Synsema docsENES

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.

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

ContextBehavior
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 serveThe gate is queued and the request blocks until a human responds out-of-band or the deadline expires (expiry denies). See below.
synsema test / conformDeterministic: 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):

includes tokens.

{"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").