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 record | Ductor concept | Responsibility |
|---|---|---|
| Brokerage | Tenant | Isolate configuration, users, and execution. |
| Buyer inquiry or seller referral | Work / routable | Represent one assignment request with a stable CRM reference. |
| Geographic team or specialty desk | Pool / route | Choose eligible destinations and assignment policy. |
| Agent or partner brokerage | Recipient | Represent an assignment destination with capacity and availability. |
| License, language, specialty, territory | Eligibility traits and profiles | Establish qualification before selection. |
| Contact and external provider IDs | Entity identity and source aliases | Link repeated inquiries to the same subject. |
| Assignment result | Decision and execution evidence | Explain selection and correlate later feedback. |
| Referral acceptance and follow-up | Workflow waits, signals, timers | Coordinate human work and escalation. |
| Agent notification | Subscriber and inbox message | Present an assignment in the CRM. |
| Contacted, appointment, closed, returned | Outcomes | Record 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_codeThe 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 scenario | Required evidence |
|---|---|
| Same inquiry retried | One accepted operation and no duplicate CRM effect. |
| Known contact asks about another property | Identity stays linked without suppressing legitimate new work. |
| Sticky agent's license expires | Qualification blocks the stale owner before selection. |
| No qualified local agent | Explicit queue, fallback, or review result. |
| Two agents claim one referral | One owner; losing attempt cannot overwrite the winner. |
| Agent hits claim budget | Another referral cannot be claimed until policy permits it. |
| Owner declines | Release reason and next handoff stay correlated. |
| Response arrives after reassignment | Old handoff response cannot change the current owner. |
| Provider writes, response times out | Receipt and reconciliation determine the result before another write. |
| Appointment callback arrives twice | One business outcome and one attribution update. |
| Another tenant requests the referral | No 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.