---
slug: 47-agentic-apps
title: Agentic apps (WebSocket routes, live processes, event bus, select)
description: The server side of an agentic app in Synsema — incoming WebSocket routes, child processes you watch line by line, an in-process event bus that fans out to every live client, one select over all of it, handler timeouts with real cancellation, and an ordered shutdown.
example_ids: [agentic]
---

# Agentic apps

An app that orchestrates agents in front of a live UI needs five things a plain REST server does not have: a **bidirectional channel** to the browser, a **child process you can watch while it runs**, a way for one event to reach **every** open client, **one wait** over all of those, and a server that **times out, cancels and shuts down** without leaving anything behind. Since engine **v0.6.7** all five are in the language, under the same rules as everything else: deny-by-default, bounded memory, loud errors.

**Not only for agents.** The same five primitives are what a chat, a multiplayer minigame, a collaborative editor, a live dashboard, a build bot or a terminal-in-the-browser need: a socket per client, a bus per room or topic (`bus_subscribe("room." + id)`), a process you watch, one `select` per connection, and a server that cleans up after itself. "Agentic" is where the gap was found; the page applies to any program with live state shared between clients.

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

## WebSocket routes — `socket`

A route with a `socket` block accepts an incoming WebSocket. Inside it, `socket` is the handle of *this* connection — the same kind of handle [`ws_connect`](/en/0.6.x/39-websocket) returns, so the whole `ws_*` family works on it unchanged:

```synsema
require serve(8080)

serve on 8080
    route "GET /ws"                              -- the handshake is a 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 idle
                    stop
                when ev["type"] == "close"
                    stop
                otherwise
                    ws_send(socket, {"echo": ev["data"]})

    route "GET /private" requires auth            -- auth runs BEFORE the upgrade: 401, no upgrade
        socket
            ws_send(socket, "hello " + request.user.name)
```

- `request`, `params`, `query`, `headers` and `user` are bound as in any route. `ws_stats(socket)["role"]` is `"server"`.
- **Closing is honest.** Block ends or `stop` → `Close 1000`. Uncaught error → `Close 1011` + the message (≤ 123 bytes) and a `[socket] … handler failed` log line. The server cancelling the handler (route `timeout`, shutdown) → `Close 1001` (going away) + the reason. A client that vanishes without a close handshake becomes `{type: "close", data: "connection reset without closing handshake"}` on the next `ws_recv`/`select`.
- A plain HTTP request to a socket route → **`426 Upgrade Required`** (JSON body, `Upgrade: websocket` header). HTTP/2 clients get the same 426; browsers always open WebSockets over HTTP/1.1.
- **Keepalive** is the server's job: it answers pings and sends its own every `SYNSEMA_WS_SERVER_PING` seconds (default 30) while the handler waits; no pong in two intervals → a `close` with reason `keepalive timeout`. Messages over `SYNSEMA_WS_MAX_MESSAGE` (16MB, ceiling 64MB) are a catchable error. Backpressure is real in both directions (bounded channels; the TCP window throttles the slow side).
- **Budget:** a socket takes one `max_streams` slot (`503` + `Retry-After` over the cap) and counts against `SYNSEMA_WS_MAX_CONNS`. **No new capability** — the route is already inside `serve`.
- Rules: `GET` only, `socket` and `stream` in one route is a parse error, not yet allowed inside an `export routes` group (clear error). Outside a route `socket` is an ordinary name.
- **A handle never crosses requests.** To push to N open sockets from a cron, an agent or another request, every socket handler subscribes to the bus and forwards — below. There is deliberately no global socket table.

## Live processes — `proc_*`

[`run`](/en/0.6.x/22-sandbox) is one-shot: it captures everything and returns at the end. An agent running `cargo test`, `npm run build` or a long worker needs to **see the output as it comes**, feed stdin and kill it — a process as a **handle with events**. Same gate as `run` (`require exec(cmd)`, scope = the command as written), args as a list, never a shell. Pipes by default (a tool that checks for a tty behaves as in CI); `"pty": true` gives it a real terminal — see [Pseudo-terminal](#pseudo-terminal-pty-true) below.

```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} or nothing
    when ev == nothing
        proc_kill(p)                              -- silent for 60 s: TERM ("KILL" to insist)
    otherwise when ev["type"] == "exit"
        print("exit " + text(ev["data"]["exit_code"]))
        stop
    otherwise
        print(ev["type"] + ": " + ev["data"])     -- one event per line, no trailing newline
proc_close(p)
```

| Builtin | Returns |
|---|---|
| `proc_spawn(cmd, args?, opts?)` | handle |
| `proc_recv(h, timeout?)` | next event, or `nothing` at the timeout |
| `proc_select(list \| map, timeout?)` | first ready event among processes (`select` takes any handle) |
| `proc_send(h, text \| bytes)` | `true`; error if stdin is closed. A blocking pipe write: a child that never reads stdin blocks you |
| `proc_close_stdin(h)` | `true` — EOF to the child. Pipes only: a pty has no separate stdin — send the EOF key (`bytes([4])` Ctrl-D, `bytes([26, 13])` Ctrl-Z + Enter) |
| `proc_resize(h, cols, rows)` | `true` — pty only (SIGWINCH / ResizePseudoConsole); error on a pipe process |
| `proc_status(h)` | `"running"` · `"exited"` · `"killed"` · `"closed"` |
| `proc_kill(h, signal?)` | `"TERM"` (default; SIGTERM on Unix) or `"KILL"`; on Windows both terminate. Reaches the **whole process tree** (v0.6.9+, see Lifecycle) |
| `proc_wait(h, timeout?)` | `{exit_code, signal}` or `nothing` |
| `proc_stats(h)` | `{pid, cmd, status, exit_code, pty, tree, queued, queued_bytes, dropped, uptime}` — `tree`: the kill reaches grandchildren |
| `proc_close(h)` | frees the handle; **kills if still alive** (TERM, KILL after 2 s, then wait) — the whole tree. No orphans. Idempotent |

`exit` arrives once, after both pipes are drained, as `{exit_code, signal}` (`exit_code` `-1` when killed by a signal; `signal` is `nothing` on a normal exit and always on Windows). `opts`: `cwd`, `env` (inherits + overrides; a `secret` value is an error — `reveal()` it explicitly), `line_mode` (`true`; `false` = raw **text** chunks ≤ 64 KiB — `data` is always text, UTF-8 lossy, never split inside a character), `stderr` (`"separate"` | `"merge"`), `pty` (+ `cols`, `rows`, `term` — below), `process_group` (`true`: own process group / Job Object so the kill reaches the tree; `false` deliberately detaches a daemon that must outlive `proc_close` — then only the direct child is killed), and the bounded queue `max_queue` (4096) / `max_queue_bytes` (64 MiB) with `on_full` = `"block"` (default: the reader stops draining, the child blocks on `write` — real backpressure, no loss) | `"drop_oldest"` | `"error"` (next recv raises, process killed).

**Lifecycle.** When the interpreter that spawned it ends — the request under `serve`, the program, the agent — every live process is killed. **The whole tree, not only the child** (v0.6.9+): the child starts in its own process group on Unix and in a Job Object with kill-on-close on Windows, so `proc_kill`/`proc_close` on `sh -c "npm run dev"` also take the `node` underneath — and on Windows even a crash of the interpreter closes the job and the tree with it. `proc_stats(h)["tree"]` tells you it is in effect. A handler never leaves a ghost; work that must outlive a request belongs in `cron_after` or an agent. `SYNSEMA_PROC_MAX` (64, ceiling 1024) caps live processes per interpreter. A grandchild that keeps the pipe open after the child exited cannot hang you: 1 s of grace, then `exit` is delivered.

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

*Engine v0.6.8+.* Many programs ask "am I talking to a terminal?" and, on a pipe, skip the question, assume "no", hang or refuse: `y/N` prompts that read `/dev/tty`, `ssh`/`sudo`/`gpg` passwords, arrow-key menus, `docker run -it`, REPLs, progress bars, and every TUI (`vim`, `htop`, an agentic CLI). `"pty": true` runs the child inside a real pseudo-terminal — `openpty` on Linux/macOS, ConPTY on Windows ≥ 10 1809. **Same API, same events, same `exec(cmd)` gate**: a pty grants no OS power a pipe doesn't; it changes how the child behaves, not what it may do. `sandbox` denies it like any `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"]              -- raw text chunks, ANSI included
        when contains(strip_ansi(screen), "[y/N]")    -- read it like a human would
            proc_send(p, "y\r")                       -- keystrokes: Enter is \r on a tty
            set screen to ""
proc_close(p)
```

What changes in pty mode — consequences of "it is a terminal", not new API:

- **One stream.** A tty doesn't separate stderr: everything arrives as `stdout`.
- **Raw text with escape sequences.** `line_mode` defaults to `false` — `data` is still **text**, not `bytes`: UTF-8 chunks, never split inside a character, invalid bytes → U+FFFD. Send them to xterm.js as text frames as they are, or [`strip_ansi(text)`](/en/0.6.x/90-builtins) for what a human sees (colors, cursor moves and OSC titles removed; `\r` redraws keep the last frame). The runtime never interprets VT.
- **Echo is on.** What you `proc_send` comes back in the output. A revealed secret you type is echoed in clear — the usual secret rules still apply (`secret` in args/env/send → error).
- **Keys, not lines.** Enter is `"\r"`, Ctrl-C is `bytes([3])`, Ctrl-D `bytes([4])`, an arrow is ESC + `[A`. No `proc_close_stdin` (the error names the key to send).
- **Size.** `cols`/`rows` (default 80×24), `proc_resize(h, cols, rows)` later. `term` sets `TERM` (default `xterm-256color`; `env.TERM` wins).
- **Kill reaches the tree** (as with pipes since v0.6.9). On Unix the child is a session leader and the whole process group is signalled; on Windows the Job Object is terminated and the pseudo console closed — no grandchild survives holding the tty.
- **The Windows handshake is handled.** ConPTY emits `ESC[6n` at start and delivers nothing until it is answered; the runtime answers that first one and strips it (an xterm.js on the other end never sees it, so no double reply). Later `ESC[6n` are the app's and pass through.
- `exit_code` is `-1` when killed by a signal; on a pty `signal` is the number when known (15, 9, 1, 2), else `nothing`.

A web terminal is a `socket` route and a pty in one `select`: browser bytes → `proc_send`, process events → `socket_send`, a `{resize}` message → `proc_resize`; xterm.js renders. Who gets that socket is the app's `auth` decision — the runtime adds no capability because there is nothing new to gate.

Try the non-interactive flag first (`--yes`, `CI=1`, `DEBIAN_FRONTEND=noninteractive`): cheaper and deterministic. The pty is for when there is none.

## Event bus — `bus_*`

`signal`/`wait_for` is point-to-point (one receiver consumes). A live UI needs **fan-out**: N handlers — one per SSE or socket client — receive the same event an agent, a cron or another request published, with no polling. That is the bus. **One bus per program**, visible from everywhere: top level, `parallel_map` workers, cron ticks, spawned agents, and every handler of every server worker. In-process only (Redis with the same API is the roadmap). No capability — same trust level as `share`/`signal`.

```synsema
bus_publish("agent.progress", {"step": 3, "of": 10})   -- → number of subscribers reached
let sub be bus_subscribe("agent.*")                     -- a topic or a list; globs * and ?
let ev be bus_recv(sub, 25)                             -- {type: "event", topic, data, timestamp} or nothing
bus_unsubscribe(sub)
bus_topics()                                            -- [{topic, subscribers}] by pattern
```

- Payloads are **data** (text/number/bool/list/map/bytes); a task or a `secret` is an error, never silently degraded. Published topics are literal — a glob in `bus_publish` errors (globs belong to `bus_subscribe`).
- **Bounded per subscriber** in count and bytes: `bus_subscribe("t", {"max_queue": 1024, "max_queue_bytes": 16777216, "on_full": "drop_oldest"})`. `"drop_oldest"` is the default (a slow subscriber never stalls the publisher); `"error"` makes the next recv raise and drops the subscription.
- A subscription lives as long as its interpreter: when the handler/agent ends it is removed — nobody fills a queue no one reads.

The SSE route that every dashboard wants — fed by the bus, heartbeat handled by the 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`

Changes on disk as a **handle with events** (engine **v0.6.9+**), in the same hub as processes, sockets and the bus — so one `select` covers "a file changed" and "the build finished". Gate: `require file(path)` — watching a tree is reading it, the same `file_read` scope as `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)                     -- kills the previous build, whole tree
        set build to proc_spawn("cargo", ["build"])
    otherwise when ev["type"] == "exit"
        print("build exit " + text(ev["data"]["exit_code"]))
```

| Builtin | Returns |
|---|---|
| `watch(path, opts?)` | handle — also accepted by `select`, tagged `source: "watch"` |
| `watch_recv(h, timeout?)` | next event, or `nothing` at the timeout |
| `watch_stats(h)` | `{path, recursive, interval, entries, scans, queued, dropped}` |
| `watch_close(h)` | frees the handle and stops the scanner. Idempotent |

Events are `{type: "create" | "modify" | "delete", path, is_dir}`. `path` uses `/` and stays relative when the root was relative. A rename is a `delete` plus a `create`. Directories only emit `create`/`delete` (their mtime changes with every child — noise). Nothing is emitted for what already existed when the watch started. `opts`: `recursive` (`true`), `interval` in seconds (`0.5`, floor `0.02`), `ignore` (entry names, `*` glob allowed; default `[".git", "node_modules", "target"]` — pass `[]` to include them), `max_entries` (100 000: over it `watch()` fails, and a tree that grows past it later makes the next recv raise and retires the handle), `max_queue` (4096; overflow drops the oldest and counts it in `dropped`).

**How it works, honestly.** It is polling with a snapshot — `mtime` + size per entry, compared every `interval` — not inotify, FSEvents or ReadDirectoryChangesW. That buys identical semantics on Linux, macOS and Windows, no kernel watch limits and no new dependency; it costs one directory walk per tick (hence `ignore` and `max_entries`) and a latency equal to `interval`. A change made and undone inside one interval is not seen: you get the state, not the history. `SYNSEMA_WATCH_MAX` (64, ceiling 1024) caps live watches per interpreter; like processes, every watch dies with the interpreter that opened it.

## The terminal — `term`

The program's **own terminal** as a handle with events (engine **v0.6.11+**), in the same hub as processes, sockets, the bus and watches — so one `select` covers "the human typed a key" and "the sub-agent published a result". Gate: `require stdin`. It is what a chat CLI, a command palette or a line editor with history needs: `read_line` gives you a cooked line at a time; `term_open` gives you every key as it is pressed.

```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 terminal
    let line be read_line("> ")            -- same program, plain input
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 = new line
        otherwise when ev["key"] == "enter"
            stop
    term_close(term)
```

Every key is an event, so the `/` menu filters **as you type**; the loop stays alive while a sub-agent talks on the bus.

| Builtin | Returns |
|---|---|
| `term_open(opts?)` | handle — also accepted by `select`, tagged `source: "term"`; **`nothing`** when stdin or stdout is not a TTY, under `serve`, `synsema test`/`conform` and in WebAssembly |
| `term_recv(h, timeout?)` | next event, or `nothing` at the timeout (default 30 s — pass one explicitly when waiting for a human) |
| `term_size(h)` | `{cols, rows}` |
| `term_write(h, text)` | writes to stdout **now**, bypassing the `print` buffer; ANSI escapes allowed (cursor, clear line, colours) |
| `term_stats(h)` | `{kitty, paste, ansi, keys, queued, dropped}` |
| `term_close(h)` | restores the terminal and stops the reader. Idempotent — and the runtime does it anyway when the handle is dropped (program end, runtime error, `stop`, panic) |

Events (all tagged `source: "term"`, `handle`, `name`):

- `{type: "key", key, text, ctrl, alt, shift}` — `key` is a **name**: `"char"` (then `text` is the character, Shift already applied: `"A"`), `"enter"`, `"tab"`, `"backtab"`, `"backspace"`, `"delete"`, `"insert"`, `"escape"`, `"up"`/`"down"`/`"left"`/`"right"`, `"home"`/`"end"`, `"pageup"`/`"pagedown"`, `"f1"`…`"f12"`. Tab and Enter never arrive as `"\t"`/`"\r"`. With `ctrl: true`, `text` is the lowercase letter (`Ctrl+O` → `{key: "char", text: "o", ctrl: true}`). Only key presses (no release/repeat events).
- `{type: "paste", text}` — a bracketed paste as **one** event, newlines included (Unix terminals; on Windows a paste arrives as a burst of `key` events — `term_stats(h)["paste"]` says which).
- `{type: "resize", cols, rows}`, `{type: "focus", gained}` (when the terminal emits them).
- `{type: "eof"}` — stdin closed; delivered once, then the handle is gone (like `read_line` → `nothing`, not an error).

`opts`: `paste` (`true`, bracketed paste), `kitty` (`true`: asks for the kitty keyboard protocol when the terminal supports it — Windows Terminal, kitty, WezTerm, foot, Ghostty — which is what makes **Shift+Enter** distinguishable; without it Shift+Enter is a plain Enter, so make **Alt+Enter** your multi-line shortcut and Shift+Enter a bonus), `ctrl_c` (`"exit"`: Ctrl+C restores the terminal and ends the process with code 130, as SIGINT would — raw mode would otherwise swallow it; `"key"`: it arrives as `{key: "char", text: "c", ctrl: true}` and the program decides), `max_queue` (16384, drop-oldest).

What holds while a terminal is open: `print`/`log` still work (the runtime writes `\r\n` for you, no staircase); free-text `ask`, `approve` and `confirm` work — the console handler suspends raw mode while the human answers and resumes it; one terminal per process — a spawned agent cannot open it while the main program holds it. Restoring the terminal is the runtime's job, never the script's: whatever you activated yourself (hidden cursor, colours) is yours to undo.

## One wait — `select`

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

`targets` is a list of handles or a map name → handle — any mix of `ws_connect`, `socket`, `proc_spawn`, `bus_subscribe`, `watch`, `term_open`. It returns the **first ready event**, tagged with `source` (`"ws"` | `"proc"` | `"bus"` | `"watch"` | `"term"`), `handle` and `name` (map form), plus the event's own fields (`type`/`data` for sockets and processes; `topic`/`data`/`timestamp` for the bus). `nothing` at the timeout or when every target is gone. Fair (round-robin), sleeps in the kernel poller while idle, fails fast with a catchable error when a target dies of a protocol/queue error, wakes at once on cancellation. `ws_select` accepts any handle too (keeping its `conn` tag); `proc_select` is the same wait restricted to processes.

The whole thing — one connection, one child, one subscription, one 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"]))
```

The runnable version with a `/events` SSE feed and a `POST /announce` publisher is `examples/agent_console.syn` in the engine repository.

## Timeouts and cancellation

By default a handler has **no time limit**. Declare one on the serve block (default for all routes) and/or per route:

```synsema
serve on 8080
    timeout 30                    -- seconds, default for every route
    route "POST /think"
        timeout 300               -- route override: top of the body, at most once
        give reason "…" given request.json
    route "GET /events"
        timeout none              -- this route opts out of the block default
        stream
            …
```

- **Sized routes:** at the deadline the client gets `504 {"error": "gateway timeout: the handler exceeded 30s", "status": 504}` with `Connection: close`, and the handler is **cancelled** — it stops at its next statement or wait (log: `handler cancelled: request timed out after 30s`). Time queued for a worker counts.
- **`stream` / `socket`:** the timeout is the connection's maximum lifetime — SSE ends with `event: error` `{"error": "cancelled: request timed out after 30s"}`; a socket gets `Close 1001` with that reason.
- **Cancellation is cooperative and cannot be cured.** The interpreter checks it before every statement and inside every wait (`sleep`, `ws_recv`, `select`, `proc_*`, `bus_recv`, `wait_for`, `run`), raising `cancelled: <reason>`. A `try`/`recover` can observe it, but the next statement raises again — clean up and leave. Children of `run`/`proc_spawn` are killed.
- `timeout` is a clause. Anywhere else (inside a `when`, a task, the top level) it is a runtime error, not a silent no-op. Not yet supported inside an `export routes` group — set it on the serve block.
- Client disconnect on a sized route is **not** detected (hyper does not expose it without a pending body); streams and sockets do detect it.

## Ordered shutdown

On **SIGINT** (Ctrl-C) — and **SIGTERM** on Unix, what `docker stop` and systemd send:

1. The listener closes: new connections are refused; a request on an open keep-alive connection gets `503 {"error": "server shutting down"}` + `Retry-After: 2`.
2. Log: `[serve] shutting down: draining N in-flight request(s), grace 10s (SYNSEMA_SHUTDOWN_GRACE)`. Cron stops scheduling, live agents are cancelled (`agent_stop`, reason `server shutting down`), streams and sockets are cancelled at once (SSE `event: error` `cancelled: server shutting down`; sockets `Close 1001`).
3. Sized requests in flight get up to `SYNSEMA_SHUTDOWN_GRACE` seconds (default 10; `0` = immediate); what is left is cancelled; `[serve] stopped`; exit code **0** (it was asked for). A second Ctrl-C during the drain exits immediately with 130.
4. With several `serve on` blocks the process exits when the **last** one has drained.

`synsema run` is untouched (no listener). Under systemd set `TimeoutStopSec` above the grace.

**From the program (engine v0.6.18+):** `shutdown(reason?)` asks for exactly this drain — from a
route, a `socket` block, a cron job or an agent. The log says `[serve] shutdown requested by the
program: <reason>`, then the steps above run and the process exits 0. Idempotent (a second call is a
no-op); an error under `synsema run` (a run program ends with its top level — `stop` leaves a loop or
a task) and before anything listens; no capability (quitting is not a host resource); a `secret` as
the reason is refused (the reason is logged). It is how a desktop app quits when its last window
closes — [Your app on the desktop](41c-desktop).

## Agents under `serve`

An agent spawned from a handler or a cron tick gets **the same wiring a cron tick has** — `state_*`, the shared database, the approvals queue, cron, the bus, the declared memory — not an island with fresh builtins. It runs under the host ceiling of `synsema serve --sandbox` or `--cap-set "<list>"` (a `require` inside it never exceeds what the operator fixed, exactly as under `run`; `--sandbox` here means `stdout,time` + `serve`), and an ordered shutdown stops it.

```synsema
agents()                  -- [{id, name, state, error, started_at, finished_at}]
agent_stop(id, reason?)   -- true if it was alive; the agent ends in state "stopped"
```

`id` is the instance (`Researcher_0`), `name` the declared agent; states `idle` · `starting` · `working` · `waiting` · `done` · `error` · `stopped`. `agent_stop` is cooperative cancellation: the agent raises `cancelled: <reason>` before its next statement and any wait wakes immediately — a `while true` agent is no longer immortal. Both work under `run` and `serve`, without a capability (introspection of your own process). Note that `give agents()` paginates like any `give <list>` — read `items`, or `give {"agents": agents()}`.

## Server knobs (process environment, not `.env`)

`synsema init` lists them, commented, in `.env.example` — with the warning that the **process** environment is what reads them (`export`, systemd `Environment=`, Docker `-e`):

| Variable | Default | What |
|---|---|---|
| `SYNSEMA_SERVE_WORKERS` | cores (min 2) | interpreter pool for sized handlers (streams/sockets have their own thread) |
| `SYNSEMA_SHUTDOWN_GRACE` | `10` | seconds to drain on SIGINT/SIGTERM; `0` = immediate |
| `SYNSEMA_SSE_KEEPALIVE` | `15` | idle seconds before a `: keepalive` SSE comment; `0` off |
| `SYNSEMA_WS_SERVER_PING` | `30` | server ping interval on `socket` routes; no pong in 2 intervals → `close`; `0` off |
| `SYNSEMA_WS_SUBPROTOCOLS` | — | comma-separated subprotocols the server may agree to; unset = none agreed |
| `SYNSEMA_WS_MAX_MESSAGE` | `16MB` | max incoming message on `socket` routes (ceiling 64MB) |
| `SYNSEMA_WS_MAX_CONNS` | `4096` | live WebSocket handles per interpreter (`ws_connect` + incoming) |
| `SYNSEMA_PROC_MAX` | `64` | live `proc_spawn` children per interpreter (ceiling 1024) |
| `SYNSEMA_WATCH_MAX` | `64` | live `watch` handles per interpreter (ceiling 1024; each one is a scanner thread) |
| `SYNSEMA_RUN_PROGRAM_MAX_DEPTH` | `4` | max nesting depth for `run_program` inside `run_program` (v0.6.14+) |

## Running Synsema you generated — `run_program` (v0.6.14+)

An agentic app that writes Synsema (a tool the LLM emits, a user's plugin, a self-repairing loop)
runs it with `run_program(source, opts)` — a child process of the same binary under a ceiling ∩ the
parent's, its `env`/`cwd`/`timeout` its own, its audit returned as a value (`require sandbox_run`).
No `exec`, no `synsema` on the `PATH`, no stderr parsing; the child can never exceed the parent. It's
the safe way to "run the code the model generated" — full model on [Sandboxing](22-sandbox), signature
in [Builtins](90-builtins).

## What is deliberately not here

- **A terminal emulator or a TUI framework.** `pty: true` gives the child a real terminal; drawing it (VT parsing, a screen model) is the consumer's job — xterm.js in a browser, `strip_ansi` for an agent. `term_open` gives *you* keys and a size, nothing more: a line editor, a `/` palette or a menu is a few dozen lines of Synsema on top of `select` (the example above), not a widget library in the runtime.
- **A native filesystem notifier.** `watch` polls a snapshot on purpose: one semantics on every OS, no kernel limits, no `*-sys` crate. If a sub-second latency on a huge tree ever matters, it would be an `opts.backend`, not a new API.
- **A cross-process bus.** Redis pub/sub will reuse `bus_*` with an `opts.backend`.
- **A global socket table.** Pushing to "all sockets" from anywhere would be an escape hatch out of request isolation; the bus is the way.

See [HTTP server](/en/0.6.x/40-serve), [WebSocket client](/en/0.6.x/39-websocket), [Multi-agent](/en/0.6.x/60-agents) and [Deploy](/en/0.6.x/71-deploy).
