Synsemadocsv0.6.xENES

HTTP server

HTTP server

A native, production HTTP server — no framework to add (async hyper/tokio). Everything is deny-by-default, so a server needs require serve(port).

serve.syn
-- Doc example: the serve response contract. Helpers return {status, value}; the
-- runtime renders them. (A real `serve on` block doesn't terminate, so the doctest
-- asserts the response shapes the handlers give — see the prose for a full server.)
intent: "doc example: serve response contract"

print("ok → " + text(status of ok({"a": 1})) + ",  fail(400) → " + text(status of fail(400, "bad")))

-- A helper a handler calls: it gets a COPY of the map, so it gives the change back.
task with_total(order)
    set order["total"] to order["qty"] * order["price"]
    give order

test "uniform response helpers carry a status + value"
    assert_eq(status of ok({"a": 1}), 200)
    assert_eq(status of created({"id": 1}), 201)
    assert_eq(status of fail(400, "bad input"), 400)
    assert_eq(status of not_found("missing"), 404)
    assert_eq((value of fail(400, "bad input"))["error"], "bad input")

test "the other forms of fail, not_found, respond and redirect"
    assert_eq(status of fail("bad"), 400)
    assert_eq(value of fail("bad"), {"error": "bad", "status": 400})
    assert_eq(value of fail(503), {"error": "error", "status": 503})
    assert_eq(value of not_found({"hint": "try /v2"}), {"hint": "try /v2"})
    assert_eq(status of respond("", "text/plain", 202), 202)
    assert_eq(status of redirect("/login"), 301)
    assert_eq(status of redirect("/login", 303), 303)
    assert_error(() => redirect("/login", 200))

test "a map a handler passes on is a value: the change comes back with give"
    let b be {"qty": 2, "price": 5}
    let priced be with_total(b)
    assert_eq(priced["total"], 10)
    assert(not contains(b, "total"))          -- the caller's map did not change

test "request.json reads the body like json_decode"
    let bom be decode(bytes([239, 187, 191]))    -- a byte order mark, as some clients send it
    let body be json_decode(bom + "{\"id\": 18446744073709551616, \"v\": -0}")
    assert_eq(body["id"], 2 ** 64)            -- exact, not a float
    assert_eq(text(body["v"]), "0")           -- -0 reads as 0
    assert_error(() => json_decode("{\"v\": NaN}"))   -- a request with it is a 400

test "optional headers: get with a default instead of indexing"
    let headers be {"content-type": "application/json"}
    assert_eq(get(headers, "x-api-key", nothing), nothing)
    assert_eq(get(headers, "content-type", nothing), "application/json")

Routes & params§

require serve(8080)

serve on 8080
    route "GET /products"
        give sql("SELECT id, name, price FROM products")
    route "GET /products/:id"
        give sql("SELECT * FROM products WHERE id = ?", [params.id])
    route "GET /files/*path"            -- catch-all (variable depth)
        give read_file(params.path)

Routes match by specificity (exact > :param > *catchall), not declaration order.

Auth & validation§

serve on 8080
    auth with check_token
    route "POST /products" requires auth
        expect body {name: text, price: number}    -- 400 if it doesn't match
        give created(json of request)

The auth task receives the bearer token; declare it with 2 parameters (task check(token, request)) and it also receives the request map — that's what unlocks cookie sessions (Login & sessions).

The request & responses§

request has .method .path .body .json .form .headers .cookies .query .params .user .ip .body_file (.cookies — engine v0.5.5+; .form — engine > v0.5.9). form of request is the parsed form body: urlencoded → {field: text}; multipart → text fields plus file uploads as {filename, content_type, data} (exact bytes); no form body → empty map — classic <form method="post"> posts need no fetch/JSON. .headers, .query and .params are maps — index by key (request.headers["authorization"]; header names are lowercased). Indexing a missing key errors, so read optional ones with get(request.headers, "x-api-key", nothing) (engine v0.6.29+; a default of your choice as the third argument) or guard with contains(request.headers, "x-api-key"). query and params are also bound as bare locals, so params.id works directly; their values are always text (int(params.id, nothing) turns "42" into 42 and "abc" into nothing instead of a 500). When a large body spills to disk (over ~1 MiB), .body is empty and .body_file is a temp-file path (in a synsema-spill-<random> folder of the system temp folder) — read it with read_body() / read_body_bytes(). The file is removed when the request ends. A process killed or crashed leaves its folder behind; since engine v0.6.46 a serve sweeps those once, the first time it spills a body: only real folders (not links) untouched for a day and holding only files as old (on Unix also of the same owner and with mode 0700). The temporary files of an atomic write (<name>.<random>.synsema.tmp) that a crash leaves next to a file are not swept; you can delete them.

json of request is read by the same parser as json_decode (engine v0.6.30): an integer of any size stays exact ({"amount": 115792089237316195423570985008687907853269984665640564039457584007913129639935} arrives as that integer, not a rounded float), -0 is 0, and a body that starts with a byte order mark is read instead of answered with 400. NaN, Infinity and 1e400 are not JSON: a body with them gets 400 {"error": "malformed JSON body"}.

The request is a value (engine v0.6.29+). let b be json of request and passing b to a task give a copy: a task that sets inside the map it received changes its own copy, not yours. The change leaves the task with give — set b to with_total(b) — and synsema check warns about a task that writes into a parameter and never gives it back. The example's tests show the pattern.

Handler scope: request, query and params live only in the handler's own scope — a task the handler calls does not see them. Pass what the task needs as an argument (e.g. lookup_user(request)), never a bare request referenced inside that task.

Responses use the uniform helpers — ok(x), created(x), fail(code, msg) (also fail(msg) → 400 and fail(code) → {"error": "error"}), not_found(msg) (a map instead of the text is the body as-is), respond(text, content_type, status?), redirect(url, status?) (default 301; any other status must be a 3xx) — or give a value directly. Any of them can carry extra headers or cookies: with_header(resp, name, value) / set_cookie(resp, name, value, opts?) — see Login & sessions.

Shared state across requests (state_*)§

A set on a global inside a handler does not persist to the next request — each request runs on its own snapshot of the globals. State shared across requests/handlers lives in an in-memory store with the life of the server: state_set(key, value), state_get(key, default?), state_incr(key, delta?) (gives the new value), state_merge(key, map), state_cas(key, expected, new) (atomic, below), state_delete(key), state_all() (a map snapshot of every key — {"a": 1, "hits": 2}). Gone on restart; for durable state use a database or the declared memory (Memory & state).

Engine v0.6.39+ enforces this for every kind of write — a path (set rows[0].name to …), an in-place append, a scalar, from a task the handler calls, and a module's own variables: each request starts from the values the program set up. Up to v0.6.38 such a write could stay in the worker that served the request (the same GET answered differently depending on the worker), so never rely on it.

What state_get gives is a copy (lists and maps are values since engine v0.6.29): after let cfg be state_get("cfg"), a set cfg["n"] to 99 changes only cfg — state_set("cfg", cfg) stores it. A seeded generator (rng(seed)) created at the top level cannot be used inside a request (it would restart the same sequence in each one, so it is an error that says so): create it inside the handler.

Changing a shared value safely — state_merge, state_cas (engine v0.6.45+)§

state_get + change + state_set is three steps, and two requests at the same time can both read the old value: the second state_set erases the first one's change (13 simultaneous webhooks that each added a key to one map left 7). Two atomic operations do the read and the write in one step:

or add, the rest stay) and gives the result. A missing key starts from {}. If the stored value is not a map it is an error: state_merge: the value of 'x' is number, not a map — use state_set to replace it.

gives true if it wrote. A missing key is nothing; equality is the language's (2.0 equals 2). Retry or give up when it gives false.

require serve(8080)

serve on 8080
    -- each webhook adds its own key to one shared map, atomically: none is lost
    route "POST /hook/:id"
        give state_merge("seen", {params.id: true})

    -- claim a job once: of many requests at the same time, exactly one gets won = true
    route "POST /claim/:job"
        let key be "job:" + params.job
        state_cas(key, nothing, "free")          -- creates it only if it is missing
        give {"won": state_cas(key, "free", "taken")}

The 13 webhooks now leave 13 keys, and of eight simultaneous POST /claim/j1 exactly one answers {"won": true}. In the wasm build both exist too; with the host's kv they are atomic only if the host offers kv.cas (see WASM).

Performance and memory§

their hot loops on the native tier, like synsema run: a loop of a million iterations written in a handler went from ~0.3 s to ~5 ms per request (reading a top-level global inside it stays native). With --labels or serve --attested they run on the reference interpreter, like any program. Up to v0.6.38 the route body itself ran on the reference interpreter: put a heavy loop in a task the route calls (it reaches native code from its second call).

frozen once, when the program has set them up, and every worker (SYNSEMA_SERVE_WORKERS, default one per core) reads that same copy; a write inside a handler copies first. RAM is about one copy of the data plus a few MB per worker. Measured: a million records plus a million integers in globals, 176 MB with 1 worker and 225 MB with 12. Choose the worker count by CPU. Up to v0.6.39 each worker built its own copy on its first request (160–190 MB per worker for a million small records, about 2.6 GB with 12): there, keep the count low with large globals.

work, not open requests. A handler that waits — sleep, wait_for, select, an HTTP or LLM call, a database, an approval, a child process, tcp_/pipe_ — releases its CPU permit while it waits, and the pool adds threads (up to SYNSEMA_SERVE_MAX_WAITING, default 256) so other requests keep running. Measured with 2 permits: six routes sleeping 2 s finish together in about 2.7 s and a fast route answers meanwhile (before: two at a time, and the fast route waited behind them). The code stays straight-line — there is no async. The extra threads leave as soon as the queue is empty. With an LLM token budget (SYNSEMA_LLM_BUDGET), calls in flight are capped at SYNSEMA_SERVE_WORKERS, so the budget is not overshot.

Proxying, tunnels and the client's IP (engine v0.6.42+)§

proxy to is a statement, like give: a route can check something first, answer by itself, or pick the destination per request. A destination computed per request has its net grant checked per request, on the same host the server connects to (only host:port — @, ?, #, \, % and spaces are refused). A literal, a constant or env(...) is checked once at startup.

A wildcard net does not reach a private upstream (engine v0.6.46+). require net("*") (or a bare require net) reaches only public addresses, and that is checked on every connection proxy to opens: with a wildcard and an upstream on 127.0.0.1, 10.x, 192.168.x… the server starts, but each request gets 502 {"error": "proxy error: upstream not allowed", "status": 502} and the reason, with the address, goes to the server's log (`[serve] proxy error: proxy to: 127.0.0.1 resolved to 127.0.0.1, a private address; a net grant with a wildcard reaches only public addresses. Name it: require net("127.0.0.1:9000")…`). Name the upstream — its host, its address or its CIDR — as below. See HTTP client for the ranges.

require serve(8080)
require net("127.0.0.1:9000")
require net("127.0.0.1:9001")

task backend_for(r)
    when get(r["query"], "v") == "2"
        give "http://127.0.0.1:9001"
    give "http://127.0.0.1:9000"

serve on 8080
    route "GET /api/*path"
        when get(headers of request, "x-token") != "ok"
            give fail(401, "unauthorized")     -- answered here; nothing is forwarded
        proxy to backend_for(request)          -- no `net` grant for it → an error, never a connection

The request body streams to the destination without being read first (max_body still counts the bytes). If the route reads the body itself — read_body(), expect, body/form/json of request — it is read whole and forwarded byte for byte. Read another way (in a task the route calls, get(request, "body")) the streamed body is not there, so check a body in the route. A declared route "OPTIONS …" wins over the automatic answer, so a CORS preflight can reach the service behind. proxy to can also target a pipe end — a tunnel written in Synsema, in Agentic apps.

Behind nginx, Caddy or a load balancer, list it in trust proxy. X-Forwarded-For, -Proto and -Host are kept only when the TCP peer is listed; from anyone else they (and Forwarded) are removed before the program, ip of request, the rate limit, the generated URLs or a proxy to see them — any client can send them.

require serve(8080)

serve on 8080
    trust proxy ["127.0.0.1", "10.0.0.0/8"]   -- your proxies (or --trust-proxy on the command line)
    route "GET /whoami"
        give {"ip": ip of request}            -- the real client, not the proxy

The trusted proxy must set X-Forwarded-Proto and -Host (not pass the client's through). Without trust proxy, behind a proxy every request seems to come from the proxy — one rate-limit bucket for everyone. A request with more than one Host header is a 400.

Built in§

Discovery — what every server publishes§

A Synsema server describes itself. Everything below is derived from what is actually wired — the route table, expect, requires auth, rate_limit, describe, and the capabilities the routes' code declares. Nothing is written twice, and what can't be derived truthfully (a response schema) is left out, not invented.

URLWhatNotes
/llms.txtMarkdown index for agents: title, intent, every endpoint with the capabilities it may use (- POST /pay [net:api.stripe.com, llm]), the describe api: list, and a ## Machine-readable section pointing at the documents belowon by default
/robots.txtAllow: / + Sitemap: <base>/sitemap.xml — Disallow: / when private
/sitemap.xmlThe pages a crawler can visit without context: GET routes without path params, without requires auth, not stream, not proxyparametric routes (/blog/:slug) are not expanded — the runtime doesn't know which slugs exist. No lastmod (there is no truth to give it)
/openapi.jsonOpenAPI 3.1 of the route table (mapping below)deterministic: paths ascending, methods in the order GET/POST/PUT/PATCH/DELETE — diffable, anchorable in tests
/docsThe API, browsable: every operation with its schema, a form per path parameter and body, and Try it (a bearer-token field kept in the tab's sessionStorage; cookies travel on their own). No CDN, no third-party script. With Accept: text/markdown the same reference comes back as Markdown for agentsdocs off turns only this page off
/.well-known/synsema-authHow to authenticate here — Agent identity
/.well-known/agent-card.jsonThe server's Agent Card (A2A shape: one skill per route, auth schemes, the server's did:key), signed with SYNSEMA_IDENTITY_KEY or the attested key — Agent identityalias /.well-known/agent.json; supportedInterfaces is [] on purpose (no A2A transport here)

Base URL (the absolute links in sitemap/robots and OpenAPI servers): domain "…" of the serve block if declared; otherwise the request's Host, with https when TLS is on or a proxy in front sends X-Forwarded-Proto: https.

Behind a proxy, declare domain. A proxy rewrites Host to the backend's authority (nginx's default $proxy_host, the built-in proxy to as well), so without domain the sitemap would say https://127.0.0.1:8090/.... X-Forwarded-Host is ignored on purpose: any client can inject it and a generic proxy passes it through — that is host header injection, and it would poison the sitemap, robots.txt and the OpenAPI servers. Same stance as X-Forwarded-For for rate limiting: the engine only trusts what you declared.

Shape and opt-outs:

serve on 8080
    describe
        about: "Bookshop API"            -- the title: /llms.txt, OpenAPI info.title, /docs
        api: ["GET /books/:id -- one book", "POST /orders -- place an order"]
        version: "1.4.0"                 -- OpenAPI info.version (default "0.0.0")
    -- private                           -- internal server: /llms.txt, /openapi.json, /sitemap.xml, /docs → 404; robots Disallow: /
    -- docs off                          -- no /docs page; /openapi.json stays published
    -- discovery off                     -- none of them: every reserved URL reaches your routes (an edge)
    route "POST /orders" requires auth
        expect body {book: text, qty: number}
        give {"ok": true}
require serve(8080)
require net("127.0.0.1:9000")

serve on 8080
    discovery off                        -- this edge publishes nothing of its own
    route "GET /*path"
        proxy to "http://127.0.0.1:9000"   -- /llms.txt, /openapi.json, /docs… are the backend's

The document from inside the program — openapi_json() (engine v0.6.20+). Under serve it returns the OpenAPI document this server would publish, already filtered, as text — to publish it from a route of your own (another path, another filter, an internal port): give respond(openapi_json(), "application/json"). Under run it is an error (only available under serve). With several serve on blocks in one process, each route gets the document of its own server.

A health endpoint, if the host asks for one (engine v0.6.20+). The runtime invents no /health — the program declares its routes. An operator (Kubernetes, a load balancer) can opt in with SYNSEMA_HEALTH_PATH=/healthz in the process environment or synsema serve --health /healthz: GET on that path answers 200 {"ok":true,"uptime_s":n,"in_flight":n,"engine":"<version>"}, without auth or rate limit, and it never appears in discovery. If the program declares a GET route at the same path, the route wins and the server warns on stderr at start. It is not baked by synsema build --serve: set the variable where the binary runs.

How /openapi.json is derived:

OpenAPIFrom
info.title / info.description / info.versiondescribe about: → intent: → "Synsema service" / intent: / describe version:
serversthe base URL above (none when it can't be known)
paths./a/{id} + parametersroute "GET /a/:id" — :id and *rest become {id} / {rest}, in: path, required, string
requestBodythe route's top-level expect body {…} → a JSON schema, every field required (text→string, number→number, bool→boolean, list→array, map→object). An expect inside a when is a branch, not a contract: not published
responses.200application/json; text/event-stream for a stream route; text/html when the last give is html()/render()/page(); the three negotiated types for content(); 302 for redirect(). Inferred from the code, no response schema — that is the honest limit
400 / 401 / 429an expect / requires auth / a rate limit
security + components.securitySchemesroutes with requires auth when the block has auth with: bearer, cookie, httpsig (RFC 9421) — the same three /.well-known/synsema-auth announces
operationIdget_books_id (method + path segments)
x-synsema-rate-limit{count, window} (window in seconds; a route inherits the block's) or "unlimited"
x-synsema-streaming / x-synsema-proxystream routes / proxy to routes
x-synsema-capabilitieswhat the route may touch, statically: the require lines of every task the route's code calls (transitively, through modules too) plus what the builtins it calls imply (fetch → net, sql → db, read_file → file.read, reason/decide → llm, remember → memory, …). It is the contract, not a trace of what ran — the runtime still gates every call. Always present; [] when nothing

Mounted groups (mount m.api at "/v1") are published with their prefix. Each host "…" block publishes its own table under its Host.

In CI, without a server: synsema openapi app.syn --out openapi.json [--base-url https://api.example] writes the same document from the source — parse only, nothing runs, no port opens; exit 2 when the file has no serve. See CLI.

See Frontend for HTML pages and Build a website for the end-to-end walkthrough.