Skip to content

Todo tracking

Opt Agent SDK sessions into Claude's task list tools and turn TaskCreate and TaskUpdate calls into a live progress display in your app.

When the task-tracking tools are switched on, Claude keeps a written task list as it works: it creates items, marks them in progress, completes them and removes ones it no longer needs. Each change appears in the message stream as a structured tool call, which makes it easy to show a progress panel in your own UI.

Newer models manage multi-step work perfectly well without a written list, so you only need this page if your application wants to read and display those task updates.

Which models have the tools

On Claude 3.x models, Opus 4 to 4.7, Sonnet 4 to 4.6 and Haiku 4.5, the task tools are on by default. On every other model, including any model ID Claude Code does not recognise, they are off unless you opt in. That default applies from Claude Code v2.1.268 (bundled from TypeScript SDK v0.3.268).

The tools involved:

ToolUsed for
TaskCreateAdd a task
TaskUpdateChange status or label, or delete with status: "deleted"
TaskGetRead one task's full details
TaskListRead the list back
TodoWriteThe older single-tool form, used instead of the four Task tools when CLAUDE_CODE_ENABLE_TASKS=0

Opting in

Any one of these switches a session on:

  • name one of the tools in allowedTools / allowed_tools;
  • include them in the tools option (remember tools restricts built-ins to what you list, so include everything else you need);
  • set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 in env. In TypeScript, spread process.env because env replaces the environment; in Python it is merged.

The SDK applies these defaults through the Claude Code binary it bundles. If you point pathToClaudeCodeExecutable (TypeScript) or cli_path (Python) at your own install, you get that install's tools and defaults. Tools reference explains how to check which tools a running session has.

Lifecycle of a task

  1. pending: created when Claude identifies a piece of work.
  2. in_progress: set when Claude starts on it.
  3. completed: set when the work is done.
  4. deleted: Claude removes a task it no longer needs with a TaskUpdate.

Claude tends to create tasks for work with three or more distinct steps, when the user supplies a list of items, for longer jobs, and whenever asked to. It usually skips them for quick single-step requests.

Where the data appears

What you wantWhere it is
A new task's subject and active labeltool_use block named TaskCreate in an assistant message (input.subject, input.activeForm)
A new task's assigned IDNot in the TaskCreate input. It is in the tool_use_result field of the user message carrying the matching tool_result, shaped { task: { id, subject } } (typed as TaskCreateOutput in TypeScript, a plain dict in Python)
Status changestool_use block named TaskUpdate (input.taskId, input.status, optionally input.activeForm)

Note: The stream shows the input exactly as the model produced it. Claude Code fixes some near-miss key names before running the tool (id or task_id to taskId, active_form to activeForm), but the stream does not reflect that repair. Read these fields defensively.

SDKTaskNotificationMessage (TypeScript) and TaskNotificationMessage (Python) are about background tasks such as backgrounded commands and subagents, not the todo list.

A simple activity log

The quickest useful thing is a log line for each create and status change. This version cannot pair updates with creates, because it never captures IDs, but it is fine for debugging:

from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, ToolUseBlock

opts = ClaudeAgentOptions(
    max_turns=20,
    permission_mode="acceptEdits",
    env={"CLAUDE_CODE_ENABLE_TODO_TOOLS": "1"},
)

try:
    async for msg in query(prompt="Add dark mode to the settings page, with tests, and track it with todos",
                           options=opts):
        if not isinstance(msg, AssistantMessage):
            continue
        for b in msg.content:
            if isinstance(b, ToolUseBlock) and b.name == "TaskCreate":
                print("new:", b.input.get("subject"))
            elif isinstance(b, ToolUseBlock) and b.name == "TaskUpdate":
                tid = b.input.get("taskId") or b.input.get("id") or b.input.get("task_id")
                if tid and b.input.get("status"):
                    print(f"  #{tid} is now {b.input['status']}")
except Exception as err:
    print("Stopped:", err)

A note on endings: if the run hits max_turns, the result message has subtype error_max_turns and the one-shot query() then raises an error containing Reached maximum number of turns. The try block handles that cleanly. The agent loop page lists all result subtypes.

A live progress panel

For a real display, keep a map keyed by task ID. The trick is pairing each TaskCreate call with its result to learn the ID:

  1. On a TaskCreate tool use, stash { subject, activeForm } under the tool use's own id.
  2. On a tool_result whose tool_use_id matches, read task.id from the message's tool_use_result and move the stashed entry into the map as pending.
  3. On TaskUpdate, update or delete the entry and re-render.
import { query } from "@anthropic-ai/claude-agent-sdk";

type Item = { subject: string; activeForm?: string; status: string };
const items = new Map<string, Item>();
const awaitingId = new Map<string, Omit<Item, "status">>();

function render() {
  const all = [...items.values()];
  const done = all.filter(i => i.status === "completed").length;
  console.clear();
  console.log(`Progress ${done}/${all.length}`);
  for (const [id, i] of items) {
    const label = i.status === "in_progress" && i.activeForm ? i.activeForm : i.subject;
    const mark = i.status === "completed" ? "[x]" : i.status === "in_progress" ? "[>]" : "[ ]";
    console.log(`${mark} ${label}  (#${id})`);
  }
}

try {
  for await (const msg of query({
    prompt: "Migrate the blog from Jekyll to Astro, keeping URLs identical. Track the work with todos.",
    options: { maxTurns: 40, permissionMode: "acceptEdits", env: { ...process.env, CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } }
  })) {
    if (msg.type === "assistant") {
      for (const b of msg.message.content) {
        if (b.type !== "tool_use") continue;
        const input = b.input as Record<string, string | undefined>;
        if (b.name === "TaskCreate") {
          awaitingId.set(b.id, { subject: input.subject ?? "", activeForm: input.activeForm ?? input.active_form });
        } else if (b.name === "TaskUpdate") {
          const id = input.taskId ?? input.id ?? input.task_id;
          if (!id) continue;
          if (input.status === "deleted") { items.delete(id); render(); continue; }
          const item = items.get(id);
          if (!item) continue;
          if (input.status) item.status = input.status;
          const af = input.activeForm ?? input.active_form;
          if (af) item.activeForm = af;
          render();
        }
      }
    }
    if (msg.type === "user" && Array.isArray(msg.message.content)) {
      for (const b of msg.message.content) {
        if (b.type !== "tool_result") continue;
        const pending = awaitingId.get(b.tool_use_id);
        if (!pending) continue;
        awaitingId.delete(b.tool_use_id);
        const taskId = (msg.tool_use_result as { task?: { id: string } })?.task?.id;
        if (!b.is_error && taskId) { items.set(taskId, { ...pending, status: "pending" }); render(); }
      }
    }
  }
} catch (err) {
  console.error("Run ended:", err);
}

The Python version is the same shape: check isinstance(msg, UserMessage), iterate ToolResultBlocks, and read msg.tool_use_result as a dict.

Showing activeForm for in-progress items ("Rewriting permalink rules") and subject otherwise ("Rewrite permalink rules") reads much better in a UI. Claude itself can read the list back with TaskList and a single item with TaskGet.