Tools

Human in the loop

Human in the loop is implemented as a single tool. When a skill calls wait_for_approval, the executor writes a pause point row, returns a marker that signals the engine to halt, and lets the chat UI render an approve or decline card. The user's decision arrives on the next conversation turn through the resume endpoint.

Updated today

Overview

At a glance
Module
src/lib/tools/providers/hitl.ts
Tool name
wait_for_approval
Table
pause_points
Marker
__APPROVAL_REQUIRED__<uuid>
Resume endpoint
POST /api/chat/resume
Status after pause
conversation.state = paused
Kinds
approval, choice, data_entry

Treating approval as a tool keeps the surface uniform. The model already knows how to call tools; the engine already knows how to read tool results. Pausing for a human decision plugs into that flow rather than adding a parallel state machine. The pause point row is the only persisted artifact; everything else is derived from it.

When to use it

Call wait_for_approval before any action with material real-world consequences.

SituationWhy approval matters
Sending an outbound email campaignMass communication that cannot be unsent
Finalising an agreement or contractLegal commitment with downstream effects
Charging a card or creating an invoiceFinancial side effect, possible chargeback exposure
Publishing content to a social accountPublic statement attached to a brand voice
Scheduling a sequence of meetingsCalendar effects across multiple human recipients
Deleting records, files, or memoryLoss of state that cannot be recovered
Connecting or disconnecting an integrationPermission changes that affect later flows
Warning
Do not call wait_for_approval as a reflex on every turn. Drafting, researching, and reviewing do not need approval. Calls that ship something or change state outside the platform do.

Tool surface

src/lib/tools/providers/hitl.tsts
30const schemas: OpenAIFunctionSchema[] = [
31 {
32 type: "function",
33 function: {
34 name: "wait_for_approval",
35 description:
36 "Pause the workflow and request explicit user approval before continuing. " +
37 "Use BEFORE any irreversible action (sending mail, finalising an agreement, " +
38 "deleting records, charging a card, publishing content, scheduling meetings).",
39 parameters: {
40 type: "object",
41 properties: {
42 title: { type: "string" },
43 description: { type: "string" },
44 options: { type: "array", items: { type: "string" } },
45 payload: { type: "object" },
46 kind: { type: "string" },
47 },
48 required: ["title", "description"],
49 },
50 },
51 },
52]

Argument shape

Five fields. Two required, three optional.

FieldTypeNotes
titlestring (<= 80 chars)Short headline for the card. The first thing the user reads.
descriptionstring, markdown allowedFull context the user needs to decide. Include counts, dollar amounts, recipients, reversibility.
optionsstring[], up to 8 entriesOptional set of choices beyond approve and decline. When present, the card renders the choices in place of the default buttons.
payloadobjectOptional structured data echoed back through the resume endpoint. Use it to hold the action's job spec.
kindstringCategory tag for logs and UI. Defaults to approval; use choice when options are supplied, data_entry for forms.

The marker contract

The executor signals the engine by returning a sentinel string.

src/lib/tools/providers/hitl.tsts
70async function waitForApproval(ctx: ToolContext, args: Record<string, unknown>) {
71 const title = String(args.title ?? "").slice(0, 200)
72 const description = String(args.description ?? "")
73 if (!title || !description) {
74 return "Error: both title and description are required."
75 }
76 const options = Array.isArray(args.options) ? (args.options as string[]).slice(0, 8) : null
77 const payload = (args.payload && typeof args.payload === "object") ? args.payload : null
78 const kind = String(args.kind ?? (options ? "choice" : "approval"))
79
80 const { data, error } = await ctx.supabase.from("pause_points").insert({
81 conversation_id: ctx.conversation.id,
82 user_id: ctx.userId,
83 turn_index: ctx.turnIndex,
84 title, description, options, payload, kind,
85 status: "pending",
86 }).select("id").single()
87 if (error) return "Error: failed to persist pause point."
88
89 return `__APPROVAL_REQUIRED__${data.id}`
90}
Warning
The marker prefix is part of a contract. The engine looks for the literal string at the start of any tool result and treats the rest as a UUID. Do not rename it without updating the engine, the chat client, and the resume endpoint in the same commit.

Pause point row

supabase/migrations/pause_points.sqlsql
1create table pause_points (
2 id uuid primary key default gen_random_uuid(),
3 conversation_id uuid not null references conversations(id) on delete cascade,
4 user_id uuid not null references auth.users(id) on delete cascade,
5 turn_index int not null,
6 title text not null,
7 description text not null,
8 options text[],
9 payload jsonb,
10 kind text not null default 'approval',
11 status text not null default 'pending',
12 resolution text,
13 resolved_at timestamptz,
14 created_at timestamptz not null default now()
15);
16
17create index pause_points_conv_idx on pause_points (conversation_id, status);
statusmeaning
pendingAwaiting user decision
resolvedUser clicked approve, decline, or one of the options. resolution holds the chosen value.
expiredPause point was abandoned. Engine ignores it on resume.

The approval card

The chat surface renders a pending pause point as an inline card instead of plain text.

When the chat client receives a tool result whose text begins with the marker, it parses the trailing UUID, loads the pause point row, and renders an approval card in place of the raw text. The card shows the title, the description (markdown rendered), the editable payload if there is one, and either approve and decline buttons or the option list. The card stays visible until the user picks an action.

src/components/ApprovalCard.tsxtsx
1export function ApprovalCard({ pause }: { pause: PausePoint }) {
2 const onDecide = async (resolution: string, edited?: unknown) => {
3 await fetch("/api/chat/resume", {
4 method: "POST",
5 body: JSON.stringify({
6 pausePointId: pause.id,
7 resolution,
8 payloadOverride: edited,
9 }),
10 })
11 }
12 return (
13 <Card kind={pause.kind}>
14 <Header>{pause.title}</Header>
15 <Markdown>{pause.description}</Markdown>
16 <PayloadEditor value={pause.payload} onChange={setEdited} />
17 <Actions options={pause.options} onDecide={onDecide} />
18 </Card>
19 )
20}

Resolution and resume

  1. 01
    User clicks an action
    The card posts the pause point id and the chosen resolution to the resume endpoint.
  2. 02
    Resume endpoint flips the row
    Status moves from pending to resolved and the resolution string is recorded.
  3. 03
    Synthetic user message
    The endpoint writes a user-role message into the conversation summarising the decision so the next turn picks it up naturally.
  4. 04
    Next turn proceeds
    The chat handler reads the new message, the model sees the user has approved or declined, and the workflow continues.
src/app/api/chat/resume/route.tsts
1export async function POST(req: Request) {
2 const { pausePointId, resolution, freeformReply } = await req.json()
3 const { supabase, userId } = await getAuth(req)
4
5 const { data: pause } = await supabase.from("pause_points")
6 .select("*")
7 .eq("id", pausePointId)
8 .eq("user_id", userId)
9 .eq("status", "pending")
10 .single()
11 if (!pause) return new Response("not found", { status: 404 })
12
13 await supabase.from("pause_points").update({
14 status: "resolved",
15 resolution,
16 resolved_at: new Date().toISOString(),
17 }).eq("id", pausePointId)
18
19 await supabase.from("messages").insert({
20 conversation_id: pause.conversation_id,
21 role: "user",
22 content: `Approval ${pause.kind}: ${resolution}${freeformReply ? ` - ${freeformReply}` : ""}`,
23 })
24 return new Response(null, { status: 204 })
25}

Patterns

A few shapes that show up repeatedly.

Simple approval

Title plus description plus no options. The card renders Approve and Decline. Use this when the choice is binary: send or do not send, charge or do not charge.

Choice

Title plus description plus an options array. The card renders one button per option. Use this when the user is picking between several explicit alternatives, like a delivery date or a sequence variant.

Editable payload

Title plus description plus a payload object. The card shows the payload in an editor so the user can tweak the action before approving it. The edited payload is echoed through payloadOverride on resume.

Data entry

Set kind to data_entry when the card is collecting structured input rather than a yes or no. The UI renders a form; the resume endpoint stores the captured data as the resolution.

Anti-patterns

Things that look right but make the surface worse.

Approval as confirmation theatre
Calling wait_for_approval after the action has already happened is not approval. It is consent theatre and it teaches users to ignore the card. Always pause before the side effect, never after.
Vague descriptions
A description like "Confirm the action above" gives the user nothing to evaluate. Include counts, dollar amounts, recipients, reversibility, and any side effects. The description is the only thing standing between the user and a mistake.
Approval inside a tight loop
Wrapping every tool call in approval makes the loop unusable. Pause for the unit of work, not for every step inside it. Approve a campaign once, not approve every recipient.

Audit trail

Every pause point keeps a record that survives the conversation.

The pause_points table is the canonical audit log for human decisions inside the workflow. Each row carries the conversation, the turn index, the kind, the resolution, and the resolution timestamp. Compliance reviews can join the table against the messages table to reconstruct who approved what and when, including any edits to the payload at resolution time.

File map

src/lib/tools/providers
hitl.tswait_for_approval schema + executor
src/app/api/chat
v2/route.tsengine reads the marker and pauses the loop
resume/route.tsflips the row and posts the synthetic user message
src/components
ApprovalCard.tsxinline approval UI
supabase/migrations
pause_points.sql

Glossary

Pause point
A row in pause_points capturing a turn that asked the user for approval. Exactly one per pause.
Marker
The sentinel string __APPROVAL_REQUIRED__<id> returned by the wait_for_approval executor. Triggers the engine to halt the loop.
Resolution
The user's chosen action. For a simple approval, approve or decline. For a choice, one of the options. For data entry, a structured value.
Payload override
An edit to the proposed action a user can submit at resolution time. Echoed back through the resume payload.
Kind
A category label that drives card rendering. Defaults: approval, choice, data_entry.
Synthetic user message
The message the resume endpoint writes back into the conversation summarising the decision. The next turn reads it like any other user input.