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:

ArrayWhat it shipsCap
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.

FieldTypeDefaultNotes
idstringRequired. Stable, unique within the app. Key-safe ([A-Za-z0-9._-]).
namestringUntitled agentDisplay name. A blank name falls back to Untitled agent (the app-key API) or "<app name> agent" (dashboard create).
descriptionstringOne-line summary for the "Create agent" picker.
systemPromptstringThe agent's instructions (its personality + rules).
llmProviderstringopenaiThe brain's provider.
llmModelstringgpt-4o-miniThe brain's model.
sttProviderstringdeepgramSpeech-to-text (transcription) provider.
ttsProviderstringelevenlabsText-to-speech provider.
ttsVoicestringrachelText-to-speech voice.
sessionConfigobject{}Call-session behaviour: opener (the greeting), recordingEnabled, and other behaviour flags.
telephonyConfigobjectOptional telephony settings object. Secret-shaped keys are stripped (see sanitization).
metadataobject{}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:

  1. strip_sensitive_in_place runs on metadata, sessionConfig, and telephonyConfig — any secret-shaped keys are removed. You cannot ship secrets in a template; use per-install secret settings or stored connections instead.
  2. strip_embedded_tools removes top-level tools, precallLookups, and flowDraft, and each flow node's tools and precallLookups. (The app's real tools are merged at call time from the binding — embedded ones are both redundant and an injection surface.)
  3. sanitize_template_flow_nodes defuses the always-runs deterministic flow nodes:
    • a tool node survives only if config.kind == "app" and config.config.app_id equals this app's id exactly — otherwise its config is dropped (and the node errors safely at runtime). This is the supported "app tool as a flow node" capability.
    • a code node loses its config entirely.
    • a transfer node has its destination number(s) blanked: config.number"", every config.destinations[].number"", and destinations[].numbers removed.
    • the node kind is read from kind OR the fallback type (defaulting to conversation), 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.

Single vs. flow agents

The platform decides the agent kind from the spec (spec_kind):

  • single — a normal one-prompt agent. The front-desk template above is single.
  • flow — a multi-context flow agent (a graph of steps) — chosen when metadata.flow carries 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 FlowGraph deserializer and validate() 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 speaks prompt and waits for the caller's reply. An optional entryMessage is 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."), add continueAfterEntry: true and 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, and skipResponse — which this page previously described as working. Neither is read anywhere. To speak a line and move straight on without waiting, use a kind: "static" step, whose entryMessage is 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

FieldNotes
idRequired. Unique within the app, key-safe.
entryThe 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.

FieldIf you omit it
name"Untitled agent"
llmProvideropenai
llmModelgpt-4o-mini
sttProviderdeepgram
ttsProviderelevenlabs
ttsVoice9BWtsMINqrJLrRacOk9x

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:

FieldWhy
s2sConfigSelecting the realtime engine is builder-only — a spec that could set it could smuggle a BYOK apiKey in and flip the voice engine
fallbackConfigSame 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
telephonyProviderNot settable from a spec
tags, isPublicForced 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

StatusWhen
403The app key lacks the agents:write scope
413The 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
400The 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:

PathScope 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/evalagents: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 use static_install_check = true, which also enforces the install's consented manifest scopes. So if your manifest does not declare agents:write, every create/delete returns 403 "app did not declare the agents:write scope" — even if the key would otherwise allow it. List them with GET /api/app-agents and remove one with DELETE /api/app-agents/:id (each only sees/touches agents this app created — the createdByApp stamp). 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 shapekind 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:

GroupCovers
agents:config:write:promptthe system prompt, and each flow node's prompt / promptMode / entryMessage
agents:config:write:modelLLM provider, model, temperature, max tokens (+ per-node override)
agents:config:write:voiceTTS provider, voice, tuning (+ per-node override)
agents:config:write:stttranscription provider and tuning
agents:config:write:behaviorturn-taking, barge-in, silence, per-node guards
agents:config:write:flowgraph structure — nodes added/removed, edges, start node
agents:config:write:telephonyopener, who speaks first, AMD / voicemail
agents:config:write:analysispost-call analysis: the per-agent enable flag, plus the custom fields and QA criteria it extracts
agents:config:write:objectivesstateful 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:writesuperset — 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:

  • tools and precallLookups — 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, subagent or play_audio node. addNodes accepts conversation, static, router, extract, dtmf, end and note. (play_audio plays org-owned audio into a live call, so it stays with the side-effecting kinds — and its config, which names the track, is unwritable anyway.)
  • s2sConfig (selects the realtime engine and carries BYOK keys), isPublic (use the owner/admin-gated /public route), flowDraft, and createdByApp.
  • 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.

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>"
  }
}
  • publicUrl is the hosted share page — send it to a customer and they can call/chat the agent in the browser.
  • embedSnippet is the one-line tag your customer drops on any site; it injects the floating widget.
  • slug is stable and preserved across toggles, so PUT {"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 — only isPublic changes.

Preconditions

RuleBehaviour
Agent must belong to the app's org AND be BOUND to this appotherwise 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/adminexposing 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.
ScopesGET 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."
    }
  ]
}
FieldNotes
idRequired. Unique within the app, key-safe.
name / descriptionDisplay 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:

FieldRequiredNotes
nameoptionalLabel for the result (defaults to scenario N).
scenariorequiredThe caller persona / situation the sim should role-play.
expectoptionalThe 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_turnsoptionalPer-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
  }
}
  • score is a 0–100 integer (u8) and is omitted when the run errored or no judge ran (e.g. critic-only with no rubric).
  • errored: true means the sim or judge itself failedpassed is then false.
  • CI should gate on allPassed (passed == total).

Caps & preconditions

RuleBehaviour
At least 1 scenarioempty → 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 turnsdefaults to 4, clamped to EVAL_MAX_TURNS = 6
Whole-request budget200 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 appotherwise 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 configuredotherwise 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

LimitValueEnforced
Agent templates per app (agents[])≤ 50manifest upload
Agent teams per app (agentTeams[])≤ 20manifest upload
Members per team1–10, unique key-safe refs; entry must equal a member refmanifest upload
Knowledge bases per app≤ 20manifest upload
Documents per KB≤ 100; each body ≤ 512 KB; title non-emptymanifest 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

</invoke>