Human approvals

Let an agent propose a write and a person decide. Durable actions that park across turns, resolving them from your own app with your own auth, and why guardrails still bind after a human says yes.

The Spikefrost Team30 Jul 20264 min read

An agent that can act is more useful and more dangerous than one that only answers. Approvals are how you get the first without the second: the agent decides what should happen, a person decides whether it happens.

Actions, not tools

Reads are tools. Writes that need a human are actions:

import { action } from "@cloudflare/think";

override getActions() {
  return {
    upsert_promotion: action({
      description: "Create or update a promotion (requires human approval)",
      inputSchema: z.object({ name: z.string(), discount: z.number() }),
      approval: true,
      kind: "durable-pause",
      approvalSummary: "Create/update a store promotion",
      execute: async (input) =>
        upsertPromotion(this.env, input, this.constructor.name),
    }),
  };
}

Two details are load-bearing:

kind: "durable-pause" is required. It parks the pending write across turns and across the conversation being evicted from memory, and — crucially — makes it resolvable from your app's code. The default kind is resumable only by the chat client that started the turn, which is fine for a toy and wrong for an approval queue that a manager reviews an hour later.

approvalSummary is what the human reads. Write it for the approver, not the model.

Resolving from your app

Approvals resolve through a route you own, so your auth decides who may approve:

app.post("/api/approvals/:id", requireRole("manager"), async (c) => {
  const { session, approve } = await c.req.json();
  const stub = await getAgentByName(c.env.PromoManager, session);
  await stub.resolveApprovalRequest(
    c.req.param("id"),        // executionId
    approve,
    c.user.email             // who decided, for the record
  );
  return c.json({ ok: true });
});

And to build the queue:

const pending = await stub.listPendingApprovals();

The platform deliberately doesn't ship an approver UI. The person approving a discount is your user with your roles — the platform has no basis to model that, and putting it in a platform screen would mean giving your staff platform accounts.

Surfacing requests

Declare approval.requested and approval.resolved in your app's event catalog, and the platform emits them for you — the request automatically at turn end, deduplicated on execution id. The event's links carry { agent, session, executionId }, which is everything a dashboard needs to render the item and post the decision back.

// app/events.ts
export default defineEvents({
  "approval.requested": {
    audience: ["business"], severity: "warn",
    title: "Approval needed: {summary}",
    fields: z.object({ summary: z.string() }),
  },
  "approval.resolved": {
    audience: ["business"],
    title: "Approval {decision} by {actor}",
    fields: z.object({ decision: z.string(), actor: z.string() }),
  },
});

Events land in your app's own database and render on your own dashboard — see Observability and costs.

A practical note on the chat side: don't try to surface an approval inside the chat stream. The turn has parked; the conversation is not the place to resolve it. Put pending items where the approver actually works.

Guardrails still bind

This is the part people get wrong, so it's worth being blunt:

A human approval is permission to attempt the write, not permission to break the rules.

Because the check lives in the domain function, an approved 60% promotion still hits a 20% ceiling and is still refused — and that refusal is itself recorded as an event.

export async function upsertPromotion(env: Env, input: PromoInput, actor: string) {
  if (input.discount > 20) {
    await emitAppEvent(env, "guardrail.refused", { rule: "discount_ceiling" }, { actor });
    throw new Error("Discount ceiling is 20%");
  }
  // ...
}

If your limits are in the prompt instead, approval becomes the moment they evaporate: a model that has been argued into proposing 60% and a human who clicks approve out of habit produce a write nothing stopped. That's the whole reason for the "guardrails in code" rule in Writing an agent.

What to gate

Gate a write when it moves money, touches a customer commitment, is hard to reverse, or is visible outside the company. Don't gate everything — an approval queue nobody reads is worse than no queue, because it manufactures the appearance of oversight.

A reasonable split:

Approval Examples
Gate it Refunds, discounts, publishing, outbound customer email, price changes, deletions
Let it run Reads, internal notes, drafts, status changes that are trivially reversible
Don't build it at all Anything the agent has no business proposing — just don't grant the tool

That last row matters. Approvals are for actions you want an agent proposing. If the answer is "it should never do this", the fix is a missing tool, not a review step.

Next

Frequently asked questions

How do I make an agent ask before it writes something?

Declare the write as an action with approval enabled and the durable-pause kind. The turn parks, the request surfaces in your app, and a person approves or rejects it.

Why durable-pause specifically?

Because a parked write must survive the conversation being evicted and be resolvable from your app's own UI. The default kind can only be resumed by the chat client that started it, which is not good enough for a real approval queue.

Who is allowed to approve?

Whoever your app says. Approvals resolve through a route in your own app, so your existing auth and roles decide — the platform doesn't impose an approver model.

If a human approves something over the limit, does it go through?

No. Guardrails live in domain code and bind after approval too. An approved 60% discount is still refused by a 20% ceiling, and the refusal is recorded.

Where do approval requests appear?

In your app's own dashboard, built by you from the approval events and the pending-approvals list. They are deliberately not a platform UI, because the approver is your user, not a platform operator.