A blocking dialog that lets an autonomous agent pull a human into the loop, and prints the human's answer to stdout.
An agent working through a long task will eventually hit something it cannot do itself: plug in a device, press a physical button, type a sudo password, confirm that a screen looks right. Ending its turn to ask is unreliable — the human is working on something else and may not look at the terminal for an hour, leaving the agent idle the whole time.
human-intervention turns that into a call the agent makes. It opens a native
dialog that takes focus wherever the human happens to be, blocks until they
answer, and prints their answer for the agent to read. The agent stays parked on
one line of shell instead of guessing or giving up.
It works just as well as a graphical confirmation in an ordinary shell script — that's just not what it's for.
Built with Deno Desktop.
The built app is self-contained — Deno Desktop bundles the runtime into the bundle, so running it needs nothing installed. Deno itself is only needed to build it.
macOS is required for the focus behaviour described below; the dialog itself is cross-platform.
human-intervention --text "Press the boot button on the device, then press Done" --confirm "Done"The command does not return until the human answers. Whatever they choose lands on stdout.
| Flag | Description |
|---|---|
--text |
Message to display (required) |
--confirm |
Label for the confirm button; clicking it prints this value to stdout |
--cancel |
Label for the cancel button; clicking it prints this value to stdout |
--prompt |
Show a text input; the typed answer is printed on the second stdout line. Takes an optional placeholder |
--no-sound |
Suppress the short alert sound the dialog plays when it opens |
--plain |
Show --text as plain text instead of rendering it as markdown |
If neither --confirm nor --cancel is given, the dialog shows only the
message. Close the window to dismiss it.
The chosen button label is printed to stdout. The process always exits with
code 0 when the dialog completes — the outcome is in the output, not the exit
code, so a caller reads stdout rather than branching on $?.
| Action | stdout |
|---|---|
| Click confirm button | confirm label (e.g. Done), then the --prompt answer on the next line |
| Click cancel button | cancel label (e.g. Cancel) |
| Decline with a reason | declined, then the reason on the next line |
| Close the window | dialog_closed |
Errors (such as a missing --text) are printed to stderr with exit code 1.
Two buttons, and the label that was clicked comes back verbatim:
answer=$(human-intervention --text "Is the device connected?" --confirm "Yes" --cancel "No")
if [ "$answer" = "Yes" ]; then
...
fiThis is often cheaper than having an agent take a screenshot and reason about it — the human can see the answer at a glance.
--prompt adds a focused text input below the message. The value it takes is
used as the input's placeholder, and it may be omitted:
token=$(human-intervention --text "Paste the 2FA code" --prompt "6 digits" --confirm "OK" | tail -n +2)Confirming prints the answer on the second stdout line, after the button label. Cancelling or closing the window discards it, so apart from a decline (see below) only a confirm produces a second line.
Every dialog carries a collapsed Decline with a reason disclosure above the buttons. Opening it reveals a text input and a Decline button; the button stays disabled until a reason is typed, since the reason is the whole point.
This is the escape hatch that makes the tool safe to hand to an agent. A cancel button only says no; a decline says no, because — and the reason is something the agent can act on. "The device is at the office, try again tomorrow" redirects the work instead of just halting it.
Declining prints declined on the first stdout line and the reason on the
second, whatever the buttons are called and whether or not --prompt was
passed. That makes it unambiguous for callers:
result=$(human-intervention --text "Ready to flash the firmware?" --confirm "Go" --cancel "Wait")
if [ "$(echo "$result" | head -n 1)" = "declined" ]; then
reason=$(echo "$result" | tail -n +2)
echo "human declined: $reason"
fiEscape closes the disclosure while it is open, so it never answers the dialog halfway through typing. Enter inside the reason input declines.
--text is rendered as markdown (headings, bold/italic, lists, links, code,
blockquotes) by default, which suits agent-generated messages — they tend to
arrive as markdown already:
human-intervention --text $'**Build failed**\n\n- missing dependency\n- see `npm install`' --confirm "OK"Pass --plain to show the text verbatim instead:
human-intervention --plain --text "Ready for review" --confirm "OK"Raw HTML in --text is escaped rather than rendered, so markdown mode can't be
used to inject arbitrary HTML into the dialog.
While the dialog is focused:
- Enter — triggers the confirm button, if present; inside the decline reason input it declines instead
- Escape — closes the decline disclosure if it is open; otherwise triggers the cancel button, if present, or closes the dialog
skills/human-intervention/ is an Agent Skill that teaches an agent when to
reach for this command: what kinds of blockers warrant interrupting a human,
which checks are cheaper to ask about than to screenshot, and the reminder that
the call blocks — so any logging or process setup that needs to observe the
human's action has to be running before the dialog opens.
Install it by symlinking it into your skills directory:
ln -s "$(pwd)/skills/human-intervention" ~/.claude/skills/human-interventionNeeds Deno 2.9.0 or later, for the deno desktop
subcommand.
deno task buildOn macOS this produces dist/human-intervention.app, with the app icon taken
from icons/icon.icns. Run it via:
./dist/human-intervention.app/Contents/MacOS/human-intervention --text "hello" --confirm "OK"The binary needs to be on your PATH for an agent to call it by name. From the
repo root, symlink it into a directory that already is (the launcher resolves
symlinks correctly):
ln -sf "$(pwd)/dist/human-intervention.app/Contents/MacOS/human-intervention" ~/.local/bin/human-interventionOr add the launcher directory to your PATH in ~/.zshrc:
export PATH="/path/to/human-intervention/dist/human-intervention.app/Contents/MacOS:$PATH"Reload your shell after changing your config:
source ~/.zshrcicon.svg in the repo root is the source of truth. The rasterized icons live in icons/ and are committed, so a normal build needs no extra tooling:
| File | Platform |
|---|---|
icons/icon.icns |
macOS |
icons/icon.ico |
Windows |
icons/icon.png |
Linux (1024px master) |
The macOS variants follow Apple's icon grid: the artwork is scaled into an 824×824 box inside a 1024×1024 canvas, leaving a transparent margin so the icon sits at the same visual size as stock apps in the Dock. The Windows and Linux variants are full-bleed, which is the convention there.
They are wired up through desktop.app.icons in deno.json. After editing icon.svg, regenerate them:
deno task iconsThat step needs librsvg (brew install librsvg) and, for the .ico, ImageMagick (brew install imagemagick).
# Run tests
deno task test
# Run in dev mode (rebuilds on change)
deno task dev -- --text "hello" --confirm "OK" --cancel "Cancel"human-intervention is a Deno Desktop app. It serves a minimal HTML dialog over a local HTTP server and opens it in a small frameless window. Button clicks are sent back to the Deno runtime, which prints the result and exits.
The bundle carries that runtime with it, as libruntime.dylib alongside the
launcher — which is why the app is self-contained, and also why it weighs around
66 MB.
The build step patches the macOS launcher to suppress Deno Desktop startup noise so stdout stays clean for scripting — an agent parsing the output must not have to filter runtime chatter out of it.
The build passes --allow-net --allow-ffi: net for the dialog server, FFI for the
AppKit calls in src/focus.ts. Note that deno desktop grants every permission when
you pass no --allow-* flag at all, but restricts itself to exactly what you
list as soon as you pass one. Dropping --allow-net therefore does not just
disable the server — Deno.serve throws and the app exits before drawing
anything, with no window and no error on stdout.