Human-in-the-loop agents need a plumbing layer
AI agents are typically described as autonomous systems that make decisions and act toward a goal without human input. But in practice, most agent workflows require human judgment at some point. Approving a purchase, confirming a destructive action, or reviewing a generated artifact all need a person in the loop. Building that pattern from scratch means wiring up async notifications, state tracking, and callback routing yourself.
Why the agent platform matters here
The Cloudflare Agents SDK gives you a foundation for stateful, real-time agents built on Durable Objects. Each agent gets a globally addressable compute instance with built-in persistence via an embedded SQLite database. That means you don't have to stand up a WebSocket server or manage horizontal scaling just to keep an agent alive across a conversation. For human-in-the-loop flows, this is particularly useful: you can pause an agent's execution, wait for an external approval event, and then resume from exactly where you left off.
The example that follows ties this together with Knock, a messaging infrastructure platform. Knock's Agent Toolkit exposes APIs that let an agent trigger cross-channel notification workflows and defer tool execution until a human responds. The full example is available in this repository.
Scenario: an agent that issues virtual cards
We'll model a virtual card issuance workflow where a user requests a card through a chat interface, an admin must approve the request, and then the card is issued asynchronously. The agent is built on the AIChatAgent abstraction from the Agents SDK, leaving us to write the LLM calling code and the system prompt. On the client side, the useAgentChat hook from the agents/ai-react package powers the real-time chat. Each user gets their own agent process and Durable Object, keyed by userId.
Designing the approval workflow in Knock
Knock's visual workflow builder is where you define the messaging logic: who gets notified, through which channels, and with what template. The workflow applies each user's notification preferences automatically. In this demo, a pre-built approve-issued-card workflow template is imported into your account via the Knock CLI.
Inside the agent's tool configuration, the card issuance capability is exposed as an issueCard tool. Rather than implementing the approval flow inline, that tool is wrapped in the requireHumanInput method from Knock's Agent Toolkit. This defers execution of the actual card issuance until an approval is received.

Walking through what happens here:
- The
issueCardtool is wrapped withrequireHumanInputfrom the Knock Agent Toolkit - The
approve-issued-cardworkflow is invoked - The
agent.nameis passed as theactor, mapping to the user ID - The workflow recipient is set to
admin_user_1 - Approve and reject URLs are passed through so message templates can include them
- The wrapped tool is returned and passed into the LLM
onChatMessagehandler
Under the hood, these options map to Knock's workflow trigger API, which calls a workflow per recipient. Recipients can be dynamic or pulled from Knock's subscriptions API for a group of users.
By leaning on Knock workflows, the approval request gets delivered across whatever channels the recipient prefers. You can also layer in delays, throttles, batching, and conditions within the workflow if your use case needs more orchestration.
Handling the asynchronous approval response
The approval request itself is not synchronous. A user may click approve or reject at any point after the message is delivered. Knock routes that event back to the agent worker through a message.interacted webhook, which tracks interactions with the underlying message—in this case whether the approve or reject button was clicked.
The webhook handler is set up in the Knock dashboard to forward interactions to the worker. The message templates include a link with a Knock message ID appended so that engagement can be tracked against the specific message: {{ data.approve_url }}?messageId={{ current_message.id }}. The repository example co-locates the approval click handler inside the agent worker for demo simplicity; a production setup would likely run that in a separate application.
import Knock from '@knocklabs/node';
import { Hono } from "hono";
const app = new Hono();
const client = new Knock();
app.get("/card-issued/approve", async (c) => {
const { messageId } = c.req.query();
if (!messageId) return c.text("No message ID found", { status: 400 });
await client.messages.markAsInteracted(messageId, {
status: "approved",
});
return c.text("Approved");
});
When the link is clicked, the worker marks the message as interacted using Knock's message interaction API, passing the approval status through as metadata. The webhook payload routes back to the agent using the userId identifier. Because a Durable Object backs the agent, going from an incoming worker request to finding and resuming the right agent process is trivial.
Resuming the deferred tool execution
With the webhook payload in hand, the agent can now resume the originally deferred tool call. The payload includes the full context of the original request, including the tool call itself.
export class AIAgent extends AIChatAgent {
// ... other methods
async handleIncomingWebhook(body: any) {
const { toolkit } = await initializeToolkit(this);
const deferredToolCall = toolkit.handleMessageInteraction(body);
if (!deferredToolCall) {
return { error: "No deferred tool call given" };
}
// If we received an "approved" status then we know the call was approved
// so we can resume the deferred tool call execution
if (result.interaction.status === "approved") {
const toolCallResult =
await toolkit.resumeToolExecution(result.toolCall);
const { response } = await generateText({
model: openai("gpt-4o-mini"),
prompt: `You were asked to issue a card for a customer. The card is now approved. The result was: ${JSON.stringify(toolCallResult)}.`,
});
const message = responseToAssistantMessage(
response.messages[0],
result.toolCall,
toolCallResult
);
// Save the message so that it's displayed to the user
this.persistMessages([...this.messages, message]);
}
return { status: "success" };
}
}
Here's what that resume logic does:
- The webhook body is transformed into a deferred tool call via
handleMessageInteraction - If the status metadata from the interaction is "approved,"
resumeToolExecutionprocesses the call - An LLM-generated message is persisted so the requesting user is informed their card was issued
Guarding against duplicate approvals
One problem the straightforward approach has: clicking the approve button twice means two card issuance events. To prevent that, the agent tracks tool calls in its built-in state, which is persisted on the Durable Object without needing a separate database or Redis store. The state keeps a map of tool call IDs to their current status, with a helper setter method for cleaner updates. The onAfterCallKnock option in requireHumanInput records when a tool call is initially requested, and the webhook handler updates the tool call's status as approved to make the entire flow idempotent.
type ToolCallStatus = "requested" | "approved" | "rejected";
export interface AgentState {
toolCalls: Record<string, ToolCallStatus>;
}
class AIAgent extends AIChatAgent<Env, AgentState> {
initialState: AgentState = {
toolCalls: {},
};
setToolCallStatus(toolCallId: string, status: ToolCallStatus) {
this.setState({
...this.state,
toolCalls: { ...this.state.toolCalls, [toolCallId]: status },
});
}
// ...
}
With these pieces in place, the full loop works: a user requests a card through chat, the agent triggers Knock to dispatch approval notifications to the appropriate admin, the admin approves via message interaction, the webhook fires back to the worker, and the agent resumes the deferred tool call to issue the card. Most of the heavy lifting is handled by the platform and the toolkit, and the code that remains is focused on business logic and message generation.
Why defer tool calls to a human?
Autonomous agents are powerful, but some actions shouldn't be taken without explicit user approval — think issuing a refund, canceling a subscription, or updating critical account details. Rather than building an approval mechanism from scratch, you can combine Cloudflare’s Agents SDK with Knock’s notification infrastructure to create a smooth escalation path where a human reviews and authorizes sensitive tool calls before they execute.
Designing the agent loop
The pattern relies on a simple state machine inside the agent. When the agent needs to perform a high-risk action, it doesn't call the tool directly. Instead, it transitions to a waiting_for_approval state and yields control back to the caller. The agent's context — including the tool name and arguments — is persisted, allowing the process to resume seamlessly once a human responds.
Cloudflare’s Agents SDK uses Durable Objects under the hood, which makes this yield-and-resume pattern straightforward. Since each user gets a dedicated Durable Object, the agent process can pause mid-task and be picked up later without losing state.
Structuring the approval flow
When the agent hits an approval checkpoint, it sends an event to Knock with a payload containing the pending tool call details. Knock routes this to the appropriate channels — email, Slack, SMS, or push — based on the user's preferences. The message includes a link to an approval page where the human can review the request and choose to approve or reject it.
The approval action itself is handled asynchronously. Once the human responds:
- The approval webhook fires and calls into the agent's API endpoint.
- The agent validates that the request is still in the pending state.
- The tool executes (or is discarded) and the agent resumes its original task.
Because the agent's full history and pending tool call are persisted in the Durable Object's storage, resuming with the correct context is just a matter of reading back that state and continuing the conversation loop.
Escalation considerations
An approval request that disappears into a void defeats the purpose of human-in-the-loop design. Knock’s workflow engine handles this with escalation rules built directly into the notification template:
- Immediate delivery informs the user and their team when the approval is requested.
Wait for acknowledgementsteps pause the workflow until the user sees it.- Digest steps batch multiple approval requests to reduce notification fatigue.
- Time-based reminders re-notify after a configurable delay if no response comes in.
- Slack or email fallbacks kick in for channels where the user may not be actively watching.
Knock tracks delivery status and read receipts, so you can inspect exactly which notification path succeeded and when it was acted upon. This visibility is useful for debugging workflows and auditing agent behavior.
Production pattern
The human-in-the-loop agent doesn’t require exotic infrastructure. The standard serverless Cloudflare Workers stack handles scaling. If you're building your own chat experience with this kind of approval flow, the full working example is available in the card-issue repo on GitHub.



