App API responses
What the app-key endpoints (/api/app-*), the dashboard proxy your app's pages call, the window.telenow.* bridge, app tools and app events send back: the actual JSON. The scope and endpoint pages tell you what you may call; this page shows what comes back.
What every API caller sees — the post-call analysis object, the live-call stream, tool results and webhook event samples — is on API responses. Every example here was checked field by field against what the platform sends.
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
An app tool's result is fed straight back to the model as the tool's output, so its shape decides what the agent can say next. This section shows what each app handler returns.
What is the same for every tool is on API responses → Tool results: how the result is wrapped for each model family, the confirm-before-execute gate, the built-in tools, unknown and skipped tools, a flow's tool step, the tool.invoked webhook and the call trace.
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 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" on that key alone. The request your service receives is {"tool":"<wire name>","arguments":{…}}, plus "caller" only when the agent has "Send caller identity to tools" turned on, signed x-telenow-signature: sha256=<hex>.
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 → manifest objects[].fields[].default (which the platform copies into the tool's config.field_defaults) → the system-injected caller_number, which is written only when the agent has "Send caller identity to tools" turned on AND the call has a caller number, and then always overwrites anything the model sent for it. With that toggle off, the platform never adds caller_number. GOTCHA: create's record does NOT carry computed fields. Computed fields are applied only when records are read through a query, 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.
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. 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 even though the unsupported-kind message below does not list it.
On failure:
{
"ok": false,
"deleted": false,
"error": "no matching record found"
}
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 row'sorg_id,app_id,object_type,created_byandupdated_atare all dropped.created_atis RFC 3339 with microseconds and aZsuffix.dataDOES include computed fields (display_nameabove), because the tool reads records the same way the Data API does rather than from the raw store. Relation<field>__expandedkeys NEVER appear here: the tool never expands relations. Hard limit of 50 rows, newest-first. Zero matches is{"results":[]}and is classed "success". Filtering is exact TEXT equality on each field's stored value, and args that are not declared stored fields are silently DROPPED from the filter.
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 (top-level keys) merge, not the patch — fields the model never mentioned are still present. Only the NEWEST record whose match field equals the value (exact text equality) is touched. The match field is stripped from the patch, and manifest handler.set constants are applied last and always win. Computed fields are NOT applied here either.
app handler sandbox (gated) — success and failure
{
"success — the snippet's return value, 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 — platform-built envelope; `status` is one of error|timeout|oom|invalid|busy, `error` is a diagnostic of at most 512 bytes": {
"ok": false,
"status": "timeout",
"error": "execution timed out"
},
"gated off (the DEFAULT — sandbox tools not enabled on the deployment) — a hard error, so the model sees only a flat `error`": {
"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". 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; busy means the platform-wide limit on concurrently running sandboxes was reached, invalid means empty/oversized source or oversized inputs. Error text is capped at 512 bytes and truncated on a UTF-8 character boundary. When sandbox tools are not enabled on the deployment, the whole call is instead a hard error: {"error":"sandbox app tools are disabled (set FEATURE_APP_SANDBOX=true)"}; a manifest with no code gives {"error":"sandbox handler missing code"}.
On failure:
{
"ok": false,
"status": "timeout",
"error": "execution timed out"
}
app handler http — failure envelopes
{
"error": "app tool failed (404 Not Found): {\"message\":\"no such clinic\"}"
}
All five are hard errors, so the model gets the flat {"error": …} form, never {ok:false}. A non-2xx response interpolates the status code with its reason phrase ("404 Not Found") and the response body TRIMMED TO 300 CHARACTERS, 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 the HTTP client's own error text under the same error key.
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 (returned as a normal tool result, not an HTTP error), so the tool call "succeeds" at the transport level and only the error key marks it. The check FAILS CLOSED when the platform cannot refresh its cached row count — so a transient database error 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).
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). 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 straight on: …and try again (this tool…, one space and no full stop before the parenthesis). 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 lookups work without the consent toggle.
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)"
}
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": {
"ok": false,
"error": "no matching record found"
},
"missing_match_value - 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": {
"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 a hard error instead — it never carries ok, and the model sees only {"error": "…"} (see the failure example below; 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"}.
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."
}
app tool — pre-dispatch refusals (before any handler runs)
{
"error": "app tool 'clinic-crm' is not available to this agent"
}
All flat {"error": …} (hard errors), 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 database failure inside insert/update/delete also surfaces here, as the database driver's raw error text under error.
app tool — unsupported handler kind
{
"error": "unsupported app handler 'js' — supported: object.create / object.query / object.update (declarative), http (external), sandbox (gated)"
}
This message is incomplete: it omits object.delete, which is fully supported. Don't rely on this string to list the supported kinds. It fires most often for js, because the SDK's shipped JSON Schema offers js in editor autocomplete while the platform only runs JS through sandbox.
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, 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"}
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). - RESERVED QUERY KEYS are swallowed and never become filters: limit, search, topK/topk, sort, dir, numeric, view, expand. A field literally named
sortorlimitis unfilterable here. - Every other query param becomes an EQUALITY filter with a STRING value —
?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.?view=names a manifest-declared saved view whose filter WINS over a caller filter on the same key; an unknown view name is a silent no-op, not a 404.- Scope reality:
data:readis enforced ONLY when the credential carries scopes — i.e. an OAuth Bearer token. A static per-install app key passes unconditionally. Theobjects:<type>scope every manifest declares is NOT enforced by any external Data API route.
On failure:
HTTP 403 {"success": false, "error": "token does not include the data:read scope"}
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 always the literal "read", the only access level the bridge supports (non-read dependencies are skipped and never listed).- 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 checks no scope and does not apply the per-provider read gate of the route below; it is discovery only.
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=. This is deliberate — 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, 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"}
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>", and its timestamp is written with+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: computed fields present, but this route never applies
view,expandorsearch— use POST /query for those. - limit default 200, clamp 1..500.
- Read is membership-only; only the WRITE routes require owner/admin/developer.
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 shallow MERGE of top-level keys, so the returned
datais 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 only matches on org+app+type+id together.
On failure:
HTTP 404 {"success": false, "error": "record not found"}
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, 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. - The unknown-object-type 400 only fires on a DEFINITE "not declared" — when the platform can't load or parse the manifest, the write is allowed, so a manifest blip fails OPEN.
- Body must be a JSON object or you get 400 "body must be a JSON object".
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)"}
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, unlike the external API's literal "api" — same field, 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)"}
POST /api/orgs/:orgId/apps/:appId/objects/:objectType/change-ticket
Scope: org membership + objectType declared
{
"success": true,
"data": {
"ticket": "9c2f0a7b41d84e6cb3f5d0a19e77c412",
"wsUrl": "wss://app.telenow.ai/ws/object-changes?ticket=9c2f0a7b41d84e6cb3f5d0a19e77c412"
}
}
ticketis a DASHLESS 32-hex uuid — 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".- Single-use, 30s TTL, consumed on connect — a second connect with the same ticket fails.
POST /api/orgs/:orgId/apps/:appId/objects/:objectType/query (dashboard proxy — filtered/sorted read)
Scope: org membership + app installed & live + objectType declared
{
"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— 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);order_byin the body is silently ignored. filtervalues may be scalars (equality) or operator objects: $eq $ne $gt $gte $lt $lte $in $contains. $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"}
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 BEFORE ANY ENRICHMENT, 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 an exact count of every matching row, not clamped to 500 like the row read.
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), always. The keys INSIDE
dataare 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, 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, 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 (missing rows are dropped, not nulled). 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, the user's uuid string for the dashboard proxy,"agent:<agentUuid>"for in-call tools,"inbound-hook"for webhook writes, and null for public-link writes. - Trust the wire over a TypeScript type: the server always sends
orgIdandupdatedAt, even where anAppObjecttyping omits them.
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". 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: a frame is published with the row exactly as it was written, before computed fields or expansions are applied — 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 — silently, with no signal that any were missed.
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 manifest field defaults (objects[].fields[].default) and — only when the agent has "Send caller identity to tools" turned on and the call has a caller number —caller_number, force-set from the trusted caller.- 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
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— the tool builds this shape itself instead of returning the camelCase row.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, so computed fields appear; relations are never expanded, so
__expandednever does. - Hard-capped at 50 rows with no cursor and no total.
- Args are filtered to the tool's DECLARED
fieldsbefore becoming the filter — 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" }
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: the host unwraps{success,data}to{count}and passes that through verbatim. - The 2nd arg is the filter and is sent as
{filter, countOnly:true}— the same countOnly behaviour as REST, so view/expand/search/limit are ignored.
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 (the host strips the envelope). 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 the server's error string — for a viewer/member that is `new Error("this action requires one of: owner, admin, developer")`; the bridge does not role-check data writes itself
telenow.data.list(objectType, query, opts) — UI bridge
Scope: none — the bridge treats an app's own objects as always-allowed
[
{
"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 it and iterate directly — there is no.data, no.objects. - nextCursor IS DISCARDED and
optshas nobefore, 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); 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.
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 the delete, THROWS AWAY the server's
{success:true}, and synthesizes{ok:true}. 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.
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, 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, so they never resolve a pending RPC.
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, and the same number of seconds is also sent as a standardRetry-Afterheader. 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 the current cap, so it moves if the cap moves. It fires on both create paths (external Data API and dashboard proxy); the agent tool instead returns an in-band
{ok:false,error:"app data store is full — cannot create more records"}at 200.
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_shape": { "success": false, "error": "<message string>" },
"notes": "Flat, exactly two keys. The wire string is the bare message - it never carries a `not found: ` / `bad request: ` prefix.",
"dashboard_proxy_object_routes": {
"route_prefix": "/api/orgs/:orgId/apps/:appId/objects/:objectType",
"404_record_not_found": {
"status": 404,
"from": "update / delete",
"body": { "success": false, "error": "record not found" }
},
"404_app_not_installed": {
"status": 404,
"from": "query / change-ticket / create / update / delete (install and object-type check)",
"body": { "success": false, "error": "app is not installed" }
},
"400_body_not_object": {
"status": 400,
"from": "create / update",
"body": { "success": false, "error": "body must be a JSON object" }
},
"400_unknown_object_type": {
"status": 400,
"from": "query / change-ticket / create / update / delete (install and object-type check)",
"body": { "success": false, "error": "unknown object type 'patient'" }
},
"403_write_role_gate": {
"status": 403,
"from": "the org role check",
"applies_to": "create / update / delete",
"body": { "success": false, "error": "this action requires one of: owner, admin, developer" }
},
"403_not_a_member": {
"status": 403,
"from": "the org membership check",
"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. No route checks an `objects:*` scope, and the UI bridge treats object CRUD as 'always allowed'. `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": "the Data API scope check (OAuth Bearer tokens only)",
"body": { "success": false, "error": "token does not include the data:read scope" }
},
"403_static_key_scope": {
"status": 403,
"from": "the app-files routes, which check a static key against the install's granted scopes",
"body": { "success": false, "error": "app did not declare the files:read scope" }
},
"429_rate_limited": {
"status": 429,
"from": "the per-(org, app) Data API rate limit",
"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": "the dashboard proxy's install-scope check",
"reachable_from": "ai:llm, files:read, links:write/read, agents:read, agents:write, agent-config read, kb:read, calls:transcribe:live, members:read, user:profile - 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": "an expired or invalid dashboard session token",
"body": { "success": false, "error": "Access token expired", "code": "token_expired" }
},
"403_with_code": {
"from": "a write attempted while a support admin is viewing the account read-only",
"body": { "success": false, "error": "Read-only view: this account is being viewed by an admin and cannot be modified. Ask the customer to grant temporary write access.", "code": "impersonation_write_denied" }
},
"502_provider": {
"from": "an upstream carrier or vendor rejecting the request",
"body": { "success": false, "error": "callback_method requires a valid URL", "code": "provider_error" }
}
},
"ui_bridge_reprojection": {
"from": "the dashboard host that runs your app page",
"note": "An app running inside the sandboxed iframe NEVER sees the REST envelope. The host catches the failed request, takes the response body's `error` string, 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 on session-token (JWT) failures, coded 403s, coded 400s (such as a carrier refusing a dial) and provider errors, and no object route raises any of them itself. erroris a SENTENCE, not a fixed code — never branch on its text; branch on the HTTP status.- Any unmapped internal failure collapses to HTTP 500
{"success":false,"error":"Something went wrong"}— a database error on a query looks identical to any other internal crash.
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 resolves with the frame'sdataon ok and rejects withnew Error(error)otherwise. - 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" and "your role does not permit this action" never reached the network at all.
Calls & post-call analysis
Call history, one call in full, and the analysis document.
The analysis object
The calls endpoints below carry a call's post-call analysis as the same object the rest of the API returns. Every field, its type and its empty values are on API responses → The post-call analysis object.
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 (includeAnalysis / includeCost) is set, 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. 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 — the dashboard's own call rows have them, this one never does. Money fields are JSON STRINGS ("0.016237"), not numbers; convert them yourself (the dashboard uses parseFloat). cost.breakdown.events[] is stripped here — per-event detail only from /api/app-billing/charges/:sessionId. includeCost additionally requires billing:read or the whole request 403s.
On failure:
// 403 — neither calls scope granted
{ "success": false, "error": "requires the calls:read or calls:read:org scope" }
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 — unlike the list route where they are flag-gated. transcript[].agentId is CAMELCASE ONLY ON THIS ROUTE: the transcript is built with snake_case agent_id and this route explicitly renames it — the dashboard proxy twin does NOT, so the same transcript is agent_id there. transcript[].agentId is a STRING uuid recorded with each message, 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. participants is a MAP keyed by agent-uuid string → agent name, and is {} (empty object, not null/array) when no turn carried attribution. at is the time the message was recorded, RFC3339.
On failure:
// 404 — not this org's call
{ "success": false, "error": "call not found" }
// 403 — org owns it but the install isn't bound to its agent, and no :org scope
{ "success": false, "error": "app is not bound to this call's agent" }
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": { /* the full analysis object, 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, 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 — the route accepts no such parameter. Row shape is otherwise byte-identical to the app-key plane because both planes build the row the same way.
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 passes the transcript through as built, so transcript rows keep snake_case agent_id. The app-key route 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.
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
{ "success": false, "error": "live assist is only available on manual softphone calls" }
// 409 — not live right now
{ "success": false, "error": "call is not live" }
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 with no dashes, single-use, 30s TTL. wsUrl already carries the ticket — connect to it directly; the scheme is derived from x-forwarded-proto so it is ws:// only on plain-http local dev.
On failure:
// 409 — call is not live on this node
{ "success": false, "error": "call is not live" }
WS /ws/live-call-stream — the frames
The ticket above opens the same socket the organization's own backend uses. Its frames, every topic, the softphone live-assist frames and the close codes are on API responses → Live call stream.
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 function;
// 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 navigates the dashboard to/calls/:id, which unmounts the app iframe.stream.subscriberesolves to an UNSUBSCRIBE FUNCTION, not to the streamId — the{streamId}object is internal plumbing.- 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.
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": { /* the full analysis object, 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. Three conventions reachable from one app: history rows (snake), get row (camel), get transcript rows (snake).
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 (the host hands back only thecallsarray), 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. 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" }
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 bridge rejects the Promise with new Error(error), 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 coded 403s, on 502 provider_error, and on coded 400s — which now include 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. The live-call WebSocket does not use either envelope — it signals failure with a 1008 close frame carrying a plain-text reason.
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:
{ "success": false, "error": "agent was not created by this app" }
404 for missing/cross-org: { "success": false, "error": "agent not found" }
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 — 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.
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": [],
"minDurationSec": 20,
"minCustomerTurns": 2,
"language": "en",
"sections": { "summary": true, "sentiment": true, "disposition": true, "actionItems": true, "objections": true, "evidence": true, "topics": true, "judge": true },
"basePrompt": "You are writing the notes the owner of this AI agent reads after each call. The owner is the business or person the agent answers for. …"
},
"objectives": { "enabled": false, "askCap": 2, "slots": [], "actions": [], "dispositions": [], "fallbackDisposition": null },
"tools": [
{ "name": "book_appointment", "description": "Book a slot in the clinic calendar", "kind": "http" }
],
"realtimeEngine": null
}
}
- camelCase throughout. Every one of the 16 top-level keys is ALWAYS present — none are omitted.
flowisnullfor a single-context agent; everything else is identical, soprompt.systemPromptworks without branching onkind.voice.config/stt.config/telephony.configarenull(NOT{}) when the agent's underlyingtts_config/stt_config/telephony_configis unset.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. 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:
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:
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;kindfalls back to "custom" when absent,name/descriptionfall back to "" (never null).analysis.enabledis the NEWER group — it defaults to FALSE, which is why an app reading calls with includeAnalysis=true seesanalysis: nullon every row.customFields/qaCriteriadefault to[]when metadata.postCallAnalysis is absent. Truthiness matches exactly how the platform decides whether to analyze a call, including legacy strings:"true"/"yes"→ true, a JSON NUMBER (1) → FALSE.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 PICKED UP for analysis, so it has no analysis 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 platform 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 analysis 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 snapshot of the agent taken when the call started, so editing a threshold never re-gates calls that already ran.analysis.languageis the language the analysis' FREE TEXT is written in (summary, actionItems, objections, topics, keywords, coaching/hallucinations/cx notes, free-text custom fields); labels a program reads —sentiment,disposition,cx.rating,severity, booleans, numbers, option-list custom fields — stay English whatever it says. RESOLVED on read like the thresholds: the stored value when the analysis can name it, else"en"(the default) — never null. WRITABLE withagents:config:write:analysis: a code ("hi-IN"), a name ("Hindi") or a builder key ("hi-IN|Hinglish (Hindi in Latin letters)"); anything the analysis cannot name is a 400 rather than a silent no-op, andnullclears it back to English. A pool number's routing endpoint can still override it for ONE call (overrides.analysis_language); that override is the call's and never shows here.analysis.sectionsis which parts of the analysis are asked for, RESOLVED on read: all eight keys (summary,sentiment,disposition,actionItems,objections,evidence,topics,judge), eachtrueunless the agent stores itfalse— never absent. A part that isfalseis left out of the prompt AND the extraction schema, and its fields stay empty in the stored row and oncall.analyzed(null/[]/{});judgecoversscore,coaching,hallucinationsandcx. WRITABLE withagents:config:write:analysis; a patch MERGES key by key —{"sections": {"judge": false}}leaves the other stored parts alone, a key set tonullturns that part back on, and"sections": nullturns every part back on. Unknown keys and non-booleans are a 400.analysis.basePromptis the opening paragraph of the analysis prompt — who the analysis is written for and how its free text reads. RESOLVED on read likelanguage: the agent's own paragraph, else the platform default's text (written for the agent's owner, in the third person) — never null. Everything after it is fixed and cannot be changed from it: what the transcript is, the field names and fixed values,sections, custom fields, the QA rubric and judge, andlanguage. WRITABLE withagents:config:write:analysis: text of at most 2,000 characters (counted after trimming);nullor blank goes back to the default, and longer text or a non-string is a 400. The default's own text is never stored, so writing the group back exactly as read keeps the agent on the default. It applies from the next call; analyses already stored are not rewritten.objectivesis the agent's stateful context: the per-agent enable flag, the slots the call tries to establish, the actions it must actually run, and the disposition rules it is graded by. Its six keys are RESOLVED on read and never absent:enabled(defaults to FALSE; truthiness is read exactly likeanalysis.enabled),askCap(how many times one slot may be asked before it is recorded as declined — 1 to 4,2when unset),slots,actionsanddispositions(each[]when unset) andfallbackDisposition(nullwhen unset). WRITABLE withagents:config:write:objectives: those six keys plussource("compiled"lets the platform's call-plan compiler replace the plan,"manual"stops it; any other value is dropped). A patch MERGES key by key, a key set tonullgoes back to its default, and the platform caps the lists at 30 slots, 20 actions and 12 dispositions and drops malformed slots.status,lints,compiledFrom,boundInputsandattemptedAtbelong to the compiler and are a 403 ("`objectives.status` is written by the call-plan compiler and cannot be set directly"); any other key is a 400 ("unknown `objectives` field `x` — expected enabled, askCap, slots, actions, dispositions, fallbackDisposition, source").realtimeEngineis a bare provider STRING (or null) pulled from s2s_config.provider — not the s2s blob.updatedAtis RFC-3339 with sub-second precision; echo it asifUnmodifiedSinceon the PATCH.- TYPE DRIFT: the TypeScript agent-config type does NOT declare
analysisorobjectives, and the patch type omits them too — so the runtime serves groups the types deny. The bridge derives groups from the patch's own keys at runtime, so{ analysis: {...} }and{ objectives: {...} }work; only the types are wrong.
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 and is created LAZILY the first time it is asked for, so the URL is present and stable even while isPublic is false (the widget just 404s). publicUrl uses the dashboard host, embedSnippet uses the API host — two different hosts, trailing slash trimmed. PUT body is {"enabled": true}. Only the public on/off flag changes; branding/access code/lead capture are read back and preserved.
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. memberCount is just the length of members, so it is always redundant with the array.
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 — it is never null. description IS nullable. id is a manifest slug; feed it straight back as :templateId.
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": [],
"minDurationSec": 20,
"minCustomerTurns": 2,
"language": "en",
"sections": { "summary": true, "sentiment": true, "disposition": true, "actionItems": true, "objections": true, "evidence": true, "topics": true, "judge": true },
"basePrompt": "You are writing the notes the owner of this AI agent reads after each call. The owner is the business or person the agent answers for. …"
},
"objectives": { "enabled": false, "askCap": 2, "slots": [], "actions": [], "dispositions": [], "fallbackDisposition": null },
"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": [],
"minDurationSec": 20,
"minCustomerTurns": 2,
"language": "en",
"sections": { "summary": true, "sentiment": true, "disposition": true, "actionItems": true, "objections": true, "evidence": true, "topics": true, "judge": true },
"basePrompt": "You are writing the notes the owner of this AI agent reads after each call. The owner is the business or person the agent answers for. …"
},
"objectives": { "enabled": false, "askCap": 2, "slots": [], "actions": [], "dispositions": [], "fallbackDisposition": null },
"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: tools/precallLookups become toolCount/precallLookupCount)": {
"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 (org membership for GET; role owner|admin|developer for PATCH), and it checks that the app is still live, so a disabled app cannot keep writing.
On failure:
403 with the upgrade hint the app-key plane does NOT emit:
{ "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" }
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 the SAME body on both planes, so the app-key plane and the UI-bridge plane never describe the same link two different ways. PUT here checks the SIGNED-IN USER is owner/admin; the app-key PUT instead checks the INSTALLER's role when it saves.
On failure:
403 (not connected): { "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" for the identical condition.
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": [],
"minDurationSec": 20,
"minCustomerTurns": 2,
"language": "en",
"sections": { "summary": true, "sentiment": true, "disposition": true, "actionItems": true, "objections": true, "evidence": true, "topics": true, "judge": true },
"basePrompt": "You are writing the notes the owner of this AI agent reads after each call. The owner is the business or person the agent answers for. …"
},
"objectives": { "enabled": false, "askCap": 2, "slots": [], "actions": [], "dispositions": [], "fallbackDisposition": null },
"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 config document again (freshly re-read after the update) — identical shape to the GET, including a NEWupdatedAtyou must capture for the next conditional write.updatedlists the GROUPS the patch touched, in a fixed group order (prompt, model, voice, stt, behavior, flow, telephony, analysis, objectives) — 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.ifUnmodifiedSinceis stripped from the body before validation — 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):
{ "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):
{ "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):
{ "success": false, "error": "app did not declare the agents:config:write:voice scope" }
403 (never-writable): { "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): { "success": false, "error": "unknown config group `prompts` — expected one of: prompt, model, voice, stt, behavior, flow, telephony, analysis, objectives" }
400 (no-op patch): { "success": false, "error": "this patch changes nothing — send at least one setting inside a group" }
400 (graph regression): { "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." }
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". NOTE the key is agentId here but id in the LIST response — the same object is named two different ways across two routes of the same API.
On failure:
413 when the per-app cap (100 agents) is hit:
{ "success": false, "error": "this app has reached its agent limit (100) for this org" }
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 omitted rather than null — the key is ABSENT when the judge did not score (no expect rubric, or the run errored). name is never null: it defaults to "scenario N". Max 3 scenarios per request, 200 s budget.
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" }
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].agentIdandentryAgentIdare nullable — they render asnullif a member ref did not resolve to a created agent, so type both asstring | null. The bridge relies on this: when no entry inagentsmatchesentryAgentId, it falls back toagents[0]. Not transactional — a mid-way failure best-effort deletes the partial team and returns the error instead.
On failure:
413: { "success": false, "error": "creating this team would exceed the app's agent limit (100) for this org" }
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 (itsmetadata.flow), not from the created row — so it is computed before creation, from the same >1-node-or-any-edge test. - Gated ONLY by the signed-in user's org role (owner or admin) — the server checks no app scope at all. The
agents:readrequirement developers see is a CLIENT-SIDE pre-check in the bridge, not a server gate. Side effects not visible in the response: the app is auto-bound and its KBs attached.
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" }
telenow.agents.getConfig(agentId) / updateConfig(agentId, patch)
Scope: getConfig: agents:config:read + any org role (view activity) · updateConfig: agents:config:write:<group> per key + role owner/admin/developer
{
"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": [],
"minDurationSec": 20,
"minCustomerTurns": 2,
"language": "en",
"sections": { "summary": true, "sentiment": true, "disposition": true, "actionItems": true, "objections": true, "evidence": true, "topics": true, "judge": true },
"basePrompt": "You are writing the notes the owner of this AI agent reads after each call. The owner is the business or person the agent answers for. …"
},
"objectives": { "enabled": false, "askCap": 2, "slots": [], "actions": [], "dispositions": [], "fallbackDisposition": null },
"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": [],
"minDurationSec": 20,
"minCustomerTurns": 2,
"language": "en",
"sections": { "summary": true, "sentiment": true, "disposition": true, "actionItems": true, "objections": true, "evidence": true, "topics": true, "judge": true },
"basePrompt": "You are writing the notes the owner of this AI agent reads after each call. The owner is the business or person the agent answers for. …"
},
"objectives": { "enabled": false, "askCap": 2, "slots": [], "actions": [], "dispositions": [], "fallbackDisposition": null },
"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 to the frame'sdata, so there is nosuccessand no outerdatato destructure. - The bridge pre-checks scopes from the patch's top-level keys minus
ifUnmodifiedSince— soifUnmodifiedSincestill travels inside the patch object, exactly as the REST body expects. - The bridge does NOT validate group NAMES (unknown groups are left to the server, so there is only one rule), so a typo surfaces as a server 400, not a local throw.
analysisandobjectiveswork at runtime here (groups are derived generically) even though the TypeScript patch type omits them — cast if TypeScript complains.
telenow.agents.list() — BRIDGE DIFFERS from GET /api/app-agents
Scope: agents:read (bridge-side check) + any org role (view activity)
[
{ "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 (limit 200, offset 0) 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), but the re-projection means an app never sees that.
telenow.agents.publicLink(agentId) / setPublic(agentId, enabled) / createFromTemplate(templateId, opts) / createTeamFromTemplate(teamId)
Scope: publicLink agents:read+any org role (view activity) · setPublic agents:write+owner/admin · createFromTemplate & createTeamFromTemplate agents:read (bridge-only pre-check)+role owner/admin/developer
// 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/workspacedepending onkind, which UNMOUNTS the app page and destroys the iframe, so any code after the await may never run. Pass{ open: false }to stay mounted. createTeamFromTemplate ALWAYS navigates to the entry agent — 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.
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), coded 403s, coded 400s (carrier refusals on the dial routes, exotel_*), 502 provider (provider_error). None of the agent routes emit code. Anything unmapped becomes 500 with the literal string "Something went wrong".
BRIDGE: the host replies {ok:false, error} and the SDK REJECTS the promise with new Error(error) — so an app catches an Error and reads err.message; there is no success flag and no HTTP status to branch on. The host flattens error to a single string, preferring the server's own error sentence, 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 or your role does not permit this action.
Status map for these routes: 400 Bad Request, 403 Forbidden, 404 Not Found, 409 Conflict (ifUnmodifiedSince), 413 Payload Too Large (agent cap), 429 Too Many Requests.
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. Org REST-hooks (/api/v1/hooks) use X-VoiceAI-Signature instead — same HMAC, different header name. 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. Response body is truncated to 4096 chars and stored with the delivery record.
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\". Writing \"action\" leaves it unset and the handler silently never runs.",
"casing": "Mixed by construction: session_id and caller_number are snake_case, agentId is camelCase.",
"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.",
"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.",
"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.",
"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. Paths are dotted, with an optional leading $. that is stripped. A missing segment resolves to nothing → 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.
On failure:
Silent no-ops, no error surface: (a) if the mapped `key` field resolves to absent/empty the whole upsert is skipped; (b) if the app's row cap is hit the upsert is skipped with only a server-side warning.
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. Rule paths aresummary, notanalysis.summary. (2) NOeventkey — this is the only topic whose payload omits it, because it is the analysis fields with three fields appended. (3) - CASING IS MIXED IN ONE OBJECT:
caller_numberandsession_idare snake_case,agentIdis camelCase (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 (it is the call's from-number). Fires only if post-call analysis is enabled on the agent.
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": {
"corr": "t7:n=collect_details:g=2",
"played_ms": 1840,
"turn_audio_ms": 4200
},
"inner_payload_s2s": {
"corr": "t7:n=-:g=0",
"source": "s2s_vad"
},
"surface_1_stored_debug_trace": {
"_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": {
"_note": "sessionId is merged INTO data on this surface.",
"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": {
"_note": "sessionId is a SIBLING of data, not inside it. Same frame reaches an app verbatim through the UI bridge, 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.
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 event is emitted before any routing decision).
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. That enrichment is added AFTER the event has gone to apps, and only for org webhook endpoints that set include_recording/include_transcript. To get the audio, subscribe to recording.ready. Note the key is fromOrTo here vs from on call.started. durationSecs is wall-clock (end time − start time), not billed seconds.
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. Fields live under data; sessionId IS inside data.",
"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, relayed verbatim by the dashboard host. sessionId is a SIBLING of data here, NOT inside it — it is never merged into data on this surface.",
"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 optional — the keys are ALWAYS present and become null, never omitted. system_prompt is always absent (stripped from both the webhook and the live-stream copy).",
"node_name": null,
"entry_message": null
}
}
The internal trace carries system_prompt (the node's full instructions); the app copy has it REMOVED, so do not code against it. Credential-shaped keys are also scrubbed before dispatch. Fires per node ENTRY, including re-entry — node_generation increments so corr stays unique. node_kind ∈ conversation | static | router | extract | dtmf | end | note. Flow agents only; a single-context agent never emits this.
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.
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 always a non-null uuid. startTime is RFC3339 with sub-second precision. Unlike the mid-call topics this needs NO calls:read — a bound app receives it; only an UNBOUND org-wide app is scope-checked (for calls:read:org).
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 before delivery. 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} — 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-event buffer drops events rather than slow down a live call. Only these five internal trace types map to app topics; everything else (llm.request, tts, tool.call…) never leaves the platform. Scope is enforced at dispatch — no scope, no error, just silence.
On failure:
—
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 — subscribing without
billing:readyields silence, never an error. Org-scoped: no agent binding needed, and you receive EVERY agent's settlements. - Amounts here are floating-point JSON NUMBERS, deliberately (predicate-friendly); the authoritative exact amounts 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;billingModeisprepaid|postpaid(null likewise).callTypeismanual|phone|web. - NEVER fires for demo sessions.
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.
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) — never org-wide. Emitted only from the explicit create paths (agent object.create tool, the data API create, the dashboard data proxy); a declarative rule handler's own upsert 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).
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 platform cannot sign a download URL (e.g. a recording stored under a different storage 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. agentId is always present in the APP copy because apps are not sent this event at all when the recording has no agent — the org REST-hook copy still fires and can carry "agentId": null. URL TTL is 3600s.
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 one of the subscribable event topics and cannot be subscribed to via events[].on; the schedule's own handler runs it. This is the ONLY dispatch path the platform retries on its own failure — a transient database failure on the upsert/enqueue makes the platform retry the tick; configuration problems are settled without a retry.
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.
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, andsessionIdsits at the top level ONLY — unlike the webhook body,datadoes not repeat it. - The socket carries THREE topics the webhook path never sends:
call.transcript_partial(live interim STT, from the internalstt.partial),call.assistant_turn(the agent's own reply, once per reply, withtext,engineandcorr) andcall.context_note(a live context note reached the agent). Their payloads are on API responses → Live call stream. 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.
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 own status value (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, so sends will start failing right after this arrives.
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. 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?}, 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).wamidis nullable. Delivered once per message — a Meta redelivery is suppressed before it reaches you.
whatsapp.message.status
Scope: whatsapp
{
"_note": "ONE event, TWO wire shapes, both carrying the same 10-key payload. Org REST-hooks deliver it FLAT (body = payload verbatim). App event webhooks NEST it under `data` and add `appId`. 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 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.164even 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 — a Meta redelivery or a backwards status is dropped, so you will not see duplicates but you also may not see every intermediate state.
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 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 just the number of rows in the current page, NOT a full count. - There is NO app-developer-facing route for app deliveries — the deliveries API lists only deliveries to org webhook endpoints; app deliveries (which have no
endpoint_id) 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
// (a warning in the server log only). Silence is the failure mode; check
// the preflight's SCOPE_TOPIC_REQUIRED warning at publish time instead.
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 AppObject row 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 — re-fetch on reconnect, never treat the stream as a complete ledger.
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.assistant_turn (socket-only: the agent's reply), call.node_entered, call.barge_in, call.silence, call.dtmf, call.context_note (socket-only: a live context note reached the agent) — thecall.assistant_turnandcall.context_notepayloads are on API responses → Live call stream.system_promptis stripped from call.node_entered, and every payload is run through the secret stripper. A lagging socket drops frames silently.
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 navigates away immediately after resolving, 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.
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 nullable — null if a member's ref never resolved. This op ALWAYS navigates to the entry agent's builder; there is no {open:false} escape hatch, so treat it as the last thing a click does.
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": [],
"minDurationSec": 20,
"minCustomerTurns": 2,
"language": "en",
"sections": { "summary": true, "sentiment": true, "disposition": true, "actionItems": true, "objections": true, "evidence": true, "topics": true, "judge": true },
"basePrompt": "You are writing the notes the owner of this AI agent reads after each call. The owner is the business or person the agent answers for. …"
},
"objectives": { "enabled": false, "askCap": 2, "slots": [], "actions": [], "dispositions": [], "fallbackDisposition": null },
"tools": [ { "name": "book_appointment", "description": "Book a slot", "kind": "app" } ],
"realtimeEngine": null
}
- UNDOCUMENTED GROUPS: the response carries
analysisandobjectivesobjects that the SDK'sTelenowAgentConfigtype does NOT declare.analysisis the post-call-analysis switch, which defaults OFF — this is how you explainanalysis:nullrows fromcalls.historyinstead of guessing at permissions.objectivesis the stateful-context group (slots, actions, dispositions), also OFF by default. model.temperature/model.maxTokensare read out of the agent's LLM config 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.
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: the signed-in user must be able to view activity (any org role can).
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 returns the same read-back as publicLink, 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 dashboard's base URL and embedSnippet from the public API base URL — they can be different hosts. setPublic needs owner/admin BOTH client-side and server-side.
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]
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": [],
"minDurationSec": 20,
"minCustomerTurns": 2,
"language": "en",
"sections": { "summary": true, "sentiment": true, "disposition": true, "actionItems": true, "objections": true, "evidence": true, "topics": true, "judge": true },
"basePrompt": "You are writing the notes the owner of this AI agent reads after each call. The owner is the business or person the agent answers for. …"
},
"objectives": {
"enabled": false,
"askCap": 2,
"slots": [],
"actions": [],
"dispositions": [],
"fallbackDisposition": null
},
"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 group order (prompt, model, voice, stt, behavior, flow, telephony, analysis, objectives), 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 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,minDurationSec,minCustomerTurns,language,sectionsandbasePrompt;analysis.modelis explicitly forbidden (403) and any other key is a 400 — but it is missing from the SDK's patch type. So isobjectives, writable withagents:config:write:objectives(its keys are listed under GET /api/app-agents/:id/config above).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 changed since you read it (now <ts>) — re-read the config and re-apply") [HTTP 409]
// unknown group:
// Error("unknown config group `voices` — expected one of: prompt, model, voice, stt, behavior, flow, telephony, analysis, objectives")
// never-writable field:
// Error("`tools` cannot be changed by an app — an app may rewrite what an agent says, never what it can do")
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.
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 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)")
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 IS WRONG HERE:
createMockBridgereturns{tiers:['fast','balanced','smart'], models:[]}— 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 accepts any one of the three scopes, so any one
ai:*scope unlocks the whole catalog.pricingisnull(not an object) when a model has no rates. Reasoning models are hidden unless reasoning models are enabled on the deployment.
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 manual softphone calls (callMode: "manual") 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]
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 post-call analysis never ran.transcript[i].agent_idis snake_case inside an otherwise camelCase document 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.
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 in its own camelCase form. 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")
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 the request fails 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: the signed-in user must be owner, admin or developer.
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")
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 navigates the dashboard to /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.
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": [
"The host builds this context and adds `theme` afterwards; there is NO success/data envelope - the context is inlined into the iframe's <script>, not fetched.",
"`user` is null when nobody is signed in; `role` may be null.",
"`name` and `email` are ABSENT (not null) unless the app's granted scopes contain `user:profile`; `name` falls back to the email when the user's first and last name are empty.",
"`permissions` is a fixed list of 11 permissions filtered by the user's role, always in this 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\"].",
"Slot keys are spread at the TOP LEVEL of context, never nested under `context`, and differ per slot: call_detail_panel = 8 keys; softphone_call_panel = sessionId, callMode, fromNumber, toNumber and NO callId; agent_builder_panel = agentId; agents_overview_panel = agentCount; call_list_panel = NO extra keys at all (there is no totalCalls); dashboard_widget = orgName + a NESTED summary object; campaign_targets_panel = campaignId, agentId.",
"settings.all() is a static snapshot injected when the page loads; `set()` only updates the local copy, it never refetches.",
"Server side: a FLAT map keyed by each manifest setting's `key`. Secret-typed settings 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 always added by the host, so it is present even though the SDK's TelenowContext type omits it. user is null when there is no signed-in 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. Extra keys from the slot's context are spread in at the top level (slot mode adds e.g. callId). settings.all() is a STATIC snapshot: settings.set() does not refresh it (the bridge patches the local object, but a concurrent dashboard edit is invisible). Secret-typed settings are stripped server-side and can never appear.
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.
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 shallow merge of top-level keys, so omitted keys survive; it does NOT replace the blob. Both need an owner/admin/developer role — 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("this action requires one of: owner, admin, developer") [HTTP 403]
// unknown type: Error("unknown object type 'appointmnet'") [HTTP 400]
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 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— 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.
telenow.data.remove(objectType, id)
{ "ok": true }
BRIDGE-BUILT. The REST handler returns {"success": true} with NO data key at all, so unwrapping it 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}.
telenow.data.subscribe(objectType, onChange)
{
"_subscribe_resolves_to": "an unsubscribe FUNCTION, not JSON. The host's {streamId} 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": {
"data_payload": "only the record's stored fields (row.data) are published",
"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",
"errors": "surface as a rejected Promise (new Error(error)), not as {ok:false}; socket close pushes no terminal frame — onChange just stops"
}
}
The RPC itself resolves {streamId} (a random id the host generates) but the bootstrap swallows it and hands back a function — the app NEVER sees a streamId. Calling that function returns a promise resolving {ok:true}. The host holds the WebSocket; when your page unmounts, the host closes every socket.
telenow.files.delete(path)
Scope: files:write
{ "deleted": true }
deleted:false is a REAL value here (unlike links.revoke) — it means no stored file matched that path, so nothing was removed. Deleting a nonexistent path is not an error.
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. charscounts Unicode scalar values (code points), 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")
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, and files.list cannot tell you either (it always reports application/octet-stream) — infer the type from the path's extension. Server sets X-Content-Type-Options: nosniff and Content-Disposition: attachment.
telenow.files.list(prefix)
Scope: files:read
[
{ "path": "exports/q3.csv", "contentType": "application/octet-stream", "sizeBytes": 20418, "updatedAt": "2026-07-29T09:14:32.118431Z" },
{ "path": "intake/asha-nair.pdf", "contentType": "application/octet-stream", "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. contentType is nullable, and in practice always application/octet-stream (the type you upload with is not kept). Hard-capped at 1000 rows server-side with no cursor — a prefix filter is the only way to page. No bytes here.
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 its stored metadata.
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]
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, never the org's other knowledge bases. The SDK declares no return type for this op.
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]
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 computed (pastexpiresAtAND not used AND not revoked), not a stored status. Ordered createdAt DESC. NOTE the bridge does not role-gate this op, only scope-gates it.
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.
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 (owner, admin or developer) in addition to the scope.
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.
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.
telenow.settings.set(values)
Scope: — (owner/admin role only)
{ "ok": true }
- BRIDGE-BUILT. The REST PATCH really returns the whole
{schema, values, secretsSet}settings document, but the bridge awaits and DISCARDS it, replying{ok:true}. - The bootstrap then merges your
valuesinto the local SETTINGS snapshot — 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 the owner/admin/developer bar used by the messaging ops.
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).
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]
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). The bridge keeps FOUR keys and REWRITESkind: the rawkindfield 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.
telenow.whatsapp.send(channelId, to, message)
Scope: whatsapp:send
{ "ok": true }
BRIDGE-BUILT — the REST call really returns {success:true, data:{wamid:"wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSMkE4RTZE…"}} 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.
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}— so code written against the mock will readwamidas undefined in production. - The bridge ASSEMBLES the WhatsApp
componentsarray for you: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.
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 collapses it to a bare string (the body'serror, else itsmessage, else the request failure's own message), 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.
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"}
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 floating-point NUMBERS here (contrast: billing money is decimal STRINGS). s2sis ALWAYS present on this route — unlike the public /api/catalog route, which leaves it out while the speech-to-speech engine is disabled on the deployment. Reasoning models are hidden unless reasoning models are enabled on the deployment. Dashboard-proxy twin GET /api/orgs/{orgId}/apps/{appId}/ai/models emits the identical body.
On failure:
403 {"success":false,"error":"requires an ai:llm, ai:tts or ai:stt scope"}
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. These amounts are stored with exactly 10 decimal places, so 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; all other breakdown keys always survive.- COST BASIS IS STRIPPED: there is no
totalCostUsd, and each event losescostUsd.agentId/agentName/startTimeare nullable (null when the charge's agent or session can no longer be found).hasMoreis computed from this page only:offset + charges.length < total.
On failure:
403 {"success":false,"error":"app did not declare the billing:read scope"}
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
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 floating-point JSON NUMBERS, 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 — onlymode,suspended,balanceUsdare meaningful.- When the org has no billing account at all the whole payload is
{"success":true,"data":null}— 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"}
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 ends in "+00:00" — every other timestamp on the app planes 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, the campaign's app metadata andstartedAt/completedAtare NOT exposed.status∈ draft | running | paused | completed | cancelled. Capped at 200 rows, newest first, no pagination params.
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. callCountsonly lists statuses that occur, so a status with zero calls is ABSENT rather than 0 — always read it ascallCounts.skipped ?? 0. Possible keys: "pending" | "dialing" | "completed" | "failed" | "skipped". Values are integers.completedTargets/failedTargetsare the campaign's own counters and can lagcallCounts— they are not derived from it. 404 unless the campaign was created by this app: you can never see a user-created or another app's campaign.
On failure:
404 {"success":false,"error":"campaign not found"}
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": the platform always stores that value and ignores any Content-Type you PUT. Never branch on it — infer the type from the path extension, which is exactly what /api/app-ai/extract does.sizeBytesis an integer.?prefix=filters by path prefix; the list is capped at 1000 rows with no pagination.
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 stored file for this (org, app, path)
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 — it isnullfor a KB that was not created with one.documentCountis an integer, defaulting to 0. The KB'sorgId,appId,createdBy,deletedAtare deliberately NOT projected.kb:writeis accepted as a substitute forkb:readso a write-only manifest's sync loop doesn't 403 on its own reads.
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"}
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 (an attachment whose agent no longer exists is not listed). Note the key is agentId/agentName, not id/name — the same row shape the SDK's attachments() returns.
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(the body's length in bytes) andcontentHash(lowercase hex sha256 of the body, 64 chars) for sync diffing. statusis one of "pending" | "embedding" | "embedded" | "failed" — 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.
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 same precedence the?status=filter applies).openCountis an integer.targetType/targetId/opensAt/usedAt/revokedAt/lastOpenedAtare all nullable.limitdefaults to 100, clamped 1..500, and is NOT echoed — there is no cursor and nototal.
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— 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 just the length ofmembers— 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.
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"}
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 the provider reports alongside its usage, so a request forgpt-4o-minicomes back as the dated snapshotgpt-4o-mini-2024-07-18; billing still uses the model you ASKED for. If the provider reports no usage,modelfalls back to your requested string andusage.estimatedis true with tokens = chars/4.providerechoes the resolved provider verbatim — the google→gemini / grok→xai canonicalization happens only on the billing record, not here.usage.inputTokens/outputTokensare non-negative integers. Same body is produced by the dashboard proxy POST /api/orgs/{orgId}/apps/{appId}/ai/llm — 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"}
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 BEFORE the stream opens and do come back as a normal JSON error body with a 4xx. The bare:line is a 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.
On failure:
data: {"error":"llm stream: 429 rate_limit_exceeded"}
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 a lowercase string, one of "mulaw" | "pcm16" | "wav" | "mp3" | "raw" | "linear16". It is whatever the provider stream actually emitted — telephony providers give mulaw/8000/1, but do NOT hardcode that assumption.sampleRate,channelsandcharsare all non-negative integers.charsis the character count of the TRIMMED input text (cap 5000).- Unlike /llm,
sessionIdis REQUIRED here (a uuid) and must belong to your org. A provider 4xx (bad voice id) is mapped to HTTP 400, not 502. 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."}
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 integer counts of the targets added (suppressed = DNC-blocked or unnormalizable numbers). statusis a FIXED VALUE derived from the request'sautostartflag ("running" when it is true, else "draft"), NOT read back from the campaign — 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"}
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 — neither the create path nor the find-existing path counts documents, so the count always comes back as 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"}
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 document is soft-deleted and a NEW one is created, 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"}
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 (an integer).topKis clamped to 1..8 regardless of what you send. Not metered in v1. BRIDGE:telenow.kb.search(kbId, {query, topK})returns the identical{results:[…]}object.
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 — the platform keeps only an identifier for it, never the token itself, so the list route deliberately never returns it; if you lose the url you must re-mint.urlis always on the dashboard host, never the request Host: the/l/:tokenpage only exists in the dashboard app, so a URL on the API host would be a dead link.requiresCodeis true when a code was set; the code itself is stored hashed and never echoed.singleUsedefaults to TRUE when omitted. Identical body from the dashboard proxy POST /api/orgs/{orgId}/apps/{appId}/links — that path additionally records which user created the link.
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"}
POST /api/app-links/{id}/revoke
Scope: links:write
{
"success": true,
"data": {
"revoked": true
}
}
revoked is a CONSTANT true, not a report of whether anything changed — re-revoking an already-revoked link keeps its original revocation time and 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"}
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 whenactionis "agent_call"; it isnullfor "form" and "page". 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 (
usedAtset) — 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}
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 link's own
context.fields[]spec does not declare are silently DROPPED (not rejected), string values are capped at 4000 chars, and a field that declaresoptionsmust match one of them 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"}
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(an integer), while the LIST route calls the same quantitysizeBytes— the two names do not match.pathis echoed as you sent it (after path validation, which rejects leading/,.., backslashes, >512 chars). - DELETE's
deletedis honest: it isfalsewhen no file was stored at that path, 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"}
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, 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.
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.
Error envelope (all app-key REST planes)
// the ordinary shape — 404 / 400 / 401 / 403 / 409 / 413 / 500
{ "success": false, "error": "knowledge base not found" }
// 429 — rate limit (`retryAfter` in the body, plus the same seconds in a `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, an integer), and the same value is sent as aRetry-Afterheader. - TWO DIFFERENT 403 STRINGS for the same denial: the app-key REST planes say
"app did not declare the {scope} scope"for a static key and"token does not include the {scope} scope"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". 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"}
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.