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 (sonaAvailableToinsrc/lib/assistant/presence.ts, the same asresolveAssistantCaller) 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:
- 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. - The organization: the model sent
nullor 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. - 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 theREFUSALSrule tells Sona to correct what it shaped and ask the person only for what is theirs to decide. - Interviewing instead of drafting: the
GATHERrule 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. - 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).
- 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.
questionPromptinsrc/lib/respondent/questions.tsbuilds 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 returnsanswer,skiporunclear, never a guess. Its answer is checked with the form's ownvalidateAnswer; anything the form would refuse becomesunclear, and the question is asked again. WithASSISTANT_MODEL=stub(tests), a free reply is alwaysunclear. - 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/sessionopens a Voice Live call with two tools,answer_question(the person's words, verbatim) andend_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 pollsGET /api/survey-agent/voice/callwith 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 (
retryTextinquestions.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_ENABLEDis 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, andevents, in order, each{at, type, ...}:sonaandpersonlines with their words,turnper reply (question number, type, outcome),end_call,model_error, andend. Written by the server, the only side that hears both voices, as the system within the survey's organization (row level security onorganizationId, 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 markedcut. 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:
endReasonandendedBy. 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, andclient_unspecifiedfor 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,goodbyeandgoodbye_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 inclientReason. - 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 asclient_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.m4afrom Safari) instsondelycalls, containerrespondent-calls-devorrespondent-calls-prod, deleted after 120 days by a lifecycle rule (catalyzeup-devopsazure/sondely-call-audio.tf).audioStatussaysstored,not_configured(no storage, the transcript is still kept), orfailed. 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.turnper reply (callId, question number and type, outcomeanswer,skip,unclear,refused_skip,stale_replyorfailed, 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_errorandsocket_errorwith their codes,respondent.voice.audio_storedandaudio_failed,respondent.record.write_failedandtruncated. - 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
- 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. - Keys over MCP read only, as TourCockpit decided, while
draftkeys keep writing over REST. Built that way on 8 November 2026 (tests/integration/sona-mcp.test.ts); still Chip's to confirm. - Editing a published survey: refused (a new version instead), or allowed with a warning. It decides the
SURVEY_PUBLISHEDclass and what results mean across a change. - A session resumed on another channel: one channel per session (start a new session), or a channel per answer.
- Recording consent on calls, separate from survey consent, and whether a respondent can take a survey by call without being recorded.
- Reminders by WhatsApp need approved templates per language; whether phase 8a includes them or respondents only resume when they write first.