hitl-cli
v0.5.0
Published
Reach a human from inside an agent session.
Maintainers
Readme
HITL
Reach a human from inside an agent session. Your phone buzzes when the agent finishes, when it gets stuck, and when it needs a decision only you can make — and its question waits for your answer.
npm i -g hitl-cli
hitl login
hitl initThree lines, per project. Sessions in that project are connected: no scripts to copy, no keys to paste, no settings to hand-edit — and no other project on your machine starts notifying because of it.
Status: early. Everything below works — the server is deployed, the commands run, and the whole path has been exercised end to end against production. The version number is low because it is new, not because it is a sketch: expect rough edges, and expect flags to settle before 1.0.
The one thing you cannot do from a terminal is approve your own machine — that step is a browser and a signed-in human on purpose, and it is what stops a leaked code being worth anything.
Install
npm i -g hitl-cliNode 18 or newer. Nothing else — no Python, no jq, no shell scripts.
Sign in
hitl loginPrints a short code and opens the HITL admin site in your browser:
Open https://hitl-admin-web.fly.dev/device
Code BXQT-7K2M
Waiting for approval…In the browser you sign in with Google, see which machine and which project is asking, and approve. If an application already matches the project, it is preselected; if none does, you get a one-click Create and approve already named after the folder. The terminal picks the credential up on its next poll.
Logins are per project
hitl login signs in the project you are standing in, not just the laptop.
Two repos on one machine can reach two different audiences, and cd is the
only thing that decides which:
cd ~/Dev/hitl && hitl login # sends as HITL
cd ~/Dev/oxy && hitl login # sends as OxyThe project is its git repository root, so running a command from
src/components is still the same project as running it from the top.
Credentials live in ~/.hitl/config.json (mode 0600), keyed by project path
— deliberately not in a file inside the project, because a credential in a
working tree is one that ends up in a commit or a container image.
Resolution, most specific first: HITL_TOKEN from the environment, then this
project's credential, then a machine-wide one from hitl login --machine.
Three things worth knowing:
- The application decides who gets buzzed. A request goes to that application's members. If you are the only member, it goes to your phone. Adding teammates to the application is done on the same site.
- The credential belongs to this machine. Revoke a laptop without touching any other machine, from Agents in the admin site.
- The code is safe to read aloud. It is useless without someone signed in approving it, and it expires in 15 minutes. The secret half never leaves the terminal.
Check it worked:
hitl statusTurn the hooks on
hitl initThis writes HITL's hooks into this project's .claude/settings.json,
merging into whatever is already there rather than replacing it. Sessions in
this project are connected; nothing else on the machine changes.
hitl init --global # every project on this machine instead
hitl init --print # show the JSON and change nothing
hitl init --remove # take them back out (add --global for those)hitl init refuses to run in a project that has no credential of its own,
rather than quietly adopting one from elsewhere and sending this project's
notifications as some other application.
A project only notifies if you ran hitl login in it. That holds even with
--global: the hooks fire in every session, but one with no credential of its
own does nothing. So --global is safe — it means "be ready everywhere", not
"report everything to whichever project I signed into last".
hitl init is idempotent. Run it again after an upgrade and it reconciles.
What it writes
No hidden magic — this is the whole of it:
{
"hooks": {
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "hitl hook", "args": [], "timeout": 10 }] }
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "hitl hook", "args": [], "timeout": 15 }] }
],
"Notification": [
{ "matcher": "permission_prompt|idle_prompt|agent_needs_input",
"hooks": [{ "type": "command", "command": "hitl hook", "args": [], "timeout": 15 }] }
]
}
}hitl hook reads the event on stdin and decides what to do with it. That is
why there is no script to install: the CLI is the hook handler.
What you get
| When | What arrives | |---|---| | The agent finishes a turn | Onyx is done in hitl — with what it actually did, not just that it stopped | | It hits a permission prompt | Onyx needs you in hitl — including the command it wants to run | | It has been idle waiting on you | a nudge, once | | It asks you something | a question you answer from the lock screen; the session waits |
Each card says which project and which agent it came from, so two sessions in two repos are never confused for one another.
Ask a human, from inside a session
The hooks are one direction. This is the other:
hitl ask "Ship v2.41 to production?"It blocks until you answer on your phone, prints your answer to stdout, and exits non-zero if you refuse or it times out. That is the whole interface, and it is the point of the product: an agent can stop and genuinely wait for you.
Tell your agent it exists — a line in CLAUDE.md is enough:
When you need a decision only I can make, run
hitl ask "<question>". It blocks until I answer on my phone and prints my answer. Prefer it over stopping and waiting. Don't use it for anything you can work out yourself.
Add Bash(hitl:*) to your allowed tools so it doesn't prompt every time.
Commands
| Command | Does |
|---|---|
| hitl login | Signs in this project. --machine for a machine-wide credential. |
| hitl logout | Revokes this project's credential, server-side too. --machine for that one. |
| hitl status | Project, credential, where it came from, hooks, and whether it works. |
| hitl doctor | Why a notification did not arrive. --send posts a real one. |
| hitl init | Writes the hooks for this project. --global, --print, --remove. |
| hitl ask "…" | Asks a question. Blocks. Prints the answer. |
| hitl notify "…" | Tells you something. Returns immediately. |
| hitl approve "…" | Asks for a yes/no, or --option repeated for a custom set. |
| hitl hook | The hook handler. Called by Claude Code, not by you. |
| hitl mcp | Runs as an MCP server over stdio, for clients that prefer tools to a CLI. |
Keeping it quiet
A Stop hook fires on every turn. Left alone that is sixty buzzes in an
afternoon, and you will mute HITL by Thursday — so hitl init ships a filter
already on: a turn is only worth telling you about if it took longer than 90
seconds, because a turn that short means you never walked away.
hitl config set minSeconds 30 # chattier
hitl config set minSeconds 0 # every turn
hitl config set stop off # done-notifications off, questions still work
hitl config set idle on # also nudge if you have not looked after a minuteidle is off by default. Claude Code fires it about a minute after a turn
ends — which is a minute after the finished notification already told you the
same thing, so left on it buzzes twice for one event. Turn it on if you want
the reminder.
Everything the CLI sends carries an idempotency key derived from the session and the turn, so a retry after a dropped connection can never buzz twice for one event.
Where things live
| Path | What |
|---|---|
| ~/.hitl/config.json | Credentials per project, plus settings. Mode 0600 |
| .claude/settings.json | This project's hooks, after hitl init |
| ~/.claude/settings.json | Every project's hooks, with hitl init --global |
When it doesn't work
Nothing arrives. hitl status first. If that is healthy, the request
reached Apple and the problem is on the phone — check
Settings → Notifications → HITL for Scheduled Summary, a Focus filter, or
Deliver Quietly, which one accidental tap from a notification's context
menu will turn on.
Anything at all. Run hitl doctor. It checks, in the order a
notification travels: is the CLI current, are the hooks installed, does the
path in the settings file still exist, would a hook find a credential here,
is anything muted, is the server reachable, is the credential still valid.
Add --send to post a real notification and see it arrive.
That last-but-one check is the one worth knowing about: a hook resolves
credentials differently from anything you type — it refuses a machine-wide
fallback — so hitl status can look healthy while hooks send nothing. Only
doctor answers that question.
Hooks don't fire. /hooks inside Claude Code shows what is loaded and
where it came from.
A project notifies that you did not expect. It was signed into. hitl
logout there, or hitl init --remove to stop that project reporting while
keeping the credential.
"command not found: hitl" in the transcript. Claude Code runs hooks in a
non-interactive shell that may not have your PATH. hitl init writes an
absolute path for exactly this reason; re-run it after moving your Node install.
Too noisy. See Keeping it quiet. Do that before muting the app.
It buzzed twice. Report it — the idempotency key should make that impossible, so it is a bug rather than something to configure around.
Removing it
hitl init --remove # hooks out of the settings file
hitl logout # credential revoked server-side
npm rm -g hitl-clihitl logout revokes on the server, so a credential cannot outlive the machine
that held it. Revoking from the admin site's Agents page does the same
thing from the other end.
