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.
-- The watch test writes into ./_doctest_watch and deletes it at the end.
intent: "doc example: event bus + select"
require file("_doctest_watch")          -- watch() reads the tree it watches
require file("_doctest_watch/*")
require file.write("_doctest_watch")
require file.write("_doctest_watch/*")
require stdin                           -- term_open()

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

test "on_full \"error\": the next recv raises and the subscription is gone"
    let strict be bus_subscribe("strict", {"max_queue": 1, "on_full": "error"})
    bus_publish("strict", 1)
    bus_publish("strict", 2)                            -- the queue overflows
    assert_error(() => bus_recv(strict, 0.1))
    assert_error(() => bus_recv(strict, 0.1))           -- unknown or closed subscription

test "max_queue_bytes bounds the queue by size (drop_oldest by default)"
    let tiny be bus_subscribe("bytes", {"max_queue_bytes": 10})
    bus_publish("bytes", "aaaaaaaa")
    bus_publish("bytes", "bbbbbbbb")                    -- both do not fit: the oldest goes
    assert_eq(bus_recv(tiny, 0.1)["data"], "bbbbbbbb")
    assert_eq(bus_recv(tiny, 0.1), nothing)
    bus_unsubscribe(tiny)
    assert(not some(bus_topics(), (t) => t["topic"] == "bytes"))

test "select takes handles, never nothing — build the map with the ones that exist"
    assert_error(() => select({"files": wide, "build": nothing}, 0.1))
    let targets be {"files": wide}
    assert_eq(select(targets, 0.1), nothing)

-- The message of an error, or nothing when the call did not fail.
task try_message(f)
    try
        f()
    recover e
        give e
    give nothing

test "run and proc_spawn with cwd: a relative command is resolved against that folder (v0.6.46+)"
    -- the grant must name the path inside cwd; checked before anything starts
    assert(contains(try_message(() => run("./tool.bat", [], 5, {"cwd": "sub"})), "exec(\"./sub/tool.bat\")"))
    assert(contains(try_message(() => proc_spawn("./tool.bat", [], {"cwd": "sub"})), "exec(\"./sub/tool.bat\")"))

test "watch: create/delete events, ignore, stats, close"
    write_file("_doctest_watch/seed.txt", "0")
    let w be watch("_doctest_watch", {"interval": 0.05, "ignore": ["*.tmp"], "recursive": false, "max_entries": 100, "max_queue": 16})
    write_file("_doctest_watch/a.txt", "1")
    write_file("_doctest_watch/b.tmp", "1")             -- ignored by name
    let ev be watch_recv(w, 2)
    assert_eq(ev["type"], "create")
    assert_eq(ev["path"], "_doctest_watch/a.txt")
    assert_eq(ev["is_dir"], false)
    assert_eq(ev["source"], "watch")
    assert_eq(watch_recv(w, 0.3), nothing)              -- nothing for b.tmp
    delete_file("_doctest_watch/a.txt")
    assert_eq(watch_recv(w, 2)["type"], "delete")
    let st be watch_stats(w)
    assert_eq(st["recursive"], false)
    assert_eq(st["interval"], 0.05)
    assert_eq(sort(keys(st)), ["dropped", "entries", "interval", "path", "queued", "recursive", "scans"])
    watch_close(w)
    watch_close(w)                                      -- idempotent
    -- v0.6.46+: a recursive watch (the default) reads the whole tree, so it needs "<folder>/**";
    -- this file grants the folder and its first level only
    let e be try_message(() => watch("_doctest_watch"))
    assert(contains(e, "file_read(\"_doctest_watch/**\")"))
    delete_dir("_doctest_watch", {"recursive": true})

test "term_open() is nothing without a terminal (pipes, CI, serve, synsema test)"
    assert_eq(term_open(), nothing)                     -- fall back to read_line

test "a pipe carries bytes both ways; close is sticky and half close still lets the other side answer (v0.6.42+)"
    let p be pipe()
    pipe_send(p["a"], "hola")
    let m be pipe_recv(p["b"], 1)
    assert_eq(m["type"], "data")
    assert_eq(decode(m["data"]), "hola")
    pipe_close(p["a"], "write")                         -- a stops sending
    assert_eq(pipe_recv(p["b"], 1)["type"], "close")
    assert_eq(pipe_recv(p["b"], 1)["type"], "close")    -- sticky: it says close again at once
    pipe_send(p["b"], "respuesta")                      -- b can still answer
    let ev be select({"x": p["a"]}, 1)
    assert_eq(ev["source"], "pipe")
    assert_eq(ev["name"], "x")
    assert_eq(decode(ev["data"]), "respuesta")

test "pipe_write takes what fits right now and never waits; pipe_stats shows the queues (v0.6.45+)"
    let p be pipe({"max_buffer": 4})
    assert_eq(pipe_write(p["a"], "abcdef"), 4)          -- took 4 bytes: credit those, keep "ef"
    assert_eq(pipe_write(p["a"], "ef"), 0)              -- full right now: 0, no timeout error
    assert_eq(pipe_stats(p["a"]), {"queued_in": 0, "queued_out": 4, "status": "open"})
    assert_eq(pipe_stats(p["b"])["queued_in"], 4)
    assert_eq(decode(pipe_recv(p["b"], 1)["data"]), "abcd")
    assert_eq(pipe_write(p["a"], "ef"), 2)              -- room again

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), env_clean (engine v0.6.45+, also in run: true starts the child with no environment from the parent, only env — plus SystemRoot on Windows, without which the C runtime and sockets do not start; the command itself is still found on the parent's PATH, so pass PATH in env if the child starts other programs. Without it the child inherits the environment minus Synsema's secrets — and since v0.6.45 that holds for pty: true too), 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).

cwd and a relative command (engine v0.6.46+, breaking). With cwd, a command with a path separator (./tool, bin/tool) is resolved against that folder, as cd sub && ./tool would, and that is the path the grant must name: run("./tool.bat", [], 60, {"cwd": "sub"}) and proc_spawn("./tool.bat", [], {"cwd": "sub"}) ask for exec("./sub/tool.bat") and run sub/tool.bat. Before v0.6.46 they asked for exec("./tool.bat") and which file ran depended on the platform (the one in sub on Unix, the one next to the program on Windows), so the grant did not say which file it allowed. A command without a separator (git, tool.bat) is still looked up in PATH — not in cwd — and an absolute path is unchanged. To migrate: write the grant as the error names it — Capability not granted: exec("./sub/tool.bat") … add require exec("./sub/tool.bat")` — with the ./ (exec("sub/tool.bat")` does not match it).

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: file.read — watching a tree is reading it: require file.read("<folder>/**") for a recursive watch (the default), the folder alone with {"recursive": false}.

require file("src/**")         -- src and everything below it: a recursive watch reads the whole tree
require exec("cargo")

let files be watch("src", {"interval": 0.2, "ignore": ["*.tmp"]})
let build be nothing
while true
    let targets be {"files": files}
    when build != nothing                         -- select takes handles only, never nothing
        set targets["build"] to build
    let ev be select(targets, 60)
    when ev != nothing                            -- nothing = 60 s of quiet: wait again
        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"]))

There is no continue: the rest of the body goes under when ev != nothing. And select refuses a nothing target (expected a connection handle …, got nothing), so the map is built with the handles that exist; a finished build stays in it harmlessly — after its exit it has nothing more to say.

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

A recursive watch needs file.read("<folder>/") (engine v0.6.46+, breaking)*, like grep and static. watch("src") with require file.read("src") (or "src/") no longer starts: Capability not granted: file_read("src/") … add require file.read("src/")` (with only "src/" the message names file_read("src"), since src/ does not cover the folder itself). Before, the grant was checked only on the root, so the watch reported the names of files under src/sub/ that the program could not read. Grant "src/", or pass {"recursive": false}, which still needs only the folder (like list_dir; watch_stats then says recursive: false). A root that is a file** when the watch starts is watched as recursive: false (if a folder replaces it, only its first level is seen). A root that does not exist is an error, as before: watch: cannot watch "nope": no such file or directory`.

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\u001b[2K> " + buf + "   " + join(matches, "  "))   -- ESC[2K: clear the line
        let ev be select({"keys": term, "agent": sub}, 60)
        when ev != nothing                         -- nothing = 60 s idle: redraw and wait
            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["type"] == "key"     -- resize/focus events carry no "key"
                when ev["key"] == "char" and not ev["ctrl"]
                    set buf to buf + ev["text"]
                otherwise when ev["key"] == "backspace"
                    set buf to slice(buf, 0, length(buf) - 1)
                otherwise when ev["key"] == "tab" and length(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. Three details the loop gets right: the escape character is written \u001b (since v0.6.29 \uXXXX is an escape; \x1b is NOT one and would print four characters); reading a key a map does not have is an error (Map has no key 'key'), so ev["key"] is read only on type: "key" events; and a stop inside nested whens still leaves the while.

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, tcp_connect and pipe ends (v0.6.42+). It returns the first ready event, tagged with source ("ws" | "proc" | "bus" | "watch" | "term" | "tcp" | "pipe"), handle and name (map form), plus the event's own fields (type/data for sockets, processes, TCP and pipes; 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.

Raw TCP and pipes — a tunnel in Synsema (engine v0.6.42+)§

Two byte channels join the five pieces above. tcp_connect(host, port, opts?) opens an outbound TCP connection, gated by net("host:port") with the exact port (a grant for :8080 does not reach :8081); tcp_send, tcp_recv, tcp_close and tcp_stats work on it. pipe(opts?) returns two connected byte ends {a, b} (pipe_send, pipe_recv, pipe_close). Both are bounded in each direction, and both go in select. tcp_recv/pipe_recv give {type: "data", data} (bytes — decode them for text), {type: "close"} when the other side stops sending, or nothing at the timeout. close is sticky, like a closed channel: every later read gives it again at once, and the connection stays open for your answer until you close it; pipe_close(end, "write") is a half close.

10054 on Windows) arrives as {type: "close", reason: "connection reset by peer"} (engine v0.6.45+) — in tcp_recv/pipe_recv and in select, without cutting the wait of the other connections. Sending on that connection afterwards is an error that says so: … connection N: the other side is gone (connection reset by peer)`. A protocol violation is still an error.

and give how many bytes they took — never waiting; 0 = full right now. tcp_send/pipe_send stay all-or-timeout. A loop that serves many streams credits each one with what it really took and keeps the rest for the next turn, instead of parsing the text of a timeout error. With pipe({"max_buffer": 4}), pipe_write(p["a"], "abcdef") gives 4 and the next write 0.

that write, pipe_stats(p["a"]) is {queued_in: 0, queued_out: 4, status: "open"} and its pair shows queued_in: 4.

require net("127.0.0.1:8080")

let c be tcp_connect("127.0.0.1", 8080, {"timeout": 5})
tcp_send(c, "GET / HTTP/1.0\r\nHost: x\r\n\r\n")
let reply be ""
let live be true
while live
    let m be tcp_recv(c, 5)
    when m == nothing or m["type"] == "close"
        set live to false
    otherwise
        set reply to reply + decode(m["data"])
tcp_close(c)
print(reply)

A pipe end is a number, and it can be handed to another interpreter — from a route to an agent, from a request to a socket route; the first one that uses it owns it. That, plus proxy to a pipe end, is a whole tunnel: the server speaks HTTP over one end (SSE and WebSocket upgrades included) and whoever holds the other end carries the bytes anywhere. Here an agent copies them to a local service:

require serve(8080)
require net("127.0.0.1:3000")

agent Bridge
    require net("127.0.0.1:3000")
    let svc be tcp_connect("127.0.0.1", 3000)
    let live be true
    while live
        let m be select({"pipe": peer, "svc": svc}, 20)
        when m == nothing
            set live to false
        otherwise when m["type"] == "close"
            set live to false
        otherwise when m["name"] == "pipe"
            tcp_send(svc, m["data"])
        otherwise
            pipe_send(peer, m["data"])
    tcp_close(svc)
    pipe_close(peer)

serve on 8080
    route "GET /*path"
        let p be pipe()                       -- one pipe per request
        spawn Bridge with peer = p["b"]       -- the agent takes one end…
        proxy to p["a"]                       -- …the server speaks HTTP over the other

A tunnel product — accounts, a page per user, quotas — is a Synsema program on top of these primitives; the engine knows nothing of tunnels. An end nobody takes is closed after 60 s without activity on its pair, and a program can have at most 4096 ends waiting to be taken.

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

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); since v0.6.42 it caps CPU work, not open requests
SYNSEMA_SERVE_MAX_WAITING256extra threads while handlers wait (sleep, HTTP, LLM, databases, select…), so a waiting route does not hold the rest; they leave as soon as the queue is empty; 0 = none (v0.6.42+)
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_SUBPROTOCOLS—comma-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+)
SYNSEMA_STATE_DIR<program dir>/.synsema/statewhere memory("NAME") keeps <NAME>.db — point it at a mounted volume and keep the code dir read-only (the .db is created there, nothing under the program dir)
SYNSEMA_LLM_BUDGET_PER_IDENTITY(none)id=N,id2=M — per-identity LLM token ceilings under serve (the identity the auth task returns); process-wide counters; at the line that identity's ops degrade to [llm budget exceeded for identity …], the rest continue (v0.6.20+)
SYNSEMA_HEALTH_PATH(none)opt-in host health endpoint: GET <path> → 200 {ok, uptime_s, in_flight, engine}, no auth, no rate limit, absent from discovery; a declared route at that path wins (warning at start); same as serve --health (v0.6.20+)
SYNSEMA_AUDIT(none)json / <path> / fd:N / unix:<path> — the capability audit stream without the flag, also inside a synsema build binary; the --audit flag wins (v0.6.20+)
SYNSEMA_LLM_BUDGETunlimitedper-process LLM token ceiling — at the ceiling the LLM ops degrade to a [llm budget exceeded: …] marker, never an error; unlike the rows above, also honored from .env — see LLM primitives

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.

Since engine v0.6.45: env cannot set SYNSEMA_ nor the variables that pick host paths (HOME, TMP, XDG_, …); the ceiling's paths travel absolute, anchored at the parent (a cwd moves the child, not its ceiling); filename is a label, never a folder; and the child keeps its state where the parent keeps its own. A "native" child granted memory=<name> shares the parent's memory, also from a cron tick or a serve handler — details on Sandboxing.

What is deliberately not here§

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