# 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 operator sets `FEATURE_APP_SANDBOX=true`.
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 **5 extension slots** (do not invent others): `call_detail_panel`, `dashboard_widget`,
  `agent_builder_panel`, `agents_overview_panel`, `call_list_panel`. 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:** the published `UiSlot` type lists only
  `call_detail_panel` + `dashboard_widget`; cast the other 3 (`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 JSONB 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 `browser.ts` types mark `orgId` absent and `updatedAt`/`createdBy` optional — but the backend serializer
> (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 JSONB merge** (`data || body`): 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 hub
never back-pressures the write. Treat it as "something changed → re-list", not a durable log. Fires from the DB
write chokepoint, 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 `FEATURE_APP_SANDBOX=true` (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`|`/edit` — **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.barge_in call.silence call.dtmf call.node_entered`.
- **`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 fn).

### 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 (`RBAC_PERMS`):
`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 via the raw db layer 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 None/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` | `rachel` | |
| `sessionConfig` | `{}` | `opener`, `recordingEnabled`, behaviour flags |
| `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`** deserializes as a **number** (u32,
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 12 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), `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` is a PHANTOM field** — the runtime `FlowNode` has no such key; serde ignores it (harmless but
> meaningless). The doctor-crm example and app-agents.md sprinkle it everywhere. The real knob is **`skipResponse`**
> (bool, default false): `false` = a normal turn that waits for the caller; `true` = speak `entryMessage` then
> auto-advance an `always` edge with no user turn.

**Node object (key fields, camelCase):** `id`* (unique, key-safe), `kind` (default `conversation`), `name?`,
`prompt?`, `promptMode?` (`append` default | `replace`), `skipResponse?`, `entryMessage?`, `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:** `ExtractVar = { 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 (i32, 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 with all 173 verified examples: `/docs-md/app-responses.md` (derived from the code
that builds each response, then verified field-by-field). Read it 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_cascade_internal_ChatMessage__and_openai_family_wire": {
    "_source": "orchestration.rs:13729-13735 (cascade voice) and :18170-18176 (text/WhatsApp/public API); sent verbatim by providers/llm/openai.rs:353",
    "role": "tool",
    "content": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}",
    "tool_call_id": "call_9f2bA1",
    "name": "clinic_crm_book_appointment"
  },
  "2_anthropic_bedrock_cascade": {
    "_source": "providers/llm/anthropic.rs:169-181 — consecutive tool turns collapse into ONE user message; `name` is dropped",
    "role": "user",
    "content": [
      {
        "type": "tool_result",
        "tool_use_id": "toolu_01A9f2bA1",
        "content": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}"
      }
    ]
  },
  "3_gemini_cascade": {
    "_source": "providers/llm/gemini.rs:565-570 + tool_content_to_response gemini.rs:485-491 — a JSON object passes through UNWRAPPED; a non-object becomes {\"result\": v}",
    "role": "user",
    "parts": [
      {
        "functionResponse": {
          "name": "clinic_crm_book_appointment",
          "response": {
            "ok": true,
            "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
            "record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
          }
        }
      }
    ]
  },
  "4_s2s_openai_realtime": {
    "_source": "orchestration.rs:7770 -> providers/s2s/openai.rs:273-281 — no role, no name; call_id/output, nested under `item`",
    "type": "conversation.item.create",
    "item": {
      "type": "function_call_output",
      "call_id": "call_9f2bA1",
      "output": "{\"ok\":true,\"id\":\"7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63\",\"record\":{\"patient_name\":\"Asha Rao\",\"caller_number\":\"+919876543210\"}}"
    }
  },
  "5_s2s_gemini_live": {
    "_source": "orchestration.rs:7770 -> providers/s2s/gemini.rs:372-387 — the string is re-parsed and ALWAYS nested under response.result",
    "toolResponse": {
      "functionResponses": [
        {
          "id": "call_9f2bA1",
          "name": "clinic_crm_book_appointment",
          "response": {
            "result": {
              "ok": true,
              "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
              "record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
            }
          }
        }
      ]
    }
  },
  "6_app_facing_event_not_the_model_envelope": {
    "_source": "orchestration.rs:9449-9458 emit_tool_invoked — camelCase, and `result` is an OBJECT not a string",
    "event": "tool.invoked",
    "sessionId": "b41c8e02-5a7d-4f19-9c33-2ad6e0f81b45",
    "agentId": "3f6a1d90-77c4-4e2b-8a51-9db0c4e73f12",
    "name": "clinic_crm_book_appointment",
    "arguments": { "patient_name": "Asha Rao" },
    "result": {
      "ok": true,
      "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63",
      "record": { "patient_name": "Asha Rao", "caller_number": "+919876543210" }
    },
    "status": "success",
    "latencyMs": 214
  },
  "7_app_tool_error_envelope": {
    "_source": "tool.rs:1421-1424 (row cap) — soft error stays ok:false inside a successful dispatch; a dispatcher Err becomes {\"error\": msg} at orchestration.rs:18146/18152",
    "soft": { "ok": false, "error": "app data store is full — cannot create more records" },
    "hard": { "error": "unknown tool: clinic_crm_book_appointment" }
  }
}
```
> There is NO `{success,data}` wrapper anywhere on this surface — that is the REST convention only. The handler's JSON Value is stringified verbatim (`result.to_string()`, compact, no spaces) into the tool message `content`. Two consequences developers trip on: (1) a handler that returns a bare array or `null` reaches the model as the literal string `null` / `[...]`; (2) the platform decides `status

**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` (tool.rs:1384) → manifest `objects[].fields[].default` via `config.field_defaults` (tool.rs:1414-1418, built at app_manifest.rs:1530-1541) → the system-injected `caller_number` (tool.rs:1419-1421), which is written UNCONDITIONALLY when the c

**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 (HTTP-less, `Ok(...)`), so the tool call "succeeds" at the transport level and only the `error` key marks it. The check FAILS CLOSED on a DB error while refreshing the cached count (app_quota.rs:135-138) — so a Postgres blip produces this exact same "store is full" text even when the app is nowhere near 100k rows. The count is cached for 6

**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 AppObjectRow's `org_id`, `app_id`, `object_type`, 

**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) — tool.rs:1477-1498. A genuine LIST/BROWSE tool with no stored-field parameter falls through to the bounded 50-row read instead of refusing. The two variants differ by a suffix appended inside the same `error` string when the manifest declares a n

**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 JSONB merge, not the patch — fields the model never mentioned are still present. Only the NEWEST record matching `data->>match = value` is touched (db/app_objects.rs:200). The `match` field is stripped from the patch (tool.rs:1583) and manifest `handler.set` constants are applied last and always win (tool.rs:1584-1588). Compu

**app handler `object.update` — no match / missing match value**
```jsonc
{
  "no_matching_record (tool.rs:1604)": {
    "ok": false,
    "error": "no matching record found"
  },
  "missing_match_value (tool.rs:1564-1580 -> orchestration.rs:13584) - NOTE: no `ok` key": {
    "error": "missing value for match field `appointment_id` — this tool received [caller_number, notes, phone]. The `match` field must be the name of an ARGUMENT the model fills (or a `handler.map` target), not just a field of the object."
  },
  "success, for contrast (tool.rs:1602)": {
    "ok": true,
    "id": "9f2c1b84-3d5e-4a17-9c60-7b1e2af03d55",
    "record": {
      "appointment_id": "APT-4821",
      "phone": "+14155550111",
      "status": "cancelled",
      "updated_at": "2026-07-29T11:42:07Z"
    }
  }
}
```
> TWO DIFFERENT ENVELOPES for what reads like one failure. "no matching record found" is the soft `{ok:false,error}` envelope. A MISSING/blank match VALUE is an `anyhow` error instead — it never carries `ok`, and the model sees only `{"error": "…"}` (see error_example, transcribed from the `anyhow!` at tool.rs:1574-1579; the `[…]` list is the sorted arg keys that actually arrived). A missing `match`

**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. `record` is the deleted row's data, returned so the agent can read back what it removed. Deletes the NEWEST matching record only, one per call (tool.rs:1623). The missing-match-value error here is the TERSE form — unli

**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 `Value::Null`, and the model receives the literal string `null` — not an error; (2) if the service returns a top-level `error` key, the platform classes the call "error" purely on t

**app handler `http` — failure envelopes**
```jsonc
{
  "error": "app tool failed (404 Not Found): {\"message\":\"no such clinic\"}"
}
```
> All five are `anyhow::bail!` → the flat `{"error": …}` wrap, never `{ok:false}`. Non-2xx interpolates reqwest's StatusCode Display ("404 Not Found") and the response body TRIMMED TO 300 CHARS (`text.chars().take(300)`), so a long vendor error is cut mid-JSON — expect unparseable fragments in that string. The other four, verbatim: `app tool response exceeded 1 MB cap`; `external app tool missing ba

**app handler `sandbox` (gated) — success and failure**
```jsonc
{
  "success — tool.rs:1713, `outcome.value` returned verbatim; keys are whatever the app's JS snippet returned, always a JSON object, NO envelope and NO `ok` field": {
    "total": 1770,
    "currency": "INR",
    "breakdown": { "base": 1500, "tax": 270 }
  },
  "failure — tool.rs:1714-1716, hand-built envelope; `status` is one of error|timeout|oom|invalid|busy (code_runner.rs:56), `error` is a ≤512-char diagnostic": {
    "ok": false,
    "status": "timeout",
    "error": "execution timed out"
  },
  "gated off (the DEFAULT — FEATURE_APP_SANDBOX unset) — tool.rs:1699-1701 bails, and the call site renders the Err as orchestration.rs:7687": {
    "error": "sandbox app tools are disabled (set FEATURE_APP_SANDBOX=true)"
  }
}
```
> On success the JS return value is passed through RAW with no envelope — no `ok`, no wrapper. The snippet MUST return an object: a scalar, array, `undefined`, a function, or a circular value all become the failure envelope with `"error":"snippet must return an object"` (code_runner.rs:186-193). Args arrive in JS as the frozen global `dv` (alias `vars`). Failure envelope keys are `ok`/`status`/`erro

**app tool — unsupported handler kind**
```jsonc
{
  "error": "unsupported app handler 'js' — supported: object.create / object.query / object.update (declarative), http (external), sandbox (gated)"
}
```
> ★ THE MESSAGE IS WRONG AND WILL MISLEAD YOU: it omits `object.delete`, which IS dispatched (tool.rs:1607) and IS in `DECLARATIVE_HANDLER_KINDS` (app_manifest.rs:843-844). Do not use this string as the authority on supported kinds. It fires most often for `js`, because the SDK's shipped JSON Schema offers `js` in editor autocomplete (app_preflight.rs:137-139) while the dispatcher only knows `sandbo

**app tool — pre-dispatch refusals (before any handler runs)**
```jsonc
{
  "error": "app tool 'clinic-crm' is not available to this agent"
}
```
> All flat `{"error": …}` (anyhow bails), no `ok`. Verbatim set: `app tool '<app_id>' is not available to this agent` (agent is not BOUND to that app — the guard against a hand-crafted flow node carrying another app's `config.app_id`); `app tools are not available in this context`; `app tool missing config`; `app tool missing app_id`; `calling agent has no org`; and per-handler `object.<kind> handle

**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 non-DNC-suppressed number of the chosen label). Distinctive failures, verbatim: `transfer tool has no destination configured`; `all transfer destinations are on the do-not-call list`; plus the carrie

**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\" — v

**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 to the post-loop teardown so the goodbye line drains first, so "ending" means armed, not hung up. `navigate.to` is the flow NODE ID, not the label the model passed. Navigate's failure is the terse `{"error":"navigate: unknown target"}` (:25154). Both are terminal natives: the tool loop breaks after them.

**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, they come from JSONB `data`), `row_count` = length; a bare aggregate → `result` is a NUMBER and `row_count` is `null` (e.g. `{"result":143,"row_count":null}`, dataset_query.rs:239)

**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 LLM context (:25512-25522). `at` is `when_display`, which PRESERVES THE CALLER-LOCAL OFFSET the model supplied (not UTC) precisely so the model reads back the right wall-clock time. In a simulation the shape changes to `{"status":"simulated","at":"…","note":"simulati

**native `adjust_volume` / `set_language` / `opt_out` — what the model sees**
```jsonc
{
  "status": "adjusted",
  "volume_percent": 141,
  "at_limit": false,
  "note": "volume changed — repeat your last point and confirm it's better"
}

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

{
  "status": "opted_out",
  "note": "recorded — the caller will not be contacted again. Confirm that plainly, then close politely."
}
```
> These three are DATA-ONLY natives (orchestration.rs:13614-13621): the tool loop continues and the model must speak afterwards — hence the `note` key, which is a behavioural INSTRUCTION to the model, not display text. `volume_percent` is integer milli/10, range 50–200 (100 = normal); `at_limit` is true at 500 or ≥1996 milli and swaps `note` for `already at maximum volume — if the caller still can't

**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 dispatched and get `skipped: an earlier tool in this turn already took over the call`; when the call is already tearing down, a transfer/handoff gets `skipped: the call is already ending`. Both 

**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 flagged `needs_confirmation`, the FIRST call never reaches `invoke_app` at all (orchestration.rs:13567, :13572) — the model gets this instead of the handler's shape. The re-arm is keyed on tool name + a hash of the arguments, so re-calling with different args re-asks. Internal status is `confirmation_pending` (not `success`/`error`), which is what lands in `tool_invocation

**Unknown / unavailable tool name**
```jsonc
{ "error": "unknown tool: book_appointmnt" }
```
> Internal status is `not_found`, not `error`. On s2s there is a SECOND, kinder variant used when the name IS in the graph-wide tool union but not at the current node — the wire carries the union while dispatch is per-node: `{"error":"this tool is not available at this step: 'refund_order' belongs to a different step of this conversation. Use only the tools listed in your current instructions, or na

**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 flattens ONLY top-level String/Number/Bool values into session variables. So `record` (object) and `results` (array) are DROPPED entirely: after an `object.query` node no new variable appears at all, and after `object.create` you g

**`tool.invoked` webhook + `tool.call` debug-trace payload**
```jsonc
{
  "__wire_shape_1__ tool.invoked webhook — orchestration.rs:9449-9458; this object IS the entire HTTP POST body (no {success,data} envelope; delivery metadata is in headers)": {
    "event": "tool.invoked",
    "sessionId": "c0a81f43-6d2e-4f7b-b1a9-8e5c3d4f2a11",
    "agentId": "b41e77c2-9d0a-4f35-8c62-1a7e5f3b90d4",
    "name": "clinic_crm_book_appointment",
    "arguments": { "patient_name": "Asha Rao", "slot": "2026-08-03T10:30:00+05:30" },
    "result": { "ok": true, "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63", "record": { "status": "booked" } },
    "status": "success",
    "latencyMs": 214
  },
  "__wire_shape_2__ tool.call debug-trace NDJSON line — payload orchestration.rs:13692-13701 (cascade) and :7726-7735 (s2s); type/seq/t_ms stamped flat by call_tracer.rs:312-326": {
    "corr": "t3:n=book_slot:g=1",
    "id": "call_9XkQm2ZrT4",
    "name": "clinic_crm_book_appointment",
    "args": { "patient_name": "Asha Rao", "slot": "2026-08-03T10:30:00+05:30" },
    "result": { "ok": true, "id": "7d3f9a1e-2c48-4b6a-9f10-5e8b2d4c7a63", "record": { "status": "booked" } },
    "status": "success",
    "error": null,
    "latency_ms": 214,
    "type": "tool.call",
    "seq": 42,
    "t_ms": 8137
  }
}
```
> The webhook is camelCase (`sessionId`, `agentId`, `latencyMs`) while the `result` it carries keeps the handler's own casing — so a single payload mixes both conventions. `result` is the UNSTRINGIFIED Value here, unlike the model-facing tool message. `status` ∈ success | error | not_found | confirmation_pending. The debug-timeline event is a DIFFERENT shape from the same data: `{"corr","id","name",

## 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` — the agent-IMPROVER plane; 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`.
