# Telenow App-Building Skill (for AI assistants)

> **You are helping build an installable app for the Telenow voice-AI platform.** This one file is the
> complete framework, rules, and reference — distilled from the 14 human doc pages and the SDK/Rust source so an
> AI can build ANY app with nothing left out. Follow it exactly. Keep it in context for the whole task. When you
> need even more depth, fetch the linked raw docs (e.g. `/docs-md/app-data.md`).

**Package:** `telenow` (npm, unscoped). **Never** write `@telenow/app` — that package does not exist (older
example code that imports `@telenow/app/react` is WRONG; the correct import is `telenow/react`).
**Docs:** `/docs/app-platform` (human) · `/docs-md/*.md` (raw) · **this skill:** `/app-building-skill.md`.

---

## Table of contents

1. [What a Telenow app is](#1-what-a-telenow-app-is)
2. [The 17 HARD RULES](#2-the-17-hard-rules)
3. [Build workflow + dev harness](#3-build-workflow--dev-harness)
4. [Manifest reference — every field](#4-manifest-reference--every-field)
5. [The data store: AppRecord, fields, relations, computed, views, semantic](#5-the-data-store)
6. [Tools — every handler kind + return shapes + caller identity](#6-tools--handlers)
7. [The React UI + `window.telenow` bridge (complete App.tsx)](#7-the-react-ui--windowtelenow-bridge)
8. [Automation: events, workflows, schedules, inbound hooks](#8-automation)
9. [Bundled agents, flow graphs & teams](#9-bundled-agents-flow-graphs--teams)
10. [Backend & the app-key REST API (complete Express example)](#10-backend--the-app-key-rest-api)
11. [Scopes, settings & stored connections](#11-scopes-settings--connections)
12. [Whitelisted icons](#12-whitelisted-icons)
13. [Limits & quotas](#13-limits--quotas)
14. [Publishing, versions & rollout](#14-publishing-versions--rollout)
15. [Ship checklist](#15-ship-checklist)
16. [Complete end-to-end `telenow.app.json`](#16-complete-end-to-end-telenowappjson)
17. [Deep reference links](#17-deep-reference-links)

---

## 1. What a Telenow app is

A **Telenow app** is like a Shopify app or WordPress plugin, but for a voice-AI dashboard and its AI phone agent.
An app is a **manifest** (`telenow.app.json`) plus, optionally, a **React UI bundle** (renders inside the
dashboard iframe) and/or your **own backend** (reached over an app-key REST API). Installing an app gives the org:
a shared data store, tools the voice agent calls mid-call, dashboard pages, automation, and ready-made agents/KBs.

**Runtime tiers** (`manifest.runtime`, default `declarative`) — one app can MIX them; the tier is advisory, the
`handler.kind` is what actually decides dispatch:
- **declarative** — no app code. Objects, `object.*` tools, UI, events, schedules, workflows, and inbound hooks
  all run on Telenow's runtime. **No hosting.** Prefer this.
- **external** — your own server. `handler.kind:"http"` tools + `handler.kind:"webhook"` events POST to your
  `base_url`; you verify signatures and read/write via the app-key REST API. Requires `base_url`.
- **sandboxed** — `handler.kind:"sandbox"` runs pure-compute JS in a hardened runtime (no I/O). Feature-flag gated.

---

## 2. The 17 HARD RULES

1. **Package is `telenow`.** Imports: `telenow` (server helpers + manifest types), `telenow/react` (UI hooks),
   `telenow/browser` (raw bridge + dev mock). CLI: `npx telenow app init|dev|validate|build`. The doctor-crm
   example's `import … from '@telenow/app/react'` is **wrong** — copy `from 'telenow/react'`.
2. **`id` and `version` are required and key-safe** (`[A-Za-z0-9._-]`, alphanumeric at both ends). Use semver for
   `version`. **App ids are GLOBAL and first-come** — a taken id → `409`.
3. **Handler kinds are exactly 6:** `object.create`, `object.query`, `object.update`, `object.delete`, `http`,
   `sandbox`. **`js` is NOT valid** — a `js` handler passes upload then throws at runtime. The TS `HandlerKind`
   union has no `js`. Sandbox is gated OFF unless the platform operator enables sandbox tools on the deployment.
4. **Workflow step config keys are literal and specific** (§8). `update-object` uses `match` + **`matchValue`** +
   **`data`** (NO `set`). `outbound-call` uses `agentId` + **`toNumber`**. **This differs from the TOOL handler
   `object.update`, which uses `match` + `set`.** Do not cross-wire them.
5. **Campaign explicit targets use `phoneNumber`** (`targets:[{ phoneNumber, variables? }]`) — a `phone` key is
   silently skipped → empty list → `400 no dialable targets`. `targetQuery` uses `phoneField` (a different concept:
   the stored object field name).
6. **App-agents REST scopes:** create/delete need **`agents:write`**; list/eval need `agents:read`. (The
   in-dashboard bridge `createFromTemplate` is gated by owner/admin ROLE only, not by these scopes.)
   **`agents:*` does NOT cover reading or writing an agent's SETTINGS** — that is the separate
   `agents:config:*` family (§11). Create/list/delete act on agents the app CREATED (`createdByApp`);
   `config`/`eval`/`public` act on agents the app is BOUND to. An improver app wants the BOUND ones.
   No scope lets an app write `tools`, a node's `config`, `s2sConfig` or `isPublic` — a prompt patch
   PRESERVES tools rather than dropping them (do NOT reuse the create-path sanitizers for updates).
7. **`timeoutSecs` is clamped 1–30, default 15** (not 1–10).
8. **Icons are a fixed 27-name whitelist** (§12). `boxes` and `sticky-note` are **not** in it and silently fall
   back to `box` — use a valid name like `box`, `file`, `users`, `calendar`.
9. **The REST list response is nested:** `{ success:true, data:{ objects:[…] } }`. `POST`/`PATCH` return
   `{ success:true, data:<AppRecord> }`; `DELETE` returns `{ success:true }` (no `data`). The `DataClient` wrapper
   UNWRAPS all of these (`db.list()` → `{objects}`, `db.create/update()` → the `AppRecord`).
10. **`useObjects(type, query?)` and `useCall().initiate(agentId, phone)` are 2-arg** and drop extras silently. For
    sort/view/expand/search use `getTelenow().data.list(type, query, opts)`; to pass call variables use the RAW
    bridge `getTelenow().calls.initiate(agentId, phone, variables)` — the hook's 3rd arg is NEVER forwarded.
11. **Operator filters (`$gt`, `$in`, `$contains`, …) work only through the in-dashboard bridge / `DataClient`, not
    the REST query string** — which is equality-only and STRINGIFIES every value (`?vip=true` matches the string
    `"true"`).
12. **Knowledge bases are manifest-only** — there is no runtime KB REST API. Ship docs in `knowledgeBases[]`.
13. **Workflow triggers are event-only** in v1 (`trigger.event`). No cron/schedule triggers (use `schedules[]`).
    **`trigger.event` is NOT validated against the topic allowlist** — a typo silently never fires.
14. **Scopes are consented at install and frozen**; adding scopes in a later version forces re-consent. **There is
    NO server-side scope whitelist** — a misspelt scope silently grants nothing and fails at runtime with a `403`.
15. **Secrets never reach the browser.** Secret settings and stored connections are injected server-side only. A
    `secret` setting must NOT declare a `default` (upload rejects it — it would be plaintext).
16. **`telenow validate` (CLI) checks a SUBSET.** Treat **upload / `telenow build`** as the authoritative gate. The
    CLI also false-warns "unrecognised scope" on the real scopes `agents:write`, `files:read/write`,
    `campaigns:read/write`, `data:read/write`, and skips schedule `every` validation — ignore those.
17. **Never invent** fields, scopes, slots, event topics, node kinds, or step kinds beyond the lists in this file.

---

## 3. Build workflow + dev harness

```bash
npx telenow app init my-app     # scaffold: telenow.app.json, ui/index.tsx, ui/App.tsx, package.json, README, screenshots/
cd my-app && npm install
# …author telenow.app.json + ui/App.tsx…
npx telenow dev                 # local preview: mock bridge OR paste the localhost URL into the dashboard "Dev preview" for REAL data
npx telenow validate            # local pre-flight (subset of checks)
npx telenow build               # → my-app-<version>.telenow.zip (validates, bundles UI via esbuild, zips)
```
Then: **Apps → Your apps → Upload app zip** (private, live in seconds) or **submit for marketplace review**
(admin-reviewed, needs README + a screenshot). See §14.

`ui/index.tsx` is one line: `import { mount } from 'telenow/react'; import App from './App'; mount(App);`
Add `"$schema": "./node_modules/telenow/telenow.app.schema.json"` at the top of the manifest for autocomplete
(the CLI strips it at build).

**Standalone dev harness — `createMockBridge`.** Run/test the UI in a plain browser tab with no dashboard. The mock
does REAL equality+operator CRUD (persisted to localStorage), believable agents/calls/whatsapp stubs; but
`http()` **rejects** and `stream`/`data.subscribe` are **no-ops**. Default mock user is an owner with
`['view','manage_agents','manage_members','manage_billing','monitor_calls']`.

```ts
import { createMockBridge } from 'telenow/browser';
window.telenow = createMockBridge({
  context: { page: 'patients' },
  seed: { patient: [{ name: 'Asha', phone: '+919876500001', status: 'active' }] },
  settings: { clinic_name: 'Demo Clinic' },
});
// MockBridgeOptions: { context?, user?, seed?, agents?, calls?, whatsappChannels?, settings?, persist?, storageKey?, log? }
```

**Dev preview against REAL data:** the **Dev preview** button (owner/admin/developer only) loads your local
`telenow dev` bundle (`${devUrl}/index.js`, +`/index.css` only if `ui.styles` is set) into the real iframe, hot-
reloading over SSE at `${devUrl}/__livereload`. **All bridge calls are REAL mutations against the installing org —
use a test org.**

---

## 4. Manifest reference — every field

Top level: `id`*, `version`*, `name`, `runtime`, `category`, `blurb`, `icon`, `base_url` (required if ANY `http`
tool OR `webhook` event OR `webhook` schedule), `scopes[]`, `objects[]`, `tools[]`, `ui{}`, `events[]`,
`schedules[]`, `workflows[]`, `inboundHooks[]`, `settings[]`, `agents[]`, `agentTeams[]`, `knowledgeBases[]`,
`readme`, `changelog`, `screenshots[]`. Unknown fields are tolerated. `category` ∈ `crm | productivity |
healthcare | ecommerce | finance | support | marketing | telephony | other`.

### objects[] — see §5 for full field semantics
```jsonc
{ "type": "patient", "label": "Patient",
  "fields": [
    { "key": "name",   "type": "string", "index": true },
    { "key": "phone",  "type": "phone",  "index": true },
    { "key": "status", "type": "enum", "values": ["active","inactive"], "default": "active", "index": true },
    { "key": "notes",  "type": "string" },
    { "key": "display","type": "string", "computed": { "template": "{{name}} ({{phone}})" } },
    { "key": "doctor_id", "type": "string", "index": true, "relation": { "object": "doctor" } }
  ],
  "views": [ { "name": "active", "label": "Active", "filter": { "status": "active" },
              "orderBy": { "field": "name", "desc": false, "numeric": false } } ],
  "semantic": true }
```
- `type` unique + key-safe. `fields[].type` (advisory UI/index hint): `text|string|number|boolean|date|datetime|select|enum|email|phone|url|json`.
- `index:true` ONLY on fields you filter/match/sort on (indexes are shared platform-wide).
- A field is **stored OR relation OR computed** — never a combination.

### tools[] — see §6 for handler kinds, return shapes, defaults, caller identity
```jsonc
{ "name": "book_appointment",
  "description": "Book a clinic appointment for the caller.",   // the model reads this to decide WHEN to call
  "parameters": { "type": "object",
    "properties": {
      "phone": { "type": "string" },
      "start": { "type": "string", "description": "ISO 8601", "x-ui": { "widget": "date", "label": "Appointment date" } },
      "problem": { "type": "string", "x-ui": { "widget": "textarea", "placeholder": "e.g. fever 3 days" } }
    }, "required": ["phone","start"] },
  "handler": { "kind": "object.create", "object": "appointment" },
  "timeoutSecs": 4, "handoff": "One moment while I check the calendar." }
```
- Exposed to the LLM as **`{app_id}_{name}`** (letters/digits/`_`/`-` kept, else `_`), auto-bound when the org
  builds your agents. A per-agent binding **alias** can override the wire name; your manifest `name` stays the
  reference for flow nodes and the data UI.
- **`parameters`** is a JSON Schema object: `{ type:"object", properties:{ <name>:{ type, description?, enum?, x-ui? } }, required:[…] }`.
- **`x-ui` widget hints** (per property, for the dashboard form only — the model still sees plain schema):
  `{ widget, label?, placeholder? }`, e.g. `widget:"textarea"`, `widget:"date"`.
- `timeoutSecs` clamped **1–30, default 15**; `handoff` is a spoken filler while the tool runs.

### ui — a React dashboard bundle in a sandboxed iframe
```jsonc
"ui": {
  "entry": "ui/index.tsx",   // source path in dev; telenow build compiles it to ui/index.js (and ui/index.css)
  "styles": "ui/index.css",  // optional, set by build if styles are produced
  "pages": [ { "id": "patients", "title": "Patients", "icon": "users", "menu": true, "group": null } ],
  "extensions": [ { "slot": "agents_overview_panel", "page_id": "reports", "title": "Agent insights" } ]
}
```
- Each `menu:true` page → a sidebar item. `menu` defaults **false** (reachable in-app / via a slot only). `group` =
  submenu label.
- The **7 extension slots** (do not invent others): `call_detail_panel`, `dashboard_widget`,
  `agent_builder_panel`, `agents_overview_panel`, `call_list_panel`, `softphone_call_panel`,
  `campaign_targets_panel` (context: `campaignId`, `agentId`). A slot-mounted page receives its `page_id` as
  `context.page` (so the same `if (page === …)` branch renders in both), plus extra read-only context (e.g.
  `callId` in `call_detail_panel`). **SDK-type caveat:** SDK 0.5.0 and later type all seven slots; on an older SDK
  cast the newer ones (`slot: 'agents_overview_panel' as any`) — the manifest still validates.

### events[], schedules[], workflows[], inboundHooks[] — see §8
### settings[] — see §11 · agents[]/agentTeams[]/knowledgeBases[] — see §9

---

## 5. The data store

The store holds **schemaless JSON rows**, scoped to a `(app, org)` pair — hard tenant isolation. Declared `fields`
are advisory (extra keys you write are kept).

### The AppRecord envelope — YOUR fields live under `.data`

**This is the #1 silent-wrong-output trap: read `record.data.phone`, NEVER `record.phone`.**

```ts
interface AppRecord<T = Record<string, unknown>> {
  id: string;            // uuid (top level — use record.id)
  orgId: string;         // ALWAYS present (camelCase, not org_id)
  appId: string;         // e.g. "clinic-crm"
  objectType: string;    // e.g. "appointment"
  data: T;               // <-- YOUR declared fields live here
  createdBy: string | null; // provenance/writer tag, NOT a user id; REST writes stamp "api", null when unknown
  createdAt: string;     // ISO-8601; default (newest-first) sort key
  updatedAt: string;     // ISO-8601; always serialized (non-null), bumped on every write
}
```
> The SDK's browser types mark `orgId` absent and `updatedAt`/`createdBy` optional — but the server
> (authoritative) always sends `orgId` and `updatedAt`, and `createdBy` as `null` (not omitted) when unknown.

A stored appointment:
```json
{ "id":"5f6a…", "orgId":"9a1c…", "appId":"clinic-crm", "objectType":"appointment",
  "data": { "patient_name":"Asha Rao", "phone":"+919876543210", "status":"scheduled", "patient_id":"01J9ZK…" },
  "createdBy":"api", "createdAt":"2026-07-01T08:15:00Z", "updatedAt":"2026-07-01T08:15:00Z" }
```

### Field definitions
| Field | Notes |
|---|---|
| `key` | field name. **Required.** |
| `type` | advisory: `text string number boolean date datetime select enum email phone url json` |
| `index` | `true` to index for fast filter + upsert/match (shared platform-wide — use sparingly) |
| `values` | allowed values for `select`/`enum` |
| `default` | applied on create when the field is omitted (also auto-folded into `object.create` tools) |
| `relation` | `{ object, many? }` — store target row id (or `[ids]` when `many`) |
| `computed` | `{ template }` — read-only `{{field}}` interpolation |

### Relations & `expand`
`expand:['patient_id']` attaches `data.patient_id__expanded` = the referenced row's **`data`** (the id field is left
untouched alongside). `many:true` → an **array** of referenced rows' data. Caps: **≤8 expand fields** per read
(extras ignored), **≤500 referenced rows** per field (excess truncated). Dangling to-one → `null`; dangling to-many
ids are filtered out. Unknown/non-relation expand keys are **silently ignored**.

### Computed fields
Pure string interpolation (no math/expressions). References **stored fields only** (not another computed field). An
unknown key or a non-scalar value (object/array/null) renders **empty** (surrounding text survives). Capped at 8192
chars. Read-only — cannot write/filter/sort on it.

### Views
Saved `{ name, label?, filter, orderBy:{field, desc?, numeric?} }`, applied via `?view=` (REST) or `opts.view`.
**Precedence:** the view's filter predicates **override the caller's filter on the same key** (view wins; other keys
kept); the view's `orderBy` applies **only when the caller passed no sort** (caller sort wins). An **unknown view
name is a silent no-op** (NO 404) — check spelling.

### Semantic search
`semantic:true` → each row embedded on write; rank by meaning via `opts.search` / `?search=` (`topK` aliases
`limit` on REST). **⚠️ Degrades SILENTLY to newest-first** if the object is NOT `semantic:true`, pgvector isn't
configured, or the embedding fails — you get plausible-but-wrong latest rows. Embedded text = declared **SCALAR**
fields only (string/number/bool + string-arrays joined `", "`); relation/computed/undeclared/empty are SKIPPED.
Embedding is async (eventually consistent); re-embedding is skipped when the body is byte-identical.

### Query filters & operator semantics (bridge / DataClient ONLY)
```ts
const rows = await telenow.data.list('appointment', {
  status: 'scheduled',                                  // equality
  start:  { $gte: '2026-07-01', $lt: '2026-07-08' },    // range (STRING operand ⇒ text compare; ISO sorts right)
  age:    { $gte: 18 },                                 // NUMBER operand ⇒ numeric compare
  phone:  { $in: ['+919876543210', '+918887776655'] },  // membership (operand MUST be an array or matches NOTHING)
});
```
Operators: `$eq $ne $gt $gte $lt $lte $in $contains`. Gotchas: `$ne` uses `IS DISTINCT FROM` so it ALSO matches
null/absent fields; `$contains` is case-insensitive ILIKE with `% _ \` escaped; an unknown `$operator` is silently
ignored. **These are bridge-only** — the raw REST GET is equality-only and stringifies every value.

### Sort / limit / paging
`opts.orderBy = { field, desc?, numeric? }`. Default sort is **text** (ISO-8601 sorts correctly as text — leave
`numeric` off for dates). `numeric:true` casts to float8; non-numeric → NULL sorted **LAST** (not excluded). Ties
break by `id`. No `orderBy` → newest-first by `createdAt`. `limit` default **100**, hard-clamped **1..500** (over-
large is clamped, not rejected). **No offset/page param** — to get >500 rows, filter down (date range/status/view).

### Writes: semantics & errors (all surfaces)
- **`PATCH` is a shallow merge** of top-level keys: adds/overwrites the keys you send, leaves others. It CANNOT delete
  a key — to blank a field, write it explicitly to `null` or `""`.
- Non-object body → `400 "body must be a JSON object"`. Non-UUID `:id` on PATCH/DELETE → `400` (not 404);
  well-formed unknown id → `404 "record not found"`.
- Row cap: **100,000 records per (org, app)** across ALL types → create returns
  `413 "app data store is full (max 100000 records per app)"`.
- **Automation coupling:** a REST/tool/inbound-hook **create fires `object.<type>.created`**; **`PATCH`/`DELETE`
  do NOT fire any automation event** (no `.updated`/`.deleted` topics). They DO emit on the realtime stream.

### Realtime — `data.subscribe`
```ts
interface ObjectChangeEvent<T = Record<string, unknown>> {
  event: 'created' | 'updated' | 'deleted';
  objectType: string;
  id: string;
  data: T | null;   // full row data for created/updated; null for deleted
}
const unsub = await telenow.data.subscribe('appointment', (c) => { /* re-list to reconcile */ });
// on unmount: unsub();
```
Best-effort telemetry with a **256-frame** per-subscriber buffer — a slow subscriber **drops frames** and the platform
never slows the write down for it. Treat it as "something changed → re-list", not a durable log. Fires from the single
place every write goes through, so every write path (UI, REST, agent tools, event-rule upserts, inbound hooks) emits uniformly.

### Three surfaces, three auths
| Surface | Runs in | Auth | Query power |
|---|---|---|---|
| **(a) UI bridge** — `useObjects`/`telenow.data.*` | React iframe | signed-in user (relayed) | full operators + opts |
| **(b) app-key REST** — `/api/app-data/:type` | your backend | `Bearer <app key>` | equality-only query string |
| **(c) agent `object.*` tools** | voice agent mid-call | declarative | equality on declared fields |

---

## 6. Tools — handlers

The LLM decides when to call a tool, fills `parameters` from the conversation, and speaks with whatever the tool
returns. Namespaced `{app_id}_{name}`, auto-bound to agents built from your app (embedded tool defs in templates are
stripped for safety).

### Argument defaults precedence (all kinds)
An omitted arg is filled from `cfg.defaults` (populated by the object field `default` AND by a flow tool-node's
Arguments editor) — **caller-supplied values always win**. Then `object.create` additionally stamps the trusted
`caller_number` onto the row (overrides everything, unspoofable).

### Caller identity — how it reaches each kind
★★ **NEVER put an app key in an app PAGE.** `X-App-Key` / `Bearer vai_app_…` is for the app's OWN
BACKEND, server-to-server. A page is ALREADY authenticated — the bridge relays each call to the parent
and performs it as the signed-in user, org+app-scoped ("no auth token ever enters the iframe").
Asking the org to paste a key into a setting FAILS BOTH WAYS: a `secret` setting is withheld from the
iframe (no bridge op returns one) so the header goes out empty → *"the app key was rejected"*; a
NON-secret setting is injected into the iframe in PLAINTEXT — a long-lived credential in the browser.
If the bridge lacks what a page needs, that is a platform gap to file, not to route around.
Bridge calls ops: `initiate · open · history · get · arm`.

- **`http`** tools receive the **trusted caller envelope** as the top-level **`caller`** key in the POST body:
  `{ number?, identifier?, channel?, session_id? }` (ALL optional → null-guard `caller?.number`).
- **`object.create`** auto-stamps `caller.number` onto the new row as **`caller_number`** (declare a `caller_number`
  field to index/filter by it later). Only `.number` is stamped — not identifier/channel/session_id.
- **`object.query` / `object.update` / `object.delete`** get no caller fields automatically but can REQUEST one via
  **`handler.map`** (below). **`sandbox`** never gets them.
- ★★ **`handler.map` = `{ "<arg>": "<caller_source>" }`** on any `object.*` handler — fills that argument from the
  TRUSTED caller identity when the model supplied nothing. Sources: `caller_number` `caller_identifier`
  `caller_channel` `session_id`. **The model's value always WINS** (so "use a different number" works). For it to
  fire at all the arg must be OPTIONAL — leave it out of `required[]` and describe it as "OMIT to use the number
  the caller is calling from"; left required, the model always sends something and the map never runs.
  ★★ **CONSENT DEPENDS ON WHERE THE VALUE LANDS.** A pure LOOKUP key needs NO setup — `object.query` (all args
  are filters) and the `match` field of `object.update`/`object.delete` work with the agent's "Send caller
  identity to tools" toggle OFF, because the value only becomes a WHERE predicate (never stored, never returned
  to the app backend). STORING it — `object.create` (every arg is written), or an update field that ISN'T
  `match` (lands in the patch) — still REQUIRES that toggle. Rationale: the toggle is all-or-nothing and also
  POSTs the full caller envelope to the app's EXTERNAL backend on every `http` tool, so requiring it for a
  lookup would force an org to grant third-party egress to match against its own records.
  ★ `caller_number` is TELEPHONY-ONLY (inbound=caller ID, outbound=DIALED number); web calls have no number.
  Bad source / undeclared target / non-`object.*` handler = rejected at UPLOAD.
  **Why it exists:** a phone lookup otherwise depends on the model transcribing spoken digits against an EXACT
  string match — one dropped digit gives `{"results":[]}` with `status:success`, identical to "no such record".
- ★★★ **`map` AS A PIN — for any key the model must NOT author.** Everything above is the SOFT binding (declared
  optional param, model may override) — correct for a phone number, WRONG for a record id. For `candidate_id`,
  `user_id`, an order number, any RELATION field: **OMIT THE PARAMETER FROM `parameters.properties` ENTIRELY.**
  Dropping it from `required[]` is NOT enough — the model still sees it, still fills it, and the model's value
  always wins, so a guess beats the truth. A `map` target must be a declared FIELD of the object; it need NOT be
  a declared parameter, and `match` accepts a `map` target. With the param absent the model CANNOT supply or
  override it.
  ```jsonc
  { "name": "save_interview_outcome",
    "parameters": { "type": "object", "properties": { "outcome": {"type":"string"}, "notes": {"type":"string"} },
                    "required": ["outcome"] },
    "handler": { "kind": "object.update", "object": "interview", "match": "candidate_id",
                 "map": { "candidate_id": "caller_identifier", "call_session_id": "session_id" },
                 "set": { "status": "completed" } } }
  ```
  ★★ **THE RULE: the model authors CONTENT, the platform authors IDENTITY.** Anything the model would have to
  RECALL rather than HEAR goes in `map`, never in `parameters`. Apply this when splitting args on EVERY tool.
  ★ Set `caller_identifier` at call start — `identifier` on `initiate-call` / `init-web-call` (server-side, your
  API key) / Chat API, or an `identifier` COLUMN on a campaign contact list (extra columns become per-target
  variables). The anonymous share link `/p/…` sends NO identifier (anyone could set one) — never pin against it.
  ★ Mapping `session_id` into a declared field stamps each row with its call, so the backend reconciles a record
  to a call/recording/transcript without trusting the model. `session_id` is NOT PII → no consent toggle; and
  `caller_identifier` into `match` is a LOOKUP position → also no toggle. **The whole pattern works toggle-OFF.**
  ★★ **A pin that can't resolve is LOUD, and loud invites RETRIES:** no identity on the call ⇒ `object.update`
  throws `missing value for match field` ⇒ the model calls it again — a real trace re-fired the tool on
  "Thank you" and "Bye-bye". Never expose a pinned write on an agent reachable by a call shape lacking the id.
  ★ `object.update` matches the NEWEST row for the field ⇒ a pinned id must be UNIQUE per record.
  ★ `http` handlers CANNOT use `map` (upload-rejected) — an external backend reads `caller.identifier` from the
  POST body, which DOES require the toggle (that path is egress).
  **Why it matters:** asked for an id it cannot know, a model invents a plausible one — a real trace produced
  three different values across four retries (`kundan-kumar-sw-eng`, `kundan-kumar-telenow`, `Kundan Kumar`),
  none matching any record. A wrong `outcome` is fixable with a better description; a wrong id is a corrupt row.

### The 6 handler kinds — with exact return shapes

**`object.create` `{ object }`** — insert a row from the args (declared defaults folded in, `caller_number` stamped).
```
success → { "ok": true, "id": "...", "record": { ...row } }
at cap  → { "ok": false, "error": "app data store is full — cannot create more records" }   (does NOT throw)
```

**`object.query` `{ object }`** — equality-only on DECLARED fields, **hard-capped 50 rows**, no paging. Args that
aren't declared fields are DROPPED before filtering. Steer the model with a filter param.
```
→ { "results": [ { "id": "...", "data": { ...row }, "created_at": "..." } ] }   (empty results if none)
no filter left → { "ok": false, "error": "no lookup value was provided — ask the caller…" }
```
> ★★ An EMPTY filter is REFUSED, not treated as "match everything" — blank/dropped/absent args would otherwise
> return the newest 50 rows and the agent would read a STRANGER's record aloud. (Pre-mid-2026 builds did exactly
> that.) When the tool declares a `handler.map` and the call carries no caller identity at all (web call, or a
> withheld caller ID), the error says so — it is NOT a toggle problem, since a query filter is a lookup position.
> **Note the nesting:** read `results[i].data.<field>`, NOT `results[i].<field>` (unlike the flattened bridge rows).

★★ **`match` must name an ARGUMENT the model fills (or a `handler.map` target) — NOT merely a field of the
object.** `match:"candidate"` alongside a `candidate_id` PARAMETER uploads clean and then fails on 100% of
calls: `missing value for match field \`candidate\``. The value is read from `arguments[match]`, so the two
names must be identical. Manifest validation now REJECTS this at upload. And a RELATION field stores another
record's id — a model cannot guess it, so bind it from context (`handler.map`) rather than asking; otherwise
it invents a plausible slug and matches nothing. ★★★ For an id, PIN it — omit the parameter from the schema
entirely so the map is the only source; see the `map` AS A PIN bullet above.

**`object.update` `{ object, match, set? }`** — find the NEWEST row where `match`==arg, patch it. `set` = constant
fields applied last (overwrite any arg). Rules: the match value must be a **non-empty string** in args else it
THROWS `missing value for match field \`<field>\``; the match field is **removed from the patch** (can't self-
update). `match` MUST be a declared field (upload checks this).
```
success → { "ok": true, "id": "...", "record": { ...row } }
no match→ { "ok": false, "error": "no matching record found" }   (nothing created)
```

**`object.delete` `{ object, match }`** — delete the newest matching row (same non-empty-string match rule).
```
success → { "ok": true, "deleted": true, "id": "...", "record": { ...row } }
no match→ { "ok": false, "deleted": false, "error": "no matching record found" }
```

**`http` `{ path, method? }`** — the platform ALWAYS **POSTs** (declared `method` is ignored at dispatch — write your
route as POST) to `base_url + path`, body = `ToolCallRequest`:
```jsonc
{ "tool": "clinic-crm_check_insurance",   // {app_id}_{name}, sanitized (or a binding alias)
  "arguments": { "member_id": "INS-4821" },
  "caller": { "number": "+91…", "channel": "phone", "session_id": "…" } }
```
Constraints (cause silent failures): HTTPS-only + SSRF-guarded (no loopback/private/metadata) else abort; the
install must have a signing secret; response **≤1 MB**; **any non-2xx aborts** the tool
(`app tool failed (<status>): <first 300 chars>`); reply parsed as JSON (small object) and fed back into the
conversation; localhost is unreachable — use an HTTPS tunnel in dev. Signed `X-Telenow-Signature: sha256=<hex>`.

**`sandbox` `{ code }`** — pure-compute JS **function body** in a hardened rquickjs runtime. Args arrive as the
**frozen** global `dv` (alias `vars`); read `dv.field`. It MUST `return` an **object** (scalar/undefined →
`snippet must return an object`).
```jsonc
{ "handler": { "kind": "sandbox", "code": "return { minutes: Math.max(5, dv.ahead * 12), ok: true };" } }
```
- Success → your object **verbatim** (no wrapper — add your own `ok:true` to branch). Error → `{ ok:false, status,
  error }` where `status ∈ error|timeout|oom|invalid|busy`.
- Deleted globals: `eval Function Promise setTimeout setInterval fetch XMLHttpRequest require import process
  WebAssembly`. `console.*` is a no-op. No async/timers/network/filesystem.
- Limits (not configurable): source ≤16 KB, output ≤256 KB, input `dv` ≤256 KB, heap 16 MB, exec **100 ms–5000 ms**
  (independent of `timeoutSecs`), global concurrency 32 → `busy`.
- **Gated OFF** unless the platform operator enables sandbox tools on the deployment (else mid-call abort). Don't ship a sandbox tool as the only path
  to a critical feature. Author with `handler.code` (inline body) or `handler.codeFile` (a `.js` file ≤64 KB that
  `telenow build` inlines into `code` — never both, never neither).

### A tool as a deterministic flow node
Place your app tool in a `kind:"tool"` flow node whose inner `config.kind:"app"` runs it **every time, in order**
(e.g. look the caller up before greeting). See §9 for the exact nested shape — `config.config.app_id` MUST equal
your app id or the node is stripped at install.

---

## 7. The React UI + `window.telenow` bridge

Your UI runs in a **sandboxed iframe on an opaque origin** — no cookies, no tokens, no API keys. The dashboard
injects `window.telenow`; every call is relayed to the parent, performed under the signed-in user, scoped to your
app+org, and enforced server-side. Use `telenow/react` hooks; drop to `getTelenow()` (from `telenow/browser`) for
opts/subscriptions/files. `getTelenow()` **throws** outside the iframe — guard with `hasTelenow()` for a dev
placeholder.

### Correct imports
```ts
import { mount, useObjects, useUser, useCall, useTelenowContext } from 'telenow/react';
import { getTelenow, hasTelenow, createMockBridge } from 'telenow/browser';
// WRONG (in the doctor-crm example — do NOT copy): import { ... } from '@telenow/app/react';
```

### A complete, runnable UI

`ui/index.tsx`:
```tsx
import { mount } from 'telenow/react';
import App from './App';
mount(App);
```

`ui/App.tsx` — form + list + create/update/remove via `useObjects`, context routing, loading/error:
```tsx
import { useState, type FormEvent } from 'react';
import { useObjects, useTelenowContext, useUser } from 'telenow/react';

interface Patient { name: string; phone: string; status: 'active' | 'inactive'; notes?: string; }

export default function App() {
  const { page } = useTelenowContext();          // 'patients' | 'reports' | … (the active manifest page id)
  if (page === 'reports') return <Reports />;
  return <Patients />;
}

function Patients() {
  const { user, can } = useUser();
  const canWrite = can('manage_agents') || user?.role === 'owner' || user?.role === 'admin';
  const { data, loading, error, create, update, remove } = useObjects<Patient>('patient');
  const [form, setForm] = useState({ name: '', phone: '', notes: '' });
  const [busy, setBusy] = useState(false);

  if (loading) return <p className="tn-muted">Loading…</p>;

  const add = async (e: FormEvent) => {
    e.preventDefault();
    if (!form.name || !form.phone) return;
    setBusy(true);
    try { await create({ ...form, status: 'active' }); setForm({ name: '', phone: '', notes: '' }); }
    catch (err) { alert(err instanceof Error ? err.message : String(err)); }   // DataClient/bridge throws
    finally { setBusy(false); }
  };

  return (
    <div style={{ maxWidth: 720, margin: '0 auto', padding: 16, color: 'var(--tn-fg)' }}>
      <h1>Patients</h1>
      {error && <div className="tn-badge" style={{ color: 'var(--tn-danger)' }}>{error.message}</div>}

      {canWrite && (
        <form onSubmit={add} className="tn-card" style={{ display: 'flex', gap: 8, padding: 12 }}>
          <input className="tn-input" placeholder="Name"  value={form.name}
                 onChange={(e) => setForm({ ...form, name: e.target.value })} />
          <input className="tn-input" placeholder="Phone" value={form.phone}
                 onChange={(e) => setForm({ ...form, phone: e.target.value })} />
          <button className="tn-btn tn-btn-primary" disabled={busy}>{busy ? 'Saving…' : 'Add'}</button>
        </form>
      )}

      <table className="tn-table" style={{ width: '100%', marginTop: 12 }}>
        <thead><tr><th>Name</th><th>Phone</th><th>Status</th><th /></tr></thead>
        <tbody>
          {data.map((r) => (                                  // r is an AppRecord — read r.data.*, r.id at top level
            <tr key={r.id}>
              <td>{r.data.name}</td>
              <td>{r.data.phone}</td>
              <td>{r.data.status ?? 'active'}</td>
              <td>
                {canWrite && (
                  <>
                    <button className="tn-btn"
                      onClick={() => update(r.id, { status: r.data.status === 'active' ? 'inactive' : 'active' })}>
                      Toggle
                    </button>
                    <button className="tn-btn" onClick={() => remove(r.id)}>Delete</button>
                  </>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function Reports() { return <p>Reports…</p>; }
```

### `useObjects` return shape
```ts
interface UseObjectsResult<T> {
  data: AppRecord<T>[];
  loading: boolean;
  error: Error | null;
  reload: () => void;
  create: (body: Partial<T>) => Promise<AppRecord<T>>;
  update: (id: string, body: Partial<T>) => Promise<AppRecord<T>>;   // (id, body) — NOT (type, id, body)
  remove: (id: string) => Promise<void>;                             // hook: void; raw telenow.data.remove: { ok:true }
}
function useObjects<T>(objectType: string, query?: Record<string, string>): UseObjectsResult<T>;  // equality only, no opts
```

### The bridge namespaces (`getTelenow()`)
- **`data`** — `list(type, query?, opts?)` → `AppRecord[]`; `count(type, query?)` → `{ count }`; `create`,
  `update(type,id,body)`, `remove(type,id)` → `{ ok:true }`; `subscribe(type, onChange)` → `Promise<unsubscribe>`.
  `QueryOpts = { orderBy?:{field,desc?,numeric?}, limit?, view?, expand?, search? }`. Operators go in the **query**
  (2nd arg); sort/limit/view/expand/search go in **opts** (3rd arg).
- **`agents`** — `list()` → `{id,name}[]`; `createFromTemplate(id)` → `{ agentId, kind:'single'|'flow' }`;
  `createTeamFromTemplate(id)` → `{ teamId, entryAgentId, agents:[{ref,agentId,kind}] }`. **Side effect:** on
  success the host NAVIGATES to `/agents/:id/flow`|`/workspace` — **your page unmounts**, don't chain UI after.
- **`calls`** — `initiate(agentId, phone, variables?)` → `{ sessionId?, status? }`; `history(filters?)` →
  `CallRecord[]`.
- **`whatsapp`** — `channels()` → `{id,kind?}[]`; `send(channelId, to, message)` → `{ ok:true }`.
- **`softphone`** — `dial(phone)` → `{ ok:true, opened:true }`.
- **`session`** — `token()` → `{ token, expiresIn:1800 }` (JWT for your backend).
- **`stream`** — `subscribe(sessionId, onFrame)` → `Promise<unsubscribe>`; frame = `{ topic, sessionId, data }`;
  topics: `call.turn call.transcript_partial call.assistant_turn call.barge_in call.silence call.dtmf call.node_entered
  call.context_note` (`call.assistant_turn` = the agent's reply, `call.context_note` = a live context note reached the
  agent; payloads in `/docs-md/api-responses.md`, Live call stream).
- **`files`** — `list(prefix?)` → `TelenowFileMeta[]` (`{ path, contentType?, sizeBytes, updatedAt }`);
  `put(path, body)` → `{ path, size }`; `get(path)` → **`ArrayBuffer` (raw bytes — decode with `new
  TextDecoder().decode(buf)`, NOT `JSON.parse`)**; `delete(path)` → `{ deleted:boolean }`. Uploads always stored as
  `application/octet-stream` — encode logical type into the path.
- **`http(request)`** — a **callable** (raw), or `useHttp().fetch(request)` (hook). `TelenowHttpRequest = { url,
  method?, headers?, body?, connection? }`; `TelenowHttpResponse = { status, headers, body }` where **body is TEXT
  (≤1 MB) — `JSON.parse` it yourself**. `connection:'<provider>'` injects a stored credential server-side and
  OVERRIDES any Authorization header. HTTPS-only, SSRF-guarded, no redirects, lowercased response header names.
- **`settings`** — `all()` / `get<T>(key)` (undefined for secrets). Synchronous STATIC snapshot baked into the
  iframe at load — NOT reactive (updates only on reload).
- **`context`** — `{ appId, pageId, page, title, user? }` (`page` is an alias of `pageId`). Slot pages get extra
  keys merged in, e.g. `(ctx as any).callId` in `call_detail_panel`.
- **`user`** / **`theme`** (theme is NOT in the SDK type — read `(window as any).telenow?.theme`; `theme.mode()` →
  `'light'|'dark'`, `theme.onChange(cb)` → unsubscribe function).

### Hooks quick map (all from `telenow/react`)
`mount(App)`, `useObjects(type, query?)`, `useUser()` → `{ user, can }`, `useSettings()` → `{ all(), get(key) }`,
`useAgents()` → `{ agents, loading, error }`, `useCall()` → `{ initiate(agentId,phone), history(f?) }`,
`useCallHistory(f?)` → `{ calls, loading, error, reload }`, `useWhatsapp()`, `useSoftphone()`, `useSession()`,
`useHttp()` → `{ fetch(req) }`, `useTelenowContext()`.

### `TelenowUser` + RBAC
```ts
interface TelenowUser { id: string; role: string|null; permissions: string[]; name?: string; email?: string; }
// role ∈ owner | admin | developer | viewer | member | null   (name/email only with user:profile scope)
```
`can(p)` = `user.permissions.includes(p)` — an invalid string ALWAYS returns false. Valid permissions:
`view manage_agents manage_numbers manage_members manage_billing manage_workplace monitor_calls manage_api_keys
manage_recordings manage_publish manage_org`. **`can('admin')`/`can('owner')` are NOT permissions** — gate on
`user.role`. Canonical derived gates:
```ts
const canCommunicate  = can('manage_agents') || user?.role === 'owner' || user?.role === 'admin';
const canViewActivity = can('view') || canCommunicate;
```

### Two gates: scope AND role
Beyond the manifest scope, the host enforces a role gate (rejection: `"your role does not permit this action"`).
Render-gate these so users don't hit dead buttons:
- **canCommunicate** gates: `agents.createFromTemplate`, `agents.createTeamFromTemplate`, `calls.initiate`,
  `whatsapp.channels`, `whatsapp.send`, `softphone.dial`.
- **canViewActivity** gates: `agents.list`, `calls.history`, `stream.subscribe`.
- **No role gate:** `session.token`, `files.*`, `http`, `data.*`, `data.subscribe`.

### `CallRecord` (snake_case, from `calls.history`)
```ts
interface CallRecord {
  id: string; agent_id?: string; agent_name?: string;
  channel?: 'telephony'|'softphone'|'web_call'|'whatsapp'|'web_chat';
  direction?: 'inbound'|'outbound'|'web'|'whatsapp';
  from_number?: string; to_number?: string; status?: string;
  duration_sec?: number; start_time?: string; end_time?: string; [k: string]: unknown;
}
// history(filters?: Record<string,string>) — commonly { number?, sessionId? } (comma-separated for bulk)
```

### The `variables`-dropped trap
```ts
// WRONG — hook drops the 3rd arg silently:
const { initiate } = useCall();
await initiate(agentId, phone, { patient_name: 'Asha' });   // compiles, vars LOST
// CORRECT — raw bridge takes variables:
await getTelenow().calls.initiate(agentId, '+919876500001', { patient_name: 'Asha', appointment_time: '4:30 PM' });
```

### Design system
Host injects `--tn-*` CSS variables + `.tn-*` classes into every iframe (light/dark synced via
`:root[data-tn-theme="dark"]`, no reload). **No spacing tokens — use raw px.** Tokens: `--tn-bg --tn-fg --tn-muted
--tn-muted-bg --tn-border --tn-card --tn-card-border --tn-primary --tn-primary-fg --tn-danger --tn-radius(8px)
--tn-shadow --tn-font`. Classes: `.tn-card .tn-btn .tn-btn-primary .tn-input .tn-select .tn-badge .tn-muted
.tn-table` (with `th`/`td`).

### Graceful degradation
Read hooks (`useAgents`, `useCallHistory`) return **empty** on a capability gap (check `error` too). Action methods
reject with `"<cap>" is unavailable — update the dashboard to use it.`. Wrap actions in try/catch; the host also
catches any thrown error and shows a banner + renders the stack in-iframe.

---

## 8. Automation

Four manifest sections react to things automatically. **THE #1 FOOT-GUN:** a **rule** handler and a **webhook**
handler see the SAME event at DIFFERENT depths.

| Handler | Receives | Field path |
|---|---|---|
| `rule` (in-core upsert) | the **bare event context** | top level — `summary`, `caller_number` … |
| `webhook` (POST to your backend) | `EventRequest` envelope `{ event, appId, data }` | one deeper — `data.summary` … |
| `workflow` `trigger` | the **raw payload** | `{{trigger.*}}` (for object-create: `{{trigger.data.field}}`) |

For `object.<type>.created` the payload itself is `{ objectType, id, data:<row> }`, so: a RULE reads `data.phone`; a
WEBHOOK reads `data.data.phone`; a WORKFLOW reads `{{trigger.data.phone}}`. For `call.analyzed` a RULE reads
top-level `summary` (NO `data.` prefix), a WEBHOOK reads `data.summary`.

### events[] — the topic table (the ONLY valid `on` values; typo fails upload)
| Topic | Fires when | Extra scope |
|---|---|---|
| `call.started` | a call connects | — |
| `call.ended` | a call hangs up | — |
| `call.analyzed` | post-call analysis ready | — |
| `recording.ready` | recording stored | — |
| `call.turn` | each speaker turn, mid-call | `calls:read` |
| `call.barge_in` | caller interrupts, mid-call | `calls:read` |
| `call.silence` | silence threshold, mid-call | `calls:read` |
| `call.dtmf` | keypad digit, mid-call | `calls:read` |
| `call.node_entered` | flow node entered, mid-call | `calls:read` |
| `object.<type>.created` / `object.*.created` (wildcard) | a row is created | — |

**`rule` handler** — declarative upsert (the only `do` is `"upsert"`). Fires ONLY if: `object` AND `key` present,
`map` non-empty, and the mapped `key` resolves to a **non-empty string** (null/absent map sources are silently
skipped → map the key from an always-present source). `when` is a single `{ path, equals? }` (omit `equals` →
truthy/present check). Map dot-paths: leading `$.` stripped, array indices work (`entry.0.field`).
```jsonc
{ "on": "call.analyzed",
  "handler": { "kind": "rule", "do": "upsert", "object": "lead", "key": "phone",
    "when": { "path": "disposition", "equals": "interested" },        // top-level path, NO data.
    "map": { "phone": "caller_number", "name": "summary", "sentiment": "sentiment" } } }
```
> `call.analyzed` rule context (FLAT): `summary sentiment disposition topics[] keywords[] custom.<key> caller_number
> session_id`. There is **NO** `outcome`, `caller_name`, or `agent_name` — those resolve to nothing.

**`webhook` handler** — POST the signed `EventRequest` to `base_url + path` (needs `base_url`).

### Event payload reference (bare context per topic)
| Topic | Fields |
|---|---|
| `call.started` | `sessionId agentId userId from variables startTime` |
| `call.ended` | `sessionId agentId userId durationSecs messageCount fromOrTo variables` (caller # is **`fromOrTo`**; lean — no recording/transcript) |
| `recording.ready` | `recordingId sessionId agentId durationSecs recording:{ id, url?, expiresAt? }` (no top-level `recordingUrl`; delivered only to apps bound to the agent) |
| `call.completed` | `sessionId agentId outcome from to durationSecs endedAt variables analysis?` + **`callRecord?`** — `{ disposition, established{}, seeded[], declined[], actionsDone[], optedOut }` when the agent runs stateful context. `callRecord.disposition` is the LEAD GRADE; `outcome` is the carrier verdict. Absent (not empty) when there is no record. Check `seeded` before writing a value back — those came from your own dial row. Values honour the org's PII rules. |
| `campaign.call.completed` | `campaignId campaignName callId phone disposition answered attempt sessionId completedAt variables` + **`callRecord?`** (same shape; `established`/`seeded` omitted unless the app also holds `calls:read`) |
| `call.analyzed` | `summary sentiment disposition topics[] keywords[] custom.<key> caller_number session_id` |
| `object.<type>.created` | `objectType id data:{…row}` |
| mid-call topics | `sessionId` + sanitised topic fields (node `system_prompt` stripped) |

### schedules[] (max 20)
```jsonc
{ "key": "nightly-sweep", "every": "24h", "handler": { "kind": "webhook", "path": "/jobs/nightly" } }
// rule schedule (constant upserts only): { "key":"sweep", "every":"6h", "handler":{ "kind":"rule", "object":"lead", "key":"phone", "map":{} } }
```
`every` = `<positive-int><unit>`, unit ∈ `{m|min, h|hr, d|day}`, ANY multiple (`90m`, `6h`, `2d`), **floor 5 min**,
no seconds/weeks/cron. **Server-only validation** (CLI skips it) — `10s`/`2m` pass `validate` then fail upload.
Tick payload: `{ schedule:"<key>", appId:"<appId>", firedAt:"<ISO>" }` (event name `schedule.<key>`).

### workflows[] — durable, retrying (max 20, ≤20 steps)
Trigger takes an **event topic only**; **NOT validated against the allowlist** — spell it exactly or it silently
never fires. The 6 step kinds with EXACT config keys:
| kind | config keys | output `{{steps.N.*}}` |
|---|---|---|
| `create-object` | `object`, `data{}` | `{ id, data }` |
| `update-object` | `object`, `match`, `matchValue`, `data{}` | `{ id, data }` or `null` (no match) |
| `delay` | `seconds` OR `delay:"5m"` (s/m/h/d) | — |
| `outbound-call` | `agentId`, `toNumber`, `variables{}` (OBJECT of placeholders) | `{ sessionId }` |
| `send-message` | `channelId`, `to`, `body` OR `template`/`language`/`variables[]` (ARRAY, ordered) | `{ sent: true }` |
| `http` | `url`, `method?`(default GET), `headers{}?`, `body?`, `timeoutSecs?`(default 15, clamp 1–30) | `{ status, ok, body }` |

```jsonc
"workflows": [
  { "id": "lead-callback", "name": "Call new leads", "trigger": { "event": "object.lead.created" },
    "steps": [
      { "kind": "delay", "seconds": 120 },
      { "kind": "outbound-call", "agentId": "<uuid app-bound>", "toNumber": "{{trigger.data.phone}}",
        "variables": { "name": "{{trigger.data.name}}" } },
      { "kind": "update-object", "object": "lead", "match": "phone", "matchValue": "{{trigger.data.phone}}",
        "data": { "status": "called" } }
    ] }
]
```
- **Templating:** a `{{path}}` that is the WHOLE string preserves the value's TYPE (number stays number); embedded
  in text → string. Missing → `null` (whole) / empty (embedded). `{{steps.N.*}}` is 0-indexed; `{{trigger.*}}` is
  the raw payload.
- **`http` step:** method ∈ `GET|POST|PUT|PATCH|DELETE|HEAD`; object/array body → JSON + `content-type`; string body
  verbatim; body cap 256 KB; SSRF-guarded; branch on `{{steps.N.status}}`/`.ok` (body is truncated ~8 KB).
- **Prereqs (retry-then-fail after 6):** `outbound-call` needs a valid app-bound agent UUID + a wallet that covers
  the call; `send-message` needs an existing org channel UUID + non-empty `body` OR approved `template`+`language`
  (default `en_US`)+`variables[]`.
- **Engine caps:** in-flight runs per (org,app) **1000** (over → trigger **silently dropped**); step attempts **6**
  → run `failed`; backoff `5s×2^attempt` capped 7 days; `delay` clamp 7 days; `http` retries on 5xx/transport, a
  4xx is `Done` (continue); `update-object` no-match is `Done(null)`, not a retry.
- **Loop safety:** a workflow `create-object` writes straight to the store and does **NOT** re-fire
  `object.<type>.created` — so it can't trigger another workflow/rule. (Inbound hooks + agent-tool/Data-API creates
  DO fire it.) Chain the no-backend lead loop through the inbound hook's create event.

### inboundHooks[] — platform-hosted receivers, no backend (max 20)
```jsonc
{ "id": "meta-leads", "object": "lead", "key": "phone",
  "map": { "name": "full_name", "phone": "phone_number", "source": "ad_id" },  // target ← dot-path in raw POST body (NO data.)
  "verify": { "header": "x-hub-signature-256", "algo": "sha256", "prefix": "sha256=", "encoding": "hex" } }
```
Platform hosts `POST https://api.telenow.ai/webhooks/app/<installationId>/<hookId>`: verifies, field-maps the body
into `object` (upsert by `key` if set), fires `object.<type>.created`. Body cap 256 KB, rate-limited, never fails
open.
- **verify:** `algo` = `sha256` (default) or `sha1` — **anything not exactly `sha1` ⇒ sha256** (masks typos).
  `prefix` stripped then `.trim()`ed; `encoding` = `hex`(default)|`base64`.
- **KEY PRECEDENCE: `secretSetting` > `secret` > the install signing secret.** Pick by WHO SIGNS:
  - **A vendor that mints its own webhook secret (Shopify, Meta, Stripe, most branded SaaS) ⇒ you MUST use
    `"secretSetting": "<a settings[] key of `type:"secret"`>"`.** The merchant pastes the vendor's secret there;
    it is stored AES-GCM encrypted per install and decrypted server-side at verify time. Declared-but-unset fails
    CLOSED. **Getting this wrong is undetectable from the app side** — the vendor sees 401s, you see nothing.
  - **Only when the sender is YOUR OWN backend** (or a vendor that lets the merchant TYPE the secret, e.g.
    Razorpay/WooCommerce) may you omit both and fall back to the install signing secret — it matches because we
    handed that value to whoever signs. Then TELL the merchant to paste it into the vendor's "Secret" field;
    nothing does that for you.
  - A literal `secret` sits in PLAINTEXT in the manifest AND is identical for every install, so it can never hold
    a per-merchant value. Publish refuses `secret` + `secretSetting` together, and refuses a `secretSetting` that
    is not a `type:"secret"` setting (non-secret settings are served to your iframe).
- **Set `prefix` when the header carries one.** `x-hub-signature-256` sends `sha256=<hex>`; an empty `prefix`
  strips nothing and compares a prefixed value against a bare digest — a guaranteed 401 **independent of the key**.
  Publish now refuses this combination. `Stripe-Signature` is refused outright: it is composite
  (`t=…,v1=…` over `<timestamp>.<body>`) and this receiver cannot verify it — use the token form for Stripe.
- **No `verify` ⇒ the URL must carry `?token=<install signing_secret>`.**
- **Your Setup page MUST point at the URL, and must NOT try to print it.** The URL is per-install and your iframe
  is never given the installation id. The platform renders it under **Inbound webhooks** on the app's own page
  (owner/admin only). Say that in your setup copy — "point your webhook at this app's inbound hook" with no
  pointer is why every shipped app was unusable without support.
- **Meta GET handshake:** Meta sends `GET …?hub.mode=subscribe&hub.challenge=ABC&hub.verify_token=<token>`; Telenow
  echoes the raw challenge with **200 iff `hub.verify_token` == the install signing secret** (else 403; 404 if
  stale). In Meta's "Verify Token" field, paste the install signing secret.
  **TRAP: the handshake and the deliveries use DIFFERENT secrets.** `hub.verify_token` is operator-chosen (ours),
  but Meta signs each POST with YOUR Meta app secret. So the handshake goes green and every subsequent lead is
  silently rejected. Meta hooks need `secretSetting` + `"prefix": "sha256="` or they deliver nothing.
- **Response codes:** `200 {ok:true,id}` = written + fired; `200 {ok:true,mapped:0}` = map matched nothing (dot-
  paths wrong / all null — nothing written); `400 missing key field`; `401 unauthorized`; `404 not found`/`no such
  hook`; `413 store full`; `500 write failed`.

### The no-backend loop
`3rd-party POST → inboundHooks (verify+map) → lead row → fires object.lead.created → workflows (delay →
outbound-call) → your agent rings the lead` — all manifest JSON, zero server code.

### Webhook body TS shapes (verify raw body FIRST, then parse)
```ts
interface EventRequest    { event: string; appId: string; data: Record<string, unknown> }        // event webhook
interface ToolCallRequest { tool: string; arguments: Record<string, unknown>; caller?: CallerEnvelope }  // http tool
interface CallerEnvelope  { number?: string; identifier?: string; channel?: string; session_id?: string }
```

---

## 9. Bundled agents, flow graphs & teams

Ship ready-made agents/teams/KBs. One click builds a REAL agent, auto-bound to your tools + KBs.

### agents[] — template fields (the ONLY keys read; others incl. `llm_config`/`stt_config`/`tts_config`/`tags`/`tools` are forced unset/stripped)
| Field | Default | Notes |
|---|---|---|
| `id` | — | **Required**, key-safe, unique in app |
| `name` | `Untitled agent` | display name |
| `description` | — | for the "Create agent" picker |
| `systemPrompt` | — | instructions + personality |
| `llmProvider` | `openai` | |
| `llmModel` | `gpt-4o-mini` | |
| `sttProvider` | `deepgram` | |
| `ttsProvider` | `elevenlabs` | |
| `ttsVoice` | `9BWtsMINqrJLrRacOk9x` | |
| `sessionConfig` | `{}` | `opener`, `recordingEnabled`, behaviour flags |
| `telephonyConfig` | — | optional telephony settings; secret-shaped keys are stripped |
| `llmConfig` / `sttConfig` / `ttsConfig` | — | provider tuning (LLM temperature, STT language hints, TTS model variant); credential-shaped keys are stripped |
| `metadata` | `{}` | put `{ flow: {…} }` here for a multi-context flow agent |

```jsonc
{ "id": "front-desk", "name": "Clinic Front Desk",
  "systemPrompt": "You are the clinic receptionist… use the clinic tools.",
  "llmModel": "gpt-4o-mini", "ttsVoice": "rachel",
  "sessionConfig": { "opener": "Thanks for calling! How can I help?", "recordingEnabled": true } }
```

**single vs flow:** kind is `flow` iff `metadata.flow` has **>1 node OR any edge**; else `single` (a lone node with
zero edges runs identically to a single-context agent). You never declare `tools` on a template — auto-bound.

### The flow graph (`metadata.flow`)
Envelope: `{ schema, startNodeId, routerModel?, nodes[], edges[] }`. `startNodeId` is REQUIRED and must match a node
id (else the flow is discarded at runtime → single-context fallback; manifest upload still passes because the graph
is validated only at run/builder-open). `routerModel` (optional) for inline `ai`-edge classification:
`'platform'|'agent'|'node'|{provider,model}` or a model string. **`schema`** is read as a **number** (a non-negative integer,
default 1); examples write `"v1"` (string) — safe only inside a template (not parsed at upload); prefer `1` for a
graph meant to run directly. Caps: **≤100 nodes, ≤300 edges**.

**The 13 node kinds** (`kind`; any other kind → save-time validation error → single-context fallback):
`conversation` (free-form turn — the workhorse), `subagent` (self-contained sub-conversation), `static` (palette
"Say" — fixed line, no LLM, auto-advance), `play_audio` (palette "Play Audio" — plays an uploaded track from the
org's audio library, waits for it to finish, then advances), `tool` (deterministic app-tool call), `code` (sandboxed JS — execution
DEFERRED today), `router` (palette "Logic Split" — pure routing), `extract` (palette "Extract Variable" — capture
typed vars), `dtmf` (palette "Press Digit"), `transfer` (palette "Call Transfer" — human), `agent` (palette "Agent
Transfer" — hand off to another AI agent), `end` (palette "Ending"), `note` (canvas-only, never executed).

> **`expectsInput` and `skipResponse` are PHANTOM fields** — the runtime reads neither (harmless but meaningless). Older
> examples, including the doctor-crm example, still carry them. `skipResponse: true` does NOT make a step move on: the
> step still speaks and then waits for the caller. To speak a line and move straight on without waiting, use a
> **`kind: "static"`** step (palette "Say"), whose `entryMessage` is spoken on arrival before the step auto-advances. On a
> `conversation` step, `continueAfterEntry: true` lets the model carry straight on from its `entryMessage` instead of
> waiting — leave it off when that line IS the question, or the agent talks over the answer.

**Node object (key fields, camelCase):** `id`* (unique, key-safe), `kind` (default `conversation`), `name?`,
`prompt?`, `promptMode?` (`append` default | `replace`), `entryMessage?`, `continueAfterEntry?`, `model?`
(`{provider,model,temperature?,maxTokens?}`), `voice?`, `stt?`, `behavior?`, `knowledgeBaseIds?`, `extract?`,
`guards?` (`{bargeInSensitivity?, silenceHangupSecs?, keypad?}`), global flags (below), `config?` (kind-specific).

**Node `config` per kind (validated — a bad config rejects the whole flow):**
```jsonc
// app-tool node — config.config.app_id MUST equal THIS app's id or the node is stripped at install
{ "id":"lookup", "name":"Look up caller", "kind":"tool",
  "config": { "name":"find_patient", "kind":"app",
    "config": { "app_id":"clinic-crm", "object":"patient", "handler":{ "kind":"object.query" } } } }
// transfer node — needs ≥1 destination with a digit-bearing number
{ "id":"x1", "kind":"transfer",
  "config": { "destinations":[{ "label":"Sales", "numbers":["+15551234567"] }], "message":"Connecting…", "noAnswerMessage":"They're unavailable" } }
// agent handoff node — needs ≥1 non-empty agentId (a sibling ref in a team)
{ "id":"toBilling", "kind":"agent",
  "config": { "agents":[{ "agentId":"billing", "label":"Billing", "mode":"transfer" }] } }
// end node
{ "id":"bye", "kind":"end", "config": { "message":"Thanks for calling, goodbye!" } }
```

**Extract vars → equation branches:** each extract var is `{ name*, type ('string'|'number'|'boolean'|'enum', default
string), description?, values? (enum), required? }`. Captured names are what `{var}` in an `equation` edge
references; tool-node outcome branches read the reserved `_tool_status` var (`ok`|`error`).

**Edges** — `{ id*, source (node id or "*" for a global edge), target, label?, priority (an integer, default 0, LOWER
first), condition }`. `condition.kind` ∈ `always | equation | tool_result | dtmf | ai | fallback` (per-kind required
field, else flow rejected): `ai` → `describe` (natural-language intent); `equation` → `expr`; `dtmf` → `digit`;
`tool_result` → `match` (`"ok"`|`"error"`, default `ok`); `always`/`fallback` → nothing. Routing: deterministic
edges resolve first, then `fallback`, then `ai` via the router model — **always give a branching node a fallback**.
```jsonc
{ "id":"e1", "source":"menu", "target":"sales", "priority":1, "condition":{ "kind":"dtmf", "digit":"1" } }
{ "id":"e2", "source":"qualify", "target":"adult", "condition":{ "kind":"equation", "expr":"{age} >= 18" } }
```

**`equation` grammar** (tiny + total — any parse failure → false, edge silently doesn't fire): exactly ONE
comparison; each side a `{var}` (curly braces required; unset → Null) or a literal (single/double-quoted string,
bare number, `true`/`false`). Operators `== != > < >= <=`. **NO `&&`/`||`, no nesting, no arithmetic, no functions.**
`"age >= 18"` FAILS (needs `{age}`). Equality is numeric when both look numeric else string; ordering needs both
numeric-coercible else false.

**Global nodes** (jump from anywhere): `isGlobal:true` + `globalCondition` (natural-language trigger; empty ⇒ not a
jump target), `globalExamples[]`, `globalGoBack?` (return after running — an interjection),
`globalPreventRetriggerSteps?` (cooldown). Only a global node's CONDITIONAL edges propagate globally; its
`always`/`fallback` edges stay local.

Complete flow example (app-tool lookup before greeting):
```jsonc
"metadata": { "flow": {
  "schema": 1, "startNodeId": "lookup", "routerModel": "gpt-4o-mini",
  "nodes": [
    { "id":"lookup", "name":"Look up caller", "kind":"tool",
      "config": { "name":"find_patient", "kind":"app",
        "config": { "app_id":"clinic-crm", "object":"patient", "handler":{ "kind":"object.query" } } } },
    { "id":"greet", "name":"Greeting", "kind":"conversation",
      "prompt":"If a patient name was found, greet them by name; otherwise greet as a new caller. Then ask how you can help." }
  ],
  "edges": [ { "id":"e1", "source":"lookup", "target":"greet" } ] } }
```

### agentTeams[] (1–10 members)
Each member = a flattened agent spec + a required key-safe `ref` (unique in team). `entry` = the member ref the call
starts on (must equal a member ref). A handoff is an `agent`-kind flow node whose `config.agents[].agentId` is set
to a **sibling ref** (a placeholder). On Create team, `rewrite_handoff_refs` rewrites each `agentId` from the
sibling ref → the real created agent id; a value that is NOT a sibling ref (e.g. a real UUID) is left untouched (so
you can hand off to an existing org agent).
```jsonc
"agentTeams": [
  { "id": "front-desk-team", "name": "Front Desk Team", "entry": "triage",
    "members": [
      { "ref": "triage", "name": "Triage", "systemPrompt": "…hand off to booking…",
        "metadata": { "flow": { "schema":1, "startNodeId":"greet",
          "nodes":[ { "id":"greet", "kind":"conversation", "prompt":"Greet and ask how you can help." },
                    { "id":"toBooking", "kind":"agent", "config":{ "agents":[{ "agentId":"booking", "label":"booking", "mode":"transfer" }] } } ],
          "edges":[ { "id":"e1", "source":"greet", "target":"toBooking" } ] } } },
      { "ref": "booking", "name": "Booking", "systemPrompt": "…book with clinic tools…" }
    ] }
]
```
Create-team response: `{ teamId, entryAgentId, agents:[{ ref, agentId, kind }] }`.

### knowledgeBases[] (RAG, manifest-only)
```jsonc
"knowledgeBases": [ { "id": "clinic-info", "name": "Clinic Info",
  "documents": [ { "title": "Hours", "body": "Open Mon–Sat 9–7…" } ] } ]
```
Embedded on install, **auto-attached** to the app's agents (RAG at call time — you wire nothing). Manifest-only:
NO runtime KB API — to change knowledge, bump the manifest version.

### Untrusted-spec sanitization (runs automatically on every template/spec)
`strip_embedded_tools` removes top-level `tools`/`precallLookups`/`flowDraft` AND each node's `tools`/
`precallLookups`. `sanitize_template_flow_nodes` defuses always-run nodes: a `tool` node survives ONLY if
`config.kind=="app"` AND `config.config.app_id`==this app's id exactly (else config dropped); `code` nodes lose
config; `transfer` nodes get numbers blanked; on EXPORT (no install context) NO tool nodes survive. Secrets stripped
from `metadata`/`sessionConfig`/`telephonyConfig`.

### Caps
`agents[]` ≤50, `agentTeams[]` ≤20 (1–10 members), `knowledgeBases[]` ≤20 (≤100 docs, ≤512 KB each, non-empty
title); runtime **≤100 provisioned agents/(org,app)** (`413`, across all create paths).

---

## 10. Backend & the app-key REST API

For **external** apps or backend automation. Mint an **app key** and read the **signing secret** in the dashboard
(installed app → API keys / Signing secret; secret shown ONCE). The key is bound to one `(org, app)`. Base host:
`https://api.telenow.ai`. Auth: `Authorization: Bearer <app key>`. Rate-limited per (org,app) → `429 "Data API rate
limit reached — slow down"`. Every response is `{ success, data?, error? }` (except blob downloads = raw bytes).

### Endpoint & scope reference
| Method & path | Purpose | Scope |
|---|---|---|
| `GET /api/app-data/:type?filter…&limit&sort&dir&numeric&view&expand&search&topK` | list → `{success,data:{objects}}` | `data:read`* |
| `POST /api/app-data/:type` → `{success,data:<AppRecord>}` | create (fires `object.<type>.created`) | `data:write`* |
| `PATCH /api/app-data/:type/:id` → `{success,data:<AppRecord>}` | merge-update | `data:write`* |
| `DELETE /api/app-data/:type/:id` → `{success:true}` | delete | `data:write`* |
| `GET /api/app-files/` (`?prefix=`) → `{success,data:{files}}` | list blobs | `files:read` |
| `PUT /api/app-files/*path` (raw body) → `{success,data:{path,size}}` | upload/overwrite | `files:write` |
| `GET /api/app-files/*path` → **RAW BYTES** (attachment; not JSON) | download | `files:read` |
| `DELETE /api/app-files/*path` → `{success,data:{deleted}}` | delete blob | `files:write` |
| `GET /api/app-agents` → `{success,data:{agents:[{id,name,kind}]}}` | list app's agents | `agents:read` |
| `POST /api/app-agents` → `{success,data:{agentId,kind}}` | create agent (flattened spec) | **`agents:write`** |
| `DELETE /api/app-agents/:id` → `{success:true}` | delete app's agent | **`agents:write`** |
| `POST /api/app-agents/:id/eval` | eval scenarios → pass/fail | `agents:read` |
| `GET /api/app-calls?includeAnalysis=true` → `{calls:[{…,analysis,analysisEnabled}]}` | call history + POST-CALL ANALYSIS | `calls:read` (bound agents) or `calls:read:org` |
| `GET /api/app-calls/:sessionId` → one call + `analysis` + `transcript` | call detail | same; 403 unless bound or org-wide |
| `GET /api/app-agents/:id/config` → grouped, secret-free config | read a BOUND agent's whole setup | `agents:config:read` |
| `PATCH /api/app-agents/:id/config` → `{updated:[groups],config}` | write settings back (sparse, merge-preserving) | `agents:config:write:<group>` per group |
| `GET/POST /api/app-campaigns` · `GET /:id` · `POST /:id/pause` · `POST /:id/cancel` | bulk outbound | `campaigns:read`/`campaigns:write` |
| `POST /api/app-calls/:sessionId/stream-ticket` → `{ticket,wsUrl}` → WS `/ws/live-call-stream?ticket=` | live stream | `calls:read` + agent binding |
| `POST /webhooks/app/:installationId/:hookId` | inbound hook (no bearer; HMAC or `?token=`) | — |

\* **Static-key vs OAuth asymmetry:** for a **static app key** the Data-API CRUD routes do NOT enforce
`data:read`/`data:write` (a static key is already tenant+app bound). Those scopes only constrain OAuth tokens.
**Files/Agents/Campaigns/streaming DO enforce declared scopes even for a static key.** Two 403 strings:
`"token does not include the <scope> scope"` (OAuth) vs `"app did not declare the <scope> scope"` (static key). REST
query string is equality-only + `limit/topK/sort/dir/numeric/view/expand/search`; operators need the bridge.

### Bootstrap: keys, signing secret, session token (user-authenticated — owner/admin dashboard session)
| Method & path | Result |
|---|---|
| `POST /api/orgs/:orgId/apps/:appId/keys` (body `{label?}`) | `{ key:{id,installationId,appId,lastFour,label,createdAt,revokedAt}, secret:"vai_app_…" }` (secret shown ONCE; must install the app first else `400 install the app before creating a key`) |
| `GET /api/orgs/:orgId/apps/:appId/keys` | `{ keys:[…] }` (un-revoked, newest first) |
| `DELETE /api/orgs/:orgId/apps/:appId/keys/:keyId` | `{success:true\|false}` |
| `GET /api/orgs/:orgId/apps/:appId/signing-secret` | `{ signingSecret }` |
| `POST /api/orgs/:orgId/apps/:appId/session-token` | `{ token, expiresIn:1800 }` (needs `session:token`) |

A key only resolves while the install is enabled+active AND the publisher is active (else every call 401s). Session
JWT (HS256, install signing secret) claims: `{ sub, email? (only with user:profile), org_id, app_id, role,
aud:"app:<appId>", exp, iat, jti }`. Verify with `verifyAppToken(token, signingSecret, appId)`.

### Signatures
Every `http` tool call + event webhook is signed `X-Telenow-Signature: sha256=<hex>` = HMAC-SHA256 over the **raw**
body, keyed with the signing secret. Verify against the raw bytes BEFORE JSON parsing:
`verifySignature(signingSecret, rawBody, header)`.

### DataClient (unwraps the envelope; throws on non-2xx or success:false)
```ts
const db = new DataClient('https://api.telenow.ai', process.env.TELENOW_APP_KEY!);
const { objects } = await db.list('appointment', { phone: '+91…' });  // equality only
objects[0].id; objects[0].data.phone; objects[0].createdAt;           // fields under .data
const appt = await db.create('appointment', { phone, slot_start });   // returns full AppObject
appt.id;            // ✅ record id (top level)
appt.data.phone;    // ✅ (NOT appt.phone)
```

### Complete backend (Express) — http tool + event webhook + session verify
```ts
import express from 'express';
import { verifySignature, verifyAppToken, DataClient, type ToolCallRequest, type EventRequest } from 'telenow';

const SIGNING_SECRET = process.env.TELENOW_SIGNING_SECRET!;
const db = new DataClient('https://api.telenow.ai', process.env.TELENOW_APP_KEY!);
const app = express();
// IMPORTANT: capture the RAW body for signature verification, before JSON parsing.
app.use(express.json({ verify: (req, _res, buf) => ((req as any).rawBody = buf) }));

// An agent `http` tool — the platform ALWAYS POSTs here (declared method is ignored).
app.post('/tools/book', async (req, res) => {
  if (!verifySignature(SIGNING_SECRET, (req as any).rawBody, req.header('x-telenow-signature')))
    return res.status(401).json({ error: 'bad signature' });
  const { arguments: args, caller } = req.body as ToolCallRequest;   // caller fields are ALL optional
  try {
    const appt = await db.create('appointment', { phone: caller?.number, ...args, status: 'booked' });
    res.json({ ok: true, id: appt.id, when: args.slot_start });      // returned JSON is spoken back into the call
  } catch (e) {
    res.json({ ok: false, error: 'could not book' });                // small, clean object — no stack traces
  }
});

// A post-call event webhook — reply 2xx fast, do slow work async.
app.post('/events', (req, res) => {
  if (!verifySignature(SIGNING_SECRET, (req as any).rawBody, req.header('x-telenow-signature')))
    return res.status(401).end();
  const evt = req.body as EventRequest;                              // { event, appId, data:{ sessionId, … } }
  console.log('event', evt.event, evt.data.sessionId);
  res.status(204).end();
});

// Verify a UI session token to trust which user is calling your backend.
app.get('/me', (req, res) => {
  try {
    const claims = verifyAppToken(req.headers.authorization!.slice(7), SIGNING_SECRET, 'clinic-crm');
    res.json({ userId: claims.sub, org: claims.org_id, role: claims.role });
  } catch { res.status(401).end(); }
});

app.listen(3000);
```

### Campaigns — create body
| Field | Default | Notes |
|---|---|---|
| `agentId` | — | **REQUIRED**, must be app-bound else `403` |
| `name` | `"App campaign"` | |
| `targets` | — | explicit `[{ phoneNumber, variables? }]` — **`phoneNumber` not `phone`** |
| `targetQuery` | — | `{ object, phoneField (default "phone"), filter }` — each row's `data` → call `{placeholder}` vars |
| `concurrency` | `5` | simultaneous calls |
| `maxAttempts` | `3` | retry attempts |
| `retryBackoffSecs` | `300` | |
| `retryOnNoAnswer` | `true` | |
| `machineDetection` | — | AMD mode string |
| `window` | — | `{ start:"09:00", end:"18:00", timezone:"Asia/Kolkata" }` (local time) |
| `autostart` | `true` | `false` → `draft` |
| `result` | — | `{ object* , map* (target←`{{outcome.*}}`/`{{variables.*}}`), key? (default "phone") }`; no `map` → nothing written back |

`{{outcome.*}}` fields: `phone disposition attempt completedAt sessionId campaignId`.
Create response: `{ campaignId, queued, suppressed, status }`. Status: `{ id, name, agentId, status, totalTargets,
completedTargets, failedTargets, createdAt, callCounts:{…} }`. Caps: 50 active/app, 5,000 targets, 2 MB body.

### Eval — request/response
```jsonc
// request — scenario REQUIRED; ≤3 scenarios; max_turns default 4, clamped 6
{ "scenarios": [ { "name":"reschedule", "scenario":"A patient calls to move tomorrow's 9am…", "expect":"offers a new slot and confirms", "max_turns":6 } ] }
// response — gate CI on data.allPassed
{ "success": true, "data": {
  "results": [ { "name":"reschedule", "passed":true, "score":88, "reasoning":"…", "turns":3, "errored":false } ],
  "passed": 1, "total": 1, "allPassed": true } }
```
`score` (0–100) is OMITTED when errored or critic-only. Empty scenarios → `400`; >3 → `400`; 200 s budget →
`400`; unbound/arbitrary agent → `403 app is not bound to this agent`; sim not configured → `400`.

### Files & live stream
`GET /api/app-files/*path` returns **raw bytes** (forced `Content-Disposition: attachment` + `nosniff` — never
renders inline) — don't `JSON.parse` it. Live stream: `stream-ticket` → single-use 30 s `wsUrl`; four gates
(`calls:read`, call in your org, app bound to the agent, call live).

---


## Response shapes — what actually comes back
★★ FULL catalogue of verified examples: `/docs-md/app-responses.md` for the app endpoints, bridge, app tools and
app events, and `/docs-md/api-responses.md` for the analysis object, the live-call stream and what every tool
returns to the model (verified field-by-field). Read them before generating client code.
★★ FIVE CONVENTIONS THAT BREAK GENERATED CODE:
1. Envelope is `{success,data}` — but several nest AGAIN: `data.objects`, `data.calls`, `data.agents`,
   `data.knowledgeBases`. Destructure the level the example shows.
2. CASING IS PER-PLANE: app surface camelCase, dashboard/public-v1 snake_case, and some shared helpers
   emit snake_case a route then renames. Do NOT assume.
3. The UI BRIDGE RE-PROJECTS several ops — what an app PAGE receives != the REST payload.
4. YOUR FIELDS ARE NESTED: `row.data.<field>`, and a query tool gives `results[i].data.<field>`.
   `results[i].phone` is silently undefined.
5. TWO ERROR FLAVOURS: a TOOL returns `{ok:false,error}` INSIDE a 200 so the model can recover mid-call;
   a REST route returns an HTTP error. They are not interchangeable.
### Tool returns (what the agent receives mid-call — the shapes your prompt must reason about)

**Envelope contract — what the model actually receives**
```jsonc
{
  "1_openai_family_cascade_and_text": {
    "role": "tool",
    "content": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}",
    "tool_call_id": "call_9f2bA1",
    "name": "clinic_crm_book_appointment"
  },
  "2_anthropic_bedrock_cascade": {
    "role": "user",
    "content": [
      {
        "type": "tool_result",
        "tool_use_id": "toolu_01A9f2bA1",
        "content": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}"
      }
    ]
  },
  "3_gemini_cascade": {
    "role": "user",
    "parts": [
      {
        "functionResponse": {
          "name": "clinic_crm_book_appointment",
          "response": {
            "ok": true,
            "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
            "record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
          }
        }
      }
    ]
  },
  "4_s2s_openai_realtime": {
    "type": "conversation.item.create",
    "item": {
      "type": "function_call_output",
      "call_id": "call_9f2bA1",
      "output": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}"
    }
  },
  "5_s2s_gemini_live": {
    "toolResponse": {
      "functionResponses": [
        {
          "id": "call_9f2bA1",
          "name": "clinic_crm_book_appointment",
          "response": {
            "result": {
              "ok": true,
              "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
              "record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
            }
          }
        }
      ]
    }
  },
  "6_app_facing_event_not_the_model_envelope": {
    "event": "tool.invoked",
    "sessionId": "b41c8e02-5a7d-4f19-9c33-2ad6e0f81b45",
    "agentId": "3f6a1d90-77c4-4e2b-8a51-9db0c4e73f12",
    "name": "clinic_crm_book_appointment",
    "arguments": { "patient_name": "Asha Rao" },
    "result": {
      "ok": true,
      "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
      "record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
    },
    "status": "success",
    "latencyMs": 214
  },
  "7_app_tool_error_envelope": {
    "soft": { "ok": false, "error": "app data store is full — cannot create more records" },
    "hard": { "error": "unknown tool: clinic_crm_book_appointment" }
  }
}
```
> There is NO `{success,data}` wrapper anywhere on this surface — that is the REST convention only. The tool's JSON is passed to the model as compact text (no spaces), unchanged, as the tool message `content`, and each model family wraps it differently. Case 1 (OpenAI and OpenAI-compatible models, voice and text): the tool message as shown. Case 2 (Anthropic, including Bedrock): a `tool_result` block in a user message — consecutive tool results collapse into ONE user message, and `name` is dropped. Case 3 (Gemini): a `functionResponse` part — a JSON object passes through UNWRAPPED as `response`; anything else becomes `{"result": …}`. Case 4 (OpenAI Realtime): a `function_call_output` item — no role, no name; `call_id`/`output` nested under `item`. Case 5 (Gemini Live): the text is re-parsed and ALWAYS nested under `response.result`. Case 6 is NOT the model envelope: the app-facing `tool.invoked` event is camelCase, and its `result` is an OBJECT, not a string. Case 7: a soft refusal stays `ok:false` inside a successful tool call, while a hard failure reaches the model as `{"error": "<message>"}`. Two consequences developers trip on: (1) a handler that returns a bare array or `null` reaches the model as the literal string `null` / `[...]`; (2) the platform decides `status` for logging purely on whether a top-level `error` key is present — so an `{"ok":false,"error":…}` app envelope IS classed "error", but `{"results":[]}` is classed "success". Key casing on this surface is MIXED and per-handler, not one convention: declarative app handlers emit snake_case (`ok`, `record`, `created_at`), natives emit a mix (`at_limit`, `volume_percent`, `row_count` snake; `toAgent`, `numberEnding` camel), and the `tool.invoked` event uses camelCase `latencyMs`.

**app handler `object.create` — success**
```jsonc
{
  "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.create` — row-cap refusal**
```jsonc
{
  "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` — success**
```jsonc
{
  "results": [
    {
      "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
      "data": {
        "patient_name": "Asha Rao",
        "phone": "+919845012345",
        "slot": "2026-08-03T10:30:00+05:30",
        "status": "booked",
        "caller_number": "+919845012345",
        "display_name": "Asha Rao — 3 Aug"
      },
      "created_at": "2026-07-29T09:14:22.481293Z"
    }
  ]
}
```
> ★ NESTED WHERE YOU EXPECT FLAT: every stored field is under `results[i].data.<field>`, never `results[i].<field>`. ★ NO `ok` KEY AT ALL — this is the one declarative handler with no `ok`; a reader (or a flow `tool_result` edge) that keys off `ok` sees `undefined` on the happy path. Only three keys survive per row — `id`, `data`, `created_at` — the row's `org_id`, `app_id`, `object_type`, `created_by` and `updated_at` are all dropped. `created_at` is RFC 3339 with microseconds and a `Z` suffix. `data` DOES include computed fields (`display_name` above), because the tool reads records the same way the Data API does rather than from the raw store. Relation `<field>__expanded` keys 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.query` — the empty-filter refusal (two variants)**
```jsonc
{
  "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. When the manifest declares a non-empty `handler.map`, a suffix is appended to the same `error` string — one space, then `(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)` — so the sentence runs straight on, `…and try again (this tool…`, with no full stop before the parenthesis; reproduce it exactly. 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.

**app handler `object.update` — success**
```jsonc
{
  "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 `object.update` — no match / missing match value**
```jsonc
{
  "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": "…"}` (the `missing_match_value` case above; 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`"}`.

**app handler `object.delete` — success and not-found**
```jsonc
{
  "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. Not found is `{"ok":false,"deleted":false,"error":"no matching record found"}`. `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.

**app handler `http` (external tier) — passthrough**
```jsonc
{
  "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 `http` — failure envelopes**
```jsonc
{
  "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 `sandbox` (gated) — success and failure**
```jsonc
{
  "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`"}`.

**app tool — unsupported handler kind**
```jsonc
{
  "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`.

**app tool — pre-dispatch refusals (before any handler runs)**
```jsonc
{
  "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`.

**native `transfer` — what the model sees**
```jsonc
{
  "status": "transferring",
  "to": "+912261234567"
}
```
> Natives never use `ok` — they use `status` (a per-kind verb string) plus kind-specific keys, and failures are the flat `{"error": …}` with no `ok`. `to` is the E.164 number actually dialled (the first of the chosen destination's numbers that is not on the Do-Not-Call list). Distinctive failures, verbatim: `transfer tool has no destination configured`; `all transfer destinations are on the do-not-call list`; plus the carrier's own error text on a failed bridge. On success the tool loop STOPS (a terminal native) — the model gets this result but no further turn.

**native agent handoff — what the model sees**
```jsonc
{
  "status": "handed_off",
  "mode": "transfer",
  "toAgent": "b41e77c2-9d0a-4f35-8c62-1a7e5f3b90d4"
}
```
> camelCase `toAgent` sitting next to snake_case `status`/`mode` — the native surface is not internally consistent. `mode` is exactly "transfer" or "connect" (anything else in the manifest normalises to "transfer"). `toAgent` is the target agent uuid, not a label. The unknown-destination error echoes the RESOLVABLE labels so the model can retry: `{"error":"unknown handoff destination \"billing\" — valid destinations: Sales, Support"}`; with none configured: `agent handoff tool has no target agent configured`.

**native `end_call` / `navigate` — what the model sees**
```jsonc
{ "status": "ending" }

{ "status": "navigating", "to": "node_collect_details" }
```
> `end_call` returns a single key and nothing else — the hangup is DEFERRED until the tool loop finishes, so the goodbye line plays first and "ending" means armed, not hung up. `navigate.to` is the flow NODE ID, not the label the model passed. Navigate's failure is the terse `{"error":"navigate: unknown target"}`. Both are terminal natives: the tool loop breaks after them.

**native dataset lookup (structured KB) — what the model sees**
```jsonc
{
  "result": [
    { "sku": "TN-4410", "name": "Wall mount bracket", "price": 1499, "in_stock": "yes" }
  ],
  "row_count": 1
}
```
> `result` is polymorphic and `row_count` is NULL whenever it is not an array — read them together. Three shapes from the same tool: rows → `result` is an array of the raw dataset row objects (all cell values are STRINGS), `row_count` = length; a bare aggregate → `result` is a NUMBER and `row_count` is `null` (e.g. `{"result":143,"row_count":null}`); a grouped aggregate → `result` is `[{"group":"Mumbai","value":42}]`. At most 20 rows. Failures are flat `{"error": …}`: `could not understand the lookup arguments: <parse detail>` (fails CLOSED — a filter missing `op` never degrades to an unfiltered read), `unknown column: <name>` / `unsupported op: <op>` / `<func> needs a numeric column; '<col>' is not numeric`, and the deliberately vague `could not run that lookup on the dataset` for any database error (the real detail goes to the server log, never to the model).

**native follow-up scheduling — what the model sees**
```jsonc
{
  "status": "scheduled",
  "at": "2026-07-29T17:00:00+05:30",
  "numberEnding": "2345"
}
```
> ★ The full callback number is NEVER returned — only `numberEnding`, the last 4 digits, deliberately so the number does not enter the model's context. `at` PRESERVES THE CALLER-LOCAL OFFSET the model supplied (not UTC) precisely so the model reads back the right wall-clock time. In a simulation the shape changes to `{"status":"simulated","at":"…","note":"simulation — no real follow-up call was scheduled"}` and nothing is saved. Every failure is flat `{"error": "<caller-facing sentence>"}` and the sentences are instructions to the model, not diagnostics — e.g. `follow-up calls only work on phone calls, and this is a web session — politely tell the caller you cannot schedule an automatic call-back from this channel`; `this call already has 3 pending follow-ups — do not schedule more`; `this number already has 2 pending follow-ups — do not schedule more`; ``invalid `at` value "5pm" — send an ISO 8601 date-time WITH a timezone offset (e.g. 2026-07-04T17:00:00+05:30), or use `in_minutes` ``.

**native `adjust_volume` / `set_language` / `opt_out` — what the model sees**
```jsonc
{
  "status": "adjusted",
  "volume_percent": 141,
  "at_limit": false,
  "note": "volume changed. Do not announce the change or narrate this tool; just repeat your last point so they can judge it for themselves."
}

{
  "status": "language_set",
  "language": "Hindi",
  "note": "From now on reply ONLY in Hindi, whatever language the caller speaks. Briefly confirm the switch to the caller in Hindi now."
}

{
  "status": "opted_out",
  "note": "recorded — the caller will not be contacted again. Confirm that plainly, then close politely."
}
```
> These three are DATA-ONLY natives: the tool loop continues and the model must speak afterwards — hence the `note` key, which is a behavioural INSTRUCTION to the model, not display text. `volume_percent` is an integer from 50 to 200 (100 = normal); at a limit `at_limit` is true and `note` becomes `already at maximum volume — if the caller still can't hear, suggest they raise their phone or speaker volume` or `already at minimum volume`. `set_language.language` is the RESOLVED display name, not the code the model sent, and its failure is `{"error":"unrecognized language 'klingon' — retry with a plain language name like 'hindi', 'telugu', 'english'"}`. `opt_out` takes NO arguments — the number comes from the call itself, server-side; its errors always state plainly that nothing was recorded.

**native tools — shared failure and skip envelopes**
```jsonc
{
  "error": "skipped: an earlier tool in this turn already took over the call"
}
```
> Every native failure is the flat `{"error": …}` — no `ok`, no `status`. Two envelopes exist that no handler produced: when a terminal native already ran this turn, later natives in the same batch are never run and get `skipped: an earlier tool in this turn already took over the call`; when the call is already tearing down, a transfer/handoff gets `skipped: the call is already ending`. Both still reach the model as normal tool results, so every tool call the model made gets a result. A stored native whose kind this server does not know: `{"error":"unsupported native tool kind: Some(\"voicemail\")"}` — the kind is shown in the platform's raw internal form, `Some(…)` and quotes included, so don't parse it.

**Confirm-before-execute gate — the envelope that replaces the real result**
```jsonc
{
  "status": "confirmation_required",
  "instruction": "Do NOT treat this as done. Tell the caller you're about to send a payment link for ₹1499 to +919876543210, and ask them to confirm. Only after they clearly say yes, call this tool again with the same details. If they decline or change anything, do not proceed."
}
```
> Easy to miss: for a tool with `requiresConfirmation` (and for any money-moving connector action such as `payment.*`, whatever its setting), the FIRST call never runs the tool at all — the model gets this instead of the handler's shape. The re-arm is keyed on tool name + a hash of the arguments, so re-calling with different args re-asks. The first call is recorded with status `confirmation_pending` (not `success`/`error`), which is what lands in the call's tool log and the `tool.invoked` webhook; if the details changed between the read-back and the caller's yes, the agent reads the new details back instead of running the tool, and that call is recorded `confirmation_superseded`. The read-back is built from the tool's arguments only, never secrets.

**Unknown / unavailable tool name**
```jsonc
{ "error": "unknown tool: book_appointmnt" }
```
> Recorded with status `not_found`, not `error`. On s2s there is a SECOND, kinder variant used when the name IS one of the flow's tools but not available at the current step (the model is offered every tool in the flow, while each step only runs its own): `{"error":"this tool is not available at this step: 'refund_order' belongs to a different step of this conversation. Use only the tools listed in your current instructions, or navigate to the step that offers it."}`. And a built-in tool of a kind speech-to-speech calls can't run gets `{"error":"tool 'x' (native kind None) is not available on speech-to-speech calls"}` — again the kind appears in the platform's raw internal form (`None`, or `Some("…")`), so don't parse it.

**Flow `tool` NODE — the SAME handlers, re-projected into variables (differs)**
```jsonc
// object.create returns {"ok":true,"id":"7d3f9a1e-…","record":{…}}
// but a flow tool NODE merges this into session variables instead:
{
  "_tool_status": "ok",
  "ok": true,
  "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63"
}
```
> ★ This is the closest thing to a bridge re-projection on this surface, and it silently LOSES data. A deterministic flow tool node does not hand the JSON to a model — it copies ONLY top-level text, number and true/false values into session variables. So `record` (object) and `results` (array) are DROPPED entirely: after an `object.query` node no new variable appears at all, and after `object.create` you get only `ok` and `id`. WORSE: `_tool_status` is "ok" whenever the tool answered, including the soft `{ok:false,error:…}` envelopes — so the row-cap refusal, "no matching record found", and the empty-filter refusal all route down the SUCCESS branch of a `tool_result` edge, with the refusal text flattened into an `error` variable. Only a hard failure sets `_tool_status` = "error". In a simulation the node never executes and just sets `_tool_status`="ok".

**`tool.invoked` webhook + `tool.call` debug-trace payload**
```jsonc
{
  "__wire_shape_1__ tool.invoked webhook — this object IS the entire HTTP POST body (no {success,data} envelope; delivery metadata is in headers)": {
    "event": "tool.invoked",
    "sessionId": "c0a81f43-6d2e-4f7b-b1a9-8e5c3d4f2a11",
    "agentId": "b41e77c2-9d0a-4f35-8c62-1a7e5f3b90d4",
    "name": "clinic_crm_book_appointment",
    "arguments": { "patient_name": "Asha Rao", "slot": "2026-08-03T10:30:00+05:30" },
    "result": { "ok": true, "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63", "record": { "status": "booked" } },
    "status": "success",
    "latencyMs": 214
  },
  "__wire_shape_2__ tool.call debug-trace NDJSON line (cascade and s2s calls); type/seq/t_ms are added flat onto the payload": {
    "corr": "t3:n=book_slot:g=1",
    "id": "call_9XkQm2ZrT4",
    "name": "clinic_crm_book_appointment",
    "args": { "patient_name": "Asha Rao", "slot": "2026-08-03T10:30:00+05:30" },
    "result": { "ok": true, "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63", "record": { "status": "booked" } },
    "status": "success",
    "error": null,
    "latency_ms": 214,
    "type": "tool.call",
    "seq": 42,
    "t_ms": 8137
  }
}
```
> The webhook is camelCase (`sessionId`, `agentId`, `latencyMs`) while the `result` it carries keeps the handler's own casing — so a single payload mixes both conventions. `result` is the parsed JSON here, not the text the model received. `status` ∈ success | error | not_found | abandoned | confirmation_pending | confirmation_superseded. The debug-timeline event is a DIFFERENT shape from the same data: `{"corr","id","name","kind","args","result","status","error","latency_ms","http"}` — snake_case `latency_ms`, `args` not `arguments`, plus `id` (the tool_call_id), `kind` (connector | mcp | app | native | tool) and a nullable `error` string that the webhook does not carry. A deterministic flow tool node records `flow_tool.call` instead, with `{"corr","name","kind","status","latency_ms","args","result","http","saved","unresolved"}` and no `id`; a bundled (composite) tool records one `tool.step` per step with `{"composite","step","name","kind","status","args","result","latency_ms","http"}`. `http` lists every HTTP request the tool made, in order — see `/docs-md/api-responses.md` (the `tool.invoked` webhook and the call trace).

## 11. Scopes, settings & connections

### Scope catalogue (declare in `scopes[]`, consented at install, frozen; NO server whitelist — spell exactly)
`user:profile` · `session:token` · `agents:read` · `agents:write` · `calls:read` · `calls:initiate` ·
`agents:config:read` · `agents:config:write` (superset) · `agents:config:write:<group>` where group ∈
`prompt model voice stt behavior flow telephony analysis objectives` — the agent-IMPROVER plane (`objectives` = 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); the group is decided by the
FIELD, so editing a flow node's prompt needs `…:write:prompt`, NOT `…:write:flow` ·
`whatsapp:send` · `softphone:dial` · `files:read` · `files:write` · `campaigns:read` · `campaigns:write` ·
`objects:<type>` (per type — no `objects:*`) · `http:<host>` (leading wildcard ok) · `connection:<provider>` ·
`data:read` · `data:write` (OAuth-scoped only; static keys aren't CRUD-scope-gated).

The CLI typo-guard is INCOMPLETE (knows only `user:profile session:token agents:read calls:read calls:initiate
whatsapp:send softphone:dial` + `objects:`/`http:`/`connection:` prefixes) → it false-warns on `agents:write`,
`files:*`, `campaigns:*`, `data:*`. Runtime 403s: `host \`<h>\` is not in the app's granted http: scopes`, `app did
not declare the <scope> scope`, `token does not include the <scope> scope`.

**`http:<host>` matching:** `http:*.example.com` matches `foo.example.com`/`a.b.example.com` but **NOT** the apex
`example.com` (declare BOTH if you call the apex) and not `evil-example.com`. Case-insensitive, trailing-dot
tolerant.

### settings[] — per-install admin form (validated: unknown SETTINGS keys ARE rejected, unlike scopes)
```jsonc
"settings": [
  { "key": "clinic_name", "label": "Clinic name", "type": "text", "required": true },
  { "key": "tier", "label": "Plan", "type": "select", "options": [ { "value": "free", "label": "Free" } ] },  // select REQUIRES options
  { "key": "api_key", "label": "API key", "type": "secret", "required": true }   // secret: NO default allowed
]
```
`type`: `text|textarea|number|boolean|select|secret`. `key` must be key-safe + unique. `secret:true` (or
`type:"secret"`) → encrypted, server-side only; **must NOT declare a `default`** (upload rejects — it'd be
plaintext). Non-secret values reach the UI via `useSettings()` (static snapshot). `get('api_key')` → undefined.

### Stored connections & secret headers (credential never enters the browser)
- **`connection:'<provider>'`** on `telenow.http` injects the org's stored, auto-refreshed OAuth/API credential as
  `Authorization` server-side (always wins over app-supplied Authorization). Needs the `connection:<provider>` scope
  + the host(s). Provider ids the platform ships: `salesforce hubspot shopify stripe calendly notion airtable gmail
  google_calendar google_sheets` (else `400 unknown connection provider`).
  ```jsonc
  "scopes": ["connection:salesforce", "http:*.salesforce.com", "http:salesforce.com"]
  ```
- **`secret_headers: { "<Header>": "<settingKey>" }`** — the browser names only the KEY; the server decrypts your
  `secret` setting and sets that header. **Allowed ONLY when the host is granted by an EXACT `http:<host>` scope** (a
  `*.` wildcard is NOT enough → `403 secret injection requires an exact http:{host} scope`).

RBAC (`can()`) is UI gating only — real enforcement is server-side on every relayed call.

---

## 12. Whitelisted icons

For `icon`, `pages[].icon`, `extensions[].icon` (unknown → silent `box` fallback):

`bell` `book` `box` `briefcase` `calendar` `chart` `clipboard` `clock` `card` `file` `heart` `home` `inbox`
`grid` `list` `mail` `map` `message` `package` `phone` `settings` `cart` `star` `stethoscope` `ticket` `user`
`users`.

There is **no** `boxes` or `sticky-note`. For a custom logo, ship an `icon.png/.jpg/.jpeg/.webp` (≤4 MB) beside the
manifest.

---

## 13. Limits & quotas

- **Data:** 100,000 rows / (org,app) across all types (`413` on create). List default 100, clamp **1–500**; no
  offset. Expand ≤8 fields / ≤500 rows-per-field. Computed ≤8192 chars. Semantic `topK`/`limit` default 20.
- **Data API rate:** ~20 req/s sustained, burst 60 → `429` (dashboard proxy exempt). Proxy: 20/s + burst 40,
  concurrency 64, per-host breaker (5 fails → open 30 s), response ≤1 MB, HTTPS-only.
- **Blobs:** 25 MB/file, 1 GB total, 10,000 files.
- **Automation:** 20 each schedules/workflows/inbound hooks; ≤20 steps/workflow; schedule floor 5 min; workflow
  in-flight runs 1000 (over → dropped), 6 step attempts, `http` step body 256 KB / response ~8 KB / timeout 1–30 s
  (default 15); inbound hook body 256 KB.
- **Tools:** `timeoutSecs` 1–30 (default 15); `object.query` 50 rows; `http`/proxy response ≤1 MB HTTPS-only.
  Sandbox: source ≤16 KB, output ≤256 KB, exec 100 ms–5000 ms, concurrency 32 → `busy`, `codeFile` ≤64 KB.
- **Agents/KB:** templates ≤50, teams ≤20 (1–10 members), KB ≤20 (≤100 docs, ≤512 KB); runtime ≤100 provisioned
  agents/(org,app) (`413`).
- **Eval:** 3 scenarios, 6 turns, 200 s budget. **Campaigns:** 50 active, 5,000 targets, 2 MB body.
- **Package:** upload 32 MB, ≤400 files, ≤40 MB uncompressed, UI bundle ≤8 MB, styles ≤2 MB, ≤5 screenshots (≤4 MB
  each), README/CHANGELOG ≤64 KB each, manifest ≤512 KB. Session token 1800 s; stream ticket 30 s single-use.

---

## 14. Publishing, versions & rollout

**Build:** `npx telenow build` validates (server rules), bundles the UI via esbuild (React de-duped, inline
sourcemap), and writes `<id>-<version>.telenow.zip`.

**Two distributions:**
- **Private** — `POST /api/apps/:orgId/upload` (no `?review`): status `published`, visibility `private`, live in
  seconds; no README/screenshots required.
- **Marketplace** — `POST /api/apps/:orgId/upload?review=true` (validates + requires README + ≥1 screenshot) OR
  `POST /api/apps/:orgId/:appId/submit-review` (re-runs the checklist on an already-uploaded version) → status
  `in_review`. Only a platform admin's `approve_app` sets `visibility='public'`. **Private and public are
  independent** — `publish_app` never changes visibility.

**Readiness checklist** (`GET /api/apps/:orgId/:appId/readiness` → `{version, ready, checklist[]}`): required =
`name`, `blurb`, `readme`, `screenshots`; optional = `icon`. Submitting before ready → `422`.

**Versions are IMMUTABLE** (publish guard `precheck_publish`): reusing a `published`/`in_review` version →
`409 version X already exists — publish under a new version number`; a globally-taken id → `409 app id 'X' is
already taken by another developer`; self-publishing a private version of an already-public app → `409 … submit
updates for review`; suspended dev account → `403`. To ship a change: **bump version, rebuild, re-upload.**

**Staged rollout** (`PATCH /api/apps/:orgId/:appId/visibility`, owner/admin or the uploader): `stage` ∈ `dev`
(uploader + admins — the default; a private upload starts here, so teammates see NOTHING until you promote),
`staging` (admins + named `audienceMembers` Uuid[], kept only on staging), `prod` (everyone). Other value → `400`.
Marketplace installs start in `prod`.

**App keys/signing secret:** mint from the installed app (§10) — install first. **Uninstall:** `DELETE
…?deleteData=<bool>` (default false). Always removes sidebar pages + schedules; `false` KEEPS objects/blobs/KBs
(signing secret preserved on re-install), `true` purges blobs + soft-deletes KBs.

---

## 15. Ship checklist

- [ ] `id` + semver `version`; least-privilege `scopes[]` spelled EXACTLY; every `object.*` tool references a
      declared object; `objects:<type>` declared per type your tools/UI touch.
- [ ] `object.update`/`object.delete` tools declare a real `match` field (a declared field); `http`/`webhook`
      tools/events/schedules set `base_url`.
- [ ] Workflow steps use exact keys (`matchValue`, `data`, `toNumber`, `channelId`) — NOT the tool `set`; campaign
      targets use `phoneNumber`; workflow `trigger.event` topic spelled exactly.
- [ ] Read UI fields off `record.data.*` (never `record.*`); use the raw bridge for opts/`calls.initiate(…,
      variables)`; imports are `telenow/react` + `telenow/browser` (never `@telenow/app`).
- [ ] Icons from the 27-name whitelist; UI `entry` points at a real `.tsx`; page ids unique.
- [ ] No `js` handler; secrets marked `secret:true` with NO `default`; flow app-tool nodes use YOUR `app_id`.
- [ ] `npx telenow validate` clean-ish (ignore known false scope/schedule warnings), then `npx telenow build`, then
      upload.

---

## 16. Complete end-to-end `telenow.app.json`

A declarative clinic CRM exercising objects + tools + ui + workflow + inboundHook + an event rule + a flow agent.

```jsonc
{
  "$schema": "./node_modules/telenow/telenow.app.schema.json",
  "id": "clinic-crm",
  "version": "1.0.0",
  "name": "Doctor CRM",
  "runtime": "declarative",
  "category": "healthcare",
  "blurb": "Patient records, appointments & follow-up calls for clinics.",
  "icon": "calendar",
  "scopes": ["objects:patient", "objects:appointment", "objects:lead", "agents:read",
             "calls:read", "calls:initiate", "whatsapp:send", "user:profile"],

  "objects": [
    { "type": "patient", "label": "Patient", "semantic": true,
      "fields": [
        { "key": "name", "type": "string", "index": true },
        { "key": "phone", "type": "phone", "index": true },
        { "key": "caller_number", "type": "phone", "index": true },
        { "key": "status", "type": "enum", "values": ["active","inactive"], "default": "active", "index": true },
        { "key": "notes", "type": "string" },
        { "key": "display", "type": "string", "computed": { "template": "{{name}} ({{phone}})" } }
      ],
      "views": [ { "name": "active", "label": "Active", "filter": { "status": "active" }, "orderBy": { "field": "name" } } ] },
    { "type": "appointment", "label": "Appointment",
      "fields": [
        { "key": "patient_name", "type": "string", "index": true },
        { "key": "phone", "type": "phone", "index": true },
        { "key": "problem", "type": "string" },
        { "key": "start", "type": "datetime", "index": true },
        { "key": "status", "type": "enum", "values": ["scheduled","visited","cancelled"], "default": "scheduled", "index": true },
        { "key": "patient_id", "type": "string", "index": true, "relation": { "object": "patient" } }
      ],
      "views": [ { "name": "scheduled", "label": "Scheduled", "filter": { "status": "scheduled" }, "orderBy": { "field": "start", "numeric": false } } ] },
    { "type": "lead", "label": "Lead",
      "fields": [
        { "key": "name", "type": "string", "index": true },
        { "key": "phone", "type": "phone", "index": true },
        { "key": "source", "type": "string" },
        { "key": "status", "type": "enum", "values": ["new","called","converted"], "default": "new", "index": true }
      ] }
  ],

  "tools": [
    { "name": "find_patient",
      "description": "Look up an existing patient to recognise the caller. Pass phone (preferred) or name.",
      "parameters": { "type": "object", "properties": {
        "phone": { "type": "string", "description": "patient phone (preferred)" },
        "name":  { "type": "string", "description": "patient full name" } } },
      "handler": { "kind": "object.query", "object": "patient" } },
    { "name": "register_patient", "description": "Create a new patient record.",
      "parameters": { "type": "object",
        "properties": { "name": { "type": "string" }, "phone": { "type": "string" } },
        "required": ["name","phone"] },
      "handler": { "kind": "object.create", "object": "patient" } },
    { "name": "book_appointment", "description": "Book a clinic appointment for the caller.",
      "parameters": { "type": "object", "properties": {
        "patient_name": { "type": "string" },
        "phone": { "type": "string" },
        "problem": { "type": "string", "x-ui": { "widget": "textarea", "placeholder": "e.g. fever 3 days" } },
        "start": { "type": "string", "description": "ISO 8601", "x-ui": { "widget": "date", "label": "Appointment date" } } },
        "required": ["patient_name","phone","start"] },
      "timeoutSecs": 4, "handoff": "One moment while I check the calendar.",
      "handler": { "kind": "object.create", "object": "appointment" } },
    { "name": "cancel_appointment",
      "description": "Cancel the caller's most recent appointment (matched by phone).",
      "parameters": { "type": "object", "properties": { "phone": { "type": "string" } }, "required": ["phone"] },
      "handler": { "kind": "object.update", "object": "appointment", "match": "phone", "set": { "status": "cancelled" } } }
  ],

  "ui": {
    "entry": "ui/index.tsx",
    "pages": [
      { "id": "patients",     "title": "Patients",     "icon": "users",    "menu": true },
      { "id": "appointments", "title": "Appointments", "icon": "calendar", "menu": true },
      { "id": "reports",      "title": "Reports",      "icon": "chart",    "menu": true }
    ],
    "extensions": [ { "slot": "agents_overview_panel", "page_id": "reports", "title": "Agent insights" } ]
  },

  "settings": [
    { "key": "clinic_name", "label": "Clinic name", "type": "text", "required": true },
    { "key": "reminders",   "label": "Send reminders", "type": "boolean", "default": true }
  ],

  "events": [
    { "on": "call.analyzed",
      "handler": { "kind": "rule", "do": "upsert", "object": "lead", "key": "phone",
        "when": { "path": "disposition", "equals": "interested" },
        "map": { "phone": "caller_number", "name": "summary", "source": "session_id" } } }
  ],

  "workflows": [
    { "id": "lead-callback", "name": "Call new leads within 2 minutes",
      "trigger": { "event": "object.lead.created" },
      "steps": [
        { "kind": "delay", "seconds": 120 },
        { "kind": "outbound-call", "agentId": "00000000-0000-0000-0000-000000000000",
          "toNumber": "{{trigger.data.phone}}", "variables": { "name": "{{trigger.data.name}}" } },
        { "kind": "update-object", "object": "lead", "match": "phone",
          "matchValue": "{{trigger.data.phone}}", "data": { "status": "called" } }
      ] }
  ],

  "inboundHooks": [
    { "id": "lead-intake", "object": "lead", "key": "phone",
      "map": { "name": "full_name", "phone": "phone_number", "source": "ad_id" },
      "verify": { "header": "x-hub-signature-256", "algo": "sha256", "prefix": "sha256=", "encoding": "hex" } }
  ],

  "agents": [
    { "id": "front-desk", "name": "Clinic Front Desk",
      "systemPrompt": "You are the clinic receptionist. Greet callers, identify the patient, and help them book, reschedule, or cancel. Always confirm date and time. Use the clinic tools.",
      "llmModel": "gpt-4o-mini", "ttsVoice": "rachel",
      "sessionConfig": { "opener": "Thanks for calling the clinic! How can I help?", "recordingEnabled": true } },
    { "id": "smart-front-desk", "name": "Smart Front Desk (auto-lookup)",
      "llmModel": "gpt-4o-mini", "ttsVoice": "rachel",
      "sessionConfig": { "opener": "One moment while I pull up your details." },
      "metadata": { "flow": {
        "schema": 1, "startNodeId": "lookup", "routerModel": "gpt-4o-mini",
        "nodes": [
          { "id": "lookup", "name": "Look up caller", "kind": "tool",
            "config": { "name": "find_patient", "kind": "app",
              "config": { "app_id": "clinic-crm", "object": "patient", "handler": { "kind": "object.query" } } } },
          { "id": "greet", "name": "Greeting", "kind": "conversation",
            "prompt": "If a patient name was found, greet them by name; otherwise greet as a new caller. Then ask how you can help." }
        ],
        "edges": [ { "id": "e1", "source": "lookup", "target": "greet" } ] } } }
  ],

  "knowledgeBases": [
    { "id": "clinic-info", "name": "Clinic Info",
      "documents": [ { "title": "Hours and services",
        "body": "Open Monday to Saturday, 9 AM to 7 PM; closed Sundays. General medicine, pediatrics, dermatology, basic lab tests. Cancellations require 4 hours notice." } ] }
  ]
}
```

---

## 17. Deep reference links

Overview `/docs-md/app-platform.md` · Capabilities `/docs-md/app-capabilities.md` · Quickstart
`/docs-md/app-quickstart.md` · Manifest `/docs-md/app-manifest.md` · Data `/docs-md/app-data.md` · Tools
`/docs-md/app-tools.md` · UI `/docs-md/app-ui.md` · Automation `/docs-md/app-automation.md` · Agents
`/docs-md/app-agents.md` · Flow agents `/docs-md/flow-agents.md` · Backend/API `/docs-md/app-backend.md` · Scopes
`/docs-md/app-scopes.md` · Publishing `/docs-md/app-publishing.md` · Limits `/docs-md/app-limits.md` · Worked
example `/docs-md/app-examples.md`. Human docs: `/docs/app-platform`.
