Synsemadocsv0.6.xENES

HTTP server

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.

agentic.syn
-- 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 returns, so the whole ws_* family works on it unchanged:

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)

Live processes — proc_*§

run 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 below.

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)
BuiltinReturns
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.

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:

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.

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

The SSE route that every dashboard wants — fed by the bus, heartbeat handled by the server:

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.

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"]))
BuiltinReturns
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.

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.

BuiltinReturns
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):

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§

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:

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:

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

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.

Agents under serve§

An agent spawned from a handler or a cron tick gets the same wiring a cron tick hasstate_*, 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.

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):

VariableDefaultWhat
SYNSEMA_SERVE_WORKERScores (min 2)interpreter pool for sized handlers (streams/sockets have their own thread)
SYNSEMA_SHUTDOWN_GRACE10seconds to drain on SIGINT/SIGTERM; 0 = immediate
SYNSEMA_SSE_KEEPALIVE15idle seconds before a : keepalive SSE comment; 0 off
SYNSEMA_WS_SERVER_PING30server ping interval on socket routes; no pong in 2 intervals → close; 0 off
SYNSEMA_WS_SUBPROTOCOLScomma-separated subprotocols the server may agree to; unset = none agreed
SYNSEMA_WS_MAX_MESSAGE16MBmax incoming message on socket routes (ceiling 64MB)
SYNSEMA_WS_MAX_CONNS4096live WebSocket handles per interpreter (ws_connect + incoming)
SYNSEMA_PROC_MAX64live proc_spawn children per interpreter (ceiling 1024)
SYNSEMA_WATCH_MAX64live watch handles per interpreter (ceiling 1024; each one is a scanner thread)
SYNSEMA_RUN_PROGRAM_MAX_DEPTH4max 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, signature in Builtins.

What is deliberately not here§

See HTTP server, WebSocket client, Multi-agent and Deploy.