{"id":"f3855f16-3a7a-45cb-9b76-42de6259225c","slug":"herdr-hitl","kind":"skill","name":"herdr-hitl","summary":"Block a Herdr coding agent on a human decision, delivered to your phone over Telegram or Discord","owner":"huketo","repoUrl":"https://github.com/huketo/herdr-hitl","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"skill","createdAt":"2026-09-01T01:30:32.000Z","addedAt":"2026-10-21T12:58:56.542Z","logoUrl":"https://avatars.githubusercontent.com/u/75968351?u=520a9645b30aeb148ffd61e7015847ff5a1b4dc1&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["communication"],"tags":["ai-agents","cli","discord-bot","go","golang","herdr","herdr-plugin","human-in-the-loop"],"runtime":"go","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-22T18:12:04.539Z","sample":false,"url":"https://conduits.dev/tools/herdr-hitl","language":"Go","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# herdr-hitl\n\n<p align=\"center\">\n  <img src=\"assets/herdr-hitl-bot-profile.png\" alt=\"herdr-hitl bot profile\" width=\"220\">\n  <br>\n  <em>When an agent needs a human, herdr-hitl brings the question to you.</em>\n</p>\n\n*English · [한국어](README.ko.md)*\n\nA [Herdr](https://herdr.dev) plugin that lets a coding agent block on a human decision delivered over Telegram or Discord. An agent halfway through a long run hits something it must not decide alone — force-push over a colleague's branch, pick between two migration strategies, supply a credential it cannot read, resolve a requirement the issue never stated. Today it either guesses, or it stops and waits for someone to notice a terminal that nobody is watching. `herdr-hitl` gives it a third option: one blocking command, `herdr-hitl ask`, that pushes the question to your phone with buttons or a text box, waits for you to answer, and prints the answer on stdout. Exit code says whether you answered, let it time out, or declined.\n\n## Quickstart\n\nInstall as a Herdr plugin (builds the binary, registers a startup hook that keeps the daemon alive):\n\n```sh\nherdr plugin install huketo/herdr-hitl\nherdr plugin action invoke huketo.hitl.config-init\nherdr plugin action invoke huketo.hitl.install-cli   # puts herdr-hitl on PATH\n```\n\nOr install the CLI standalone — the plugin manifest is optional, everything works without Herdr:\n\n```sh\ngo install github.com/huketo/herdr-hitl/cmd/herdr-hitl@latest\n```\n\nThen configure one messenger and check it:\n\n```sh\n$EDITOR \"$(herdr-hitl config path)\"\nherdr-hitl doctor\nherdr-hitl ask -t \"Smoke test\" -m \"Reply with anything.\" --timeout 2m\n```\n\n### Where questions are delivered\n\nBoth transports deliver by direct message. Their preconditions differ, and the difference is not obvious:\n\n| | Telegram | Discord |\n| --- | --- | --- |\n| Day-to-day surface | DM with the bot | DM with the bot |\n| Needs a group / server? | **No** | **Yes — the bot must share a server with you** |\n| One-time precondition | You message the bot first (`/start`) | You invite the bot to a server you are in |\n| Error when skipped | `403 bot can't initiate conversation with a user` | `50278 no mutual guilds` |\n\nDiscord's shared server is plumbing, not a destination: no question is ever posted there and you never open it. An empty server containing only you and the bot is enough. Leave that server, or remove the bot from it, and DMs break again.\n\nPoint a transport at a group or channel instead when a team should see the question — and then set `allowed_user_ids`, or anyone in that space can decide on your behalf. One target is a trap: a **Telegram channel** is broadcast-only, so it takes buttons and can never take a typed answer. `herdr-hitl doctor` says so when it detects one.\n\n### Route questions by presence\n\nA human at the keyboard should not be paged for a decision they can answer in the pane they are looking at. No focus, idle-time, or attached-client probe can establish that reliably, so routing uses declared state for each question.\n\nThe recommended interactive setup is:\n\n```toml\nchannel = \"auto\"\n```\n\nWith `auto`, questions stay in the terminal while you are present and go to the messenger while the Away marker is set:\n\n```sh\nherdr-hitl away\nherdr-hitl here\n```\n\n`herdr-hitl away --for 2h` sets an expiry; without `--for`, the marker remains until you clear it. The marker is `<state dir>/away`, contains `forever` or an RFC 3339 expiry, and is created with mode `0600`. A malformed marker counts as away; an expired marker does not.\n\nTo declare that you are unavailable (AFK):\n\n```sh\nherdr-hitl afk\nherdr-hitl afk --for 2h\n```\n\nActive AFK overrides explicit and configured channels, resolving to `afk`. Both `ask` and `notify` refuse before daemon or network with exit 6, writing nothing to stdout and granting no `--default` approval. `herdr-hitl here` clears it; `herdr-hitl away` replaces it. Expired AFK returns to existing routing, and malformed AFK expiry does not downgrade to message delivery. The CLI does not run models, and AFK authorizes no actions.\n\nAn unattended launcher, such as a scheduler or a detached agent run, should declare that nobody is watching:\n\n```sh\nexport HITL_CHANNEL=messenger\n```\n\nEach `ask` or `notify` resolves its policy in this order: `--channel messenger|terminal|auto`, `HITL_CHANNEL`, the asking workspace's route (below), `channel` in `config.toml`, then the default `messenger`. `HITL_CHANNEL` and `channel` accept only those three values; an invalid value is a configuration error at load time. If the result is `terminal`, nothing contacts the daemon or messenger; the command explains that the agent must ask in its own interface on stderr and exits `5`. The agent must not treat that status as approval or retry the delivery. Use `--channel messenger` to override one call.\n\n### Route questions by workspace\n\nSome workspaces have someone else watching the agent's pane: a coordinating agent that drives the session and relays its questions to the right people. Their questions belong in that pane, not in your messenger, while other workspaces keep the usual policy. A `[[workspace]]` route sets the channel policy for the named Herdr workspaces:\n\n```toml\nchannel = \"auto\"\n\n[[workspace]]\nlabels = [\"project-a\", \"project-a-infra\"]\nchannel = \"terminal\"\n```\n\nWith this route, `ask` and `notify` from those workspaces exit `5` as the terminal channel does, so the agent asks in its own interface, and `herdr-hitl channel` reports `terminal` with the reason `workspace route`. The Away marker does not move a route set to `terminal` or `messenger`; set the route to `auto` to keep presence routing for those workspaces. A route ranks below `HITL_CHANNEL` and `--channel`, so an unattended launcher that exports `HITL_CHANNEL=messenger` still delivers, and above `channel` in `config.toml`. Active AFK still overrides it.\n\nLabels are Herdr workspace labels, matched exactly, and a label may appear in one route only. An agent pane exports only `HERDR_WORKSPACE_ID`, so the CLI reads the label with `herdr workspace get`; if that lookup fails, the workspace gets no route. Routes are read on every invocation, so adding or removing a label takes effect on the next question. `herdr-hitl doctor` lists the routes.\n\n## Configuration\n\nTwo files, both in the config directory:\n\n| File | Purpose |\n| --- | --- |\n| `config.toml` | Everything non-secret. Written by `herdr-hitl config init`. |\n| `.env` | Tokens and ids. Copy from `.env.example`, `chmod 600`. |\n\nWhere they live:\n\n| Platform | Config directory | State directory |\n| --- | --- | --- |\n| Linux | `$XDG_CONFIG_HOME/herdr-hitl` (default `~/.config/herdr-hitl`) | `$XDG_STATE_HOME/herdr-hitl` (default `~/.local/state/herdr-hitl`) |\n| macOS | `~/Library/Application Support/herdr-hitl` | `$XDG_STATE_HOME/herdr-hitl` (default `~/.local/state/herdr-hitl`) |\n| Windows | `%APPDATA%\\herdr-hitl` | `%LOCALAPPDATA%\\herdr-hitl` |\n\nThese locations do not change when Herdr launches the binary. Herdr injects `HERDR_PLUGIN_CONFIG_DIR` and `HERDR_PLUGIN_STATE_DIR` for plugin actions and startup hooks, but not for the pane an agent asks from; honouring them would give those two callers different configs and — because the socket derives from the state directory — two daemons on one bot token. `HITL_CONFIG_DIR` and `HITL_STATE_DIR` are the only overrides. Run `herdr-hitl config path` to see the resolved location.\n\n### `config.toml`\n\n```toml\n# Route questions through messenger (default), terminal, or the Away marker (auto).\nchannel = \"auto\"\n# Transports used when `ask` omits --transport, in delivery order.\ntransports = [\"telegram\"]\n# Default deadline for `ask`. 0 waits forever.\ntimeout = \"30m\"\n\n# Questions from these Herdr workspaces use this channel policy instead.\n[[workspace]]\nlabels = [\"project-a\"]\nchannel = \"terminal\"\n\n[telegram]\nenabled = true\nbot_token = \"\"            # prefer .env\nchat_id = \"123456789\"\nallowed_user_ids = [\"123456789\"]   # empty = anyone in the chat may answer\napi_base = \"\"             # override only for a local Bot API server\n\n[discord]\nenabled = false\nbot_token = \"\"            # prefer .env\nchannel_id = \"\"           # blank + user_id set = deliver by DM\nuser_id = \"\"\nallowed_user_ids = []\nmessage_content_intent = false   # true only for free-text replies in a guild channel\n\n[daemon]\nidle_shutdown = \"0s\"      # 0 = stay resident; otherwise exit after this long with nothing pending\nlog_level = \"info\"        # debug | info | warn | error\n\n[herdr]\nnotifications = true      # mirror questions to Herdr's notification surface\npane_tokens = true        # expose pending state as a pane token for the status bar\n```\n\n### Environment variables\n\n| Variable | Overrides | Alias |\n| --- | --- | --- |\n| `HITL_TELEGRAM_BOT_TOKEN` | `telegram.bot_token` | `TELEGRAM_BOT_TOKEN` |\n| `HITL_TELEGRAM_CHAT_ID` | `telegram.chat_id` | `TELEGRAM_CHAT_ID` |\n| `HITL_DISCORD_BOT_TOKEN` | `discord.bot_token` | `DISCORD_BOT_TOKEN` |\n| `HITL_DISCORD_CHANNEL_ID` | `discord.channel_id` | `DISCORD_CHANNEL_ID` |\n| `HITL_DISCORD_USER_ID` | `discord.user_id` | — |\n| `HITL_TRANSPORTS` | `transports` (comma-separated) | — |\n| `HITL_CHANNEL` | `channel` | — |\n| `HITL_TIMEOUT` | `timeout` | — |\n| `HITL_LOG_LEVEL` | `daemon.log_level` | — |\n| `HITL_IDLE_SHUTDOWN` | `daemon.idle_shutdown` | — |\n| `HITL_HERDR_NOTIFICATIONS` | `herdr.notifications` | — |\n| `HITL_HERDR_PANE_TOKENS` | `herdr.pane_tokens` | — |\n| `HITL_CONFIG_DIR` | config directory | — |\n| `HITL_STATE_DIR` | state directory | — |\n| `HITL_SOCKET` | daemon endpoint path | — |\n| `HITL_AGENT` | default `--agent` label | — |\n\n## CLI reference\n\n### `ask` — post a question and block until it is answered\n\n| Flag | Meaning |\n| --- | --- |\n| `-t, --title string` | One-line summary. Shown as the message heading. |\n| `-m, --message string` | Question body, Markdown. `-` reads stdin. |\n| `--message-file PATH` | Read the body from a file instead. |\n| `-c, --choice strings` | Repeatable. `id=Label` or bare `Label` (id slugified from the label). |\n| `--primary strings` | Choice ids rendered as the primary/affirmative button. |\n| `--danger strings` | Choice ids rendered as the destructive button. |\n| `--free` | Allow a free-text answer. Default `true`; `--free=false` forces a choice. |\n| `-a, --attach strings` | Repeatable path to an image or document (`.md`, `.html`, `.png`, …). |\n| `--timeout duration` | Deadline. Default from config (`30m`). `0` waits forever. |\n| `--transport strings` | `telegram`, `discord`. Default from config. |\n| `--channel string` | `messenger`, `terminal`, or `auto`. Overrides routing for this call. |\n| `--agent string` | Label shown to the human. Default `$HITL_AGENT`, else `agent`. |\n| `--default string` | Text to print if the deadline passes, instead of failing. |\n| `-o, --format string` | `text` or `json`. Default `text`. |\n\n`-o text` prints **only** the answer text on stdout, so command substitution works. Every log line and diagnostic goes to stderr. `-o json` prints the `hitl.Answer` object.\n\n```sh\nANSWER=$(herdr-hitl ask \\\n  -t \"Force-push to main?\" \\\n  -m \"Rebase dropped 2 merge commits. Force-push, or open a PR instead?\" \\\n  -c \"push=Force-push\" -c \"pr=Open a PR\" \\\n  --danger push --primary pr --free=false --timeout 15m)\n```\n\n### `notify` — fire-and-forget message, never blocks\n\nFlags: `-t/--title`, `-m/--message`, `--message-file`, `-a/--attach`, `--transport`, `--channel`, `--agent`.\n\n```sh\nherdr-hitl notify -t \"Migration finished\" -m \"42 tables, 0 errors, 6m12s.\" -a report.md\n```\n\n### `channel` — print the resolved destination\n\n```sh\nherdr-hitl channel\nherdr-hitl channel -o json\n```\n\nText output is `messenger`, `terminal`, or `afk`. JSON output contains `channel`, `policy`, `reason`, and, when set, `away_until`. The reason is `flag`, `config`, `away marker`, `no away marker`, `away marker expired`, `afk marker`, or `default`.\n\n### `away` — declare that nobody is watching the terminal\n\n```sh\nherdr-hitl away\nherdr-hitl away --for 2h\n```\n\nWithout `--for`, the marker remains until cleared. The command prints what it wrote and the resolved channel, so a marker that does not affect `channel = \"messenger\"` is visible immediately.\n\n### `afk` — declare unavailable mode\n\n```sh\nherdr-hitl afk\nherdr-hitl afk --for 2h\n```\n\nDeclares that the human is unavailable. Active AFK overrides explicit and configured channels, resolving to `afk`. `ask` and `notify` refuse before the daemon or network with exit 6, writing no answer to stdout and granting no `--default` approval. `herdr-hitl here` clears it; `herdr-hitl away` replaces it.\n\n### `here` — declare that a human is watching the terminal\n\n```sh\nherdr-hitl here\n```\n\nClears the Away or AFK marker and prints the resolved channel.\n\n### `pending` — list questions currently awaiting an answer\n\n```sh\nherdr-hitl pending -o json\n```\n\n### `answer` — resolve a question from the terminal instead of the messenger\n\n```sh\nherdr-hitl answer a3f19c7b0e42 --choice pr\nherdr-hitl answer a3f19c7b0e42 --text \"use pgbouncer\"\n```\n\n### `cancel` — withdraw a question\n\n```sh\nherdr-hitl cancel a3f19c7b0e42 --reason \"resolved by reading the migration guide\"\n```\n\n### `serve` — run the daemon in the foreground\n\nHolds the messenger connections and serves CLI clients. Exits `0` quietly when\nanother daemon already owns the endpoint. Use `daemon start` to spawn a\ndetached one instead.\n\n```sh\nherdr-hitl serve\n```\n\n### `daemon` — lifecycle\n\n```sh\nherdr-hitl daemon start     # idempotent; spawns a detached serve if none is live\nherdr-hitl daemon status -o json   # -o text|json, default text\nherdr-hitl daemon restart   # after editing config or rotating a token\nherdr-hitl daemon stop\n```\n\n### `doctor` — diagnose\n\n```sh\nherdr-hitl doctor -o json\n```\n\nThe `channel` check is `OK` when the resolved route delivers and `WARN` when it does not (terminal exits 5, AFK exits 6). It includes the reason and a hint to switch presence.\n\n### `config` — inspect and scaffold\n\n```sh\nherdr-hitl config path   # path of config.toml, whether or not it exists\nherdr-hitl config show   # effective config after env overrides, tokens redacted\nherdr-hitl config init   # write a starter config.toml; never overwrites without --force\n```\n\n### `install-cli` — put the binary on PATH\n\n```sh\nherdr-hitl install-cli --dir ~/.local/bin\n```\n\n### `version`\n\n```sh\nherdr-hitl version -o json\n```\n\n### Exit codes\n\n| Code | Meaning |\n| --- | --- |\n| `0` | Answered. (Or: `notify` delivered, other command succeeded.) |\n| `1` | Error — no transport, bad token, daemon unreachable, delivery failed. |\n| `2` | Usage error — unknown flag, missing required flag, bad value. |\n| `3` | Timeout — the deadline passed with no answer. `--default` converts this to `0`. |\n| `4` | Canceled or declined — the human dismissed it, or the daemon was told to cancel. |\n| `5` | Terminal channel — nothing was sent. Ask in the current interface; this is never approval. |\n| `6` | AFK channel — human declared unavailable mode; questions and notifications are refused before daemon or network. Never approval. |\n\n## Messenger setup\n\n- [docs/setup-telegram.md](docs/setup-telegram.md)\n- [docs/setup-discord.md](docs/setup-discord.md)\n\n## Agent Skill\n\nThe skill lives in-repo at [`skills/herdr-hitl/SKILL.md`](skills/herdr-hitl/SKILL.md) — it is the file that teaches an agent *when* to ask, not just how. An agent that never loads it never asks.\n\n### With `npx skills` (recommended)\n\n[`skills`](https://github.com/vercel-labs/skills) is a skill installer that works across Claude Code, Cursor, Codex, Copilot, opencode, and 70-odd other harnesses.\n\n```sh\nnpx skills add huketo/herdr-hitl\n```\n\nThat clones the repo, finds `skills/herdr-hitl/SKILL.md` by the `skills/` convention — no manifest needed — and links it into your agent's skill directory. Useful variants:\n\n```sh\nnpx skills add huketo/herdr-hitl -g                                   # install for every project\nnpx skills add huketo/herdr-hitl --skill herdr-hitl -a claude-code -y # non-interactive\nnpx skills add huketo/herdr-hitl#v0.1.0                               # pin to a tag\nnpx skills add ./                                                     # from a local checkout\nnpx skills list                                                       # what is installed\nnpx skills update herdr-hitl                                          # pull a newer version\n```\n\nIt installs a canonical copy at `.agents/skills/herdr-hitl` (or `~/.agents/skills/…` with `-g`) and symlinks each selected agent's directory at it, so one update reaches every harness.\n\n> The package is `skills`, plural. `npx skill` — singular — is an unrelated package that can only install from one hardcoded Vercel repository, and it will reject `huketo/herdr-hitl`.\n\n### By hand\n\nNo Node, or you want the skill to track a Herdr-managed checkout:\n\n```sh\n# From an installed plugin — plugin_root is where Herdr keeps the checkout.\nROOT=$(herdr plugin list --plugin huketo.hitl --json | jq -r '.result.plugins[0].plugin_root')\nln -s \"$ROOT/skills/herdr-hitl\" ~/.claude/skills/herdr-hitl\n\n# From a git checkout\nln -s \"$PWD/skills/herdr-hitl\" ~/.claude/skills/herdr-hitl\n```\n\nSymlink rather than copy so a plugin upgrade updates the skill too.\n\nThe skill is English-only on purpose: the CLI flags and exit codes are English, and two skills with overlapping trigger descriptions would both fire and drift apart. See [ADR-0003](docs/adr/0003-agent-skill-distribution.md) for why it ships in-repo instead of being generated.\n\n## Architecture\n\nThe daemon exists because the messenger APIs demand a single long-lived connection per bot token — Telegram's update queue is destructive and per-token, Discord rate-limits IDENTIFY at 1000/24h and punishes overruns with a token reset. See [ADR-0001](docs/adr/0001-blocking-cli-over-a-resident-daemon.md).\n\n```mermaid\nsequenceDiagram\n    participant A as Agent\n    participant C as herdr-hitl ask\n    participant D as daemon (serve)\n    participant M as Telegram / Discord\n    participant H as Human\n\n    A->>C: exec, blocks on stdout\n    C->>D: connect (unix socket / named pipe), ipc.OpAsk\n    Note over C,D: connection held open for the whole ask —<br/>client EOF cancels the question\n    D->>M: Poster.Post — message + buttons + attachments\n    M->>H: notification on phone\n    H->>M: taps a button or types a reply\n    M->>D: update / interaction\n    D->>M: Poster.Settle — disable controls, show outcome\n    D-->>C: ipc.Response with hitl.Answer\n    C-->>A: answer on stdout, status as exit code\n```\n\n## Development\n\n```sh\nmake build   # -> bin/herdr-hitl\nmake test    # go test -race\nmake lint    # golangci-lint\nmake cover   # coverage.out + summary\nmake check   # vet + lint + test\n```\n\nLink the working tree into Herdr instead of installing it — `plugin link` skips build commands, so build first:\n\n```sh\nmake build && herdr plugin link \"$PWD\"\n```\n\nDomain vocabulary is in [CONTEXT.md](CONTEXT.md); decisions are in [docs/adr/](docs/adr/).\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n"}