Todo tracking
Todo tracking is reading Claude's own task list off the message stream, where each change arrives as a structured tool call. Your code keeps the list and draws whatever progress display it likes.
Last updated: 20 Oct, 2026 · Claude Agent SDK
In a session that has the task tools, Claude writes down the steps it means to take and marks each one off as it goes. You see none of that unless you look: the changes are tool_use blocks in the assistant messages, so an application that wants a progress bar has to read them. The todo tracking page is the reference for the tools and their fields.
The task tools, and getting them switched on
There are five: TodoWrite, and the four that replaced it, TaskCreate, TaskGet, TaskUpdate and TaskList. The documentation names the models that carry them by default, and that list moves as models come and go; newer models work through multi-step tasks without a written list, so they do not have them unless you ask. Three ways to ask:
- Name one of the tools in
allowed_tools, which is the way this lesson uses. - List them in the
toolsoption, which cuts the session's built-in tools down to the ones it names. - Set
CLAUDE_CODE_ENABLE_TODO_TOOLS=1inenv, which the documentation's own examples use.
Opt a session in when your code reads the calls. A session that emits a todo feed nobody is listening to has spent tokens writing a list for itself.
The four states one todo passes through
- pending when Claude adds the task, through
TaskCreate. - in_progress when it starts the work, through
TaskUpdate. - completed when the work finished.
- deleted when the task turned out to be unnecessary, which is also a
TaskUpdate, with that status.
Giving the desk a tool-calling loop
The desk saved in the cost lesson answers in one round and calls nothing. This lesson adds a loop and the task tools to shop_desk.py: turn is replaced, use and task are new, and message is replaced. The rest of the file stays as it was.
The new turn keeps going while the model asks for tools, and counts the rounds it took.
async def turn(self, prompt):
self.said.append(prompt)
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": []})
# the loop: a step either asks for tools or has the text to answer with
results, turns = [], 0
while "calls" in (step := self.model.reply(self.said, results)):
turns += 1
results = [await self.use(name, args) for name, args in step["calls"]]
await self.say(step["text"])
await self.finish("success", turns + 1, step["text"])use sends the tool call, refuses anything that is not in allowed_tools, and sends the result back. That refusal is what makes the opt-in visible later in the lesson.
async def use(self, name, args):
call = f"toolu_{next(self.ids)}"
await self.send(self.message("assistant", [{"type": "tool_use", "id": call,
"name": name, "input": args}]))
# a tool nobody allowed never runs, and the refusal goes back as its result
allowed = name in self.options.allowed_tools
text = self.task(name, args) if allowed else f"{name} was not allowed."
await self.send(self.message("user", [{"type": "tool_result", "tool_use_id": call,
"content": text, "is_error": not allowed}]))
return texttask is where a real Claude Code would keep the todo list. The stand-in keeps a counter instead, and hands back the id it made so the next block can attach it to the result.
def task(self, name, args):
# Claude Code is the side that assigns a task id, so the stand-in does too
if name == "TaskCreate":
self.made += 1
self.created = {"task": {"id": f"t{self.made}", "subject": args["subject"]}}
return "ok"The id a task ends up with is not in the TaskCreate call. Claude Code assigns it and hangs the structured output on the user message that carries the tool result, under tool_use_result, so message is replaced to do the same.
def message(self, kind, content, parent=None):
body = {"role": kind, "content": content}
if kind == "assistant":
body.update(model="shop-desk", id=f"msg_{next(self.ids)}")
line = {"type": kind, "message": body, "parent_tool_use_id": parent,
"session_id": self.session_id}
# Claude Code hangs a tool's structured output on the user line beside its result
if kind == "user" and self.created is not None:
line["tool_use_result"], self.created = self.created, None
return lineView the code here
import asyncio
import itertools
import json
import re
from claude_agent_sdk import ClaudeAgentOptions, Transport
ORDER = re.compile(r"\b[A-Z]\d+\b")
SESSIONS = itertools.count(1)
class ShopModel:
"""Stands in for Claude: reads the conversation, picks the next step."""
def __init__(self, script=None):
self.script = list(script or [])
def reply(self, said, results):
# a scripted reply lets a lesson force a step the stand-in would not pick
if self.script:
return self.script.pop(0)
if results:
return {"text": " ".join(results)}
# the newest message first, then the last id named earlier in the chat
orders = ORDER.findall(said[-1]) or ORDER.findall(" ".join(said))[-1:]
return {"text": f"I cannot help with order {orders[0]} yet." if orders
else "Which order is this about?"}
class ShopDesk(Transport):
"""Stands in for the Claude Code binary: it runs the loop over JSON lines."""
def __init__(self, options=None, model=None):
self.options = options or ClaudeAgentOptions()
self.model = model or ShopModel()
# a counted id, not a UUID, so the printed output is the same every run
self.session_id = f"session-{next(SESSIONS)}"
self.inbox, self.outbox = asyncio.Queue(), asyncio.Queue()
self.prompts, self.said = asyncio.Queue(), []
self.ids, self.setup = itertools.count(1), {}
self.made, self.created = 0, None
self.ready = False
async def connect(self):
self.ready = True
def is_ready(self):
return self.ready
async def write(self, data):
# one JSON line at a time, which is what the SDK writes to a real process
await self.inbox.put(json.loads(data))
async def end_input(self):
# for a real process this would close its standard input
await self.inbox.put(None)
async def close(self):
self.ready = False
async def send(self, message):
await self.outbox.put(message)
async def read_messages(self):
# the listener and the worker run alongside the SDK's own read loop
asyncio.create_task(self.listen())
self.worker = asyncio.create_task(self.work())
while (message := await self.outbox.get()) is not None:
if isinstance(message, Exception):
raise message
yield message
async def listen(self):
while (message := await self.inbox.get()) is not None:
# a control request is the SDK asking a question; initialize is first
if message["type"] == "control_request":
if message["request"]["subtype"] == "initialize":
self.setup = message["request"]
await self.respond(message["request_id"])
elif message["type"] == "user":
await self.prompts.put(message["message"]["content"])
# the input has ended: finish the work in hand, then close the stream
await self.prompts.put(None)
await self.worker
await self.send(None)
async def respond(self, request_id):
# the stand-in says yes to every request the SDK makes of it
await self.send({"type": "control_response", "response": {
"subtype": "success", "request_id": request_id, "response": {}}})
async def work(self):
while (prompt := await self.prompts.get()) is not None:
await self.turn(prompt)
async def turn(self, prompt):
self.said.append(prompt)
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": []})
# the loop: a step either asks for tools or has the text to answer with
results, turns = [], 0
while "calls" in (step := self.model.reply(self.said, results)):
turns += 1
results = [await self.use(name, args) for name, args in step["calls"]]
await self.say(step["text"])
await self.finish("success", turns + 1, step["text"])
async def use(self, name, args):
call = f"toolu_{next(self.ids)}"
await self.send(self.message("assistant", [{"type": "tool_use", "id": call,
"name": name, "input": args}]))
# a tool nobody allowed never runs, and the refusal goes back as its result
allowed = name in self.options.allowed_tools
text = self.task(name, args) if allowed else f"{name} was not allowed."
await self.send(self.message("user", [{"type": "tool_result", "tool_use_id": call,
"content": text, "is_error": not allowed}]))
return text
def task(self, name, args):
# Claude Code is the side that assigns a task id, so the stand-in does too
if name == "TaskCreate":
self.made += 1
self.created = {"task": {"id": f"t{self.made}", "subject": args["subject"]}}
return "ok"
async def say(self, text):
# with include_partial_messages on, the text goes out a word at a time
if self.options.include_partial_messages:
for word in text.split(" "):
delta = {"type": "text_delta", "text": word + " "}
await self.send({"type": "stream_event", "uuid": f"ev_{next(self.ids)}",
"session_id": self.session_id, "event": {
"type": "content_block_delta", "index": 0,
"delta": delta}})
# the whole message still follows, so code written earlier keeps working
await self.send(self.message("assistant", [{"type": "text", "text": text}]))
async def finish(self, subtype, turns, text):
ok = subtype == "success"
result = {"type": "result", "subtype": subtype, "is_error": not ok,
"duration_ms": 0, "duration_api_ms": 0, "num_turns": turns,
"session_id": self.session_id, "total_cost_usd": 0.0,
"usage": {"input_tokens": len(" ".join(self.said).split()),
"output_tokens": len(text.split())}}
# cost is zero and usage counts words, because no model was called
result.update({"result": text} if ok else {"errors": [text]})
await self.send(result)
def message(self, kind, content, parent=None):
body = {"role": kind, "content": content}
if kind == "assistant":
body.update(model="shop-desk", id=f"msg_{next(self.ids)}")
line = {"type": kind, "message": body, "parent_tool_use_id": parent,
"session_id": self.session_id}
# Claude Code hangs a tool's structured output on the user line beside its result
if kind == "user" and self.created is not None:
line["tool_use_result"], self.created = self.created, None
return line
Holding a create until its id arrives
The tracker lives in its own file. A create cannot be filed straight away, so it is held aside and paired with the result that answers it by tool_use_id.
from claude_agent_sdk import (AssistantMessage, ToolResultBlock, ToolUseBlock,
UserMessage, query)
from shop_desk import ShopDesk
# TASKS is the board; WAITING holds creates whose id has not come back yet
TASKS, WAITING = {}, {}
def show():
done = sum(1 for t in TASKS.values() if t["status"] == "completed")
live = [t["label"] for t in TASKS.values() if t["status"] == "in_progress"]
print(f"{done}/{len(TASKS)} done", "·", ", ".join(live) or "nothing in hand")def on_use(block):
# the id Claude Code gives a task is not in the call, so hold the subject until the result
if block.name == "TaskCreate":
WAITING[block.id] = block.input
elif block.name == "TaskUpdate":
task = TASKS.get(block.input.get("taskId") or block.input.get("task_id"))
# a missing status would overwrite the one on record with None
if task and block.input.get("status"):
task["status"] = block.input["status"]
show()def on_result(block, output):
made = (output or {}).get("task") or {}
call = WAITING.pop(block.tool_use_id, None)
# a refused create has no structured output, so there is no id to key the task by
if call and made.get("id"):
TASKS[made["id"]] = {"label": call.get("activeForm") or call["subject"],
"status": "pending"}
show()Reading the two sides of the stream
Creates and updates arrive on the assistant side as tool calls; the ids arrive on the user side with the tool results. One pass over the stream feeds both handlers.
async def track(options, model):
async for message in query(prompt="Answer A17 and track the steps", options=options,
transport=ShopDesk(options, model)):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, ToolUseBlock):
on_use(block)
# the result block sits on the user side, beside the structured output
elif isinstance(message, UserMessage) and isinstance(message.content, list):
for block in message.content:
if isinstance(block, ToolResultBlock):
on_result(block, message.tool_use_result)A scripted run to track
The stand-in's model follows a script, so the run below makes two tasks, starts the first and finishes it. Save this beside the two files above, as track_todos.py.
from claude_agent_sdk import ClaudeAgentOptions
from shop_desk import ShopModel
from todos import TASKS, track
# two tasks, the first one started and then finished
SCRIPT = [
{"calls": [("TaskCreate", {"subject": "Look up order A17", "activeForm": "Looking up A17"}),
("TaskCreate", {"subject": "Reply to the customer", "activeForm": "Replying"})]},
{"calls": [("TaskUpdate", {"taskId": "t1", "status": "in_progress"})]},
{"calls": [("TaskUpdate", {"taskId": "t1", "status": "completed"})]},
{"text": "Order A17 shipped on 3 March."},
]Running it with the tools left out
First without opting the session in. Nothing names a task tool, so the desk refuses both of them, the creates come back as errors with no structured output, and the tracker never gets an id to file anything under.
# no task tool is named, so the desk refuses every one of them
await track(ClaudeAgentOptions(allowed_tools=[]), ShopModel(script=SCRIPT))
print(len(TASKS), "tasks on the board")0 tasks on the board
No progress line was printed, because no task was ever filed. That is the failure to recognise when an application shows an empty progress panel while the agent is clearly working.
Running it with the tools named
Naming the two tools in allowed_tools is enough. The same script now reaches the tracker.
# naming a task tool is one of the three ways to opt the session in
await track(ClaudeAgentOptions(allowed_tools=["TaskCreate", "TaskUpdate"]),
ShopModel(script=SCRIPT))
print(len(TASKS), "tasks on the board")0/1 done · nothing in hand 0/2 done · nothing in hand 0/2 done · Looking up A17 1/2 done · nothing in hand 2 tasks on the board
What the four progress lines show
- The first two lines are the creates. Each is printed when its id came back on the user message, which is why the board grows one task at a time rather than both at once.
- The third line names the work in hand. Moving a task to
in_progressswitches its label from the subject to the active form, which is the wording meant for a progress display. - The fourth line counts one done. The completed task leaves the in-progress list, so the right-hand side goes back to nothing in hand.
- Two tasks are on the board at the end. The second was never started, which is the ordinary state of a list the agent stopped working through.
TaskCreate against TaskUpdate
TaskCreate | TaskUpdate | |
|---|---|---|
| What it carries | subject, and activeForm for the in-progress wording | A task id and the new status |
| Where the id is | Not in the call: it comes back in the structured output | In the call, under taskId |
| What your code does | Holds the subject until the result pairs it with an id | Looks the task up and changes it in place |
When a todo feed is worth reading
- A user-facing agent where a long task needs to show what it is doing rather than a spinner.
- A log of task activity, where the plain create and update lines are enough with no display at all.
- A debugging pass over a run that went long, where the list says which step it got stuck on.
id or task_id to taskId and active_form to activeForm, but the repair never reaches the stream. Read those fields with fallbacks, the way on_use does, or a run will lose updates at random.Related
- Previous: Observability with OpenTelemetry
- Next: Hosting the SDK
- See also: The messages you get back
- Reference: Track todos
- Add a
TaskUpdatewithstatusset todeletedand makeon_usedrop the task. - Change the second create's
activeFormand start it, to see which wording the line uses. - Send an update for a task id that was never created and check that the tracker survives it.
Every expert started right here.