SpyBara
Go Premium

agent-sdk/todo-tracking.md 2026-08-14 23:00 UTC to 2026-08-15 16:00 UTC

This page contains 36 additions and 14 deletions.

2026
Tue 4 22:59 Mon 10 22:57 Sat 15 16:00 Thu 20 23:01 Fri 28 23:59

Todo Lists

Track and display todos using the Claude Agent SDK for organized task management

The Claude Agent SDK includes built-in todo functionality that helps organize complex workflows and keep users informed about task progression.

Model availability

On the models that don't get the task-tracking tools, you see no tool_use blocks for them in the message stream unless you opt in. If you point cli_path in Python or pathToClaudeCodeExecutable in TypeScript at your own Claude Code install, you get whichever tools that install provides. To get the same tools as on other models, do one of the following:

  • Name one of the tools in the allowedTools option, allowed_tools in Python
  • List the tools in the tools option, which restricts the session's built-in tools to the ones it names. Include the tools you want alongside the other built-in tools you use
  • Set CLAUDE_CODE_ENABLE_TODO_TOOLS=1 in the env option, as the examples on this page do. In TypeScript, env replaces the subprocess environment, so spread ...process.env to keep inherited variables. In Python, env is merged on top of the inherited environment

Todo Lifecycle

Claude moves each todo through a predictable lifecycle:

  1. Created: Claude adds the todo as pending when it identifies a task
  2. Activated: Claude sets the todo to in_progress when it starts the work
  3. Completed: Claude marks it completed when the task finishes successfully
  4. Removed: Claude deletes a todo it no longer needs by setting status: "deleted" in a TaskUpdate call

When Todos Are Used

In a session that has the task-tracking tools, Claude creates todos for most multi-step work, such as:

  • Complex multi-step tasks requiring 3 or more distinct actions
  • User-provided task lists when multiple items are mentioned
  • Non-trivial operations that benefit from progress tracking
  • Explicit requests when users ask for todo organization

It may skip todos for very short or single-step requests.

Examples

Before running these examples, install the Claude Agent SDK by following the quickstart.

Each example runs until the agent finishes and yields its final result message. If a session reaches its turn limit first, that result message has the error_max_turns subtype. Check subtype to detect that ending.

These examples use single-shot query() calls. After yielding an error_max_turns result, query() raises an error that includes Reached maximum number of turns. Each example wraps its loop in a try block to exit cleanly when that happens.

See Handle the result for the result subtypes.

Monitoring Todo Changes

import { query } from "@anthropic-ai/claude-agent-sdk";

try {
for await (const message of query({
prompt: "Optimize my React app performance and track progress with todos",
// Re-enable TodoWrite, which this example monitors. Without it, the SDK uses
// Task tools instead and these tool_use blocks never appear. ENABLE_TODO_TOOLS
// keeps the tools on models where Claude Code otherwise doesn't provide them.
options: { maxTurns: 15, env: { ...process.env, CLAUDE_CODE_ENABLE_TASKS: "0", CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } }
})) {
// Todo updates are reflected in the message stream
if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "tool_use" && block.name === "TodoWrite") {
const todos = block.input.todos;

console.log("Todo Status Update:");
todos.forEach((todo, index) => {
const status =
todo.status === "completed" ? "✅" : todo.status === "in_progress" ? "🔧" : "❌";
console.log(`${index + 1}. ${status} ${todo.content}`);
});
}
}
}
}
} catch (error) {
// A single-shot query() throws after yielding an error result,
// such as when the maxTurns limit is hit.
console.log(`Session ended with an error: ${error}`);
}

Real-time Progress Display

import { query } from "@anthropic-ai/claude-agent-sdk";

class TodoTracker {
private todos: any[] = [];

displayProgress() {
if (this.todos.length === 0) return;

const completed = this.todos.filter((t) => t.status === "completed").length;
const inProgress = this.todos.filter((t) => t.status === "in_progress").length;
const total = this.todos.length;

console.log(`\nProgress: ${completed}/${total} completed`);
console.log(`Currently working on: ${inProgress} task(s)\n`);

this.todos.forEach((todo, index) => {
const icon =
todo.status === "completed" ? "✅" : todo.status === "in_progress" ? "🔧" : "❌";
const text = todo.status === "in_progress" ? todo.activeForm : todo.content;
console.log(`${index + 1}. ${icon} ${text}`);
});
}

async trackQuery(prompt: string) {
try {
for await (const message of query({
prompt,
// On every model, re-enable TodoWrite, which this tracker watches for.
options: { maxTurns: 20, env: { ...process.env, CLAUDE_CODE_ENABLE_TASKS: "0", CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } }
})) {
if (message.type === "assistant") {
for (const block of message.message.content) {
if (block.type === "tool_use" && block.name === "TodoWrite") {
this.todos = block.input.todos;
this.displayProgress();
}
}
}
}
} catch (error) {
// A single-shot query() throws after yielding an error result,
// such as when the maxTurns limit is hit.
console.log(`Session ended with an error: ${error}`);
}
}
}

// Usage
const tracker = new TodoTracker();
await tracker.trackQuery("Build a complete authentication system with todos");

Migrate to Task tools

The Task tools split the single TodoWrite call into TaskCreate for each new item and TaskUpdate for each status change, with TaskList and TaskGet available for the model to read back the current list. Your monitoring code still inspects tool_use blocks in the assistant stream, but maintains a map keyed by task ID instead of replacing the whole list on every call.

With TodoWrite With Task tools
One tool call rewrites the full todos array TaskCreate adds one item, TaskUpdate patches one item by taskId
Match block.name === "TodoWrite" Match block.name === "TaskCreate" or "TaskUpdate"
Item shape: { content, status, activeForm } TaskCreate input: { subject, description, activeForm?, metadata? }. TaskUpdate input: { taskId, status?, subject?, description?, activeForm?, addBlocks?, addBlockedBy?, owner?, metadata? }. status is "pending", "in_progress", or "completed"; set status: "deleted" to delete
Render block.input.todos directly Accumulate items across calls, or read a snapshot from a TaskList tool result

The assigned task ID is not in the TaskCreate input. It comes back in the matching tool_result as { task: { id, subject } }, so capture it from the result block to key your map.

The following example shows the minimal change to the Monitoring Todo Changes loop. It leaves CLAUDE_CODE_ENABLE_TASKS unset, because the Task tools are the default, and sets only CLAUDE_CODE_ENABLE_TODO_TOOLS=1, the opt-in for the models that otherwise don't get the tools. It reads only tool_use inputs and skips capturing IDs from tool_result blocks. To render a complete list, watch for a TaskList tool result in the stream or accumulate TaskCreate results and TaskUpdate inputs into a map.

The streamed tool_use input is the raw shape the model emitted. Claude Code repairs some close-but-incorrect key names before execution, mapping id or task_id to taskId and active_form to activeForm, but that repair is not reflected in the stream. Read TaskUpdate input fields defensively, as the samples below do, rather than assuming the canonical name is always present.

import { query } from "@anthropic-ai/claude-agent-sdk";

try {
for await (const message of query({
prompt: "Optimize my React app performance and track progress with todos",
// Keeps the Task tools on models where Claude Code otherwise doesn't provide them.
options: { maxTurns: 15, env: { ...process.env, CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } },
})) {
if (message.type !== "assistant") continue;
for (const block of message.message.content) {
if (block.type !== "tool_use") continue;
if (block.name === "TaskCreate") {
const input = block.input as { subject: string };
console.log(`+ ${input.subject}`);
} else if (block.name === "TaskUpdate") {
const input = block.input as {
taskId?: string;
id?: string;
task_id?: string;
status?: string;
};
const taskId = input.taskId ?? input.id ?? input.task_id;
if (taskId && input.status) console.log(`  ${taskId} -> ${input.status}`);
}
}
}
} catch (error) {
// A single-shot query() throws after yielding an error result.
console.log(`Session ended with an error: ${error}`);
}