pinecallDocs
Key concepts / Agent overview

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.

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:

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:

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 of agents/<name>/agent.py or agent.rb. The folder's name is the agent's slug on the gateway: agents/front-desk/ is the agent front-desk. A class whose static slug names 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, @stt and static fields — and then they win over the settings; one declared as an instance field is refused when the class loads. See Settings in the class.

Next steps#