Asking a Human
The engine has exactly one human-in-the-loop pipeline: every question, approval card, and grant request goes through the same gateway, renders in Telegram and the dashboard automatically, and resolves through the same event. You never build your own “wait for the user” mechanism — you pick a verb, and the engine owns delivery, rendering, and resolution.
Two verbs matter for brain authors:
| You need | Verb | Blocks? |
|---|---|---|
| An answer before the agent can continue | AskUserQuestion (agent tool) | Yes — the agent pauses (awaiting_input) and resumes with the answer |
| A decision the human makes at their own pace (minutes/hours/days) | hitl_request_approval (agent tool) | No — the card is sent, the agent keeps going |
| The same two, from a pipeline step | awaitApproval / requestApproval (pipeline SDK) | Yes (the card expires after 168 h unless you say otherwise) / No |
One-way delivery with no decision attached is not HITL at all — that is a
notification channel, see notifications.md. Nor is
waiting on something outside the engine (a PR’s reviews, CI, the merge): an
agent subscribes itself with watch_create and goes idle — the events wake it
as one batched turn — and a pipeline waits with subscribeResource
(features.md § Events System, pipelines.md).
When a watch expires with nothing having happened, the final message asks the
agent to decide what the silence means — reminding a person through
AskUserQuestion / hitl_request_approval is one of its options. Capability
grant cards (the engine asking “may this agent use email:send?”) are also not
yours to send — they fire automatically on a missing grant; what one approval
buys is covered in contract.md § 7.
Blocking: AskUserQuestion
Available to every interactive agent without declaration. It is the only
channel that reaches the user — a question phrased as plain text output is
discarded, never shown. Supports options (buttons) and multiSelect. The run
suspends until the user answers (button tap or free-text reply on the card) and
resumes with the answer in the transcript. Heartbeat and other background
agents run non-interactive and do not get this tool — for them, use a detached
card. A role that must never ask sets promptFlags: { questions: false }: the
question instructions AND the tool go, on every model.
The other two hatches (contract.md § 7.4):
hitl_request_grant is offered to every agent. It asks a person for
any capability — one that needs approval (a call failed with missing_grant)
or one the agent’s role does not hold at all (a tool refused as outside its
grant, a missing shell): an approval adds the capability to that agent from
its next step on (contract.md § 7.1). hitl_request_connection
is offered only to an agent that holds integration:execute.
A standing grant for someone else — grant_create / grant_extend /
grant_revoke (governance:write: restricted, HITL, granted to no role by
default) are for an agent that administers what others may do without asking:
issue a scenario, heartbeat, hook, agent or session a grant ahead of the run
that needs it, give one more time or calls, end one. The agent needs a
person’s approval of governance:write first — like any gated capability, that
yes is itself a grant, so the agent’s calls within its lifetime run without
another card. An agent can never grant an admin-only capability
(loop:auto_accept — it hands away a human gate itself), governance:write
(the gate on these writes — so a yes is never renewed by the agent it was
given to) nor a person’s whole principal, and gives at most 720 h; those are a
person’s, on the dashboard. A Boss run’s own grant lives and ends with the run:
grant_extend / grant_revoke refuse it (boss_stop).
grant_list, grant_get and grant_list_declarations read what stands.
Who may answer — when the chat has other people in it
When a bot serves more than its owners (chat-access.md), a card is not answered by whoever happens to press it. Telegram lets anyone in a group press any button, so the engine checks every press on the server. These rules cannot be configured:
- An approval or a grant — only an
owner. - A question — the person the asking turn was serving (its addressee) or an
owner. - Where the card appears. A question stays in the chat it was asked in:
only its addressee or an owner can answer it there. An approval or grant
raised by a turn that served a non-owner (say, a grant the group’s agent
needs) goes to the owner’s HITL chat instead, with a line naming the chat
and the person it came from. Such a card never hangs in a group where the
owner is not the one reading it. A card raised by a run that served nobody
(a cron-triggered pipeline, a heartbeat) stays in the chat it was given —
a scenario’s
chatbot.surface, say — since that chat is the one you chose. - Which bot posts it. A card in its own chat is posted by that chat’s bot.
A card that goes to the HITL chat (
TELEGRAM_HITL_CHAT_ID, else the primary owner’s DM) — moved there as above, or raised with no chat at all (a cron run, MCP, the dashboard) — is posted by the chief’s bot, the bot whose role ischief.role. One exception: a card with no chat raised by an agent of a persona role (a role a bot inbots[]runs that is notchief.role, such as a persona’s heartbeat) goes to that persona’s DM with the primary owner, posted by its own bot — even whenTELEGRAM_HITL_CHAT_IDis set — so the answer reaches the persona that asked. A brain whose bots all run other roles has no bot for those cards: they stay answerable from the dashboard and MCP, and the doctor’sSystem messagescheck fails.
Anyone else who presses gets a private notice, and the card stays pending.
Questions from sub-agents
An agent that another agent spawned (agent_task, agent_call,
agent_enqueue, agent_create) asks with the same AskUserQuestion. Two
things differ, both automatic:
The parent goes first. The question is usually one the parent can answer —
it wrote the task. So a sub-agent’s question waits for its parent for a head
start before any person sees it. The parent’s agent_task / agent_call /
agent_await returns status: 'awaiting_input' with (agent_get carries the same under current):
pendingHitl: {
hitlId,
kind, // 'question' | 'grant_request' | …
questions, // what the child asked (kind 'question' only, else null)
deliverAt, // ISO — the latest a person gets it; null = sent to a person at once
}
The parent then does one of three things:
| Parent | Tool | Effect |
|---|---|---|
| knows the answer | agent_answer({ agent_id, answer }) | the child resumes with Agent’s answer to your question…; no person is bothered |
| cannot answer | agent_escalate({ agent_id, note }) | the question goes to the person now; your note is shown above the child’s question — say what the person needs: the task, what the child did, what you need from them |
| nothing | — | at deliverAt the question reaches the person on its own |
Then agent_await the child again. There is no resume tool: an answer —
yours or the person’s — resumes the child. Both tools work only on your own
descendants and need nothing beyond agent:execute (answering your own
child’s question is no more than agent_enqueue sending it a message), so
there is no approval card for the parent.
The person gets it on their chat, with context. A sub-agent runs without a
chat of its own (otherwise its output would stream into the parent’s chat), so
its card goes to the chat of the nearest agent above it that has one — the
chat where the person who started the work actually is — and only falls back
to the HITL chat when no ancestor has a chat. The card says who asks
(name · role), delegated by <parent> with an excerpt of the child’s task,
and the parent’s escalation note when there is one. A business chat’s tree
still sends its cards to the owner’s DM, never to the counterpart.
Grant requests are not the parent’s. A child’s hitl_request_grant
or hitl_request_connection goes to the person at once (same chat, same
context) — a parent cannot approve it. Leave it to the person; a parent
holding hitl:write could only deny it via hitl_respond.
Tuning the head start — delegation.parentFirstMs, default 60000
(one minute); 0 means “ask a person immediately”:
# constitution/MANIFEST.yaml — every delegated agent
delegation:
parentFirstMs: 300000
# role-desktop-agent.md frontmatter — this role's questions only
delegation:
parentFirstMs: 0 # it works on the person's live PC: they are the one to ask
It belongs to the ASKING role — the child’s — since what the child asks about
decides who should answer. There is no per-call knob: a parent that wants a
person now uses agent_escalate, and one that wants no questions at all
spawns the child with interactive: false. A question reaches its person up to
a minute after deliverAt (the release sweep runs every minute). Value tiers:
contract.md § 9.
Detached: hitl_request_approval
Sends a card and returns the hitlId immediately. The tool provides the hitl:request
capability (safe, no gate of its own): a role offers it only when its capabilities:
lists hitl:request (or '*') — without it the agent is not shown the tool, and a call
is refused as not_offered.
hitl_request_approval({
title: 'Review draft',
message: '...', parseMode: 'markdown',
actions: [ // omit for default Approve/Deny
{ id: 'send', label: '📤 Send', style: 'success' },
{ id: 'snooze', label: '💤 Snooze', style: 'muted' },
],
tags: ['outreach'], // routing discriminator for hooks
metadata: { draftId: 'd-42' }, // machine context for whoever consumes the response
cancellable: false, // drop the Cancel row
onReply: 'conversation', // optional — see consumer 3
})
The response is a structural union: a button tap yields { actionId }, a
free-text reply on the card yields { customReply }. A tap from a surface
with a text field may also carry note — a short why (the dashboard’s “Skip
with a note” on a cycle card); a Telegram button tap never does.
A page that decides it. An action may open a page instead of answering —
{ id: 'review', label: '🧩 Review', widget: { file: 'ui://engine/review', input: { items } } }
— and the page answers the same card as the person at it, with that id (or
another of the card’s) plus structured data, held to the card’s
responseSchema when it declares one. The page closes when the card is
decided anywhere. Input requests offer a form the same way (the engine’s
ui://engine/form by default). Details, the engine’s pages and writing your
own: widgets.md § 12.
Consuming the response — three composable consumers
The engine does nothing with a detached response by itself — you declare what consumes it. All three compose; pick per card:
1. Hook on hitl:responded — structured downstream action (send the
draft, update the CRM). A *.hook.md in your brain:
---
event: hitl:responded
filter:
kind: approval
tags: outreach # matches the card's tags array (contains semantics)
agent:
role: outreach-handler
---
Approval {{decision}}. Request: {{payload}}
Response: {{response}}
If response.actionId is "send", send it; on customReply, revise first.
The event body carries the full snapshot (decision, response, payload,
context, lifted tags) — no DB read needed. Each fire mints a fresh agent.
Placeholders are the body’s top-level names, not paths — for this event
{{hitlId}}, {{decision}}, {{response}}, {{respondedBy}}, {{kind}},
{{producer}}, {{agentId}}, {{payload}}, {{context}}, {{tags}}, plus
{{eventName}} and {{hookName}} which every hook body gets.
Anything that is not a plain string renders as JSON — hand the agent the whole
object and let it read it. A dotted {{payload.metadata.id}} resolves to
nothing and is left in the prompt verbatim, deliberately, so the typo is
visible instead of silently empty. The same happens to a name that is simply
absent on this fire — {{response}} on a cancelled card, {{tags}} on a card
that carried none — so write the template against the fields you know are
there, or accept a literal {{tags}} in the prompt.
2. Poll later with hitl_list — a future run reads resolved rows on its
own schedule (e.g. a morning heartbeat calibrating on yesterday’s answer):
filter status: ['answered'], kind: ['approval'], window: 'yesterday' (the
requests decided yesterday — a window reads when a request was decided, and never
hides a pending one), sort: 'decided' (newest decided first), then
detail: 'full' on the row you need (or hitl_get on its id) — response holds the union above, payload.tags
identifies your cards. No hook required; silence is a valid outcome.
3. Conversation — onReply: 'conversation' (engine ≥ 0.189). For cards
that live inside an ongoing chat relationship (a persona’s daily card in the
owner’s DM). A free-text reply is first recorded on the row exactly as above —
consumers 1 and 2 see customReply unchanged — and is then handed to the
chat agent that owns the conversation on that surface, as a normal chat turn:
the text exactly as written, the card quoted above it, and a reply link to
the card’s feature — a card raised by a feature run (a heartbeat’s daily card)
attaches that feature’s reply contexts for the turn and its keep follow-ups
(contexts.md § 2.1). That context is where the rules for
answering the card belong; the card’s metadata is not shown to the persona.
The persona answers in-session, with full history. A reply finds its card
however old it is, as long as the card is pending; once decided, a reply is an
ordinary message (it still carries the feature’s link). Button taps never
converse — a tap is data, not a message. Without
this flag a free-text reply is write-only until something reads the row, and
the card’s footer hint says so honestly (“…for a custom response” vs “…to
continue the conversation”).
Choosing: hook when the response drives an action; poll when the response tunes a future run; conversation when a human wrote to a persona and expects the persona to answer. A card whose reply has no declared consumer is a design smell — the user types into the void.
Reading the queue
hitl_list filters by status, kind and producer (each a list: any of
them), agentId, runId (any execution: a workflow or pipeline run, or a
tick’s or fire’s agent), responder, capability (grant requests naming it),
q (text in the title, message, questions, reason or capabilities, the asking
agent’s name, its run’s slug or title, its scenario’s name), stuck (decided,
yet whatever waited was never woken), closesWithinHours (pending, closing
unanswered within that many hours), feature / entity and a window (default
all): a window holds the requests decided in it and every pending one —
never the ones opened in it. It sorts oldest (the default for
status: ['pending']), newest, expiry (soonest to close first), risk or
decided (newest decided first, closed requests only), and pages with cursor
— a cursor of another sort is refused, and so is a risk cursor after the
capability policy changed (start the read again). facets: true adds the counts
per kind, producer, agent, responder, capability and status, and stuck.true /
closing.true (within closesWithinHours, else 24 hours), each under the other
filters. hitl_get reads one request whole: what it
links to (the person, the session that opened it, its run, task, scenario,
thread, planning session, pipeline step) and its delivery (attempts, the last
error, a timeline, stuck).
Every row says, beside requestStatus:
| Field | Means |
|---|---|
requestKind | What it asks, in words: question, permission (a grant), approval, input, sign_in, admission (a bot added to a chat — answered in that chat) |
risk | safe · moderate · dangerous · restricted — a grant takes its riskiest capability’s risk under your engine-capabilities.json (a capability no policy names reads restricted); a budget card takes budget:widen’s (raising a limit spends real money); any other approval is moderate, the rest safe |
allows | The verdicts it takes now: approve, deny, answer, edit (approve with edits), reply (an approval’s customReply), cancel — none once closed; an approval with cancellable: false has no cancel |
bulkable | Whether hitl_respond_bulk may decide it: a pending grant, or an approval with a one-tap pick — the default Approve / Deny, or exactly one action of the best style present (success, then primary, unset, muted; deny: exactly one danger). Two actions sharing that style are a choice (a rating’s scores, a budget’s raise amounts), so the row takes no approve and no bulk answer. A row that is not bulkable is skipped by a bulk answer and named in its skipped |
expiry | { at, consequence? } when it closes unanswered — the consequence is the producer’s (a pipeline’s waiting step fails, a workflow input’s run does not start) |
stuck | Decided, yet whatever waited on it was never woken after a failed attempt — the engine keeps retrying |
grantTerms | A grant request’s terms: the scope approving it mints and lifeHours, how long the grant lives when the answer sets no life |
outcome | Once closed, how: verdict (approved · approved_part · denied · answered · replied · submitted · signed_in · sign_in_failed · closed · expired), label in words (“Approved 2 of 3”, the card’s own action, “Expired”), a grant’s approved n of m, the quote (the answer, the reply, the note, why it was closed) and responder (person · dashboard · boss · agent · other, null when the engine closed it); null while it waits |
An interception (a person driving a live workflow agent) is not a request:
hitl_list never answers one — it shows on its session (agent_get).
Pipelines
awaitApproval(ctx, opts) blocks the step and returns the response union
(cancel throws ApprovalCancelledError). Its card expires by default: a
waiting run keeps its journal across every deploy until someone answers, so a
card nobody answers must end. The deadline is 168 hours unless you say
otherwise — brain-wide with pipelines.approvalExpiresInHours in
constitution/MANIFEST.yaml (0 = no default deadline), per call with
expiresInMs (milliseconds, or null = never). A code change the waiting run
can no longer replay does not wait for the answer: the engine ends or restarts
the run at the change (onCodeChange, pipelines.md § Editing
a .pipeline.ts with in-flight invocations), and its card is cancelled with it. Expired, the card shows
Expired and the helper throws ApprovalCancelledError with an expired:
reason. requestApproval(ctx, opts) is the detached variant — same options
incl. onReply, same three consumers — and keeps no default deadline: nothing
waits on it, so expiresInMs absent means the card stays open until answered
(set one for high-volume batches), however long after its run completed. askQuestion / requestInput cover free-text
and schema-validated input. All render the same cards; nothing extra to wire.
awaitApproval / requestApproval take a widget (a page that decides the
card — ENGINE_WIDGETS.review for item-by-item decisions) and requestInput
offers a form where widgets are hosted (widgets.md § 12).