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, or with “nobody answered” after 24 h unless you say otherwise |
| 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; with wake: true the answer comes back to it as a message (the card expires after 168 h unless you say otherwise) |
| 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.
A question has a deadline. While it waits, the agent holds its turn lock —
it is deaf to other agents’ agent_send, to its watches, its scheduled prompts
and the brief, everything but a person writing in its chat. So an unanswered
question ends: after 24 hours by default, brain-wide
hitl.questionExpiresInHours in constitution/MANIFEST.yaml (0 = no default
deadline), per call expiresInHours (null = wait forever); a planning
session’s question has no default deadline. Expired, the
agent resumes and is told nobody answered in time — and that silence is never
consent: a question that asked for permission or confirmation counts as
declined, so the agent does not do that action; otherwise it takes a fallback
that needs no consent, stops and reports, or asks without blocking (a detached
card with wake: true). An autonomous role (a chief or lead that runs the
operation for days, not a chat with a person) should set
promptFlags: { questions: false } and ask only with a detached card and
wake: true (§ Assign a task to a person): it
never blocks and never loses the answer.
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
wake: true, // optional — see consumer 4
expiresInHours: 48, // optional — default 168, null = never
})
A card has a deadline: 168 hours unless you say otherwise — brain-wide
hitl.approvalExpiresInHours in constitution/MANIFEST.yaml (0 = no default
deadline), per call expiresInHours (null = never). Expired, the card shows
Expired, a hook sees decision: 'cancelled' with an expired: reason, and a
wake card tells its agent. Halfway through its life (hitl.remindAtFraction,
0.5; 0 = no reminders) the person gets one reminder — a reply to the card in
its chat, which they can answer like the card itself — when someone waits on the
card: a question, a wake card, a pipeline’s awaitApproval. A card nobody
waits on (no wake; a pipeline’s requestApproval) only expires: a
fire-and-forget batch or a daily card does not nag.
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 — four composable consumers
The engine does nothing with a detached response by itself — you declare what consumes it. They compose; pick per card (one overlap: a chat reply on a conversational card is already a turn of its own, so it does not also wake; a reply typed on the card’s page or in the dashboard starts no turn and does):
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”).
4. Wake the asker — wake: true. For a card the agent itself needs the
answer to. When the card is answered, cancelled or expires, the agent that
sent it gets one short message in its chat: the card’s title and hitlId, the
tapped action (its label, a note, a page’s data) or the reply — clipped to
1000 characters, the whole answer stays on the row (hitl_get) — and who
answered; or that it expired or was closed, and why. The message queues behind
the agent’s chat like any message and never interrupts a turn; it arrives once
per card, however the engine retries. A card the agent decided itself
(hitl_respond / hitl_cancel) wakes nobody, and neither does a free-text
reply in the chat on an onReply: 'conversation' card — consumer 3 already
handed it to the chat’s agent as a turn (a button tap there still wakes).
Default off: a batch of outreach cards handled by a hook should not wake the
agent fifty times.
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; wake when the agent that asked acts on the answer itself. A card whose reply has no declared consumer is a design smell — the user types into the void.
Assign a task to a person
A person is the agent’s hands for what it cannot do itself — pass a KYC, call a
bank, sign, fetch an API key. There is no task kind: a task is an approval card
with outcome buttons, a deadline and wake:
hitl_request_approval({
title: 'Create the Stripe account',
message: 'Sign up at https://dashboard.stripe.com/register with ops@acme.com, then tap Done.',
actions: [
{ id: 'done', label: '✅ Done', style: 'success' },
{ id: 'cant', label: "🚫 Can't", style: 'danger' },
{ id: 'later', label: '⏳ Later', style: 'muted' },
],
expiresInHours: 72,
wake: true,
})
Add a form when you need data back — an action with
widget: { file: 'ui://engine/form', input: { schema } } and the card’s
responseSchema (widgets.md § 12). When the wake message
arrives the agent checks the result itself with its own tools (the Stripe API,
the mailbox, the site) — “done” is the person’s word, not proof. later or an
expiry: send a new card with a new deadline, or take the fallback.
A secret: secret_request
When an agent needs a key the brain declares in defineProject({ env }) — an
API token, a password — it never asks for the value in chat (a chat message
lives in the conversation, the transcript and every later turn’s context). It
calls secret_request({ name, why, howToGet? }) (capability secret:request):
- The engine sends a card — ”🔑
NAMEis needed”, yourwhyandhowToGet— whose button opens the engine pageui://engine/secret. It goes where the agent is talking, like any card — to the default HITL chat when nobody there may store a key. The agent is not paused; it keeps working. Onlysecret_requestsends such a card: any other request offering that page is refused. - An owner types the value on that page (Telegram owner tier or the dashboard;
an MCP host cannot store one). It is held to the variable’s own schema (a
number knob refuses “20 per day”, and the page says why without repeating
the value); the engine stores it encrypted in its own table and closes the
card with
{ name, stored: true }— the value is never the card’s answer, never an event, a log line or a tool result. The card takes no text reply (a reply is refused unread — it is likely the key itself) and no answer from an agent. - The card is a
wakecard with the approval deadline (hitl.approvalExpiresInHours): the asking agent gets one short message in its own chat when it closes — answered with{ name, stored: true }, or expired / cancelled without a value. From then on the value is in every engine process’s environment —env.NAME,process.env.NAMEand every shell, CLI agent or MCP server those processes start. Code that reads it per call sees it at once; a value read once at startup (a client built at module load) needs a restart.
The tool refuses a name the brain’s env schema does not declare (declare it
optional — z.string().optional() — to ask for it), every engine variable
(DATABASE_URL, CONNECTIONS_TOKEN_ENC_KEY, …) and a variable the deployment
environment already sets: the environment always wins, so an admin can
override a stored value. A stored value counts as set for env validation at
boot, so a required variable can be filled this way. credential_list lists
stored secrets as brain-secret:<NAME> (in the variable’s lane) — name and
date, never the value — and flags one that is stored yet not read (it does not
decrypt, its schema refuses it, the brain no longer declares it). Storing the
same name again replaces it; secret_delete({ name }) (secret:delete,
approval-gated by default) drops it, and the variable reads as unset again.
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 hitl.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, the same hook / poll / conversation 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).