Response reference
Response reference
Every scope and endpoint in these docs tells you what you may call. This page tells you what comes back — the actual JSON, taken from the code that builds it.
Each example below was derived from its construction site in the backend and then checked field-by-field against that site. Where an example and the code disagreed, the code won. File and line are cited under every entry so you can read the source yourself.
Read this first — five things that catch people
1. The envelope, and where it nests deeper. Most REST handlers return {"success": true, "data": …}. Several nest one level further — data.objects, data.calls, data.agents, data.knowledgeBases. Destructure the level the example shows, not the one you expect.
2. Casing is not uniform across planes. The app surface is camelCase (agentId, createdAt). The dashboard and public /v1 surfaces are snake_case. A few shared helpers emit snake_case that a route then renames on the way out. The examples show the real casing per surface — trust them over intuition.
3. The bridge re-projects. window.telenow.* does not always hand your page the REST payload. Some ops hand-build a narrower or differently-cased object. Every op whose bridge shape differs from its REST shape says so.
4. Your fields are nested. An object row carries your data at row.data.<field>; a query tool returns results[i].data.<field>. Reaching for results[i].phone gets you undefined, silently.
5. Errors come in two flavours, and they are not interchangeable. A tool returns {"ok": false, "error": "…"} inside a successful call so the model can read it and recover mid-conversation. A REST route returns an HTTP error status with an error body. Handling one as the other means handling neither.
Quick index by scope
A developer usually arrives here holding a scope. This maps each one to the section that answers it.
| Scope | Where to look |
|---|---|
agents:config:read | Agents & agent config · Bridge ops (window.telenow.*) |
agents:config:write | Agents & agent config · Bridge ops (window.telenow.*) |
agents:config:write:<group> | Agents & agent config · Bridge ops (window.telenow.*) |
agents:read | Agents & agent config · Bridge ops (window.telenow.*) |
agents:write | Agents & agent config · Bridge ops (window.telenow.*) |
ai:* | AI · KB · files · billing · links · members |
ai:llm | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
ai:stt | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
ai:tts | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
billing:read | AI · KB · files · billing · links · members · Calls & post-call analysis · Events & webhooks |
calls:initiate | Bridge ops (window.telenow.*) |
calls:read | Bridge ops (window.telenow.*) · Calls & post-call analysis · Events & webhooks |
calls:read:org | Bridge ops (window.telenow.*) · Calls & post-call analysis · Events & webhooks |
calls:transcribe:live | Bridge ops (window.telenow.*) · Calls & post-call analysis |
campaigns:read | AI · KB · files · billing · links · members |
campaigns:write | AI · KB · files · billing · links · members |
connection:<provider> | Bridge ops (window.telenow.*) |
data:* | Data API & the object store |
data:read | Data API & the object store |
data:write | Data API & the object store |
files:read | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
files:write | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
http:<host> | Bridge ops (window.telenow.*) |
kb:read | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
kb:write | AI · KB · files · billing · links · members |
links:read | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
links:write | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
members:read | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
objects:<type> | Data API & the object store |
session:token | Bridge ops (window.telenow.*) |
softphone:dial | Bridge ops (window.telenow.*) |
user:profile | AI · KB · files · billing · links · members · Bridge ops (window.telenow.*) |
whatsapp:send | Bridge ops (window.telenow.*) |
Tool returns — what the agent receives mid-call
The least-documented surface on the platform, and the one your prompt has to reason about. A declarative tool's result is fed straight back to the model as the tool output, so its shape decides what the agent can say next.
Confirm-before-execute gate — the envelope that replaces the real result
{
"status": "confirmation_required",
"instruction": "Do NOT treat this as done. Tell the caller you're about to send a payment link for ₹1499 to +919876543210, and ask them to confirm. Only after they clearly say yes, call this tool again with the same details. If they decline or change anything, do not proceed."
}
Easy to miss: for a tool flagged needs_confirmation, the FIRST call never reaches invoke_app at all (orchestration.rs:13567, :13572) — the model gets this instead of the handler's shape. The re-arm is keyed on tool name + a hash of the arguments, so re-calling with different args re-asks. Internal status is confirmation_pending (not success/error), which is what lands in tool_invocations and the tool.invoked webhook. The {summary} interpolation comes from spec.confirmation_summary(args).
<sub>Source: voice_ai_rust/src/services/orchestration.rs:12114-12152 (envelope :12141-12148)</sub>
Envelope contract — what the model actually receives
This block shows several variants side by side. Each top-level key labels a case (status code, method, or provider) — the keys themselves are not part of any response.
{
"1_cascade_internal_ChatMessage__and_openai_family_wire": {
"_source": "orchestration.rs:13729-13735 (cascade voice) and :18170-18176 (text/WhatsApp/public API); sent verbatim by providers/llm/openai.rs:353",
"role": "tool",
"content": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}",
"tool_call_id": "call_9f2bA1",
"name": "clinic_crm_book_appointment"
},
"2_anthropic_bedrock_cascade": {
"_source": "providers/llm/anthropic.rs:169-181 — consecutive tool turns collapse into ONE user message; `name` is dropped",
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01A9f2bA1",
"content": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}"
}
]
},
"3_gemini_cascade": {
"_source": "providers/llm/gemini.rs:565-570 + tool_content_to_response gemini.rs:485-491 — a JSON object passes through UNWRAPPED; a non-object becomes {\"result\": v}",
"role": "user",
"parts": [
{
"functionResponse": {
"name": "clinic_crm_book_appointment",
"response": {
"ok": true,
"id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
"record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
}
}
}
]
},
"4_s2s_openai_realtime": {
"_source": "orchestration.rs:7770 -> providers/s2s/openai.rs:273-281 — no role, no name; call_id/output, nested under `item`",
"type": "conversation.item.create",
"item": {
"type": "function_call_output",
"call_id": "call_9f2bA1",
"output": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}"
}
},
"5_s2s_gemini_live": {
"_source": "orchestration.rs:7770 -> providers/s2s/gemini.rs:372-387 — the string is re-parsed and ALWAYS nested under response.result",
"toolResponse": {
"functionResponses": [
{
"id": "call_9f2bA1",
"name": "clinic_crm_book_appointment",
"response": {
"result": {
"ok": true,
"id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
"record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
}
}
}
]
}
},
"6_app_facing_event_not_the_model_envelope": {
"_source": "orchestration.rs:9449-9458 emit_tool_invoked — camelCase, and `result` is an OBJECT not a string",
"event": "tool.invoked",
"sessionId": "b41c8e02-5a7d-4f19-9c33-2ad6e0f81b45",
"agentId": "3f6a1d90-77c4-4e2b-8a51-9db0c4e73f12",
"name": "clinic_crm_book_appointment",
"arguments": { "patient_name": "Asha Rao" },
"result": {
"ok": true,
"id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
"record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
},
"status": "success",
"latencyMs": 214
},
"7_app_tool_error_envelope": {
"_source": "tool.rs:1421-1424 (row cap) — soft error stays ok:false inside a successful dispatch; a dispatcher Err becomes {\"error\": msg} at orchestration.rs:18146/18152",
"soft": { "ok": false, "error": "app data store is full — cannot create more records" },
"hard": { "error": "unknown tool: clinic_crm_book_appointment" }
}
}
There is NO {success,data} wrapper anywhere on this surface — that is the REST convention only. The handler's JSON Value is stringified verbatim (result.to_string(), compact, no spaces) into the tool message content. Two consequences developers trip on: (1) a handler that returns a bare array or null reaches the model as the literal string null / [...]; (2) the platform decides status for logging purely by v.get("error").is_some() (orchestration.rs:13578, :7683, :18143) — so an {"ok":false,"error":…} app envelope IS classed "error", but {"results":[]} is classed "success". Key casing on this surface is MIXED and per-handler, not one convention: declarative app handlers emit snake_case (ok, record, created_at), while natives emit a mix (at_limit, volume_percent, row_count snake; toAgent, numberEnding, latencyMs camel).
On failure:
{"role":"tool","tool_call_id":"call_9f2bA1","name":"clinic_crm_book_appointment","content":"{\"error\":\"app tool 'clinic-crm' is not available to this agent\"}"}
<sub>Source: voice_ai_rust/src/services/orchestration.rs:13729-13735 (cascade voice), :18170-18176 (text/WhatsApp/public API), :7770 (s2s send_tool_result)</sub>
Flow tool NODE — the SAME handlers, re-projected into variables (differs)
// object.create returns {"ok":true,"id":"7d3f9a1e-…","record":{…}}
// but a flow tool NODE merges this into session variables instead:
{
"_tool_status": "ok",
"ok": true,
"id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63"
}
★ This is the closest thing to a bridge re-projection on this surface, and it silently LOSES data. A deterministic flow tool node does not hand the JSON to a model — it flattens ONLY top-level String/Number/Bool values into session variables. So record (object) and results (array) are DROPPED entirely: after an object.query node no new variable appears at all, and after object.create you get only ok and id. ★ WORSE: _tool_status is "ok" for ANY Ok(...) return, including the soft {ok:false,error:…} envelopes — so the row-cap refusal, "no matching record found", and the empty-filter refusal all route down the SUCCESS branch of a tool_result edge (edge match at services/flow.rs:1138-1142), with the refusal text flattened into an error variable. Only an anyhow bail sets _tool_status = "error". In a simulation the node never executes and just sets _tool_status="ok" (:24266-24274).
<sub>Source: voice_ai_rust/src/services/orchestration.rs:24284-24306 (flatten :24287-24298, _tool_status :24286/:24304)</sub>
tool.invoked webhook + tool.call debug-trace payload
{
"__wire_shape_1__ tool.invoked webhook — orchestration.rs:9449-9458; this object IS the entire HTTP POST body (no {success,data} envelope; delivery metadata is in headers)": {
"event": "tool.invoked",
"sessionId": "c0a81f43-6d2e-4f7b-b1a9-8e5c3d4f2a11",
"agentId": "b41e77c2-9d0a-4f35-8c62-1a7e5f3b90d4",
"name": "clinic_crm_book_appointment",
"arguments": { "patient_name": "Asha Rao", "slot": "2026-08-03T10:30:00+05:30" },
"result": { "ok": true, "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63", "record": { "status": "booked" } },
"status": "success",
"latencyMs": 214
},
"__wire_shape_2__ tool.call debug-trace NDJSON line — payload orchestration.rs:13692-13701 (cascade) and :7726-7735 (s2s); type/seq/t_ms stamped flat by call_tracer.rs:312-326": {
"corr": "t3:n=book_slot:g=1",
"id": "call_9XkQm2ZrT4",
"name": "clinic_crm_book_appointment",
"args": { "patient_name": "Asha Rao", "slot": "2026-08-03T10:30:00+05:30" },
"result": { "ok": true, "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63", "record": { "status": "booked" } },
"status": "success",
"error": null,
"latency_ms": 214,
"type": "tool.call",
"seq": 42,
"t_ms": 8137
}
}
The webhook is camelCase (sessionId, agentId, latencyMs) while the result it carries keeps the handler's own casing — so a single payload mixes both conventions. result is the UNSTRINGIFIED Value here, unlike the model-facing tool message. status ∈ success | error | not_found | confirmation_pending. The debug-timeline event is a DIFFERENT shape from the same data: {"corr","id","name","args","result","status","error","latency_ms"} — snake_case latency_ms, args not arguments, plus id (the tool_call_id) and a nullable error string that the webhook does not carry. A deterministic flow tool node emits flow_tool.call instead, with {"corr","name","status","latency_ms","result"} and no args/id.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:9439-9462 (webhook); :13692-13704 and :7725-7737 (debug trace tool.call)</sub>
app handler http (external tier) — passthrough
{
"available": true,
"slots": ["2026-08-03T10:30:00+05:30", "2026-08-03T11:00:00+05:30"],
"clinic": "Andheri West"
}
NO platform envelope is added — whatever the developer's service returns is handed to the model verbatim, so the app owns this shape entirely. Two traps: (1) a 2xx response whose body is not valid JSON silently becomes Value::Null, and the model receives the literal string null — not an error; (2) if the service returns a top-level error key, the platform classes the call "error" purely on that (orchestration.rs:13578). The request your service receives is {"tool":"<wire name>","arguments":{…}} plus "caller" only when the agent consented (tool.rs:1667-1670, egress tier stripped at :1223-1234), signed x-telenow-signature: sha256=<hex>.
<sub>Source: voice_ai_rust/src/services/tool.rs:1692-1693 (serde_json::from_slice(&bytes).unwrap_or(Value::Null)); request built at :1667-1681</sub>
app handler object.create — success
{
"ok": true,
"id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
"record": {
"patient_name": "Asha Rao",
"phone": "+919845012345",
"slot": "2026-08-03T10:30:00+05:30",
"status": "booked",
"caller_number": "+919845012345"
}
}
record is the STORED row (row.data), not the model's arguments. It contains three merged layers, in this precedence: model args → author config.defaults (tool.rs:1384) → manifest objects[].fields[].default via config.field_defaults (tool.rs:1414-1418, built at app_manifest.rs:1530-1541) → the system-injected caller_number (tool.rs:1419-1421), which is written UNCONDITIONALLY when the call has a caller number and can never be overridden by the model. GOTCHA: create's record does NOT carry computed fields — apply_computed runs only in the query path (app_object_enrich.rs:273-274), so a field the app's own UI shows is absent here but present in object.query. id is the record uuid as a bare string, NOT nested under record.
<sub>Source: voice_ai_rust/src/services/tool.rs:1443 (Ok(json!({ "ok": true, "id": row.id, "record": row.data })))</sub>
app handler object.delete — success and not-found
{
"ok": true,
"deleted": true,
"id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
"record": {
"patient_name": "Asha Rao",
"phone": "+919845012345",
"slot": "2026-08-03T10:30:00+05:30",
"status": "booked"
}
}
Delete is the ONLY handler with a deleted key, and it is present on BOTH outcomes (true / false) alongside ok — do not treat deleted as a success discriminator on its own. record is the deleted row's data, returned so the agent can read back what it removed. Deletes the NEWEST matching record only, one per call (tool.rs:1623). The missing-match-value error here is the TERSE form — unlike object.update it does NOT list the args that arrived: {"error":"missing value for match field booking_ref"}. Note object.delete is fully supported (app_manifest.rs:843-844) even though the unsupported-kind message below forgets to list it.
On failure:
{
"ok": false,
"deleted": false,
"error": "no matching record found"
}
<sub>Source: voice_ai_rust/src/services/tool.rs:1628-1641 (success :1638, not-found :1640); missing match value :1620-1622</sub>
app handler object.query — success
{
"results": [
{
"id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
"data": {
"patient_name": "Asha Rao",
"phone": "+919845012345",
"slot": "2026-08-03T10:30:00+05:30",
"status": "booked",
"caller_number": "+919845012345",
"display_name": "Asha Rao — 3 Aug"
},
"created_at": "2026-07-29T09:14:22.481293Z"
}
]
}
★ NESTED WHERE YOU EXPECT FLAT: every stored field is under results[i].data.<field>, never results[i].<field>. ★ NO ok KEY AT ALL — this is the one declarative handler with no ok; a reader (or a flow tool_result edge) that keys off ok sees undefined on the happy path. Only three keys survive per row — id, data, created_at — the AppObjectRow's org_id, app_id, object_type, created_by, updated_at are all dropped (row struct: db/app_objects.rs:13-22). created_at is chrono RFC3339 with microseconds and a Z suffix. data DOES include computed fields (display_name above) because the tool goes through app_object_enrich::query_enriched, not the raw store (tool.rs:1532). Relation <field>__expanded keys NEVER appear here: the tool passes expand: Default (empty) at tool.rs:1537-1540. Hard limit: 50, newest-first. Zero matches is {"results":[]} and is classed "success". Filtering is exact TEXT equality (data->>'k' = value, db/app_objects.rs:250-252), and args that are not declared stored fields are silently DROPPED from the filter (tool.rs:1452-1459).
<sub>Source: voice_ai_rust/src/services/tool.rs:1532-1548 (results built at :1544-1547)</sub>
app handler object.update — success
{
"ok": true,
"id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
"record": {
"patient_name": "Asha Rao",
"phone": "+919845012345",
"slot": "2026-08-05T16:00:00+05:30",
"status": "rescheduled",
"caller_number": "+919845012345"
}
}
Same shape as create. record is the FULL merged record after a shallow JSONB merge, not the patch — fields the model never mentioned are still present. Only the NEWEST record matching data->>match = value is touched (db/app_objects.rs:200). The match field is stripped from the patch (tool.rs:1583) and manifest handler.set constants are applied last and always win (tool.rs:1584-1588). Computed fields are NOT applied here either.
<sub>Source: voice_ai_rust/src/services/tool.rs:1599-1602; SQL data = data || $2 at db/app_objects.rs:204-211</sub>
app handler sandbox (gated) — success and failure
{
"success — tool.rs:1713, `outcome.value` returned verbatim; keys are whatever the app's JS snippet returned, always a JSON object, NO envelope and NO `ok` field": {
"total": 1770,
"currency": "INR",
"breakdown": { "base": 1500, "tax": 270 }
},
"failure — tool.rs:1714-1716, hand-built envelope; `status` is one of error|timeout|oom|invalid|busy (code_runner.rs:56), `error` is a ≤512-char diagnostic": {
"ok": false,
"status": "timeout",
"error": "execution timed out"
},
"gated off (the DEFAULT — FEATURE_APP_SANDBOX unset) — tool.rs:1699-1701 bails, and the call site renders the Err as orchestration.rs:7687": {
"error": "sandbox app tools are disabled (set FEATURE_APP_SANDBOX=true)"
}
}
On success the JS return value is passed through RAW with no envelope — no ok, no wrapper. The snippet MUST return an object: a scalar, array, undefined, a function, or a circular value all become the failure envelope with "error":"snippet must return an object" (code_runner.rs:186-193). Args arrive in JS as the frozen global dv (alias vars). Failure envelope keys are ok/status/error — note status here is a SANDBOX status string, unrelated to the platform's success/error classification. status ∈ ok | error | timeout | oom | invalid | busy (code_runner.rs:56); busy means the concurrency semaphore was full, invalid means empty/oversized source or oversized inputs. Error text is capped and truncated on a UTF-8 boundary. When the feature flag is off the whole thing is instead an anyhow bail: {"error":"sandbox app tools are disabled (set FEATURE_APP_SANDBOX=true)"} (tool.rs:1699-1702), and a manifest with no code: {"error":"sandbox handler missing code"}.
On failure:
{
"ok": false,
"status": "timeout",
"error": "execution timed out"
}
<sub>Source: voice_ai_rust/src/services/tool.rs:1710-1717 ("ok" => Ok(outcome.value)); CodeOutcome at services/code_runner.rs:55-62</sub>
native adjust_volume / set_language / opt_out — what the model sees
{
"status": "adjusted",
"volume_percent": 141,
"at_limit": false,
"note": "volume changed — repeat your last point and confirm it's better"
}
{
"status": "language_set",
"language": "Hindi",
"note": "From now on reply ONLY in Hindi, whatever language the caller speaks. Briefly confirm the switch to the caller in Hindi now."
}
{
"status": "opted_out",
"note": "recorded — the caller will not be contacted again. Confirm that plainly, then close politely."
}
These three are DATA-ONLY natives (orchestration.rs:13614-13621): the tool loop continues and the model must speak afterwards — hence the note key, which is a behavioural INSTRUCTION to the model, not display text. volume_percent is integer milli/10, range 50–200 (100 = normal); at_limit is true at 500 or ≥1996 milli and swaps note for already at maximum volume — if the caller still can't hear, suggest they raise their phone or speaker volume / already at minimum volume. set_language.language is the RESOLVED display name, not the code the model sent, and its failure is {"error":"unrecognized language 'klingon' — retry with a plain language name like 'hindi', 'telugu', 'english'"}. opt_out takes NO arguments — the number is resolved server-side from the session row; its errors always state plainly that nothing was recorded.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:25217-25226 (adjust_volume), :25280-25291 (set_language), :25232-25240 (opt_out)</sub>
native end_call / navigate — what the model sees
{ "status": "ending" }
{ "status": "navigating", "to": "node_collect_details" }
end_call returns a single key and nothing else — the hangup is DEFERRED to the post-loop teardown so the goodbye line drains first, so "ending" means armed, not hung up. navigate.to is the flow NODE ID, not the label the model passed. Navigate's failure is the terse {"error":"navigate: unknown target"} (:25154). Both are terminal natives: the tool loop breaks after them.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:25116 (end_call), :25148-25157 (navigate)</sub>
native transfer — what the model sees
{
"status": "transferring",
"to": "+912261234567"
}
Natives never use ok — they use status (a per-kind verb string) plus kind-specific keys, and failures are the flat {"error": …} with no ok. to is the E.164 number actually dialled (the first non-DNC-suppressed number of the chosen label). Distinctive failures, verbatim: transfer tool has no destination configured; all transfer destinations are on the do-not-call list; plus the carrier error string on a failed bridge. On success the tool loop STOPS (terminal native, orchestration.rs:13676-13678) — the model gets this result but no further turn.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:25050-25060 (success :25052)</sub>
native agent handoff — what the model sees
{
"status": "handed_off",
"mode": "transfer",
"toAgent": "b41e77c2-9d0a-4f35-8c62-1a7e5f3b90d4"
}
camelCase toAgent sitting next to snake_case status/mode — the native surface is not internally consistent. mode is exactly "transfer" or "connect" (anything else in the manifest normalises to "transfer"). toAgent is the target agent uuid, not a label. The unknown-destination error echoes the RESOLVABLE labels so the model can retry: {"error":"unknown handoff destination \"billing\" — valid destinations: Sales, Support"} (orchestration.rs:25080-25084); with none configured: agent handoff tool has no target agent configured.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:25092-25104 (success :25097-25099); mode resolution at services/tool.rs:506-513</sub>
native dataset lookup (structured KB) — what the model sees
{
"result": [
{ "sku": "TN-4410", "name": "Wall mount bracket", "price": 1499, "in_stock": "yes" }
],
"row_count": 1
}
result is polymorphic and row_count is NULL whenever it is not an array — read them together. Three shapes from the same tool: rows → result is an array of the raw dataset row objects (all cell values are STRINGS, they come from JSONB data), row_count = length; a bare aggregate → result is a NUMBER and row_count is null (e.g. {"result":143,"row_count":null}, dataset_query.rs:239); a grouped aggregate → result is [{"group":"Mumbai","value":42}] (dataset_query.rs:230-234). Hard LIMIT 20 rows. Failures are flat {"error": …}: could not understand the lookup arguments: <serde msg> (fails CLOSED — a filter missing op never degrades to an unfiltered read), unknown column: <name> / unsupported op: <op> / <func> needs a numeric column; '<col>' is not numeric from compile, and the deliberately vague could not run that lookup on the dataset for any DB error (the real sqlx detail goes to the log, never to the model).
<sub>Source: voice_ai_rust/src/services/dataset_query.rs:277-281 (json!({ "result": result, "row_count": row_count })); dispatched from orchestration.rs:25172-25182 and (text brain) :18124-18140</sub>
native follow-up scheduling — what the model sees
{
"status": "scheduled",
"at": "2026-07-29T17:00:00+05:30",
"numberEnding": "2345"
}
★ The full callback number is NEVER returned — only numberEnding, the last 4 digits, deliberately so the number does not enter the LLM context (:25512-25522). at is when_display, which PRESERVES THE CALLER-LOCAL OFFSET the model supplied (not UTC) precisely so the model reads back the right wall-clock time. In a simulation the shape changes to {"status":"simulated","at":"…","note":"simulation — no real follow-up call was scheduled"} and nothing is persisted. Every failure is flat {"error": "<caller-facing sentence>"} and the sentences are instructions to the model, not diagnostics — e.g. follow-up calls only work on phone calls, and this is a web session — politely tell the caller you cannot schedule an automatic call-back from this channel; this call already has 3 pending follow-ups — do not schedule more; this number already has 2 pending follow-ups — do not schedule more; invalid atvalue "5pm" — send an ISO 8601 date-time WITH a timezone offset (e.g. 2026-07-04T17:00:00+05:30), or usein_minutes``.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:25499-25536 (success :25528-25532); simulation branch :25400-25410</sub>
Unknown / unavailable tool name
{ "error": "unknown tool: book_appointmnt" }
Internal status is not_found, not error. On s2s there is a SECOND, kinder variant used when the name IS in the graph-wide tool union but not at the current node — the wire carries the union while dispatch is per-node: {"error":"this tool is not available at this step: 'refund_order' belongs to a different step of this conversation. Use only the tools listed in your current instructions, or navigate to the step that offers it."} (orchestration.rs:7704-7710). And a non-native, non-app tool named on an s2s call gets {"error":"tool 'x' (native kind None) is not available on speech-to-speech calls"} (:7673-7677) — again Rust {:?} formatting leaking into a model-facing string.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:13588 & :18152 (cascade/text), :7691-7715 (s2s)</sub>
app handler http — failure envelopes
{
"error": "app tool failed (404 Not Found): {\"message\":\"no such clinic\"}"
}
All five are anyhow::bail! → the flat {"error": …} wrap, never {ok:false}. Non-2xx interpolates reqwest's StatusCode Display ("404 Not Found") and the response body TRIMMED TO 300 CHARS (text.chars().take(300)), so a long vendor error is cut mid-JSON — expect unparseable fragments in that string. The other four, verbatim: app tool response exceeded 1 MB cap; external app tool missing base_url; app install has no signing secret; app tool url rejected: <ssrf reason>. A network timeout/connection failure surfaces as reqwest's own Display text under the same error key.
<sub>Source: voice_ai_rust/src/services/tool.rs:1687-1691 (non-2xx), :1684-1686 (1 MB cap), :1650 (base_url), :1666 (signing secret), :1662 (SSRF)</sub>
app handler object.create — row-cap refusal
{
"ok": false,
"error": "app data store is full — cannot create more records"
}
Verbatim string, no interpolation. This is a soft envelope (HTTP-less, Ok(...)), so the tool call "succeeds" at the transport level and only the error key marks it. The check FAILS CLOSED on a DB error while refreshing the cached count (app_quota.rs:135-138) — so a Postgres blip produces this exact same "store is full" text even when the app is nowhere near 100k rows. The count is cached for 60s per (org, app).
<sub>Source: voice_ai_rust/src/services/tool.rs:1423-1428; cap constant MAX_ROWS_PER_APP = 100_000 at services/app_quota.rs:109, check at :124-152</sub>
app handler object.query — the empty-filter refusal (two variants)
{
"ok": false,
"error": "no lookup value was provided — ask the caller for the detail this lookup needs (e.g. their phone number) and try again"
}
Fires only when the surviving filter is empty AND the tool has a real lookup key (a declared parameter or a handler.map target that is a stored field) — tool.rs:1477-1498. A genuine LIST/BROWSE tool with no stored-field parameter falls through to the bounded 50-row read instead of refusing. The two variants differ by a suffix appended inside the same error string when the manifest declares a non-empty handler.map; reproduce it exactly (note the sentence runs on with no space before (this tool…). Cause when you see the map variant: the call carries NO caller identity (web call, or withheld caller ID) — not a consent-toggle problem, because a query filter is a lookup position and lookup bypasses the consent toggle (tool.rs:1249-1255).
On failure:
{
"ok": false,
"error": "no lookup value was provided — ask the caller for the detail this lookup needs (e.g. their phone number) and try again (this tool normally uses the caller's own details, but this call has none — a web call, or a withheld caller ID — so ask the caller for it)"
}
<sub>Source: voice_ai_rust/src/services/tool.rs:1500-1525 (message at :1518-1524, declares_map hint at :1507-1517)</sub>
app handler object.update — no match / missing match value
This block shows several variants side by side. Each top-level key labels a case (status code, method, or provider) — the keys themselves are not part of any response.
{
"no_matching_record (tool.rs:1604)": {
"ok": false,
"error": "no matching record found"
},
"missing_match_value (tool.rs:1564-1580 -> orchestration.rs:13584) - NOTE: no `ok` key": {
"error": "missing value for match field `appointment_id` — this tool received [caller_number, notes, phone]. The `match` field must be the name of an ARGUMENT the model fills (or a `handler.map` target), not just a field of the object."
},
"success, for contrast (tool.rs:1602)": {
"ok": true,
"id": "9f2c1b84-3d5e-4a17-9c60-7b1e2af03d55",
"record": {
"appointment_id": "APT-4821",
"phone": "+14155550111",
"status": "cancelled",
"updated_at": "2026-07-29T11:42:07Z"
}
}
}
TWO DIFFERENT ENVELOPES for what reads like one failure. "no matching record found" is the soft {ok:false,error} envelope. A MISSING/blank match VALUE is an anyhow error instead — it never carries ok, and the model sees only {"error": "…"} (see error_example, transcribed from the anyhow! at tool.rs:1574-1579; the […] list is the sorted arg keys that actually arrived). A missing match KEY in the manifest is a third: {"error":"object.update handler missing match"} (tool.rs:1557).
On failure:
{
"error": "missing value for match field `candidate` — this tool received [candidate_id, notes, stage]. The `match` field must be the name of an ARGUMENT the model fills (or a `handler.map` target), not just a field of the object."
}
<sub>Source: voice_ai_rust/src/services/tool.rs:1604 (no match); :1564-1580 (missing match value → anyhow::bail, wrapped into {"error": …} by orchestration.rs:13584)</sub>
app tool — pre-dispatch refusals (before any handler runs)
{
"error": "app tool 'clinic-crm' is not available to this agent"
}
All flat {"error": …} (anyhow bails), no ok. Verbatim set: app tool '<app_id>' is not available to this agent (agent is not BOUND to that app — the guard against a hand-crafted flow node carrying another app's config.app_id); app tools are not available in this context; app tool missing config; app tool missing app_id; calling agent has no org; and per-handler object.<kind> handler missing object``. A DB failure inside insert/update/delete also surfaces here as sqlx's Display string under error.
<sub>Source: voice_ai_rust/src/services/tool.rs:1361-1363 (binding gate), :1337, :1341, :1345, :1354</sub>
app tool — unsupported handler kind
{
"error": "unsupported app handler 'js' — supported: object.create / object.query / object.update (declarative), http (external), sandbox (gated)"
}
★ THE MESSAGE IS WRONG AND WILL MISLEAD YOU: it omits object.delete, which IS dispatched (tool.rs:1607) and IS in DECLARATIVE_HANDLER_KINDS (app_manifest.rs:843-844). Do not use this string as the authority on supported kinds. It fires most often for js, because the SDK's shipped JSON Schema offers js in editor autocomplete (app_preflight.rs:137-139) while the dispatcher only knows sandbox.
<sub>Source: voice_ai_rust/src/services/tool.rs:1719-1722</sub>
native tools — shared failure and skip envelopes
{
"error": "skipped: an earlier tool in this turn already took over the call"
}
Every native failure is the flat {"error": …} — no ok, no status. Two envelopes exist that no handler produced: when a terminal native already ran this turn, later natives in the same batch are never dispatched and get skipped: an earlier tool in this turn already took over the call; when the call is already tearing down, a transfer/handoff gets skipped: the call is already ending. Both still reach the model as normal tool results so the protocol stays consistent. A stored native whose kind the dispatcher does not know: {"error":"unsupported native tool kind: Some(\"voicemail\")"} — note this interpolates Rust's {:?} Option debug format, quotes and Some(...) included.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:13653-13662 (skips), :25293-25296 (unsupported kind)</sub>
Data API & the object store
Your app's records. Note that everything you stored lives under data — never flat on the row.
DELETE /api/app-data/:objectType/:id (external Data API — remove)
Scope: data:write — enforced ONLY for OAuth Bearer tokens; a static app key is NOT gated
{ "success": true }
★ THERE IS NO data KEY AT ALL on success — this is the one CRUD route whose envelope is bare {success:true}. Destructuring const {data} = res gives undefined, not the deleted row. ★ Contrast the dashboard proxy DELETE (routes/apps.rs:3351), which is byte-identical {success:true}, and the agent object.delete tool, which DOES return the deleted record.
On failure:
HTTP 404 {"success": false, "error": "record not found"}
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_data.rs:370</sub>
GET /api/app-data/:objectType (external Data API — list)
Scope: data:read — enforced ONLY for OAuth Bearer tokens; a static app key is NOT gated
{
"success": true,
"data": {
"objects": [
{
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": { "first_name": "Asha", "phone": "+919876543210", "status": "active" },
"createdBy": "api",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T09:14:22.481233Z"
}
]
}
}
★ DOUBLE-NESTED: data.objects — res.data is an OBJECT with one key, not the array. ★ NO nextCursor, NO total, NO hasMore on this route — unlike the dashboard proxy's list, which does return nextCursor. You cannot page the external Data API; you get one limit-sized window (default 100, hard max 500 via effective_limit, app_object_enrich.rs:25-36). ★ RESERVED QUERY KEYS are swallowed and never become filters: limit, search, topK/topk, sort, dir, numeric, view, expand (app_data.rs:101-129). A field literally named sort or limit is unfilterable here. ★ Every other query param becomes an EQUALITY filter with a STRING value (filter.insert(k, Value::String(v)), :126) — ?age=30 compares text, and operators ($gt/$in/…) are not expressible; use the dashboard proxy's POST /query for those. ★ ?search= only re-ranks when the manifest declares the object semantic:true, and it silently falls through to the normal recency query otherwise (app_object_enrich.rs:228). ★ ?view= names a manifest-declared saved view whose filter WINS over a caller filter on the same key (app_object_enrich.rs:250-257); an unknown view name is a silent no-op, not a 404. ★ Scope reality: check_auth_scope is called with static_install_check=false (app_data.rs:92), so data:read is enforced ONLY when app.scopes is Some — i.e. an OAuth Bearer token. A static per-install app key passes unconditionally. The objects:<type> scope every manifest declares is NOT enforced anywhere in this file.
On failure:
HTTP 403 {"success": false, "error": "token does not include the data:read scope"}
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_data.rs:148</sub>
GET /api/app-data/bridge (cross-app read catalog)
Scope: no scope gate — only the rate limit; both manifests + both installs decide
{
"success": true,
"data": {
"grants": [
{ "providerAppId": "clinic-crm", "objectType": "patient", "access": "read" },
{ "providerAppId": "clinic-crm", "objectType": "appointment", "access": "read" }
]
}
}
★ data.grants, and each entry is exactly these three keys — camelCase providerAppId, and access is the hardcoded literal "read" (:187), the only access level the bridge supports (non-read dependencies are skipped at :171). ★ An empty grants: [] is the normal answer for "provider not installed" or "provider stopped exposing it" — it is computed LIVE from both manifests on every call, so it shrinks the moment either side revokes. ★ This route calls neither check_auth_scope nor bridge_authorize; it is discovery only.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_data.rs:184-192</sub>
GET /api/app-data/bridge/:providerAppId/:objectType (read another app's rows)
Scope: no scope gate — gated by BOTH manifests (consumer declares appDependencies read, provider exposes to you) + both installs live
{
"success": true,
"data": {
"objects": [
{
"id": "5e8a1f30-7c62-4b8d-9a44-1d2e3f4a5b6c",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": { "first_name": "Asha", "phone": "+919876543210" },
"createdBy": "api",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T09:14:22.481233Z"
}
]
}
}
★ Envelope matches the normal list (data.objects) but appId on every row is the PROVIDER's app id, not yours — do not key your cache on it assuming ownership. ★ ★ RAW ROWS, NO ENRICHMENT: no computed fields, no __expanded, no views, no ?search=. The comment at :208-209 says so explicitly — the provider's private views/relations don't cross the boundary. A field the provider's own UI shows (a computed display_name) is simply ABSENT here. ★ Only equality filters + ?limit= (clamped 1..500 at :217, unlike the main list's 0-means-default). ★ Two distinct 403 texts tell you WHICH half of the gate failed — consumer-side declaration vs provider-side exposure.
On failure:
HTTP 403 {"success": false, "error": "app 'clinic-crm' does not share 'patient' with your app"}
HTTP 403 {"success": false, "error": "your app did not declare a read dependency on 'patient' from 'clinic-crm'"}
HTTP 400 {"success": false, "error": "use the normal data API to read your own app's data"}
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_data.rs:232 (rows fetched raw at :221 via db::app_objects::query, deliberately NOT query_enriched)</sub>
GET /api/orgs/:orgId/apps/:appId/objects/:objectType (dashboard proxy — list)
Scope: org membership (any role) — NOT objects:<type>, NOT data:read
{
"success": true,
"data": {
"objects": [
{
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": { "first_name": "Asha", "phone": "+919876543210", "display_name": "Asha Rao" },
"createdBy": "7d1e6c90-3b48-4f2a-8c55-9e0a1b2c3d4e",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T09:14:22.481233Z"
}
],
"nextCursor": "2026-07-29T09:14:22.481233+00:00,c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4"
}
}
★ data.objects PLUS data.nextCursor — the external Data API has no cursor, this one does. ★ nextCursor is null (present, not omitted) whenever the page came back short of limit, which is how you detect the end. ★ ★ CURSOR FORMAT GOTCHA: it is "<rfc3339>,<uuid>" built with created_at.to_rfc3339(), which emits +00:00 — NOT Z — even though every other timestamp in the same response serializes with Z. Pass it back verbatim as ?before=; do not normalize it. ★ A full page ALWAYS yields a cursor even when it is exactly the last page — the next call just returns an empty array. ★ Rows ARE enriched (query_enriched at :2314): computed fields present, but this route hardcodes ..Default::default() so view, expand and search are unavailable — use POST /query for those. ★ limit default 200, clamp 1..500 (:2312). ★ Read is membership-only; only the WRITE routes require owner/admin/developer.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:2329-2332</sub>
PATCH /api/app-data/:objectType/:id (external Data API — merge-update)
Scope: data:write — enforced ONLY for OAuth Bearer tokens; a static app key is NOT gated
{
"success": true,
"data": {
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": { "first_name": "Asha", "phone": "+919876543210", "status": "discharged" },
"createdBy": "api",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T13:40:55.002881Z"
}
}
★ Same double-nesting as create: the updated record is at res.data.data. ★ It is a jsonb MERGE (data = data || $5, db/app_objects.rs:143), so the returned data is the WHOLE merged record, not just your patch — top-level keys you omitted survive, keys you sent are overwritten wholesale (a nested object is REPLACED, not deep-merged). ★ Sending null for a key stores JSON null, it does not remove the key. ★ Raw row again — no computed fields, no expansions. ★ 404 (not 400) when the id exists but under a different objectType — the UPDATE is scoped by org+app+type+id.
On failure:
HTTP 404 {"success": false, "error": "record not found"}
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_data.rs:352</sub>
POST /api/app-data/:objectType (external Data API — create)
Scope: data:write — enforced ONLY for OAuth Bearer tokens; a static app key is NOT gated
{
"success": true,
"data": {
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": { "first_name": "Asha", "phone": "+919876543210" },
"createdBy": "api",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T09:14:22.481233Z"
}
}
★ data IS THE ROW ITSELF here — not data.object, not data.objects[0]. So the record you posted comes back at res.data.data, one level deeper than most readers expect. ★ The response is the RAW insert row: computed fields and expansions are NOT applied on create (no enrich call on this path), so a field your list reads back as display_name is absent from the create response. ★ createdBy is always the literal string "api" on this route (:322). ★ The unknown-object-type 400 only fires on a DEFINITE "not declared" — declares_object returns None (⇒ write allowed) when the manifest can't be loaded or parsed (app_object_enrich.rs:111-123), so a manifest blip fails OPEN. ★ Body must be a JSON object or you get 400 "body must be a JSON object" (:290).
On failure:
HTTP 400 {"success": false, "error": "unknown object type 'candidates' — declare it under the manifest's objects[]"}
HTTP 413 {"success": false, "error": "app data store is full (max 100000 records per app)"}
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_data.rs:331</sub>
POST /api/orgs/:orgId/apps/:appId/objects/:objectType (dashboard proxy — create)
Scope: org role owner | admin | developer (viewer/member are read-only) + objectType declared
{
"success": true,
"data": {
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": { "first_name": "Asha", "phone": "+919876543210" },
"createdBy": "7d1e6c90-3b48-4f2a-8c55-9e0a1b2c3d4e",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T09:14:22.481233Z"
}
}
★ data is the row itself → the record you sent is at res.data.data. ★ createdBy here is the ACTING USER's uuid as a string (:3293), unlike the external API's literal "api" — same column, different meaning per surface. ★ Raw insert row: no computed fields on the create/update response even though the list route materializes them. ★ Role gate is stricter than the read: a member/viewer gets 403 on create/update/delete but 200 on list/query.
On failure:
HTTP 413 {"success": false, "error": "app data store is full (max 100000 records per app)"}
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:3309 (PATCH twin at :3333)</sub>
POST /api/orgs/:orgId/apps/:appId/objects/:objectType/change-ticket
Scope: org membership + objectType declared (ensure_app_object)
{
"success": true,
"data": {
"ticket": "9c2f0a7b41d84e6cb3f5d0a19e77c412",
"wsUrl": "wss://app.telenow.ai/ws/object-changes?ticket=9c2f0a7b41d84e6cb3f5d0a19e77c412"
}
}
★ ticket is a DASHLESS 32-hex uuid (Uuid::new_v4().simple(), :3173) — not the canonical dashed form; treat it as an opaque string. ★ wsUrl already contains the ticket as a query param — just open it, don't re-append. Scheme is wss:// unless x-forwarded-proto is literally "http" (routes/calls.rs:869-891). ★ Single-use, 30s TTL in Redis (vai:objchange:{ticket}), consumed by GETDEL on connect — a second connect with the same ticket fails.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:3188-3191</sub>
POST /api/orgs/:orgId/apps/:appId/objects/:objectType/query (dashboard proxy — filtered/sorted read)
Scope: org membership + app installed & live + objectType declared (ensure_app_object)
{
"success": true,
"data": {
"objects": [
{
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "appointment",
"data": {
"patient_id": "5e8a1f30-7c62-4b8d-9a44-1d2e3f4a5b6c",
"slot_start": "2026-07-30T10:30:00Z",
"status": "booked",
"patient_id__expanded": { "first_name": "Asha", "phone": "+919876543210" }
},
"createdBy": "agent:9a3d5c71-0f28-4e6b-b1a7-c8d9e0f1a2b3",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T09:14:22.481233Z"
}
],
"nextCursor": null
}
}
★ Identical envelope to the GET list (data.objects + data.nextCursor) — the difference is entirely in the REQUEST. ★ ★ nextCursor is FORCED to null whenever you passed orderBy OR view (:2422) — sorted and view results are NOT pageable, so a sorted query silently caps at limit (default 200, max 500) with no signal that more exist. ★ Request keys are camelCase (orderBy, countOnly) — QueryBody carries rename_all="camelCase" (:2346); order_by in the body is silently ignored. ★ filter values may be scalars (equality) or operator objects: $eq $ne $gt $gte $lt $lte $in $contains (db/app_objects.rs:301-377). $gt-family compares NUMERICALLY only when the JSON operand is a number, otherwise as text (ISO dates sort correctly as text). A malformed $in (operand not an array) matches NOTHING by design; an UNKNOWN operator is silently ignored, so {"$like":"x"} returns everything. ★ expand embeds at data.<field>__expanded (max 8 fields, 500 ids each). ★ search needs the object declared semantic:true, else it is ignored and you get a normal recency query.
On failure:
HTTP 400 {"success": false, "error": "unknown object type 'patients'"}
HTTP 404 {"success": false, "error": "app is not installed"}
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:2427-2430 (request body struct at :2345-2370)</sub>
POST /api/orgs/:orgId/apps/:appId/objects/:objectType/query with {"countOnly": true}
Scope: org membership + app installed & live + objectType declared
{ "success": true, "data": { "count": 42 } }
★ SAME URL as the query route — the body flag switches the entire response shape. data.objects is ABSENT (not empty), data.nextCursor is absent, data.count is a number. ★ ★ countOnly RETURNS EARLY BEFORE ENRICHMENT (:2385-2390), so it ignores view, expand, search, orderBy and limit entirely and counts the RAW filter against the whole (org, app, type) partition. A count taken with a view will NOT match the row count that same view returns. ★ The count is unlimited — it is a real COUNT(*), not clamped to 500 like the row read.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:2389</sub>
The AppObject row — the unit every objects endpoint returns
{
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": {
"first_name": "Asha",
"last_name": "Rao",
"phone": "+919876543210",
"status": "active",
"doctor_id": "a71c04e8-92b5-4d3f-8e10-6b2f9c4d7a55",
"display_name": "Asha Rao",
"doctor_id__expanded": { "name": "Dr. Mehta", "dept": "cardiology" }
},
"createdBy": "api",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T11:02:07.930114Z"
}
★ TWO CASING PLANES IN ONE OBJECT. The envelope is camelCase (id/orgId/appId/objectType/createdBy/createdAt/updatedAt) because of rename_all="camelCase" on AppObjectRow. The keys INSIDE data are whatever the app stored — the shipped manifests use snake_case (first_name, doctor_id), and nothing renames them. ★ EVERYTHING YOUR APP STORED IS UNDER data, never flat on the row: row.data.phone, not row.phone. ★ Computed fields are MATERIALIZED INTO data (app_object_enrich.rs:299), so display_name is indistinguishable from a stored field in the response — it is recomputed from a snapshot on every read and any value you PATCH into it is overwritten. ★ Relation expansion lands at data.<field>__expanded (app_object_enrich.rs:459 builds the key), NOT at row.<field>__expanded and not replacing the id field — to-one gives an object (null if the target is gone), to-many gives an array, and the array is silently SHORTER than the id array when a referenced row was deleted (:472-480 filter_map drops misses). Only the target row's data is embedded — no nested id/createdAt. ★ createdBy is a provenance TAG, not always a user id: "api" for the external Data API (routes/app_data.rs:322), the user's uuid string for the dashboard proxy (routes/apps.rs:3293), "agent:<agentUuid>" for in-call tools (services/tool.rs:1427), "inbound-hook" for webhook writes (routes/app_hooks.rs:127), and null for public-link writes (routes/public_links.rs:479). ★ The frontend's own AppObject TS interface (/Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/api/apps.ts:190-197) is INCOMPLETE — it omits orgId and updatedAt, both of which the server always sends.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/db/app_objects.rs:11-22 (struct AppObjectRow, #[serde(rename_all="camelCase")]); computed inserted at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/services/app_object_enrich.rs:299; expansion inserted at :470 and :479</sub>
WebSocket /ws/object-changes — one text frame per change
Scope: the single-use ticket from change-ticket (which required org membership)
{ "event": "created", "objectType": "patient", "id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4", "data": { "first_name": "Asha", "phone": "+919876543210" } }
{ "event": "deleted", "objectType": "patient", "id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4", "data": null }
★ NO success envelope — the frame IS the payload, unlike every REST response on this surface. ★ Exactly four keys. event is one of "created" | "updated" | "deleted" (&'static str). ★ ★ data HERE IS NOT THE ROW — it is only the row's data object (the stored fields), with NO id/orgId/appId/createdAt wrapper. The record id lives in the sibling id key. So a frame handler reads frame.id + frame.data.phone, while a REST handler reads row.id + row.data.phone — same nesting for the fields, different for the id. ★ data is null on "deleted" (and only then). ★ ★ FRAMES ARE UNENRICHED: publish() is called from the db layer with the row straight off RETURNING, before app_object_enrich runs — so computed fields and __expanded are ABSENT from a frame but PRESENT in the list response for the same record. Don't diff one against the other. ★ "updated" frames carry the full merged row data, not the patch. ★ Frames are dropped (not buffered) for a lagging client — RecvError::Lagged is skipped silently at :84-87.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/websocket/object_change_stream.rs:73-78 (frame built at db chokepoints /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/db/app_objects.rs:46, :122, :154, :181, :243)</sub>
object.create / object.update / object.delete (declarative agent tools)
Scope: manifest-declared tool; no data:* scope check on this path
// object.create and object.update (success):
{ "ok": true, "id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4", "record": { "first_name": "Asha", "phone": "+919876543210", "status": "active", "caller_number": "+919876543210" } }
// object.delete (success):
{ "ok": true, "deleted": true, "id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4", "record": { "first_name": "Asha", "phone": "+919876543210" } }
★ The stored fields come back under record here — a THIRD name for the same thing (data on REST/bridge, data inside object.query results, record on these three). All in-band at HTTP 200: ok:false is the failure signal, there is no success key and no non-2xx. ★ object.delete carries BOTH ok and deleted, and returns the deleted row's fields — unlike both REST DELETEs, which return nothing. ★ record is the RAW stored data (no computed fields), and on create it includes server-injected keys the model never sent: the object's manifest field_defaults (:1414-1419) and caller_number, force-set from the trusted caller (:1420-1422). ★ update/delete locate the NEWEST row matching handler.match — they are single-record ops, never bulk.
On failure:
{ "ok": false, "error": "no matching record found" } // object.update miss
{ "ok": false, "deleted": false, "error": "no matching record found" } // object.delete miss
{ "ok": false, "error": "app data store is full — cannot create more records" } // object.create at the row cap
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/services/tool.rs:1442 (create), :1602 and :1604 (update), :1638 and :1640 (delete)</sub>
object.query (declarative agent tool over the same store)
Scope: manifest-declared tool; no data:* scope check on this path
{
"results": [
{
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"data": { "first_name": "Asha", "phone": "+919876543210", "display_name": "Asha Rao" },
"created_at": "2026-07-29T09:14:22.481233Z"
}
]
}
★ ★ THE ONE snake_case SHAPE ON THIS SURFACE: created_at, not createdAt — it is hand-built with json! and never touches AppObjectRow's serde. orgId/appId/objectType/updatedAt/createdBy are all DROPPED. ★ NO success and NO ok on the happy path — just {results:[…]}; but the two failure paths DO return {ok:false,error} at 200, so a consumer must check for results rather than for ok. ★ ★ FIELDS ARE UNDER results[i].data — results[0].data.phone, never results[0].phone. This is the single most common mis-destructure on the object store. ★ Rows ARE enriched (query_enriched, :1529-1543), so computed fields appear; expand is not requested, so __expanded never does. ★ Hard-capped at 50 rows (:1540) with no cursor and no total. ★ Args are filtered to the tool's DECLARED fields before becoming the filter (:1450-1458) — an arg that isn't a stored field is dropped, not matched.
On failure:
{ "ok": false, "error": "no lookup value was provided — ask the caller for the detail this lookup needs (e.g. their phone number) and try again" }
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/services/tool.rs:1545-1548</sub>
telenow.data.count(objectType, query) — UI bridge
Scope: none
{ "count": 42 }
★ ★ RESOLVES TO AN OBJECT, NOT A NUMBER. const n = await telenow.data.count('patient') gives {count: 42} — n + 1 is the string "[object Object]1". Use (await telenow.data.count('patient')).count. This is the one bridge data op that does NOT flatten what the REST envelope gave it: countAppObjects unwraps {success,data} to {count} and the host passes that through verbatim. ★ The 2nd arg is the filter and is sent as {filter, countOnly:true} — same countOnly early-return, so view/expand/search/limit are ignored.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:635; /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/api/apps.ts:850-861</sub>
telenow.data.create(objectType, body) / .update(objectType, id, body) — UI bridge
Scope: none at the bridge; the server still requires org role owner | admin | developer
{
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": { "first_name": "Asha", "phone": "+919876543210" },
"createdBy": "7d1e6c90-3b48-4f2a-8c55-9e0a1b2c3d4e",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T09:14:22.481233Z"
}
★ BRIDGE DIFFERS FROM REST: REST gives {success, data:<row>}; the bridge resolves to the ROW directly (unwrap() strips the envelope, api/apps.ts:1052). Your stored fields are at row.data.* — one .data here, two on REST. ★ Raw row: no computed fields on the create/update reply even though list() shows them for the same record. Re-list (or recompute locally) if your UI renders a computed field right after a write.
On failure:
the Promise REJECTS with `new Error("your role does not permit this action")` or with the server's error string
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:637 and :640; /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/api/apps.ts:1046-1065</sub>
telenow.data.list(objectType, query, opts) — UI bridge
Scope: none — the bridge treats an app's own objects as always-allowed (no need() call)
[
{
"id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4",
"orgId": "1b9d6bcd-bbfd-4b2d-9b5d-ab8dfbbd4bed",
"appId": "clinic-crm",
"objectType": "patient",
"data": { "first_name": "Asha", "phone": "+919876543210", "display_name": "Asha Rao" },
"createdBy": "7d1e6c90-3b48-4f2a-8c55-9e0a1b2c3d4e",
"createdAt": "2026-07-29T09:14:22.481233Z",
"updatedAt": "2026-07-29T09:14:22.481233Z"
}
]
★ ★ BRIDGE DIFFERS FROM REST. REST returns {success, data:{objects, nextCursor}}; the bridge resolves to the BARE ARRAY. The host unwraps the envelope and then takes .objects ((await queryAppObjects(...)).objects, :633). Await it and iterate directly — there is no .data, no .objects. ★ ★ nextCursor IS DISCARDED (:633) and the opts type (:565-571) has no before, so THE BRIDGE CANNOT PAGE. Raise opts.limit (server max 500) or you silently see only the first page. ★ It POSTs to the /query route, not the GET list — so list() gets operators, sort, view, expand and search even though it is named "list". ★ ARGUMENT SHAPE TRAP: the 2nd arg is the FILTER ONLY (it becomes filter, :626); sort/limit/view/expand/search go in the 3rd arg opts. Passing {limit: 50} as the 2nd arg filters on a stored field called "limit" and returns nothing. ★ Row shape is otherwise identical to REST, computed + __expanded included.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:624-633 (SDK stub at :210); underlying call /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/api/apps.ts:836-847</sub>
telenow.data.remove(objectType, id) — UI bridge
Scope: none at the bridge; the server still requires org role owner | admin | developer
{ "ok": true }
★ ★ HAND-BUILT BY THE BRIDGE, NOT FROM THE SERVER. The host awaits deleteAppObject, THROWS AWAY the server's {success:true}, and synthesizes {ok:true} (:644). So this is the only place on the whole object surface where the truthiness key is ok rather than success — and it is a client-side constant, never evidence of what the server said beyond "it did not throw". ★ The deleted row is not returned anywhere on this path.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:643-644</sub>
telenow.data.subscribe(objectType, onChange) — UI bridge realtime
Scope: none at the bridge (same trust as data.list); ticket mint requires org membership
// resolves to an unsubscribe function:
const off = await telenow.data.subscribe('patient', (f) => { /* f is the frame below */ });
// each onChange call receives, verbatim:
{ "event": "updated", "objectType": "patient", "id": "c3f1b8a2-6d4e-4a91-b2f7-0e5a9c81d3f4", "data": { "first_name": "Asha", "status": "discharged" } }
★ The awaited value is a FUNCTION (unsubscribe), not data — the underlying RPC's {streamId} never reaches your code. ★ The callback argument is the raw WS frame, forwarded verbatim by the host (data: frame, :1004), so it carries the same four keys and the same caveats as the WS entry: data is the stored-fields object only, null on "deleted", and UNENRICHED (no computed, no __expanded) — so a subscribe-driven list will lose the computed fields a list() put there unless you recompute. ★ Frames arrive on a separate postMessage channel keyed by stream, not by reply id (:188), so they never resolve a pending RPC.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:987-1010 (host relay) and :217-225 (SDK stub, which swaps the RPC's {streamId} for the unsubscribe closure)</sub>
Data-API rate limit (429) and row cap (413)
HTTP 429
{ "success": false, "error": "Data API rate limit reached — slow down", "retryAfter": 1 }
HTTP 413
{ "success": false, "error": "app data store is full (max 100000 records per app)" }
★ 429 is the ONLY object-surface error with a third key: retryAfter, an integer in SECONDS, camelCase in the body (there is no Retry-After header from this arm). It is always 1 for the Data API. ★ The 429 gate runs FIRST on every /api/app-data route, before auth scope checks, and is per-(org, app). ★ 413 (PAYLOAD_TOO_LARGE) is the row-cap signal, not a body-size signal — the number in the message is interpolated from the constant, so it moves if the cap moves. It fires on both create paths (external :311 and dashboard proxy /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:3288); the agent tool instead returns an in-band {ok:false,error:"app data store is full — cannot create more records"} at 200.
<sub>Source: 429 constructed at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_data.rs:54-58, serialized at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/error.rs:112-124; 413 at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_data.rs:311-314 (cap constant MAX_ROWS_PER_APP = 100_000, /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/services/app_quota.rs:109)</sub>
Standard REST error envelope (all object routes)
This block shows several variants side by side. Each top-level key labels a case (status code, method, or provider) — the keys themselves are not part of any response.
{
"title": "Standard REST error envelope (app object routes)",
"envelope_construction_site": "voice_ai_rust/src/error.rs:151",
"envelope_shape": { "success": false, "error": "<message string>" },
"notes": "Flat, exactly two keys. The wire string is the raw message passed to the AppError variant - the `not found: {0}` / `bad request: {0}` Display prefixes are NOT used by the response body.",
"dashboard_proxy_object_routes": {
"route_prefix": "/api/orgs/:orgId/apps/:appId/objects/:objectType",
"404_record_not_found": {
"status": 404,
"from": "voice_ai_rust/src/routes/apps.rs:3327 (update_object), :3347 (delete_object)",
"body": { "success": false, "error": "record not found" }
},
"404_app_not_installed": {
"status": 404,
"from": "voice_ai_rust/src/routes/apps.rs:3262 (ensure_app_object)",
"body": { "success": false, "error": "app is not installed" }
},
"400_body_not_object": {
"status": 400,
"from": "voice_ai_rust/src/routes/apps.rs:3284 (create_object), :3322 (update_object)",
"body": { "success": false, "error": "body must be a JSON object" }
},
"400_unknown_object_type": {
"status": 400,
"from": "voice_ai_rust/src/routes/apps.rs:3265 (ensure_app_object)",
"body": { "success": false, "error": "unknown object type 'patient'" }
},
"403_write_role_gate": {
"status": 403,
"from": "voice_ai_rust/src/routes/apps.rs:3281/3319/3342 -> voice_ai_rust/src/services/org.rs:1583-1586",
"applies_to": "create / update / delete",
"body": { "success": false, "error": "this action requires one of: owner, admin, developer" }
},
"403_not_a_member": {
"status": 403,
"from": "voice_ai_rust/src/routes/apps.rs:2382/3169 (require_member) -> voice_ai_rust/src/services/org.rs:1551",
"applies_to": "query / count / change-ticket",
"body": { "success": false, "error": "not a member of this organization" }
},
"no_objects_scope_403": "There is no `objects:<type>` scope check on any object route. `ensure_app_scope` (apps.rs:2436-2469) is never called with an `objects:*` scope, and the UI bridge marks object CRUD 'always allowed' (AppPageHost.tsx:612-644). `objects:patient` is install-consent metadata only."
},
"app_key_data_api_object_routes": {
"route_prefix": "/api/app-data/:objectType",
"403_oauth_token_scope": {
"status": 403,
"from": "voice_ai_rust/src/routes/app_data.rs:74",
"body": { "success": false, "error": "token does not include the data:read scope" }
},
"403_static_key_scope": {
"status": 403,
"from": "voice_ai_rust/src/routes/app_data.rs:805-807",
"body": { "success": false, "error": "app did not declare the files:read scope" }
},
"429_rate_limited": {
"status": 429,
"from": "voice_ai_rust/src/routes/app_data.rs:52-56 -> voice_ai_rust/src/error.rs:112-124",
"note": "Extra camelCase `retryAfter` key on top of the standard envelope.",
"body": { "success": false, "error": "Data API rate limit reached — slow down", "retryAfter": 1 }
}
},
"scope_denial_403_actual_wording_and_routes": {
"status": 403,
"from": "voice_ai_rust/src/routes/apps.rs:2463-2467 (ensure_app_scope)",
"reachable_from": "ai:llm (apps.rs:2483, 2940), files:read (2500), links:write/read (2518, 2532, 2544), agents:read (2590), agents:write (2626), agent-config read (2840), kb:read (2962, 2996), calls:transcribe:live (3076), members:read (3119), user:profile (3120) - NOT from object routes",
"body": { "success": false, "error": "this install has not granted the kb:read scope. If the app's current manifest declares it, the org is still on an older installed version — upgrade the app in Settings → Apps to grant the new scopes" }
},
"envelope_variants_that_add_a_key": {
"401_jwt": {
"from": "voice_ai_rust/src/error.rs:88-92",
"body": { "success": false, "error": "Access token expired", "code": "token_expired" }
},
"403_with_code": {
"from": "voice_ai_rust/src/error.rs:97-103 (AppError::ForbiddenCode)",
"body": { "success": false, "error": "support write access has expired", "code": "support_write_revoked" }
},
"502_provider": {
"from": "voice_ai_rust/src/error.rs:133-144",
"body": { "success": false, "error": "callback_method requires a valid URL", "code": "provider_error" }
}
},
"ui_bridge_reprojection": {
"from": "voice_ai_frontend/src/components/apps/AppPageHost.tsx:1042 with voice_ai_frontend/src/utils/error.ts:13-21",
"note": "An app running inside the sandboxed iframe NEVER sees the REST envelope. The host catches the axios error, pulls `response.data.error`, and posts back a different shape - `success` is replaced by `ok` and the HTTP status is dropped.",
"rest_shape": { "success": false, "error": "record not found" },
"bridge_shape": { "source": "telenow-host", "id": "4f2c1e90-9a3c-4b21-8f77-1d0a6b5e2c31", "ok": false, "error": "record not found" },
"bridge_success_shape_for_contrast": { "source": "telenow-host", "id": "4f2c1e90-9a3c-4b21-8f77-1d0a6b5e2c31", "ok": true, "data": {} }
}
}
★ Two keys only: success:false and error (a human string). There is NO code on these — code appears only for JWT failures (error.rs:90), ForbiddenCode (:100) and provider errors (:140), none of which the object routes raise. ★ error is a SENTENCE, not an enum — never branch on its text; branch on the HTTP status. ★ Any unmapped internal failure collapses to HTTP 500 {"success":false,"error":"Something went wrong"} (error.rs:147) — a DB error on a query looks identical to a panic.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/error.rs:151 (generic arm); 403 text at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:2463-2467</sub>
UI-bridge failure frame (postMessage) — what an app page actually sees
// wire frame the host posts back into the iframe:
{ "source": "telenow-host", "id": "r7", "ok": false, "error": "record not found" }
// what your code sees: the Promise rejects
try { await telenow.data.update('patient', id, { status: 'x' }); }
catch (e) { e.message === 'record not found'; }
★ ★ THE BRIDGE'S SUCCESS KEY IS ok, THE REST SURFACE'S IS success — different words for the same idea on the two halves of the same feature. You almost never see the frame: the SDK resolves d.data on ok and rejects new Error(d.error) otherwise (:191). ★ The rejection message is a flattened STRING — the HTTP status, and any retryAfter, are LOST crossing the bridge, so an app page cannot distinguish 429 from 404 except by matching text. ★ Bridge-local failures use the same channel and are indistinguishable from server ones: "objectType required", "recordId required" (:620, :639, :642) and "your role does not permit this action" (:540) never reached the network at all.
<sub>Source: frame built at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:1042 (success twin at :1040); turned into a rejection by the injected SDK at :191</sub>
Calls & post-call analysis
Call history, one call in full, and the analysis document.
CallAnalysis — the analysis object (every field)
Scope: served inside calls:read / calls:read:org responses
{
"sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"orgId": "c81b0f47-2e6a-4d3b-8f19-5a7c6e2d40b1",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"status": "done",
"summary": "Caller rescheduled a dental cleaning from Tue 29 Jul to Tue 4 Aug 11:00; SMS confirmation promised.",
"sentiment": "positive",
"sentimentScore": 0.62,
"disposition": "appointment_rescheduled",
"actionItems": ["Send SMS confirmation for 2026-08-04 11:00"],
"customData": { "patient_id": "PT-4471", "branch": "Indiranagar" },
"evidence": {
"sentiment": "that works perfectly, thank you",
"disposition": "let's move it to next Tuesday instead"
},
"qa": [
{ "key": "verified_identity", "met": true, "evidence": "Can you confirm your date of birth?" },
{ "key": "offered_reminder", "met": false, "evidence": "" }
],
"objections": ["morning slots are hard for me"],
"score": 88,
"coaching": [
{ "issue": "Never offered the SMS reminder opt-in", "suggestion": "Offer the reminder before closing the call", "severity": "low" }
],
"hallucinations": [],
"cx": { "rating": "good", "friction": ["repeated the new date twice"], "highlights": ["reschedule took under 30 seconds"] },
"topics": ["appointment", "rescheduling"],
"keywords": ["dental cleaning", "Tuesday", "SMS confirmation"],
"agentWords": 142,
"customerWords": 88,
"agentTurns": 9,
"customerTurns": 8,
"model": "openai/gpt-4o-mini",
"error": null,
"createdAt": "2026-07-29T09:21:47.115900Z",
"updatedAt": "2026-07-29T09:21:52.884301Z"
}
TWO NAME COLLISIONS with the enclosing call row — analysis.status is the ANALYSIS job state ("pending" | "done" | "failed"; written by claim_batch/mark_done/mark_failed, post_call_analysis.rs:117,176,211), NOT the call's status; and analysis.disposition is the LLM's outcome label while the row's top-level disposition is the CARRIER disposition (answered/no-answer/busy/failed). A row can exist with status "pending" and every content field null/[]/{}. qa, objections, actionItems, coaching, hallucinations, topics, keywords are jsonb ARRAYS that default to [], never null; customData, evidence, cx are jsonb OBJECTS defaulting to {} (post_call_analysis.rs:337-356 coerces anything non-conforming to the empty form). qa[] is {key, met, evidence}; coaching[] is {issue, suggestion, severity} where severity is low|medium|high and may be null; hallucinations[] is {claim, why}; cx is {rating, friction[], highlights[]} with rating excellent|good|fair|poor or null — these four nested structs have NO rename_all, their fields are single words so they read the same in both conventions. score is the 0-100 LLM-judge quality score (i32|null); sentimentScore is a SEPARATE f32 in -1.0..1.0. agentWords/customerWords/agentTurns/customerTurns are FLAT here — the call.analyzed webhook nests the same four under talkRatio instead. model is "provider/model" (services/post_call_analysis.rs:356). Analysis is per-AGENT opt-in via agents.metadata.postCallAnalysis.enabled and ships OFF, so for most agents nothing was ever produced.
<sub>Source: voice_ai_rust/src/db/post_call_analysis.rs:16-58 (struct + #[serde(rename_all="camelCase")]); nested item shapes at voice_ai_rust/src/services/post_call_analysis.rs:84-115 (Coaching/Hallucination/Cx) and 337-356 (normalisation before persist)</sub>
GET /api/app-calls (app-key plane, camelCase)
Scope: calls:read (bound agents) | calls:read:org (whole org). +billing:read if includeCost=true
{
"success": true,
"data": {
"calls": [
{
"id": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"agentName": "Clinic Reception",
"status": "ended",
"callMode": "agent",
"channel": "telephony",
"direction": "inbound",
"startTime": "2026-07-29T09:14:02.331245Z",
"endTime": "2026-07-29T09:16:11.884012Z",
"durationSec": 129,
"fromNumber": "+919845012345",
"toNumber": "+918031138113",
"answeredBy": "human",
"disposition": "answered",
"wrapupDisposition": null,
"hasRecording": true,
"latency": {
"sttMsAvg": 312,
"turnHoldMsAvg": 420,
"llmMsAvg": 688,
"ttsMsAvg": 241,
"netRttMsAvg": null,
"flowMsAvg": null,
"respMsAvg": 1104,
"respMsMax": 1980,
"respSamples": 8,
"audioGapCount": 0
},
"createdAt": "2026-07-29T09:14:02.331245Z",
"analysis": {
"sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"orgId": "b1d0a4c7-2e56-4d90-8f3a-6c17e9b25a08",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"status": "done",
"summary": "Caller rescheduled a dermatology appointment from Thursday to Monday morning and confirmed the SMS reminder.",
"sentiment": "positive",
"sentimentScore": 0.62,
"disposition": "appointment_rescheduled",
"actionItems": ["Send updated confirmation SMS for Mon 10:30"],
"customData": { "patient_id": "P-40182", "department": "dermatology" },
"evidence": { "patient_id": "my patient number is four zero one eight two" },
"qa": [{ "key": "verified_identity", "met": true, "evidence": "Can you confirm your date of birth?" }],
"objections": ["Thursday slot was too late in the day"],
"score": 88,
"coaching": [{ "issue": "Did not offer the earlier 9:15 slot", "suggestion": "Read the two nearest openings before confirming", "severity": "low" }],
"hallucinations": [],
"cx": { "rating": "good", "friction": ["hold while checking the calendar"], "highlights": ["confirmed the new time twice"] },
"topics": ["appointment_rescheduling", "sms_reminder"],
"keywords": ["dermatology", "monday", "reminder"],
"agentWords": 214,
"customerWords": 96,
"agentTurns": 9,
"customerTurns": 8,
"model": "gpt-4.1-mini",
"error": null,
"createdAt": "2026-07-29T09:21:44.120388Z",
"updatedAt": "2026-07-29T09:21:44.120388Z"
},
"analysisEnabled": true,
"cost": {
"sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"agentName": "Clinic Reception",
"callType": "agent_voice",
"startTime": "2026-07-29T09:14:02.331245Z",
"llmUsd": "0.001842",
"sttUsd": "0.000930",
"ttsUsd": "0.002105",
"telephonyUsd": "0.006450",
"platformAiUsd": "0",
"postCallAnalysisUsd": "0.000410",
"simulationUsd": "0",
"embeddingUsd": "0",
"platformFeeUsd": "0.004500",
"featureSurchargeUsd": "0",
"totalChargeUsd": "0.016237",
"hasEstimates": false,
"breakdown": {
"billedMinutes": "2.15",
"feePercent": "10",
"feePerMinUsd": "0.002",
"softphoneFee": false,
"feeOverridden": false,
"durationSecs": 129,
"telephonyBilledSecs": 129,
"demo": false,
"featureSurchargeUsd": "0",
"featureSurcharges": [],
"feeRule": null
},
"ratedAt": "2026-07-29T09:21:00.004881Z"
}
}
],
"total": 412,
"limit": 50,
"offset": 0,
"hasMore": true
}
}
DOUBLE-NESTED: rows are at data.calls, not data. analysis, analysisEnabled and cost keys are ONLY present when the matching query flag is set — they are inserted inside if q.include_analysis / if q.include_cost (app_data.rs:589,597), so absent (not null) otherwise. LATENCY IS NESTED: sttMsAvg/llmMsAvg/... live under latency, not flat on the row. analysis: null + analysisEnabled: false means post-call analysis is OFF for that agent and the null is PERMANENT; analysisEnabled: true + null means still pending (app_data.rs:591-595). hasRecording is a boolean only — no recording id or URL is ever exposed on this surface. DELIBERATELY OMITTED from every app call row: the initiating user's identity, metadata, variables (app_data.rs:437-441) — the dashboard's call_to_json (routes/calls.rs:689) has them, this one never does. Money fields are rust_decimal with the default serde feature = JSON STRINGS ("0.016237"), not numbers; the frontend coerces with parseFloat (voice_ai_frontend/src/api/recordings.ts:815). cost.breakdown.events[] is stripped here (app_data.rs:578-580) — per-event detail only from /api/app-billing/charges/:sessionId. includeCost additionally requires billing:read or the whole request 403s (app_data.rs:572).
On failure:
// 403 — neither calls scope granted (routes/app_data.rs:432)
{ "success": false, "error": "requires the calls:read or calls:read:org scope" }
<sub>Source: voice_ai_rust/src/routes/app_data.rs:604 (envelope) + app_data.rs:444 app_call_to_json (row) + app_data.rs:589-599 (analysis/analysisEnabled/cost merge)</sub>
GET /api/app-calls/:sessionId (app-key plane, camelCase)
Scope: calls:read (must be BOUND to the call's agent) | calls:read:org
{
"success": true,
"data": {
"id": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"agentName": "Clinic Reception",
"status": "ended",
"callMode": "agent",
"channel": "telephony",
"direction": "inbound",
"startTime": "2026-07-29T09:14:02.331245Z",
"endTime": "2026-07-29T09:16:11.884012Z",
"durationSec": 129,
"fromNumber": "+919845012345",
"toNumber": "+918031138113",
"answeredBy": "human",
"disposition": "answered",
"wrapupDisposition": null,
"hasRecording": true,
"latency": {
"sttMsAvg": 312,
"turnHoldMsAvg": 420,
"llmMsAvg": 688,
"ttsMsAvg": 241,
"netRttMsAvg": null,
"flowMsAvg": null,
"respMsAvg": 1104,
"respMsMax": 1980,
"respSamples": 8,
"audioGapCount": 0
},
"createdAt": "2026-07-29T09:14:02.331245Z",
"analysis": {
"sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"orgId": "5c8b2d41-6e70-4a92-b1f3-2a7d90e4c815",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"status": "done",
"summary": "Caller rescheduled a Tuesday cleaning to the 4th at 11:00.",
"sentiment": "positive",
"sentimentScore": 0.62,
"disposition": "resolved",
"actionItems": ["Send the caller an SMS confirmation for Aug 4, 11:00"],
"customData": { "appointment_type": "cleaning", "patient_id": "PT-40912" },
"evidence": { "sentiment": "Sure — I can do Tuesday the fourth at eleven." },
"qa": [
{ "key": "verified_identity", "met": true, "evidence": "Can I confirm your date of birth?" },
{ "key": "offered_reminder", "met": false, "evidence": "" }
],
"objections": [],
"score": 88,
"coaching": [
{ "issue": "Did not offer an SMS reminder", "suggestion": "Close by offering a reminder text", "severity": "low" }
],
"hallucinations": [],
"cx": { "rating": "good", "friction": [], "highlights": ["Rebooked in under a minute"] },
"topics": ["appointment rescheduling", "dental cleaning"],
"keywords": ["Tuesday", "cleaning", "reschedule"],
"agentWords": 96,
"customerWords": 41,
"agentTurns": 6,
"customerTurns": 5,
"model": "claude-sonnet-4-5",
"error": null,
"createdAt": "2026-07-29T09:16:20.104553Z",
"updatedAt": "2026-07-29T09:16:38.772910Z"
},
"analysisEnabled": true,
"transcript": [
{ "role": "assistant", "text": "Thanks for calling Smile Dental, how can I help?", "at": "2026-07-29T09:14:04.102331Z", "agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35" },
{ "role": "user", "text": "I need to move my Tuesday cleaning.", "at": "2026-07-29T09:14:09.774210Z", "agentId": null },
{ "role": "assistant", "text": "Sure — I can do Tuesday the fourth at eleven.", "at": "2026-07-29T09:14:15.310884Z", "agentId": "7b0e4c22-1a9f-4d55-8e30-cc61b9a7d248" }
],
"participants": {
"3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35": "Clinic Reception",
"7b0e4c22-1a9f-4d55-8e30-cc61b9a7d248": "Scheduling Specialist"
}
}
}
Single-nested: the call object IS data (no data.call). analysis and analysisEnabled are ALWAYS present here (unconditional, app_data.rs:645-656) — unlike the list route where they are flag-gated. transcript[].agentId is CAMELCASE ONLY ON THIS ROUTE: the shared helper transcript_with_participants emits snake_case agent_id and this handler explicitly renames it (app_data.rs:669-679) — the dashboard proxy twin does NOT, so the same transcript is agent_id there. transcript[].agentId is a STRING uuid read out of message metadata (calls.rs:613-619), and is null on user turns and on any assistant turn written before per-turn attribution. role is only "user" | "assistant" — system turns are filtered out (calls.rs:622). participants is a MAP keyed by agent-uuid string → agent name, and is {} (empty object, not null/array) when no turn carried attribution (calls.rs:640). at is the message created_at, RFC3339.
On failure:
// 404 — not this org's call (app_data.rs:629)
{ "success": false, "error": "call not found" }
// 403 — org owns it but the install isn't bound to its agent, and no :org scope (app_data.rs:639)
{ "success": false, "error": "app is not bound to this call's agent" }
<sub>Source: voice_ai_rust/src/routes/app_data.rs:683 (envelope) + app_data.rs:644-681 (analysis + transcript rename + participants); transcript rows built at voice_ai_rust/src/routes/calls.rs:624</sub>
GET /orgs/:orgId/apps/:appId/calls (dashboard proxy for app PAGES)
Scope: install must hold calls:read | calls:read:org; caller must be an org member
// GET /orgs/{orgId}/apps/{appId}/calls?limit=50&includeAnalysis=true
{
"success": true,
"data": {
"calls": [
{
"id": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"agentName": "Clinic Reception",
"status": "ended",
"callMode": "agent",
"channel": "telephony",
"direction": "inbound",
"startTime": "2026-07-29T09:14:02.331245Z",
"endTime": "2026-07-29T09:16:11.884012Z",
"durationSec": 129,
"fromNumber": "+919845012345",
"toNumber": "+918031138113",
"answeredBy": "human",
"disposition": "answered",
"wrapupDisposition": null,
"hasRecording": true,
"latency": { "sttMsAvg": 312, "turnHoldMsAvg": 420, "llmMsAvg": 688, "ttsMsAvg": 241, "netRttMsAvg": null, "flowMsAvg": null, "respMsAvg": 1104, "respMsMax": 1980, "respSamples": 8, "audioGapCount": 0 },
"createdAt": "2026-07-29T09:14:02.331245Z",
"analysis": { /* full CallAnalysis, or null */ },
"analysisEnabled": true
}
]
}
}
DIFFERS FROM THE APP-KEY TWIN: data carries ONLY calls. There is NO total, NO limit, NO offset, NO hasMore — even though the route accepts limit/offset (apps.rs:2704-2705), so a page cannot tell whether more rows exist except by asking for another page and seeing it come back short. Also no includeCost/cost on this plane at all — AppCallsQuery has no such field (apps.rs:2636-2660). Row shape is otherwise byte-identical to the app-key plane because both call the same builder.
<sub>Source: voice_ai_rust/src/routes/apps.rs:2748 (envelope) reusing routes/app_data.rs:444 app_call_to_json for each row</sub>
GET /orgs/:orgId/apps/:appId/calls/:sessionId (dashboard proxy for app PAGES)
Scope: install must hold calls:read | calls:read:org; caller must be an org member
{
"success": true,
"data": {
"id": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"agentName": "Clinic Reception",
"status": "ended",
"callMode": "agent",
"channel": "telephony",
"direction": "inbound",
"startTime": "2026-07-29T09:14:02.331245Z",
"endTime": "2026-07-29T09:16:11.884012Z",
"durationSec": 129,
"fromNumber": "+919845012345",
"toNumber": "+918031138113",
"answeredBy": "human",
"disposition": "answered",
"wrapupDisposition": null,
"hasRecording": true,
"latency": {
"sttMsAvg": 312,
"turnHoldMsAvg": 420,
"llmMsAvg": 688,
"ttsMsAvg": 241,
"netRttMsAvg": null,
"flowMsAvg": null,
"respMsAvg": 1104,
"respMsMax": 1980,
"respSamples": 8,
"audioGapCount": 0
},
"createdAt": "2026-07-29T09:14:02.331245Z",
"analysis": {
"sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"orgId": "6b0d4c11-9e57-4a02-8f3c-2d71b5e9a084",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"status": "done",
"summary": "Caller rescheduled a Tuesday cleaning to Thursday 4pm and confirmed the SMS reminder.",
"sentiment": "positive",
"sentimentScore": 0.62,
"disposition": "appointment_rescheduled",
"actionItems": ["Send confirmation SMS for Thu 4:00pm"],
"customData": { "patient_name": "Anita Rao", "new_slot": "2026-08-06T16:00:00+05:30" },
"evidence": { "new_slot": "Thursday at four in the afternoon works" },
"qa": [{ "key": "verified_identity", "met": true, "evidence": "Can I have your date of birth?" }],
"objections": ["Did not want a Monday slot"],
"score": 86,
"coaching": [{ "issue": "Did not offer the earlier cancellation slot", "suggestion": "Read the waitlist openings before proposing a new day", "severity": "low" }],
"hallucinations": [],
"cx": { "rating": 4, "friction": ["Repeated the phone number twice"], "highlights": ["Confirmed the new time back to the caller"] },
"topics": ["appointment_rescheduling"],
"keywords": ["cleaning", "Tuesday", "Thursday", "reminder"],
"agentWords": 143,
"customerWords": 61,
"agentTurns": 7,
"customerTurns": 6,
"model": "gpt-4.1-mini",
"error": null,
"createdAt": "2026-07-29T09:16:44.512907Z",
"updatedAt": "2026-07-29T09:16:44.512907Z"
},
"analysisEnabled": true,
"transcript": [
{ "role": "assistant", "text": "Thanks for calling Smile Dental, how can I help?", "at": "2026-07-29T09:14:04.102331Z", "agent_id": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35" },
{ "role": "user", "text": "I need to move my Tuesday cleaning.", "at": "2026-07-29T09:14:09.774210Z", "agent_id": null }
],
"participants": {
"3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35": "Clinic Reception"
}
}
}
★ CASING DIVERGENCE FROM THE APP-KEY TWIN: this route assigns the helper's output straight through (obj["transcript"] = Value::Array(transcript), apps.rs:2822), so transcript rows keep the helper's snake_case agent_id. The app-key route at routes/app_data.rs:669-679 renames it to agentId. Same call, same field, two spellings depending on which plane you read it from — branch on t.agentId ?? t.agent_id. Everything else (top-level camelCase row, always-present analysis/analysisEnabled, participants map) matches the app-key route.
<sub>Source: voice_ai_rust/src/routes/apps.rs:2824 (envelope) + apps.rs:2809-2823 (analysis, transcript, participants)</sub>
POST /api/app-calls/:sessionId/assist
Scope: calls:transcribe:live
{
"success": true,
"data": {
"armed": true,
"sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42"
}
}
armed is a constant true — it is never false; failure is an HTTP error, not armed:false. Idempotent: re-arming an already-armed live call returns the same body. Manual SOFTPHONE calls only. The transcripts it produces do not come back in this response — they arrive on the call's live stream as call.turn / call.transcript_partial frames with a speaker field (see the live-assist stream row).
On failure:
// 400 — not a softphone call (app_data.rs:767)
{ "success": false, "error": "live assist is only available on manual softphone calls" }
// 409 — not live right now (app_data.rs:779)
{ "success": false, "error": "call is not live" }
<sub>Source: voice_ai_rust/src/routes/app_data.rs:792-795 (app-key plane); byte-identical body from the dashboard proxy at voice_ai_rust/src/routes/apps.rs:3106</sub>
POST /api/app-calls/:sessionId/stream-ticket
Scope: calls:read (bound) | calls:read:org
{
"success": true,
"data": {
"ticket": "5b81a3c74f6e4d2ab0973e1c8f4d2266",
"wsUrl": "wss://app.telenow.ai/ws/live-call-stream?ticket=5b81a3c74f6e4d2ab0973e1c8f4d2266"
}
}
ticket is a 32-char uuid-simple (no dashes), single-use via Redis GETDEL, 30s TTL (app_data.rs:724-728). wsUrl already carries the ticket — connect to it directly; the scheme is derived from x-forwarded-proto (calls.rs:869-880) so it is ws:// only on plain-http local dev.
On failure:
// 409 — call is not live on this node (app_data.rs:721)
{ "success": false, "error": "call is not live" }
<sub>Source: voice_ai_rust/src/routes/app_data.rs:735-738 (identical shape from the dashboard twin at voice_ai_rust/src/routes/calls.rs:857)</sub>
WS /ws/live-call-stream — frame envelope + orchestrator topics
Scope: calls:read | calls:read:org (enforced when the ticket is minted, not by the socket)
// One JSON TEXT frame per event. Envelope is always {topic, sessionId, data}.
{"topic":"call.transcript_partial","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"corr":"t3:n=greet:g=1","text":"i need to move my tues"}}
{"topic":"call.turn","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"corr":"t3:n=greet:g=1","text":"I need to move my Tuesday cleaning."}}
{"topic":"call.node_entered","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"corr":"t4:n=reschedule:g=2","node_id":"reschedule","node_name":"Reschedule","node_kind":"conversation","node_generation":2,"llm_model":"gpt-4o-mini","tts_provider":"telenow","tts_voice":"aditi","entry_message":"Let me pull up your appointment.","duration_ms":31}}
{"topic":"call.barge_in","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"corr":"t4:n=reschedule:g=2","played_ms":820,"turn_audio_ms":3400}}
{"topic":"call.dtmf","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"corr":"t5:n=reschedule:g=2","digit":"2"}}
{"topic":"call.silence","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"corr":"t6:n=reschedule:g=2","text":"Are you still there?","llm_generated":false}}
★ THE ENVELOPE IS CAMELCASE BUT EVERY PAYLOAD IS SNAKE_CASE — data is the raw internal trace payload, verbatim. Read frame.data.node_id, frame.data.played_ms, frame.data.llm_generated. Exactly SEVEN topics can ever appear; there are no others (services/live_call_stream.rs:34-44). Topic names are the PUBLIC names, the internal trace types differ (stt.partial→call.transcript_partial, user.turn→call.turn, node.enter→call.node_entered, barge_in→call.barge_in, silence.checkin→call.silence, dtmf→call.dtmf). corr is a diagnostic string "t{turn_seq}:n={node_id}:g={node_generation}" (orchestration.rs:3298-3303) — parse it only if you must. call.node_entered NEVER carries system_prompt: it is removed on the socket path (services/live_call_stream.rs:63-67) even though the debug trace keeps it, and every payload is additionally run through strip_sensitive_in_place. Only single-context/flow VOICE calls emit node_entered. No heartbeat frame exists; the socket closes with code 1000 "call ended" once the call goes non-active (websocket/live_call_stream.rs:112-117).
On failure:
// Auth/liveness failures are WS CLOSE frames, never JSON — code 1008 with a reason
// (websocket/live_call_stream.rs:139-146):
// 1008 "missing ticket" | 1008 "invalid or expired ticket" | 1008 "call is not live"
// Normal end of call: 1000 "call ended".
<sub>Source: envelope: voice_ai_rust/src/websocket/live_call_stream.rs:84-88 · topic map: voice_ai_rust/src/services/live_call_stream.rs:34-44 · payloads: voice_ai_rust/src/services/orchestration.rs:11249 (stt.partial), :12004 (user.turn), :22932 (node.enter), :14529 (barge_in), :25673 (dtmf), :16435 (silence.checkin)</sub>
WS /ws/live-call-stream — live-assist (softphone) transcript frames
Scope: calls:transcribe:live to arm; calls:read | calls:read:org to subscribe
{"topic":"call.transcript_partial","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"text":"so the renewal quote is","speaker":"member"}}
{"topic":"call.turn","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"text":"So the renewal quote is eighteen thousand.","speaker":"member"}}
{"topic":"call.turn","sessionId":"9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42","data":{"text":"That's higher than last year.","speaker":"remote"}}
★ SAME TWO TOPICS, DIFFERENT PAYLOAD SHAPE. When the transcript comes from live-assist on a manual softphone call the payload is {text, speaker} — there is NO corr, and speaker ("member" = the human rep's leg, "remote" = the other party) does not exist on the orchestrator's version. Conversely the orchestrator's version has corr and no speaker. Do not assume either field is present: switch on "speaker" in frame.data. Empty/VAD-marker text is skipped, so you never receive a blank text (live_assist.rs:367-370).
<sub>Source: voice_ai_rust/src/services/live_assist.rs:372-382 (publish sites) · speaker values from live_assist.rs:83-88 · same envelope as above, websocket/live_call_stream.rs:84-88</sub>
Webhook call.analyzed (org webhook endpoints) — NOT the REST analysis shape
Scope: webhook endpoint subscribed to "call.analyzed" or "*"
{
"event": "call.analyzed",
"sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"analysis": {
"summary": "Caller rescheduled a dental cleaning to Tue 4 Aug 11:00.",
"sentiment": "positive",
"sentimentScore": 0.62,
"disposition": "appointment_rescheduled",
"actionItems": ["Send SMS confirmation for 2026-08-04 11:00"],
"customData": { "patient_id": "PT-4471" },
"evidence": { "sentiment": "that works perfectly, thank you" },
"qa": [{ "key": "verified_identity", "met": true, "evidence": "Can you confirm your date of birth?" }],
"objections": ["morning slots are hard for me"],
"score": 88,
"coaching": [{ "issue": "Never offered the SMS reminder opt-in", "suggestion": "Offer the reminder before closing", "severity": "low" }],
"hallucinations": [],
"cx": { "rating": "good", "friction": ["repeated the new date twice"], "highlights": ["reschedule took under 30 seconds"] },
"topics": ["appointment", "rescheduling"],
"keywords": ["dental cleaning", "Tuesday", "SMS confirmation"],
"talkRatio": { "agentWords": 142, "customerWords": 88, "agentTurns": 9, "customerTurns": 8 },
"model": "openai/gpt-4o-mini"
}
}
★ THIS IS NOT THE SAME OBJECT AS THE REST analysis. Two structural differences: (1) the four talk-ratio counters are NESTED under talkRatio here but FLAT (agentWords, customerWords, agentTurns, customerTurns) on the REST/DB shape; (2) the webhook omits sessionId/orgId/agentId from inside analysis (sessionId is hoisted to the top level), and omits status, error, createdAt, updatedAt entirely. model is the same "provider/model" string. Endpoints configured with include_recording / include_transcript get extra top-level recording / transcript keys grafted on per endpoint (voice_ai_rust/src/services/webhook.rs:297-306). A separate, much smaller projection {summary, sentiment, disposition, custom, topics, keywords} is what the app WORKFLOW runtime sees (post_call_analysis.rs:421-428) — note it is custom there, not customData.
<sub>Source: voice_ai_rust/src/services/post_call_analysis.rs:387-412 (payload) → enqueued at post_call_analysis.rs:413-414</sub>
telenow.calls.arm(sessionId, opts) / telenow.calls.open(sessionId) / telenow.stream.subscribe(...)
Scope: calls:transcribe:live (arm) · calls:read|calls:read:org (open, subscribe)
// telenow.calls.arm(sessionId, { language: 'en-IN', sttModel: 'nova-2' })
{ "armed": true, "sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42" }
// telenow.calls.open(sessionId) — NAVIGATES the dashboard away, unmounting your page
{ "ok": true }
// telenow.stream.subscribe(sessionId, onEvent) — the RPC resolves to an unsubscribe fn;
// internally the host replies with just:
{ "streamId": "a2c4e6f8-1b3d-4a5c-9e7f-0d2b4c6a8e10" }
// then pushes each socket frame to the iframe as:
{ "source": "telenow-host", "stream": "a2c4e6f8-…", "data": { "topic": "call.turn", "sessionId": "9f1c…", "data": { "corr": "t3:n=greet:g=1", "text": "I need to move my Tuesday cleaning." } } }
calls.open never touches the server — the bridge synthesises {ok:true} and calls navigate('/calls/:id'), which unmounts the app iframe. stream.subscribe resolves to an UNSUBSCRIBE FUNCTION, not to the streamId — the {streamId} object is internal plumbing (AppPageHost.tsx:330-338). ★ The frame handed to your onEvent callback is DOUBLE-NESTED: frame.data is the actual event payload because the host relays the raw socket JSON ({topic, sessionId, data}) as the data of its push message. So the caller's text is at frame.data.text where frame is what onEvent receives. The host holds the WebSocket and the ticket; the iframe never sees either.
<sub>Source: arm: voice_ai_rust/src/routes/apps.rs:3106 (bridge passes through, AppPageHost.tsx:948) · open: voice_ai_frontend/src/components/apps/AppPageHost.tsx:738 (hand-built, no server call) · subscribe: AppPageHost.tsx:986 (streamId) and AppPageHost.tsx:979-982 (push envelope)</sub>
telenow.calls.get(sessionId) — UI bridge
Scope: calls:read | calls:read:org, AND view-activity role
// resolves to the REST `data` object VERBATIM — camelCase, snake_case transcript
{
"id": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"agentName": "Clinic Reception",
"status": "ended",
"callMode": "agent",
"channel": "telephony",
"direction": "inbound",
"startTime": "2026-07-29T09:14:02.331245Z",
"endTime": "2026-07-29T09:16:11.884012Z",
"durationSec": 129,
"fromNumber": "+919845012345",
"toNumber": "+918031138113",
"answeredBy": "human",
"disposition": "answered",
"wrapupDisposition": null,
"hasRecording": true,
"latency": { "sttMsAvg": 312, "turnHoldMsAvg": 420, "llmMsAvg": 688, "ttsMsAvg": 241, "netRttMsAvg": null, "flowMsAvg": null, "respMsAvg": 1104, "respMsMax": 1980, "respSamples": 8, "audioGapCount": 0 },
"createdAt": "2026-07-29T09:14:02.331245Z",
"analysis": { /* full CallAnalysis, or null */ },
"analysisEnabled": true,
"transcript": [
{ "role": "assistant", "text": "Thanks for calling Smile Dental.", "at": "2026-07-29T09:14:04.102331Z", "agent_id": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35" }
],
"participants": { "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35": "Clinic Reception" }
}
★ ASYMMETRIC WITH ITS OWN SIBLING. calls.history() re-projects to snake_case; calls.get() does NOT re-project at all — only the {success,data} envelope is unwrapped. So inside a single app, the same call is agent_id from history() and agentId from get(). Then within THIS object the top level is camelCase but transcript[].agent_id is snake_case, because this route is the dashboard proxy that skips the rename (apps.rs:2822). Three conventions reachable from one app: history rows (snake), get row (camel), get transcript rows (snake).
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:794 (data = await getAppCall(...), no re-projection) → voice_ai_frontend/src/api/apps.ts:663 → voice_ai_rust/src/routes/apps.rs:2824</sub>
telenow.calls.history(filters, { includeAnalysis }) — UI bridge
Scope: calls:read | calls:read:org, AND the signed-in user must be able to view activity
[
{
"id": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"agent_id": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"agent_name": "Clinic Reception",
"channel": "telephony",
"direction": "inbound",
"from_number": "+919845012345",
"to_number": "+918031138113",
"status": "ended",
"duration_sec": 129,
"start_time": "2026-07-29T09:14:02.331245Z",
"end_time": "2026-07-29T09:16:11.884012Z",
"analysis": {
"sessionId": "9f1c2b8e-4a3d-4f26-9b71-0c5ad83e1f42",
"orgId": "c81b4f27-6e0a-4d33-8f52-2a7c9b1d6e04",
"agentId": "3d5a1f90-7c2e-4b18-a6d4-91e2f0c74b35",
"status": "done",
"summary": "Caller rescheduled a dermatology appointment from Thursday to Saturday morning and asked whether the clinic bills their insurer directly.",
"sentiment": "positive",
"sentimentScore": 0.62,
"disposition": "resolved",
"actionItems": [
"Send the Saturday 11:00 confirmation SMS",
"Email the insurance pre-authorisation form"
],
"customData": { "department": "dermatology", "reschedule": true },
"evidence": {
"disposition": "great, Saturday at eleven works for me",
"department": "I need to move my skin appointment"
},
"qa": [
{ "key": "verified_identity", "met": true, "evidence": "can I confirm your date of birth?" },
{ "key": "offered_callback", "met": false, "evidence": null }
],
"objections": ["Thursday slot was too early"],
"score": 84,
"coaching": [
{ "issue": "Did not offer a callback number", "suggestion": "Close by confirming the best number to reach them on.", "severity": "low" }
],
"hallucinations": [],
"cx": { "rating": 4, "friction": ["Two attempts to hear the date"], "highlights": ["Reschedule handled in one turn"] },
"topics": ["appointment_reschedule", "insurance"],
"keywords": ["dermatology", "Saturday", "pre-authorisation"],
"agentWords": 142,
"customerWords": 96,
"agentTurns": 9,
"customerTurns": 8,
"model": "gpt-4o-mini",
"error": null,
"createdAt": "2026-07-29T09:16:44.010982Z",
"updatedAt": "2026-07-29T09:16:51.447203Z"
},
"analysis_enabled": true
},
{
"id": "b7e0d413-52c8-4a90-9f6b-1d84c7a2e550",
"agent_id": "5c9e7a02-13bd-4c77-8e41-6fa03b9d2c18",
"agent_name": null,
"channel": "web_call",
"direction": "web",
"from_number": null,
"to_number": null,
"status": "active",
"duration_sec": null,
"start_time": "2026-07-29T09:22:18.664301Z",
"end_time": null,
"analysis": null,
"analysis_enabled": false
}
]
★ THE BRIDGE RE-PROJECTS. Three separate divergences from the REST shape it wraps: (1) the {success,data} envelope AND the calls wrapper are both unwrapped (voice_ai_frontend/src/api/apps.ts:657 does .then(d => d.calls)), so you get a bare array; (2) keys are rewritten to SNAKE_CASE; (3) it is a SUBSET — only the 11 keys above survive. callMode, answeredBy, disposition, wrapupDisposition, hasRecording, latency and createdAt are DROPPED and unreachable through this method. analysis / analysis_enabled appear only when { includeAnalysis: true } is passed. ★ The analysis VALUE is spliced in untouched, so its inner keys stay CAMELCASE (actionItems, agentWords) while its wrapper key analysis_enabled is snake_case — one object, both conventions. Filters are validated client-side: anything outside [agentId, callMode, status, from, to, sort, limit, offset] throws rather than being silently ignored (AppPageHost.tsx:755-761). No pagination metadata is available at all.
On failure:
// the bridge rejects the Promise with an Error; the raw postMessage is
{ "source": "telenow-host", "id": "r7", "ok": false, "error": "calls.history: unsupported filter(s) [phone] — this app plane supports agentId, callMode, status, from, to, sort, limit, offset" }
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:774-789 (hand-built re-projection) — the underlying REST call is voice_ai_rust/src/routes/apps.rs:2748</sub>
Error envelopes across the call surfaces
// Every REST route on both planes (app-key and dashboard proxy):
{ "success": false, "error": "call not found" } // 404
{ "success": false, "error": "requires the calls:read or calls:read:org scope" } // 403
{ "success": false, "error": "app is not bound to this call's agent" } // 403
{ "success": false, "error": "call is not live" } // 409
{ "success": false, "error": "live assist is only available on manual softphone calls" } // 400
{ "success": false, "error": "Something went wrong" } // 500, message is always this literal
// Rate limited (extra field):
{ "success": false, "error": "Too many requests, please try again later", "retryAfter": 30 } // 429
// Expired/invalid JWT (extra machine-readable code):
{ "success": false, "error": "Access token expired", "code": "token_expired" } // 401
// UI BRIDGE — a different shape entirely, over postMessage:
{ "source": "telenow-host", "id": "r7", "ok": false, "error": "this app didn't request the \"calls:read\" capability" }
★ TWO DIFFERENT ERROR CONTRACTS. REST is {success: false, error: "<message>"} — note the flag is success, not ok, and there is no data key at all on failure. The UI bridge is {ok: false, error} and the app never sees it directly: the injected rpc() shim rejects the Promise with new Error(d.error) (AppPageHost.tsx:191), so in app code you only get an Error whose .message is that string. A code field appears ONLY on 401 (token_expired | token_invalid), on ForbiddenCode 403s, and on 502 provider_error — the call/analysis routes above never set one, so do not branch on code for them; match on HTTP status. 500 bodies are always the literal "Something went wrong" with the real cause only in server logs (error.rs:145-148). The live-call WebSocket does not use either envelope — it signals failure with a 1008 close frame carrying a plain-text reason.
<sub>Source: voice_ai_rust/src/error.rs:151 (generic {success:false,error}) · error.rs:112-124 (429 + retryAfter) · error.rs:88-92 (401 + code) · error.rs:97-103 (403 + code) · voice_ai_frontend/src/components/apps/AppPageHost.tsx:1042 (bridge {ok:false,error})</sub>
Agents & agent config
Creating agents, and the grouped configuration document an improver app reads and patches.
DELETE /api/app-agents/:id
Scope: agents:write
{ "success": true }
NO data key at all — the one route on this surface that breaks the {success, data} convention. Destructuring const { data } = res yields undefined, not {}.
On failure:
403 when the agent exists but this app did not create it (app_agents.rs:200-202):
{ "success": false, "error": "agent was not created by this app" }
404 for missing/cross-org: { "success": false, "error": "agent not found" }
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_agents.rs:204</sub>
GET /api/app-agents (list agents THIS app created)
Scope: agents:read
{
"success": true,
"data": {
"agents": [
{ "id": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77", "name": "Front Desk", "kind": "single" },
{ "id": "b41d9a02-5e67-4c8a-8f13-9d0e6a2c5b90", "name": "Collections", "kind": "flow" }
]
}
}
DOUBLE-NESTED: data.agents, not data. Key is id (create returns agentId for the same value). Only agents stamped metadata.createdByApp == <this app> are returned (created_by, app_agents.rs:92-99) — an agent the org built and merely CONNECTED to the app does NOT appear here even though /config, /public and /eval all work on it. Hard-capped at 500 rows with no paging and no total (list_for_org(org, 500, 0, None) at :174-176).
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_agents.rs:177-182</sub>
GET /api/app-agents/:id/config (the full agent-config document)
Scope: agents:config:read
{
"success": true,
"data": {
"agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77",
"name": "Front Desk",
"description": "Books and reschedules appointments",
"kind": "flow",
"updatedAt": "2026-07-29T09:14:22.481930Z",
"prompt": { "systemPrompt": "You are the front desk for Acme Dental. Be brief." },
"model": {
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.7,
"maxTokens": 300,
"config": { "provider": "openai", "model": "gpt-4o-mini", "temperature": 0.7, "maxTokens": 300 }
},
"voice": {
"provider": "elevenlabs",
"voice": "21m00Tcm4TlvDq8ikWAM",
"config": { "provider": "elevenlabs", "voice": "21m00Tcm4TlvDq8ikWAM", "speed": 1.0 }
},
"stt": {
"provider": "deepgram",
"config": { "provider": "deepgram", "language": "en-IN", "punctuation": true }
},
"behavior": {
"memoryType": "ephemeral",
"maxDuration": 600,
"contextMode": "window",
"silenceHangupSecs": 30,
"bargeInSensitivity": 0.5,
"debugTrace": true
},
"telephony": {
"provider": "twilio",
"config": {
"phoneNumber": "+918031138113",
"firstResponse": "agent",
"agentMsg": "Thanks for calling Acme Dental.",
"recording": true
}
},
"flow": {
"schema": 1,
"startNodeId": "greet",
"nodes": [
{
"id": "greet",
"kind": "conversation",
"name": "Greeting",
"position": { "x": 0, "y": 0 },
"prompt": "Greet the caller and find out what they need.",
"promptMode": "append",
"toolCount": 0,
"precallLookupCount": 0
},
{
"id": "book",
"kind": "tool",
"name": "Book slot",
"config": {},
"toolCount": 2,
"precallLookupCount": 1
}
],
"edges": [
{ "id": "e1", "source": "greet", "target": "book", "condition": { "kind": "ai", "describe": "caller wants to book" } }
]
},
"analysis": { "enabled": false, "customFields": [], "qaCriteria": [] },
"tools": [
{ "name": "book_appointment", "description": "Book a slot in the clinic calendar", "kind": "http" }
],
"realtimeEngine": null
}
}
camelCase throughout. Every one of the 12 top-level keys is ALWAYS present — none are omitted.
★ flow is null for a single-context agent (agent_config.rs:282-292); everything else is identical, so prompt.systemPrompt works without branching on kind.
★ voice.config / stt.config / telephony.config are null (NOT {}) when the underlying column is NULL — public_blob returns Value::Null for None (:192-196). behavior is the session_config object itself (never null, may be {}), and it is FLAT — there is no behavior.config wrapper, unlike every other provider group.
★ model.temperature / model.maxTokens are ALWAYS-present keys read out of llm_config; they are null when the blob lacks them (llm.get(...) → None → null, :304-305). They are also DUPLICATED inside model.config, which is the raw llm_config blob (so config.provider/config.model repeat the flat fields too).
★ Secrets are removed by KEY NAME from every blob (strip_sensitive_in_place, utils/secrets.rs:118-153,213-228): apiKey, token, authToken, accountSid, clientSecret, authorization, x-api-key, password, privateKey, accessKeyId… So a Twilio telephony.config shows phoneNumber/firstResponse but NEVER accountSid/authToken, and a node override never shows model.extra.apiKey.
★ Flow nodes are RE-PROJECTED (public_node, :223-249): tools and precallLookups arrays are DELETED and replaced by integer toolCount / precallLookupCount; the whole node is then secret-stripped, so a transfer node keeps config but with credentials removed. Everything else on the node passes through as stored.
★ tools is agent-level and reduced to {name, description, kind} only (public_tools, :254-271); kind falls back to "custom" when absent, name/description fall back to "" (never null).
★ analysis.enabled is the NEWER group — it defaults to FALSE, and its own doc comment (:323-328) says this is why an app reading calls with includeAnalysis=true sees analysis: null on every row. customFields/qaCriteria default to [] when metadata.postCallAnalysis is absent (:331-338). Truthiness matches the claim query exactly, including legacy strings: "true"/"yes" → true, a JSON NUMBER (1) → FALSE (pca_enabled, :350-354 + tests :1033-1049).
★ realtimeEngine is a bare provider STRING (or null) pulled from s2s_config.provider (:342-343) — not the s2s blob.
★ updatedAt is RFC-3339 with sub-second precision; echo it as ifUnmodifiedSince on the PATCH.
★ FE TYPE DRIFT: the TypeScript AppAgentConfig at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/api/apps.ts:583-599 does NOT declare analysis, and AppAgentConfigPatch (:604-620) omits it from the Pick — so the runtime serves a group the types deny. The bridge derives groups from Object.keys() at runtime, so { analysis: {...} } works; only the type is wrong.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/services/agent_config.rs:279-345 (read_config); returned at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_agents.rs:361</sub>
GET /api/app-agents/:id/public and PUT /api/app-agents/:id/public
Scope: GET agents:read · PUT agents:write (plus the app INSTALLER must be an org owner/admin)
{
"success": true,
"data": {
"isPublic": false,
"slug": "a3f19c0d4b72",
"publicUrl": "https://app.telenow.ai/p/a3f19c0d4b72",
"embedSnippet": "<script src=\"https://api.telenow.ai/widget.js\" data-slug=\"a3f19c0d4b72\"></script>"
}
}
GET and PUT return the IDENTICAL body — PUT is not a no-content route. slug is 12 lowercase hex chars (gen_slug, services/publish.rs:355-357) and is materialised LAZILY by get_or_create, so the URL is present and stable even while isPublic is false (the widget just 404s). publicUrl uses FRONTEND_BASE_URL, embedSnippet uses PUBLIC_BASE_URL — two different hosts, trailing slash trimmed. PUT body is {"enabled": true} (SetPublicBody, :447-453). Only is_public changes; branding/access code/lead capture are read back and preserved (set_agent_public, :464-487).
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_agents.rs:284-296 (public_link_json), returned at :444 (GET) and :505 (PUT)</sub>
GET /api/orgs/:orgId/apps/:appId/agent-teams
Scope: — (org member; catalog fallback when not installed)
{
"success": true,
"data": {
"teams": [
{
"id": "front-desk-team",
"name": "Front Desk Team",
"description": "Reception plus billing",
"memberCount": 2,
"members": [
{ "ref": "receptionist", "name": "Receptionist", "kind": "flow" },
{ "ref": "billing", "name": "billing", "kind": "single" }
]
}
]
}
}
Nested at data.teams. Members here use ref + name + kind — there is no agentId yet (nothing has been built). Both name fields fall back to their id/ref (:897, :902). memberCount is just members.len(), so it is always redundant with the array.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:892-908</sub>
GET /api/orgs/:orgId/apps/:appId/agent-templates (what an app ships)
Scope: — (org member; falls back to the CATALOG manifest when not installed)
{
"success": true,
"data": {
"templates": [
{ "id": "front-desk", "name": "Front Desk", "description": "Answers and books", "kind": "flow" },
{ "id": "after-hours", "name": "after-hours", "description": null, "kind": "single" }
]
}
}
Nested at data.templates. name falls back to the template id when the manifest omits it (:530) — it is never null. description IS nullable. id is a manifest slug; feed it straight back as :templateId.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:525-536</sub>
GET /api/orgs/:orgId/apps/:appId/agents/:agentId/config · PATCH same path (dashboard-proxy twins)
Scope: GET agents:config:read + org member · PATCH agents:config:write:<group> + user role owner/admin/developer
This block shows several variants side by side. Each top-level key labels a case (status code, method, or provider) — the keys themselves are not part of any response.
{
"GET /api/orgs/{orgId}/apps/{appId}/agents/{agentId}/config": {
"success": true,
"data": {
"agentId": "9f1c2a84-7b3e-4d51-a0c6-2e58d43f1b90",
"name": "Clinic Front Desk",
"description": "Books and reschedules appointments",
"kind": "single",
"updatedAt": "2026-07-29T09:14:52.183042Z",
"prompt": { "systemPrompt": "You are Asha, the front desk assistant for Sunrise Clinic." },
"model": {
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.4,
"maxTokens": 400,
"config": { "temperature": 0.4, "maxTokens": 400 }
},
"voice": { "provider": "telenow", "voice": "asha", "config": { "language": "hi-IN", "speed": 1.0 } },
"stt": { "provider": "deepgram", "config": { "model": "nova-2", "language": "en-IN" } },
"behavior": {
"memoryType": "short-term",
"maxDuration": 600,
"contextMode": "window",
"contextWindow": 10,
"silenceHangupSecs": 30
},
"telephony": {
"provider": "plivo",
"config": {
"phoneNumber": "+918031138113",
"firstResponse": "agent",
"agentMsg": "Hi, this is Asha from Sunrise Clinic.",
"recording": true
}
},
"flow": null,
"analysis": { "enabled": false, "customFields": [], "qaCriteria": [] },
"tools": [
{ "name": "book_appointment", "description": "Book a slot in the clinic calendar", "kind": "custom" }
],
"realtimeEngine": null
}
},
"PATCH /api/orgs/{orgId}/apps/{appId}/agents/{agentId}/config": {
"success": true,
"data": {
"updated": ["prompt", "model"],
"config": {
"agentId": "9f1c2a84-7b3e-4d51-a0c6-2e58d43f1b90",
"name": "Clinic Front Desk",
"description": "Books and reschedules appointments",
"kind": "single",
"updatedAt": "2026-07-29T09:21:07.664118Z",
"prompt": { "systemPrompt": "You are Asha. Confirm the patient's name before booking." },
"model": {
"provider": "openai",
"model": "gpt-4o",
"temperature": 0.2,
"maxTokens": 400,
"config": { "temperature": 0.2, "maxTokens": 400 }
},
"voice": { "provider": "telenow", "voice": "asha", "config": { "language": "hi-IN", "speed": 1.0 } },
"stt": { "provider": "deepgram", "config": { "model": "nova-2", "language": "en-IN" } },
"behavior": {
"memoryType": "short-term",
"maxDuration": 600,
"contextMode": "window",
"contextWindow": 10,
"silenceHangupSecs": 30
},
"telephony": {
"provider": "plivo",
"config": {
"phoneNumber": "+918031138113",
"firstResponse": "agent",
"agentMsg": "Hi, this is Asha from Sunrise Clinic.",
"recording": true
}
},
"flow": null,
"analysis": { "enabled": false, "customFields": [], "qaCriteria": [] },
"tools": [
{ "name": "book_appointment", "description": "Book a slot in the clinic calendar", "kind": "custom" }
],
"realtimeEngine": null
}
}
},
"flow agent - same document, data.flow is non-null (nodes re-projected by public_node)": {
"schema": 1,
"startNodeId": "greet",
"nodes": [
{
"id": "greet",
"kind": "conversation",
"name": "Greeting",
"position": { "x": 120, "y": 80 },
"prompt": "Greet the caller and ask what they need.",
"promptMode": "append",
"toolCount": 2,
"precallLookupCount": 0
}
],
"edges": [
{
"id": "e1",
"source": "greet",
"target": "book",
"label": "wants appointment",
"priority": 0,
"condition": { "kind": "ai", "describe": "caller wants to book an appointment" }
}
]
},
"UI bridge (telenow.agents.getConfig / updateConfig) - envelope STRIPPED": {
"getConfig resolves to": "the data object above, verbatim - no { success } wrapper",
"updateConfig resolves to": { "updated": ["prompt"], "config": "same object as data.config above" },
"on failure": "the promise REJECTS with Error(message); the host->iframe frame is { ok: false, error }"
},
"error (HTTP 409 - ifUnmodifiedSince mismatch)": {
"success": false,
"error": "agent changed since you read it (now 2026-07-29T09:21:07.664118+00:00) - re-read the config and re-apply"
}
}
Same shape, DIFFERENT gates. The proxy adds a USER-role check on top of the install scope (require_member for GET at apps.rs:2839; require_role owner|admin|developer for PATCH at :2866-2869), and it checks liveness via app_access so a disabled app cannot keep writing (:2887-2892). Route registered at apps.rs:118-121.
On failure:
403 with the upgrade hint the app-key plane does NOT emit (apps.rs:2896-2902):
{ "success": false, "error": "this install has not granted the agents:config:write:prompt scope. If the app's current manifest declares it, the org is still on an older installed version — upgrade the app in Settings → Apps to grant the new scopes" }
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:2846-2849 (GET) and :2921-2927 (PATCH); both call services/agent_config.rs read_config/apply_patch</sub>
GET /api/orgs/:orgId/apps/:appId/agents/:agentId/public · PUT same path (dashboard-proxy twins)
Scope: GET agents:read + org member · PUT agents:write + user role owner/admin
{
"success": true,
"data": {
"isPublic": true,
"slug": "a3f19c0d4b72",
"publicUrl": "https://app.telenow.ai/p/a3f19c0d4b72",
"embedSnippet": "<script src=\"https://api.telenow.ai/widget.js\" data-slug=\"a3f19c0d4b72\"></script>"
}
}
Deliberately ONE builder shared by both planes (the doc comment at app_agents.rs:281-283 says the app-key plane and the UI-bridge plane must not describe the same link two different ways). PUT here checks the SIGNED-IN USER is owner/admin (apps.rs:2622-2625); the app-key PUT instead checks the INSTALLER's role inside PublishService::save.
On failure:
403 (not connected — apps.rs:2570-2572): { "success": false, "error": "app is not connected to this agent" } — note the wording differs from the app-key plane's "app is not bound to this agent" (app_agents.rs:321-322) for the identical condition.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:2598-2601 (GET) and :2631-2634 (PUT) — both call routes/app_agents.rs:284-296 public_link_json</sub>
PATCH /api/app-agents/:id/config (write settings back)
Scope: agents:config:write:<group> for EVERY group the body touches (or the superset agents:config:write)
{
"success": true,
"data": {
"updated": ["prompt", "voice"],
"config": {
"agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77",
"name": "Front Desk",
"description": "Answers inbound clinic calls and books appointments",
"kind": "flow",
"updatedAt": "2026-07-29T09:31:07.220114Z",
"prompt": { "systemPrompt": "You are Riya at Sunrise Clinic. Be brief and warm." },
"model": {
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.6,
"maxTokens": 512,
"config": { "temperature": 0.6, "maxTokens": 512 }
},
"voice": { "provider": "telenow", "voice": "aarohi", "config": { "speed": 1.0, "language": "en-IN" } },
"stt": { "provider": "deepgram", "config": { "model": "nova-3", "language": "en-IN" } },
"behavior": {
"opener": "Thanks for calling Sunrise Clinic, how can I help?",
"bargeIn": true,
"bargeInSensitivity": 0.5,
"silenceSecs": 8,
"debugTrace": true
},
"telephony": { "provider": "twilio", "config": { "phoneNumber": "+919876543210", "recording": true } },
"flow": {
"startNodeId": "greeting",
"nodes": [
{
"id": "greeting",
"kind": "conversation",
"name": "Greeting",
"prompt": "Greet the caller and find out what they need.",
"position": { "x": 120, "y": 80 },
"toolCount": 0,
"precallLookupCount": 0
},
{
"id": "book",
"kind": "conversation",
"name": "Book appointment",
"prompt": "Collect the preferred slot and confirm it back.",
"position": { "x": 420, "y": 80 },
"knowledgeBaseIds": ["1f4b7a02-6c19-4e83-9a55-cc0f2d7e1b34"],
"toolCount": 2,
"precallLookupCount": 1
}
],
"edges": [
{
"id": "e1",
"source": "greeting",
"target": "book",
"label": "wants an appointment",
"priority": 0,
"condition": { "kind": "ai", "describe": "the caller wants to book an appointment" }
}
]
},
"analysis": { "enabled": false, "customFields": [], "qaCriteria": [] },
"tools": [
{ "name": "book_slot", "description": "Book a clinic appointment slot", "kind": "custom" },
{ "name": "check_availability", "description": "List free slots for a date", "kind": "custom" }
],
"realtimeEngine": null
}
}
}
data.config is the FULL read_config document again (freshly re-read from the updated row) — identical shape to the GET, including a NEW updatedAt you must capture for the next conditional write.
★ updated lists the GROUPS the patch touched, in GROUPS declaration order (agent_config.rs:57-78: prompt, model, voice, stt, behavior, flow, telephony, analysis) — NOT the field paths you sent, and NOT necessarily the keys of your body. A patch of {"flow":{"nodes":[{"id":"greet","prompt":"…"}]}} returns updated: ["prompt"], because the group is decided by the FIELD, not by where it sits (node_field_group, :370-382).
★ ifUnmodifiedSince is stripped from the body before validation (app_agents.rs:389-391) — it is a precondition, not a group, and never appears in updated.
All groups are authorized BEFORE anything is applied, so a partially-permitted patch fails whole and changes nothing.
On failure:
409 (stale read — agent_config.rs:112-116):
{ "success": false, "error": "agent changed since you read it (now 2026-07-29T09:31:07.220114+00:00) — re-read the config and re-apply" }
400 (bad precondition FORMAT, deliberately distinct from the conflict — :106-110):
{ "success": false, "error": "`ifUnmodifiedSince` must be an RFC-3339 timestamp (echo the `updatedAt` you read); got `last tuesday`" }
403 (missing group scope, names the exact scope — app_agents.rs:337-345):
{ "success": false, "error": "app did not declare the agents:config:write:voice scope" }
403 (never-writable — agent_config.rs:400-404): { "success": false, "error": "`tools` cannot be changed by an app — an app may rewrite what an agent says, never what it can do" }
400 (typo, names the offender — :409-414): { "success": false, "error": "unknown config group `prompts` — expected one of: prompt, model, voice, stt, behavior, flow, telephony, analysis" }
400 (no-op patch — :532-536): { "success": false, "error": "this patch changes nothing — send at least one setting inside a group" }
400 (graph regression — :713-719): { "success": false, "error": "this change would make the flow graph invalid: … It would be discarded at call time and the agent would silently run single-context." }
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_agents.rs:422-428; group derivation at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/services/agent_config.rs:391-538</sub>
POST /api/app-agents (create an agent from a spec)
Scope: agents:write
{
"success": true,
"data": {
"agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77",
"kind": "single"
}
}
camelCase. Exactly two fields — nothing else about the created agent comes back (no name, no slug). kind is "flow" only when the stored metadata.flow has >1 node OR any edge, else "single" (agent_kind, app_agents.rs:80-89). NOTE the key is agentId here but id in the LIST response below — the same object is named two different ways across two routes of the same file.
On failure:
413 when the per-app cap is hit (MAX_AGENTS_PER_APP = 100, app_agents.rs:57,119-123):
{ "success": false, "error": "this app has reached its agent limit (100) for this org" }
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_agents.rs:160-163</sub>
POST /api/app-agents/:id/eval (run simulated-call scenarios)
Scope: agents:read (plus the app must be BOUND to the agent)
{
"success": true,
"data": {
"results": [
{
"name": "reschedule request",
"passed": true,
"score": 88,
"reasoning": "Agent confirmed the existing slot and offered two alternatives.",
"turns": 4,
"errored": false
},
{
"name": "scenario 2",
"passed": false,
"reasoning": "simulation failed: no LLM provider configured",
"turns": 0,
"errored": true
}
],
"passed": 1,
"total": 2,
"allPassed": false
}
}
★ data.passed is a COUNT (number) while results[i].passed is a BOOLEAN — same key name, two types, one response. Gate CI on allPassed, not on passed.
score is skip_serializing_if = "Option::is_none" (agent_eval.rs:47) — the key is ABSENT, not null, when the judge did not score (no expect rubric, or the run errored). name is never null: it defaults to "scenario N" (agent_eval.rs:85-89). Max 3 scenarios per request (MAX_EVAL_SCENARIOS, app_agents.rs:72), 200 s budget (:76).
On failure:
400 on timeout: { "success": false, "error": "eval timed out — try fewer scenarios or turns" }
400 when sim is off: { "success": false, "error": "agent simulation is not configured on this platform" }
403: { "success": false, "error": "app is not bound to this agent" }
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/app_agents.rs:268-274; per-result struct at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/services/agent_eval.rs:42-53</sub>
POST /api/orgs/:orgId/apps/:appId/agent-teams/:teamId/create (createTeamFromTemplate)
Scope: — (NO app scope checked; user role owner/admin only)
{
"success": true,
"data": {
"teamId": "front-desk-team",
"agents": [
{ "ref": "receptionist", "agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77", "kind": "flow" },
{ "ref": "billing", "agentId": "b41d9a02-5e67-4c8a-8f13-9d0e6a2c5b90", "kind": "single" }
],
"entryAgentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77"
}
}
teamId echoes the MANIFEST team id (a slug string), NOT a uuid — unlike agentId/entryAgentId, which are real uuids. ref is the team-local member handle from the manifest.
★ agents[i].agentId and entryAgentId are Option<&Uuid> serialized directly (:1011, :1021) — they render as null if the ref→id map has no entry, so both are nullable in the type (and the FE types them string | null, api/apps.ts:522-527). The bridge relies on this: it falls back to team.agents[0] when the entry lookup misses (AppPageHost.tsx:715).
Not transactional — a mid-way failure best-effort deletes the partial team and returns the error instead (:979-984).
On failure:
413: { "success": false, "error": "creating this team would exceed the app's agent limit (100) for this org" }
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:1005-1023</sub>
POST /api/orgs/:orgId/apps/:appId/agent-templates/:templateId/create (createFromTemplate)
Scope: — (NO app scope checked; user role owner/admin only)
{
"success": true,
"data": {
"agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77",
"kind": "flow"
}
}
Same two-key shape as POST /api/app-agents. kind comes from the MANIFEST TEMPLATE spec (tpl.kind() → spec_kind on spec.metadata.flow, services/app_manifest.rs:248-262), not from the created row — so it is computed before creation, from the same >1-node-or-any-edge test.
★ Gated ONLY by require_role(owner, admin) at apps.rs:790-793 — there is no ensure_app_scope call. The agents:read requirement developers see is a CLIENT-SIDE pre-check in the bridge (AppPageHost.tsx:654), not a server gate.
Side effects not visible in the response: the app is auto-bound (:824-826) and its KBs attached (:828).
On failure:
413: { "success": false, "error": "this app has reached its agent limit (100) for this org" }
404: { "success": false, "error": "agent template not found" } / { "success": false, "error": "app is not installed" }
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/routes/apps.rs:830-833</sub>
telenow.agents.getConfig(agentId) / updateConfig(agentId, patch)
Scope: getConfig: agents:config:read + canViewActivity · updateConfig: agents:config:write:<group> per key + canCommunicate
{
"getConfig": {
"agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77",
"name": "Sunrise Dental Front Desk",
"description": "Books and reschedules patient appointments",
"kind": "single",
"updatedAt": "2026-07-29T09:14:22.481930Z",
"prompt": { "systemPrompt": "You are Asha, the front-desk assistant at Sunrise Dental. Confirm the patient's name and preferred slot before booking." },
"model": { "provider": "openai", "model": "gpt-4o-mini", "temperature": 0.6, "maxTokens": 400, "config": { "temperature": 0.6, "maxTokens": 400 } },
"voice": { "provider": "telenow", "voice": "asha", "config": { "speed": 1.0, "language": "en-IN" } },
"stt": { "provider": "deepgram", "config": { "model": "nova-2", "language": "en-IN" } },
"behavior": { "bargeIn": true, "silenceSecs": 8, "debugTrace": false },
"telephony": { "provider": "twilio", "config": { "agentMsg": "Sunrise Dental, this is Asha — how can I help?", "agentMsgOutbound": "Hi, this is Asha calling from Sunrise Dental." } },
"flow": null,
"analysis": { "enabled": false, "customFields": [], "qaCriteria": [] },
"tools": [ { "name": "book_appointment", "description": "Book a slot in the clinic calendar", "kind": "custom" } ],
"realtimeEngine": null
},
"updateConfig": {
"updated": ["prompt"],
"config": {
"agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77",
"name": "Sunrise Dental Front Desk",
"description": "Books and reschedules patient appointments",
"kind": "single",
"updatedAt": "2026-07-29T09:41:07.902114Z",
"prompt": { "systemPrompt": "You are Asha at Sunrise Dental. Always offer the earliest two open slots before asking for a preference." },
"model": { "provider": "openai", "model": "gpt-4o-mini", "temperature": 0.6, "maxTokens": 400, "config": { "temperature": 0.6, "maxTokens": 400 } },
"voice": { "provider": "telenow", "voice": "asha", "config": { "speed": 1.0, "language": "en-IN" } },
"stt": { "provider": "deepgram", "config": { "model": "nova-2", "language": "en-IN" } },
"behavior": { "bargeIn": true, "silenceSecs": 8, "debugTrace": false },
"telephony": { "provider": "twilio", "config": { "agentMsg": "Sunrise Dental, this is Asha — how can I help?", "agentMsgOutbound": "Hi, this is Asha calling from Sunrise Dental." } },
"flow": null,
"analysis": { "enabled": false, "customFields": [], "qaCriteria": [] },
"tools": [ { "name": "book_appointment", "description": "Book a slot in the clinic calendar", "kind": "custom" } ],
"realtimeEngine": null
}
}
}
Same PAYLOAD as the REST twin, but the app receives data DIRECTLY — the SDK promise resolves to d.data, so there is no success and no outer data to destructure (AppPageHost.tsx:191: if(d.ok) p.resolve(d.data)).
★ The bridge pre-checks scopes by taking Object.keys(patch) minus ifUnmodifiedSince (:696-701) — so ifUnmodifiedSince still travels inside the patch object, exactly as the REST body expects.
★ The bridge does NOT validate group NAMES (comment at :693-695: unknown groups are left to the server so one rule can't drift), so a typo surfaces as a server 400, not a local throw.
★ analysis works at runtime here (groups are derived generically) even though the TS AppAgentConfigPatch at api/apps.ts:604-620 omits it — cast if TypeScript complains.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:683-706; unwrapping at api/apps.ts:671-686 and the promise resolve at AppPageHost.tsx:191</sub>
telenow.agents.list() — BRIDGE DIFFERS from GET /api/app-agents
Scope: agents:read (bridge-side check) + role canViewActivity
[
{ "id": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77", "name": "Front Desk" },
{ "id": "b41d9a02-5e67-4c8a-8f13-9d0e6a2c5b90", "name": "Collections" }
]
★ TWO differences from the REST twin, both easy to get burned by.
(1) SHAPE: a BARE ARRAY, not {agents:[…]}, and each row is hand-rebuilt to {id, name} ONLY — kind is dropped, so a page cannot tell a flow agent from a single one and must call getConfig to find out.
(2) SCOPE OF DATA: this does NOT hit the app-agent plane. It calls the dashboard's org-wide GET /api/agents via listAgents(200, 0) (api/agents.ts:110-116, server at routes/agents.rs:666-680) and therefore returns EVERY agent in the org — including agents this app never created and is not bound to. The app-key GET /api/app-agents returns only the app's own creations. Capped at 200 with no paging.
The underlying dashboard rows are snake_case (llm_provider, system_prompt, created_at — db/agents.rs:6-56 has no serde rename_all), but the re-projection means an app never sees that.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:647-650</sub>
telenow.agents.publicLink(agentId) / setPublic(agentId, enabled) / createFromTemplate(templateId, opts) / createTeamFromTemplate(teamId)
Scope: publicLink agents:read+canViewActivity · setPublic agents:write+owner/admin · createFromTemplate & createTeamFromTemplate agents:read (bridge-only pre-check)+canCommunicate
// publicLink / setPublic →
{ "isPublic": true, "slug": "a3f19c0d4b72", "publicUrl": "https://app.telenow.ai/p/a3f19c0d4b72", "embedSnippet": "<script src=\"https://api.telenow.ai/widget.js\" data-slug=\"a3f19c0d4b72\"></script>" }
// createFromTemplate →
{ "agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77", "kind": "flow" }
// createTeamFromTemplate →
{ "teamId": "front-desk-team", "agents": [ { "ref": "receptionist", "agentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77", "kind": "flow" } ], "entryAgentId": "7c1f0b8e-3a24-4d51-9f6e-2b8c0d1a4e77" }
These four are pass-through: same keys as the REST responses, envelope unwrapped.
★ createFromTemplate and createTeamFromTemplate NAVIGATE by default — createFromTemplate jumps to /agents/:id/flow or /agents/:id/edit depending on kind (AppPageHost.tsx:663-667), which UNMOUNTS the app page and destroys the iframe, so any code after the await may never run. Pass { open: false } to stay mounted (:231-235). createTeamFromTemplate ALWAYS navigates to the entry agent (:716-720) — there is no opt-out.
★ createFromTemplate gives you an agentId, but a public agent_call link resolves by SLUG — call publicLink(agentId) afterwards to get something you can actually point at.
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:657-658 (create), :674 (publicLink), :682 (setPublic), :713-714 (team) — all pass the server body through untouched after unwrap (api/apps.ts:522-570)</sub>
Error envelopes — REST/proxy vs bridge
// REST + dashboard proxy (HTTP status carries the class):
{ "success": false, "error": "app is not bound to this agent" }
// 429 on the app-key plane adds one field:
{ "success": false, "error": "Data API rate limit reached — slow down", "retryAfter": 1 }
// UI bridge postMessage frame (never seen by app code directly):
{ "source": "telenow-host", "id": "r3", "ok": false, "error": "this app didn't request the \"agents:config:read\" capability" }
★ TWO DIFFERENT ERROR SHAPES for the same underlying failure.
REST/proxy: {success:false, error:"…"} — error is always a plain STRING, never an object or a code. A few variants add a sibling code: 401 JWT (token_expired/token_invalid, error.rs:88-92), ForbiddenCode (:97-103), 502 provider (provider_error, :135-143). None of the agent routes emit code. Anything unmapped becomes 500 with the literal string "Something went wrong" (:145-148).
BRIDGE: the host replies {ok:false, error} and the SDK REJECTS the promise with new Error(d.error) (:191) — so an app catches an Error and reads err.message; there is no success flag and no HTTP status to branch on. error there is squashed by errorMessage() (utils/error.ts:13-21), which prefers the server's data.error string, so the server's sentence survives.
Bridge-local denials never reach the server at all and read: this app didn't request the "<scope>" capability (:530-532) or your role does not permit this action (:539-541).
Status map for these routes: 400 BadRequest, 403 Forbidden, 404 NotFound, 409 Conflict (ifUnmodifiedSince), 413 PayloadTooLarge (agent cap), 429 TooManyRequests (error.rs:105-124).
<sub>Source: /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_rust/src/error.rs:151 (base envelope), :112-124 (429 + retryAfter); bridge at /Users/navinkumar/Documents/Metty_AI/voice_ai/voice_ai_frontend/src/components/apps/AppPageHost.tsx:1040-1042 and :191</sub>
Events & webhooks
What a webhook handler receives per topic, and the context a declarative rule maps over.
App webhook ENVELOPE (all topics) + signature headers
Scope: — (per-topic, see rows below)
POST https://api.acme-crm.example.com/telenow/events
x-telenow-signature: sha256=4d1f7a9c0b3e5d8a2c6f10b4e7d93a5518c2ff7b6a0d4e91c3b85f2a7d604e13
X-VoiceAI-Event: call.analyzed
X-VoiceAI-Delivery: 018f2c3a-77b1-4d0e-9a41-6c5b2e8d9f30
Content-Type: application/json
{
"event": "call.analyzed",
"appId": "acme-crm",
"data": { /* the per-topic object — see the rows below */ }
}
THREE headers, not one, and the names are inconsistent: only the signature header is app-branded. x-telenow-signature is lowercase, value is sha256=<hex hmac> over the RAW body bytes, keyed by the install's signing secret (db::apps::signing_secret_for). Org REST-hooks (/api/v1/hooks) use X-VoiceAI-Signature instead — same HMAC, different header name (webhook_worker.rs:135 branches on sig_style). URL = manifest base_url + events[].handler.path. Delivery: retries with backoff, dead-letters after 8 attempts; HTTP 410 Gone permanently disables the app's delivery. data is the topic payload verbatim — NOT re-cased, NOT flattened.
On failure:
There is no error envelope FROM the platform. Your endpoint's response is the signal: 2xx = delivered; any other status = retry; 410 = deregister (webhook_worker.rs:168-195). Response body is truncated to 4096 chars and stored on the delivery row.
<sub>Source: voice_ai_rust/src/services/app_events.rs:427 (envelope) + voice_ai_rust/src/utils/webhook_worker.rs:135-141 (headers)</sub>
Declarative rule handler context (what map/when paths resolve against)
Scope: — (same as the topic)
{
"manifest_fragment": {
"on": "call.analyzed",
"handler": {
"kind": "rule",
"do": "upsert",
"object": "patient",
"key": "phone",
"when": { "path": "custom.condition", "equals": "fever" },
"map": {
"phone": "caller_number",
"last_summary": "summary",
"agent": "agentId"
}
}
},
"resolution_context_call_analyzed": {
"summary": "Patient reported a fever for two days and asked for an evening slot.",
"sentiment": "neutral",
"disposition": "appointment_requested",
"custom": { "condition": "fever" },
"topics": ["fever", "appointment"],
"keywords": ["evening slot"],
"caller_number": "+919812345678",
"session_id": "9f3c1b52-7d0a-4e6b-8a11-2c5d9e4f7a30",
"agentId": "4a1e6d78-25b3-4f0c-9c8e-7b1d3a5f2e94"
},
"notes": {
"handler_action_key": "The JSON key is \"do\", NOT \"action\" (app_manifest.rs:615 renames the Rust field `action` to `do`). Writing \"action\" leaves it None and the handler silently never runs (app_events.rs:262).",
"casing": "Mixed by construction: session_id and caller_number are snake_case, agentId is camelCase (app_events.rs:470-472).",
"nulls": "summary, sentiment, disposition and caller_number are present-as-null when unknown, never omitted; the map skips null sources so no field is written (app_events.rs:271).",
"key_field_is_a_map_target": "`key` must name a TARGET key of `map` (here \"phone\"), not a source path — the key value is read out of the already-mapped record (app_events.rs:276).",
"no_envelope_on_call_topics": "There is no {event,appId,data} wrapper on the rule path for call.* topics; that envelope is built only for kind:\"webhook\" delivery (app_events.rs:427).",
"envelope_exists_on_other_topics": "Paths are resolved against the WHOLE payload, so nested topics need a prefix."
},
"other_topic_contexts": {
"object.*.created": {
"objectType": "patient",
"id": "b7e2c410-8f36-4d19-a0e5-1d84f6c2b933",
"data": { "phone": "+919812345678", "name": "R. Menon" }
},
"object_created_map_example": { "phone": "data.phone", "name": "data.name" },
"schedule.<key>": {
"schedule": "nightly_sweep",
"appId": "doctor-crm",
"firedAt": "2026-07-29T02:00:00Z"
}
}
}
★ A rule path NEVER includes data. or event — the webhook body's {event, appId, data} wrapper is added only on the webhook path (app_events.rs:427). Paths are dotted, with an optional leading $. that is stripped. A missing segment yields None → the field is silently omitted from the upsert; a null value is skipped too. when supports equals plus numeric lt/lte/gt/gte; a bare path means truthiness (empty string / empty array / empty object are FALSE, and 0 is falsy for a bare path but comparable under lt/gt). Numeric gates accept a numeric STRING (money on the wire). A workflow trigger receives this exact same object as its run input (app_events.rs:233).
On failure:
Silent no-ops, no error surface: (a) if the mapped `key` field resolves to absent/empty the whole upsert is skipped (app_events.rs:276-283); (b) if the app's row cap is hit the upsert is skipped with only a server-side warn (app_events.rs:287).
<sub>Source: voice_ai_rust/src/services/app_events.rs:262-308 (map/upsert), :479 (resolve_path), :499 (eval_when)</sub>
GET /api/v1/events/{event_type}/samples — a real or canned sample of a topic
Scope: — (X-API-Key, /api/v1 only)
{
"event_type": "whatsapp.message.status",
"is_real": true,
"samples": [
{
"event": "whatsapp.message.status",
"channelId": "c41d9a02-6b58-4e17-9f83-2a7d0e5c1b64",
"channelPhone": "+14155550100",
"wamid": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAERgSN0YzQjRDNUQ2RTdGODA5MQA=",
"status": "delivered",
"recipient": "+919812345678",
"timestamp": "2026-07-29T09:14:07+00:00",
"errors": null,
"pricing": { "category": "utility", "billable": true, "type": "regular" },
"conversation": { "id": "a1b2c3d4e5f60718", "origin": { "type": "utility" } }
}
]
}
★ NOT wrapped in {success, data} — this v1 route returns a bare object, and its own three keys are snake_case (event_type, is_real) while the sample INSIDE is camelCase. is_real: true means it replayed your org's most recent real delivery; false means it is the hand-written canned sample. ★ It only ever reads CUSTOMER endpoint deliveries (endpoint_id IS NOT NULL, public_api.rs:644) — app-platform deliveries are deliberately excluded, so this endpoint shows you the REST-hook flavour of a topic, which for call.analyzed and call.ended is NOT what your app receives. Useful as a schema cross-check for the whatsapp/* and recording.ready topics, where both surfaces share one payload.
<sub>Source: voice_ai_rust/src/routes/public_api.rs:653-657 (response), :663-780 (canned_sample fallback)</sub>
call.analyzed — the APP payload (rule context / webhook data)
Scope: — (agent binding is the gate; calls:read:org for unbound org-wide apps)
{
"summary": "Caller asked to move tomorrow's 11:00 appointment; agent rebooked it for Friday 16:30.",
"sentiment": "positive",
"disposition": "rescheduled",
"custom": { "condition": "fever", "followup_needed": true },
"topics": ["appointment", "rescheduling"],
"keywords": ["reschedule", "friday", "clinic"],
"caller_number": "+919812345678",
"session_id": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"agentId": "4a1e6f3b-2c9d-4e57-8a10-b6d2f0c93e11"
}
★★ THREE traps. (1) FLAT, not wrapped — there is no analysis object; the customer REST-hook shape ({event, sessionId, analysis:{...}}) is a DIFFERENT payload built at post_call_analysis.rs:385. Rule paths are summary, not analysis.summary. (2) NO event key — this is the only topic whose payload omits it, because build_context copies the analysis object and appends three fields. (3) ★ CASING IS MIXED IN ONE OBJECT: caller_number and session_id are snake_case, agentId is camelCase (app_events.rs:470-472 — agentId was added later, deliberately camel to match the rest of the app API). Only SIX analysis fields cross to apps (summary/sentiment/disposition/custom/topics/keywords) — sentimentScore, actionItems, evidence, qa, objections, score, coaching, hallucinations, cx, talkRatio, model are customer-webhook-only. Note custom here vs customData on the customer webhook. caller_number is null for web/chat calls (sessions.from_number). Fires only if post-call analysis is enabled on the agent.
<sub>Source: voice_ai_rust/src/services/app_events.rs:458-474 (build_context) fed by voice_ai_rust/src/services/post_call_analysis.rs:421-435</sub>
call.analyzed — the CUSTOMER REST-hook payload (differs from the app one)
Scope: — (org webhook endpoint, /api/v1/hooks)
{
"event": "call.analyzed",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"analysis": {
"summary": "Caller asked to move tomorrow's 11:00 appointment; agent rebooked it for Friday 16:30.",
"sentiment": "positive",
"sentimentScore": 0.72,
"disposition": "rescheduled",
"actionItems": ["Send the Friday 16:30 confirmation SMS"],
"customData": { "condition": "fever", "followup_needed": true },
"evidence": { "disposition": "can you move it to Friday evening" },
"qa": [{ "key": "identity_verified", "met": true, "evidence": "date of birth is 4 March 1991" }],
"objections": ["Friday morning did not work"],
"score": 88,
"coaching": [{ "issue": "Did not confirm the new slot back", "suggestion": "Read the new time back verbatim", "severity": "low" }],
"hallucinations": [],
"cx": { "rating": "good", "friction": [], "highlights": ["Rebooked in under a minute"] },
"topics": ["appointment", "rescheduling"],
"keywords": ["reschedule", "friday", "clinic"],
"talkRatio": { "agentWords": 214, "customerWords": 96, "agentTurns": 9, "customerTurns": 8 },
"model": "openai/gpt-4o-mini"
}
}
Included so you can see exactly how far this diverges from the app payload above — same topic name, different envelope, different key names (customData vs custom), and a superset of fields. Signed with X-VoiceAI-Signature, not x-telenow-signature. cx/coaching/hallucinations inner structs carry no serde rename_all so their fields stay snake-ish as written (rating, friction, highlights; issue, suggestion, severity; claim, why) — post_call_analysis.rs:84-115. score is the 0-100 LLM-judge score, clamped; sentimentScore is -1.0..1.0.
<sub>Source: voice_ai_rust/src/services/post_call_analysis.rs:385-412</sub>
call.barge_in (mid-call, live) — TWO different shapes
Scope: calls:read (or calls:read:org)
{
"_note": "call.barge_in has THREE surfaces, not two. The engine split (cascade vs s2s) only changes the INNER payload; the envelope differs per surface.",
"inner_payload_cascade": {
"_built_at": "voice_ai_rust/src/services/orchestration.rs:14534-14538",
"corr": "t7:n=collect_details:g=2",
"played_ms": 1840,
"turn_audio_ms": 4200
},
"inner_payload_s2s": {
"_built_at": "voice_ai_rust/src/services/orchestration.rs:6581",
"corr": "t7:n=-:g=0",
"source": "s2s_vad"
},
"surface_1_stored_debug_trace": {
"_built_at": "voice_ai_rust/src/services/call_tracer.rs:325-327",
"_note": "type is the INTERNAL name barge_in, NOT call.barge_in. No sessionId here.",
"corr": "t7:n=collect_details:g=2",
"played_ms": 1840,
"turn_audio_ms": 4200,
"type": "barge_in",
"seq": 214,
"t_ms": 18740
},
"surface_2_app_webhook": {
"_built_at": "envelope voice_ai_rust/src/services/app_events.rs:427; sessionId injected voice_ai_rust/src/services/live_call_events.rs:88-94",
"event": "call.barge_in",
"appId": "acme-crm",
"data": {
"corr": "t7:n=collect_details:g=2",
"played_ms": 1840,
"turn_audio_ms": 4200,
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1"
}
},
"surface_3_live_socket": {
"_built_at": "voice_ai_rust/src/websocket/live_call_stream.rs:83-87",
"_note": "sessionId is a SIBLING of data, not inside it. Same frame reaches an app verbatim through the UI bridge (AppPageHost.tsx:971-982) wrapped as {source:'telenow-host',stream,data:<this frame>}.",
"topic": "call.barge_in",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"data": {
"corr": "t7:n=collect_details:g=2",
"played_ms": 1840,
"turn_audio_ms": 4200
}
}
}
★ One topic, two disjoint payloads depending on the engine — an s2s call NEVER sends played_ms/turn_audio_ms, and a cascade call never sends source. Branch on which keys are present, not on the topic. played_ms is clamped to [0, turn_audio_ms] and is 0 when playback had not started.
<sub>Source: cascade voice_ai_rust/src/services/orchestration.rs:14529-14539; s2s voice_ai_rust/src/services/orchestration.rs:6580-6582</sub>
call.dtmf (mid-call, live)
Scope: calls:read (or calls:read:org)
{
"event": "call.dtmf",
"appId": "ivr-router",
"data": {
"corr": "t7:n=main_menu:g=1",
"digit": "2",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1"
}
}
digit is a ONE-CHARACTER STRING ("2", "*", "#"), never a number and never a batched sequence — one event per keypress. Fires for every captured digit regardless of whether the agent uses DTMF routing (the trace sits before the routing branch).
<sub>Source: voice_ai_rust/src/services/orchestration.rs:25673</sub>
call.ended
Scope: — (agent binding is the gate; calls:read:org for unbound org-wide apps)
{
"event": "call.ended",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"agentId": "4a1e6f3b-2c9d-4e57-8a10-b6d2f0c93e11",
"userId": "1c8b2d44-9e31-4a77-bb0e-3f5c7a91d200",
"durationSecs": 142,
"messageCount": 18,
"fromOrTo": "+919812345678",
"variables": { "customer_name": "Rohit Sharma", "order_id": "SO-88213" }
}
★ Apps NEVER get recording or transcript on this topic, even though the org REST-hook version of the same event can carry both. The enrichment is built AFTER the app dispatch and grafted on per-endpoint by WebhookService::enqueue only for endpoints that set include_recording/include_transcript (orchestration.rs:9370-9382, services/webhook.rs:297-312). To get the audio, subscribe to recording.ready. Note the key is fromOrTo here vs from on call.started. durationSecs is wall-clock (now − start_time), not billed seconds.
<sub>Source: voice_ai_rust/src/services/orchestration.rs:9349-9360</sub>
call.node_entered (mid-call, live)
Scope: calls:read (or calls:read:org)
{
"_webhook_body": {
"_note": "POST to <manifest.base_url>/<handler.path>, signed x-telenow-signature. Built at voice_ai_rust/src/services/app_events.rs:427. Fields live under data; sessionId IS inside data (injected at services/live_call_events.rs:89).",
"event": "call.node_entered",
"appId": "flow-analytics",
"data": {
"corr": "t7:n=collect_details:g=2",
"node_id": "collect_details",
"node_name": "Collect details",
"node_kind": "conversation",
"node_generation": 2,
"llm_model": "gpt-4o-mini",
"tts_provider": "telenow",
"tts_voice": "aarohi",
"entry_message": "Sure — let me pull that up for you.",
"duration_ms": 34,
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1"
}
},
"_ui_bridge_frame": {
"_note": "What telenow.stream.subscribe(sessionId, onEvent) hands onEvent. Built at voice_ai_rust/src/websocket/live_call_stream.rs:85-87, relayed verbatim by voice_ai_frontend/src/components/apps/AppPageHost.tsx:961-983 and dispatched at :188. sessionId is a SIBLING of data here, NOT inside it — services/live_call_stream.rs:59-70 never injects it.",
"topic": "call.node_entered",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"data": {
"corr": "t7:n=collect_details:g=2",
"node_id": "collect_details",
"node_name": "Collect details",
"node_kind": "conversation",
"node_generation": 2,
"llm_model": "gpt-4o-mini",
"tts_provider": "telenow",
"tts_voice": "aarohi",
"entry_message": "Sure — let me pull that up for you.",
"duration_ms": 34
}
},
"_nulls": {
"_note": "node_name and entry_message are Option<String> (services/flow.rs:102, :144) emitted by json! — the keys are ALWAYS present and become null, never omitted. system_prompt is always absent (stripped at services/live_call_events.rs:50-56 and services/live_call_stream.rs:63-66).",
"node_name": null,
"entry_message": null
}
}
★ The internal trace carries system_prompt (the node's full instructions); the app copy has it REMOVED by strip_internal_fields, so do not code against it. Credential-shaped keys are also scrubbed by strip_sensitive_in_place before dispatch (live_call_events.rs:83). Fires per node ENTRY, including re-entry — node_generation increments so corr stays unique. node_kind ∈ conversation | static | router | extract | dtmf | end | note (services/agent_config.rs:156). Flow agents only; a single-context agent never emits this.
<sub>Source: payload voice_ai_rust/src/services/orchestration.rs:22932-22946; system_prompt stripped at voice_ai_rust/src/services/live_call_events.rs:50-56</sub>
call.silence (mid-call, live)
Scope: calls:read (or calls:read:org)
{
"event": "call.silence",
"appId": "clinic-crm",
"data": {
"corr": "t7:n=collect_details:g=2",
"text": "Are you still there?",
"llm_generated": false,
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1"
}
}
text is the check-in line the AGENT spoke after a long caller silence — it is not caller speech. llm_generated distinguishes a model-composed line from the configured static one. The internal trace type is silence.checkin (with the dot); silence alone maps to nothing (live_call_events.rs:41 and its regression test at :115).
<sub>Source: voice_ai_rust/src/services/orchestration.rs:16435-16437</sub>
call.started
Scope: — (agent binding is the gate; calls:read:org for unbound org-wide apps)
{
"event": "call.started",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"agentId": "4a1e6f3b-2c9d-4e57-8a10-b6d2f0c93e11",
"userId": "1c8b2d44-9e31-4a77-bb0e-3f5c7a91d200",
"from": "+919812345678",
"variables": { "customer_name": "Rohit Sharma", "order_id": "SO-88213" },
"startTime": "2026-07-29T09:14:02.117Z"
}
camelCase. event is duplicated INSIDE data as well as in the envelope. from is the telephony number (null on web/chat calls — there is no telephony block). variables is null (not {}) when the call resolved no context variables. userId is a non-null Uuid (VoiceSession.user_id is Uuid, not Option). startTime is RFC3339 with sub-second precision. Unlike the mid-call topics this needs NO calls:read — a bound app receives it (app_events.rs:183 routes call.* through resolve_call_event_manifests, which scope-checks only the UNBOUND org-wide arm, db/apps.rs:217).
<sub>Source: voice_ai_rust/src/services/orchestration.rs:9325-9335</sub>
call.turn (mid-call, live)
Scope: calls:read (or calls:read:org)
{
"event": "call.turn",
"appId": "clinic-crm",
"data": {
"corr": "t7:n=collect_details:g=2",
"text": "Yeah, can you move it to Friday evening instead?",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1"
}
}
★★ ALL FIVE mid-call topics share one envelope rule: the trace payload verbatim + a sessionId key merged in by the drainer. There is NO event, NO agentId, NO timestamp in the payload — the topic name lives only in the envelope's event field, and the agent is implied by the binding. Fields are snake_case here (node_id, played_ms, llm_generated) while every other topic is camelCase, because these are raw internal debug-trace payloads. corr is t{turn_seq}:n={node_id}:g={node_generation} (orchestration.rs:3298) — group a call's frames by it to reconstruct a turn; the node segment is - for single-context (non-flow) agents. Delivery is BEST-EFFORT: a bounded 8192-slot channel drops events rather than back-pressure a live call (live_call_events.rs:20,61-70). Only these five internal trace types map to app topics (live_call_events.rs:35-45); everything else (llm.request, tts, tool.call…) never leaves the platform. Scope is enforced at dispatch (app_events.rs:80-85) — no scope, no error, just silence.
On failure:
—
<sub>Source: payload voice_ai_rust/src/services/orchestration.rs:6116 (cascade STT), :6666 (s2s), :12004 (committed utterance); sessionId grafted at voice_ai_rust/src/services/live_call_events.rs:87-93</sub>
charge.settled
Scope: billing:read
{
"event": "charge.settled",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"agentId": "4a1e6f3b-2c9d-4e57-8a10-b6d2f0c93e11",
"callType": "agent_voice",
"chargeUsd": 0.0412,
"components": {
"llmUsd": 0.0041,
"sttUsd": 0.0063,
"ttsUsd": 0.0089,
"telephonyUsd": 0.0102,
"platformAiUsd": 0.0,
"postCallAnalysisUsd": 0.0008,
"simulationUsd": 0.0,
"embeddingUsd": 0.0,
"platformFeeUsd": 0.0109,
"featureSurchargeUsd": 0.0
},
"hasEstimates": false,
"isResettle": false,
"walletBalanceUsd": 43.87,
"billingMode": "prepaid"
}
Scope is REQUIRED and enforced at dispatch (app_events.rs:124-130) — subscribing without billing:read yields silence, never an error. Org-scoped: no agent binding needed, and you receive EVERY agent's settlements. ★ Amounts here are f64 JSON NUMBERS, deliberately (predicate-friendly); the authoritative Decimals are on /api/app-billing/charges as decimal STRINGS — do not compare the two by string equality. walletBalanceUsd is the balance AFTER the charge and is null when the org has no billing account row; billingMode is prepaid | postpaid (null likewise). callType is manual | phone | web. ★ NEVER fires for demo sessions (if !sess.is_demo, line 831). isResettle true means this call was re-rated — treat as an amendment, not a second charge. Typical use: "when": { "path": "walletBalanceUsd", "lt": 10 } for low-balance alerting.
<sub>Source: voice_ai_rust/src/utils/billing_settlement_worker.rs:857-879</sub>
object.<type>.created (and the object.*.created wildcard)
{
"event": "object.patient.created",
"appId": "acme-clinic",
"data": {
"objectType": "patient",
"id": "7e1b0a9c-3d24-4f6b-9a01-2c8e5f7b1d44",
"data": {
"phone": "+919812345678",
"name": "Rohit Sharma",
"condition": "fever"
}
}
}
★ The record's own fields sit at data.data.<field> in the webhook body, and at data.<field> in a rule map — one of the easiest things to get wrong. No event key inside the payload. Fires ONLY to the app that owns the object type (per-app silos, single-row manifest lookup at app_events.rs:368) — never org-wide. Emitted only from the explicit create sites (agent object.create tool, the data API create, the dashboard data proxy); the declarative engine's own upsert_by_field writes do NOT re-fire it, which is what keeps an object.*.created → upsert rule from looping. object.*.created matches any type's create but NOT .updated/.deleted (there is no created-event for updates).
<sub>Source: voice_ai_rust/src/services/app_events.rs:366 (payload), :384-389 (topic_matches wildcard)</sub>
recording.ready
Scope: — (agent binding is the gate; calls:read:org for unbound org-wide apps)
{
"event": "recording.ready",
"recordingId": "b2f4c8d0-1e7a-4c39-8f52-a0d61b47e903",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"agentId": "4a1e6f3b-2c9d-4e57-8a10-b6d2f0c93e11",
"durationSecs": 142,
"recording": {
"id": "b2f4c8d0-1e7a-4c39-8f52-a0d61b47e903",
"url": "https://telenow-recordings.s3.ap-south-1.amazonaws.com/rec/b2f4c8d0.wav?X-Amz-Expires=3600&X-Amz-Signature=...",
"expiresAt": "2026-07-29T10:16:44.902Z"
}
}
★ recording DEGRADES SILENTLY: when the presign fails (signed_url_unchecked → None, e.g. a row written under a different blob backend) the object collapses to { "id": "..." } with no url and no expiresAt — the event still ships. Always guard on recording.url. The id is duplicated at recordingId and recording.id. sessionId and durationSecs are nullable (Recording.session_id / duration_sec are Option). agentId is always present in the APP copy because dispatch is skipped entirely when the recording has no agent_id (recording.rs:326) — the org REST-hook copy still fires and can carry "agentId": null. URL TTL is 3600s.
<sub>Source: voice_ai_rust/src/services/recording.rs:314-321 (payload), :308-313 (the recording sub-object)</sub>
schedule.<key> (cron tick)
// event name is `schedule.nightly_digest`; webhook body:
{
"event": "schedule.nightly_digest",
"appId": "acme-crm",
"data": {
"schedule": "nightly_digest",
"appId": "acme-crm",
"firedAt": "2026-07-29T02:00:00Z"
}
}
Three keys only — a synthetic tick, no business data. appId appears twice (envelope + payload). The topic string is schedule. + your manifest schedules[].key, so it is not in APP_EVENT_TOPICS and cannot be subscribed to via events[].on; the schedule's own handler runs it. This is the ONLY dispatch path whose handler return value matters — false (a transient DB failure on the upsert/enqueue) makes the worker retry the tick; config problems settle as true.
<sub>Source: voice_ai_rust/src/services/app_events.rs:337-342</sub>
telenow.data.subscribe(objectType, onChange) — object-change frames
telenow.data.subscribe('patient', function (frame) { /* frame is: */ });
{
"event": "created",
"objectType": "patient",
"id": "7e1b0a9c-3d24-4f6b-9a01-2c8e5f7b1d44",
"data": { "phone": "+919812345678", "name": "Rohit Sharma", "condition": "fever" }
}
★ Do not confuse with the object.<type>.created WEBHOOK topic. Here event is the bare verb "created" | "updated" | "deleted" (no object. prefix, no type in it), and the record is at data — ONE level, not the webhook's data.data. data is null on a delete. Live only while the app page is open; it is not durable and has no retry, so it is a UI live-update channel, not an integration channel.
<sub>Source: voice_ai_rust/src/websocket/object_change_stream.rs:73-78; bridged at voice_ai_frontend/src/components/apps/AppPageHost.tsx:217-223 / :987-1010</sub>
telenow.stream.subscribe(sessionId, onEvent) — live-call frames (bridge shape ≠ webhook shape)
Scope: calls:read
{
"topic": "call.turn",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"data": {
"corr": "t7:n=collect_details:g=2",
"text": "Yeah, move it to Friday."
}
}
★★ SAME EVENTS, DIFFERENT ENVELOPE. Webhook body is {event, appId, data}; this bridge/socket frame is {topic, sessionId, data} — the key is topic, not event, there is no appId, and sessionId appears BOTH at the top level and inside data. ★ The socket carries ONE EXTRA topic the webhook path never sends: call.transcript_partial (live interim STT, from the internal stt.partial) — live_call_stream.rs:36 vs live_call_events.rs:35-45. Same redaction (secrets stripped, node system_prompt removed). subscribe resolves to an unsubscribe function; the host owns the WebSocket and the app never sees the one-time ticket or URL. Frames drop silently if the app lags (512-frame buffer), and the socket closes with code 1000 "call ended" ~2s after the call ends.
<sub>Source: frame built at voice_ai_rust/src/websocket/live_call_stream.rs:84-88; relayed to the iframe by voice_ai_frontend/src/components/apps/AppPageHost.tsx:962-986 (host holds the socket) and dispatched at :188</sub>
whatsapp.account.health
Scope: whatsapp
{
"event": "whatsapp.account.health",
"wabaId": "104382917465023",
"field": "phone_number_quality_update",
"value": {
"display_phone_number": "+14155550100",
"event": "FLAGGED",
"current_limit": "TIER_1K"
}
}
Four keys, that is all — no channelId, no org id, no timestamp. field is the raw Meta webhook field that fired: account_update | phone_number_quality_update | account_review_update. ★ value is Meta's object VERBATIM (snake_case, shape varies per field) and can be null; there is a nested value.event that is NOT the topic name — "event" at the top level is the topic, value.event is Meta's enum (FLAGGED, DISABLED_UPDATE, ACCOUNT_RESTRICTION, …). This event is a REFRESH SIGNAL only: re-read the live health GET for authoritative state. A DISABLED_UPDATE / ACCOUNT_DELETED / PARTNER_APP_UNINSTALLED also revokes the org's token server-side (webhooks.rs:1017-1025), so sends will start failing right after this arrives.
<sub>Source: voice_ai_rust/src/routes/webhooks.rs:988-995 (account_health_payload); emitted at :1039 and :1060</sub>
whatsapp.message.received
Scope: whatsapp
{
"restHookAndWorkflowBody": {
"event": "whatsapp.message.received",
"channelId": "c41d9a02-6b58-4e17-9f83-2a7d0e5c1b64",
"channelPhone": "+14155550100",
"threadId": "5b8e14c7-90fa-4d23-8e61-0c4a9d72f318",
"messageId": "a07f3d61-24b8-4c95-b1e0-6f8d3a45c209",
"wamid": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAEhgUM0E0RjZBQkMxMjM0NTY3ODkwAA==",
"from": "+919812345678",
"contactName": "Rohit Sharma",
"type": "text",
"body": "Hi, has my order shipped?",
"timestamp": "2026-07-29T09:14:02+00:00",
"content": null,
"media": null,
"raw": {
"from": "919812345678",
"id": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAEhgUM0E0RjZBQkMxMjM0NTY3ODkwAA==",
"timestamp": "1785316442",
"type": "text",
"text": { "body": "Hi, has my order shipped?" }
}
},
"appWebhookHandlerBody": {
"event": "whatsapp.message.received",
"appId": "order-desk",
"data": {
"event": "whatsapp.message.received",
"channelId": "c41d9a02-6b58-4e17-9f83-2a7d0e5c1b64",
"channelPhone": "+14155550100",
"threadId": "5b8e14c7-90fa-4d23-8e61-0c4a9d72f318",
"messageId": "b13c8e40-77d5-4a6e-9c21-3ea5f0b7d842",
"wamid": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAEhgUNEI1RzdCQ0QyMzQ1Njc4OTAxAA==",
"from": "+919812345678",
"contactName": "Rohit Sharma",
"type": "image",
"body": "Here's the damaged box",
"timestamp": "2026-07-29T09:16:31+00:00",
"content": {
"media": {
"id": "1084729384756",
"mime": "image/jpeg",
"sha256": "9f8Ct1QwqzM8kK0m0f3q4z2sV1bYc8Xk7Nn2R5j6Q0A=",
"caption": "Here's the damaged box"
}
},
"media": {
"mime": "image/jpeg",
"filename": null,
"signedUrl": "https://telenow-blobs.s3.ap-south-1.amazonaws.com/whatsapp-media/7c3f9a12-8d40-4b6e-a1f2-55e0c9d3a884/b13c8e40-77d5-4a6e-9c21-3ea5f0b7d842?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=3600&X-Amz-Signature=4b1d0c9e77a2"
},
"raw": {
"from": "919812345678",
"id": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAEhgUNEI1RzdCQ0QyMzQ1Njc4OTAxAA==",
"timestamp": "1785316591",
"type": "image",
"image": {
"id": "1084729384756",
"mime_type": "image/jpeg",
"sha256": "9f8Ct1QwqzM8kK0m0f3q4z2sV1bYc8Xk7Nn2R5j6Q0A=",
"caption": "Here's the damaged box"
}
}
}
},
"mediaFetchFailure_mediaField": {
"mime": "application/pdf",
"filename": "invoice-8842.pdf",
"fetchError": "channel has no linked WABA"
}
}
Scope REQUIRED, enforced at dispatch (app_events.rs:156-162). Org-scoped, no agent binding. ★ media is ALWAYS present as a key (null for text) so media !== null is a safe test — but for a media message the event is DEFERRED until the bytes are pulled from Meta (their URLs expire in ~5 min), so it arrives later than a text message would. On fetch failure the event still ships with media: { mime, filename, fetchError } and NO signedUrl — always guard on media.signedUrl. content is the structured extract and is null for plain text; media messages nest it as content.media.{id,mime,sha256?,caption?,filename?,voice?,animated?} (services/whatsapp_hub.rs:872-896), interactive replies as content.reply.{kind,id,title,description}, quick-reply buttons as content.button.{payload,text}. raw is Meta's message object verbatim — note its from has NO + and its timestamp is a STRING of unix seconds, while the top-level from is normalized E.164 with + and timestamp is RFC3339 (+00:00, not Z — chrono to_rfc3339). wamid is nullable (provider_msg_id is Option). Delivered once per message — a Meta redelivery is suppressed upstream by persist_inbound.
<sub>Source: voice_ai_rust/src/routes/webhooks.rs:585-604 (base payload); media filled at :786-790 (ok) / :797-801 (failure); fan-out voice_ai_rust/src/routes/webhooks.rs:811-823 → app_events.rs:144</sub>
whatsapp.message.status
Scope: whatsapp
{
"_note": "ONE event, TWO wire shapes. Construction site: voice_ai_rust/src/routes/webhooks.rs:724-739 builds the 10-key payload. Org REST-hooks deliver it FLAT (services/webhook.rs:296-325, body = payload verbatim). App event webhooks NEST it under `data` and add `appId` (services/app_events.rs:426). Rule handlers and workflow triggers see the bare payload (top level).",
"orgRestHook_delivered": {
"event": "whatsapp.message.status",
"channelId": "c41d9a02-6b58-4e17-9f83-2a7d0e5c1b64",
"channelPhone": "+14155550100",
"wamid": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAERgSN0YzQjRDNUQ2RTdGODA5MQA=",
"status": "delivered",
"recipient": "+919812345678",
"timestamp": "2026-07-29T09:14:07+00:00",
"errors": null,
"pricing": { "billable": true, "category": "utility", "pricing_model": "CBP", "type": "regular" },
"conversation": { "id": "a1b2c3d4e5f60718", "origin": { "type": "utility" }, "expiration_timestamp": "1785748442" }
},
"orgRestHook_failed": {
"event": "whatsapp.message.status",
"channelId": "c41d9a02-6b58-4e17-9f83-2a7d0e5c1b64",
"channelPhone": "+14155550100",
"wamid": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAERgSN0YzQjRDNUQ2RTdGODA5MQA=",
"status": "failed",
"recipient": "+919812345678",
"timestamp": "2026-07-29T12:41:10+00:00",
"errors": [
{
"code": 131047,
"title": "Re-engagement message",
"message": "Message failed to send because more than 24 hours have passed since the customer last replied to this number.",
"error_data": { "details": "Message failed to send because more than 24 hours have passed since the customer last replied to this number." },
"href": "https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/"
}
],
"pricing": null,
"conversation": null
},
"appEventWebhook_delivered": {
"event": "whatsapp.message.status",
"appId": "clinic-crm",
"data": {
"event": "whatsapp.message.status",
"channelId": "c41d9a02-6b58-4e17-9f83-2a7d0e5c1b64",
"channelPhone": "+14155550100",
"wamid": "wamid.HBgMOTE5ODEyMzQ1Njc4FQIAERgSN0YzQjRDNUQ2RTdGODA5MQA=",
"status": "delivered",
"recipient": "+919812345678",
"timestamp": "2026-07-29T09:14:07+00:00",
"errors": null,
"pricing": { "billable": true, "category": "utility", "pricing_model": "CBP", "type": "regular" },
"conversation": { "id": "a1b2c3d4e5f60718", "origin": { "type": "utility" }, "expiration_timestamp": "1785748442" }
}
}
}
★ errors, pricing and conversation are Meta's sub-objects passed through VERBATIM and UNRENAMED — so this one payload mixes camelCase platform keys (channelId, channelPhone) with Meta's snake_case (pricing_model, error_data, expiration_timestamp). errors is an ARRAY when present, null otherwise; the inbox row only keeps the bare "failed" string, so this event is the ONLY place the failure code/title reaches you. status ∈ sent | delivered | read | failed. recipient is re-prefixed to +E.164 (webhooks.rs:730) even though Meta sends it bare. timestamp is Meta's unix-seconds string parsed to RFC3339, and is null if unparseable. ★ Emitted ONLY when the (wamid, status) pair actually ADVANCED (webhooks.rs:717) — a Meta redelivery or a backwards status is dropped, so you will not see duplicates but you also may not see every intermediate state.
<sub>Source: voice_ai_rust/src/routes/webhooks.rs:724-739</sub>
Delivery outcomes — what failure looks like (there is no error payload to your app)
{
"success": true,
"data": {
"deliveries": [
{
"id": "018f2c3a-77b1-4d0e-9a41-6c5b2e8d9f30",
"endpoint_id": "6d0b4f21-8c39-45ae-b7f2-19d3c8e05a77",
"org_id": "8f2a4d19-53c7-4e08-9b16-c0a7e3d21f45",
"event_type": "call.ended",
"status": "failed",
"payload": {
"event": "call.ended",
"sessionId": "9d7c1c2e-5b0a-4a1f-9b3d-7f2a51c8e4a1",
"agentId": "3b7e9a04-1d62-4c8f-8a55-2e14b9c7d033",
"userId": "c41d8e77-9a26-4b03-95ef-7d2c6a1b40e8",
"durationSecs": 187,
"messageCount": 24,
"fromOrTo": "+919876543210",
"variables": null
},
"attempt": 3,
"response_status": 502,
"response_body": "upstream connect error",
"error": "HTTP 502",
"next_attempt_at": "2026-07-29T09:22:00.481233Z",
"created_at": "2026-07-29T09:16:44.102778Z",
"updated_at": "2026-07-29T09:18:12.481233Z"
}
],
"total": 1
}
}
★ This row struct carries NO serde rename_all, so it is pure snake_case (event_type, response_status, next_attempt_at) — the only snake_case surface in this whole reference, sitting one level under a camel-ish {success, data:{deliveries, total}} envelope. Also note total is just rows.len() of the current page, NOT a full count. ★ There is NO app-developer-facing route for app deliveries — webhook_deliveries is queried by routes only for endpoint-backed customer rows; app deliveries (endpoint_id NULL, app_id set) are invisible via REST. status ∈ pending | delivered | failed | dead. Failure semantics your app must design for: non-2xx → retry with exponential backoff, dead after 8 attempts; HTTP 410 → the app's delivery is disabled immediately (no more events, ever, until reinstall); a URL that fails the SSRF re-validation at send time is marked dead and the target disabled.
On failure:
{
"success": false,
"error": "forbidden"
}
// The platform never POSTs an error body TO your webhook — a bad payload,
// a missing scope, or an unparseable manifest all result in NO DELIVERY AT ALL
// (tracing::warn server-side only). Silence is the failure mode; check
// app_preflight's SCOPE_TOPIC_REQUIRED warning at publish time instead
// (voice_ai_rust/src/services/app_preflight.rs:627-658).
<sub>Source: envelope voice_ai_rust/src/routes/webhooks_mgmt.rs:98; row struct voice_ai_rust/src/services/webhook.rs:60-75; state machine voice_ai_rust/src/utils/webhook_worker.rs:146-205</sub>
Bridge ops (window.telenow.*)
What each op RESOLVES TO inside an app page. Several re-project the REST payload — where they differ it is called out.
data.subscribe → onChange(frame)
{
"event": "updated",
"objectType": "appointment",
"id": "5a6c1e90-3b47-4d82-9f15-c0e83d21b7aa",
"data": { "patient_id": "7b2f4c08-91da-4e63-85b7-0c3e1a97d254", "status": "cancelled" }
}
★ data here is the record's INNER data blob only — NOT the AppObjectRow envelope. So frame.data.status, and there is no frame.data.data. This is the mirror image of data.list, where the same fields sit one level deeper. event is "created" | "updated" | "deleted"; data is null for deleted. A lagging subscriber silently SKIPS frames (RecvError::Lagged is continue, :84) — re-fetch on reconnect, never treat the stream as a complete ledger.
<sub>Source: voice_ai_rust/src/websocket/object_change_stream.rs:73-78; publisher voice_ai_rust/src/db/app_objects.rs:46-48 (created), :122-124 (upsert-update), :154-156 (update), :181-183 (delete)</sub>
stream.subscribe → onEvent(frame)
Scope: calls:read
[
{
"topic": "call.transcript_partial",
"sessionId": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104",
"data": { "text": "I want to reschedul", "speaker": "remote" }
},
{
"topic": "call.turn",
"sessionId": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104",
"data": { "text": "I want to reschedule my appointment.", "speaker": "remote" }
},
{
"topic": "call.transcript_partial",
"sessionId": "9f3c1a76-5b20-4de8-91ac-7d0e4c2b8a15",
"data": { "corr": "t19:n=greeting:g=1", "text": "I need to move my appoint" }
},
{
"topic": "call.turn",
"sessionId": "9f3c1a76-5b20-4de8-91ac-7d0e4c2b8a15",
"data": { "corr": "t19:n=greeting:g=1", "text": "I need to move my appointment." }
},
{
"topic": "call.node_entered",
"sessionId": "9f3c1a76-5b20-4de8-91ac-7d0e4c2b8a15",
"data": {
"corr": "t19:n=booking:g=2",
"node_id": "booking",
"node_name": "Booking",
"node_kind": "conversation",
"node_generation": 2,
"llm_model": "gpt-4o-mini",
"tts_provider": "elevenlabs",
"tts_voice": "rachel",
"entry_message": null,
"duration_ms": 12
}
},
{
"topic": "call.dtmf",
"sessionId": "9f3c1a76-5b20-4de8-91ac-7d0e4c2b8a15",
"data": { "corr": "t20:n=booking:g=2", "digit": "3" }
}
]
★ data is SNAKE_CASE (node_id, node_kind, duration_ms) — the only snake_case payload the app receives besides calls.history. ★ data does NOT repeat sessionId (unlike the webhook plane, which injects it) — read it off the envelope. ★ The SAME topic carries DIFFERENT payloads by call type: an AI-pipeline call emits {corr, text} for call.turn, while a live-assist softphone call emits {text, speaker} with no corr. Branch defensively. Topics: call.turn, call.transcript_partial (socket-only, never webhooked), call.node_entered, call.barge_in, call.silence, call.dtmf. system_prompt is stripped from call.node_entered, and every payload is run through the secret stripper. A lagging socket drops frames silently.
<sub>Source: voice_ai_rust/src/websocket/live_call_stream.rs:84-88 (envelope); topic map voice_ai_rust/src/services/live_call_stream.rs:34-44; payloads voice_ai_rust/src/services/orchestration.rs:6620-6625 (stt.partial), :6666-6668 (user.turn), :22932-22945 (node.enter), :25673 (dtmf); live-assist voice_ai_rust/src/services/live_assist.rs:372-381</sub>
telenow.agents.createFromTemplate(templateId, opts)
Scope: agents:read (+ owner/admin)
{ "agentId": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60", "kind": "flow" }
kind is "single" | "flow". ★ With the default open:true the host calls navigate() immediately after resolving (:663-667), which UNMOUNTS your page — the promise resolves but your .then may never run to completion. Pass {open:false} to keep the page alive. The new agent is auto-bound to the app and the app's KBs are attached. To reach it publicly you still need agents.publicLink — the public page resolves by SLUG, not by agentId.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:657-667 — REST voice_ai_rust/src/routes/apps.rs:830-832</sub>
telenow.agents.createTeamFromTemplate(teamId)
Scope: agents:read (+ owner/admin)
{
"teamId": "triage-team",
"agents": [
{ "ref": "reception", "agentId": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60", "kind": "flow" },
{ "ref": "billing", "agentId": "a70df519-4c8e-4b21-9d6f-3e05b8a72c11", "kind": "single" }
],
"entryAgentId": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60"
}
★ KEY ORDER DIFFERS from the SDK type: the server emits teamId, agents, entryAgentId. agentId and entryAgentId are Option<Uuid> — null if a member's ref never resolved. This op ALWAYS navigates to the entry agent's builder (:715-720); there is no {open:false} escape hatch, so treat it as the last thing a click does.
<sub>Source: voice_ai_rust/src/routes/apps.rs:1017-1025 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:713-720</sub>
telenow.agents.getConfig(agentId)
Scope: agents:config:read
{
"agentId": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60",
"name": "Front Desk",
"description": "Books appointments for Acme Clinic",
"kind": "flow",
"updatedAt": "2026-07-29T08:02:11.447312Z",
"prompt": { "systemPrompt": "You are the receptionist for Acme Clinic." },
"model": {
"provider": "openai",
"model": "gpt-4o-mini",
"temperature": 0.7,
"maxTokens": 400,
"config": { "temperature": 0.7, "maxTokens": 400 }
},
"voice": { "provider": "elevenlabs", "voice": "rachel", "config": { "speed": 1.0 } },
"stt": { "provider": "deepgram", "config": { "language": "en-IN" } },
"behavior": {
"memory_type": "short-term",
"maxDuration": 600,
"contextMode": "window",
"contextWindow": 10,
"bargeInSensitivity": 0.5,
"rememberInterruptions": false,
"silenceHangupSecs": 30,
"behavior": {
"tone": "friendly",
"naturalFillers": true,
"aiDisclosureWhenAsked": true,
"mustDeliverMarkers": []
}
},
"telephony": {
"provider": "plivo",
"config": {
"firstResponse": "agent",
"agentMsg": "Thanks for calling Acme Clinic!",
"ringDurationSecs": 30
}
},
"flow": {
"schema": 1,
"startNodeId": "node_mkq3f81a2c",
"nodes": [
{
"id": "node_mkq3f81a2c",
"kind": "conversation",
"name": "Greeting",
"position": { "x": 80, "y": 80 },
"prompt": "Greet the caller.",
"promptMode": "append",
"toolCount": 0,
"precallLookupCount": 0
},
{
"id": "node_mkq3f82pd9",
"kind": "conversation",
"name": "Booking",
"position": { "x": 440, "y": 80 },
"prompt": "Book a slot.",
"promptMode": "append",
"toolCount": 1,
"precallLookupCount": 0
}
],
"edges": [
{
"id": "edge_mkq3f83zk4",
"source": "node_mkq3f81a2c",
"target": "node_mkq3f82pd9",
"priority": 0,
"condition": { "kind": "ai", "describe": "wants an appointment" }
}
]
},
"analysis": { "enabled": false, "customFields": [], "qaCriteria": [] },
"tools": [ { "name": "book_appointment", "description": "Book a slot", "kind": "app" } ],
"realtimeEngine": null
}
★ UNDOCUMENTED GROUP: the response carries an analysis object (agent_config.rs:329-339) that the SDK's TelenowAgentConfig type does NOT declare. It is the post-call-analysis switch, which defaults OFF — this is how you explain analysis:null rows from calls.history instead of guessing at permissions. ★ model.temperature / model.maxTokens are llmConfig.get(...) and are present as null (not absent) when unset, while model.config holds the full blob — the two can disagree (see the example). ★ flow is null for a single-context agent; nodes have tools/precallLookups REPLACED by toolCount/precallLookupCount, and every node is run through the secret stripper (a real graph stores BYOK keys at flow.nodes[i].model.extra.apiKey). ★ tools is read-only — no scope writes it. realtimeEngine is the s2s provider string or null. updatedAt is what you echo back as ifUnmodifiedSince.
<sub>Source: voice_ai_rust/src/services/agent_config.rs:279-345 (read_config), routed at voice_ai_rust/src/routes/apps.rs:2851-2854 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:690</sub>
telenow.agents.list()
Scope: agents:read
[
{ "id": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60", "name": "Front Desk" },
{ "id": "a70df519-4c8e-4b21-9d6f-3e05b8a72c11", "name": "Collections" }
]
★ BRIDGE RE-PROJECTS HARD: GET /api/agents returns {agents, total, limit, offset} with ~25 fields per agent (prompt, llmProvider, ttsVoice, sessionConfig …). The bridge maps to {id, name} ONLY and drops the pagination envelope. Fetched with limit=200, offset=0 — an org with >200 agents is silently truncated, and there is no bridge op to page. Also role-gated on canViewActivity (:651).
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:650</sub>
telenow.agents.publicLink(agentId) / .setPublic(agentId, enabled)
Scope: agents:read / agents:write (+ owner/admin for setPublic)
{
"isPublic": false,
"slug": "front-desk-acme-clinic",
"publicUrl": "https://app.telenow.ai/p/front-desk-acme-clinic",
"embedSnippet": "<script src=\"https://api.telenow.ai/widget.js\" data-slug=\"front-desk-acme-clinic\"></script>"
}
Both ops return the IDENTICAL shape — setPublic is a read-back of the same builder, so use its return value rather than re-calling publicLink. The slug is materialised lazily on first read and is stable across publish/unpublish, so it is safe to read before publishing (the /p/:slug page just 404s). publicUrl is built from the server's frontend_base_url and embedSnippet from public_base_url — they can be different hosts. setPublic needs owner/admin BOTH client-side (:680) and server-side (:2622).
On failure:
// agent exists but the app was never connected to it:
// Error("app is not connected to this agent") [HTTP 403]
// agent id from another org / nonexistent:
// Error("agent not found") [HTTP 404]
<sub>Source: voice_ai_rust/src/routes/app_agents.rs:284-296 (public_link_json), called from voice_ai_rust/src/routes/apps.rs:2596-2600 and :2629-2633 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:674 / :682</sub>
telenow.agents.updateConfig(agentId, patch)
Scope: agents:config:write:<group> per group (or agents:config:write)
{
"updated": ["model", "prompt"],
"config": {
"agentId": "9f1c6a2e-4b73-4d18-9a55-2c0d7e8f3b41",
"name": "Clinic Front Desk",
"description": "Books, reschedules and cancels appointments",
"kind": "single",
"updatedAt": "2026-07-29T09:14:22.481930Z",
"prompt": {
"systemPrompt": "You are Priya, the front desk assistant for Sunrise Clinic..."
},
"model": {
"provider": "xai",
"model": "grok-4-fast-non-reasoning",
"temperature": 0.4,
"maxTokens": 400,
"config": { "temperature": 0.4, "maxTokens": 400 }
},
"voice": {
"provider": "telenow",
"voice": "priya-en-in",
"config": { "speed": 1.0 }
},
"stt": {
"provider": "deepgram",
"config": { "model": "nova-3" }
},
"behavior": {
"memory_type": "short-term",
"maxDuration": 600,
"bargeInSensitivity": 0.5,
"contextMode": "window",
"contextWindow": 12,
"behavior": {
"tone": "professional_conversational",
"naturalFillers": true,
"highEmpathy": false,
"echoVerification": false,
"natoPhonetic": false,
"speechNormalization": true,
"smartMatching": true,
"aiDisclosureWhenAsked": true,
"scopeBoundaries": true
}
},
"telephony": {
"provider": "twilio",
"config": {
"firstResponse": "agent",
"agentMsg": "Hi, this is Priya from Sunrise Clinic.",
"recording": true
}
},
"flow": null,
"analysis": {
"enabled": true,
"customFields": [],
"qaCriteria": []
},
"tools": [
{
"name": "book_appointment",
"description": "Book a slot for the caller",
"kind": "connector"
}
],
"realtimeEngine": null
}
}
★ updated is NOT your patch's keys — it is the server-derived group list in the fixed GROUPS order (prompt, model, voice, stt, behavior, flow, telephony, analysis), and the group is decided by the FIELD: patching flow.nodes[0].prompt reports and authorizes prompt, not flow. ★ BRIDGE/SERVER DIVERGENCE: the bridge's client-side pre-check (:696-701) demands agents:config:write:<top-level-key>, so an app holding only …:write:prompt that sends {flow:{nodes:[{id,prompt}]}} is refused BY THE BRIDGE with this app didn't request the "agents:config:write:flow" capability even though the server would allow it. ★ analysis is a writable group (enabled / customFields / qaCriteria only; analysis.model is explicitly forbidden) but is missing from the SDK's patch type. config is the same document getConfig returns — no need for a follow-up read. Needs an owner/admin/developer user on top of the scopes.
On failure:
// stale read (ifUnmodifiedSince mismatch):
// Error("agent was modified since <ts> — re-read the config and retry") [HTTP 409]
// unknown group:
// Error("unknown config group `voices` — expected one of: prompt, model, voice, stt, behavior, flow, telephony, analysis")
// never-writable field:
// Error("`tools` cannot be changed by an app — an app may rewrite what an agent says, never what it can do")
<sub>Source: voice_ai_rust/src/routes/apps.rs:2921-2927 — group derivation voice_ai_rust/src/services/agent_config.rs:391-470; bridge pre-check voice_ai_frontend/src/components/apps/AppPageHost.tsx:696-706</sub>
telenow.ai.llm(request)
Scope: ai:llm
{
"text": "The caller wants to move their 30 July appointment to 3 August.",
"provider": "openai",
"model": "gpt-4o-mini-2024-07-18",
"byok": false,
"usage": { "inputTokens": 812, "outputTokens": 46, "estimated": false }
}
★ model is the SERVED model, not what you asked for — it comes back with the provider's version suffix when the provider reports one, so res.model !== req.model is normal. ★ byok tells you whether the org's own key paid (true) or the platform wallet did (false) — present in the response but absent from the SDK's AiLlmResult type. ★ usage.estimated:true means the provider reported NO usage and the numbers are a chars/4 guess — do not bill or budget off them. sessionId in the request is optional: pass it to attach spend to a call, omit it to meter to the org.
<sub>Source: voice_ai_rust/src/routes/app_ai.rs:491-501 — proxied at voice_ai_rust/src/routes/apps.rs:2481-2484; bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:886</sub>
telenow.ai.llmStream(request, onToken)
Scope: ai:llm
// RESOLVED VALUE (assembled IN the iframe from the final frame):
{
"text": "The caller wants to move their appointment.",
"usage": { "inputTokens": 812, "outputTokens": 46, "estimated": false },
"provider": "openai",
"model": "gpt-4o-mini-2024-07-18"
}
// onToken receives raw string deltas: "The", " caller", " wants", …
★ DIFFERENT SHAPE FROM ai.llm: no byok. The bootstrap picks exactly four keys off the final {done:true,…} frame (:307) and drops byok, so the same request through the two ops gives you differently-shaped results. text is accumulated client-side from the deltas — it is not sent by the server. The RPC itself resolves {streamId} internally but that never surfaces. A mid-stream {error} frame rejects the outer promise, so tokens already delivered to onToken are orphaned — buffer them yourself if partial output matters.
On failure:
// mid-stream provider failure → the promise REJECTS:
// Error("llm stream: upstream 429 rate_limit_exceeded")
// transport failure → Error("stream failed (502)")
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:300-312 (bootstrap assembles it), :887-902 (host opens the SSE) — server frames voice_ai_rust/src/routes/app_ai.rs:573-582</sub>
telenow.ai.models()
Scope: any of ai:llm | ai:tts | ai:stt
{
"tiers": { "llm": ["fast", "balanced", "smart"] },
"catalog": {
"llm": [
{ "id": "openai", "name": "OpenAI", "blurb": null, "keyEnvVar": "OPENAI_API_KEY",
"status": "ga", "latency": { "ms": 620, "tier": "low" },
"models": [
{ "id": "gpt-4o-mini", "name": "GPT-4o mini", "contextWindow": 128000,
"supportsFunctions": true, "supportsCaching": true,
"supportsReasoning": false, "reasoningEffortStyle": null,
"pricing": { "inputPerM": 0.15, "outputPerM": 0.6, "cachedInputPerM": 0.075 },
"latency": { "ms": 600, "tier": "low" } }
] }
],
"stt": [ /* + realtime, languages, perMinuteUsd, models */ ],
"tts": [ /* + streaming, voices, perMillionCharsUsd, models, capabilities */ ],
"telephony": [ /* + capabilities, perMinuteUsd */ ],
"s2s": [ ]
}
}
★ THE DEV MOCK LIES: createMockBridge returns {tiers:['fast','balanced','smart'], models:[]} (app-sdk-release/src/browser.ts:1027) — a FLAT array for tiers and a models key that does not exist in production. The real shape is tiers.llm (an object) plus catalog keyed by modality. Code written against the mock breaks the moment it runs for real. ★ The scope check here is an OR of three separate ensure_app_scope calls, so any one ai:* scope unlocks the whole catalog. pricing is null (not an object) when a model has no rates. Reasoning models are hidden unless REASONING_ENABLED.
<sub>Source: voice_ai_rust/src/routes/apps.rs:3059-3066 — catalog voice_ai_rust/src/services/catalog.rs:275, provider_json :829-860, model_json :758-783; bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:957</sub>
telenow.calls.arm(sessionId, opts)
Scope: calls:transcribe:live
{ "armed": true, "sessionId": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104" }
armed is always literally true on success — a failure is a rejection, never {armed:false}. Only works on call_mode = 'manual' softphone calls that are live ON THIS NODE. Once armed, subscribe with stream.subscribe(sessionId) to receive call.transcript_partial / call.turn frames. opts keys are {language, sttProvider, sttModel} — note the bridge forwards sttProvider even though the SDK doc-comment only mentions language/sttModel.
On failure:
// wrong call type: Error("live assist is only available on manual softphone calls") [400]
// call already ended: Error("call is not live") [409]
// bad tuning value: Error("invalid sttModel") [400]
<sub>Source: voice_ai_rust/src/routes/apps.rs:3105-3108 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:948-953</sub>
telenow.calls.get(sessionId)
Scope: calls:read or calls:read:org
{
"id": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104",
"agentId": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60",
"agentName": "Front Desk",
"status": "ended",
"callMode": "agent",
"channel": "telephony",
"direction": "outbound",
"startTime": "2026-07-29T09:14:32.118431Z",
"endTime": "2026-07-29T09:15:46.902117Z",
"durationSec": 74,
"fromNumber": "+918031138113",
"toNumber": "+919876543210",
"answeredBy": "human",
"disposition": "answered",
"wrapupDisposition": null,
"hasRecording": true,
"latency": {
"sttMsAvg": 310,
"turnHoldMsAvg": 120,
"llmMsAvg": 640,
"ttsMsAvg": 380,
"netRttMsAvg": null,
"flowMsAvg": 15,
"respMsAvg": 1180,
"respMsMax": 2240,
"respSamples": 8,
"audioGapCount": 0
},
"createdAt": "2026-07-29T09:14:31.004512Z",
"analysis": {
"sessionId": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104",
"orgId": "2f7c1a90-51d4-4c3e-9b8a-6d0f4e11c723",
"agentId": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60",
"status": "done",
"summary": "Caller rescheduled an appointment to Friday at 4pm.",
"sentiment": "positive",
"sentimentScore": 0.81,
"disposition": "booked",
"actionItems": ["Send the confirmation SMS for Friday 4pm"],
"customData": { "patient_id": "PT-40921" },
"evidence": {
"sentiment": "That works perfectly, thank you.",
"disposition": "Yes, book me for Friday at four."
},
"qa": [
{ "key": "verified_identity", "met": true, "evidence": "Can you confirm your date of birth?" }
],
"objections": [],
"score": 88,
"coaching": [
{
"issue": "Did not confirm the clinic address",
"suggestion": "Restate the location before closing",
"severity": "low"
}
],
"hallucinations": [],
"cx": { "rating": "good", "friction": [], "highlights": ["Rebooked in under a minute"] },
"topics": ["appointment rescheduling"],
"keywords": ["reschedule", "friday", "confirmation"],
"agentWords": 214,
"customerWords": 96,
"agentTurns": 7,
"customerTurns": 6,
"model": "openai/gpt-4o-mini",
"error": null,
"createdAt": "2026-07-29T09:16:02.441870Z",
"updatedAt": "2026-07-29T09:16:19.775104Z"
},
"analysisEnabled": true,
"transcript": [
{
"role": "assistant",
"text": "Thanks for calling Acme Clinic!",
"at": "2026-07-29T09:14:34.220118Z",
"agent_id": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60"
},
{
"role": "user",
"text": "I need to move my appointment.",
"at": "2026-07-29T09:14:39.881542Z",
"agent_id": null
}
],
"participants": { "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60": "Front Desk" }
}
★ PASS-THROUGH, so this is camelCase — the exact opposite of calls.history, which the bridge snake_cases. Two ops on the same data with two casings; do not share a row type between them. ★ analysis / analysisEnabled are ALWAYS present here (unlike history, where they are opt-in) — analysis is null when PCA never ran. ★ transcript[i].agent_id is snake_case inside an otherwise camelCase document (routes/calls.rs:628) and is null on user turns. participants is a MAP of agentId → name, {} when no turn carried an agent id. System-role messages are filtered out of transcript. A call whose agent the app is not bound to is a 403 (app is not bound to this call's agent) unless the app holds calls:read:org.
<sub>Source: voice_ai_rust/src/routes/apps.rs:2811-2824 (obj[…] assignments + reply) over voice_ai_rust/src/routes/app_data.rs:442-475; transcript voice_ai_rust/src/routes/calls.rs:620-654 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:794 (pass-through)</sub>
telenow.calls.history(filters, opts)
Scope: calls:read or calls:read:org
[
{
"id": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104",
"agent_id": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60",
"agent_name": "Front Desk",
"channel": "telephony",
"direction": "outbound",
"from_number": "+918031138113",
"to_number": "+919876543210",
"status": "completed",
"duration_sec": 74,
"start_time": "2026-07-29T09:14:32.118431Z",
"end_time": "2026-07-29T09:15:46.902117Z",
"analysis": {
"sessionId": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104",
"orgId": "3f5c1a02-77e4-4b19-9c8d-05a6ef2b7d31",
"agentId": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60",
"status": "done",
"summary": "Caller booked a follow-up for 3 Aug.",
"sentiment": "positive",
"sentimentScore": 0.81,
"disposition": "appointment_booked",
"actionItems": ["Send the confirmation SMS before 5pm"],
"customData": { "preferred_slot": "2026-08-03T10:00:00+05:30" },
"evidence": { "preferred_slot": "Let's do 3rd August at 10." },
"qa": [
{ "key": "greeted_by_name", "met": true, "evidence": "Hi Priya, this is the front desk." }
],
"objections": [],
"score": 88,
"coaching": [
{ "issue": "Did not confirm the callback number", "suggestion": "Read the number back before closing", "severity": "low" }
],
"hallucinations": [],
"cx": { "rating": "good", "friction": [], "highlights": ["Booked in under a minute"] },
"topics": ["appointment"],
"keywords": ["follow-up", "3 August"],
"agentWords": 210,
"customerWords": 88,
"agentTurns": 9,
"customerTurns": 8,
"model": "gpt-4o-mini",
"error": null,
"createdAt": "2026-07-29T09:16:02.331Z",
"updatedAt": "2026-07-29T09:16:07.884Z"
},
"analysis_enabled": true
}
]
★★ THE BIGGEST RE-PROJECTION ON THE SURFACE. REST returns {success, data:{calls:[…]}} with camelCase rows carrying callMode, answeredBy, disposition, wrapupDisposition, hasRecording, createdAt and a whole latency:{sttMsAvg,turnHoldMsAvg,llmMsAvg,ttsMsAvg,netRttMsAvg,flowMsAvg,respMsAvg,respMsMax,respSamples,audioGapCount} block. The bridge hand-builds a BARE ARRAY of ELEVEN snake_case fields and throws the rest away. If you need latency or recording presence, use calls.get. ★ analysis / analysis_enabled appear ONLY when you pass {includeAnalysis:true} — otherwise the keys are absent, not null. ★ MIXED CASING INSIDE ONE ROW: the row is snake_case but the nested analysis object is camelCase, because it is passed through untouched from the serde struct. ★ analysis:null with analysis_enabled:false means post-call analysis is OFF for that agent (default) — nothing was ever produced; flip it via agents.updateConfig({analysis:{enabled:true}}). Filters are whitelisted client-side to [agentId, callMode, status, from, to, sort, limit, offset] and anything else throws rather than being silently dropped. limit defaults 50, clamped 1–200.
On failure:
// unsupported filter (bridge-local, thrown before any request):
// Error("calls.history: unsupported filter(s) [search] — this app plane supports agentId, callMode, status, from, to, sort, limit, offset")
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:774-789 (the projection) — REST voice_ai_rust/src/routes/apps.rs:2748 + row builder voice_ai_rust/src/routes/app_data.rs:442-475; analysis struct voice_ai_rust/src/db/post_call_analysis.rs:17-57</sub>
telenow.calls.initiate(agentId, phone, variables)
Scope: calls:initiate
{
"sessionId": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104",
"status": "active",
"callStatus": "queued",
"callId": "a9f4c1d2-8e77-11f0-9c3b-0242ac120002",
"phoneNumber": "+919876543210"
}
★ FIVE fields, not the two the SDK type declares — callStatus (the carrier's own status string), callId (the carrier call SID) and phoneNumber are real and useful. status is the SESSION status, serialized lowercase ("active"|"inactive"|"ended"), and is NOT the carrier status — do not branch on it for dial outcome. HTTP is 201, not 200. On a carrier failure the server returns 502 with {success:false,error,reason,retryAfterSecs}; axios rejects and the bridge collapses it to the error STRING only, so reason/retryAfterSecs never reach the app. Role-gated on canCommunicate.
On failure:
// carrier rejected the dial (HTTP 502 body, surfaced as a rejection):
// Error("Plivo: number not enabled for this destination")
// breaker open (HTTP 502): Error("circuit open") — body also carries
// { "reason": "carrier_circuit_open", "retryAfterSecs": 12.5 } which the bridge DROPS
// DNC / quota: Error("number is on the do-not-call list")
<sub>Source: voice_ai_rust/src/routes/sessions.rs:883-891 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:725-729</sub>
telenow.calls.open(sessionId)
Scope: calls:read or calls:read:org
{ "ok": true }
★ BRIDGE-BUILT — no server call at all. The host synthesises {ok:true} and then navigate('/calls/:id'), which unmounts your iframe. The value resolves but your page is already being torn down; treat it as the last statement in a click handler.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:738-739</sub>
telenow.context / telenow.user / telenow.settings.all()
Scope: — (user.name/email need user:profile)
{
"telenow.context__standalone_page": {
"appId": "clinic-crm",
"pageId": "dashboard",
"page": "dashboard",
"title": "Clinic dashboard",
"user": {
"id": "2d1b8f36-9e04-4c57-a8d2-7f61b3e90c45",
"role": "admin",
"permissions": [
"view",
"manage_agents",
"manage_numbers",
"manage_members",
"manage_billing",
"manage_workplace",
"monitor_calls",
"manage_api_keys",
"manage_recordings",
"manage_publish"
]
},
"theme": "light"
},
"telenow.context__slot_call_detail_panel": {
"appId": "clinic-crm",
"pageId": "call-panel",
"page": "call-panel",
"title": "Patient card",
"user": {
"id": "2d1b8f36-9e04-4c57-a8d2-7f61b3e90c45",
"role": "admin",
"permissions": ["view", "manage_agents", "manage_numbers", "manage_members", "manage_billing", "manage_workplace", "monitor_calls", "manage_api_keys", "manage_recordings", "manage_publish"]
},
"callId": "b41d7e28-0c95-4f1a-8d63-2e7ab95c1104",
"agentId": "7c3e9a15-4d82-4b60-9f11-0a5c6de8321b",
"channel": "phone",
"status": "completed",
"fromNumber": "+918031138113",
"toNumber": "+919845012345",
"durationSec": 184,
"startedAt": "2026-07-29T09:14:22Z",
"theme": "light"
},
"telenow.user__no_user_profile_scope": {
"id": "2d1b8f36-9e04-4c57-a8d2-7f61b3e90c45",
"role": "developer",
"permissions": ["view", "manage_agents", "manage_numbers", "manage_api_keys", "manage_recordings"]
},
"telenow.user__with_user_profile_scope": {
"id": "2d1b8f36-9e04-4c57-a8d2-7f61b3e90c45",
"role": "admin",
"permissions": ["view", "manage_agents", "manage_numbers", "manage_members", "manage_billing", "manage_workplace", "monitor_calls", "manage_api_keys", "manage_recordings", "manage_publish"],
"name": "Priya Sharma",
"email": "[email protected]"
},
"telenow.settings.all__": {
"api_base": "https://api.acmeclinic.in",
"default_kb": "9e14c0d7-2b58-41a6-8f03-6d7c1e59b482"
},
"notes": [
"AppPageHost.tsx:144-151 builds ctx; :165 adds `theme` afterwards; there is NO success/data envelope - ctx is inlined into the iframe's <script>, not fetched.",
"AppPageHost.tsx:505 - `user` is null when nobody is signed in; :508 - `role` may be null.",
"AppPageHost.tsx:511-514 - `name` and `email` are ABSENT (not null) unless the app's granted scopes contain `user:profile`; `name` falls back to the email when firstName/lastName are empty.",
"AppPageHost.tsx:63-75 + hooks/useCurrentRole.ts:44-52 - `permissions` is RBAC_PERMS filtered by the role MATRIX, always in RBAC_PERMS order: view, manage_agents, manage_numbers, manage_members, manage_billing, manage_workplace, monitor_calls, manage_api_keys, manage_recordings, manage_publish, manage_org. owner = all 11; admin = all but manage_org; developer = the 5 shown; viewer/member = [\"view\"].",
"AppPageHost.tsx:150 - slot keys spread at the TOP LEVEL of context, never nested under `context`, and differ per slot: call_detail_panel = 8 keys (CallDetailPage.tsx:398-406); softphone_call_panel = sessionId, callMode, fromNumber, toNumber and NO callId (SoftphoneCallPanel.tsx:688); agent_builder_panel = agentId (AgentBuilderPage.tsx:5188); agents_overview_panel = agentCount (AgentsListPage.tsx:150); call_list_panel = totalCalls (CallsHistoryPage.tsx:155); dashboard_widget = orgName + a NESTED summary object (DashboardPage.tsx:170).",
"settings.all() is served from AppPageHost.tsx:347-357 (a duplicate `settings:` key at :196-199 in the same object literal is dead code - the later one wins). The value is a static snapshot injected at :156/:168; `set()` only Object.assigns the local copy, it never refetches.",
"Server side: routes/apps.rs:3225 -> services/app_settings.rs:170-184 `non_secret_view` - a FLAT map keyed by the manifest's SettingDef.key. Secret-typed defs are skipped entirely; an unset key is emitted with its manifest `default`, and omitted if there is no default. Key casing is whatever the app developer wrote in the manifest, so it does not follow the platform's camelCase convention."
]
}
NOT an RPC — hand-built by the host and inlined as JSON in the srcdoc. page is an alias of pageId. theme is stamped on at :165, so it is present even though the SDK's TelenowContext type omits it. user is null when there is no auth user; without the user:profile scope it is ONLY {id, role, permissions} — name/email are absent keys, not nulls. permissions is filtered from a fixed 11-item list (:63-75). Extra keys from contextPayload are spread in at :150 (slot mode adds e.g. callId). settings.all() is a STATIC snapshot: settings.set() does not refresh it (the bridge patches the local object at :356 but a concurrent dashboard edit is invisible). Secret-typed settings are stripped server-side and can never appear.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:144-151 (ctx), :504-514 (user), :156 (settings) — server side voice_ai_rust/src/routes/apps.rs:3223 → services/app_settings.rs:170-184</sub>
telenow.data.count(objectType, query)
{ "count": 42 }
Same POST /objects/:type/query endpoint with countOnly:true; returns before any row work. The object wrapper is real — destructure { count }, the value is not returned bare.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:635 → api/apps.ts:850-861 — REST voice_ai_rust/src/routes/apps.rs:2389</sub>
telenow.data.create(objectType, body) / .update(objectType, id, body)
{
"id": "5a6c1e90-3b47-4d82-9f15-c0e83d21b7aa",
"orgId": "3f9a1c42-77b0-4e2d-9a31-5c8e0b6d1f04",
"appId": "clinic-crm",
"objectType": "appointment",
"data": { "patient_id": "7b2f4c08-91da-4e63-85b7-0c3e1a97d254", "status": "confirmed" },
"createdBy": "2d1b8f36-9e04-4c57-a8d2-7f61b3e90c45",
"createdAt": "2026-07-29T09:14:32.118431Z",
"updatedAt": "2026-07-29T09:20:07.442901Z"
}
Returns the WHOLE row envelope, not just your fields — read back res.data.status. update is a JSONB merge (data = data || $2), so omitted keys survive; it does NOT replace the blob. Both need an owner/admin/developer role (routes/apps.rs:3282, :3316) — a viewer gets a 403 even though data.* needs no manifest scope.
On failure:
// role gate — viewer/member are read-only on app data
// rejects with: Error("Insufficient permissions") [HTTP 403 from require_role]
// unknown type: Error("unknown object type 'appointmnet'") [HTTP 400]
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:637 / :640 — REST voice_ai_rust/src/routes/apps.rs:3309 (create), :3333 (update)</sub>
telenow.data.list(objectType, query, opts)
Scope: — (app's own data, no scope needed)
[
{
"id": "5a6c1e90-3b47-4d82-9f15-c0e83d21b7aa",
"orgId": "3f9a1c42-77b0-4e2d-9a31-5c8e0b6d1f04",
"appId": "clinic-crm",
"objectType": "appointment",
"data": {
"patient_id": "7b2f4c08-91da-4e63-85b7-0c3e1a97d254",
"slot_start": "2026-08-03T10:30:00Z",
"status": "confirmed",
"patient_id__expanded": { "name": "Asha Nair", "phone": "+919876543210" }
},
"createdBy": "2d1b8f36-9e04-4c57-a8d2-7f61b3e90c45",
"createdAt": "2026-07-29T09:14:32.118431Z",
"updatedAt": "2026-07-29T09:14:32.118431Z"
}
]
★ BRIDGE RE-PROJECTS: REST returns {success, data:{objects, nextCursor}}; the bridge hands the app the BARE ARRAY (.objects) and DROPS nextCursor — there is no way to page past limit through the bridge. ★ orgId is on every row (AppObjectRow is rename_all="camelCase") even though the SDK's AppRecord type omits it. ★ Your fields are NESTED under data — it is rows[i].data.status, never rows[i].status. ★ expand embeds under data.<field>__expanded (services/app_object_enrich.rs:459) — also inside data, and the original id field is left in place. To-one ⇒ object (null when the target is missing), to-many ⇒ array. updatedAt is always present (equals createdAt on a fresh row) despite being optional in the SDK type. limit defaults 200, clamped 1–500.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:624-633 (.objects extraction) — REST voice_ai_rust/src/routes/apps.rs:2427-2430; row struct voice_ai_rust/src/db/app_objects.rs:11-23</sub>
telenow.data.remove(objectType, id)
{ "ok": true }
★ BRIDGE-BUILT. The REST handler returns {"success": true} with NO data key at all (voice_ai_rust/src/routes/apps.rs:3351), so unwrap() would yield undefined; the bridge discards the response and synthesises {ok:true}. A missing record is a rejection (Error("record not found")), never {ok:false}.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:643-644</sub>
telenow.data.subscribe(objectType, onChange)
{
"_subscribe_resolves_to": "an unsubscribe FUNCTION, not JSON (AppPageHost.tsx:217-225). The host's {streamId} (AppPageHost.tsx:1010) is consumed inside the bootstrap and never reaches the app. Rejects (throws) if objectType is not declared by the installed app.",
"_onChange_frame_created_or_updated": {
"event": "updated",
"objectType": "appointment",
"id": "9f1c7a2e-4b06-4de2-8a31-6c0f2b7d5e14",
"data": {
"patient_name": "Asha Menon",
"phone": "+919876543210",
"slot_start": "2026-07-31T10:30:00Z",
"provider": "Dr. Iyer",
"reason": "follow-up",
"status": "confirmed"
}
},
"_onChange_frame_deleted": {
"event": "deleted",
"objectType": "appointment",
"id": "9f1c7a2e-4b06-4de2-8a31-6c0f2b7d5e14",
"data": null
},
"_unsubscribe_resolves_to": {
"ok": true
},
"_notes": {
"construction_site_frame": "voice_ai_rust/src/websocket/object_change_stream.rs:73-78",
"construction_site_data_payload": "voice_ai_rust/src/db/app_objects.rs:46,122,154,181,212 — publishes &row.data (the JSONB field bag) only",
"construction_site_unsubscribe": "voice_ai_frontend/src/components/apps/AppPageHost.tsx:1015",
"casing": "envelope keys are camelCase (event/objectType/id/data); keys INSIDE data are the manifest's own — snake_case in every shipped app",
"nesting": "fields are at frame.data.<field>, never frame.<field>; id is top-level and data carries no id/createdAt/updatedAt",
"option_fields": "data is emitted as null on deleted, not omitted",
"ui_bridge": "no re-projection — the host relays the parsed WS frame verbatim (AppPageHost.tsx:1003-1006)",
"errors": "surface as a rejected Promise (new Error(d.error), AppPageHost.tsx:191), not as {ok:false}; socket close pushes no terminal frame (AppPageHost.tsx:1008) — onChange just stops"
}
}
The RPC itself resolves {streamId} (a host-side crypto.randomUUID, :1010) but the bootstrap swallows it and hands back a closure — the app NEVER sees a streamId. Calling the closure returns a promise resolving {ok:true}. The host holds the WebSocket; on unmount the effect cleanup closes every socket (:1069).
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:217-225 (bootstrap wrapper), :987-1010 (host opens the WS), :1011-1015 (unsubscribe)</sub>
telenow.files.delete(path)
Scope: files:write
{ "deleted": true }
deleted:false is a REAL value here (unlike links.revoke) — it means no metadata row matched, so nothing was removed. Deleting a nonexistent path is not an error.
<sub>Source: voice_ai_rust/src/routes/apps.rs:1134 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:1031</sub>
telenow.files.extract(path)
Scope: files:read
{
"text": "INTAKE FORM\nName: Asha Nair\nDOB: 1989-04-12\nAllergies: penicillin\n…",
"chars": 4187,
"truncated": false
}
Gated on files:read, NOT an ai:* scope, and it costs nothing — it parses a blob, it calls no model, despite living under the /ai/ path prefix. ★ chars is text.chars().count() (Unicode scalar values), so for non-ASCII text it will NOT equal text.length in JS (which counts UTF-16 code units). truncated:true means the document exceeded the AI gateway's input limit and the text was cut to fit.
On failure:
// row/tabular formats are refused outright:
// Error("csv/json/xlsx hold rows — read the blob with files.get and parse it")
// scanned / image-only PDF: Error("no extractable text")
<sub>Source: voice_ai_rust/src/routes/app_ai.rs:664 (run_extract) — proxied voice_ai_rust/src/routes/apps.rs:2500-2503; bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:936</sub>
telenow.files.get(path)
Scope: files:read
// RESOLVED VALUE: a raw ArrayBuffer (byteLength 20418), NOT JSON
const buf = await telenow.files.get('exports/q3.csv');
new TextDecoder().decode(buf).slice(0, 40); // "patient,slot,status\nAsha Nair,2026-08-"
★ THE ONE OP THAT DOES NOT RETURN JSON. The response is the raw body, not a {success,data} envelope, and it crosses postMessage as a structured-cloned ArrayBuffer. There is no contentType alongside it — get that from files.list. Server sets X-Content-Type-Options: nosniff and Content-Disposition: attachment.
<sub>Source: voice_ai_frontend/src/api/apps.ts:407-410 (responseType:'arraybuffer', returns r.data) — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:1023; server voice_ai_rust/src/routes/apps.rs:1113-1124</sub>
telenow.files.list(prefix)
Scope: files:read
[
{ "path": "exports/q3.csv", "contentType": "text/csv", "sizeBytes": 20418, "updatedAt": "2026-07-29T09:14:32.118431Z" },
{ "path": "intake/asha-nair.pdf", "contentType": "application/pdf", "sizeBytes": 184922, "updatedAt": "2026-07-28T17:41:09.220Z" }
]
★ BRIDGE RE-PROJECTS the envelope away: REST returns {success, data:{files:[…]}}, the app gets the BARE ARRAY. Rows are camelCase via serde rename_all. contentType is nullable. Hard-capped at 1000 rows server-side (app_blob_ops.rs:119) with no cursor — a prefix filter is the only way to page. No bytes here.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:1019 (.files extraction) — REST voice_ai_rust/src/routes/apps.rs:1092; row voice_ai_rust/src/db/app_blobs.rs:18-25</sub>
telenow.files.put(path, body)
Scope: files:write
{ "path": "exports/q3.csv", "size": 20418 }
size (not sizeBytes — the list op uses the other name for the same quantity). path is echoed from the URL segment. Upsert keyed on (org, app, path): re-PUTting overwrites both the blob and the metadata row.
<sub>Source: voice_ai_rust/src/routes/apps.rs:1104 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:1027</sub>
telenow.http(request)
Scope: http:<host> (+ connection:<provider> when using connection)
{
"status": 200,
"headers": {
"content-type": "application/json; charset=utf-8",
"x-request-id": "req_9fA2kQ"
},
"body": "{\"ok\":true,\"id\":\"cus_QX8mZ2\"}"
}
★ body is ALWAYS A STRING — JSON.parse it yourself; the proxy never parses. ★ A non-2xx UPSTREAM status is a SUCCESSFUL resolve with status: 404 — only proxy-level failures reject. Branch on res.status, do not rely on try/catch. ★ headers keys are LOWERCASED, set-cookie and hop-by-hop headers are stripped. Body is capped at 1 MB (a larger response rejects rather than truncating), and non-UTF-8 bytes are lossily converted. This is the ONE bridge op with no client-side gate — the server is the sole authority on the host allowlist + SSRF guard.
On failure:
// host not in the app's granted http:<host> scopes:
// Error("host api.stripe.com is not in this app's allowed hosts") [403]
// response too large: Error("response exceeds 1 MB") [413]
// upstream unreachable: Error("request failed: connection refused") [400]
<sub>Source: voice_ai_rust/src/routes/apps.rs:2126-2132 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:1032-1036</sub>
telenow.kb.list()
Scope: kb:read
{
"knowledgeBases": [
{ "id": "9e14c0d7-2b58-41a6-8f03-6d7c1e59b482", "name": "Clinic FAQ" },
{ "id": "41b6a2f5-7d90-4c38-a51e-2f8b04c96d73", "name": "Insurance policies" }
]
}
An OBJECT, not a bare array — destructure { knowledgeBases }. Only the app's OWN KBs for this org (app_kbs_full), never the org's other knowledge bases. The SDK declares no return type for this op.
<sub>Source: voice_ai_rust/src/routes/apps.rs:3001-3004 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:943 (pass-through)</sub>
telenow.kb.search(kbId, request)
Scope: kb:read
{
"results": [
{ "chunkId": "d3f0a917-88c4-4b62-9e05-71ab3cd48260",
"docId": "6c15e408-2a7d-4f19-b3e6-90d5c72a1f38",
"seq": 4,
"text": "Follow-up consultations within 14 days are not charged separately." }
]
}
An OBJECT wrapping results, not a bare array. NO similarity score is returned — results are ordered by relevance and that ordering is all you get. topK defaults to 5 and is CLAMPED to 1–8, so asking for 50 silently gives you 8. seq is the chunk's position within its document.
On failure:
// KB belongs to another app or org: Error("knowledge base not found") [404]
// blank query: Error("query is required") [400]
<sub>Source: voice_ai_rust/src/routes/apps.rs:2980-2985 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:940 (pass-through)</sub>
telenow.links.list(query)
Scope: links:read
{
"links": [
{
"id": "7e2c9b40-1d63-4a85-b0f7-59ce8a134d26",
"action": "agent_call",
"targetType": "appointment",
"targetId": "5a6c1e90-3b47-4d82-9f15-c0e83d21b7aa",
"expiresAt": "2026-08-05T09:14:32.118431Z",
"opensAt": null,
"singleUse": true,
"usedAt": null,
"revokedAt": null,
"openCount": 2,
"lastOpenedAt": "2026-07-29T11:03:18.552Z",
"createdAt": "2026-07-29T09:14:32.118431Z",
"requiresCode": false
}
]
}
An OBJECT wrapping links. ★ No token field — by design. openCount counts opens, usedAt marks consumption of a single-use link: a link can have openCount > 0 and usedAt null. Query filters: targetType, targetId, status ('active'|'used'|'expired'|'revoked'), limit — 'expired' is a computed predicate (expires_at < NOW AND not used AND not revoked), not a stored column. Ordered createdAt DESC. NOTE the bridge does not role-gate this op, only scope-gates it.
<sub>Source: voice_ai_rust/src/routes/app_links.rs:272-287 (run_list) — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:921-925</sub>
telenow.links.mint(request)
Scope: links:write
{
"id": "7e2c9b40-1d63-4a85-b0f7-59ce8a134d26",
"token": "N2UyYzliNDAtMWQ2My00YTg1LWIwZjctNTljZThhMTM0ZDI2LmE5ZDRjMjEwMzhmNzRlNmJiNWMwZDdlMTlmMjRhYjgzLjE3ODU5MjEyNzI.6CfHPqS6TYHtijVni0qOoIFZJgE7B5zyogu098FZhY4",
"url": "https://app.telenow.ai/l/N2UyYzliNDAtMWQ2My00YTg1LWIwZjctNTljZThhMTM0ZDI2LmE5ZDRjMjEwMzhmNzRlNmJiNWMwZDdlMTlmMjRhYjgzLjE3ODU5MjEyNzI.6CfHPqS6TYHtijVni0qOoIFZJgE7B5zyogu098FZhY4",
"expiresAt": "2026-08-05T09:14:32.118431Z",
"singleUse": true,
"requiresCode": false
}
★ token is returned exactly ONCE and is not stored in plaintext — links.list never returns it. Persist url if you will need it again; there is no read-back. singleUse defaults true when omitted from the request. requiresCode reflects whether you supplied code (stored hashed, never returned). For an agent_call link, context.slug must be the agent's PUBLIC SLUG from agents.publicLink — resolution is by slug, not agentId.
<sub>Source: voice_ai_rust/src/routes/app_links.rs:174-187 (run_mint) — proxied voice_ai_rust/src/routes/apps.rs:2517-2519; bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:918</sub>
telenow.links.revoke(linkId)
Scope: links:write
{ "revoked": true }
Idempotent and ALWAYS true on a 2xx — revoking an already-revoked or nonexistent-in-this-app link is not an error, so this value cannot be used to test whether a link existed. Role-gated on canCommunicate in addition to the scope.
<sub>Source: voice_ai_rust/src/routes/app_links.rs:325 (run_revoke) — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:930</sub>
telenow.members.list()
Scope: members:read (email additionally needs user:profile)
{
"members": [
{
"userId": "2d1b8f36-9e04-4c57-a8d2-7f61b3e90c45",
"name": "Priya Sharma",
"firstName": "Priya",
"lastName": "Sharma",
"role": "admin",
"email": "[email protected]"
},
{
"userId": "be07c3a1-5f42-4d88-9016-c72e5b0da934",
"name": "",
"firstName": null,
"lastName": null,
"role": "member",
"email": "[email protected]"
}
],
"total": 2
}
★ name is "" (empty string, not null) when the account has neither first nor last name — render a fallback or you ship a blank cell. firstName/lastName are independently nullable and are joined with a single space only when both exist. ★ email is an ABSENT KEY (not null) unless the install holds user:profile as well. total is just members.length — there is no pagination, the query returns the whole roster ordered by join date.
<sub>Source: voice_ai_rust/src/routes/apps.rs:3128-3153 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:960 (pass-through)</sub>
telenow.session.token()
Scope: session:token
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyZDFiOGYzNi05ZTA0LTRjNTctYThkMi03ZjYxYjNlOTBjNDUiLCJvcmdfaWQiOiIzZjlhMWM0Mi03N2IwLTRlMmQtOWEzMS01YzhlMGI2ZDFmMDQiLCJhcHBfaWQiOiJjbGluaWMtY3JtIiwicm9sZSI6ImFkbWluIiwiYXVkIjoiYXBwOmNsaW5pYy1jcm0iLCJleHAiOjE3ODUzMTIwMDAsImlhdCI6MTc4NTMxMDIwMH0.qz3Vh0Xk8s2m1nA5yQd7B_pLcRt9WfEu4KjNhGvIsYo",
"expiresIn": 1800
}
expiresIn is SECONDS (fixed 1800 = 30 min), not a timestamp. HS256, signed with the install's signing secret — your backend verifies it with the secret it already has. Claims: sub (user id), org_id, app_id, role, aud: "app:<appId>", exp, iat, jti; email is included only when the install ALSO holds user:profile. This is the ONLY server-to-server identity path — telenow.user is UI RBAC and is not verifiable.
<sub>Source: voice_ai_rust/src/routes/apps.rs:1700 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:881</sub>
telenow.settings.set(values)
Scope: — (owner/admin role only)
{ "ok": true }
★ BRIDGE-BUILT. The REST PATCH really returns the whole {schema, values, secretsSet} AppSettings document (voice_ai_rust/src/routes/apps.rs:3532+), but the bridge awaits and DISCARDS it, replying {ok:true}. ★ The bootstrap then merges your values into the local SETTINGS snapshot (:356) — so settings.get() reflects what YOU wrote but not the server's canonical view (defaults it filled in, or a concurrent dashboard edit). Keep your own state or reload. Secrets are not writable here. Requires owner/admin — deliberately narrower than the canCommunicate bar used by the messaging ops.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:903-912</sub>
telenow.softphone.dial(phone)
Scope: softphone:dial
{ "ok": true, "opened": true }
★ BRIDGE-BUILT, NO NETWORK CALL. The host dispatches a dialer:prefill CustomEvent on the dashboard window and returns immediately. opened:true means the event was dispatched, NOT that a dialer is mounted or that the user dialled — there is no callback and no way to learn the outcome. The user must still press Dial (consent stays in the trusted dashboard).
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:877-879</sub>
telenow.stream.subscribe(sessionId, onEvent)
Scope: calls:read or calls:read:org
// RESOLVED VALUE: an unsubscribe function
const off = await telenow.stream.subscribe(sessionId, onEvent);
off(); // → Promise<{ ok: true }>
Resolves to a closure, not a streamId (same pattern as data.subscribe). The ticket is single-use with a ~30s TTL and is exchanged by the HOST — the iframe never sees the ticket or the ws URL. Subscribing to a call that has already ended REJECTS at ticket-mint time; it does not resolve into a silent stream.
On failure:
// call not live on this node: Error("Call is not live") [HTTP 409 from stream-ticket]
// foreign / unknown call: Error("Call not found") [HTTP 404]
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:330-338 (bootstrap), :962-986 (host mints the ticket + opens the WS) — ticket voice_ai_rust/src/routes/calls.rs:858-862</sub>
telenow.whatsapp.channels()
Scope: whatsapp:send
[
{ "id": "c1a7e3d8-52b9-4f06-9e14-8b0d75a3c621", "label": "Clinic reception", "phone": "+919876500000", "kind": "cloud" },
{ "id": "f0d4b917-6c28-4a53-b7ef-1d9c30ea8452", "label": "Team WhatsApp", "phone": "+919876500001", "kind": "web" }
]
★ BRIDGE RE-PROJECTS + FILTERS. The REST row (GET /orgs/:id/whatsapp/channels) is SNAKE_CASE with ~15 fields (org_id, kind, session_id, connection_id, provider_id, webhook_status, agent_id, ai_enabled, calling_enabled, calling_agent_id, created_by, created_at, updated_at — see voice_ai_frontend/src/api/whatsappHub.ts:14-31). The bridge keeps FOUR keys and REWRITES kind: the raw kind column is discarded and a new one is derived from provider_id (telenow_whatsapp_cloud → "cloud", telenow_whatsapp → "web"). ★ Channels on any other provider are FILTERED OUT of the array entirely, so this list can be shorter than the dashboard's. Branch on kind: 'cloud' outside the 24h window requires sendTemplate. phone may be null.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:809-823</sub>
telenow.whatsapp.send(channelId, to, message)
Scope: whatsapp:send
{ "ok": true }
★ BRIDGE-BUILT — the REST call really returns {success:true, data:{wamid:"wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSMkE4RTZE…"}} (voice_ai_rust/src/routes/whatsapp_hub.rs:510) but the bridge awaits it and DISCARDS it, synthesising {ok:true}. You cannot get the message id back through this op — use sendTemplate (which does pass it through) if you need one for reconciliation.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:828-829</sub>
telenow.whatsapp.sendTemplate(channelId, to, opts)
Scope: whatsapp:send
{ "wamid": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSMkE4RTZEMDlDMkYxQTk0RjE3AA==" }
★ INCONSISTENT WITH send: this one PASSES THE PAYLOAD THROUGH, so you get {wamid} while the plain send gives {ok:true}. The SDK types it Promise<unknown> and the dev-mock returns {ok:true} (app-sdk-release/src/browser.ts:928) — so code written against the mock will read wamid as undefined in production. ★ The bridge ASSEMBLES the WhatsApp components array for you (:846-859): urlSuffix becomes a {type:'button', sub_type:'url', index:'0'} component, and variables becomes a body component. Putting a URL token in variables produces a message that looks perfect but whose button points at the bare base URL. Language defaults to en_US server-side.
<sub>Source: voice_ai_rust/src/routes/whatsapp_hub.rs:791 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:860-869 (pass-through)</sub>
ANY bridge op — the rejection shape
// Every failure is a REJECTED PROMISE with a plain Error. There is no
// {ok:false} object, no status code, and no structured body:
try {
await telenow.calls.history({ agentId });
} catch (e) {
e.message; // "this app didn't request the \"calls:read\" capability"
// "your role does not permit this action"
// "this install has not granted the agents:config:read scope. If the app's
// current manifest declares it, the org is still on an older installed
// version — upgrade the app in Settings → Apps to grant the new scopes"
// "this app is not currently active for this organization"
// "unknown op 'calls.list'"
}
★ THE HTTP ERROR ENVELOPE NEVER REACHES THE APP. The server sends {success:false, error, code?} with a real status (404/403/409/413/429); the host's errorMessage() (voice_ai_frontend/src/utils/error.ts:13-20) collapses it to data.error ?? data.message ?? e.message, a bare string, and everything else — status, code, retryAfter, the 422 errors/checklist arrays, reason/retryAfterSecs from a failed dial — is DISCARDED. You cannot distinguish 403-from-scope, 404-from-missing and 409-from-conflict except by matching the message text. ★ Three distinct gates produce look-alike messages: the bridge's own client-side scope check (this app didn't request the "X" capability), its role check (your role does not permit this action), and the SERVER's scope check (this install has not granted the X scope… — which means the org is on an older installed version and must upgrade, not that the manifest is wrong). ★ An unknown op name also rejects, so a typo fails at runtime with unknown op 'x', never silently.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:1041-1043 (reply({ok:false, error: errorMessage(err)})) → bootstrap :191 (p.reject(new Error(d.error))); bridge-local gates :531, :537, :540; server envelope voice_ai_rust/src/error.rs:105-124</sub>
AI · KB · files · billing · links · members
The remaining app-key REST planes.
Bare-success writes: DELETE /api/app-kb/{kbId} · DELETE …/documents/{docId} · POST …/attach · DELETE …/attach/{agentId} · POST /api/app-campaigns/{id}/pause · /cancel
Scope: kb:write · campaigns:write
{
"success": true
}
★ THERE IS NO data KEY AT ALL on these six routes — not data:null, not data:{}. A client that does const { data } = await res.json() gets undefined, and the published SDK's remove()/attach()/detach() typed as Promise<void> returns undefined for exactly this reason. Check success (or the HTTP status), never the presence of data. Delete of a KB is a soft delete; attach/detach additionally require the app to be BOUND to the agent.
On failure:
403 {"success":false,"error":"app is not bound to this agent"}
404 {"success":false,"error":"knowledge base not found"}
<sub>Source: voice_ai_rust/src/routes/app_kb.rs:202, :347, :401, :420; voice_ai_rust/src/routes/app_campaigns.rs:261, :274</sub>
GET /api/app-ai/models
Scope: any one of ai:llm | ai:tts | ai:stt
{
"success": true,
"data": {
"tiers": { "llm": ["fast", "balanced", "smart"] },
"catalog": {
"llm": [
{
"id": "openai",
"name": "OpenAI",
"blurb": "Industry-leading reasoning, broad tool-call support.",
"keyEnvVar": "OPENAI_API_KEY",
"status": "ga",
"latency": { "ms": 0, "tier": "low" },
"models": [
{
"id": "gpt-4o-mini",
"name": "GPT-4o mini",
"contextWindow": 128000,
"supportsFunctions": true,
"supportsCaching": true,
"supportsReasoning": false,
"reasoningEffortStyle": null,
"pricing": { "inputPerM": 0.15, "outputPerM": 0.6, "cachedInputPerM": 0.075 },
"latency": { "ms": 280, "tier": "low" }
}
]
}
],
"stt": [
{
"id": "deepgram",
"name": "Deepgram",
"blurb": "Best-in-class streaming WER and sub-300 ms partials.",
"keyEnvVar": "DEEPGRAM_API_KEY",
"status": "ga",
"latency": { "ms": 230, "tier": "realtime" },
"realtime": true,
"languages": ["en", "es", "fr", "de", "hi", "pt"],
"perMinuteUsd": 0.0058,
"models": [
{ "key": "nova-3", "name": "Nova 3 (multilingual)", "configKey": "model", "perMinuteUsd": 0.0058 }
]
}
],
"tts": [
{
"id": "elevenlabs",
"name": "ElevenLabs",
"blurb": "Most natural prosody; great for branded voices.",
"keyEnvVar": "ELEVENLABS_API_KEY",
"status": "ga",
"latency": { "ms": 320, "tier": "low" },
"streaming": true,
"voices": [
{
"id": "21m00Tcm4TlvDq8ikWAM",
"name": "Rachel",
"tags": ["warm", "narrative"],
"gender": "female",
"language": "en"
},
{
"id": "pNInz6obpgDQGcFmaJgB",
"name": "Adam",
"tags": ["deep", "authoritative"],
"gender": "male",
"language": "en"
}
],
"perMillionCharsUsd": 50.0,
"models": [
{
"key": "eleven_turbo_v2_5",
"name": "Turbo v2.5",
"configKey": "modelId",
"perMillionCharsUsd": 50.0
}
],
"capabilities": { "cloning": true }
}
],
"telephony": [
{
"id": "plivo",
"name": "Plivo",
"blurb": "Inbound + outbound, India compliance support.",
"keyEnvVar": null,
"status": "ga",
"latency": { "ms": 180, "tier": "realtime" },
"capabilities": { "inbound": true, "outbound": true, "recording": true, "compliance": true },
"perMinuteUsd": 0.014
}
],
"s2s": [
{
"id": "openai",
"name": "OpenAI Realtime",
"blurb": "One realtime model handles listening, thinking and speaking — lowest voice-to-voice latency.",
"keyEnvVar": "OPENAI_API_KEY",
"status": "ga",
"latency": { "ms": 0, "tier": "realtime" },
"models": [
{
"providerId": "openai",
"modelKey": "gpt-realtime-2.1",
"name": "GPT Realtime 2.1",
"voices": ["marin", "cedar", "alloy", "ash", "ballad", "coral", "echo", "sage", "shimmer", "verse"],
"contextWindow": 128000,
"audioInPerMUsd": 32.0,
"audioOutPerMUsd": 64.0,
"textInPerMUsd": 4.0,
"textOutPerMUsd": 24.0,
"cachedAudioInPerMUsd": 0.4,
"cachedTextInPerMUsd": 0.4
}
]
}
]
}
}
}
★ catalog has EXACTLY five keys — llm, stt, tts, telephony, s2s — each an ARRAY of providers, never a map keyed by provider id. ★ The provider object's key set VARIES BY KIND: llm has models only; stt adds realtime/languages/perMinuteUsd; tts adds streaming/voices/perMillionCharsUsd/capabilities; telephony has capabilities/perMinuteUsd and NO models key at all; s2s has models with a completely different member shape (modelKey, not id). ★ LLM models key on id; tts/stt models key on key; s2s models key on modelKey. pricing is null (not {}) when both per-million rates are unset. All prices are f64 NUMBERS here (contrast: billing money is decimal STRINGS). ★ This route calls list_all(false) directly, so s2s is ALWAYS present — unlike the public /api/catalog route, which strips it while S2S_ENGINE_ENABLED is off. Reasoning models are hidden unless REASONING_ENABLED is on. Dashboard-proxy twin GET /api/orgs/{orgId}/apps/{appId}/ai/models emits the identical body (routes/apps.rs:3058-3064).
On failure:
403 {"success":false,"error":"requires an ai:llm, ai:tts or ai:stt scope"}
<sub>Source: voice_ai_rust/src/routes/app_ai.rs:839-845 (envelope + tiers); services/catalog.rs:274 (the 5-key catalog object), :829-868 (provider_json), :758-783 (model_json), :786-793 (tts_model_json), :797-805 (stt_model_json), :811-825 (s2s_model_json)</sub>
GET /api/app-billing/charges
Scope: billing:read
{
"success": true,
"data": {
"charges": [
{
"sessionId": "6a1d0f37-9c25-4e8b-b3f0-71c4a2e59d86",
"agentId": "3f5a91c2-8e47-4b0d-9c11-6a2f8d7e4b13",
"agentName": "Support Line",
"callType": "agent_voice",
"startTime": "2026-07-29T08:41:02.518473Z",
"llmUsd": "0.0041000000",
"sttUsd": "0.0125000000",
"ttsUsd": "0.0088000000",
"telephonyUsd": "0.0210000000",
"platformAiUsd": "0.0006000000",
"postCallAnalysisUsd": "0.0000000000",
"simulationUsd": "0.0000000000",
"embeddingUsd": "0.0000000000",
"platformFeeUsd": "0.0175000000",
"featureSurchargeUsd": "0.0000000000",
"totalChargeUsd": "0.0645000000",
"hasEstimates": false,
"breakdown": {
"billedMinutes": "1.75",
"feePercent": "10",
"feePerMinUsd": "0.005",
"softphoneFee": false,
"feeOverridden": false,
"durationSecs": 103,
"telephonyBilledSecs": 105,
"demo": false,
"featureSurchargeUsd": "0",
"featureSurcharges": [],
"feeRule": null
},
"ratedAt": "2026-07-29T08:43:37.902114Z"
}
],
"total": 184,
"limit": 50,
"offset": 0,
"hasMore": true
}
}
Nested at data.charges with sibling pagination keys. ★ EVERY money field is a decimal STRING, not a number — rust_decimal is built with the serde feature and no serde-float (Cargo.toml:93), so it serializes via to_string(). The columns are NUMERIC(20,10) (migrations/0065), so Postgres returns scale 10 and you get "0.0041000000". Parse with a decimal library, never +. ★ breakdown money values are ALSO strings but keep whatever scale the settlement computation produced ("1.75", "0") — different scale from the top-level fields. ★ breakdown.events[] is REMOVED unless you pass ?includeEvents=true (app_billing.rs:102-107); all other breakdown keys always survive. ★ COST BASIS IS STRIPPED: there is no totalCostUsd, and each event loses costUsd (db/billing.rs:544-560). agentId/agentName/startTime are nullable (LEFT JOINs). hasMore is computed from this page only: offset + charges.len() < total.
On failure:
403 {"success":false,"error":"app did not declare the billing:read scope"}
<sub>Source: voice_ai_rust/src/routes/app_billing.rs:145-154 (page envelope), :98-108 (charge_to_json); db/billing.rs:566-588 (AppChargeRow, #[serde(rename_all="camelCase")]); utils/billing_settlement_worker.rs:796-818 (the breakdown object)</sub>
GET /api/app-billing/charges/{sessionId}
Scope: billing:read
{
"success": true,
"data": {
"sessionId": "6a1d0f37-9c25-4e8b-b3f0-71c4a2e59d86",
"agentId": "3f5a91c2-8e47-4b0d-9c11-6a2f8d7e4b13",
"agentName": "Support Line",
"callType": "agent_voice",
"startTime": "2026-07-29T08:41:02.518473Z",
"llmUsd": "0.0041000000",
"sttUsd": "0.0125000000",
"ttsUsd": "0.0088000000",
"telephonyUsd": "0.0210000000",
"platformAiUsd": "0.0006000000",
"postCallAnalysisUsd": "0.0000000000",
"simulationUsd": "0.0000000000",
"embeddingUsd": "0.0000000000",
"platformFeeUsd": "0.0175000000",
"featureSurchargeUsd": "0.0000000000",
"totalChargeUsd": "0.0645000000",
"hasEstimates": false,
"breakdown": {
"events": [
{
"kind": "llm",
"provider": "openai",
"model": "gpt-4o-mini",
"inputQty": "4120.000000",
"outputQty": "318.000000",
"chargeUsd": "0.0041",
"estimated": false,
"byok": false,
"purpose": null,
"cachedTokens": 1024
},
{
"kind": "platform_ai",
"provider": "openai",
"model": "gpt-4o-mini",
"inputQty": "980.000000",
"outputQty": "142.000000",
"chargeUsd": "0.0006",
"estimated": false,
"byok": false,
"purpose": "app:live-coach",
"cachedTokens": null
},
{
"kind": "stt",
"provider": "Telenow Smart",
"model": null,
"inputQty": "103.000000",
"outputQty": "0",
"chargeUsd": "0.0125",
"estimated": false,
"byok": false,
"composite": "telenow_smart"
}
],
"billedMinutes": "1.75",
"feePercent": "10.0000000000",
"feePerMinUsd": "0.0050000000",
"softphoneFee": false,
"feeOverridden": false,
"durationSecs": 103,
"telephonyBilledSecs": 105,
"demo": false,
"featureSurchargeUsd": "0",
"featureSurcharges": [],
"feeRule": null
},
"ratedAt": "2026-07-29T08:43:37.902114Z"
}
}
★ data is the charge object FLAT (no charge wrapper), and unlike the list route it ALWAYS carries breakdown.events. ★ data is null — with HTTP 200 — while the session is still unsettled (settlement lands ~2-3 min after hangup) or when the session isn't this org's; there is no 404 for either case, so poll on data !== null. ★ Per-event: costUsd is stripped, chargeUsd survives; purpose/cachedTokens come from the event's meta and are ABSENT-or-null when unset; a Telenow Smart STT line is a single collapsed row carrying composite:"telenow_smart" and no purpose/cachedTokens keys at all. inputQty/outputQty are Decimal strings.
On failure:
200 {"success":true,"data":null} // still unsettled, or not this org's session
<sub>Source: voice_ai_rust/src/routes/app_billing.rs:170 (charge_to_json(r, true) — events always kept here); utils/billing_settlement_worker.rs:486-500 (per-event line), :512-525 (the collapsed Telenow Smart line)</sub>
GET /api/app-billing/wallet
Scope: billing:read
{
"success": true,
"data": {
"mode": "prepaid",
"suspended": false,
"balanceUsd": 128.4732,
"currency": "INR",
"walletRate": 88.5,
"walletNative": 11369.8782
}
}
★ These money fields are JSON NUMBERS (Decimal::to_f64() → Option<f64>), which is the OPPOSITE of /charges where every money field is a decimal STRING. Don't share a parser. ★ mode is "prepaid" | "postpaid"; when it is "postpaid", currency, walletRate and walletNative are ALL null (app_billing.rs:61) — only mode, suspended, balanceUsd are meaningful. ★ When the org has no billing_accounts row at all the whole payload is {"success":true,"data":null} (app_billing.rs:47) — a 200, not a 404. walletNative = balanceUsd × walletRate at the pinned purchase rate, which is what the org's own Billing page shows.
On failure:
200 {"success":true,"data":null} // org has no billing account yet
403 {"success":false,"error":"app did not declare the billing:read scope"}
<sub>Source: voice_ai_rust/src/routes/app_billing.rs:63-76; db/billing_money.rs:17-34 (BillingAccount)</sub>
GET /api/app-campaigns
Scope: campaigns:read
{
"success": true,
"data": [
{
"id": "e2b8c4d6-5a17-4c93-8f2e-1d0b6a37f954",
"name": "July winback",
"agentId": "3f5a91c2-8e47-4b0d-9c11-6a2f8d7e4b13",
"status": "running",
"totalTargets": 428,
"completedTargets": 191,
"failedTargets": 7,
"createdAt": "2026-07-29T09:20:11.482301+00:00"
}
]
}
★ data IS THE ARRAY — no campaigns wrapper. This breaks the pattern of every other app-plane list route (data.knowledgeBases, data.links, data.members, data.charges, data.files). ★ createdAt here is .to_rfc3339(), so it ends in "+00:00" — every other timestamp on the app planes goes through chrono's serde and ends in "Z". Same string parses either way, but a strict regex will miss it. Only these 8 keys are projected; concurrency, window, retry policy, app_meta and startedAt/completedAt are NOT exposed. status ∈ draft | running | paused | completed | cancelled. Capped at 200 rows, newest first, no pagination params.
<sub>Source: voice_ai_rust/src/routes/app_campaigns.rs:227-228 (envelope), :296-307 (summarize)</sub>
GET /api/app-campaigns/{id}
Scope: campaigns:read
{
"success": true,
"data": {
"id": "e2b8c4d6-5a17-4c93-8f2e-1d0b6a37f954",
"name": "July winback",
"agentId": "3f5a91c2-8e47-4b0d-9c11-6a2f8d7e4b13",
"status": "running",
"totalTargets": 428,
"completedTargets": 191,
"failedTargets": 7,
"createdAt": "2026-07-29T09:20:11.482301+00:00",
"callCounts": {
"pending": 230,
"dialing": 5,
"completed": 186,
"failed": 7
}
}
}
The list row PLUS a callCounts object. ★ callCounts is a GROUP BY result, so a status with zero rows is ABSENT rather than 0 — always read it as callCounts.skipped ?? 0. Possible keys: "pending" | "dialing" | "completed" | "failed" | "skipped" (campaign.rs:76). Values are i64. ★ completedTargets/failedTargets are the campaign row's own counters and can lag callCounts — they are not derived from it. 404 unless app_meta->>'createdByApp' matches this app: you can never see a user-created or another app's campaign.
On failure:
404 {"success":false,"error":"campaign not found"}
<sub>Source: voice_ai_rust/src/routes/app_campaigns.rs:239-248 (summarize + out["callCounts"]); services/campaign.rs:521-536 (call_status_counts)</sub>
GET /api/app-files (list blobs)
Scope: files:read
{
"success": true,
"data": {
"files": [
{
"path": "resumes/cand_8812.pdf",
"contentType": "application/octet-stream",
"sizeBytes": 184203,
"updatedAt": "2026-07-28T13:07:44.219503Z"
}
]
}
}
Nested at data.files. Four keys per row — no id, no etag, no createdAt. ★ contentType is ALWAYS the literal "application/octet-stream": store() hard-codes it and ignores any Content-Type you PUT (services/app_blob_ops.rs:60). Never branch on it — infer the type from the path extension, which is exactly what /api/app-ai/extract does. sizeBytes is i64. ?prefix= filters by path prefix; the list is capped at 1000 rows with no pagination.
<sub>Source: voice_ai_rust/src/routes/app_data.rs:832 (envelope); db/app_blobs.rs:18-25 (BlobMeta, #[serde(rename_all="camelCase")])</sub>
GET /api/app-files/{path} · GET /api/app-assets/{key} (RAW BYTES — no JSON envelope)
Scope: files:read · — (app-assets is unauthenticated)
HTTP/1.1 200 OK
Content-Type: application/octet-stream
X-Content-Type-Options: nosniff
Content-Disposition: attachment
<raw bytes>
// GET /api/app-assets/apps/recruit-crm/1.4.0/3-a91f2c-bundle.js
HTTP/1.1 200 OK
Content-Type: text/javascript
Cache-Control: public, max-age=31536000, immutable
<raw bytes>
★ NEITHER route returns {success,data} — the body is the bytes. Do not res.json(). On /api/app-files the response is forced to application/octet-stream + nosniff + attachment regardless of what the blob is (an app could store HTML/JS), so res.blob()/arrayBuffer() is the only correct read. /api/app-assets is a DIFFERENT surface: unauthenticated, only serves keys under apps/, guesses Content-Type from the extension (js/mjs, css, png, jpg/jpeg, webp, gif, else octet-stream), caches immutably, and deliberately sends NO Access-Control-Allow-Origin. GET /api/app-assets/scaffold is a special case returning application/zip (the starter kit). Failures on both are a bare 404 status with an EMPTY body — no JSON error object.
On failure:
404 (empty body) — app-assets: key outside apps/, contains "..", or missing in the blob store
404 {"success":false,"error":"file not found"} — app-files: no tracking row for this (org, app, path)
<sub>Source: voice_ai_rust/src/routes/app_data.rs:861-868 (download); voice_ai_rust/src/routes/app_assets.rs:61-84 (serve), :22-39 (content_type_for)</sub>
GET /api/app-kb
Scope: kb:read (kb:write also passes)
{
"success": true,
"data": {
"knowledgeBases": [
{
"id": "b7c41e08-2d93-4f56-a180-93ee5c7d2f41",
"key": "faq",
"name": "Product FAQ",
"description": "Synced from Notion",
"embeddingModel": "text-embedding-3-small",
"documentCount": 42,
"createdAt": "2026-07-02T11:04:19.882471Z",
"updatedAt": "2026-07-28T06:12:03.114902Z"
}
]
}
}
★ Nested one level: data.knowledgeBases, NOT data as an array. ★ key is the app's stable per-app key and is app_kb_key on the row — it is null for a KB that was not created with one. documentCount is an i64 number, defaulted to 0 by unwrap_or(0). The row's orgId, appId, createdBy, deletedAt are deliberately NOT projected. kb:write is accepted as a substitute for kb:read (app_kb.rs:63-68) so a write-only manifest's sync loop doesn't 403 on its own reads.
<sub>Source: voice_ai_rust/src/routes/app_kb.rs:123 (envelope), :98-109 (kb_to_json)</sub>
GET /api/app-kb/{kbId}
Scope: kb:read (kb:write also passes)
{
"success": true,
"data": {
"id": "b7c41e08-2d93-4f56-a180-93ee5c7d2f41",
"key": "faq",
"name": "Product FAQ",
"description": "Synced from Notion",
"embeddingModel": "text-embedding-3-small",
"documentCount": 42,
"createdAt": "2026-07-02T11:04:19.882471Z",
"updatedAt": "2026-07-28T06:12:03.114902Z"
}
}
Flat KB object under data, same fields as the list rows, and here documentCount IS the live count. 404 covers missing, soft-deleted, cross-org and cross-app alike — it never confirms another tenant's KB exists.
On failure:
404 {"success":false,"error":"knowledge base not found"}
<sub>Source: voice_ai_rust/src/routes/app_kb.rs:186; services/knowledge.rs:455-473 (find_app_kb computes the count subquery)</sub>
GET /api/app-kb/{kbId}/attachments
Scope: kb:read (kb:write also passes)
{
"success": true,
"data": {
"agents": [
{
"agentId": "3f5a91c2-8e47-4b0d-9c11-6a2f8d7e4b13",
"agentName": "Support Line"
}
]
}
}
Nested at data.agents. Two keys per row, both non-null (the SQL INNER JOINs agents). Note the key is agentId/agentName, not id/name — the same row shape the SDK's attachments() returns.
<sub>Source: voice_ai_rust/src/routes/app_kb.rs:369-373</sub>
GET /api/app-kb/{kbId}/documents
Scope: kb:read (kb:write also passes)
{
"success": true,
"data": {
"documents": [
{
"id": "c8d52f19-3ea4-4067-b291-04ff6d8e3052",
"kbId": "b7c41e08-2d93-4f56-a180-93ee5c7d2f41",
"title": "Pricing",
"sourceType": "text",
"status": "embedded",
"error": null,
"contentBytes": 4187,
"contentHash": "9f2b7c4e1a86d035b9e2f47c0d1a8e63b5427f90cc1de8a4f36b027d59e14ab8",
"createdAt": "2026-07-28T06:11:58.203914Z",
"updatedAt": "2026-07-28T06:12:03.114902Z"
}
]
}
}
Nested at data.documents. ★ The document BODY is never returned on any app-KB route — you get contentBytes (a byte length, d.body.len()) and contentHash (lowercase hex sha256 of the body, 64 chars) for sync diffing. ★ status is one of "pending" | "embedding" | "embedded" | "failed" (knowledge.rs:342, :2769, :2792) — the published SDK type omits "embedding" and invents "indexed"; do not switch on those. error carries the embed failure text when status is "failed", else null. sourceType is "text" for everything the app API creates.
<sub>Source: voice_ai_rust/src/routes/app_kb.rs:219 (envelope), :83-96 (doc_to_json)</sub>
GET /api/app-links
Scope: links:read
{
"success": true,
"data": {
"links": [
{
"id": "7f2c1a94-3b6e-4d21-9a5f-0c8e17b4d602",
"action": "agent_call",
"targetType": "candidate",
"targetId": "cand_8812",
"expiresAt": "2026-08-05T09:15:42.311894Z",
"opensAt": "2026-07-30T04:30:00Z",
"singleUse": true,
"usedAt": "2026-07-30T04:41:19.882471Z",
"revokedAt": null,
"openCount": 2,
"lastOpenedAt": "2026-07-30T04:41:19.882471Z",
"createdAt": "2026-07-29T09:15:42.311894Z",
"requiresCode": false
}
]
}
}
Nested at data.links. ★ NO token and no url — by design; listing answers "did they open it?", not "resend it". ★ There is also NO status field even though ?status=active|used|expired|revoked is a FILTER: derive it yourself from revokedAt / usedAt / expiresAt vs now, in that precedence (the SQL CASE at app_links.rs:254-259 is the authority). openCount is an i32. targetType/targetId/opensAt/usedAt/revokedAt/lastOpenedAt are all nullable. limit defaults to 100, clamped 1..500, and is NOT echoed — there is no cursor and no total.
<sub>Source: voice_ai_rust/src/routes/app_links.rs:273-288 (run_list)</sub>
GET /api/app-members
Scope: members:read (+ user:profile to get email)
{
"success": true,
"data": {
"members": [
{
"userId": "4c9e2b71-6d38-4a52-b7e1-8f03c5a61d29",
"name": "Priya Sharma",
"firstName": "Priya",
"lastName": "Sharma",
"role": "owner",
"email": "[email protected]"
},
{
"userId": "a1f47d90-52c6-4b38-91ae-6d02e7c4b815",
"name": "Rahul",
"firstName": "Rahul",
"lastName": null,
"role": "member",
"email": "[email protected]"
},
{
"userId": "e07b3c14-9a25-4d6f-8c30-b51f2a7d9e64",
"name": "",
"firstName": null,
"lastName": null,
"role": "viewer",
"email": "[email protected]"
}
],
"total": 3
}
}
Nested at data.members with a sibling total. ★ email is a CONDITIONAL KEY: it is inserted only when the app ALSO holds user:profile (app_members.rs:37-39, :69-71) — when absent the key is missing entirely, not null. Check with 'email' in m. ★ name is derived by concatenation and is the EMPTY STRING (not null) when both first and last are null, while firstName/lastName themselves are nullable. total is just members.len() — the query is unpaginated and returns every org member ordered by join date. Dashboard-proxy twin GET /api/orgs/{orgId}/apps/{appId}/members emits a byte-identical body (routes/apps.rs:3134-3154).
<sub>Source: voice_ai_rust/src/routes/app_members.rs:62-79</sub>
POST /api/app-ai/extract
Scope: files:read (NOT an ai:* scope)
{
"success": true,
"data": {
"text": "Master Services Agreement\n\n1. Scope of Work\nVendor shall provide…",
"chars": 18422,
"truncated": false
}
}
Lives on the /app-ai router but is gated on files:read, not ai:llm/ai:tts — it parses a blob you already stored via files.put, it calls no model and costs nothing. ★ chars is the length of the RETURNED text, not of the source document: when the doc exceeds 48 000 chars the text is cut and chars reports 48000 with truncated:true. csv/json/xlsx are refused (they hold rows, not prose).
On failure:
400 {"success":false,"error":"csv/json/xlsx hold rows, not prose — read the blob and parse it yourself"}
404 {"success":false,"error":"file not found"}
<sub>Source: voice_ai_rust/src/routes/app_ai.rs:664 (run_extract builds data), :633 (envelope), :631 (files:read gate)</sub>
POST /api/app-ai/llm
Scope: ai:llm
{
"success": true,
"data": {
"text": "The caller wants to move Tuesday's delivery to Friday morning.",
"provider": "openai",
"model": "gpt-4o-mini-2024-07-18",
"byok": false,
"usage": {
"inputTokens": 412,
"outputTokens": 27,
"estimated": false
}
}
}
camelCase. FIVE keys only — there is no id, no finishReason, no choices[], no messages. ★ model is the SERVED model id echoed by the provider's usage frame (served_model, app_ai.rs:356-360), so a request for gpt-4o-mini comes back as the dated snapshot gpt-4o-mini-2024-07-18; billing still uses the model you ASKED for (app_ai.rs:486). If the provider reports no usage frame, model falls back to your requested string and usage.estimated is true with tokens = chars/4 (app_ai.rs:480-484). provider echoes the resolved provider verbatim — the google→gemini / grok→xai canonicalization happens only on the billing row, not here. usage.inputTokens/outputTokens are u64 numbers. Same body is produced by the dashboard proxy POST /api/orgs/{orgId}/apps/{appId}/ai/llm (routes/apps.rs:2486) — identical shape, no re-projection.
On failure:
400 {"success":false,"error":"model openai/gpt-9 is not in the platform catalog"}
400 {"success":false,"error":"sessionId not found for this org (it must be one of your calls)"}
502 {"success":false,"error":"llm stream: 401 Unauthorized","code":"provider_error"}
<sub>Source: voice_ai_rust/src/routes/app_ai.rs:491 (run_llm builds data), :200 (handler wraps {success,data})</sub>
POST /api/app-ai/llm/stream (SSE frames)
Scope: ai:llm
HTTP/1.1 200 OK
Content-Type: text/event-stream
X-Accel-Buffering: no
data: {"token":"The "}
data: {"token":"caller "}
data: {"token":"wants "}
:
data: {"done":true,"provider":"openai","model":"gpt-4o-mini-2024-07-18","byok":false,"usage":{"inputTokens":412,"outputTokens":27,"estimated":false}}
★ NOT wrapped in {success,data} — the envelope exists only on the non-streaming route. Every frame is a bare data: line with a JSON object and NO event: name and NO [DONE] sentinel; terminate on done:true. Three mutually-exclusive frame shapes: {"token":"…"}, the single terminal {"done":true,…}, and {"error":"llm stream: …"}. ★ A mid-stream provider failure arrives as an error FRAME with HTTP 200 already sent — you cannot detect it from the status code. Validation/scope/catalog failures happen in prepare_llm BEFORE the stream opens and do come back as a normal JSON error body with a 4xx. The bare : line is axum's default keep-alive comment. ★ The done frame is emitted AFTER metering, so a client that aborts early gets tokens but no done (and is still billed for what was generated). BRIDGE DIFFERS: telenow.ai.llmStream(req, onToken) in the iframe resolves to an aggregate { text, usage, provider, model } that never exists on the wire — the host accumulates tokens and reads them off the done frame (AppPageHost.tsx:300-312).
On failure:
data: {"error":"llm stream: 429 rate_limit_exceeded"}
<sub>Source: voice_ai_rust/src/routes/app_ai.rs:548 (token frame), :576-584 (done frame), :534 & :560 (error frame), :586-588 (Sse + KeepAlive), :602 (X-Accel-Buffering header)</sub>
POST /api/app-ai/tts
Scope: ai:tts
{
"success": true,
"data": {
"audioBase64": "//79/Pv6+fj39vX08/Lx8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQ",
"format": "mulaw",
"sampleRate": 8000,
"channels": 1,
"provider": "elevenlabs",
"voice": "21m00Tcm4TlvDq8ikWAM",
"byok": false,
"chars": 143
}
}
★ format is the lowercase serde rename of the AudioFormat enum (types/audio.rs:4-13): one of "mulaw" | "pcm16" | "wav" | "mp3" | "raw" | "linear16". It is whatever the provider stream actually emitted (providers/traits.rs:463-476) — telephony providers give mulaw/8000/1, but do NOT hardcode that assumption. sampleRate u32, channels u8, chars usize — all numbers. chars is the char count of the TRIMMED input text (cap 5000). ★ Unlike /llm, sessionId is REQUIRED here (TtsBody.session_id: Uuid, app_ai.rs:677) and must belong to your org. A provider 4xx (bad voice id) is mapped to HTTP 400, not 502 (app_ai.rs:762-767). No dashboard-proxy twin exists for TTS.
On failure:
400 {"success":false,"error":"TTS synthesis rejected by elevenlabs: upstream 400 voice_not_found. Check that the voice id exists on the API key used (BYOK vs platform) and that the model supports it."}
<sub>Source: voice_ai_rust/src/routes/app_ai.rs:807-821</sub>
POST /api/app-campaigns (create + populate + start)
Scope: campaigns:write
{
"success": true,
"data": {
"campaignId": "e2b8c4d6-5a17-4c93-8f2e-1d0b6a37f954",
"queued": 428,
"suppressed": 12,
"status": "running"
}
}
Four keys. ★ The key is campaignId, not id — and none of the campaign's own fields (name, agentId, counters) come back; GET /api/app-campaigns/{id} for those. queued / suppressed are usize counts from add_targets (suppressed = DNC-blocked or unnormalizable numbers). ★ status is a HARD-CODED LITERAL derived from the request's autostart flag (if autostart {"running"} else {"draft"}), NOT read back from the row — a campaign the engine immediately parks (outside its calling window) still reports "running" here.
On failure:
400 {"success":false,"error":"agentId (uuid) is required"}
403 {"success":false,"error":"agent is not bound to this app — bind it or create it via the app"}
400 {"success":false,"error":"no dialable targets (provide `targets` or a `targetQuery` that matches rows with a phone)"}
400 {"success":false,"error":"app campaign limit reached (50 active) — cancel finished ones"}
<sub>Source: voice_ai_rust/src/routes/app_campaigns.rs:148-156</sub>
POST /api/app-kb (create, idempotent on key)
Scope: kb:write
{
"success": true,
"data": {
"id": "b7c41e08-2d93-4f56-a180-93ee5c7d2f41",
"key": "faq",
"name": "Product FAQ",
"description": "Synced from Notion",
"embeddingModel": "text-embedding-3-small",
"documentCount": 0,
"createdAt": "2026-07-02T11:04:19.882471Z",
"updatedAt": "2026-07-02T11:04:19.882471Z"
}
}
★ data is the KB object FLAT — not wrapped in knowledgeBase, unlike the list route's knowledgeBases. ★ TRAP: documentCount is ALWAYS 0 on this route, even when the call is the idempotent re-POST that resolved an EXISTING KB holding 42 documents — both the find branch and the INSERT branch select NULL::bigint AS document_count (knowledge.rs:287, :302) and kb_to_json turns that into 0. Re-read GET /api/app-kb/{kbId} if you need the real count.
On failure:
400 {"success":false,"error":"app KB limit reached (10)"}
400 {"success":false,"error":"key and name are required"}
<sub>Source: voice_ai_rust/src/routes/app_kb.rs:174; services/knowledge.rs:277-312 (create_app_kb)</sub>
POST /api/app-kb/{kbId}/documents · PUT /api/app-kb/{kbId}/documents/{docId}
Scope: kb:write
{
"success": true,
"data": {
"id": "c8d52f19-3ea4-4067-b291-04ff6d8e3052",
"kbId": "b7c41e08-2d93-4f56-a180-93ee5c7d2f41",
"title": "Pricing",
"sourceType": "text",
"status": "pending",
"error": null,
"contentBytes": 4187,
"contentHash": "9f2b7c4e1a86d035b9e2f47c0d1a8e63b5427f90cc1de8a4f36b027d59e14ab8",
"createdAt": "2026-07-29T09:02:41.663180Z",
"updatedAt": "2026-07-29T09:02:41.663180Z"
}
}
data is the document object FLAT (no document wrapper). ★ status is always "pending" here — chunking + embedding are fire-and-forget; poll the documents list until it flips to "embedded" or "failed". ★ PUT is a REPLACE, not a patch: the old row is soft-deleted and a NEW row is inserted, so the returned id DIFFERS from the {docId} you addressed. Key your sync map on your own source id, never on the platform doc id. Omitting title on PUT carries the old title forward.
On failure:
400 {"success":false,"error":"document limit reached (200 per knowledge base)"}
400 {"success":false,"error":"document body exceeds 1024 KB"}
404 {"success":false,"error":"document not found"}
<sub>Source: voice_ai_rust/src/routes/app_kb.rs:254 (create), :331 (replace); services/knowledge.rs:316-356, :527-550</sub>
POST /api/app-kb/{kbId}/search
Scope: kb:read (kb:write also passes)
{
"success": true,
"data": {
"results": [
{
"chunkId": "d9e63a20-4fb5-4178-93a2-15aa7e9f4163",
"docId": "c8d52f19-3ea4-4067-b291-04ff6d8e3052",
"seq": 3,
"text": "Annual plans are billed up front and include a 15% discount versus monthly."
}
]
}
}
Nested at data.results. ★ There is NO relevance score, no distance, no document title on the result — only these four keys; join back to the documents list on docId if you need a title. seq is the chunk's ordinal within the document (i32). topK is clamped to 1..8 regardless of what you send (app_kb.rs:282). Not metered in v1. BRIDGE: telenow.kb.search(kbId, {query, topK}) returns the identical {results:[…]} object (routes/apps.rs:2981-2985).
<sub>Source: voice_ai_rust/src/routes/app_kb.rs:287-298</sub>
POST /api/app-links (mint)
Scope: links:write
{
"success": true,
"data": {
"id": "7f2c1a94-3b6e-4d21-9a5f-0c8e17b4d602",
"token": "N2YyYzFhOTQtM2I2ZS00ZDIxLTlhNWYtMGM4ZTE3YjRkNjAyLjRjMWU3ZDBhOWI2ZjRhMWU4YzJkNWI3ZjNhMGU2YzkxLjE3ODY1NTAxNDI.wkL9XdMwTFS31mB4BCtfGxi6YI1KglepBtaLmXcoa0U",
"url": "https://app.telenow.ai/l/N2YyYzFhOTQtM2I2ZS00ZDIxLTlhNWYtMGM4ZTE3YjRkNjAyLjRjMWU3ZDBhOWI2ZjRhMWU4YzJkNWI3ZjNhMGU2YzkxLjE3ODY1NTAxNDI.wkL9XdMwTFS31mB4BCtfGxi6YI1KglepBtaLmXcoa0U",
"expiresAt": "2026-08-05T09:15:42.311894Z",
"singleUse": true,
"requiresCode": false
}
}
Six keys, camelCase. ★ token is returned HERE AND NOWHERE ELSE — it is stored only as a hash-side token_jti, so the list route deliberately never returns it; if you lose the url you must re-mint. ★ url is built from FRONTEND_BASE_URL, never from the request Host: the /l/:token route only exists in the SPA, so deriving it from the API host produced dead links. requiresCode is derived (code_hash.is_some()), the code itself is bcrypt-hashed and never echoed. singleUse defaults to TRUE when omitted. Identical body from the dashboard proxy POST /api/orgs/{orgId}/apps/{appId}/links (routes/apps.rs:2521) — that path additionally records created_by.
On failure:
400 {"success":false,"error":"unknown action 'inteview' — expected one of agent_call, form, page"}
400 {"success":false,"error":"lifetime is 15552001s (max 7776000s ≈ 90 days)"}
400 {"success":false,"error":"context must be a JSON object"}
<sub>Source: voice_ai_rust/src/routes/app_links.rs:174-187 (run_mint); utils/app_link_token.rs:102-109 (token = B64URL(payload) "." B64URL(hmac))</sub>
POST /api/app-links/{id}/revoke
Scope: links:write
{
"success": true,
"data": {
"revoked": true
}
}
★ revoked is a CONSTANT true, not a report of whether the row changed — the UPDATE uses COALESCE(revoked_at, NOW()), so re-revoking an already-revoked link still returns {revoked:true} (idempotent by design). A 404 means the id doesn't belong to this (org, app) — one app can never revoke another's links.
On failure:
404 {"success":false,"error":"link not found"}
<sub>Source: voice_ai_rust/src/routes/app_links.rs:325 (run_revoke)</sub>
POST /api/public/link/{token} (UNAUTHENTICATED resolve)
Scope: — (public router; no app key)
{
"success": true,
"data": {
"action": "agent_call",
"context": {
"candidateName": "Priya Sharma",
"roleId": "rec_4471",
"agentSlug": "sales-screen"
},
"appId": "recruit-crm",
"sessionTicket": "N2YyYzFhOTQtM2I2ZS00ZDIxLTlhNWYtMGM4ZTE3YjRkNjAyLjRjMWU3ZDBhOWI2ZjRhMWU4YzJkNWI3ZjNhMGU2YzkxLjE3ODY1NTAxNDI.wkL9XdMwTFS31mB4BCtfGxi6YI1KglepBtaLmXcoa0U"
}
}
Four keys. ★ context is echoed VERBATIM as the app stored it at mint time — its inner keys are entirely the app's own schema and are NOT camelCased or validated by the platform. ★ sessionTicket is a String ONLY when action == "agent_call"; it is null for "form" and "page" (public_links.rs:278-279). Hand it to session-init; the browser must not name the link itself. ★ SIDE EFFECT: for a single-use non-form link this call BURNS the link (used_at set) — a second resolve fails. A form link is burned on SUBMIT instead, so it stays re-openable. openCount/lastOpenedAt increment on every resolve either way.
On failure:
400 {"success":false,"error":"This link has expired. Ask whoever sent it for a new one."}
400 {"success":false,"error":"This link has already been used. Ask whoever sent it for a new one."}
403 {"success":false,"error":"This link needs the code that was sent to you.","code":"link_code_required"}
403 {"success":false,"error":"That code isn't right. 3 attempt(s) left.","code":"link_code_wrong"}
403 {"success":false,"error":"Too many incorrect codes. Ask whoever sent this for a new link.","code":"link_code_locked"}
429 {"success":false,"error":"Too many attempts — please wait a moment and try again.","retryAfter":60}
<sub>Source: voice_ai_rust/src/routes/public_links.rs:281-289; mounted at /api/public/link (main.rs:1497)</sub>
POST /api/public/link/{token}/submit (UNAUTHENTICATED form submit)
Scope: — (public router; no app key)
{
"success": true,
"data": {
"submitted": true
}
}
★ One boolean, constant true. The created/updated object is NOT returned — the recipient never learns the row id, the object type, or which of their fields were kept. ★ Fields the row's own context.fields[] spec does not declare are silently DROPPED (not rejected), string values are capped at 4000 chars, and enum fields must match a declared options entry or the whole submit 400s. A create fires object.created for the app's workflows; an update path (submitTo.mode == "update") does not.
On failure:
400 {"success":false,"error":"Please fill in: email, phone"}
400 {"success":false,"error":"'status' is not one of the allowed options."}
400 {"success":false,"error":"This form has already been submitted."}
400 {"success":false,"error":"This form has no destination configured."}
403 {"success":false,"error":"That code isn't right.","code":"link_code_wrong"}
<sub>Source: voice_ai_rust/src/routes/public_links.rs:500</sub>
PUT /api/app-files/{path} · DELETE /api/app-files/{path}
Scope: files:write
// PUT (body = raw bytes)
{
"success": true,
"data": {
"path": "resumes/cand_8812.pdf",
"size": 184203
}
}
// DELETE
{
"success": true,
"data": {
"deleted": true
}
}
★ PUT's key is size (i64), while the LIST route calls the same quantity sizeBytes — the two names do not match. path is echoed as you sent it (post-clean_path, which rejects leading /, .., backslashes, >512 chars). ★ DELETE's deleted is honest: it is false when no tracking row existed, and that is still a 200 — a delete of a non-existent path does NOT 404.
On failure:
413 {"success":false,"error":"file exceeds the 10 MiB per-file limit"}
413 {"success":false,"error":"app storage quota exceeded"}
413 {"success":false,"error":"app file limit reached (max 500 files per app)"}
400 {"success":false,"error":"invalid file path"}
<sub>Source: voice_ai_rust/src/routes/app_data.rs:845 (upload), :881 (delete_file)</sub>
telenow.files.* (iframe bridge)
Scope: files:read / files:write
// telenow.files.list(prefix) → BARE ARRAY (REST nests it under data.files)
[
{
"path": "resumes/cand_8812.pdf",
"contentType": "application/octet-stream",
"sizeBytes": 184203,
"updatedAt": "2026-07-28T13:07:44.219503Z"
}
]
// telenow.files.get(path) → ArrayBuffer (NOT JSON)
// telenow.files.put(path, body)
{ "path": "resumes/cand_8812.pdf", "size": 184203 }
// telenow.files.delete(path)
{ "deleted": true }
// telenow.files.extract(path)
{ "text": "Master Services Agreement\n\n1. Scope of Work…", "chars": 18422, "truncated": false }
★ BRIDGE DIFFERS ON TWO OPS. (1) files.list() resolves to the BARE ARRAY — the host strips .files — whereas REST gives data.files. (2) files.get() resolves to an ArrayBuffer, not an object; there is no envelope to unwrap. put, delete and extract match their REST data objects exactly. ★ ERRORS NEVER CARRY STATUS: every bridge op rejects with a plain new Error(message) built from the host's {ok:false,error} postMessage frame (AppPageHost.tsx:1042 → :191), so the iframe cannot see 400 vs 403 vs 413 — only the error string. Client-side scope gates in the bridge throw before the network call (e.g. this app didn't request an "ai" capability); the server re-checks regardless.
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:1019 (.files unwrap), :1023 / voice_ai_frontend/src/api/apps.ts:407-410 (arraybuffer), :394-404 (put), :412-415 (delete), :897-903 (extract)</sub>
telenow.kb.list() / telenow.kb.search() (iframe bridge)
Scope: kb:read
// telenow.kb.list() → BRIDGE SHAPE (differs from REST)
{
"knowledgeBases": [
{ "id": "b7c41e08-2d93-4f56-a180-93ee5c7d2f41", "name": "Product FAQ" }
]
}
// telenow.kb.search(kbId, { query, topK }) → same as REST
{
"results": [
{
"chunkId": "d9e63a20-4fb5-4178-93a2-15aa7e9f4163",
"docId": "c8d52f19-3ea4-4067-b291-04ff6d8e3052",
"seq": 3,
"text": "Annual plans are billed up front and include a 15% discount versus monthly."
}
]
}
★ BRIDGE DIFFERS FROM REST. The dashboard proxy behind kb.list re-projects to TWO keys — { id, name } — dropping key, description, embeddingModel, documentCount, createdAt, updatedAt that the app-key route GET /api/app-kb returns. It exists only to resolve a KB id for kb.search. An app that reads documentCount off telenow.kb.list() gets undefined, not 0. ★ The bridge exposes only list and search — create / documents / replace / attach have NO bridge op; those are app-key REST only. All bridge promises resolve to the REST data object with the {success,data} envelope already stripped (AppPageHost.tsx:191).
<sub>Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:941-943 (op dispatch), :320 (SDK surface); voice_ai_rust/src/routes/apps.rs:2998-3004 (the proxy's OWN projection)</sub>
Error envelope (all app-key REST planes)
// the ordinary shape — NotFound / BadRequest / Unauthorized / Forbidden / Conflict / PayloadTooLarge / 500
{ "success": false, "error": "knowledge base not found" }
// 429 — rate limit (body only; there is NO Retry-After header)
{ "success": false, "error": "app-AI rate limit reached — slow down", "retryAfter": 1 }
{ "success": false, "error": "Data API rate limit reached — slow down", "retryAfter": 1 }
// 502 — upstream provider rejected us (LLM / TTS)
{ "success": false, "error": "llm stream: 401 Unauthorized", "code": "provider_error" }
// 403 with a machine-readable code (public link resolve/submit only)
{ "success": false, "error": "This link needs the code that was sent to you.", "code": "link_code_required" }
// 401 — app key
{ "success": false, "error": "app key required (Authorization: Bearer or X-App-Key)" }
{ "success": false, "error": "invalid or revoked app key" }
★ error is ALWAYS a plain string, never an object and never an array of field errors. ★ code exists on only three cases — provider_error (502), the link codes link_code_required / link_code_wrong / link_code_locked (403), and the JWT-only token_expired / token_invalid (401, dashboard plane) — so branch on HTTP status first and treat code as optional. ★ On 429 the retry hint is retryAfter IN THE BODY (seconds, i64); no Retry-After header is set. ★ TWO DIFFERENT 403 STRINGS for the same denial: the app-key REST planes say "app did not declare the {scope} scope" (routes/app_data.rs:805) for a static key and "token does not include the {scope} scope" (app_data.rs:75) for an OAuth token, while the dashboard proxy says "this install has not granted the {scope} scope. If the app's current manifest declares it, the org is still on an older installed version — upgrade the app in Settings → Apps to grant the new scopes" (routes/apps.rs:2463). Never string-match these. A 500 is always the opaque "Something went wrong".
On failure:
403 {"success":false,"error":"app did not declare the kb:write scope"}
403 {"success":false,"error":"token does not include the kb:write scope"}
500 {"success":false,"error":"Something went wrong"}
<sub>Source: voice_ai_rust/src/error.rs:105-152 (the match + the plain {success,error} tail at :151), :112-124 (TooManyRequests), :133-144 (Provider), :97-103 (ForbiddenCode); middleware/auth.rs:365-392 (app-key 401s)</sub>
If an example here is wrong
Tell us — it is a bug of the same severity as a broken endpoint. These shapes were derived from the code rather than written by hand precisely because a plausible-but-wrong example is worse than none: you destructure it, ship, and find out on a live call.