# Responses — `web.response.send`
Everything a pipeline sends back over HTTP goes through `web.response.send`: JSON,
a rendered page, a redirect, a cookie, a header. Nothing is implicit — a
script cannot set a status or a header; it returns the next payload and the
graph decides which `web.response.send` answers. A webhook run that reaches
no `web.response.send` answers `204 No Content` with no body: the run's
payload — the request body, anything read into it — is never sent on its own.
One other node answers the caller the same way: `auth.oauth.approve`, which
ends an app's login for a published MCP server with its redirect back to the
agent (`help("pipeline/examples/mcp-oauth-login")`).
For writing a rendered page to project storage instead of answering the
request, `web.site.generate` renders the same templates
(`help("pipeline/examples/static-entry-generation")`).
## Flags
The table is rendered from the node's definition when this page is read, so
it cannot lag behind the code; `help("pipeline/nodes/web.response")` has the
same rows with their schemas and examples.
Quote any value that contains `{{ }}` or a space as one argument;
`--header Location={{ input.url }}` unquoted is cut at the first space and refused.
## Redirects, cookies, bodies
A redirect is a 3xx `--status` with a `Location` header; a `Location` without
a 3xx, or a 3xx (other than 304) without a `Location`, is refused at run.
```
web.response.send --status 303 --header "Location=/home"
```
A cookie is a `Set-Cookie` header, sent exactly as written — nothing is added,
so a session cookie writes its own attributes. Repeat `--header` for several;
a repeated name is sent once per value, so two `Set-Cookie` headers are two
cookies:
```
web.response.send --status 303 --header "Location=/home" \
--header "Set-Cookie=session={{ input.token.access_token }}; Path=/; Max-Age=86400; SameSite=Lax; HttpOnly" \
--header "Set-Cookie=theme=dark; Path=/; Max-Age=31536000; SameSite=Lax"
```
A session cookie carries `Path=/; SameSite=Lax; HttpOnly`, and `Secure`
behind HTTPS. A cookie without `Max-Age` or `Expires` lasts until the browser
closes. Logout is `--header "Set-Cookie=session=; Path=/; Max-Age=0"` — the
same `Path` as the cookie it clears.
`--body` answers a value: a string as `text/plain`, anything else as JSON,
unless a `Content-Type` header says otherwise. At most one of `--body`,
`--template` and `--file`; with none, the payload answers as JSON.
`web.response.send` passes its payload on unchanged — it adds no key.
## Patterns
**JSON**
```
| trigger.webhook --route /api/posts --method GET
| sekejap.query.run -- "SELECT id, title FROM posts ORDER BY created_at DESC"
| web.response.send --body "{{ input.query.rows }}"
```
**A page**
```
| trigger.webhook --route /blog --method GET
| sekejap.query.run -- "SELECT id, title, published_at FROM posts ORDER BY published_at DESC LIMIT 20"
| web.response.send --template pages/blog-home.tsx
```
**Found or 404** — branch, then answer on each pin
```
[a] trigger.webhook --route /blog/:slug --method GET
[b] sekejap.query.run --param "1={{ $trigger.params.slug }}" -- "SELECT * FROM posts WHERE slug = $1"
[c] logic.if --when "input.query.rows.length > 0"
[d] web.response.send --template pages/post.tsx
[e] web.response.send --status 404 --template pages/not-found.tsx
[a] -> [b]
[b] -> [c]
[c]:true -> [d]
[c]:false -> [e]
```
**A designed 404 for paths nobody registered** — a `trigger.error`
pipeline, not a webhook. A webhook `--route /*` or `/:path` is not a
catch-all; it never sees a path that matched nothing.
```
| trigger.error --status 404
| web.response.send --status 404 --template pages/not-found.tsx
```
`--status 4xx`, `5xx` or empty widen it; the most specific active one wins.
The trigger answers under its source, `error` (`input.error.original_path`
is the path that failed); every field is on `help("pipeline/nodes/trigger.error")`.
**Redirect**
```
| trigger.webhook --route /go/signup --method GET
| web.response.send --status 302 --header "Location=/auth/register?source=landing"
```
**Redirect to a computed URL**
```
| trigger.webhook --route /after-login --method GET --auth jwt --credential jwt_main
| sekejap.query.run --param "1={{ $trigger.auth.sub }}" -- "SELECT home FROM users WHERE id = $1"
**Login — mint a token, set the cookie**
```
[a] trigger.webhook --route /auth/login --method POST
[b] sekejap.query.run --param "1={{ input.webhook.body.email }}" -- "SELECT id, name, password_hash, roles FROM users WHERE email = $1"
[c] logic.if --when "input.query.rows.length === 1"
[d] crypto.password.verify --from "{{ $nodes.a.webhook.body.password }}" --hash "{{ input.query.rows[0]?.password_hash }}"
[g] web.response.send --status 302 --header "Location=/home" --header "Set-Cookie=zebflow_session={{ input.token.access_token }}; Path=/; Max-Age=86400; SameSite=Lax; HttpOnly"
[h] web.response.send --status 401 --body "{{ { error: 'invalid credentials' } }}"
[a] -> [b]
[b] -> [c]
[c]:true -> [d]
[c]:false -> [h]
[d]:true -> [e]
[d]:false -> [h]
[e] -> [f]
[f] -> [g]
```
`crypto.password.verify` reads the candidate from `--from` and the stored
hash from `--hash`, routes to `true`/`false`, and adds `password: { valid }`
to the payload, keeping `input.query.rows` for `[e]`. An empty hash is
refused to `:error`, never `true`. The submitted password is no longer in `input` after the query, so
it is read from the trigger's own output, `$nodes.a.webhook.body.password`. `roles`
must be an array. `:public` goes on the claim's name (`roles:public=`), so
the value is a whole `{{ }}` and keeps the type its expression gives: a list
stays a list, an 18-digit NIM or NIP stays the text it was. The full
recipe with registration: `help("pipeline/examples/cookie-jwt-auth")`.
**Headers**
```
| trigger.webhook --route /api/data --method GET
| sekejap.query.run -- "SELECT * FROM data"
| web.response.send --body "{{ input.query.rows }}" --header Cache-Control=max-age=60 --header X-Version=2
```
## What a template receives
The payload becomes the page's `input`, and `web.response.send` merges the request
context into it — `route`, `params`, `query`, `search`, `headers`, `auth` — so
they are there even after `sekejap.query.run` replaced the payload. `input.auth`
carries only the claims minted with `:public`; a token whose claims are all
private gives `input.auth = null` in the browser, while the same claims stay
complete in `$trigger.auth` and `ctx.trigger.auth` server-side.
```tsx
import { useState } from "zeb/react";
export default function Dashboard(input) {
const user = input.auth; // { name, roles } — public claims only
const tab = input.query?.tab ?? "overview";
if (!user) return <main>Not signed in</main>;
return <main><h1>Hello {user.name}</h1>{input.query.rows.map((r) => <p key={r.id}>{r.title}</p>)}</main>;
}
```
`help("web")` for the page side.
## Expressions in flags
`{{ }}` works in every flag value and is resolved just before the response:
```
| web.response.send --status 302 --header "Location=/users/{{ $trigger.params.id }}/{{ $nodes.lookup.query.rows[0].slug }}"
| web.response.send --header "X-User-Id={{ $trigger.auth.sub }}"
```
Scope: `input`, `$trigger`, `$nodes` — `help("pipeline/dsl")`.
## Project files and an installable app — `--file`
`web.response.send --file` answers a project file: content type by extension, a
`.ts` compiled to JavaScript, everything else byte for byte. That is how a
robots.txt, a sitemap, an icon or a web-app manifest is served, and how a
service worker is: every script served this way starts with
`self.__ZF = { version, source }` (the build and the file's hash) so a
worker can key its cache on them.
```text
register pipelines/pwa/icons -- | trigger.webhook --route /pwa/{file} --method GET | web.response.send --root pwa/icons --file "{{ input.webhook.params.file }}"
```
- The manifest is a JSON file you write (`name`, `id`, `start_url`, `scope`
ending in `/`, `display: standalone`, `icons` at 192 and 512). A second app
(a member area at `/member/`) is a second file and route.
- A worker controls only pages under the directory it is served from, so the
site's worker answers at `/sw.js`; from deeper, add
`--header Service-Worker-Allowed=/`. A stored object on the file host
would control nothing — that host never runs scripts.
- With `--root`, `--file` is a bare filename, usually from the route, and can
never leave that folder — safe to expose.
- Every page carries the manifest and the Apple icon in `page.head.links` and
its colour in `page.head.themeColor`. A component in the shell registers the
worker once (`navigator.serviceWorker.register("/sw.js")`) and shows the
install offer as a quiet card, never a modal.
- Check it: `route_fetch path="/"` answers a `pwa` block — `installable: true`
or the `reasons` that stop it. Localhost is a secure origin, so all of this
works on the dev host; only the install button itself needs a real
(non-headless) Chrome.
## A stored file — `--file <FileRef>`, `--path <key>`
A file in the project's stores (an upload kept by `fs.file.put`, a generated
report) is answered by handing `--file` its FileRef, or `--path` its store key
(`--store` names the store; a FileRef names its own). The bytes stream from
the store — never into the run — with the FileRef's type, `Accept-Ranges`,
`Range` (206; 416 past the end), `HEAD`, `ETag` and `Last-Modified` (304 on
`If-None-Match` / `If-Modified-Since`) and `Cache-Control: private, no-cache`.
It shows inline; `--filename NAME` makes it a download under that name.
```text
| trigger.webhook --route /documents/:id --auth jwt --credential member-key
| sekejap.query.run --param "1={{ input.webhook.params.id }}" --param "2={{ input.webhook.auth.sub }}" -- "SELECT path FROM documents WHERE _key = $1 AND owner = $2"
| web.response.send --path "{{ input.query.rows[0]?.path ?? null }}"
```
- The query is the ownership check: the owner gets the bytes; anyone else
finds no row, `--path` resolves to null, and the route answers **404**.
- A stored file that is not there — deleted, or a key that is not a key of
the stores (`..`, an absolute path, `.zebfs/`) — fails
`FW_NODE_WEB_RESPONSE_SEND_NOT_FOUND`: the visitor gets 404, the run keeps
the reason. Wire `:error` to answer your own page instead.
- From a key in a record: `fs.file.get --from "{{ … }}" --return file` answers
the FileRef without reading the file; then `--file "{{ input.file }}"`.
- HTML, SVG and XML are sent as downloads and every stored answer is
`nosniff` and sandboxed, as on the file host.