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

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

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

Agent SDKでTodoを追跡するとは

対象モデル(下記)では、Claudeは書き出したTodoリストが無くても多段階の作業を追跡でき、Claude CodeはAgent SDKのセッションにタスク管理ツールを既定で含めません。この既定オフの範囲と復活方法自体はClaude Code TodoツールがOpus 4.8以降で既定オフにまとめてあります。本記事は、タスク管理ツールを有効にしたうえで、Agent SDKのメッセージストリームからtool_useブロックを読み取り、アプリ側に進捗を表示する実装に絞ります。

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

モデルの対象範囲とツールを有効にする方法

TypeScript Agent SDK 0.3.233以降、Python Agent SDK 0.2.139以降では、Opus 4.8・Sonnet 5・Fable 5・Mythos 5以降のモデルでTodoWrite TaskCreate TaskGet TaskUpdate TaskListが既定で使えません。それ以外のモデルでは、Claude CodeはTask系ツールを既定で提供し、CLAUDE_CODE_ENABLE_TASKS=0を設定した場合だけTodoWriteを提供します。

対象モデルでこれらのツールを有効にする方法は3つあります。本記事のコード例はenvでの指定を使います。

  • allowedTools(Python: allowed_tools)にツール名を挙げる
  • toolsオプションでセッションの組み込みツール一覧を指定し、その中に含める
  • envオプションでCLAUDE_CODE_ENABLE_TODO_TOOLS=1を設定する(TypeScriptのenvはサブプロセスの環境変数を丸ごと置き換えるため...process.envを展開する。Pythonのenvは継承した環境の上にマージされる)

Todoのライフサイクル

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

状態内容
作成(pending)内容Claudeがタスクを識別した時点で追加
着手(in_progress)内容作業を始めた時点で設定
完了(completed)内容タスクが正常に終わった時点で設定
削除内容TaskUpdate呼び出しでstatus: "deleted"にして不要なタスクを消す

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

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

import { query } from "@anthropic-ai/claude-agent-sdk";
 
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}`);
    }
  }
}

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

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

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

割り当てられたタスクIDはTaskCreateの入力そのものには含まれません。Claude Codeは各ツールの構造化された出力を、tool_resultブロックを運ぶユーザーメッセージのtool_use_resultフィールドに載せて返します。TaskCreateの場合、その中身は{ task: { id, subject } }という形です。つまり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 };
      this.pendingCreates.set(block.id, input);
    } 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) return;
      if (input.status === "deleted") {
        this.tasks.delete(taskId);
        this.displayProgress();
        return;
      }
      const task = this.tasks.get(taskId);
      if (task && input.status) {
        task.status = input.status;
        this.displayProgress();
      }
    }
  }
 
  handleToolResult(block: { tool_use_id: string; is_error?: boolean }, result: unknown) {
    const create = this.pendingCreates.get(block.tool_use_id);
    if (!create || block.is_error) return;
    this.pendingCreates.delete(block.tool_use_id);
    const out = result as { task?: { id: string } };
    if (!out?.task?.id) return;
    this.tasks.set(out.task.id, { ...create, status: "pending" });
    this.displayProgress();
  }
}

TaskTrackerpendingCreates(まだ結果が返っていない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);
    }
  }
}

TaskListでタスク一覧を、TaskGetで1件の詳細をClaude自身が読み返すこともできます。アプリ側で表示がずれていないか疑わしいときは、これらの呼び出し結果と自分のtasksマップを突き合わせて検証できます。TaskListTaskGetもほかのツール呼び出しと同じtool_use / tool_resultのペアとしてストリームに流れるため、TaskTrackerとは別に、これらの呼び出しだけを拾って自分の状態と食い違っていないかを確認するデバッグ用のログを足すのも実用的です。

subjectactiveFormの役割分担

各Todoには2種類のラベルがあります。subjectはタスク名で、作成時に一度だけ決まり、完了・保留の状態でも変わりません。activeFormはタスクがin_progressの間だけ使う現在進行形の表現で、着手時に一緒に渡されることも、TaskUpdateで後から追加・更新されることもあります。上記のTaskTrackerstatus === "in_progress"のときだけactiveFormを優先して表示しているのはこのためで、それ以外の状態では常にsubjectを表示します。この使い分けを知らずにsubjectだけを表示に使うと、着手中のタスクが何をしているかが伝わりにくい進捗表示になります。

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

SDKTaskNotificationMessage(Python: TaskNotificationMessage)というタスクシステムメッセージは、バックグラウンド化されたコマンドやサブエージェントといった、バックグラウンドで動くタスクを報告します。Todoのtool_useブロックとは別の系統のメッセージなので、サブエージェントを使う構成でバックグラウンドの実行状況も見たい場合は、こちらのメッセージタイプも合わせて監視します。

この系統のメッセージは、Todoのライフサイクルとは独立したイベントストリームです。フォアグラウンドで進む会話はTodoのtool_useで、バックグラウンドで並行して動く処理はSDKTaskNotificationMessageで、それぞれ別々に追跡するのが実装上の切り分け方になります。両方を1つのTaskTrackerに統合するか別々のログにするかは、アプリ側でフォアグラウンドとバックグラウンドの進捗を1画面にまとめたいかどうかで決めます。

いつClaudeがTodoを作るか

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

よくあるつまずき

  • タスクIDが常にundefinedに見える: TaskCreatetool_use入力にはIDが無いのが仕様です。IDは対応するtool_resulttool_use_result({ task: { id, subject } })からしか取れません。入力ブロックだけを見ていると気づきにくい落とし穴です
  • taskIdが読めない: TaskUpdateの入力はtaskId id task_idのどれで来るか、コード側の想定と実際の呼び出しで食い違うことがあります。正規名を1つだけ決め打ちにせず、複数の候補を順に見る書き方にします
  • 対象モデルでツールが渡されないまま実装してしまう: Opus 4.8以降・Sonnet 5以降などの対象モデルでは、envCLAUDE_CODE_ENABLE_TODO_TOOLS=1(またはallowedTools / toolsでの指定)を忘れるとtool_useブロック自体が一切流れてきません。エラーにはならないため、実装のバグだと誤解しやすい症状です
  • deletedステータスの扱い漏れ: TaskUpdatestatus: "deleted"を送ってきた場合の削除処理を書き忘れると、実際には消えたタスクが表示上残り続けます

ログ型とリアルタイム表示型、どちらを選ぶか

用途向いているパターン
タスク活動の履歴をあとから確認したいだけ向いているパターンログ型(ステップ1)。IDの対応付けが不要な分、実装が単純
進捗バーやチェックリストをUIに描画したい向いているパターンリアルタイム表示型(ステップ2)。tool_use_idでIDを確定させる分の実装が要る
Hookイベント経由で監視したい向いているパターンAgent SDKのメッセージストリームではなくClaude CodeのHookを使う。既存の自動化にタスク管理を組み込みたい場合はClaude Code TodoツールがOpus 4.8以降で既定オフTaskCreated Hookの解説を参照
自前アプリのUIに組み込みたい向いているパターンAgent SDKでSlack常駐botを作るのように、自分のアプリのメッセージストリーム処理へTaskTrackerを差し込む

まとめ

Agent SDKでTodoの進捗をアプリに表示するには、まず対象モデルでタスク管理ツールをenvCLAUDE_CODE_ENABLE_TODO_TOOLS=1などで有効にし、次にtool_useブロックからTaskCreate TaskUpdateを監視します。タスクIDはTaskCreateの入力ではなく対応するtool_resulttool_use_resultからしか取れない点、TaskUpdateのフィールド名は防御的に読む必要がある点が、実装でもっともつまずきやすい2箇所です。活動のログだけで足りるならID対応付け不要の単純な監視で十分ですが、進捗をUIに描画したい場合はtool_use_idで作成と結果を突き合わせるTaskTrackerのような構造が要ります。

よくある質問

TaskCreatetool_useブロックにタスクIDは含まれますか

含まれません。TaskCreateのtool_use入力に入っているのはsubjectactiveFormだけです。割り当てられたIDは、対応するtool_resulttool_use_resultフィールド({ task: { id, subject } })からしか取得できません。

この実装はどのモデルでもそのまま動きますか

Opus 4.8・Sonnet 5・Fable 5・Mythos 5以降の対象モデルでは、envCLAUDE_CODE_ENABLE_TODO_TOOLS=1などでタスク管理ツールを明示的に有効にしないとtool_useブロック自体が流れてきません。それより前のモデルではTask系ツールが既定で提供されるため、この設定なしでも動きます。

SDKTaskNotificationMessageは何を報告しますか

バックグラウンド化されたコマンドやサブエージェントなど、バックグラウンドで動くタスクの状況を報告するメッセージです。Todoのtool_useブロックとは別系統なので、両方を監視すればフォアグラウンドのTodoとバックグラウンドの実行状況を合わせて把握できます。

ログ型とリアルタイム表示型、実装コストはどれくらい違いますか

ログ型はtool_useブロックを見るだけなので数行で書けます。リアルタイム表示型は、加えてtool_resulttool_use_idで突き合わせる状態管理(pendingCreatestasksの2つのマップ)が必要になるため、実装量は増えますが、進捗をUIに正しく反映したい場合はこの構造が実質的に必須です。

TaskListはどのくらいの頻度で呼び出せばよいですか

呼び出し頻度を制御しているのはアプリ側ではなくClaude自身です。Claudeが必要と判断したタイミングでTaskList TaskGetを呼びます。アプリ側から任意のタイミングで最新状態を強制的に取得する仕組みは無いため、常に最新の状態を保ちたい場合はTaskTrackerのような自前のイベント駆動の状態管理を組む方が確実です。

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