Skip to main content

Human-in-the-Loop

Most of the time you want the AI to just handle it. But some moments need a human: approving a refund, signing off on a contract clause, or quietly stepping into a conversation that's going sideways. Human-in-the-loop (HITL) gives you both, an asynchronous approval checkpoint baked into the flow, and a live spectator + co-pilot console for real-time intervention.

Viglet Turing has two complementary HITL surfaces:

  1. humanApproval node: a graph-declared checkpoint that parks a conversation until a person approves or rejects, notified over their channel of choice.
  2. Spectator + co-pilot mode: an operator watches any live conversation and can take the wheel for a turn.

The first is for designed approval gates (compliance, escalation); the second is for ad-hoc supervision and rescue.


The humanApproval node​

Drop a humanApproval node into a Chat Flow wherever the conversation must wait for a human decision. When the flow reaches it:

  1. First entry: the approval slot is empty, so the service raises a PENDING approval record, fires the notification, and the engine parks the conversation on this node.
  2. Re-entry while pending: if the visitor keeps talking before a decision lands, it's a no-op; the conversation stays parked.
  3. Decision present: once the operator's decision is written into the approval slot, the engine advances on the next edge.

What the node carries​

FieldMeaning
approvalSlotThe slot the decision is written into (what unblocks the flow)
channelHow the approver is notified: email, slack, or webhook
targetThe channel destination (email address, Slack channel, webhook name)
promptTextWhat the approver sees: the question/context to decide on
timeoutBehaviorWhat happens if no one decides in time (see below)
resumeTokenAn opaque, single-use token that ties a decision back to this exact parked conversation + node

Each pending approval is persisted as a record with a status of PENDING → DECIDED / TIMED_OUT / CANCELLED.

Notifications & decisions​

When an approval is raised, TurHumanApprovalNotifier dispatches over the configured channel, an email, a Slack message, or a webhook, carrying the promptText and a link/token to decide. The approver decides through the approval API:

MethodEndpointPurpose
GET/api/genai/approval/{token}Fetch a pending approval by its resume token (what the decision page loads)
POST/api/genai/approval/{token}Submit the decision { "decision": "..." }: writes the approval slot and resumes the parked flow
GET/api/genai/approvalsList all pending approvals (the operator queue)

Submitting a decision writes the approval slot and resumes the conversation in one step.

Timeouts​

A human might never respond. Each approval has an expiresAt, and the timeout sweep job (TurHumanApprovalTimeoutSweepJob) periodically resolves anything past its deadline according to the node's timeoutBehavior:

  • auto-reject (the safe default): the approval resolves as rejected and the flow advances down the rejection path.
  • auto-approve: the approval resolves as approved (use only where a missed deadline should not block the visitor).

Either way the record moves to TIMED_OUT and the conversation is unparked, so a conversation is never stuck forever waiting on a human.

Force-unblock

An admin can force-advance a parked conversation from the parked-conversations view; the still-pending approval is then marked CANCELLED rather than left dangling.


Spectator + co-pilot mode​

Sometimes you don't want a designed gate: you want to watch a conversation happening right now and step in if needed. An operator opens a live conversation in the admin console at /conversation/{id}?spectate=true and gets a real-time view fed by three conversation-scoped SSE streams (all under /api/chat/sessions, admin-secured):

StreamEndpointWhat it carries
MessagesGET /{conversationId}/spectate/streamA transcript snapshot, then one event per completed turn (visitor + assistant)
SlotsGET /{conversationId}/spectate/slots/streamLive slot updates as they're written
WorkspaceGET /{conversationId}/spectate/workspace/streamLive agent-workspace file changes

A header is driven by GET /{conversationId}/state, the active flow, current node, guardrail method, A/B experiment metadata, and a suspended reason when the cursor is parked on a suspend or humanApproval node (so a spectator can see why a conversation is waiting).

Taking the wheel​

Toggle ?manual=true and the operator can take the wheel for a turn:

POST /api/chat/sessions/{conversationId}/manual-turn

The operator's text becomes the assistant message (the LLM is skipped) but it's written through the same flow-advance + telemetry path as a normal turn, so slots, analytics, and the transcript stay consistent. Spectators watching the message stream see the operator's reply arrive exactly like an AI reply. It's the rescue hatch for a conversation the model is mishandling.

Single-node SSE

The spectate streams (like the slot/workspace buses) are per-node, a spectator connects to the node serving the conversation. They carry a periodic heartbeat to keep the connection alive.


Choosing between them​

You need…Use
A required sign-off before the flow can continue (refund, contract, compliance)humanApproval node
Notification of the approver over email / Slack / webhookhumanApproval node
To watch conversations live and only intervene if something goes wrongSpectator mode
To type a reply yourself mid-conversationCo-pilot (manual-turn)

  • Chat Flow: where the humanApproval and suspend nodes are authored
  • Webhooks: a notification channel for approvals, and the handoff surface
  • Agent Workspace: the files a spectator sees on the workspace stream
  • Chat Analytics: review parked/escalated conversations after the fact