Claude Media
Agent SDK Todo Trackingで進捗をアプリに表示する

Agent SDK Todo Trackingで進捗をアプリに表示する

Agent SDKのTaskCreate / TaskUpdateのtool_useストリームからタスクIDを取り出し、アプリ側の進捗表示を実装する手順とつまずきをまとめます。

Agent SDKでTodoを追跡するとは

タスク管理ツールが既定で使えるのは、Claude 3.x・Opus 4〜4.7・Sonnet 4〜4.6・Haiku 4.5です。新しいモデルは、書き出したTodoリストが無くても多段階の作業を進められるため、Claude CodeはAgent SDKのセッションにこのツールを含めません。既定オフの経緯と復活方法はClaude Code TodoツールがOpus 4.8以降で既定オフにまとめてあります。

本記事は、タスク管理ツールが有効なセッションで、メッセージストリームのtool_useブロックを読み取り、アプリ側に進捗を表示する実装を扱います。

Todoを有効にしたセッションでは、Claudeが書き出すTodoリストの各項目のステータス変更を、構造化データとして受け取れます。タスク活動をログに残す場合も、独自の進捗表示を描画する場合も、同じデータを使います。Agent SDKのセットアップが済んでいる前提で進めます。

どのモデルで既定になり、どう有効にするか

既定の対象外のモデルでは、Claude Codeが認識できないモデルIDを含め、自分でオプトインしない限りtool_useブロックが流れてきません。この既定はClaude Code v2.1.268以降のもので、TypeScript Agent SDKは0.3.268から同梱しています。

ツールが使える場合、渡されるのはTaskCreate TaskGet TaskUpdate TaskListの4つです。CLAUDE_CODE_ENABLE_TASKS=0を設定したときだけ、代わりにTodoWriteが渡されます。

オプトインの方法は3つあります。本記事のコード例はenvでの指定を使います。

設定

セッションをオプトインする3つの方法

  • allowedTools

    allowedTools(Python: allowed_tools)にタスク系ツールのどれかを挙げます。

  • tools オプション

    toolsでセッションの組み込みツールを指定し、その中に含めます。指定した一覧が組み込みツールの全体になるので、ほかに使うツールも並べます。

  • env

    envにCLAUDE_CODE_ENABLE_TODO_TOOLS=1を渡します。TypeScriptのenvはサブプロセスの環境変数を丸ごと置き換えるため...process.envを展開します。Pythonのenvは継承した環境の上にマージされます。

CLIにも同名の--toolsがあります。SDKのtoolsオプションの挙動そのものではありませんが、組み込みツールの一覧を指定するという考え方は共通です。v2.1.289のclaude --helpでは次のように説明されています。

claude --help | grep -A4 -E "^\s+--tools"
  --tools <tools...>                    Specify the list of available tools from
                                        the built-in set. Use "" to disable all
                                        tools, "default" to use all tools, or
                                        specify tool names (e.g.
                                        "Bash,Edit,Read").

Todoのライフサイクル

Claudeは各Todoを次の順で遷移させます。

手順

Todoの一生

  1. 1

    作成(pending)

    Claudeがタスクを識別した時点で追加します。

  2. 2

    着手(in_progress)

    作業を始めた時点で設定します。

  3. 3

    完了(completed)

    タスクが正常に終わった時点で設定します。

  4. 4

    削除

    不要になったタスクは、TaskUpdate呼び出しでstatus: "deleted"にして消します。

ステップ1: タスク活動をログとして監視する

もっとも単純な実装は、assistantメッセージのtool_useブロックからTaskCreateとTaskUpdateだけを拾ってログに出すパターンです。IDの対応付けをせず活動履歴だけ欲しい場合はこれで十分です。

import { query } from "@anthropic-ai/claude-agent-sdk";
 
try {
  for await (const message of query({
    prompt: "ホーム・About・共通スタイルシートを持つ静的サイトを作って。進捗はTodoで管理して",
    options: {
      maxTurns: 15,
      permissionMode: "acceptEdits",
      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") {
        console.log(`+ ${(block.input as { subject: string }).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) {
  console.log(`Session ended with an error: ${error}`);
}

TaskUpdateの入力フィールド名はtaskId id task_idのいずれかで来ることがあります。Claude Codeはidやtask_idをtaskIdへ、active_formをactiveFormへ内部で補正してから実行します。ただし、この補正はストリームに流れるtool_useの入力には反映されません。正規名だけを決め打ちで読むとフィールドが空に見えるため、上記のように複数の候補を??で拾う防御的な読み方が必須です。

このログ型の弱点は、+行に割り当て済みのタスクIDが含まれないことです。あとから来る更新行がどの作成行に対応するかを追えないので、対応付けが必要なら次のステップに進みます。

ステップ2: リアルタイムで進捗を表示する

割り当てられたタスクIDはTaskCreateの入力には含まれません。Claude Codeは各ツールの構造化された出力を、tool_resultブロックを運ぶユーザーメッセージのtool_use_resultフィールドに載せて返します。TaskCreateの場合、その中身は{ task: { id, subject } }という形です(TypeScriptの型名はTaskCreateOutput、Pythonでは同じ形の辞書です)。IDを取るには、tool_use(作成呼び出し)と対応するtool_resultをtool_use_idで突き合わせます。

type Task = { subject: string; activeForm?: string; status: string };
 
class TaskTracker {
  private tasks = new Map<string, Task>();
  private pendingCreates = new Map<string, { subject: string; activeForm?: string }>();
 
  displayProgress() {
    const items = [...this.tasks.values()];
    const completed = items.filter((t) => t.status === "completed").length;
    console.log(`進捗: ${completed}/${this.tasks.size} 完了`);
    for (const [id, task] of this.tasks) {
      const icon = task.status === "completed" ? "✅" : task.status === "in_progress" ? "🔧" : "❌";
      const text = task.status === "in_progress" && task.activeForm ? task.activeForm : task.subject;
      console.log(`${id}. ${icon} ${text}`);
    }
  }
 
  handleToolUse(block: { id: string; name: string; input: unknown }) {
    if (block.name === "TaskCreate") {
      const input = block.input as { subject: string; activeForm?: string; active_form?: string };
      this.pendingCreates.set(block.id, {
        subject: input.subject,
        activeForm: input.activeForm ?? input.active_form,
      });
    } else if (block.name === "TaskUpdate") {
      const input = block.input as {
        taskId?: string; id?: string; task_id?: string; status?: string;
        activeForm?: string; active_form?: string;
      };
      const taskId = input.taskId ?? input.id ?? input.task_id;
      if (!taskId) return;
      if (input.status === "deleted") {
        this.tasks.delete(taskId);
        this.displayProgress();
        return;
      }
      const task = this.tasks.get(taskId);
      if (!task) return;
      if (input.status) task.status = input.status;
      const active = input.activeForm ?? input.active_form;
      if (active) task.activeForm = active;
      this.displayProgress();
    }
  }
 
  handleToolResult(block: { tool_use_id: string; is_error?: boolean }, result: unknown) {
    const create = this.pendingCreates.get(block.tool_use_id);
    if (!create) return;
    this.pendingCreates.delete(block.tool_use_id);
    if (block.is_error) return;
    const out = result as { task?: { id: string } };
    if (!out?.task?.id) return;
    this.tasks.set(out.task.id, { ...create, status: "pending" });
    this.displayProgress();
  }
}

TaskTrackerはpendingCreates(まだ結果が返っていないTaskCreate呼び出し)とtasks(IDが確定したタスク)を分けて持ちます。handleToolUseが呼び出しを、handleToolResultがその結果を処理し、結果が来て初めてタスクIDが判明してtasksに登録されます。displayProgress()を差し替えれば、コンソールログの代わりに独自のUIコンポーネントへ描画できます。

ストリーム側の呼び出しコードは、assistantメッセージからtool_useを、ユーザーメッセージからtool_resultを拾って両方のハンドラに渡します。

for await (const message of query({ prompt, options: { /* 省略 */ } })) {
  if (message.type === "assistant") {
    for (const block of message.message.content) {
      if (block.type === "tool_use") tracker.handleToolUse(block);
    }
  }
  if (message.type === "user" && Array.isArray(message.message.content)) {
    for (const block of message.message.content) {
      if (block.type === "tool_result") tracker.handleToolResult(block, message.tool_use_result);
    }
  }
}

subjectとactiveFormの役割分担

各Todoには2種類のラベルがあります。subjectはタスク名で、作成時に決まります。activeFormはin_progressの間に表示する現在進行形の表現で、TaskCreateの入力に含まれることも、TaskUpdateで後から渡されることもあります。

上記のTaskTrackerがin_progressのときだけactiveFormを優先するのはこのためです。TaskUpdateで届いたactiveFormでtask.activeFormを書き換える処理も、後から更新されるケースのために入れています。subjectだけを表示に使うと、着手中のタスクが何をしているか伝わりにくい進捗表示になります。

ツールの入出力で何が取れるか

TypeScript SDKのリファレンスには、Task系ツールの入力と出力の型が載っています。進捗表示に効くフィールドを抜き出すと次のとおりです。

ツール入力で受け取れるもの出力(tool_use_result)で受け取れるもの
TaskCreate入力で受け取れるものsubject description activeForm metadata出力(tool_use_result)で受け取れるものtask.id と task.subject
TaskUpdate入力で受け取れるものtaskId status subject description activeForm addBlocks addBlockedBy owner metadata出力(tool_use_result)で受け取れるものsuccess taskId updatedFields error statusChange(from / to)
TaskGet入力で受け取れるものtaskId出力(tool_use_result)で受け取れるものtask(id subject description status blocks blockedBy)。IDが無いとnull
TaskList入力で受け取れるものなし出力(tool_use_result)で受け取れるものtasksの配列(id subject status owner blockedBy)

見落としやすいのは次の3点です。

  • TaskCreateはsubjectのほかにdescriptionも型の上では必須で、本記事のコードはsubjectしか読んでいません。詳細を画面に出したいならdescriptionもpendingCreatesに保存します
  • TaskUpdateの出力にはsuccessとerrorがあります。上記のTaskTrackerはtool_useの入力を見た時点で状態を書き換えるので、失敗した更新も画面に反映します。更新の結果側のsuccessを見てから反映する書き方もあります
  • addBlocks / addBlockedByでタスク間の依存を張れ、TaskListのblockedByで読み出せます。依存を画面に出したいアプリは、TaskTrackerのマップに依存関係を足します

TaskListとTaskGetはClaude自身がタスクを読み返すために呼びます。ほかのツール呼び出しと同じtool_use / tool_resultのペアでストリームに流れるため、これらだけを拾ってtasksマップとの食い違いを調べるデバッグ用ログを足すこともできます。

バックグラウンドタスクの通知を受け取る

SDKTaskNotificationMessage(Python: TaskNotificationMessage)は、バックグラウンドで動くタスクの完了・失敗・停止を知らせるシステムメッセージです。対象はrun_in_backgroundで動かしたBashコマンド、Monitorの監視、バックグラウンドのサブエージェントです。Todoのtool_useとは別の系統です。

くらべる

Todoのtool_useと、タスク通知の違い

Todoリストの変化

Todoの tool_use

assistantメッセージのtool_useブロックとして届きます。TaskCreate TaskUpdateの入力から、Claudeが管理するTodoリストの変化を追います。

バックグラウンド処理

SDKTaskNotificationMessage

type: "system"、subtype: "task_notification"のメッセージです。task_id status(completed / failed / stopped)summary output_fileを持ち、元の呼び出しはtool_use_idで辿れます。

サブエージェントを使う構成でバックグラウンドの実行状況も画面に出したいなら、こちらも合わせて監視します。両方を1つのTaskTrackerに統合するか別々のログにするかは、1画面にまとめたいかどうかで決められます。

いつClaudeがTodoを作るか

タスク管理ツールがあるセッションでは、3ステップ以上の複雑な作業、利用者が複数項目を一度に伝えたとき、進捗追跡が助けになる長めの作業、明示的な依頼があったときに、Claudeは主にTodoを作ります。ごく短い単発の依頼では作られないこともあります。判断基準の詳細はClaude Code TodoツールがOpus 4.8以降で既定オフにまとめています。

よくあるつまずき

  • tool_useブロックが一切流れてこない: Claude 3.x・Opus 4〜4.7・Sonnet 4〜4.6・Haiku 4.5以外のモデルでは、envのCLAUDE_CODE_ENABLE_TODO_TOOLS=1(またはallowedTools / toolsでの指定)を忘れるとTaskのブロックが流れません。エラーにはならないため、実装のバグだと誤解しやすい症状です
  • deletedステータスの扱い漏れ: TaskUpdateがstatus: "deleted"を送ってきた場合の削除処理を書き忘れると、消えたタスクが表示上残り続けます
  • ターン上限でエラーが出る: maxTurnsに達すると、最後の結果メッセージのsubtypeはerror_max_turnsになります。単発のquery()はその結果を返したあとにエラーを投げるので、上のコード例のようにループ全体をtryで包みます

Hookイベントで監視したい場合

Hookイベント経由で監視したい場合は、Agent SDKのメッセージストリームではなくClaude CodeのHookを使います。既存の自動化にタスク管理を組み込む場合は、Claude Code TodoツールがOpus 4.8以降で既定オフのTaskCreated Hookの解説が手がかりになります。自前アプリのUIに組み込む場合は、Agent SDKでSlack常駐botを作るのように、メッセージストリームの処理へTaskTrackerを差し込みます。

まとめ

実装で最初に決めるのは、TaskUpdateの失敗を画面にどう扱うかです。入力を見た時点で反映すれば表示は速く、結果のsuccessを待てば画面とClaudeの手元のリストが食い違いにくくなります。

よくある質問

Pythonでも同じ構造で組めますか

組めます。assistantメッセージのToolUseBlockから作成・更新の呼び出しを、ユーザーメッセージのToolResultBlockから結果を拾い、UserMessageのtool_use_result(辞書)でtask.idを読む流れはTypeScriptと同じです。入力はblock.input.get("taskId")のように辞書として読み、envは継承した環境の上にマージされるため、環境変数の展開は要りません。

この記事を共有:XはてブLinkedIn