CT call copilot on Cloudflare + Jev: production-shaped proposal
Status: architecture proposal, 2026-09-24. Feeds the otto-plan PRD. Everything not cited is marked ASSUMPTION. Paths are absolute on devbox.
1. Summary
- One Worker (
copilot-api), one Durable Object class (CallSession), one D1 database, one R2 bucket, one KV namespace, one Queue, one Pages site. Nothing else for M1. - Jev only judges. Every number, threshold, timer, memory and gate lives in TypeScript inside the
CallSessionDO; the DO is the reference copilot'sCallSession(reference/jev-sales-copilot/copilot/engine.py) moved into a single-writer, persistent, WebSocket-holding object. - Policy = question bank + playbook + weights + thresholds + checklist, per scenario, as one immutable versioned JSON in D1 with a content hash; KV mirrors the published version; the DO pins the version for the whole call.
- Raw Jev answers are stored per utterance keyed by
(call_id, i, question_bank_hash, model), so weights and thresholds are re-tunable for zero Jev calls, and a question-bank change is a new hash, never a silent overwrite. - Per utterance: stitch Aircall fragments in code, build a filtered state (12-utterance window + compact facts, ~0.8k tokens), ask ~70 questions in one speculative fan-out (~10k tokens, hard cap 12k against the 32k Cloudflare limit), decide in code, push one
decisionmessage. - Hero metrics per Stevan's decision: Onboarding = must-say checklist completeness (code over persisted
said_*nouls) plus risk flags; Customer Success = resolution state plus a call-health composite. No closing probability. - Rep-facing text is either an approved playbook line addressed by
text_id+ policy version, or a tailored rewrite that passed three Jev verifier nouls (invents_fact,on_move,makes_promise_or_prediction) and is delivered on a separate, droppable message. - Ingestion: historical Aircall calls replay from
call_turnswith no STT; new audio goes through@cf/deepgram/nova-3(diarised utterances, verified 2026-09-24); speaker roles are assigned in code, confirmed by one Jev Choice, overridable by the admin. - Eval harness = labelled utterances + call-shape assertions + stability + budget, run as a Workflow from the admin console; a policy cannot be published unless the latest eval run on it passed. Auditable: every shown card and every publish is a row.
- M1 is the thin vertical slice: import or upload a call, replay it into the dashboard with all panels live, on the production storage layout and the production WebSocket protocol, with the eval harness gating policy v1. Buildable in 1–2 weeks of agent-driven work if scope holds.
2. Design stance
Angle given: optimise for the shape the system must have in production and make M1 the first thin vertical slice of that shape. Production means: several reps and one admin using it at once; state that survives a tab reload and a Worker redeploy; policies that are versioned, diffable and promotable; an audit trail that a compliance reviewer can read; and an eval gate that makes changing a question or a weight boring.
What this stance buys: no rewrite between the PoC and the live system, because the DO, D1 schema, policy format and WebSocket protocol are the same objects in M1 and M3. What it costs: M1 carries about two days of scaffolding (auth, schema, policy loader, audit rows) that a throwaway demo would skip. The reference copilot is the counter-example: everything in memory, one socket = one session, playbook loaded at import, weights lost on reconnect (docs/research/reference-copilot-dissection.md §7a, §10c). We keep its engine and question style verbatim and replace only its process model.
Three rules I hold throughout, from the jev-1.13 jaggedness page (docs/vendor/typesafe/model-jaggedness__jev-1.13.md): (a) literal, single-condition questions with aligned true/false criteria; (b) arithmetic, counting, dates, thresholds and memory in code; (c) small, filtered state. And one from docs/jev-guide.md §4.6/§4.11: log response.model on every answer, because Cloudflare does not let us pin a Jev version.
3. System overview
flowchart LR
subgraph Browser["Pages site (Cloudflare Access)"]
Dash[Rep dashboard]
Admin[Admin console]
end
subgraph Edge["Worker copilot-api"]
API[HTTP routes /api/*]
WS[WebSocket upgrade → DO]
Ingest[Ingest: stitch, redact, role-assign]
STT[nova-3 client]
Jev[Jev client: budget, retry, unwrap]
end
DO[(Durable Object CallSession\nper call: window, facts, EMA,\nobjection, cards, WS fan-out)]
D1[(D1: calls, utterances, answers,\ndecisions, policies, labels, audit)]
R2[(R2: audio, raw transcripts,\nnova-3 JSON, eval runs)]
KV[(KV: published policy mirror)]
Q[[Queue: batch scoring,\nSTT jobs, eval steps]]
AI[[Workers AI: typesafe/jev,\n@cf/deepgram/nova-3]]
GW[AI Gateway: named gateway,\nbody logging off, spend limit]
Dash <-->|WS| WS --> DO
Admin --> API
API --> D1
API --> Ingest --> R2
Ingest --> STT --> AI
Ingest --> D1
DO -->|per utterance| Jev --> GW --> AI
DO -->|append| D1
DO -->|read once per call| KV
API --> Q --> DO
Admin -->|publish| D1 --> KV
Data flow for one replayed utterance: DO timer fires at t_end → DO appends the utterance to its window → sends utterance to the socket immediately → builds state → one env.AI.run('typesafe/jev', …) → stores raw answers (DO SQLite, then D1 append) → runs the decision code → sends decision → optionally enqueues a rewrite job → the rewrite arrives ~1 s later as a rewrite message or not at all.
4. Components on Cloudflare
Workers (copilot-api). Stateless HTTP + WebSocket front door. Responsibility: auth check (Cloudflare Access JWT → users row), REST routes for calls/policies/labels/eval, ingestion (stitching, redaction, role assignment, nova-3 call), WebSocket upgrade forwarded to the right DO by call_id. Why: it is the only compute primitive with the AI binding, and env.AI.run measured ~800 ms including cold start (verified 2026-09-24). Limits that matter: 30 s CPU default per invocation (docs/research/corpus-and-curation.md §5, fetched limits), so anything longer than one nova-3 call or one Jev call goes to the DO, a Queue or a Workflow; request body 100 MB, which covers a 40-minute mp3 for upload (docs/research/stt-options.md §3.3).
Durable Objects (CallSession, SQLite-backed). One per call (historical replay or live). Holds the rolling window, persisted facts, objection state, EMA, card state (hysteresis, cooldowns), the pinned policy version, and the WebSocket connections (one rep, N observers). Single-writer serialises utterances so a burst never races Jev calls. Uses the WebSocket Hibernation API so idle sessions cost nothing; replay pacing runs on a setTimeout chain while a socket is open, with a DO alarm as a resume watchdog. Why: the reference's in-process CallSession maps 1:1 onto a DO; KV is 1 write/s per key and eventually consistent, D1 has no affinity and no sockets (corpus-and-curation.md §5). Limits: 10 GB SQLite per object (we keep ≤ 5 MB), soft 1,000 req/s per object, 32 MiB received WS messages. ASSUMPTION: DO storage writes per utterance (~3 KB) are well under the write-unit budget for a 200-utterance call.
D1. System of record for everything queryable across calls: users, calls, utterances (pseudonymised), raw answers, decisions, policy versions, labels, exemplars, eval runs, audit log. Why: cross-call SQL (rankings, per-rep stats, label joins, "which decisions used policy v7"). Limits: 10 GB per database, 2 MB per row (answers rows are ~3 KB), 100 bound parameters per statement (batch inserts of ≤ 12 utterance rows per statement, use batch()), 100 KB statement.
R2. Write-once blobs: uploaded audio, raw call-coach turn JSON, nova-3 response JSON, redaction reports, eval run timelines. Private bucket, lifecycle rule on raw/ (retention is a Stevan/compliance decision, §10). Why: unbounded size, no PII in a query store, cheap egress.
KV. Read-hot mirror of published policies: policy:{scenario}:v{n} (immutable) and policy:{scenario}:current (pointer). The DO reads the pointer once at session start and pins the version. Why: one read per call instead of a D1 query; eventual consistency (up to 60 s) is harmless because the pointer names an immutable key.
Queues. One queue, typed messages: score_call (M2 batch corpus scoring), stt_job (audio uploaded, transcribe), rewrite (tailored line off the critical path), eval_step. Consumer is the same Worker with concurrency ≤ 10 (Jev rate limit on Cloudflare is unmeasured; the Text Generation default is 300 rpm, corpus-and-curation.md §0). Why: idempotent retries and backpressure without a scheduler. M1 uses it only for rewrite and stt_job.
Pages. The dashboard and admin console as one SPA (routes /, /calls/:id, /admin/*), behind Cloudflare Access. Deliverables also publish here per Stevan's decision. Pages Functions are not used (the AI binding cannot be declared in wrangler for Pages, docs/jev-guide.md §4.2); the Worker is the only backend.
Workers AI models. typesafe/jev via the binding (REST measured 400–550 ms, binding ~800 ms incl. cold start; response nested at result.result over REST, unwrap defensively per docs/jev-guide.md §4.7). @cf/deepgram/nova-3 over REST with diarize=true&utterances=true&punctuate=true&smart_format=true&language=en-GB&numerals=true, verified to return results.utterances[] with speaker/start/end/confidence in 1.9 s for 81 s of audio at 473 neurons/min ($0.31/h). Add keyterm (GBP, SWIFT, IBAN, mid-market, forward, beneficiary, safeguarding, CurrencyTransfer), mode=finance, mip_opt_out=true (untested flags; stt-options.md §5.1). Whisper-turbo is kept only as a text-only bulk path.
AI Gateway. A named gateway ct-copilot rather than default: request/response body logging off (bodies contain transcript text; ZDR does not cover gateway logs, docs/jev-guide.md §4.3), per-gateway spend limit, cf-aig-metadata {call_id, policy_version} on REST calls, caching off (identical states are rare and cached answers hide version drift). Billing is from prepaid credits with a 5% top-up fee; 402 code 2021 means empty credits (docs/jev-guide.md §4.6).
5. Data model
Principle: raw judgments and derived decisions are separate tables, both keyed to the policy that produced them. Recompute is a SQL read plus code.
-- identity
CREATE TABLE users(user_id TEXT PRIMARY KEY, email TEXT UNIQUE, role TEXT CHECK(role IN ('admin','rep','reviewer')), rep_name TEXT, created_at TEXT);
-- calls and transcript (pseudonymised text only)
CREATE TABLE calls(call_id TEXT PRIMARY KEY, source TEXT CHECK(source IN ('aircall_export','upload','live')),
scenario TEXT, scenario_conf REAL, rep_user_id TEXT, aircall_agent TEXT, direction TEXT,
recorded_at TEXT, duration_s REAL, engine TEXT, role_map_json TEXT, role_map_conf REAL,
raw_r2_key TEXT, audio_r2_key TEXT, coach_json TEXT, created_at TEXT);
CREATE TABLE utterances(call_id TEXT, i INTEGER, t REAL, t_end REAL, speaker TEXT CHECK(speaker IN ('rep','client','unknown')),
kind TEXT CHECK(kind IN ('turn','backchannel')), text TEXT, words INTEGER, source_turn_idxs TEXT, pii_json TEXT,
PRIMARY KEY(call_id, i));
-- policy: one immutable bundle per version per scenario
CREATE TABLE policy_versions(scenario TEXT, version INTEGER, policy_hash TEXT, question_bank_hash TEXT,
policy_json TEXT, status TEXT CHECK(status IN ('draft','evaluated','published','retired')),
eval_run_id TEXT, created_by TEXT, created_at TEXT, note TEXT, PRIMARY KEY(scenario, version));
CREATE TABLE policy_pointers(scenario TEXT PRIMARY KEY, published_version INTEGER, published_at TEXT, published_by TEXT);
-- what Jev said (free recompute) and what we did with it (audit)
CREATE TABLE answers(call_id TEXT, i INTEGER, question_bank_hash TEXT, model TEXT, answers_json TEXT,
input_tokens INTEGER, output_tokens INTEGER, latency_ms INTEGER, cf_request_id TEXT, created_at TEXT,
PRIMARY KEY(call_id, i, question_bank_hash));
CREATE TABLE decisions(call_id TEXT, i INTEGER, policy_hash TEXT, decision_json TEXT, shown_text_ids TEXT, created_at TEXT,
PRIMARY KEY(call_id, i, policy_hash));
CREATE TABLE rewrites(rewrite_id TEXT PRIMARY KEY, call_id TEXT, i INTEGER, move_id TEXT, candidate TEXT, shown INTEGER,
rejected_reason TEXT, verification_json TEXT, llm_model TEXT, policy_hash TEXT, created_at TEXT);
-- curation (M2 fills these; M1 creates them)
CREATE TABLE labels(label_id TEXT PRIMARY KEY, call_id TEXT, i INTEGER, question_id TEXT, gold_json TEXT, admin TEXT, created_at TEXT);
CREATE TABLE exemplars(exemplar_id TEXT PRIMARY KEY, call_id TEXT, start_i INTEGER, end_i INTEGER, scenario TEXT, topic TEXT,
quality TEXT CHECK(quality IN ('model','acceptable','avoid')), note TEXT, use_as_example INTEGER, promoted_text_id TEXT, admin TEXT, created_at TEXT);
CREATE TABLE eval_runs(run_id TEXT PRIMARY KEY, scenario TEXT, policy_version INTEGER, model TEXT, passed INTEGER,
l1_pass_rate REAL, l4_pass INTEGER, l5_max_std REAL, l6_max_tokens INTEGER, cost_usd REAL, r2_key TEXT, created_at TEXT);
CREATE TABLE audit_log(id INTEGER PRIMARY KEY, at TEXT, actor TEXT, action TEXT, subject TEXT, detail_json TEXT);
The policy JSON (one per scenario):
interface Policy {
scenario: 'onboarding' | 'customer_success';
version: number;
question_bank: Record<string, JevQuestion>; // shared + scenario questions; hashed separately
playbook: { moves: Move[] }; // {id,title,what,not_for,lines:[{text_id,text,source_exemplar?}]}
checklist: string[]; // question ids of said_* nouls (onboarding: 10)
risk_flags: RiskRule[]; // {id, question_id, min, label} plus code rules by name
weights: Record<string, { kind: 'noul'|'fact'|'score'|'code'; w: number }>;
thresholds: { persist_fact: 0.70; signal_on: 0.60; objection_open: 0.60; objection_clear: 0.60;
objection_max_age: 8; next_move_min_conf: 0.35; phrasing_min_conf: 0.30;
switch_margin: 0.12; confirm_updates: 2; card_cooldown_utts: 3; uncertain_band: [0.30, 0.70] };
ema_alpha: 0.40;
stage_prior: Record<string, number>;
token_budget: { hard_cap: 12000; window: 12; drop_order: ['phrasings', 'window_to_8'] };
}
question_bank_hash is the hash of question_bank alone; policy_hash covers the whole bundle. Changing a weight creates a new policy version with the same question_bank_hash, so every stored answers row still applies and recompute costs nothing. Changing any question text creates a new question_bank_hash; old answers stay under the old hash for rollback and the eval run re-asks only the labelled set. This is the cache-key discipline from the consistency cookbooks (corpus-and-curation.md §6b).
Thresholds are re-derivable offline because we store answers, not just the reference's derived features (its recompute cannot retune FACT_PERSIST_THRESHOLD, dissection §3e). The DO's recompute(policy) re-runs the full pipeline from answers.
6. Per-utterance decision loop
6.1 Stitching Aircall fragments
Evidence from data/samples/onboarding-strong-3339895706.json: 159 fragments, 118 overlap the previous one, 71 are two words or fewer, median gap −0.17 s. The call-coach call_turns.data already carries stitched_chain_len and members, but chains are still short (stitched_chain_len: "1" on the sampled rows). Rules, all code, run at ingest and stored as utterances (never re-run live):
- Sort fragments by
start; maprole internal → rep,external → client. - A fragment is a
backchannelif it has ≤ 2 words after stripping punctuation and every word is in{yeah, yes, mhmm, mm, okay, ok, right, sure, uh-huh, gotcha, exactly, absolutely, cool, fine, no, yep}. Backchannels are stored, shown in the transcript, included in the state window (they carry acceptance signal), but never trigger a Jev request. - Merge consecutive same-speaker non-backchannel fragments into one utterance when
next.start − prev.end < 1.0 s(overlap counts as < 1.0) and the merged length stays ≤ 120 words. An interleaved backchannel from the other speaker does not break a chain; a non-backchannel from the other speaker does. - Split any merged utterance > 120 words at sentence boundaries into sub-utterances sharing
t, sequentialt_end. (Reference monologue threshold is 70 words; ours is a separate code signal.) t_endof the utterance = maxendof its members. Replay fires the decision att_end, exactly as the reference does for video mode (stt-options.md§1).- Garbled cross-talk ("It's it's not a pay even it's three like a to pay four. cut.") is a transcription artefact; it is not fixable in code and is the reason re-transcription via nova-3 exists when audio is available.
ASSUMPTION: the 1.0 s and 120-word constants; tune by eye on 20 calls in M1 and store them in the policy token_budget/ingest config so they are versioned.
6.2 State sent to Jev
interface JevState {
call_facts: {
scenario: 'onboarding' | 'customer_success';
client_type?: 'personal' | 'corporate'; // from pre-call if known
minute: number; // code
talk_ratio_rep: number; // code, whole call, 2dp
stage_history: string[]; // compressed, last 6
known_facts: string[]; // persisted *_known ids
checklist_done: string[]; // persisted said_* ids
objection: { open: true; type: string } | { open: false };
issue?: { reported: true; owned: boolean }; // CS only, code state machine
};
recent_transcript: { t: string; speaker: 'rep'|'client'; text: string }[]; // last 12 utterances incl. backchannels
latest_utterance: { t: string; speaker: 'rep'|'client'; text: string };
}
No pre_call block in M1 (the CT DB join is M2); the shape reserves it. Token budget, measured by ceil(JSON.length / 4) in code (no tokenizer is published, docs/jev-guide.md §2.5): state ≈ 0.8k (12 utterances at ~30 words each plus facts), questions ≈ 9k (see 6.3), overhead ≈ 0.3k → ~10k per request. Hard cap 12k enforced before the call: over budget → drop the per-move phrasing Choices first (they are speculative), then shrink the window to 8; log the degradation on the decision message. Never exceed the cap; treat an oversized-request 4xx as a bug, not a retry.
6.3 Question bank structure
The bank is the DRAFT from docs/research/ct-domain-brief.md §4, kept in Stevan's format, assembled in code from four layers:
| Layer | Onboarding | Customer Success | Notes |
|---|---|---|---|
| Shared nouls (client-turn, rep-turn, either) | 18 | 18 | client_objecting, client_accepts, client_disengaging, client_confused, client_asked_about_{rate,safety,timing,fees,documents}, client_mentioned_{alternative_provider,deadline}, client_ready_to_book, next_step_agreed, rep_asked_open_question, rep_explaining_at_length, rep_made_rate_prediction, rep_made_guarantee, rep_proposed_next_step |
| Shared scores | 3 | 3 | engagement, urgency, trust, 3 levels each |
| Scenario nouls | 11 *_known facts + 10 said_* checklist + funding_from_third_party + jurisdiction_concern |
issue_reported, issue_resolved_or_owned, upcoming_payment_known, no_upcoming_need, client_wants_to_wait_for_rate, client_asked_about_{forwards,alerts_or_orders,recurring,access}, rep_offered_tool, rep_checked_status_with_specifics, said_forward_deposit_and_liability |
window nouls read recent_transcript, so they can lock on a rep turn |
| Scenario choices | onboarding_stage (10 + none), onboarding_objection_type (8 + none), next_move (14 moves) |
cs_stage (9 + none), cs_objection_type (7 + none), next_move (13 moves) |
none in every Choice; a Choice picks which, the paired noul decides whether |
| Phrasing choices | 14 × 3 lines | 13 × 3 lines | speculative; first to be dropped under budget |
Total ≈ 72 questions for onboarding. Speaker masking in code: client-only nouls are zeroed on rep turns and vice versa, exactly as PROSPECT_ONLY_SIGNALS in the reference. The stage Choices get an explicit none: "Too little context yet" option, absent in the draft, per the guide's rule that a Choice always crowns a winner.
6.4 Decision code (all in the DO)
type Answers = Record<string, JevAnswer>;
function decide(s: Session, a: Answers, p: Policy): Decision {
const spk = s.latest.speaker;
const n = (id: string) => (a[id]?.type === 'noul' ? maskBySpeaker(id, spk, a[id].noul, p) : 0);
// durable facts + checklist (memory in code, threshold from policy)
for (const id of [...p.facts, ...p.checklist]) if (!s.persisted[id] && n(id) >= p.thresholds.persist_fact) s.persisted[id] = s.latest.i;
// objection lifecycle: reference rules + `since` refreshed on re-raise (dissection §4b lists that as a defect)
updateObjection(s, a, p);
// stage with hysteresis: switch only if the new stage leads the current by switch_margin or wins confirm_updates in a row
s.stage = stableChoice(s.stageState, a[p.stageQuestion], p.thresholds);
// hero metrics, code only
const hero = p.scenario === 'onboarding'
? { checklist_pct: p.checklist.filter(id => s.persisted[id]).length / p.checklist.length,
risk_flags: riskFlags(s, a, p) } // rep_made_guarantee>=.6, rep_made_rate_prediction>=.6, funding_from_third_party>=.7, booking-without-binding-said
: { resolution: s.issue, call_health: ema(s, composite(a, s, p)) }; // composite = Σ w·x with scores centred, EMA α .40, clamp [.03,.95]
// next move: code pre-filter then Jev's relative pick, then anti-flicker
const allowed = allowedMoves(s, p); // drop moves whose `requires` facts are missing or whose `blocked_by` facts are persisted; hard rules live here, not in `not_for`
const card = stableCard(s.cardState, a.next_move, allowed, p.thresholds); // min conf .35, switch margin .12, confirm 2, cooldown 3 utterances, no repeat of an acked card
const line = bestLine(a[`phrasing::${card?.move_id}`], p.thresholds.phrasing_min_conf);
return { i: s.latest.i, stage: s.stage, hero, checklist: checklistView(s, a, p), objection: s.objection,
card: card && { move_id: card.move_id, text_id: line.text_id, confidence: card.confidence, why: card.why },
signals: signalView(a, s, p), sensitivity: topDeltas(s, 3), budget: s.lastBudget, jev: s.lastJev };
}
Gates and anti-flicker, with sources: fact persist 0.70, signal on 0.60, objection open/clear 0.60, next-move confidence 0.35, phrasing 0.30 (reference constants.py, dissection §5a); uncertain band 0.30–0.70 shown as "uncertain" in the checklist rather than forced to yes/no (cookbooks__consistency_noul_cookbook.md); switch margin 0.12, confirm streak 2, cooldown after a rep acks a card (call-coach-ai/decide.js, docs/research/prior-art.md §2.1); one card at a time, "listening" by default. Choice thresholds are never reused for nouls (jaggedness §"structural invariants").
Onboarding hero: the checklist is a fraction, in code, of the ten said_* items persisted; items also show uncertain when the latest window answer sits in the band. Risk flags are binary code rules that light red regardless of everything else. CS hero: resolution is a three-state machine in code (none → reported → owned/resolved) driven by issue_reported ≥ 0.6 and issue_resolved_or_owned ≥ 0.7; call_health is the reference composite with the CS weights from the domain brief §4.6 (draft) and the EMA. Weights are policy data and tunable with free recompute.
6.5 Tailored rewrite path (off the critical path)
Trigger rules copied from the reference (personalize.py): move changed, new fact persisted, or same move held 8 utterances; cooldown 2; skip moves in rewrite_skip_moves. The DO enqueues {call_id, i, move_id, window6, facts} to the Queue; the consumer calls a small generative model (ASSUMPTION: Claude Haiku via AI Gateway; the model is a policy field), cleans the line in code (length ≤ move cap, one sentence), then makes a second Jev request with three verifier nouls:
invents_fact: doescandidate_linestate a number, price, rate, name, date, partner, document or promise not present inrecent_transcriptorknown_facts? drop ≥ 0.5on_move: iscandidate_linea reasonable way to carry outmove? drop < 0.5makes_promise_or_prediction(CT-specific): doescandidate_lineguarantee a rate, saving, arrival time, or approval, or predict which way a rate will move? drop ≥ 0.3 (stricter: false positives are cheap, a shown promise is not)
Stale check: if the DO's current move differs, drop. Delivery is a separate rewrite message the UI can ignore; a Jev or LLM error means no rewrite, never a fallback to unverified text (the reference passes on verifier error, dissection §6d; we invert that). Every candidate, shown or not, is a rewrites row.
7. Ingestion and STT
Historical Aircall calls (no STT). Source: ~/dev/jev/data/call-coach/call_turns.jsonl.gz (735,130 rows; data is {start, end, role, text, agent, direction, …}; psql COPY escaping means unescape backslashes before JSON.parse), call_records for category, data.summary, rubric dims and quotes (kept as calls.coach_json for the admin, never sent to Jev). scripts/import-call.mjs selects a call, stitches (6.1), redacts (§10), assigns roles from role (already reliable: internal/external), writes R2 raw/{call_id}/turns.json and D1 rows, and POST /api/calls/import does the same from the browser for a pasted export. Scenario comes from category (Onboarding → onboarding, Customer Service → customer_success); Biz Dev is out of scope.
Uploaded audio (nova-3). POST /api/calls/upload streams the file to R2 audio/{call_id}.{ext}, enqueues stt_job; the consumer POSTs the binary body to /ai/run/@cf/deepgram/nova-3 with the verified query string, stores the raw JSON in R2, maps results.utterances[] → fragments {start, end, speaker: 0|1, text} and runs the same stitcher. Aircall recordings are downloadable via scripts/download_call_audio.py on the coach server, so a historical call can be re-transcribed when its export text is garbled. Open question for Stevan: are Aircall recordings dual-channel? If so multichannel=true replaces diarisation entirely (stt-options.md §3.3).
Speaker → role mapping. Diarisation gives 0/1, not rep/client. Code heuristic first (the speaker who says "CurrencyTransfer" or "calling from" in the first 60 s is rep; on outbound calls the rep usually speaks second after "Hello?"), then one Jev request with one Choice per speaker cluster over the first 8 turns of each: {rep, client, other}; accept at confidence ≥ 0.8, else the call is flagged role_map_conf low and the admin flips it in the UI (corpus-and-curation.md §1b). unknown is a legal speaker so masking degrades instead of mislabelling.
8. Dashboard and admin console
8.1 Dashboard panels
| Panel | Shows | Source |
|---|---|---|
| Hero | Onboarding: checklist completeness % with a 10-item list (said / uncertain / unsaid) and a red risk-flag strip. CS: resolution state chip + call-health line chart (EMA vs instant) | decision.hero, decision.checklist |
| Transcript | Utterances as they fire, speaker-coloured, backchannels dimmed, talk-share bar, monologue warning | utterance messages (independent of Jev latency) |
| Stage strip | Current stage with confidence, compressed history | decision.stage |
| Objection card | Type, since, confidence; the topic's approved lines | decision.objection |
| Next best move | Title, what, the highlighted approved line, ack/dismiss buttons; "listening…" when gated; the tailored line appears below it if and when a rewrite arrives |
decision.card, rewrite |
| Signals | Weighted signals with on/off dots, lock icon for persisted facts, score levels | decision.signals |
| What moved | Top 3 contribution deltas (CS only) | decision.sensitivity |
| Jev | model id, latency, tokens, budget degradation, cumulative cost | decision.jev, decision.budget |
| Replay controls | play/pause, speed 0.5–20×, seek | client → DO |
8.2 WebSocket protocol
Endpoint wss://…/ws/calls/{call_id}; Access JWT on the upgrade; every message {v: 1, type, seq, ts, ...}; the server replays decision history from DO storage on reconnect so a reload never loses the timeline (the reference cannot, dissection §7a).
type ClientMsg =
| { type: 'start_replay'; speed: number; from_i?: number }
| { type: 'pause' } | { type: 'resume' } | { type: 'seek'; i: number } | { type: 'set_speed'; speed: number }
| { type: 'utterance'; speaker: 'rep'|'client'; text: string; t?: number; t_end?: number } // live/type mode
| { type: 'ack_card'; card_id: string } | { type: 'dismiss_card'; card_id: string }
| { type: 'set_weights'; weights: Policy['weights'] } // admin/reviewer only; experiment, not publish
| { type: 'ping' };
type ServerMsg =
| { type: 'hello'; session_id: string; call: CallMeta; policy: { scenario; version; policy_hash }; model_expected: string; history: Decision[] }
| { type: 'utterance'; i: number; t: number; t_end: number; speaker: string; kind: 'turn'|'backchannel'; text: string }
| { type: 'decision'; d: Decision } // one per non-backchannel utterance
| { type: 'rewrite'; i: number; move_id: string; rewrite_id: string; text: string | null; rejected?: string; verification: Record<string, number> }
| { type: 'recomputed'; timeline: Decision[]; policy_hash: string }
| { type: 'replay_state'; playing: boolean; i: number; total: number; speed: number }
| { type: 'replay_done' } | { type: 'pong' } | { type: 'error'; code: string; message: string };
Rep-facing text is typed so the UI cannot render anything else:
type RepFacingText = { kind: 'approved'; text_id: string; policy_version: number }
| { kind: 'rewrite'; rewrite_id: string; verified: true };
8.3 Admin console
M1 ships the read side and the policy loader; M2 ships curation. Screens: Calls (list, scenario, engine, role-map confidence, open replay); Policy (view the published version per scenario, diff two versions, upload a draft JSON, run eval, publish if the run passed); Eval runs (table with pass rates and the per-case diff). M2 adds: Review queue ordered by information value (uncertain-band episodes first, then unlabelled top/bottom calls; corpus-and-curation.md §3c); Moment view (Jev-tagged span with transcript excerpt, mark model / acceptable / avoid, tick "use as criterion example", "promote to playbook line" which creates a lines[] entry with source_exemplar in a new draft policy); Weights (sliders, live recompute over the corpus from answers, zero Jev calls); Promotion flow: draft → eval run → review flipped cases → publish (D1 pointer + KV + audit row) → optional backfill job.
9. Evaluation harness and promotion gate
Modelled on the reference's two real-API layers (dissection §9b–c), run as a Workflow from POST /api/eval/run?scenario=…&version=… and from scripts/eval.mjs in CI against the same Worker.
| Layer | Input | Check | Gate |
|---|---|---|---|
| L1 labelled utterances | labels rows (M1: 20–40 per scenario hand-written from the four exemplar transcripts + synthetic edge cases: negations, injection text, a client who says "guaranteed?") |
per-case threshold checks (client_objecting >= 0.6, objection_type in {rate}, …) written as data |
≥ 90% pass, no case regressing across a threshold vs the published version |
| L4 call shapes | the strong/weak exemplars per scenario | strong onboarding ends with checklist ≥ 0.6 and zero risk flags; weak onboarding ends ≤ 0.4; strong CS reaches resolved; a card is shown on 50–100% of utterances |
hard |
| L5 stability | L1 re-run 3× with a fresh uid in state |
per-question std ≤ 0.03; list threshold crossers | report; crossers become criteria rewrites |
| L6 budget | every run | tokens/request ≤ 12k, p95 Jev latency < 1.5 s, cost/call ≤ $0.05, model unchanged from the expected id |
hard |
| Calibration (M2+) | ≥ 200 labels | reliability buckets per question type; nouls are best calibrated, scores worst (primeline test, prior-art.md §5) |
report; set thresholds per type from data |
Promotion: draft → evaluated (run passed) → published. The publish route refuses a version whose latest eval_runs.passed is 0 or whose run predates the policy's created_at. A change in response.model fails L6 and opens a "model drift" run automatically; the same gate is used for a model upgrade with model as the changed variable. Answers for eval are cached by (state_hash, question_bank_hash, model) so re-running an unchanged bank is free.
10. Compliance and PII
Redaction before storage and before Jev (code, ingest time). Regex + checksum: IBAN (mod-97), UK sort code + account number, card numbers (Luhn), phone, email, postcode, DOB patterns, passport-like tokens; spoken digit runs ("four two, one one") normalised then matched. Names: the call-coach pd_first_name/pd_last_name and the Aircall external_phone are known per call and replaced with [CLIENT_NAME] / [PHONE]; the rep's name stays (it is an employee, and said_rm_contact needs it). Amounts are kept (they drive amount_known and are not identifiers on their own; ASSUMPTION to confirm). Typed placeholders keep the transcript readable for Jev. A pii_json report per utterance records what was replaced; the mapping is not stored in M1. M2 adds a Jev noul sweep pii_present on every utterance as a second pass (corpus-and-curation.md §1d).
Data processing notes. Raw audio and un-redacted turns live only in the private R2 raw/ prefix with a lifecycle rule (retention TBD by Stevan). Only redacted text reaches D1, the DO, KV, the browser and Jev. AI Gateway body logging is off on the named gateway. mip_opt_out=true on nova-3 (Deepgram model-improvement program). TypeSafe states Jev is not trained on customer requests, but whether its DPA covers traffic proxied through Workers AI ("Third-party" model) is unresolved (docs/jev-guide.md §4.11): until Stevan confirms, M1 runs on historical calls that have already been processed by the existing OpenAI-based coach pipeline (reference/call-coach/README.md), not on new live audio.
Approved-text policy. Every string the rep sees comes from a published policy version by text_id, or is a verified rewrite with its three verifier probabilities stored. decisions.shown_text_ids records what was on screen per utterance. Playbook lines marked [VERIFY] in the draft brief cannot be published: the policy loader rejects a bundle containing that marker.
Audit log. Rows for: policy upload/eval/publish/retire, role-map override, label and exemplar writes, weight experiments, rewrite shown/dropped, user role changes. Actor = Access identity.
Recording disclosure. Whether calls must be announced as recorded is a Stevan/compliance question (domain brief §2.3); the copilot does not change what Aircall already records in M1.
11. Milestones
M1: replay PoC on the production shape (target 1–2 weeks)
Deliverables and acceptance criteria:
- Infra:
wrangler.jsoncwith AI, D1, R2, KV, Queue, DO bindings; named AI Gateway with body logging off; Cloudflare Access on the Pages site and the Worker. AC:wrangler deployfrom clean; an unauthenticated request to/api/*and/ws/*is rejected. - D1 schema v1 (§5) with migrations;
usersseeded with Stevan as admin. AC: migration applies;PRAGMA-level check in CI. - Policy v1 per scenario built from the domain brief's DRAFT banks and playbooks by
scripts/build-policy.mjs, withnoneoptions added and[VERIFY]lines quarantined; loaded to D1 + KV. AC: the loader rejects a bundle with[VERIFY]; both scenarios resolve throughpolicy:{scenario}:current. - Importer for call-coach exports (stitch, redact, role map, R2 + D1). AC: the four sample calls import; stitched utterance count is 30–70% of raw fragment count; no IBAN/phone/email pattern survives in
utterances.text. - Uploader (audio → R2 → Queue → nova-3 → stitcher). AC: an mp3 ≤ 40 min produces speaker-labelled utterances; role map confidence and the override path work.
CallSessionDO with the decision loop (§6), token budget enforcement, retry policy (408/429/5xx, 2 retries, jittered backoff, 10 s deadline → "unknown, do not act"), DO storage of raw answers and decisions, D1 append, reconnect history. AC: replaying the strong onboarding sample produces onedecisionper non-backchannel utterance, p95 request tokens ≤ 12k, zero requests over cap, a reload mid-replay restores the timeline.- Dashboard (§8.1) on Pages with all panels for both scenarios, replay controls, ack/dismiss. AC: the four samples replay end to end at 1× and 10×; hero metric is checklist % + risk flags for onboarding and resolution + health for CS.
- Eval harness L1/L4/L5/L6 as a Workflow +
scripts/eval.mjs; 20–40 labelled utterances per scenario; publish route gated. AC: policy v1 passes L1 ≥ 90%; a deliberately broken draft (atrue/falseswap) fails and cannot be published. - Rewrite path behind a policy flag, default off (stretch; slips to M2 without touching anything else). AC when on: a candidate that quotes a rate not on the call is dropped with
invents_fact≥ 0.5 recorded. - Cost/latency report from the first 50 replayed calls: tokens, latency, credits delta, effective $/Mtok (guide §4.10 reconciliation). AC: the numbers in §12 are replaced by measured ones.
Out of M1: CT DB pre_call join, curation UI, corpus-wide scoring, Vectorize, live audio.
M2: curation console and corpus scoring (2–3 weeks)
Batch scoring of the usable Onboarding (1,895) and Customer Service (1,226) calls via the Queue with stored answers; the review queue, moment tagging, exemplar → criterion example and → playbook line promotion; weight sliders with corpus recompute; calibration report; PII noul sweep; CT DB join for pre_call and outcome labels (activation within 30 days from broker_accounts.verified_at); rewrite path on for CS if compliance agrees.
M3: live audio (scope TBD with the Aircall decision)
nova-3 WebSocket from the DO (interim_results, endpointing=300, utterance_end_ms=1000, KeepAlive), or per-leg audio if the telephony path provides it; a complete noul + silence timer for interim commits (prior-art.md §2.5); rep-side hotkeys; observer mode for managers.
12. Cost and latency budget
Assumptions: ~10k input tokens per decision request (§6.2), $0.042/M input pass-through, +5% credit fee, output free (verified facts); a stitched call has ~45 decision-triggering utterances (ASSUMPTION: 65 raw fragments per call on average from 735k/11.3k, roughly 30% of which are backchannels, ~40% merge); usable onboarding calls average 7.3 min (229 h / 1,895).
| Item | Per call | Per 1,000 historical calls |
|---|---|---|
| Jev decisions (45 × 10k tokens) | 450k tokens ≈ $0.019, $0.020 with fee | ≈ $20 |
| Rewrites (≈ 7 per call: LLM ~1k tokens + Jev ~1.5k) | ≈ $0.01 (ASSUMPTION, Haiku-class pricing) | ≈ $10, only if enabled |
| nova-3 re-transcription (7.3 min × $0.0052) | ≈ $0.04 | ≈ $38, only for calls needing audio |
| D1/R2/KV/DO | negligible at this scale | < $5 |
| Wall-clock for batch at ~300 rpm (unmeasured Cloudflare limit) | 45k requests ≈ 2.5 h |
Latency per decision (replay and live): utterance t_end → utterance message < 50 ms; Jev via binding 400–800 ms (REST measured 400–550 ms; binding ~800 ms including cold start; steady-state binding latency is unmeasured, ASSUMPTION ~500 ms); decision code < 5 ms; WS delivery < 50 ms. Target p95 end-to-end < 1.5 s, gated in L6. Rewrites arrive 1–3 s later and are never awaited.
13. Risks, unknowns, questions for Stevan
Risks:
- Jev version drift on Cloudflare (no pinning). Mitigation:
modelstored on every answer, L6 fails on change, eval re-run is one click. - Transcript quality of Aircall exports (overlapping garbled cross-talk) degrades literal questions. Mitigation: re-transcribe with nova-3 where audio exists; measure L1 on both engines.
- Cloudflare Jev rate limit and steady-state binding latency are unmeasured. Mitigation: M1 deliverable 10 measures both before M2 batch sizing.
- 32k context: current budget is ~10k; adding
pre_call, value-extraction Choices or more moves can double it. Mitigation: hard cap and drop order in code, L6 gate. - Choice option order affects results (
docs/jev-guide.md§3.5); policy diffs that reorder moves change answers. Mitigation: option order is part of the hash; the eval run catches it. - Adversarial or odd client speech can move nouls. Mitigation: injection cases in L1; nothing on screen is generated from client text except through the verified rewrite path.
- Draft question bank and playbook are unreviewed (
ct-domain-brief.mdis marked DRAFT). Everything downstream inherits their errors until Stevan red-pens them.
Questions only Stevan can answer:
- The must-say list per scenario and the compliance-approved wording for safeguarding, who holds funds, how CT is paid, FSCS; which
[VERIFY]lines are true. - Is a tailored rewrite acceptable at all for onboarding, or CS only, or neither (flag default)?
- Are Aircall recordings mono or dual-channel; can audio be pulled for every historical call; what retention applies to
raw/? - May pseudonymised transcripts be sent to
typesafe/jevon Workers AI under CT's DPA obligations (Third-party model, no ZDR marker)? - Which rep identities map to
usersand does a rep see only their own calls? - Whether onboarding calls in
category='Onboarding'are the post-wizard activation call (the brief assumes so), and whether Biz Dev should ever be in scope. - Outcome definition for M2 weight learning: activation =
broker_accounts.verified_atwithin N days of the call; N?
14. Sources
/home/stevan/dev/jev/docs/jev-guide.md(contract, semantics, Cloudflare integration, gotchas)/home/stevan/dev/jev/docs/research/reference-copilot-dissection.md(engine, gates, protocol, gaps)/home/stevan/dev/jev/docs/research/stt-options.md(nova-3, whisper, live transport)/home/stevan/dev/jev/docs/research/prior-art.md(anti-flicker mechanics, calibration evidence, admin patterns)/home/stevan/dev/jev/docs/research/ct-domain-brief.md(DRAFT question banks, playbooks, checklist, composite weights)/home/stevan/dev/jev/docs/research/corpus-and-curation.md(storage limits, curation loop, harness layers)/home/stevan/dev/jev/docs/vendor/typesafe/model-jaggedness__jev-1.13.md/home/stevan/dev/jev/reference/call-coach/README.md,/home/stevan/dev/jev/reference/call-coach/config/rubrics.json/home/stevan/dev/jev/reference/jev-sales-copilot/copilot/{engine.py,constants.py,personalize.py,server.py}/home/stevan/dev/jev/data/samples/onboarding-strong-3339895706.{json,txt}(fragment statistics computed 2026-09-24)/home/stevan/dev/jev/data/call-coach/{call_turns,call_records,call_features}.jsonl.gz/home/stevan/dev/jev/src/index.ts,/home/stevan/dev/jev/scripts/jev.mjs,/home/stevan/dev/jev/wrangler.jsonc- Verified facts supplied in the task brief (2026-09-24): Jev REST/binding latency and envelope, nova-3 output and neuron cost, corpus counts.