Skip to main content

Human-in-the-Loop (HITL)

Pause an agent run at specific lifecycle events, route the pending action to a human (or an LLM judge, or both), and resume with an approve / reject / modify decision. AgentOS exposes HITL on three integration surfaces, each with its own contract. agency({ hitl }) passes an ApprovalRequest to hitl.handler and acts on the ApprovalDecision it returns (approved, reason, modifications). A graph's human node resolves from its own autoAccept, autoReject or judge options, or suspends the run with an interrupt. The runtime HumanInteractionManager takes a PendingAction in requestApproval() and answers with its own ApprovalDecision type (actionId, approved, rejectionReason, decidedBy, decidedAt).

Three HITL surfaces, each with its own decision contract. Agency lane: agency({ hitl }) with five opt-in approval triggers (beforeTool for listed tool names or '*', beforeAgent for listed agent names, beforeEmergent for spawn_specialist, beforeReturn for the final answer, and beforeStrategyOverride, which pauses nothing) and six handler factories; its contract is an ApprovalRequest, the handler's ApprovalDecision (approved, reason, modifications), with timeoutMs and onTimeout applied on beforeTool and beforeReturn, and a post-approval check (code-safety, pii-redaction) that can veto a yes. Graph lane: a human node resolves by autoAccept, autoReject or a judge, in that order, and otherwise suspends (a workflow() step takes no mode, so it always suspends); a yes no human gave is checked through deps.guardrailEngine when one is wired, and with no decision the runtime saves a checkpoint and emits an interrupt, and the host writes the answer into a fork of it and resumes. Runtime lane: HumanInteractionManager takes a severity-aware PendingAction through four interaction modes; requestApproval() notifies approval_required, submitApprovalDecision() answers with the manager's own ApprovalDecision type, and ToolOrchestrator and the step gate ask it before tools with side effects when hitl.enabled is set.

What HITL is in AgentOS​

Three places HITL plugs in:

LayerPrimitiveSourceUse when
AgencyHitlConfig on agency({ hitl: {...} }); agent() accepts the field and does not read itsrc/api/types.tsThe host runs a multi-agent agency and wants declarative gates at specific lifecycle events (a tool name, an agent name, a specialist spawn, the final return).
Workflow / graphhumanNode({ prompt, autoAccept?, autoReject?, judge? }) in an AgentGraph; workflow()'s step({ human: { prompt } })src/orchestration/builders/nodes.ts + src/orchestration/runtime/NodeExecutor.tsThe host owns an explicit graph and wants a node that suspends the run until the host resumes it.
RuntimeHumanInteractionManager implementing IHumanInteractionManagersrc/orchestration/hitl/A subsystem (planner, custom orchestrator, evaluator) needs severity-aware approval with clarification, edit, and escalation flows in addition to approve/reject.

The agency-level surface is what most apps need. Reach for a human node when you're already authoring a graph. Reach for the runtime manager when you need the full clarification/edit/escalation vocabulary outside of an agency() run.

Five approval triggers​

HitlConfig.approvals is the declarative trigger surface. Every field is optional. A tool or agent named in beforeTool or beforeAgent, or beforeEmergent or beforeReturn set to true, pauses the run at that lifecycle event; beforeStrategyOverride pauses nothing (see the table below):

import { agency, hitl } from '@framers/agentos';

const guarded = agency({
agents: { worker: { instructions: 'Execute tasks.' } },
hitl: {
approvals: {
beforeTool: ['delete-file', 'send-email'],
beforeAgent: ['billing-specialist'],
beforeEmergent: true,
beforeReturn: true,
beforeStrategyOverride: true,
},
handler: hitl.cli(),
},
});
TriggerPauses beforeTypical use
beforeTool: string[]Any tool whose name appears in the list ('*': every tool), on every tool loop of the agency's seats and nested agencies, after onBeforeToolExecution has runDestructive or high-cost tool calls (delete-file, send-email, purchase).
beforeAgent: string[]Any agent in the agency whose name appears in the listSpecialists that should only run after human go-ahead (billing-agent, legal-review).
beforeEmergent: booleanRuntime synthesis of a new specialist via spawn_specialistProduction agencies that allow emergent capabilities but require approval before the roster grows.
beforeReturn: booleanThe final answer leaves the agencyCustomer-facing channels where the last response gets a human or judge review.
beforeStrategyOverride: booleanNothing: no strategy makes a strategy-override request. Like every trigger, setting it makes agency() require a handlerReserved; set it and nothing pauses.

A team a GMI launches with launch_agency runs under agencies.hitl (a gmi() agent's, or AgentOSConfig.agencies on the full runtime) at a root launch, after the gate the launching turn carries: its beforeTool list covers every tool call of every seat, a nested team's seats included, each call asked once, and a call whose turn has already stopped is refused without asking anyone. On the full runtime a seat's call to a composition the runtime's emergent engine holds is asked by the composition's name, and each step the composition reaches by the step tool's name, before the step runs. An approval is for the tool it was asked about: on the full runtime, a tool registered under the name while the approval was pending does not run under it, and the seat's call is refused (STEP_REPLACED) with nothing run. A handler that fails on a call of a nested team's seat fails the call of the root team, whose gate it is: the nested launch reports how its own team ran, every later tool call under that gate is refused, and the root team's call rejects with the handler's error once its strategy settles, as it does when the handler fails on a call of one of its own seats. Its beforeAgent and beforeReturn are asked in the root team alone.

Source: HitlConfig.approvals in src/api/types.ts.

Six handler factories​

The hitl namespace exports six ready-to-use handler factories. Each returns a HitlHandler (an async function taking ApprovalRequest and resolving to ApprovalDecision), so you compose them by wrapping in your own function when you need logging, fallback chains, or conditional routing.

Source: src/api/hitl.ts.

hitl.cli()​

Interactive terminal prompt. Reads from process.stdin. Before it asks, it prints the request's description, agent, action, type and details; for a tool call the details hold the arguments the tool will run with, so the approver sees the recipient, the path or the query being approved. Use locally and in interactive scripts; not safe for CI or serverless.

handler: hitl.cli();

hitl.autoApprove()​

Approves every request immediately. Use in tests and CI.

handler: hitl.autoApprove();

hitl.autoReject(reason?)​

Rejects every request. Use for dry-run mode where you want to confirm which actions would fire without permitting any.

handler: hitl.autoReject('dry-run mode — no side effects permitted');

hitl.webhook(url)​

POSTs the ApprovalRequest as JSON to your endpoint and reads back an ApprovalDecision. Non-2xx is treated as rejection with the status code as the reason. Use this when you have an in-house approval service.

handler: hitl.webhook('https://approvals.example.com/decide');

hitl.slack({ channel, token })​

Posts the request to a Slack channel and approves it at once. The message asks for a :white_check_mark: or :x: reaction, and the handler reads none. Use it as an audit trail, and pair it with a handler that decides when the team must answer.

handler: hitl.slack({ channel: '#approvals', token: process.env.SLACK_BOT_TOKEN! });

hitl.llmJudge({ ... })​

Delegates the decision to a model (OpenAI and the central judge model by default). The judge replies with a structured {approved, confidence, reasoning} JSON object. When confidence < confidenceThreshold (default 0.7), the reply lacks those fields, or the call fails, the request goes to the fallback handler, which rejects by default. The judge sees the request's type, agent, action, description and details.

handler: hitl.llmJudge({
model: 'gpt-4o-mini',
criteria: 'Approve unless the action would delete data, send messages externally, or spend more than $1.',
confidenceThreshold: 0.8,
fallback: hitl.cli(),
});

This is the recommended production default for agencies where most actions are low-risk: a cheap judge auto-handles the easy majority, the human only sees what the judge isn't confident about.

The ApprovalRequest / ApprovalDecision contract​

Source: ApprovalRequest + ApprovalDecision in src/api/types.ts.

Every handler receives this:

interface ApprovalRequest {
id: string;
type: 'tool' | 'agent' | 'emergent' | 'output' | 'strategy-override';
agent: string; // name of the agent that triggered the pause
action: string; // short label (tool/agent name)
description: string;
details: Record<string, unknown>; // structured args / config
context: {
agentCalls: AgentCallRecord[];
totalTokens: number;
totalCostUSD: number;
elapsedMs: number;
};
}

…and must resolve to this:

interface ApprovalDecision {
approved: boolean;
reason?: string;
modifications?: {
toolArgs?: unknown; // never applied: a beforeTool approval that carries it is refused
output?: string; // replaces the final text (beforeReturn)
instructions?: string; // added to the agent's input (beforeAgent)
};
}

When approved: true and modifications are set, the orchestrator applies them before proceeding. This is the path for "approve but with these changes": output on a beforeReturn approval replaces the final text (a hitl.webhook() endpoint can return a sanitized version of a text the post-approval check passes, but not clear one it blocks, as the safety net explains; hitl.llmJudge() returns no modifications), after the replacement has gone through the agency's output guardrails and the post-approval check (in an agency with an output schema, parsed is read from the replacement, and one that does not match the schema is returned with parsed undefined, without a second run of the strategy; a replacement equal to the strategy's text counts as an approval without modifications, and a text that does not match is retried under generate(), not under stream(); without one, a replacement that changes the text leaves parsed undefined), and instructions on a beforeAgent approval are added to the input of the agent it lets run, under every strategy (sequential, parallel, hierarchical, graph, review-loop, debate and panel), the post-approval check reads that input with them, and the agent's record in the result's agentCalls holds the input it was sent, the instructions included. Tool arguments are the exception. A beforeTool approval approves or refuses the arguments the call will run with; it never applies modifications.toolArgs, and it refuses an approval that carries them (anything but undefined or null), so the call is skipped rather than run with the arguments the approver meant to replace. Rewrite tool arguments in onBeforeToolExecution, which runs before the handler is asked.

Timeout policy​

hitl: {
approvals: { beforeTool: ['delete-file'] },
handler: hitl.webhook('https://approvals.example.com/decide'),
timeoutMs: 60_000,
onTimeout: 'reject',
}
FieldDefaultMeaning
timeoutMs30_000Milliseconds a beforeTool or beforeReturn handler may take before onTimeout decides.
onTimeout: 'reject'(default)The request is denied: a beforeTool call is skipped and the run goes on, and a beforeReturn request makes generate() reject with an AgencyConfigError whose message starts Final output rejected by HITL.
onTimeout: 'approve'—The request is approved as if the handler had approved it: the post-approval check runs, and the action proceeds unless the check vetoes it. Use sparingly.
onTimeout: 'error'—The request fails with AgencyConfigError('HITL approval timed out'): on beforeTool, the tool and every later one are skipped and the call rejects with that error once the strategy settles; on beforeReturn, generate() rejects with it. Use for hard SLAs where neither approve nor reject is acceptable on timeout.

The timer covers beforeTool and beforeReturn requests, which agency() asks through resolveApprovalDecision() in src/api/runtime/approval-gate.ts. A beforeAgent or beforeEmergent request calls the handler directly, in checkBeforeAgentInput() and checkBeforeEmergentSpawn() (src/api/runtime/strategies/shared.ts), with no timer: the run waits for the handler's answer however long it takes, and onTimeout never applies.

Guardrail-override post-approval safety net​

After a beforeTool, beforeAgent or beforeReturn approval, agency() runs built-in pattern checks named by postApprovalGuardrails over the tool's name and arguments, the agent's name and the input the strategy passes it, or the final text, and vetoes the approval when one matches. That input is the one the strategy built for the agent, and the approver is shown it in details.input: under the sequential, graph, review-loop and debate strategies and at the panel's chair it carries what other agents wrote (the previous agent's output, the dependencies' outputs, the draft and the reviewer's feedback, the debate transcript, the seats' answers), so an earlier agent's output the check blocks vetoes the approval and never reaches the agent. There the check also reads the task and each of those outputs on its own, since the strategy writes more text after them, so a statement the check matches only at the end of a text (rm -rf ., a DELETE FROM <table> with nothing after it but whitespace and a semicolon) is caught at the end of the task or of an earlier agent's output as it is at the end of the input. The check reads every text as JSON, where a line break or a tab inside a text is written \n or \t; a space, a line break or a tab after such a statement still leaves it at the end of the text. Every strategy adds the approval's modifications.instructions to the agent's input, and the check reads the input as the strategy passes it and the text the agent is sent, the instructions included, so instructions the check blocks veto the approval. On a beforeReturn approval it reads the strategy's text and, when the approval's modifications.output replaces it, the replacement, after the agency's output guardrails have read it, so a replacement the check blocks vetoes the approval as a blocked final text does. A replacement does not clear a strategy text the check blocks: under stream() that text has been streamed before the approval is asked for, and under sequential, graph and review-loop it is the last agent's output in the result's agentCalls, so the check vetoes the approval whatever the replacement says. A webhook that redacts an SSN or a card number the pii-redaction check finds in the final text gets the call rejected, unless pii-redaction is left out of postApprovalGuardrails. These are fixed checks inside agency(), not the guardrail extension packs: code-safety looks for destructive commands and statements (rm -rf /, mkfs., dd ... of=/dev, DROP TABLE, TRUNCATE TABLE, chmod -R 777 /, shutdown and others), pii-redaction for unredacted SSNs and card numbers, and any other id passes. A veto fires guardrailHitlOverride (its toolName is the tool's name, agent:<name> for an agent, or return for the final output) and skips the tool, turns the agent's approval into a rejection, so the agent does not run, or, for the final output, rejects generate() with an AgencyConfigError. A beforeEmergent approval gets no check. This catches the case where a human (or LLM judge) approves something destructive.

hitl: {
approvals: { beforeTool: ['delete-file'] },
handler: hitl.llmJudge({ /* ... */ }),
guardrailOverride: true, // default
postApprovalGuardrails: ['pii-redaction', 'code-safety'], // default
}

Set guardrailOverride: false to disable the safety net and give the handler full autonomy. Default true is the right setting for production.

Graph human nodes​

A human node suspends a graph run until the host resumes it. workflow() lowers step('id', { human: { prompt } }) to humanNode({ prompt, timeout }): the step takes a prompt and the step's timeout, and nothing else, so it always suspends. The resolution modes below are options of humanNode(), which an AgentGraph takes as a node.

import { humanNode } from '@framers/agentos/orchestration';

graph.addNode('human-review', humanNode({
prompt: 'Approve the draft, or reject it with a reason.',
judge: {
model: 'gpt-4o-mini',
criteria: 'Approve unless the draft contains unverified claims or PII.',
confidenceThreshold: 0.8,
},
}));

Resolution modes, checked in this order:

ModeBehaviour
autoAccept: trueResolve at once as approved. Use in tests.
autoReject: true or 'reason string'Resolve at once as rejected. Use for dry-run pipelines.
judge: { model?, provider?, criteria?, confidenceThreshold? }Ask a model for { approved, confidence, reasoning }. At or above confidenceThreshold (default 0.7) its decision stands; below it, or when the call fails, the node suspends as with no mode.
(none of the above)Suspend: the runtime saves a checkpoint and emits an interrupt event.

After an approval that no human gave (auto-accept, the judge, or onTimeout: 'accept'), the node runs the guardrail ids pii-redaction and code-safety through the graph's deps.guardrailEngine when one is wired, and a block turns the decision into approved: false; guardrailOverride: false turns that off.

resume(checkpointId) marks a suspended human node complete with its recorded output ({ prompt }). A host that has the human's answer writes it into the state with the checkpoint store's fork(checkpointId, patch) and resumes the fork (Checkpointing). Every human node has the effect class human, so on resume a node whose output is recorded is not run again.

Runtime HumanInteractionManager​

Source: src/orchestration/hitl/HumanInteractionManager.ts + interface IHumanInteractionManager.

This is the richer surface of the full runtime: pass one as hitlManager in the AgentOS config and, with toolOrchestratorConfig.hitl.enabled, its ToolOrchestrator asks requestApproval() before a tool with hasSideEffects: true runs; the emergent step gate asks it before a composed tool's side-effecting step. A call or a step whose stop has already come (its turn stopped, its time ran out) is refused (STEP_ABORTED) without asking. A pending approval is not withdrawn when the stop comes: the call waits for its answer, or for the request's timeout (approvalTimeoutMs, else the manager's defaultTimeoutMs), and is then refused, so nothing runs. Custom orchestrators can call it too. It speaks four interaction modes plus checkpoints and feedback ingestion:

interface IHumanInteractionManager {
requestApproval(action: PendingAction): Promise<ApprovalDecision>;
requestClarification(request: ClarificationRequest): Promise<ClarificationResponse>;
requestEdit(draft: DraftOutput): Promise<EditedOutput>;
escalate(context: EscalationContext): Promise<EscalationDecision>;
// ...checkpoint submission, feedback ingestion, status queries
}

PendingAction carries the dimensions a high-stakes approval needs: a severity level, a category, whether the action is reversible, potential consequences, and an estimated cost.

type ActionSeverity = 'low' | 'medium' | 'high' | 'critical';

interface PendingAction {
actionId: string;
description: string;
severity: ActionSeverity;
category?: 'data_modification' | 'external_api' | 'financial'
| 'communication' | 'system' | 'other';
agentId: string;
context: Record<string, unknown>;
potentialConsequences?: string[];
reversible: boolean;
estimatedCost?: { amount: number; currency: string };
alternatives?: AlternativeAction[];
requestedAt: Date;
timeoutMs?: number;
}

Escalation reasons (EscalationReason) cover the situations the agent should not decide unilaterally:

type EscalationReason =
| 'low_confidence' | 'repeated_failures'
| 'ethical_concern' | 'out_of_scope'
| 'resource_limit' | 'conflicting_instructions'
| 'safety_concern' | 'user_requested'
| 'policy_violation' | 'unknown_territory';

…and escalation decisions return one of:

type EscalationDecision =
| { type: 'human_takeover'; instructions?: string }
| { type: 'agent_continue'; guidance: string; adjustedParameters?: Record<string, unknown> }
| { type: 'abort'; reason: string }
| { type: 'delegate'; targetAgentId: string; instructions: string };

Wire a notification handler (HITLNotificationHandler) to surface new pending actions to whatever channel hosts your humans — a UI queue, a Slack channel, a PagerDuty incident, etc.

Worked example — CLI handler (local dev)​

import { agency, hitl } from '@framers/agentos';

const writer = agency({
provider: 'openai',
model: 'gpt-4o-mini',
agents: {
drafter: { instructions: 'Draft a paragraph based on the user input.' },
},
hitl: {
approvals: { beforeReturn: true },
handler: hitl.cli(),
timeoutMs: 60_000,
onTimeout: 'reject',
},
});

const result = await writer.generate('Why AgentOS uses cognitive memory.');
console.log(result.text);

Running from a terminal pauses before the draft is returned and prints:

[APPROVAL NEEDED] Approve the final agency response before returning it.
Agent: __agency__ | Action: return
Type: output
Details: { output: '<the drafted paragraph>' }
Approve? (y/n):

The agent line names the agency (__agency__ when it has no name), and the details hold the draft being approved. Approve and the draft returns. Answer n, or nothing within the 60 seconds of timeoutMs (onTimeout: 'reject'), and generate() rejects with an AgencyConfigError whose message starts Final output rejected by HITL.

Worked example — LLM judge with CLI fallback (production default)​

import { agency, hitl } from '@framers/agentos';

const guarded = agency({
provider: 'openai',
model: 'gpt-4o-mini',
agents: {
worker: {
instructions: 'Execute requested tasks using the available tools.',
// ...tools, etc.
},
},
hitl: {
approvals: {
beforeTool: ['delete-file', 'send-email'],
beforeReturn: true,
},
handler: hitl.llmJudge({
model: 'gpt-4o-mini',
criteria: 'Approve unless the action would delete user data, send a message externally, or spend more than $1.',
confidenceThreshold: 0.8,
fallback: hitl.cli(),
}),
guardrailOverride: true,
postApprovalGuardrails: ['pii-redaction', 'code-safety'],
},
});

Routing pattern: cheap judge handles low-risk approvals; the human only sees calls the judge can't confidently decide.

Worked example — Slack notification​

import { agency, hitl } from '@framers/agentos';

const teamAgency = agency({
provider: 'openai',
model: 'gpt-4o-mini',
agents: { worker: { instructions: 'Run the requested operation.' } },
hitl: {
approvals: { beforeTool: ['publish-blog-post'] },
handler: hitl.slack({
channel: '#approvals',
token: process.env.SLACK_BOT_TOKEN!,
}),
},
});

The Slack handler posts the approval message to the channel and approves at once; it reads no reactions. Treat Slack as an audit trail and combine it with a gating handler when you need to wait on the team:

import type { HitlHandler } from '@framers/agentos';

const slackThenWebhook: HitlHandler = async (request) => {
await hitl.slack({ channel: '#approvals', token: process.env.SLACK_BOT_TOKEN! })(request);
return hitl.webhook('https://approvals.internal/decide')(request);
};

Worked example — a graph human node​

import { AgentGraph, START, END, gmiNode, humanNode } from '@framers/agentos/orchestration';
import { z } from 'zod';

const draftThenReview = new AgentGraph({
input: z.object({ topic: z.string() }),
scratch: z.object({}),
artifacts: z.object({}),
})
.addNode('draft', gmiNode({ instructions: 'Draft a 2-paragraph post on the topic.' }))
.addNode('review', humanNode({
prompt: 'Approve the draft (yes/no).',
judge: {
model: 'gpt-4o-mini',
criteria: 'Approve unless the draft contains unverified claims, PII, or marketing fluff.',
confidenceThreshold: 0.8,
},
}))
.addEdge(START, 'draft')
.addEdge('draft', 'review')
.addEdge('review', END)
.compile({ deps: { /* host bindings: loopController, providerCall, guardrailEngine */ } });

For agencies that already use the higher-level agency({ hitl: { approvals: { beforeReturn: true } } }), prefer the agency-level surface; a human node is for explicit graphs that mix LLM, non-LLM, and human nodes.

Pitfalls​

hitl.cli() hangs in non-interactive environments. It reads from process.stdin. In CI, serverless, or any environment without a TTY, the handler never resolves: on a beforeTool or beforeReturn request the onTimeout policy fires after timeoutMs, and a beforeAgent or beforeEmergent request, which has no timer, never gets an answer. Use hitl.autoApprove() in CI and hitl.cli() only locally.

hitl.slack(...) approves after notifying. It does not wait for a reaction. Use it for audit, or wrap it in a webhook for blocking approval.

beforeEmergent: true requires emergent to be enabled. Setting beforeEmergent: true without emergent: { enabled: true } on the agency does nothing — there's no emergent path to gate. Pair the two.

postApprovalGuardrails names built-in checks, not loaded guardrails. The two ids it knows are code-safety and pii-redaction (the default list); any other id passes without a check, and the guardrail packs registered with a runtime are not consulted.

A human node's resolution modes are checked in a fixed order. autoAccept first, then autoReject, then judge: a node with autoAccept: true and a judge never asks the judge. Pick one mode per node.

FAQ​

Does beforeReturn block streaming? Yes — when beforeReturn: true, the agency's stream.finalTextStream does not emit until the handler resolves. stream.textStream (raw live chunks) continues unaffected.

Can a handler modify the action without rejecting it? For the final answer and for an agent run, yes. Return { approved: true, modifications: { output: '...' } } from a beforeReturn approval to replace the final text (the replacement goes through the agency's output guardrails and the post-approval check, and one the check blocks rejects the call, as does any replacement of a strategy text the check blocks; with an output schema, parsed is read from the replacement, and one that does not match comes back with parsed undefined and no retry, unless it is the strategy's own text handed back, which counts as an approval without modifications and is retried under generate(), not under stream()), or { approved: true, modifications: { instructions: '...' } } from a beforeAgent approval to add instructions to that agent's input. For a tool call, no: a beforeTool approval does not apply modifications.toolArgs, and it refuses an approval that carries them, so a handler that redacts or redirects an argument gets a skipped call, never the original one. Rewrite tool arguments in onBeforeToolExecution, which runs before the handler is asked.

Do agency callbacks (approvalRequested, approvalDecided) fire for graph human nodes? No — those callbacks are on AgencyCallbacks and only fire for HitlConfig-driven pauses. A human node that suspends yields an interrupt event on the compiled graph's stream().

Can the LLM judge see the full agent call history? No. A beforeReturn request carries the run's agent calls in ApprovalRequest.context.agentCalls; a beforeAgent or beforeEmergent request carries the strategy's own list of agent calls, not a copy, which grows as later calls finish, including calls of the same graph tier that finish while the request waits (under the parallel strategy it is an empty list of its own), and a beforeTool request carries none. hitl.llmJudge() sends the judge only the request's type, agent, action, description and details; a handler of your own can read context and pass it on.

See also​