Writing an agent
The class: config, state, stages, tools, hooks, the call, the panel, and what is refused when it loads.
An agent is one TypeScript class, the default export of agents/<slug>/agent.tsx. Everything the
model can see about it comes from four places: the docstring above the class, what the class
declares, the docstrings of its tools, and render().
import { Agent } from "@pinecall/agents";
/**
* You are the front desk of Clínica Norte. Formal, short sentences.
* Everything you say is read aloud: no lists, no markdown.
*/
export default class ClinicaNorte extends Agent {}
That docstring is the identity block of the prompt — the first of the static blocks, which never
change during a call and are what a provider caches. Write it as instructions to a person, not as
documentation. It must be a /** … */ comment directly above the class: the CLI hands the class
its own source, so the comment survives compilation. static doc = "…" says it instead, for a
build that strips comments.
Config: what the agent is#
Written on the class, because it is not something the agent remembers. It never changes during a call and no view renders it.
| written | what it does |
|---|---|
channelRules = false |
leaves out the <channel> part of the prompt: how to write for a voice, a website's chat, WhatsApp |
static slug = "front-desk" |
the name the agent registers as; left out, the folder's name. A class served under another slug is refused |
static events = { "slot.released": { from: ["app"] } } |
the outside events the agent takes, and from whom: app is your backend, participant a browser in the call |
The environment: the settings', or the class's#
Everything the agent runs on is its settings': set per world, versioned, with who set it and
why, and changed without a deploy — by pinecall agent set, the console's Settings tab, or the
verb the table names. The class may declare any of it instead, and what the class declares wins:
the settings of that field are not read for the agent, the console shows it locked, "set by the
class", and a set of it is refused naming the class. Take it out of the class to set it again.
@voice("cartesia", "a0e99841-438c-4a64-b679-ae501e7d6091", { model: "sonic-2" })
@llm("openai/gpt-5.4-mini", { temperature: 0.3, builds: "responses.LLM", options: { use_websocket: true } })
@stt("soniox/stt-rt-v3", { endOfTurn: "smart-turn" })
export default class ClinicaNorte extends Agent {
static language = "es";
static greeting = "Clínica Norte, buenas, ¿en qué le ayudo?";
static hangup = "the caller says goodbye or needs nothing else";
}
greeting is the words, said as written — instant, and no model runs — or the model's own:
improvise opens on the prompt alone, improvise("Greet them by name if you know it") with an
instruction for the opening. The caller cannot cut it short unless it says so:
improvise("…", { interruptible: true }), or { text: "…", interruptible: true } for words.
hangup is when the model may end the call, in your words, or true whenever it judges the call
done. @stt also takes endOfTurn, who says the caller's turn is over: "stt" the ears
themselves (Deepgram Flux; refused for ears that cannot), "livekit" or "smart-turn" (Smart Turn
v3), a model on the worker that runs on any key. Left out, the ears end the turn where they can and Smart Turn v3 does everywhere else.
@judge names the model the agent's calls are judged on, over the org's choice and the platform's:
on a key of your org's own its evals are never billed, a local model included —
@judge("openai/qwen3-32b", { options: { base_url: "http://gpu:8000/v1" } }) — and it must call
tools, since every judge answers through one.
@llm, @stt and @judge take vendor/model or a vendor alone; @voice the vendor and its own id for the
voice. Each takes builds, a class of the vendor's livekit plugin other than its default (dotted
names reach a module of it: responses.LLM is OpenAI's Responses API, and use_websocket its
WebSocket), and options, that class's keyword arguments as the plugin names them, passed as given
and over the operator's. Both run only on your org's own key for that vendor: on a key Pinecall
lends they are refused at registration, naming pinecall providers add <vendor>, since an option
can point the plugin at another server. @llm also takes temperature, which runs on any key. A
vendor that is not installed, or does not do the stage, is refused at registration. A decorator
fixes the whole stage: with @llm in the class, --temperature, --llm-builds and --llm-option
are refused too. The settings take the same knobs for a class that leaves the stage to them
(pinecall agent set --temperature 0.3 --llm-builds responses.LLM --llm-option use_websocket=true). The rest are static fields: an instance field is the call's state, so one of these
declared there is refused when the class loads.
| field | what it is | on the class | or in the settings |
|---|---|---|---|
voice |
the voice: its vendor and the vendor's id | @voice("<vendor>", "<id>") |
pinecall agent set --voice |
llm |
the model that answers, and its temperature | @llm("<vendor>/<model>", { temperature }) |
pinecall agent set --llm |
stt |
the ears, and who ends the caller's turn | @stt("<vendor>/<model>", { endOfTurn }) |
pinecall agent set --stt · --end-of-turn |
judge |
the model its calls are judged on | @judge("<vendor>/<model>", { options }) |
pinecall agent set --judge |
language |
the language the call is in | static language = "es" |
pinecall agent set --language |
greeting |
how the call opens: the words, or the model's own | static greeting = "…" · improvise · improvise("…") |
pinecall agent set --greeting '…' · --greeting improvise · --greeting improvise:'…' |
hangup |
whether the model may end the call itself, and when, in your words | static hangup = "…" · true |
pinecall agent set --hangup '…' · --hangup any |
turn |
when the caller has finished, and may interrupt | static turn = { endpointingMs: 300 } |
pinecall agent set |
says |
how a word the voice would misread is said | static says = [{ word, spoken }] |
pinecall lexicon add <word> --say '…' |
hears |
the words the ears must know: names, brands, the doctor's surname | static hears = ["…"] |
pinecall lexicon hear <word> … |
memory |
what to remember about a caller across calls, and what never to | static memory = { remember: […], forget: […] } |
pinecall memory policy --remember '…' --forget '…' |
record |
whether the call is recorded | static record = false |
pinecall agent set --record on|off |
knowledge |
what the agent knows by heart: a page of Markdown, read whole on every call | static knowledge = { path, text } |
pinecall agent knowledge edit |
docs |
the base the agent searches per turn | static docs = { base: "…", k: 4 } |
pinecall docs push, then pinecall docs attach <base> |
Neither a fact the agent remembers nor a chunk of a base ever reaches the prompt. Both arrive
as a tool result, in the history, where a model reads them as information rather than as an
instruction. A tool that searches calls this.knowledge.search(…); the class says nothing else
about it, and a class that reads this.knowledge is refused at registration when no base is
attached, rather than at the first turn.
State: what it remembers#
Every own field of the instance is state, and so is every getter. Each call gets its own instance, so field initializers run once per call and two callers never share a list.
import { Agent, state, type Stages } from "@pinecall/agents";
export default class ClinicaNorte extends Agent {
@state stage: Stages<"identify" | "choose" | "book" | "done"> = "identify";
@state({ pii: true }) patient?: Patient | undefined;
@state slots: Slot[] = [];
@state proposed?: Slot | undefined;
get identified() { // derived: in every snapshot, assigned by nobody
return this.patient !== undefined;
}
private agenda() { // a method is a collaborator, never state
return new Agenda();
}
}
stageis typed withStages<…>, which names its values once: a tool'sstageuses the same words, and a misspelled stage is a compile error.@statesays who may see a field:@state({ pii: true })masks it in the log,@state({ visibility: "public" })lets a participant's browser read it, and bare@stateis the default,tenant. Decorating one field decides them all: a class with no@statehas every field as state, and a class with any has those and nothing else. An undecorated field is then scratch space, out of the prompt, the log and goldens —stageincluded, so the tools it gates would never show. Decorate every field, or none.- A getter is state, read every time the state is copied. A collaborator belongs in a method.
Tools and hooks are the only writers. Once the agent is sealed — every call seals its instance —
a write anywhere else throws UnauthoredWrite:
state field patient was assigned outside a tool and outside a lifecycle hook; tools are the only writers of state
Every write is recorded with its author — the tool's name, hook:onCall, event:<name> — carried
by AsyncLocalStorage, so two calls served at the same moment never read each other's.
this.collapse("…") replaces the record with one sentence; this.log(name, data) writes a line of
your own into the call's log.
Tools: what the model may do#
/** Finds the patient's file by their full name and phone. Call it only once you have both. */
@tool({ stage: "identify", pii: ["name", "phone"] })
async findPatient(name: string, phone: string): Promise<Patient | null> {
this.patient = await this.agenda().find(name, phone);
if (this.patient) this.stage = "choose";
return this.patient ?? null;
}
The docstring is what the model reads to choose the tool, joined into one line. The signature is
the schema it fills: string, number, boolean and arrays of them are typed; an interface or a
type declared in the same file is read field by field; an optional parameter is not required. What
the model sends is checked against it before the method runs.
| option | what it does |
|---|---|
stage |
visible while the state is in this stage, or one of these: stage: ["choose", "book"] |
when |
a question asked of the state on every change: when: (s) => s.slots.length > 0 |
confirm |
the sentence said after the tool runs. This is what makes a tool irreversible on the wire; {{result.x}} names a field of what it returns |
announce |
what the agent says as the tool starts ("Let me check the agenda."), when the model's turn said nothing itself; a turn that spoke first is not announced twice |
preview |
how many items of a list the model sees. The state keeps every one |
pii |
the parameters that carry personal data, masked in the log |
timeout |
how many seconds the platform waits for this method: 30 unless set, 300 at most |
params |
an explicit zod schema, when the signature is not enough |
A tool that throws does not crash the call: its message becomes the tool's result, marked as an
error, and the model reads it. Throw a sentence a model can use — "Tuesday has no free slots" —
never a stack trace. And ask the model for a word, not a record: book(slot: string), looked up
among the slots it was offered, is safer than book(slot: Slot).
The hooks#
override async onCall(call: Call) { // a call started; writes here are authored by the hook
this.patient = await this.agenda().byPhone(call.from ?? "");
if (this.patient) this.stage = "choose";
}
override onEnd(call: Call) { // a line logged here still lands
this.log("outcome", { stage: this.stage });
}
override onEvent(name: string, data: Record<string, unknown>, meta: EventMeta) {} // an accepted outside fact
override onMemory(ops: MemoryOp[], call: Call) {} // memory read or written
onCall runs before the first prompt goes out, so the model never reads a state the call was not
in; the state a golden or a persona opens the call in is applied after it, so the hook never
overwrites it. An outside fact reaches onEvent only if the class declared the pair in
static events: an event declared from: ["app"] that arrives from a browser is somebody else's
event with your name on it, and the hook never sees it.
The call#
Inside a tool, a hook or render(), this.call is the live call; outside one it throws.
await this.say("One moment, let me check."); // true once the turn lands, false after 30 s
await this.reply("Tell them it is booked."); // the model speaks, guided by words nobody hears
this.call.send("cart", { total: 42 }); // a payload to the browsers in the room
this.call.participant(identity).mute(); // and .remove()
this.call.invite("+34910000001", { kind: "sip" }); // a phone leg into the call
await this.call.transfer("+34910000002"); // { ok, to, mode, error? }: ok false if they are still here
await this.call.attention("wants to talk to a person", { waitS: 60 }); // { ok, by, error? }
this.call.hold(); // and unhold()
this.call.dtmf("1#");
this.call.claim("4821"); // the page showing 4821 follows this call
this.call.callback("+34600000001", { when: "tomorrow afternoon", note: "a quote" });
this.call.optOut("does not want more calls"); // their number joins the do-not-call list
this.call.hangup("done");
await this.knowledge.search("summer opening hours", { k: 3 }); // the chunks, searched for this call
this.call also carries id, contact, from, channel, medium, today — the day the call
runs in, which is what "next Tuesday" is counted from: the day a golden pinned when one did, else
the day the call opened — history, room and claimed.
There is no LiveKit here and no escape hatch to it: a need the room cannot express is a new command with a name.
The panel beside a conversation: @view#
The console draws a pane beside every thread in Calls. A class that declares a view has its own panel drawn there — the customer's file, their orders, the balance — from your own systems:
import { Agent, view, type Who } from "@pinecall/agents";
import { Badge, Panel, Row, Rows, Stat, Table } from "@pinecall/agents/panels";
async function CustomerCard(who: Who) {
const client = await crm.find(who.contact);
if (client === undefined) return <Panel title="Not on file">Not in the CRM.</Panel>;
return (
<Panel title={client.name}>
<Rows>
<Row label="Since">{client.since}</Row>
<Row label="Area">{client.area}</Row>
</Rows>
<Stat label="Jobs" value={client.jobs.length} />
<Table columns={["date", "job", "amount"]} rows={client.jobs} />
<Badge tone={client.debt > 0 ? "warn" : "good"}>{client.debt > 0 ? "owes" : "paid up"}</Badge>
</Panel>
);
}
@view(CustomerCard, "Customer")
export default class ClinicaNorte extends Agent {}
What reaches the console is a tree of the closed catalogue — Panel, Rows, Row, Stat,
Table, Badge, Text — drawn by the console's own parts; nothing a tenant writes reaches the
page's styling or its scripts. who is the conversation — agent, contact, call — and nothing
else: the panel is read beside threads that ended weeks ago, so it fetches what it shows. One per
class.
What is refused when the class loads#
Every one of these is refused before any call, with a message that says what to write:
| refused | the sentence |
|---|---|
| a tool with no docstring | tool book: without a docstring no model can choose it; write a /** one line */ above the method |
| a tool name a model cannot call | a tool name is one word a model can call, not … |
pii: ["dni"] on a tool with no dni |
tool book: pii names parameters the tool has; unknown: dni |
stage on a class with no stage field |
tool book: stage names a value of this agent's own stage field, and ClinicaNorte declares none; add \stage: Stages<"…"> = "…"` to the class, or ask when(state) instead` |
@tool on something that is not a method |
@tool goes on a method; … is not one |
@state({ pii: true, visibility: "public" }) |
two different answers to one question; write one |
both @render(Prompt) and a render() method |
ClinicaNorte declares both @render(Prompt) and a render() method; two ways to answer one question — keep one |
two @view decorators |
ClinicaNorte declares two views (…); a class draws one panel |
language, greeting, says, … as an instance field |
`language` is the class's, not a call's state: declare it as static language = … |
both @llm(…) and a static llm |
ClinicaNorte declares both @llm(…) and a static llm; keep one |