pinecallDocs
Key concepts / Stages

Stages

Which tools the model sees, by where the conversation is.

A stage says where the conversation is, and each tool says in which stages the model may call it. It is the simplest way to stop a model booking before it knows who it is talking to.

What is a stage?#

stage is an ordinary state field. Typing it with Stages<…> names its values once, so the class and every @tool({ stage }) use the same words, and a misspelled stage is a compile error.

agents/clinica-norte/agent.tsx
import { Agent, tool, type Stages } from "@pinecall/agents";

export default class ClinicaNorte extends Agent {
  stage: Stages<"identify" | "choose" | "book" | "done"> = "identify";
  patient?: Patient | undefined;

  /** Finds the patient by full name and phone. Ask for both before calling it. */
  @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;
  }

  /** Free slots on one day, for one specialty. */
  @tool({ stage: ["choose", "book"] })
  async freeSlots(day: string, specialty: string): Promise<Slot[]> { … }
}
agents/clinica-norte/agent.py
from typing import Literal

from pinecall import Agent, tool


class ClinicaNorte(Agent):
    stage: Literal["identify", "choose", "book", "done"] = "identify"
    patient: dict | None = None

    @tool(stage="identify", pii=("name", "phone"))
    def find_patient(self, name: str, phone: str) -> dict | None:
        """Finds the patient by full name and phone. Ask for both before calling it."""
        self.patient = self.agenda.find(name, phone)
        if self.patient:
            self.stage = "choose"
        return self.patient

    @tool(stage=("choose", "book"))
    def free_slots(self, day: str, specialty: str) -> list[dict]:
        """Free slots on one day, for one specialty."""
        ...

stage is a Literal of the values, opening at the one written. A tool naming a stage the Literal does not hold is refused when the class is created, and assigning any other value raises NotAStage.

agents/clinica-norte/agent.rb
class ClinicaNorte < Pinecall::Agent
  stage :identify, :choose, :book, :done
  state :patient

  # Finds the patient by full name and phone. Ask for both before calling it.
  tool stage: :identify, pii: %i[name phone]
  def find_patient(name:, phone:)
    self.patient = agenda.find(name, phone)
    self.stage = :choose if patient
    patient
  end

  # Free slots on one day, for one specialty.
  tool stage: %i[choose book]
  def free_slots(day:, specialty:)
    # ...
  end
end

stage declares the values, and the first is the initial one. A tool naming a stage the class did not declare is refused at load.

A tool moves the conversation on by writing stage, like any other field.

How it works#

The class declares the stages#

stage: Stages<"identify" | "choose" | "book" | "done"> = "identify". The initial value is where every call starts.

Each tool names the stages it belongs to#

@tool({ stage: "identify" }), or a list: @tool({ stage: ["choose", "book"] }). A tool with no stage is visible in every stage.

Every state change re-checks the tools#

stage is turned into a when(state) question when the class is declared. After each change, Pinecall asks it of the new state and sends the model the list of tools that are visible now.

stage and when combine: both must hold. Use when for anything that is not a stage:

TypeScript
@tool({ stage: "book", when: (s) => s.slots.length > 0 })
async book(slot: string): Promise<Booking> { … }

A getter is state too, so when can ask it: get identified() { return !!this.patient }.

The model's prompt lists every tool the class declares, with its docstring, visible or not. That keeps the prompt's cached prefix the same all call long. Only the list of tools the model may call changes. So a view that never says "you already know who you are talking to" leaves a model that sees findPatient in the list asking a known patient for their name again.

Seeing the stages#

pinecall prompt and pinecall start --show-prompt print, under the prompt's blocks, the stage the agent is in and the tools that stage shows. Give pinecall prompt a golden's state to see any stage:

Terminal
pinecall prompt --state test/clinica-norte/goldens/identifica-al-paciente.json

Refusals#

A tool that names a stage on a class with no stage field could never be visible. The class is refused when it is mounted:

tool findPatient: 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

Next steps#