ADR-0111: Outbound vendor-calling agent
- Status: Proposed (held for Fede's review — do not merge without his explicit approval)
- Date: 2026-07-22 (v2 — expanded to implementation depth same day)
- Deciders: Fede (pending), Gera (vendor-comms lane)
- Related: ADR-0059 (vendor dispatch emails), ADR-0060 (preferred vendors), ADR-0044 (turnover confirm recap), ADR-0033 (vendor data model)
- Amendment 2026-07-31 — the arm gates are gone. The two-factor arm this ADR assumed
(
VENDOR_VOICE_CALLING=armedglobal +Property.vendorVoiceCallingEnabledper-property) was deleted (founder ruling: "I don't want gates. Only review queue."). The PM's approval in the /review queue is now the single gate between a turnover dispatch and a real vendor dial: a vendor-assigned dispatch ALWAYS creates theawaiting_approvalsession, and only an approve there dials. Everything else in the dial path — PO hold, wrong-company guard, attempt cap, callback windows, payload checks — is a workflow invariant and stays. - Research base:
pm-domain-knowledge/vendor-scheduling-calls-and-ai-caller-compliance.md(2026-07-22, adversarially verified; legal claims cite primary FCC/statute sources)
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:
- Email dispatch built but gated off everywhere (ADR-0059) — no vendor email ever sent in prod.
- Preferred-vendor roster (ADR-0060) — data model + resolver merged (PR #4345); answers who to call per trade, ranked, with fallbacks, and email-mined entries are never dispatchable until confirmed.
- Inbound vendor awareness (Gera's lane): vendor caller-ID matching (PR #4050),
vendor-mail classification (PR #4211). No vendor
IdentityTypeyet (tracked separately), no binding of an inbound vendor call to an open work order yet. - Voice stack: ElevenLabs conversational agents over Twilio, with tool handlers doing the real work (turnover-intake agent is the precedent). The replay harness (PR #4333) established text-to-text testing at the tool layer; the founder has confirmed no audio rig is needed.
- No competitor in property management currently fields vendor-side AI calling (verified 2026-07-06 across 17 companies). Google Duplex is the design precedent: autonomous phone scheduling with self-monitoring and human handoff.
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:
- Arrival windows are the norm (founder, from operating experience — 2026-07-23: "most of the time it's arrival windows in real life"). Firm slots are the rare case; big jobs span multiple days; some vendors email the schedule after the call. The outcome model below still covers every shape, but the agent's default posture asks for and confirms a window, not a slot — Camellia's call/email corpus refines the distribution, it doesn't reopen the question.
- No universal "7-step" script; no verified IVR failure-rate numbers. IVR handling starts conservative (§9) and is tuned from evidence.
- "Existing business relationship" does not straightforwardly satisfy TCPA consent for AI-voice calls — but the founder decided NO consent gate anyway; §10 records the residual risk for the pre-arming counsel review.
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):
- the vendor (name, contact person when known — from the roster resolver, ADR-0060)
- the property + unit and the plain-English job scope from the work order
- access: v1 is turnovers only, so a fixed "the unit is vacant" line (founder, 2026-07-23); richer occupied-unit access comes later
- the callback number (the property's own number, §8) and the PM's name
- the voicemail script, pre-rendered — injected like everything else, so hitting voicemail costs zero tool round-trips
- No price numbers in the payload (founder, 2026-07-23): the agent schedules; it never states or approves a price. If the vendor volunteers a quote she notes it for the PM (capture, never approve — §12 Q2).
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 →
- 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_claimedendpoint on the vendor, and identity upgrades as calls accumulate — we learn who they are as they call. - Look up open coordination state for that vendor in priority order: open
VendorCallSessionwithexpectedCallbackUntilin 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?"). - The conversation binds to that context (the
ActiveContextBindingpattern from turnover capture) and resumes the thread; the post-call extraction closes the same session row. - 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:
- the vendor asks for a person, or the agent is uncertain about an answer
- the vendor pushes for a price decision. Nuance (founder, 2026-07-23): if the vendor volunteers a quote, the agent takes the information down — it lands in the post-call extraction for the PM to review — but it never accepts or blesses a price ("ok sure, that price is fine" is a human's line for now). Eventually a threshold lets the AI auto-approve small quotes (the existing auto-approve setting is the natural home); until then, price approval is always the PM's.
- the vendor disputes the job's premise (wrong property, prior unpaid invoice, etc.)
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:
- The PM sees, in-channel (same surfaces as the turnover confirm recap, ADR-0044): vendor, job one-liner, what the agent may commit to ("pick a time; nothing else"), and approve/skip/edit-first actions in plain English.
- Approval expires with the business day — an unapproved call never dials stale.
- The queue is the same mechanism as the vendor-email approval leg (the "thumbs up before send" the team decided on), so PMs learn one gesture for both.
8. Phone infrastructure
- Calls originate from the property's Clara line — our 800 number (founder correction 2026-07-23: vendors do NOT already know this number; the PM historically called from their own line). The value isn't familiarity, it's consistency: outbound calls, voicemails, and the callback number are all the same line we answer 24/7 with context, so every return call binds to its session (§5). Vendors learn the number as "Camellia's number" through repetition. A separate per-agent line is still rejected — one number per property, inbound and outbound.
- Live transfer = Twilio conference/redirect to the PM's number; on no-answer, fall back to the callback promise + PM notification.
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. |
10. Consent and recording (compliance posture)
- TCPA consent: ✅ DECIDED (Fede, 2026-07-22): no vendor consent gate — no consent capture at roster confirmation, no consent flag blocking calls or texts, for any channel. Recorded residual risk, stated plainly (not relitigating the decision): FCC 24-17 treats AI voices as "artificial voice" under TCPA, and artificial-voice calls to cell phones require prior express consent even B2B — most vendor numbers are cells. The existing business relationship with dispatched vendors is the practical defense, but it is not a settled safe harbor. This is exactly what the pre-arming counsel review (open item, §13) must bless or overturn; until then the decision stands and nothing is built for consent capture.
- Recording: Colorado is one-party consent (C.R.S. 18-9-303) — recording is lawful for us as a party. Default posture anyway: the agent announces "this call may be recorded" at start — the simplest stance that also covers two-party states (CA, FL, WA, PA, MA, …) for interstate calls and matches the pending NPRM's direction. A per-state map is therefore not a launch dependency.
- Duplex precedent for both: its production calls opened with "automated service" + recording notice and it survived contact with the public. We match the recording notice and the on-behalf-of identity; the "automated" wording is the founder's call (§1).
11. Observability and learning loop
- Full transcript + tool-call trace per session (Langfuse via
tracedClaudeCallconventions); transcript pointer on the session row. Sentry on failed dials, cap hits, transfer failures. - Per-vendor outcome stats accumulate on the vendor record (answer rate, typical resolution shape firm/window/callback, best time-of-day) — this is how the refuted "everyone gives windows" assumption becomes real per-vendor knowledge, and later feeds vendor scoring.
- Every call outcome is a harness case: transcripts become replay corpus entries (text-to-text, PR #4333 pattern) so regressions in call handling are caught in CI.
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
- ADR-0060 roster — merged (model/resolver #4345); UI landing now. Supplies who-to-call, fallback, and the consent-capture hook (§10).
- Vendor identity binding (Gera's lane + vendor-IdentityType card) — §5 needs it.
- Calendar windows — blocked on the product decision (§13 Q1).
- Consent capture at roster confirmation (§10) — small, ships with the confirm-card work.
- Then the calling agent: session entity + Temporal workflow → approval queue → agent tools → scenario battery green → arm HITL-only on one property.
Open questions
- 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: freerenders on the grid while GraphgetSchedule.availabilityViewreads the covered half-hours as0(free); adjacent Busy tour events correctly read2. Implementation note — current-code gap: tour availability today already subtracts Outlook events (check-availability.ts→ GraphcalendarView), andeventsToBusySlots(check-availability.ts:115) counts EVERY event as busy — it never readsshowAs. The build must (a) requestshowAsin the Graph$selectand skipshowAs === '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).
- 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.
- 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").
- 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).
- 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
- Vendor scheduling stops depending on PM availability; the PM approves instead of dials.
- One resolution machinery across phone and email prevents a divergent email implementation.
- Tool-gated commitments make the worst LLM failure (promising something we can't honor) structurally impossible rather than prompt-discouraged.
- HITL is the whole safety story: no session dials without a PM's approval in the review queue (the arm gates that originally accompanied it were removed 2026-07-31 — see the amendment at the top).
- Consent capture rides the roster-confirmation flow, so the compliance prerequisite is satisfied as a side effect of a feature we're already shipping.
- The text-only harness keeps call-flow regressions testable in CI without audio infrastructure; per-vendor outcome stats replace refuted industry assumptions with measured behavior.