Guides

Build a real estate CRM

Model brokerage lead assignment, referral handoffs, lead ponds, follow-up, and outcome feedback with Ductor.

Build a CRM where agents work leads in your application and Ductor coordinates assignment, durable follow-up, and integration effects. Start with one brokerage, two territory pools, and synthetic buyer referrals. Expand only after the assignment and recovery paths have evidence.

Decide what each system owns

CRM recordDuctor conceptResponsibility
BrokerageTenantIsolate configuration, users, and execution.
Buyer inquiry or seller referralWork / routableRepresent one assignment request with a stable CRM reference.
Geographic team or specialty deskPool / routeChoose eligible destinations and assignment policy.
Agent or partner brokerageRecipientRepresent an assignment destination with capacity and availability.
License, language, specialty, territoryEligibility traits and profilesEstablish qualification before selection.
Contact and external provider IDsEntity identity and source aliasesLink repeated inquiries to the same subject.
Assignment resultDecision and execution evidenceExplain selection and correlate later feedback.
Referral acceptance and follow-upWorkflow waits, signals, timersCoordinate human work and escalation.
Agent notificationSubscriber and inbox messagePresent an assignment in the CRM.
Contacted, appointment, closed, returnedOutcomesRecord business results with the original assignment reference.

Keep contact records, properties, transaction documents, and the commission ledger in the CRM. An assignment identifies who should receive work; it does not establish that the agent contacted the client or earned a commission.

The CRM server holds Ductor credentials. Agent browsers call your authenticated CRM backend, which derives tenant and agent authority from the signed-in session. Never let a browser choose another brokerage's tenant or an arbitrary agent's recipient identity.

Native CRM implementation

The Realty CRM reference implementation adds a ductor distribution method to its existing routing groups and a manager-only Ductor settings tab. A server-side connection key selects either a self-hosted API or a provisioned cloud API; credentials never enter the browser.

Intake commits a durable routing intent with stable work and idempotency keys, frozen geographic/source facts, and group/binding revisions. The tenant worker leases it, routes outside the CRM transaction, persists the correlated decision, and rechecks current scope and agent availability before creating the native assignment, follow-up task, activity and authorization outbox.

A lost response triggers durable decision lookup using the original work ID. An attempted request with no durable result remains uncertain rather than being sent again after an idempotency cache expires. Preview uses a dry run and creates no native assignment. Assignment, agent acceptance and referral settlement remain separate business events.

Start with buyer and seller routing

Create separate buyer and seller pools. Add active recipients and assign durable traits for license, territory, language, and specialty. Bind the relevant eligibility profile before choosing a strategy.

Profile binding alone does not enable qualification. Send routing options eligibility.enabled: true and eligibility.required: true; omitting profile_id resolves the active pool-bound profile in the selected environment. The native CRM adapter requires qualification proof in every assigned decision, including zero fallback and warning counts. A result with a fallback, a warning, or missing proof goes to manual review rather than assigning a CRM owner.

An in_territory requirement with an explicit territory trait definition verifies the recipient's governed assignment against the coverage set. A requirement containing only territory_set_id instead evaluates the recipient's persisted geographic or account facts against that set. Missing geographic facts, inactive sets, or invalid distances fail closed. An explicit trait requirement still needs its assignment; geographic membership cannot substitute for that permission.

Both forms evaluate the recipient. They do not compare the incoming lead's address to the set.

For exact lead coverage, add a pool rule with top-level cel_expression: "true" and this expression in its filter action's parameters.include:

has(routable.attributes.geo_state) &&
has(recipient.attributes.region) &&
routable.attributes.geo_state == recipient.attributes.region &&
has(routable.attributes.postal_code) &&
has(recipient.attributes.postal_code) &&
routable.attributes.postal_code == recipient.attributes.postal_code

The CRM snapshots geo_state, geo_city, postal_code, and country; recipient state uses region. Missing state/postal values fail the guarded filter. This example covers exact equality; polygon or multiple-postal coverage needs its own rules or pool partitioning.

An isolated live API run on 2026-10-06 tested four recipients: qualified, missing-license, different-territory, and expired-license. Required qualification left one eligible recipient; matching postal coverage assigned that recipient. An outside postal code produced no assignment (DECISION_OUTCOME_QUEUED in the dry run). A future qualification check rejected the active license after expiry. The exercise found and repaired a routing DAG handoff that dropped eligibility options; the repeated live run confirmed enforcement and qualification metadata. These results establish the tested behavior, not production capacity.

Use pool capacity and availability to exclude agents who cannot take more work. Use weighted or round-robin selection for new inquiries and entity_sticky_assignment when an existing client relationship should influence selection. Sticky ownership must still respect current eligibility and availability.

Treat a new inquiry as a new work item, even when its contact is already known. Use deduplication for repeated submissions within the chosen window. Durable contact identity and duplicate submission detection answer different questions: one person may have several legitimate property inquiries.

Record the CRM inquiry ID, the Ductor work ID, decision ID, selected recipient, and workflow run reference together. Preserve these references when retrying a request or applying outcome feedback.

Add a referral pond

A lead pond lets agents pull unassigned referrals. Create a queue for an existing pool with POST /api/claim-queues. Substitute your pool UUID:

{
  "pool_id": "550e8400-e29b-41d4-a716-446655440000",
  "name": "California buyer referrals",
  "max_claims": 2,
  "claim_period_seconds": 3600,
  "claim_ttl_seconds": 300,
  "priority": 7,
  "filters": "routable.type == 'buyer_referral' && routable.attributes.state == 'CA'"
}

Read back the queue configuration, then bind its returned ID using config.routing_mode: "claim" and config.claim_queue_id on the pool. The routing result is queued_for_claim; it is not yet an agent assignment.

Queue filters evaluate incoming work and must return a boolean. Direct intake through POST /api/claim-queues/{queue_id}/items requires a persisted work ID in the queue's tenant and pool; Ductor loads its stored facts before evaluating the filter. Before exposing a pond or submitting a claim, the CRM must enforce the signed-in agent's permission to act for the recipient.

The production claim gate checks current pool membership, availability, schedule, and the active bound qualification profile in the default environment. Missing or expired required evidence denies the claim; a qualification fallback cannot authorize it. An active profile outside its effective window also denies admission. Generic pools with no bound policy do not gain a license requirement.

PostgreSQL commits the queue/recipient budget check with the claim mutation, including simultaneous claims on different referrals. Hourly/daily budgets use UTC boundaries and count unreleased claims in the window; releasing a claim frees that held budget. They do not represent lifetime purchase caps. Recipient capacity is currently observed, rather than reserved by a claim. Shared capacity limits across automatic routing and claims need the coordinated acquisition receipt implementation before production can rely on that guarantee.

Claim with POST /api/claim-queues/{queue_id}/items/{item_id}/claim, supplying the authorized recipient_id. Read back the claim owner and expiry, and show the agent the acceptance window. An inbox CTA click records notification interaction; the CRM backend must separately execute and confirm the claim.

Release with POST /api/claim-queues/{queue_id}/items/{item_id}/release when the owner declines. Keep the reason attached to CRM handoff history. Test that a second agent cannot release an owned referral and that an expired or released referral becomes available according to the queue lifecycle.

Make acceptance and first contact distinct

Use a durable workflow for a referral's acceptance deadline and first-contact deadline. A five-minute queue claim TTL is a claim lease, not evidence that the client received a call. Likewise, reading or snoozing an inbox message does not acknowledge a referral.

With a Go or TypeScript workflow SDK, implement CRM-side steps for updating ownership and writing the handoff history. Use a named workflow signal for an explicit agent response, and a durable timer for timeout. Correlate responses to the specific handoff attempt so a late response cannot overwrite a newer owner.

For cross-brokerage referrals, represent sending and receiving parties explicitly in your CRM. Record proposed recipient, accepted recipient, referral agreement reference, and outcome attribution. Require an acceptance transition before considering the transfer complete. Use Ductor workflow evidence to explain coordination; maintain the agreement and commission accounting in the CRM's transaction model.

Deliver updates and measure the result

Run CRM writes and provider notifications through durable workflow steps or connector effects. Use stable operation keys and execution receipts to reconcile retries. A timeout after sending a provider request may mean the provider performed the write; reconcile before repeating an irreversible operation.

Use the inbox for assignment, acceptance-needed, and deadline notices. Subscriber IDs are CRM user IDs scoped to a tenant; recipient IDs are routing destinations. Store the mapping explicitly rather than assuming those IDs are interchangeable.

Record outcomes for contact, appointment, and transaction milestones through the outcome feedback surface. Capture event time, source event identity, and the decision reference. Deliver feedback to learning or attribution workflows only after validating the business transition; duplicate or late callbacks must not double-count a closing.

Exercise the unhappy paths

Synthetic scenarioRequired evidence
Same inquiry retriedOne accepted operation and no duplicate CRM effect.
Known contact asks about another propertyIdentity stays linked without suppressing legitimate new work.
Sticky agent's license expiresQualification blocks the stale owner before selection.
No qualified local agentExplicit queue, fallback, or review result.
Two agents claim one referralOne owner; losing attempt cannot overwrite the winner.
Agent hits claim budgetAnother referral cannot be claimed until policy permits it.
Owner declinesRelease reason and next handoff stay correlated.
Response arrives after reassignmentOld handoff response cannot change the current owner.
Provider writes, response times outReceipt and reconciliation determine the result before another write.
Appointment callback arrives twiceOne business outcome and one attribution update.
Another tenant requests the referralNo contact details or mutation cross the tenant boundary.

Start with synthetic data and isolated scenario runs. A service test with fake storage verifies application behavior; it does not establish PostgreSQL concurrency safety or production throughput. Re-run the same cases through the authenticated API, persistence, workers, and CRM adapter before launch.