gate-sh
v0.1.0
Published
ntfy tells you things. gate asks you things. Human approval as a curl.
Maintainers
Readme
The one-liner
curl -d "Deploy v2 to prod?" "https://gate.legible.sh/ops-x7k2q?wait=300"The curl blocks. On your phone, https://gate.legible.sh/ops-x7k2q is a page with the question and two buttons. Tap one, and the curl returns:
{"id":"aB3dE9fG","status":"decided","decision":"approve","askedAt":"2026-07-06T09:14:02.113Z","decidedAt":"2026-07-06T09:14:31.870Z"}The hosted API at gate.legible.sh is in soft launch. To run the identical API yourself:
npx gate-sh servestarts it onhttp://127.0.0.1:4180in one command.
Why
Your agents are smart but infrastructurally naked. A cron agent hits a destructive migration at 3am and its "interactive ask" mode hangs forever in a headless container. A CI pipeline needs a human sign-off and grows a whole Slack app. ntfy can tell you the agent wants something — but it can't carry your answer back, and anyone holding the topic name can forge an "approval." gate is the missing verb: a blocking question with per-request decide tokens, from any language, any sandbox, anything that can curl.
The whole API
| Route | What it does |
|---|---|
| POST /{topic} | Ask. Body is the question — plain text, or JSON {question, options?, context?}. Blocks up to ?wait= seconds (default 300, max 3600) → 200 decided or 408 pending. ?async=1 → 201 {id, status, poll} immediately. |
| GET /{topic}/{id} | Poll one ask. ?wait= to long-poll (default 0) → 200 decided / 408 pending. Browser: a single-ask decide page. |
| GET /{topic} | curl: JSON {pending, recent}. Browser: phone-friendly page with Approve/Deny buttons and a note field. Accept: text/event-stream: SSE (ask / decision events, heartbeat comments every 25s). |
| POST /{topic}/{id}/decide | Decide. {decision:"approve"\|"deny", note?} + the per-ask decide token (X-Decide-Token header or ?t=). Plain-text approve/deny and HTML form posts work too. |
| PUT /{topic}/config | {"notify":"https://ntfy.sh/xyz"} — every new ask POSTs the question + decide link there (fire-and-forget, 2s timeout). "" clears. Write-only. |
| GET /{topic}/log | Audit trail: every ask, decision, and config change with timestamps, notes, user-agents. |
| GET /README.md · /llms.txt | These docs, as text, for agents. Never token-gated; GET / with Accept: text/markdown serves the README too. |
Topics match [a-zA-Z0-9_-]{1,64} and are created by first use. Errors are JSON {"error":"...","code":"...","hint":"..."} with correct status codes — hint spells out the correct next request. All limits (16 KB bodies, 100 pending per topic, 7-day retention, …) live in src/limits.mjs.
Asking
# Plain text, blocking (the default wait is 300s):
curl -d "Deploy v2 to prod?" "http://127.0.0.1:4180/ops?wait=300"
# JSON: context renders on the approval page; options relabels the two buttons
# ([approveLabel, denyLabel] — the wire decision is still approve/deny):
curl -H "Content-Type: application/json" \
-d '{"question":"Ship the migration?","context":"+120 -3 across 4 files","options":["Ship","Hold"]}' \
"http://127.0.0.1:4180/ops?async=1"
# Fire-and-forget returns 201 immediately:
# {"id":"aB3dE9fG","status":"pending","poll":"/ops/aB3dE9fG"}
# ...then long-poll it:
curl "http://127.0.0.1:4180/ops/aB3dE9fG?wait=60"Deciding
Every ask mints a random decide token — approving requires it, so seeing a question is not the same as being able to answer it. Where the token comes from depends on the mode:
Convenience mode (default). The /{topic} page embeds the tokens in its buttons, and the JSON listing carries decideToken on pending asks. Knowing the topic name is the full capability — honest capability-by-obscurity, same model as ntfy. Pick unguessable topic names.
curl "http://127.0.0.1:4180/ops" # find id + decideToken
curl -d approve "http://127.0.0.1:4180/ops/aB3dE9fG/decide?t=TOKEN"
# or with a note:
curl -H "X-Decide-Token: TOKEN" -H "Content-Type: application/json" \
-d '{"decision":"deny","note":"not on a friday"}' \
"http://127.0.0.1:4180/ops/aB3dE9fG/decide"Bound mode. Set a notify URL and tokens are only delivered to that channel — the page and JSON stop exposing them, and the asker can no longer approve its own request:
curl -X PUT -d '{"notify":"https://ntfy.sh/my-private-alerts-k3j9"}' \
"http://127.0.0.1:4180/ops/config"Now every ask pings your phone via ntfy with a one-tap decide link, and /{topic} says "decide link was sent to this topic's notification channel."
Watching and auditing
curl "http://127.0.0.1:4180/ops" # one-shot JSON
curl -N -H "Accept: text/event-stream" "http://127.0.0.1:4180/ops" # live SSE
curl "http://127.0.0.1:4180/ops/log" # audit trailTeach your agent
Paste this into your CLAUDE.md / AGENTS.md (set the base URL and topic for your project):
## Human approvals (gate)
GATE=https://gate.legible.sh # or your self-hosted instance
Before destructive/irreversible actions, ask a human and block:
curl -s -d "one-line question" "$GATE/{topic}?wait=300"
→ 200 {"decision":"approve"|"deny","note"?} — obey it
→ 408 {"id","status":"pending","poll":"/{topic}/{id}"} — nobody answered; treat as NO,
or keep waiting: curl -s "$GATE/{topic}/{id}?wait=300"
Add detail with JSON: -H "Content-Type: application/json" -d '{"question":"...","context":"diff/plan summary"}'
Fire-and-forget: append ?async=1 → 201 {"id","poll"}.
Humans answer at $GATE/{topic} in a browser. Never decide your own asks.
Shell shortcut (if the gate CLI is installed): `gate ask {topic} "question"` exits 0=approve 1=deny 2=timeout.Self-hosting
npx gate-sh serve
# gate listening on http://127.0.0.1:4180Or clone and run — there are zero dependencies to install:
git clone https://github.com/legible-sh/gate.git && cd gate
npm test # 52 tests, no network needed
node bin/gate.mjs serve --port 4180 --data-dir ./data --base-url https://gate.internal| Flag | Effect |
|---|---|
| --port / --host | Listen address (default 4180 on 0.0.0.0). |
| --data-dir | Persist to <dir>/events.jsonl (JSONL, replayed and compacted on boot). Without it, state is in-memory only. |
| --token | Require Authorization: Bearer <token> on every route except /, /README.md, and /llms.txt. Browsers can use ?token= — pages thread it through their forms. HTML pages are gated too, because they embed decide tokens. |
| --base-url | Absolute URLs in poll fields, pages, and notify links (otherwise derived from the Host header). |
CLI
The CLI is sugar over the same HTTP API — curl remains the contract. Base URL: --url flag, then GATE_URL env, then https://gate.legible.sh. Bearer auth: --auth or GATE_TOKEN.
gate ask ops "Deploy v2 to prod?" --timeout 600 # blocks; exits 0=approve 1=deny 2=timeout
gate ask ops "Deploy?" --async # prints the ask id
gate list ops # pending + recent [--json]
gate decide ops aB3dE9fG approve --note "go" # fetches the decide token for you
gate log ops # audit trail [--json]
gate serve --port 4180 --data-dir ./data # the serverThe exit codes make bash the whole integration:
gate ask ops "Deploy v2 to prod?" && ./deploy.shAnd examples/claude-hook.sh is a Claude Code PreToolUse hook that gates dangerous shell commands behind a phone tap. More walkthroughs in examples/.
Pro
Planned for the hosted instance at gate.legible.sh — capacity and guarantees, never the core verbs. Self-hosting stays complete:
- Reserved topic names — own
opsinstead ofops-x7k2q. - Retention — decision audit trails beyond the free 7 days.
- Named deciders — per-person decide tokens for teams: who approved, not just that someone did.
- Slack / email delivery — decide links in the channels your team already watches.
- SLA on the hosted instance.
Straight talk
- Convenience mode is capability-by-obscurity. Anyone with the topic name can see questions and answer them (the page hands out the tokens — that is the point). Unguessable names are the access model, exactly like ntfy. Want more: bound mode,
--token, or reserved names on the hosted instance. - Bound mode's remaining gap is topic-name config. Anyone with the topic name can repoint
notify. gate warns the previous channel the moment that happens and records it in the audit log, but the real fix is--tokenon your own instance (config then requires auth) or a reserved topic. - A timeout is not a deny.
408means nobody answered. The ask stays decidable until it's pruned. Script accordingly (&&treats timeout as NO, which is the safe default). - Retention is 7 days, and this is not a database. State is in-memory with an optional JSONL file. Everything — pending asks, decisions, log — is pruned after 7 days. Need the audit forever? Pull
/loginto your own storage. - The data dir contains secrets.
events.jsonlholds decide tokens (mode600, but it's on you). Same for the notify channel: anyone who can read your ntfy topic can decide your asks — use a private one. - No WebSocket. Long-poll and SSE only — deliberate, to stay at zero dependencies. Notify delivery is fire-and-forget with a 2s timeout; a dead webhook never delays an ask.
The family
gate is one of the legible primitives — same stack, same conventions, one verb each:
| | | |---|---| | gate (4180) | ntfy tells you things. gate asks you things. | | bigred (4181) | The big red button for your agent fleet. | | trail (4182) | The flight recorder for agent runs. | | slate (4183) | The blackboard from the multi-agent papers, as a URL. | | relay (4184) | The work queue your agents can provision themselves. | | mutex (4185) | flock(1) for agents that live on different machines. | | quorum (4186) | Coordination for agents that do not share a parent process. | | meter (4187) | The kill-brake for agent spend. 429-as-a-service. | | stash (4188) | ntfy moves signals. stash moves bytes. | | tally (4189) | StatHat reborn as ntfy. Three months too late — or right on time. |
License
MIT.
