Skip to content
Conduit

skill

herdr-hitl

Block a Herdr coding agent on a human decision, delivered to your phone over Telegram or Discord

huketo/herdr-hitlcommunitylistedunreviewed

Set up

Skills are installed by placing their folder where your agent loads skills.

The download comes from github.com/huketo/herdr-hitl itself, not from our server, so what you get is exactly what its author published.

Install instructions come from the README

Skills do not use an MCP config. Follow the steps in the README below.

README

synced Oct 22, 2026

herdr-hitl

English · 한국어

A Herdr 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.

Quickstart

Install as a Herdr plugin (builds the binary, registers a startup hook that keeps the daemon alive):

herdr plugin install huketo/herdr-hitl
herdr plugin action invoke huketo.hitl.config-init
herdr plugin action invoke huketo.hitl.install-cli   # puts herdr-hitl on PATH

Or install the CLI standalone — the plugin manifest is optional, everything works without Herdr:

go install github.com/huketo/herdr-hitl/cmd/herdr-hitl@latest

Then configure one messenger and check it:

$EDITOR "$(herdr-hitl config path)"
herdr-hitl doctor
herdr-hitl ask -t "Smoke test" -m "Reply with anything." --timeout 2m

Where questions are delivered

Both transports deliver by direct message. Their preconditions differ, and the difference is not obvious:

TelegramDiscord
Day-to-day surfaceDM with the botDM with the bot
Needs a group / server?NoYes — the bot must share a server with you
One-time preconditionYou message the bot first (/start)You invite the bot to a server you are in
Error when skipped403 bot can't initiate conversation with a user50278 no mutual guilds

Discord'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.

Point 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.

Route questions by presence

A 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.

The recommended interactive setup is:

channel = "auto"

With auto, questions stay in the terminal while you are present and go to the messenger while the Away marker is set:

herdr-hitl away
herdr-hitl here

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.

To declare that you are unavailable (AFK):

herdr-hitl afk
herdr-hitl afk --for 2h

Active 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.

An unattended launcher, such as a scheduler or a detached agent run, should declare that nobody is watching:

export HITL_CHANNEL=messenger

Each 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.

Route questions by workspace

Some 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:

channel = "auto"

[[workspace]]
labels = ["project-a", "project-a-infra"]
channel = "terminal"

With 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.

Labels 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.

Configuration

Two files, both in the config directory:

FilePurpose
config.tomlEverything non-secret. Written by herdr-hitl config init.
.envTokens and ids. Copy from .env.example, chmod 600.

Where they live:

PlatformConfig directoryState directory
Linux$XDG_CONFIG_HOME/herdr-hitl (default ~/.config/herdr-hitl)$XDG_STATE_HOME/herdr-hitl (default ~/.local/state/herdr-hitl)
macOS~/Library/Application Support/herdr-hitl$XDG_STATE_HOME/herdr-hitl (default ~/.local/state/herdr-hitl)
Windows%APPDATA%\herdr-hitl%LOCALAPPDATA%\herdr-hitl

These 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.

config.toml

# Route questions through messenger (default), terminal, or the Away marker (auto).
channel = "auto"
# Transports used when `ask` omits --transport, in delivery order.
transports = ["telegram"]
# Default deadline for `ask`. 0 waits forever.
timeout = "30m"

# Questions from these Herdr workspaces use this channel policy instead.
[[workspace]]
labels = ["project-a"]
channel = "terminal"

[telegram]
enabled = true
bot_token = ""            # prefer .env
chat_id = "123456789"
allowed_user_ids = ["123456789"]   # empty = anyone in the chat may answer
api_base = ""             # override only for a local Bot API server

[discord]
enabled = false
bot_token = ""            # prefer .env
channel_id = ""           # blank + user_id set = deliver by DM
user_id = ""
allowed_user_ids = []
message_content_intent = false   # true only for free-text replies in a guild channel

[daemon]
idle_shutdown = "0s"      # 0 = stay resident; otherwise exit after this long with nothing pending
log_level = "info"        # debug | info | warn | error

[herdr]
notifications = true      # mirror questions to Herdr's notification surface
pane_tokens = true        # expose pending state as a pane token for the status bar

Environment variables

VariableOverridesAlias
HITL_TELEGRAM_BOT_TOKENtelegram.bot_tokenTELEGRAM_BOT_TOKEN
HITL_TELEGRAM_CHAT_IDtelegram.chat_idTELEGRAM_CHAT_ID
HITL_DISCORD_BOT_TOKENdiscord.bot_tokenDISCORD_BOT_TOKEN
HITL_DISCORD_CHANNEL_IDdiscord.channel_idDISCORD_CHANNEL_ID
HITL_DISCORD_USER_IDdiscord.user_id—
HITL_TRANSPORTStransports (comma-separated)—
HITL_CHANNELchannel—
HITL_TIMEOUTtimeout—
HITL_LOG_LEVELdaemon.log_level—
HITL_IDLE_SHUTDOWNdaemon.idle_shutdown—
HITL_HERDR_NOTIFICATIONSherdr.notifications—
HITL_HERDR_PANE_TOKENSherdr.pane_tokens—
HITL_CONFIG_DIRconfig directory—
HITL_STATE_DIRstate directory—
HITL_SOCKETdaemon endpoint path—
HITL_AGENTdefault --agent label—

CLI reference

ask — post a question and block until it is answered

FlagMeaning
-t, --title stringOne-line summary. Shown as the message heading.
-m, --message stringQuestion body, Markdown. - reads stdin.
--message-file PATHRead the body from a file instead.
-c, --choice stringsRepeatable. id=Label or bare Label (id slugified from the label).
--primary stringsChoice ids rendered as the primary/affirmative button.
--danger stringsChoice ids rendered as the destructive button.
--freeAllow a free-text answer. Default true; --free=false forces a choice.
-a, --attach stringsRepeatable path to an image or document (.md, .html, .png, …).
--timeout durationDeadline. Default from config (30m). 0 waits forever.
--transport stringstelegram, discord. Default from config.
--channel stringmessenger, terminal, or auto. Overrides routing for this call.
--agent stringLabel shown to the human. Default $HITL_AGENT, else agent.
--default stringText to print if the deadline passes, instead of failing.
-o, --format stringtext or json. Default text.

-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.

ANSWER=$(herdr-hitl ask \
  -t "Force-push to main?" \
  -m "Rebase dropped 2 merge commits. Force-push, or open a PR instead?" \
  -c "push=Force-push" -c "pr=Open a PR" \
  --danger push --primary pr --free=false --timeout 15m)

notify — fire-and-forget message, never blocks

Flags: -t/--title, -m/--message, --message-file, -a/--attach, --transport, --channel, --agent.

herdr-hitl notify -t "Migration finished" -m "42 tables, 0 errors, 6m12s." -a report.md

channel — print the resolved destination

herdr-hitl channel
herdr-hitl channel -o json

Text 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.

away — declare that nobody is watching the terminal

herdr-hitl away
herdr-hitl away --for 2h

Without --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.

afk — declare unavailable mode

herdr-hitl afk
herdr-hitl afk --for 2h

Declares 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.

here — declare that a human is watching the terminal

herdr-hitl here

Clears the Away or AFK marker and prints the resolved channel.

pending — list questions currently awaiting an answer

herdr-hitl pending -o json

answer — resolve a question from the terminal instead of the messenger

herdr-hitl answer a3f19c7b0e42 --choice pr
herdr-hitl answer a3f19c7b0e42 --text "use pgbouncer"

cancel — withdraw a question

herdr-hitl cancel a3f19c7b0e42 --reason "resolved by reading the migration guide"

serve — run the daemon in the foreground

Holds the messenger connections and serves CLI clients. Exits 0 quietly when another daemon already owns the endpoint. Use daemon start to spawn a detached one instead.

herdr-hitl serve

daemon — lifecycle

herdr-hitl daemon start     # idempotent; spawns a detached serve if none is live
herdr-hitl daemon status -o json   # -o text|json, default text
herdr-hitl daemon restart   # after editing config or rotating a token
herdr-hitl daemon stop

doctor — diagnose

herdr-hitl doctor -o json

The 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.

config — inspect and scaffold

herdr-hitl config path   # path of config.toml, whether or not it exists
herdr-hitl config show   # effective config after env overrides, tokens redacted
herdr-hitl config init   # write a starter config.toml; never overwrites without --force

install-cli — put the binary on PATH

herdr-hitl install-cli --dir ~/.local/bin

version

herdr-hitl version -o json

Exit codes

CodeMeaning
0Answered. (Or: notify delivered, other command succeeded.)
1Error — no transport, bad token, daemon unreachable, delivery failed.
2Usage error — unknown flag, missing required flag, bad value.
3Timeout — the deadline passed with no answer. --default converts this to 0.
4Canceled or declined — the human dismissed it, or the daemon was told to cancel.
5Terminal channel — nothing was sent. Ask in the current interface; this is never approval.
6AFK channel — human declared unavailable mode; questions and notifications are refused before daemon or network. Never approval.

Messenger setup

Agent Skill

The skill lives in-repo at 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.

With npx skills (recommended)

skills is a skill installer that works across Claude Code, Cursor, Codex, Copilot, opencode, and 70-odd other harnesses.

npx skills add huketo/herdr-hitl

That 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:

npx skills add huketo/herdr-hitl -g                                   # install for every project
npx skills add huketo/herdr-hitl --skill herdr-hitl -a claude-code -y # non-interactive
npx skills add huketo/herdr-hitl#v0.1.0                               # pin to a tag
npx skills add ./                                                     # from a local checkout
npx skills list                                                       # what is installed
npx skills update herdr-hitl                                          # pull a newer version

It 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.

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.

By hand

No Node, or you want the skill to track a Herdr-managed checkout:

# From an installed plugin — plugin_root is where Herdr keeps the checkout.
ROOT=$(herdr plugin list --plugin huketo.hitl --json | jq -r '.result.plugins[0].plugin_root')
ln -s "$ROOT/skills/herdr-hitl" ~/.claude/skills/herdr-hitl

# From a git checkout
ln -s "$PWD/skills/herdr-hitl" ~/.claude/skills/herdr-hitl

Symlink rather than copy so a plugin upgrade updates the skill too.

The 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 for why it ships in-repo instead of being generated.

Architecture

The 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.

sequenceDiagram
    participant A as Agent
    participant C as herdr-hitl ask
    participant D as daemon (serve)
    participant M as Telegram / Discord
    participant H as Human

    A->>C: exec, blocks on stdout
    C->>D: connect (unix socket / named pipe), ipc.OpAsk
    Note over C,D: connection held open for the whole ask —<br/>client EOF cancels the question
    D->>M: Poster.Post — message + buttons + attachments
    M->>H: notification on phone
    H->>M: taps a button or types a reply
    M->>D: update / interaction
    D->>M: Poster.Settle — disable controls, show outcome
    D-->>C: ipc.Response with hitl.Answer
    C-->>A: answer on stdout, status as exit code

Development

make build   # -> bin/herdr-hitl
make test    # go test -race
make lint    # golangci-lint
make cover   # coverage.out + summary
make check   # vet + lint + test

Link the working tree into Herdr instead of installing it — plugin link skips build commands, so build first:

make build && herdr plugin link "$PWD"

Domain vocabulary is in CONTEXT.md; decisions are in docs/adr/.

License

MIT. See LICENSE.

↑↓ navigate openesc close