サポートのフォローアップをスケジュールする

CloudflareBeginner
オンラインで実践に進む

はじめに

サポートチームは、顧客が修正を試した後、サービスウィンドウが終了した後、またはエスカレーション期限の前に、チケットを再確認すると約束することがあります。ブラウザーのタイマーにこの約束を任せるのは安全ではありません。タブを閉じるとタイマーが失われるためです。Agent のスケジュールは将来の処理を名前付きの Agent とともに保存するため、指定した時刻になるとプラットフォームがその永続的なインスタンスを起動できます。

この実験では、言語モデルを使わずに小さなフォローアップボードを作成します。

  1. schedule() は遅延コールバックを 1 つ登録し、永続的なスケジュール ID を返します。
  2. listSchedules() は、現在の非同期 API を使って保留中の処理を確認できるようにします。
  3. cancelSchedule() は、サーバーが所有対象を確認した後、まだ保留中の項目を削除します。
  4. コールバックは Agent の状態に範囲を制限した完了記録を保存し、プライバシーを制限したログを出力します。

短いタスクをスケジュールして完了を確認し、その後、長いタスクを作成して実行前にキャンセルします。呼び出しでは合成チケット参照だけを使用します。同一の登録リクエストでは SDK のべき等性を有効にするため、誤ってダブルクリックしても重複した処理は作成されません。

Agents SDK は、このライフサイクルを SQLite ベースの Durable Object alarm 上に実装します。アラームのタイムスタンプやストレージレコードを自分で管理する代わりに、高レベルのスケジュール API を使用します。ただし、処理は引き続き 1 つの名前付き Agent インスタンスに属し、通常の Worker 再起動後も維持されます。

このコースに直接進む前に、LabEx を Cloudflare アカウントに接続するを完了してください。 新しい LabEx VM ごとに、独自の Wrangler 認証が必要です。コース内の以前の実験を完了しておくことを推奨しますが、この実験では分離されたリソースを独自に作成して削除します。

VM を認証し、Agent を設定する

このステップでは、新しい VM を認証し、この実験で使用する使い捨ての Worker と Durable Object クラスを 1 つ定義します。

cd /home/labex/project/follow-up-agent
npx wrangler login
npx wrangler whoami --json

表示されたデバイスリンクを LabEx ブラウザーで開き、表示されたコードを確認して、学習用アカウントを承認します。パスワード、トークン、認証コードを他人に送信しないでください。JSON 結果で "loggedIn": true になっていることを確認し、アカウント名を読み取って ID をコピーします。

一意のリソース名を生成し、wrangler.jsonc を作成します。

RUN="labex-c11-s04-$(openssl rand -hex 6)"
ACCOUNT_ID="YOUR_ACCOUNT_ID"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "durable_objects": {
    "bindings": [
      { "name": "FollowUpAgent", "class_name": "FollowUpAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] }
  ]
}
JSON
python3 .labex/verify.py auth

バインディング名は、ルーターとクライアントが使用する名前です。クラス名は実装を示します。マイグレーション v1 は、そのクラス用の SQLite ベースのストレージを Cloudflare に作成させます。特定の名前付きインスタンスをすぐに作成するわけではありません。planning のようなインスタンスは、最初にその名前宛てのリクエストが届いたときに作成されます。

永続的なフォローアップスケジュールを実装する

このステップでは、1 つの名前付き Agent に、登録、確認、キャンセル、後で実行するコールバックを実装します。

cat > src/server.ts <<'TS'
import { Agent, callable, routeAgentRequest, type Schedule } from "agents";

type CompletedFollowUp = { ticketId: string; completedAt: string };
export type FollowUpState = { completed: CompletedFollowUp[]; revision: number };
export type PendingFollowUp = { id: string; ticketId: string; runAt: string };

export class FollowUpAgent extends Agent<Cloudflare.Env, FollowUpState> {
  initialState: FollowUpState = { completed: [], revision: 0 };

  private ticket(value: unknown): string {
    const ticketId = typeof value === "string" ? value.trim().toUpperCase() : "";
    if (!/^T-[A-Z0-9-]{3,24}$/.test(ticketId)) {
      throw new Error("ticket must look like T-DEMO-101");
    }
    return ticketId;
  }

  @callable()
  async scheduleFollowUp(ticketInput: string, delaySeconds: number): Promise<PendingFollowUp> {
    const ticketId = this.ticket(ticketInput);
    if (!Number.isInteger(delaySeconds) || delaySeconds < 3 || delaySeconds > 300) {
      throw new Error("delay must be an integer from 3 to 300 seconds");
    }
    const scheduled = await this.schedule(
      delaySeconds,
      "completeFollowUp",
      { ticketId },
      {
        idempotent: true,
        retry: { maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 500 }
      }
    );
    return this.pending(scheduled);
  }

  @callable()
  async listFollowUps(): Promise<PendingFollowUp[]> {
    const schedules = await this.listSchedules({ type: "delayed" });
    return schedules
      .filter((item) => item.callback === "completeFollowUp")
      .map((item) => this.pending(item))
      .sort((left, right) => left.runAt.localeCompare(right.runAt));
  }

  @callable()
  async cancelFollowUp(scheduleId: string): Promise<boolean> {
    if (!/^[a-zA-Z0-9_-]{8,80}$/.test(scheduleId)) throw new Error("invalid schedule ID");
    const owned = await this.getScheduleById(scheduleId);
    if (!owned || owned.callback !== "completeFollowUp") return false;
    return this.cancelSchedule(scheduleId);
  }

  @callable()
  getBoard(): FollowUpState {
    return this.state;
  }

  async completeFollowUp(payload: unknown, _schedule: Schedule<unknown>): Promise<void> {
    const ticketId = this.ticket((payload as { ticketId?: unknown })?.ticketId);
    const next: FollowUpState = {
      completed: [...this.state.completed, { ticketId, completedAt: new Date().toISOString() }].slice(-5),
      revision: this.state.revision + 1
    };
    this.setState(next);
    console.log(JSON.stringify({
      event: "follow_up_completed",
      instance: this.name,
      revision: next.revision,
      completedCount: next.completed.length
    }));
  }

  private pending(schedule: Schedule<unknown>): PendingFollowUp {
    const payload = schedule.payload as { ticketId?: unknown };
    return {
      id: schedule.id,
      ticketId: this.ticket(payload.ticketId),
      runAt: new Date(schedule.time * 1000).toISOString()
    };
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    return (await routeAgentRequest(request, env)) ?? new Response("Not found", { status: 404 });
  }
};
TS
python3 .labex/verify.py server

Cloudflare.Env は、コンパイル前に作成する Wrangler 生成のバインディング宣言に由来します。そのため、ソース内で環境の定義を手書きで重複管理する必要はありません。schedule() には、相対遅延、コールバック名、小さなシリアライズ可能なペイロードを渡します。{ idempotent: true } を指定すると、同じコールバックとペイロードを繰り返しても、別のスケジュールを追加せず、既存の保留中スケジュールが返されます。再試行ポリシーでは、短く範囲を制限したバックオフを使用して、コールバックを最大 2 回試行します。そのため、恒久的なエラーが無限にループすることはありません。コールバックは合成チケットの完了記録を 5 件だけ保持し、構造化ログにはチケット参照を含めません。

一覧取得メソッドと検索メソッドには、意図的に await を付けています。古い例では同期的な getSchedule()getSchedules() の呼び出しが示されている場合がありますが、現在の Agents SDK コードでは getScheduleById()listSchedules() を使用してください。

フォローアップボードを接続する

このステップでは、現在のデコレータ変換を設定し、提供されているページを 1 つの名前付き Agent に接続します。

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": { "noEmit": true },
  "include": ["src/**/*.ts", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import agents from "agents/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [agents(), cloudflare()] });
TS

cat > src/client.ts <<'TS'
import { AgentClient } from "agents/client";
import type { FollowUpState, PendingFollowUp } from "./server";

document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
  <main><p class="eyebrow">Durable scheduling</p><h1>Support Follow-Up Board</h1>
  <p id="status" class="status">Connecting to FollowUpAgent:planning…</p>
  <form id="form"><input id="ticket" value="T-DEMO-101" aria-label="Ticket reference">
  <input id="delay" type="number" min="3" max="300" value="12" aria-label="Delay in seconds">
  <button>Schedule follow-up</button></form><p id="error" class="error"></p>
  <div class="columns"><section class="panel"><h2>Pending</h2><div id="pending"></div></section>
  <section class="panel"><h2>Completed</h2><div id="completed"></div></section></div>
  <p class="notice">This demonstration uses synthetic ticket references only.</p></main>`;

const client = new AgentClient<FollowUpState>({ agent: "FollowUpAgent", name: "planning", host: window.location.host });
const pendingView = document.querySelector<HTMLDivElement>("#pending")!;
const completedView = document.querySelector<HTMLDivElement>("#completed")!;
const statusView = document.querySelector<HTMLParagraphElement>("#status")!;
const errorView = document.querySelector<HTMLParagraphElement>("#error")!;

function renderCompleted(state: FollowUpState) {
  completedView.innerHTML = state.completed.map((item) =>
    `<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.completedAt).toLocaleTimeString()}</small></div>`
  ).join("") || '<p class="empty">No completed follow-ups yet</p>';
}

async function refresh() {
  const pending = await client.call<PendingFollowUp[]>("listFollowUps", []);
  pendingView.innerHTML = pending.map((item) =>
    `<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.runAt).toLocaleTimeString()}</small><br>` +
    `<button class="secondary" data-id="${item.id}">Cancel</button></div>`
  ).join("") || '<p class="empty">No pending follow-ups</p>';
  const state = await client.call<FollowUpState>("getBoard", []);
  renderCompleted(state);
}

await client.ready;
statusView.textContent = "Connected to FollowUpAgent:planning";
await refresh();
setInterval(() => refresh().catch(() => undefined), 2000);

document.querySelector<HTMLFormElement>("#form")!.addEventListener("submit", async (event) => {
  event.preventDefault(); errorView.textContent = "";
  try {
    const ticket = document.querySelector<HTMLInputElement>("#ticket")!.value;
    const delay = Number(document.querySelector<HTMLInputElement>("#delay")!.value);
    await client.call("scheduleFollowUp", [ticket, delay]); await refresh();
  } catch (cause) { errorView.textContent = cause instanceof Error ? cause.message : String(cause); }
});

pendingView.addEventListener("click", async (event) => {
  const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-id]");
  if (!button) return;
  await client.call("cancelFollowUp", [button.dataset.id]); await refresh();
});
TS
python3 .labex/verify.py client

このページは、プレーンな TypeScript の構成を読みやすく保つためだけに、2 秒ごとに Agent をポーリングします。スケジュール自体はブラウザーのタイマーではないため、ページを閉じてもキャンセルされません。検証、所有権の確認、実行はサーバーが引き続き担当します。

型を生成してアプリケーションをビルドする

このステップでは、バインディング型を生成し、ランタイムを起動する前にアプリケーションの両方の部分をチェックしてビルドします。

正確なバインディングから環境型を生成し、TypeScript の両側を確認して、Worker と静的ページをビルドします。

npx wrangler types
grep -n "FollowUpAgent" worker-configuration.d.ts | head
npm run check
npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'
python3 .labex/verify.py build

エラーのないビルドは、バインディング、デコレータ変換、共有型、バンドルが互いに一致していることを示します。ただし、まだ alarm が発火することや、デプロイしたリソースをクラウドアカウントが所有していることまでは確認できません。これらは次のステップでランタイムを使って確認します。

ライフサイクルをローカルで確認する

このステップでは、ローカルの Cloudflare ランタイムで、永続的な実行とキャンセルが機能することを確認します。

ローカルランタイムを、終了させずにバックグラウンドジョブとして起動します。

CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
for attempt in $(seq 1 40); do
  curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
  sleep 1
done
curl --silent --head http://127.0.0.1:5173/ | head

LabEx のデスクトップブラウザーで http://localhost:5173 を開きます。T-DEMO-101 を 12 秒後に実行するようスケジュールします。最初は Pending に表示されます。ページを閉じたり更新したりしても、その処理の所有権は失われません。指定した時刻になると、コールバックがスケジュールストアから項目を削除し、Completed に記録します。

次に、T-DEMO-CANCEL を 90 秒後に実行するようスケジュールし、Cancel をクリックします。項目が Pending から消え、Completed には表示されません。独立したプローブを実行します。このプローブはランダムな独自の Agent 名を使用し、べき等な登録、実行、キャンセルを確認します。

python3 .labex/verify.py local

デプロイしてスケジュール済みの処理を確認する

このステップでは、Cloudflare 上でライフサイクルを繰り返し、観測可能な動作を Dashboard の証拠と結び付けます。

ローカルの正確なプロセスを停止し、本番ビルドをデプロイして URL を待ちます。

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
WORKER_URL="https://YOUR_WORKER_URL"
for attempt in $(seq 1 30); do
  curl --silent --fail "$WORKER_URL/" > /dev/null && break
  sleep 2
done

組み込みブラウザーで正確な URL を開きます。T-CLOUD-101 を 20 秒後に実行するようスケジュールし、まず永続的な保留中の行を確認します。

保留中のスケジュール一覧にクラウド上のフォローアップが表示されている

実行時刻とスケジュール ID は、今回の使い捨て実行環境に固有のものです。値は異なります。重要なのは、その項目がページに保存されたカウントダウンではなく、Agent によって一覧表示されていることです。

コールバックが実行されるまで待ち、同じ合成チケットが Completed に表示されることを確認します。

スケジュールされたコールバックによって合成チケットが完了状態に移動している

T-CLOUD-CANCEL を 90 秒後に実行するよう作成し、保留中の状態を確認してからキャンセルします。保留中のパネルが空に戻り、Completed の項目は変わらないことを確認します。

より長い保留中のフォローアップが明示的なキャンセルを待っている

キャンセルしたスケジュールは表示されず、以前の完了項目は残っている

Workers & Pages を開き、正確な labex-c11-s04-... Worker を選択して Bindings を確認します。FollowUpAgent が同じクラス名を指していることを確認します。

Worker のバインディングがリクエストを FollowUpAgent に接続している

Durable Objects を開き、FollowUpAgent namespace を確認します。Agent の状態とスケジュールには永続的なレコードが必要なため、SQL storage を使用しています。

FollowUpAgent Durable Object namespace が SQL storage を使用している

最後に、Observability → Logs を開き、follow_up_completed でフィルターしてイベントを 1 つ展開します。範囲を制限したイベントには Agent インスタンス、リビジョン、完了数が含まれますが、チケット参照は含まれません。

範囲を制限した完了ログに合成チケット参照が含まれていない

Dashboard の表示には遅延が発生する場合があるため、独立したリモートプローブを正式な確認結果として扱います。

python3 .labex/verify.py deployed
python3 .labex/verify.py observed

スケジュール用 namespace と Worker を削除する

このステップでは、この実験で作成したクラス namespace と Worker だけを削除します。

スケジュールと完了状態は Durable Object クラスの namespace に保存されています。残りのステートレスな Worker を削除する前に、そのクラスを明示的に削除します。

cat > src/cleanup.ts <<'TS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
TS
RUN="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).name)')"
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] },
    { "tag": "v2", "deleted_classes": ["FollowUpAgent"] }
  ]
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
python3 .labex/verify.py deleted

アカウント内の関係ないリソースは削除しないでください。生成された正確な Worker と、その FollowUpAgent namespace だけが削除されたことを確認します。

クリーンアップ後、使い捨てのスケジュール用 Worker が表示されていない

削除マイグレーション後、FollowUpAgent namespace が表示されていない

この VM の認証を取り消す

このステップでは、使い捨て VM に保存された OAuth grant を削除し、ログアウト状態が構造化された形式で表示されることを確認します。

クラウドのクリーンアップが成功したら、この使い捨て VM に保存された OAuth 認証を削除します。

npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout

明示的に "loggedIn": false になっていることを確認します。ネットワークエラーだけでは判断できないため、再試行してください。使い捨て Worker、そのスケジュール用 namespace、この VM のローカル認証が削除されました。

まとめ

開いているブラウザーや言語モデルに依存せず、名前付きの Cloudflare Agent に永続的な将来の処理を登録しました。範囲を制限した遅延コールバックを登録し、登録の繰り返しをべき等にし、現在の非同期 API で保留中のスケジュールを確認し、キャンセル前に所有権を検証し、少量の完了履歴だけを保存しました。

さらに、SDK の抽象化を Durable Object alarm のライフサイクルに結び付け、ローカルとリモートで完了およびキャンセルを確認し、プライバシーを制限した証拠を確認しました。最後に、クラス namespace、Worker、使い捨て VM の認証を明示的に削除しました。