---
slug: 47-agentic-apps
title: Apps agénticas (rutas WebSocket, procesos vivos, bus de eventos, select)
description: El lado servidor de una app agéntica en Synsema — rutas WebSocket entrantes, procesos hijos que mirás línea a línea, un bus de eventos in-process que llega a todos los clientes vivos, un solo select sobre todo eso, timeouts de handler con cancelación real y un shutdown ordenado.
example_ids: [agentic]
---

# Apps agénticas

Una app que orquesta agentes frente a una UI en vivo necesita cinco cosas que un servidor REST común no tiene: un **canal bidireccional** al navegador, un **proceso hijo que podés mirar mientras corre**, una forma de que un evento llegue a **todos** los clientes abiertos, **una sola espera** sobre todo eso, y un servidor que **vence, cancela y se apaga** sin dejar nada atrás. Desde el engine **v0.6.7** las cinco están en el lenguaje, con las mismas reglas que todo lo demás: deny-by-default, memoria acotada, errores que se dicen.

**No es sólo para agentes.** Las mismas cinco primitivas son lo que necesitan un chat, un minijuego multijugador, un editor colaborativo, un dashboard en vivo, un bot de build o una terminal en el navegador: un socket por cliente, un bus por sala o tema (`bus_subscribe("room." + id)`), un proceso que mirás, un `select` por conexión y un servidor que limpia lo suyo. "Agéntica" es donde se encontró el hueco; la página aplica a cualquier programa con estado vivo compartido entre clientes.

```synsema
-- Doc example: the event bus (`bus_*`) and the unified `select` — the plumbing of an
-- agentic app. N subscribers receive the SAME event (fan-out — unlike signal/wait_for,
-- which one receiver consumes), queues are bounded, topics accept globs, and ONE wait
-- covers any mix of handles. Pure: no capability (same trust level as share/signal).
-- Incoming sockets (`route … socket`) and live processes (`proc_*`) need a running
-- server / an `exec` grant, so the page prose covers those with the shapes that run.
intent: "doc example: event bus + select"

let wide be bus_subscribe("agent.*")
let exact be bus_subscribe(["agent.done", "ui.*"])
let other be bus_subscribe("other")

test "publish fans out to every matching subscriber and says how many got it"
    assert_eq(bus_publish("agent.done", {"step": 1}), 2)
    let ev be bus_recv(wide, 1)
    assert_eq(ev["type"], "event")
    assert_eq(ev["topic"], "agent.done")
    assert_eq(ev["data"]["step"], 1)
    assert_eq(bus_recv(exact, 1)["topic"], "agent.done")
    assert_eq(bus_recv(other, 0.1), nothing)           -- no match → nothing at the timeout

test "select waits on any handles at once and tags the winner"
    bus_publish("ui.click", "go")
    let ev be select({"wide": wide, "exact": exact}, 1)
    assert_eq(ev["name"], "exact")
    assert_eq(ev["source"], "bus")
    assert_eq(ev["topic"], "ui.click")
    assert_eq(ev["data"], "go")
    assert_eq(select([wide, other], 0.1), nothing)      -- nothing ready → nothing

task publish_glob()
    give bus_publish("agent.*", 1)                      -- globs belong to bus_subscribe

task publish_task()
    give bus_publish("agent.done", publish_glob)        -- a task is not data

test "a published topic is literal and the payload must be data (loud, not degraded)"
    assert_error(publish_glob)
    assert_error(publish_task)

test "queues are bounded: drop_oldest keeps the newest — the publisher never blocks"
    let small be bus_subscribe("q", {"max_queue": 2})
    bus_publish("q", 1)
    bus_publish("q", 2)
    bus_publish("q", 3)
    assert_eq(bus_recv(small, 1)["data"], 2)
    assert_eq(bus_recv(small, 1)["data"], 3)
    bus_unsubscribe(small)

test "bus_topics lists live subscriptions by pattern"
    assert(some(bus_topics(), (t) => t["topic"] == "agent.*"))
```

## Rutas WebSocket — `socket`

Una ruta con un bloque `socket` acepta un WebSocket entrante. Adentro, `socket` es el handle de *esta* conexión — el mismo tipo de handle que devuelve [`ws_connect`](/es/0.6.x/39-websocket), así que toda la familia `ws_*` funciona sin cambios:

```synsema
require serve(8080)

serve on 8080
    route "GET /ws"                              -- el handshake es un GET (RFC 6455)
        socket
            ws_send(socket, {"hello": "agent"})
            while true
                let ev be ws_recv(socket, 30)    -- {type: "text"|"binary"|"close", data}
                when ev == nothing               -- 30 s sin nada
                    stop
                when ev["type"] == "close"
                    stop
                otherwise
                    ws_send(socket, {"echo": ev["data"]})

    route "GET /private" requires auth            -- la auth corre ANTES del upgrade: 401, sin upgrade
        socket
            ws_send(socket, "hello " + request.user.name)
```

- `request`, `params`, `query`, `headers` y `user` están ligados como en cualquier ruta. `ws_stats(socket)["role"]` es `"server"`.
- **El cierre es honesto.** Termina el bloque o `stop` → `Close 1000`. Error no atrapado → `Close 1011` + el mensaje (≤ 123 bytes) y una línea `[socket] … handler failed` en el log. El server cancelando el handler (`timeout` de la ruta, shutdown) → `Close 1001` (going away) + el motivo. Un cliente que desaparece sin handshake de cierre llega como `{type: "close", data: "connection reset without closing handshake"}` en el próximo `ws_recv`/`select`.
- Un request HTTP común a una ruta socket → **`426 Upgrade Required`** (body JSON, header `Upgrade: websocket`). Los clientes HTTP/2 reciben el mismo 426; los navegadores siempre abren WebSockets por HTTP/1.1.
- **El keepalive** es del server: responde pings y manda los suyos cada `SYNSEMA_WS_SERVER_PING` segundos (default 30) mientras el handler espera; sin pong en dos intervalos → un `close` con motivo `keepalive timeout`. Un mensaje mayor a `SYNSEMA_WS_MAX_MESSAGE` (16MB, techo 64MB) es un error atrapable. El backpressure es real en las dos direcciones (canales acotados; la ventana TCP frena al lado lento).
- **Presupuesto:** un socket ocupa un slot de `max_streams` (`503` + `Retry-After` al agotar) y cuenta contra `SYNSEMA_WS_MAX_CONNS`. **Sin capability nueva** — la ruta ya está dentro de `serve`.
- Reglas: sólo `GET`, `socket` y `stream` en la misma ruta es error de parseo, todavía no se permite dentro de un grupo `export routes` (error claro). Fuera de una ruta `socket` es un nombre común.
- **Un handle jamás cruza requests.** Para empujar a N sockets abiertos desde un cron, un agente u otro request, cada handler de socket se suscribe al bus y reenvía — abajo. A propósito no hay una tabla global de sockets.

## Procesos vivos — `proc_*`

[`run`](/es/0.6.x/22-sandbox) es one-shot: captura todo y devuelve al final. Un agente que corre `cargo test`, `npm run build` o un worker largo necesita **ver la salida a medida que sale**, mandar stdin y matarlo — un proceso como **handle con eventos**. Mismo gate que `run` (`require exec(cmd)`, scope = el comando tal como se escribe), args como lista, nunca un shell. Pipes por defecto (una herramienta que detecta "no es una tty" se comporta como en CI); `"pty": true` le da una terminal real — ver [Pseudo-terminal](#pseudo-terminal-pty-true) abajo.

```synsema
require exec("cargo")

let p be proc_spawn("cargo", ["test"], {"cwd": "./repo"})
while true
    let ev be proc_recv(p, 60)                   -- {type: "stdout"|"stderr"|"exit", data} o nothing
    when ev == nothing
        proc_kill(p)                              -- 60 s en silencio: TERM ("KILL" para insistir)
    otherwise when ev["type"] == "exit"
        print("exit " + text(ev["data"]["exit_code"]))
        stop
    otherwise
        print(ev["type"] + ": " + ev["data"])     -- un evento por línea, sin el salto final
proc_close(p)
```

| Builtin | Devuelve |
|---|---|
| `proc_spawn(cmd, args?, opts?)` | handle |
| `proc_recv(h, timeout?)` | próximo evento, o `nothing` al vencer |
| `proc_select(lista \| mapa, timeout?)` | primer evento listo entre procesos (`select` acepta cualquier handle) |
| `proc_send(h, texto \| bytes)` | `true`; error si stdin está cerrado. Es una escritura bloqueante de pipe: un hijo que nunca lee stdin te bloquea |
| `proc_close_stdin(h)` | `true` — EOF al hijo. Sólo pipes: un pty no tiene un stdin aparte — mandá la tecla de EOF (`bytes([4])` Ctrl-D, `bytes([26, 13])` Ctrl-Z + Enter) |
| `proc_resize(h, cols, rows)` | `true` — sólo pty (SIGWINCH / ResizePseudoConsole); error en un proceso por pipes |
| `proc_status(h)` | `"running"` · `"exited"` · `"killed"` · `"closed"` |
| `proc_kill(h, señal?)` | `"TERM"` (default; SIGTERM en Unix) o `"KILL"`; en Windows ambas terminan. Alcanza al **árbol de procesos entero** (v0.6.9+, ver Ciclo de vida) |
| `proc_wait(h, timeout?)` | `{exit_code, signal}` o `nothing` |
| `proc_stats(h)` | `{pid, cmd, status, exit_code, pty, tree, queued, queued_bytes, dropped, uptime}` — `tree`: el kill alcanza a los nietos |
| `proc_close(h)` | libera el handle; **mata si sigue vivo** (TERM, KILL a los 2 s, luego wait) — el árbol entero. Sin huérfanos. Idempotente |

`exit` llega una vez, después de drenar los dos pipes, como `{exit_code, signal}` (`exit_code` `-1` si murió por señal; `signal` es `nothing` en una salida normal y siempre en Windows). `opts`: `cwd`, `env` (hereda + sobreescribe; un valor `secret` es error — `reveal()` explícito), `line_mode` (`true`; `false` = chunks crudos de **texto** ≤ 64 KiB — `data` siempre es texto, UTF-8 lossy, nunca partido dentro de un carácter), `stderr` (`"separate"` | `"merge"`), `pty` (+ `cols`, `rows`, `term` — abajo), `process_group` (`true`: grupo de procesos / Job Object propio para que el kill alcance al árbol; `false` desprende a propósito un daemon que debe sobrevivir a `proc_close` — entonces sólo muere el hijo directo), y la cola acotada `max_queue` (4096) / `max_queue_bytes` (64 MiB) con `on_full` = `"block"` (default: el lector deja de drenar, el hijo se frena en su `write` — backpressure real, sin pérdida) | `"drop_oldest"` | `"error"` (el próximo recv falla, el proceso se mata).

**Ciclo de vida.** Cuando termina el intérprete que lo lanzó — el request bajo `serve`, el programa, el agente — todo proceso vivo se mata. **El árbol entero, no sólo el hijo** (v0.6.9+): el hijo nace en su propio grupo de procesos en Unix y en un Job Object con kill-on-close en Windows, así `proc_kill`/`proc_close` sobre `sh -c "npm run dev"` se llevan también al `node` de abajo — y en Windows hasta un crash del intérprete cierra el job y el árbol con él. `proc_stats(h)["tree"]` te dice que está en efecto. Un handler nunca deja un fantasma; el trabajo que debe sobrevivir a un request va en `cron_after` o en un agente. `SYNSEMA_PROC_MAX` (64, techo 1024) acota los procesos vivos por intérprete. Un nieto que retiene el pipe tras el exit del hijo no te cuelga: 1 s de gracia y se entrega el `exit`.

### Pseudo-terminal — `pty: true`

*Engine v0.6.8+.* Muchos programas preguntan "¿estoy hablando con una terminal?" y, sobre un pipe, saltan la pregunta, asumen "no", se cuelgan o se niegan: prompts `y/N` que leen `/dev/tty`, contraseñas de `ssh`/`sudo`/`gpg`, menús con flechas, `docker run -it`, REPLs, barras de progreso y toda TUI (`vim`, `htop`, un CLI agéntico). `"pty": true` corre al hijo dentro de un pseudo-terminal real — `openpty` en Linux/macOS, ConPTY en Windows ≥ 10 1809. **Misma API, mismos eventos, mismo gate `exec(cmd)`**: un pty no otorga ningún poder del SO que un pipe no dé; cambia cómo se comporta el hijo, no qué puede hacer. `sandbox` lo deniega como a cualquier `exec`.

```synsema
require exec("npm")

let p be proc_spawn("npm", ["install"], {"pty": true, "cols": 120, "rows": 40})
let screen be ""
while true
    let ev be proc_recv(p, 60)
    when ev == nothing
        proc_kill(p)
    otherwise when ev["type"] == "exit"
        stop
    otherwise
        set screen to screen + ev["data"]              -- texto crudo en chunks, ANSI incluido
        when contains(strip_ansi(screen), "[y/N]")    -- leerlo como lo leería un humano
            proc_send(p, "y\r")                       -- teclas: Enter es \r en una tty
            set screen to ""
proc_close(p)
```

Qué cambia en modo pty — consecuencias de "es una terminal", no API nueva:

- **Un solo stream.** Una tty no separa stderr: todo llega como `stdout`.
- **Texto crudo con secuencias de escape.** `line_mode` pasa a `false` por defecto — `data` sigue siendo **texto**, no `bytes`: chunks UTF-8, nunca partidos dentro de un carácter, bytes inválidos → U+FFFD. Mandáselos a xterm.js como frames de texto tal cual, o [`strip_ansi(texto)`](/es/0.6.x/90-builtins) para ver lo que ve un humano (colores, movimientos de cursor y títulos OSC fuera; los redibujos con `\r` conservan el último cuadro). El runtime nunca interpreta VT.
- **El eco está activo.** Lo que mandás con `proc_send` vuelve por la salida. Un secret revelado que tipeás vuelve en claro — siguen valiendo las reglas de siempre (`secret` en args/env/send → error).
- **Teclas, no líneas.** Enter es `"\r"`, Ctrl-C es `bytes([3])`, Ctrl-D `bytes([4])`, una flecha es ESC + `[A`. No hay `proc_close_stdin` (el error dice qué tecla mandar).
- **Tamaño.** `cols`/`rows` (default 80×24), `proc_resize(h, cols, rows)` después. `term` fija `TERM` (default `xterm-256color`; `env.TERM` gana).
- **El kill alcanza al árbol** (como con pipes desde v0.6.9). En Unix el hijo es líder de sesión y se señala al grupo de procesos entero; en Windows se termina el Job Object y se cierra la pseudo-consola — ningún nieto sobrevive colgado de la tty.
- **El handshake de Windows está resuelto.** ConPTY emite `ESC[6n` al arrancar y no entrega nada hasta que alguien contesta; el runtime contesta ese primero y lo quita del stream (un xterm.js del otro lado no lo ve, así no contesta dos veces). Los `ESC[6n` posteriores son de la app y pasan intactos.
- `exit_code` es `-1` si murió por señal; en un pty `signal` es el número cuando se conoce (15, 9, 1, 2), si no `nothing`.

Una terminal web es una ruta `socket` y un pty en un mismo `select`: bytes del navegador → `proc_send`, eventos del proceso → `socket_send`, un mensaje `{resize}` → `proc_resize`; xterm.js dibuja. Quién recibe ese socket es una decisión de `auth` de la app — el runtime no agrega ninguna capability porque no hay nada nuevo que gatear.

Probá primero el flag no interactivo (`--yes`, `CI=1`, `DEBIAN_FRONTEND=noninteractive`): más barato y determinista. El pty es para cuando no existe.

## Bus de eventos — `bus_*`

`signal`/`wait_for` es punto a punto (un receptor consume). Una UI en vivo necesita **fan-out**: N handlers — uno por cliente SSE o socket — reciben el mismo evento que publicó un agente, un cron u otro request, sin polling. Eso es el bus. **Un bus por programa**, visible desde todos lados: top-level, workers de `parallel_map`, ticks de cron, agentes spawneados y cada handler de cada worker del server. Sólo in-process (Redis con la misma API es el roadmap). Sin capability — mismo nivel de confianza que `share`/`signal`.

```synsema
bus_publish("agent.progress", {"step": 3, "of": 10})   -- → cuántos suscriptores lo recibieron
let sub be bus_subscribe("agent.*")                     -- un topic o una lista; globs * y ?
let ev be bus_recv(sub, 25)                             -- {type: "event", topic, data, timestamp} o nothing
bus_unsubscribe(sub)
bus_topics()                                            -- [{topic, subscribers}] por patrón
```

- Los payloads son **datos** (texto/número/bool/lista/mapa/bytes); una task o un `secret` es error, nunca una degradación silenciosa. Los topics publicados son literales — un glob en `bus_publish` es error (los globs son de `bus_subscribe`).
- **Acotado por suscriptor** en cantidad y bytes: `bus_subscribe("t", {"max_queue": 1024, "max_queue_bytes": 16777216, "on_full": "drop_oldest"})`. `"drop_oldest"` es el default (un suscriptor lento nunca frena al publicador); `"error"` hace fallar el próximo recv y retira la suscripción.
- Una suscripción vive lo que vive su intérprete: al terminar el handler/agente se retira — nadie llena una cola que nadie lee.

La ruta SSE que todo dashboard quiere — alimentada por el bus, con el heartbeat a cargo del server:

```synsema
route "GET /events"
    stream
        let sub be bus_subscribe("agent.*")
        while true
            let ev be bus_recv(sub, 25)
            when ev != nothing
                send ev["data"] as ev["topic"]
```

## File-watch — `watch`

Cambios en disco como **handle con eventos** (engine **v0.6.9+**), en el mismo hub que procesos, sockets y bus — así un solo `select` cubre "cambió un archivo" y "terminó el build". Gate: `require file(path)` — mirar un árbol es leerlo, el mismo scope `file_read` que `list_dir`.

```synsema
require file("src")
require file("src/*")
require exec("cargo")

let files be watch("src", {"interval": 0.2, "ignore": ["*.tmp"]})
let build be nothing
while true
    let ev be select({"files": files, "build": build}, 60)
    when ev == nothing
        continue
    when ev["source"] == "watch"                  -- {type, path, is_dir}
        when build != nothing
            proc_close(build)                     -- mata el build anterior, árbol entero
        set build to proc_spawn("cargo", ["build"])
    otherwise when ev["type"] == "exit"
        print("build exit " + text(ev["data"]["exit_code"]))
```

| Builtin | Devuelve |
|---|---|
| `watch(path, opts?)` | handle — también lo acepta `select`, etiquetado `source: "watch"` |
| `watch_recv(h, timeout?)` | el próximo evento, o `nothing` al timeout |
| `watch_stats(h)` | `{path, recursive, interval, entries, scans, queued, dropped}` |
| `watch_close(h)` | libera el handle y apaga el scanner. Idempotente |

Los eventos son `{type: "create" | "modify" | "delete", path, is_dir}`. `path` usa `/` y queda relativa si la raíz era relativa. Un rename es un `delete` más un `create`. Los directorios sólo emiten `create`/`delete` (su mtime cambia con cada hijo — ruido). No se emite nada por lo que ya existía al arrancar el watch. `opts`: `recursive` (`true`), `interval` en segundos (`0.5`, piso `0.02`), `ignore` (nombres de entrada, glob `*` permitido; default `[".git", "node_modules", "target"]` — pasá `[]` para incluirlos), `max_entries` (100 000: por encima `watch()` falla, y un árbol que lo supera después hace que el próximo recv falle y retira el handle), `max_queue` (4096; el overflow descarta lo más viejo y lo cuenta en `dropped`).

**Cómo funciona, honestamente.** Es polling con snapshot — `mtime` + tamaño por entrada, comparados cada `interval` — no inotify, FSEvents ni ReadDirectoryChangesW. Eso compra semántica idéntica en Linux, macOS y Windows, sin límites de watches del kernel y sin dependencia nueva; cuesta un recorrido del directorio por tick (de ahí `ignore` y `max_entries`) y una latencia igual a `interval`. Un cambio hecho y deshecho dentro de un mismo intervalo no se ve: recibís el estado, no la historia. `SYNSEMA_WATCH_MAX` (64, techo 1024) acota los watches vivos por intérprete; como los procesos, cada watch muere con el intérprete que lo abrió.

## La terminal — `term`

La terminal **propia** del programa como handle con eventos (engine **v0.6.11+**), en el mismo hub que procesos, sockets, bus y watches — así un solo `select` cubre "el humano tocó una tecla" y "el subagente publicó un resultado". Gate: `require stdin`. Es lo que necesita un chat CLI, una paleta de comandos o un editor de línea con historial: `read_line` te da una línea cocinada por vez; `term_open` te da cada tecla en el momento en que se pulsa.

```synsema
require stdin
require stdout

let commands be ["/help", "/history", "/approve", "/model", "/quit"]
let term be term_open()
when term == nothing                       -- pipe / CI / serve / test: no hay terminal
    let line be read_line("> ")            -- el mismo programa, entrada plana
otherwise
    let buf be ""
    let sub be bus_subscribe("agent.*")
    while true
        let matches be where(commands, (c) => starts_with(c, buf))
        term_write(term, "\r\x1b[2K> " + buf + "   " + join(matches, "  "))
        let ev be select({"keys": term, "agent": sub}, 60)
        when ev == nothing
            continue
        when ev["source"] == "bus"
            term_write(term, "\r\n[agent] " + json_encode(ev["data"]) + "\r\n")
        otherwise when ev["type"] == "eof"
            stop
        otherwise when ev["type"] == "paste"
            set buf to buf + ev["text"]
        otherwise when ev["key"] == "char"
            set buf to buf + ev["text"]
        otherwise when ev["key"] == "backspace"
            set buf to slice(buf, 0, len(buf) - 1)
        otherwise when ev["key"] == "tab" and len(matches) == 1
            set buf to matches[0]
        otherwise when ev["key"] == "enter" and ev["alt"]
            set buf to buf + "\n"                  -- Alt+Enter = nueva línea
        otherwise when ev["key"] == "enter"
            stop
    term_close(term)
```

Cada tecla es un evento, así que el menú de `/` filtra **mientras se tipea**; el loop sigue vivo mientras un subagente habla por el bus.

| Builtin | Devuelve |
|---|---|
| `term_open(opts?)` | handle — también lo acepta `select`, etiquetado `source: "term"`; **`nothing`** cuando stdin o stdout no son una TTY, bajo `serve`, `synsema test`/`conform` y en WebAssembly |
| `term_recv(h, timeout?)` | el próximo evento, o `nothing` al vencer (default 30 s — pasá uno explícito cuando esperás a un humano) |
| `term_size(h)` | `{cols, rows}` |
| `term_write(h, text)` | escribe a stdout **ya**, sin pasar por el buffer de `print`; se permiten escapes ANSI (cursor, borrar línea, colores) |
| `term_stats(h)` | `{kitty, paste, ansi, keys, queued, dropped}` |
| `term_close(h)` | restaura la terminal y apaga el lector. Idempotente — y el runtime lo hace igual al soltar el handle (fin del programa, error de runtime, `stop`, pánico) |

Eventos (todos etiquetados `source: "term"`, `handle`, `name`):

- `{type: "key", key, text, ctrl, alt, shift}` — `key` es un **nombre**: `"char"` (y `text` es el carácter, con Shift ya aplicado: `"A"`), `"enter"`, `"tab"`, `"backtab"`, `"backspace"`, `"delete"`, `"insert"`, `"escape"`, `"up"`/`"down"`/`"left"`/`"right"`, `"home"`/`"end"`, `"pageup"`/`"pagedown"`, `"f1"`…`"f12"`. Tab y Enter nunca llegan como `"\t"`/`"\r"`. Con `ctrl: true`, `text` es la letra minúscula (`Ctrl+O` → `{key: "char", text: "o", ctrl: true}`). Sólo pulsaciones (sin eventos de release/repeat).
- `{type: "paste", text}` — un pegado (bracketed paste) como **un solo** evento, saltos de línea incluidos (terminales Unix; en Windows un pegado llega como ráfaga de eventos `key` — `term_stats(h)["paste"]` dice cuál aplica).
- `{type: "resize", cols, rows}`, `{type: "focus", gained}` (cuando la terminal los emite).
- `{type: "eof"}` — stdin se cerró; se entrega una vez y el handle desaparece (como `read_line` → `nothing`, no es un error).

`opts`: `paste` (`true`, bracketed paste), `kitty` (`true`: pide el kitty keyboard protocol cuando la terminal lo soporta — Windows Terminal, kitty, WezTerm, foot, Ghostty — que es lo que hace distinguible **Shift+Enter**; sin él Shift+Enter es un Enter común, así que hacé de **Alt+Enter** tu atajo multilínea y de Shift+Enter un bonus), `ctrl_c` (`"exit"`: Ctrl+C restaura la terminal y termina el proceso con código 130, como haría SIGINT — el raw mode si no se lo tragaría; `"key"`: llega como `{key: "char", text: "c", ctrl: true}` y el programa decide), `max_queue` (16384, descarta los más viejos).

Lo que se sostiene con una terminal abierta: `print`/`log` siguen funcionando (el runtime escribe `\r\n` por vos, sin escalera); `ask` de texto libre, `approve` y `confirm` funcionan — el handler de consola suspende el raw mode mientras el humano contesta y lo reanuda; una terminal por proceso — un agente spawneado no puede abrirla mientras la tiene el programa principal. Restaurar la terminal es tarea del runtime, nunca del script: lo que activaste vos (cursor oculto, colores) lo deshacés vos.

## Una sola espera — `select`

```synsema
let ev be select({"sock": socket, "child": child, "feed": feed, "cancel": sub}, 60)
```

`targets` es una lista de handles o un mapa nombre → handle — cualquier mezcla de `ws_connect`, `socket`, `proc_spawn`, `bus_subscribe`, `watch`, `term_open`. Devuelve el **primer evento listo**, etiquetado con `source` (`"ws"` | `"proc"` | `"bus"` | `"watch"` | `"term"`), `handle` y `name` (forma mapa), más los campos propios del evento (`type`/`data` en sockets y procesos; `topic`/`data`/`timestamp` en el bus). `nothing` al vencer o cuando todos los targets desaparecieron. Equitativo (round-robin), duerme en el poller del kernel mientras no hay nada, falla rápido con un error atrapable cuando un target muere por error de protocolo/cola, y despierta al instante ante una cancelación. `ws_select` también acepta cualquier handle (conserva su etiqueta `conn`); `proc_select` es la misma espera restringida a procesos.

Todo junto — una conexión, un hijo, una suscripción, un loop:

```synsema
require serve(8080)
require exec("sh")

serve on 8080
    route "GET /console"
        socket
            let sub be bus_subscribe("agent.*")
            let child be proc_spawn("sh", ["-c", "for i in 1 2 3; do echo step $i; sleep 1; done"])
            while true
                let ev be select({"ui": socket, "child": child, "bus": sub}, 60)
                when ev == nothing
                    ws_send(socket, "idle")
                otherwise when ev["name"] == "ui"
                    when ev["type"] == "close"
                        stop
                    otherwise
                        ws_send(socket, "you said " + ev["data"])
                otherwise when ev["name"] == "child"
                    when ev["type"] == "exit"
                        ws_send(socket, "exit " + text(ev["data"]["exit_code"]))
                    otherwise
                        ws_send(socket, ev["type"] + ": " + ev["data"])
                otherwise
                    ws_send(socket, "[" + ev["topic"] + "] " + json_encode(ev["data"]))
```

La versión completa, con un feed SSE `/events` y un `POST /announce` que publica, es `examples/agent_console.syn` en el repo del engine.

## Timeouts y cancelación

Por default un handler **no tiene límite de tiempo**. Declaralo en el bloque serve (default de todas las rutas) y/o por ruta:

```synsema
serve on 8080
    timeout 30                    -- segundos, default de todas las rutas
    route "POST /think"
        timeout 300               -- override de ruta: al principio del cuerpo, una sola vez
        give reason "…" given request.json
    route "GET /events"
        timeout none              -- esta ruta se exime del default del bloque
        stream
            …
```

- **Rutas sized:** al vencer, el cliente recibe `504 {"error": "gateway timeout: the handler exceeded 30s", "status": 504}` con `Connection: close`, y el handler queda **cancelado** — se corta en su próximo statement o espera (log: `handler cancelled: request timed out after 30s`). El tiempo en cola esperando un worker cuenta.
- **`stream` / `socket`:** el timeout es la vida máxima de la conexión — el SSE termina con `event: error` `{"error": "cancelled: request timed out after 30s"}`; un socket recibe `Close 1001` con ese motivo.
- **La cancelación es cooperativa y no se cura.** El intérprete la chequea antes de cada statement y dentro de cada espera (`sleep`, `ws_recv`, `select`, `proc_*`, `bus_recv`, `wait_for`, `run`), levantando `cancelled: <motivo>`. Un `try`/`recover` puede observarla, pero el próximo statement vuelve a fallar — limpiá y salí. Los hijos de `run`/`proc_spawn` se matan.
- `timeout` es una cláusula. En cualquier otro lugar (dentro de un `when`, una task, el top-level) es un error de runtime, no un no-op silencioso. Todavía no se soporta dentro de un grupo `export routes` — ponelo en el bloque serve.
- La desconexión del cliente en una ruta sized **no** se detecta (hyper no la expone sin body pendiente); streams y sockets sí la detectan.

## Shutdown ordenado

Ante **SIGINT** (Ctrl-C) — y **SIGTERM** en Unix, lo que mandan `docker stop` y systemd:

1. El listener se cierra: las conexiones nuevas se rechazan; un request sobre una conexión keep-alive abierta recibe `503 {"error": "server shutting down"}` + `Retry-After: 2`.
2. Log: `[serve] shutting down: draining N in-flight request(s), grace 10s (SYNSEMA_SHUTDOWN_GRACE)`. El cron deja de programar, los agentes vivos se cancelan (`agent_stop`, motivo `server shutting down`), streams y sockets se cancelan de inmediato (SSE `event: error` `cancelled: server shutting down`; sockets `Close 1001`).
3. Los requests sized en vuelo tienen hasta `SYNSEMA_SHUTDOWN_GRACE` segundos (default 10; `0` = inmediato); lo que queda se cancela; `[serve] stopped`; código de salida **0** (fue pedido). Un segundo Ctrl-C durante el drain sale ya con 130.
4. Con varios bloques `serve on` el proceso sale cuando drenó el **último**.

`synsema run` no cambia (no tiene listener). Bajo systemd poné `TimeoutStopSec` por encima de la gracia.

**Desde el programa (engine v0.6.18+):** `shutdown(reason?)` pide exactamente este drain — desde una
ruta, un bloque `socket`, un job de cron o un agente. El log dice `[serve] shutdown requested by the
program: <motivo>`, después corren los pasos de arriba y el proceso sale con 0. Idempotente (una
segunda llamada no hace nada); error bajo `synsema run` (un programa run termina con su top level —
`stop` sale de un loop o de una task) y antes de que algo escuche; sin capability (apagarse no es un
recurso del host); un `secret` como motivo se rechaza (el motivo se loguea). Es como una app de
escritorio se apaga cuando se cierra su última ventana — [Tu app en el escritorio](41c-desktop).

## Agentes bajo `serve`

Un agente spawneado desde un handler o un tick de cron recibe **el mismo cableado que un tick de cron** — `state_*`, la base compartida, la cola de approvals, cron, el bus, la memoria declarada — no una isla con builtins frescos. Corre bajo el techo del host de `synsema serve --sandbox` o `--cap-set "<lista>"` (un `require` adentro nunca excede lo que fijó el operador, exactamente como bajo `run`; `--sandbox` acá es `stdout,time` + `serve`), y un shutdown ordenado lo detiene.

```synsema
agents()                  -- [{id, name, state, error, started_at, finished_at}]
agent_stop(id, motivo?)   -- true si estaba vivo; el agente termina en estado "stopped"
```

`id` es la instancia (`Researcher_0`), `name` el agente declarado; estados `idle` · `starting` · `working` · `waiting` · `done` · `error` · `stopped`. `agent_stop` es cancelación cooperativa: el agente levanta `cancelled: <motivo>` antes de su próximo statement y cualquier espera despierta al instante — un agente en `while true` ya no es inmortal. Los dos funcionan bajo `run` y `serve`, sin capability (introspección del propio proceso). Ojo: `give agents()` pagina como todo `give <lista>` — leé `items`, o `give {"agents": agents()}`.

## Knobs del server (entorno del proceso, no `.env`)

`synsema init` los lista, comentados, en `.env.example` — con la advertencia de que los lee el entorno del **proceso** (`export`, `Environment=` de systemd, `-e` de Docker):

| Variable | Default | Qué |
|---|---|---|
| `SYNSEMA_SERVE_WORKERS` | cores (mín. 2) | pool de intérpretes para handlers sized (streams/sockets tienen hilo propio) |
| `SYNSEMA_SHUTDOWN_GRACE` | `10` | segundos de drain ante SIGINT/SIGTERM; `0` = inmediato |
| `SYNSEMA_SSE_KEEPALIVE` | `15` | segundos sin frames antes de un comentario SSE `: keepalive`; `0` apaga |
| `SYNSEMA_WS_SERVER_PING` | `30` | intervalo del ping del server en rutas `socket`; sin pong en 2 intervalos → `close`; `0` apaga |
| `SYNSEMA_WS_SUBPROTOCOLS` | — | subprotocolos que el server puede acordar (coma-separados); sin la variable no acuerda ninguno |
| `SYNSEMA_WS_MAX_MESSAGE` | `16MB` | tamaño máximo de un mensaje entrante en rutas `socket` (techo 64MB) |
| `SYNSEMA_WS_MAX_CONNS` | `4096` | handles WebSocket vivos por intérprete (`ws_connect` + entrantes) |
| `SYNSEMA_PROC_MAX` | `64` | hijos `proc_spawn` vivos por intérprete (techo 1024) |
| `SYNSEMA_WATCH_MAX` | `64` | handles `watch` vivos por intérprete (techo 1024; cada uno es un hilo scanner) |
| `SYNSEMA_RUN_PROGRAM_MAX_DEPTH` | `4` | profundidad máxima de anidamiento de `run_program` dentro de `run_program` (v0.6.14+) |

## Correr Synsema que generaste — `run_program` (v0.6.14+)

Una app agéntica que escribe Synsema (una tool que emite el LLM, el plugin de un usuario, un loop que
se auto-repara) lo corre con `run_program(source, opts)` — un proceso hijo del mismo binario bajo un
techo ∩ el del padre, con su propio `env`/`cwd`/`timeout` y su audit devuelto como un valor (`require
sandbox_run`). Sin `exec`, sin `synsema` en el `PATH`, sin parsear stderr; el hijo nunca puede exceder
al padre. Es la forma segura de "correr el código que generó el modelo" — modelo completo en
[Sandbox](/es/0.6.x/22-sandbox), firma en [Builtins](/es/0.6.x/90-builtins).

## Lo que a propósito no está

- **Un emulador de terminal o un framework de TUI.** `pty: true` le da al hijo una terminal real; dibujarla (parsear VT, un modelo de pantalla) es tarea del consumidor — xterm.js en un navegador, `strip_ansi` para un agente. `term_open` te da a *vos* teclas y tamaño, nada más: un editor de línea, una paleta de `/` o un menú son unas decenas de líneas de Synsema sobre `select` (el ejemplo de arriba), no una biblioteca de widgets en el runtime.
- **Un notificador nativo del filesystem.** `watch` hace polling sobre un snapshot a propósito: una sola semántica en todos los SO, sin límites del kernel, sin crate `*-sys`. Si algún día importa latencia sub-segundo sobre un árbol enorme, sería un `opts.backend`, no una API nueva.
- **Un bus entre procesos.** Redis pub/sub va a reusar `bus_*` con un `opts.backend`.
- **Una tabla global de sockets.** Empujar a "todos los sockets" desde cualquier lado sería una puerta de escape del aislamiento por request; el camino es el bus.

Mirá [Servidor HTTP](/es/0.6.x/40-serve), [Cliente WebSocket](/es/0.6.x/39-websocket), [Multi-agente](/es/0.6.x/60-agents) y [Deploy](/es/0.6.x/71-deploy).
