State class in state.py.
agent.yaml says only where a value comes from and which step confirms it.
This is the complete reference. For an introduction, start with
Variables.
How a value moves
Values are supplied at session start, filled byprefetch:, or saved when
a task finishes through assign:. Prompts and tools read them through
explicit {{state.name}} references. Conversation sharing is controlled
separately by context.history.
state.py
agent.yaml
The State class holds the types
state.py sits next to agent.yaml. It holds one class named State, a
Pydantic BaseModel. Each field is one variable. The field’s type says what
the value is, its default says what it starts as, and Field(description=...)
is what the model reads when a task asks it for that value.
state.py
Booking above, are plain Pydantic models.
Use one as a field type to keep values that travel together in one object.
What a field can be
Core Pydantic and the standard library types it supports are open to you. So
are three
pydantic_extra_types modules, and no others:
An import from any other
pydantic_extra_types module is refused, so the
generated project’s dependency list stays known.
A phone number in E.164
PhoneNumber on its own saves the RFC 3966 form, tel:+34-600-111-222, which
no prompt or tool expects. Wrap it so the saved value is E.164:
state.py
caller_phone: Phone | None = None. The saved value reads
+34600111222.
A name and an email address
Pydantic’s ownNameEmail is one string in the schema, so a prompt cannot read
.name from it. Use a model with two fields:
state.py
{{state.contact.name}} and {{state.contact.email}}.
Every field has a default
Every field onState needs a default. Use = None with | None, = "" for
text and = [] for a list. A default that its own type refuses is refused too.
Literal["a", "b"] = "" fails, because an empty string is not one of the two
words. Write Literal["a", "b"] | None = None instead.
An unset value renders as none recorded yet. in a prompt.
What is refused
unmute validate names the field and the fix for each of these:
- An alias, or a frozen model.
- A
dictfield. Declare its keys as a model instead. - A union of two types that are not
None. - A model that holds itself.
- A field name that shadows a
BaseModelattribute, starts withmodel_, or isstate. - A field name the generated class uses:
initial_value,is_confirmed,is_unconfirmed,lookup,plain,render,save_batch,save_call_start,save_fact,save_result,slng_session_idandwithdraw_confirmation. - A name that is not lowercase words joined by single underscores. This applies at every depth, to the fields of a model inside a model too.
How a type is enforced
A declared type does two jobs, in two different places. The model is told, not constrained. A task’s finish call takes one argument for each value it assigns. Its schema is built from the field’s type and description. Format and pattern keywords are not sent, because one target sends its schema with strict mode on and rejects them. The rule travels as words in the description instead. The value is checked where it is saved. The generated project validates a value against your model before anything enters call state. A value that does not fit is refused, nothing is written, and the previous contents stand. The reason goes back to the model as the tool result. The model reads what was wrong and corrects itself on the next turn. Checking runs inside the process and makes no network request. The one thing that costs a model request is a refusal, because the caller waits through the retry. A type that refuses something a caller can legitimately say is worth changing. These checks validate format. Use tools to check calendar validity, record existence, phone ownership, and other business rules. Usestr for an external
identifier that needs other characters, and keep the exact value a tool
returned.
How unmute reads state.py
unmute validate and unmute compile run state.py through uv with pinned
Pydantic packages on Python 3.12, and read the JSON Schema Pydantic writes for
State. A warm run takes about 0.2 seconds.
uvmust be onPATHfor a package with astate.py, an SLNG-only package included. The first run needs network to fetch the pinned packages.- A package without a
state.pyneeds nouv. state.pymay importpydantic,phonenumbers, the three modules above and the standard library. A sibling import is refused.
state.py as written. A generated
call_state.py adds CallState, a subclass of your State that holds the one
way a value is saved, the confirmation marks and the rendering. It also builds
one result model per task step from your field types. The project’s dependencies
include email-validator when you use an email type, and
pydantic-extra-types when you import one of its modules.
Target support
variables: says where a value comes from
A field onState is a variable whether or not it appears in agent.yaml.
List it under variables: only when it needs a source: or a confirm:.
The block is a list, one entry per value.
agent.yaml
type:, default: or description: key, are refused
with a pointer to state.py. So is a shapes: block, which is a model in
state.py now. An entry with neither source: nor confirm: is refused too.
string
required
The field on
State this entry configures. An entry naming no field is refused.string
Accepts
call_start, conversation (SLNG only), or a system call
fact. If omitted, the variable has no automatic call-fact
source; a task, pre-fetch, or session payload can still fill it. Route support is
checked for system sources. conversation requires a Field(description=...) and an
= None default in state.py. Inbound code-target calls require a default for
call_start.string
The name of the task that must hear the caller agree to a pre-fetched value. Until
confirmed, only that task can read it in a prompt and tools cannot silently use it. If
omitted, the value is usable as soon as it arrives.
Sources
A value the model records during the call reaches no prompt. It has no value
when the prompt is built, and SLNG rejects a prompt that names one:
Field(description=...), which the model reads as it fills the value, and refer to the thing in prose instead of
with a placeholder.
| session_id | the phone adapter | LiveKit sip or connector |
| call_id | the phone adapter | LiveKit sip or connector, and both Pipecat Twilio routes |
| stream_id | the phone adapter | LiveKit connector, and Pipecat cloud-websocket |
| direction | the phone adapter | LiveKit sip or connector, and both Pipecat Twilio routes |
| from_number | the phone adapter | LiveKit sip or connector, both directions; both Pipecat Twilio routes, inbound calls only |
| to_number | the phone adapter | LiveKit sip or connector, both directions; Pipecat cloud-websocket, outbound calls only |
| carrier | the phone adapter | LiveKit sip or connector |
| connection | the phone adapter | LiveKit sip or connector |
The last eight are system sources. You declare them, and the selected route
must be able to supply the fact: a route that grants nothing for it refuses the
declaration at validation. A prefetch: entry reads the same route table as a
variable’s own source:. The same fact hydrates either way, on whichever route
grants it. The full grid has exactly what
each route grants, fact by fact and direction by direction.
For an inbound LiveKit or Pipecat telephony channel, every call_start variable
needs a default. There is no dispatch payload to supply an unsolicited incoming
call before its greeting.
A value from the dispatch or from the phone adapter is checked against the
field’s type in state.py when it is saved, the same check a step’s assign:
gets. A dispatched value that does not fit stops the call before the greeting,
and the error names the field. A phone-adapter fact that does not fit, such as
anonymous for an E.164 phone field, is treated as a fact that never arrived.
prefetch: resolves a value before the call starts
Some values are knowable before anybody speaks: today’s date, the number the call
came from, whatever a lookup keyed on that number returns. prefetch: resolves
them once per call, before the greeting, so the model never spends a turn
discovering them.
It is an ordered list. Entries resolve top to bottom.
agent.yaml
profile assigns two variables from one lookup. An entry can assign: as
many variables as the result has fields, from the one call.
Every entry carries a name: and exactly one source key.
A
clock: now entry reads the clock once and may assign: as many of the six
fields as you want. All six come from that one reading, so an entry assigning
result.date and result.time cannot straddle a second, and one assigning
result.date and result.day_of_week cannot straddle midnight.
args: belongs to a tool: entry only, and takes the same value grammar as a
tool’s inject:.
assign: and args: are lists of one-key items, not mappings. Each pair gets
its own - line. An item holding two pairs is a dropped indent and is refused
with its line number.Prefetch fields
string
required
A unique entry name used in errors and logs. Use lower snake case. No name is inferred.
string
Only
now is accepted. Choose exactly one of clock, source, and tool; omitting
all three is refused.string
Required with
clock: an IANA zone such as Europe/Madrid. There is no default,
because the container clock is UTC. Refused on source and tool entries.string
One of the eight system call facts. Choose exactly one
of
clock, source, and tool. An unavailable fact skips the entry and leaves its
destination values unchanged.string
A declared local or webhook tool. Choose exactly one of
clock, source, and tool.
No tool runs when this key is absent.boolean
Required with
tool: true if this use changes data, or false if it only reads.
There is no inferred answer. Refused with clock or source.list of one-key pairs
Arguments for a tool entry, as argument/value pairs. Values may use declared variable
placeholders. Omit when the tool needs no authored arguments; required inputs must still
be supplied. Refused on clock and source entries.
list of one-key pairs
required
One or more
variable: result.field pairs. Every destination must be declared. Values
are checked together before any are saved. No assignment is inferred.output: schema. Its
arguments may be fixed scalars or reference values saved by earlier entries.
The receiving fields must already be declared on State, with no source: of
their own. Prefetch fills scalar values, including date, time, a checked
phone number, an email address and a compatible Literal. A model, a list and a
nullable bare scalar such as str | None must be saved through a task. For
prefetched text, give the field = "" until the value is available.
A reference to a variable assigned by a later prefetch entry is refused.
Entries run in authored order. writes: true entries appear in the compile
report and generated runbook; the declaration does not trigger a confirmation
conversation before execution.
What happens when it cannot resolve
The whole list shares a two-second budget. Missing inputs skip an entry; a failed lookup or invalid result is logged without failing the call. Assignments from one entry are validated together before any are saved. Skipped assignments leave the destination values unchanged. Later entries can run only if their inputs are available, including any usable defaults. Prefetched text is limited to 512 characters per value; longer text is shortened with a log message. Keep long records in your backend and fetch what a task needs during the conversation.unmute validate reports facts unavailable on a target’s route or direction.
On a real call, a fact may still be absent even when the route supports it.
See Where it works for the full route table
and Prefetch for the setup and missing-value workflow.
A caller’s number is best effort
from_number and to_number resolve less often than the other system
sources, on every route that grants them. A caller can withhold their own
number, and withholding does not arrive as nothing:
- Twilio’s own policy is to set it to the word
anonymous. - Where an upstream carrier sends a word such as ANONYMOUS or RESTRICTED instead, Twilio converts it to keypad digits, which look exactly like a real number.
- Some calls simply arrive with the field empty.
+ followed by 8 to 15 digits, and a
short list of known digit placeholders is rejected on top of that check.
Either way the entry is skipped, and the log names which entry and why.
On LiveKit sip, the number is also absent when the dispatch rule sets
HidePhoneNumber. On pipecat cloud-websocket it can be missing for a
configuration reason instead: the number rides a <Parameter> in the TwiML
Bin you made, so a Bin created before this existed does not carry it. Nothing
warns about that at compile time, because checking would need carrier
credentials the compiler never asks for.
Treat the caller’s number as best effort on every route that grants it, not
only on Pipecat. A route granting the fact is not a promise that any one call
will carry it.
Denied on the slng target: that platform owns session start, so there is no seam
to resolve a fact in.
confirm: marks a value the caller has to agree to
A fact that arrives from the carrier is a proposal, not a settled value. Somebody
may be ringing from a friend’s phone, or may hold a second account.
agent.yaml
- renders in no prompt but that step’s own, refused at compile time everywhere else, including the greeting and every agent prompt;
- makes every tool injecting it refuse itself to the model, naming the value it is waiting for.
assign: after agreement.
A prefetch lookup can use a candidate before confirmation, but its dependent
values inherit the same restriction. When the candidate changes, those old
prefetched values are cleared, not silently reused or automatically fetched
again. A task must look up the corrected value if it is needed.
See the confirmation walkthrough.
How a variable reaches a prompt
A saved value enters a prompt only where you write{{state.variable}} or
{{state.variable.field}}. This applies to every type, including models and
lists. A bare {{variable}} naming a field on State is refused with the fix,
write {{state.variable}}. A $NAME Vault token is unchanged.
The greeting may only name a variable with a session-start source, a default,
or a prefetch assignment. A prefetch can still be skipped, so handle a missing
value. Agent and task instructions may name values that will be filled later.
Undeclared variable names are compile errors at every site.
Naming one part of a value
{{state.variable.field}} reads one field from an object. {{state.variable}}
reads the whole object. Dotted paths can follow nested models. To read a list item’s
fields, first save the selected item in its own variable.
Write a label followed by an instruction for the missing case. These
placeholders do not support conditions, filters, indexing, or function calls.
A wrong field name is a compile error that names the fields you can use.
Rendered values are limited to 4000 characters each and shortened with a
warning. Keep records small or reference just the fields the prompt needs.
confirm: limits where a
candidate value may be referenced. Only its confirming task may name it in a
prompt; a prompt elsewhere is refused at compile time. The tool path uses the
runtime guard described below.
Conversation sharing is a separate choice. With messages, a receiver can
still read a value somebody said aloud. See Reduce context sharing step by
step to choose exactly what crosses a boundary.
Seeding values locally
state.py, so an
int field receives a number and not a quoted string. Pass JSON for a model or
a list, quoted for your shell: --var 'contact={"name": "Ada", "email": "ada@example.com"}'. --var is the local stand
in for the dispatch payload production sends, so it accepts the two kinds of
variable that payload fills: source: call_start, and a variable that declares
no source: at all. It refuses a runtime-owned source, because that one
arrives from the carrier, not the dispatch:
Seeding a caller ID
--var stands in for the dispatch payload. To stand in for a caller ID, use
--source, which seeds the fact a prefetch: entry reads:
Passing a value into a tool
tools/book_appointment.yaml
inject values are not part of the model’s schema, so the model can neither
see them nor overwrite them. An inject key that also names an input
property is a compile error, for exactly that reason.
Each authored injected value must be a scalar, such as a string containing
{{state.variable}}. An exact placeholder passes the value with its native type,
including a referenced object or list. Mixed text such as "Booking {{state.booking_id}}"
produces a string. An authored map or list is refused.
Keep injected keys out of both input.properties and input.required.
Injection hides an argument from the model; it does not hide data a tool
returns or data already present in the conversation.
inject is legal on webhook, local, and hosted slng tools, and on
builtin: send_sms for its one setting, the sender (see
Prebuilt tools). See
Hosted tools for the hosted attachment rules.
When an injected variable is missing or unconfirmed, the tool refuses before
execution. The error names the task that supplies or confirms it, if there is
one. Otherwise it tells the model to ask the caller:
Assigning a task result to a variable
agent.yaml
State supplies the finish argument’s type and description. The
task validates all assigned values before saving any of them. A failed
validation keeps the task open so the model can correct the result. Finishing
with a non-empty unserved_request saves no assignments.
A + on the key appends one entry instead of replacing the value:
agent.yaml
list[T] field. It accepts one item of type T or
null, which adds nothing. Identical structured items are saved once; plain
values may repeat. A changed object is a new item. Without +, the assignment
replaces the variable’s value.
Picking one part of a structured result
The right side of anassign: pair does not have to be the whole result. It
can be result.<field>, or a dotted path into a model from state.py,
result.<field>.<subfield>, as deep as the model goes:
agent.yaml
appointments declared list[Appointment] and last_booking_day
declared date | None. The whole-object assignment establishes the
appointment result field as Appointment | None; the second line can then
project its date.
The picked part’s type has to fit the field it lands in: a date into a
date, a Literal into the same Literal, a whole model into a field
declared with that model.
Two paths are refused rather than silently accepted:
- a path through a list. Nothing says which entry to take, so
result.appointments.scheduled_dateis refused. - a path into a field that is not a model. Once the path reaches a scalar, going one level deeper has nothing left to read.
Appointment | None above, the picked
value may be absent for a visit where the task left it out. Give the field
the same option, date | None, so an absent pick is a legal value, or use an
appending assign, name+:, which already skips an absent entry instead of
writing one into the list.
The same path form works in a prompt placeholder too: see Naming one part of
a value.
A value the caller may not give ends in ?
A step must return every whole value it assigns. A finish that leaves one out,
or sends an explicit null for it, is refused: nothing is saved, the model is
told why and gets another go, and the caller waits through that round trip.
When the caller may simply not give a value, mark it with ? after the result
field:
agent.yaml
time | None = None in state.py as well, so an empty value
is a legal state. Do not try to solve it in the description: no wording reliably
stops a model saying “nothing” when there is nothing.
An empty string is still right for a text value that is always asked for and may
not be known yet. The difference is whether absence is a legal outcome of the
step. If it is, say so with ?.
Absence is already legal in one other place, and needs nothing: an appending
assign, name+:, drops an absent entry instead of writing it into the list.
A finish with a non-empty unserved_request is never refused for a missing
value, because it saves nothing.
A handoff can read selected declared values
Declared values stay in call state across a handoff. The receiving model sees only the ones its prompt names.agents/appointment-manager.md
history: reset, this explicit reference still resolves but no prior
conversation is inherited. An unsaved detail from the triggering sentence is
gone, so the receiving agent asks for it.
Where to go next
Variables, explained
The same surface as a walkthrough.
Secrets
Why a secret is never a variable.