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:
| Tool | Used for |
|---|---|
TaskCreate | Add a task |
TaskUpdate | Change status or label, or delete with status: "deleted" |
TaskGet | Read one task's full details |
TaskList | Read the list back |
TodoWrite | The 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
toolsoption (remembertoolsrestricts built-ins to what you list, so include everything else you need); - set
CLAUDE_CODE_ENABLE_TODO_TOOLS=1inenv. In TypeScript, spreadprocess.envbecauseenvreplaces 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
- pending: created when Claude identifies a piece of work.
- in_progress: set when Claude starts on it.
- completed: set when the work is done.
- 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 want | Where it is |
|---|---|
| A new task's subject and active label | tool_use block named TaskCreate in an assistant message (input.subject, input.activeForm) |
| A new task's assigned ID | Not 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 changes | tool_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 (
idortask_idtotaskId,active_formtoactiveForm), 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:
- On a
TaskCreatetool use, stash{ subject, activeForm }under the tool use's ownid. - On a
tool_resultwhosetool_use_idmatches, readtask.idfrom the message'stool_use_resultand move the stashed entry into the map aspending. - 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.