pinecallDocs
Python / Writing an agent

Writing an agent

The class: config, state, the stage, tools, the events it accepts, and what is refused when it is created.

An agent is one Python class. Everything the model can see about it comes from four places: the class's docstring, what the class declares, the docstrings of its tools, and the view.

Python
from typing import Literal

from pinecall import Agent, state, tool


class ClinicaNorte(Agent):
    """You are the front desk of Clínica Norte. Formal, short sentences.
    Everything you say is read aloud: no lists, no markdown."""

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 reaches the model on one line, its blank lines dropped.

Config: what the agent is#

Written on the class, unannotated or as a ClassVar, because it is not something the agent remembers. It never changes during a call and no view renders it.

written what it does
channel_rules = False leaves out the <channel> part of the prompt: how to write for a voice, a website's chat, WhatsApp
slug = "front-desk" the name the agent registers as; left out, the class's name in kebab-case (ClinicaNorte → clinica-norte)
accepts = {"slot.released": ["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 Configure screen, or the verb the table names. The class may declare any of them 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 pinecall agent set of it is refused naming the class. Take it out of the class and deploy, and the settings apply again.

Python
from typing import ClassVar

from pinecall import Agent, judge, llm, stt, voice


@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", end_of_turn="smart-turn")
@judge("openai/qwen3-32b", options={"base_url": "http://gpu:8000/v1"})
class ClinicaNorte(Agent):
    """Recepción de Clínica Norte: da, cambia y cancela turnos."""

    language = "es"
    greeting = "Clínica Norte, buenas, ¿en qué le ayudo?"
    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: greeting = improvise opens on the prompt alone, greeting = improvise("Saludá por el nombre si lo sabés") with an instruction for the opening. The caller cannot cut it short unless it says so: improvise("…", interruptible=True), or words("…", interruptible=True) for words. hangup is when the model may end the call, in your words, or hangup = True whenever it judges the call done. @stt also takes end_of_turn=, 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.

The three models and knowledge are decorators; the rest are class attributes, plain or annotated ClassVar. An attribute annotated any other way is the call's state, so one of these written that way is refused when the class is created, with how to declare it; and knowledge as an attribute would hide self.knowledge.search, so it is refused for @knowledge(path=…, text=…).

@llm and @stt 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 (a dot reaches into 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 Pinecall's. Both run only on your org's own key for that vendor: on a key Pinecall lends they are refused when the agent registers, 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. @llm fixes the whole model: with it on the class, --temperature, --llm-builds and --llm-option are refused too.

@judge is the model the agent's calls are judged on at hang-up, over the org's choice and Pinecall's own: vendor/model or a vendor alone, with builds= and options= as @llm takes them. On a key of your org's own for that vendor — a local model's server among them, through options={"base_url": "…"} — its evals are never billed; on a key Pinecall lends they are. The judge is asked for a forced tool call, so a local model must call tools.

field what it is on the class or in the settings
voice the voice: its vendor and the vendor's id @voice("<vendor>", "<id>", model=…) pinecall agent set --voice
llm the model that answers, and its temperature @llm("<vendor>/<model>", temperature=0.3) pinecall agent set --llm
stt the ears, and who ends the caller's turn @stt("<vendor>/<model>", end_of_turn="smart-turn") pinecall agent set --stt · --end-of-turn
judge the model the calls are judged on; on your own key its evals are not billed @judge("<vendor>/<model>", options={"base_url": "…"}) the settings' judge, judge_builds, judge_options
language the language the call is in language = "es" pinecall agent set --language
greeting how the call opens: the words, or the model's own 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 hangup = "…" · True pinecall agent set --hangup '…' · --hangup any
turn when the caller has finished, and may interrupt turn: ClassVar = {"endpointing_ms": 300} pinecall agent set --endpointing-ms
says how a word the voice would misread is said says: ClassVar = [{"word": "DKV", "spoken": "de ka uve"}] pinecall lexicon add <word> --say '…'
hears the words the ears must know: names, brands, the doctor's surname hears: ClassVar = ["Vidal", "Sanitas"] pinecall lexicon hear <word> …
memory what to remember about a caller across calls, and what never to memory: ClassVar = {"remember": […], "forget": […]} pinecall memory policy --remember '…' --forget '…'
record whether the call is recorded record = False pinecall agent set --record on|off
knowledge what the agent knows by heart: a page of Markdown, read whole on every call @knowledge(path="knowledge.md", text=…) pinecall agent knowledge edit
docs the base the agent searches per turn docs: ClassVar = {"base": "clinica-norte", "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 self.knowledge.search(…); the class says nothing about it, and the package reads it off the class's file when the agent registers, so a world with no base attached is refused then rather than at the first turn.

State: what it remembers#

Every annotated attribute whose name does not start with _ and that is not a ClassVar is a state field. Its value is where every call opens; a list or a dict is copied, so no two calls ever share one.

Python
class ClinicaNorte(Agent):
    stage: Literal["identify", "choose", "book", "done"] = "identify"
    patient: dict | None = state(pii=True)
    slots: list[dict] = []
    proposed: dict | None = None

    @property
    def identified(self) -> bool:  # derived: in every snapshot, assigned by nobody
        return self.patient is not None
  • stage is a Literal of the values it may hold, opening at the one written (the first, if none is). A tool's stage= is checked against it, and setting it to anything else raises NotAStage — pyright says so before the code runs.
  • state(...) is for a field that also says who may see it: state(pii=True), or state(default, visibility="public"). Left out, the wire's default applies (tenant).
  • A public @property is a derived field. A cached_property, a method or a _name is a collaborator — the agenda, a client — and never state.
  • A subclass's own __init__ needs no super().__init__(): the state opens on its first use.

Tools are the only writers. Once the agent is sealed — every call seals its instance — a write anywhere but in a tool or a hook raises 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 own name, carried by a ContextVar: two calls served at the same moment never read each other's. agent.changes() is the record; agent.collapse("…") replaces it with one sentence; agent.log(name, data) writes a line of your own into the call's log.

Tools: what the model may do#

Python
class ClinicaNorte(Agent):
    @tool(stage="identify", pii=("name", "phone"))
    def find_patient(self, name: str, phone: str) -> dict | None:
        """Finds the patient's file by their full name and phone.

        Call it only once the patient has given you both.

        Args:
            name: the full name, as they said it
            phone: the phone number, as they said it
        """
        self.patient = self.agenda.find(name, phone)
        if self.patient:
            self.stage = "choose"
        return self.patient

The docstring, up to its first section, is what the model reads to choose the tool; the Args: section is what it reads of each parameter; the annotations are the schema it fills, and what it sends is checked and converted against them before the method runs: a Literal is an enum, a pydantic model a nested object, "3" for an int is 3. A parameter with no annotation is str — what a person on the phone says.

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 agent on every change: when=lambda self: bool(self.slots)
confirm= the read-back said before running. 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 rows of a list the model sees. The state keeps every row
pii= the parameters that carry personal data, masked in the log
timeout= how many seconds the platform waits for this method

A tool may be a def or an async def. In a call, an async def runs on the event loop and a def on a thread of its own, so a blocking call to your systems never holds the socket; both write as the tool. In a test, agent.run_tool("find_patient", {"name": "Ana", "phone": "600…"}) runs either to its end, as a call would.

The hooks#

Python
class ClinicaNorte(Agent):
    def on_call(self, call: CallWorld) -> None:  # a call started; writes here are the hook's
        self.patient = self.agenda.by_phone(call.from_ or "")
        if self.patient:
            self.stage = "choose"

    def on_end(self, call: CallWorld) -> None:  # a line logged here still lands
        self.log("outcome", {"stage": self.stage})

    def on_event(
        self, name: str, data: JsonObject, meta: EventMeta
    ) -> None: ...  # an accepted outside fact
    def on_memory(self, ops: list[MemoryOp], call: CallWorld) -> None: ...  # memory read or written

Each may be a def or an async def. on_call 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 on_event only if the class accepts the pair — 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 or a hook, self.call is the live call; outside one it says so rather than being None.

Python
self.say("One moment, let me check.")  # an Answer: True once the turn lands, False after 30 s
self.reply("Tell them it is booked.")  # the model speaks, guided by words nobody hears
self.call.send("cart", {"total": 42})  # a payload to the browsers in the room
self.call.participant(identity).mute()  # and .remove()
self.call.invite("+34910000001")  # a phone leg; invite(identity, "participant") for a seat
self.call.transfer("+34910000002")  # an Answer: a Transferred, ok=False if they are still here
self.call.attention("wants to talk to a person", wait_s=60)  # an Attended: who took the line
self.call.hold()  # and unhold()
self.call.dtmf("1#")
self.call.claim("4821")  # the page showing 4821 follows this call; call.claimed says so
self.call.callback("+34600000001", when="tomorrow afternoon", note="a quote")
self.call.opt_out("does not want more calls")  # their number joins the do-not-call list
self.call.hangup("done")
self.knowledge.search("summer opening hours", k=3)  # an Answer: the chunks, searched for this call

A verb that waits answers with an Answer. In an async def tool, await it; in a def tool, which runs on a thread of its own, .result() waits for it there — or leave it, and the command still goes. It is settled by the entry that says how it went, by its ceiling, or by the call ending first (ok=False, the call ended before it was answered).

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: @panel#

The console draws a pane beside every thread in Calls. Without a panel it is what the console itself knows — how many conversations there have been with this person, what they came in by, how long the agent spent on the line with them. A class that declares a panel has its own drawn over that, and that is where the business's data goes: the customer's file, their orders, the balance.

Python
from pinecall import Agent, Drawing, Who, panel


class ClinicaNorte(Agent):
    @panel("Customer")
    def file(self, who: Who, draw: Drawing) -> None:
        client = self.crm.find(who.contact)
        if client is None:
            with draw.panel("Not on file"):
                draw.text("Not in the CRM.")
            return
        with draw.panel(client.name):
            with draw.rows():
                draw.row("Since", client.since)
                draw.row("Area", client.area)
            draw.stat("Jobs", len(client.jobs))
            draw.table(["date", "job", "amount"], client.jobs)
            draw.badge("owes" if client.debt else "paid up", tone="warn" if client.debt else "good")

It is the TypeScript package's @view, and what reaches the console is the same: a tree of the closed catalogue — panel, rows, row, stat, table, badge, text — drawn by the console's own parts, in the theme the person reading chose; nothing a tenant writes reaches the page's styling, its scripts or its key. 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, on an instance of its own, never from a call's state. It may be an async def. A table's row is a sequence in the columns' order or a mapping keyed by them; a number is written whole when it is whole, and None or a boolean draws nothing. One per class, and a subclass does not inherit it.

What is refused when the class is created#

Every one of these raises DeclarationRefused on the file's import — never in the middle of a call, and never as a refusal from a gateway:

refused the sentence
a tool with no docstring without a docstring no model can choose it
def book(self, *chosen), **kwargs, or a positional-only parameter a model fills a JSON object by name, so every parameter is one it can name
pii=("dni",) on a tool with no dni pii names parameters the tool has; unknown: dni
stage="pay" where the Literal has no "pay" pay is not one of this agent's stages (identify, book)
stage= on a class with no stage …declares none; add stage: Literal["identify", "book"] to the class
stage: str stage names the values it may hold: stage: Literal["identify", "book"]
a field named like the agent's own (snapshot, log, call…) `snapshot` is a name the agent itself uses; call the field otherwise
a tool named like the agent's own method a tool named so would hide the agent's own log
an accepts sender that is not app or participant 'browser' sends nothing; an event comes from app or participant
state(pii=True, visibility="public") a field is pii or public, not both
two @panel methods declares two panels (Customer and Orders); a class draws one panel
a @panel method that does not take (self, who, draw) a panel draws with (self, who, draw)
language: str = "es" (annotated as state) `language` is the class's, not a call's state: declare it as a class attribute, `language = …` or `language: ClassVar = …`
knowledge = {…} `knowledge` is the class's, not a call's state: declare it as @knowledge(path="…", text="…")
both @llm(…) and an llm attribute ClinicaNorte declares both @llm(…) and a llm attribute; keep one