Guides
Human in the loop: agent approvals on camelRun
Human-in-the-loop approvals, questions and forms for camelRun agents: the run suspends, waits minutes or days with nothing billed, then resumes.
View as Markdown · Docs index for coding agents
A run can stop and wait for a person: the model asks a question, a tool needs
approval before it runs, or a tool asks for a form or a setup step. The run
suspends. It ends with status: "input_required" and the inputs it waits
on, and the agent sleeps (nothing is billed while it waits). Once the last input
is answered, minutes or days later and from any process, the turn resumes. The
model sees an ordinary tool call and its result, told who answered and how long
it took. For
a short tour, see human-in-the-loop AI agents on camelRun.
let run = await agent.run("Delete the staging database", { user: "alice" });
while (run.status === "input_required") {
const input = run.inputs[0];
console.log(input.kind, input.message); // "approval", "Allow db__drop to run?"
run = await input.answer(true, { from: "alice" }); // resolves with the resumed run
}
console.log(run.text);Where inputs come from
- Approvals. A tool with
needsApproval: true(@tool(needs_approval=True)), or a function of its arguments that decides per call, is approved before each call. A definition's MCP and OpenAPI sources take an approval policy:"approval": {"default": "never" | "always" | "destructive", "tools": {"delete_repo": "always"}}.destructivemeans MCP tools annotateddestructiveHint, and OpenAPI operations other than GET, HEAD and OPTIONS. OpenAPI also takes"methods": ["POST", "DELETE"]. The person sees the real call: the tool, its source and its arguments. An approved call runs with exactly those arguments and carries the approval (context.identity.approval), so a tool can require it. A declined call never runs. - Questions. The
ask_userbuilt-in ("builtins": ["ask_user"]on the agent or its definition) lets the model ask 1 to 4 multiple-choice questions, with free text where it allows. - The tool itself.
context.confirm(message),context.ask(message, schema)(a flat form) andcontext.requireUrl(url, message)(a step on an https page, such as connecting an account).
const deleteApp = tool({
description: "Delete an app",
input: schema.Object({ app: schema.String() }),
execute: async ({ app }, context) => {
if (!await context.confirm(`Delete ${app}? Its URL stops working.`)) return { cancelled: true };
return apps.delete(app, { idempotencyKey: context.idempotencyKey });
},
});An ask ends the call. Once the person answers, camelRun calls the tool again
with the same arguments and the ask returns the answer, so everything before
an ask runs again. Ask first and act after, and key side effects by
context.idempotencyKey, which is the same on both calls.
Code in js_exec can't wait for days. A call from code that would ask fails
with "needs the user's input: call it directly", so tools that ask are always
offered to the model directly.
Answering
input.answer(value, { from }) (Python answer(value, from_=)) takes a plain value by the input's kind:
| Kind | Value |
|---|---|
approval | true to approve, false to decline |
question | The chosen option's label (or labels, or free text where allowed). For several questions, { "<question>": answer } |
form | The fields, as an object, checked against the form's schema. A confirmation (context.confirm, a form without fields) takes true or false |
url | true once the person has done what the page asks, else false |
input.decline() declines any input. Answering resolves with the resumed run.
When the run also waits on other inputs, it resolves with the run still
input_required, listing those. Answering the same way twice is safe; a
different answer to an input that already settled is a 409 that says how it
settled.
Inputs outlive your process, so you can answer later from anywhere:
agent.pendingInputs()(Pythonpending_inputs()) lists an agent's waiting inputs, each withanswer().agents.runtime.inbox("pending")lists inputs across all your agents, for an approvals queue.- Over REST:
GET /v1/agents/{id}/inputs?state=pending, thenPOST /v1/agents/{id}/inputs/{inputId}with{"action": "accept" | "decline" | "cancel", "content": ..., "from": ...}. A question'scontentis{"answers": {"<question>": "<label>" | ["<label>"] | "<own words>"}}, and a form's is its fields. The reply is{input, request}, whererequestis the resumed run (null until the last input is answered). Poll it like a prompt. onInput(Pythonon_input) onupserthears each input as it's asked. Return an answer to give it right away, or nothing to answer later.- An MCP client talking to the agent through its MCP endpoint is asked through elicitation, when it supports it.
- Events
input_requiredandinput_resolved, and the webhooksinput.requestedandinput.resolved.
A background sub-agent that waits on a person tells its parent so. Answer its inputs as you would any agent's, by the sub-agent's id.
Who may answer
By default, the person whose message started the turn (the run's user, or its
actor), plus the definition's humanInput.approvers. An answer must say who
is answering (from) whenever the input names who may (responders.audience,
or the definition has approvers). The SDKs refuse to send an answer without it,
and the API refuses it with a 400. A from who may not answer gets a 403. The
model has no way to answer.
Waiting and giving up
-
A new message to the agent supersedes its waiting inputs. Their calls close with "Not answered: the user sent a new message instead", then the message runs.
-
agent.abort()cancels them. -
Inputs expire after
humanInput.expiresInSeconds: 900 (15 minutes) by default, 60 to 2,592,000 (30 days), never past the agent's own expiry. Then the runtime answers for the person, and the model goes on with its turn. The answer says it is automatic, not the user's:- an approval is never given: the call does not run, and its result is
The user did not respond in a timely manner, so this call was not approved and did not run. Continue without it, or ask again later.; ask_userreturns{"answers": {}, "timedOut": true, "message": "The user did not respond in a timely manner. Continue. …"};- a tool's own request (a form, a URL,
context.confirm) is cancelled: the tool is not called again, and the model reads that the request was cancelled.
The input's
stateisexpired, itsanswer.byis{system: "expired"}, and the agent'sinput_resolvedevent and theinput.resolvedwebhook say the same. An answer that comes later is a 409 (This input expired: no one answered in time…); send a new message instead. - an approval is never given: the call does not run, and its result is
{"name": "Ops", "builtins": ["ask_user"],
"humanInput": {"expiresInSeconds": 86400, "approvers": ["slack:U0123"]},
"mcpServers": [{"name": "github", "url": "...", "approval": {"tools": {"delete_repo": "always"}}}]}In a channel
In Slack or Telegram, a suspended turn's reply ends with its first
input as text: a question's numbered options, the call to approve, the URL, or
a confirmation. The next message from someone allowed to answer, if it fits (an
option's number or label, approve or yes, deny or no, done), is the
answer. Anything else is a new message. See
Channels.