---
slug: 62-human
title: Humano en el loop
description: Gates de aprobación, confirmaciones, preguntas y previews son primitivas del lenguaje en Synsema — esperan a un humano real en la terminal, deniegan fail-closed donde no hay humano, y se encolan con tokens de un solo uso bajo serve.
example_ids: [human]
---

# Humano en el loop

Los gates de aprobación y las preguntas son primitivas del lenguaje — devuelven valores sobre
los que ramificás. El runtime garantiza una cosa en todos lados: **un gate jamás se
auto-aprueba en silencio**. Donde un humano puede responder, Synsema lo espera; donde no hay
ninguno, deniega fail-closed y lo dice.

```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")
```

## Primitivas

```synsema
let ok be approve "¿Deploy a producción?"               -- gate sí/no (devuelve un bool)
confirm "¿Mandar email a 500 clientes?"                 -- confirmación
let env be ask "¿Qué entorno?" with ["staging", "prod"]
show data as "Preview"                                  -- mostrar a un humano
```

Usalas como expresiones:

```synsema
when approve "Pago grande: $" + text(amount)
    process_payment()
otherwise
    cancel()
```

## Timeouts: `within`

`approve`, `confirm` y `ask` aceptan un **`within <n><s|m|h|d>`** opcional — la espera máxima
por la respuesta humana antes de denegar fail-closed:

```synsema
let ok be approve "¿Borrar la tabla de producción?" within 2h
let v be ask "¿Color del tema?" with ["azul", "oscuro"] within 90s
```

Precedencia: el `within` del gate > el knob `SYNSEMA_HUMAN_TIMEOUT` (segundos, env/`.env`) >
default de **300s**. Al vencer, el gate devuelve `false` (o el fallback documentado de `ask`)
y un aviso único por stderr explica que ningún humano respondió — el programa sigue, jamás
queda colgado para siempre.

## De dónde llega la respuesta

| Contexto | Comportamiento |
|---|---|
| `synsema run` en una terminal (TTY) | El gate **pregunta ahí mismo** (`[approve] … (y/n):`) y te espera — sin timeout; Ctrl+C corta, EOF deniega. |
| `synsema run` sin TTY (pipes, CI, manejado por un agente) | **Deniega al instante, fail-closed**, con un aviso único por stderr que le dice explícitamente a los agentes de IA que debe aprobar un HUMANO — un agente no puede fingir tu aprobación. `ask` cae al fallback (primera opción / `""`). |
| `synsema serve` | El gate se **encola** y el request bloquea hasta que un humano responde fuera de banda o vence el deadline (el vencimiento deniega). Ver abajo. |
| `synsema test` / `conform` | Determinista: los gates auto-pasan para que las suites nunca bloqueen en un prompt. |

## Aprobaciones bajo `serve`

Cuando un handler llega a un gate, el server imprime una línea en su consola con un
**token de un solo uso** y el comando listo:

```
[synsema] approval pending interact_1 — "¿Borrar la tabla de producción?" (expires in 7200s).
A HUMAN can respond with: POST /approvals/interact_1 {"decision": true|false, "token": "<64-hex>"}
```

Dos rutas reservadas (atendidas antes que las tuyas, como `/llms.txt`):

- `GET /approvals` → `{"pending": [{"id", "message", "type", "expires_at"}]}` — **nunca**
  incluye tokens.
- `POST /approvals/{id}` con `{"token": "...", "decision": true|false}` (o
  `{"token": "...", "value": "texto"}` para `ask`) → `200`; token incorrecto → `403` y el
  gate sigue esperando; id inexistente/vencido/ya respondido → `404`; body malformado → `400`.

El token se genera por aprobación (32 bytes aleatorios), se consume al usarse y expira con el
deadline — poseer la consola del server es lo que te autoriza a responder. Un gate bloqueado
retiene su hilo del request toda la espera, así que el `within` bajo serve es para minutos,
no días.

## Notificá cualquier canal: webhooks + links de decisión

Seteá `SYNSEMA_HUMAN_WEBHOOK=<url>` y cada gate encolado dispara además un **webhook
firmado** — un POST plano (el mismo patrón de los webhooks de Stripe/GitHub), así que lo
recibe CUALQUIER cosa: otro programa Synsema, n8n, una lambda, un bot de chat. El payload
lleva id, mensaje, vencimiento, token y — con `SYNSEMA_HUMAN_PUBLIC_URL` seteada — **links
de decisión listos para reenviar**:

```json
{"id": "interact_1", "type": "approve", "message": "¿Borrar la tabla de producción?",
 "expires_at": 1786500000, "token": "<64-hex>",
 "respond_path": "/approvals/interact_1",
 "respond_url": "https://mi-app.com/approvals/interact_1",
 "respond_link_yes": "https://mi-app.com/approvals/interact_1/<token>?d=yes",
 "respond_link_no": "https://mi-app.com/approvals/interact_1/<token>?d=no"}
```

Tu canal reenvía los links por SMS/chat/email; el humano decide **abriendo uno**
(`GET /approvals/{id}/{token}?d=yes|no` — un solo uso, devuelve una pagina mínima de
confirmación). Con `SYNSEMA_HUMAN_WEBHOOK_SECRET` seteada, el body va firmado con HMAC-SHA256
en `X-Synsema-Signature: sha256=<hex>` para que tu receptor verifique el origen — en
producción seteala siempre. El envío es fire-and-forget (un intento, 10s): un canal caído
jamás bloquea el gate — quedan la consola y `GET /approvals` de fallback. Un canal escrito en
Synsema son ~6 líneas: una ruta que hace `json_decode(body of request)` y reenvía
`respond_link_yes`/`no` adonde quieras.

## Sin TTY (pipes / CI / tests)

Sin canal humano, el `ask "q"` de texto libre devuelve `""` y `ask "q" with [opts]` toma la
**primera** opción (como muestra el doctest de arriba) — con un aviso único de que ningún
humano respondió de verdad. No confíes en el `ask` de texto libre para entrada ahí. Para stdin
que funciona con pipes, usá **`read_line(prompt?)`**; para entrada estilo config trivial de
testear, usá **`env("NAME", "default")`**.
