Human-in-the-loop(HITL)パターンは、エージェントの各層で承認や入力を挟みます。MCP サーバーからのリクエストに応答する、アプリケーション作業を耐久的な Workflow で保留する、モデル生成コードがツールを呼ぶ前にコネクタ呼び出しを承認する、といった使い方ができます。
- コンプライアンス: 規制要件により、特定の操作に人間の承認が必要な場合があります
- 安全性: 高リスクの操作(支払い、削除、外部への通信)には監督が必要です
- 品質: 人間のレビューは、AI が見落とす誤りを拾います
- 信頼: 重要な操作を承認できると、ユーザーは安心しやすくなります
よくある用途は、財務承認、コンテンツモデレーション、一括データ操作、副作用のあるツール呼び出し前の承認、アクセス制御の変更です。
誰が一時停止を始め、どこで止まるかでパターンを選びます。
| パターン | 承認の層 | 開始する人 | 典型的な待ち時間 | 主な API |
|---|---|---|---|---|
| MCP elicitation | エージェントクライアントが扱う MCP リクエスト | MCP サーバー開発者 | 数分 | configureElicitationHandlers() |
| Workflow 承認 | 耐久的なアプリケーションタスクまたはツール操作 | エージェントアプリ開発者 | 数か月から数年 | waitForApproval() |
| Code Mode 承認 | モデル生成コード内のコネクタ呼び出し | Code Mode エージェント開発者 | 設定した有効期限まで | requiresApproval, approve(), reject() |
アプリケーションが、承認に必要なだけタスクやツール操作を保留するときは Cloudflare Workflows を使います。waitForApproval() は Cloudflare Workflows を裏にした耐久ゲートを作るので、エージェントを動かし続けずに、数か月以上の待ちも続けられます。
import { Agent } from "agents";
import { AgentWorkflow } from "agents/workflows";
export class ExpenseWorkflow extends AgentWorkflow {
async run(event, step) {
const expense = event.payload;
// Step 1: Validate the expense
const validated = await step.do("validate", async () => {
if (expense.amount <= 0) {
throw new Error("Invalid expense amount");
}
return { ...expense, validatedAt: Date.now() };
});
// Step 2: Report that we are waiting for approval
await this.reportProgress({
step: "approval",
status: "pending",
message: `Awaiting approval for $${expense.amount}`,
});
// Step 3: Wait for human approval (pauses the workflow)
const approval = await this.waitForApproval(step, {
timeout: "7 days",
});
console.log(`Approved by: ${approval?.approvedBy}`);
// Step 4: Process the approved expense
const result = await step.do("process", async () => {
return { expenseId: crypto.randomUUID(), ...validated };
});
await step.reportComplete(result);
return result;
}
}import { Agent } from "agents";
import { AgentWorkflow } from "agents/workflows";
import type { AgentWorkflowEvent, AgentWorkflowStep } from "agents/workflows";
type ExpenseParams = {
amount: number;
description: string;
requestedBy: string;
};
export class ExpenseWorkflow extends AgentWorkflow<
ExpenseAgent,
ExpenseParams
> {
async run(event: AgentWorkflowEvent<ExpenseParams>, step: AgentWorkflowStep) {
const expense = event.payload;
// Step 1: Validate the expense
const validated = await step.do("validate", async () => {
if (expense.amount <= 0) {
throw new Error("Invalid expense amount");
}
return { ...expense, validatedAt: Date.now() };
});
// Step 2: Report that we are waiting for approval
await this.reportProgress({
step: "approval",
status: "pending",
message: `Awaiting approval for $${expense.amount}`,
});
// Step 3: Wait for human approval (pauses the workflow)
const approval = await this.waitForApproval<{ approvedBy: string }>(step, {
timeout: "7 days",
});
console.log(`Approved by: ${approval?.approvedBy}`);
// Step 4: Process the approved expense
const result = await step.do("process", async () => {
return { expenseId: crypto.randomUUID(), ...validated };
});
await step.reportComplete(result);
return result;
}
}待機中の Workflow を承認または却下するメソッドを、エージェントが提供します。
import { Agent, callable } from "agents";
export class ExpenseAgent extends Agent {
initialState = {
pendingApprovals: [],
};
// Approve a waiting workflow
@callable()
async approve(workflowId, approvedBy) {
await this.approveWorkflow(workflowId, {
reason: "Expense approved",
metadata: { approvedBy, approvedAt: Date.now() },
});
// Update state to reflect approval
this.setState({
...this.state,
pendingApprovals: this.state.pendingApprovals.filter(
(p) => p.workflowId !== workflowId,
),
});
}
// Reject a waiting workflow
@callable()
async reject(workflowId, reason) {
await this.rejectWorkflow(workflowId, { reason });
this.setState({
...this.state,
pendingApprovals: this.state.pendingApprovals.filter(
(p) => p.workflowId !== workflowId,
),
});
}
// Track workflow progress to update pending approvals
async onWorkflowProgress(workflowName, workflowId, progress) {
const p = progress;
if (p.step === "approval" && p.status === "pending") {
// Add to pending approvals list for UI display
this.setState({
...this.state,
pendingApprovals: [
...this.state.pendingApprovals,
{
workflowId,
amount: 0, // Would come from workflow params
description: p.message || "",
requestedBy: "user",
requestedAt: Date.now(),
},
],
});
}
}
}import { Agent, callable } from "agents";
type PendingApproval = {
workflowId: string;
amount: number;
description: string;
requestedBy: string;
requestedAt: number;
};
type ExpenseState = {
pendingApprovals: PendingApproval[];
};
export class ExpenseAgent extends Agent<Env, ExpenseState> {
initialState: ExpenseState = {
pendingApprovals: [],
};
// Approve a waiting workflow
@callable()
async approve(workflowId: string, approvedBy: string): Promise<void> {
await this.approveWorkflow(workflowId, {
reason: "Expense approved",
metadata: { approvedBy, approvedAt: Date.now() },
});
// Update state to reflect approval
this.setState({
...this.state,
pendingApprovals: this.state.pendingApprovals.filter(
(p) => p.workflowId !== workflowId,
),
});
}
// Reject a waiting workflow
@callable()
async reject(workflowId: string, reason: string): Promise<void> {
await this.rejectWorkflow(workflowId, { reason });
this.setState({
...this.state,
pendingApprovals: this.state.pendingApprovals.filter(
(p) => p.workflowId !== workflowId,
),
});
}
// Track workflow progress to update pending approvals
async onWorkflowProgress(
workflowName: string,
workflowId: string,
progress: unknown,
): Promise<void> {
const p = progress as { step: string; status: string; message?: string };
if (p.step === "approval" && p.status === "pending") {
// Add to pending approvals list for UI display
this.setState({
...this.state,
pendingApprovals: [
...this.state.pendingApprovals,
{
workflowId,
amount: 0, // Would come from workflow params
description: p.message || "",
requestedBy: "user",
requestedAt: Date.now(),
},
],
});
}
}
}Workflow が無限に待ち続けないよう、タイムアウトを設定します。
const approval = await this.waitForApproval(step, {
timeout: "7 days", // Also supports: "1 hour", "30 minutes", etc.
});
if (!approval) {
// Timeout expired - escalate or auto-reject
await step.reportError("Approval timeout - escalating to manager");
throw new Error("Approval timeout");
}const approval = await this.waitForApproval<{ approvedBy: string }>(step, {
timeout: "7 days", // Also supports: "1 hour", "30 minutes", etc.
});
if (!approval) {
// Timeout expired - escalate or auto-reject
await step.reportError("Approval timeout - escalating to manager");
throw new Error("Approval timeout");
}エスカレーションのリマインダーは schedule() で設定します。
import { Agent, callable } from "agents";
class ExpenseAgent extends Agent {
@callable()
async submitForApproval(expense) {
// Start the approval workflow
const workflowId = await this.runWorkflow("EXPENSE_WORKFLOW", expense);
// Schedule reminder after 4 hours
await this.schedule(Date.now() + 4 * 60 * 60 * 1000, "sendReminder", {
workflowId,
});
// Schedule escalation after 24 hours
await this.schedule(Date.now() + 24 * 60 * 60 * 1000, "escalateApproval", {
workflowId,
});
return workflowId;
}
async sendReminder(payload) {
const workflow = this.getWorkflow(payload.workflowId);
if (workflow?.status === "waiting") {
// Send reminder notification
console.log("Reminder: approval still pending");
}
}
async escalateApproval(payload) {
const workflow = this.getWorkflow(payload.workflowId);
if (workflow?.status === "waiting") {
// Escalate to manager
console.log("Escalating to manager");
}
}
}import { Agent, callable } from "agents";
class ExpenseAgent extends Agent<Env, ExpenseState> {
@callable()
async submitForApproval(expense: ExpenseParams): Promise<string> {
// Start the approval workflow
const workflowId = await this.runWorkflow("EXPENSE_WORKFLOW", expense);
// Schedule reminder after 4 hours
await this.schedule(Date.now() + 4 * 60 * 60 * 1000, "sendReminder", {
workflowId,
});
// Schedule escalation after 24 hours
await this.schedule(Date.now() + 24 * 60 * 60 * 1000, "escalateApproval", {
workflowId,
});
return workflowId;
}
async sendReminder(payload: { workflowId: string }) {
const workflow = this.getWorkflow(payload.workflowId);
if (workflow?.status === "waiting") {
// Send reminder notification
console.log("Reminder: approval still pending");
}
}
async escalateApproval(payload: { workflowId: string }) {
const workflow = this.getWorkflow(payload.workflowId);
if (workflow?.status === "waiting") {
// Escalate to manager
console.log("Escalating to manager");
}
}
}変更できない監査証跡は this.sql で残します。
import { Agent, callable } from "agents";
class ExpenseAgent extends Agent {
async onStart() {
// Create audit table
this.sql`
CREATE TABLE IF NOT EXISTS approval_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id TEXT NOT NULL,
decision TEXT NOT NULL CHECK(decision IN ('approved', 'rejected')),
decided_by TEXT NOT NULL,
decided_at INTEGER NOT NULL,
reason TEXT
)
`;
}
@callable()
async approve(workflowId, userId, reason) {
// Record the decision in SQL (immutable audit log)
this.sql`
INSERT INTO approval_audit (workflow_id, decision, decided_by, decided_at, reason)
VALUES (${workflowId}, 'approved', ${userId}, ${Date.now()}, ${reason || null})
`;
// Process the approval
await this.approveWorkflow(workflowId, {
reason: reason || "Approved",
metadata: { approvedBy: userId },
});
}
}import { Agent, callable } from "agents";
class ExpenseAgent extends Agent<Env, ExpenseState> {
async onStart() {
// Create audit table
this.sql`
CREATE TABLE IF NOT EXISTS approval_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workflow_id TEXT NOT NULL,
decision TEXT NOT NULL CHECK(decision IN ('approved', 'rejected')),
decided_by TEXT NOT NULL,
decided_at INTEGER NOT NULL,
reason TEXT
)
`;
}
@callable()
async approve(
workflowId: string,
userId: string,
reason?: string,
): Promise<void> {
// Record the decision in SQL (immutable audit log)
this.sql`
INSERT INTO approval_audit (workflow_id, decision, decided_by, decided_at, reason)
VALUES (${workflowId}, 'approved', ${userId}, ${Date.now()}, ${reason || null})
`;
// Process the approval
await this.approveWorkflow(workflowId, {
reason: reason || "Approved",
metadata: { approvedBy: userId },
});
}
}{
"name": "expense-approval",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"compatibility_flags": ["nodejs_compat"],
"durable_objects": {
"bindings": [{ "name": "EXPENSE_AGENT", "class_name": "ExpenseAgent" }],
},
"workflows": [
{
"name": "expense-workflow",
"binding": "EXPENSE_WORKFLOW",
"class_name": "ExpenseWorkflow",
},
],
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["ExpenseAgent"] }],
}name = "expense-approval"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
compatibility_flags = [ "nodejs_compat" ]
[[durable_objects.bindings]]
name = "EXPENSE_AGENT"
class_name = "ExpenseAgent"
[[workflows]]
name = "expense-workflow"
binding = "EXPENSE_WORKFLOW"
class_name = "ExpenseWorkflow"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "ExpenseAgent" ]MCP elicitation では、ツール呼び出しに追加情報や帯域外の操作が必要だと、MCP サーバー開発者が判断できます。エージェントは MCP クライアントとして動き、リクエストをユーザーに見せて応答を返します。こうしたやり取りは、通常は数分で終わります。
Form モードは、機密ではない構造化入力を集めます。URL モードは、第三者認可や支払いなど、帯域外のフローをユーザーに開かせます。
ユーザー向けハンドラーは onStart() で設定します。
import { Agent } from "agents";
export class MyAgent extends Agent {
onStart() {
this.mcp.configureElicitationHandlers({
form: (request, serverId) =>
this.forwardElicitationToUser(request, serverId),
url: (request, serverId) =>
this.forwardElicitationToUser(request, serverId),
});
}
forwardElicitationToUser(request, serverId) {
// Present the request in your UI and resolve after the user responds.
throw new Error(
`Implement elicitation for ${serverId}: ${request.params.message}`,
);
}
}import { Agent } from "agents";
import type { ElicitRequest, ElicitResult } from "agents/mcp";
export class MyAgent extends Agent<Env> {
onStart() {
this.mcp.configureElicitationHandlers({
form: (request, serverId) =>
this.forwardElicitationToUser(request, serverId),
url: (request, serverId) =>
this.forwardElicitationToUser(request, serverId),
});
}
private forwardElicitationToUser(
request: ElicitRequest,
serverId: string,
): Promise<ElicitResult> {
// Present the request in your UI and resolve after the user responds.
throw new Error(
`Implement elicitation for ${serverId}: ${request.params.message}`,
);
}
}Form、URL、ブラウザ転送のパターン全体は MCP クライアント elicitation を参照してください。サーバー側のリクエスト API は McpAgent elicitation を参照してください。
コーディングエージェントや、Code Mode パターンを使う他のエージェントでは、耐久的な Code Mode ランタイム を使います。コネクタメソッドに requiresApproval: true を付けると、モデル生成コードが下層のツールを呼ぶ前に一時停止します。
次の例は、GitHub MCP ツールを承認必須にし、コネクタを耐久ランタイムへ追加し、保留中の操作を UI から確認・承認・却下できるメソッドを公開します。
import { AIChatAgent } from "@cloudflare/ai-chat";
import {
createCodemodeRuntime,
DynamicWorkerExecutor,
McpConnector,
} from "@cloudflare/codemode";
import { callable } from "agents";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { model } from "./model";
class GitHubConnector extends McpConnector {
connection;
constructor(ctx, env, connection) {
super(ctx, env);
this.connection = connection;
}
name() {
return "github";
}
createConnection() {
return this.connection;
}
tool(name, tool) {
if (name === "create_issue") {
return { ...tool, requiresApproval: true };
}
return tool;
}
}
export class CodingAgent extends AIChatAgent {
runtime() {
const server = this.mcp
.listServers()
.find((item) => item.name === "GitHub");
if (!server) throw new Error("GitHub MCP server is not registered.");
const connection = this.mcp.mcpConnections[server.id];
if (!connection) throw new Error("GitHub MCP connection is unavailable.");
return createCodemodeRuntime({
ctx: this.ctx,
executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
connectors: [new GitHubConnector(this.ctx, this.env, connection)],
});
}
async onChatMessage() {
const result = streamText({
model,
messages: await convertToModelMessages(this.messages),
tools: { codemode: this.runtime().tool() },
stopWhen: stepCountIs(10),
});
return result.toUIMessageStreamResponse();
}
@callable()
async pendingApprovals() {
return this.runtime().pending();
}
@callable()
async approveExecution(executionId) {
return this.runtime().approve({ executionId });
}
@callable()
async rejectExecution(executionId, seq) {
return this.runtime().reject({ executionId, seq });
}
}import { AIChatAgent } from "@cloudflare/ai-chat";
import {
createCodemodeRuntime,
DynamicWorkerExecutor,
McpConnector,
type CodemodeRuntimeHandle,
type ConnectorTool,
type McpConnectionLike,
type PendingAction,
} from "@cloudflare/codemode";
import { callable } from "agents";
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import { model } from "./model";
class GitHubConnector extends McpConnector<Env> {
private connection: McpConnectionLike;
constructor(
ctx: DurableObjectState,
env: Env,
connection: McpConnectionLike,
) {
super(ctx, env);
this.connection = connection;
}
override name() {
return "github";
}
protected override createConnection() {
return this.connection;
}
protected override tool(name: string, tool: ConnectorTool): ConnectorTool {
if (name === "create_issue") {
return { ...tool, requiresApproval: true };
}
return tool;
}
}
export class CodingAgent extends AIChatAgent<Env> {
private runtime(): CodemodeRuntimeHandle {
const server = this.mcp
.listServers()
.find((item) => item.name === "GitHub");
if (!server) throw new Error("GitHub MCP server is not registered.");
const connection = this.mcp.mcpConnections[server.id];
if (!connection) throw new Error("GitHub MCP connection is unavailable.");
return createCodemodeRuntime({
ctx: this.ctx,
executor: new DynamicWorkerExecutor({ loader: this.env.LOADER }),
connectors: [new GitHubConnector(this.ctx, this.env, connection)],
});
}
async onChatMessage() {
const result = streamText({
model,
messages: await convertToModelMessages(this.messages),
tools: { codemode: this.runtime().tool() },
stopWhen: stepCountIs(10),
});
return result.toUIMessageStreamResponse();
}
@callable()
async pendingApprovals(): Promise<PendingAction[]> {
return this.runtime().pending();
}
@callable()
async approveExecution(executionId: string) {
return this.runtime().approve({ executionId });
}
@callable()
async rejectExecution(executionId: string, seq: number): Promise<boolean> {
return this.runtime().reject({ executionId, seq });
}
}生成コードが github.create_issue() を呼ぶと、ランタイムは保留中のメソッドと引数を記録し、MCP ツール実行の前に一時停止します。承認後は、同じソースコードと execution ID でもう一度パスを始めます。完了済みの呼び出しは耐久ログから再生され、承認した呼び出しが実行され、生成コードが続きます。
保留中の承認と実行履歴は、リクエスト完了と Durable Object のハイバネーションを越えて残ります。実行と再生のモデルは abort と replay による承認 を参照してください。
エージェントの状態を使い、UI に保留中の承認を表示します。
import { useAgent } from "agents/react";
function PendingApprovals() {
const { state, agent } = useAgent({
agent: "expense-agent",
name: "main",
});
if (!state?.pendingApprovals?.length) {
return <p>No pending approvals</p>;
}
return (
<div className="approval-list">
{state.pendingApprovals.map((item) => (
<div key={item.workflowId} className="approval-card">
<h3>${item.amount}</h3>
<p>{item.description}</p>
<p>Requested by {item.requestedBy}</p>
<div className="actions">
<button
onClick={() => agent.stub.approve(item.workflowId, "admin")}
>
Approve
</button>
<button
onClick={() => agent.stub.reject(item.workflowId, "Declined")}
>
Reject
</button>
</div>
</div>
))}
</div>
);
}複数の承認者が必要な機密操作向けです。
import { Agent, callable } from "agents";
class MultiApprovalAgent extends Agent {
@callable()
async approveMulti(workflowId, userId) {
const approval = this.state.pendingMultiApprovals.find(
(p) => p.workflowId === workflowId,
);
if (!approval) throw new Error("Approval not found");
// Check if user already approved
if (approval.currentApprovals.some((a) => a.userId === userId)) {
throw new Error("Already approved by this user");
}
// Add this user's approval
approval.currentApprovals.push({ userId, approvedAt: Date.now() });
// Check if we have enough approvals
if (approval.currentApprovals.length >= approval.requiredApprovals) {
// Execute the approved action
await this.approveWorkflow(workflowId, {
metadata: { approvers: approval.currentApprovals },
});
return true;
}
this.setState({ ...this.state });
return false; // Still waiting for more approvals
}
}import { Agent, callable } from "agents";
type MultiApproval = {
workflowId: string;
requiredApprovals: number;
currentApprovals: Array<{ userId: string; approvedAt: number }>;
rejections: Array<{ userId: string; rejectedAt: number; reason: string }>;
};
type State = {
pendingMultiApprovals: MultiApproval[];
};
class MultiApprovalAgent extends Agent<Env, State> {
@callable()
async approveMulti(workflowId: string, userId: string): Promise<boolean> {
const approval = this.state.pendingMultiApprovals.find(
(p) => p.workflowId === workflowId,
);
if (!approval) throw new Error("Approval not found");
// Check if user already approved
if (approval.currentApprovals.some((a) => a.userId === userId)) {
throw new Error("Already approved by this user");
}
// Add this user's approval
approval.currentApprovals.push({ userId, approvedAt: Date.now() });
// Check if we have enough approvals
if (approval.currentApprovals.length >= approval.requiredApprovals) {
// Execute the approved action
await this.approveWorkflow(workflowId, {
metadata: { approvers: approval.currentApprovals },
});
return true;
}
this.setState({ ...this.state });
return false; // Still waiting for more approvals
}
}- 承認基準を明確にする — 意味のある結果がある操作(支払い、メール、データ変更)だけ確認を求めます
- 詳しいコンテキストを出す — 引数を含め、操作が何をするかをそのまま見せます
- タイムアウトを入れる — 妥当な時間のあと、
schedule()でエスカレーションまたは自動却下します - 監査証跡を残す — コンプライアンスのため、承認決定はすべて
this.sqlに記録します - 切断を扱う — 保留中の承認はエージェント状態に置き、切断後も残るようにします
- 段階的な縮退 — 承認が却下されたときのフォールバックを用意します