Human in the loop
Let an agent ask a human, and guarantee it never gets stuck waiting
An agent driving an environment sometimes needs a person: to confirm a deploy, to pick between two plans, to say whether a diff looks right. Fuse gives that a first-class shape. The agent raises a request, a human answers it from the CLI or the API, and the answer goes back to the agent. Every request has a deadline, so whatever the human does (or does not do), the agent always gets an outcome and carries on.
This page covers the core loop and the four request kinds an agent can
raise: ask for a decision, review for a verdict on a file or a diff,
secret for a credential, and takeover to hand a human the shell or the
desktop. Then gates that hold exec, lifecycle and computer actions until a
human decides, deciders that answer for the human when they can, the stall
and expiry signals Fuse raises on its own, and the access model around all
of it: who may answer, how a person is named, the audit log and webhooks.
Concepts
An intervention is one request for a human. It has an id, belongs to an
environment, and moves exactly once from pending to a terminal state:
| State | How it got there | decision.outcome |
|---|---|---|
approved |
A human answered it. | approve |
rejected |
A human rejected it, with a reason. | reject |
expired |
Its deadline passed with no decision. | from on_timeout |
cancelled |
Its environment was destroyed while it was pending. | reject |
Every terminal intervention carries a decision, so the agent only ever
reads decision.outcome (and decision.answer for an approved ask, or
decision.reason for anything else). Expiry and cancellation are decided by
the actor system.
Invariants
These are what keep an agent from getting stuck:
- Every intervention has a deadline. The default is 10 minutes, the range is 10s to 24h. There are no unbounded waits.
- Every pending intervention reaches a terminal state, and a waiting caller always sees it. A read past the deadline expires the intervention on the spot; a sweep on the reconcile loop catches the rest.
- Destroying an environment cancels its pending interventions.
- A pending intervention keeps its environment alive. The idle reaper
(
resources.idle_timeout) skips an environment while a human is deciding. - State lives in Postgres, not in the connection. An orchestrator restart or a dropped client loses nothing: the caller polls the same id again and gets the decision.
- A rejection always carries a reason the agent can read and replan on.
A migrate carries pending interventions to the new environment id. A fork inherits the policy but starts with no pending interventions.
Declaring it
Add a hitl: block to the Fusefile. Declaring a request kind, even as {},
is what allows it; without the block every request is refused.
version: 1
run: ./agent.sh
hitl:
timeout: 10m # default deadline, 10s to 24h
on_timeout: reject # or allow
requests:
ask: {}
on_timeout: allow resolves an expired ask as approve with an empty
answer. Use it only when “nobody objected” really is a yes.
The block is enforced by the orchestrator; nothing of it goes into the guest.
fuse compile shows it under hitl in the create request, and raw API
callers send the same shape as hitl on POST /v1/environments:
{
"hitl": {
"timeout_seconds": 600,
"on_timeout": "reject",
"requests": { "ask": {} }
}
}
Asking
The agent raises an ask with a question and, optionally, a fixed set of choices. A per-request timeout can shorten the deadline, never extend it. The SDKs wait for the outcome by default.
in, err := client.Environments.RequestHuman(ctx, envID, fuse.HumanRequest{
Question: "Deploy build 812 to prod?",
Choices: []string{"yes", "no"},
})
if err != nil {
return err
}
if in.Approved() && in.Decision.Answer == "yes" {
deploy()
} else {
log.Printf("not deploying: %s", in.Decision.Reason)
}
Set NoWait: true to get the pending intervention back at once and wait on
it later with client.Interventions.Wait(ctx, in.ID). The other SDKs have
the same call: requestHuman in TypeScript, request_human in Python and
Rust.
Under the hood this is two calls:
# raise: 201 with the pending intervention
curl -X POST "$FUSE/v1/environments/$ENV?action=request" \
-H "Authorization: Bearer $TOKEN" \
-d '{"kind":"ask","question":"Deploy build 812 to prod?","choices":["yes","no"]}'
# wait: long-polls up to 50s, always 200, loop while state is pending
curl "$FUSE/v1/interventions/$ID?wait=30s" -H "Authorization: Bearer $TOKEN"
An undeclared kind, or an environment created without a hitl: block, is a
400 that says so.
Answering
From the terminal:
fuse hitl list # pending, across every environment
fuse hitl list --env fuse-agent --state all
fuse hitl show int_0187...
fuse hitl answer int_0187... yes
fuse hitl reject int_0187... --reason "build 812 failed QA, wait for 813"
From the API, POST /v1/interventions/{id}?action=approve with
{"answer": "yes"}, or ?action=reject with {"reason": "..."}. The SDKs
expose this as interventions.approve and interventions.reject.
An answer must be one of the ask’s choices when it has any. Deciding an intervention that is already resolved, including one whose deadline passed a moment before the decision arrived, is a 409.
Deciding requires the hitl:decide scope or the master token. A
full-access API key can raise a request but cannot answer one, so an agent
driving Fuse with its own key cannot approve itself. Give the human a scoped
key instead, as below.
Reviews
A review asks a human for a verdict on something the agent produced: a
patch, a migration, a config file, a draft. The human approves or rejects
it, and their comments go back to the agent either way.
hitl:
requests:
review: {}
The agent sends exactly one of:
path: an absolute, clean guest path. Fuse reads the file at request time, up to 1 MiB, so the human reviews what the agent submitted even if the file changes afterwards. A longer file is cut at 1 MiB and the review is markedtruncated. A file Fuse cannot read is a 400.content: the text inline, at most 1 MiB.
format is text or diff. Left empty, Fuse guesses: diff when the
content starts like a unified diff (diff --git, or --- followed by a
+++ line) or the path ends in .diff or .patch, else text. message
is the agent’s note to the reviewer.
r, err := client.Environments.RequestReview(ctx, envID, fuse.ReviewRequest{
Path: "/work/migrations/0042_add_index.sql",
Message: "adds the index the slow query log asked for; safe to run online?",
})
if err != nil {
return err
}
if !r.Approved {
return fmt.Errorf("review rejected: %s", r.Comments)
}
log.Printf("approved, reviewer says: %s", r.Comments)
TypeScript has the same call as requestReview, Python and Rust as
request_review. From the API it is the
ordinary request:
curl -X POST "$FUSE/v1/environments/$ENV?action=request" \
-H "Authorization: Bearer $TOKEN" \
-d '{"kind":"review","path":"/work/migrations/0042_add_index.sql","message":"safe to run online?"}'
The intervention’s payload.review holds what is up for review:
"payload": {
"review": {
"path": "/work/migrations/0042_add_index.sql",
"content": "CREATE INDEX CONCURRENTLY ...",
"format": "text",
"message": "safe to run online?"
}
}
The human reads it with fuse hitl show, which prints the content under the
details, and decides with a reason as the comments:
fuse hitl show int_0187...
fuse hitl approve int_0187... --reason "fine, but run it outside business hours"
fuse hitl reject int_0187... --reason "CONCURRENTLY cannot run inside a transaction; split it out"
The comments are decision.reason on both outcomes. A review takes no
answer, edited_argv, edited_text or grant. Like every intervention
payload, the content is encrypted at rest with TOKEN_ENCRYPTION_KEY.
Gates
A gate holds an action Fuse relays until a human decides, records it, or
refuses it. Three surfaces have gates: exec (SDK and CLI exec),
lifecycle (environment API actions) and computer (the
computer actions a computer-use agent sends).
hitl:
timeout: 10m
gates:
- name: force-push
on: exec
match: { program: git, args: [push, --force] }
grant: ttl:30m # an approval covers repeats for 30m
timeout: 2m
- name: watch-shell
on: exec
match: { shell: true }
mode: notify # let it through, record it
- name: no-self-destruct
on: lifecycle
match: { action: [destroy, migrate] }
mode: deny
reason: this environment is not the agent's to destroy
Gate reference
| Field | Meaning |
|---|---|
name |
Names the gate on interventions, grants and webhooks. 1-64 of a-z 0-9 - _ . |
on |
exec, lifecycle or computer; stall and expiry are signals. |
match.program |
exec: the basename of argv[0], one name or a list, after known wrappers are stripped. |
match.args |
exec: arguments that appear in argv in this order, not necessarily next to each other. |
match.shell |
exec: any opaque shell exec, a shell: string or sh/bash -c. Cannot be combined with program or args. |
match.action |
lifecycle: destroy, fork, migrate, drain, snapshot, rotate-token. computer: a computer action name, or a list. |
match.text_contains |
computer: a type action whose text contains any entry, ignoring case. |
mode |
ask (default) holds until decided; notify lets it through and records it; deny refuses it with reason. |
grant |
Default reach of an approval: once (default), env or ttl:<duration>. Only for ask. |
timeout |
Deadline for this gate, overriding hitl.timeout. |
reason |
Returned to the caller on deny, shown to the human on ask. |
decide |
A decider asked before a human: url and secret. Only for ask. |
Every field set in match must match. When several gates match one action,
deny beats ask beats notify; each matching notify gate still leaves a
record. Start a new gate as notify, read what it would have caught, then
tighten it to ask.
Wrappers. env (and its VAR=x arguments), sudo, nice, timeout,
xargs, nohup and stdbuf are stripped before matching, so
env GIT_TRACE=1 git push --force and sudo git push --force are both
git push --force to a gate.
What a held call sees. The API answers 202 with
{"intervention_id", "intervention"}. When a human approves, the
orchestrator performs the action itself, exactly as approved, and stores the
outcome on the intervention’s result: result.exec for an exec,
result.computer for a computer action, result.env_id for a fork or
migrate, result.snapshot_id for a snapshot, result.error when the
approved action failed. GET /v1/interventions/{id}?wait= returns once that result is in. A deny
answers 403 with details.reason: hitl_denied, the gate in
details.gate, and the gate’s reason as the message.
Across an orchestrator restart. With restate off, an approved action the
orchestrator was running when it stopped is settled with result.error saying
it may or may not have run. With restate on (FUSE_RESTATE_MODE=self or
cloud), the approval is a restate Intervention/{id}.Run invocation and
the restarted orchestrator carries it on: an exec runs once (the host agent
replays a command it already finished instead of running it again; an exec
whose first attempt is more than an hour old gets the may-or-may-not error
instead, since the agent may have forgotten it), and a
destroy, snapshot or token rotation runs as that operation’s own durable
handler. A computer action, drain, fork or migrate that was already under way
when the orchestrator died still gets the may-or-may-not-have-run error, so
it is never done twice. A waiting ?wait= or SDK call gets the result once
it is stored. Each pending intervention’s deadline is a restate timer too, so
on_timeout applies on time after a restart, even with nobody reading it.
The SDKs wait on a 202 for you: exec returns the approved command’s
result, computer the action’s output and screenshot, fork the new
environment, and so on. A rejection raises
InterventionRejectedError with the reason; wait: false (or the client
option) raises PendingInterventionError with the handle instead of
waiting. The CLI prints the intervention it is waiting on:
fuse environment exec vm-1 -- git push --force origin main
# held by hitl gate force-push: waiting on intervention int_...
Approving, editing and granting
fuse hitl approve int_... # run it as asked
fuse hitl approve int_... --edit -- git push --force-with-lease origin main
fuse hitl approve int_... --edit # edit argv in $EDITOR
fuse hitl approve int_... --text "staging.example.com" # retype a held computer type
fuse hitl approve int_... --grant env # and don't ask again
fuse hitl reject int_... --reason "push to a branch instead"
An edited argv is checked against the deny gates again before it is
accepted. A grant lets identical repeats through without a prompt: the
same gate and the exact same argv (or the same lifecycle action, or the
exact same computer action body), for the
environment’s lifetime (env) or a duration (ttl:30m). A different argv
still prompts. Grants follow a migrate, are not copied to a fork, are
dropped on destroy, and are audited (grant_created, and grant_used each
time one lets an action through). An edit cannot carry a grant, since the
grant would cover the original argv the human did not approve as written.
Computer gates
A computer-use agent drives the desktop one action at a time through
POST /v1/environments/{id}/computer. A computer gate holds, records or
refuses those actions by their name, and a type action by its text:
hitl:
gates:
- name: watch-typing
on: computer
match: { action: type }
mode: notify # record everything the agent types
- name: prod-host
on: computer
match: { text_contains: [prod.example.com] } # implies action: type
timeout: 5m
- name: no-drag
on: computer
match: { action: [left_click_drag, left_mouse_down] }
mode: deny
reason: drag and drop is off on this desktop
match.action takes the computer action names: screenshot,
cursor_position, mouse_move, left_click, right_click,
middle_click, double_click, triple_click, left_click_drag,
left_mouse_down, left_mouse_up, type, key, hold_key, scroll,
wait and zoom. match.text_contains matches a type action whose text
contains any entry, ignoring case. It implies action: type, so it can only
be combined with action: [type]; a gate that lists other actions as well
is refused, and should be split in two.
ask, notify and deny work as they do for exec. A held action answers
202 with the intervention, and the SDKs wait on it. The human sees the
action in payload.computer: its action, the text of a type, and
input, the whole body the agent sent.
"payload": {
"computer": {
"action": "type",
"text": "ssh deploy@prod.example.com",
"input": { "action": "type", "text": "ssh deploy@prod.example.com" }
}
}
On approval the orchestrator performs the action itself, with the body the
agent sent, and stores what the guest answered (output and screenshot)
in result.computer. The SDK returns that as the action’s result, so the
agent’s loop carries on with a screenshot taken after the approved action,
not before it.
The human can retype a held type before it runs:
fuse hitl approve int_0187... --text "ssh deploy@staging.example.com"
From the API that is {"edited_text": "..."} on ?action=approve, and in
Go ApproveOptions.EditedText. It applies only to a type, cannot be
empty (reject the action instead), and is checked against the deny gates
again before it is accepted. Like an edited argv it cannot carry a grant:
an explicit grant with it is a 400, and a gate’s default grant drops to
once.
Why there are no coordinate rules. A gate cannot say “never click at
(812, 430)”. Coordinates name a spot on the screen, not a thing: a window
that moves, a banner that pushes the page down or a different resolution
puts something else under the same pixel. Action types do not shift, so
gates match those. To hold “clicking the Pay button”, gate left_click
with a decider: it gets a screenshot of the display with the
action, and can tell what is under the pointer.
Deciders
A gate in ask mode can put a decider in front of the human: a webhook
Fuse asks first. It answers allow, deny or ask, and only ask reaches
a person. Classifiers, model judges and business rules live there, so the
human sees only what a rule could not settle.
version: 1
run: ./agent.sh
secrets:
- decider_secret
hitl:
gates:
- name: push
on: exec
match: { program: git, args: [push] }
decide:
url: https://decider.example.com/fuse
secret: decider_secret
fuse up --secret decider_secret="$(openssl rand -hex 32)"
decide goes on an ask gate on exec, lifecycle or computer. A
notify or deny gate has nothing to decide, and a stall or expiry
signal has no action to judge, so decide on any of those is refused.
secret names an entry of the top-level secrets: list (in the create
request, a key of secrets), and the create is a 400 without it. Its value
signs each call the same way as webhook deliveries, and is
sealed and kept the same way.
For each held action Fuse sends a POST with this JSON body (hitl.DeciderRequest):
{
"env_id": "fuse-agent",
"gate": "push",
"on": "exec",
"triggered_by": { "id": "key:ak_123", "source": "key", "key_id": "ak_123" },
"exec": { "argv": ["git", "push", "origin", "agent/fix-flaky-test"] },
"deadline": "2026-11-04T15:14:05Z"
}
Unlike a webhook delivery it carries the action itself, since judging it is
the point: exec for an exec gate, lifecycle for a lifecycle gate, or
computer (the same action, text and input as payload.computer)
for a computer gate. A computer call also carries screenshot, a base64
PNG of the display as it is before the action, or nothing when one could
not be taken within 5 seconds. deadline is when the intervention would
expire if it went to a human.
The decider answers 200 with:
{ "decision": "deny", "reason": "push to main; open a pull request instead" }
decision |
What happens |
|---|---|
allow |
The action goes on as if this gate had not matched. Fuse records an intervention born approved, decided by {"id": "decider:push", "source": "decider"}. Another matching ask gate still applies. |
deny |
The call gets 403 with details.reason: hitl_denied, the gate in details.gate, and the decider’s reason as the message. The intervention is born rejected. |
ask |
The action is held for a human as usual. payload.decider carries {"verdict": "ask", "reason": "..."}, so the human sees why it was sent on. |
Any failure is ask. The call has a fixed 5 second timeout (a computer
call first spends up to 5 more taking the screenshot), and it is not
retried. A timeout, a transport error, a non-2xx answer, a redirect (which
is not followed), a body that is not JSON or names any other decision, and
a signing secret the orchestrator no longer holds all hold the action for a
human, with the error in payload.decider.error. A broken decider can only
send more work to a person, never let an action through. The decider is
asked only when no grant already covers the action, and an allow creates
no grant.
Writing a decider
Verify the signature against the raw body before anything else, and answer quickly: the agent’s call is waiting. A minimal decider in Python, with the standard library only:
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["DECIDER_SECRET"].encode()
def verify(header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not t.isdigit() or not v1 or abs(time.time() - int(t)) > tolerance:
return False
want = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1)
def decide(req: dict) -> dict:
argv = (req.get("exec") or {}).get("argv", [])
if "main" in argv or "--force" in argv:
return {"decision": "deny", "reason": "push a branch and open a pull request instead"}
if argv and argv[-1].startswith("agent/"):
return {"decision": "allow"}
return {"decision": "ask", "reason": "not an agent/ branch"}
class Decider(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify(self.headers.get("Fuse-Signature", ""), body):
# a non-2xx answer holds the action for a human
self.send_response(401)
self.end_headers()
return
out = json.dumps(decide(json.loads(body))).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(out)))
self.end_headers()
self.wfile.write(out)
HTTPServer(("0.0.0.0", 8080), Decider).serve_forever()
decision is read case-insensitively, reason is optional (an empty one
becomes allowed by the decider of gate <name> or denied by the decider of gate <name>), and the answer is read up to 64 KiB.
Allowlist mode
Matching what to stop is a blocklist, and a blocklist misses whatever its author did not think of. For an agent you do not trust, list what may run instead:
hitl:
exec:
default: ask # or deny
allow: [ls, cat, grep, git, make]
Anything not in allow is held (or refused), and interpreters and shells
are not special: python -c ..., bash -c ... and shell: execs are held
unless their program is listed. Leave interpreters out of allow: allowing
python allows everything. Gates still apply on top, so git can be
allowed while git push --force stays gated.
A guardrail, not a boundary
Gates see what Fuse relays: API exec, the environment actions and API
computer actions. They do not see what happens inside the guest. A process the agent starts there (a
run: agent, a script, a shell it already holds through fuse environment shell) can do anything root can, with no gate involved. So:
- Gate the agent’s API access. When the agent runs outside the VM and only reaches it through exec, gates are enforced on everything it does.
- When the agent runs inside the guest, exec gates do not apply to its
own commands, and computer gates do not see input it sends to the display
itself (with
xdotool, say). Lifecycle gates still do, since those actions only exist on the API. - Gates match structure (argv), not strings. They cannot see inside a shell
string, which is why
shellis its own match and why allowlist mode holds shells. - Deciding needs
hitl:decide, which a full-access key does not hold, but the master token does. Exec currently needs the master token, so an agent that execs holds a credential that could also approve its own held execs. Lifecycle actions work with a full-access key; give the agent one of those wherever it does not need exec. - A
denyondestroyalso stops you: the environment is then removed by itsmax_runtimeoridle_timeout, which are not gated.
Secrets
Sometimes the agent needs a credential nobody gave it: a token to push a
branch, a login for a private package mirror. A secret request carries it
from a human into the guest without the value passing through the agent,
an API response, Postgres or the logs.
Ask for credentials through secret, never during a takeover. A
takeover shell is recorded, and whatever the guest prints, including a token
pasted into a command line or typed at a prompt that echoes, ends up in the
recording. A secret request writes the value to one declared place and
nothing else ever sees it.
Declare every place a value may land. The agent names one of these targets when it asks; it can never pick a path or a variable of its own.
hitl:
requests:
secret:
targets:
- env: GH_TOKEN
label: GitHub token with repo scope on acme/api
- file: /root/.netrc
label: netrc for the internal package mirror
Each target sets exactly one of env (a variable name) or file (an
absolute guest path outside /fuse, which Fuse keeps for its own
credentials), and a label. Up to 20 targets are allowed.
The agent raises the request with a target and, optionally, a message:
in, err := client.Environments.RequestHuman(ctx, envID, fuse.HumanRequest{
Kind: fuse.HitlKindSecret,
Target: "GH_TOKEN",
Message: "git push to acme/api got a 403; need a token to open the PR",
})
if err != nil {
return err
}
if !in.Approved() {
return fmt.Errorf("no token: %s", in.Decision.Reason)
}
// in.Decision.Answer is "done". the value is in the guest, never here.
A target that is not declared is a 400. The agent gets back
decision.answer: "done" and nothing more: the value is never stored,
logged, returned or put on the event stream, and the intervention and its
secret_answered audit row record only who answered and when.
Read the label first. The agent writes the message, so a confused or
prompt-injected agent can write anything there, including “paste the AWS
root key”. The label comes from the Fusefile, and the intervention’s
payload.secret carries it apart from the agent’s text, so show it first:
"payload": {
"secret": {
"target": "GH_TOKEN",
"label": "GitHub token with repo scope on acme/api",
"message": "git push to acme/api got a 403; need a token to open the PR"
}
}
The human answers from the CLI, which prints the label first, then the agent’s message, and reads the value without echoing it (or from stdin when it is not a terminal):
fuse hitl secret int_0187...
or from the API with POST /v1/interventions/{id}?action=secret and
{"value": "..."}. The SDKs expose this as interventions.answerSecret in
TypeScript, AnswerSecret in Go and answer_secret in Python and Rust:
await fuse.interventions.answerSecret(id, value);
Answering needs the hitl:secret scope, which a full-access key never
holds (fuse hitl invite fuse-agent --scopes secret mints one), and the same no self-approval and
require_actor rules as deciding. Two humans answering the same request at
once get one write and one 409.
Where the value lands:
env: every exec call the agent makes after the answer sees the variable. It is read per call, so a process that was already running, or an attach shell, does not.file: the file is created (with its parent directories) with mode 0600 before the value is written, replacing what was there.
Secret requests need TOKEN_ENCRYPTION_KEY on the orchestrator. Without
it, creating an environment that declares hitl.requests.secret is a 400,
and so is raising or answering a secret request.
Takeover
Sometimes the fastest fix is a person at the keyboard: a process wedged on a lock, a merge the agent keeps getting wrong, a tool that only works interactively. A takeover hands the environment to a human and keeps the agent out until they hand it back.
hitl:
timeout: 30m # how long a takeover may hold the environment
requests:
takeover:
via: [terminal] # the default; add desktop for the live desktop
via lists the ways in: terminal, a recorded shell, and desktop, the
recorded live desktop. An empty list means
terminal.
A takeover starts one of two ways. The agent asks for one, and its call waits:
in, err := client.Environments.RequestHuman(ctx, envID, fuse.HumanRequest{
Kind: fuse.HitlKindTakeover,
Reason: "migration 0042 hangs on a lock and I cannot find the holder",
})
if err != nil {
return err
}
log.Printf("human says: %s", in.Decision.Answer)
Or a human starts it. If the agent has a takeover request pending, this claims it (the agent may not claim its own); otherwise Fuse opens one on the human’s behalf:
fuse hitl takeover fuse-agent # take control and open a recorded shell
fuse hitl takeover fuse-agent --via desktop # or the recorded desktop
fuse hitl takeover fuse-agent --no-shell # only take the lock
fuse hitl release fuse-agent --note "killed the stale psql session holding the lock; rerun 0042"
From the API, POST /v1/environments/{id}?action=takeover (body
{"via": "desktop", "reason": "..."}, both optional) and ?action=release with
{"note": "..."}. The SDKs expose environments.takeover and
environments.release. Both need the hitl:takeover scope, which a
full-access key never holds: mint one for the human, for example with
fuse hitl invite fuse-agent --scopes takeover, or use the master token.
While a human holds the environment, the agent waits. Its exec calls and computer actions get a 423 before they reach the guest:
{
"error": {
"code": "locked",
"message": "environment fuse-agent is locked by takeover intervention int_0187...; wait for it to resolve",
"details": { "intervention_id": "int_0187...", "kind": "takeover" }
}
}
The SDKs handle this for you: on a 423 they wait on that intervention until
it is no longer pending, then send the call again, so an agent’s
exec simply takes longer. Pass the same no-wait option as for a
held gate to get a LockedError with the intervention id and kind
instead. Raw API callers do the same loop with
GET /v1/interventions/{id}?wait=50s.
The deadline bounds the lock. A takeover is an intervention like any
other, so it has a deadline: hitl.timeout, or the agent’s shorter
timeout_seconds. When it passes the lock lifts and the takeover expires.
Set hitl.timeout to the longest takeover you want, since a request can
only shorten it.
Release hands the note to the agent. It becomes the takeover’s
decision.answer, so write it for the agent: what you changed and what it
should do next. An empty note becomes released by <actor>. Anyone with
hitl:takeover can release, not only the holder, so a takeover whose holder
walked away does not have to wait out its deadline. A second human taking
over an environment someone holds gets a 409; the holder taking over again
gets the same takeover back.
The recorded shell
fuse hitl takeover opens the shell with
GET /v1/interventions/{id}/attach, the attach stream with a recording on
it. Only the takeover’s holder (or the master token) may open it, and only
the Go SDK and the CLI speak it. Closing the shell ends that recorded session, not the takeover:
the agent stays locked out until a release or the deadline.
The first thing the human sees is a banner:
[fuse] this session is recorded (takeover int_0187..., environment fuse-agent, as alice).
[fuse] ask for credentials through a secret request, never type them here.
The session is saved as an asciinema v2 cast: what
the guest printed and each window resize, never the keystrokes. A password
typed at a prompt that does not echo is not in it; anything the guest echoes
back is. The cast is encrypted with TOKEN_ENCRYPTION_KEY (and stored in
the clear, with a warning in the orchestrator log, without it), stops
growing at 8 MiB, and is linked from a takeover_session audit row by its
recording_id. Fetch it with hitl:read:
curl "$FUSE/v1/hitl/audit?env=fuse-agent" -H "Authorization: Bearer $TOKEN" \
| jq -r '.entries[] | select(.event == "takeover_session") | .recording_id'
curl "$FUSE/v1/hitl/recordings/rec_0187..." -H "Authorization: Bearer $TOKEN" > session.cast
asciinema play session.cast
The SDKs fetch the same thing with interventions.getRecording in
TypeScript, Recording in Go and get_recording in Python.
The recorded desktop
A takeover via desktop hands the human the environment’s live desktop,
for the cases a shell cannot fix: a login page with a captcha, a dialog the
agent keeps misreading, a drag it cannot get right. It needs a top-level
desktop: block; a Fusefile (or a create request) that allows
via: [desktop] without one is refused.
desktop:
width: 1280
height: 800
hitl:
requests:
takeover:
via: [terminal, desktop]
fuse hitl takeover fuse-agent --via desktop takes the lock and opens the
same browser viewer as fuse desktop (--no-open prints its URL instead,
--listen picks where it listens). Closing the viewer keeps the takeover
held, as closing the shell does; release it with fuse hitl release.
The viewer streams from GET /v1/interventions/{id}/desktop with
Upgrade: fuse-vnc/1: the computer stream (raw RFB) with a recording on
it. It needs hitl:takeover, only the takeover’s holder
(or the master token) may open it, and the takeover must have been started
with via: desktop. When the human claims an agent’s pending request, the
via the human names wins over the one the agent asked for. In Go,
Environments.TakeoverDesktop opens it.
While the takeover is held, the agent’s computer actions get the 423 above,
and so does the plain GET /v1/environments/{id}/computer/stream, since
input sent on it would get past the lock. fuse desktop therefore fails
during a takeover; the holder’s viewer is the one way in.
The session is recorded as:
- an input-action log: each change of the pointer buttons, with its
coordinates; named keys such as
Return,Tab,Escape, the arrows andF1-F12, and chords such asctrl+c; the number of printable keys typed, never which ones; and the size of each clipboard paste, never its content. - a screenshot when the session starts and every 10 seconds after.
Like the terminal cast, it never holds what the human typed. The screenshots
are what shows the work, so whatever is on the screen when one is taken is
in the recording: ask for credentials through a secret request,
not by typing them into the desktop. The recording is sealed with TOKEN_ENCRYPTION_KEY
when the viewer closes, linked from a takeover_session audit row by
recording_id, and stops taking screenshots at 32 MiB (the log is capped at
50,000 entries), marked truncated; the view keeps working.
GET /v1/hitl/recordings/{id} returns a desktop recording as
application/json with Fuse-Recording-Kind: desktop; a terminal cast
stays application/x-asciicast with Fuse-Recording-Kind: terminal. The
JSON looks like this, with t in seconds since the session started:
{
"version": 1,
"kind": "desktop",
"intervention_id": "int_0187...",
"env_id": "fuse-agent",
"actor": "alice",
"started_at": "2026-11-04T15:04:05Z",
"ended_at": "2026-11-04T15:09:41Z",
"events": [
{ "t": 3.2, "type": "pointer", "x": 640, "y": 412, "buttons": 1 },
{ "t": 3.3, "type": "pointer", "x": 640, "y": 412, "buttons": 0 },
{ "t": 4.1, "type": "text", "chars": 12 },
{ "t": 5.0, "type": "key", "key": "Return" },
{ "t": 9.7, "type": "clipboard", "bytes": 38 }
],
"screenshots": [
{ "t": 0.0, "png": "iVBORw0KGgo..." }
]
}
truncated is set when a cap was hit. input_log_incomplete is set when
the viewer sent something the log does not understand, after which no more
input was logged; the screenshots carry on.
Signals
Gates fire when the agent does something. Signals fire when Fuse
notices something on its own: the agent looks stuck (stall), or the
environment is about to be reaped (expiry). They are declared as gates:
hitl:
gates:
- name: failing-loop
on: stall
match: { failures: 3 } # three non-zero exec exits in a row
- name: gone-quiet
on: stall
match: { quiet: 15m } # no exec or computer call for 15m
mode: notify
- name: about-to-reap
on: expiry
match: { before: 5m } # 5m before idle_timeout or max_runtime
In the create request the durations are seconds: quiet_seconds and
before_seconds. A signal gate is ask (the default) or notify, never
deny, takes no grant, and matches only its own fields.
Stall
| Match | Raised when |
|---|---|
failures |
This many exec calls in a row exited non-zero, 1 to 100. A zero exit resets the count, and so does raising the stall. |
quiet |
The agent made no exec or computer call for this long, 1m to 24h, counted from creation until its first call. One quiet spell raises one stall. |
A gate can set both. In ask mode a stall pauses the agent: its exec
and computer calls get the same 423 as a takeover, with kind: "stall", and
the SDKs wait the same way. A human acks it with a plain approve or reject;
either one lifts the lock and the agent’s waiting call goes through. In
notify mode the stall is only recorded, born resolved, and nothing pauses.
No stall is raised while the environment is already locked, and the lock
lifts at the stall’s deadline (timeout on the gate, else hitl.timeout)
like any other.
fuse hitl show int_0187... # payload.signal: failures and last_exit_code, or quiet_seconds
fuse hitl approve int_0187... # let the agent carry on
Expiry
before raises an expiry this long ahead of the reaper that is due first:
resources.idle_timeout, or max_runtime for an environment running a
task. It is 1m to 24h. payload.signal names the reaper and its
reap_at.
In ask mode the reaper waits while the expiry is pending. What happens
next is up to the human:
- Approve keeps the environment alive. With no duration that is one more
full window of the reaper that was due (at most 24h);
--extendpicks one, 1m to 24h. The decision’s answer readsextended until <time>, andpayload.signal.extended_untilholds it. The extension survives an orchestrator restart, and the gate fires again ahead of the next reap. - Reject lets the reap go ahead.
- Nobody answering does too. An expiry’s deadline is capped at the reap
time, and it always times out as a reject, whatever
on_timeoutsays.
fuse hitl approve int_0187... --extend 30m
From the API, approve with {"extend_seconds": 1800}; in the SDKs,
extendSeconds in TypeScript and ExtendSeconds in Go’s ApproveWith.
extend_seconds on anything other than an expiry approval is a 400. A
notify expiry only records the warning; the reaper does not wait for it.
Access
Fuse has no users or roles. What a human needs to handle one environment’s requests is a short-lived, narrow credential, so that is what Fuse mints: an API key with scopes.
| Scope | Allows |
|---|---|
hitl:read |
List and read interventions and the audit log. |
hitl:decide |
Approve and reject interventions. Implies hitl:read. |
hitl:secret |
Answer secret requests. Implies hitl:read. |
hitl:takeover |
Take over and release the environment, and open the takeover shell or desktop. This is shell access to the VM, so it is its own scope. |
hitl:act-as |
Name the actor the key acts for with the Fuse-Actor header. |
A key minted without scopes is a full-access key, exactly as every key
was before scopes existed. It holds hitl:read, but never hitl:decide,
hitl:secret, hitl:takeover or hitl:act-as: those are only ever minted
on purpose. That is what keeps an agent’s own key from approving its own
requests, and keeps a takeover shell as rare as attach, which is
master-only.
A key minted with scopes:
- reaches only the hitl routes (
/v1/interventions,/v1/hitl/*, and minting a narrower key). Anything else is a 403 with reasonenv_scope. - may be limited to one environment. It then covers that environment and every environment forked from it, never a parent or a sibling, and gets a 404 on any other environment, as if it did not exist. A migrate moves the key to the new environment id.
- must expire, at most 24 hours after it is minted. An expired key is a 401.
- is revoked when its environment is destroyed.
- may be pinned to an actor (see Actors).
The quickest way to mint one is fuse hitl invite. It defaults to
--scopes decide and --ttl 1h, and prints the secret once:
fuse hitl invite fuse-agent --scopes decide --ttl 2h --actor alice
Hand the key to Alice; with it she can list, read and decide the requests of
fuse-agent and its forks, and nothing else. fuse apikeys create mints the
same thing with every option spelled out:
fuse apikeys create --scopes read,decide --env fuse-agent --ttl 2h \
--actor alice --actor-name "Alice Ng" --label alice-oncall
From the API, POST /v1/api-keys with
{"scopes": ["hitl:decide"], "env_id": "fuse-agent", "ttl_seconds": 7200, "actor": {"id": "alice"}}.
Minting cannot escalate. The master token can mint anything. A scoped key
can mint a narrower copy of itself: only scopes it holds, only its own
environment or one of its forks, expiring no later than it does, and for its
own pinned actor (pinning an actor from an unpinned key needs
hitl:act-as). Anything wider is a 403 with reason mint_escalation. A
full-access key cannot mint at all. Every mint of a scoped key, and its first
use, is an audit row.
Actors
A key answers “is this call allowed”. An actor answers “which person is
this”: an opaque id (at most 128 bytes, like user_42, alice@corp.com or
slack:U0123) and an optional display name. Fuse records it on every
intervention and audit row and never checks it. Whoever builds on Fuse maps
their own auth onto it.
It travels as two headers on any request:
Fuse-Actor: alice@corp.com
Fuse-Actor-Name: Alice Ng
The server resolves who a request is recorded as from the key and the headers:
| Credential | Recorded actor | source |
|---|---|---|
Key pinned to an actor (--actor alice) |
The pinned actor. A different Fuse-Actor is a 403 (actor_mismatch). |
pinned |
Key with hitl:act-as |
Whatever Fuse-Actor says, with the key id that asserted it. Without the header, the key itself. |
asserted |
| Any other key | key:<key_id>, named after the key’s label. Sending Fuse-Actor is a 403 (actor_not_allowed) rather than silently dropped. |
key |
| Master token | Fuse-Actor if present, else master. |
asserted / key |
There are two common setups. A small team uses fuse hitl invite --actor,
so every request on Alice’s key records as alice with no integration at
all. A product built on Fuse holds one hitl:act-as key in its backend and,
after its own session check, names the logged-in user on each call.
An asserted actor is only as true as the key holder’s own auth. Fuse
cannot know whether the backend checked the session; it records the claim
and who made it (alice, asserted by ak_123), and nothing more.
Every SDK has a withActor call that returns a copy of the client sending
the headers. The original client is unchanged.
const fuse = client.withActor({ id: session.user.id, name: session.user.email });
await fuse.interventions.approve(id, { answer: "yes" });
cl := client.WithActor(fuse.ActorLabel{ID: user.ID, Name: user.Email})
in, err := cl.Interventions.Approve(ctx, id, "yes", "")
from fuse import ActorLabel
c = client.with_actor(ActorLabel(id=user.id, name=user.email))
c.interventions.approve(intervention_id, "yes")
let c = client.with_actor(ActorLabel::new(&user.id).name(&user.email));
c.interventions().approve(&id, "yes", "").await?;
Each SDK also takes an actor client option, so the agent’s side can tag
which run raised each request. From the CLI, fuse hitl answer and
fuse hitl reject take --actor.
GET /v1/interventions?actor=alice (and the audit log’s actor filter)
finds everything an actor raised or decided.
Requiring an actor
By default any key with hitl:decide can decide. Set require_actor to
insist that every decision names a person: it must come from a key pinned to
an actor, or carry Fuse-Actor from a hitl:act-as key or the master token.
Anything else is a 403 with reason actor_required.
hitl:
require_actor: true
requests:
ask: {}
No self-approval
An intervention can never be decided by the key that raised it, or by the
actor that raised it, whatever scopes the decider holds. Both are checked, so
a person cannot raise a request under one key and approve it with another.
Either case is a 403 with reason self_approval. The one exemption is the
master token with no Fuse-Actor header: it is the operator’s root
credential, not a party to the request, so it may decide a request it raised
the same way.
Audit log
Every hitl action appends a row to an append-only audit log in Postgres:
| Event | When |
|---|---|
raised |
An intervention was raised. |
decided |
It was approved or rejected. |
expired |
Its deadline passed. |
cancelled |
Its environment was destroyed while it was pending. |
invite_minted |
A scoped key was minted. |
invite_used |
A scoped key authenticated for the first time. |
secret_answered |
A secret request was answered. Who and when, never the value. |
takeover_started |
A human took control of the environment. |
takeover_ended |
The takeover was released. |
takeover_session |
A recorded takeover shell or desktop view closed. Carries recording_id. |
A row records the actor, its source, the key id, the remote address, the
time and the intervention id, and for a takeover_session the id of its
recording. It never holds the question, the answer or the reason; those stay
on the encrypted intervention row, so the audit log is safe to export.
fuse hitl audit # every row, oldest first
fuse hitl audit --env fuse-agent --since 24h
fuse hitl audit --actor alice
fuse hitl audit --verify
From the API, GET /v1/hitl/audit?env=&actor=&since= with hitl:read. A key
limited to an environment sees only that environment’s rows.
Each row carries prev_hash, the hash of the row before it, so the rows form
a chain. Editing, deleting or reordering a row breaks the chain at that
point. --verify reads the whole log, recomputes every hash on your machine
rather than trusting the server’s view, and reports the first row that no
longer matches:
audit chain broken at seq 42: row content does not match its hash (the row was edited)
Because it reads every row, --verify takes no filters and needs a key that
is not limited to one environment.
Webhooks
hitl.notify tells a webhook about every audit event on the environment, so
a Slack bot or a pager can ping someone the moment a request is raised. Each
entry names a URL and a secret from the Fusefile’s secrets: list, whose
value signs the deliveries. Up to five webhooks are allowed.
version: 1
run: ./agent.sh
secrets:
- hitl_webhook_secret
hitl:
requests:
ask: {}
notify:
- webhook: https://hooks.example.com/fuse
secret: hitl_webhook_secret
fuse up --secret hitl_webhook_secret="$(openssl rand -hex 32)"
A delivery is a POST whose JSON body is the whole notification:
{
"event": "raised",
"intervention_id": "int_0187...",
"env_id": "fuse-agent",
"gate": "",
"on": "ask",
"deadline": "2026-10-29T15:04:05Z",
"url": "https://fuse.example.com/v1/interventions/int_0187..."
}
It is content-free by design: the receiver learns that something needs a
look and where, never what was asked. The human fetches the content from the
API with their own key. url is built from the orchestrator’s
ORCH_PUBLIC_URL and is left out when that is unset. Invite events carry no
intervention id, kind or deadline. gate names the gate that raised the
intervention and is empty for a request.
Each delivery has a Fuse-Signature header:
Fuse-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
The HMAC is keyed by the secret’s value and computed over the timestamp, a dot, and the raw request body. Verify against the raw bytes, not re-encoded JSON, compare in constant time, and reject old timestamps so a captured delivery cannot be replayed:
import crypto from "node:crypto";
// rawBody is the request body as a Buffer, before any json parsing.
export function verifyFuse(secret, header, rawBody, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const { t, v1 } = parts;
if (!t || !v1) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return want.length === v1.length && crypto.timingSafeEqual(Buffer.from(want), Buffer.from(v1));
}
import hashlib
import hmac
import time
def verify_fuse(secret: bytes, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
# raw_body is the request body as received, before any json parsing.
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not t.isdigit() or not v1 or abs(time.time() - int(t)) > tolerance:
return False
want = hmac.new(secret, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1)
A delivery is retried on any transport error or non-2xx answer, with a 10s timeout per try. Redirects are not followed. With restate off it is tried up to 4 times: at once, then after 1s, 4s and 16s, in the orchestrator process, so a restart drops it; the audit log is the record, the webhook is a nudge.
With restate on (FUSE_RESTATE_MODE=self or cloud) and TOKEN_ENCRYPTION_KEY
set, each delivery is a restate Webhooks.Deliver invocation and survives an
orchestrator restart. It is retried after 1s, then four times longer each try
up to once an hour, for 24 hours from the event; a delivery still failing
after that is recorded as a hitl_webhook dead letter. The same notification
can arrive more than once if the orchestrator dies mid-post, so dedupe on
intervention_id and event. Restate journals the delivery, but the
endpoint url and signing secret go in sealed with TOKEN_ENCRYPTION_KEY;
the journal holds the notification’s ids, gate, deadline and link. Without
the key, deliveries stay in process as with restate off.
The signing secrets are sealed with TOKEN_ENCRYPTION_KEY. Without it they
are kept in memory only, so deliveries stop after an orchestrator restart
rather than the secret being stored in the clear.
Watching
GET /v1/environments/{id}/events emits an intervention event whenever one
is raised or resolved:
{"event":"intervention","vm_id":"fuse-agent","state":"running",
"intervention":{"id":"int_0187...","kind":"ask","state":"pending"}}
It carries ids and states only, never the question or the answer, and the stream drops events under load. Treat it as a hint to re-read the intervention, never as the record.
Where the content lives
The payload (a question, a held argv or computer action, a review’s
content), the answer and the reason are encrypted at rest with the
orchestrator’s TOKEN_ENCRYPTION_KEY before they reach Postgres. Ids,
states and timestamps stay plaintext so the deadline sweep and list filters
work without decrypting. Without the key (dev mode) the content is stored in
the clear.
Two things are different. A secret’s value is never stored at all, with or without the key: it goes into the guest and is dropped. Takeover recordings, terminal and desktop, are sealed with the same key, and stored in the clear without it. The screenshot sent to a decider is not stored at all.