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.
-- 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)
request,params,query,headersanduserare 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 failedlog line. The server cancelling the handler (routetimeout, 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 nextws_recv/select. - A plain HTTP request to a socket route →
426 Upgrade Required(JSON body,Upgrade: websocketheader). 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_PINGseconds (default 30) while the handler waits; no pong in two intervals → aclosewith reasonkeepalive timeout. Messages overSYNSEMA_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_streamsslot (503+Retry-Afterover the cap) and counts againstSYNSEMA_WS_MAX_CONNS. No new capability — the route is already insideserve. - Rules:
GETonly,socketandstreamin one route is a parse error, not yet allowed inside anexport routesgroup (clear error). Outside a routesocketis 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 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)
| 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.
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_modedefaults tofalse—datais still text, notbytes: UTF-8 chunks, never split inside a character, invalid bytes → U+FFFD. Send them to xterm.js as text frames as they are, orstrip_ansi(text)for what a human sees (colors, cursor moves and OSC titles removed;\rredraws keep the last frame). The runtime never interprets VT. - Echo is on. What you
proc_sendcomes back in the output. A revealed secret you type is echoed in clear — the usual secret rules still apply (secretin args/env/send → error). - Keys, not lines. Enter is
"\r", Ctrl-C isbytes([3]), Ctrl-Dbytes([4]), an arrow is ESC +[A. Noproc_close_stdin(the error names the key to send). - Size.
cols/rows(default 80×24),proc_resize(h, cols, rows)later.termsetsTERM(defaultxterm-256color;env.TERMwins). - 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[6nat 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). LaterESC[6nare the app's and pass through. exit_codeis-1when killed by a signal; on a ptysignalis the number when known (15, 9, 1, 2), elsenothing.
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
- Payloads are data (text/number/bool/list/map/bytes); a task or a
secretis an error, never silently degraded. Published topics are literal — a glob inbus_publisherrors (globs belong tobus_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:
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"]))
| 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.
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}—keyis a name:"char"(thentextis 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". Withctrl: true,textis 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 ofkeyevents —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 (likeread_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§
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
…
- Sized routes: at the deadline the client gets
504 {"error": "gateway timeout: the handler exceeded 30s", "status": 504}withConnection: 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 withevent: error{"error": "cancelled: request timed out after 30s"}; a socket getsClose 1001with 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), raisingcancelled: <reason>. Atry/recovercan observe it, but the next statement raises again — clean up and leave. Children ofrun/proc_spawnare killed. timeoutis a clause. Anywhere else (inside awhen, a task, the top level) it is a runtime error, not a silent no-op. Not yet supported inside anexport routesgroup — 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.
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):
| 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, signature in Builtins.
What is deliberately not here§
- A terminal emulator or a TUI framework.
pty: truegives the child a real terminal; drawing it (VT parsing, a screen model) is the consumer's job — xterm.js in a browser,strip_ansifor an agent.term_opengives you keys and a size, nothing more: a line editor, a/palette or a menu is a few dozen lines of Synsema on top ofselect(the example above), not a widget library in the runtime. - A native filesystem notifier.
watchpolls a snapshot on purpose: one semantics on every OS, no kernel limits, no*-syscrate. If a sub-second latency on a huge tree ever matters, it would be anopts.backend, not a new API. - A cross-process bus. Redis pub/sub will reuse
bus_*with anopts.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, WebSocket client, Multi-agent and Deploy.