---
slug: 40-serve
title: HTTP server
description: Synsema's built-in production HTTP server — routes, path params, declarative auth and validation, pagination, SSE, rate limiting, static files, CORS, and automatic HTTPS.
example_ids: [serve]
---

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

```synsema
-- 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")))

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

## Routes & params

```synsema
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

```synsema
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](/en/0.6.x/40a-web-auth)).

## 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 guard optional ones with `contains(request.headers, "x")`. `query` and `params` are also bound as bare locals, so `params.id` works directly. When a large body spills to disk (over ~1 MiB), `.body` is empty and `.body_file` is a temp-file path — read it with `read_body()` / `read_body_bytes()`.

**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)`, `not_found(msg)`, `respond(text, content_type)`, `redirect(url)` — 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](/en/0.6.x/40a-web-auth)**.

## 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?)`, `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](/en/0.6.x/61-memory)**).

## Built in

- **Pagination:** `give paged("SELECT … ORDER BY id")` — `LIMIT`/`OFFSET` pushdown + exact `COUNT(*)`.
- **SSE:** a `stream` block with `send` for server-sent events — an LLM can stream into it token by token with [`llm_stream`](50-llm-primitives). The server writes a `: keepalive` comment after 15 idle seconds (`SYNSEMA_SSE_KEEPALIVE`), so proxies don't cut quiet streams (engine v0.6.7+). Feed it from an agent/cron/another request through the event bus (`bus_subscribe` + `bus_recv`) — see [Agentic apps](47-agentic-apps).
- **WebSocket routes (engine v0.6.7+):** a `socket` block instead of `stream` accepts an incoming WebSocket; `socket` is the connection's handle and the whole `ws_*` family works on it. `426` without an upgrade, auth before the upgrade, honest close codes — [Agentic apps](47-agentic-apps).
- **Timeouts & cancellation (engine v0.6.7+):** `timeout N` on the serve block and/or a route (`timeout none` opts out). No clause = no limit. At the deadline: `504` and the handler is really cancelled (cooperative, checked per statement and in every wait). `Ctrl-C`/SIGTERM drains in-flight work (`SYNSEMA_SHUTDOWN_GRACE`, default 10 s) and exits 0 — [Agentic apps](47-agentic-apps).
- **Rate limiting:** `rate_limit N per window`.
- **Static files:** `static "/assets" from "./static"` (ETag/Range/gzip), plus **`cache "1h"`** (Cache-Control per mount; `"immutable"`, `"no-store"`, `<N>s/m/h/d`) and **`fallback "index.html"`** (SPA history-fallback). Declared routes win over static — from a declared route, serve a **binary** asset with `binary(read_file_bytes(p), "image/png")` (plain `read_file` is UTF-8-lossy). More in [Frontend](41-frontend).
- **Where it listens — the `bind` clause (engine v0.6.18+):** `bind "127.0.0.1"` inside the serve block makes the listener local (any IP or host name; evaluated at start). `--bind` on the command line still wins; without either the default stays `0.0.0.0`. Inside a `host` block it is an error. `synsema build --serve` bakes the clause's literal. A desktop app is a server with `bind "127.0.0.1"` that calls `shutdown()` when its last window closes — [Your app on the desktop](41c-desktop).
- **Installable app / PWA (engine v0.6.15+):** a manifest + a service worker served from `/` (`.webmanifest` gets `application/manifest+json`) make the site installable on Android, iOS and desktop; native push with `push_send` (`require net(<push service>)`) — everything scaffolded by `synsema init --pwa`. See [Your app on the phone](41b-pwa).
- **Custom error pages:** `errors with <task>` — a `task(status, message, request)` shapes 401/404/405/500 (HTML for browsers, `nothing` keeps the JSON default for agents; a `redirect()` is honored — the "401 → login" pattern; every other status is preserved, no soft-404s).
- **Mounted routes:** `mount shop.tienda [at "/store"]` mounts a module's `export routes` group — split a big serve into files (serve level only, not inside `host` blocks). Per-route `rate_limit`/`timeout` inside a group work since engine v0.6.19 (own zone per mounted route; a prefix is another zone); `stream`/`socket` routes stay in the serve block, and `synsema check` refuses them in a group before `serve` does. See [Modules](14-modules).
- **CORS:** `cors "*"`. **Negotiation:** `content()` serves HTML/Markdown/JSON from one source.
- **Discovery:** `/llms.txt`, `/robots.txt`, `/sitemap.xml`, `/openapi.json` and `/docs` are generated from the route table — see [Discovery](#discovery-what-every-server-publishes) below.
- **gzip for dynamic responses** (render/html/content/JSON ≥ 1 KB) when the client accepts it.
- **TLS / automatic HTTPS** via CLI flags (`--domain … --tls-auto`); HTTP/2, vhosts, reverse proxy.
- **Dev loop:** `synsema serve app.syn --watch` restarts on `.syn` changes (templates/statics already hot-reload per request); `render("literal.html")` paths are validated at startup.
- **Observability:** `log`/`print` reach the terminal with a `[serve]` prefix.

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

| URL | What | Notes |
|---|---|---|
| `/llms.txt` | Markdown 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 below | on by default |
| `/robots.txt` | `Allow: /` + `Sitemap: <base>/sitemap.xml` — `Disallow: /` when `private` | |
| `/sitemap.xml` | The pages a crawler can visit without context: `GET` routes **without** path params, **without** `requires auth`, not `stream`, not `proxy` | parametric routes (`/blog/:slug`) are **not** expanded — the runtime doesn't know which slugs exist. No `lastmod` (there is no truth to give it) |
| `/openapi.json` | OpenAPI 3.1 of the route table (mapping below) | deterministic: paths ascending, methods in the order GET/POST/PUT/PATCH/DELETE — diffable, anchorable in tests |
| `/docs` | The 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 agents** | `docs off` turns only this page off |
| `/.well-known/synsema-auth` | How to authenticate here — [Agent identity](46-agent-identity) | |

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

```synsema
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
    route "POST /orders" requires auth
        expect body {book: text, qty: number}
        give {"ok": true}
```

- `describe about:` / `api:` / `version:` are the only inputs you write. An `api:` entry whose prefix is exactly `"POST /orders"` becomes that operation's `description`; the program's `intent:` is the API description.
- **`private`** hides all four generated documents (as it always hid `/llms.txt`); **`docs off`** hides only `/docs`.
- A declared `route` or a static file at any of these paths **wins** over the generated one.
- `describe`, `private` and `docs` are soft keywords — special only inside a serve block.

How `/openapi.json` is derived:

| OpenAPI | From |
|---|---|
| `info.title` / `info.description` / `info.version` | `describe about:` → `intent:` → `"Synsema service"` / `intent:` / `describe version:` |
| `servers` | the base URL above (none when it can't be known) |
| `paths./a/{id}` + `parameters` | `route "GET /a/:id"` — `:id` and `*rest` become `{id}` / `{rest}`, `in: path`, required, string |
| `requestBody` | the 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.200` | `application/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` / `429` | an `expect` / `requires auth` / a rate limit |
| `security` + `components.securitySchemes` | routes with `requires auth` when the block has `auth with`: `bearer`, `cookie`, `httpsig` (RFC 9421) — the same three `/.well-known/synsema-auth` announces |
| `operationId` | `get_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-proxy` | `stream` routes / `proxy to` routes |
| `x-synsema-capabilities` | **what 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](70-cli).

See **[Frontend](41-frontend)** for HTML pages and **[Build a website](41a-build-a-website)** for the end-to-end walkthrough.
