Tools
A tool is what the model can call. Render a <Tool> in a skill, and it exists while that skill is
loaded. Add one to the claim skill:
// 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 (
<>
<Instruction>Ask what happened and when, then call open_claim.</Instruction>
<Text>{claim ? `Claim opened: ${claim}.` : "No claim is open yet."}</Text>
<Tool
name="open_claim"
description="Open a claim."
schema={z.object({ what: z.string(), when: z.string() })}
onSubmit={({ what, when }) => {
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).
The model reads message and data. A message on ok: false is an instruction to the model,
so say what to do next.
Props
<Tool
schema={z.object({
email: z
.string()
.email("Email address is invalid. Ask the user for a corrected email address.")
.describe("Customer email address."),
})}
name="submit_property_damage_claim" // snake_case verb, unique across the agent
description="Submit a property damage claim intake."
success="Property damage claim intake was accepted."
onSubmit={(input) => ({ ok: true, data: input })}
/>
-
schemais 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()).onSubmitgets the parsed, typed value, so give a tool the shape it needs instead of a string to split: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 isz.iso.date()orz.iso.datetime(). -
onSubmitreturns aToolResult:{ ok: boolean, message?, data? }. Schema validation failures are generated automatically from the Zod messages; for handler-level failures return{ ok: false, message }or throwToolInputError. Rendered<ErrorMessage>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 writeToolInputErrormessages for anything the model can fix. -
Tools inside
<GlobalTools>exist from the first turn, whichever skills are loaded; tools inside a skill exist only while that skill is loaded. -
tryWithis 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:<Tool schema={verifySchema} name="verify_customer" description="Check the customer number and year of birth." tryWith={{ customerNumber: "K-482913", birthYear: "1985" }} // a customer your demo data knows onSubmit={verify} /> -
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, likedefineAgent) and pass it viadef: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: <Tool def={submitClaim} /> // equivalently: <Tool {...submitClaim} />Both compile to the same tool. The object supports every inline variant:
schema+onSubmit,onSubmitalone, oronCall; you can also type an object by hand with the exportedToolPropstype instead ofdefineTool.
Calling your own API from a tool
// .env.example lists ORDER_API_URL, such as https://shop.example.com/api/ (with the final "/").
async function orderApi(path: string): Promise<Response> {
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.";
<Tool
schema={z.object({ order: z.string().min(1).describe("Order number, such as A-1234.") })}
name="look_up_order"
description="Look up the status of an order."
onSubmit={async ({ order }) => {
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")ishttps://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 withconsole.error. An emptycatch {}hides why calls fail. - Throw
ToolInputErrorfor 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). 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.
-
Put the credential in
.env, whichnpm run devreads: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. -
Add the names to
curb.requiredRuntimeSecretsinpackage.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.{ "curb": { "requiredRuntimeSecrets": [ "CALENDAR_CLIENT_ID", "CALENDAR_CLIENT_SECRET" ] } } -
Read the variables in the tool, each time it runs:
import { defineTool, z } from "curb"; // An app-only token from the calendar's sign-in server (OAuth client credentials). async function calendarToken(): Promise<string> { 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
<Tool def={findFreeSlots} />.
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.envinside 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: make a tool appear only when it should.
- Confirmations: have the caller approve an action first.