Settings in the class
Voice, models and the rest declared in the class itself: what it declares wins over the settings.
An agent's voice, models, language and the rest of its settings can be written in the class itself. What the class declares wins: the agent runs on it whatever its settings say, and the console shows that field locked, "set by the class".
How it works#
Every setting has two possible sources, and each field is read from one of them:
pinecall agent set or the console, per corner, versioned, changed without a deploy.
A field the class declares is fixed: its settings are not read, and a change to it from the CLI or the console is refused with a sentence that names the class. A field the class does not declare comes from the settings, as before. Take a field out of the class and deploy, and the settings apply to it again. Fix in the class what must not change without a code review; leave in the settings what an operator tunes from the console.
Example#
import { Agent, improvise, llm, stt, voice } from "@pinecall/agents";
/** Recepción de Clínica Norte: da, cambia y cancela turnos. */
@voice("cartesia", "a0e99841-438c-4a64-b679-ae501e7d6091", { model: "sonic-2" })
@llm("openai/gpt-5.4-mini", { temperature: 0.3 })
@stt("soniox/stt-rt-v3", { endOfTurn: "smart-turn" })
export default class ClinicaNorte extends Agent {
static language = "es";
static greeting = improvise("Greet them by name if you know it, and ask how you can help");
static hangup = "the caller says goodbye or needs nothing else";
static memory = { remember: ["allergies", "their usual doctor"], forget: ["payment details"] };
}
from typing import ClassVar
from pinecall import Agent, improvise, llm, stt, voice
@voice("cartesia", "a0e99841-438c-4a64-b679-ae501e7d6091", model="sonic-2")
@llm("openai/gpt-5.4-mini", temperature=0.3)
@stt("soniox/stt-rt-v3", end_of_turn="smart-turn")
class ClinicaNorte(Agent):
"""Recepción de Clínica Norte: da, cambia y cancela turnos."""
language = "es"
greeting = improvise("Greet them by name if you know it, and ask how you can help")
hangup = "the caller says goodbye or needs nothing else"
memory: ClassVar = {"remember": ["allergies", "their usual doctor"], "forget": ["payment details"]}
# Recepción de Clínica Norte: da, cambia y cancela turnos.
class ClinicaNorte < Pinecall::Agent
voice "cartesia", "a0e99841-438c-4a64-b679-ae501e7d6091", model: "sonic-2"
llm "openai/gpt-5.4-mini", temperature: 0.3
stt "soniox/stt-rt-v3", end_of_turn: :smart_turn
language "es"
greeting improvise("Greet them by name if you know it, and ask how you can help")
hangup "the caller says goodbye or needs nothing else"
memory remember: ["allergies", "their usual doctor"], forget: ["payment details"]
end
In TypeScript the three models are decorators and the rest are static fields. In Python the
models and knowledge are decorators (@knowledge(path=…, text=…), since a knowledge attribute
would hide self.knowledge.search) and the rest are class attributes, plain or ClassVar. In Ruby
every one is a class macro. A field of the instance is the call's state, so one
of these declared there (an instance field in TypeScript, an attribute annotated as state in
Python) is refused when the class loads, with the way to declare it instead.
What a class can declare#
| field | TypeScript | what it is |
|---|---|---|
| voice | @voice("<vendor>", "<voice id>", { model }) |
the vendor that speaks and its own id for the voice. Both are required |
| model | @llm("<vendor>/<model>", { temperature }) |
the model that answers. A vendor alone runs its default model |
| ears | @stt("<vendor>/<model>", { endOfTurn }) |
the speech-to-text, and who ends the caller's turn (below) |
| judge | @judge("<vendor>/<model>", { options }) |
the model the agent's calls are judged on (below) |
| language | static language = "es" |
the language the voice and the ears are set to |
| greeting | static greeting = "…" · improvise · improvise("…") |
how the call opens (below) |
| hang-up | static hangup = "…" · true |
lets the model end the call, and says when |
| turn taking | static turn = { endpointingMs: 300, minInterruptionWords: 2 } |
when a caller's turn is over, and what interrupts the agent |
| pronunciation | static says = [{ word: "GSA", spoken: "ge ese a" }] |
how the voice says a word it would misread |
| vocabulary | static hears = ["Vidal", "Sanitas"] |
the words the ears must know |
| knowledge | static knowledge = { path: "knowledge.md", text } |
Markdown read whole on every call |
| documents | static docs = { base: "clinica-norte", k: 4 } |
the knowledge base searched per turn |
| memory | static memory = { remember: […], forget: […] } |
what to keep about a caller across calls, and what never to |
| recording | static record = false |
whether calls keep their audio |
@llm fixes the whole model: its vendor, its model, its temperature and its plugin options. With
@llm in the class, pinecall agent set --temperature is refused too. The same goes for @voice
(with --tts and --tts-model) and @stt.
A vendor that is not installed, or that does not do that job, is refused when the agent registers,
not on its first call: no vendor named 'openai-but-misspelt', anthropic has no stt: it does llm.
How the call opens and ends#
A greeting is words, or the model's own:
static greeting = "Clínica Norte, buenas, ¿en qué le ayudo?"; // said as written, at once
static greeting = improvise; // the model opens, on its prompt
static greeting = improvise("Greet them by name if you know it"); // the model opens, on this instruction
static hangup = "the caller says goodbye or needs nothing else"; // when it may end the call
static hangup = true; // whenever it judges the call done
greeting = "Clínica Norte, buenas, ¿en qué le ayudo?"
greeting = improvise
greeting = improvise("Greet them by name if you know it")
hangup = "the caller says goodbye or needs nothing else"
hangup = True
greeting "Clínica Norte, buenas, ¿en qué le ayudo?"
greeting :improvise
greeting improvise("Greet them by name if you know it")
hangup "the caller says goodbye or needs nothing else"
hangup true
Words cost nothing and are the same every time; improvise costs one model turn before the caller
hears anything. The caller cannot cut the opening short — a "hello?" over it does not stop the
agent — unless it says so: improvise("…", { interruptible: true }), or { text: "…", interruptible: true } for words (interruptible=True and words("…", interruptible=True) in Python,
interruptible: true in Ruby).
Who ends the turn#
@stt takes endOfTurn, who says the caller has finished speaking:
| value | what decides | runs |
|---|---|---|
"stt" |
the ears themselves | only ears that can (Deepgram Flux); refused for the rest when the agent registers |
"smart-turn" |
Smart Turn v3, a model that reads the end of the turn off the audio | beside any ears, on the worker, on any key |
"livekit" |
LiveKit's own detector, the same way | beside any ears, on the worker, on any key |
Left out, the ears end the turn where they can (Flux), and Smart Turn v3 does everywhere else: Soniox
runs Smart Turn without a word. Name one only to choose another, as endOfTurn: "livekit" does;
in the settings, --end-of-turn livekit.
Plugin options#
Every vendor runs on its LiveKit plugin. builds picks a class of that plugin other than its
default, and options are that class's keyword arguments, written as the plugin names them and
passed as given. The OpenAI Responses API over a WebSocket, which keeps one connection open for the
whole call instead of a request per turn:
@llm("openai/gpt-5.4-mini", { builds: "responses.LLM", options: { use_websocket: true } })
@llm("openai/gpt-5.4-mini", builds="responses.LLM", options={"use_websocket": True})
llm "openai/gpt-5.4-mini", builds: "responses.LLM", options: { use_websocket: true }
builds reaches into a module of the plugin with a dot: responses.LLM is
livekit.plugins.openai.responses.LLM. A name the plugin does not export is refused in a sentence
that says so. An option the plugin does not take is refused before the call, in the plugin's own
words. Each option the class names replaces Pinecall's option of the same name for that vendor; the
others are kept.
Plugin options and
buildsrun only on your org's own key for that vendor. A stage that runs on the key Pinecall lends is refused with them, when the agent registers and when they are set: add your key withpinecall providers add <vendor>, or take them out. An option can point the plugin at another server, and Pinecall's key never goes anywhere but the vendor.
The same three are settings too, for an agent whose class does not fix that model:
--llm-builds, --llm-option, and their stt, tts and judge twins (Agent settings).
The judge model#
Every finished call is judged by a model. @judge names the one this agent's calls are
judged on, over your org's choice (pinecall judging --model) and Pinecall's. On your org's own key for that
vendor its evals are never billed — a model on a server of your own, through base_url, included:
@judge("openai/qwen3-32b", { options: { base_url: "http://gpu:8000/v1" } })
@judge("openai/qwen3-32b", options={"base_url": "http://gpu:8000/v1"})
judge "openai/qwen3-32b", options: { base_url: "http://gpu:8000/v1" }
Every judge answers through a forced tool call, so the model must call tools.
In the console and the CLI#
The agent's Configure screen shows a field the class fixes read-only, marked "set by the class",
with the value the next call runs on for the voice, the models and the language; the Lexicon
screen does the same for says and hears. pinecall agent prints class in the column of every
corner for that field:
yours team production
voice class class class
llm class class class
language — es es
A change to a fixed field is refused, and nothing is saved:
$ pinecall agent set --voice amelia
voice set by the class of clinica-norte: the class's declaration wins over these settings, so change it there, or take it out of the class to set it here
Which fields are fixed is what the process holding the agent declared. With no process holding the agent in that environment, nothing is fixed and every field is the settings'.