- name
- weft-api
- description
- Read when the program needs to answer calls from outside (an HTTP route, a websocket, anything a page or a service will call), and before briefing a frontend on what it will call: the URL known before activation, how a request's parts and body reach the graph, pictures in and out as links, streaming an answer as it is produced, holding a conversation open, who may call, and how to try it.
# Building an API
Everything here is in the `api` package of the catalog. You read each
node's `metadata.json` before wiring it; when a name below drifts from it,
the metadata wins. A [route] is one `Route` or `Socket` node: one trigger,
and one call is one fresh execution carrying the request on its ports. An
[answer] is any node that writes back to that caller: the runtime hands the
caller's connection to every node in the run, so a node of your own is one
as readily as a catalog node. The `api` package ships three: `Reply` sends
a body, `Stream` sends a bus as it flows, `Close` ends the exchange. A
[gate] is an auth access node wired into a [route]'s `auth`; the package
ships one per way of proving who is calling, and the listing names them. A
[stored-file value] is a few hundred bytes saying where a file sits in
storage, the only way bytes travel a wire.
## The URL is known before anything runs
A [route] answers at `<install>/connect/local/<project id>/<path>`, and every part of that address is fixed before the route is ever activated: the install, and the project id, which is the `id` under `[package]` in the project's `weft.toml` (the same on every install, so two projects can serve the same path). On this machine the install
is `http://127.0.0.1:14111`, so `hello = Route { path: "hello" }` answers at
`http://127.0.0.1:14111/connect/local/<project id>/hello`, and a socket at the same
address with `ws://`. On a cloud install it is the target's `url` in
`weft.toml` (`https://weft-role-dispatcher-123456789.us-central1.run.app/connect/local/<project id>/hello`,
and `wss://`). You write those URLs into the frontend-builder's
[the brief] the moment the routes are shaped, while the graph is still being
built; `weft activate` only turns them on.
Each project also has an address of its own where the program answers its routes at the root with nothing of weft's in between, which is the fastest way in: on this machine a free port it gets the first time it is activated (14200 or above) and keeps, moving to another free one if a program took it (`http://127.0.0.1:14200/hello`; `weft activate --port <n>` opens a chosen one and keeps it; if another program or project holds it, the command fails and nothing is activated), on a cloud install the project's own Cloud Run address. `weft activate` prints it and `weft status` shows it, or says why it is unavailable. While a route's trigger is parked or still coming on, a call is answered `503` with `Retry-After: 5`, so tell the frontend to retry a `503` that carries `Retry-After`, and never one without it; once the project is switched off (wiped), its own port is closed and `/connect/local/<project id>/<path>` answers 404. When you brief a frontend, give it the `/connect/local/<project id>/` address: it has the same shape on every install, so the frontend's code runs unchanged on a cloud.
Both local addresses, the project's own port and `127.0.0.1:14111`, answer only on this machine. When the install has a public address (a tunnel), `weft daemon status` prints it, and each route answers there at `<public address>/connect/local/<project id>/<path>`, reaching the same program. A frontend that runs
anywhere else (a hosted site, a phone, a browser on another machine) uses the
public one; a server on this same machine may use either.
A program that is an API can have a domain of its own on a cloud install.
If the user wants one and has agreed to its price, the deployer runs `weft
domain add api.shop.com --for api --on prod` (with `--accept-cost` for the
install's first domain), which serves the project's routes at the root of that domain,
so `hello` answers at `https://api.shop.com/hello` as well as at its
`/connect/local/<project id>/` address. A domain costs money, because it needs a load balancer, so
design for the free address and offer a domain only when the user wants a
name people see; read weft-deploying before you offer one.
## The shape
The run ends when it has sent its [answer]. You declare the body keys you
want on the trigger's arrow, never a body blob, and each arrives on its own
port.
```weft
hello = Route -> (name: String) { path: "hello", method: "POST" }
answer = Reply { status: 201, body: hello.name }
```
A [route]'s fixed output ports carry the request as the gateway saw it: the
method, the path as called, the captures, the query, the headers, and who
the [gate] let in. Its metadata lists them.
What you DECLARE on the arrow is how the body reaches the graph, and the
rule depends on the body shape the [route] is set to (`dataType` in the
source, "Body shape" in the editor):
- an object body: each declared name is a top-level key of it. There is no
port for the whole object, so you declare every key you read, and a key
that is itself an object arrives as one value (`meta: JsonDict`). An
undeclared key is dropped.
- a text body: the whole body arrives as a `String` on the ONE port you
declare.
- a binary body: it is stored, and the [stored-file value] arrives on the
one port you declare.
Names can collide, and the order is fixed: a fixed port beats a body key of
the same name, and a declared port named like a path capture reads the
capture on every body shape. It never counts as the text or binary body's one
port either, so a bytes or text route still reads its captures: on
`session/preview/{view}`, `-> (view: String)` is the capture, and the body
lands on whatever else you declare.
```weft
card = Route -> (id: String) { path: "cards/{id}", method: "POST" }
```
The first thing the program sends commits the status line. A `Reply` on a
branch with `status: 404` works. A `Reply` or a second `Stream` after a
`Stream` is a compile error, because the head already went out: end a
streamed answer with a `Close`. A
program that never sends anything holds the caller while it runs. When that
run ends, it is recorded as failed, and the caller gets `500` with the body
`execution failed: the run ended without answering: every path to its Reply
(or Stream, or Close) was skipped, usually because a value it waited on never
arrived`. If you
catch yourself wiring a branch that reaches no [answer], stop and write:
"Wait. Every branch answers." Then end that branch on a `Reply` or `Close`.
When a run ends before it answers, the status tells the caller why:
| Status | When |
|---|---|
| `500` | the program failed, got stuck, or ended without answering |
| `504` | a time limit ran out: the run's hour on a cloud install, the route's `maxSessionSecs`, a wait held for its whole `holdSecs`, or, on a cloud install, the worker stopping just before Cloud Run cuts its connection to weft (which it does after an hour), because every run on it was only waiting |
| `503` | weft or a person stopped the run (`weft stop`, `ctx.stop_tagged`, a deactivate, or a new build whose `--drain-timeout` ran out), or the run carries on without this caller (it paused, or moved to another worker) |
That `503` carries no `Retry-After`, unlike the one a parked route sends:
calling again would start a second run beside the one that may still be
going. If the answer had already started streaming, its status line is
gone, so the error comes in the body: a JSON-lines answer ends with
`{"error": "...", "status": 504}`. A socket closes with `1011` for a
failure or a time limit, and `1013` when the run was stopped or carries on
without this caller.
Two routes of one account can share a call as long as one of them spells out
what the other captures: `chat/general` beside `chat/{room}` is fine, and the
literal takes that one call while the capture takes the rest. What `weft
activate` refuses, naming both, is the pair where neither is the more specific
(`chat/{room}` beside `chat/{name}`, or `a/{x}/c` beside `a/b/{y}`, which both
answer to `a/b/c`). Give one of them a literal the other captures, or distinct
methods.
## Request-response
The graph decides the status. A Python node reading `params` and `query`
returns the body and the status together:
```weft
user = Route { path: "users/{id}", method: "GET" }
lookup = ExecPython(params: Dict[String, String], query: Dict[String, String]) -> (body: JsonDict, status: Number) {
params: user.params
query: user.query
code: "uid = params['id']\nif uid == '42':\n return {'body': {'id': uid, 'verbose': query.get('verbose')}, 'status': 200}\nreturn {'body': {'error': 'no user ' + uid}, 'status': 404}"
}
found = Reply { body: lookup.body, status: lookup.status }
```
A `GET` route has no body: declare nothing on the arrow and read the fixed
ports. A `text` route: `say = Route -> (line: String) { path: "say", dataType:
"text" }`, and the `Reply` body must be a `String`.
If the answer has a different shape from what came in, the [answer] node says
which with `answerAs`: `json`, `text` or `bytes`, and empty follows the
route's `dataType`. A `GET` that answers a picture is the usual case: it takes
no body in, so the route keeps its default, and the [answer] node (a `Reply`,
say) sets `answerAs: "bytes"` with a [stored-file value] as the body. Sent that way over HTTP, the file goes out
with its own content type and an inline `content-disposition` carrying its
filename, unless `headers` sets them.
A run that stops without a body ends through the closing [answer] node,
which takes no data from upstream: its `_should_flow` gate is the whole
wiring. So the shape for "do this, and if it did not work, stop here" is a
`Switch` on the outcome, the failing case gating a close with its reason
and status already written on it, the passing case gating the rest.
```weft
sweep = Route { path: "sweep/{what}", method: "DELETE" }
clear = ExecPython(params: Dict[String, String]) -> (removed: Number) {
params: sweep.params
code: "return {'removed': 3 if params['what'] == 'cards' else 0}"
}
outcome = Switch {
value: clear.removed
cases: [
{ "kind": "gt", "value": 0, "port": "some" },
{ "kind": "otherwise", "port": "none" }
]
}
report = Reply { body: clear.removed, _should_flow: outcome.some }
nothing = Close { status: 404, reason: "nothing to sweep", _should_flow: outcome.none }
```
The gate takes any port of any type; the value is never read, only a
`false` says no, so wire a Boolean into it only when its `false` should mean
"do not close". A branch that skipped closes its ports, so the `Close` behind
it skips too and the other branch's [answer] stands.
The gateway answers on its own before any run starts: `405` for a served path
called with the wrong method (naming the verbs it serves), `404` for an
unknown path, `401` for a caller the [gate] refused.
If you want a webhook receiver that says `200` at once and does the slow
part after, wire the `Reply` early in the graph, then the rest, and set
`outlivesCaller: true` so the caller hanging up does not cancel the run.
A slow job (anything that takes minutes: a render, a build, a batch of model
calls) never sits in one call the caller waits on. Answer early (a `202`
with an id), run the work after the answer with `outlivesCaller: true`, and
show progress: on the infra node's display when the work runs in a container
(the `weft-node-authoring` skill has the display), or as a status the caller
polls. On a cloud install one stretch of a run lasts an hour at most, so a job
that can run longer is split with a pause (a timer), which gives it a fresh
hour. For the polled status, the route that answered writes the job's state
to the project's Postgres, the work updates it as it goes, and a second route
reads it back. Two routes and a table, no new node.
If a page asks a route for a status every few seconds, set `recorded: false`
on the `Route` so each call does not leave a run behind. weft keeps no history
of such a run: one that fails is listed in `weft executions` with the step that
failed and why, one that reported a cost, or asked weft for something its
worker does not already hold (a stored file, a new connection or infrastructure
address, starting its infra, a stop by tag, a wait on a timer or a form), is
listed with its costs and how it ended, and any other leaves nothing. An
unrecorded run keeps no record to pause on, and it cannot be tagged.
A run that cannot pause (an unrecorded one, or one whose caller is still on
the line with `outlivesCaller` off) still waits on a timer or a form: the wait
holds the worker and the call open, and the answer carries the run on. If
nothing moves in the run for the route's `holdSecs` (60 by default, `0` to
never wait), the waiting node fails saying it gave up, and the caller hears
that.
A route also has `durable`, off by default, and it cannot be combined with `recorded: false`. With it off, a run whose worker dies ends cancelled. With it on, another worker carries the run on from its finished steps, but the caller's connection died with the old worker, so a step that answers the caller after that fails and the caller has to call again. For the details, go and read How a run is kept in the `weft-language` skill.
## Pictures in and out
A value a node emits is at most 100 KB; above that the node fails, naming
the port. A picture a caller sends inside a JSON body lands on a port
declared as its kind, and the [route] stores it on the way in:
```weft
upload = Route -> (photo: Image, caption: String) { path: "cards", method: "POST" }
```
The body key `photo` holds a `data:image/png;base64,...` URL (the media type
comes from it) or bare base64 (the media type comes from the bytes' own
signature); bytes that are not what the port declares fail the run by name.
Multipart bodies are not read: the caller sends JSON with a data URL. The
file lands at execution scope, walled to that run and swept when it ends: a
later request cannot read it even if it was kept. If you want to serve a picture from a
later request, wire it through `KeepFile { scope: "project" }`, which copies
it into the project's storage, then write the whole [stored-file value] it
emits into a jsonb column, exactly as it arrived. If it should not live for
ever, give `KeepFile` a `ttl_days` too: the copy then expires that many days
after anybody last read it.
Two columns, and only one of them is right:
- the whole value in a jsonb column: correct, and every answer builds a fresh
link out of it.
- a `url` you pulled out of that value and put in a text column: dead by
morning, because the link is minted per answer and expires in minutes.
So never unwrap the value, never pull a field out of it, never strip its
`url` before writing it. `Reply` and `Stream` recognise a stored file by its
whole shape, and a value you edited comes back to the caller as a raw storage
key instead of a picture.
Sending that picture back needs nothing else. An [answer] node walks the
whole body and turns every stored file it finds, however deep (in an object,
in a list of rows), into `{ url, mimeType, filename, sizeBytes }`, so a list
route serving rows with pictures is three nodes and no loop. The `url` is
built on the address the caller used: a call to `127.0.0.1:14111` gets a
loopback link, a call through the tunnel gets a tunnel link. A run no request
started (`weft run --fire`) gets the install's internet address when it has
one, loopback otherwise. A frontend puts the `url` straight in an `<img>`. `Cast` the value to `Image` or `File` only when you want the file
as a typed value on a wire, to hand it to a node that takes a picture.
If you want to serve a picture that is redrawn often (a live preview), the
shape is one place that always names the latest copy, which every render
overwrites and the serving route reads and answers as bytes. A render's own
file is at execution scope and swept when its run ends, so a link to it dies
with that run: each render is kept in the project scope first. One way to
build it, with a table: each render goes through
`KeepFile { scope: "project", ttl_days: 1 }`, and its whole value goes over
one row per preview (`INSERT INTO preview (view, picture) VALUES ($view, $picture)
ON CONFLICT (view) DO UPDATE SET picture = EXCLUDED.picture`). No storage node
rewrites a picture in place, so the row is what stays put: the old copies
expire a day after their last read, and the one on screen is read on every
call, which resets its clock. The route that serves it, with `db` the
program's `PostgresDatabase`:
```weft
preview = Route -> (view: String) { path: "session/preview/{view}", method: "GET" }
latest = PostgresExecuteQuery(view: String) -> (picture: JsonDict) {
account: db.access
view: preview.view
query: "SELECT picture FROM preview WHERE view = $view"
}
show = Reply { answerAs: "bytes", body: latest.picture }
```
An `<img>` can point straight at that route. A json answer works for a
project-scope file too, through the `url` the [answer] builds.
If you catch yourself putting base64 on a wire or reading a multipart body,
stop and write: "Wait. Files are links." Then declare the port `Image` or
`File`.
## Streaming
`Stream { bus, format }` pipes a bus to the caller, one chunk per message,
until the bus closes. Any node that opens a bus can feed it, and the node's
own ports say which output is a `Bus`; a model streaming its answer is the
one you will reach for most:
```weft
ask = Route -> (prompt: String) { path: "chat", method: "POST" }
prov = OpenRouterProvider { model: "openai/gpt-4.1-nano" }
live = LlmStream { provider: prov.provider, prompt: ask.prompt }
out = Stream { format: "sse", bus: live.stream }
```
`format` has two good answers and one for the rare case. Pick between the
first two on what the reader is, and neither is a fallback for the other:
- `ndjson`, one JSON value per line (`application/x-ndjson`). Every payload
arrives exactly as it left, whitespace included, and a reader is one
`JSON.parse` per line. Reach for it first for anything whose text matters,
which includes model output, because a delta usually begins with a space.
- `sse`, server-sent events (`text/event-stream`). Reach for it when the
reader is a browser's built-in `EventSource`, or a client that already
speaks SSE. The grammar is `data: <line>` per line of the payload and a
blank line per message, and a reader must strip exactly ONE space after the
colon and no more. A hand-rolled parser that does `.trim()` instead eats the
leading space of every delta and fuses words together, which looks fine
until you diff it. If you write the reader yourself, say so in the
frontend's brief.
- `raw`, the payloads as they are, no separator. For a body that is already
its own format (bytes, one long document). Read what the route section says
about a ceiling before you pick it.
`first` sends one value ahead of the bus, framed exactly like the messages
behind it: the id, the row just created, the session token, then the feed.
`status` and `headers` ride the first chunk. The response ends when the bus
closes; a bus that closed before the `Stream` node ran still streams whole.
The status line went out with the first chunk, so a failure after that
cannot change it. It arrives in the body's own framing, as the last thing the
reader gets: a final `{"error":"..."}` line on `ndjson`, an `event: error` on
`sse`, a `[error] ...` line on anything else. Tell the frontend's reader to
look for it.
A run behind a `Route` lives as long as its caller: when they hang up, the
run is cancelled and whatever it was still doing stops. That is
`outlivesCaller` on the trigger, and it is the lever for what a disconnect
MEANS: off (the default) ties the run to its caller; on, the run finishes on
its own, and only what it would have SENT the caller goes nowhere. Its
writes still land.
**The longer a node holds the caller open, the more of the run the caller
can end.** A node answers in one shot or holds the connection and writes to
it over time; that is the node's own choice, made through the caller the
runtime hands every node, and any node can be written either way. Whatever
holds it open, the caller's chance to leave lasts as long as the holding
does, and on a caller-tied run their leaving cancels the whole run, not just
the sending: every node still to fire never fires.
That is how a run loses a write. Work placed after the answering node is
work the caller can cancel by closing a tab, and the row it would have
written keeps whatever it started with, for ever. Nothing reports it. Your
own client never shows it, because you let it finish.
So a run with a write behind its answer owns itself (`outlivesCaller:
true`), and then you name what ends it, as below. Prove it by hanging up in
the middle:
```bash
عرض على GitHub