Skip to main content
You have one agent working from Your first agent. This page adds one task to it. Nothing here needs a second agent, a phone number or a new account. Every change below ends with 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.
  1. Start with no task - confirm the package you already have still works
  2. Give the agent a tool - a lookup, so there is something to move
  3. Say what you want to keep - add fields to State
  4. Add the task - move the tool onto it
  5. Save the answer - save:
  6. Read it back - a placeholder in the owner’s prompt
  7. Say what success looks like - an outcome, so the tool ends the step
  8. Open with the question - start: with say: and listen: true
  9. 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:
tools/look_up_parcel.yaml
The file has no 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
Look at the 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
And tell the agent what to do with it, at the end of instructions.md:
instructions.md
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.
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 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
Then add the task to the agent:
agent.yaml
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 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
The task now says who may call look_up_parcel.Change the agent’s prompt to match. It no longer looks parcels up itself:
instructions.md
The warning is gone. The model now has one way to look up a parcel, and it goes through the task.
5

Save the answer

Right now the task can find the parcel, but when it ends, what it found is gone. 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
Here is what that line did. Every task gets a 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 instructions.md:
instructions.md
Before the task has run, an empty value renders as 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 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
Read it as a sentence: “when 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:
And every 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:
The task still has a 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

Your keys from the quickstart are still in .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 is agent.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:.
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.