unmute validate, and
every change leaves you a package that compiles.
The example is a parcel tracking line. A caller reads out a tracking number,
the agent looks it up and says when the parcel is due. It is small on purpose.
The shape is what matters, and you will swap the parcel for your own thing.
Tasks run on LiveKit, Pipecat and SLNG. The package
unmute init
writes targets LiveKit, so you are ready. SLNG takes most task features;
the SLNG page lists them.Twilio compiles one agent with one prompt and refuses tasks. Fold the step
into the agent’s instructions or use another target.What a task is
A task is a smaller conversation inside the call. It has its own prompt and its own tools. To the model, a task is one more entry in its function list, right next to the agent’s tools. When the model calls it, the task’s prompt takes over. When the task is done, the agent that started it, the owner, gets the caller back, along with whatever the task saved. You reach for a task when part of the call needs a narrower prompt, a smaller tool list, or an answer you want to keep. If none of those is true, stay with one agent and its tools. Most agents should. One word to settle before we start. A task is one step of the call. Some of the compiler’s messages say “step” for that reason. On this page the thing you write is always a task. On this page: nine steps, each ending with a package that validates, so you can stop anywhere and come back.- Start with no task - confirm the package you already have still works
- Give the agent a tool - a lookup, so there is something to move
- Say what you want to keep - add fields to
State - Add the task - move the tool onto it
- Save the answer -
save: - Read it back - a placeholder in the owner’s prompt
- Say what success looks like - an outcome, so the tool ends the step
- Open with the question -
start:withsay:andlisten: true - Run it - talk to it in the browser
1
Start with no task
This is the package from the last page. Make sure it still validates:One agent, one prompt, one built-in tool so it can hang up. Keep this
picture in mind: each change below adds one thing to it.
2
Give the agent a tool
A tool does real work and comes back with a result. This one is a small
Python function that stands in for your delivery system. It lives in the
package, in two files.The tool file says what the tool does and which function runs it:The file has no Look at the And tell the agent what to do with it, at the end of This already works, and for many agents it is enough. Stop here if the job
is one lookup and one answer. The rest of this page is for when you want the
lookup to be its own small conversation, and you want to keep what it
found.
tools/look_up_parcel.yaml
input: or output:. The handler is a typed Python
function, and handler: names it as file.py:function. Its arguments are
the tool’s input. The Pydantic model it returns is the tool’s output:tools/look_up_parcel.py
status field. Its Literal is a fixed list of the values it
can hold. That list is what will let the task know, later, whether the
lookup worked. Write one for any tool a task will end on.Every file in tools/ is a tool already. Name it in the agent’s own list in
agent.yaml to say the agent may call it.agent.yaml
instructions.md:instructions.md
3
Say what you want to keep
A task can save what it learns. You say what that is by declaring a
field, once, with a type.
unmute init already wrote a state.py next to
agent.yaml with an empty State class. Add two fields to it:state.py
date is a Pydantic type. A value that is not a date in that form is
refused where it enters, and the model is told the form, so it can try
again. The description is read by the model, so write it for the model.
unmute reads state.py through uv, so uv must be on your PATH.Nothing reads these yet. Declaring a value and using it are two separate
things, and that is on purpose.4
Add the task
A task is written inside the agent that runs it, under Then add the task to the agent:It still validates, and it warns:Read that warning, because it is the most common mistake with a first
task. The model sees one list with the agent’s tools and the task in it,
and it takes the shortest route. If the agent can call The task now says who may call The warning is gone. The model now has one way to look up a parcel, and it
goes through the task.
tasks:. It has a
name, a when: that tells the model when to run it, its own prompt file,
and its own tools.Make a tasks/ folder next to agent.yaml and put the task’s prompt in it:tasks/find-parcel.md
agent.yaml
look_up_parcel
itself, it will, and the task never runs. The fix is to move the tool: take
it off the agent and leave it on the task.agent.yaml
look_up_parcel.Change the agent’s prompt to match. It no longer looks parcels up itself:instructions.md
5
Save the answer
Right now the task can find the parcel, but when it ends, what it found is
gone. Here is what that line did. Every task gets a
save: says which values the task saves, and where. Each line is
one variable you declared, and the tool result field it takes:agent.yaml
finish call, a function
the model calls to say the task is done. save: gives that call two
arguments, tracking_number and delivery_date, typed from the State fields
they land in. When the model calls finish, each value is checked against
its type. If one is wrong, nothing is saved and the task stays open, so a
bad date never gets written down.You do not write “call finish when you are done” in the task’s prompt.
unmute-core adds that rule for you.A wrong name on the left is refused with the line it is on:6
Read it back
Saving a value does not show it to anybody. A prompt sees a saved value only
when it names it, with the variable in double braces. Add this to the end
of Before the task has run, an empty value renders as
instructions.md:instructions.md
none recorded yet., so
write the sentence so it still makes sense with those words in it. After
the task has run, the owner reads the real values on its next turn and can
answer without looking the parcel up again.7
Say what success looks like
So far the task ends when the model decides to call Read it as a sentence: “when And every The task still has a
finish. That is one
more model request after the lookup, and one more chance for the model to
do something else first. The tool already knows whether it worked: its
status is found or not_found. An outcome on the tool lets that result
end the task. The tool becomes an entry with a name: and outcomes::agent.yaml
look_up_parcel returns status: found, the
task is done”. When that happens, the task saves its save: straight from
the tool result and ends. The model is not asked, and the result never
reaches it. When the status is not_found, no outcome matches. The result
goes back to the model as normal and the task stays open, which is exactly
when you want the prompt’s “ask the caller to check the number” line to do
its job.Two rules keep this honest, and the compiler holds both. An outcome starts
with an
if: that checks the result:if: value has to be one the tool’s return type allows, here
the Literal on status, so a typo is caught here and not on a call that never ends:finish call. When the call runs, the unmute-core
library adds lines about it under your own instructions. They name the
values finish saves, and they say the step ends by itself when
look_up_parcel succeeds. See tasks in unmute-core.So the finish call is still there. The model uses it when the caller asks
for something the task cannot do, and for a value the task already holds.
Your own prompt should not argue with those lines.8
Open with the question
A task’s first turn is normally a model request that writes an opening
line. This task always opens the same way: it asks for the tracking
number. Say that line yourself and skip the request:
agent.yaml
start: replaces the model’s first turn. say: is one fixed line, spoken
exactly as written. listen: true says: speak it, then wait for the
caller. The line is recorded as the task’s own first turn, so the caller’s
answer lands in a conversation the task can see.Update the task’s prompt so it does not ask the question twice:tasks/find-parcel.md
9
Run it
.env. When the browser opens,
ask where your parcel is and read out one of the two numbers in the
handler, “A B one two three four five six”.Watch the dev page while you talk. The task shows up as its own row when
the model enters it, then look_up_parcel gets a row of its own, and then
the agent answers. If you never see the task’s row, the model went round
it. Check two things: that the agent does not hold the task’s tool, and
that the when: line describes what the caller actually said. Running it
locally explains every row on that page.The whole file
This isagent.yaml at the end, with the scaffold’s comments taken out. The
three lists near the top are the whole change: one tool on the agent, one task
nested under it, and the task’s own tool with its outcome, start: and save:.
Complete agent.yaml
Complete agent.yaml
channels: and capacity: live in targets.yaml, above targets:.
targets.yaml
What you have now
Where to go next
Tasks
Every task key in full: history, what returns, sharing a task between agents.
Flows
Two or more tasks that have to run in a fixed order.
Making a task actually run
Why the model skips a task, and what to give it so it does not.
Designing declared state
Choosing what to save, and writing prompts that read well when it is empty.