ADR-0111: Outbound vendor-calling agent

Context

Turnovers are the current product priority, and the slowest link in a turnover is vendor scheduling: today a PM (or nobody) calls the plumber. The founder's direction (voice memo, 2026-07-22) is an agent that places outbound calls to vendors on behalf of the PM, handles callbacks and reschedules with full context, and treats email scheduling (e.g. Miracle Method sending proposed dates) as the same conversation on a different channel.

What exists today:

What the research says a scheduling call actually is

Verified anatomy (field-service dispatch material): greet → identify → describe the job → capture/confirm intake fields → resolve scheduling. The fields a vendor-side dispatcher expects are exactly: property address + unit, scope narrative, access details (tenant home? key? gate code?), contact callback number, and PM-side budget references (NTE, PO#) when asked.

Equally important, the refuted claims — things we must NOT design around as fact:

Decision

0. Channel-agnostic by mandate, not just by construction

Everything in this ADR applies to every communication channel — voice, SMS, and email — not just phone calls (founder requirement, 2026-07-22). The session entity, the outcome model (§3), the tool-gated commitment surface (§2), callback/context binding (§5), cadence caps (§4), and HITL approval (§7) are one machinery; voice is merely the first delivery surface. A vendor scheduled by SMS or a schedule that arrives by email flows through the same session, same outcomes, same guarantees. No channel gets its own divergent implementation. And all channels are gated equally on HITL (founder, 2026-07-23): the §7 approval gate applies uniformly — no channel dials, texts, or emails a vendor with a weaker approval bar than any other, and un-gating is a single decision that covers or excludes a channel explicitly, never a per-channel drift.

1. Same platform pattern as every existing voice agent — context injected, not fetched

This section follows the platform's established ElevenLabs pattern (founder direction 2026-07-23: don't invent new machinery), exactly as Triage and the turnover-intake agent work today: all call context is injected up front as dynamic variables at call start — the outbound twin of the inbound personalization webhook (/api/voice/personalization). No mid-call context tools; tool calls cost latency.

Injected at dial time (assembled server-side, same builder discipline as buildHandymanCallContext / the leasing context injection):

Identity at call start, always: "This is Clara, calling for <PM name> at <company>" (founder, 2026-07-23: she calls as Clara — the same name she answers inbound with, so a vendor who calls back reaches the voice they spoke to) — then straight to the job. Naming the company satisfies the entity-identity requirement. The greeting does NOT recite a callback number (founder correction 2026-07-22: the goal of a live call is completing the job, not inviting a callback). The callback number is stated when it belongs: in voicemail, when the vendor says they'll call back, or at call close. This still satisfies the artificial-voice rules — 47 CFR 64.1200(b)(1) requires identity at the beginning, and (b)(2) requires the telephone number "during or after" the message, not in the greeting — which apply to AI-voice calls under FCC 24-17. Per founder decision (2026-07-22), we do not add an "AI" self-disclosure beyond what law requires; the FCC's in-call AI-disclosure rule is a proposal, not law. Re-check the NPRM's status before launch.

2. The agent's tool surface — the platform's existing tools, nothing custom in v1

The existing agents' toolset already covers this call shape (founder, 2026-07-23):

Tool Status
end_call ElevenLabs native — already on every agent
transfer_to_number existing platform tool — live transfer to the PM; if the PM doesn't pick up, the agent tells the vendor the PM will call back (no reachability flag needed)
skip_turn existing platform tool, carried over

That's it. No get_job_context (context is injected, §1), no propose_times (v1 doesn't offer PM-calendar slots to vendors — the vendor states their window and Clara accepts it; the window lands on the calendar afterward via the §12 Q1 projection), no record_resolution (outcomes are extracted post-call from the transcript, §3), no voicemail tool (the script is injected, §1).

The commitment guard is unchanged in substance: with no scheduling-write, price, scope, or approval tools on the call, the agent structurally cannot mutate anything mid-call — it can only talk, transfer, or hang up. Everything it "commits" to is what the post-call extraction reads back out of the transcript, and dispatch state only changes there.

3. The first call PLACES THE WORK REQUEST; the schedule is optional

The main goal of the first call is getting the job into the vendor's system (founder, 2026-07-23). Vendors often won't have a schedule on the call — they email it or call back later. That's the normal flow, not a failure. Scheduling detail is captured when offered, never forced.

Outcome capture follows the platform's existing post-call pipeline (founder, 2026-07-23: "why not do this post call" — exactly): the ElevenLabs post-call webhook (/api/voice/call-ended) hands us the transcript, and a server-side extraction pass classifies it and writes the state. Same mechanism the turnover-intake agent uses; the table below is the extraction schema, not an in-call tool contract.

Primary axis — did the work request land?

Outcome Stored as What happens next
Request placed, schedule to follow (the common case) request-accepted marker + expected follow-up (call or email) with expiry the schedule arrives later by email/callback and lands in the same session via §5; expiry triggers one follow-up per cadence (§4)
Request placed, window given (windows are the norm when a schedule IS given — founder) date + range (AM/PM or hours); multi-day range for big jobs calendar window projection (§12 Q1, decided)
Request placed, firm slot (rare) date + time on the WO calendar entry; existing notification flows
Not placed — declined / no capacity / wrong number decline + reason notify the PM; the PM picks the next move. No automatic failover to the next-ranked vendor (founder, 2026-07-22); the roster ranking informs the PM's choice, it doesn't dial on its own

No-answer always leaves the scripted voicemail (Fede, 2026-07-22) — never a silent hang-up-and-retry. Voicemail collapses into "callback promised" (scripted message: job one-liner + callback number, then the expected-callback marker). Every outcome writes a plain-English timeline event on the work order — "Called MTech Plumbing — scheduled Thu 9–11am" — never internal state names. The distribution of outcomes per vendor is recorded (§11) so the product learns each vendor's actual scheduling behavior instead of assuming one.

4. Call session entity and cadence

A new entity owns the lifecycle (single-table row under the WO's property partition):

VendorCallSession {
  id, workOrderId, turnoverId?, vendorCompanyId, propertyId,
  direction: 'outbound' | 'inbound_callback',
  status: 'queued' | 'awaiting_approval' | 'approved' | 'dialing' | 'in_call'
        | 'resolved' | 'voicemail_left' | 'no_answer' | 'failed' | 'cancelled',
  outcome?: { kind: 'firm' | 'window' | 'callback' | 'declined', ... },
  attempt: number,                 // per-vendor attempt counter, hard-capped
  expectedCallbackUntil?: ISO,     // set on voicemail/callback outcomes
  transcriptRef?: string,          // stored transcript pointer (§11)
  consent: { recordingAnnounced: boolean, vendorConsentOnFile: boolean },
  version                          // optimistic locking, house pattern
}

Cadence (proposed defaults — not research-derived; tune from real data): max 2 dial attempts per vendor per day, second attempt no sooner than 3 business hours after a no-answer; after voicemail, wait out expectedCallbackUntil (default: end of next business day) before re-dialing; after the attempt cap with no contact, fall to the next-ranked roster vendor and tell the PM. All attempts within business hours in the property's timezone. Every counter is hard-capped (lesson of the 3,386-attempt reconciler incident: caps + terminal states + alert, from day one).

Orchestration is Temporal, following the turnover workflow patterns: the call session is an activity + timer sequence (dial attempt → outcome signal → callback-expiry timer → re-dial or fallback), so callbacks/expiries survive deploys and the walker/reconciler conventions (ADR-0068/0070) apply. Workflow started by WORKFLOW_TYPE_STRING constant, not function ref (house rule).

5. Callbacks with context (the other half of the feature)

Inbound vendor call or email →

  1. Caller-ID / sender matching identifies the VendorCompany (shipped: #4050, #4211) — but vendors often call from a number that isn't on file (founder, 2026-07-23: workers dial in from their own cells; the office line is mostly inbound). So number-match is only the fast path: when the number is unknown, the agent asks who's calling and matches by company name (the #4375 flow), stamps the new number as a self_claimed endpoint on the vendor, and identity upgrades as calls accumulate — we learn who they are as they call.
  2. Look up open coordination state for that vendor in priority order: open VendorCallSession with expectedCallbackUntil in the future → open WOs assigned to that vendor → scheduled visits → recent outbound call history to that vendor (closed sessions included) — the agent must remember calls we placed, so "returning your call from Tuesday about the tub at 204" resolves even when no explicit callback was promised (founder requirement, 2026-07-22). Ambiguity (multiple open items) → the agent asks one disambiguating question ("is this about the tub at Camellia 204 or the leak at 117?").
  3. The conversation binds to that context (the ActiveContextBinding pattern from turnover capture) and resumes the thread; the post-call extraction closes the same session row.
  4. Reschedule/cancel mutate the same WO scheduling state and notify the PM.

Channel-agnostic by construction: a Miracle Method email proposing three dates enters the same resolution machinery via the mail classifier; the "conversation" is the session, not the medium.

6. Transfer and escalation boundaries

The agent transfers — live during business hours, else "I'll have <PM name> call you back" + PM notification (founder, 2026-07-23: reachability just maps to business hours, no cleverness) — whenever:

Asymmetric by design: free to give verified job facts, structurally unable to commit beyond schedule selection (§2). This is the Duplex self-monitoring lesson: know what you can't complete and hand off early.

7. Approval queue (HITL leg) — lives on the existing /review page

DECIDED (Fede, 2026-07-22): reuse the app's existing PM review queue (/review — typed rows, accept/reject, detail drawer). Outbound vendor contacts become a new item TYPE ("Vendor outreach") alongside the existing "Tenant confirmation" type — no new page, no Slack-reply approval flow. The row shows the vendor, the job, and what Clara may commit to; approve dials (or sends), reject cancels with a reason.

While a property is in HITL mode, every outbound call sits in awaiting_approval:

8. Phone infrastructure

9. Failure modes and mitigations

Failure Mitigation
IVR / phone tree answers v1: navigate only trivial trees ("press 2 for scheduling" when options are clearly enumerated); anything deeper → hang up, mark no_answer, and prefer that vendor's email channel if one exists. IVR mechanics have no verified failure-rate data — start conservative, expand from transcripts.
Answering service (human, not dispatcher) Deliver the voicemail-script content as a message + callback number; outcome = callback promised.
Wrong/stale number post-call extraction stores not-placed (wrong number) → flag the vendor record for PM correction; do not retry.
Vendor confusion / suspicion of spam Identity + on-behalf-of + property name up front (§1); transfer offer is always one sentence away.
Hallucinated commitment The call has no mutating tools (§2) — nothing changes mid-call; the post-call extraction only stores what the transcript supports, and the §12 harness scores over-promising directly.
Agent loop / repeated dials Hard attempt caps + terminal states + Sentry alert on cap hit (§4).
Recording-consent drift across states Announce-recording default (§10) removes the per-call decision.

11. Observability and learning loop

12. Testing — an extremely comprehensive harness is a hard requirement

The harness is not a nice-to-have battery; it is a shipping gate (founder requirement, 2026-07-22): every channel (voice, SMS, email), every outcome, every guardrail, replayed in CI before anything is armed.

Scenario battery (text-to-text, before any real dial): decision-maker reached / IVR-trivial / IVR-deep / answering service / voicemail / firm slot / window (incl. multi-day) / schedule-arrives-later-by-email / callback with and without an open expectation / decline / wrong number / suspicious vendor / price-negotiation attempt / scope-change attempt / transfer request / mid-call hangup / after-hours inbound / duplicate resolution (idempotency) — each run across all three channels where the channel applies. Scored dimensions: resolution correctness, guardrail compliance (zero out-of-scope commitments), transfer correctness, identity-line compliance, cross-channel session continuity (a thread started by phone and finished by email resolves as ONE session). Taskmaster-1's auto-repair scheduling dialogs serve as loose structural inspiration only (simulated, consumer-side); the real eval corpus is Camellia's own vendor calls/emails as they accumulate.

Dependencies and sequencing

  1. ADR-0060 roster — merged (model/resolver #4345); UI landing now. Supplies who-to-call, fallback, and the consent-capture hook (§10).
  2. Vendor identity binding (Gera's lane + vendor-IdentityType card) — §5 needs it.
  3. Calendar windows — blocked on the product decision (§13 Q1).
  4. Consent capture at roster confirmation (§10) — small, ships with the confirm-card work.
  5. Then the calling agent: session entity + Temporal workflow → approval queue → agent tools → scenario battery green → arm HITL-only on one property.

Open questions

  1. Calendar windows vs. tour slots — now decision-ready (deep-research run 2026-07-22, 25/25 claims verified 3-0 against primary docs; full matrix in pm-domain-knowledge/vendor-windows-vs-bookable-slots-calendar-patterns.md). The standup's two candidates, corrected and placed in the full option space:
    • (a) tentative events on the shared calendar: unsafe as discussed — "tentative" BLOCKS availability in Microsoft Bookings and Calendly-for-Outlook; only "Free"-transparency events are visible-but-non-blocking, and events default to Busy.
    • (b) separate vendor calendar: viable (booking layers can scope conflict-checks to specific calendars) but rests on per-tool configuration that drifts.
    • DECIDED (Fede, 2026-07-22): the pattern every field-service product converged on — PropFlow's own scheduling engine stays the source of truth for tour availability; vendor windows are display-only data (ServiceTitan treats arrival windows exactly this way); each UNIT is a resource so a vendor visit and a tour conflict only via range-overlap on the same unit; external calendars receive one-way read-only projections (as Free-transparency events), so a PM hand-editing a projected event can never corrupt availability. Soft-holds pending tenant access confirmation use human-scale TTLs, not checkout-style minutes. Demonstrated live (2026-07-22) on the test leasing calendar (propflowaicalendar@outlook.com, Yale 25 Station): a "🔧 MTech Refinishing — Unit 204, 9–11 AM" event created with showAs: free renders on the grid while Graph getSchedule.availabilityView reads the covered half-hours as 0 (free); adjacent Busy tour events correctly read 2. Implementation note — current-code gap: tour availability today already subtracts Outlook events (check-availability.ts → Graph calendarView), and eventsToBusySlots (check-availability.ts:115) counts EVERY event as busy — it never reads showAs. The build must (a) request showAs in the Graph $select and skip showAs === 'free' events when computing busy slots, else our own Free projections would block tours; (b) add unit/WO range-overlap as a first-class availability input (today vendor visits are invisible to tour booking entirely).
  2. Price — ✅ DECIDED (Fede, 2026-07-22, refined 2026-07-23): capture, never approve. Clara carries no budget numbers (no NTE in the payload) and never accepts a price — approval is a human's for now. But if the vendor volunteers a quote she takes it down, and the post-call extraction stores it for the PM's review through the existing maintenance approval gate. Future: an auto-approve threshold (the existing "auto-approve under $X" setting is the natural home) lets the AI accept small quotes; not in v1. Nothing price-shaped ever blocks a call.
  3. After-hours vendor callbacks — ✅ DECIDED (Fede, 2026-07-22): inbound is answered 24/7 — a returning vendor always gets Clara with full context, any hour; anything beyond picking a time still waits for the PM in the morning. Outbound dialing defaults to business hours; a config option may later allow dialing any time and leaving a voicemail on no-answer ("we only call out during business hours, or we could call at any time and leave a voicemail").
  4. Who builds — ✅ DECIDED (Fede, 2026-07-23): Fede's lane (this session) builds it. Gera's vendor-comms inbound work (caller-ID matching, vendor-mail classification) stays his; coordination happens at the shared primitives (vendor identity, session binding).
  5. Consent copy + NPRM watch — counsel review of the §10 consent message before arming; re-check whether the FCC's AI-disclosure NPRM has finalized before launch.

Consequences