Bundled agents & knowledge
Bundled agents & teams
The headline feature of the Telenow App Platform is this: your app can ship ready-made voice agents. When an org installs your app and clicks Create agent, the platform builds a real, working voice agent — already wired to your app's tools and knowledge bases — in one click. No prompt-writing, no tool-binding, no copy-paste. The org just installs and dials.
You declare these in three manifest arrays:
| Array | What it ships | Cap |
|---|---|---|
agents[] | Ready-made agents — single or multi-step flow agents. | ≤ 50 per app |
agentTeams[] | Whole multi-agent teams with hand-offs already wired. | ≤ 20 per app; each team 1–10 members |
knowledgeBases[] | Bundled knowledge that auto-attaches to the app's agents (RAG). | ≤ 20 per app; ≤ 100 docs each |
Exceeding any of these caps fails the manifest upload. Separately, there is a runtime cap of 100 provisioned agents per (org, app) — see Caps & limits. Every example on this page is a real snippet from the clinic-crm ("Doctor CRM") example app at sdk/examples/doctor-crm/telenow.app.json.
Shipping an agent — agents[]
An entry in agents[] is an agent template: a stable id plus the agent's spec. The agent-spec fields are flattened as top-level keys — you write them as natural keys right next to id, not nested under some spec object.
{
"id": "front-desk",
"name": "Clinic Front Desk",
"description": "A friendly receptionist that finds patients, books and cancels appointments, and logs visits — using the clinic's tools.",
"systemPrompt": "You are the front-desk receptionist for a busy clinic. Greet callers warmly, identify the patient, and help them book, reschedule or cancel appointments. Always confirm the date and time back to the caller. Use the clinic tools to look up and update records.",
"llmModel": "gpt-4o-mini",
"ttsVoice": "rachel",
"sessionConfig": {
"opener": "Thank you for calling the clinic! How can I help you today?",
"recordingEnabled": true
}
}
Template fields
These are the only keys build_agent_data_from_app_spec reads off a template. Anything else (including llm_config, stt_config, tts_config, tags) is ignored — those are forced to None server-side, so don't bother setting them.
| Field | Type | Default | Notes |
|---|---|---|---|
id | string | — | Required. Stable, unique within the app. Key-safe ([A-Za-z0-9._-]). |
name | string | Untitled agent | Display name. A blank name falls back to Untitled agent (the app-key API) or "<app name> agent" (dashboard create). |
description | string | — | One-line summary for the "Create agent" picker. |
systemPrompt | string | — | The agent's instructions (its personality + rules). |
llmProvider | string | openai | The brain's provider. |
llmModel | string | gpt-4o-mini | The brain's model. |
sttProvider | string | deepgram | Speech-to-text (transcription) provider. |
ttsProvider | string | elevenlabs | Text-to-speech provider. |
ttsVoice | string | rachel | Text-to-speech voice. |
sessionConfig | object | {} | Call-session behaviour: opener (the greeting), recordingEnabled, and other behaviour flags. |
telephonyConfig | object | — | Optional telephony settings object. Secret-shaped keys are stripped (see sanitization). |
metadata | object | {} | Put a { flow: { ... } } graph here for a multi-context / flow agent (see below). |
You don't declare tools on a template. The app's own tools are merged in automatically when the agent is built (auto-bind) — and any tool definitions you do embed in a template are stripped on the way in, for safety. See Agent tools → auto-bind and Scopes & security.
Untrusted-spec sanitization
A manifest is untrusted data. Before a template becomes a real agent, build_agent_data_from_app_spec runs three hardening passes so a shipped template can't smuggle secrets, exfiltrating tools, arbitrary code, or toll-fraud transfers:
strip_sensitive_in_placeruns onmetadata,sessionConfig, andtelephonyConfig— any secret-shaped keys are removed. You cannot ship secrets in a template; use per-install secret settings or stored connections instead.strip_embedded_toolsremoves top-leveltools,precallLookups, andflowDraft, and each flow node'stoolsandprecallLookups. (The app's real tools are merged at call time from the binding — embedded ones are both redundant and an injection surface.)sanitize_template_flow_nodesdefuses the always-runs deterministic flow nodes:- a
toolnode survives only ifconfig.kind == "app"andconfig.config.app_idequals this app's id exactly — otherwise itsconfigis dropped (and the node errors safely at runtime). This is the supported "app tool as a flow node" capability. - a
codenode loses itsconfigentirely. - a
transfernode has its destination number(s) blanked:config.number→"", everyconfig.destinations[].number→"", anddestinations[].numbersremoved. - the node kind is read from
kindOR the fallbacktype(defaulting toconversation), so a hostile manifest can't dodge defusing by renaming the key. - On export (no install context)
allow_app = None, so no tool nodes survive at all.
- a
Single vs. flow agents
The platform decides the agent kind from the spec (spec_kind):
single— a normal one-prompt agent. Thefront-desktemplate above is single.flow— a multi-context flow agent (a graph of steps) — chosen whenmetadata.flowcarries more than one node, OR any edge.
You don't set the kind; it's inferred. A flow with a single node and no edges still counts as single.
Multi-context flow agents in a template
A flow agent walks a graph of nodes connected by edges, moving the caller through distinct steps. The graph lives under metadata.flow and uses this envelope:
{
"schema": 1, // MUST be the NUMBER 1 — "v1" fails to deserialize and the ENTIRE graph is discarded
"startNodeId": "lookup", // the node the call starts on
"routerModel": "gpt-4o-mini", // optional model for inline edge classification
"nodes": [ /* ... */ ],
"edges": [ /* ... */ ]
}
Pre-publish preflight now runs your flow through the real
FlowGraphdeserializer andvalidate()at upload, and reports what it finds on the upload/publish response. This exists because it was previously unchecked, and every shipped app in this repo carried"schema": "v1"— a string where the runtime wants a number. serde rejected it, the whole graph was discarded, and the agent silently ran single-context: no error at upload, none at runtime, just an agent that ignored every step you authored. Read the preflight output on upload, and still test your flow agents (see the eval harness).
The runtime FlowGraph deserializes schema as a number (u32) and reads each node's kind from this fixed list of 13 node kinds:
conversation, subagent, static, play_audio, tool, code,
router, extract, dtmf, transfer, agent, end, note
For the authoritative node-kind catalog, edge/condition grammar, and routing rules, see Flow agents. The two node shapes you'll use most in a template are:
- conversation node —
{ id, name?, prompt, kind: "conversation" }. It speakspromptand waits for the caller's reply. An optionalentryMessageis spoken the moment the call arrives at the step, before it waits. If that line only hands over rather than asking anything ("Great — let's get you booked."), addcontinueAfterEntry: trueand the model carries straight on from it instead of leaving the caller in silence; leave it off when the line is the question, or the agent will talk over the answer. (Two keys you may see in older examples are ignored by the runtime:expectsInput, andskipResponse— which this page previously described as working. Neither is read anywhere. To speak a line and move straight on without waiting, use akind: "static"step, whoseentryMessageis spoken on arrival before the step auto-advances.) - app tool node —
{ id, name?, kind: "tool", config: { name, kind: "app", config: { app_id, object, handler } } }
A deterministic app-tool lookup before greeting
The smart-front-desk template runs an app tool node first — deterministically looking the caller up in the clinic's patient records — and then greets them by name.
{
"id": "smart-front-desk",
"name": "Smart Front Desk (auto-lookup)",
"description": "A multi-context flow that DETERMINISTICALLY looks the caller up in the clinic's patient records (an app-tool flow node) before greeting them by name.",
"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 warmly by name; otherwise greet them as a new caller. Then ask how you can help.",
"expectsInput": true
}
],
"edges": [{ "id": "e1", "source": "lookup", "target": "greet" }]
}
}
}
Because there are two nodes and an edge, this builds as a flow agent. The tool node survives install hardening only because config.kind is "app" and config.config.app_id is "clinic-crm" — this app's own id. Point it at any other app id (or use a non-app kind) and the node's config is stripped at build time. See Untrusted-spec sanitization.
A multi-step intake flow
The intake-flow template is a guided three-step flow — greet, capture details, then book — wired by edges:
{
"id": "intake-flow",
"name": "Patient Intake (multi-step)",
"description": "A guided multi-context flow: greet the caller, capture their details, then book the appointment.",
"llmModel": "gpt-4o-mini",
"ttsVoice": "rachel",
"sessionConfig": { "opener": "Hi! I'll help you get registered and booked in." },
"metadata": {
"flow": {
"schema": 1,
"startNodeId": "greeting",
"nodes": [
{ "id": "greeting", "kind": "conversation", "prompt": "Greet the caller and ask whether they are a new or returning patient.", "expectsInput": true },
{ "id": "capture", "kind": "conversation", "prompt": "Collect the patient's full name and phone number, then register them if new.", "expectsInput": true },
{ "id": "booking", "kind": "conversation", "prompt": "Offer available slots and book the appointment using the clinic tools. Confirm the date and time.", "expectsInput": true }
],
"edges": [
{ "id": "e1", "source": "greeting", "target": "capture" },
{ "id": "e2", "source": "capture", "target": "booking" }
]
}
}
}
For the full flow-node grammar (node kinds, routing, conditions), see Flow agents.
Shipping a team — agentTeams[]
A team is several agents that hand calls off to each other — a triage agent that routes to a booking specialist, for example. One click builds the whole set, with the hand-offs already pointing at the real agents.
{
"id": "front-desk-team",
"name": "Front Desk Team",
"description": "A triage agent that routes callers, handing off to a booking specialist.",
"entry": "triage",
"members": [
{
"ref": "triage",
"name": "Triage",
"systemPrompt": "You are the clinic's triage agent. Greet the caller, find out what they need, and hand off to the booking specialist when they want an appointment.",
"llmModel": "gpt-4o-mini",
"ttsVoice": "rachel",
"metadata": {
"flow": {
"schema": 1,
"startNodeId": "greet",
"nodes": [
{ "id": "greet", "kind": "conversation", "prompt": "Greet the caller and ask how you can help.", "expectsInput": true },
{
"id": "toBooking",
"kind": "agent",
"config": { "agents": [{ "agentId": "booking", "label": "booking", "mode": "transfer" }] }
}
],
"edges": [{ "id": "e1", "source": "greet", "target": "toBooking" }]
}
}
},
{
"ref": "booking",
"name": "Booking Specialist",
"systemPrompt": "You are the clinic's booking specialist. Find or register the patient and book/cancel the appointment using the clinic tools. Confirm the date and time.",
"llmModel": "gpt-4o-mini",
"ttsVoice": "rachel",
"sessionConfig": { "opener": "I can help you book your appointment." }
}
]
}
How teams are wired
| Field | Notes |
|---|---|
id | Required. Unique within the app, key-safe. |
entry | The member ref the call starts on — here, "triage". Must equal one of the member refs (validated at upload). |
members[] | The agents in the team — 1 to 10, each with a unique, key-safe ref. Each member is a normal flattened agent spec plus a ref. |
The hand-off node
A hand-off is an agent-kind flow node inside a member's metadata.flow, plus the edge that reaches it:
{
"id": "toBooking",
"kind": "agent",
"config": { "agents": [{ "agentId": "booking", "label": "booking", "mode": "transfer" }] }
}
agentId is the sibling member's ref (here "booking") — a friendly placeholder, not a real id. When the org clicks Create team, every member is built and auto-bound to the app, then rewrite_handoff_refs rewrites every config.agents[].agentId (and the legacy config.targetAgentId) from the sibling ref to the real agent id the platform just created.
Only values that match a sibling ref are rewritten — a value that isn't a sibling ref (e.g. a real external agent UUID) is left untouched, so you can also hand off to an existing org agent by its real id.
Building bundled agents
Once your app is installed, there are two ways to turn a template into a live agent.
From the dashboard (one click)
An owner or admin opens the installed app, picks an agent or team template, and clicks Create agent / Create team. The new agent is auto-bound to the app's tools and bundled knowledge. Under the hood the dashboard UI calls the bridge:
import { useAgents } from 'telenow/react';
function CreateButtons() {
const { createFromTemplate, createTeamFromTemplate } = useAgents();
return (
<>
<button onClick={() => createFromTemplate('front-desk')}>
Create Front Desk agent
</button>
<button onClick={() => createTeamFromTemplate('front-desk-team')}>
Create Front Desk team
</button>
</>
);
}
The in-dashboard createFromTemplate / createTeamFromTemplate calls hit POST /:appId/agent-templates/:templateId/create and POST /:appId/agent-teams/:teamId/create. These routes are gated ONLY by an owner/admin role check (require_role) — they do not require the agents:read or agents:write scope. Those scope requirements apply to the app-key REST API below, not the dashboard bridge. See Dashboard UI.
Create agent responds:
{ "success": true, "data": { "agentId": "<uuid>", "kind": "single" } }
kind is "flow" iff metadata.flow has more than one node or any edge, else "single".
Create team responds:
{
"success": true,
"data": {
"teamId": "front-desk-team",
"agents": [
{ "ref": "triage", "agentId": "<uuid>", "kind": "flow" },
{ "ref": "booking", "agentId": "<uuid>", "kind": "single" }
],
"entryAgentId": "<uuid>"
}
}
entryAgentId is the real id of the member named by entry — the agent the call starts on. (Listing teams via GET /:appId/agent-teams returns memberCount plus per-member { ref, name, kind }.)
Every created agent auto-binds to the app and the app's bundled KBs auto-attach (attach_app_kbs), so it has the app's tools and knowledge at call time.
Programmatically (app-key API)
POST /api/app-agents Authorization: Bearer $TELENOW_APP_KEY
Your own backend can provision agents over the app-key REST API — handy for CI, or for spinning up a tailored agent per end-customer. Needs the agents:write scope.
curl -X POST https://api.telenow.ai/api/app-agents \
-H "Authorization: Bearer $TELENOW_APP_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Clinic Front Desk",
"systemPrompt": "You are the receptionist for Acme Clinic...",
"llmProvider": "openai", "llmModel": "gpt-4o-mini",
"sttProvider": "deepgram",
"ttsProvider": "elevenlabs", "ttsVoice": "9BWtsMINqrJLrRacOk9x",
"telephonyConfig": { "firstResponse": "agent", "agentMsg": "Thanks for calling Acme Clinic!" }
}'
Response: { "success": true, "data": { "agentId": "…", "kind": "single" | "flow" } }.
Agents created this way are stamped metadata.createdByApp = <your app id>, auto-bound to the app's tools, and have its bundled KBs attached. list and delete on this route are scoped to that stamp — an app can only manage agents it created.
This is not the same request as POST /api/agents
The body here is the flattened agent spec, the same shape a manifest template uses — not the dashboard create payload. The differences are the most common source of "it created an agent, but not the one I described":
Nothing is required. Every field has a fallback, so a body of {} succeeds and returns an agent. The dashboard API would reject the same body with a 422.
| Field | If you omit it |
|---|---|
name | "Untitled agent" |
llmProvider | openai |
llmModel | gpt-4o-mini |
sttProvider | deepgram |
ttsProvider | elevenlabs |
ttsVoice | 9BWtsMINqrJLrRacOk9x |
Those defaults are silent. If your org meant to run its own stack, an omitted llmProvider does not fail — it quietly bills OpenAI. Send the full provider set every time.
Four fields are ignored if you send them. Not an error, just dropped:
| Field | Why |
|---|---|
s2sConfig | Selecting the realtime engine is builder-only — a spec that could set it could smuggle a BYOK apiKey in and flip the voice engine |
fallbackConfig | Same reasoning: a spec that could name its own rungs could route a tenant's calls onto a provider they never chose, past any curated roster |
telephonyProvider | Not settable from a spec |
tags, isPublic | Forced to null / false. Publishing an agent to the open internet has its own owner/admin-gated route |
Credentials are stripped, not rejected. Every credential-shaped key is removed from metadata, telephonyConfig, sessionConfig, llmConfig, sttConfig and ttsConfig before the agent is written — allow-by-default, deny-known-secrets. A spec cannot smuggle a BYOK apiKey in and bill the org for it. Provider tuning survives: STT language hints, the TTS model variant, LLM temperature. Embedded tools are stripped from metadata too, and flow tool/code/transfer nodes are defused except this app's own tool nodes — see Untrusted-spec sanitization.
sessionConfig.opener — read this if your agent answers and says nothing
opener is the key the authoring guide and every shipped template use for the greeting, and nothing reads it at runtime. The real knobs are telephonyConfig.firstResponse and telephonyConfig.agentMsg. An agent built with only sessionConfig.opener would install, answer, and then stay silent until the caller spoke first — which reads as a broken agent rather than a misplaced key.
Rather than only warn about it, the create path now translates it: if sessionConfig.opener is a non-empty string and telephonyConfig.agentMsg is not already set, it becomes agentMsg, and firstResponse defaults to "agent". That repairs every already-installed app the next time an agent is built from it, with no manifest change and no republish.
An explicit telephonyConfig.agentMsg always wins — the translation only fills a gap. Write telephonyConfig directly in new specs; opener is a compatibility shim, not the supported spelling.
Limits and errors
| Status | When |
|---|---|
403 | The app key lacks the agents:write scope |
413 | The app has hit its agent cap for this org — 100. The status is unintuitive; match on it and read the message, which names the limit |
400 | The app is not installed, or the install has no owner to attribute the agent to (an app key carries no user, so the agent is attributed to whoever installed the app) |
Scopes — agents:read vs agents:write
There are two distinct agent scopes, and the app-key API enforces both the scope on the key/token and the install's consented manifest scopes:
| Path | Scope required |
|---|---|
POST /api/app-agents (create) | agents:write |
DELETE /api/app-agents/:id (delete) | agents:write |
GET /api/app-agents (list) | agents:read |
POST /api/app-agents/:id/eval | agents:read |
GET /api/app-agents/:id/public (read link) | agents:read |
PUT /api/app-agents/:id/public (enable/disable) | agents:write |
GET /api/app-agents/:id/config (read settings) | agents:config:read |
PATCH /api/app-agents/:id/config (write settings) | agents:config:write:<group> — see below |
Both scopes must be declared in your manifest
scopes[]and consented at install. These paths usestatic_install_check = true, which also enforces the install's consented manifest scopes. So if your manifest does not declareagents:write, every create/delete returns403 "app did not declare the agents:write scope"— even if the key would otherwise allow it. List them withGET /api/app-agentsand remove one withDELETE /api/app-agents/:id(each only sees/touches agents this app created — thecreatedByAppstamp). Full auth, signatures and envelopes are in External backends & the app-key API.
Read and improve an existing agent
Creating agents is only half of it. An app whose job is to make an agent better — a prompt optimiser, a QA app that learns from call outcomes, an A/B tester — needs to read the agent the org already built and write a better version back. That is GET/PATCH /api/app-agents/:id/config.
Unlike create/list/delete, these act on any agent your app is bound to, not just agents your app created — the point is improving the org's own agent. Same model as eval and /public.
The read
curl https://api.telenow.ai/api/app-agents/$AGENT_ID/config -H "X-App-Key: $APP_KEY"
{
"agentId": "…", "name": "Reception", "kind": "flow",
"updatedAt": "2026-07-28T09:12:44Z",
"prompt": { "systemPrompt": "You are the receptionist for…" },
"model": { "provider": "openai", "model": "gpt-4o-mini", "temperature": 0.7, "config": {…} },
"voice": { "provider": "elevenlabs", "voice": "rachel", "config": {…} },
"stt": { "provider": "deepgram", "config": {…} },
"behavior": { "bargeIn": true, "silenceSecs": 8, … }, // the session config
"telephony": { "provider": null, "config": { "agentMsg": "Thanks for calling!" } },
"flow": { "startNodeId": "n1", "nodes": [ … ], "edges": [ … ] },
"tools": [ { "name": "book_appointment", "description": "Book a slot", "kind": "app" } ],
"realtimeEngine": null
}
Single-context and flow agents return the same shape — kind tells you which you have and flow is null for a single. So prompt.systemPrompt is the agent's instructions either way, and a prompt optimiser needs no branch to support both.
What you never see: BYOK keys are stripped from every config blob, tools are reduced to name/description/kind (no handler, URL or credentials), and a flow node reports toolCount instead of its tools.
The write
PATCH the same document, sparse — send only what changes:
curl -X PATCH https://api.telenow.ai/api/app-agents/$AGENT_ID/config \
-H "X-App-Key: $APP_KEY" -H 'Content-Type: application/json' \
-d '{
"prompt": { "systemPrompt": "You are the receptionist for Acme Clinic. Confirm the callback number before booking." },
"ifUnmodifiedSince": "2026-07-28T09:12:44Z"
}'
Returns { success: true, data: { updated: ["prompt"], config: { … } } } — the groups that landed plus the fresh config.
A patch is a patch. Every blob is merged onto the stored one, so setting behavior.silenceSecs leaves every other turn-taking knob alone. Send an explicit null to remove a key.
ifUnmodifiedSince is optional but you want it. Echo back the updatedAt you read; if a human edited the agent in the dashboard while your optimiser was thinking, you get 409 instead of silently overwriting them.
Every patch snapshots the pre-edit state into the agent's version history, so anything your app changes is revertible from the dashboard.
Editing a flow agent
flow.nodes is a sparse merge by node id — list only the nodes you touch, and within each only the fields you change:
{ "flow": {
"nodes": [ { "id": "greeting", "prompt": "Greet warmly and ask how you can help." } ],
"addNodes": [ { "id": "callback", "kind": "conversation", "prompt": "Collect a callback number." } ],
"removeNodeIds": ["dead_end"],
"edges": [ … ] // full replace when present
} }
Removals apply first, then merges, then additions — so removing and re-adding the same id reads as a clean replace.
Scopes are per setting group
Write authority is split by what you change, not where it lives:
| Group | Covers |
|---|---|
agents:config:write:prompt | the system prompt, and each flow node's prompt / promptMode / entryMessage |
agents:config:write:model | LLM provider, model, temperature, max tokens (+ per-node override) |
agents:config:write:voice | TTS provider, voice, tuning (+ per-node override) |
agents:config:write:stt | transcription provider and tuning |
agents:config:write:behavior | turn-taking, barge-in, silence, per-node guards |
agents:config:write:flow | graph structure — nodes added/removed, edges, start node |
agents:config:write:telephony | opener, who speaks first, AMD / voicemail |
agents:config:write:analysis | post-call analysis: the per-agent enable flag, plus the custom fields and QA criteria it extracts |
agents:config:write:objectives | stateful context: the per-agent enable flag, the slots the call tries to establish, the actions it must actually run, and the disposition rules it is graded by |
agents:config:write | superset — every group above |
The group is decided by the field, not by where it sits. Editing a flow node's prompt needs …:write:prompt, not …:write:flow — so a prompt optimiser works on flow agents without ever holding structural authority. Every group in a body is authorized before any of it is applied, so a partially-permitted patch fails whole rather than landing half an improvement.
What no scope will let an app write
An app can rewrite what an agent says. It can never rewrite what an agent can do, or where it can send a call. These are refused with a 403 naming the field, at any scope:
toolsandprecallLookups— agent-level and per node. A prompt edit preserves them; it cannot replace them. (This is why the config plane doesn't reuse the create-path sanitizers, which drop tools outright.)- A node's
config— a transfer node's destination number, a tool node's handler, a code node's source. - Creating a
tool,code,transfer,agent,subagentorplay_audionode.addNodesacceptsconversation,static,router,extract,dtmf,endandnote. (play_audioplays org-owned audio into a live call, so it stays with the side-effecting kinds — and itsconfig, which names the track, is unwritable anyway.) s2sConfig(selects the realtime engine and carries BYOK keys),isPublic(use the owner/admin-gated/publicroute),flowDraft, andcreatedByApp.analysis.model— it chooses the LLM that bills the org for every analysed call.
Unknown groups, unknown node fields and node patches with no id are 400s, not silent no-ops — a typo'd promt tells you so rather than reporting success and changing nothing.
From a dashboard page
The same surface is on the UI bridge for apps whose optimiser runs in their own page: agents.getConfig / agents.updateConfig. Same scopes, plus the user must be owner/admin/developer — rewriting an agent from inside an app page can never do what the clicker couldn't do directly in the builder.
Public link & embeddable widget
Turn a voice agent into a shareable public link and a drop-anywhere widget over the app-key API — the same public exposure the dashboard's Publish tab drives. Your app can flip an agent public and read its link back, so end-users can call it from a web page or an embedded snippet with no login.
An agent you just created is not reachable yet. The share page and the widget resolve an agent by its slug, not its id, and a new agent starts private — so a template build alone gives you an agent nothing you hand the user can call. Publishing it is what mints the reachable link. From a dashboard UI page use the bridge equivalents
agents.setPublic/agents.publicLink; they return this same object.
# Enable the public link (idempotent) — returns the link + embed snippet
curl -X PUT https://api.telenow.ai/api/app-agents/$AGENT_ID/public \
-H "Authorization: Bearer $TELENOW_APP_KEY" \
-H "Content-Type: application/json" \
-d '{ "enabled": true }'
# Read the current link + whether it's public (does not change anything)
curl https://api.telenow.ai/api/app-agents/$AGENT_ID/public \
-H "Authorization: Bearer $TELENOW_APP_KEY"
Both return the same envelope:
{
"success": true,
"data": {
"isPublic": true,
"slug": "a1b2c3d4e5f6",
"publicUrl": "https://telenow.ai/p/a1b2c3d4e5f6",
"embedSnippet": "<script src=\"https://api.telenow.ai/widget.js\" data-slug=\"a1b2c3d4e5f6\"></script>"
}
}
publicUrlis the hosted share page — send it to a customer and they can call/chat the agent in the browser.embedSnippetis the one-line tag your customer drops on any site; it injects the floating widget.slugis stable and preserved across toggles, soPUT {"enabled": false}takes the agent private without breaking the link if you re-enable it later. Toggling exposure preserves branding, the access code, lead capture and the embed allow-list — onlyisPublicchanges.
Preconditions
| Rule | Behaviour |
|---|---|
| Agent must belong to the app's org AND be BOUND to this app | otherwise 403 "app is not bound to this agent". Agents created from a template or the app-key API are auto-bound; to publish a dashboard-created agent, connect this app to it first. |
PUT — the app installer must be an org owner/admin | exposing an agent to the internet is a governance decision, enforced on the user who installed the app. If they aren't an owner/admin, PUT is refused — even with agents:write. |
| Scopes | GET needs agents:read; PUT needs agents:write (declared in your manifest and consented at install). |
The link is served to the public through the unauthenticated widget endpoints — a visitor never sees your app key, the org, or the agent prompt. See the widget for how the snippet renders.
Bundled knowledge bases
A knowledge base (KB) is a set of documents the agent can retrieve from during a call — so it answers "What are your opening hours?" from real content instead of guessing. Ship one in knowledgeBases[] and it is created on install, its documents are chunked and embedded, and it auto-attaches to every agent the app builds. Retrieval (RAG) just works at call time — you wire nothing.
{
"id": "clinic-info",
"name": "Clinic Info",
"description": "Clinic hours, services and policies the front-desk agent answers from.",
"documents": [
{
"title": "Clinic hours and services",
"body": "Sunrise Family Clinic is open Monday to Saturday from 9 AM to 7 PM and is closed on Sundays. We provide general medicine, pediatrics, dermatology, and basic lab tests. New patients should arrive 15 minutes early to complete registration. We accept cash, card, and major insurance. Appointment cancellations require at least 4 hours notice. For emergencies outside opening hours, call the on-call doctor listed on our website."
}
]
}
| Field | Notes |
|---|---|
id | Required. Unique within the app, key-safe. |
name / description | Display metadata. |
documents[] | Each is { title, body }. title must be non-empty; body ≤ 512 KB. The body is chunked + embedded on install. |
You can ship up to 20 KBs per app, 100 documents per KB, each document body ≤ 512 KB, with a non-empty title — exceeding any of these fails the upload. The agent that's built from your templates retrieves from them automatically — the orchestrator's RAG path is unchanged, so no extra config or tool is needed.
KBs are manifest-only. Documents are loaded from the manifest at install time. There is no app-key KB REST API — you cannot push or edit KB documents at runtime. To change the knowledge, update the documents in your manifest and publish a new version.
Testing agents with the eval harness
Before you ship, you can score an agent against a suite of simulated call scenarios for CI. POST /api/app-agents/:id/eval runs each scenario as a full dry-run conversation, has an LLM judge grade it, and returns per-scenario pass/fail so your pipeline can gate on it.
Request shape
A scenario's scenario field is required (there is no default — omit it and you get a 422). The full shape:
| Field | Required | Notes |
|---|---|---|
name | optional | Label for the result (defaults to scenario N). |
scenario | required | The caller persona / situation the sim should role-play. |
expect | optional | The pass rubric the LLM judge grades against. When present, the judge is authoritative; when absent, the simulation's built-in critic decides pass/fail. |
max_turns | optional | Per-scenario turn cap. Defaults to 4 if omitted, and is clamped to EVAL_MAX_TURNS = 6. |
A complete request — note scenario is filled in for every entry:
curl -X POST https://api.telenow.ai/api/app-agents/$AGENT_ID/eval \
-H "Authorization: Bearer $TELENOW_APP_KEY" \
-H "Content-Type: application/json" \
-d '{
"scenarios": [
{
"name": "books an appointment",
"scenario": "A returning patient named Sarah Chen calls wanting to book a dermatology appointment for next Tuesday afternoon. She gives her phone number when asked.",
"expect": "The agent identifies the patient, offers a Tuesday afternoon slot, books it, and reads the date and time back to confirm.",
"max_turns": 6
}
]
}'
Response shape
{
"success": true,
"data": {
"results": [
{
"name": "books an appointment",
"passed": true,
"score": 88,
"reasoning": "The agent identified the patient and confirmed the slot.",
"turns": 5,
"errored": false
}
],
"passed": 1,
"total": 1,
"allPassed": true
}
}
scoreis a0–100integer (u8) and is omitted when the run errored or no judge ran (e.g. critic-only with no rubric).errored: truemeans the sim or judge itself failed —passedis thenfalse.- CI should gate on
allPassed(passed == total).
Caps & preconditions
| Rule | Behaviour |
|---|---|
| At least 1 scenario | empty → 400 "at least one scenario is required" |
Max 3 scenarios per request (MAX_EVAL_SCENARIOS) | more → 400 "at most 3 scenarios per eval request" — batch a larger suite across calls |
| Per-scenario turns | defaults to 4, clamped to EVAL_MAX_TURNS = 6 |
| Whole-request budget | 200 s — overrun → 400 "eval timed out — try fewer scenarios or turns" (in-flight sims are cancelled) |
| Agent must belong to the app's org AND be BOUND to this app | otherwise 403 "app is not bound to this agent". Agents created from a template or the app-key API are auto-bound; an arbitrary org agent id will not work. |
| Simulation must be configured | otherwise 400 "agent simulation is not configured on this platform" |
It needs the agents:read scope. See Caps & limits for all platform quotas, and External backends & the app-key API for auth and signatures.
Caps & limits
| Limit | Value | Enforced |
|---|---|---|
Agent templates per app (agents[]) | ≤ 50 | manifest upload |
Agent teams per app (agentTeams[]) | ≤ 20 | manifest upload |
| Members per team | 1–10, unique key-safe refs; entry must equal a member ref | manifest upload |
| Knowledge bases per app | ≤ 20 | manifest upload |
| Documents per KB | ≤ 100; each body ≤ 512 KB; title non-empty | manifest upload |
| Provisioned agents per (org, app) | 100 (MAX_AGENTS_PER_APP) | runtime — counted across dashboard create, app-key API, and whole-team create |
| Eval scenarios per request | ≤ 3; ≥ 1; turns clamped to 6 (default 4) | runtime |
The 100-agent runtime cap is shared across all create paths — a dashboard "Create agent", an app-key POST /api/app-agents, and a whole-team build all count toward it (a team that would push you over the cap is rejected before any member is created). See Caps & limits for the platform-wide quota reference.
Next
- Agent tools — the tools your bundled agents call, and how auto-bind wires them.
- Flow agents — the authoritative node-kind catalog and edge/condition grammar for
metadata.flow. - External backends & the app-key API — the
/api/app-agentsand eval endpoints in full. - Scopes & security —
agents:read/agents:writeand the untrusted-spec hardening model. - Caps & limits — every platform quota in one place.
- Worked example: clinic-crm — the running example end-to-end. </content>