The Chat Loop

Resume and approvals

Human in the loop is a first class primitive. Skills call wait_for_approval to pause the turn, the engine writes a pause_points row, and the chat surface shows an approval card. When the user approves, the resume endpoint unblocks the original turn from exactly where it left off.

Updated today

Overview

At a glance
Tool
wait_for_approval
Module
src/lib/tools/providers/hitl.ts
Table
pause_points
Marker
__APPROVAL_REQUIRED__
Resume endpoint
POST /api/chat/resume
State after pause
conversation.state = paused

Approvals are deliberately not a side channel. Treating HITL as a tool means it composes with everything else the engine already understands: it has a schema the model sees, an executor that runs server side, a tool result that the model reads on resume. No separate state machine, no parallel queue.

The wait_for_approval tool

A standard tool schema with a runtime executor that does one specific thing: write a pause point row and return a marker the chat UI renders as an approve/decline card.

src/lib/tools/providers/hitl.tsts
32// Schema (abbreviated). See file for full descriptions.
33{
34 type: "function",
35 function: {
36 name: "wait_for_approval",
37 parameters: {
38 type: "object",
39 properties: {
40 title: { type: "string" }, // <= 80 chars
41 description: { type: "string" }, // markdown
42 options: { type: "array", items: { type: "string" } },
43 payload: { type: "object" }, // resume context
44 kind: { type: "string" }, // approval | choice | data_entry
45 },
46 required: ["title", "description"],
47 },
48 },
49}
src/lib/tools/providers/hitl.tsts
70async function waitForApproval(ctx: ToolContext, args: Record<string, unknown>) {
71 // Persist the pause point. The chat surface renders it as an inline card.
72 const { data, error } = await ctx.supabase.from("pause_points").insert({
73 conversation_id: ctx.conversation.id,
74 user_id: ctx.userId,
75 turn_index: ctx.turnIndex,
76 title, description, options, payload,
77 kind, status: "pending",
78 }).select("id").single()
79 if (error) return "Error: failed to create pause point."
80
81 // Return a sentinel string. The model treats this as a tool result and
82 // acknowledges to the user that it's waiting. The chat UI parses out the
83 // id and renders an approve/decline card instead of the raw text.
84 return `__APPROVAL_REQUIRED__${data.id}`
85}
Note
The marker travels back to the model as the literal tool result. The model is system-prompted to acknowledge the pause and wait, not to call the next tool. The conversation effectively halts on the model side, with no special engine state required.

pause_points schema

The durable record of an in-flight approval.

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 action text not null,
7 summary text not null,
8 payload jsonb,
9 risk text not null default 'medium',
10 state text not null default 'pending',
11 decision text,
12 decided_at timestamptz,
13 created_at timestamptz not null default now()
14);
15
16create index pause_points_conv_idx on pause_points (conversation_id, state);
StateMeaningNext transition
pendingAwaiting user decisionapproved, rejected, or expired
approvedUser clicked approveEngine resumes the turn
rejectedUser clicked rejectEngine resumes the turn with a rejection result
expiredPause point timed outEngine resumes with a timeout error

The approval marker

A short sentinel string the engine recognizes. It is the only side channel from the executor to the loop.

src/lib/skills/engine.tsts
1// After dispatching tool calls, check every result for the marker
2const pauseResult = results.find((r) =>
3 typeof r.content === 'string' && r.content.startsWith('__APPROVAL_REQUIRED__'),
4)
5
6if (pauseResult) {
7 const pauseId = pauseResult.content.replace('__APPROVAL_REQUIRED__', '')
8 await supabase.from('conversations')
9 .update({ state: 'paused', pause_point_id: pauseId })
10 .eq('id', conversation.id)
11 return { stopReason: 'pause', pauseId }
12}
Warning
The marker is intentionally string-typed so it survives serialization across the streaming boundary. Do not change the prefix without updating both the executor and the engine in the same commit.

Resume endpoint

A small POST that records the decision and posts a synthetic user message back into the conversation. The next regular turn picks up naturally.

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 // 1. Load and validate the pause point
6 const { data: pause } = await supabase
7 .from("pause_points")
8 .select("*")
9 .eq("id", pausePointId)
10 .eq("user_id", userId)
11 .eq("status", "pending")
12 .single()
13 if (!pause) return new Response("not found", { status: 404 })
14
15 // 2. Flip the pause point to resolved
16 await supabase.from("pause_points").update({
17 status: "resolved",
18 resolution,
19 resolved_at: new Date().toISOString(),
20 }).eq("id", pausePointId)
21
22 // 3. Post a synthetic user message so the next model turn sees the decision
23 await supabase.from("messages").insert({
24 conversation_id: pause.conversation_id,
25 role: "user",
26 content: `Approval ${pause.kind}: ${resolution}${freeformReply ? ` - ${freeformReply}` : ""}`,
27 })
28
29 return new Response(null, { status: 204 })
30}
Tip
Resume does not surgically re-enter the loop. It writes a message and lets the regular chat handler do its thing on the next user-triggered turn. This avoids state-snapshot serialization and keeps the engine pure with respect to history.

UI affordance

The chat surface renders a pause point as an inline approval card. The card shows what the skill wants to do, why, and lets the user approve, edit, or reject.

The approval card is just another message block. The chat renderer detects messages tied to a pause point id and swaps the default text block for the approval block. The user sees a structured card with the summary, the action payload, the risk level, and three buttons.

src/components/ApprovalCard.tsxtsx
1export function ApprovalCard({ pause }: { pause: PausePoint }) {
2 const onDecide = async (decision: 'approve' | 'reject', override?: unknown) => {
3 await fetch('/api/chat/resume', {
4 method: 'POST',
5 body: JSON.stringify({ pausePointId: pause.id, decision, payloadOverride: override }),
6 })
7 }
8 return (
9 <Card riskTone={pause.risk}>
10 <Header>{pause.summary}</Header>
11 <PayloadEditor value={pause.payload} onChange={setEdited} />
12 <Actions>
13 <Reject onClick={() => onDecide('reject')} />
14 <Approve onClick={() => onDecide('approve', edited)} />
15 </Actions>
16 </Card>
17 )
18}

End to end flow

A complete approval cycle, from the skill calling the tool to the engine resuming.

  1. 01
    Skill calls wait_for_approval
    The skill exposes a structured summary, the action it wants to take, the payload, and a risk hint.
  2. 02
    Executor writes a pause point
    The executor inserts into pause_points and returns the marker.
  3. 03
    Engine detects the marker
    The loop exits with stop reason pause. Conversation state moves to paused. The pause point id is stamped on the conversation.
  4. 04
    Chat surface renders the approval card
    The card shows the summary, the editable payload, and three buttons: approve, edit and approve, reject.
  5. 05
    User decides
    The browser posts to /api/chat/resume with the pause point id and the decision.
  6. 06
    Engine posts a synthetic user message
    The resume handler writes a user-role message into the conversation summarizing the decision. The next chat turn the user takes (or an auto-continue if configured) reads that message and lets the model proceed.

File map

src/app/api/chat
v2/route.tsmain handler
resume/route.tsresume endpoint
src/lib/tools/providers
hitl.tswait_for_approval, ask_clarification
src/lib/skills
engine.tsmarker detection, resume logic
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.
Approval marker
The sentinel string __APPROVAL_REQUIRED__<id> returned by the wait_for_approval tool. The engine treats it as a signal to exit the loop.
Resume
The act of accepting an approval decision and unblocking the original turn from where it paused.
Payload override
An optional edit to the proposed action a user can submit at approve time. The engine substitutes it into the tool result.
Risk tone
A low/medium/high label declared by the skill. The approval card surfaces it visually so the user weights the decision.