Steering a running agent¶
A session turn used to run the agent one-shot and wait for it to exit. A message sent while it ran was refused, the only way to stop it was to kill it, and the agent ran with its permission checks off. Claude Code and Codex turns now run live. While a turn runs you can:
- steer it: add a message to the running turn, and the agent reads it at its next step;
- interrupt it: stop the turn, or stop and carry on with a new message;
- approve its tool calls: with
approvals: ask, you allow or deny each command the agent wants to run.
Other harnesses (pi, Antigravity) still run one-shot. You can interrupt them (the run is stopped), but you can't steer them.
hangar session new api-fix "move the retry logic into the client" -e backend --approvals ask
hangar session steer api-fix "keep the public signature unchanged"
hangar session approvals api-fix --watch # what the agent waits on
hangar session approvals api-fix --allow ap-3f9c0a1b2c4d
hangar session interrupt api-fix "stop; write the failing test first"
hangar session send to a running Claude Code or Codex session is a steer now, not an error. If the turn ends before the message arrives, the message starts the next turn instead, so it is never lost. While it is still on its way (sending), it can be taken back (withdraw, below); the web console shows it as a queued row with a take-back until the agent has it.
Steer¶
The message is added to the running turn:
- Claude Code reads it with the next tool result.
- Codex gets it with
turn/steer.
The agent keeps the work it has done and adjusts. Each steer is recorded on the turn (steers, status delivered or queued for one that started the next turn) and in the session's events (progress, "steered: …").
Interrupt¶
interrupt without a message stops the turn. Its outcome is interrupted (exit status 130). The session is idle and the conversation is kept, so the next send continues it.
interrupt with a message stops what the agent is doing and gives it that message. On a live turn this all happens in the same turn and the same conversation. On a one-shot turn the run is stopped and the message becomes the next turn.
An interrupt always stops the turn. If the driver doesn't confirm it (no answer in time, or an answer it didn't sign), hangar kills the run instead, as it does for a one-shot turn.
On a job, an interrupt is a person taking over, not a failure:
- With a message, the job carries on with it: in the same turn when the turn is live, else as the job's next turn (a follow-up).
- Without one, the job pauses in
needs_input, with the question "Interrupted by … Follow up with what the agent should do instead, or cancel the job." Nothing is verified or delivered. Answer it like any question (follow_up_job,/hangar …on GitHub, or the MCP elicitation): the agent continues the same conversation. Cancel the job to end it.
Tool approvals¶
The policy is set per session (approvals on new_session, --approvals), per environment (approvals: in its YAML), or for the whole deployment (HANGAR_APPROVALS). The first one set wins. A fork, and each best-of-N attempt, keeps its parent's policy.
| Policy | What happens when the agent wants to run a command or edit a file |
|---|---|
auto (default) |
Nothing is asked. The harness runs with its permission checks off, as before. |
ask |
The agent waits. The request shows as pending_approvals on the session and its job, and as an approval_request event. Anyone with access answers allow, deny or allow_always. Unanswered after HANGAR_APPROVAL_TIMEOUT_SECONDS (default 600), it is denied. |
deny-risky |
hangar answers at once. It denies force-pushes and remote branch deletion, sudo/su, piping a download into a shell, recursive deletes of the working tree or anything outside it, system commands (mkfs, dd of=/dev/…, shutdown), reading or writing credentials and hangar's files (~/.ssh, .git-credentials, ~/.hangar, /proc/*/environ and mem) except the turn's own $HANGAR_IO, and file writes outside the repository and $HANGAR_IO. It allows everything else. The rules are a guard rail against mistakes, not a sandbox: paths are resolved against the turn's directory, so cd /elsewhere && rm -r x gets past them. |
governed |
The deny-risky rules and the taint gate sort the calls. What they flag waits while Tower decides; the rest runs at once. |
- Deny: the agent sees the denial, with your message if you gave one, and carries on: the turn does not fail.
allow_always: also allows calls like this one for the rest of the turn. For Claude Code these are the CLI's own suggestions with destinationsession; for Codex it isacceptForSession. Both live in the agent's running process: nothing is written to the repository's or the user's settings, and the next turn asks again (verified with both CLIs).- hangar's own hand-back commands are not asked about: under every policy but
auto, a shell call that is exactlyhangar-agent done,progress,askorresult '<json>'(one simple command: nothing chained with;,&&or|, no redirection, no$(...)or backquotes) is allowed by the policy at once, recorded as an approval resolvedby: policy. These only write the turn's files under$HANGAR_IO.hangar-agent artifact,result @FILE,env,svcand the computer commands, and anything chained to an allowed one (hangar-agent done ok; rm -rf ~), still ask. - What you are shown is the whole command, up to 2,000 characters. A longer one shows both ends with the cut marked ("[…N characters not shown…]"); deny what you can't see.
- No prompts: every policy but
autoneeds a live turn. A session on pi or Antigravity with one fails its turn rather than run with checks off. So does a sandbox withoutpython3(3.8 or later, withctypes), which the driver needs. The one repair run that asks for a missing structured result is skipped under these policies, for the same reason.
Answering from an MCP client¶
A client that supports elicitation is asked directly:
wait_for_turnreturns early when an approval is pending. For 2026-07-28 clients this is aninput_requiredresult with a form (decision: allow | deny | allow_always, and an optionalmessage). On handshake-era sessions it is anelicitation/createrequest. Once you answer, it keeps waiting. The result'sapprovalsays what happened:answered,declined,cancelledorstale.- Tasks turn
input_requiredwith the same form, under keys likeapprove-ap-3f9c0a1b2c4d; answer withtasks/update.
If you decline the form, you aren't asked about that approval again. It stays pending until someone else answers it or it times out.
A client without elicitation sees pending_approvals in wait_for_turn, get_session and get_job, and answers with answer_approval(ref, approval, decision, message). To steer or interrupt, use steer_turn(ref, message, interrupt=false), where ref is a job id or a session name.
Taint¶
Every turn records whether the agent has read untrusted content (a web page, an untrusted MCP server's result, an
untrusted issue): taint on the turn, the session and its job (security/taint.md). It is sticky
across turns, follow-ups and forks. Once a turn is tainted, under ask and deny-risky these count as risky as well:
- any
git push(bash -lc '...'is looked into), andgh pr create; curl,wgetorncto a host other than the sandbox itself;- the environment's MCP write tools:
mcp.<name>.write_tools, fnmatch patterns of the server's tool names (write_tools: ["create_*", "update_*", "merge_*"]).
deny-risky denies them with "after reading untrusted content"; ask asks a person, and the request carries taint
(why it asks). auto changes nothing. Intake jobs should run under deny-risky: they are tainted from turn 1
(security/taint.md). The driver hears of the taint at the turn's start (--tainted), from
hangar's signed taint command mid-turn, or from a web tool it sees in the stream; nothing clears it.
Classifying steers¶
A steer is not always the agent's fault. The person may have changed the task, or remembered something the ticket never said. So that a harness is judged only on the interventions it made necessary, each steer, interrupt and job follow-up carries a class (#162):
| Class | Meaning |
|---|---|
correction |
the agent got it wrong |
redirect |
the person changed the scope or the approach |
new_info |
the person added something the task didn't say. An answer to the agent's own question (needs_input) is this by default: the agent asked |
unclassified |
nobody said (the default for everything else, including steers and interrupts sent over REST or MCP) |
Say which:
- Slack: click Correction, Redirect or New info under hangar's "Continuing…" reply to your thread message. Only the person who sent the reply can classify it; anyone else's click gets a private refusal (slack.md).
- Slack and Jira: start the reply with
fix:(orcorrection:),redirect:orinfo:. It is classified on arrival and the prefix is stripped from what the agent gets. - Jira:
/hangar class correction|redirect|infoclassifies your last unclassified follow-up on the issue (intake.md). - REST:
POST /api/jobs/{job_id}/turns/{n}/class(the job's submitter, or admin;nis the 1-based index in the job'sturns) andPOST /api/sessions/{ref}/steers/{steer_id}/class(admin), body{"class": "correction"}. There is no MCP tool: the tool list is at its cap.
The class, classified_by and classified_at are on the turn or steer record. Each classification is a steer.classified progress event in the session's activity, and the trace export puts hangar.steer.class on steer and interrupt events (with hangar.seq, their place in the event log) and on follow-up turn spans; the run records in the outbox carry it. It is self-reported and optional: the weekly metrics show the unclassified share.
REST¶
| Route | Body |
|---|---|
POST /api/sessions/{ref}/steer |
{"message": "..."} |
POST /api/sessions/{ref}/steers/{id}/withdraw |
none: takes back a message that is still sending (a send to a busy session, on its way to the agent). Once it is delivered or queued there is nothing to take back; interrupt instead. The steer's status becomes cancelled. |
POST /api/sessions/{ref}/interrupt |
optional {"message": "..."} |
GET /api/sessions/{ref}/approvals |
the running turn's policy, pending and recent approvals |
POST /api/sessions/{ref}/approvals/{id} |
{"decision": "allow" \| "deny" \| "allow_always", "message": "..."} |
The same four routes exist under /api/jobs/{job_id}/… for the job's submitter (a jobs-scoped token) and for admins. The session routes are admin only. Classifying: POST /api/sessions/{ref}/steers/{id}/class and POST /api/jobs/{job_id}/turns/{n}/class with {"class": "correction" | "redirect" | "new_info"} (above).
Verified¶
Against the real CLIs, offline, with scripted model APIs (tests/test_driver_real_clis.py; set HANGAR_TEST_CLAUDE / HANGAR_TEST_CODEX):
| CLI | What was checked |
|---|---|
| Claude Code 2.1.296 | Steering mid-turn (command_lifecycle, the steer surfaced in the next request); can_use_tool allow, deny (an error tool result, then the turn goes on) and updatedPermissions; interrupt (control_cancel_request for open prompts, the result error_during_execution, the process takes the next message); cumulative total_cost_usd and modelUsage; --resume and --fork-session; Read outside the working tree asks first (so deny-risky can refuse credentials); allow_always writes no settings file and a resumed turn asks again |
| codex 0.162.1 | initialize / initialized / thread/start / turn/start; turn/steer (-32600 "no active turn to steer" when idle); turn/interrupt (status interrupted, the approval resolved via serverRequest/resolved); item/commandExecution/requestApproval with accept and decline; thread/tokenUsage/updated totals; thread/resume and thread/fork; cat of a file outside the working tree asks under untrusted; acceptForSession does not outlive the process (a resumed turn asks again) |
pi 1.2's --mode rpc has prompt, steer and abort but no permission prompts. It is not wired up yet, so pi turns run one-shot.
Moved from this page¶
| Section | Now on |
|---|---|
| How it works | How steering works |
| Trees | How steering works |
| The uid split | The uid split |
| hangar-owned harness settings | hangar-owned harness settings |