---
slug: 62-human
title: Human-in-the-loop
description: Approval gates, confirmations, questions and previews are language primitives in Synsema — waiting for a real human in the terminal, denying fail-closed where no human exists, and queueing with one-time tokens under serve.
example_ids: [human]
---

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

```synsema
-- 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

```synsema
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:

```synsema
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:

```synsema
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**:

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