camelAI Documentation

Guides

Human input

Approvals, questions and forms that suspend a run until a person answers

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.

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)) 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. The person sees the real call, and an approved call runs with exactly those arguments.
  • Questions. The ask_user built-in 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.

Answering

input.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
urltrue once the person has done what the page asks

input.decline() declines any input. 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.
  • 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": ...}.
  • onInput on upsert hears each input as it's asked. Return an answer to give it right away, or nothing to answer later.
  • Events input_required and input_resolved, and the webhooks input.requested and input.resolved.

Who may answer

By default, the person whose message started the turn, plus the definition's humanInput.approvers. An answer must say who is answering (from) whenever the input names who may. 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: 7 days by default, at most 30.
json
{"name": "Ops", "builtins": ["ask_user"],
 "humanInput": {"expiresInSeconds": 86400, "approvers": ["slack:U0123"]},
 "mcpServers": [{"name": "github", "url": "...", "approval": {"tools": {"delete_repo": "always"}}}]}

In Slack, Telegram or Discord, a suspended turn's reply ends with its first input as text. The next message from someone allowed to answer, if it fits (an option's number, approve or deny, done), is the answer. See Channels.