camelAI Documentation

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"}}. destructive means MCP tools annotated destructiveHint, 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_user built-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) and context.requireUrl(url, message) (a step on an https page, such as connecting an account).
ts
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:

KindValue
approvaltrue to approve, false to decline
questionThe chosen option's label (or labels, or free text where allowed). For several questions, { "<question>": answer }
formThe fields, as an object, checked against the form's schema. A confirmation (context.confirm, a form without fields) takes true or false
urltrue 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() (Python pending_inputs()) lists an agent's waiting inputs, each with answer().
  • agents.runtime.inbox("pending") lists inputs across all your agents, for an approvals queue.
  • Over REST: GET /v1/agents/{id}/inputs?state=pending, then POST /v1/agents/{id}/inputs/{inputId} with {"action": "accept" | "decline" | "cancel", "content": ..., "from": ...}. A question's content is {"answers": {"<question>": "<label>" | ["<label>"] | "<own words>"}}, and a form's is its fields. The reply is {input, request}, where request is the resumed run (null until the last input is answered). Poll it like a prompt.
  • onInput (Python on_input) on upsert hears 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_required and input_resolved, and the webhooks input.requested and input.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_user returns {"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 state is expired, its answer.by is {system: "expired"}, and the agent's input_resolved event and the input.resolved webhook say the same. An answer that comes later is a 409 (This input expired: no one answered in time…); send a new message instead.

json
{"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.