Introduction
A validated tool can still be too eager. If a model proposes a record change, a person may need to inspect the exact action before any write occurs. A human-in-the-loop approval pauses that tool call, shows its arguments in the client and lets a person approve or deny it.
In this lab, you will extend a small synthetic support Agent with one read tool and one approval-gated update:
lookupSupportCaseremains a read-only server tool and runs without approval.requestPriorityChangeuses the supportedneedsApprovaloption, so itsexecutefunction cannot run until the client sends an approval response.- The React client renders the pending action and calls
addToolApprovalResponse()for Approve or Deny. - A durable idempotency ledger records the operation key, so repeated approved delivery returns the first result instead of applying a second update.
- Deterministic probes and one bounded Workers AI flow prove pending, denied, approved and duplicate outcomes.
Approval and authorization answer different questions. Authorization limits which queue the signed session may reach; approval asks whether a person accepts this exact proposed change. Idempotency handles a third problem: a network or client may deliver the same approved action more than once. All records in this lab are synthetic and disposable; no real help-desk system is connected.
The supplied shell and short-lived session token keep the focus on the approval boundary rather than frontend or authentication boilerplate. Workers AI free allocations are shared with other account activity. If the account has no allocation remaining, stop rather than enabling a paid plan.
Before entering this course directly, complete Connect LabEx to Your Cloudflare Account. Every fresh LabEx VM needs its own Wrangler authorization. Earlier course labs are recommended, but their VMs and resources are never reused here.
Authorize the VM and Declare the Approval Worker
In this step, you will authorize the fresh VM and declare the resources used by the approval-gated Agent.
Open a terminal and enter the prepared project:
cd /home/labex/project/approval-record-changes
Authorize this VM:
npx wrangler login
Open the displayed link, approve the documented Wrangler permissions for your dedicated learning account, then return to the terminal. Confirm the structured result:
npx wrangler whoami --json
Look for "loggedIn": true, confirm the account name, and copy that account's actual ID. Save it with a unique disposable Worker name:
ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s06-$(openssl rand -hex 6)"
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 },
"ai": { "binding": "AI", "remote": true },
"durable_objects": {
"bindings": [
{ "name": "ApprovalAgent", "class_name": "ApprovalAgent" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ApprovalAgent"] }
]
}
JSON
python3 .labex/verify.py authorization
The AI binding provides model inference without embedding an API key. The Durable Object binding gives every named ApprovalAgent its own SQLite storage for the synthetic case and its idempotency ledger. The browser will use the name planning; a separate name receives a separate instance and cannot see planning data. Nothing has been deployed yet.
Define the Tool Contracts
In this step, you will describe exactly which arguments each tool accepts.
A tool schema is a runtime contract. TypeScript types help while compiling, but model output arrives at runtime and must be checked again. Create src/cases.ts:
cat > src/cases.ts <<'TS'
import { z } from "zod";
const queue = z.string()
.min(3)
.max(40)
.regex(/^[a-z0-9-]+$/, "queue must use lowercase letters, digits or hyphens");
export const lookupCaseInput = z.object({
queue,
ticketId: z.literal("T-SYNTH-101")
}).strict();
export const changePriorityInput = lookupCaseInput.extend({
priority: z.literal("high"),
operationKey: z.string()
.min(8)
.max(80)
.regex(/^[a-z0-9-]+$/, "operation key must use lowercase letters, digits or hyphens")
}).strict();
export type LookupCaseInput = z.infer<typeof lookupCaseInput>;
export type ChangePriorityInput = z.infer<typeof changePriorityInput>;
export type SupportCase = {
queue: string;
ticketId: "T-SYNTH-101";
summary: string;
priority: "low" | "medium" | "high";
revision: number;
};
export type ChangeResult = SupportCase & { duplicate: boolean; operationKey: string };
export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
const result = schema.safeParse(input);
if (!result.success) {
const issue = result.error.issues[0];
throw new Error(`invalid tool input: ${issue.path.join(".") || "request"} ${issue.message}`);
}
return result.data;
}
TS
python3 .labex/verify.py schemas
The read contract accepts only a valid queue name and the one synthetic ticket. The change contract narrows the exercise to priority high and requires a stable operationKey. .strict() also rejects unexpected fields, reducing ambiguity and preventing a caller from smuggling unsupported instructions into the operation.
An idempotency key identifies one logical operation across retries. The server will store the first successful result under that key; receiving the same approved operation again returns the stored result instead of writing twice. Validation does not grant approval or access—the Agent still checks its durable name, and the SDK still waits for the human decision.
Implement the Approval Gate and Idempotent Effect
In this step, you will keep reads automatic, pause the write with needsApproval and make the approved effect idempotent.
Create src/server.ts:
cat > src/server.ts <<'TS'
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import { callable, routeAgentRequest } from "agents";
import { convertToModelMessages, stepCountIs, streamText, tool } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import {
changePriorityInput,
lookupCaseInput,
parseInput,
type ChangePriorityInput,
type ChangeResult,
type LookupCaseInput,
type SupportCase
} from "./cases";
import { verifySessionRequest } from "./session-auth";
export class ApprovalAgent extends AIChatAgent<Cloudflare.Env> {
maxPersistedMessages = 12;
private ensureTables(): void {
this.sql`CREATE TABLE IF NOT EXISTS support_cases (
ticket_id TEXT PRIMARY KEY,
queue TEXT NOT NULL,
case_summary TEXT NOT NULL,
priority TEXT NOT NULL,
revision INTEGER NOT NULL
)`;
this.sql`INSERT OR IGNORE INTO support_cases
(ticket_id, queue, case_summary, priority, revision)
VALUES ('T-SYNTH-101', ${this.name}, 'Synthetic customer cannot open a sample invoice', 'medium', 0)`;
this.sql`CREATE TABLE IF NOT EXISTS approval_operations (
operation_key TEXT PRIMARY KEY,
ticket_id TEXT NOT NULL,
applied_revision INTEGER NOT NULL
)`;
}
private scopedCase(input: LookupCaseInput): SupportCase {
if (input.queue !== this.name) throw new Error("queue is outside this Agent scope");
this.ensureTables();
const rows = this.sql<{
queue: string;
ticketId: "T-SYNTH-101";
summary: string;
priority: "low" | "medium" | "high";
revision: number;
}>`SELECT queue, ticket_id AS ticketId, case_summary AS summary, priority, revision
FROM support_cases WHERE ticket_id = ${input.ticketId}`;
const record = rows[0];
if (!record || record.queue !== this.name) throw new Error("case not found in this Agent scope");
return record;
}
@callable()
inspectCase(input: unknown): SupportCase {
return this.scopedCase(parseInput(lookupCaseInput, input));
}
private applyApprovedChange(input: unknown): ChangeResult {
const parsed: ChangePriorityInput = parseInput(changePriorityInput, input);
const current = this.scopedCase(parsed);
const prior = this.sql<{ appliedRevision: number }>`SELECT applied_revision AS appliedRevision
FROM approval_operations WHERE operation_key = ${parsed.operationKey}`[0];
if (prior) {
return { ...current, duplicate: true, operationKey: parsed.operationKey };
}
this.sql`UPDATE support_cases
SET priority = ${parsed.priority}, revision = ${current.revision + 1}
WHERE ticket_id = ${parsed.ticketId} AND queue = ${this.name}`;
const changed = this.scopedCase(parsed);
this.sql`INSERT INTO approval_operations (operation_key, ticket_id, applied_revision)
VALUES (${parsed.operationKey}, ${parsed.ticketId}, ${changed.revision})`;
console.log(JSON.stringify({
event: "approval_change_applied",
instance: this.name,
operationKey: parsed.operationKey,
revision: changed.revision
}));
return { ...changed, duplicate: false, operationKey: parsed.operationKey };
}
@callable()
async verifyApprovedChange(input: unknown, proof: string): Promise<ChangeResult> {
const parsed = parseInput(changePriorityInput, input);
const payload = new TextEncoder().encode(`approval-probe:${JSON.stringify(parsed)}`);
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(this.env.SESSION_SIGNING_KEY),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const normalized = proof.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const signature = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
const valid = await crypto.subtle.verify("HMAC", key, signature, payload);
if (!valid) throw new Error("approval probe proof is invalid");
return this.applyApprovedChange(parsed);
}
async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) {
const tools = {
lookupSupportCase: tool({
description: "Read synthetic ticket T-SYNTH-101 only from the current named support queue.",
inputSchema: lookupCaseInput,
execute: async (input) => this.inspectCase(input)
}),
requestPriorityChange: tool({
description: "Set synthetic ticket T-SYNTH-101 to high priority in the current queue. Use operation key raise-synthetic-priority.",
inputSchema: changePriorityInput,
needsApproval: true,
execute: async (input) => this.applyApprovedChange(input)
})
};
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/zai-org/glm-4.7-flash", {
reasoning_effort: null,
chat_template_kwargs: { enable_thinking: false }
}),
system: `You assist only the synthetic ${this.name} queue. Perform exactly the one action the user requests. For a lookup, call lookupSupportCase only. For a priority request, call requestPriorityChange only with operationKey raise-synthetic-priority and wait for the human decision. Never claim a denied or pending change happened. Keep the final answer to one short sentence.`,
messages: await convertToModelMessages(this.messages),
tools,
stopWhen: stepCountIs(4),
maxOutputTokens: 96,
temperature: 0,
abortSignal: options?.abortSignal
});
return result.toUIMessageStreamResponse();
}
}
export default {
async fetch(request: Request, env: Cloudflare.Env): Promise<Response> {
const authorize = (candidate: Request, route: { name: string }) =>
verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
return (await routeAgentRequest(request, env, {
onBeforeConnect: authorize,
onBeforeRequest: authorize
})) ?? new Response("Not found", { status: 404 });
}
};
TS
python3 .labex/verify.py server
The model never receives direct database access. It proposes typed arguments, but needsApproval: true prevents execute from running until the client submits a positive approval response. Denial therefore leaves the method untouched. The read-only callable supports deterministic inspection. A separate verification callable can reach the effect only with an HMAC proof derived from the local signing secret, so an ordinary browser client cannot bypass the human gate.
The Agent handles each synchronous callable invocation without an await, so a second delivery sees the ledger row created by the first. The stable operation key returns the already-applied result with duplicate: true; it does not increment the record again. Only the event, Agent instance, operation key and revision are logged—the case text is not.
Render the Human Approval Decision
In this step, you will render a pending tool call as an explicit decision instead of silently executing it.
Create the TypeScript and Vite configuration:
cat > tsconfig.json <<'JSON'
{
"extends": "agents/tsconfig",
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["@cloudflare/workers-types", "vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON
cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import react from "@vitejs/plugin-react";
import agents from "agents/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [react(), agents(), cloudflare()] });
TS
Create src/client.tsx:
cat > src/client.tsx <<'TSX'
import { getToolApproval, useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { getToolName, isToolUIPart } from "ai";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";
function ApprovalChat() {
const parameters = new URLSearchParams(window.location.search);
const session = parameters.get("session") ?? "";
const token = parameters.get("token") ?? "";
if (!session || !token) {
return <main><h1>Signed session required</h1><p className="help">Open the complete URL printed by the token command.</p></main>;
}
const agent = useAgent({
agent: "ApprovalAgent",
name: session,
host: window.location.host,
query: { token }
});
const { messages, sendMessage, addToolApprovalResponse, status, error } = useAgentChat({
agent,
autoContinueAfterToolResult: false
});
return (
<main>
<p className="eyebrow">Human approval before effect</p>
<h1>Synthetic Change Review</h1>
<p className="scope">Allowed queue: <strong>{session}</strong> · allowed ticket: <strong>T-SYNTH-101</strong></p>
<p className="status">Status: <strong>{status}</strong></p>
<section className="messages" aria-live="polite">
{messages.length === 0 && <p className="empty">No change request in this signed session yet.</p>}
{messages.map((message) => (
<article className={`message ${message.role}`} key={message.id}>
<span className="role">{message.role}</span>
{message.parts.map((part, index) => {
if (part.type === "text") return <span key={index}>{part.text}</span>;
if (isToolUIPart(part)) {
const toolName = getToolName(part);
if ("approval" in part && part.state === "approval-requested") {
const approvalId = getToolApproval(part)?.id;
return (
<div className="approval" key={part.toolCallId}>
<strong>Approval required: {toolName}</strong>
<p>Review these exact synthetic arguments. No record has changed yet.</p>
<pre>{JSON.stringify(part.input, null, 2)}</pre>
<div className="approval-actions">
<button disabled={!approvalId} () => {
if (!approvalId) return;
await addToolApprovalResponse({ id: approvalId, approved: true });
sendMessage();
}}>Approve</button>
<button className="deny" disabled={!approvalId} => approvalId && addToolApprovalResponse({ id: approvalId, approved: false })}>Deny</button>
</div>
</div>
);
}
return (
<div className="tool-card" key={part.toolCallId}>
<strong>{toolName}</strong><span className="tool">{part.state}</span>
{"output" in part && part.output !== undefined && <pre>{JSON.stringify(part.output, null, 2)}</pre>}
</div>
);
}
return null;
})}
</article>
))}
</section>
<div className="quick-actions">
<button type="button" disabled={status === "streaming" || status === "submitted"} => sendMessage({ text: `Look up T-SYNTH-101 in ${session}.` })}>Check current case</button>
<span>Read-only: safe before and after a decision.</span>
</div>
<form => {
event.preventDefault();
const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
const text = input.value.trim();
if (!text) return;
sendMessage({ text });
}}>
<input name="message" defaultValue={`Request high priority for T-SYNTH-101 in ${session} with operation key raise-synthetic-priority.`} maxLength={220} aria-label="Change request" />
<button type="submit" disabled={status === "streaming" || status === "submitted"}>Send</button>
</form>
<p className="notice">Training fixture only: this page cannot reach a real support system.</p>
{error && <p className="error" role="alert">{error.message}</p>}
</main>
);
}
createRoot(document.getElementById("root")!).render(
<Suspense fallback={<main><p>Restoring the signed approval session…</p></main>}><ApprovalChat /></Suspense>
);
TSX
python3 .labex/verify.py client
useAgent() connects to exactly one named Agent with its short-lived token. The read-only button and the change form deliberately create separate turns: each turn has one purpose, so the learner can observe state before and after a decision without mixing an already-finished read tool into the paused write. useAgentChat() exposes the approval response helper. The Agents client notifies the server about that decision; automatic client continuation is disabled here so the same approved tool call is not submitted a second time. The tool result and a fresh read provide stronger evidence than an extra model-generated summary sentence. isToolUIPart() distinguishes a tool action from ordinary assistant text, while getToolApproval() reads the approval object through the SDK's supported interface. The approval ID binds the person's decision to this exact tool call; the browser does not invoke the database method directly. The JSON cards make the lookup result, proposed arguments and eventual write result observable without exposing account credentials.
Build and Prove the Boundaries Locally
In this step, you will compile the application and exercise the actual tool implementation without spending a model call.
Generate exact environment types, type-check and build both bundles:
npx wrangler types
npm run check
npm run build
python3 .labex/verify.py build
Wrangler derives Cloudflare.Env from the real bindings. This prevents a hand-written environment interface from drifting away from wrangler.jsonc.
Workers AI is a remote binding, so the local runtime needs the OAuth access already stored by Wrangler. Pass it only to the child process and immediately clear the shell copy:
DEV_PROXY_TOKEN="$(npx wrangler auth token --json | node -e 'let data="";process.stdin.on("data",chunk=>data+=chunk).on("end",()=>process.stdout.write(JSON.parse(data).token))')"
CLOUDFLARE_API_TOKEN="$DEV_PROXY_TOKEN" CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
unset DEV_PROXY_TOKEN
for attempt in $(seq 1 40); do
curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
sleep 1
done
tail -n 12 .labex/dev.log
python3 .labex/verify.py local
Do not print the temporary OAuth value or save it in .dev.vars. The independent probe uses a random named Agent, the read-only inspectCase() method and a test-only HMAC proof derived from the local signing secret. That proof lets the verifier exercise the same private effect used by the approved model tool without publishing a callable that bypasses approval. It proves the effect's server-side safety independently of model behavior:
- the initial priority is
mediumat revision0; - a cross-queue read fails;
- one approved-effect call becomes
highat revision1; - the same operation key returns
duplicate: trueand remains at revision1; and - another named Agent retains its isolated revision
0record.
This deterministic test answers whether repeated delivery is safe. The browser flow separately proves that the supported SDK gate prevents the effect before approval and on denial.
Deploy and Exercise Denial, Approval and Replay
In this step, you will deploy, then observe the same proposed change remain pending, be denied, be approved once and stay safe when repeated.
Deploy the production bundle and upload the generated signing key as a secret:
npm run deploy
npx wrangler secret bulk .dev.vars
The secret command sends the value without placing it in the configuration or bundle. Do not print .dev.vars.
Save the exact origin printed by the deployment, then create a ten-minute token for planning:
WORKER_URL="https://paste-the-workers-dev-origin-printed-by-deploy"
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf '%s/?session=planning&token=%s\n' "${WORKER_URL%/}" "$TOKEN"
Open the complete URL in the LabEx browser. Select Check current case first: the read-only result reports priority medium at revision 0. Then send the prepared change request. This second, single-purpose turn stops at Approval required. Before a decision, its server-side execute function has not run:

Select Deny. The tool becomes denied and the Agent must not claim that the update occurred. Select Check current case again: its new read still reports priority medium at revision 0, proving the denied execution made no change. Send the prepared change request again to create a new approval card:

Select Approve on this second request. The supported client sends the approval ID back to the Agent, the server executes once and the result reports priority high, revision 1 and duplicate: false. Select Check current case to observe the same revision independently:

Send the same prepared change request a third time and approve it. The durable ledger recognizes raise-synthetic-priority; the result reports duplicate: true. Select Check current case once more and the record remains at revision 1:

Exact assistant sentences are model-generated and may differ. The approval-card state, tool result fields and record revision are the useful evidence. The queue, ticket and record are synthetic examples.
Run a fresh, independently named cloud probe. It does not consume another model call:
python3 .labex/verify.py deployed
The probe checks the exact deployed bindings and namespace, then repeats scope rejection, one successful change, identical-key replay safety and named-Agent isolation against an independently named remote Agent. It tests the idempotent effect without consuming another model call; the browser flow is what proves the SDK approval gate.
Inspect and Remove the Approval Resources
In this step, you will connect the runtime behavior to Cloudflare's resource views, then delete only this lab's resources.
In the Cloudflare Dashboard, open Workers & Pages, select your exact labex-c11-s06-... Worker and inspect Bindings. You should see the AI Workers AI binding and the ApprovalAgent Durable Object binding. Then open Settings > Variables and Secrets to confirm that SESSION_SIGNING_KEY is stored as an encrypted secret rather than plain text:

Open Durable Objects and select the SQL-backed namespace owned by this Worker. planning and verifier names are separate object instances inside this one class namespace:

Open the Worker's logs or observability view and find approval_change_applied. One accepted logical operation produces one structured entry. It contains the Agent instance, stable operation key and revision, but not the synthetic case summary or chat text:

After inspection, create an explicit class-deletion migration and remove the exact Worker:
python3 - <<'PY'
import json
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data.pop('durable_objects', None)
data['migrations'].append({'tag': 'v2', 'deleted_classes': ['ApprovalAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
Confirm that the disposable Worker is absent:

Then confirm that its ApprovalAgent namespace is absent:

Prove both absences while this VM is still authorized:
python3 .labex/verify.py deleted
Deleting only the Worker would leave the stateful class lifecycle ambiguous. Migration v2 explicitly removes this lab's namespace, synthetic record and idempotency ledger before the Worker deletion is verified.
Revoke This VM's Authorization
In this step, you will revoke the temporary VM authorization after cloud cleanup is proven.
npx wrangler logout
npx wrangler whoami --json || true
The structured result should report "loggedIn": false, or Wrangler may return a nonzero unauthenticated result. Logout is intentionally last: the deletion verifier needs valid read access, while the discarded VM does not.
Summary
You placed a supported human decision in front of a Cloudflare AIChatAgent record change. You:
- kept the read automatic while marking the write with
needsApproval; - rendered
approval-requestedparts and submitted explicit approve or deny responses; - proved that pending and denied operations leave the synthetic record unchanged;
- stored a durable idempotency key so an approved retry returns revision
1instead of writing again; - kept authorization separate by enforcing the named Agent scope on the server;
- observed a privacy-limited approval event; and
- deleted the exact SQLite class namespace and Worker before logging out.
Human approval is now explicit and auditable, while the idempotency ledger protects the effect from repeated delivery. The next lab changes direction: it publishes a synthetic read-only capability through MCP, where discovery and transport become the new concepts.



