Human-in-the-loop
Approval gates and questions are language primitives — they return values you branch on. The
runtime guarantees one thing everywhere: a gate is never silently auto-approved. Where a
human can answer, Synsema waits for them; where none can, it denies fail-closed and says so.
-- Doc example: human interaction. The primitives are interactive; this shows the
-- documented NON-TTY behavior (CI/tests/pipes), which is deterministic.
intent: "doc example: human interaction (non-TTY)"
print("ask picked → " + (ask "Pick an environment" with ["staging", "prod"])) -- no TTY → first
test "ask with options takes the FIRST option when there is no TTY (CI/tests/pipes)"
let choice be ask "Pick an environment" with ["staging", "prod"]
assert_eq(choice, "staging")
Primitives
let ok be approve "Deploy to production?" -- yes/no gate (returns a bool)
confirm "Send email to 500 customers?" -- confirmation
let env be ask "Which environment?" with ["staging", "prod"]
show data as "Preview" -- display to a human
Use them as expressions:
when approve "Large payment: $" + text(amount)
process_payment()
otherwise
cancel()
Timeouts: within
approve, confirm and ask take an optional within <n><s|m|h|d> — the maximum wait
for the human answer before denying fail-closed:
let ok be approve "Delete the production table?" within 2h
let v be ask "Theme color?" with ["blue", "dark"] within 90s
Precedence: within on the gate > the SYNSEMA_HUMAN_TIMEOUT env/.env knob (seconds) >
300s default. On expiry the gate returns false (or ask's documented fallback) and a
one-time stderr notice explains that no human answered — the program continues, never hangs
forever.
Where the answer comes from
| Context | Behavior |
|---|---|
synsema run in a terminal (TTY) | The gate prompts right there ([approve] … (y/n):) and waits for you — no timeout; Ctrl+C aborts, EOF denies. |
synsema run without a TTY (pipes, CI, driven by an agent) | Denies instantly, fail-closed, with a one-time stderr notice that explicitly tells AI agents a HUMAN must approve — an agent cannot fake your approval. ask falls back (first option / ""). |
synsema serve | The gate is queued and the request blocks until a human responds out-of-band or the deadline expires (expiry denies). See below. |
synsema test / conform | Deterministic: gates auto-pass so test suites never block on a prompt. |
Approvals under serve
When a route handler hits a gate, the server prints one line on its console with a
one-time token and a ready-to-use command:
[synsema] approval pending interact_1 — "Delete the production table?" (expires in 7200s).
A HUMAN can respond with: POST /approvals/interact_1 {"decision": true|false, "token": "<64-hex>"}
Two reserved routes (served before your own, like /llms.txt):
GET /approvals→{"pending": [{"id", "message", "type", "expires_at"}]}— never
includes tokens.
POST /approvals/{id}with{"token": "...", "decision": true|false}(or
{"token": "...", "value": "text"} for ask) → 200; wrong token → 403 and the gate
keeps waiting; unknown/expired/already-answered id → 404; malformed body → 400.
The token is generated per approval (32 random bytes), is consumed on use, and expires with
the deadline — holding the server console is what authorizes you to answer. A blocked gate
holds its request thread for the whole wait, so within under serve is meant for minutes,
not days.
Notify any channel: webhooks + decision links
Set SYNSEMA_HUMAN_WEBHOOK=<url> and every queued gate also fires a signed webhook —
a plain POST (the same pattern as Stripe/GitHub webhooks), so ANY receiver works: another
Synsema program, n8n, a lambda, a chat bot. The payload carries the id, message, expiry,
token and — with SYNSEMA_HUMAN_PUBLIC_URL set — ready-to-forward decision links:
{"id": "interact_1", "type": "approve", "message": "Delete the production table?",
"expires_at": 1786500000, "token": "<64-hex>",
"respond_path": "/approvals/interact_1",
"respond_url": "https://my-app.com/approvals/interact_1",
"respond_link_yes": "https://my-app.com/approvals/interact_1/<token>?d=yes",
"respond_link_no": "https://my-app.com/approvals/interact_1/<token>?d=no"}
Your channel forwards the links by SMS/chat/email; the human decides by opening one
(GET /approvals/{id}/{token}?d=yes|no — single-use, returns a small confirmation page).
With SYNSEMA_HUMAN_WEBHOOK_SECRET set, the body is signed with HMAC-SHA256 in
X-Synsema-Signature: sha256=<hex> so your receiver can verify origin — always set it in
production. Delivery is fire-and-forget (one attempt, 10s): a dead channel never blocks the
gate — the console notice and GET /approvals remain as fallback. A channel written in
Synsema is ~6 lines: a route that does json_decode(body of request) and forwards
respond_link_yes/no wherever you like.
No TTY (pipes / CI / tests)
Without a human channel, free-text ask "q" returns "" and ask "q" with [opts] takes the
first option (as the doctest above shows) — with a one-time notice that no human actually
answered. Don't rely on free-text ask for input there. For stdin that works with pipes, use
read_line(prompt?); for config-style input that's trivial to test, use
env("NAME", "default").