Manifest reference

Manifest reference

Every Telenow app is described by a single file: telenow.app.json. This manifest is the source of truth for your app — it declares the data your app stores, the tools your voice agents can call, the dashboard pages you render, the events you react to, the agents you ship, and the permissions you ask for at install. For a declarative app (the default), the manifest is the whole app — no code runs on a server you own.

This page is the canonical field-by-field reference. Each section below links to a deeper page for full detail.

A minimal valid manifest

Only two fields are required: id and version. The smallest manifest that passes validation is:

{
  "$schema": "./node_modules/telenow/telenow.app.schema.json",
  "id": "my-first-app",
  "version": "1.0.0",
  "name": "My First App"
}

That alone is a valid (if empty) app. You then add objects, tools, ui, agents, and so on to give it capabilities.

$schema for editor autocomplete (core sections only)

The npm install telenow package (the package is telenow, never @telenow/app) ships the JSON Schema for the manifest, and the scaffold (npx telenow app init) wires it up for you:

"$schema": "./node_modules/telenow/telenow.app.schema.json"

With this line, editors like VS Code give you inline autocomplete, type checking, and field descriptions as you type. But the bundled schema covers only the core sections, not every feature:

Schema-backed (autocomplete + inline validation)NOT in the schema (accepted, but no autocomplete)
id, version, name, runtime, category, blurb, icon, base_url, scopes, objects, tools, ui, events, schedules, settings, readme, changelog, screenshotsworkflows, inboundHooks, agents, agentTeams, knowledgeBases
objects[].fields[] covers key, type, index, values, defaultobjects[].fields[].relation, objects[].fields[].computed
objects[] covers type, label, fieldsobjects[].views, objects[].semantic
ui covers entry, styles, pagesui.extensions
tools[].handler covers kind, object, match, path, method, code, codeFile

Because the schema sets additionalProperties: true, the un-schema'd sections are accepted with zero editor autocomplete or inline validation — they look like free-form objects in your editor. That's by design (forward-compatibility), but it means schema-backed autocomplete covers the core sections only; the rest are validated by the server and documented here. Rely on these docs plus telenow validate for the newer sections.

Top-level fields

FieldTypeNotes
idstring (required)Unique app id. Key-safe charset; becomes a storage-key segment.
versionstring (required)App version. Semver recommended (e.g. 1.2.0). The marketplace channel only moves forward.
namestringDisplay name shown in the catalog and sidebar.
runtime"declarative" | "external" | "sandboxed"Absent ⇒ declarative. See App platform for the tiers.
categorystringMarketplace category: crm, productivity, healthcare, ecommerce, finance, support, marketing, telephony, other.
blurbstringOne-line tagline for the catalog card.
iconstringWhitelisted icon name (e.g. box, file, users, calendar). See Icon names.
base_urlstring (uri)Required if any tool handler is http, or if any event/schedule handler is a webhook. Your external service base URL.
scopesstring[]Permissions consented at install. Frozen — adding scopes later requires re-consent.
objectsObjectDef[]Declarative data types.
toolsToolDef[]Agent-callable functions.
uiUiConfigDashboard UI: { entry, styles?, pages[], extensions[] }.
eventsEventDef[]Event subscriptions.
schedulesScheduleDef[]Fixed-interval jobs.
workflowsWorkflowDef[]Durable multi-step automations.
inboundHooksInboundHookDef[]Platform-hosted inbound webhooks.
settingsSettingDef[]Per-install config form.
agentsAppAgentTemplate[]Ready-made agents the app ships.
agentTeamsAppAgentTeam[]Multi-agent teams.
knowledgeBasesAppKnowledgeBase[]Bundled KBs auto-attached to the app's agents.
readmestringBundled from README.md by the CLI — don't set by hand.
changelogstringBundled from CHANGELOG.md by the CLI.
screenshotsScreenshot[]{ file, url?, caption? } marketplace listing images.

id and version rules

Both id and version become storage-key segments, so they must be key-safe:

  • Only the characters [A-Za-z0-9._-] (letters, digits, dot, hyphen, underscore).
  • Must start and end with a letter or digit.
  • No .. (no path traversal).
{ "id": "clinic-crm", "version": "2.1.2" }   // valid
{ "id": "Clinic CRM" }                         // invalid: space
{ "id": "-crm" }                               // invalid: must start alphanumeric

For version, semantic versioning (MAJOR.MINOR.PATCH, e.g. 1.2.0) is recommended. The marketplace channel is forward-only: once you publish a version, a later upload must advance it — you can't re-publish or go backward. See Publishing for the release flow.

Icon names

The icon field accepts only a fixed whitelist of names (the dashboard resolves each to a bundled icon, so app-supplied strings stay inert and the bundle stays small). An unrecognised name silently falls back to a neutral default box icon — it does not error, so a typo just shows the wrong icon. The same whitelist applies to ui.pages[].icon and ui.extensions[].icon.

The 27 valid names:

bellbookboxbriefcase
calendarchartclipboardclock
cardfilehearthome
inboxgridlistmail
mapmessagepackagephone
settingscartstarstethoscope
ticketuserusers

Common mistakes: boxes and sticky-note are not valid names — use box and file (or clipboard) instead. Anything outside the list above silently renders the default box icon.


Sections

Each top-level section is summarised below with a tiny snippet and a pointer to its deep page.

scopes — permissions

The permissions your app requests, consented by the org admin at install time. Scopes are frozen after install — a new version that adds scopes triggers a re-consent prompt.

"scopes": ["objects:patient", "calls:initiate", "whatsapp:send", "user:profile"]

The CLI warns on unrecognised scope shapes (a typo guard). Full list and the security model: Scopes & permissions.

objects — data types

Declarative entity types stored in Telenow's shared object store. Each has a type (unique, key-safe), fields[], optional saved views[], and an opt-in semantic flag for meaning-based search.

"objects": [
  {
    "type": "patient",
    "label": "Patient",
    "fields": [
      { "key": "name", "type": "string", "index": true },
      { "key": "phone", "type": "phone", "index": true },
      { "key": "display", "type": "string", "computed": { "template": "{{name}} ({{phone}})" } }
    ],
    "semantic": true
  }
]

Fields can be plain, a relation (FK to another object), or computed — never a combination.

index: true is platform-wide. Setting index: true on a field creates a publish-time expression index shared across the platform by field name (scoped internally so it stays selective for your app). Only set it on fields you actually query/filter on, or use as a tool match/upsert key — don't index a field "just in case". Adding one to a published app rebuilds that index over the whole store, so treat it as a deliberate release.

default applies on create only. A field default seeds a value the writer omitted when the row is inserted. It is not a filter — an object.query tool over the object doesn't silently add status = <default>, so a record that has moved past its initial state is still findable. See Data & objects.

Declare every field you filter on. A filter key that isn't in fields[] is dropped by the agent's object.query (returning unfiltered rows) and kept as a dead equality by the REST API (returning none). Neither errors.

Full details: Data & objects.

tools — agent functions

Functions your voice agents can call mid-call. Each tool has a name, a description (this guides the model — write it well), JSON Schema parameters, and a handler. Handlers come in four declarative object.* kinds plus http, js, and sandbox.

"tools": [
  {
    "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" } }
  }
]

On the wire each tool is namespaced as {app_id}_{name} so two installed apps never collide, and it auto-binds to the agents the app builds.

handler.map — fill an argument from the caller, not from the model

A lookup keyed on the caller's phone number has a problem: the model has to produce that number, which means transcribing spoken digits. object.query is an exact string match, so one dropped digit returns {"results": []} — with status: success, indistinguishable from "this caller has no record."

map binds the argument to the caller identity the platform already knows:

"handler": {
  "kind": "object.query", "object": "appointment",
  "map": { "phone": "caller_number" }
}

A model-supplied value always wins. The caller ID is the default, not a lock — when the caller says "don't use the number I'm calling from, it's booked under 555-0143", the model passes that number and the map stands aside. Only an absent, null or blank argument gets filled.

For that to happen the model has to be able to omit it, so drop the field from required[] and say so in its description. (That is the right recipe for a value the caller may legitimately override — for a record id you want the stronger form below, which drops the parameter altogether.)

"parameters": { "type": "object", "properties": { "phone": { "type": "string",
  "description": "OMIT to use the number the caller is calling from. Only pass a value when the caller gives a DIFFERENT number." } } }

Sources: caller_number · caller_identifier · caller_channel · session_id. All are set by the session layer and cannot be influenced by the conversation. An unknown source or a target that isn't a declared field is rejected at upload — both would otherwise fail silently at runtime. caller_number is telephony-only; web calls have no number.

Lookups need no extra setup. A mapped value used purely as a lookup key — any object.query filter, or the match field of an object.update/object.delete — works regardless of the agent's "Send caller identity to tools" toggle, because it only ever becomes a WHERE predicate. Storing the caller's number (an object.create field, or an update field that isn't match) still requires that toggle, since it writes caller PII into your app's store. See Agent tools.

An empty filter is never a wildcard. If nothing fills the lookup field, object.query returns an error telling the agent to ask the caller — it does not fall through to "return the newest 50 records", which would hand the agent a stranger's record to read aloud.

handler.map as a PIN — for ids the model must not author

Everything above is the soft binding: a declared, optional parameter that the model may override. That is right for a phone number and wrong for a record id.

For candidate_id, user_id, an order number, or any relation field — values your backend needs exactly right and the model can only guess at — omit the parameter entirely and let map fill it. A map target must be a declared field of the object; it need not be a declared parameter, and match accepts a map target:

{
  "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" }
  }
}

With candidate_id absent from the schema the model cannot supply it, so "the model's value always wins" never applies — the trusted value is the only one there is. The model is left authoring the outcome, which is the only thing it actually learned.

Set caller_identifier when you start the call: the identifier field on initiate-call / init-web-call / the Chat API, or an identifier column on a campaign contact list. Mapping session_id too stamps each row with its call for later reconciliation.

Rule of thumb: the model authors content; the platform authors identity. Anything the model would have to recall rather than hear belongs in map, not in parameters. Full walkthrough, consent behaviour and failure modes: Pin a key the model must never author.

js / sandbox handler code: code vs codeFile

A js/sandbox handler runs your JavaScript in Telenow's hardened runtime (pure compute — no host, data, or network access; it receives the tool args and returns a result object). You supply the code one of two ways:

KeyWhat it is
handler.codeAn inline JS function-body string, written directly in the manifest.
handler.codeFileA path to a bundled JS file, relative to the manifest. telenow build reads that file and inlines its contents into code before upload.
// inline
"handler": { "kind": "sandbox", "code": "return { total: dv.a + dv.b };" }

// from a file (bundled into `code` at build time)
"handler": { "kind": "sandbox", "codeFile": "tools/total.js" }

At the manifest-spec level, only code survives to the server — the backend Handler has a single code field. codeFile is a CLI convenience: it is resolved and bundled into code by telenow build before upload, so the uploaded manifest carries inline code, not a file path.

Full details: Agent tools.

ui — dashboard UI

The React UI your app contributes: an entry bundle, optional styles, sidebar pages[], and extensions[] that render a page into a named platform surface.

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

Extension slots are limited to exactly six: call_detail_panel, dashboard_widget, agent_builder_panel, agents_overview_panel, call_list_panel, softphone_call_panel.

ui must be an object. Write ui as { entry, pages, ... }. A legacy array or any non-object value is accepted without error but silently yields no pages — if your pages don't show up, check that ui is an object.

Full details: Dashboard UI.

events — subscriptions

React to platform events. When on fires, the handler runs — either a declarative rule (upsert into an object) or a webhook to your base_url.

"events": [
  { "on": "call.ended", "handler": { "kind": "rule", "do": "upsert", "object": "lead", "key": "phone",
      "map": { "phone": "caller_number", "notes": "summary" } } }
]

Subscribable topics: call.started, call.ended, call.analyzed, recording.ready, the mid-call call.turn / call.barge_in / call.silence / call.dtmf / call.node_entered, and object.<type>.created (incl. the object.*.created wildcard). One CLI caveat: npx telenow validate only recognises the four coarse topics (call.started, call.ended, call.analyzed, recording.ready) plus object.*.created, so it flags the mid-call topics as "not a recognised topic" even though the server accepts them — for those, lean on the server gate (upload / telenow build). Two validation rules to know:

  • A handler that names an object must reference a declared object (true for both rule and webhook handlers). An undeclared object fails install.
  • A webhook-kind handler requires a top-level base_url — there's nowhere to deliver otherwise, so a manifest with a webhook handler and no base_url fails install. (This is the same base_url requirement as http tools and webhook schedules.)

Live-call scope foot-gun. Subscribing to the mid-call topics (call.turn, call.barge_in, call.silence, call.dtmf, call.node_entered) without the calls:read scope passes validation but silently delivers nothing. These topics stream live transcript content; the scope is enforced at dispatch time, not at install — so add calls:read to scopes whenever you subscribe to them.

Full details: Automation.

schedules — fixed-interval jobs

Run a handler every fixed interval (not a full cron). every is a simple duration string of the form <positive-int><unit>:

UnitMeaningExamples
m or minminutes30m, 15min
h or hrhours1h, 6hr
d or daydays1d, 2day

There are no seconds, weeks, or cron expressions. The minimum interval is 5 minutes (300 seconds) and the maximum is 20 schedules per app. A value below the floor or with a bad unit is rejected at validate — e.g. 2m (under 5 min) and 10s (seconds aren't a unit) both fail.

"schedules": [
  { "key": "nightly-sweep", "every": "24h", "handler": { "kind": "webhook", "path": "/cron/sweep" } }
]

A webhook schedule handler requires a top-level base_url (same rule as webhook events). Full details: Automation.

workflows — durable automations

Persisted, retrying state machines. A trigger.event starts a run that walks steps[] in order; each step retries with backoff and survives restarts. Max 20 workflows per app, max 20 steps each. The v1 trigger is event-only.

"workflows": [
  { "id": "appointment-followup", "trigger": { "event": "object.appointment.created" },
    "steps": [
      { "kind": "delay", "seconds": 1 },
      { "kind": "create-object", "object": "visit",
        "data": { "phone": "{{trigger.data.phone}}", "notes": "Auto follow-up" } }
    ] }
]

The seven step kinds and their required config (a manifest missing these fails install):

Step kindRequired configNotes
create-objectobjectplus data{}
update-objectobjectplus match + matchValue + data
delayseconds (or a duration)
outbound-callboth agentId and toNumberplaces an agent call
send-messageboth channelId and toWhatsApp
httpurlSSRF-guarded
connectorcapabilitycalls a connected integration; the connection resolves per install — don't pin connectionId

Placeholders {{trigger.x}} / {{steps.N.y}} resolve at run time. For the full step config shapes and examples, see Automation.

inboundHooks — platform-hosted webhooks

Let a third party (Meta lead-ads, Stripe, Calendly…) POST into your objects with no backend of your own. The platform verifies, field-maps the body into object, and fires object.<type>.created. Max 20 per app.

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

The verify object configures HMAC signature checking. Its fields and defaults:

FieldRequired?Default
headerrequired— (the header carrying the signature, e.g. x-hub-signature-256)
secretoptionalthe install's signing secret when absent
algooptionalsha256 (also accepts sha1)
prefixoptional'' (a prefix stripped before comparing, e.g. sha256=)
encodingoptionalhex (also accepts base64)

If verify is absent entirely, the receiver URL must instead carry ?token=<install signing_secret> — the unguessable URL plus token is the auth. (The ?token= fallback applies only when verify is absent.) Full details: Automation.

settings — per-install config

A config form the org admin fills in once. Each field has a key, label?, type? (text|textarea|number|boolean|select|secret), and flags like required and secret.

"settings": [
  { "key": "clinic_name", "label": "Clinic name", "type": "text", "required": true },
  { "key": "api_key", "label": "External API key", "type": "secret" }
]

secret values are encrypted at rest and injected server-side only — they never reach the iframe UI, and a secret may not declare a default. Non-secret values are available to your UI via useSettings() and to tools/handlers server-side. See Dashboard UI and External backends.

agents — ready-made agents

The platform headline: ship working voice agents your users build in one click, auto-bound to your tools. Agent-spec fields (systemPrompt, llmModel, ttsVoice, sessionConfig, metadata.flow…) are flattened as top-level keys on each entry.

"agents": [
  { "id": "front-desk", "name": "Clinic Front Desk",
    "systemPrompt": "You are the front-desk receptionist for a busy clinic...",
    "llmModel": "gpt-4o-mini", "ttsVoice": "rachel",
    "sessionConfig": { "opener": "Thank you for calling the clinic! How can I help you today?", "recordingEnabled": true } }
]

The agent is built as flow if its spec carries a flow graph with more than one node or any edge, else single. Full details: Agents & knowledge.

agentTeams — multi-agent teams

A whole multi-agent team built with one click, handoffs pre-wired. Each team has an entry (the member ref the call starts on) and members[]; a member's flow agent nodes reference a sibling's ref.

"agentTeams": [
  { "id": "front-desk-team", "entry": "triage",
    "members": [
      { "ref": "triage", "name": "Triage", "systemPrompt": "...hand off to booking when they want an appointment." },
      { "ref": "booking", "name": "Booking Specialist", "systemPrompt": "...book/cancel using the clinic tools." }
    ] }
]

Full details: Agents & knowledge.

knowledgeBases — bundled KBs

Knowledge bases your app ships. On install each becomes a real, embedded KB (owned by the app) and auto-attaches to every agent the app builds, so the agent retrieves from it at call time. Max 20 per app. Documents are { title, body }.

"knowledgeBases": [
  { "id": "clinic-info", "name": "Clinic Info",
    "documents": [
      { "title": "Clinic hours and services", "body": "Sunrise Family Clinic is open Monday to Saturday from 9 AM to 7 PM..." }
    ] }
]

KBs are manifest-only — documents load from the manifest at install. Full details: Agents & knowledge.

screenshots, readme, changelog — listing

Marketplace listing content. readme and changelog are bundled by the CLI from your README.md and CHANGELOG.md — you normally don't set them by hand. screenshots[] are { file, url?, caption? }:

"screenshots": [
  { "file": "screenshots/patients.png", "caption": "Patient list" }
]

file is a package-relative image path, rewritten to a stored URL at publish. See Publishing.


Validation: local pre-flight vs the authoritative server gate

There are two validators, and they are not identical:

  1. npx telenow validate (and the same step inside telenow build) — a fast local pre-flight that checks a subset of rules. It runs entirely on your machine, so it's instant and catches the most common mistakes early.
  2. The server, at upload (app_manifest.rs::validate) — the authoritative gate. It runs the full set of checks. This is the one that actually decides whether your app installs.

A clean telenow validate does not guarantee a clean upload: the CLI skips several sections, so server-only rejections can still happen after a green local validate.

What the CLI (telenow validate) checks

AreaCLI check
id / versionpresent and key-safe (error); version not semver → warning
runtimeunknown value → warning
objectsobject type present + unique
toolsname present; object.* handler references a declared object; object.update/object.delete name a match field that exists on the object; http handler requires base_url
eventson is a recognised topic (only the four coarse topics + object.*.created — mid-call topics aren't in the CLI's set yet); a handler that names an object references a declared object
uientry required when pages declared; page ids unique; the entry source file exists on disk
scopesunrecognised scope shape → warning

What ONLY the server checks (skipped locally)

The CLI does not validate any of these — they're enforced only at upload:

  • schedules (the every grammar/floor, the 20-schedule cap, webhook base_url)
  • workflows (step kinds, per-step required config, the 20-workflow × 20-step caps)
  • inboundHooks (declared object, non-empty map, the 20-hook cap)
  • settings (key/type validity, select needs options, secrets can't carry a default)
  • agents / agentTeams / knowledgeBases (key-safe unique ids, per-app caps, team member refs)
  • objects[].fields[].relation / computed (relation targets a declared object; a field can't be both)
  • objects[].views and ui.extensions (slot is one of the five known slots; page_id points at a declared page)

So a workflow with a bad step kind, a select setting missing options, a slot typo, or too many schedules will pass telenow validate locally and then be rejected at upload. Treat upload / telenow build as the authoritative gate, and use the local validate as a quick first pass.

Tip: telenow build runs the same subset of CLI checks before bundling, then writes the uploadable .telenow.zip. The server re-runs the full set when you upload that zip.

Unknown fields are tolerated

The manifest parser ignores fields it doesn't recognise (forward-compatibility with newer manifest versions). An unknown key won't fail validation — so $schema, future fields, and your own annotations are all safe to include. Conversely, a misspelled known field is treated as unknown and silently dropped, which is why telenow validate plus $schema autocomplete are your friends.


Limits & caps at a glance

Every numeric cap referenced above (schedules, workflows, inbound hooks, KBs, tool timeouts, row/blob/file quotas, HTTP proxy limits, inbound-hook body size, and more) is collected in one place: Limits & quotas.


See the full example

The clinic-crm ("Doctor CRM") example at sdk/examples/doctor-crm/telenow.app.json exercises almost every section above — four objects (with a computed field, a relation, views, and semantic:true), eight object.* tools, five UI pages plus an extension, three bundled agents, a team, a knowledge base, three workflows, and an HMAC-verified inbound hook. It's the best place to see a real, complete manifest. Walkthrough: Worked example.


Next

  • Data & objects — objects, fields, relations, computed, views, queries.
  • Agent tools — handler kinds, params, x-ui, namespacing, auto-bind.
  • Automation — events, webhooks, inbound hooks, schedules, durable workflows.
  • Limits & quotas — every cap and quota in one table.
  • Publishing — validate, build, upload, and the marketplace.