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