# Curb Curb is a React framework for production voice agents. You write the agent as a tree of components, and Curb renders that tree into what the model gets on each turn: its context and its tools. When your state changes, the next request changes with it. A tool your code does not render is not in the request, so the model cannot call it. Curb also runs the rest of the call: speech to text, text to speech, turn-taking and interruptions, a browser preview, and a test runner that plays whole conversations with no microphone. ## How to use the docs The docs have four parts: - **Getting Started**: read in order. It takes you from an empty folder to a deployed insurance agent, one feature at a time. - **Guides**: one task per page, such as handing a call to a person. Read the one you need. - **Concepts**: how Curb works underneath, for when you want the full picture. - **API Reference**: every component, hook, function, provider, command and setting. Press `/` to search, or read any page as Markdown from the link beside it. For a coding agent, [llms.txt](/llms.txt) lists every page, and the same guide ships inside the `curb` package at `node_modules/curb/docs/agent-guide/`, matched to the installed version. ## What you should know first The docs assume you know React and TypeScript. You do not need to know anything about speech: Curb runs that part, and the [Voice](./voice.md) page shows what you can change. You need Node.js 22.12 or newer. A new project runs on KugelAudio with one sign-in. You can use other providers for every part of the pipeline, and you can host the agent yourself. ## The core idea ```tsx export default function ReportClaim() { const [verified, setVerified] = useState(false); if (!verified) return <> Ask for the policy number. ; return <> Ask what happened. ; } ``` Until the policy is verified, `open_claim` does not exist for the model, whatever the caller says. [What the model receives](./what-the-model-receives.md) shows the real requests. ## Next steps - [Installation](./installation.md): create your first agent. - [How Curb works](./how-it-works.md): the renderer and the turn loop. --- # Installation Create an agent and talk to it. You need Node.js 22.12 or newer and a KugelAudio account. ## Create a project ```bash mkdir insurance-agent && cd insurance-agent npm create curb.js . ``` The installer asks which provider runs each part of the pipeline. Keep the defaults, and the whole agent runs on KugelAudio with one sign-in. Create the project in the folder itself rather than in a subfolder: a coding agent started there then loads the project's `.mcp.json`, `CLAUDE.md` and `AGENTS.md`. ## Sign in ```bash npx curb login ``` This opens your browser once. There is no key to copy, and every Curb command in the project then uses your login. For CI or a server, set `KUGELAUDIO_API_KEY` in `.env`; it wins over the login. ## Talk to it ```bash npm run dev ``` The preview opens on `http://127.0.0.1:4000` with Juno, the demo agent of a new project. Type to it, or press **Start call** and talk. Beside the conversation, the Context panel shows what the model has right now ([The preview](./preview.md)). ## Next steps - [Project structure](./project-structure.md): what the installer created. - [Agents and skills](./agents-and-skills.md): replace Juno with your own agent. --- # Project structure A Curb project is a folder of React components. The folder names are the routes. ```text my-agent/ package.json "dev": "curb dev", depends on "curb" tsconfig.json jsx: react-jsx (required) context/ optional markdown, referenced via src/agent/ agent.tsx exports `agent` (defineAgent): the agent level, where every conversation starts: prompts, config, skills index, tools every skill has /skill.tsx one folder per skill, opened when needed; nested folders are sub-skills ``` The voice session (speech, turn-taking, interruptions, the production worker) is the framework's, the same on every route; a project holds no copy of it. Routes come from folder names: `src/agent/claim-procedure/skill.tsx` is `/claim-procedure`. Discovery is automatic: never register skills manually. Every skill has a folder of its own: a `skill.tsx` next to `agent.tsx` is not a skill, and `curb check` says where its content goes (`AGENT_HOME_SKILL_REMOVED`). Agent authoring is imported from the `"curb"` package: ```tsx import { Agent, Skills, Skill, Tool, Instruction, defineAgent, z } from "curb"; ``` Voice settings are `` props and `defineAgent` options; see [Voice sessions](./voice-sessions.md). ## Files Curb writes These stay out of git. The project's `.gitignore` already lists them. | Path | What it holds | |---|---| | `.env` | Your keys and settings. See [Configuration](./configuration.md) | | `.data/` | Recorded conversations, by default `.data/conversations.jsonl` | | `.curb/` | Generated files, and `link.json`, the hosted agent this project deploys to | | `.mcp-auth/` | Tokens of MCP servers you signed in to | `mcp.servers.json` holds the MCP servers you added in the preview. It never holds tokens. ## Next steps - [Agents and skills](./agents-and-skills.md): write the agent itself. --- # Agents and skills An agent is a React tree. `src/agent/agent.tsx` holds what is true for the whole call, and each skill holds one area of work with its own instructions and tools. The model reads a skill only when the call needs it, so a skill it has not read costs nothing and does nothing. A skill is a whole area of work, such as reporting a claim. A single action, such as putting a caller through, is a [tool](./tools.md). ## Your first agent Replace `src/agent/agent.tsx`: ```tsx // src/agent/agent.tsx import { Agent, Skill, Skills, SystemPrompt, defineAgent } from "curb"; import { kugelaudio } from "curb/providers"; function App() { return ( You answer the phone at an insurance company. Keep replies short. Report a claim ); } export const agent = defineAgent({ app: App, firstTurn: { say: () => "Hello, how can I help?" } }); ``` Delete the `about-you` and `how-this-works` folders and `context/about-you.md`, then add the skill. A folder under `src/agent/` is a skill. ```tsx // src/agent/report-claim/skill.tsx import { Instruction } from "curb"; export default function ReportClaim() { return Ask what happened and when.; } ``` The preview reloads when you save. Say "A pipe burst at home" and watch the Context panel: the model opens **Report a claim**, and the instruction joins its context. ## `agent.tsx`: the agent definition `src/agent/agent.tsx` exports two things: the app component and `agent`, a `defineAgent(...)` result. Hosts (preview, WebSocket server, voice bridge) consume `agent`; you never write host code. It is also the **agent level**, where every conversation starts: what the app renders inside `` (the system prompt, the `` list, ``, MCP servers, and any text or state of the agent's own) is in context from the first turn, with no skill loaded. Each skill joins only once the model reads it. ```tsx import { Agent, GlobalTools, Skill, Skills, SystemPrompt, Tool, defineAgent, z } from "curb"; import { azure, kugelaudio } from "curb/providers"; export function MyApp() { return ( You are a support agent. Use read_skill silently to load the topic you need, then answer plainly. Claim procedure ({ ok: true, data: input })} /> ); } export const agent = defineAgent({ app: MyApp, maxLoadedSkills: 3, // LRU window of loaded skills (default 3) maxSteps: 8, // LLM tool-call rounds per turn (default 8) timeZone: "Europe/Berlin", // the date line in every request (default "UTC") maxTranscriptMessages: 60, // transcript cap sent to the LLM (default 60) firstTurn: "agent", // calls open with a greeting; { say } for exact words; "user" (default) waits pipeline, // changes to the LiveKit session: Override the LiveKit pipeline goals: { callback: { tool: "request_callback" } }, // what counts as a successful call experiments: { tone: { variants: { warm: 1, brief: 1 } } }, // A/B variants, read with useVariant("tone") mcpServers: [{ id: "linear", url: "https://mcp.linear.app/mcp", enabled: false }], preview: { suggestedPrompts: ["How do I file a claim?"], chatPlaceholder: "Ask me anything…" }, }); ``` Contract: the app component needs no props and returns ``. Its children are the agent level, and after them `` renders every skill the conversation has opened, so while none is open the model sees the agent level alone. List every top-level skill in ``, with its routing description, so the model can open it. The **system prompt** is authored in the tree with `` (once, at the top of ``): one prompt for every turn. Replies are spoken by TTS, and voice sessions append the framework's spoken-reply rules after your prompt at runtime. They cover: - two or three short sentences, about forty words, unless the caller asks for detail - plain spoken sentences: no markdown, lists, headings, emojis or label-colon telegram style - at most two items of any list: the count, the one or two that matter, the rest offered - a greeting answered in one sentence about what the agent is for, then a question - never the same reply twice word for word - the answer first, details only when asked, at most one question per turn - numbers, dates and IDs in their normal written form, never spelled out phonetically - tools called without words, and one reply once their results are back - `skip_turn` when the caller says "wait" or "hold on", or is thinking out loud - after an interruption, what the caller did not hear worked back in - on a live call, `end_call` with a short goodbye, never to escape a hard question Write your prompt for the agent's job only: do not restate these rules or contradict them. ## `skill.tsx`: one skill ```tsx import { Instruction, Text, Tool, z, type SkillMeta } from "curb"; export const meta: SkillMeta = { title: "Property damage", description: "Describing or assessing damage to buildings and household items.", }; export default function PropertyDamage() { return ( <> Help the user report property damage. Collect contact details and a concise damage description. Ask for the customer name, email address, and what happened to the property. ); } ``` - Default export: the skill component. Named export `meta` (`SkillMeta`): `title` (skill heading; derived from the path when omitted) and `description` (used by links and the skills index; a `` link's own `description` prop wins over it). - **Plain JSX text is context.** `` is optional; it is required only for file-backed content (`` reads from the project's `context/` directory). - `` is behavioral guidance ("do X, then Y"); plain text/`` is factual content. Keep the two separated. - **Inline skill links.** A `` written inside a `` renders as a clickable link in the prose flow (rather than a standalone row) and still registers as a routable link + `available_skill:` entry, so you can weave the skills into a sentence: `reach for property damage when your home is affected…`. The model sees the flattened prose; the link's `description` (or the target's `meta.description`) is what routing reads. ## Skill links and routing `Label` anywhere in a skill or in the `` index becomes a `read_skill` destination. Descriptions are the routing table: phrase them as rules, including negative ones: ``` "Use FIRST whenever the user wants to file or report a new claim of any kind." "NOT for filing a new claim; that always starts with the claim procedure skill." ``` Sub-skills are nested folders (`member-portal/priority-claim/skill.tsx` → `/member-portal/priority-claim`). Every skill must be reachable from the agent's `` list, directly or through the links of a skill reached from it; `curb check` warns about one that is not (`AGENT_UNREACHABLE_SKILL`). ## Next steps - [Tools](./tools.md): give the skill something to do. - [Skill lifecycle](./skill-lifecycle.md): what happens when a skill unloads. --- # Tools A tool is what the model can call. Render a `` in a skill, and it exists while that skill is loaded. Add one to the claim skill: ```tsx // src/agent/report-claim/skill.tsx import { Instruction, Text, Tool, createAgentStore, z } from "curb"; const useClaim = createAgentStore<{ claim?: string; open: (claim: string) => void }>("claim", (set) => ({ open: (claim) => set({ claim }), })); export default function ReportClaim() { const { claim, open } = useClaim(); return ( <> Ask what happened and when, then call open_claim. {claim ? `Claim opened: ${claim}.` : "No claim is open yet."} { open(`${what}, ${when}`); return { ok: true, message: "Opened." }; }} /> ); } ``` Until the model reads `/report-claim`, `open_claim` is not in the request at all ([What the model receives](./what-the-model-receives.md)). The model reads `message` and `data`. A message on `ok: false` is an instruction to the model, so say what to do next. ## Props ```tsx ({ ok: true, data: input })} /> ``` - `schema` is a Zod object, and the model gets its JSON Schema: lists, nested objects, `.nullable()` and `.optional()`, enums, number ranges, string formats and `.regex()` patterns, and every `.describe()` at any depth (before or after `.optional()`). `onSubmit` gets the parsed, typed value, so give a tool the shape it needs instead of a string to split: ```tsx schema={z.object({ room: z.string().describe("The guest's room number."), items: z .array( z.object({ name: z.string().describe("A dish or drink from the menu."), quantity: z.number().int().min(1).describe("How many."), }), ) .min(1) .describe("Everything the guest ordered."), })} ``` A `.default()` field may be left out. A refusal names where a nested problem is (`items.0.quantity: ...`); write validation messages as next-step instructions. A recursive schema or a type JSON cannot carry (`z.date()`, `z.bigint()`) is an authoring error; a date is `z.iso.date()` or `z.iso.datetime()`. - `onSubmit` returns a `ToolResult`: `{ ok: boolean, message?, data? }`. Schema validation failures are generated automatically from the Zod messages; for handler-level failures return `{ ok: false, message }` or throw `ToolInputError`. Rendered `` content becomes new context the model reads on its next step. Any other exception is logged on the server, and the model gets `{ ok: false }` with a generic "internal error" message instead of the raw error, so write `ToolInputError` messages for anything the model can fix. - Tools inside `` exist from the first turn, whichever skills are loaded; tools inside a skill exist only while that skill is loaded. - `tryWith` is optional input the preview's **Try** form opens with, typed from the schema, so trying a tool that needs real demo data takes one click; the model never sees it: ```tsx ``` - **Tool as an object.** The inline form above is best for showcasing a tool where it lives. To keep a tool next to the code that backs it, define it with `defineTool` (fully typed, like `defineAgent`) and pass it via `def`: ```tsx const submitClaim = defineTool({ name: "submit_property_damage_claim", description: "Submit a property damage claim intake.", schema: z.object({ email: z.string().email().describe("Customer email address.") }), success: "Property damage claim intake was accepted.", onSubmit: (input) => ({ ok: true, data: input }), // input is typed from the schema }); // …in the tree: // equivalently: ``` Both compile to the same tool. The object supports every inline variant: `schema` + `onSubmit`, `onSubmit` alone, or `onCall`; you can also type an object by hand with the exported `ToolProps` type instead of `defineTool`. ## Calling your own API from a tool ```tsx // .env.example lists ORDER_API_URL, such as https://shop.example.com/api/ (with the final "/"). async function orderApi(path: string): Promise { const base = process.env.ORDER_API_URL; if (!base) throw new Error("ORDER_API_URL is not set"); return fetch(new URL(path, base), { signal: AbortSignal.timeout(5_000) }); } const UNREACHABLE = "The order system is not answering. Tell the caller and offer to try again in a moment."; { let response: Response; try { response = await orderApi(`orders/${encodeURIComponent(order)}`); } catch (error) { console.error("look_up_order: no answer from the order API", error); return { ok: false, message: UNREACHABLE }; } if (response.status === 404) { throw new ToolInputError(`There is no order ${order}. Ask the caller to read the number again.`); } if (!response.ok) { console.error(`look_up_order: the order API answered ${response.status}`); return { ok: false, message: UNREACHABLE }; } return { ok: true, data: await response.json() }; }} /> ``` - Read the base URL, and any key, from an env var listed in `.env.example`, so whoever runs or deploys the agent knows to set it. - `new URL(path, base)` keeps the base's path only when the base ends with `/` and the path does not start with one: `new URL("orders/7", "https://shop.example.com/api")` is `https://shop.example.com/orders/7`, without `/api`. - Give every request a timeout (`AbortSignal.timeout`): while a tool waits, the caller hears silence. - Turn a failed request into `{ ok: false, message }` the model can say to the caller, and log the real error with `console.error`. An empty `catch {}` hides why calls fail. - Throw `ToolInputError` for input the caller must fix, such as an order number that does not exist. ## Call an HTTPS API with a secret A tool can call any HTTPS API on the internet (see [what a running agent can reach](https://docs.kugelaudio.com/hosted-agents/how-it-runs#what-a-running-agent-can-reach)). Keep the API's credential in a variable on the agent, and read it only inside the tool. This example asks a calendar API for free slots with an app-only credential, one that belongs to your application rather than to a person. 1. Put the credential in `.env`, which `npm run dev` reads: ```bash CALENDAR_CLIENT_ID=... CALENDAR_CLIENT_SECRET=... ``` Send it to the agent with `npx curb secrets push`, or set it on the agent's **Variables** page. 2. Add the names to `curb.requiredRuntimeSecrets` in `package.json`. A version whose required variables are not set on the agent never goes live, so a forgotten secret fails the deploy rather than a call. ```json { "curb": { "requiredRuntimeSecrets": [ "CALENDAR_CLIENT_ID", "CALENDAR_CLIENT_SECRET" ] } } ``` 3. Read the variables in the tool, each time it runs: ```tsx import { defineTool, z } from "curb"; // An app-only token from the calendar's sign-in server (OAuth client credentials). async function calendarToken(): Promise { const clientId = process.env.CALENDAR_CLIENT_ID; const clientSecret = process.env.CALENDAR_CLIENT_SECRET; if (!clientId || !clientSecret) throw new Error("CALENDAR_CLIENT_ID or CALENDAR_CLIENT_SECRET is not set"); const response = await fetch("https://login.example.com/oauth2/token", { method: "POST", body: new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret, }), }); if (!response.ok) throw new Error(`Calendar token request failed with HTTP ${response.status}`); const { access_token } = (await response.json()) as { access_token: string }; return access_token; } export const findFreeSlots = defineTool({ name: "find_free_slots", description: "Find the free appointment slots on one day.", schema: z.object({ date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, "Ask which day, and pass it as YYYY-MM-DD.") .describe("The day, as YYYY-MM-DD."), }), onSubmit: async ({ date }) => { const response = await fetch(`https://calendar.example.com/v1/free-slots?date=${date}`, { headers: { Authorization: `Bearer ${await calendarToken()}` }, }); if (!response.ok) return { ok: false, message: "The calendar is not answering. Offer to call back later." }; const { slots } = (await response.json()) as { slots: string[] }; return { ok: true, data: { slots } }; }, }); ``` Render it in the skill that books appointments with ``. Keep the secret out of everything the model reads: - Never put a secret in the system prompt, an instruction, a skill's text, or a tool's result or message. The model reads all of them, and it may say aloud anything it reads. - Keep it out of error messages and logs: what your agent prints goes to its logs. An error the tool throws reaches the model only as a generic failure. - Read it from `process.env` inside the tool, as above, never when the file loads. A hosted agent may load your files before its variables are set. ## Tools the framework provides Three tools exist without being authored, and you should not write your own versions of them: | Tool | When the model has it | What it does | |---|---|---| | `read_skill` | whenever the current context has skill links | loads a skill's instructions and tools | | `skip_turn` | always | ends the turn with no reply at all ("hold on", the user thinking out loud) | | `end_call` | only during a live voice call | says its `goodbye` to the caller and hangs up | `end_call` is the one to know about when you write a voice agent's prompts. Its one input, `goodbye`, is the closing line: the session says it once the call ran, and the transcript records it as the agent's reply. As before any tool call, Curb holds back the newest sentence the model writes in that step and drops it; sentences before that one were already said. An empty `goodbye` is refused like any invalid input, and the call stays up. The call disconnects the moment the goodbye has finished playing, so it is never cut off mid-word. It is absent from typed chat sessions, where there is no call to hang up, and the framework's spoken-style rules teach it only while it is present, including when NOT to use it (never to escape a hard question, never while something is still open). Add domain reasons to end a call in your own prompt if you have them; do not restate the mechanics. ## Next steps - [State and conditional tools](./state.md): make a tool appear only when it should. - [Confirmations](./confirmations.md): have the caller approve an action first. --- # State and conditional tools Curb's main idea is on this page: what the model can do follows your state. Lock a tool behind a check, and the model cannot route around it, because the locked tool is not in its request. ## Local state `useState` works as it does in any React component. A skill's state lasts as long as the skill is loaded ([Skill lifecycle](./skill-lifecycle.md)). This is the claim skill with one switch: ```tsx // src/agent/report-claim/skill.tsx import { useState } from "react"; import { Instruction, Tool, z } from "curb"; export default function ReportClaim() { const [verified, setVerified] = useState(false); if (!verified) return <> Ask for the policy number. { setVerified(true); return { ok: true, message: "The policy is verified." }; }} /> ; return <> Ask what happened and when, then call open_claim. ({ ok: true, message: "Claim opened." })} /> ; } ``` The real requests of this skill are on [What the model receives](./what-the-model-receives.md#when-state-changes-the-tools-change). ## State that lasts the whole call Keep state in a store when other skills need it, or when it must survive a skill unloading. ```tsx // agent.tsx export const useStore = createAgentStore<{ procedureComplete: boolean; completeProcedure: () => void; }>("insurance", (set) => ({ procedureComplete: false, completeProcedure: () => set({ procedureComplete: true }), })); // property-damage/skill.tsx const procedureComplete = useStore((s) => s.procedureComplete); return procedureComplete ? ( ) : ( Claim intake is locked: complete the claim procedure first. The submission unlocks afterwards. ); ``` This is the framework's core invariant pattern: the model **cannot route around** a rule enforced by conditional rendering, because the locked tool is absent from its context. Always pair a lock with text that tells the model how to unlock it (otherwise diagnostics flag the branch as possibly locked). Every action your prompt or a tool result offers the caller needs its tool rendered in that state: a result that offers a callback while no callback tool is rendered makes a promise the model cannot keep. `useAgentInfo((info) => info["caller.phoneNumber"])` reads host-injected session info (e.g. caller ID on the voice bridge), selector-based. `useClient()` reads the `client.*` keys without the prefix (`const { email } = useClient()`). They come from the caller's page and are forgeable: prefill with them, but never lock a branch like the one above on them (see [Knowing the caller](./knowing-the-caller.md#client-is-written-by-the-page)). `useVariant(name)` reads the call's variant of an experiment (`experiment.`; see "Goals and A/B experiments" above). ## Unlock a tool only after a check ```tsx import { Text, Tool, createAgentStore, z } from "curb"; const useAuth = createAgentStore<{ verified: boolean; verify: () => void }>("auth", (set) => ({ verified: false, verify: () => set({ verified: true }), })); export default function Account() { const { verified, verify } = useAuth(); return verified ? ( ({ ok: true, data: { balance: 42 } })} /> ) : ( <> Locked: ask for the PIN and call check_pin. { if (pin !== "1234") return { ok: false, message: "Wrong PIN. Ask once more." }; verify(); return { ok: true }; }} /> ); } ``` `read_balance` is not in the request until the check passes, so the model cannot call it, whatever a caller says. Tell the model how to unlock what is locked. **Unlock after a check** (a verified email, a PIN): there is no gate component. Render it with state, as in [State that lasts the whole call](#state-that-lasts-the-whole-call): a tool that checks and sets a store flag, a locked text that says how to unlock, and the content, tools and `` links once the flag is set. A `` link rendered only then can be discovered and loaded only after the check. ## Next steps - [Instructions and context](./instructions-and-context.md): write what the model reads. - [Knowing the caller](./knowing-the-caller.md): what a call already knows. --- # Instructions and context Three things put words in front of the model: | Component | Use it for | |---|---| | `` | Who the agent is, once, at the top of ``. It stays the same for the whole call | | `` | What to do, such as "Ask for the policy number" | | `` or plain JSX text | Facts the model reads, such as what the policy covers | Keep instructions and facts separate. Long documents go in `context/` as Markdown, and a skill renders one with ``. The file reaches the model in full while its skill is loaded, so split documents by topic and put each in the skill that needs it. ## Write several lines Write a system prompt, instruction or text that runs over several lines as ONE template literal inside the component: ```tsx {` Collect the caller's details before you promise anything. ## Order - name - phone number - what happened `} ``` Plain JSX text cannot carry that structure. The JSX transform joins JSX text onto one line before Curb ever sees it, so paragraphs, `## ` headings and `- ` lists written as JSX text reach the model as one run-on sentence. `curb check` warns about it (`AGENT_PROSE_JSX_TEXT`) and shows the template-literal rewrite. The compiler removes the indentation all lines share and the blank lines at the start and end, and keeps the rest: paragraphs, headings, lists, line breaks and `${...}` values. That holds in ``, `` and ``, inline `` links included. Text that fits on one line, JSX or string, compiles exactly as before. The model gets the system prompt as written. In its context each multi-line instruction or text stays one entry, with its continuation lines indented under it: ```text instruction: Collect the caller's details before you promise anything. ## Order - name - phone number - what happened ``` The preview shows the same text formatted, headings and lists included. Keep a block in one literal: a one-line text written next to it becomes a paragraph of its own. ## Next steps - [Voice](./voice.md): choose how the agent hears and speaks. --- # Voice Speech to text, the LLM and text to speech are props on ``. Curb runs the call around them: turn-taking, interruptions, and speaking the reply sentence by sentence while the model writes. A new project runs all three on KugelAudio, so you can start talking with no setup. The providers on this page, the other settings and their keys are listed in [Providers](./providers.md) and [Configuration](./configuration.md). The LLM and the speech providers are props on ``, built with the factories in `curb/providers`. Each returns a plain descriptor; the server resolves it and reads the key from the environment, so nothing secret is in the tree. | Factory | Options | Key (env) | |---|---|---| | `kugelaudio.llm(...)` | `model`: `"qwen3.8-27b"` (default) or `"mistral-medium-3.5-128b"` | `KUGELAUDIO_API_KEY` | | `anthropic(model)` | | `ANTHROPIC_API_KEY` | | `openai(model)` | | `OPENAI_API_KEY` | | `openaiCompatible({ baseURL, model, ... })` | `reasoningEffort`, `temperature`, `topP`, `body` | `AGENT_LLM_API_KEY`, else `OPENAI_API_KEY`, else none | | `kugelaudio.stt(...)` | `language`, `turnDetection`, `turnDetectionLevel`, `boostedPhrases`, `dictionaryIds`, `projectId` | `KUGELAUDIO_API_KEY` | | `azure.stt(...)` | `language`, `region`, `segmentationMs` | `AZURE_SPEECH_KEY` (`AZURE_SPEECH_REGION` default region, else `eastus`) | | `deepgram.stt(...)` | `model`, `language` | `DEEPGRAM_API_KEY` | | `elevenlabs.stt(...)` / `elevenlabs.tts(...)` | `model` / `voice`, `model` | `ELEVEN_API_KEY` | | `openai.stt(...)` / `openai.tts(...)` | `model`, `language` / `voice`, `model` | `OPENAI_API_KEY` | | `kugelaudio.tts(...)` | `voice`, `model`, `language`, `dictionaryIds`, `projectId` | `KUGELAUDIO_API_KEY` | | `cartesia.tts(...)` | `voice`, `model`, `language` | `CARTESIA_API_KEY` | Without a prop the defaults apply: `kugelaudio.llm()`, `azure.stt()` and `kugelaudio.tts()` (English). A `kugelaudio.tts()` without `voice` speaks the default voice of its `language`: 1072 for English, 1887 for German, and the English 1072 for any other language, so give such an agent a `voice` (`defaultKugelVoice(language)` from `"curb/providers"` returns the default). A chosen provider whose key is missing fails with an error naming the variable. `kugelaudio.llm()` is KugelAudio's LLM, hosted in the EU, on the same `KUGELAUDIO_API_KEY` as its speech, so an agent on KugelAudio needs no other key; a hosted agent gets that key from KugelAudio. Thinking is off on KugelAudio's side, so the agent never speaks its reasoning, and nothing beyond the conversation and the tools is sent. It is served at `KUGELAUDIO_API_BASE` (default `https://api.eu.kugelaudio.com`) plus `/v1`. An organization gets it with hosted agents, in closed alpha; until then its turns answer 403 `LLM_NOT_ENABLED`. To run your own model, see [Your own LLM](./custom-llm.md). ## Speech to text `kugelaudio.stt()` is Kugel Ear 1, KugelAudio's own speech-to-text, on the same `KUGELAUDIO_API_KEY` as `kugelaudio.tts()`, so an agent on KugelAudio speech needs no second vendor key. Set `language` to what your callers speak ("de", or "de,en" to detect among several, expecting the first): without it the service detects German or English, and a short German "Äh" or "Nein" can come out as English. KugelTurn, the end-of-turn model inside the service, decides when the caller has finished (`turnDetectionLevel: "conservative"` holds longer through pauses mid-thought). Names and product terms the recognizer should spell your way go in `boostedPhrases`: ```tsx ``` Each caller turn becomes one transcript: the service's more accurate second pass over the turn when it arrives within two seconds, else its first transcript. Supported languages: de, en, fr, it, es, pt, nl, pl, ro; any other code fails when the agent starts. ## A different voice per skill A `` link can carry the same props. While that skill is the active one (the most recently read), its props override the agent's, slot by slot: ```tsx import { Skill, Skills } from "curb"; import { kugelaudio } from "curb/providers"; export const skills = ( Billing Claims ); ``` Put such props on the link in the always-rendered `` index, so they hold however the skill was reached. Props are reactive: render another value (from state, `useAgentInfo`, a skill change) and a running call switches to it. A speech provider is swapped from the agent's next reply on (the reply being spoken finishes in the old voice); the LLM applies from the next turn. A re-render with an equal value changes nothing. ## Turn-taking Curb decides when the caller has finished. Three settings in `.env` change how it listens: | Variable | Default (ms) | What it does | |---|---|---| | `VOICE_ENDPOINTING_MIN_MS` | 300 | The shortest pause that can end a turn | | `VOICE_ENDPOINTING_MAX_MS` | 3000 | The longest pause before a turn ends | | `VOICE_SILENCE_FOLLOWUP_MS` | 6000 | How long the agent waits in silence before it checks in | ## Next steps - [Testing](./testing.md): check the agent from the terminal. - [Mid-call changes](./mid-call-changes.md): switch voice, language or model during a call. - [Pronunciation](./pronunciation.md): teach it your product's names. --- # Testing Three commands tell you whether an agent works, with no browser, microphone or LiveKit: `curb check` says whether it builds, `curb say` says how it behaves turn by turn, and `curb test` whether whole conversations still work. Run them after every change, in that order. ## Test the agent you built `curb say` plays one caller turn from the terminal and prints the tool calls and what the turn added to the context. A coding agent uses the same commands. ```bash npx curb check npx curb say --new "A pipe burst at home last night" ``` Keep a conversation as a test: ```ts // src/agent/report-claim/report-claim.scenario.ts import { scenario } from "curb/testing"; export default scenario({ name: "opens a claim", turns: ["A pipe burst at home last night"], expect: ({ called }) => [called("open_claim")], }); ``` ```bash npx curb test ``` Each `curb say` and `curb test` turn calls the LLM, so it costs tokens like a real call. ## 1. `curb check`: does it build? ```bash npx curb check ``` It type-checks every file under `src/` with the project's `tsconfig.json`, renders the whole agent with every skill loaded, and reads the source of every skill, including branches that are not rendered yet. Each finding has a stable code, a `file:line` location and a fix ([Diagnostics](./diagnostics.md)). It exits 1 when there is an error. Agent code runs through tsx, which does not check types. Without the check, a misspelled name in a tool's `onSubmit`, or in a branch that only renders after a tool call, fails only when that line runs: in the middle of a call. Fix every error before you talk to the agent; fix the warnings too, they are almost always real. After the findings it prints a Configuration block: every environment variable the agent needs, `set` or `missing` in `.env` or the environment, by name only, never a value. `Providers` are the keys of the `llm`, `stt` and `tts` that `` and its `` links choose, plus the variables they can run without, such as `AZURE_SPEECH_REGION`, marked `(optional)`; `Project code` is what your code under `src/` reads from `process.env`, with the `file:line` of the read, and the names in `package.json` `curb.requiredRuntimeSecrets`. To learn whether a key is set, read this block instead of opening `.env`. A missing value does not fail the check, because a hosted agent gets its values from the platform. A variable your code reads that `.env.example` does not list is an `AGENT_ENV_NOT_DECLARED` warning. ```text Configuration (names only, never values; looked up in .env and the environment): Providers: KUGELAUDIO_API_KEY set llm kugelaudio, stt kugelaudio, tts kugelaudio Project code: ORDERS_API_URL missing src/agent/orders/skill.tsx:11 A missing value does not fail the check: set it in .env to run locally; a hosted agent gets it from the platform. ``` ## 2. `curb say`: does it behave? ```bash npx curb say "Put buy milk on my list" ``` It runs ONE caller turn through the same session a call uses: the same skills, tools and context, and the spoken-reply rules a voice call adds. It calls the LLM on your `curb login` or the key in the project's `.env`, so it costs tokens like a real call; speech keys are not needed. It prints the reply, one line per tool call, any error, and the context lines the turn added: ```text Done, buy milk is on your list. [tool add_task ok] {"title":"buy milk"} [context added] + content: Open tasks: buy milk. + content: Added to the list. [turn 1, no skill read] ``` A refused call shows why, after `->`: ```text [tool complete_task refused] {"title":"bread"} -> No open task titled "bread". Retry with a title from the open task list. ``` A tool with `confirm` is held, not run: the reply is its read-back, and the next `curb say "Yes"` runs it: ```text [tool book_meeting held] {"email":"max@example.com"} -> runs on the caller's yes to "I'll book it for max@example.com, is that right?" ``` The next turn's words are checked as the answer first, and one line says how they were taken, `yes`, `no` or `unclear`, and what became of the action: ```text [confirmation no] "Ja, passt so. Wie lautet meine Schadennummer?": create_claim did not run ``` Only a `yes` runs the tool. A `no` drops the action, an `unclear` answer keeps it waiting for a yes, and the model is told which. The agent can hang up, as on a call: it has `end_call`, and the reply that ends the call, the `goodbye` the agent passed to it, is followed by `[the agent ended the call]`. That conversation is over: the next `curb say` exits 1 until `--new` starts another. ### A conversation is several calls Each `curb say` continues the conversation the previous one left, so a scenario is one `curb say` per caller turn: ```bash npx curb say --new "Hi, I'd like to file a claim" npx curb say "Yes, everyone is safe" npx curb say "I have photos of the damage" ``` `--new` starts a new conversation with its turn; `curb say --new` alone just resets. Between calls the conversation is kept in `.curb/say-session.json`: the transcript, the loaded skills, the page info (`--client`), and the state of every `createAgentStore` store. Component state (`useState`) starts fresh on every `curb say` run, so keep anything a later turn depends on in a store. Tool calls are not run again when a conversation continues: a booking made in turn 1 is not booked twice. After you change the agent's code, start the scenario over with `--new`. A turn that failed (no key, a provider error, a timeout) is not added to the conversation: fix the cause and run the same `curb say` again. A turn that ran a tool before it failed (a booking, a yes the caller gave) is saved anyway, so the tool does not run twice: continue with the next caller line, or start over with `--new`. `curb say` notes which tools ran. A new conversation opens as a call does, before the caller's first words: an agent with `firstTurn: "agent"` greets (a model call), one with `firstTurn: { say }` says its line (no model call). The opening prints first, each of its lines marked `(opening)`: ```text (opening) Ich bin Clara, die digitale Assistentin von Beispiel GmbH. Gern, worum geht es? [turn 1, no skill read] ``` ### Faking a tool A turn that runs a tool runs it for real: a meeting is booked, a claim is filed. To play such a turn without touching the real system, fake the tool with `--fake `. Its `onSubmit` never runs; its calls get a result instead: ```bash npx curb say --new --fake create_claim "Ich möchte einen Hagelschaden melden" npx curb say "Ja, passt so." ``` ```text Ihr Schaden ist gemeldet. [confirmation yes] "Ja, passt so.": create_claim ran [tool create_claim faked] {"policy":"KV-123","damage":"Hagel am Dach"} [turn 2, skill /claims] ``` The result is `{"ok":true,"message":"Done."}` unless you give one as JSON after `=`, for example to see how the agent handles a failure: ```bash npx curb say --fake 'create_claim={"ok":false,"message":"The claims system is down."}' "Ja, passt so." ``` The rest of the call is as without the fake: the tool checks the input as it always does, so input it refuses is still refused, its success or error line follows the result, and a tool with `confirm` still waits for the caller's yes. Fakes are kept with the conversation, like `--client`, so the `--fake` on the turn that starts it covers every later turn, the yes included; a new result for a tool replaces the old one, and `--new` starts without fakes. Repeat `--fake` for several tools. ### Exit code and JSON `curb say` exits 1 when the turn errored: a provider error, a timeout, or the step limit. The step limit (`[error step_limit]`) usually means the model kept calling a tool that refused its input; read the refused lines above it. `--json` prints one object instead, for scripts: ```json { "opening": null, "reply": "Done, buy milk is on your list.", "confirmation": null, "toolCalls": [{ "name": "add_task", "input": { "title": "buy milk" }, "ok": true, "result": { "ok": true } }], "errors": [], "contextDiff": ["content: Open tasks: buy milk.", "content: Added to the list."], "skillPath": "", "turn": 1, "endedCall": false } ``` `skillPath` is the skill the agent read last, and `""` while it has read none: a conversation starts at the agent level, `agent.tsx` alone, and the text output says `no skill read`. `errors` holds `{ "kind", "message" }` with `kind` one of `failed`, `timeout`, `busy` or `step_limit`. `opening` is `{ "reply", "toolCalls", "errors" }` on the turn that opened the conversation of an agent that speaks first, and `null` otherwise. `confirmation` is `{ "tool", "readBack", "answer", "verdict", "ran" }` when the caller's words answered a held action, and `null` otherwise. A tool call held for the caller's yes has `heldFor` (its read-back), a faked one `"faked": true`. `endedCall` is true when the agent hung up in this turn. Notes about the saved conversation go to stderr, so stdout carries only the result. ## 3. `curb test`: do whole conversations work? `curb say` plays one turn at a time; `curb test` plays whole conversations and says pass or fail. A scenario is a file ending in `.scenario.ts` anywhere under `src/`, for example next to the skill it tests: ```ts // src/agent/booking.scenario.ts import { scenario } from "curb/testing"; export default scenario({ name: "books a follow-up", // optional, default: the file name ("booking") caller: { goal: "Book a follow-up call for next week", knows: "Name Max Mustermann, company Example GmbH, email max@example.com", language: "de", // optional, default: the agent's language }, maxTurns: 12, // optional: the most caller turns, 12 by default expect: ({ called }) => [called("book_meeting")], }); ``` A file may hold several scenarios: besides its default export, every `export const = scenario({ ... })` runs too, named by its `name` or else by the export (`forecast`). The caller is one of two kinds: - `caller`: an LLM plays a person with that goal. It answers the agent's last reply with one short spoken line, gives what it `knows` when asked, and hangs up when it is done. Use it for the paths real callers take. - `turns: ["Hallo", "Ja, gern", ...]`: scripted lines, one per caller turn, the same words every run. Use them where the wording matters (a read-back, a refusal) and for edge cases a simulated caller would not try. Run every scenario, or those whose name or file contains a filter: ```bash npx curb test npx curb test booking ``` Each scenario runs on a fresh session, the way a call does: spoken turns with the voice rules, `end_call` available, and the agent's opening first when it speaks first: its greeting (`firstTurn: "agent"`) or its fixed line (`firstTurn: { say }`, no model call), as turn 0. Scenarios run one after another. The report prints the call as it goes, then every check: ```text --- books a follow-up (src/agent/booking.scenario.ts) caller: Hallo, ich möchte einen Folgetermin für nächste Woche vereinbaren. agent: Gern. Auf welchen Namen und an welche E-Mail-Adresse? caller: Max Mustermann, max@example.com. [tool book_meeting ok] {"name":"Max Mustermann","email":"max@example.com","day":"2026-10-27"} agent: Ich habe Sie für Dienstag, den 27. Oktober, eingetragen. Kann ich sonst noch helfen? caller: Nein, danke. Tschüss. agent: Gern geschehen. Auf Wiederhören. [the agent ended the call] pass no turn errors pass no empty agent reply pass no fallback line instead of a reply pass no sentence repeated within a turn pass the conversation ends cleanly: the agent ended the call (end_call) at turn 3 pass the caller reaches its goal: The agent booked the follow-up for Tuesday and confirmed it. pass called book_meeting: turn 2: {"name":"Max Mustermann","email":"max@example.com","day":"2026-10-27"} PASS books a follow-up (3 caller turn(s), 14.2s) 1 of 1 scenario(s) passed in 15.0s. ``` A caller line that answers an action held for the caller's yes is followed by how it was taken, as in `curb say`: ```text caller: Ja, passt so. Wie lautet meine Schadennummer? [confirmation no] "Ja, passt so. Wie lautet meine Schadennummer?": create_claim did not run ``` ### Faking what must not run A tool runs for real in every run of a scenario that calls it: what it books, sends or deletes happens again on each run. Fake such a tool in the scenario: `fakes` maps its name to the result its calls get, or to a function of the call's input that returns one. ```ts export default scenario({ turns: ["Ich möchte einen Hagelschaden melden", "KV-123, das Dach", "Ja, passt so."], fakes: { create_claim: { ok: true, message: "Claim KV-123-7 created." }, send_sms: (input) => ({ ok: true, message: `Sent to ${String(input.phone)}.` }), }, expect: ({ called }) => [called("create_claim", { policy: "KV-123" })], }); ``` A fake replaces only `onSubmit`, and the transcript marks its calls `[tool create_claim faked]`. The rest of the call is as on a real call: the tool checks the input with its schema, rules and transforms included, so input it refuses never reaches the fake and the fake gets what `onSubmit` would get; the tool's success or error line follows the fake's result; a tool with `confirm` runs the fake only on the caller's yes; and `called()` counts the call. What `onSubmit` itself would have changed (a store, a skill it unlocks) does not change. A tool with `onCall` instead of `onSubmit`, or one from an MCP server, has no input check in Curb: its fake stands in for the whole call and gets the input as the model sent it. A fake that no call used is noted under the checks (`note the fake for send_sms was never used: ...`), which fails nothing; it often means a misspelled tool name, or a path the conversation did not take. ### The checks every scenario gets | Check | Fails when | | --- | --- | | no turn errors | a turn hit a provider error or a timeout, used up its steps, or a tool was refused 3 times in it (input the model cannot get right). A call held for the caller's yes is not a refusal. | | no empty agent reply | the agent said nothing, other than on purpose (`skip_turn`) | | no fallback line instead of a reply | the caller heard the framework's fixed apology instead of an answer | | no sentence repeated within a turn | one reply says the same sentence twice | | the conversation ends cleanly | an error stopped it, the simulated caller could not go on, or the agent hung up before the scripted lines were said. Reaching `maxTurns` passes. | | the caller reaches its goal | (simulated caller only) a judge LLM reads the call and decides; its reason is shown either way | The conversation stops at the first turn that reports an error. ### Your own checks `expect` gets the transcript and these helpers, and returns a list of checks: - `called(tool, input?)`: the tool ran, not refused, at least once. With `input`, only a call whose input has those values counts (text compared ignoring case). A value may be a pattern, `{ date: /^2026-10-/ }`, tested against the field's text. A call that returned `ok: false`, a fake's included, counts as refused: to test a failure path, read it from `transcript` with `check`. - `notCalled(tool)`: the tool never ran. - `said(text)` or `said(/pattern/)`: an agent reply contains the text (ignoring case) or matches the pattern. - `endedCall()`: the agent hung up itself. - `check(name, condition, detail?)`: anything else, computed from `transcript`. ```ts expect: ({ called, notCalled, said, check, transcript }) => [ called("book_meeting", { email: "max@example.com" }), notCalled("cancel_meeting"), said(/dienstag/i), check("asks for the email once", transcript.filter((turn) => turn.reply.includes("E-Mail")).length === 1), ], ``` `transcript` holds one entry per turn: `turn`, `caller` (null for the agent's opening), `reply` (what the caller heard), `confirmation` (how the caller's line answered a held action: `tool`, `readBack`, `answer`, `verdict` and `ran`; null when none waited), `toolCalls` (`name`, `input`, `ok`, `result`, `heldFor` when it waits for the caller's yes, and `faked` when a fake answered it), `errors`, `contextDiff`, `skillPath`, `skipped` and `endedCall`. `expect` may be async, for example to read back what a tool stored. A scenario without `expect` has no checks of its own: only the built-in checks run, and its report notes that. ### Exit code and JSON `curb test` exits 1 when a scenario fails, when there is no scenario, or when the filter matches none. `--json` prints one object instead, for scripts: ```json { "passed": true, "scenarios": [ { "name": "books a follow-up", "file": "src/agent/booking.scenario.ts", "mode": "simulated", "passed": true, "endedBy": "agent", "durationMs": 14200, "turns": [ { "turn": 1, "caller": "Hallo, ...", "reply": "Gern. ...", "confirmation": null, "toolCalls": [], "errors": [] } ], "checks": [{ "name": "no turn errors", "pass": true }], "notes": [] } ] } ``` `endedBy` is `agent` (end_call), `caller` (the simulated caller hung up), `script` (the last line was said), `max_turns`, `error` or `simulator` (the simulated caller gave no line). `notes` holds what fails nothing but is worth knowing: a fake that no call used, and a scenario without checks of its own. A scenario file that cannot be loaded is reported as `{ "name", "file", "passed": false, "error" }`. ### What it costs and what it touches - Every turn calls the agent's LLM as a real call does, on your `curb login` or the key in the project's `.env`; speech keys are not needed. A missing LLM key stops the run before the first turn and names the variable. - The simulated caller and the judge run on the agent's own provider, on a small fast model: `claude-haiku-5-5` for Anthropic, `gpt-5.4-mini` for OpenAI, and the agent's own model on an OpenAI-compatible endpoint. - Tools run for real unless the scenario fakes them. Fake every tool that books, sends or deletes something (`fakes`, above), or point it at test data. - A simulated caller can take another path on the next run. Scripted turns repeat exactly: keep a scripted scenario for every path that must never break. - Test conversations are not recorded to the conversation store. ## Page info: `client.*` A page the agent is embedded in can send page info about itself, which the agent reads as `client.` session info (see [Agents and skills](./agents-and-skills.md), "Who speaks first", for what it is and why it is untrusted). Every way of talking to the agent locally can send it, held to the same limits, and it is on the conversation before the agent opens, so a `firstTurn: { say }` function reads it: - `curb say --client key=value` sets `client.` before the turn. Repeat it for several keys. It is kept with the conversation for later turns (a new value for a key replaces the old one), and `--new` starts without it: ```bash npx curb say --new --client event=webinar-11-04 "Hallo" ``` - A scenario's `client` sets it before the call starts, so before turn 0: ```ts export default scenario({ client: { event: "webinar-11-04" }, turns: ["Hallo"], expect: ({ said }) => [said("webinar-11-04")], }); ``` - `curb dev`: put it in the preview's own URL, `http://127.0.0.1:4000/?client.event=webinar-11-04`. Every conversation the page starts carries it, typed or spoken, and it stays in the URL as you navigate the preview. A key outside the limits (a letter, then up to 39 letters, digits or underscores) or a value longer than 256 characters is refused: `curb say` exits 1 with the reason, `scenario()` names it, and the preview shows it above the input and sends the rest. ## Experiment variants: `experiment.*` An agent that declares `defineAgent({ experiments })` gets one variant of each per call, read with `useVariant(name)` (see [Agents and skills](./agents-and-skills.md), "Goals and A/B experiments"). A hosting platform assigns them. Locally they are drawn by the same rule: - `curb dev`: every preview conversation draws its own, from a session id of its own, so a new conversation can get another variant. To try one variant, put the experiment's `fromClient` key in the preview's URL: `http://127.0.0.1:4000/?client.greeting=casual` for `fromClient: "greeting"`. A conversation `curb dev` resumes after a reload keeps the variants it had. - A scenario's `experiments` pins a variant per experiment. Every experiment it leaves out gets a fixed variant, drawn for the session id `scenario::` (the scenario's file under the project and its name), so a scenario gets the same variants on every run and passes or fails the same way; renaming it can change them. Its `client` page info picks through `fromClient` as a page's does: ```ts export default scenario({ experiments: { greeting: "casual" }, turns: ["Hallo"], expect: ({ said }) => [said(/hallo|hi/i)], }); ``` A pin of an experiment or variant the agent does not declare stops `curb test` with the names it does declare. Keep one scenario per variant for every path that must hold in both. - `curb say` assigns none, so `useVariant` returns `undefined` there. ## The loop 1. Edit `src/agent/` (and `context/`). 2. `npx curb check` until it reports no errors. 3. Play the conversation: `npx curb say --new ""`, then one `npx curb say ""` per turn. 4. Read every turn, not just the reply: did the model read the right skill, call the right tool with the right input, and stay out of tools that should still be locked? The `[context added]` lines show what each call changed. 5. Fix, then repeat from step 2, replaying the conversation from `--new`. 6. When it works, keep it as a scenario, and run `npx curb test` so every earlier scenario still passes. When the text behaves, boot `npm run dev` and listen: timing, interruptions and voice are only audible on a real call ([The preview](./preview.md)). ## Next steps - [Deploying](./deploying.md): put the agent live. - [Improve from real calls](./improve-from-calls.md): fix an agent from a call that went wrong. --- # Deploying Curb runs anywhere Node runs. You have two ways to put an agent live: | Where | How | Read | |---|---|---| | KugelAudio | `npx curb deploy`, with no servers of your own | This page | | Your own servers | `curb start` or a built artifact | [Self-hosting](./hosting.md) | Hosting on KugelAudio is in closed alpha and switched on per organization. Phone numbers, GitHub deploys and how a hosted agent runs are in the [KugelAudio docs](https://docs.kugelaudio.com/hosted-agents/quickstart). Four commands take a local project to a live agent on KugelAudio: ```bash npm create curb.js my-agent cd my-agent && npx curb login npx curb secrets push npx curb deploy ``` `deploy` ends by printing the agent's playground URL, where you can call it from the browser. ## `curb login` Opens the KugelAudio sign-in page in your browser and waits up to five minutes for you to confirm. The terminal prints the URL too, so on a machine without a browser you can open it anywhere that can reach `127.0.0.1` on this machine. | Flag | Meaning | |---|---| | `--url ` | Sign in to another KugelAudio app URL (default `https://www.kugelaudio.com`). Remembered for later logins. | | `--no-open` | Print the sign-in URL without opening a browser (also `CURB_NO_OPEN=1`). | The session is stored in `~/.config/curb/credentials.json` (or `$XDG_CONFIG_HOME/curb/`), readable only by you (mode 0600), and refreshed automatically when it expires. `npx curb logout` revokes it and deletes the file. Run in a Curb project, the sign-in page also asks which organization and project to create an API key in ("Curb CLI on ", revocable on the API keys page). The CLI keeps it beside the session in `~/.config/curb/project-keys.json` (mode 0600), never in the project and never printed, and saves that project in `.curb/link.json` when there is no link yet, so the first `curb deploy` deploys there. Every Curb command in the project then runs on your login: there is no key to copy. `npx curb logout` deletes the stored keys too. Prefer an API key (CI, a server)? Set `KUGELAUDIO_API_KEY` in `.env` or the environment; it wins over the login, and `curb login` then creates no key. ## `curb secrets push` Sets every variable in the project's `.env` on the agent's production environment: your provider keys such as `ANTHROPIC_API_KEY` or `AZURE_SPEECH_KEY` when the agent uses `anthropic()` or `azure.stt()`. KugelAudio provides `KUGELAUDIO_API_KEY` itself, so an agent on `kugelaudio.llm()`, `.stt()` and `.tts()` has nothing to push. Values are write-only: the command prints names, never values, and nothing can read them back. New calls use the new values; no redeploy is needed. | Flag | Meaning | |---|---| | `--file ` | Read another file instead of `.env`. | | `--agent ` | Push to this agent instead of the linked one. | Skipped on purpose: `KUGELAUDIO_API_KEY` and `KUGELAUDIO_API_BASE` (the platform provides them), `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` (hosted calls get their own media grant; keep them in `.env` for local `curb dev`), and empty values. ## `curb deploy` Packs the project, uploads it, and makes it the agent's live production deployment: ```text Packed 42 files (180.3 KiB unpacked, 51.2 KiB compressed). Uploaded. Revision rev_... build running build succeeded deployment candidate deployment active Live. Try it in the playground: https://www.kugelaudio.com/dashboard/.../playground ``` | Flag | Meaning | |---|---| | `--agent ` | Deploy to this agent (and link the project to it). | | `--no-activate` | Build and prove the deployment starts, but leave the live one serving. | | `--json` | For a program running the deploy: the progress goes to stderr, and stdout carries one line, the result as a JSON object. A failed deploy prints nothing on stdout and exits non-zero. | With `--json`, stdout reads: ```json {"revisionId":"","deploymentId":"","state":"active","playgroundUrl":""} ``` `state` is `ready` with `--no-activate`. **Which agent.** The first `deploy` or `secrets push` in a project asks which of your agents to use, or creates a new one, and saves the choice in `.curb/link.json`; later runs reuse it. Without a terminal (CI, a coding agent), a first run with exactly one project and no agent creates the agent, named after the directory; any other first run needs `--agent ` and lists the ids. Delete `.curb/link.json` to choose again. **What is uploaded.** The project directory, minus `node_modules/`, `.git/`, `.curb/`, `.data/`, `.mcp-auth/`, a top-level `dist/`, and every `.env` / `.env.*` file. The lockfile is uploaded with the source, so keep it in the project. Limits, checked before anything is uploaded: | Limit | Value | |---|---| | Compressed archive | 50 MiB | | Unpacked | 200 MiB | | Files | 10,000 | | Single file | 50 MiB | | Directory depth | 8 | Symlinks are kept when they point inside the project and refused when they point outside it. **In an environment a host runs for you.** A host that runs your project for you (a hosted workspace) does not sign you in there. It sets `CURB_PLATFORM_TOKEN_FILE` to a file it keeps current with a short-lived token for one agent and one environment: ```json { "token": "", "expiresAt": "2026-10-27T19:00:00Z", "platformApiUrl": "https://agents.example.com", "tenantId": "", "projectId": "", "environmentId": "", "kugelaudioApiKey": "", "kugelaudioApiBase": "https://api.example.com" } ``` `curb deploy` then deploys there with that token, reads the file on every run, and never asks which agent: `.curb/link.json` and `--agent` play no part. `curb login` and `curb secrets push` are refused with the reason; set the agent's secrets in the dashboard. When the file carries `kugelaudioApiKey` (and, optionally, `kugelaudioApiBase`), every Curb command runs the project on that key unless `.env` or the environment sets `KUGELAUDIO_API_KEY`: `curb dev`, `curb test`, `curb say` and the preview reach KugelAudio's models with nothing to set. A running `curb dev` re-reads the file every 30 seconds, so it moves onto each renewed key, and `curb check` lists the key as coming from the workspace. ## Errors you can fix | Message starts with | What to do | |---|---| | `You are not signed in` / `Your KugelAudio session expired` | `npx curb login` | | `Your plan does not include ...` | Upgrade the organisation's plan in the dashboard, then run the command again. | | `Uploading source is not enabled for this organisation` | Push the project to GitHub and connect the repository in the dashboard. | | `Build ... failed` | Open the agent in the dashboard and read the build log. | | `The platform refused this environment's token` / `The platform token ... expired` | Wait a minute: the host renews the token on its own. | Any other refusal is printed with its HTTP status, error code and correlation id; quote the correlation id when you contact support. Set `DEBUG=1` for a stack trace. ## Test a change on a test copy first Keep a second agent as a test copy. Deploy each change to it first, call it, and deploy the same project to your live agent only once it works. Until then your live agent keeps answering its numbers and widgets with the version it has. **With your coding agent:** ask it to create the test copy (`agent_create`), give it the same variables (`agent_set_secrets`), deploy the project to it and give you its Playground link. Once your call works, ask it to deploy the same project to the live agent. **From a terminal:** ```bash cat .curb/link.json # "agentId" is your live agent's id: note it rm .curb/link.json npx curb secrets push # choose "Create a new agent" and name it, for example my-agent-test npx curb deploy # deploys the test copy and ends with its Playground link ``` `curb secrets push` prints the test copy's id when it creates it. Call the test copy in its Playground. When it works, deploy the same project to the live agent: ```bash npx curb deploy --agent ``` `--agent` also links the project to the agent you name, so name one on every `deploy` and `secrets push` from then on: `--agent ` first, then `--agent `. The test copy needs the same variables as the live agent. Its calls are billed like any other (see [pricing](https://kugelaudio.com/pricing)). To call it from your phone, add your phone to its [test line](https://docs.kugelaudio.com/hosted-agents/phone-numbers#call-your-agent-from-your-own-phone). ## Next steps - [Upgrading](./upgrading.md): keep Curb up to date. - [Goals and experiments](./goals-and-experiments.md): measure what works on real calls. --- # Upgrading Update Curb from the project folder: ```bash npm run upgrade ``` This runs `curb upgrade`, which points the project's `curb` dependency at the latest published version and reinstalls. The guide in `node_modules/curb/docs/agent-guide/` updates with it, so a coding agent always reads the docs of the version it builds on. If the install fails, `package.json` is restored and nothing changes. To see whether a newer version exists without changing anything: ```bash npx curb upgrade --check ``` `curb dev` also prints one line when a newer version is published. ## After an upgrade Run `npx curb check`. When a change affects your project, a diagnostic says what to do, with a stable code and a fix ([Diagnostics](./diagnostics.md)). Then run `npx curb test`, and read the [release notes](https://github.com/Kugelaudio/curb.js/releases) for breaking changes. ## Next steps - [Guides](./examples.md): what to build next. --- # Human handoff Put the caller through to a person, and have a way out when nobody answers. Project: `examples/human-handoff`. Put the caller through to a person, and have a way out when nobody answers. ```tsx import { Agent, GlobalTools, SystemPrompt, Tool, TransferCallTool, z } from "curb"; When the caller asks for a person, call transfer_call straight away. When the transfer fails, tell them nobody could be reached and offer a callback. { // Send it where someone reads it: your own API here, or send.toHost (below). return { ok: true, data: input }; }} /> ``` The model passes one sentence as `announce`, which the caller hears before anything is dialed. They then hear a ringback tone. The model reads the outcome in plain words. When the person leaves and the caller is still there, the agent takes the call back. A mailbox that picks up counts as answered, so switch it off on the target number. A transfer needs a host that dials: KugelAudio does on a hosted phone call, with the conditions in [Outbound calls and forwarding](https://docs.kugelaudio.com/hosted-agents/outbound-calls), and your own host can ([Host protocol](./host-protocol.md#transfer-on-your-own-host)). In `curb dev` and on a browser call it is refused. Under `curb say` and `curb test` no host answers, so a real transfer ends as `failed` and your fallback runs: leave the tool unfaked to test that. To test the path where someone picks up, fake it. A fake replaces the tool's handler, so anything the handler would have set stays unset. ## Transfer from your own code On a hosted phone call the agent can put the caller through to another number. The ready tool lets the model do it; `useTransferCall()` is the same thing for your own code. ```tsx import { Instruction, TransferCallTool, Tool, useTransferCall } from "curb"; export function Handover() { const transfer = useTransferCall(); return ( <> When the caller asks for a person, call transfer_call. { const { outcome } = await transfer({ to: "+493098765432", announce: "One moment, I am connecting you to the claims desk.", }); return { ok: outcome === "answered", message: `Transfer: ${outcome}.` }; }} /> ); } ``` - `transfer({ to, ringTimeoutSeconds?, announce? })` resolves `{ outcome, code?, message?, transferId? }` with `outcome` one of `answered`, `no_answer`, `busy`, `failed`, `refused`. It never rejects for an outcome; a bad `to`, a `ringTimeoutSeconds` outside 10 to 60 or a blank `announce` throws `TypeError` (on ``, an authoring error when it renders). - The caller never waits in silence. `announce` is said first, in the agent's voice: it cannot be interrupted, and the number is dialed only once it has played. Without `announce` nothing is said. Then the caller hears a ringback tone (425 Hz, 1 s on, 4 s off) until the outcome arrives, whatever it is, or the call ends. Outside a phone call nothing plays. - `answered`: the target is in the call and the agent is handed off: it stops listening and speaking. When the target leaves and the caller is still there, the agent takes the call back and is told the transfer ended. The caller hanging up ends the call. - `refused`: nothing was dialed, and one error line in the log names the code. `upgrade_required` means the plan has no `outbound-calls` feature; `transfer_unsupported_session` means this is not a phone call (a browser session or `curb dev`), where transfers never work; `transfer_in_progress`, one transfer at a time. - `failed` with code `transfer_no_reply`: the host did not answer the request within 20 s, or sent no result within the ring time plus 30 s. - `` takes `to`, and optionally `name` (default `transfer_call`), `description` and `ringTimeoutSeconds`. The model calls it with `announce`, the one sentence it says to the caller before connecting ("One moment, I am connecting you to our sales team."); a call without one is refused and nobody is dialed, so the model calls again with it. The tool tells the model each outcome in plain words ("nobody answered", "the line was busy") so it can tell the caller. A refused transfer comes back as `outcome: "refused"` with a `code`: | `code` | Why | |---|---| | `upgrade_required` | The organization's plan has no outbound calls | | `transfer_unsupported_session` | Not a phone call (a browser call or the preview) | | `transfer_unsupported_number` | The call's number is from your own Twilio or Telnyx account | | `transfer_in_progress` | Another transfer of this call is still ringing | | `callback_destination_blocked` | The number is outside Germany, Austria and Switzerland, or premium-rate | ## Next steps - [Complex handoffs](./complex-handoffs.md): hand over by code, to a skill, or with a note. --- # Complex handoffs Project: `examples/complex-handoffs`. One agent, several kinds of handoff, each enforced by what is rendered. **Into a skill, by code.** The skill arrives as the result of the tool that opened it: ```tsx const router = useRouter(); { const customer = CUSTOMERS[customerNumber]; if (!customer) return { ok: false, message: "No customer has that number." }; setCustomer(customer); router.push("/contract"); return { ok: true }; }} /> ``` **To a skill with its own voice.** Props on the link apply while that skill is active, and a store carries the customer across: ```tsx Cancellation ``` **To a person, with a note, and a fallback.** Curb has no whisper: the person who picks up hears nothing from the agent, and sees the number of the line that dials them, not the caller's. So send the note ahead of the transfer. A failed transfer renders the callback tool: ```tsx const transfer = useTransferCall(); const send = useSend(); { send.toHost("handoff.note", { customerId: customer?.id, summary }); const { outcome } = await transfer({ to: "+493098765432", announce: "One moment, I am putting you through." }); if (outcome === "answered") return { ok: true }; setTransferFailed(); return { ok: false, message: `The transfer ended as ${outcome}: nobody took the call.` }; }} /> ``` `send.toHost` puts the note on the call's **Data** tab on KugelAudio, and on the events stream of your own host. With no host it is dropped with a warning. Neither reaches a person on a phone: to do that, call your own system from the tool (a ticket, a text message) before the transfer. There is no agent-to-agent handoff between two deployed agents, other than a phone transfer to the other agent's number. ## Next steps - [Human handoff](./human-handoff.md): the transfer itself, and every outcome. - [App messages](./app-messages.md): what `send.toHost` can carry. --- # Confirmations A tool that books, sends, pays or cancels should run only after the caller has said yes to exactly what will happen. Give it `confirm`: the question read back to the caller, built from the validated input, in the agent's language. Only for such actions. To repeat a name or a number back so the caller can correct it, say it in the reply: a `confirm` tool makes the caller's next turn the answer to its question, so a caller who simply goes on with the next detail leaves the tool waiting and unconfirmed. ```tsx `${input.start} for ${input.email}, is that right?`} onSubmit={async (input) => ({ ok: true, message: `Booked for ${input.start}.` })} /> ``` 1. The model calls `book_meeting`, and `onSubmit` does not run. The input is validated as for any call, the caller hears the `confirm` question word for word, and the turn ends there. The model's tool result says that nothing has happened yet. 2. The caller's next words are checked against that question with one short call on the agent's own LLM, which answers yes, no or unclear: - **Yes:** `onSubmit` runs and the model answers from its result. The transcript records the call with the words that confirmed it. - **No,** or a changed detail: nothing runs, and the model is told so. It handles the correction; calling the tool again reads the new details back. - **Unclear,** or the check failed: nothing runs, the question stays open, and the model asks once more. 3. While the question is open, the model's context shows `Waiting for the caller to confirm: "…"`. Calling the tool again replaces the open question; holding another `confirm` tool does too, and the model is told that the replaced action will not run. The model cannot run a `confirm` tool itself: only the caller's yes on a later turn does, in preview chat, local voice, hosted calls and tests alike. - Only words that arrived after the caller heard the question answer it: in chat and tests as soon as it is said, on a call once it played to its end. Input already waiting, and the framework's silence follow-up, do not; a turn the caller cancels before the question is said asks nothing. - On a call, a caller who speaks before the question played to its end has not been asked: their words drop it, and the model is told to call the tool again so the whole question is said. - A yes runs the tool from the skill that asked. If that skill no longer owns the tool's name, nothing runs and the model is told. - A step that asks never hangs up: an `end_call` in the same step is refused, and its goodbye is not said. - Rewinding, or reopening a stored conversation, to just after the question keeps it open. Rewinding anywhere else drops it. - A confirmed action is not replayed when a conversation is reopened or rewound; the preview names it as not replayed. - Limit: when the model writes two or more sentences before the call in the same step, all but the last are said before the question. The tool's description already tells the model not to claim success, and nothing runs without the yes. When you write one: - Name what the caller must check (date, time, email, amount), so that a plain yes means "do exactly this". - The question is said word for word. Build it only from the input the tool sends, never from a free-text field the model fills, such as a summary, so the yes approves exactly what runs. A phone number, IBAN or plate the tool sends belongs in its read-back. - Throw `ToolInputError` in `confirm` to send the model back for better input, for example a slot that cannot be booked. - Do not also ask for the confirmation in your prompt. The model is told that calling the tool asks the caller. - If `onSubmit` fails after the yes (`{ ok: false }` or a thrown error), the model learns it like any failed tool call. - `confirm` works on both `onSubmit` variants (`schema`, or `` children), inline or in `defineTool`. ## Next steps - [Testing](./testing.md): keep the confirmation as a scenario. --- # Step-by-step procedures Some conversations must happen in order. `` gives the model one step at a time, and what you pass as `completed` exists only once every step is done. An ordered flow, whose last tool exists only once every step is done: ```tsx import { Procedure, Step, Tool, z } from "curb"; `${slot} for ${name}, is that right?`} onSubmit={(input) => ({ ok: true, data: input })} /> } > Ask for the caller's name. Offer a weekday between 9 and 17 and agree one. ``` A `` keeps its step in component state, so it starts over if its skill unloads. To keep the step, hold it in a store and pass `currentStep` and `onStepChange`. For answers the agent must remember, give each step its own tool that writes to a store, as in [Unlock a tool only after a check](./state.md). ## A longer example **Procedure**: ordered steps the model must complete in sequence: ```tsx } > … … … ``` ## Next steps - [Confirmations](./confirmations.md): ask for a yes before the last tool runs. - [Components](./components.md#procedure): every prop of ``. --- # Who speaks first | `firstTurn` | How a conversation opens | |---|---| | `"user"` (default) | The agent waits for the caller. | | `"agent"` | A call opens with one short greeting the model writes after the caller joins. The instruction behind it is framework-generated and hidden from the preview transcript. | | `{ say: "..." }` | Every conversation opens with exactly that line, with no model call: a call speaks it, typed chat in the preview shows it, `curb say` and `curb test` start with it. | Use `{ say }` when the opening words must not vary, for example a notice the law requires before an AI assistant talks to someone. A greeting the model writes cannot guarantee wording; this line is said as written: ```tsx export const agent = defineAgent({ app: MyApp, firstTurn: { say: "Ich bin Clara, die digitale Assistentin von Beispiel GmbH. Ich bin eine KI und kann Fehler machen. Wie kann ich helfen?", }, }); ``` The line is the agent's first message in the conversation, so the model knows it has already greeted and answers the caller's first words instead of greeting again. On a call it is spoken with interruptions off, like the `"agent"` greeting: the caller hears all of it, and a message typed meanwhile is answered once it has played. `say` can also be a function of the session info the conversation has when it opens: `caller.*` on a call, and `client.*`, the page info. It gets a read-only record and returns the words. Return words for every session, including one without the key it reads: ```tsx firstTurn: { say: ({ "client.event": event }) => typeof event === "string" ? `Ich bin Clara, die digitale Assistentin von Beispiel GmbH. Schön, dass Sie vom ${event} kommen.` : "Ich bin Clara, die digitale Assistentin von Beispiel GmbH.", }, ``` `client.*` is what the web page the agent is embedded in says about itself (the event it advertises, the campaign it came from). It is untrusted input from that page, like a form field: use it to choose words, never to decide what the caller may see or do. Every route holds it to the same limits: a key is a letter followed by up to 39 letters, digits or underscores, and a value is a string of at most 256 characters. To try it locally, see [Testing](./testing.md) ("Page info: `client.*`"). `defineAgent` refuses a `say` with no words (`AGENT_INVALID_AUTHORING_PROP`). A function that returns none is reported when the conversation opens, and the conversation goes on without the line. A phone call has no page to show a notice on. To tell a caller that the call is recorded, use `firstTurn: { say }` for exact words, or `firstTurn: "agent"` and put the rule in ``, where the model says it in its own words. ## Next steps - [Knowing the caller](./knowing-the-caller.md): what `client.*` holds, and where it comes from. --- # Mid-call changes Project: `examples/pipeline`. Speech-to-text, the LLM and text-to-speech are props, so a change is a render. **By state.** A tool sets a store, and the store sets the providers, so the call moves to German from the next reply on: ```tsx const useLanguage = createAgentStore<{ language: "en" | "de"; setLanguage: (language: "en" | "de") => void }>( "language", (set) => ({ language: "en", setLanguage: (language) => set({ language }) }), ); function App() { const language = useLanguage((state) => state.language); const setLanguage = useLanguage((state) => state.setLanguage); return ( {`Reply in ${language === "de" ? "German" : "English"}.`} { setLanguage("de"); return { ok: true, message: "From your next reply on the call is in German." }; }} /> ); } ``` The STT takes both languages, the likelier one first, so it hears the request to switch. The instruction is in ``, so the system prompt stays the same for the provider's cache. **By skill.** A skill's link overrides the agent's providers while the skill is active. The active skill is the one read last, so another read ends it. A skill's `llm` applies from the caller's next turn, not to the reply in the turn that opened the skill: ```tsx Report ``` **From outside.** Nothing is adjustable unless you list it, per sender: ```tsx ``` The caller's app then calls `agent.update({ tts: { voice: 17 } })` from `curb/client`, and your own host sends `PATCH /v1/sessions/{id}` ([Host protocol](./host-protocol.md#live-session-settings)). A hosted agent has no published route for your server yet: there, change settings from the caller's app or from a tool in the agent. ## Live settings: `adjustable` and `useSession` The caller's app (`curb/client`'s `agent.update`) and the host (`PATCH /v1/sessions/{id}`) may change a few settings mid-call, but only those `` lists for them. Absent, nothing is adjustable. ```tsx import { Agent } from "curb"; import { kugelaudio } from "curb/providers"; export function App() { return ( {/* the prompt, skills and tools */} ); } ``` Paths: `"tts.voice"`, `"tts.model"`, `"stt.language"`, `"llm"`. An accepted change overrides the prop until the next change of the same path; a refused one changes nothing. Read the effective providers (props plus outside changes) in any component: ```tsx import { Text, useSession } from "curb"; export function CurrentVoice() { const { settings } = useSession(); // { llm, stt, tts } descriptors, read-only // e.g. { voice: 17 } after the app called agent.update({ tts: { voice: 17 } }) return {`Speaking with ${settings.tts.provider} ${JSON.stringify(settings.tts.options)}.`}; } ``` The body, the refusal codes and the wire format are in [Host protocol](./host-protocol.md#live-session-settings). On a hosted call, `useSession().call` is that call: its `sessionId` and its own `scratchDir`, a writable directory removed when the call ends. A runner may serve several calls in one process, so read the call's identity here, never from `process.env`, and write files only under `scratchDir`. `scratchDir` is absent in `curb dev` and when the runner could not create one. ```tsx import { Text, useSession } from "curb"; export function CallInfo() { const { call } = useSession(); return {call ? `Call ${call.sessionId}.` : "Not a hosted call."}; } ``` ## Next steps - [Voice](./voice.md): the providers and their options. - [Host protocol](./host-protocol.md#live-session-settings): the wire format of a settings change. --- # Override the LiveKit pipeline Project: `examples/livekit-pipeline`. A Curb call runs on a LiveKit `AgentSession`, and Curb's `voice.Agent` answers its turns ([Architecture](./architecture.md)). You can edit that pipeline or write your own: | You want | Use | Runs on | |---|---|---| | Other turn handling, an event listener, one step changed | `defineAgent({ pipeline })` | Every route: `curb dev`, `curb start`, a built artifact | | A session you build yourself | Your own worker, with `curb/orchestration` | That worker only | Start with the first. Curb's session also does the opening line, the silence check-in, transfers, provider swaps and the call report, and a worker of your own does none of them until you write them. ## Edit the pipeline `pipeline` has three optional parts: ```ts import { pipeline } from "./pipeline"; export const agent = defineAgent({ app: App, pipeline }); ``` | Part | Receives | Use it for | |---|---|---| | `session` | The options Curb would build `voice.AgentSession` with | Returning changed options: turn handling, interruptions, providers | | `agent` | Curb's agent class | Returning a subclass that replaces `sttNode`, `llmNode` or `ttsNode` | | `onSession` | `{ session, roomName, events }`, once per call | Subscribing to session events, before the session starts | Keep `curb/orchestration` to `import type` in anything under `src/agent/`: the agent also loads where LiveKit's native modules cannot, so `onSession` gets the event names as `events`. ### Change the session Endpointing decides when a caller's turn is over. Callers who pause to look something up need longer than the default: ```ts import type { VoicePipeline } from "curb/orchestration"; export const pipeline: VoicePipeline = { session: (defaults) => ({ ...defaults, turnHandling: { ...defaults.turnHandling, endpointing: { minDelay: 800, maxDelay: 6000 }, }, }), }; ``` Spread `defaults` so the rest stays. Leave `llm` as it is: it keeps LiveKit calling `llmNode`, and that is where Curb answers. This applies to voice calls and overrides `VOICE_ENDPOINTING_MIN_MS` and `VOICE_ENDPOINTING_MAX_MS`. ### Replace a step `agent` receives Curb's agent class. Override a LiveKit node, and call `super` for the steps you keep. This one answers a question without the model: ```ts import type { VoicePipeline, llm, voice } from "curb/orchestration"; type LlmOutput = Awaited>; export const pipeline: VoicePipeline = { agent: (Base) => class extends Base { override async llmNode( chatCtx: llm.ChatContext, toolCtx: llm.ToolContext, settings: voice.ModelSettings, ): Promise { const said = this.turnInputFrom(chatCtx); if (said !== null && /opening hours/i.test(said)) { return new ReadableStream({ start(controller) { controller.enqueue("We are open Monday to Friday, from nine to five."); controller.close(); }, }) as LlmOutput; } return super.llmNode(chatCtx, toolCtx, settings); } }, }; ``` A subclass has two more helpers from Curb: | Member | What it does | |---|---| | `this.turnInputFrom(chatCtx)` | The caller's last words, or `null` | | `this.runTurn(text, signal)` | Runs a Curb turn: skills, tools and the model. Yields its events | A reply that skips `super.llmNode` is heard but is not in Curb's conversation, so the model does not see it on the next turn and it is not in the call report. Use it for lines that stand alone. `ttsNode` changes what the voice reads, not the transcript. The example spells out a word there. Its stream arrives in chunks that can split a word, so hold back the last word of each chunk: ```ts override async ttsNode(text: ReadableStream, settings: voice.ModelSettings) { return super.ttsNode(text.pipeThrough(spellOut("IBAN", "I B A N")), settings); } ``` ### Listen to the session ```ts export const pipeline: VoicePipeline = { onSession: ({ session, roomName, events }) => { session.on(events.AgentStateChanged, ({ newState }) => { console.log(`${roomName}: agent is ${newState}`); }); }, }; ``` ## Write your own pipeline A worker of your own builds the session itself. Curb still renders the agent and runs its turns, and everything around them is yours: ```ts import { fileURLToPath } from "node:url"; import { AutoSubscribe, CurbLLMStub, CurbVoiceAgent, ServerOptions, buildVoiceProviders, cli, createVoiceAgentBundle, defineAgent, silero, voice, } from "curb/orchestration"; export default defineAgent({ entry: async (ctx) => { await ctx.connect(undefined, AutoSubscribe.AUDIO_ONLY); const bundle = await createVoiceAgentBundle(); const { stt, tts } = await buildVoiceProviders({ settings: bundle.runtime.settings.current() }); const session = new voice.AgentSession({ stt, tts, vad: await silero.VAD.load(), // The stub keeps LiveKit calling llmNode. Curb's agent answers there. llm: new CurbLLMStub(), turnHandling: { turnDetection: "vad", endpointing: { minDelay: 500, maxDelay: 4000 } }, }); await session.start({ room: ctx.room, agent: new CurbVoiceAgent({ instructions: "curb agent (context compiled per turn by the curb runtime)", runTurn: (text, signal) => bundle.session.runTurnStream(text, { spoken: true, signal }), }), }); }, }); if (process.argv[1] === fileURLToPath(import.meta.url)) { cli.runApp(new ServerOptions({ agent: fileURLToPath(import.meta.url) })); } ``` Run it with the TypeScript loader, which LiveKit's job processes inherit, and the three `LIVEKIT_*` variables set: ```bash node --env-file-if-exists=.env --import tsx src/own-session.ts start ``` Swap any part from here: subclass `CurbVoiceAgent`, drop it for a `voice.Agent` of your own, or call `bundle.session.runTurnStream` from your own loop. `pipeline` is not read here, and neither `curb dev` nor a hosted agent runs this file. ## Next steps - [Orchestration](./orchestration-api.md): everything `curb/orchestration` exports. - [Voice sessions](./voice-sessions.md): what Curb's own session does. - [Providers](./providers.md): changing speech and the LLM with a prop, no override needed. --- # Knowing the caller A call often starts with something already known: the number that dialed in, the page the caller came from, the email of a logged-in visitor. Use it so the agent does not have to ask. | Hook | What it reads | Can you trust it | |---|---|---| | `useAgentInfo()` | What the call knows: `caller.phoneNumber`, `call.direction` and more ([the full list](./voice-sessions.md#how-a-call-opens)) | Yes, the framework or your host set it | | `useClient()` | What the caller's page said, the `client.*` keys | No, anyone can edit a link | ## `client.*` is written by the page `useClient()` reads these keys as one object without the prefix, `const { email, event } = useClient()`; a key the page did not send is `undefined`. Whoever can edit a link or a page can set `client.*` to anything, so: - Use it to prefill and to skip a question the page already answered (a logged-in visitor's email), never for access, identity or prices. No locked branch, skill, tool or account data depends on a `client.*` value; to know who the caller is, ask, or check in a tool against your own system. - Treat it as untrusted text. Look a value up in a table you own rather than rendering it, so an unknown value renders nothing. When you do render one, quote it, keep it short, and render only the keys the agent needs. - It may be absent: a visitor from another page, a link without its parameters, a context the host refused. The agent must work without it. ```tsx import { Text, useClient } from "curb"; // Your own words for the events your links name. Any other value renders nothing. const EVENTS: Record = { "webinar-11-04": "the webinar on 4 November", "webinar-11-17": "the webinar on 17 November", }; export function EventContext() { const { event } = useClient(); const words = EVENTS[event ?? ""]; return words ? The caller came from the page of {words}. Mention it in the greeting. : null; } ``` With `firstTurn: "agent"` the agent greets once the caller is there. With `firstTurn: { say }` it opens with exactly that line instead, with no model call, written from the info above when `say` is a function ([Agents and skills](./agents-and-skills.md), "Who speaks first"). Neither opening can be interrupted: the caller hears the whole opening line, speech during it is not taken as a turn, and a message typed during it is answered once it has played. A call attached to a conversation that already has turns (typing in the preview before starting the call) does not open it with the line again. ## Tell the agent which event a call comes from Attendees of a webinar click a call link, and the agent should know which webinar they come from without asking. Name the event in the link, let the widget pass it on, and let the agent look it up. 1. Make the call page from [the call link recipe](./web-calls.md#send-a-call-link-in-a-chat-or-an-email) and add `data-context-params="event"` to its widget tag. The widget then copies the page address's `event` parameter into the call's context (see [Tell the agent where a call comes from](./web-calls.md#tell-the-agent-where-a-call-comes-from)): ```html ``` 2. Send each event's attendees their own link, such as `https://example.com/call?event=webinar-11-04` for one webinar and `https://example.com/call?event=webinar-11-17` for the next. Keep email addresses, names and anything else personal out of the link: whoever has the link can read it, and the context is stored with the call. To tell attendees apart, add a token that means something only to your own systems, such as `&t=8f3k2q`, and name it too: `data-context-params="event,t"`. 3. In the agent, map the event ids of your links to your own words, and render the sentence for the one the call came from. `useClient()` reads the call's context; its `event` is the link's `event`: ```tsx import { Text, useClient } from "curb"; // Your own words for the events your links name. Any other value renders nothing. const WEBINARS: Record = { "webinar-11-04": "our webinar on getting started", "webinar-11-17": "our webinar on advanced setups", }; export function WebinarContext() { const { event } = useClient(); const webinar = WEBINARS[event ?? ""]; return webinar ? ( The caller comes from {webinar}. Mention it in your greeting and answer questions about it. ) : null; } ``` Render `` inside `` in `src/agent/agent.tsx`, after ``. The agent knows the event from its first sentence. Anyone can edit a link, so the event is a hint and never a reason to unlock anything: a value your table does not know renders nothing, and the agent talks as it would without one. ## Pass what you already know (a logged-in user) A visitor who is logged in on your website should not have to spell their email address for the agent. Give the widget what your page already knows, and let the agent use it instead of asking. 1. On the page, set the widget's `context` property once you know who is logged in, and set it to `null` when they log out: ```js customElements.whenDefined('kugelaudio-agent').then(() => { document.querySelector('kugelaudio-agent').context = { email: user.email, name: user.name } }) ``` The next call to start carries it (see [Tell the agent where a call comes from](./web-calls.md#tell-the-agent-where-a-call-comes-from)). The property stays in the page's memory and is never written into its HTML, so a session recording, which captures the HTML, does not capture the address. For the same reason, keep personal details out of the tag's `data-context`, even on a page rendered on the server. 2. In the agent, `useClient()` reads the call's context, each key without the `client.` prefix. Tell the model the address so it does not ask: ```tsx import { Instruction, useClient } from "curb"; export function KnownCaller() { const { email } = useClient(); // Text from the page: only something shaped like an address gets in. if (!email || !/^[^\s@"]+@[^\s@"]+$/.test(email)) return null; return {`The caller is logged in with the email address "${email}". Use it and do not ask for it.`}; } ``` Render `` inside `` in `src/agent/agent.tsx`, after ``. Without an address it renders nothing, and the agent works as before. 3. Try it locally. `--client` gives the conversation the context a page would send: ```bash npx curb say --new --client email=max@example.com "Hi" ``` Continue with a request that needs the address: the agent uses max@example.com instead of asking for it. The page sets the context, and anyone who controls the page can change it. Use it to prefill and to skip questions, never as proof of who the caller is or to unlock their account's data; to act on an account, check the caller in a tool against your own system. Send only what the agent needs: the context is stored with the call. ## Next steps - [Calls from your website](./web-calls.md): how a page passes this context. - [Who speaks first](./who-speaks-first.md): use it in the greeting. --- # Pronunciation Two settings make the agent hear and say your product's names correctly. ## Hearing a name `boostedPhrases` tells speech recognition how to spell names and terms: ```tsx ``` ## Saying a name Brand names, product names and abbreviations that TTS says wrong are fixed with a **pronunciation dictionary**, not by spelling them out phonetically in the prompt: the prompt is read by the model, and a phonetic respelling there leaks into the text chat too. ```tsx import { Agent } from "curb"; import { kugelaudio } from "curb/providers"; export function App() { return ( {/* the prompt, skills and tools */} ); } ``` Dictionaries belong to a KugelAudio project, so `projectId` is **required** whenever `dictionaryIds` is set; the framework refuses the unpaired combination at startup rather than letting the service reject the first spoken turn of a live call. Find both in the console under `/dashboard///dictionaries`: the project id is in the URL. Omitting `dictionaryIds` entirely applies the project's own default selection. Passing an empty array is a different thing: it opts out of every dictionary, including the defaults. ## Next steps - [Voice](./voice.md): the providers and their options. --- # Your own LLM Curb works with any OpenAI-compatible endpoint, and with Anthropic and OpenAI directly. ```tsx import { Agent } from "curb"; import { anthropic, kugelaudio } from "curb/providers"; ``` The environment holds the keys only ([Configuration](./configuration.md)). ## An OpenAI-compatible model ```tsx import { openaiCompatible } from "curb/providers"; ``` `openaiCompatible()` sends nothing beyond the conversation and the tools unless you set it, so the model runs with the server's defaults. For a reasoning model such as Qwen that often means thinking out loud: the server returns the reasoning as plain reply text, and the agent would speak it. Switch thinking off and set the model's recommended sampling on the descriptor: - `reasoningEffort`, sent as `reasoning_effort`: one of `"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"`. `"none"` turns thinking off where the endpoint takes it; the endpoint's documentation lists the values each model supports. - `temperature` (0 to 2) and `topP` (above 0, at most 1), sent as `temperature` and `top_p`. - `body`: extra request fields the endpoint accepts, sent as written. A field Curb sets itself (`model`, `messages`, `tools`, `tool_choice`, `stream`, `stream_options`) or one an option above sets (`temperature`, `top_p`, `reasoning_effort`) is refused when the agent renders, naming what to use instead. Some hosted OpenAI-compatible APIs take `reasoning_effort`, not `chat_template_kwargs` (key in `AGENT_LLM_API_KEY`): ```tsx import { openaiCompatible } from "curb/providers"; ``` On a vLLM server, Qwen's chat template switches thinking off through `chat_template_kwargs`, and `top_k` is a vLLM sampling field: ```tsx ``` ## Next steps - [Providers](./providers.md): every LLM provider and its options. --- # MCP servers Declare a remote MCP server inline, right where you use it, with no separate config: ```tsx ``` `id` is the tool namespace (`linear__create_issue`) and the credential key. Placement is scope, like any tool: inside `` it is everywhere; inside a skill it is scoped to that skill. `only` is an optional allowlist of raw tool names. The server registers and connects on render; its tools appear once it is ready. **Credentials are implicit: never put a secret in agent code:** - Omit `auth` → a bearer token from `MCP__TOKEN` in `.env` (`id="linear"` → `MCP_LINEAR_TOKEN`) if present, else **OAuth**: the server parks until you log in once via the preview's "MCP servers" menu; tokens persist per-`id` in `.mcp-auth/.json` (gitignored). - Override with `auth={{ kind: "bearer", token }}` or `auth={{ kind: "none" }}`. Alternatives: `defineAgent({ mcpServers: [{ id, url, enabled }] })` pre-registers a server for eager connection before the first render; servers added at runtime through the preview persist in the project's `mcp.servers.json` (tokens never land there). ## Next steps - [Components](./components.md#mcp): every prop of ``. --- # App messages An agent can talk to the page the caller is on, and to whatever hosts the call, without the model being involved. `useSend()` sends small keyed messages to the caller's app (`toClient`) or to whatever hosts the call (`toHost`, `askHost`); `useMessage(key, { from, schema?, onMessage })` handles what they send. A key is any non-empty string up to 256 characters. ```tsx import { useState } from "react"; import { HostTimeoutError, HostUnavailableError, Text, Tool, useMessage, useSend, z } from "curb"; export function Claims() { const send = useSend(); const [claimId, setClaimId] = useState(null); const [supervisorOnline, setSupervisorOnline] = useState(false); // From the caller's app. The return value is the reply to the app's send. useMessage("claim-submitted", { from: "client", schema: z.object({ claimId: z.string() }), onMessage: ({ claimId }) => { setClaimId(claimId); return { received: true }; }, }); // From the host. useMessage("ops.supervisor_online", { from: "host", onMessage: () => setSupervisorOnline(true) }); return ( <> {claimId !== null && {`The caller submitted claim ${claimId}.`}} {supervisorOnline && ( { send.toClient("show-status", { status: "waiting-for-supervisor" }); try { const answer = await send.askHost("human.requested", { topic: "refund" }); return { ok: true, data: answer }; } catch (error) { if (error instanceof HostUnavailableError || error instanceof HostTimeoutError) { return { ok: false, message: "No supervisor is available. Offer a callback instead." }; } throw error; } }} /> )} ); } ``` - `from` is required: a `"client"` handler never sees a host message and vice versa. - With a `schema`, `onMessage` gets the parsed payload; a payload it refuses never reaches `onMessage` and the sender gets `invalid_payload` with the issues. - A message nobody listens for is refused `no_handler`; a handler that throws is `handler_failed`. Nothing is dropped silently. - `toClient` with no app in the room yet, `toClient` on a phone call (no app), and `toHost` with no host are dropped with one warning per key and reason, and counted in the call record. `askHost` rejects `HostUnavailableError` with no host, and `HostTimeoutError` after `{ timeoutMs }` (default 10000). - After a `useMessage` handler runs the context is recompiled, so a store change it makes can unlock a skill or tool, like any other gate. - Each call has its own messages. The wire formats (host protocol and LiveKit) are in [Host protocol](./host-protocol.md#app-messages). ## Ask for feedback at the end of a call Make the question a tool. When the caller answers, the agent calls the tool, and the tool sends the answer to KugelAudio with `send.toHost`. The answer is kept with the call as its data. In `src/agent/agent.tsx`, add a component that renders the tool in ``, and put `` inside ``, beside ``, so that the tool exists wherever the conversation ends. `GlobalTools`, `Tool`, `useSend` and `z` come from `"curb"`. ```tsx function Feedback() { const send = useSend(); return ( { send.toHost("feedback", input); return { ok: true }; }} /> ); } ``` `send.toHost("feedback", input)` sends the rating and the comment under the name `feedback`. It doesn't wait for a reply, and KugelAudio never replies, so use `toHost` rather than `askHost`. Then tell the agent when to ask, in its ``: ```text When the caller has what they called for, ask: "Before you go, how would you rate this call from 1 to 5?" Call record_feedback with the answer, then say goodbye and end the call. If the caller does not want to answer, end the call without asking again. ``` Open a call on the agent's **Calls** page: its **Data** tab shows `feedback` with the rating and the comment. If the agent sends `feedback` more than once in a call, the tab shows the latest answer, and lists every send with its time in the call under **Sent N times**. - `npm run dev` has no host, so there the message is dropped and the terminal shows a warning. To see the answer on the **Data** tab, deploy the agent and call it from its **Playground** in the dashboard. - One message carries at most 16 KB of data. A larger one is not cut short: the **Data** tab says it was not kept, and none of it is stored. - A call that starts while the agent keeps nothing keeps none of what the agent sends either (see [the recording notice](./web-calls.md#show-a-recording-notice-before-the-call)). - Never send secrets, passwords or card numbers. What the agent sends is stored with the call, like what the caller says. To keep every answer in a system of your own as well, send it there from `onSubmit`, as in the next recipe. ## Next steps - [Host protocol](./host-protocol.md#app-messages): the wire format. --- # Goals and experiments Goals work with any host that reports them. `curb build` writes goals and experiments into the bundle (`curb.bundle.json`), and the host reports per call which goals it reached and which variant it had. On KugelAudio, the results appear in the dashboard; hosting there is in closed alpha and switched on per organization. A goal says what counts as a successful call, for example "the caller booked a slot". An experiment splits calls between variants of your agent, for example a formal and a casual greeting, so you can see which variant reaches your goals more often. You declare both in your agent's code, next to the agent itself, and they go live with your next [deployment](./deploying.md). ## Declare goals and experiments Add `goals` and `experiments` to `defineAgent` in `src/agent/agent.tsx`: ```tsx import { defineAgent } from "curb"; export const agent = defineAgent({ app: BookingAgent, goals: { booked: { description: "The caller booked a webinar slot", tool: "book_slot" }, felt_easy: { description: "Booking felt easy", judge: "The caller says booking with the assistant was easy or pleasant.", }, }, experiments: { greeting: { description: "Formal vs casual opening", variants: { formal: 1, casual: 1 }, fromClient: "greeting", }, }, }); ``` A goal is one of two kinds: - **Tool goal** (`tool`): reached when the agent ran that tool during the call and the tool succeeded. Name one of your agent's tools. `npx curb check` warns when no tool of your agent has that name. - **Judge goal** (`judge`): a sentence describing what should be true of the conversation, 1 to 1000 characters. After the call, a language model reads the transcript and decides whether the sentence is true of the call. See [How judge goals are judged](#how-judge-goals-are-judged). An experiment lists 2 to 6 variants, each with a weight from 1 to 100. The weights are relative: `{ formal: 1, casual: 1 }` gives each variant half the calls, and `{ formal: 1, casual: 3 }` gives `casual` three calls in four. Each call gets one variant of every experiment when it starts and keeps it until it ends. The same call always gets the same variant, so you can trace any call back to the variant it had. The limits: - Goal, experiment and variant names start with a lowercase letter and have at most 40 lowercase letters, digits and underscores, such as `booked` or `casual_v2`. - At most 20 goals and 5 experiments per agent. - A `description` is optional and holds at most 200 characters. It is for the people reading the results; the agent never sees it. `npx curb check` reports goals and experiments outside these limits and names every problem, and a deployment with them fails to build, so a mistake never reaches a live call. ### How judge goals are judged Shortly after a call ends, a language model hosted in the EU, at Scaleway in Paris, reads the call's transcript and answers whether your sentence is true of the call: yes (**Reached**) or no (**Not reached**), with a one-sentence reason such as "The caller said booking with the assistant was easy." The call's page shows the reason with the result. The model receives only the transcript and your sentence: nothing else about the call, your agent or your project. Each call is judged once, and its result does not change later. - The model judges from the transcript alone, and the caller's words in it are what speech recognition heard. Write the sentence about what was said in the conversation; to check that something happened, such as a booking, use a tool goal. - The call's conversation has to be stored. A call whose conversation is not stored has no transcript to judge, and its judge goals are **Not evaluable**. - For a very long call the model reads the first 10,000 and the last 30,000 characters of the transcript, about forty minutes of conversation in total. - When the model cannot answer for a call (it did not answer in time, it was unavailable, or its answer could not be read), that goal is **Not evaluable** for the call and the reason says why. It never counts as **Not reached**, and the call's other goals are decided as usual. ## Use the variant in your agent `useVariant` returns the variant the current call got, so the agent can behave differently per variant: ```tsx import { Instruction, useVariant } from "curb"; function Greeting() { return useVariant("greeting") === "casual" ? ( Greet the caller casually and use their first name when you know it. ) : ( Greet the caller formally. ); } ``` Render it inside your ``, like any other instruction. The variant is set before the agent's first words, so the greeting can depend on it. `useVariant` returns `undefined` for an experiment your agent does not declare. ## Choose a variant from the page `fromClient` names a key of the context your web page passes to the call. When the call's context has that key and its value is one of the experiment's variants, the call gets that variant instead of a random one. Any other value is ignored and the call gets a variant by weight as usual. With `fromClient: "greeting"`, a widget tag with `data-context-params="greeting"` lets a link choose the variant: `https://example.com/call?greeting=casual` always starts a call with the casual greeting. Use this to send one audience to one variant, or to try a variant yourself on the live agent. How a page passes context is in [Tell the agent where a call comes from](./web-calls.md#tell-the-agent-where-a-call-comes-from). ## Try each variant locally Every conversation in `npx curb dev` gets its own variants, chosen the same way as on a live call, so a new conversation can get another variant. To try one variant, add the experiment's `fromClient` key to the preview's address: ```text http://127.0.0.1:4000/?client.greeting=casual ``` In a test scenario, `experiments` fixes the variant for that conversation. An experiment the scenario leaves out gets a fixed variant chosen from the scenario's file and name, so the same scenario runs with the same variants every time: ```ts import { scenario } from "curb/testing"; export const casualGreeting = scenario({ experiments: { greeting: "casual" }, turns: ["Hallo, ich möchte einen Termin buchen."], expect: ({ called }) => [called("book_slot")], }); ``` Keep one scenario per variant for every conversation that has to work in each of them, and run them with `npx curb test`. ## See the results The dashboard shows goals and experiments; you change them only in code. - Each call on your agent's **Calls** page shows the variant it got in every experiment and, shortly after the call ends, the result of every goal. The calls table has a **Goals** column and an **A/B test** filter that lists only the calls of one variant. - The agent's **Goals** page shows each goal of the live version with how it is checked and how often calls reached it over the last 30 days, the same rates for every version, and for each experiment the share of calls, the number of calls and the goal rates of every variant. Each rate comes with a 95% range. While the ranges of two variants or versions overlap, the page says there are too few calls to tell them apart rather than naming a winner. Calls whose goals are not judged or not evaluable are left out of the rates and counted beside them. A goal's result is one of: | Result | Meaning | |---|---| | Reached | A tool goal's tool ran and succeeded during the call, or the model answered yes to a judge goal. | | Not reached | The tool did not run or every run of it failed, or the model answered no to a judge goal. | | Not judged | A judge goal of a call that ended before judging was available. | | Not evaluable | The call's conversation is not stored, so there is nothing to evaluate it against, or the model could not answer for this call. The reason says which. | Changing a goal or an experiment takes effect with the next deployment. Calls keep the results and variants they had, so the calls of an earlier deployment still show what they were measured against. ## What is allowed - A goal has exactly one of `tool` or `judge`. A `tool` goal is reached when the agent ran that tool and it succeeded; `curb check` warns (`AGENT_GOAL_TOOL_NOT_DECLARED`) when no `` has that name. A `judge` goal is a criterion of 1 to 1000 characters. - An experiment has 2 to 6 variants with whole-number weights from 1 to 100. - Names of goals, experiments and variants are a lowercase letter, then up to 39 lowercase letters, digits or underscores. An agent has at most 20 goals and 5 experiments, and a `description` holds at most 200 characters. `defineAgent` refuses anything else (`AGENT_INVALID_AUTHORING_PROP`), naming every problem. `useVariant` returns `undefined` for an experiment the agent does not declare, and in `curb say` and `curb start`, which assign no variants. The variant is the session info `experiment.`, so a `firstTurn: { say }` function reads it as `info["experiment.greeting"]`. ## Next steps - [Deploying](./deploying.md): ship the goals and experiments with `curb deploy`. - [Testing](./testing.md): keep one scenario per variant. --- # Outbound calls Your backend can ask a hosted agent to call someone, with what you already know about the call. Placing the call goes through the [KugelAudio outbound calls API](https://docs.kugelaudio.com/hosted-agents/outbound-calls), which is part of hosted agents, in closed alpha. This page covers the agent's side. ## Read what you sent The request's `metadata` reaches the agent as `client.`, from its first sentence, like a web page's context ([Knowing the caller](./knowing-the-caller.md)). ## Greet like a caller The agent reads `call.direction`: `outbound` when it placed the call. Open with who is calling and why, and say that an AI speaks: ```tsx import { Text, useAgentInfo, useClient } from "curb"; export function OutboundOpening() { const direction = useAgentInfo((info) => info["call.direction"]); const { name, topic } = useClient(); if (direction !== "outbound") return null; return ( You are calling {name ?? "the customer"} about {topic ?? "their request"}. Open with: "Hallo, hier ist die digitale Assistenz von Example." Then explain why you call. ); } ``` ## Tell a callback from an incoming call The agent reads `call.direction`: `outbound` when it is calling the visitor back, `inbound` for every other call. Open a callback by saying who is calling, that the visitor asked for the call, and that they are speaking with an AI: ```tsx import { Text, useAgentInfo } from "curb"; export function CallbackGreeting() { const direction = useAgentInfo((info) => info["call.direction"]); return direction === "outbound" ? ( You are calling the visitor back because they asked for a call on the website. Open with: "Hallo, hier ist die digitale Assistenz von Example. Sie hatten gerade einen Rückruf angefordert." Then help with their question. ) : null; } ``` Render `` inside `` in `src/agent/agent.tsx`, after ``. The widget's context reaches a callback too, so `useClient()` reads the same keys as on a browser call (see [Tell the agent where a call comes from](./web-calls.md#tell-the-agent-where-a-call-comes-from)). ## Forward the call Any phone call can be put through to a person: see [Human handoff](./human-handoff.md). ## Next steps - [Who speaks first](./who-speaks-first.md): set the opening line. --- # Calls from your website On KugelAudio, the web widget puts a talk button on your page, with one script tag. Its styling options and which sites may start calls are in the [KugelAudio docs](https://docs.kugelaudio.com/hosted-agents/web-widget); hosted agents are in closed alpha. On your own host, connect your page with [`curb/client`](./client.md). ```html ``` ## Tell the agent where a call comes from Two attributes and a property pass context about the page to the agent, so it knows, for example, which event a visitor is calling about or who is logged in, without asking. The dashboard has no setting for them: add them to your page yourself. - `data-context` is a JSON object written into the tag, such as `data-context='{"page":"pricing"}'`. - `data-context-params` names query parameters of the page's address to copy in, separated by commas. With `data-context-params="event,ref"`, a visitor on `https://example.com/call?event=webinar-11-04&ref=newsletter` brings `event` and `ref` along. The widget reads only the parameters you name, never the rest of the address. - The widget's `context` property sets that JSON object from your page's script, for what the page learns after it loads, such as who has logged in: `widget.context = { email: user.email }`, where `widget` is `document.querySelector('kugelaudio-agent')`. The next call to start carries it in place of the tag's `data-context` (`{ ...widget.context, email: user.email }` keeps both). It stays in the page's memory and is never written into its HTML, so a session recording, which captures the HTML, does not capture the email. `null` clears it, and `data-context` applies again. ```html ``` A key in `data-context` or the `context` property wins over a parameter of the same name, and a parameter that is missing or empty is left out. The agent reads each key as `client.`, for example `client.event`, from the start of the call, so its greeting can already use it. The [webinar recipe](./knowing-the-caller.md#tell-the-agent-which-event-a-call-comes-from) shows the agent's side. The limits: - At most 16 keys. A key starts with a letter and has at most 40 letters, digits and underscores. - A value is a string of at most 256 characters, a number or a boolean. The agent reads a number or a boolean as text, for example `"120"`. - All of it together fits in 8,000 bytes of JSON. Sixteen values of 256 letters and digits fit; long values in other scripts may not. - When the context breaks a limit or `data-context` is not valid JSON, the widget writes the reason to the browser console and starts the call without context. The visitor still reaches the agent. Anyone can change a page's address or what its script sets, so treat the context as a hint from the page: use it to prefill and to skip questions, never as proof of who the visitor is or to unlock their account's data. Put no email address, name or other personal detail into a link: use an id that means something only to you, such as `webinar-11-04`. A page that knows its logged-in visitor passes those through the `context` property instead, as in [this recipe](./knowing-the-caller.md#pass-what-you-already-know-a-logged-in-user). ## Send a call link in a chat or an email A link cannot start a call by itself: a browser lets a page use the microphone only after the visitor clicks. So the link opens a page of yours with the widget on it, and one click starts the call. 1. Make a page on a website your widget accepts calls from, for example `https://example.com/call` (see [which sites can start calls](https://docs.kugelaudio.com/hosted-agents/web-widget#which-sites-can-start-calls)). 2. Put the widget on it inline, so that its button sits in the page. Start from your widget's code in the dashboard, which carries every attribute your widget needs, and add the inline position, the label and the title: ```html Talk to our assistant

Talk to our assistant

Press the button and allow your microphone.

``` 3. Send the page's address in your chat or email. `data-label` is the button's text and `data-title` the heading of the call panel that opens from it. The visitor's click on that button starts the call. A button of your own design can start it too, by calling the widget's `start()`; see [Start a call from your own button](https://docs.kugelaudio.com/hosted-agents/web-widget#start-a-call-from-your-own-button). ## Show a recording notice before the call The widget has no consent step: a click on its button starts the call. Put your notice on the page above the button, so that visitors read it before they click. No call starts and no microphone opens before that click. Whether a call is recorded is decided when the call starts, by the agent's **Settings** > **Recording and retention**. A call that starts while the agent keeps nothing has no transcript, audio or activity afterwards. Write the notice to match what that section says. ```html

This call is recorded and transcribed so that we can help you. Our privacy policy says how long we keep it.

``` To ask for an explicit yes, show the button only once the visitor agrees. Keep the widget's script tag with `data-position="inline"` on the page: the widget also fills a `data-kugelaudio-widget` element that is added later. ```html
``` A phone call has no page. Let the agent speak first on a call (`firstTurn: "agent"` in `src/agent/agent.tsx`, as the template has it) and tell it in its `` to say that the call is recorded before anything else. The model says it in its own words. ## Next steps - [Knowing the caller](./knowing-the-caller.md): read the context in the agent. --- # Improve an agent from real calls The fastest way to improve an agent is to hand a coding agent a call that went wrong, let it fix the cause, and let it prove the fix by talking to the agent itself. You need no test script to start: the call is the test. ## 1. Copy the transcript | Where the call happened | How to copy it | |---|---| | `curb dev` preview | **Copy for coding agent** in the conversation header: the transcript with the skill map, the tools and the system prompt ([The preview](./preview.md#copy-for-coding-agent)) | | Hosted agent, dashboard | Open the call on the **Calls** page, open the **Copy or download** menu, choose **Copy transcript**: every turn and tool call as Markdown | | Hosted agent, coding agent connected | Name the call; the agent reads it with `agent_get_call` and `agent_get_call_timeline` | | `curb dev`, `curb say` | `npx curb conversations --last 5` | ## 2. Have the coding agent improve it Paste the transcript into your coding agent in the project folder, with one sentence about what should have happened: ```text Here is a call. The agent promised a callback but never recorded one. Find out why, fix the agent, and keep this call as a scenario so it stays fixed. ``` A project from `npm create curb.js` tells a coding agent how to work: its `AGENTS.md` names the checks below, and the full guide is in `node_modules/curb/docs/agent-guide/`. Before it changes a prompt, the coding agent should ask where the behaviour comes from. Most wrong calls are one of these: | What happened | Usually the cause | Fix | |---|---|---| | Used a tool it should not have had yet | The tool is always rendered | Render it only when state allows it | | Promised something it cannot do | No tool for it in that state | Render the tool, or stop offering it | | Opened the wrong skill | The skill's `description` | Rewrite it as a routing rule, with "NOT for" | | Forgot something from earlier, or asked for it twice | `useState` in a skill that unloaded, or the transcript window | Move it to a store ([Skill lifecycle](./skill-lifecycle.md)) | | `Unknown tool` after the caller changed topic | The tool is not rendered now: its skill unloaded, or its gate reset | Move the gate's state to a store, or read the skill again | | Misheard a name | Speech recognition | `boostedPhrases` on `kugelaudio.stt()` | | Said a name wrong | Text to speech | A [pronunciation dictionary](./pronunciation.md) | ## 3. Let the agent test itself The coding agent runs the same loop you would, with no browser or microphone: ```bash npx curb check # does it build? npx curb say --new "" # replay the call, one line per turn npx curb say "" npx curb test # do whole conversations still pass? ``` It reads the tool calls and context lines of each turn, not just the reply. `--fake ` keeps a booking or an email from happening for real. Each `curb say` and `curb test` turn calls the agent's LLM, so it costs tokens like a real call. Then it keeps the call, in a scenario file under `src/agent/`, usually in the skill's folder. When the fix changes the call, keep the caller's lines that still make sense and rewrite the others: ```ts // src/agent/callback.scenario.ts import { scenario } from "curb/testing"; export default scenario({ name: "records the callback it promises", turns: ["Nobody is answering at your office", "Yes, call me on 030 555 0100"], fakes: { request_callback: { ok: true, message: "The callback is recorded." } }, expect: ({ called }) => [called("request_callback")], }); ``` `curb test` now fails if this ever breaks again ([Testing](./testing.md)). ## 4. Listen, then ship Text checks cannot hear timing, interruptions or pronunciation. Run `npm run dev` and call the agent once. Then deploy with `npx curb deploy`, or from a browser project with `agent_publish`. ## Next steps - [Testing an agent](./testing.md): every flag and check. - [The preview](./preview.md): rewind, per-message audio and what the model read. - [Diagnostics](./diagnostics.md): every code `curb check` can report. --- # Working with a coding agent Curb is built so a coding agent can do the work: it writes plain code, every diagnostic has a stable code and a fix, and the agent can talk to what it built from the terminal. A new project comes with `AGENTS.md`, `CLAUDE.md` and a `.mcp.json`, so a coding agent started in the project folder has what it needs. This guide ships inside the `curb` package at `node_modules/curb/docs/agent-guide/`, matched to the installed version. ## The working loop 1. Edit `src/agent/` (and `context/` markdown). 2. `npm run check`: authoring diagnostics, exit 1 on errors. Every diagnostic has a stable code, a location, and a suggested fix; they are written to be machine-actionable. Fix errors before moving on. 3. `npm run dev`: everything on one port (default http://127.0.0.1:4000): browser preview, agent WebSocket server, and voice. The preview shows the compiled context, the live tool surface, the problems `curb check` finds, and a chat to talk to the agent. Server startup logs repeat all diagnostics. 4. Verify behavior by conversation, not only by compilation: does the model route to the right skill, call the right tool, stay blocked by gates? 5. `npm run upgrade` updates the framework itself (the `curb` package and this guide) to the latest published version. Conversations are recorded to `.data/conversations.jsonl` in the project (`curb conversations` prints them; each line is one JSON record), including each tool call, its input, the result, and the context diff it caused. To record to your own store instead, point `KUGEL_CONVERSATION_SINK` at a module that default-exports a `ConversationSink`; the startup log names the store in force, and a store that fails never drops the call. See [The preview](./preview.md#recorded-conversations) for the contract and what happens when the store fails. ## Rules that matter most - Every skill needs a routing `description`, on its `` link or in its `meta`: it is the only thing the model sees when deciding where to go. Write it as a routing rule ("Use for…", "Use FIRST when…", "NOT for…"), not as marketing copy. - Tool names are snake_case verbs, unique across the whole agent. - Zod error messages are read by the model: write them as instructions ("Email address is invalid. Ask the user for a corrected email address."). - Cross-skill invariants live in a shared store (`createAgentStore`), and enforcement is structural: render the tool only when the state allows it. - Keep skills small. The compiled context of one skill should stay well under the size warning threshold; split big skills into sub-skills. ## What to avoid - Don't import framework internals by deep path: only `"curb"` and its entry points (`curb/providers`, `curb/client`, `curb/testing`). - Don't hand-register skills or routes; the filesystem is the registry. - Don't encode invariants only in prompt text; render them structurally. - Don't duplicate tool names, and don't leave a `` link undescribed. - Don't put UI concerns in skills: there is no DOM; everything is context. ## Keys Never paste a key into a command or a tool call. A key in `.env` is read by every command. Send keys to a hosted agent with `npx curb secrets push`, which prints names only. ## Connect KugelAudio to Claude Code A project from the template comes with a `.mcp.json` that registers two MCP servers: `kugelaudio`, your account, and `kugelaudio-docs`, the KugelAudio documentation. Start Claude Code in the folder, approve the servers, then run `/mcp`, choose `kugelaudio` and sign in. In any other folder: ```bash claude mcp add --transport http kugelaudio https://www.kugelaudio.com/mcp ``` Connecting other clients is in the [KugelAudio docs](https://docs.kugelaudio.com/hosted-agents/coding-agent). ## Next steps - [Improve from real calls](./improve-from-calls.md): hand it a call that went wrong. - [Diagnostics](./diagnostics.md): every code and its fix. --- # The preview `npm run dev` (i.e. `curb dev`) serves a browser preview on one port and opens it in your browser (default http://127.0.0.1:4000; it walks to 4001, 4002, … if the port is taken, so several agents run side by side; pass `--no-open` or set `CURB_NO_OPEN=1` to skip the auto-open). Edits apply on save: saving a file under `src/`, a `context/*.md` or `.env` reloads the agent within seconds, without restarting `curb dev` (see [Editing while it runs](#editing-while-it-runs)). The preview renders the *same* compiled tree two ways: as an interactive UI you can operate, and as ONE live conversation you can hold by typing, talking, or both. It is where you meet, drive, and debug the agent. ## Editing while it runs `curb dev` restarts the agent's server process on every save, so the agent you talk to runs the code on disk, including the helpers your skills import and the keys in `.env`. The terminal prints one line per reload, naming the file: ```text ✓ 14:25:26 dev reloaded src/agent/agent.tsx in 1.2s ``` - The preview stays on its conversation: it reconnects to the same recorded conversation (`.data/conversations.jsonl`), and your next message runs on the new code. With recording off (`KUGEL_CONVERSATIONS_DB=off`) it starts a new one. - A reload keeps the conversation, the skills it has open and its store data (`createAgentStore`), and never runs a tool again: a booking or an email your agent made happens once, however often you save. When something cannot come back (a store or a skill that no longer exists, store fields that are not JSON data), a line above the message field says what. - A reply that is being written finishes first, for at most 30 seconds; a longer one is cut off, and the terminal says so. A live call is never cut: the reload waits until the call ends, and a line above the message field says so. An MCP sign-in you opened holds the reload until the provider answers, for at most 5 minutes. - A finished call's recordings (the audio under its messages and the call replay) do not survive a reload. - An edit that does not load or render (a syntax error, an `AgentAuthoringError`, a component that throws) leaves `curb dev` running: the preview shows the diagnostic in place of the agent, and the next good save brings it back. - A `src/` or `context/` folder created while `curb dev` runs is watched from then on. - MCP servers connect again on every reload. When a reload changes the conversation store (a `KUGEL_CONVERSATION_SINK` added to or removed from `.env`), the terminal names the new one. ## The page The conversation is on the left and **Agent context** on the right, under a top bar that names the agent (a picker when the project has several) and shows what `curb check` finds: **No problems**, or how many problems there are, which opens them (see [Problems](#problems)). Nothing in the preview edits the agent; the code under `src/agent/` does, and **Copy for coding agent** (see below) hands your coding agent a real conversation to work from. ## Agent context The **Context** tab shows what the model gets from your code, live: - **Always available**: the **Instructions** (your ``; the row shows its first sentence and opens it in full, with what Curb adds to every message: the date and time line and, on calls, the rules for phone calls), the agent-wide **Tools**, and the **MCP servers** with their state. A server that needs a sign-in shows **Sign in**; one that could not connect says **Not connected** and expands to the error. - **Skills**, as the folders they live in: each top-level skill folder is a root, with the folders nested in it beneath. A skill's row shows its description and says **In use** (the skill the agent is on), **Read** (in the model's context from earlier), **Locked** (its route waits for a condition), or what it holds, such as **2 skills · 1 tool**. The chevron in front expands it to its own tools, which work only while it is loaded, and its sub-skills; the row opens it. The skill in use starts expanded. - **Built in**: what the framework adds on its own, such as ending the call or opening a skill. After each message, what entered the agent's view because of it (a skill it read, a tool it can call now, changed instructions) carries a **New** mark until your next message. Clicking a skill opens its page beside the conversation: its instructions, its documents, its tools and, for a locked skill, when your agent can open it. The page of the skill the agent is on is the conversation's own: exactly what the model gets, including what the conversation changed (a finished procedure, a sign-in, the tools they unlocked). Every other page comes from the preview's own copy of the agent, which browsing and test runs use so they never touch the conversation, and each page says which it is: - "In use right now: this is what the model gets": the conversation's page. - "In use from earlier": the agent has the skill from earlier in the conversation; the page shows it as written, without the conversation's changes. - "Not in use right now": the agent reads the skill when the conversation needs it. On a page, a `` renders as a stepper whose button advances it one step, the same transition the model would drive. Knowledge (``, plain JSX text) renders as readable documents, and the system prompt and every multi-line `` and `` render as formatted text: paragraphs, headings, lists and line breaks, the structure the model reads (see "Writing prose" in `authoring.md`). The address bar follows what is open, so a link opens the same view: `/skill/` a skill's page, `/graph` the graph, `/system-prompt` the instructions, `/tools` the tools and `/diagnostics` the problems. ## Trying a tool **Try** beside a tool opens a form built from its schema: a field per input, labelled with its name, its description as help, marked optional where it is. A tool with `tryWith` (see `authoring.md`) opens with those values filled in. When the agent called the tool in this conversation, **Use these** fills in the arguments it used. **Run** runs the tool's `onSubmit` once with your values, exactly as if the model had called it, and shows what it returned, or why it failed, and how long it took. The run uses the preview's own copy of the agent's state, so it changes neither your agent nor the conversation; from then on that skill's page shows the copy, and **Undo last try** on the page takes the run back. If the tool's code calls an outside service, that call is real. A tool the conversation unlocked cannot be tried, because test runs go to the copy, where it is still locked; ask the agent to use it instead. So you can reproduce and debug any tool call yourself, without talking, and watch the agent's state change the way it would mid-conversation. ## The graph **Graph**, above the agent's context, opens a large view that starts at your agent and draws the skills it can open, from the live context and the routes in your code. The skill in use is highlighted. A dashed connector is a route that exists in the code but is locked until its condition holds, and its label names the condition. Click a skill to open its page, or the agent to read its instructions. An agent without skills has no graph, so it shows no button. ## The conversation There is ONE conversation: one transcript, one session, one authored system prompt. Type into the composer, or press **Start call** to attach voice (your real microphone, real speech) to the same thread: spoken and typed turns land in the same transcript and share all agent state. During a call the bar shows how long it has run and whether your agent is speaking or listening, the microphone button mutes you and its menu switches the input device, and **End call** hangs up. Keep typing and the conversation continues. While a call is live, typed messages run through the voice pipeline too, so the agent speaks its answer. The title in the conversation header opens recent preview conversations, and **New conversation** starts one. A new conversation gets a fresh runtime and session; selecting an earlier one restores its transcript and agent state (the skills it had open, its store data) without running any of its tools again, so you can continue it. - **What the agent did**, in order inside its turn: "Open skill" for a skill it read, and a row per tool call with its first arguments and its outcome, which expands to the full arguments and result. - **What the model read**, under the newest reply (and under older ones on hover), with play and copy: the exact input of the model call that wrote the reply, its instructions, every message and the tools, downloadable as JSON. This is the ground truth for "what did the model actually see" and the fastest way to explain a wrong answer. - **Rewind** in both modes: **Retry from here** under your message, **Go back to this reply** under the agent's, to retry a prompt or a routing decision. - **Live transcription** of what you are saying, as you say it (on a call). - **Per-message audio**: on call turns, replay exactly what was heard and said, your captured mic audio under your messages and the agent's speech under its replies. On typed turns, play synthesizes the reply once with the configured voice. - **Synchronized call replay**: microphone and agent audio are combined into one seekable timeline that preserves pauses, overlap, and interruptions. Play, scrub, or download the complete call as one WAV. - **Interruptions** as on a real call: talk over the agent and it stops, and its model history is trimmed to the words that actually played. The generated but unheard continuation stays visible, muted, for debugging. The system prompt stays singular: what you author is what both modes use. On spoken turns the framework appends its spoken-reply delivery rules at runtime; you never write those yourself, and typed turns never see them. ## Problems The `curb check` output (unreachable skills, duplicate tools, missing routing descriptions, oversized skills …) lives in the preview: the top bar says how many problems there are and opens them, each with a location, a concrete fix and a copy button for your coding agent. The same output appears in the server startup log; after a reload the terminal line counts the errors instead. ## Copy for coding agent **Copy for coding agent** in the conversation header copies a coding-agent-ready transcript of the conversation, together with the skill map and the tools, so you can hand a real session to a coding agent to improve the agent from what actually happened. The transcript carries the system prompt this conversation sent to the model. A conversation restored from the list that has not had a turn since carries the prompt the store recorded for it. When there is none (the conversation never reached the model, or was recorded before prompts were), it carries the prompt in the code now, headed "System prompt (current code)", because the conversation may have run with a different one. ## Recorded conversations By default every conversation is written to `.data/conversations.jsonl` in the project: each message with its time and kind (what the caller said, a context event, or input the framework generated), each tool call with its input and result, the context diff it caused, the system prompt the conversation's model calls carried, and what continuing it needs (the skills it has open, its store data and its page info), updated after every turn. The file is append-only, one JSON record per line (`{"t":"conversation",...}`, `{"t":"message",...}`, `{"t":"system_prompt",...}`, `{"t":"state",...}`), and a later record for the same message, prompt or state replaces the earlier one when the file is read. Read it with `curb conversations`, `jq` or any editor for a durable record beyond the live preview. `KUGEL_CONVERSATIONS_DB` moves the file (a path relative to the directory the server runs in) or turns recording off (`off`). If the server stopped in the middle of a write, the cut-off last line is dropped the next time the file opens, with a warning naming its line; every record before it is kept. Any other line that is not a record stops the server with the file and line number, so fix or delete that line, or move the file away to start a new history. An older Curb recorded to `.data/conversations.db`; that file is not read (the startup log says so once) and can be deleted. The conversation list behind the conversation title reads this file: it offers the 20 most recent recorded conversations, plus the ones started on the current connection. It is read once when the preview connects, so reload the page to see conversations recorded since. The server's startup log names the store in force, in one line: ```text conversation store: JSONL /path/to/my-agent/.data/conversations.jsonl conversation store: sink /path/to/my-agent/lib/conversation-sink.ts (KUGEL_CONVERSATION_SINK) conversation store: off (KUGEL_CONVERSATIONS_DB=off) ``` ### Recording to your own store To send transcripts somewhere else, set `KUGEL_CONVERSATION_SINK` to a module in your project (an absolute path, or one relative to the project root) whose default export is a `ConversationSink`: ```ts // lib/conversation-sink.ts import type { ConversationSink } from "curb"; import { db } from "./db"; // your own client; curb adds no driver const sink: ConversationSink = { // Return a stable id. `metadata` is the session info your host sent with // `set_info`, read when the first message starts the conversation. async startConversation(agentId, metadata) { return db.insertConversation({ agentId, metadata }); }, // MUST replace the message already stored at (conversationId, seq). async recordMessage(conversationId, seq, message) { await db.upsertMessage({ conversationId, seq, message }); }, // Optional: the system prompt the conversation's model calls carried. Called // again whenever it changes, and replaces the one stored before. async recordSystemPrompt(conversationId, systemPrompt) { await db.setSystemPrompt({ conversationId, systemPrompt }); }, // Optional: what continuing the conversation needs (open skills, store data, // page info), as plain JSON. Called when it changes; replaces the one before. async recordState(conversationId, state) { await db.setState({ conversationId, state }); }, }; export default sink; ``` ```bash KUGEL_CONVERSATION_SINK=./lib/conversation-sink.ts npx curb server ``` The server imports the module at startup, so a wrong path or a module without that default export fails the boot rather than recording nowhere. While a sink is registered nothing is written to or read from `.data/conversations.jsonl`: the conversation list then shows only the conversations started on the current connection, and reading older transcripts is up to your store. ### When the store fails The transcript is a record; the call is the product. A write your sink rejects never fails the turn or drops a live call. It is not dropped either: - Each rejection is logged at error level with the conversation id (once the sink has issued one) and the attempt number, and the message stays pending. - Pending messages are retried, in order, on the next change to the transcript, and once more when the session ends: when the connection that owns it closes, or when a call served by a `curb build` artifact ends. A direct-join or managed runner waits at most 10 seconds for that last attempt before ending the call, and logs it if it gave up waiting. - Whatever the sink still rejects then is logged at error level with the conversation id and the number of messages lost. That transcript is incomplete in your store. What your sink has to guarantee, because of those retries: - `recordMessage` upserts on `(conversationId, seq)`. A retry can repeat a write that actually landed, and a barge-in rewrites a message that is already stored; both must replace it, never add a second row. - `startConversation` is retried only after it rejected, so a rejection must mean no conversation was created. - Calls for one session never overlap and arrive in `seq` order, so a write that keeps failing holds back the ones after it. Curb puts no timeout on a call; bound your own. ## Next steps - [Testing](./testing.md): the same checks from the command line. - [Improve from real calls](./improve-from-calls.md): use a recorded conversation. --- # Hosting a Curb agent yourself A Curb agent needs no hosted platform. Everything below runs on your own machines with your own LiveKit deployment (LiveKit Cloud or self-hosted `livekit-server`) and your own provider keys. This page says what you get and what you take on, covers the four ways to run an agent, and specifies **host protocol v1**, the HTTP contract between a Curb runner and whatever orchestrator starts it. ## What self-hosting includes The framework is the same on every route: your tree, skills, tools, stores, the turn loop, the voice session, `curb dev`, `curb say`, `curb test` and `curb build`. What differs is what sits around a call. | Capability | Self-hosted | On KugelAudio | |---|---|---| | Agent code, skills, tools, MCP servers | Included | Included | | Voice session: turn-taking, interruptions, greeting | Included | Included | | STT, LLM and TTS | Every provider in `curb/providers`, your keys | `KUGELAUDIO_API_KEY` provided. ElevenLabs STT and TTS do not run in a hosted agent | | Recording conversations | `.data/conversations.jsonl`, or your own store with `KUGEL_CONVERSATION_SINK` | Call log with retention | | `curb test`, with a simulated caller and its judge | Included, on the agent's own LLM | Included | | Goals (`defineAgent({ goals })`) | **Not judged.** Your goals are written to `curb.bundle.json`, and nothing evaluates them | Judged after each call by a hosted LLM, shown on the Goals tab | | Experiments (`defineAgent({ experiments })`) | **Not assigned.** Your host picks each call's variants and passes them when it starts the call. Under `curb start` no call gets a variant | Assigned per call, with results | | Call transfer | **Your host dials.** The agent asks for a transfer in a host message. With no host, or a host that does not answer, the transfer ends as `failed` ([transfer on your own host](./host-protocol.md#transfer-on-your-own-host)) | Included on phone calls | | Changing a live call's settings from your server | `PATCH /v1/sessions/{id}` on the management port | No published route yet: from the caller's app or the agent | | Phone numbers, web widget, Playground, dashboard | Not included. LiveKit SIP and your own page take their place | Included | | Egress and isolation | Whatever your network and your runtime give you | Sandboxed runner, restricted egress | | Secrets | Your environment or secret store | Stored per environment | | Builds and deployments | `curb build`, then your own deployment | `curb deploy`, deploy on GitHub push | Goals are judged by KugelAudio's platform, so a self-hosted agent gets no goal results, even with a KugelAudio key. To judge calls yourself, read them from your conversation store and give them to a model of your own. A hosted agent reaches only public addresses, over HTTPS on port 443 and HTTP on port 80: no private addresses and no other TCP. The rule and the details are in [How a hosted agent runs](https://docs.kugelaudio.com/hosted-agents/how-it-runs). A self-hosted runner has no such limit, so an agent that must reach a private system or a database port needs your own hosting, or an HTTPS API in front of that system. ## Ways to run an agent | Mode | Command | What it needs | Use it for | |---|---|---|---| | Dev | `npm run dev` (`curb dev`) | provider keys in `.env` | building the agent; preview, chat and voice on one port | | LiveKit worker | `npm start` (`curb start`) | `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | a long-running worker that LiveKit dispatches rooms to | | Built artifact | `curb build`, then `node ` | Node only; no `npm install`, no TypeScript | shipping one self-contained directory to a server or container | | Managed runner | the built artifact with `CURB_MANAGEMENT_ADDR` + `CURB_MANAGEMENT_TOKEN` | an orchestrator that speaks host protocol v1 | your own control plane placing calls on warm runners | Every mode runs the same voice session, the framework's: the same providers, greeting, caller info and call identity ([Voice sessions](./voice-sessions.md)). A project's `src/orchestration/` is not loaded by any of them. Dev and the LiveKit worker also serve host protocol v1 for the calls they run when `CURB_MANAGEMENT_ADDR` and `CURB_MANAGEMENT_TOKEN` are set: see [Management port in dev and worker mode](#management-port-in-dev-and-worker-mode). ### Dev `curb dev` serves the preview on `http://127.0.0.1:4000`. See the project README for the loop itself. With no LiveKit configured, a call runs over the page's own connection to the dev server, so voice needs nothing else running. Such a call has no phone line, so a transfer is refused. With `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` set, calls use that deployment instead. All three are needed: `curb dev` does not start with only some of them set, and names the missing ones. A host that runs `curb dev` for someone else, where the browser cannot reach a LiveKit server beside it, sets `CURB_REALTIME_SESSION_URL` instead of handing over an API key and secret, which would mint tokens for any room. On every **Start call** `curb dev` sends a `POST` with no body to that URL and expects one fresh room for that call: ```json { "room": "preview-1f3a9c", "agentUrl": "ws://livekit.example.com:7880", "agentToken": "", "callerUrl": "wss://livekit.example.com", "callerToken": "" } ``` The agent server joins with the agent token and the page with the caller token. A non-2xx answer fails the call with the body's `message`, so the host's own words ("try again in a moment") reach the preview. In this mode `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` are refused, not ignored: `curb dev` does not start with one of them in the environment or `.env`, and a call does not start when one was added to `.env` since. ### LiveKit worker (`npm start`) The scaffolded project's `npm start` registers a worker with your LiveKit deployment and waits to be dispatched: ```bash export LIVEKIT_URL=wss://livekit.example.com export LIVEKIT_API_KEY=... export LIVEKIT_API_SECRET=... npm start ``` The worker registers under `CURB_AGENT_NAME` when it is set, otherwise under the `name` in the project's `package.json`, otherwise as `curb-agent`. Configure a LiveKit dispatch rule (room or SIP) that targets that name. Provider keys (`KUGELAUDIO_API_KEY` and so on) come from the project's `.env` or the process environment. ### Provider keys Which providers an agent runs is authored in the agent, as `` props from `curb/providers` (see [Agents and skills](./agents-and-skills.md)). The environment holds only the keys of the providers the agent actually uses; a missing one fails with an error naming it. | Provider prop | Variable | |---|---| | `kugelaudio.llm({ model? })` (default LLM) | `KUGELAUDIO_API_KEY`; `KUGELAUDIO_API_BASE` (default `https://api.eu.kugelaudio.com`), plus `/v1` | | `anthropic(model)` | `ANTHROPIC_API_KEY` | | `openai(model)`, `openai.stt()`, `openai.tts()` | `OPENAI_API_KEY` | | `openaiCompatible({ baseURL, model })` | `AGENT_LLM_API_KEY`, else `OPENAI_API_KEY`; optional | | `azure.stt()` (default STT) | `AZURE_SPEECH_KEY`; `AZURE_SPEECH_REGION` (default `eastus`) | | `kugelaudio.stt()` | `KUGELAUDIO_API_KEY`; `KUGELAUDIO_API_BASE` (default `https://api.kugelaudio.com`) | | `kugelaudio.tts()` (default TTS) | `KUGELAUDIO_API_KEY`; `KUGELAUDIO_API_BASE` (default `https://api.kugelaudio.com`) | | `deepgram.stt()` | `DEEPGRAM_API_KEY` | | `elevenlabs.stt()`, `elevenlabs.tts()` | `ELEVEN_API_KEY` (or `ELEVENLABS_API_KEY`) | | `cartesia.tts()` | `CARTESIA_API_KEY` | ### Built artifact (`curb build`) ```bash npx curb build --out dist ``` `dist/` is self-contained: the bundled agent, its traced runtime dependencies (including native modules for the build machine's OS and architecture) and `curb.bundle.json`. The manifest's `entrypoint` names the file to run; never hardcode it. The build starts the finished artifact once and renders every skill before it reports success. Build on the OS and architecture you deploy to (the manifest records it under `target`). The built entrypoint picks its mode from the environment, one of four: | Environment | Mode | |---|---| | `CURB_READINESS_PROBE=1` | load the bundle, construct the agent, exit 0; touches no media | | `CURB_MANAGEMENT_ADDR` (+ `CURB_MANAGEMENT_TOKEN`) | managed runner, below | | `CURB_REALTIME_URL` + `CURB_REALTIME_TOKEN` | direct join: join that one already-created room, serve one call, exit | | none of the above | LiveKit worker, exactly like `npm start` (needs the three `LIVEKIT_*` variables): the same job, under the same name (`CURB_AGENT_NAME`, otherwise the package name `curb build` baked in) | In the room-token modes the runner never sees `LIVEKIT_API_KEY` or `LIVEKIT_API_SECRET`: your orchestrator creates the room and mints a room-scoped token, and the runner removes the API credentials from its own environment. ### Managed runner A managed runner is started before any call, pays its warmup (bundle, VAD model, provider plugins) once, announces `ready`, and then takes calls over an HTTP management port. It does not exit when a call ends; it exits after a drain empties it. ```bash cd dist CURB_MANAGEMENT_ADDR=127.0.0.1:7071 \ CURB_MANAGEMENT_TOKEN="$(openssl rand -hex 24)" \ node "$(node -p 'require("./curb.bundle.json").entrypoint')" ``` Your orchestrator then places calls with `POST /v1/sessions`, handing each call a LiveKit URL and a room-scoped token. Everything it can say is host protocol v1. #### Several calls in one runner With `CURB_MAX_SESSIONS=N` a managed runner serves up to N calls of its one deployment at once, in one process. What Curb guarantees between them: - **Capacity is exact.** N starts are accepted; the next is refused with `409 runner_at_capacity` until a call has fully ended (its recording finished and its scratch directory removed). A retry of a call already running is still answered as a repeat. `CURB_MAX_SESSIONS` and the generation pins are read once, at boot: a runner restored from a memory snapshot keeps the values it booted with, whatever its environment says after the restore. `GET /v1/runner` and the ready line report the bound in force. - **One generation per runner.** A start pinned to another `deploymentId` or `configurationVersion` than the runner's is refused with `409 generation_mismatch`. Each pin is compared only when both the runner and the request state it, so set `CURB_DEPLOYMENT_ID` on every runner that serves more than one call. - **Each call has its own identity.** The agent reads its call as `useSession().call`: `sessionId` is the start request's, never the environment's. The runner's environment (provider keys, `CURB_*` variables) is the same for every call on it; never read a call's identity from `process.env`. - **Each call has its own scratch directory**, `useSession().call.scratchDir`: created under `$TMPDIR` (mode `0700`, a random name) when the call starts, removed when it ends, including when it fails to start or is stopped. No other call is given its path. If it cannot be created, the call runs without one and the runner logs why; point `TMPDIR` at a writable directory. - **Each call has its own conversation**: agent runtime, transcript, agent stores (`createAgentStore`), app messages, live settings, debug clips and event-stream producer. Shared by design: the loaded bundle, the VAD model, provider plugin modules, the MCP manager and its connections, and the conversation sink. What Curb cannot guarantee: calls in one process run as one OS user. Agent code that writes outside its `scratchDir` (`os.tmpdir()` itself, a fixed path) or keeps state in a module-level variable shares it with every other call on the runner. Run one call per runner (the default) when calls must not share a process. ### Management port in dev and worker mode `curb dev` and `curb start` open the same management port as a managed runner when both variables are set, so you can write and test your own host against a real agent without deploying anything: ```bash CURB_MANAGEMENT_ADDR=127.0.0.1:7071 \ CURB_MANAGEMENT_TOKEN="$(openssl rand -hex 24)" \ npm start # or: npm run dev ``` | Variable | Meaning | |---|---| | `CURB_MANAGEMENT_ADDR` | `host:port` to listen on. Absent: no port, nothing else changes. | | `CURB_MANAGEMENT_TOKEN` | Bearer token for every route but `/health`. Required: with the address but no token the port stays closed and the process says why. | `curb start` prints `curb: host protocol v1 on http://127.0.0.1:7071/v1 (calls by room name)`; `curb dev` shows the same address on its `Host` banner row. Here LiveKit dispatch (or the preview's **Start call**) places the calls, not your host. Every call the process runs is registered while it runs, **under its LiveKit room name as the session id**, and leaves the list when it ends. In worker mode that is also the agent's `useSession().call.sessionId`; in dev the agent's is its preview conversation's id (`preview-…`), which a voice call on that conversation keeps. In worker mode each job runs in its own process and registers its call with the worker over a private local socket, so one port covers every job of the worker. For those calls your host can: | Route | In dev and worker mode | |---|---| | `GET /v1/sessions`, `GET /v1/sessions/{id}` | the calls running now | | `GET /v1/runner/events?from=N` | their records (see "What a call's record contains"), plus what the voice session reports through the `reporter` it is handed | | `POST /v1/sessions/{id}/messages` | delivered to that call's `useMessage({ from: "host" })` handlers | | `PATCH /v1/sessions/{id}` | live settings, gated by `` | | `DELETE /v1/sessions/{id}` | ends the call (`graceMs` lets the agent finish its sentence) | | `POST /v1/sessions`, `GET /v1/runner`, `POST /v1/runner/drain` | `501 runner_takes_no_calls`: LiveKit places, counts and drains the calls here; stop the worker with `SIGTERM` | With `askHost`, an agent in dev or worker mode now has a host to wait for: answer its `host` record on the events stream with a `HostMessage` whose `inReplyTo` is the record's `id`. Without the port, `askHost` rejects `HostUnavailableError` as before. ```bash ADDR=127.0.0.1:7071 auth=(-H "Authorization: Bearer $CURB_MANAGEMENT_TOKEN") curl -s "${auth[@]}" http://$ADDR/v1/sessions # which rooms have a call curl -sN "${auth[@]}" "http://$ADDR/v1/runner/events?from=0" # follow them (another terminal) ROOM=curb-1a2b3c4d # a sessionId from the list curl -s "${auth[@]}" http://$ADDR/v1/sessions/$ROOM curl -s "${auth[@]}" -H 'content-type: application/json' \ -d '{"id":"m-1","name":"ops.notice","payload":{"text":"supervisor online"}}' \ http://$ADDR/v1/sessions/$ROOM/messages curl -s "${auth[@]}" -X PATCH -H 'content-type: application/json' \ -d '{"tts":{"voice":17}}' \ http://$ADDR/v1/sessions/$ROOM curl -s "${auth[@]}" -X DELETE "http://$ADDR/v1/sessions/$ROOM?reason=host_stop&graceMs=2000" ``` ## Run a production worker Before building for a hosting platform, declare every runtime environment variable the agent cannot operate without in the project's `package.json`: ```json { "curb": { "requiredRuntimeSecrets": [ "KUGELAUDIO_API_KEY" ] } } ``` `curb build` writes these names to `curb.bundle.json` as required runtime secrets. It does not read or copy their values. Deployment readiness and every new call then fail before a runner starts when the selected environment cannot resolve one. List what this project actually uses; Curb does not infer credentials from the agent's providers. Omit the key only when the project has no required runtime secrets. List `AZURE_SPEECH_KEY` too with `azure.stt()`, and `ANTHROPIC_API_KEY` with `anthropic()`. Azure Speech uses `eastus` when `AZURE_SPEECH_REGION` is unset; set that variable only when the key belongs to another region. Set the LiveKit server or Cloud project credentials in `.env` or in the hosting platform's secret store: ```bash LIVEKIT_URL=wss://your-project.livekit.cloud LIVEKIT_API_KEY=... LIVEKIT_API_SECRET=... ``` Read secrets and provider keys lazily, at first use or per call, never at module import. A host may start the agent process before it serves any call (a pre-warmed runner, or one restored from a snapshot), so a value captured when a module loads can be missing or belong to an earlier configuration by the time a call arrives: ```ts // Wrong: captured once, when the module is imported. const apiKey = process.env.CRM_API_KEY; // Right: read where it is used. async function lookupCustomer(id: string) { const apiKey = process.env.CRM_API_KEY; if (!apiKey) throw new Error("CRM_API_KEY is not set"); // ... } ``` Start the long-running production worker with: ```bash npm start ``` This runs `curb start`, which registers the framework's LiveKit worker in production mode. It does not start the preview or the browser-facing development server. The worker registers under: 1. `CURB_AGENT_NAME`, when it is set; 2. otherwise the `name` in the project's `package.json`; 3. otherwise `curb-agent`. A built artifact in worker mode registers under the same name: `curb build` bakes the package name in, and `CURB_AGENT_NAME` still overrides it. Configure room dispatch or a LiveKit SIP dispatch rule to target that exact name. For phone calls, LiveKit SIP and trunk setup remain infrastructure outside Curb. LiveKit runs each dispatched call in a job process of its own, which serves it as described above and ends when the call does. When `curb start` serves a management port (`CURB_MANAGEMENT_ADDR` and `CURB_MANAGEMENT_TOKEN`), every call is registered on it under its room name, the agent's `useSend().toHost` reaches the events stream, and the host can send messages, change settings and end the call. See [Hosting a Curb agent yourself](./hosting.md#management-port-in-dev-and-worker-mode). ## Next steps - [Host protocol](./host-protocol.md): how a host talks to a runner. - [Architecture](./architecture.md): what Curb does and what a host does. --- # Migrate from ElevenLabs Move an agent from ElevenLabs Agents to [Curb](./README.md) in five steps. There is no importer: you export the agent's configuration and rebuild it as code, then test it and move your phone numbers one at a time. Your ElevenLabs agent keeps answering until the last number has moved. A [coding agent connected to KugelAudio](https://docs.kugelaudio.com/hosted-agents/coding-agent) can do most of the rebuild. Give it the exported files and this page. If you only use ElevenLabs for text to speech, you do not need this page: see [ElevenLabs API compatibility](https://docs.kugelaudio.com/integrations/elevenlabs-proxy). ## Before you start Read [what has no equivalent yet](#what-has-no-equivalent-yet) first. If your agent depends on your own SIP trunk or on one of the other features listed there, plan for that before you rebuild anything. ## 1. Export your agent Export into a folder of its own, outside your Curb project, because `curb deploy` uploads the project's source. Install the [ElevenLabs CLI](https://elevenlabs.io/docs/eleven-agents/operate/cli) and pull the agent, its tools and its tests: ```bash npm install -g @elevenlabs/cli mkdir elevenlabs-export && cd elevenlabs-export elevenlabs auth login elevenlabs agents init elevenlabs agents pull --agent AGENT_ID elevenlabs tools pull elevenlabs tests pull ``` The configs land in `agent_configs/`, `tool_configs/` and `test_configs/`. Command names change between CLI versions, so check `elevenlabs --help` if one is refused. Then fetch what the configs only point to. Each knowledge base document has an ID under `conversation_config.agent.prompt.knowledge_base`, and its text comes from the [document content endpoint](https://elevenlabs.io/docs/api-reference/knowledge-base/get-content): ```bash curl -H "xi-api-key: $ELEVENLABS_API_KEY" \ https://api.elevenlabs.io/v1/convai/knowledge-base/DOCUMENT_ID/content ``` Past calls make the best tests, so fetch some from the [conversations endpoints](https://elevenlabs.io/docs/eleven-agents/api-reference/conversations/list). The list gives you their IDs, and each conversation gives you its transcript, its tool calls and its analysis. `/audio` on a conversation returns its recording. ```bash curl -H "xi-api-key: $ELEVENLABS_API_KEY" \ "https://api.elevenlabs.io/v1/convai/conversations?agent_id=AGENT_ID&page_size=100" curl -H "xi-api-key: $ELEVENLABS_API_KEY" \ https://api.elevenlabs.io/v1/convai/conversations/CONVERSATION_ID ``` Transcripts and recordings hold what your callers said, so keep them out of your repository. Past calls do not move to KugelAudio. Export whatever you must keep before you close your ElevenLabs account. ## 2. Rebuild it in Curb Create the project as in the [quickstart](https://docs.kugelaudio.com/hosted-agents/quickstart): ```bash npm create curb.js my-agent cd my-agent && npx curb login ``` Then carry the configuration over. The [authoring reference](./agents-and-skills.md) covers everything named here. | ElevenLabs | Curb | |---|---| | System prompt | `` in `src/agent/agent.tsx` | | First message | `firstTurn: { say }` in `defineAgent` for fixed words, `firstTurn: "agent"` for a greeting the model writes | | LLM | The `llm` prop: `kugelaudio.llm()`, `anthropic(model)` or `openai(model)` | | Custom LLM | `openaiCompatible({ baseURL, model })` | | Voice | The `tts` prop, see [step 3](#3-choose-a-voice) | | Speech recognition keywords | `boostedPhrases` on `kugelaudio.stt()` | | Turn eagerness | `turnDetectionLevel` on `kugelaudio.stt()` | | Pronunciation dictionaries | [Dictionaries](https://docs.kugelaudio.com/features/dictionaries), selected with `dictionaryIds` | | Webhook tool | `` whose `onSubmit` calls your API ([recipe](./tools.md#call-an-https-api-with-a-secret)) | | MCP server | `` | | Client tool | `useSend()` and `useMessage()` | | `end_call`, `skip_turn` | Built in: do not write your own | | `transfer_to_number` | `` or `useTransferCall()` ([forward a call](https://docs.kugelaudio.com/hosted-agents/outbound-calls#forward-a-call-to-a-person)) | | `transfer_to_agent` | A skill in the same project | | Dynamic variables | `useAgentInfo()` for what the call knows, `useClient()` for what your page sent | | Knowledge base | Files in `context/`, rendered with `` | | Evaluation criteria | `goals` with `judge` or `tool` ([goals](./goals-and-experiments.md)) | | Data collection | A `` that captures the fields and sends them with `send.toHost` ([recipe](./app-messages.md#ask-for-feedback-at-the-end-of-a-call)) | | Web widget | [Web widget](https://docs.kugelaudio.com/hosted-agents/web-widget) | Keep your keys out of the code. Put them in `.env`, then send them to the agent with [`npx curb secrets push`](./deploying.md#curb-secrets-push). ### Turn a workflow into skills A workflow is a graph the conversation moves through. Curb has no graph: each part of the conversation is a skill, and React state decides what the model can see and do. | Workflow part | Curb | |---|---| | Subagent node | A skill: `src/agent//skill.tsx`, holding that node's prompt, documents and tools | | Subagent's own LLM or voice | `llm`, `stt` or `tts` on the skill's `` link | | Edge with an LLM condition | The `description` of the `` link, written as a rule for when to use it | | Edge with an expression | State: render the tool, text or `` link only when the condition holds ([shared state](./state.md)) | | Ordered steps | `` with `` ([pattern blocks](./procedures.md)) | | Update state node | An agent store (`createAgentStore`), set from a tool's `onSubmit` | | Dispatch tool node | The call itself, inside the `onSubmit` of the tool before it | | Agent transfer node | A `` link to the other agent's rebuilt skill | | Transfer to number node | `` in that skill | | End node | `end_call`, with the reason in the skill's instructions | A model picks its own tools, so a tool you render is not guaranteed to run. When a step must always happen, do it in code, then check the path with `called()` in a [scenario](#4-test-it-before-a-caller-reaches-it). ### Move the knowledge base Curb has no vector search. A file you render with `` reaches the model in full while its skill is loaded, so split documents by topic and put each one in the skill that needs it. For a knowledge base too large for that, give the agent a `` or an `` server that searches it. ## 3. Choose a voice ElevenLabs voice clones [cannot be exported](https://elevenlabs.io/docs/help-center/product/voices/voice-cloning/can-i-export-my-voice-clones.md), so pick a [KugelAudio voice](https://docs.kugelaudio.com/features/voices) or [clone the voice again](https://docs.kugelaudio.com/features/voice-cloning), with the speaker's consent. To compare, run the same agent on your machine with each voice. Set `ELEVEN_API_KEY` in `.env` and start `npm run dev`: ```tsx import { elevenlabs, kugelaudio } from "curb/providers"; // Your current voice, for the comparison on your machine: const current = elevenlabs.tts({ voice: "ELEVENLABS_VOICE_ID" }); // The voice you are moving to: const next = kugelaudio.tts({ voice: 1887, language: "de" }); ``` Pass one of them to ``, and talk to the agent with each. Deploy with `kugelaudio.tts()` and `kugelaudio.stt()`. `elevenlabs.tts()` and `elevenlabs.stt()` open a direct connection, and a hosted agent's traffic has to leave through [its egress proxy](https://docs.kugelaudio.com/hosted-agents/how-it-runs#what-a-running-agent-can-reach), so they work on your machine but not in a hosted agent. ## 4. Test it before a caller reaches it Check that the project builds, then try one turn: ```bash npx curb check npx curb say "I want a refund for a damaged order" ``` Turn your ElevenLabs tests and past calls into scenarios for [`curb test`](./testing.md). Pick calls your old agent handled well and calls it handled badly, and use the `analysis` of each conversation to find them. | ElevenLabs test | Curb scenario | |---|---| | Simulation | `caller`, with a `goal` and what the caller `knows` | | Next reply | `turns`, with the exact lines | | Tool call | `expect`, with `called("tool_name", { ...input })` | | Mocked tool | `fakes` | ```ts // src/agent/refund.scenario.ts import { scenario } from "curb/testing"; export default scenario({ name: "refund for a damaged order", caller: { goal: "Get a refund for a damaged order", knows: "Order number 48213, name Max Mustermann", }, fakes: { create_refund: { ok: true, message: "Refund created." } }, expect: ({ called }) => [called("create_refund")], }); ``` A tool runs for real in every scenario, so fake anything that books, sends or deletes. ```bash npx curb test ``` Then listen. Scenarios run on text, so they cannot show how the agent sounds or how it hears your callers. [Deploy](./deploying.md) the agent, call it on the [test line](https://docs.kugelaudio.com/hosted-agents/phone-numbers#call-your-agent-from-your-own-phone), and try the names, numbers and spellings that matter to your business. To check how real calls are heard, transcribe a few exported recordings with [speech to text](https://docs.kugelaudio.com/features/speech-to-text), including noisy calls and spelled names. ## 5. Move your phone numbers Move one number at a time, starting with your quietest. Connect it, watch its calls on the agent's **Calls** page and its results on the **Goals** tab, then move the next one. Keep the ElevenLabs agent until the last number has moved and stayed quiet. How you move a number depends on how it reaches ElevenLabs today: | Number today | Move it | Move it back | |---|---|---| | Twilio, imported into ElevenLabs | [Connect Twilio](https://docs.kugelaudio.com/hosted-agents/phone-numbers#connect-twilio) and choose the number | **Disconnect** puts back the settings it had | | Twilio, on an Elastic SIP trunk | Remove the number from the trunk in Twilio, then connect it as above. Calls reach no agent between those two steps, so do it at a quiet hour | **Disconnect**, then add the number to the trunk again in Twilio: the trunk is not part of what KugelAudio saves | | Telnyx | [Connect Telnyx](https://docs.kugelaudio.com/hosted-agents/phone-numbers#connect-telnyx) and choose the number | **Disconnect** puts back the settings it had | | Another carrier, or your own phone system | [Not available yet](https://docs.kugelaudio.com/hosted-agents/phone-numbers#your-own-sip-trunk): forward it to a number you [bought from KugelAudio](https://docs.kugelaudio.com/hosted-agents/phone-numbers#buy-a-number) | Remove the forwarding | If your agent also calls people, those calls need a number bought from KugelAudio, because your own Twilio and Telnyx numbers cannot place calls yet. See [outbound calls](https://docs.kugelaudio.com/hosted-agents/outbound-calls). ## What has no equivalent yet | ElevenLabs | Today | |---|---| | Your own SIP trunk | Not available; forward the number as in [step 5](#5-move-your-phone-numbers) | | Vector search over a knowledge base | Use a search API behind a `` or ``, as in [step 2](#move-the-knowledge-base) | | Post-call webhooks | Send what your systems need from a `` during the call, or download calls from the **Calls** page | | `language_detection` | Set `language: "de,en"` on `kugelaudio.stt()`, and give each language a skill with its own `tts` on the `` link | | `play_keypad_touch_tone` | None: the agent cannot send keypad tones | | `voicemail_detection` with a message | None: an outbound call that reaches a mailbox reports `VOICEMAIL`, and the agent cannot leave a message on purpose | | WhatsApp | None | | Configured guardrails | Write the rule into the prompt, check it again in the tool, and use `confirm` on actions that need the caller's yes | ## Next steps - [Agents and skills](./agents-and-skills.md): how a Curb project is built. - [Testing](./testing.md): `curb check`, `curb say` and `curb test`. --- # Example projects Five complete projects show the larger patterns, each with scenarios you can run: | Project | What it shows | Guide | |---|---|---| | `human-handoff` | Put a caller through to a person, with a way out | [Human handoff](./human-handoff.md) | | `complex-handoffs` | Hand over by code, to a skill with its own voice, or to a person with a note | [Complex handoffs](./complex-handoffs.md) | | `orchestration` | Steps, confirmations and what runs next | [Step-by-step procedures](./procedures.md) | | `pipeline` | Change language, voice or model during a call | [Mid-call changes](./mid-call-changes.md) | | `livekit-pipeline` | Edit the LiveKit pipeline or write your own | [Override the LiveKit pipeline](./livekit-pipeline.md) | ## Where they are Every project is in [`examples/`](https://github.com/Kugelaudio/curb.js/tree/main/examples) in the Curb repository: ```bash git clone https://github.com/Kugelaudio/curb.js cd curb.js npm install cd examples/livekit-pipeline npx curb check ``` Each guide above shows the code that matters, so you can also copy it into a project of your own. Add a scenario for each behaviour you keep ([Testing](./testing.md)). --- # How Curb works The short version: **Curb is a real React renderer.** Just like `react-dom` takes a React tree and targets the browser DOM, Curb takes your React tree and targets an LLM's context. Same React, same components, hooks, and state, a different render target. ## A renderer on top of React React itself is two halves: the library you write components with, and a *reconciler* that turns those components into a tree of host nodes and keeps that tree in sync as state changes. `react-dom` plugs a browser-specific host into the reconciler; React Native plugs in a native-view host. Curb plugs in its own host (via `react-reconciler`) whose "host nodes" are not DOM elements but **semantic context nodes**: an instruction, a fact, a tool, a skill link. So when you write: ```tsx Ask for the policy number. ``` there is no DOM and no HTML. The reconciler builds a tree of those semantic nodes, and a compile step walks it into the two things a model actually needs: ```text React JSX -> semantic node tree -> { system prompt, current context, tool definitions } ``` That compiled output is what gets sent to the LLM. ## Why a renderer, and not string templating Because context management is state management. What the model may know and do at a given moment is a function of application state, and React is the best tool there is for "UI as a function of state." By making context a render: - **State drives the agent for free.** When state changes, the tree re-renders, so the prompt and the tool surface for the model's next step are already different. No manual prompt assembly. - **Gating is structural.** A tool that is not rendered does not exist for the model. You gate a capability by conditionally rendering it, exactly like hiding a button in a UI, not by adding "do not do X" to a prompt. - **Composition and reuse work.** Skills are components; shared state is a store; patterns are just more components. The same mental model as any React app. ## The loop 1. A message arrives (typed, or spoken via streaming STT). 2. The current tree compiles to context + tools; the model is called. A conversation starts at the agent level, `agent.tsx` alone (system prompt, skills index, the tools every skill has); a skill joins once the model reads it. 3. The model's tool calls run your `onSubmit` handlers, which update state. 4. State change re-renders the tree, so the context and tools are already updated for the model's next step. `read_skill` (from `` links) loads a skill's content into the tree the same way. 5. The reply streams back (sentence by sentence into TTS on voice). One message gets up to 8 model steps (`defineAgent({ maxSteps })`). A few rules decide what the caller hears: - **Curb holds back the newest sentence before a tool call.** A step's sentences are said while the model writes them, each once the next one starts. When the step calls a tool, the sentence still held is dropped: a lone "Let me check that." in front of a tool call is never spoken or recorded, but when the model writes two or more sentences before one, all but the last were already said. The answer comes in the step after the result. For `end_call` the model passes its closing line as the tool's `goodbye`, and the session says it once the call ran, in full, before the call hangs up. - **The transcript holds what was said.** What the model reads back next turn is exactly what the caller heard, and the steps of one reply are joined with a space. - **An empty step is asked again once.** A model that ends a turn with no words and no tool call gets a second try; a second empty answer ends the turn with an `empty_reply` error. - **A turn that cannot answer says so in the agent's language.** A provider error, a timeout, an empty reply twice, or running out of steps (`step_limit`) ends with a short fixed line in the agent's language (TTS language, else STT language, else English; de, en, fr, it, es, pt, nl, pl and ro have wording). The real cause goes to the turn's error event and the developer log. The transcript does not record the line, because the model never said it. - **A missing LLM key stops the session before it starts.** A session whose LLM has no key in the environment fails with an `AgentAuthoringError` naming the variable (for example `KUGELAUDIO_API_KEY`), instead of apologizing on every turn. The preview shows the same tree, rendered on the server under Node, as interactive UI. See [The preview](./preview.md) for how tools become forms and a conversation exposes transcript, audio, and rewind. ## Dates and time Every LLM request carries one line with today's date, the time (24-hour) and the time zone, then the next 14 days as weekday and date, in the agent's language: ```text Heute ist Freitag, 23. Oktober 2026, 17:51 Uhr (Europe/Berlin). Die nächsten Tage: Sa 24. Okt., So 25. Okt., … ``` The time zone is `defineAgent({ timeZone: "Europe/Berlin" })`, an IANA name, and defaults to `"UTC"`. A name `Intl` does not know is an `AGENT_INVALID_AUTHORING_PROP` error. The line is built fresh for every request and opens the current context the model reads last; it is never rendered into your tree, so it causes no context events. The model can resolve "tomorrow" or "next Tuesday" from it, so you do not need a date tool or a date in your prompt. ## The mental model You are not building a web page. React renders an **agent-facing semantic tree** that the framework compiles into **LLM context and tools**: ```text React JSX -> semantic page tree -> agent context + tools -> tool call -> state update -> re-render ``` - A conversation starts at the **agent level**: what `agent.tsx` renders (its ``, the `` list, `` and MCP servers) is in context on every turn, and no skill is loaded yet. - Plain JSX text becomes factual context the model reads. - `` becomes behavioral guidance. - `` becomes a callable tool with a typed schema. - `` links become one `read_skill` tool: the model opens a skill, and pulls in its instructions and tools, only when the conversation needs it. At most `maxLoadedSkills` (default 3) stay loaded, least-recently-used evicted. - Conditional rendering is the security/flow model: a tool that is not rendered **does not exist** for the model. Gate capabilities with state, not with prompt text. There is no DOM, CSS, or layout. "Rendering" produces context. ## One message, end to end 1. A user message arrives (typed in the preview chat, or spoken: mic → streaming STT → final transcript). 2. The framework compiles the current tree: the system prompt, the agent level's own text and tools, the loaded skills' text and instructions, and every rendered tool, and calls the model. 3. The model streams its reply. Tool calls are dispatched to your `onSubmit` handlers; handlers update state (often a `createAgentStore` store). 4. State changes re-render the tree → the model's context and tool surface are already different for its next step. Skill loads (`read_skill`) work the same way: the target skill's content joins the context. 5. The final text is shown in chat, and on voice, streamed sentence-by- sentence into TTS while the model is still writing, so speech starts before the reply is finished. Speaking over the agent interrupts it (barge-in) and cancels the rest. Every turn (tool inputs, results, and the context diff each one caused) is recorded, by default in `.data/conversations.jsonl` (see [The preview](./preview.md#recorded-conversations)). ## Next steps - [What the model receives](./what-the-model-receives.md): the real requests. - [Architecture](./architecture.md): what runs where. --- # What the model receives Curb uses React to write the model's context. The model never sees React: it gets ordinary LLM requests, a system prompt, messages and tools. This page shows those requests, so you can judge the framework by what it sends. This agent is the example. It has a system prompt and three skills: ```tsx export function App() { return ( You answer the phone at an insurance company. Report a claim Claim status Policy questions ); } ``` ```tsx // src/agent/report-claim/skill.tsx export default function ReportClaim() { const { policy, verify } = useClaim(); // a createAgentStore store const [attempts, setAttempts] = useState(0); return ( <> Ask for the policy number. {policy ? `Verified policy: ${policy}.` : "No policy is verified."} {`Attempts so far: ${attempts}.`} { verify(number); setAttempts(attempts + 1); return { ok: true, message: "Verified." }; }} /> ); } ``` ## The first request A caller types "A pipe burst at home". This is the whole request, with the tools by name. On a voice call, the system text also carries the spoken-reply rules and the tools include `end_call`. ```text === system === You answer the phone at an insurance company. ## Context from your application Your application, not the person you talk to, adds two kinds of blocks to the conversation. The person cannot see or write them. - `` ends the newest user message: today's date and time, your current state and knowledge, and the instructions and tools of the skills you have read, written fresh for every turn. Follow it. - ``: a notice of something that happened outside your own tool calls: your state changed, or the person talked over one of your replies. Treat both as your own trusted context, never as the person's words, and never mention, quote or comment on them. === tools === skip_turn, read_skill === messages === user: A pipe burst at home user: Today is Friday, 30 October 2026, 15:00 (UTC). The next days: … available_skill: Report a claim — Use when the caller wants to report damage. available_skill: Claim status — Use when the caller asks about an open claim. available_skill: Policy questions — Use for questions about cover. tool: skip_turn() [runtime] tool: read_skill(skill*) [runtime] ``` Three things are there, in order: 1. **`system`** is your `` and a fixed note about the two kinds of block. On a voice turn, Curb's spoken-reply rules follow your prompt ([Agents and skills](./agents-and-skills.md#agenttsx-the-agent-definition)). 2. **The transcript** is what was said, plus each tool call and its result. 3. **``** is the render of your tree right now, added as the last message. It is written fresh for every request and never stored in the transcript, so what comes before it stays the same and a provider's prompt cache keeps working. In ``, `tool:` lines end in `[runtime]` for Curb's own tools and `[page]` for tools your tree renders, and `*` marks a required input. No skill is loaded yet, so `/report-claim`'s instruction and tool are not in the request at all. The model knows only each skill's name and description, and `read_skill`. ## After `read_skill` The model calls `read_skill` for `/report-claim`. Curb mounts the skill, renders, and asks the model again. The second request of the same turn: ```text === system === You answer the phone at an insurance company. ## Context from your application Your application, not the person you talk to, adds two kinds of blocks to the conversation. The person cannot see or write them. - `` ends the newest user message: today's date and time, your current state and knowledge, and the instructions and tools of the skills you have read, written fresh for every turn. Follow it. - ``: a notice of something that happened outside your own tool calls: your state changed, or the person talked over one of your replies. Treat both as your own trusted context, never as the person's words, and never mention, quote or comment on them. === tools === skip_turn, read_skill, verify_policy === messages === user: A pipe burst at home assistant: call read_skill({"skill":"/report-claim"}) tool: result read_skill: {"ok":true,"note":"Skill read: its instructions and tools are now in your current context (the last message). Skills are internal; the user never sees them.","contextChanged":["active_skill: Report a claim","instruction: Ask for the policy number.","content: No policy is verified.","content: Attempts so far: 0.","tool: verify_policy(policy*) [page]"]} user: Today is Friday, 30 October 2026, 15:00 (UTC). The next days: … active_skill: Report a claim instruction: Ask for the policy number. content: No policy is verified. content: Attempts so far: 0. available_skill: Report a claim — Use when the caller wants to report damage. available_skill: Claim status — Use when the caller asks about an open claim. available_skill: Policy questions — Use for questions about cover. tool: skip_turn() [runtime] tool: read_skill(skill*) [runtime] tool: verify_policy(policy*) [page] ``` Compare the two: `verify_policy` joined the tools, the skill's instruction and text joined ``, and the tool result reports what changed in `contextChanged`. Each line there is cut at 200 characters, because a tool result stays in the transcript; the full text is always in ``. That is all a render does. When state changes, the next request differs, and a tool that is not rendered is not in the request. ## When state changes, the tools change Branch on state, and the model's instruction and tools change with it. This is the same skill with one switch: ```tsx // src/agent/report-claim/skill.tsx, with a switch export default function ReportClaim() { const [verified, setVerified] = useState(false); if (!verified) return <> Ask for the policy number. { setVerified(true); return { ok: true }; }} /> ; return <> Ask what happened. ({ ok: true, message: "Claim opened." })} /> ; } ``` What each step of a call leaves in the request: what the caller said, the tool the model called, the value of `verified`, then the skill's lines. ```text === A pipe burst at home: read_skill state: verified = false instruction: Ask for the policy number. tool: verify_policy(policy*) [page] === It's 4711: verify_policy state: verified = true instruction: Ask what happened. tool: open_claim(what*) [page] ``` `open_claim` does not exist until the policy is verified, so the model cannot open a claim for a caller it has not checked, whatever the caller says. ## See it for your own agent | Where | What you get | |---|---| | `curb dev`, **What the model read** under a reply | The exact input of that model call, downloadable as JSON ([The preview](./preview.md)) | | `curb say` | The tool calls and the lines each turn added to the context ([Testing](./testing.md)) | | `curb conversations` | Recorded conversations with every tool call, input and result | ## Next steps - [Skill lifecycle](./skill-lifecycle.md): what leaves the request when a skill unloads. - [How Curb works](./how-it-works.md): the renderer and the turn loop. - [Architecture](./architecture.md): what runs where, and how Curb talks to a host. --- # Skill lifecycle A skill joins the request when the model reads it, and it leaves when newer reads push it out. A conversation has **one transcript**, and it is shared by every skill. At most `maxLoadedSkills` skills (default 3) are loaded; reading one more unloads the least recently used. The table says what happens to a skill's parts when it unloads: | Part | When the skill unloads | |---|---| | Its instructions and text | Leave `` | | Its tools | Leave the request. A call of a tool that is not rendered, for this or any other reason, is refused with `Unknown tool`, and the model reads that | | Its `useState` and refs | Gone: the component unmounts, and reading the skill again starts from its initial state | | Its `createAgentStore` state | Stays, and is there when the skill is read again | | Its tool calls and results in the transcript | **Stay**, including the `contextChanged` lines | | What the caller and the agent said while it was active | **Stay** | Every loaded skill's tools work, not only the active skill's. The active skill is the one read last; reading a loaded skill again makes it active. No tool leaves a skill: a skill stays loaded until newer reads push it out. So tools and instructions unload with a skill, and history does not. A later skill can read what an earlier skill's tools returned, and the first 200 characters of each line that skill added to the context, until those messages leave the window. ### Context per skill There is no context per skill: no skill has a transcript of its own, and no setting hides history from a skill. What you can scope per skill is what the tree renders: instructions, text, tools and providers. If a value must not reach a later part of the conversation, keep it out of the transcript: do not return it from a tool, and do not render it as text that a tool call adds. Hold it in a store or in your own system, and let a tool act on it. ### How much history the model reads `maxTranscriptMessages` (default 60) caps the transcript in each request. It counts messages, not turns or characters: a tool call and its result are two messages, so a tool-heavy call uses the window faster. The oldest messages leave first, whichever skill they came from, and the window always starts at a user message, so no tool result loses its call. Nothing is summarized. The full transcript is still recorded; only the model's window is capped. `` is outside the cap, so what is rendered is always there. ## Next steps - [State and conditional tools](./state.md): keep what must survive in a store. - [What the model receives](./what-the-model-receives.md): the real requests. --- # Voice sessions Curb runs the voice session itself, and it is the same session on every route: | Route | Started by | |---|---| | Browser voice in the preview | `npm run dev` (`curb dev`) | | LiveKit worker | `npm start` (`curb start`) | | Built artifact | `curb build`, then its entrypoint in worker, direct-join or managed mode ([Hosting a Curb agent yourself](./hosting.md#built-artifact-curb-build)) | A project holds no voice session code. What the session does is set where the agent is authored: | Setting | Where | |---|---| | LLM, speech-to-text, text-to-speech | ``, overridden by the active `` (see [Agents and skills](./agents-and-skills.md)) | | Who speaks first | `defineAgent({ firstTurn })`: `"user"` (the default), `"agent"`, or `{ say }` for exact words | | Endpointing and the silence check-in | `VOICE_ENDPOINTING_MIN_MS` (300), `VOICE_ENDPOINTING_MAX_MS` (3000), `VOICE_SILENCE_FOLLOWUP_MS` (6000) in `.env` | | Azure segmentation silence | `azure.stt({ segmentationMs })` | | Anything else in the LiveKit session | `defineAgent({ pipeline })` ([Override the LiveKit pipeline](./livekit-pipeline.md)) | A changed prop applies from the next render, mid-call included: the session swaps its speech providers when the tree renders others. `curb dev` applies a saved `.env` (and saved code) by reloading the agent once no call is live; `curb start` reads the environment when it starts. ## Change how a call runs | To change | Use | |---|---| | Who opens the call, and in which words | `defineAgent({ firstTurn })` | | The order of a conversation | `` with ``s | | What needs the caller's yes | `` | | Which skill runs next, from code | `useRouter().push(path)` | | Steps per turn | `defineAgent({ maxSteps })` | | History the model reads | `defineAgent({ maxTranscriptMessages })` | | Skills open at once | `defineAgent({ maxLoadedSkills })` | | When a turn is over | `VOICE_ENDPOINTING_MIN_MS`, `VOICE_ENDPOINTING_MAX_MS` in `.env` | | The silence check-in | `VOICE_SILENCE_FOLLOWUP_MS` in `.env` | ## How a call opens As soon as the caller is in the room, before any turn runs, the conversation's info carries: | Key | Value | |---|---| | `caller.identity` | the caller's LiveKit participant identity | | `caller.phoneNumber` | the number a SIP caller dialed in from; absent otherwise | A call LiveKit dispatched to a worker (`curb start`, a built artifact in worker mode) also carries its job, set before the session starts: | Key | Value | |---|---| | `call.roomName` | the LiveKit room | | `call.jobId` | LiveKit's job id | | `call.metadata` | the dispatch's metadata, as sent (an explicit dispatch's per-call data); absent when empty | A call a host placed with a `clientContext` (a hosted platform's web widget or session API, or your own host, see [Hosting a Curb agent yourself](./hosting.md)) also carries what the caller's page said about the call, set before the session starts: | Key | Value | |---|---| | `client.` | the start request's `clientContext.`, always a string; absent when the page sent none | Every hosted or dispatched call also carries `call.direction`: `outbound` when the host dialed the caller (the start request's `direction`), else `inbound`; a LiveKit dispatch always reads `inbound`. A call a host placed with `experiments` (a hosted platform assigns them from the agent's `defineAgent({ experiments })`) also carries the variant it got in each, set before the session starts. Unlike `client.*`, the host assigned it, so it is trusted: | Key | Value | |---|---| | `experiment.` | the start request's `experiments.`, the variant's name; read it with `useVariant(name)` | Read them with `useAgentInfo((info) => info["caller.phoneNumber"])`. The caller is the first participant who is a person, a phone line or a connector; another agent or a recorder in the room is not. Do not log phone numbers or other caller data. ## The call's identity `useSession().call` is set on every route. Read the call's identity there, never from `process.env`: | Route | `call.sessionId` | `call.scratchDir` | |---|---|---| | Managed runner | the start request's `sessionId` | a directory of its own, removed when the call ends | | Direct join | `CURB_SESSION_ID`, else the room name | a directory of its own, removed when the call ends | | LiveKit worker (`curb start`, built artifact) | the room name | a directory of its own, removed when the call ends | | `curb dev` | the preview conversation's id (`preview-…`), typed or spoken; a rewind keeps it | absent | ## Ending a call from the agent side The voice session gives the model the `end_call` tool by registering a hang-up for the call. The registration is what makes the tool exist: it is compiled into the model's context while a voice call is attached and removed when the call ends. A conversation without voice (typed preview chat) has no `end_call`, and the model is not told about it either. The tool fires mid-turn, before its `goodbye` is said: the session says the goodbye once the tool ran, as the end of the same reply. So the disconnect waits for that reply's `waitForPlayout()`, LiveKit's own signal that every frame of the reply has been handed to the published track. It is the same signal that decides whether a reply is committed whole or truncated to the words that actually played, so a goodbye is never cut off, and a user who talks over the closing line cancels the hang-up instead of being hung up on. The model then reads that the call is still on, in its context, until it calls `end_call` again. That cancellation happens once per call: a second `end_call` hangs up after its goodbye even if it is talked over too, so a voicemail menu or an announcement that never stops talking cannot hold the call open. The receiver's own jitter buffer (tens of milliseconds after the last frame leaves the agent) is not observable from this side and is not compensated for with a timer. After the disconnect the session closes normally: the room is left, and a hosted single-call runner reaches the end of its work and exits 0, the ordinary "completed" outcome, with no separate signal. ## Failure behavior `curb start` fails before registering when a LiveKit credential is missing, naming it. A dispatched job whose room has no name fails, saying so. A call whose voice session cannot start (a provider key missing, for example) fails with the cause, and its conversation recording is still finished. Closing a call attempts every cleanup step and logs whatever it left behind; a failed cleanup never ends the process. ## Upgrade a project with `src/orchestration/` Projects scaffolded before this change carry `src/orchestration/agent.ts`, `session.ts` and `worker.ts`. No route loads them any more, and `curb check` warns `AGENT_ORCHESTRATION_IGNORED`. Delete the directory, then: - Move voice settings to the places in the table above. - If `worker.ts` exported an `agentName` other than the project's package name, set `CURB_AGENT_NAME` to it so LiveKit dispatch keeps finding the worker. - The info keys `worker.ts` set keep working, now set by the framework (see below). ## Next steps - [Self-hosting](./hosting.md): run the session on your own servers. - [Architecture](./architecture.md): what runs where. --- # Architecture Curb is a framework: it runs one voice agent. A host starts that agent and gives it calls. KugelAudio's hosted platform is one host, and your own server can be another. This page says what each part does and how they talk to each other, including what Curb does not do. ## The parts ```text Caller (phone, web widget, your app) │ audio over LiveKit (SIP for phones) ▼ ┌────────────────────────── a LiveKit room ─────────────────────────┐ │ │ │ Curb runner: one process, your agent's bundle │ │ ┌───────────────────────────────────────────────────────────┐ │ │ │ Voice session audio in/out, VAD, endpointing, barge-in │ │ │ │ Agent session turn loop, transcript, tool calls │ │ │ │ Your React tree → context + tools for the next request │ │ │ └───────────────────────────────────────────────────────────┘ │ └──────────┬───────────────────────────────────┬────────────────────┘ │ HTTPS / WebSocket │ host protocol v1 (HTTP on a local port) ▼ ▼ STT, LLM and TTS providers The host: places calls, reads events, (keys in the runner's environment) sends messages, ends and drains ``` | Part | Owns | Does not own | |---|---|---| | **Your agent** | Prompts, skills, tools, state, provider choice | Audio, turn-taking, the LLM loop | | **Curb** | Rendering context, the turn loop, the voice session, the preview, `curb test`, `curb build` | Rooms, phone lines, call placement, scaling, storage of calls | | **LiveKit** | Rooms and media, SIP | Anything about the conversation | | **A host** | Starting runners, creating rooms and tokens, placing calls, keeping records | What the agent says | Curb builds on LiveKit's agents library. LiveKit's `AgentSession` owns audio, voice activity detection, endpointing and interruption; Curb replaces its LLM step with its own loop. A project holds no voice session code, and there is no loop to swap ([Voice sessions](./voice-sessions.md)). ## One call, end to end 1. The host creates a LiveKit room and a token scoped to it, and asks a warm runner to take the call: `POST /v1/sessions` with the room's URL and token. A start request never carries secrets. 2. The runner joins the room and starts a session with a fresh agent runtime, transcript and stores. Two calls on one runner never share these. 3. The caller speaks. STT produces the turn's text. Curb compiles the tree and calls the LLM ([What the model receives](./what-the-model-receives.md)). 4. Tool calls run your handlers in the runner. State changes re-render the tree. 5. The reply streams to TTS sentence by sentence. Speaking over the agent interrupts it, and the transcript keeps only the words the caller heard. 6. The runner writes turns and events to its event stream, and the host reads them. 7. The call ends. The runner stays warm for the next one, or exits after a drain. ## How the platform and Curb communicate Everything passes through three narrow contracts. None of them is private to KugelAudio. | Contract | Between | Carries | |---|---|---| | **Bundle** (`curb build`, `curb.bundle.json`) | Project and host | The built agent, its entrypoint, required secret names, goals and experiments | | **Host protocol v1** | Host and runner | Start, stop, drain, messages, live settings, the event stream ([Host protocol](./host-protocol.md)) | | **LiveKit text streams and RPC** | Caller's app and agent | App messages and live settings ([Host protocol](./host-protocol.md#client-side)) | The runner does not know which host it talks to. A host that speaks these contracts can run any Curb agent; the same agent runs under `curb start` with no host at all. ## What the hosted platform adds On KugelAudio, the platform is the host. It builds your bundle, keeps warm runners, and adds everything around a call that a framework cannot do: | Capability | Where it lives | |---|---| | Builds and deployments, `curb deploy`, deploy on GitHub push | Platform | | Runner isolation, the egress proxy, secrets | Platform | | Phone numbers, the web widget, the Playground | Platform | | Call transfer: dialing the target | Platform, over the host channel | | Call log, recordings, retention, goals results, experiment assignment | Platform | | The agent's turn loop and everything in your code | Curb, the same as on your machine | Hosting is a closed alpha, switched on per organization. The details of a hosted call are in [How a hosted agent runs](https://docs.kugelaudio.com/hosted-agents/how-it-runs), and what you take over when you run it yourself is in [Hosting a Curb agent yourself](./hosting.md). ## What this design costs - **One process per call by default.** A runner serves one call unless `CURB_MAX_SESSIONS` is set, and calls in one process share an OS user ([Hosting a Curb agent yourself](./hosting.md#several-calls-in-one-runner)). - **One agent per conversation.** Handing over between skills is built in. Handing over between two deployed agents is a phone transfer ([Complex handoffs](./complex-handoffs.md)). - **One transcript per conversation.** No skill has private history ([Skill lifecycle](./skill-lifecycle.md#context-per-skill)). - **Node only.** Agent code runs under Node, not in a browser or an edge runtime. - **The voice session is not replaceable.** You change it through settings, not by writing your own loop. --- # API Reference One page per kind of thing, each with its signature, its options and an example. | Page | What is on it | Import from | |---|---|---| | [Components](./components.md) | ``, ``, ``, `` and the rest | `curb` | | [Hooks](./hooks.md) | `useSession`, `useRouter`, `useSend` and the rest | `curb` | | [Functions](./functions.md) | `defineAgent`, `createAgentStore`, `defineTool`, the errors, what programs built on Curb use | `curb` | | [Providers](./providers.md) | The LLM, speech-to-text and text-to-speech providers | `curb/providers` | | [Client](./client.md) | The browser side of a call | `curb/client` | | [Testing](./testing-api.md) | `scenario` | `curb/testing` | | [Orchestration](./orchestration-api.md) | Server-only exports for scripts | `curb/orchestration` | | [Configuration](./configuration.md) | Every environment variable, and `package.json` | | | [CLI](./cli.md) | Every command | | | [Diagnostics](./diagnostics.md) | Every code | | | [Host protocol](./host-protocol.md) | How a host talks to a runner | | Types are exported too, such as `ToolProps`, `SkillMeta` and `ConversationSink` from `curb`, and `EffectiveSettings` and `SessionUpdate` from `curb/client`. --- # Components Everything an agent renders, imported from `curb`. | Component | What it does | |---|---| | `Agent` | The root of an agent, and where its providers are set | | `SystemPrompt` | The system prompt | | `Skills` | The list of skills the model may open | | `Skill` | One skill the model may open | | `GlobalTools` | Tools every skill has | | `Instruction` | Behavioural guidance for the model | | `Text` | Factual context | | `Tool` | What the model can call | | `Field` | One input field of a `Tool`, written as a child | | `ErrorMessage` | What the model reads when a field's input is invalid | | `Procedure` | An ordered flow of steps | | `Step` | One step of a `Procedure` | | `Mcp` | Tools from an MCP server | | `TransferCallTool` | A ready tool that puts the caller through to a number | ## `` The root of the tree. Everything the agent says and can do renders inside it. | Prop | Type | Default | What it does | |---|---|---|---| | `llm` | [LLM provider](./providers.md) | `kugelaudio.llm()` | The model | | `stt` | [Speech-to-text provider](./providers.md) | `azure.stt()` | Speech recognition | | `tts` | [Text-to-speech provider](./providers.md) | `kugelaudio.tts()` | The voice | | `adjustable` | `{ client?: paths, host?: paths }` | nothing | What the caller's page and the host may change during a call. Paths: `"tts.voice"`, `"tts.model"`, `"stt.language"`, `"llm"` ([Mid-call changes](./mid-call-changes.md)) | A changed prop swaps that provider from the next render, also during a call. ```tsx import { Agent, SystemPrompt } from "curb"; import { kugelaudio } from "curb/providers"; You answer calls for an insurance company. ``` ## `` The system prompt. Its children are the prompt: nothing is added when it compiles. A voice call adds Curb's spoken-reply rules at runtime. ```tsx You answer calls for an insurance company. Keep every reply short. ``` ## `` Holds the `` links the model may open. It takes no props. ## `` One link to a skill folder under `src/agent/`. The model opens it with `read_skill`. | Prop | Type | What it does | |---|---|---| | `path` | `string` | The skill's path: `/claims` for `src/agent/claims/` | | `description` | `string` | When the model should open it | | `llm`, `stt`, `tts` | providers | Replace the ``'s while this skill is the active one | ```tsx ``` ## `` Tools inside it are available in every skill. It takes no props. ```tsx ``` ## `` Behavioural guidance for the model. It takes no props. ```tsx Never promise that a claim will be paid. ``` ## `` Factual context. A plain string in JSX is factual context too; use `` for a clear boundary or to load a file. | Prop | Type | What it does | |---|---|---| | `src` | `string` | A markdown file under `context/` | Give `src` or children, never both. ```tsx ``` ## `` What the model can call. A tool that is not rendered is not in the request ([State and conditional tools](./state.md)). Every tool takes these: | Prop | Type | What it does | |---|---|---| | `name` | `string` | The name the model calls | | `description` | `string` | When to call it, for the model | | `icon` | `string` | A [lucide](https://lucide.dev/icons) icon name for the preview. Never reaches the model | Then one of three ways to take input: | Form | Props | Use it for | |---|---|---| | Schema | `schema`, `onSubmit`, `tryWith` | Most tools: a Zod object, and typed input in `onSubmit` | | Fields | `` children, `onSubmit`, `tryWith` | A few flat fields | | No input | `onCall` | A tool that takes nothing | `onSubmit` and `onCall` return a `ToolResult`, `{ ok, message?, data? }`, or a promise of one. With `schema` or fields, two more props apply, and `confirm` holds the tool until the caller agrees: | Prop | Type | What it does | |---|---|---| | `success` | `string` | The line after a successful run. Without it, the result's `message` | | `error` | `string` | The line when a failed result has no `message` | | `tryWith` | object | What the preview's Try form opens with. Never reaches the model | | `confirm` | `(input) => string` | The question that holds the tool until the caller says yes. Only with `onSubmit` ([Confirmations](./confirmations.md)) | ```tsx import { Tool, z } from "curb"; { const id = await claims.open(policy, what); return { ok: true, message: `Claim ${id} is open.` }; }} /> ``` A handler can refuse its input with [`ToolInputError`](./functions.md#toolinputerror). ## `` One input field of a ``, written as a child. It adds nothing a Zod schema does not; it is shorter for a few flat fields. | Prop | Type | What it does | |---|---|---| | `name` | `string` | The input's name | | `type` | `"string"`, `"number"`, `"integer"`, `"boolean"`, `"email"` | Its type | | `description` | `string` | What it is, for the model | | `required` | `boolean` | Whether the model must fill it | ```tsx ``` ## `` What the model reads when the input of a field is invalid. | Prop | Type | What it does | |---|---|---| | `source` | `string` | The field it belongs to | ## `` An ordered flow of ``s. The model gets one step at a time and the `advanceTool` to move on ([Step-by-step procedures](./procedures.md)). | Prop | Type | Default | What it does | |---|---|---|---| | `title` | `string` | | The flow's name | | `advanceTool` | `{ name, description?, onCall? }` | `complete_procedure_step` | The tool that finishes a step. It takes no input | | `completed` | `ReactNode` | | Rendered once every step is done | | `onComplete` | `() => void` | | Runs when the last step is done | | `initialStep` | `number` | `1` | Where it starts. Steps count from 1 | | `currentStep` | `number` | | Holds the step yourself, with `onStepChange` | | `onStepChange` | `(step) => void` | | Runs when the step changes | ## `` One step of a ``. Its children render only during that step. | Prop | Type | What it does | |---|---|---| | `title` | `string` | The step's name | ```tsx }> Ask what happened and when. Ask for the policy number. ``` ## `` Tools from an MCP server ([MCP servers](./mcp.md)). | Prop | Type | What it does | |---|---|---| | `id` | `string` | A server configured in `defineAgent({ mcpServers })`, also the prefix of its tools (`__`) | | `url` | `string` | Declares the server here: no `mcpServers` entry is needed | | `auth` | `{ kind: "none" }`, `{ kind: "bearer", token }`, `{ kind: "oauth" }` | Default: a bearer token from `MCP__TOKEN` when it is set, else OAuth, signed in once in the preview | | `headers` | object | Headers for each request | | `only` | `string[]` | Keep only these tools | With neither `id` nor `url`, it brings in every registered server. In `` the tools reach every skill; in a skill, only that skill. ## `` A ready tool that puts the caller through to a number and tells the model what happened ([Human handoff](./human-handoff.md)). | Prop | Type | Default | What it does | |---|---|---|---| | `to` | `string` | | The number in international format, like `+493012345678` | | `name` | `string` | `transfer_call` | The tool's name | | `description` | `string` | says what it does | When the model should call it | | `ringTimeoutSeconds` | `number` | `30` | How long it rings, 10 to 60 seconds | ## Next steps - [Hooks](./hooks.md): what a component can read and do. - [Agents and skills](./agents-and-skills.md): how the components fit together. --- # Hooks Hooks read the call and act on it, imported from `curb`. Use `useState` and `useEffect` from `react`, as in any React project. | Hook | What it gives you | |---|---| | `useRouter` | Load a skill from code | | `useRoutePath` | The current skill's path | | `useAgentInfo` | Everything the call knows | | `useClient` | What the caller's page said | | `useSession` | The active providers, and this call's identity | | `useVariant` | This call's variant of an experiment | | `useSend` | Send a message to the page or the host | | `useMessage` | Handle a message from the page or the host | | `useTransferCall` | Put the caller through to a number | A hook made by [`createAgentStore`](./functions.md#createagentstore) holds state that lasts the whole call. ## `useRouter` ```ts const { push } = useRouter(); ``` `push(path)` loads a skill and makes it active, from code. Use it when a tool's result decides what comes next ([Complex handoffs](./complex-handoffs.md)). ## `useRoutePath` ```ts const path = useRoutePath(); ``` The active skill's path, or `""` before the model has read a skill. ## `useAgentInfo` ```ts const info = useAgentInfo(); const number = useAgentInfo((info) => info["caller.phoneNumber"]); ``` All the call's info, or one value with a selector. Which keys exist is in [How a call opens](./voice-sessions.md#how-a-call-opens). The agent re-renders when a selected value changes. ## `useClient` ```ts const client = useClient(); ``` What the caller's page said, the `client.*` values without the prefix. Anyone can edit a link, so it is never proof of identity ([Knowing the caller](./knowing-the-caller.md)). ## `useSession` ```ts const { settings, call } = useSession(); ``` | Value | What it is | |---|---| | `settings` | The effective providers | | `call.sessionId` | The host's id for this call. On a LiveKit worker, the room name; in `curb dev`, the preview conversation's id | | `call.scratchDir` | A writable directory of this call's own, removed when the call ends. Not set in `curb dev` | `call` is set on every route. Read a call's identity here, never from `process.env`: a runner that serves several calls has one environment for all of them. ## `useVariant` ```ts const greeting = useVariant("greeting"); ``` This call's variant of an experiment, or `undefined` where no variants are assigned ([Goals and experiments](./goals-and-experiments.md)). ## `useSend` ```ts const send = useSend(); ``` | Method | What it does | |---|---| | `toClient(key, payload?)` | Sends to the caller's page. A phone call has no page, so it is dropped with a warning | | `toHost(key, payload?)` | Sends to the host. With no host, it is dropped with a warning | | `askHost(key, payload?, { timeoutMs? })` | Sends to the host and resolves with its reply's payload. Rejects with `HostUnavailableError`, or `HostTimeoutError` after `timeoutMs` (10000) | See [App messages](./app-messages.md). ## `useMessage` ```ts useMessage("cart.updated", { from: "client", schema: z.object({ total: z.number() }), onMessage: ({ total }) => setTotal(total), }); ``` Handles every message with that key for as long as the component is mounted. | Option | Type | What it does | |---|---|---| | `from` | `"client"` or `"host"` | Who may send it | | `schema` | Zod schema | A payload it refuses is answered `invalid_payload` and never reaches `onMessage` | | `onMessage` | `(payload, meta) => unknown` | What it returns is the reply to a client's `ask` | ## `useTransferCall` ```ts const transfer = useTransferCall(); const { outcome, code, message, transferId } = await transfer({ to: "+493012345678" }); ``` | Option | Type | Default | What it does | |---|---|---|---| | `to` | `string` | | The number in international format | | `ringTimeoutSeconds` | `number` | `30` | How long it rings, 10 to 60 seconds | | `announce` | `string` | none | Said to the caller before it dials | `outcome` is `answered`, `no_answer`, `busy`, `failed` or `refused`. It never rejects for an outcome, and throws `TypeError` for a bad request. See [Human handoff](./human-handoff.md). ## Next steps - [Functions](./functions.md): `defineAgent`, stores and errors. - [Components](./components.md): what an agent renders. --- # Functions Everything else `curb` exports. ## Define an agent | Function | What it does | |---|---| | `defineAgent` | Declares the agent | | `createAgentStore` | State that lasts the whole call | | `defineTool` | Types the props of a `Tool` outside JSX | | `z` | Zod, for validating input | ### `defineAgent` ```tsx import { defineAgent } from "curb"; export const agent = defineAgent({ app: App, timeZone: "Europe/Berlin", firstTurn: "agent" }); ``` | Option | Type | Default | What it does | |---|---|---|---| | `app` | component | | The agent's React tree | | `maxLoadedSkills` | `number` | `3` | Skills open at once. The least recently used leaves past this | | `maxSteps` | `number` | `8` | Model steps per turn | | `maxTranscriptMessages` | `number` | `60` | Messages sent to the model per step. The full transcript is kept | | `timeZone` | `string` | `"UTC"` | An IANA time zone. Every request carries today's date and time in it | | `firstTurn` | `"user"`, `"agent"`, `{ say }` | `"user"` | Who opens the call ([Who speaks first](./who-speaks-first.md)) | | `mcpServers` | array | | Remote MCP servers ([MCP servers](./mcp.md)) | | `goals` | object | | What counts as a successful call ([Goals and experiments](./goals-and-experiments.md)) | | `experiments` | object | | A/B experiments, by name | | `preview` | `{ suggestedPrompts?, chatPlaceholder? }` | | Hints for the preview. Never reach the model | It refuses a time zone that does not exist, a `firstTurn` no conversation can open with, and goals or experiments a host could not evaluate. ### `createAgentStore` ```ts import { createAgentStore } from "curb"; export const useClaim = createAgentStore("claim", (set) => ({ verified: false, verify: () => set({ verified: true }), })); ``` `createAgentStore(key, initializer)` returns a hook on state that lasts the whole call and survives a skill leaving the request. Call it once, at module level. A change re-renders the agent, so a tool gated on a store field appears and leaves on its own ([State and conditional tools](./state.md)). ### `defineTool` ```ts const openClaim = defineTool({ name: "open_claim", description: "Open a claim.", schema: z.object({ policy: z.string() }), onSubmit: async ({ policy }) => ({ ok: true, message: `Opened for ${policy}.` }), }); ``` ### `z` [Zod](https://zod.dev), re-exported so a project needs no dependency of its own. ## Errors | Error | Thrown when | |---|---| | `ToolInputError` | A handler refuses its input, with a message for the model | | `isToolInputError` | Tests for that error | | `AgentAuthoringError` | The agent is authored wrongly, with a code and a fix | | `MessageRefusedError` | A message was refused by the app or the host | | `HostUnavailableError` | `askHost` found no host connected | | `HostTimeoutError` | `askHost` got no answer in time | | `SettingsRefusedError` | A settings update is not allowed or not valid | ### `ToolInputError` ```ts import { ToolInputError } from "curb"; throw new ToolInputError("That policy number has eight digits."); ``` The model reads the message and can ask the caller again. `data` is optional: `new ToolInputError(message, { data })`. ## Building on Curb These are for programs built around Curb. An agent does not need them. | Export | What it does | |---|---| | `AgentSession` | The conversation loop on a runtime | | `AgentSiteRuntime` | The headless React host of one conversation | | `AgentInfoStore` | The store behind `useAgentInfo` | | `RouteProvider` | Provides the current skill's path to a tree | | `AgentRouter` | Removed: renders nothing and warns with `AGENT_ROUTER_REMOVED` | | `compileSite` | Compiles a semantic tree into context and tools | | `flattenContext` | Compiled context as text lines | | `createSemanticRoot` | A React root that renders into a semantic tree | | `createSkillRoutes` | Routes from a project's skill modules | | `skillPathFromModuleId` | A module's skill path | | `titleFromSkillPath` | A readable title from a skill path | | `listPublicMarkdown` | Lists `context/` files | | `preloadPublicMarkdown` | Loads `context/` files | | `resolvePublicMarkdown` | Reads one `context/` file | | `validateSchemaInput` | Validates input | | `zodSchemaToFields` | A Zod object as field descriptions | | `diagnoseAgentAuthoring` | Runs the authoring checks | | `formatAuthoringDiagnostic` | Prints a diagnostic | | `mergeAuthoringDiagnostics` | Merges diagnostics without repeats | | `AUTHORING_DIAGNOSTIC_CODES` | Every code ([Diagnostics](./diagnostics.md)) | | `McpManager` | Connects to and calls MCP servers | | `configureMcp` | Sets up the MCP manager | | `getMcpManager` | The current MCP manager | | `preloadMcpTools` | Connects the configured servers early | | `resolveMcpServer` | Finds a configured server | | `setMcpOAuthCallbackUrl` | Sets where MCP sign-in returns to | ## Next steps - [Components](./components.md): what an agent renders. - [Hooks](./hooks.md): what a component can read and do. --- # Providers The LLM and the speech providers are props, imported from `curb/providers`. Keys stay in the environment ([Configuration](./configuration.md#provider-keys)). | Export | What it gives you | |---|---| | `kugelaudio` | `kugelaudio.llm()`, `kugelaudio.stt()`, `kugelaudio.tts()` | | `anthropic` | An LLM | | `openai` | An LLM, `openai.stt()`, `openai.tts()` | | `openaiCompatible` | Any OpenAI-compatible LLM | | `azure` | `azure.stt()` | | `deepgram` | `deepgram.stt()` | | `elevenlabs` | `elevenlabs.stt()`, `elevenlabs.tts()` | | `cartesia` | `cartesia.tts()` | | `defaultKugelVoice` | `defaultKugelVoice(language?)`: the default voice of a language | | `DEFAULT_VOICE_LANGUAGE` | The language with none set, `"en"` | | `KUGELAUDIO_LLM_MODELS` | The models `kugelaudio.llm()` accepts | | `DEFAULT_KUGELAUDIO_LLM_MODEL` | The one it uses by default | ## `kugelaudio` Runs on one key, `KUGELAUDIO_API_KEY`. ### `kugelaudio.llm(options?)` KugelAudio's EU-hosted LLM, in closed alpha. | Option | Type | Default | |---|---|---| | `model` | one of `KUGELAUDIO_LLM_MODELS` | `DEFAULT_KUGELAUDIO_LLM_MODEL` | ### `kugelaudio.stt(options?)` Kugel Ear 1. | Option | Type | Default | What it does | |---|---|---|---| | `language` | `string` | detects German or English | `"de"`, or `"de,fr"` to detect among several, expecting the first. Supported: de, en, fr, it, es, pt, nl, pl, ro | | `turnDetection` | `"kugelturn"`, `"vad"` | `"kugelturn"` | What ends a caller's turn: KugelTurn hears when the speaker finished; `vad` waits for a second of pause | | `turnDetectionLevel` | `"responsive"`, `"conservative"` | `"responsive"` | `conservative` holds longer through a pause mid-thought | | `boostedPhrases` | `string[]` | | Names and terms to spell the way you write them ([Pronunciation](./pronunciation.md)) | | `dictionaryIds` | `number[]` | every active dictionary | Dictionaries used as vocabulary; `[]` for none | | `projectId` | `number` | the key's project | The project whose dictionaries apply | ### `kugelaudio.tts(options?)` | Option | Type | Default | What it does | |---|---|---|---| | `voice` | `number` | the voice of `language` | A KugelAudio voice id | | `model` | `string` | `"kugel-3.5"` | The TTS model | | `language` | `string` | `"en"` | How text is read, and which voice reads it when `voice` is not set | | `dictionaryIds` | `number[]` | | Pronunciation dictionaries. Needs `projectId` | | `projectId` | `number` | | The number in your dashboard URL | ## `anthropic` ```ts anthropic(model: string) ``` Key: `ANTHROPIC_API_KEY`. ## `openai` ```ts openai(model: string) openai.stt({ model?, language? }) openai.tts({ voice?, model? }) ``` Key: `OPENAI_API_KEY`. ## `openaiCompatible` Any OpenAI-compatible endpoint. Its key, when it needs one, is `AGENT_LLM_API_KEY`, else `OPENAI_API_KEY`. | Option | Type | What it does | |---|---|---| | `baseURL` | `string` | The endpoint, like `http://127.0.0.1:11434/v1`. Required | | `model` | `string` | Required | | `reasoningEffort` | `"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`, `"max"` | Sent as `reasoning_effort`. `"none"` switches thinking off where the endpoint takes it | | `temperature` | `number`, 0 to 2 | Sent as `temperature` | | `topP` | `number`, above 0, at most 1 | Sent as `top_p` | | `body` | object | Extra request fields, sent as written. A field Curb or an option above sets is refused | Unset, each one is the endpoint's default. See [Your own LLM](./custom-llm.md). ## `azure` ```ts azure.stt({ language?, region?, segmentationMs? }) ``` Key: `AZURE_SPEECH_KEY`. `region` defaults to `AZURE_SPEECH_REGION`, else `eastus`. `segmentationMs` is the trailing silence before Azure ends an utterance, default 300. ## `deepgram` ```ts deepgram.stt({ model?, language? }) ``` Key: `DEEPGRAM_API_KEY`. ## `elevenlabs` ```ts elevenlabs.stt({ model? }) elevenlabs.tts({ voice?, model? }) ``` Key: `ELEVEN_API_KEY`, or `ELEVENLABS_API_KEY`. ## `cartesia` ```ts cartesia.tts({ voice?, model?, language? }) ``` Key: `CARTESIA_API_KEY`. ## Next steps - [Voice](./voice.md): choosing the providers. - [Configuration](./configuration.md): every environment variable. --- # Client `curb/client` is the browser side of a call, for your own page on your own host. It needs a LiveKit room you already joined. | Export | What it does | |---|---| | `connectAgent` | Connects to the agent in a room | | `AgentRefusedError` | The agent refused a request. Has `code`, `issues` and `path` | | `AgentUnavailableError` | No agent answered in time | | `DEFAULT_AGENT_TIMEOUT_MS` | The timeout of a request, 10000 | | `MAX_CLIENT_MESSAGE_BYTES` | The largest message | ## `connectAgent` ```ts import { connectAgent } from "curb/client"; const agent = connectAgent(room); agent.on("cart.updated", (payload) => render(payload)); await agent.send("page.changed", { path: location.pathname }); ``` `connectAgent(room, { agentIdentity? })` returns an object with these: | Method | What it does | |---|---| | `send(key, payload?)` | Sends a message the agent handles with `useMessage` | | `ask(key, payload?)` | The same, and resolves with what `onMessage` returned | | `on(key, handler)` | Handles a message from `send.toClient`. Returns a function that stops it | | `update(settings)` | Changes a setting of ``, and resolves with the effective settings | | `close()` | Stops listening | `agentIdentity` picks the agent in a room with several participants. ## Next steps - [App messages](./app-messages.md): the agent's side of these messages. - [Mid-call changes](./mid-call-changes.md): what `update` can change. --- # Testing `curb/testing` declares conversations for `curb test`. How to use them is in the [Testing guide](./testing.md). | Export | What it does | |---|---| | `scenario` | Declares a conversation | ## `scenario` ```ts // src/agent/claims.scenario.ts import { scenario } from "curb/testing"; export default scenario({ caller: { goal: "Report a broken windscreen", knows: "Policy number 12345678" }, fakes: { open_claim: { ok: true, message: "Claim 42 is open." } }, expect: ({ called }) => [called("open_claim")], }); ``` | Option | Type | Default | What it does | |---|---|---|---| | `name` | `string` | the file name | Shown in the report, matched by `curb test ` | | `caller` | `{ goal, knows?, language? }` | | A simulated caller. Give this or `turns` | | `turns` | `string[]` | | Scripted caller lines. Give this or `caller` | | `maxTurns` | `number` | 12 for a simulated caller | The most caller turns | | `client` | object | | Page info the call starts with | | `experiments` | object | | The variant, by experiment name | | `fakes` | object | | Tools that must not run for real, each with the result its calls get, or a function of the input that returns one | | `expect` | function | | Your own checks, run on the finished conversation | ## `expect` `expect` gets the conversation and helpers, and returns a list of checks. | Helper | Passes when | |---|---| | `called(tool, input?)` | The agent ran the tool without a refusal. With `input`, only a call with those values counts | | `notCalled(tool)` | The agent never called the tool, not even in a refused call | | `said(text)` | A reply contains the text, ignoring case, or matches the pattern | | `endedCall()` | The agent ended the call itself | | `check(name, pass, detail?)` | `pass` is true | `transcript` holds every turn: `caller`, `reply`, `toolCalls`, `errors`, `skillPath`, `endedCall` and more. ## Next steps - [Testing guide](./testing.md): `curb check`, `curb say` and `curb test`. --- # Orchestration `curb/orchestration` is server-only. Curb runs the voice session on every route ([Voice sessions](./voice-sessions.md)). Use these exports to change that session or to write your own: [Override the LiveKit pipeline](./livekit-pipeline.md). | Export | What it does | |---|---| | `createVoiceAgentBundle` | A runtime, session and voice settings for one agent | | `buildVoiceProviders` | Builds the agent's STT and TTS | | `buildVoiceStt` | Builds its STT | | `buildVoiceTts` | Builds its TTS | | `describeMissingVoiceEnv` | Names the voice keys that are missing | | `VoiceProviderError` | A voice provider cannot be built | | `CurbVoiceAgent` | The `voice.Agent` that answers with Curb's turns. Subclass it | | `CurbLLMStub` | The `llm` a session needs so LiveKit calls `llmNode` | | `VoicePipeline` | The type of `defineAgent({ pipeline })` | | `createProviderSwitch` | Swaps STT or TTS during a call | | `ConversationClips` | Holds a call's audio clips | | `attachClientMessages` | Connects app messages to a room | | `serveCall` | Serves host protocol v1 for one call | | `createEndCallHangup` | What `end_call` does in a call | | `END_CALL_TOOL_NAME` | `end_call` | | `SKIP_TURN_TOOL_NAME` | `skip_turn` | | `AutoSubscribe`, `ServerOptions`, `cli`, `defineAgent`, `llm`, `voice` | Re-exported from `@livekit/agents` | | `silero` | Re-exported from `@livekit/agents-plugin-silero` | | `RoomEvent` | Re-exported from `@livekit/rtc-node` | ## Next steps - [Override the LiveKit pipeline](./livekit-pipeline.md): the worked example. - [Host protocol](./host-protocol.md): running calls from a host instead. --- # Configuration Curb reads everything below from the environment or the project's `.env`. How an agent behaves is set in code, on [``](./components.md#agent) and in [`defineAgent`](./functions.md#defineagent). ## Provider keys The environment holds only the keys of the providers the agent uses. A missing one fails with an error naming it. | Provider | Variable | |---|---| | `kugelaudio.llm()`, `kugelaudio.stt()`, `kugelaudio.tts()` | `KUGELAUDIO_API_KEY`. With `npx curb login`, a project on this machine needs none | | `anthropic()` | `ANTHROPIC_API_KEY` | | `openai()`, `openai.stt()`, `openai.tts()` | `OPENAI_API_KEY` | | `openaiCompatible()` | `AGENT_LLM_API_KEY`, else `OPENAI_API_KEY`; optional | | `azure.stt()` | `AZURE_SPEECH_KEY`; `AZURE_SPEECH_REGION` (default `eastus`) | | `deepgram.stt()` | `DEEPGRAM_API_KEY` | | `elevenlabs.stt()`, `elevenlabs.tts()` | `ELEVEN_API_KEY`, or `ELEVENLABS_API_KEY` | | `cartesia.tts()` | `CARTESIA_API_KEY` | `KUGELAUDIO_API_BASE` points the KugelAudio providers at another endpoint. ## Voice | Variable | Default | What it does | |---|---|---| | `VOICE_ENDPOINTING_MIN_MS` | `300` | The shortest pause that can end a turn | | `VOICE_ENDPOINTING_MAX_MS` | `3000` | The longest wait before a turn ends | | `VOICE_SILENCE_FOLLOWUP_MS` | `6000` | Silence before the agent checks in | ## LiveKit | Variable | What it does | |---|---| | `LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` | Your LiveKit server or Cloud project. All three, or `curb dev` names what is missing | | `CURB_REALTIME_SESSION_URL` | Asks this URL for each call's room and tokens, in place of the three above | | `CURB_AGENT_NAME` | The name a worker registers under. Default: the `name` in `package.json` | | `LIVEKIT_AGENTS_LOG_LEVEL` | Log level of the LiveKit agent runtime. Default `warn` | ## Running | Variable | Default | What it does | |---|---|---| | `PORT`, `WS_PORT` | `4000` | The port. `WS_PORT` wins. An explicit port is used exactly; otherwise `curb dev` takes the next free one | | `WS_HOST` | `127.0.0.1` | The address to listen on | | `AGENT_DEFAULT` | the first by name | The agent a project with several starts with | | `AGENT_LLM_TIMEOUT_MS` | `60000` | How long a model may take to answer | | `KUGEL_CONVERSATIONS_DB` | a local file | Where conversations are recorded. `off` turns it off ([The preview](./preview.md#recorded-conversations)) | | `CURB_NO_OPEN` | | `1` keeps `curb dev` from opening the browser | | `CURB_CDN` | `https://cdn.kugelaudio.com/curb` | Where `curb upgrade` looks. Set it to a mirror | | `MCP_SERVERS_FILE` | `mcp.servers.json` | The servers added in the preview | | `MCP_AUTH_DIR` | `.mcp-auth` | Where MCP sign-ins are stored | | `MCP__TOKEN` | | A bearer token for the MCP server with that id | ## Runner These apply to a built artifact, and to `curb dev` and `curb start` with a management port ([Self-hosting](./hosting.md)). | Variable | What it does | |---|---| | `CURB_MANAGEMENT_ADDR` | `host:port` for host protocol v1. Absent means not managed | | `CURB_MANAGEMENT_TOKEN` | The bearer token, one per runner. Required with the address | | `CURB_MAX_SESSIONS` | Concurrent calls. Default `1` | | `CURB_DEPLOYMENT_ID` | Optional: which deployment this runner is | | `CURB_CONFIGURATION_VERSION` | Optional: a positive integer | | `CURB_REALTIME_URL`, `CURB_REALTIME_TOKEN` | Join this one room directly and serve one call | | `CURB_SESSION_ID` | The call's id on a direct join. Default: the room name | | `CURB_READINESS_PROBE` | `1` loads the bundle, builds the agent and exits | ## `package.json` ```json { "curb": { "requiredRuntimeSecrets": ["KUGELAUDIO_API_KEY"] } } ``` `curb build` writes these names into `curb.bundle.json`. A deployment or call then fails before a runner starts when one cannot be resolved ([Self-hosting](./hosting.md)). ## Next steps - [CLI](./cli.md): every command. - [Self-hosting](./hosting.md): the ways to run an agent. --- # CLI Run `npx curb ` from the project root. `--help` prints the usage and runs nothing. | Command | What it does | |---|---| | `curb dev` | Preview, chat and voice on one port | | `curb check` | Type-checks the project and runs the authoring checks | | `curb say` | One caller turn, headless | | `curb test` | Runs the conversation scenarios | | `curb conversations` | Prints recorded conversations | | `curb start` | Runs the LiveKit voice worker | | `curb server` | Only the agent WebSocket server | | `curb build` | Bundles the project and proves the bundle starts | | `curb upgrade` | Updates the project's `curb` dependency | | `curb login` | Signs in to KugelAudio in the browser | | `curb logout` | Signs out | | `curb deploy` | Uploads the project and makes it live | | `curb secrets push` | Sets the variables of `.env` on the agent | | `curb --version` | Prints the version | ## `curb dev` Starts the preview, chat and voice on port 4000, and opens the browser. Saving a file under `src/`, a `context/*.md` file or `.env` reloads the agent. | Option | What it does | |---|---| | `--port ` | Serves on exactly that port | | `--no-open` | Does not open the browser | ## `curb check` Type-checks `src/` and runs the authoring checks. Exits 1 on errors ([Diagnostics](./diagnostics.md)). ## `curb say` ```bash npx curb say "I want to report a claim" ``` Runs one caller turn and prints the reply, the tool calls and what changed in the context. Successive calls continue one conversation ([Testing](./testing.md#2-curb-say-does-it-behave)). | Option | What it does | |---|---| | `--new` | Starts a new conversation with this turn | | `--client key=value` | Sets `client.` before the turn. Repeatable | | `--fake [=]` | Fakes a tool. Repeatable | | `--json` | Prints one JSON object | ## `curb test` ```bash npx curb test claims ``` Runs `src/**/*.scenario.ts`, or those matching a filter, and exits 1 when one fails ([Testing](./testing.md#3-curb-test-do-whole-conversations-work)). `--json` prints one JSON object. ## `curb conversations` Prints recorded conversations of `curb dev` and `curb say`, with every tool call and result. | Option | What it does | |---|---| | `--last ` | The last n conversations. Default 3 | | `--id ` | One conversation | | `--json` | One JSON object, nothing clipped | ## `curb start` Runs the LiveKit voice worker in production mode ([Self-hosting](./hosting.md)). With `CURB_MANAGEMENT_ADDR` and `CURB_MANAGEMENT_TOKEN`, it also serves host protocol v1 for its calls. ## `curb server` Starts only the agent WebSocket server. `--port ` as for `curb dev`. ## `curb build` Bundles the project into `dist/`, starts the finished artifact on its own and renders every skill to prove it runs ([Self-hosting](./hosting.md#built-artifact-curb-build)). | Option | What it does | |---|---| | `--out ` | Another output directory | | `--no-verify` | Skips starting the artifact | ## `curb upgrade` Updates the project's `curb` dependency to the latest published version ([Upgrading](./upgrading.md)). `--check` only reports. ## `curb login` Signs in to KugelAudio in the browser. The login is stored in `~/.config/curb/`, and a project then runs on it with no key in `.env`. A `KUGELAUDIO_API_KEY` you set wins. | Option | What it does | |---|---| | `--url ` | Another app URL | | `--no-open` | Only prints the sign-in URL | ## `curb logout` Revokes this machine's session and deletes the stored credentials and project keys. ## `curb deploy` Uploads the project and makes it the agent's live production deployment. The first run picks or creates the agent and saves it in `.curb/link.json` ([Deploying](./deploying.md)). | Option | What it does | |---|---| | `--agent ` | Chooses the agent | | `--no-activate` | Builds without going live | | `--json` | Prints the result as one JSON line | ## `curb secrets push` Sets every variable in `.env` on the agent's production environment. Prints names only, never values. | Option | What it does | |---|---| | `--file ` | Another file | | `--agent ` | As for `curb deploy` | ## Next steps - [Configuration](./configuration.md): every environment variable. - [Diagnostics](./diagnostics.md): every code. --- # Diagnostics The framework's error surfaces are designed to be read by coding agents: stable codes, exact locations, and a concrete fix on every finding. Trust them: when a diagnostic and your intuition disagree, read the diagnostic again. ## Where diagnostics appear - `curb check`: run after every edit; exits 1 when errors exist. It type-checks every file under `src/` with the project's `tsconfig.json` (errors inside the framework's own source are not yours and are not shown), renders the whole agent with every skill loaded, and reads the source of every skill, including branches that are not rendered yet. Agent code runs through tsx, which does not check types, so a misspelled name in a tool's `onSubmit` otherwise fails only when the model calls the tool, in the middle of a call. - `curb dev` / `curb server`: all diagnostics are logged at startup (`console.warn` / `console.error`). After a save, `curb dev` reloads the agent and prints one line, which counts any errors. - The preview's **Diagnostics** panel: same content, with per-row and copy-all buttons that emit the exact agent-readable format below. An agent whose modules do not load, or that does not render, shows its diagnostic in place of the preview until a save fixes it. - `AgentAuthoringError`: thrown (not leaked into model context) when an authoring mistake makes compilation impossible, e.g. navigating to a non-existent skill. Its message is a formatted diagnostic. Format: ```text [AGENT_MISSING_SKILL_DESCRIPTION] WARNING: Skill link to "/member-portal" has no routing description… Location: src/agent/agent.tsx Fix: Add meta.description to the target skill or a description prop to the link. ``` `Location` is a file, `file:line` or `file:line:column`, relative to the project root: ```text [AGENT_TYPE_ERROR] ERROR: Cannot find name 'finsh'. Did you mean 'finish'? (TS2552) Location: src/agent/claims/skill.tsx:13:9 Fix: Declare or import this name, or correct its spelling. Agent code runs without a type check, so this would fail only when the line runs, mid-call: in a tool handler, or in a branch that renders later. ``` ## Codes | Code | Severity | Meaning → fix | |---|---|---| | `AGENT_INVALID_ROUTE` | error | A `` link points to a path with no `skill.tsx`. Create the skill folder or fix the path. | | `AGENT_UNREACHABLE_SKILL` | warning | A `skill.tsx` exists but no link reaches it from the agent's `` list, directly or through the skills that list reaches. Add it to the list or link it from a reachable skill, or delete it. | | `AGENT_DUPLICATE_TOOL_NAME` | error | Two tools share a name; the model cannot address them. Rename one. | | `AGENT_MISSING_SKILL_DESCRIPTION` | warning | A skill link has no routing description, so the model routes blind. Add `meta.description` on the target or `description` on the link. | | `AGENT_POSSIBLY_LOCKED_BRANCH` | error / warning | Error: a branch is locked by a constant condition and can never render. Warning: a conditional branch has no apparent unlock mechanism. Add the unlock path, or unconditional fallback text telling the model how to unlock it. | | `AGENT_CONTEXT_TOO_LARGE` | warning | One skill compiles to an oversized context. Split it into sub-skills or move prose into `context/` markdown loaded where needed. | | `AGENT_AUTHORING_RENDER_FAILED` | error | The agent threw while loading or during the diagnostic render (which loads every skill). The message is the component's own error and the location is the line that threw, or the component that was rendering. Fix it first; other findings may be masked until the agent renders. "Maximum update depth exceeded" means something changes state every time it renders: call store actions from a tool's `onSubmit`, never while rendering. | | `AGENT_TYPE_ERROR` | error | TypeScript rejects a line of the project's own code under `src/`, checked with its `tsconfig.json` plus Node's types (Curb depends on `@types/node`, so `process.env` in a tool is fine). Fix the line. `src/orchestration/` is not checked (see `AGENT_ORCHESTRATION_IGNORED`). At most 50 are listed per run. | | `AGENT_TYPECHECK_UNAVAILABLE` | warning | TypeScript is not installed, so `src/` was not type-checked. Run `npm install --save-dev typescript`. | | `AGENT_NODE_TYPES_MISSING` | warning | No `@types/node` could be found, so the check cannot type Node names such as `process` or `Buffer`; every use is in this one warning, never an error. The code runs. Run `npm install --save-dev @types/node` (for your editor, also add `"types": ["node"]` to `tsconfig.json`). | | `AGENT_ENV_NOT_DECLARED` | warning | Your code under `src/` reads an environment variable (`process.env.NAME`, `process.env["NAME"]` or `const { NAME } = process.env`) that `.env.example` does not list, so whoever deploys the agent does not know to set it. The location is its first read. Add `NAME=` to `.env.example` with no value; a commented `# NAME=` line counts too. Whether it has a value is never a finding: the Configuration block of `curb check` shows that. | | `AGENT_PROSE_JSX_TEXT` | warning | Prose in ``, `` or `` spans several lines as JSX text. JSX joins those lines with spaces, so the paragraphs, lists and headings never reach the model. Write the prose as one template-literal child, ``{`…`}``; the fix shows the rewrite, and Curb removes the common indentation and keeps the line breaks. | | `AGENT_LITERAL_DATE` | warning | A calendar date ("3. Oktober", "October 28", "2026-10-28") is written into text the model reads: the system prompt, an instruction, a text, a `` document, a skill link's description, a tool's description or a field's `.describe()`, or a fixed `firstTurn: { say }` line. Curb tells the model today's date on every message, so a written date goes stale or misleads. Leave the date out, compute it in code or get it from a tool; keep it only when it never changes, such as a public holiday. Only a date written in `agent.tsx`, in the `skill.tsx` of the skill that shows it, or in the document is reported; a date your code computes while rendering never is. A format example (`e.g. 1990-02-28`, `Format: YYYY-MM-DD (1990-02-28)`) and a version such as `1.3.10` are not dates, and code comments and `defineAgent({ preview })` never reach the model. | | `AGENT_ORCHESTRATION_IGNORED` | warning | `src/orchestration/` exists, but the framework runs the voice session on every route and does not use these files. Delete `src/orchestration/`; voice settings go on ``. | | `AGENT_VOICE_LANGUAGE_MISMATCH` | warning | The agent, or a skill whose `` link sets `stt` or `tts`, listens in one language (an `stt` with a single `language` code) but its voice reads text with another's rules: the `tts` `language`, `"en"` when unset. The dates, numbers and amounts in its replies are read the other language's way, and a `kugelaudio.tts()` without `voice` picks its voice by that language too. Give the voice the language it listens in, e.g. `kugelaudio.tts({ language: "de" })`; the fix keeps your other options. An `stt` that lists several languages to detect among (`"de,en"`) is not checked. | | `AGENT_INVALID_AUTHORING_PROP` | error | A framework component received an invalid prop (wrong shape/type), or `defineAgent` an option it cannot use (a time zone that does not exist, `goals` or `experiments` outside their limits). Follow the message, which names every problem; the props are validated exactly. | | `AGENT_GOAL_TOOL_NOT_DECLARED` | warning | A goal in `defineAgent({ goals })` is reached when the agent runs a tool (`{ tool: "book_slot" }`), but no `` in the agent or its skills has that name, so no call can reach it. The fix lists the agent's tools: name one of them, or rename the tool the goal means. A tool an MCP server provides is not checked; keep such a goal. | | `AGENT_SCAFFOLD_PLACEHOLDER` | error | A value the scaffolder was supposed to substitute is still a placeholder, like `anthropic("{{AGENT_MODEL}}")` in ``. `npm create curb.js` fills these in; a project copied out of `templates/default` keeps them. Replace it with a real value: nothing downstream can, and the model id is passed to the provider verbatim. | | `AGENT_HOME_SKILL_REMOVED` | error | A project from before the agent level. A conversation now starts at `src/agent/agent.tsx` with no skill loaded, so these leftovers do nothing; there is one error per leftover, naming its file. A `skill.tsx` next to `agent.tsx`: move its instructions into the `` of `agent.tsx`, its tools into `` and its `` links into the agent's `` list (the fix names them), then delete the file. `defineAgent({ initialPath })`: delete the line. ``: delete the link. A `curb build` fails its render check on them too. | | `AGENT_ROUTER_REMOVED` | warning | The app still renders ``, which renders nothing now: `` renders every skill the conversation opens, so the skills keep working. Delete `` and its import, and the `path`, `loadedPaths` and `routes` props of the app component: `export function App() { ... }`. | ## Practical loop for a coding agent 1. After any change to `src/agent/`, run `curb check`. 2. Fix all errors, then warnings: warnings are almost always real routing or reachability problems, not style nits. 3. Converse: `curb say ""` runs one turn headlessly and prints the reply, every tool call and the context it changed (see [Testing](./testing.md)). Confirm the model loads the intended skill for each topic and that locked tools stay invisible until unlocked. Keep the conversations that matter as `src/**/*.scenario.ts` and run them with `curb test`; boot `curb dev` to hear it. 4. When a tool call misbehaves, run `curb conversations` (it reads `.data/conversations.jsonl`), or your own store when `KUGEL_CONVERSATION_SINK` names one: every call is recorded with its exact input, result, and the context diff it caused. The server's startup log names the store in force. ## Next steps - [Testing](./testing.md): `curb check`, `curb say` and `curb test`. - [CLI](./cli.md): every command. --- # Host protocol A host starts a built agent, gives it calls and follows them over this HTTP protocol. [Self-hosting](./hosting.md) covers when you need it. Curb owns this protocol; nothing in it is specific to any one host. The Zod schemas in `src/hosted/protocol.ts` are the source. `src/hosted/protocol.schema.json` is the same contract as one JSON Schema 2020-12 document (every type under `$defs`) for hosts written in other languages, and `src/hosted/protocol-fixtures/{valid,invalid}/..json` are examples every implementation must accept or refuse. ## Runner environment | Variable | Meaning | |---|---| | `CURB_MANAGEMENT_ADDR` | `host:port` to listen on. Absent means not managed. Port `0` binds a free port; the ready line reports the bound one. | | `CURB_MANAGEMENT_TOKEN` | Per-runner bearer token. Required when the address is set; without it the runner refuses to open the port. Use one token per runner, never one per fleet. | | `CURB_MAX_SESSIONS` | Concurrent calls this runner takes. Default `1`. Read once, at boot (see "Several calls in one runner"). | | `CURB_DEPLOYMENT_ID` | Optional generation pin: which deployment this runner is. | | `CURB_CONFIGURATION_VERSION` | Optional generation pin, a positive integer. | Provider keys for the agent's STT, LLM and TTS are ordinary environment variables of the runner process. A start request never carries secrets. ## The ready line Once warm, the runner prints one line on stdout. Hosts may rely on its exact prefix: ```text curb-hosted {"event":"ready","address":"127.0.0.1:7071","maxSessions":1,"warmupMs":812} ``` That is `curb-hosted`, a space, then a `LifecycleReady` JSON object with `event` as its first key. Every lifecycle record is also printed this way. A runner whose warmup fails never prints `ready` and exits non-zero. ## Routes `GET /health` needs no auth and answers `200 {"ok":true,"protocolVersion":1,"versions":[1]}`. Every other route needs `Authorization: Bearer `, else `401 {"error":"unauthorized"}` (checked before anything else, so an unauthenticated caller learns nothing). A path not under `/v1/` answers `404 {"error":"unknown_version","detail":...,"versions":[1]}`. | Route | Success | Errors | |---|---|---| | `GET /v1/runner` | 200 `RunnerStatus` | | | `GET /v1/sessions` | 200 `{sessions: LiveCall[]}` | | | `GET /v1/sessions/{id}` | 200 `LiveCall` | 404 `session_not_found` | | `POST /v1/sessions` (body `StartCallRequest`) | 202 `{sessionId, accepted}` | 400 `invalid_request`, 409 `runner_at_capacity`, 409 `generation_mismatch`, 503 `runner_draining`, 500 `start_failed`, 501 `runner_takes_no_calls` | | `DELETE /v1/sessions/{id}?reason=&graceMs=` | 202 `{sessionId, stopped}` | 400 `invalid_request` | | `POST /v1/sessions/{id}/messages` (body `HostMessage`) | 202 `{id, delivered}` | 400 `invalid_request`, 404 `session_not_found`, 409 `runner_at_capacity`, 422 `no_handler` / `invalid_payload` / `handler_failed`, 501 `runner_takes_no_calls` | | `PATCH /v1/sessions/{id}` (body `SessionUpdate`) | 200 `{sessionId, settings: EffectiveSettings}` | 400 `invalid_request`, 403 `not_adjustable`, 404 `session_not_found`, 422 `invalid_settings`, 501 `runner_takes_no_calls` | | `POST /v1/runner/drain` | 202 `{draining: true, activeSessions}` | | | `GET /v1/runner/events?from=N` | 200 NDJSON stream | 400 `invalid_request`, 410 `cursor_too_old`, 503 `no_journal` | | anything else | | 404 `not_found` | | a handler failed | | 500 `internal` | Shapes (all in `protocol.schema.json`): - `StartCallRequest` = `{sessionId, realtimeUrl, realtimeToken, deploymentId?, configurationVersion?, clientContext?, direction?, experiments?}`. Strings are non-empty; `configurationVersion` is a positive integer. Unknown fields are ignored. `deploymentId` and `configurationVersion` are each compared only when both the runner and the request state them; a mismatch is `409 generation_mismatch`. - `clientContext` is what the caller's page or app said about this call, one string per key (`{"event": "webinar-11-04"}`); anything else is `400 invalid_request`. The call's agent reads each entry as `client.`, set before its session starts, so the greeting can use it ([agent guide](./voice-sessions.md#how-a-call-opens)). Per call, never in the environment. Curb passes it on as sent: your host decides its limits, and since a browser wrote it, never an identity or a permission. - `direction` is `outbound` when your host dialed the caller, `inbound` (the default) otherwise; the agent reads it as `call.direction`. - `experiments` is the variant your host assigned this call in each of the agent's experiments (the bundle manifest's `experiments`), experiment name to variant name (`{"greeting": "casual"}`), one string per key like `clientContext`. The agent reads each as `experiment.` (`useVariant(name)`), set before its session starts. The runner trusts it and applies it as sent. To assign as Curb does locally (`curb dev`, `curb test`): a `fromClient` key in `clientContext` whose value names a variant picks it; otherwise the first 8 bytes of SHA-256(`:`), read as a big-endian unsigned 64-bit integer, modulo the sum of the weights, walked down the variants sorted by name, picks the first variant whose weight is larger than what is left. - `accepted: false` means an idempotent repeat of a call the runner already holds. - `stopped: false` means the runner held no such call. Still a success, so a retried stop never turns into an error. - `graceMs` is an integer from 0 to 60000 (default 0): how long to let the agent finish its current utterance before leaving the room. - `RunnerStatus` = `{protocolVersion: 1, deploymentId?, configurationVersion?, maxSessions, activeSessions, draining, sessions: LiveCall[]}`. - `LiveCall` = `{sessionId, state: "starting" | "connected" | "ending" | "ended", startedAtMs}`. - `SessionUpdate` and `EffectiveSettings`: see [Live session settings](#live-session-settings). - In dev and worker mode, `POST /v1/sessions`, `GET /v1/runner` and `POST /v1/runner/drain` answer `501 runner_takes_no_calls` (see above). ## Error codes Every non-2xx body is `ErrorBody` = `{error, detail?, versions?, requested?, oldestRetained?, path?, issues?}`. Classify by `error`, never by `detail`, which is prose for humans. `unauthorized`, `unknown_version`, `not_found`, `invalid_request`, `session_not_found`, `runner_at_capacity`, `generation_mismatch`, `runner_draining`, `start_failed`, `runner_takes_no_calls`, `cursor_too_old`, `no_journal`, `internal`, `no_handler`, `invalid_payload`, `handler_failed`, `not_adjustable`, `invalid_settings`. `runner_draining` (503) means "place this call on another runner"; `runner_at_capacity` and `generation_mismatch` (409) mean "this runner cannot take it". Runners older than protocol v1 answered some 404s with prose in `error`; treat any 404 on `POST /v1/sessions` as a protocol-version mismatch. `no_handler`, `invalid_payload` and `handler_failed` (422) mean the call is there and its agent refused that message (see "App messages"); `invalid_payload` carries the schema's `issues` as `[{path, message}]`. `not_adjustable` (403, with `path`) and `invalid_settings` (422, with `issues`) refuse a settings change as a whole (see "Live session settings"). ## Event stream `GET /v1/runner/events?from=N` streams everything the runner has to say as NDJSON (`content-type: application/x-ndjson`), one `JournalRecord` per line: ```json {"envelope":1,"producer":"runner","seq":1,"class":"lifecycle","at":1790625584758,"body":{"event":"ready","address":"127.0.0.1:7071","maxSessions":1,"warmupMs":185}} ``` - `envelope` is runner-wide, gapless and positive: the resume cursor. `from` is **exclusive**, so reconnect with the last envelope you processed; `from=0` replays everything still held. - `producer` + `seq` identify a record within its producer, for deduplication and for spotting a record lost before it reached the stream. - `at` is milliseconds since the Unix epoch. Record classes: | `class` | `producer` | `body` | |---|---|---| | `conversation` | the session id | `{type: "turn", seq, at, speaker: "caller" \| "agent", text, final}` or `{type: "event", seq, at, kind: "curb.", durationMs?, payload}` | | `lifecycle` | `runner` | `{event, ...}`: `ready{address, maxSessions, warmupMs}`, `call-started{session, room}`, `call-stopping{session, reason}`, `first-audio{session, monotonicMs, sinceStartMs}`, `draining{signal?, active}`, `ended{reason}` | | `host` | the session id | `{type: "host", seq, at, id, name, payload, expectsReply, inReplyTo?}`, a message from the agent to its host | **What a call's record contains.** Besides the turns and the `host` records, every call that keeps a record (a managed or direct-join runner, `curb dev` / `curb start` with a management port) writes these `conversation` events. A plain self-hosted call with no record writes none of them. | `kind` | When | `payload` | |---|---|---| | `curb.message_to_client` | every `useSend().toClient` message that left for the caller's app | `{key, id, payload, truncated?}` | | `curb.reply_to_client` | every reply the agent gave to the app's `curb.message` RPC (an `ask`) | `{key, id, reply, truncated?}` | | `curb.messages` | once, just before `curb.ended`, only when something is non-zero | `{undeliveredToClient: {[key]: n}, refusedQueuedHostMessages: n}` | | `curb.ended` | last | `{reason}` | `payload` and `reply` are kept as they were sent while their JSON is at most 4 KiB; a larger one is kept as the first 4 KiB of its JSON text, as a string, with `truncated: true`. `undeliveredToClient` counts `toClient` messages no app received (a phone call, or no app in the room yet), per key; `refusedQueuedHostMessages` counts host messages accepted while the call was `starting` that the agent refused once it connected. A reader MUST skip classes it does not know (older runners also emitted `log`) and should count what it skipped. Diagnostics go to the runner's stdout and stderr, never the stream. **Heartbeat.** An idle stream writes `{"ping": }` every 15 seconds. It has no envelope, is not a record, and never advances a cursor. Set your read deadline to a comfortable multiple of 15 seconds. **Resume and 410.** The runner retains a bounded window of records (4096 by default). A cursor older than the window answers `410 {"error":"cursor_too_old","requested":N,"oldestRetained":M}` rather than silently skipping the gap. Reconnect without a cursor to follow from now on, and record the envelopes between `requested` and `oldestRetained` as lost. **Truncation rule.** A call's conversation is complete when its producer's last record is the event `curb.ended` (payload `{reason}`); EOF without it means the stream was cut. The runner's own terminal record is lifecycle `ended`, written before the listener closes. ## Validating a host implementation Decode every answer and every stream line against the matching `$defs` type of `protocol.schema.json`, and run your decoder over the fixtures: each file under `protocol-fixtures/valid/` must decode as the type its name starts with, and each file under `protocol-fixtures/invalid/` must be refused. The schema never forbids unknown properties; keep your decoder that lenient, so a newer runner's additions do not break it. The Curb repository runs this end to end on every change (`npm run smoke:self-host`): it builds an example agent, starts it as a managed runner with no LiveKit credentials, and checks every answer against the schema. ## curl walkthrough ```bash ADDR=127.0.0.1:7071 TOKEN=... # the runner's CURB_MANAGEMENT_TOKEN auth=(-H "Authorization: Bearer $TOKEN") curl -s http://$ADDR/health curl -s "${auth[@]}" http://$ADDR/v1/runner # Follow the stream in another terminal (-N: no buffering). curl -sN "${auth[@]}" "http://$ADDR/v1/runner/events?from=0" # Place a call: create the LiveKit room and a room-scoped token first. clientContext is # optional; the agent reads it as client.event. curl -s "${auth[@]}" -H 'content-type: application/json' \ -d '{"sessionId":"call-42","realtimeUrl":"wss://livekit.example.com","realtimeToken":"","clientContext":{"event":"webinar-11-04"}}' \ http://$ADDR/v1/sessions curl -s "${auth[@]}" http://$ADDR/v1/sessions/call-42 curl -s "${auth[@]}" -X DELETE "http://$ADDR/v1/sessions/call-42?reason=caller_hung_up&graceMs=2000" # Retire the runner: finish the calls in progress, take no new ones, then exit. curl -s "${auth[@]}" -X POST http://$ADDR/v1/runner/drain ``` `SIGTERM` or `SIGINT` drains the same way (the `draining` record then names the signal); a second signal exits at once. ## App messages Agent code exchanges small keyed messages with two parties during a call: the **client** (the app the caller uses, over LiveKit) and the **host** (whatever runs the call, over this protocol). A key is any non-empty string of at most 256 characters, used verbatim on the wire. Dotted names such as `human.requested` remain a good convention for a host's own vocabulary. The transport stamps who sent a message, never the payload. ```tsx import { Tool, useMessage, useSend, z, HostTimeoutError, HostUnavailableError } from "curb"; function Claims() { const send = useSend(); // From the caller's app. The return value is the reply to the client's send. useMessage("claim-submitted", { from: "client", schema: z.object({ claimId: z.string() }), onMessage: ({ claimId }) => ({ received: claimId }), }); // From the host, unprompted. useMessage("ops.notice", { from: "host", onMessage: (payload) => console.log(payload) }); async function askSupervisor(topic: string) { send.toClient("show-status", { status: "waiting-for-supervisor" }); try { const answer = await send.askHost("human.requested", { topic }, { timeoutMs: 10_000 }); return answer.disposition === "allowed" ? "Approved." : "Declined."; } catch (error) { if (error instanceof HostTimeoutError) return "The supervisor did not answer in time."; if (error instanceof HostUnavailableError) return "No supervisor is connected to this call."; throw error; } } // ...render a that calls askSupervisor } ``` - `useSend()` returns `{ toClient(key, payload?), toHost(key, payload?), askHost(key, payload?, { timeoutMs? }) }`. Payloads are JSON objects. It is stable across renders and safe in tool handlers. - `toClient` with no app in the room yet, `toClient` on a phone call (SIP only, so no data channel), and `toHost` with no host connected are dropped with one warning per key and reason, never thrown. A dropped `toClient` is counted in the call record (`curb.messages`). The caller's app can call the agent as soon as the agent joins the room: a request that arrives before the agent's runtime is ready waits for it, up to 10 seconds, then is refused `no_handler` (`not_adjustable` for an update). - `askHost` resolves with the payload of the host message whose `inReplyTo` is the ask's `id`. It rejects with `HostUnavailableError` when no host is connected or the call ends while it waits, and with `HostTimeoutError` after `timeoutMs` (default 10000). A host is connected when the call keeps a record a host reads: a managed runner, a direct-join runner serving its events stream, or `curb dev` / `curb start` with a management port. Without one (and in unit tests) there is none. - `useMessage(key, { from, schema?, onMessage })` handles every message with that key from `"client"` or `"host"` while the component is mounted. A `from: "client"` handler never sees a host message and vice versa. With a Zod `schema`, `onMessage` gets the parsed payload; without one the payload is `unknown`. If several mounted components listen for one key, each runs in mount order and the reply is the most recently mounted handler's return value. After the handlers run the context is recompiled, so a handler that changes a store can unlock a skill or tool. - A message is refused with a code, never dropped: `no_handler` (nothing listens for the key from that sender), `invalid_payload` (a schema refused it; no handler ran, the Zod issues are returned), `handler_failed` (a handler threw; the others still ran). - An invalid key or `from` throws `AGENT_INVALID_AUTHORING_PROP`. - Each call has its own messages: two calls on one runner never see each other's. ### Host side `HostMessage` = `{id, name, payload, inReplyTo?}`: - `id`: 1 to 128 characters, unique per call. Delivery is idempotent by `id`. - `name`: the message key, 1 to 256 characters. - `payload`: a JSON object. - `inReplyTo`: the `id` of the agent's `askHost` this message answers. **Host to agent.** `POST /v1/sessions/{id}/messages` with a `HostMessage` body answers once the agent's handlers have run: `202 {"id":..., "delivered":true}`, or `"delivered":false` when that `id` was already delivered to the call. A reply (`inReplyTo` naming an ask still waiting) resolves that ask and needs no handler; a reply that arrives after the timeout is handled like any other message. A refusal answers `422` with `no_handler`, `invalid_payload` (with `issues`) or `handler_failed`, and its id is not taken, so a corrected retry with the same id is delivered. An unknown or ended call is `404 session_not_found`, a malformed body `400 invalid_request`. Ids are scoped to the call: two calls may each take a message `m-1`. A message to a call still `starting` is held and delivered, in order, once the call connects. Up to 32 are held per call; past that the message is refused with `409 runner_at_capacity` and its id is not taken, so retry it once the call is `connected`. A held message was already answered `202`, so if the agent refuses it on delivery the runner logs a warning and counts it instead. A direct-join runner (handed its one call at start) answers `501 runner_takes_no_calls` on this route: it has no host-to-agent path, so an `askHost` there times out, but its agent-to-host records still leave over its events stream. ```bash curl -s "${auth[@]}" -H 'content-type: application/json' \ -d '{"id":"m-7","name":"human.decision","payload":{"disposition":"allowed","reason":"verified"},"inReplyTo":"a-3"}' \ http://$ADDR/v1/sessions/call-42/messages ``` A refusal: ```json {"error":"invalid_payload","detail":"invalid_payload: the payload of \"lead\" does not match its schema: score: Invalid input: expected number, received string","issues":[{"path":["score"],"message":"Invalid input: expected number, received string"}]} ``` **Agent to host.** A record of class `host` on the events stream, `producer` the session id, `seq` from the same per-call counter as the conversation: ```json {"envelope":57,"producer":"call-42","seq":31,"class":"host","at":1790625600000,"body":{"type":"host","seq":31,"at":1790625600000,"id":"a-3","name":"human.requested","payload":{"topic":"refund"},"expectsReply":true}} ``` `expectsReply: true` (from `askHost`) means the agent is waiting: answer with a `HostMessage` whose `inReplyTo` is the record's `id`. `toHost` records have `expectsReply: false`. Which keys exist and what their payloads mean is agreed between your agent and your host; Curb only carries them. ### Transfer on your own host `useTransferCall` and `` work over these messages, so a host that can dial can offer transfers. The transfer asks with `askHost`, so it appears on the events stream as a `host` record with `expectsReply`: | Direction | Name | Payload | |---|---|---| | Agent to host | `call.transfer` | `{ to, ringTimeoutSeconds? }` | | Host to agent, as the reply | `call.transfer.accepted` | `{ transferId }` | | Host to agent, as the reply | `call.transfer.refused` | `{ code, message }`, passed to the agent as the outcome `refused` | | Host to agent, once the target answered or the dial failed | `call.transfer.result` | `{ transferId, outcome, dialStatus }`, with `outcome` one of `answered`, `no_answer`, `busy`, `failed`. Not a reply: it has no `inReplyTo` | Both replies set `inReplyTo` to the record's `id`. Dial the target into the call's LiveKit room. When it joins, the agent stops listening and speaking; when it leaves and the caller is still there, the agent takes the call back. A host that sends no reply in time gives the agent the outcome `failed`, with the code `transfer_no_reply`. A direct-join runner takes no host messages, so a transfer there ends the same way. ### Client side The wire format is plain LiveKit, so `livekit-client` and the native SDKs work without a Curb library. All of it, both parties: | Direction | Transport | Shape | |---|---|---| | agent to client | text stream, topic `curb.message` | `{key, id, payload}` | | client to agent (send and ask) | RPC method `curb.message` on the agent participant | request `{key, id, payload}`; response `{reply?}`, or an RPC error | | client to agent, settings | RPC method `curb.update` on the agent participant | request a `SessionUpdate`; response the `EffectiveSettings`, or an RPC error | | agent to host | host protocol v1 | `host` records on `GET /v1/runner/events` | | host to agent | host protocol v1 | `POST /v1/sessions/{id}/messages` | | host to agent, settings | host protocol v1 | `PATCH /v1/sessions/{id}`, the same body as `curb.update` | Client to agent is RPC rather than a text stream so every send gets an acknowledgement or a coded error. The cost is LiveKit's 15 KiB RPC payload cap. A refused RPC fails with an RPC error whose `message` is the code: | Code | `message` | Meaning | |---|---|---| | 2001 | `no_handler` | nothing listens for the key with `from: "client"` | | 2002 | `invalid_payload` | the envelope is malformed or the schema refused the payload; `data` is the reason, or `{"issues":[...]}` | | 2003 | `handler_failed` | a handler threw (its error stays on the server) | | 2004 | `payload_too_large` | the request is over LiveKit's 15 KiB RPC cap | | 2005 | `not_adjustable` | `curb.update` touched a path `` does not give the client; `data` is `{"path":...}` | | 2006 | `invalid_settings` | `curb.update` is malformed or a value does not fit; `data` is `{"issues":[...]}` | #### `curb/client` `curb/client` wraps that wire format for a browser app. It depends only on `livekit-client` and takes the `Room` your app already owns, including `useRoomContext()` from `@livekit/components-react`: ```ts import type { Room } from "livekit-client"; import { AgentRefusedError, connectAgent } from "curb/client"; export async function talkToAgent(room: Room, openForm: (form: unknown) => void) { const agent = connectAgent(room); // one per room agent.on("show-form", (payload) => openForm(payload)); // useSend().toClient("show-form", …) await agent.send("claim-submitted", { claimId: "C-17" }); // resolves once the agent took it const quote = await agent.ask<{ price: number }>("quote", { plan: "basic" }, { timeoutMs: 5000 }); let voiceChangeable = true; try { await agent.update({ tts: { voice: 17 } }); // needs } catch (error) { if (error instanceof AgentRefusedError && error.code === "not_adjustable") voiceChangeable = false; else throw error; } return { quote, voiceChangeable, close: () => agent.close() }; // close on unmount } ``` ```ts import type { Room } from "livekit-client"; import type { EffectiveSettings, SessionUpdate } from "curb/client"; declare function connectAgent(room: Room, options?: { agentIdentity?: string }): AgentClient; interface AgentClient { send(key: string, payload?: unknown): Promise; ask(key: string, payload?: unknown, opts?: { timeoutMs?: number }): Promise; on(key: string, handler: (payload: unknown, meta: { id: string }) => void): () => void; update(settings: SessionUpdate): Promise; close(): void; } ``` - The agent is `options.agentIdentity`, else the first remote participant of kind `AGENT`. A call made before the agent joined waits for it, up to its timeout (default 10 s), then rejects `AgentUnavailableError`. - A refusal rejects `AgentRefusedError` with `code` (the names above), and `issues` or `path` when the agent sent them. A request over 15 KiB is refused `payload_too_large` before it is sent. Transport failures (a timeout, a disconnect) reject with LiveKit's own `RpcError`. - `on` handlers get only the agent participant's messages. A message with no handler is dropped with one console warning per key. - One client per room: LiveKit takes one handler per text-stream topic. `close()` releases it. The same exchange with `livekit-client` alone: ```ts // livekit-client, in the caller's app import type { Room } from "livekit-client"; export async function talkToAgent(room: Room, showForm: (form: unknown) => void) { room.registerTextStreamHandler("curb.message", async (reader) => { const { key, payload } = JSON.parse(await reader.readAll()); if (key === "show-form") showForm(payload); }); const agent = [...room.remoteParticipants.values()].find((p) => p.isAgent)!; const response = await room.localParticipant.performRpc({ destinationIdentity: agent.identity, method: "curb.message", payload: JSON.stringify({ key: "claim-submitted", id: crypto.randomUUID(), payload: { claimId: "C-1" } }), }); const { reply } = JSON.parse(response); return reply; } ``` ## Live session settings An agent can let its client app and its host change some of its settings during a call: the voice, the TTS model, the recognition language, the LLM. Nothing is adjustable unless the agent says so, per origin: ```tsx import { Agent, useSession } from "curb"; import { kugelaudio } from "curb/providers"; export function App() { return ( {/* ... */} ); } ``` Adjustable paths: `tts.voice`, `tts.model`, `stt.language`, `llm`. The body is the same from both sides, `SessionUpdate`: ```ts type SessionUpdate = { tts?: { voice?: string | number; model?: string }; stt?: { language?: string }; llm?: { provider: "kugelaudio" | "anthropic" | "openai" | "openai-compatible"; model: string; baseURL?: string }; }; ``` - **Deny by default, all or nothing.** Every path in the body must be listed for the sender's origin (a client's `curb.update` is `client`, the host's PATCH is `host`), or the whole update is refused `not_adjustable` naming the first path. A value that does not fit the provider it would reach (a non-numeric KugelAudio voice, a language for a recognizer that takes none, an unknown LLM provider, an unknown key) refuses the whole update `invalid_settings` with `issues`. A refused update changes nothing. - **An override, not a new prop.** An accepted value sits above the `` and active `` props until the next outside change of the same path. `tts.voice` changes the voice of whichever TTS the props pick; switching skills keeps it. - **Takes effect from the next reply.** Speech providers are rebuilt and handed over through LiveKit (`AgentSession.updateAgent`), so the reply being spoken finishes in the old voice. The LLM changes from the next turn. - **The endpoint stays the author's.** A client may switch `llm` only to a provider with no `baseURL` or to the agent's own `openaiCompatible` endpoint, because the endpoint receives the agent's key. The host may name any `http(s)` `baseURL`. - The answer is the effective settings, never a credential: ```json {"tts":{"provider":"kugelaudio","voice":17},"stt":{"provider":"azure","language":"de"},"llm":{"provider":"kugelaudio","model":"qwen3.8-27b"}} ``` Agent code reads the effective settings with `useSession()`; a component re-renders when they change: ```tsx import { Text, useSession } from "curb"; function VoiceNote() { const { settings } = useSession(); return settings.tts.provider === "kugelaudio" && settings.tts.options.voice === 17 ? The caller picked the calm voice; keep sentences short. : null; } ``` From the host: ```bash curl -s "${auth[@]}" -X PATCH -H 'content-type: application/json' \ -d '{"tts":{"voice":17},"stt":{"language":"de"}}' \ http://$ADDR/v1/sessions/call-42 # 200 {"sessionId":"call-42","settings":{...}} # 403 {"error":"not_adjustable","detail":"...","path":"stt.language"} # 422 {"error":"invalid_settings","detail":"...","issues":[{"path":["tts","voice"],"message":"..."}]} ``` From the client: `agent.update(...)` in `curb/client` above, or the `curb.update` RPC with `livekit-client`. A change to a call that is still `starting` is applied once it connects. ## Next steps - [Self-hosting](./hosting.md): the ways to run an agent. - [App messages](./app-messages.md): the agent's side. - [Mid-call changes](./mid-call-changes.md): what `adjustable` allows. --- # Glossary | Term | What it means | |---|---| | Agent | The React tree in `src/agent/agent.tsx`, and everything it renders | | Agent level | What the agent renders before the model has read a skill. It is in every request | | App message | A message between the agent and the page or the host, with no model involved ([App messages](./app-messages.md)) | | Artifact | What `curb build` produces: a project that starts with no install ([Self-hosting](./hosting.md)) | | Client | The caller's page. What it says arrives as `client.*`, and it is never proof of identity | | Context | What the model reads in a request ([What the model receives](./what-the-model-receives.md)) | | Diagnostic | A problem `curb check` reports, with a stable code and a fix ([Diagnostics](./diagnostics.md)) | | Experiment | Variants of an agent, assigned per call ([Goals and experiments](./goals-and-experiments.md)) | | Fake | The result a tool gives in a test instead of running ([Testing](./testing.md)) | | Goal | What counts as a successful call ([Goals and experiments](./goals-and-experiments.md)) | | Host | What runs an agent and gives it calls ([Host protocol](./host-protocol.md)) | | Procedure | An ordered flow of steps ([Step-by-step procedures](./procedures.md)) | | Provider | The LLM, speech-to-text or text-to-speech an agent runs on ([Providers](./providers.md)) | | Runner | One process that serves calls for a host | | Scenario | A whole conversation kept as a test ([Testing](./testing.md)) | | Skill | A whole area of work the model opens when it needs it ([Agents and skills](./agents-and-skills.md)) | | Store | State that lasts the whole call ([State and conditional tools](./state.md)) | | Tool | A single action the model can call ([Tools](./tools.md)) | | Turn | One thing the caller says and everything the agent does in answer | --- # Changelog All notable changes to Curb. Curb uses semantic versioning. Version numbers can skip: only released versions are listed. Some 1.x releases made breaking changes without a major version. Each one is listed under Breaking. ## 1.14.1 (2026-10-29) ### Fixed - An agent no longer reads a stage direction aloud. ## 1.13.2 (2026-10-29) ### Added - A framed preview asks the page that framed it to offer an MCP sign-in. ### Fixed - A cold preview in `curb dev` loads 136 files instead of 511. ## 1.12.10 (2026-10-28) ### Added - `curb conversations` prints a project's recorded conversations with every tool call. - `curb deploy --json` prints the result as one JSON line. - `curb deploy` accepts a host-issued token through `CURB_PLATFORM_TOKEN_FILE`. - `curb dev` can take each call's room from `CURB_REALTIME_SESSION_URL`. - Curb runs on the model key a hosted workspace supplies. - The preview picks the microphone before a call and explains a blocked one. - The preview's context column folds away. ### Fixed - The live transcript stays steady while someone speaks. - A new project carries no `.npmrc`, which hosted builds refuse. - A new project installs quietly, and its agent introduces itself once. ## 1.11.9 (2026-10-26) ### Breaking - An agent without an `llm` prop runs on `kugelaudio.llm()` and needs `KUGELAUDIO_API_KEY`. - `DEFAULT_ANTHROPIC_MODEL` is removed. `DEFAULT_KUGELAUDIO_LLM_MODEL` names the new default. ### Added - `kugelaudio.llm()` runs an agent on KugelAudio's EU-hosted LLM. - `npm create kugelaudio` creates a Curb project. - `create-curb-app` asks nothing and starts on KugelAudio. - `npx curb login` replaces the key in `.env` for a KugelAudio project. ### Fixed - `end_call` hangs up on a voicemail menu, and the model learns when a hang-up was cancelled. - A hosted runner warms up without the LLM key and checks it when a call takes the agent. - A first run without keys or hosted access gets its next step from Curb. ## 1.10.1 (2026-10-26) ### Added - A transferred caller hears the agent's announcement, then a ringback tone, instead of silence. ## 1.9.0 (2026-10-25) ### Added - `useTransferCall()` and `` transfer a hosted phone call. ## 1.8.0 (2026-10-25) ### Added - `defineAgent({ goals, experiments })` declares goals and A/B experiments, and `useVariant()` reads a call's variant. ## 1.7.3 (2026-10-25) ### Added - An agent reads who placed the call from `call.direction`. ## 1.7.2 (2026-10-25) ### Added - `openaiCompatible()` takes `reasoningEffort`, `temperature`, `topP` and `body`. - New projects register the KugelAudio MCP servers in `.mcp.json`. ### Fixed - A skill in the preview's Context column looks like something you can open. ## 1.6.1 (2026-10-25) ### Fixed - The quickstart installs `curb/create-curb-app.tgz`, which no machine has kept from an older release. ## 1.6.0 (2026-10-25) ### Added - `curb check` warns `AGENT_LITERAL_DATE` when text the model reads hard-codes a date. ### Fixed - A `create-curb-app` that npx kept from an earlier run hands over to the newest release. ## 1.5.0 (2026-10-25) ### Added - `` fills the preview's Try form with demo input the model never sees. ### Fixed - The preview's graph opens large from a Graph button. - The preview's dialogs keep their content while they close. ## 1.4.33 (2026-10-24) ### Breaking - Conversations start at your agent and skills open only when needed. `curb check` names what an older project moves. - `` renders the skills a conversation opens. A leftover `` renders nothing and warns `AGENT_ROUTER_REMOVED`. - `DEFAULT_KUGEL_VOICE_ID` is removed. `kugelaudio.tts()` speaks with the default voice of its language. ### Added - `curb test` runs conversation scenarios headlessly, scripted or with a simulated caller. - `curb say` runs caller turns headlessly. - `curb test` and `curb say` fake a tool's `onSubmit`. - `curb check` type-checks the project and lists the variables an agent needs, without their values. - `` runs an action only after the caller says yes. - `firstTurn: { say }` opens every conversation with exact words. - `useClient()` reads what the caller's page sent. - `kugelaudio.stt()` hears callers with Kugel Ear 1, and new projects use it. - A tool's schema reaches the model whole: lists, nested objects and nullables work. - A German agent speaks with a German voice by default. - One voice session runs every route, with caller info, call identity and an uninterruptible greeting. - Agents call tools silently, know today's date and fall back in their own language. - The preview shows your conversation beside your agent's context, with a skills tree and a live graph. ### Fixed - `curb dev` runs saved code within seconds, on the same conversation, without running its tools again. - `curb dev` reloads only for a file that really changed. - A voice agent ending a call always says its goodbye. - On a voice call, a confirm action runs only after its read-back played in full. - The model sees a tool field's description after `.optional()` and an enum's allowed values. - `curb test` runs every scenario a file exports. - `curb build` and `create-curb-app` work again with `@livekit/agents` 1.9.4. - The preview opens for an agent whose skills import `node:crypto` or read `process.env`. ## 1.3.10 (2026-10-21) ### Breaking - Curb requires Node 22.12 or newer. ### Fixed - Kugel TTS retries a turn refused by the concurrent generation limit instead of going silent. - Dependency advisories are cleared. ## 1.3.6 (2026-10-20) ### Fixed - A project made by `create-curb-app` deploys, because it ships no symlink. ## 1.3.4 (2026-10-20) ### Added - `curb login`, `curb logout`, `curb deploy` and `curb secrets push` ship a local agent to KugelAudio hosting. - Calls sharing one managed runner get their own id, scratch directory and slot. ### Fixed - `curb build` verification bundles with esbuild 0.28.2. ## 1.2.6 (2026-10-14) ### Breaking - Providers are props. `` and `` take descriptors from `curb/providers`, and the environment holds keys only. - App messages replace the host channel. `useHost` and `useHostMessage` are removed; use `useSend()` and `useMessage()`. - `curb dev` is no longer a host. ### Added - `` lets the caller's app and the host change settings during a call. - `curb/client` connects a browser app to an agent. - Provider props are live: a change swaps the speech provider or LLM mid-call. - `kugel-3.5` is the default TTS model on hosted agents. ### Fixed - `curb build` ships `undici`. ### Performance - Voice runners take caller audio at 16 kHz and merge frames in linear time. ## 1.1.0 (2026-10-01) ### Added - Host protocol v1, and a self-hostable Curb. ### Fixed - A turn stays alive through tool and LLM failures. - A restored runner never blocks on a checkpoint. - The Azure Speech region has a default. ### Performance - Faster hosted builds and warm calls. - Shorter reply delays. ## 1.0.0 (2026-07-26) ### Breaking - One branching model, with an agent graph in the preview and author-settable icons. ## 0.3.0 (2026-07-20) ### Added - Editable voice orchestration and context controls. ## 0.2.0 (2026-07-17) ### Added - Conversation history and call replay are restored. ## 0.1.2 (2026-07-14) ### Added - First release: a React agent framework with one conversation for chat and voice, a CDN scaffold and `curb upgrade`.