HTTP Server
Build production-ready HTTP APIs and server-rendered applications with
Syntecnia's built-in serve primitive — no framework required.
Basic Server
Declare a server with serve on <port>. Routes are
nested inside the declaration using indentation, keeping the entire
server definition in one readable block.
require serve(8080)
serve on 8080
route "GET /hello"
give {"message": "Hello, world!"}
The require serve(8080) declaration tells the runtime to
reserve the port at startup, making permission explicit and auditable.
The server blocks the process until interrupted; no extra runner is
needed.
Routes
Routes are matched top-to-bottom within each method. Three segment kinds are supported: literal, named parameter, and catch-all.
Named parameters
A colon prefix captures a single path segment and exposes it on the
params map.
route "GET /products/:id"
give fetch_product(params.id)
Catch-all segments
A star prefix captures everything from that position to the end of the path, including slashes.
route "GET /files/*path"
give read_file(params.path)
Matching precedence
When multiple routes could match a request, Syntecnia applies this order:
- Exact literal match
- Named parameter (
:param) - Catch-all (
*catchall)
Automatic method handling
If the request path matches a known route but the method does not,
Syntecnia returns 405 Method Not Allowed and sets the
Allow header to the list of accepted methods — no
boilerplate needed.
OPTIONS and HEAD are handled
automatically for every defined route: OPTIONS replies with the
correct Allow header, and HEAD mirrors the GET handler
with the body stripped.
The Request Object
Inside any route handler the name request is a map
pre-populated by the runtime. All fields are read-only.
| Field | Type | Description |
|---|---|---|
request.method |
text | HTTP method in upper-case ("GET", "POST", …) |
request.path |
text | URL path without query string |
request.body |
text | Raw request body as text (up to body limit) |
request.json |
map / list | Body decoded from JSON; nothing if body is not valid JSON |
request.headers |
map | All request headers; keys are lower-cased |
request.user |
map / nothing | Populated by the auth task; nothing when unauthenticated |
request.body_file |
text / nothing | Temp file path when body spilled to disk |
request.ip |
text | Real peer IP after proxy headers are resolved |
Query parameters
Query string values are available as the query map. All
values are text; parse them explicitly when a number is needed.
route "GET /search"
let term = query.q
give search(term)
Path parameters
Captured route segments are available as the params map.
Like query values, all entries are text.
Reading the full body
For streaming uploads or large payloads that spilled to disk, use
read_body() to retrieve the complete content regardless
of where it is stored.
route "POST /upload"
let data = read_body()
give {"size": len(data)}
Response Contract
Syntecnia serialises whatever the route handler gives into an HTTP response automatically. The mapping is:
| You give | HTTP Response |
|---|---|
| A map | 200 with a JSON object body |
| A list | 200 with {"items": […], "count": N, "total": M, "cursor": next} |
| A scalar (text, number, bool) | 200 with a bare JSON value |
Nothing (no give) |
200 with null |
Response helpers
Use these built-ins when you need a specific status code or content type:
| Helper | Status | Notes |
|---|---|---|
ok(x) |
200 | Explicit 200; same serialisation rules as give |
created(x) |
201 | For newly created resources |
not_found(x) |
404 | JSON body with the provided value |
fail(code, msg) |
code | Arbitrary status with a message string |
html(content) |
200 | Sets Content-Type: text/html |
respond(content, content_type, status?) |
status (default 200) | Full control over body, type, and status |
route "POST /items"
let item = create_item(request.json)
give created(item)
route "GET /items/:id"
let item = find_item(params.id)
when item is nothing
give not_found({"error": "item not found"})
give item
Pagination
When a route gives a list, Syntecnia automatically paginates it. The default page size is 100 items; the maximum is 1 000 items. Clients control pagination with query parameters:
?limit=N— number of items per page?cursor=N— opaque cursor for the next page (returned in the previous response's"cursor"field)
The envelope returned for every paginated list is:
{
"items": [ /* …page contents… */ ],
"count": 25, /* items on this page */
"total": 412, /* total matching rows */
"cursor": 100 /* next-page cursor; absent on last page */
}
SQL push-down with paged()
For database queries use paged() so that the database
applies LIMIT/OFFSET and an exact
COUNT(*) is computed in a single round-trip instead of
fetching the full result set into memory.
route "GET /products"
give paged(query("SELECT * FROM products ORDER BY id"))
paged() for database-backed lists. It issues
a COUNT(*) alongside the paginated query and fills
"total" accurately without loading all rows.
Authentication
Declare an auth task with auth with <task>. The
runtime extracts the Authorization: Bearer <token>
header and calls your task with the raw token string. The task should
return either the user data (any map) or nothing to
signal an invalid token.
auth with check_token
route "GET /me" requires auth
give user of request
task check_token(token)
let row = query_one("SELECT * FROM sessions WHERE token = ?", token)
give row
-
Routes marked
requires authautomatically return 401 Unauthorized when the auth task returnsnothing. -
The authenticated user map is available inside the handler as
request.user. -
Routes without
requires authcan still readrequest.user— it will benothingfor unauthenticated requests.
Input Validation
Use expect body { … } to declare required fields and their
types. Syntecnia validates the request JSON before the handler body
runs.
route "POST /products"
expect body {name: text, price: number}
give create_product(request.json)
Supported types:
- A missing field or a type mismatch returns 400 Bad Request with the offending field name in the error body.
-
Malformed JSON (not parseable at all) is also a 400 before
expect bodyis even evaluated.
expect body validates the presence and
type of fields, not their values. Add your own business-logic
checks after the declaration.
Static Files
Serve files from the filesystem with a single declaration. No separate
file() capability is needed — the static
declaration itself is the permission grant.
# Serve everything in ./public at the root URL
static "./public"
# Serve ./assets under /assets prefix
static "/assets" from "./assets"
-
Route precedence: a defined
routealways wins over a static file at the same path. -
Path traversal: requests containing
..segments are blocked with 400 before any filesystem access occurs. -
Appropriate
Content-Typeheaders are set automatically based on the file extension.
.env secrets if path traversal protections are bypassed by
a future OS/runtime bug.
CORS
Enable Cross-Origin Resource Sharing with a single declaration. Place
it at the top of the serve block to apply server-wide, or
inside a route to limit its scope.
# Allow all origins (no credentials)
cors "*"
# Restrict to a specific origin
cors "https://app.example.com"
-
Syntecnia handles preflight
OPTIONSrequests automatically when CORS is enabled. -
Standard CORS headers
(
Access-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers) are added to every matching response.
cors "*" does not work with
credentialed requests (withCredentials: true in the
browser). Use an explicit origin when cookies or
Authorization headers must be sent cross-origin.
SSE Streaming
Use the stream block to push Server-Sent Events to the
client. The response is sent as Content-Type: text/event-stream
and the connection stays open until the block exits.
route "GET /events"
stream
each tick in range(10)
send {"count": tick}
send token as "token" # named event type
-
send <value>emits adata:event line. Maps and lists are JSON-encoded. -
send <value> as "<type>"emits anevent: <type>line before the data line. -
streamandgiveare mutually exclusive in the same route handler. -
Concurrent open streams are capped by
max_streams N(default 100). Requests beyond the cap receive 503 Service Unavailable.
serve on 8080
max_streams 500 # raise the cap server-wide
route "GET /events"
stream
each msg in subscribe("channel")
send msg
Rate Limiting
Syntecnia uses a token bucket algorithm keyed by the real peer IP address (proxy headers are respected). Limits can be set globally or per route.
# Global limit: 100 requests per minute
rate_limit 100 per minute
serve on 8080
rate_limit 100 per minute # server-wide default
route "POST /login"
rate_limit 5 per minute # tighter per-route limit
give authenticate(request.json)
route "GET /public"
rate_limit none # disable for this route
give {"status": "ok"}
Supported time windows:
-
Requests exceeding the limit receive 429 Too Many
Requests with a
Retry-Afterheader indicating when the bucket refills. - Rate limiting is checked before authentication — an attacker cannot bypass it by probing auth endpoints.
-
rate_limit nonedisables the limit for a specific route, regardless of the server-wide setting.
Request Body Limits
Syntecnia enforces a 1 MB default body limit to
protect against memory exhaustion from large uploads. Change it with
max_body.
serve on 8080
max_body "10mb" # raise limit to 10 MiB
route "POST /upload"
max_body "100mb" # per-route override
give handle_upload()
- Requests whose body exceeds the limit receive 413 Content Too Large before the handler runs.
-
Bodies larger than an internal threshold (currently 256 KB) that
are within the limit are spilled to a temporary file on disk;
request.body_fileholds the path andread_body()reads it transparently.
max_body without a corresponding
rate_limit opens a slow-loris vector. Pair large body
limits with strict per-route rate limits.
Templates (Server-Side Rendering)
Render HTML templates server-side with the render()
built-in. Templates are plain .html files using
Syntecnia's minimal, injection-safe syntax.
route "GET /welcome"
let t = "Welcome"
give render("page.html", {
"title": t,
"items": ["a", "b"]
})
Template syntax
Template expressions use single curly braces. All values are HTML-escaped by default.
| Syntax | Purpose |
|---|---|
{ title } |
Output a variable, HTML-escaped automatically |
{ each item in items }…{ end } |
Loop over a list; item is scoped to the block |
{ when condition }…{ otherwise }…{ end } |
Conditional block; { otherwise } is optional |
{ raw trusted_html } |
Output without escaping — use only for known-safe HTML |
{ } as its delimiter, CSS curly
braces would be misinterpreted as template expressions. Always link
external stylesheets.
Semantic Content
The content() system lets you build structured documents
from semantic nodes rather than raw HTML. Syntecnia negotiates the
output format based on the client's Accept header or URL
suffix.
route "GET /article/:slug"
let doc = fetch_article(params.slug)
give content(
page([
heading(1, doc.title),
prose(doc.body),
link("Back", "/")
], {"title": doc.title})
)
Content nodes
Content negotiation
| Client signals | Output format |
|---|---|
| Default (browser request) | HTML with embedded JSON-LD structured data |
Accept: text/markdown |
Markdown |
URL ends in .json |
JSON tree of the content nodes |
URL ends in .md |
Markdown |
.json suffix route give agents clean, structured access
without a separate API endpoint.
Agent Discoverability
Syntecnia auto-generates machine-readable discovery files so that LLM agents and crawlers can understand your API without manual documentation.
Automatic files
-
/llms.txt— generated from yourintentdeclaration, route annotations, anddescribeblock. Lists every public route with its method, path, and purpose. -
/robots.txt— served automatically. Allows crawlers by default.
Enriching /llms.txt with describe
serve on 8080
describe
intent "Product catalogue API"
contact "api@example.com"
version "2.1.0"
Disabling discoverability
Add the private keyword to the server declaration to
opt out entirely:
serve on 8080 private
# /llms.txt → 404
# /robots.txt → Disallow: /
private mode, /robots.txt is still
served — it returns Disallow: / so well-behaved crawlers
know not to index the server.
Request Isolation
Every inbound request executes in its own interpreter scope. Variables, intermediate values, and any state created during one request are invisible to concurrent requests and discarded when the handler returns.
- No shared mutable state between handlers. There is no global variable that one request can corrupt for another.
- Shared persistence is intentional and explicit: use the blackboard for ephemeral cross-request state, or the database for durable storage.
- Always use parameterised SQL. Syntecnia's query built-ins require parameters to be passed as a separate argument, making accidental string interpolation in SQL structurally impossible.
# Safe: parameter is separate from the query string
let user = query_one("SELECT * FROM users WHERE id = ?", params.id)
# Unsafe: never build SQL by concatenation
# query("SELECT * FROM users WHERE id = " + params.id) ← SQL injection risk
paged() and add indexes for
large tables.
Error Handling
Syntecnia's HTTP layer is designed to never crash the server. Every error path maps to a deterministic HTTP response.
| Situation | Response |
|---|---|
expect body validation fails |
400 with the failing field name |
| Request body is not valid JSON | 400 before the handler runs |
| Uncaught error in handler | 500 with detail in dev mode; generic message in --secure |
| Path matched, wrong method | 405 with Allow header listing valid methods |
| No route matches | 404 |
| Rate limit exceeded | 429 with Retry-After header |
Body exceeds max_body |
413 |
SSE streams exceed max_streams |
503 |
--secure mode, 500 responses never include stack
traces or internal error messages. Use --secure for
any internet-facing deployment.
Handling errors in handlers
You can intercept errors within a handler using attempt /
catch:
route "GET /risky"
attempt
give do_something_risky()
catch err
give fail(500, err.message)