Agent overview
An agent is a class: its fields are the state, its tools are what the model may do, its docstrings are the prompt.
What is an agent?#
You write the class. Pinecall runs everything around it: the audio, the turn taking, the call's log, memory, the judges. The class never imports any of it.
render() writes the last block, the only one that changes between turns.
The callthis.call: who is on the line, and what the agent can do to the call.
Hooks & eventsCode that runs when a call starts and ends, and facts your backend sends in.
What the class does not hold is what the agent runs on: the voice, the models, the language, the greeting, what it knows by heart and the documents it searches. Those are settings, changed without a deploy.
An example#
This is the agent pinecall new front-desk writes, with its view shortened:
import { Agent, tool, type Stages } from "@pinecall/agents";
/**
* You answer the phone for the business. Short sentences: everything you say is read aloud.
* You answer what the business's documents say. For anything else you take a message: who is calling
* and what it is about. You never promise when they will hear back.
*/
export default class FrontDesk extends Agent {
stage: Stages<"ask" | "done"> = "ask";
message?: { name: string; about: string } | undefined;
/**
* Writes down the caller's message. Call it as soon as the caller has said their name and what the call is
* about, in that same turn and without reading it back first; never with a blank or a guess.
*/
@tool({ stage: "ask", pii: ["name"] })
async takeMessage(name: string, about: string): Promise<{ name: string; about: string }> {
this.message = { name, about };
this.stage = "done";
return this.message;
}
override render() {
return (
<>
{this.stage === "ask" && <p>Greet the caller. Ask for their name and what the call is about.</p>}
{this.message && <p>The message is written down. Say goodbye and hang up.</p>}
</>
);
}
}
from typing import Literal
from pinecall import Agent, tool
class FrontDesk(Agent):
"""You answer the phone for the business. Short sentences: everything you say is read aloud.
You answer what the business's documents say. For anything else you take a message: who is calling
and what it is about. You never promise when they will hear back.
"""
stage: Literal["ask", "done"] = "ask"
message: dict[str, str] | None = None
@tool(stage="ask", pii=("name",))
def take_message(self, name: str, about: str) -> dict[str, str]:
"""Writes down the caller's message. Call it as soon as the caller has said their name and what the call is
about, in that same turn and without reading it back first; never with a blank or a guess.
"""
self.message = {"name": name, "about": about}
self.stage = "done"
return self.message
The view is a Jinja template beside the class, agents/front-desk/views/front-desk.jinja:
{% if stage == "ask" %}
Greet the caller. Ask for their name and what the call is about.
{% endif %}
{% if message %}
The message is written down. Say goodbye and hang up.
{% endif %}
require "pinecall"
# You answer the phone for the business. Short sentences: everything you say is read aloud.
# You answer what the business's documents say. For anything else you take a message: who is calling
# and what it is about. You never promise when they will hear back.
class FrontDesk < Pinecall::Agent
stage :ask, :done
state :message
# Writes down the caller's message. Call it as soon as the caller has said their name and what
# the call is about, in that same turn and without reading it back first; never with a blank or a guess.
tool stage: :ask, pii: %i[name]
def take_message(name:, about:)
self.message = { name:, about: }
self.stage = :done
message
end
end
The view is an ERB template beside the class, agents/front-desk/views/front-desk.erb:
<% if stage == :ask -%>
Greet the caller. Ask for their name and what the call is about.
<% end -%>
<% if message -%>
The message is written down. Say goodbye and hang up.
<% end -%>
Two fields of state, one tool, and a view. The model sees takeMessage (take_message in Python
and Ruby) only while stage is ask, and the view changes the moment the tool writes message.
How it works#
- The class is the agent's file: the default export of
agents/<name>/agent.tsx, the one class ofagents/<name>/agent.pyoragent.rb. The folder's name is the agent's slug on the gateway:agents/front-desk/is the agentfront-desk. A class whosestatic slugnames another slug is refused when it is served. - The class docstring is the first thing the model reads. In TypeScript it is the
/** … */comment directly above the class, which the CLI reads from the file so it survives compilation; in Python the class's docstring; in Ruby the comment above the class. - Each call gets its own instance. Field initializers run once per call, so two callers never share a list or a patient.
- Tools and hooks are the only writers. Assigning a state field 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 change carries its author (the tool's name, or hook:onCall), and that is what the call's log,
the console and a golden read.
The voice, the models, the language, the greeting and the other settings are not state. A class declares them on the class itself —
@voice,@llm,@sttandstaticfields — and then they win over the settings; one declared as an instance field is refused when the class loads. See Settings in the class.