# Build a real estate CRM (/docs/guides/real-estate-crm)



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 [#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 [#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 [#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](/docs/strategies/eligibility) 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`:

```text
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](/docs/management/pools-and-recipients)
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](/docs/concepts/deduplication-and-outcomes) 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 [#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:

```json
{
  "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 [#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](/docs/sdks), implement CRM-side steps for
updating ownership and writing the handoff history. Use a named
[workflow signal](/docs/sdks/workflow-primitives) 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 [#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](/docs/notifications/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](/docs/concepts/deduplication-and-outcomes).
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 [#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.
