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 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. The source file and line are cited under every entry.
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
Developers usually arrive 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).
Source: voice_ai_rust/src/services/orchestration.rs:12114-12152 (envelope :12141-12148)
Envelope contract — what the model actually receives
Several variants shown side by side — each top-level key labels a case (status code, method or provider) and is 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\"}"}
Source: voice_ai_rust/src/services/orchestration.rs:13729-13735 (cascade voice), :18170-18176 (text/WhatsApp/public API), :7770 (s2s send_tool_result)
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) andresults(array) are DROPPED entirely: after anobject.querynode no new variable appears at all, and afterobject.createyou get onlyokandid. - WORSE:
_tool_statusis "ok" for ANYOk(...)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 atool_resultedge (edge match at services/flow.rs:1138-1142), with the refusal text flattened into anerrorvariable. Only ananyhowbail sets_tool_status= "error". In a simulation the node never executes and just sets_tool_status="ok" (:24266-24274).
Source: voice_ai_rust/src/services/orchestration.rs:24284-24306 (flatten :24287-24298, _tool_status :24286/:24304)
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.
Source: voice_ai_rust/src/services/orchestration.rs:9439-9462 (webhook); :13692-13704 and :7725-7737 (debug trace tool.call)
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>.
Source: voice_ai_rust/src/services/tool.rs:1692-1693 (serde_json::from_slice(&bytes).unwrap_or(Value::Null)); request built at :1667-1681
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.
Source: voice_ai_rust/src/services/tool.rs:1443 (Ok(json!({ "ok": true, "id": row.id, "record": row.data })))
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"
}
Source: voice_ai_rust/src/services/tool.rs:1628-1641 (success :1638, not-found :1640); missing match value :1620-1622
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>, neverresults[i].<field>. - NO
okKEY AT ALL — this is the one declarative handler with nook; a reader (or a flowtool_resultedge) that keys offokseesundefinedon the happy path. Only three keys survive per row —id,data,created_at— the AppObjectRow'sorg_id,app_id,object_type,created_by,updated_atare all dropped (row struct: db/app_objects.rs:13-22).created_atis chrono RFC3339 with microseconds and aZsuffix.dataDOES include computed fields (display_nameabove) because the tool goes throughapp_object_enrich::query_enriched, not the raw store (tool.rs:1532). Relation<field>__expandedkeys NEVER appear here: the tool passesexpand: Default(empty) at tool.rs:1537-1540. Hardlimit: 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).
Source: voice_ai_rust/src/services/tool.rs:1532-1548 (results built at :1544-1547)
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.
Source: voice_ai_rust/src/services/tool.rs:1599-1602; SQL data = data || $2 at db/app_objects.rs:204-211
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"
}
Source: voice_ai_rust/src/services/tool.rs:1710-1717 ("ok" => Ok(outcome.value)); CodeOutcome at services/code_runner.rs:55-62
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.
Source: voice_ai_rust/src/services/orchestration.rs:25217-25226 (adjust_volume), :25280-25291 (set_language), :25232-25240 (opt_out)
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.
Source: voice_ai_rust/src/services/orchestration.rs:25116 (end_call), :25148-25157 (navigate)
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.
Source: voice_ai_rust/src/services/orchestration.rs:25050-25060 (success :25052)
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.
Source: voice_ai_rust/src/services/orchestration.rs:25092-25104 (success :25097-25099); mode resolution at services/tool.rs:506-513
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).
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
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``.
Source: voice_ai_rust/src/services/orchestration.rs:25499-25536 (success :25528-25532); simulation branch :25400-25410
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.
Source: voice_ai_rust/src/services/orchestration.rs:13588 & :18152 (cascade/text), :7691-7715 (s2s)
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.
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)
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).
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
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)"
}
Source: voice_ai_rust/src/services/tool.rs:1500-1525 (message at :1518-1524, declares_map hint at :1507-1517)
app handler object.update — no match / missing match value
Several variants shown side by side — each top-level key labels a case (status code, method or provider) and is 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."
}
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)
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.
Source: voice_ai_rust/src/services/tool.rs:1361-1363 (binding gate), :1337, :1341, :1345, :1354
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.
Source: voice_ai_rust/src/services/tool.rs:1719-1722
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.
Source: voice_ai_rust/src/services/orchestration.rs:13653-13662 (skips), :25293-25296 (unsupported kind)
Data API & the object store
Your app's records. 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
dataKEY AT ALL on success — this is the one CRUD route whose envelope is bare{success:true}. Destructuringconst {data} = resgives undefined, not the deleted row. - Contrast the dashboard proxy DELETE (routes/apps.rs:3351), which is byte-identical
{success:true}, and the agentobject.deletetool, which DOES return the deleted record.
On failure:
HTTP 404 {"success": false, "error": "record not found"}
Source: voice_ai_rust/src/routes/app_data.rs:370
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.datais an OBJECT with one key, not the array. - NO
nextCursor, NOtotal, NOhasMoreon this route — unlike the dashboard proxy's list, which does return nextCursor. You cannot page the external Data API; you get onelimit-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
sortorlimitis unfilterable here. - Every other query param becomes an EQUALITY filter with a STRING value (
filter.insert(k, Value::String(v)), :126) —?age=30compares 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 objectsemantic: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:readis enforced ONLY whenapp.scopesis Some — i.e. an OAuth Bearer token. A static per-install app key passes unconditionally. Theobjects:<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"}
Source: voice_ai_rust/src/routes/app_data.rs:148
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 — camelCaseproviderAppId, andaccessis 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.
Source: voice_ai_rust/src/routes/app_data.rs:184-192
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) butappIdon 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 computeddisplay_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"}
Source: voice_ai_rust/src/routes/app_data.rs:232 (rows fetched raw at :221 via db::app_objects::query, deliberately NOT query_enriched)
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.objectsPLUSdata.nextCursor— the external Data API has no cursor, this one does.- nextCursor is
null(present, not omitted) whenever the page came back short oflimit, which is how you detect the end. - CURSOR FORMAT GOTCHA: it is
"<rfc3339>,<uuid>"built withcreated_at.to_rfc3339(), which emits+00:00— NOTZ— even though every other timestamp in the same response serializes withZ. 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()soview,expandandsearchare 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.
Source: voice_ai_rust/src/routes/apps.rs:2329-2332
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 returneddatais 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
nullfor 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"}
Source: voice_ai_rust/src/routes/app_data.rs:352
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"
}
}
dataIS THE ROW ITSELF here — notdata.object, notdata.objects[0]. So the record you posted comes back atres.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_nameis 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)"}
Source: voice_ai_rust/src/routes/app_data.rs:331
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"
}
}
datais the row itself → the record you sent is atres.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/viewergets 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)"}
Source: voice_ai_rust/src/routes/apps.rs:3309 (PATCH twin at :3333)
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"
}
}
ticketis a DASHLESS 32-hex uuid (Uuid::new_v4().simple(), :3173) — not the canonical dashed form; treat it as an opaque string.wsUrlalready 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.
Source: voice_ai_rust/src/routes/apps.rs:3188-3191
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
orderByORview(:2422) — sorted and view results are NOT pageable, so a sorted query silently caps atlimit(default 200, max 500) with no signal that more exist. - Request keys are camelCase (
orderBy,countOnly) — QueryBody carries rename_all="camelCase" (:2346);order_byin the body is silently ignored. filtervalues 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.expandembeds atdata.<field>__expanded(max 8 fields, 500 ids each).searchneeds the object declaredsemantic: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"}
Source: voice_ai_rust/src/routes/apps.rs:2427-2430 (request body struct at :2345-2370)
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.objectsis ABSENT (not empty),data.nextCursoris absent,data.countis a number. - countOnly RETURNS EARLY BEFORE ENRICHMENT (:2385-2390), so it ignores
view,expand,search,orderByandlimitentirely and counts the RAWfilteragainst the whole (org, app, type) partition. A count taken with aviewwill 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.
Source: voice_ai_rust/src/routes/apps.rs:2389
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 INSIDEdataare 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, notrow.phone. - Computed fields are MATERIALIZED INTO
data(app_object_enrich.rs:299), sodisplay_nameis 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 atrow.<field>__expandedand 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'sdatais 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
AppObjectTS interface (voice_ai_frontend/src/api/apps.ts:190-197) is INCOMPLETE — it omitsorgIdandupdatedAt, both of which the server always sends.
Source: voice_ai_rust/src/db/app_objects.rs:11-22 (struct AppObjectRow, #[serde(rename_all="camelCase")]); computed inserted at voice_ai_rust/src/services/app_object_enrich.rs:299; expansion inserted at :470 and :479
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
successenvelope — the frame IS the payload, unlike every REST response on this surface. - Exactly four keys.
eventis one of "created" | "updated" | "deleted" (&'static str). dataHERE IS NOT THE ROW — it is only the row'sdataobject (the stored fields), with NO id/orgId/appId/createdAt wrapper. The record id lives in the siblingidkey. So a frame handler readsframe.id+frame.data.phone, while a REST handler readsrow.id+row.data.phone— same nesting for the fields, different for the id.datais 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__expandedare 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.
Source: voice_ai_rust/src/websocket/object_change_stream.rs:73-78 (frame built at db chokepoints voice_ai_rust/src/db/app_objects.rs:46, :122, :154, :181, :243)
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
recordhere — a THIRD name for the same thing (dataon REST/bridge,datainside object.query results,recordon these three). All in-band at HTTP 200:ok:falseis the failure signal, there is nosuccesskey and no non-2xx. object.deletecarries BOTHokanddeleted, and returns the deleted row's fields — unlike both REST DELETEs, which return nothing.recordis the RAW stored data (no computed fields), and on create it includes server-injected keys the model never sent: the object's manifestfield_defaults(:1414-1419) andcaller_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
Source: voice_ai_rust/src/services/tool.rs:1442 (create), :1602 and :1604 (update), :1638 and :1640 (delete)
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, notcreatedAt— it is hand-built with json! and never touches AppObjectRow's serde.orgId/appId/objectType/updatedAt/createdByare all DROPPED. - NO
successand NOokon the happy path — just{results:[…]}; but the two failure paths DO return{ok:false,error}at 200, so a consumer must check forresultsrather than forok. - FIELDS ARE UNDER results[i].data —
results[0].data.phone, neverresults[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;
expandis not requested, so__expandednever does. - Hard-capped at 50 rows (:1540) with no cursor and no total.
- Args are filtered to the tool's DECLARED
fieldsbefore 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" }
Source: voice_ai_rust/src/services/tool.rs:1545-1548
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 + 1is 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.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:635; voice_ai_frontend/src/api/apps.ts:850-861
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 atrow.data.*— one.datahere, 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
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:637 and :640; voice_ai_frontend/src/api/apps.ts:1046-1065
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. Raiseopts.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 argopts. 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 +
__expandedincluded.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:624-633 (SDK stub at :210); underlying call voice_ai_frontend/src/api/apps.ts:836-847
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 isokrather thansuccess— 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.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:643-644
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:datais the stored-fields object only, null on "deleted", and UNENRICHED (no computed, no__expanded) — so a subscribe-driven list will lose the computed fields alist()put there unless you recompute. - Frames arrive on a separate postMessage channel keyed by
stream, not by replyid(:188), so they never resolve a pending RPC.
Source: 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)
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 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.
Source: 429 constructed at voice_ai_rust/src/routes/app_data.rs:54-58, serialized at voice_ai_rust/src/error.rs:112-124; 413 at voice_ai_rust/src/routes/app_data.rs:311-314 (cap constant MAX_ROWS_PER_APP = 100_000, voice_ai_rust/src/services/app_quota.rs:109)
Standard REST error envelope (all object routes)
Several variants shown side by side — each top-level key labels a case (status code, method or provider) and is 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:falseanderror(a human string). There is NOcodeon these —codeappears only for JWT failures (error.rs:90), ForbiddenCode (:100) and provider errors (:140), none of which the object routes raise. erroris 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.
Source: voice_ai_rust/src/error.rs:151 (generic arm); 403 text at voice_ai_rust/src/routes/apps.rs:2463-2467
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 ISsuccess— different words for the same idea on the two halves of the same feature. You almost never see the frame: the SDK resolvesd.dataon ok and rejectsnew 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.
Source: frame built at voice_ai_frontend/src/components/apps/AppPageHost.tsx:1042 (success twin at :1040); turned into a rejection by the injected SDK at :191
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.
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)
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" }
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)
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" }
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
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.
Source: voice_ai_rust/src/routes/apps.rs:2748 (envelope) reusing routes/app_data.rs:444 app_call_to_json for each row
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.
Source: voice_ai_rust/src/routes/apps.rs:2824 (envelope) + apps.rs:2809-2823 (analysis, transcript, participants)
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" }
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
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" }
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)
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".
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)
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).
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
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.
Source: voice_ai_rust/src/services/post_call_analysis.rs:387-412 (payload) → enqueued at post_call_analysis.rs:413-414
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.opennever touches the server — the bridge synthesises{ok:true}and calls navigate('/calls/:id'), which unmounts the app iframe.stream.subscriberesolves to an UNSUBSCRIBE FUNCTION, not to the streamId — the{streamId}object is internal plumbing (AppPageHost.tsx:330-338).- The frame handed to your
onEventcallback is DOUBLE-NESTED:frame.datais the actual event payload because the host relays the raw socket JSON ({topic, sessionId, data}) as thedataof its push message. So the caller's text is atframe.data.textwhereframeis what onEvent receives. The host holds the WebSocket and the ticket; the iframe never sees either.
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)
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).
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
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 thecallswrapper 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,latencyandcreatedAtare DROPPED and unreachable through this method.analysis/analysis_enabledappear only when{ includeAnalysis: true }is passed. - The
analysisVALUE is spliced in untouched, so its inner keys stay CAMELCASE (actionItems,agentWords) while its wrapper keyanalysis_enabledis 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" }
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
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 on 401 (token_expired | token_invalid), on ForbiddenCode 403s, on 502 provider_error, and on the BadRequestCode 400s — which now includes calls.initiate and softphone.dial when the CARRIER refuses for something on the account (exotel_no_credit, exotel_platform_no_credit, exotel_kyc_incomplete, exotel_app_id_missing, exotel_caller_id_invalid, exotel_credentials_rejected, exotel_not_configured, exotel_rejected). Those 400s also carry flat carrier and meta keys next to error. For every OTHER failure on the call/analysis routes there is still no code, so match on HTTP status first and treat code as optional. 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.
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})
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" }
Source: voice_ai_rust/src/routes/app_agents.rs:204
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).
Source: voice_ai_rust/src/routes/app_agents.rs:177-182
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.
flowisnullfor a single-context agent (agent_config.rs:282-292); everything else is identical, soprompt.systemPromptworks without branching onkind.voice.config/stt.config/telephony.configarenull(NOT{}) when the underlying column is NULL — public_blob returns Value::Null for None (:192-196).behavioris the session_config object itself (never null, may be{}), and it is FLAT — there is nobehavior.configwrapper, unlike every other provider group.model.temperature/model.maxTokensare ALWAYS-present keys read out of llm_config; they arenullwhen the blob lacks them (llm.get(...)→ None → null, :304-305). They are also DUPLICATED insidemodel.config, which is the raw llm_config blob (soconfig.provider/config.modelrepeat 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 Twiliotelephony.configshowsphoneNumber/firstResponsebut NEVERaccountSid/authToken, and a node override never showsmodel.extra.apiKey. - Flow nodes are RE-PROJECTED (public_node, :223-249):
toolsandprecallLookupsarrays are DELETED and replaced by integertoolCount/precallLookupCount; the whole node is then secret-stripped, so atransfernode keepsconfigbut with credentials removed. Everything else on the node passes through as stored. toolsis agent-level and reduced to{name, description, kind}only (public_tools, :254-271);kindfalls back to "custom" when absent,name/descriptionfall back to "" (never null).analysis.enabledis 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 seesanalysis: nullon every row.customFields/qaCriteriadefault 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).analysis.minDurationSec/analysis.minCustomerTurnsare the minimum-substance thresholds — the second reasonanalysiscan be null on a call whose agent hasenabled: true. A finished call under the threshold is never CLAIMED, so it has no row at all (not afailedone), costs nothing, and is absent from every rollup. Both are RESOLVED on read, never echoed: an absent or undecodable stored value is reported as the platform default (20 seconds / 2 customer replies), because that is the number the claim gate will actually apply — an app toldnullwould mis-diagnose its own missing rows. Which one applies depends onchannel:whatsapp/instagram/web_chat/chatare gated on customer TURNS, everything else on SECONDS (a chat's duration is reply latency, not how much was said). Both are WRITABLE withagents:config:write:analysis, unlikeanalysis.model; values are clamped to 3600 / 100 and decoded exactly as the SQL gate decodes them (a number, or a ≤6-digit string; a bool,{},[],"soon"and"-5"all fall back to the default). The gate reads them from the call's bind-time snapshot, so editing a threshold never re-gates calls that already ran.realtimeEngineis a bare provider STRING (or null) pulled from s2s_config.provider (:342-343) — not the s2s blob.updatedAtis RFC-3339 with sub-second precision; echo it asifUnmodifiedSinceon the PATCH.- FE TYPE DRIFT: the TypeScript
AppAgentConfigat voice_ai_frontend/src/api/apps.ts:583-599 does NOT declareanalysis, andAppAgentConfigPatch(: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.
Source: voice_ai_rust/src/services/agent_config.rs:279-345 (read_config); returned at voice_ai_rust/src/routes/app_agents.rs:361
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).
Source: voice_ai_rust/src/routes/app_agents.rs:284-296 (public_link_json), returned at :444 (GET) and :505 (PUT)
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.
Source: voice_ai_rust/src/routes/apps.rs:892-908
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.
Source: voice_ai_rust/src/routes/apps.rs:525-536
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
{
"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" }
Source: voice_ai_rust/src/routes/apps.rs:2846-2849 (GET) and :2921-2927 (PATCH); both call services/agent_config.rs read_config/apply_patch
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.
Source: 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
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.configis the FULL read_config document again (freshly re-read from the updated row) — identical shape to the GET, including a NEWupdatedAtyou must capture for the next conditional write.updatedlists 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":"…"}]}}returnsupdated: ["prompt"], because the group is decided by the FIELD, not by where it sits (node_field_group, :370-382).ifUnmodifiedSinceis stripped from the body before validation (app_agents.rs:389-391) — it is a precondition, not a group, and never appears inupdated. 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." }
Source: voice_ai_rust/src/routes/app_agents.rs:422-428; group derivation at voice_ai_rust/src/services/agent_config.rs:391-538
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" }
Source: voice_ai_rust/src/routes/app_agents.rs:160-163
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" }
Source: voice_ai_rust/src/routes/app_agents.rs:268-274; per-result struct at voice_ai_rust/src/services/agent_eval.rs:42-53
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"
}
}
teamIdechoes the MANIFEST team id (a slug string), NOT a uuid — unlikeagentId/entryAgentId, which are real uuids.refis the team-local member handle from the manifest.agents[i].agentIdandentryAgentIdareOption<&Uuid>serialized directly (:1011, :1021) — they render asnullif the ref→id map has no entry, so both are nullable in the type (and the FE types themstring | null, api/apps.ts:522-527). The bridge relies on this: it falls back toteam.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" }
Source: voice_ai_rust/src/routes/apps.rs:1005-1023
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.
kindcomes from the MANIFEST TEMPLATE spec (tpl.kind() → spec_kind onspec.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 noensure_app_scopecall. Theagents:readrequirement 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" }
Source: voice_ai_rust/src/routes/apps.rs:830-833
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
dataDIRECTLY — the SDK promise resolves tod.data, so there is nosuccessand no outerdatato destructure (AppPageHost.tsx:191:if(d.ok) p.resolve(d.data)). - The bridge pre-checks scopes by taking
Object.keys(patch)minusifUnmodifiedSince(:696-701) — soifUnmodifiedSincestill 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.
analysisworks at runtime here (groups are derived generically) even though the TSAppAgentConfigPatchat api/apps.ts:604-620 omits it — cast if TypeScript complains.
Source: 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
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.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:647-650
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/flowor/agents/:id/editdepending onkind(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. createFromTemplategives you anagentId, but a publicagent_calllink resolves by SLUG — callpublicLink(agentId)afterwards to get something you can actually point at.
Source: 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)
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), BadRequestCode (400, :137-146 — carrier refusals on the dial routes, exotel_*), 502 provider (provider_error, :197-207). 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).
Source: voice_ai_rust/src/error.rs:151 (base envelope), :112-124 (429 + retryAfter); bridge at voice_ai_frontend/src/components/apps/AppPageHost.tsx:1040-1042 and :191
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.
Source: voice_ai_rust/src/services/app_events.rs:427 (envelope) + voice_ai_rust/src/utils/webhook_worker.rs:135-141 (headers)
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).
Source: voice_ai_rust/src/services/app_events.rs:262-308 (map/upsert), :479 (resolve_path), :499 (eval_when)
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: truemeans it replayed your org's most recent real delivery;falsemeans 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 forcall.analyzedandcall.endedis NOT what your app receives. Useful as a schema cross-check for the whatsapp/* and recording.ready topics, where both surfaces share one payload.
Source: voice_ai_rust/src/routes/public_api.rs:653-657 (response), :663-780 (canned_sample fallback)
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
analysisobject; the customer REST-hook shape ({event, sessionId, analysis:{...}}) is a DIFFERENT payload built at post_call_analysis.rs:385. Rule paths aresummary, notanalysis.summary. (2) NOeventkey — 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_numberandsession_idare snake_case,agentIdis camelCase (app_events.rs:470-472 —agentIdwas 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,modelare customer-webhook-only. Notecustomhere vscustomDataon the customer webhook.caller_numberis null for web/chat calls (sessions.from_number). Fires only if post-call analysis is enabled on the agent.
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
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.
Source: voice_ai_rust/src/services/post_call_analysis.rs:385-412
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.
Source: cascade voice_ai_rust/src/services/orchestration.rs:14529-14539; s2s voice_ai_rust/src/services/orchestration.rs:6580-6582
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).
Source: voice_ai_rust/src/services/orchestration.rs:25673
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.
Source: voice_ai_rust/src/services/orchestration.rs:9349-9360
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.
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
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).
Source: voice_ai_rust/src/services/orchestration.rs:16435-16437
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).
Source: voice_ai_rust/src/services/orchestration.rs:9325-9335
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:
—
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
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:readyields 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/chargesas decimal STRINGS — do not compare the two by string equality.walletBalanceUsdis the balance AFTER the charge and is null when the org has no billing account row;billingModeisprepaid|postpaid(null likewise).callTypeismanual|phone|web. - NEVER fires for demo sessions (
if !sess.is_demo, line 831).isResettletrue 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.
Source: voice_ai_rust/src/utils/billing_settlement_worker.rs:857-879
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).
Source: voice_ai_rust/src/services/app_events.rs:366 (payload), :384-389 (topic_matches wildcard)
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.
Source: voice_ai_rust/src/services/recording.rs:314-321 (payload), :308-313 (the recording sub-object)
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.
Source: voice_ai_rust/src/services/app_events.rs:337-342
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.
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
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 istopic, notevent, there is noappId, andsessionIdappears BOTH at the top level and insidedata. - The socket carries ONE EXTRA topic the webhook path never sends:
call.transcript_partial(live interim STT, from the internalstt.partial) — live_call_stream.rs:36 vs live_call_events.rs:35-45. Same redaction (secrets stripped, nodesystem_promptremoved).subscriberesolves 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.
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
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.
fieldis the raw Meta webhook field that fired:account_update|phone_number_quality_update|account_review_update. valueis Meta's object VERBATIM (snake_case, shape varies perfield) and can be null; there is a nestedvalue.eventthat is NOT the topic name —"event"at the top level is the topic,value.eventis Meta's enum (FLAGGED, DISABLED_UPDATE, ACCOUNT_RESTRICTION, …). This event is a REFRESH SIGNAL only: re-read the live health GET for authoritative state. ADISABLED_UPDATE/ACCOUNT_DELETED/PARTNER_APP_UNINSTALLEDalso revokes the org's token server-side (webhooks.rs:1017-1025), so sends will start failing right after this arrives.
Source: voice_ai_rust/src/routes/webhooks.rs:988-995 (account_health_payload); emitted at :1039 and :1060
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.
mediais ALWAYS present as a key (null for text) somedia !== nullis 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 withmedia: { mime, filename, fetchError }and NOsignedUrl— always guard onmedia.signedUrl.contentis the structured extract and is null for plain text; media messages nest it ascontent.media.{id,mime,sha256?,caption?,filename?,voice?,animated?}(services/whatsapp_hub.rs:872-896), interactive replies ascontent.reply.{kind,id,title,description}, quick-reply buttons ascontent.button.{payload,text}.rawis Meta's message object verbatim — note itsfromhas NO+and itstimestampis a STRING of unix seconds, while the top-levelfromis normalized E.164 with+andtimestampis RFC3339 (+00:00, notZ— chronoto_rfc3339).wamidis nullable (provider_msg_id is Option). Delivered once per message — a Meta redelivery is suppressed upstream bypersist_inbound.
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
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,pricingandconversationare 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).errorsis 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.recipientis re-prefixed to+E.164(webhooks.rs:730) even though Meta sends it bare.timestampis 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.
Source: voice_ai_rust/src/routes/webhooks.rs:724-739
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 notetotalis justrows.len()of the current page, NOT a full count. - There is NO app-developer-facing route for app deliveries —
webhook_deliveriesis 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 markeddeadand 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).
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
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.
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)
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" }
}
]
datais SNAKE_CASE (node_id,node_kind,duration_ms) — the only snake_case payload the app receives besidescalls.history.datadoes 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 nocorr. Branch defensively. Topics: call.turn, call.transcript_partial (socket-only, never webhooked), call.node_entered, call.barge_in, call.silence, call.dtmf.system_promptis stripped from call.node_entered, and every payload is run through the secret stripper. A lagging socket drops frames silently.
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
telenow.agents.createFromTemplate(templateId, opts)
Scope: agents:read (+ owner/admin)
{ "agentId": "8c2e9f14-6d3b-4a77-b0e5-91af2c4d7e60", "kind": "flow" }
kindis "single" | "flow".- With the default
open:truethe host callsnavigate()immediately after resolving (:663-667), which UNMOUNTS your page — the promise resolves but your.thenmay 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 needagents.publicLink— the public page resolves by SLUG, not by agentId.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:657-667 — REST voice_ai_rust/src/routes/apps.rs:830-832
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.
Source: voice_ai_rust/src/routes/apps.rs:1017-1025 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:713-720
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
analysisobject (agent_config.rs:329-339) that the SDK'sTelenowAgentConfigtype does NOT declare. It is the post-call-analysis switch, which defaults OFF — this is how you explainanalysis:nullrows fromcalls.historyinstead of guessing at permissions. model.temperature/model.maxTokensarellmConfig.get(...)and are present asnull(not absent) when unset, whilemodel.configholds the full blob — the two can disagree (see the example).flowisnullfor a single-context agent; nodes havetools/precallLookupsREPLACED bytoolCount/precallLookupCount, and every node is run through the secret stripper (a real graph stores BYOK keys atflow.nodes[i].model.extra.apiKey).toolsis read-only — no scope writes it.realtimeEngineis the s2s provider string or null.updatedAtis what you echo back asifUnmodifiedSince.
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
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).
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:650
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]
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
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
}
}
updatedis 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: patchingflow.nodes[0].promptreports and authorizesprompt, notflow.- 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:promptthat sends{flow:{nodes:[{id,prompt}]}}is refused BY THE BRIDGE withthis app didn't request the "agents:config:write:flow" capabilityeven though the server would allow it. analysisis a writable group (enabled / customFields / qaCriteria only;analysis.modelis explicitly forbidden) but is missing from the SDK's patch type.configis the same documentgetConfigreturns — 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")
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
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 }
}
modelis the SERVED model, not what you asked for — it comes back with the provider's version suffix when the provider reports one, sores.model !== req.modelis normal.byoktells you whether the org's own key paid (true) or the platform wallet did (false) — present in the response but absent from the SDK'sAiLlmResulttype.usage.estimated:truemeans the provider reported NO usage and the numbers are a chars/4 guess — do not bill or budget off them.sessionIdin the request is optional: pass it to attach spend to a call, omit it to meter to the org.
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
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)")
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
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:
createMockBridgereturns{tiers:['fast','balanced','smart'], models:[]}(app-sdk-release/src/browser.ts:1027) — a FLAT array fortiersand amodelskey that does not exist in production. The real shape istiers.llm(an object) pluscatalogkeyed 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_scopecalls, so any oneai:*scope unlocks the whole catalog.pricingisnull(not an object) when a model has no rates. Reasoning models are hidden unless REASONING_ENABLED.
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
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]
Source: voice_ai_rust/src/routes/apps.rs:3105-3108 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:948-953
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/analysisEnabledare ALWAYS present here (unlike history, where they are opt-in) —analysisis null when PCA never ran.transcript[i].agent_idis snake_case inside an otherwise camelCase document (routes/calls.rs:628) and is null on user turns.participantsis a MAP of agentId → name,{}when no turn carried an agent id. System-role messages are filtered out oftranscript. 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 holdscalls:read:org.
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)
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 carryingcallMode,answeredBy,disposition,wrapupDisposition,hasRecording,createdAtand a wholelatency:{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, usecalls.get. analysis/analysis_enabledappear 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
analysisobject is camelCase, because it is passed through untouched from the serde struct. analysis:nullwithanalysis_enabled:falsemeans post-call analysis is OFF for that agent (default) — nothing was ever produced; flip it viaagents.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")
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
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 either 400 with {success:false,error,code,carrier,meta} — when the carrier refused for something on the account (an exotel_* code; see the outbound-call docs) — or 502 with {success:false,error,reason,retryAfterSecs} for a carrier that is unwell or a tripped circuit breaker. Either way axios rejects and the bridge collapses it to the error STRING only, so code/meta/reason/retryAfterSecs never reach the app; the host toasts the error sentence for a carrier refusal so it is not lost. 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")
Source: voice_ai_rust/src/routes/sessions.rs:883-891 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:725-729
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.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:738-739
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.
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
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.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:635 → api/apps.ts:850-861 — REST voice_ai_rust/src/routes/apps.rs:2389
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]
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:637 / :640 — REST voice_ai_rust/src/routes/apps.rs:3309 (create), :3333 (update)
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 DROPSnextCursor— there is no way to page pastlimitthrough the bridge. orgIdis on every row (AppObjectRow isrename_all="camelCase") even though the SDK'sAppRecordtype omits it.- Your fields are NESTED under
data— it isrows[i].data.status, neverrows[i].status. expandembeds underdata.<field>__expanded(services/app_object_enrich.rs:459) — also insidedata, and the original id field is left in place. To-one ⇒ object (null when the target is missing), to-many ⇒ array.updatedAtis always present (equals createdAt on a fresh row) despite being optional in the SDK type. limit defaults 200, clamped 1–500.
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
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}.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:643-644
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).
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:217-225 (bootstrap wrapper), :987-1010 (host opens the WS), :1011-1015 (unsubscribe)
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.
Source: voice_ai_rust/src/routes/apps.rs:1134 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:1031
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 anai:*scope, and it costs nothing — it parses a blob, it calls no model, despite living under the/ai/path prefix. charsistext.chars().count()(Unicode scalar values), so for non-ASCII text it will NOT equaltext.lengthin JS (which counts UTF-16 code units).truncated:truemeans 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")
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
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.
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
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.
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
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.
Source: voice_ai_rust/src/routes/apps.rs:1104 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:1027
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\"}"
}
bodyis ALWAYS A STRING —JSON.parseit yourself; the proxy never parses.- A non-2xx UPSTREAM status is a SUCCESSFUL resolve with
status: 404— only proxy-level failures reject. Branch onres.status, do not rely on try/catch. headerskeys are LOWERCASED,set-cookieand 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]
Source: voice_ai_rust/src/routes/apps.rs:2126-2132 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:1032-1036
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.
Source: voice_ai_rust/src/routes/apps.rs:3001-3004 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:943 (pass-through)
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]
Source: voice_ai_rust/src/routes/apps.rs:2980-2985 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:940 (pass-through)
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
tokenfield — by design.openCountcounts opens,usedAtmarks 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.
Source: voice_ai_rust/src/routes/app_links.rs:272-287 (run_list) — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:921-925
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.
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
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.
Source: voice_ai_rust/src/routes/app_links.rs:325 (run_revoke) — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:930
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
}
nameis""(empty string, not null) when the account has neither first nor last name — render a fallback or you ship a blank cell.firstName/lastNameare independently nullable and are joined with a single space only when both exist.emailis an ABSENT KEY (not null) unless the install holdsuser:profileas well.totalis justmembers.length— there is no pagination, the query returns the whole roster ordered by join date.
Source: voice_ai_rust/src/routes/apps.rs:3128-3153 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:960 (pass-through)
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.
Source: voice_ai_rust/src/routes/apps.rs:1700 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:881
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
valuesinto the local SETTINGS snapshot (:356) — sosettings.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 thecanCommunicatebar used by the messaging ops.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:903-912
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).
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:877-879
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]
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
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 REWRITESkind: the rawkindcolumn is discarded and a new one is derived fromprovider_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 requiressendTemplate.phonemay be null.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:809-823
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.
Source: voice_ai_frontend/src/components/apps/AppPageHost.tsx:828-829
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 plainsendgives{ok:true}. The SDK types itPromise<unknown>and the dev-mock returns{ok:true}(app-sdk-release/src/browser.ts:928) — so code written against the mock will readwamidas undefined in production. - The bridge ASSEMBLES the WhatsApp
componentsarray for you (:846-859):urlSuffixbecomes a{type:'button', sub_type:'url', index:'0'}component, andvariablesbecomes abodycomponent. Putting a URL token invariablesproduces a message that looks perfect but whose button points at the bare base URL. Language defaults toen_USserver-side.
Source: voice_ai_rust/src/routes/whatsapp_hub.rs:791 — bridge voice_ai_frontend/src/components/apps/AppPageHost.tsx:860-869 (pass-through)
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'serrorMessage()(voice_ai_frontend/src/utils/error.ts:13-20) collapses it todata.error ?? data.message ?? e.message, a bare string, and everything else — status,code,retryAfter, the 422errors/checklistarrays,reason/retryAfterSecsfrom 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.
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
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"}
Source: voice_ai_rust/src/routes/app_kb.rs:202, :347, :401, :420; voice_ai_rust/src/routes/app_campaigns.rs:261, :274
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
}
]
}
]
}
}
}
cataloghas 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
modelsonly; stt adds realtime/languages/perMinuteUsd; tts adds streaming/voices/perMillionCharsUsd/capabilities; telephony has capabilities/perMinuteUsd and NOmodelskey at all; s2s hasmodelswith a completely different member shape (modelKey, notid). - LLM models key on
id; tts/stt models key onkey; s2s models key onmodelKey.pricingisnull(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, sos2sis 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"}
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)
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.chargeswith sibling pagination keys. - EVERY money field is a decimal STRING, not a number —
rust_decimalis built with theserdefeature and noserde-float(Cargo.toml:93), so it serializes viato_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+. breakdownmoney 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 losescostUsd(db/billing.rs:544-560).agentId/agentName/startTimeare nullable (LEFT JOINs).hasMoreis computed from this page only:offset + charges.len() < total.
On failure:
403 {"success":false,"error":"app did not declare the billing:read scope"}
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)
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"
}
}
datais the charge object FLAT (nochargewrapper), and unlike the list route it ALWAYS carriesbreakdown.events.dataisnull— 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 ondata !== null.- Per-event:
costUsdis stripped,chargeUsdsurvives;purpose/cachedTokenscome from the event'smetaand are ABSENT-or-null when unset; a Telenow Smart STT line is a single collapsed row carryingcomposite:"telenow_smart"and nopurpose/cachedTokenskeys at all.inputQty/outputQtyare Decimal strings.
On failure:
200 {"success":true,"data":null} // still unsettled, or not this org's session
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)
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. modeis "prepaid" | "postpaid"; when it is "postpaid",currency,walletRateandwalletNativeare ALL null (app_billing.rs:61) — onlymode,suspended,balanceUsdare meaningful.- When the org has no
billing_accountsrow 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"}
Source: voice_ai_rust/src/routes/app_billing.rs:63-76; db/billing_money.rs:17-34 (BillingAccount)
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"
}
]
}
dataIS THE ARRAY — nocampaignswrapper. This breaks the pattern of every other app-plane list route (data.knowledgeBases,data.links,data.members,data.charges,data.files).createdAthere 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_metaandstartedAt/completedAtare NOT exposed.status∈ draft | running | paused | completed | cancelled. Capped at 200 rows, newest first, no pagination params.
Source: voice_ai_rust/src/routes/app_campaigns.rs:227-228 (envelope), :296-307 (summarize)
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
callCountsobject. callCountsis a GROUP BY result, so a status with zero rows is ABSENT rather than 0 — always read it ascallCounts.skipped ?? 0. Possible keys: "pending" | "dialing" | "completed" | "failed" | "skipped" (campaign.rs:76). Values are i64.completedTargets/failedTargetsare the campaign row's own counters and can lagcallCounts— they are not derived from it. 404 unlessapp_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"}
Source: voice_ai_rust/src/routes/app_campaigns.rs:239-248 (summarize + out["callCounts"]); services/campaign.rs:521-536 (call_status_counts)
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. contentTypeis 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.sizeBytesis i64.?prefix=filters by path prefix; the list is capped at 1000 rows with no pagination.
Source: voice_ai_rust/src/routes/app_data.rs:832 (envelope); db/app_blobs.rs:18-25 (BlobMeta, #[serde(rename_all="camelCase")])
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)
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)
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, NOTdataas an array. keyis the app's stable per-app key and isapp_kb_keyon the row — it isnullfor a KB that was not created with one.documentCountis an i64 number, defaulted to 0 byunwrap_or(0). The row'sorgId,appId,createdBy,deletedAtare deliberately NOT projected.kb:writeis accepted as a substitute forkb:read(app_kb.rs:63-68) so a write-only manifest's sync loop doesn't 403 on its own reads.
Source: voice_ai_rust/src/routes/app_kb.rs:123 (envelope), :98-109 (kb_to_json)
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"}
Source: voice_ai_rust/src/routes/app_kb.rs:186; services/knowledge.rs:455-473 (find_app_kb computes the count subquery)
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.
Source: voice_ai_rust/src/routes/app_kb.rs:369-373
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()) andcontentHash(lowercase hex sha256 of the body, 64 chars) for sync diffing. statusis 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.errorcarries the embed failure text when status is "failed", else null.sourceTypeis "text" for everything the app API creates.
Source: voice_ai_rust/src/routes/app_kb.rs:219 (envelope), :83-96 (doc_to_json)
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
tokenand nourl— by design; listing answers "did they open it?", not "resend it". - There is also NO
statusfield even though?status=active|used|expired|revokedis a FILTER: derive it yourself fromrevokedAt/usedAt/expiresAtvs now, in that precedence (the SQL CASE at app_links.rs:254-259 is the authority).openCountis an i32.targetType/targetId/opensAt/usedAt/revokedAt/lastOpenedAtare all nullable.limitdefaults to 100, clamped 1..500, and is NOT echoed — there is no cursor and nototal.
Source: voice_ai_rust/src/routes/app_links.rs:273-288 (run_list)
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.memberswith a siblingtotal. emailis a CONDITIONAL KEY: it is inserted only when the app ALSO holdsuser:profile(app_members.rs:37-39, :69-71) — when absent the key is missing entirely, not null. Check with'email' in m.nameis derived by concatenation and is the EMPTY STRING (not null) when both first and last are null, whilefirstName/lastNamethemselves are nullable.totalis justmembers.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).
Source: voice_ai_rust/src/routes/app_members.rs:62-79
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, notai:llm/ai:tts— it parses a blob you already stored viafiles.put, it calls no model and costs nothing. charsis the length of the RETURNED text, not of the source document: when the doc exceeds 48 000 chars the text is cut andcharsreports 48000 withtruncated: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"}
Source: voice_ai_rust/src/routes/app_ai.rs:664 (run_extract builds data), :633 (envelope), :631 (files:read gate)
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, nofinishReason, nochoices[], nomessages. modelis the SERVED model id echoed by the provider's usage frame (served_model, app_ai.rs:356-360), so a request forgpt-4o-minicomes back as the dated snapshotgpt-4o-mini-2024-07-18; billing still uses the model you ASKED for (app_ai.rs:486). If the provider reports no usage frame,modelfalls back to your requested string andusage.estimatedis true with tokens = chars/4 (app_ai.rs:480-484).providerechoes the resolved provider verbatim — the google→gemini / grok→xai canonicalization happens only on the billing row, not here.usage.inputTokens/outputTokensare 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"}
Source: voice_ai_rust/src/routes/app_ai.rs:491 (run_llm builds data), :200 (handler wraps {success,data})
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 baredata:line with a JSON object and NOevent:name and NO[DONE]sentinel; terminate ondone:true. Three mutually-exclusive frame shapes:{"token":"…"}, the single terminal{"done":true,…}, and{"error":"llm stream: …"}. - A mid-stream provider failure arrives as an
errorFRAME with HTTP 200 already sent — you cannot detect it from the status code. Validation/scope/catalog failures happen inprepare_llmBEFORE 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
doneframe is emitted AFTER metering, so a client that aborts early gets tokens but nodone(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 thedoneframe (AppPageHost.tsx:300-312).
On failure:
data: {"error":"llm stream: 429 rate_limit_exceeded"}
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)
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
}
}
formatis the lowercase serde rename of theAudioFormatenum (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.sampleRateu32,channelsu8,charsusize — all numbers.charsis the char count of the TRIMMED input text (cap 5000).- Unlike /llm,
sessionIdis 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."}
Source: voice_ai_rust/src/routes/app_ai.rs:807-821
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, notid— and none of the campaign's own fields (name, agentId, counters) come back; GET /api/app-campaigns/{id} for those.queued/suppressedare usize counts fromadd_targets(suppressed = DNC-blocked or unnormalizable numbers). statusis a HARD-CODED LITERAL derived from the request'sautostartflag (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"}
Source: voice_ai_rust/src/routes/app_campaigns.rs:148-156
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"
}
}
datais the KB object FLAT — not wrapped inknowledgeBase, unlike the list route'sknowledgeBases.- TRAP:
documentCountis 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 selectNULL::bigint AS document_count(knowledge.rs:287, :302) andkb_to_jsonturns 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"}
Source: voice_ai_rust/src/routes/app_kb.rs:174; services/knowledge.rs:277-312 (create_app_kb)
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"
}
}
datais the document object FLAT (nodocumentwrapper).statusis 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
idDIFFERS from the{docId}you addressed. Key your sync map on your own source id, never on the platform doc id. Omittingtitleon 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"}
Source: voice_ai_rust/src/routes/app_kb.rs:254 (create), :331 (replace); services/knowledge.rs:316-356, :527-550
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
docIdif you need a title.seqis the chunk's ordinal within the document (i32).topKis 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).
Source: voice_ai_rust/src/routes/app_kb.rs:287-298
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.
tokenis returned HERE AND NOWHERE ELSE — it is stored only as a hash-sidetoken_jti, so the list route deliberately never returns it; if you lose the url you must re-mint.urlis built from FRONTEND_BASE_URL, never from the request Host: the/l/:tokenroute only exists in the SPA, so deriving it from the API host produced dead links.requiresCodeis derived (code_hash.is_some()), the code itself is bcrypt-hashed and never echoed.singleUsedefaults to TRUE when omitted. Identical body from the dashboard proxy POST /api/orgs/{orgId}/apps/{appId}/links (routes/apps.rs:2521) — that path additionally recordscreated_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"}
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))
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"}
Source: voice_ai_rust/src/routes/app_links.rs:325 (run_revoke)
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.
contextis 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.sessionTicketis a String ONLY whenaction == "agent_call"; it isnullfor "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_atset) — a second resolve fails. Aformlink is burned on SUBMIT instead, so it stays re-openable.openCount/lastOpenedAtincrement 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}
Source: voice_ai_rust/src/routes/public_links.rs:281-289; mounted at /api/public/link (main.rs:1497)
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 declaredoptionsentry or the whole submit 400s. A create firesobject.createdfor 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"}
Source: voice_ai_rust/src/routes/public_links.rs:500
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 quantitysizeBytes— the two names do not match.pathis echoed as you sent it (post-clean_path, which rejects leading/,.., backslashes, >512 chars). - DELETE's
deletedis honest: it isfalsewhen 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"}
Source: voice_ai_rust/src/routes/app_data.rs:845 (upload), :881 (delete_file)
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 givesdata.files. (2)files.get()resolves to an ArrayBuffer, not an object; there is no envelope to unwrap.put,deleteandextractmatch their RESTdataobjects 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 theerrorstring. 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.
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)
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.listre-projects to TWO keys —{ id, name }— droppingkey,description,embeddingModel,documentCount,createdAt,updatedAtthat the app-key route GET /api/app-kb returns. It exists only to resolve a KB id forkb.search. An app that readsdocumentCountofftelenow.kb.list()getsundefined, not 0. - The bridge exposes only
listandsearch— create / documents / replace / attach have NO bridge op; those are app-key REST only. All bridge promises resolve to the RESTdataobject with the{success,data}envelope already stripped (AppPageHost.tsx:191).
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)
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" }
erroris ALWAYS a plain string, never an object and never an array of field errors.codeexists on four cases —provider_error(502), the carrier-refusal codesexotel_*(400, dial routes only), the link codeslink_code_required/link_code_wrong/link_code_locked(403), and the JWT-onlytoken_expired/token_invalid(401, dashboard plane) — so branch on HTTP status first and treatcodeas optional.- On 429 the retry hint is
retryAfterIN THE BODY (seconds, i64); noRetry-Afterheader 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"}
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)
If an example here is wrong
Report it — 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.