Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

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 tools option, which cuts the session's built-in tools down to the ones it names.
  • Set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 in env, 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.

python
    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.

python
    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

task 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.

python
    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.

python
    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
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop_desk.py
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.

python
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")
python
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()
python
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.

python
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.

python
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.

Example
# 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")

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.

Example
# 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")

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_progress switches 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

TaskCreateTaskUpdate
What it carriessubject, and activeForm for the in-progress wordingA task id and the new status
Where the id isNot in the call: it comes back in the structured outputIn the call, under taskId
What your code doesHolds the subject until the result pairs it with an idLooks 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.
Watch out. The tool input in the stream is the raw shape the model emitted. Claude Code repairs close-but-wrong key names before it executes the call, mapping 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.
Try it yourself
  • Add a TaskUpdate with status set to deleted and make on_use drop the task.
  • Change the second create's activeForm and 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.