ADR 0007: Call Plan Handoff — Deep Link, Plan-in-Prompt, Plan-Match
Status
Section titled “Status”Accepted
2026-09-27
Context
Section titled “Context”Intelligence (PRD INT-9, INT-17) gives every lead a call plan the rep edits on the web: kept stages and lines, their own scripts, and an objection bank with the answer they prepared. The plan has to reach the live call in three ways: the desktop app must open on the right contact with that plan loaded, the copilot’s suggestions must be grounded in it, and when the prospect raises an objection the rep prepared for, the prepared answer must appear next to the AI counter.
Constraints that shaped the decision (ARCHITECTURE D10, D15; gap-check §3 rows 6–11, §4 D4/D18):
- The desktop is an Electron app with hash routing and a single live engine session per bundle; a link arriving mid-call must not strand the running session (the same failure Cmd+N used to cause — see
menu-call-guard.ts). - On macOS
open-urlfires beforewhenReadyon a cold start, when no renderer exists; on Windows/Linux the URL arrives in the second instance’s argv, and a dev registration withoutexecPathpoints atelectron.exe. - The Realtime session’s
instructionsare minted server-side and re-minted on every reconnect (POST /v1/call-sessions/:id/reconnect); anything a client injects as a conversation note is lost on reconnect. - The rescue assessment (
POST /v1/rescue/:id/assess) is module-gated (Second Voice) and silent on green, so it cannot be the vehicle for “a planned price objection just came up”. Token-overlap matching on the client was judged too error-prone to ship. - The engine (
@cosella/copilot-engine) is shared by the desktop and the web Sales AI page; the desktop cannot import dashboard code.
Decision
Section titled “Decision”1. Deep-link contract
Section titled “1. Deep-link contract”cosella://opencosella://call?contactId=<id>&planId=<id>[&mode=phone|meet][&source=profile]- Ids are
[A-Za-z0-9_-]{1,64}; unknown hosts, missing/invalidcontactIdand unknownmodevalues are rejected or dropped (apps/desktop/electron/deep-link.ts, pure, unit-tested). - The main process buffers every
calllink until the renderer takes it (deep-link:consume, take-once) and also pushes it (deep-link:received) for the case where the renderer is already up.openkeeps its old meaning (focus) and is never broadcast.second-instancereads its argv; a Windows cold start readsprocess.argvafter the window is created; Windows/Linux dev registers the protocol withexecPath+ the script path. - The renderer routes a link through the live-call guard (confirm → end the call, which saves the transcript → navigate), stashes it when signed out and replays it after sign-in, and lands on
/setup?contactId=&planId=&mode=— one hydration path. Setup fetches contacts owned by other reps throughGET /v1/contacts/:id(the picker hides them), loads the plan viaGET /v1/intelligence/plans/by-id/:planId, pre-selects the plan’s deal stage and offer, and drops the plan if the rep picks a different person. - The dashboard’s profile “Call” button fires the link from a hidden iframe and falls back to
/sales-ai?contact=&plan=in a new tab (features/intelligence/lib/desktop-launch.ts); the web Setup step consumes those params the same way. - No client-side capability gate: the API 403s the plan fetch when the module is off, and the panel is absent without a plan.
2. Plan in the prompt (server-side)
Section titled “2. Plan in the prompt (server-side)”StartCallDto gains callPlanId, contactId, startedFrom: 'profile' | 'setup'. The API renders the plan into the minted instructions as REP'S CALL PLAN (after PROSPECT INTEL, before THIS CALL'S OFFER, capped at 4,000 chars), persists the ids on the session so a reconnect re-mints with the plan, ignores plans on practice calls, and echoes plan: { id, objectionCodes, leadName } | null on the start response. The client never resends the plan as a note; one buildScenario() on the desktop sends the same payload for meet and phone calls (which also fixed the phone path dropping offer/context/style).
3. Live plan matching
Section titled “3. Live plan matching”Per finalized customer turn, only when the start response carried plan, the engine POSTs /v1/call-sessions/:id/plan-match { text, recentTurns: string[] } (retry-free; errors logged and swallowed — it must never delay the yes-check or rescue assessment running alongside). The server matches with the same exported regex vocabulary (OBJECTION_PATTERNS / detectObjectionCodes) post-call analysis uses, intersected with the plan’s objection codes, and dedupes per objection per session. A match reaches hosts through a dedicated EngineSink.onPlanAnswer(match); hosts
- attach it to the objections column (
ObjectionEntry.planned) — joining the live card when that card is visibly about the same objection, otherwise as a card of its own with no AI counter claimed; - hand the prepared answer to the model with
notifyCallContext(buildPlanAnswerNote(match))(a conversation item, neversession.update); - record
plan_answer_shown_liveonce per objection throughPOST /v1/intelligence/events.
The “Your call plan” panel (kept lines, scripts first; prepared objections as They say / You say with a “Raised last call” pill and a “Came up” marker) sits above the objection card in the 300 px right column, which opens expanded when a plan rode in. Its list is computed by speakingLines() / planObjections() in @cosella/domain, so the desktop and web panels agree by construction; the JSX is a thin copy per app because the desktop cannot import dashboard code.
Consequences
Section titled “Consequences”- The plan survives reconnects for free (server-minted instructions) and a Ctrl+R (the desktop and web snapshots carry the plan, the said lines, the objection cards and the engine’s plan echo, which the resume passes back so matching keeps working).
- Orgs without Intelligence pay nothing: no
planecho ⇒ no plan-match request, no panel, no change to the objections column. - Live and post-call objection detection are identical by construction (same regex, same six codes). A planned objection the regex cannot recognise (
code: null) never surfaces live — acceptable for the pilot; a semantic tier can be added behind the same endpoint without touching the clients. - The deep link cannot be exercised from a dev build on macOS (protocol handlers need a bundled app); QA it with a packaged build. Windows dev works through the
execPathregistration. - Desktop version bumped 1.0.3 → 1.1.0 with this change (the updater compares versions).