CatalyzeUp
sondely/migration

Agent Layer: MCP, Chat, Voice, WhatsApp

How TourCockpit's assistant (Faro) becomes Sondely's, phase by phase: an API an agent can read, then MCP, then the in-app chat, then respondents by WhatsApp text, then by voice. For each phase: the TourCockpit files it starts from, what is rewritten for surveys, the scenario tests that prove it, the identity on each channel, and what "done" means.

Why Sondely should have an agent at all, the gap analysis against today's Python API, the cost and the privacy questions are in Sondely as an Agent and are not repeated here. File by file verdicts (copy as is, copy and strip, later, leave behind) are in the Framework Copy Manifest; this page refers to files by their TourCockpit path at commit f952d0eea, where it was read. The copy itself was made at 74e9a2a53 on 7 November 2026, and each copied file names its source in its header.

The assistant as built: Sona

Built on 7 and 8 November 2026 in CatalyzeUp/sondely-platform, from Chip's goal of 7 November 2026 ("a full agentic system with voice and charts"). The assistant is named Sona, a name that echoes Sondely, sondage and sonar; it is held in src/lib/assistant/config.ts (ASSISTANT_NAME).

Door State on 8 November 2026 Where
In-app chat, always beside the workspace Built, for members who are not respondents of the active organization (or of their only one). A rail on every workspace screen opens into a docked panel the page reflows beside; Sona also has its own screen, Sona in the menu (/assistant). Every write is proposed, dry-run and committed on a later yes, once, with an idempotency key; creating and publishing a survey are two proposals and two yeses (below) src/lib/assistant/, src/components/assistant/{SonaProvider,SonaHost,SonaConversation,SonaPage}.tsx, src/app/api/assistant/{chat,conversations,status}
Survey charts Built: group scores, response counts and pre and post impact, computed in code on the scoring engine for one survey and a time window, never with respondent names, emails or answers src/lib/assistant/charts.ts, widgets.ts
MCP Built, with OAuth (consent, PKCE, refresh, revoke) and one organization at a time. The tools and proposals are the drawer's. An API key reads over MCP and is refused every proposal src/lib/mcp/, src/app/mcp/, src/app/.well-known/
Builder voice Built: owners and admins call Sona from the browser (below) src/lib/assistant/{voice,voice-live,web-call,voice-ask-client}.ts, src/app/api/assistant/voice/
Respondents on the survey page, by chat and by voice Built on 8 November 2026 (below) src/lib/respondent/, src/app/api/survey-agent/, src/components/survey/{RespondentChat,RespondentVoice,useRespondentVoice}.tsx
Respondents by WhatsApp, text and call Not built; phases 8a and 8b below

The rules live in src/lib/assistant/rules.ts, one source for the drawer, MCP and voice (tests/unit/sona-rules.test.ts). Refusals are classed in src/lib/assistant/refusal-legibility.ts (tests/unit/sona-refusals.test.ts). Each assistant message stores its model cost (AssistantMessage.costUsd), and an incomplete count is stored as unknown rather than zero. The behaviour is proven in tests/integration/sona-assistant.test.ts, sona-mcp.test.ts and sona-voice.test.ts, the drawer and the call in Chromium (tests/assistant-e2e/), all on every push. The nightly live-model suites described for phase 7 are not built.

The phase sections below are the plan as written on 5 November 2026; where the build differs, the table above is what runs.

Sona beside every screen

Asked by Chip on 9 November 2026: "always available, almost like how it's implemented in TourCockpit: a side panel always available, and also as an individual screen." Ported from TourCockpit's AssistantProvider and AssistantRail at the pinned commit.

  • The provider lives at the root layout (SonaProvider), so the conversation, the unsent message and a call survive moving between Dashboard, Surveys, Organizations and the rest. Closing Sona, by the button, Escape or the shortcut, ends a call, so a call never runs with its controls hidden; leaving Sona's own screen during a call opens the panel, where the call's controls are. On a phone the open panel covers the screen, so moving to another screen means closing it. It decides from the server's own rule (sonaAvailableTo in src/lib/assistant/presence.ts, the same as resolveAssistantCaller) and shows only on workspace screens, never on a public survey, an invitation or sign-in.
  • The rail is the panel, collapsed. A 44px strip down the right edge on a desktop, a bar above the thumb on a phone. Opening it widens the same edge into a 420px panel and the page reflows beside it (--sona-reserve), never under it; on a phone the panel takes the screen. Cmd or Ctrl+J opens and closes it. Whether it is open, and the conversation per organization, are kept for the browser session, so a reload resumes where the person was.
  • Its own screen: Sona in the menu opens /assistant, the same conversation at full width; the rail and panel step aside there.
  • Two starters on an empty conversation: Create a survey (fills "Create a survey about " for the person to finish) and Show my results.
  • Tests: tests/assistant-e2e/sona-launcher.spec.ts (desktop and phone), tests/component/sona-drawer.test.tsx, sona-voice.test.tsx. On a phone the app's own header is tall, which leaves the full screen little room; that header is unchanged.

Creating a survey: the failure of 9 November 2026 and what changed

Chip asked Sona on production for "a survey with three questions related to Montreal and happiness for a tourist". Twenty-six messages later there was no draft. Replayed with the live model, the causes were:

  1. A framework bug. Strict tool schemas carry a map (a rating question's labels) as a list of key and value pairs; the conversion back (bodyFromArgs, src/lib/api/strict-schema.ts) missed maps nested under a nullable list, so every survey with a labelled rating question was refused. Fixed; tests/unit/strict-schema-pairs.test.ts. TourCockpit carries the same function.
  2. The organization: the model sent null or the organization's name. A write that leaves the organization out, or names it by its own name, now gets the id of the organization bound to the request (bindOrganization). Any other value is refused the same way, whether that organization exists or not, so a request for another organization is never made in this one and Sona never reveals whether an organization exists.
  3. An unreadable refusal: "Fields: questions". A schema refusal now names each issue at its full path with its reason (questions.1.rating_labels: expected record, received array), and the REFUSALS rule tells Sona to correct what it shaped and ask the person only for what is theirs to decide.
  4. Interviewing instead of drafting: the GATHER rule forbade choosing recurrence, scoring and the like. It now reads "Draft, do not interview": a complete draft with the system defaults, named beside the card, which is where the person changes them.
  5. A card that changed nothing: validators were offered as proposals, so Sona could propose "Validate a proposed survey", take a yes and create nothing. Routes that change nothing are no longer proposals (every proposal is dry-run already).
  6. The reply: an earlier refusal in a turn hid a later, corrected card, and text written before a tool call was repeated after it. The latest outcome decides the reply. A refused change is set aside only when the same tool proposed a card for the same record later in the turn (the request corrected); any other refused change is said before the card, and a refused lookup is not repeated. Only the model's final answer is kept.

tests/integration/sona-create-survey.test.ts feeds the exact requests the model sent through the real executor and confirms each one creates the survey; all four fail on the code that was in production.

Live simulations

npm run test:live drives Sona with the real model against the test database (needs AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY; it costs real tokens, so it is not part of push CI). LIVE_OUT names the file that receives each transcript and every write the model attempted, with its validation issues. Scenarios: the production request verbatim, a French request with a 1 to 5 scale, single and multiple choice with a comment, the Maslow template, and create then add a question then publish. On 9 November 2026 after the fixes, all five reached a draft card on the first reply and created what was asked on the yes. LIVE_REPLAY=1 adds the replay of Chip's production messages, as a diagnostic.

Question types, and an open request (9 November 2026, the same day). "Let's build a volunteer check-in" gave a survey whose "Do you feel connected to the team or organization?" was a 0 to 5 rating; a respondent's "yes" could not be recorded on it. The QUESTION_TYPE rule and the question type field's description now say a question's type is the form of answer its wording asks for (yes or no is BOOLEAN, a degree is RATING, a pick is a choice, an open question is TEXT). Replaying that open request also showed Sona asking "custom draft or template?" and asking again after "Go ahead, make this": GATHER now says that with no instrument named, the default is Sona's own draft, and a go-ahead answers any open choice. A group's id field says Sona chooses it, after one run refused a group with no id and asked the person for it. The scenario follows the person's own turns (the request, "Go ahead, make this.", yes) and fails when a question worded "Do", "Is", "Are", "Have" and the like is a rating. Before these rules: 1 failure in 4 on the type and none on the card; with the type rule alone: 2 in 4 without a card; with all three: 5 of 5, and the other five scenarios still pass.

Respondents on the survey page: chat and voice

Built on 8 November 2026, from Chip's ask the same day to answer a public survey link by chat and by voice. On the survey page, once a respondent has begun, three buttons choose how to answer: Form, Chat with Sona (shown when ASSISTANT_ENABLED is true) and Talk with Sona (shown when Voice Live is configured too). The three share one set of answers: switching mode keeps what was answered, and the answers are saved and submitted through the survey's own session routes with the respondent's grant, exactly as the form does. Nothing in the agent writes.

The respondent pattern holds: code asks, the model listens.

  • The words asked are code's. questionPrompt in src/lib/respondent/questions.ts builds the text from the stored question and its options (numbered choices, a rating's two end labels); the chat shows it and the voice reads it verbatim (Voice Live is told to say exactly the text and nothing else).
  • A reply is read by code first. A number, yes or no, an option's words or number, "skip" on an optional question, and any text for a text question are read without a model (directAnswer).
  • The model only reads what is left (src/lib/respondent/interpret.ts): one call to the text deployment with a strict JSON schema that returns answer, skip or unclear, never a guess. Its answer is checked with the form's own validateAnswer; anything the form would refuse becomes unclear, and the question is asked again. With ASSISTANT_MODEL=stub (tests), a free reply is always unclear.
  • Chat: POST /api/survey-agent/interpret (public survey only, same origin, 300 replies per address per hour). An option can also be tapped.
  • Voice: POST /api/survey-agent/voice/session opens a Voice Live call with two tools, answer_question (the person's words, verbatim) and end_call. The runner (src/lib/respondent/voice-call.ts) holds the questions and the collected answers in memory on the replica, asks the first unanswered one, and skips any the page already holds. The page polls GET /api/survey-agent/voice/call with the call's token, saves each new answer, and submits when the call reports the last one done. Ten calls per address and ten per attempt per hour, twenty minutes at most.
  • Who can use it: only a respondent with an open attempt of the survey (an email given on the page, or signed in). A public link alone opens neither the chat nor a call, and an anonymous page shows the form only.
  • Code's own rules: a word about the conversation ("repeat", "hello", "stop", in English, French and Spanish) is never an answer, even to a text question. An option that is itself a number is read by its words, never by its position. Each reply belongs to the question the person had heard in full when they began speaking (Voice Live's input_audio_buffer.speech_started, after the prompt's transcript was done). A reply for an earlier question (said twice, or spoken over) is never recorded on a question the person has not heard: the current question is read again. A sentence about the conversation ("could you repeat the question?") on a text question goes to the reader instead of becoming the answer, and an option named like a control word ("Stop") is still chosen by its name. An unreachable model is said as such ("I could not process that"), never as "I did not catch that". Start Over ends the call and nothing it collected is kept.
  • A reply that is not an answer says what to say instead (retryText in questions.ts, chat and voice alike, in the survey's language for English, French and Spanish). A skip on a required question is refused with its reason: "This question needs an answer, it cannot be skipped", then the form an answer takes and the question again. Any other unreadable reply names the form: a rating its scale with its end labels ("a number from 0 to 5"), a yes or no question "yes or no", a choice "one option, by its number or its words" (or "in its words" when an option is itself a number, since position is never read then), several choices "one or more options", a text question "in your own words". Declining ("I'd rather not say", typed with either apostrophe, "no comment", "je préfère ne pas répondre", "prefiero no responder") is a skip, never a text answer, while an option worded as a decline ("Prefer not to answer") is chosen by its words, and the reader may report a skip on any question; code refuses it on a required one with the same reason. Skip words include "skip this question", "next question", "on passe", "siguiente". The call's opening offers skip only while an optional question is still to be asked, and says the call is recorded: "You can say repeat or stop at any time, and skip a question that is optional." Questions are asked in their order; a required one is never skipped.
  • Not built here: a consent sentence before the first question (the call says it is recorded, a notice, not consent), the spoken-verbatim check, and WhatsApp (phases 8a and 8b below). A live model reading of free replies is not covered by a test; CI runs the stub.
  • Decided by Chip on 9 November 2026: promoted to production. Phase 8a below gated any respondent's words reaching a model on the consent and data processing decision; Chip promoted respondent chat and voice to platform.sondely.com ("Promote the production") after trying it on dev. Respondents' replies and voices now reach the Azure OpenAI and Voice Live resources in the CatalyzeUp subscription. Still open: a consent sentence before the first question, a written data processing term per organization, retention, and a per-organization or per-survey switch (today, wherever ASSISTANT_ENABLED is true, every open survey offers it).

Tests: tests/unit/respondent-questions.test.ts, tests/integration/respondent-agent.test.ts (the chat route and the voice runner over a simulated socket), and tests/assistant-e2e/respondent.spec.ts (the real page in Chromium at desktop and phone width, chat, and voice through the fake Voice Live).

The record of a respondent call

On 9 November 2026 a production call stopped on question 4 of 5 and nothing could say what was said or why it ended: the transcript lived in memory for a minute and every page stop was logged as page. Chip: "I want to keep the voice. I've done this already in Tourcockpit." Ported from TourCockpit's call record and tape.

  • The record (respondent_calls, one row per call, src/lib/respondent/call-record.ts): the call's organization, survey and attempt (sessionId, deleted with the attempt), locale, and events, in order, each {at, type, ...}: sona and person lines with their words, turn per reply (question number, type, outcome), end_call, model_error, and end. Written by the server, the only side that hears both voices, as the system within the survey's organization (row level security on organizationId, like every survey table), and never copied into the audit trail, where it would outlive its attempt. Rewritten whole every 25 events and at the end, one write after another, at most 2,000 events plus the end, which is always kept; a line longer than 10,000 characters is cut and marked cut. A failed write is logged and never ends the call; if the row could not be created, nothing more is written, so a call id used twice never rewrites another call's record. No answer is read from it: answers are saved through the session routes as before.
  • How it ended: endReason and endedBy. The page sends its reason with the hang-up (DELETE ...?reason=, src/lib/respondent/call-end.ts): the person (person_end, start_over, mode_switch), the page (pagehide, unmount, server_ended, and client_unspecified for a page that sent none), or the page's own failure (channel_closed, peer_failed, poll_failed, connect_timeout, control_failed, session_failed). The server's own: finished, finished_timeout, goodbye and goodbye_timeout (the person asked to stop), max_duration, and the failures logged as warnings: model_error, socket_error, socket_closed (the model's line dropped), turn_failed, heartbeat_timeout (the page stopped polling for 30 seconds), abandoned (the page left while the call was opening). A page that learns of a server's end keeps its own reason beside it in clientReason.
  • The recording: the page mixes the microphone and Sona's voice into one track (MediaRecorder, Opus in WebM, src/components/survey/call-tape.ts) and uploads it once after the call (PUT /api/survey-agent/voice/call, the call's token, a stated length of at most 40 MB checked before the body is read, once per call; a recording that arrives before its hang-up ends the call first as client_unspecified, since the page sends it only after stopping; accepted while the server holds the ended call, five minutes, and only beside a record that exists). A call id that already has a record is refused before the paid line opens, so a replayed request never puts its sound under another call's record. Stored privately as {organization}/{attempt}/{call}.webm (.ogg, or .m4a from Safari) in stsondelycalls, container respondent-calls-dev or respondent-calls-prod, deleted after 120 days by a lifecycle rule (catalyzeup-devops azure/sondely-call-audio.tf). audioStatus says stored, not_configured (no storage, the transcript is still kept), or failed. A page closed mid-call keeps the record and loses the sound, because a request sent as a page closes is capped at 64 KB.
  • The notice: the opening says "This call is recorded" in English, French and Spanish. It is a notice, not consent: there is no opt-out.
  • Who reads it: the team, for diagnosis, from the database and the storage account. No screen shows it, and Sona cannot read it: a respondent's voice on an anonymous survey would identify them, so playback for the organization is a decision still open.
  • Logs, never with what was said: respondent.voice.turn per reply (callId, question number and type, outcome answer, skip, unclear, refused_skip, stale_reply or failed, milliseconds), respondent.voice.ended (reason, who, answered, skipped, question reached, seconds; a warning when a failure ended it), respondent.voice.client_reason, respondent.voice.model_error and socket_error with their codes, respondent.voice.audio_stored and audio_failed, respondent.record.write_failed and truncated.
  • Not built: the blob is not removed when its attempt is deleted (the lifecycle rule removes it after 120 days), a recording has no duration for seeking (TourCockpit fixes this with ffmpeg), and playback for the organization.

Tests: tests/integration/respondent-agent.test.ts (a refused skip says why; "yes" to a rating names the scale; the record holds every line and turn in order and who ended the call; a person's hang-up, a page failure, an old page and a server's end each kept differently; the recording stored once under its organization and attempt, and the transcript kept without storage), tests/assistant-e2e/respondent.spec.ts (the opening says the call is recorded; pressing End is kept as the person's with the recording uploaded from a real Chromium; a closed page is kept as pagehide), tests/unit/tenant-models.test.ts (row level security on the new table, read from every migration).

Two agents, one API

Sondely has two agents that share the API and little else (Sondely as an Agent):

  • The builder agent is TourCockpit's pattern applied to surveys: owners and admins, or their own Claude or ChatGPT, make, translate and publish surveys and read results. Every write is proposed, dry-run, shown and committed once on a person's yes.
  • The respondent agent has no TourCockpit counterpart. A beneficiary answers a survey by WhatsApp or by phone. Here the model does far less: code decides which question comes next and sends its text, and the model only interprets a free reply into one of the allowed answers. The builder pattern is "the model proposes, code checks"; the respondent pattern is "code asks, the model listens".

Both paths end in the same /api/v1 routes, so validation, tenant isolation, idempotency and audit are written once.

Identity per channel

Identity is resolved on every request and never cached, as in TourCockpit (src/lib/mcp/identity.ts, "built per request from the credential and never cached").

Channel Who Credential Resolves to TourCockpit source
REST A script, an integration API key bound to one organization, scope read, draft or full (Auth) The organization, the scope src/lib/api/auth.ts
MCP A person in Claude or ChatGPT OAuth: a grant per person, a connection per organization, one organization at a time The person's membership in the bound organization src/lib/mcp/oauth/*, src/lib/assistant/company-binding.ts, models McpGrant, McpConnection
MCP A script that holds a key API key Reads only. Over MCP a key is not a person, so it never commits a proposal (TourCockpit's rule in tests/integration/mcp-writes.test.ts); a draft key still writes over REST src/lib/mcp/identity.ts
In-app chat Owner or admin, signed in The session The session's active organization and role src/lib/assistant/access.ts
WhatsApp text, builder Owner or admin The WhatsApp number on their member profile; exactly one active member may carry a number That member src/lib/assistant/whatsapp.ts (an unknown number gets no reply, only the log line assistant.whatsapp.unknown_number)
WhatsApp text, respondent A beneficiary The invited number plus the invitation code it sends first (proposed: a wa.me link with the code prefilled) A respondent grant: one survey, one organization, an expiry (Auth) New. A number alone never opens a survey: Sondely as an Agent requires an organization invitation, never caller ID alone
Voice, WhatsApp call A beneficiary The calling number, which must already hold a live respondent grant from the text channel or an invitation The same grant src/lib/assistant/whatsapp-call.ts
Voice, web call, builder Owner or admin, signed in The session The session's active organization and role, as the chat src/lib/assistant/web-call.ts. Built on 8 November 2026
Chat and web call, respondent A beneficiary on the survey page The respondent grant (bearer) or sign-in, and an open attempt of that survey The attempt, checked through GET /api/v1/sessions/{id} before every reply and every call src/lib/respondent/survey.ts (assertOpenAttempt). Built on 8 November 2026

Phase 5: an API an agent can read

The foundation every later door stands on. It is useful without any agent: the web builder gets the same validation and coded errors.

From TourCockpit: src/lib/api/{asked,batch,batch-refs,names,read-back,invitation-read-back,examples,write-handlers}.ts, src/lib/assistant/{refusal-legibility,refusal-fixes}.ts, and the plumbing copied in P2 (strict-request.ts, unknown-fields.ts, idempotency.ts, request-transaction.ts with ?dryRun=true).

Rewritten for surveys

Piece TourCockpit Sondely
Ref kinds (src/lib/api/refs.ts) tour, departure, booking, hotel, zone, vehicle, driver, operator, language, childSeat, rep, cameBy survey, questionGroup, question, language, member, assignment
Asked marks (asked, askedOr) How a party pays, name or room Proposed: the survey's languages, the recurrence, a scoring type on a scored question. Fields where Sondely has a default the agent must never take on the person's behalf
Batch (POST /api/v1/batch, $0.id references) A departure and the bookings on it A survey, its groups and its questions in one request, all or none
Read-back (read-back.ts) "Booked 4 guests on the 10:00 south tour" "Draft survey Food security baseline with 3 groups and 14 questions, in French and Arabic"
Error codes and classes 65 codes in ServiceErrorParamMap, classed in LEGIBILITY_BY_CODE Sondely's codes, classed in the same four classes; the list is owned by Business Logic

The four classes stay exactly as TourCockpit defines them in src/lib/assistant/refusal-legibility.ts: ask (the person fixes it by saying something), explain (a fact of the data, with an offer), denied (their access), retry (the data moved). The table is typed against the code list, so an unclassed code does not compile, and there is no fallback class. Proposed first rows, to be confirmed against the validator:

Code (proposed) Class First word What the agent does
CHOICE_WITHOUT_OPTIONS ask, judged on the body NOT VALID YET Asks for the options of that question
SCORING_TYPE_MISMATCH ask, judged on the body NOT VALID YET Asks which scoring the question uses, offering the ones its type allows
SURVEY_EMPTY ask, judged on the record NOT VALID YET Asks for at least one question before publishing
TRANSLATION_SHAPE_MISMATCH explain NOT POSSIBLE Says which question lost an option or an id in translation, offers to translate it again
GROUP_NOT_FOUND retry NOT POSSIBLE Reads the survey again and prepares the change again
KEY_SCOPE_DENIED denied NOT ALLOWED Says the key cannot publish or delete, and stops
SURVEY_PUBLISHED explain NOT POSSIBLE Says a published survey's questions are fixed, offers a new version (depends on a product decision below)

Proves it (copied and stripped from TourCockpit, named the same way):

Test Proves
tests/unit/refusal-legibility.test.ts Every code has a class, every ask its question, every explain its offer
tests/fixtures/refusal-probes.ts with tests/unit and tests/integration/refusal-probes.test.ts Each ask judged on the body is answered NOT VALID YET by the write tool before any dry run, and the real route refuses it with its own code
tests/unit/refusal-sites.test.ts A new err(...) site wakes the test, so a new check cannot inherit a class written for another rule
tests/integration/survey-matrix.test.ts (from rule-matrix.test.ts) Down the side every survey rule, across the top the states it meets; each cell one dry run with one right answer
.claude/agents/sondely-legibility.md (from tour-platform-legibility.md) Read before any pull request that touches the API contract

Done means: the step 1 scenario of Sondely as an Agent runs as an integration test: a draft scoped key creates a Maslow pre and post survey, the dry run answers CHOICE_WITHOUT_OPTIONS naming the question, the fix goes through, and a retry with the same Idempotency-Key does not create a second survey. Every refusal test above is green in CI.

Phase 6: MCP

The outside door first: the person's own Claude or ChatGPT is the brain, Sondely is the tools, and Sondely pays no model cost.

From TourCockpit: src/lib/mcp/{server,tools,deps,identity,instructions}.ts, src/lib/mcp/oauth/*, the routes src/app/mcp/route.ts, src/app/oauth/* and src/app/.well-known/*, and the write path the tools call: src/lib/assistant/{actions,proposal-actions,proposal-card,write-tools,write-tool-names,endpoint-map,call-api,look-ahead,named-ids,refused-before,written,company-binding,company-list,config,audience}.ts, src/lib/api/strict-schema.ts, src/lib/services/mcp-connections.ts. Models McpGrant, McpConnection, AssistantConversation, AssistantMessage, AssistantProposal.

Rewritten for surveys

Piece What changes
Write tools Generated from Sondely's registry exactly as TourCockpit generates them (write-tools.ts: every write route a tool whose parameters are its strict schema). The names come from the routes, not from a hand-written list. Expected set (inferred from API Routes): create, edit, translate, publish, close and delete a survey; add, edit, reorder and delete questions and groups; invite members; assign respondents
group_change Kept as the batch tool: a whole survey with its groups and questions is one card and one yes
Proposal card (proposal-card.ts) A survey draft: title, languages, groups, question count by type, scoring, recurrence. The booking card fields are gone
Publish Always its own proposal. A survey created and published in one sentence is two cards and two yeses, so nothing reaches respondents on the strength of a draft the person never saw
Read tools (call-api.ts, endpoint-map.ts) GET only, registered routes only, RBAC per route, as TourCockpit. Results are read through aggregate routes over the scoring engine, never through model-written queries
MCP instructions (instructions.ts) The three first words (NOT VALID YET, NOT ALLOWED, NOT POSSIBLE) and their meaning are kept word for word; the rest comes from the rewritten rules (phase 7 table)
Removed The board view (ui/board-view.ts), KPI calculators, help search and changelog until Sondely has them

Proves it

Test Proves
tests/integration/mcp-route.test.ts Who gets through /mcp, what they are told they are, what a non-MCP request gets
tests/integration/mcp-oauth-flow.test.ts, mcp-connections.test.ts Registration, consent, token, refresh, revoke; one connection per organization
tests/integration/mcp-writes.test.ts A change over MCP is the chat's change: validated, dry-run as the person, proposed, committed once on the client's confirmation naming the card; a key reads and never changes, and is told why
The one-sentence scenario (new, in the style of mcp-writes.test.ts) A scripted MCP client, no model, plays "a food security baseline in French and Arabic for 40 families, monthly": batch proposal, dry-run card, commit, translation proposal, publish proposal, second yes. Asserts the database at each step
tests/e2e/mcp.spec.ts The same against the running app with real keys; two organizations each read only their own surveys
tests/unit/mcp-parity.test.ts From phase 7: anything the chat can do, MCP can do

CI cannot run Claude itself, so the scenario is proven with a scripted client on every push. A nightly run with a live model as the client is optional and waits for phase 7's nightly harness.

Done means: the one-sentence scenario passes in CI with a valid, translated draft that is published only after a separate yes; Claude connects to a dev organization through OAuth by hand once and repeats it (recorded in the phase's pull request); a key over MCP reads and is refused on a write.

Phase 7: in-app chat

The same executor behind a chat drawer, on the Azure OpenAI deployment Sondely already has. Two jobs: build a survey by talking, and read results.

From TourCockpit: the turn and its guards, src/lib/assistant/{turn,agent,prompt,stub,events,transcript,memory,model-calls,turn-context,turn-trace,step-watch,writes-together,changes-made,earlier-reads,working-notes,where-now,answer-guards,promise-guard,role-scope,screens,starters,widgets,write-refusal,company-switch,company-names,names-in-query,faro-view,door,conversation-kind,language,profile,rate-limit,service-tier,azure}.ts, the services src/lib/services/assistant*.ts, and the routes src/app/api/assistant/{chat,conversations,proposals,status}.

The rules text is rewritten, its structure kept. TourCockpit's src/lib/assistant/faro-rules.ts holds one export per principle, read by every door, and tests/unit/faro-rules-parity.test.ts proves the chat prompt and the MCP instructions carry the same rules. Sondely's src/lib/assistant/rules.ts keeps that shape. Rule by rule:

TourCockpit rule Sondely
DATA_RULES (numbers only from a response in this conversation), NO_GUESSING, AT_ITS_GRAIN, MEMORY_RULES, CLOSE_NAMES, FROM_THE_EXAMPLE Kept, words adjusted
GATHER, GATHER_IN_ONE_STEP, ANY_THAT_FITS, PROPOSE_LOOKS_AHEAD Kept: ask for everything missing in one message, propose with what was said and let NOT VALID YET name the rest
NEVER_SKIP_THE_PREVIEW, REFUSAL_IS_NOT_A_PUZZLE Kept word for word
SEVERAL_AT_ONCE, CHANGES_SIDE_BY_SIDE Kept: a survey with its groups and questions is one batch; several edits in one step are one card
COUNT_OVER_A_LIST, AGGREGATE_DESCRIPTION, DATES_NO_WINDOW Kept for results: response counts and score averages over a window, said in the organization's words
OFFICE_CLOCK Rewritten for the organization's time zone
OFFICE_ROLE_WORDS Rewritten for Sondely's role names
TOUR_WORDS, A_PARTY_AS_GIVEN, CHILDREN, MONEY, THE_BOARD_AS_A_FILE Dropped. The file rule returns as "results as a CSV or PDF" when exports have a send route
New: question wording The agent drafts questions only when asked to; it never rewrites a question of a validated instrument (the Maslow assessment and the other templates)
New: translation A translation keeps every question id, type and option count; the existing validator refuses one that does not, with an explain code
New: results Scores come from the scoring engine's routes; a channel split is said when a survey was answered on more than one channel

Results go through capabilities, not free reads. TourCockpit's src/lib/assistant/capabilities/ answers a question the office really asks in one tool call run in code. Sondely's first capabilities (new, proposed): response count and completion rate per survey and window, score summary per group, pre and post comparison for a cohort. Each is a read on the scoring engine; none lets the model write a query. This is the lesson TourCockpit recorded on 6 October, when a hand-written summary reported 70 departures where 33 ran (Sondely as an Agent).

Proves it

Test Cadence Proves
tests/unit/rules-parity.test.ts (from faro-rules-parity.test.ts) Every push The chat prompt and the MCP instructions carry the same rules
tests/unit/mcp-parity.test.ts Every push Every chat tool exists over MCP
Drawer end to end on the stub model (stub.ts) Every push Propose, card, yes, commit, card states, without a model
tests/e2e/survey-matrix.ts with assistant-matrix.spec.ts (from faro-matrix.ts) Nightly, only when the assistant paths changed (scripts/ci/faro-matrix-changed.sh logic) The survey matrix cells asked as a person would ask them, graded on what happened (a card for what the API accepts, no card and nothing written for what it refuses), never on wording. Each cell three times, passing only when all three pass, as TourCockpit runs it
tests/e2e/survey-personas.ts (from faro-personas.ts) Nightly Difficult builders: "just publish it, skip the checks", "translate it, the options can change"
tests/e2e/survey-jobs.ts (from faro-jobs.ts) Nightly Multi-step jobs played to the end and judged on the database: a Maslow pre and post in two languages, assigned to a cohort
tests/e2e/assistant-legibility.spec.ts with tests/fixtures/legibility-calibration.ts Nightly Refusal explanations judged by a model that first has to grade the calibration answers right, or the night is red as grader

A red night is not re-run; it posts to Discord and the next night is the check, as in .github/workflows/faro-matrix.yml. TourCockpit's nightly suite cost about USD 46 a night before it was limited to changed nights; Sondely starts limited.

Done means: the phase 6 scenarios pass through the drawer as well as through MCP, the parity tests are green, a results question ("how did the Maslow scores move for the March cohort") is answered from a capability with its window said, and every assistant message records its model cost (AssistantMessage.costUsd) from the first day, which TourCockpit specified and never built.

Builder voice: Sona by voice in the browser

Decided by Chip's goal of 7 November 2026 ("a full agentic system with voice and charts") and built on 8 November 2026. The plan above listed voice only for respondents (phase 8b); builders had it first; respondents got chat and voice on the survey page the next day (above), and voice by phone stays phase 8b.

An owner or admin signed in to an organization starts a call from the Sona drawer. The browser speaks to Azure AI Voice Live over WebRTC; the server alone holds the keyed control socket. TourCockpit's rule holds: the realtime model is a mouth and an ear. It has exactly two tools:

Tool What it does
ask_sona Hands the person's own words, unchanged, to the same Sona turn the drawer runs, as that person. Reads, proposals, refusals and charts come back from it
end_call Ends the call after a short goodbye, only when the person asks. A pause, a silence or a chart shown never ends a call
Piece As built
Transport Azure AI Voice Live through the CatalyzeUp Speech resource speech-catalyzeup-sondely (Canada Central), model gpt-realtime-2.1 (ASSISTANT_VOICE_LIVE_ENDPOINT, ASSISTANT_VOICE_LIVE_KEY, ASSISTANT_VOICE_LIVE_MODEL). Voice Live is limited and billed on the Speech resource, not on the Azure OpenAI realtime quota, which TourCockpit's Faro uses in full (Infrastructure)
Languages English, French and Spanish, each with a native Azure voice; what the caller says is transcribed by Voice Live in the call's language
On screen during the call The live transcript, mute, and the survey charts Sona returns, shown beside the transcript
Writes A change is read back with the details that identify it and committed only on a spoken yes in a later turn, through ask_sona; the proposal and its commit are the drawer's
Where a call lives In the memory of the replica that opened it; its polls must reach that replica. Production runs one replica until calls have session affinity
Proved by tests/unit/sona-voice*.test.ts, tests/integration/sona-voice.test.ts (a spoken question answered with a chart from seeded responses; a write proposed, then committed on a later spoken yes, and safe on replay), and tests/assistant-e2e/sona-voice.spec.ts, a call in Chromium with a fake microphone against a fake Voice Live server (tests/call-sim/fake-voice-live.ts), on every push

Not built for builders: calls by phone or WhatsApp, call recording, and a revision drain that keeps a live call through a deploy.

Phase 8a: respondents by WhatsApp text

Gate before any code: the consent and data processing decision in Sondely as an Agent (a regional or data-zone model deployment, a written data processing term per organization, retention). Answers about people's lives pass through the model in this phase; the builder phases do not send any respondent's answers to it.

From TourCockpit: src/lib/assistant/whatsapp.ts (the door: inbound webhook, number as credential, one conversation per channel), src/app/api/webhooks/whatsapp/route.ts, src/lib/whatsapp/{wire,senders,number-key}.ts, src/lib/services/{whatsapp,whatsapp-media}.ts, src/lib/assistant/{transcribe,call-limits}.ts, and tests/call-sim/meta.ts for signed webhooks. The respondent turn itself is written new; it is not a stripped Faro turn.

The survey runner. Code owns the session; the model sits in one narrow place:

Step Who What happens
Bind Code The invited number sends its code; the respondent grant is resolved; the survey's language list is read
Consent Code The organization's consent text, in the respondent's language, as a WhatsApp message with yes and no buttons. Nothing is stored as an answer before a yes
Start or resume Code POST /surveys/{id}/sessions/start, or the open session for this grant is found and resumed
Ask Code The next question's stored text in the session's language, sent byte for byte. Choices and ratings go as WhatsApp buttons or a list (inferred: within WhatsApp's interactive message limits; a longer option list is sent numbered)
Interpret Model Only for a typed or spoken free reply to a choice or rating question: maps it to one allowed option, or to "unclear". It cannot write question text, options or advice
Validate and save Code The answer is checked against the question type, then PUT /sessions/{id}/save with current_question_index
Unclear Code The same question is sent again, verbatim, with its options. Never a reworded version
Finish Code POST /sessions/{id}/complete, a closing line from the catalog

Survey-specific respondent rules, each enforced in code where it can be, and stated in the model's short instructions where it cannot:

Rule Where it lives Proved by
Questions and options are sent verbatim, never paraphrased, explained in other words or translated on the fly Code: question text is read from the survey or its stored translation and sent by the runner; the model never produces it Integration test compares every outbound question message byte for byte with the stored text
The agent never suggests an answer, never says which answer is "good", never comments on an answer Model instructions, the only rule that must live there Nightly respondent personas ("what should I put?", "is 3 a good score?") graded by a calibrated judge
Consent is recorded before the first answer, with its text version, language, channel and time Code, on the session Integration test: an answer before consent is refused (CONSENT_REQUIRED, class ask)
The channel is recorded on the session Code: SurveySession.channel (web, whatsapp, voice), set at start (Data Model); a session resumed on another channel records the channel per answer (proposed) Integration test reads the stored session
A half-finished session resumes where it stopped, without asking stored answers again Code: the open session for the grant and its current_question_index The resume scenario below
A free text answer is stored as the respondent wrote it, or as transcribed from a voice note Code; the model never summarizes it Integration test on a TEXT question
Scores of validated instruments are reported by channel The results routes and capabilities Unit test on the channel split

Respondent refusals use the same four classes. Proposed: ANSWER_NOT_AN_OPTION and ANSWER_OUT_OF_RANGE (ask: the question again), CONSENT_REQUIRED (ask), SESSION_COMPLETED and SURVEY_CLOSED (explain), GRANT_EXPIRED (denied).

WhatsApp constraint: a business-initiated message outside 24 hours of the respondent's last message needs a template approved by Meta (inferred from WhatsApp Business rules; TourCockpit manages its templates in src/lib/services/whatsapp-templates.ts, left behind in the copy). A respondent who writes back resumes freely; a reminder that Sondely sends first needs a template.

Proves it

Test Cadence Proves
tests/integration/respondent-whatsapp.test.ts (in the style of assistant-whatsapp.test.ts) Every push Signed webhooks through the real route (tests/call-sim/meta.ts), stub model: bind, consent, ask, save, unknown number gets no reply
The resume scenario Every push A respondent completes a 12-question Maslow survey in Spanish, stops after question 6, resumes the next day (clock moved in the test), finishes; every answer stored once, with channel and consent
tests/e2e/respondent-personas.ts (from faro-personas.ts) Nightly, changed nights only Live model: asks for advice, asks for an explanation, answers off the list, switches language; graded on what was stored and sent, and by the calibrated judge on whether anything suggested an answer

Done means: the done line of Sondely as an Agent: the resume scenario passes in CI, every answer carries its channel and consent record, and the nightly eval shows no question paraphrased and no answer suggested.

Phase 8b: respondents by voice

The rule from TourCockpit holds: the realtime model is a mouth and an ear, never a second assistant. It holds one tool that reaches anything (TourCockpit's ask_faro, here an ask tool that hands the respondent's words to the phase 8a runner) and end_call, which reaches nothing (Building Voice Agents, internal). A call is therefore the WhatsApp runner heard instead of read: same session, same validation, same consent record.

From TourCockpit: src/lib/assistant/{whatsapp-call,voice,voice-live,voice-ask-client,voice-character,call-controller,call-delivery,call-nudge,opening,call-relay,call-mix,call-recorder,call-audio,call-audio-response,call-id,live-calls,recording-check,recording-sound,replay,reply-recovery,said-since-ask,until-quiet}.ts, models AssistantCallRecording and AssistantLiveCall, .github/workflows/drain-revisions.yml (a deploy never cuts a live call), and the agents tour-platform-calls.md and tour-platform-voice-architect.md. The transport is where TourCockpit spent most of its assistant effort (recording, silence, interruptions, hang-up), and that code is copied as is.

Rewritten for surveys

Piece Change
voice.ts instructions Read the question the ask tool returns exactly as written; read the options; do not explain, do not reword, do not react to an answer
call-delivery.ts TourCockpit's rule that a list read aloud is useless becomes: options are read one by one with their number, a rating with its two end labels
opening.ts, call-nudge.ts, voice-character.ts The organization's name, the survey's name, and the consent sentence before anything else
Recording Built for web calls as a notice, not consent ("This call is recorded", decided by Chip on 9 November 2026, see "The record of a respondent call"). A separate recording consent, where a refusal means the call is not recorded and the survey goes on, remains open for the WhatsApp call
Verbatim on a call Code cannot send the words byte for byte, because the voice model speaks them. The recorded event stream carries the transcript of what was spoken; the check compares it with the question text (inferred mechanism, to be confirmed against Voice Live's events)

Proves it

Test Cadence Proves
tests/integration/respondent-call-simulated-*.test.ts (from _whatsapp-call-simulated.ts) Every push, in the integration-calls job A WhatsApp call simulated whole with tests/call-sim/{caller,fake-voice-live,opus,meta}.ts: the survey runs through the real webhook, answers are saved, and the recording is heard (each side's tone found where the call record says it spoke)
tests/e2e/voice-scenarios.ts (scenarios as data, audio synthesised on demand) Nightly, .github/workflows/voice-scenarios.yml Live realtime model: an interruption mid-question, a long silence, "repeat that", a change of answer, hanging up halfway and calling back
Verbatim check on the spoken transcript Nightly Each question spoken matches its stored text within a threshold the first runs will measure

Done means: a simulated respondent completes the Maslow survey by WhatsApp call in CI with every answer saved, its channel voice and both consents recorded; the nightly scenarios are green for a week of changed nights; a call cut halfway resumes as a session on the next call or on WhatsApp text.

The test catalogue, by TourCockpit name

TourCockpit Sondely Phase
tests/integration/rule-matrix.test.ts survey-matrix.test.ts P5
tests/unit/refusal-legibility.test.ts, refusal-fixes.test.ts, refusal-sites.test.ts, tests/integration/refusal-probes.test.ts Same names P5
tests/integration/mcp-*.test.ts, tests/e2e/mcp.spec.ts Same names P6
tests/unit/faro-rules-parity.test.ts rules-parity.test.ts P7
tests/unit/mcp-parity.test.ts Same name P7
tests/e2e/faro-matrix.ts, assistant-matrix.spec.ts survey-matrix.ts, assistant-matrix.spec.ts P7
tests/e2e/faro-personas.ts, faro-jobs.ts survey-personas.ts, survey-jobs.ts; respondent-personas.ts in P8a P7, P8a
tests/e2e/assistant-legibility.spec.ts, tests/fixtures/legibility-calibration.ts Same names P7
tests/integration/assistant-whatsapp.test.ts respondent-whatsapp.test.ts P8a
tests/integration/_whatsapp-call-simulated.ts and its five part files respondent-call-simulated P8b
tests/e2e/voice-scenarios.ts, assistant-voice-scenarios.spec.ts Same names, survey scenarios P8b

Where these run in CI is in Testing and Bugs.

Size by complexity

Phase New concepts for Sondely Coupling Reversible
5 Legible API Refusal classes, asked marks, batch, read-back Every write route and its tests Yes
6 MCP OAuth grants and connections, generated write tools, proposals On top of 5; mostly copied Yes, the door can be switched off
7 In-app chat The assistant turn, conversation storage, capabilities over the scoring engine, nightly evals On top of 6; the largest strip (agent.ts, 2,022 lines) Yes
8a WhatsApp respondents Respondent grant by number, the survey runner, consent, channel on sessions, a paid channel Sessions, scoring, privacy Partly: answers collected stay collected
8b Voice respondents Call transport, recording consent, spoken verbatim check On top of 8a; transport copied as is Partly, as 8a

Phases 5 to 7 are mostly copying what TourCockpit already proved and rewriting its words. Phase 8a is new product ground and a decision before it is code; 8b adds TourCockpit's most expensive area, though as copied code rather than new code. Builder voice, built on 8 November 2026, already carries the browser half of that transport (Voice Live, the call held on one replica); respondent voice adds the phone, recording and the survey runner.

Decisions for Chip

  1. The assistant's name: taken. Sona, from Chip's goal of 7 November 2026; it echoes Sondely, sondage and sonar. It lives in src/lib/assistant/config.ts.
  2. Keys over MCP read only, as TourCockpit decided, while draft keys keep writing over REST. Built that way on 8 November 2026 (tests/integration/sona-mcp.test.ts); still Chip's to confirm.
  3. Editing a published survey: refused (a new version instead), or allowed with a warning. It decides the SURVEY_PUBLISHED class and what results mean across a change.
  4. A session resumed on another channel: one channel per session (start a new session), or a channel per answer.
  5. Recording consent on calls, separate from survey consent, and whether a respondent can take a survey by call without being recorded.
  6. Reminders by WhatsApp need approved templates per language; whether phase 8a includes them or respondents only resume when they write first.