# wirl docs, in full

wirl hosts the internal tools a company's people build with coding agents, behind the company's sign-in, with keys kept out of the code.

Every page of this site follows as Markdown, in the sidebar's order. Each begins at its own `# Title` line, then a `Source:` line with the address of that page as Markdown, then the page itself. A link from one page to another names such an address. https://docs.wirl.dev/llms.txt lists the pages with a short description each.

# For agents: sign in and deploy

Source: https://docs.wirl.dev/auth.md

How a coding agent signs in to wirl, deploys a first app, and what to do when wirl refuses a request.

An agent cannot create a wirl account: sign-in is Google, the company's Google Workspace domain is the organisation, and a person approves every agent in the browser.

Commands here run as written, except a word in `<angle brackets>`: replace it, with the app's name for `<your-app>`, the address the person gave you for `<their-email>` (never one you made up), and so on. `npx` fetches the CLI from npm the first time and needs Node 22.13 or later (in a terminal, npm first asks "Ok to proceed? (y)": answer y).

## 1. Check

Run `npx wirl whoami`, or call `wirl_whoami` over MCP. Signed in, it prints `scope: full` or `scope: deploy` and one line for each organisation you belong to: go to step 4. Signed out, the CLI says "Not signed in.": go to step 2. To see which account, run `npx wirl whoami --json`: it adds `email`. If that is the wrong person, have the right one approve a new `npx wirl login`; a `WIRL_TOKEN` in the environment wins over the saved sign-in until it is unset.

The hosted MCP server and the CLI sign in separately. The hosted `wirl_deploy` and `wirl_preview` hand back a command for the CLI to run, so check the CLI too before you deploy. The local MCP server (`npx -y @wirl/mcp`) uses the CLI's sign-in.

## 2. Sign in

Sign in the way that fits where you run. Never print, paste or save a token anywhere: not in a message, a file or a log. The CLI keeps its sign-in in `~/.config/wirl/credentials.json`; leave that file alone. A hosted MCP sign-in does not sign in the CLI, so deploying through it needs `npx wirl login` too.

- **Claude Code.** Run `claude mcp add --transport http wirl https://app.wirl.dev/mcp`. `claude mcp add` only saves the server's configuration. Then sign in: run `/mcp` inside Claude Code and follow the steps in the browser, or run `claude mcp login wirl` in a terminal. If you will need to share, ask the person to tick the full-access box on the consent page (step 3).
- **Any shell.** `npx wirl login` prints a link and a code for the person to approve in their browser: show them both. It then waits for the approval and saves the sign-in; the code expires after 10 minutes. Run it where you can read its first lines while it waits (in the background, if your shell tool returns only when a command ends). If you will need to share, ask the person to tick full access on the approval page (step 3).
- **CI.** The person creates a token on the Connect an agent page, https://app.wirl.dev/settings/tokens, and puts it in the job's secrets as `WIRL_TOKEN`; it is shown once, and no token can make another. A `WIRL_TOKEN` in the environment is used before the saved sign-in.

## 3. Access

The person decides how much an agent may do. `npx wirl login` gives build-and-update access unless the person ticks full access on the approval page. On the MCP consent page the box "Also give it full access" is that choice: the person ticks it or leaves it. A token made for CI has deploy access unless the person picks "can do everything".

- **Deploy access** deploys, previews, reads logs, rolls back and pulls.
- **Full access** also shares an app, changes who can open it, deletes, restores, moves or transfers it, manages connections and members, creates organisations and revokes tokens.
- No token makes an app public: a person does that on the app's Access page.

With deploy access, a request that needs full access is refused with a `403` whose sentence names the page where the person does it: show them that page.

## 4. First app

Before you write the app, read [The contract](https://docs.wirl.dev/contract.md): the module's shape, `env.DB`, migrations written one statement a line, and who is asking. When the folder's name is not the app's, `npx wirl init <folder> --server --app <your-app>` names the app.

| Step | CLI | MCP tool |
| --- | --- | --- |
| Start a folder | `npx wirl init <your-app> --server`, then `cd <your-app>` | none: it writes files on this machine |
| Try it here | `npx wirl dev` | none |
| Preview it: never live, its own address and database | `npx wirl preview` | `wirl_preview` |
| See what the preview answers | `npx wirl fetch / --preview <preview-name>` | `wirl_preview_fetch` |
| Deploy it | `npx wirl deploy` | `wirl_deploy` |
| Share it, with an address the person gave you | `npx wirl share <their-email>` | `wirl_share`, with full access |

Run each command inside the app's folder. A folder you already have needs no `init`: a deploy takes the app from its `wirl.json`, else from the folder's name, and makes the app if it is new; if that name is already an app you can deploy to, it updates that app, so run `npx wirl apps` first when unsure. It prints the URL and who can open it. `npx wirl dev` keeps running until stopped: start it in the background, read its output for the address, and stop it when you are done. The hosted `wirl_deploy` and `wirl_preview` cannot read your files, so they hand back the command to run in a terminal; the local MCP server uploads for itself. If a command names several organisations and asks which, ask the person, then pass `--org <slug>`.

## 5. When wirl refuses

Read the sentence first: it says what happened and usually what to do. The CLI commands on this page and the MCP tools print the sentence and its hint, and usually not the status, the code or the `Retry-After` header: those are the API's, and the table gives them so you can match a sentence. Wait as long as the sentence says (a minute, 30 seconds, or `at least N seconds` in the hint of `migrations_rate_limited`); if it gives no time, wait a minute. Then send the same request again, once, and do not retry in a loop; if it is refused again, tell the person what the sentence says. A program that calls the API itself reads `Retry-After`: a number of seconds.

| API status | What you see | What to do |
| --- | --- | --- |
| `401` | `Not signed in.` with no sign-in at all; `Not signed in, or this token cannot do that.` for a `401`, or a `403` with no sentence of its own. The API's `401` says `unauthenticated`, the hosted MCP server's `unauthorized`. | Sign in (step 2). If you just did, this token cannot do that: tell the person. On the local MCP server, call `wirl_login`. |
| `403` | `insufficient_scope`: `This token can deploy, but not <action>. Do it at <page>, or connect again with a full token.` | Stop and show the person that page. If they want you to do it, they connect you again with full access: the box ticked on the consent page or the approval page, or "can do everything" for a CI token (step 3). |
| `404` | `not_found`: `Not found, or you do not have access to it: app "<app>" in organisation "<org>".` | wirl answers the same for an app that is not there and one the person cannot open, so do not try other names to find out which. Check the name, `--org` and `--app`, and what `npx wirl apps` or `wirl_list_apps` lists. |
| `409` | `migration_needs_approval`: `<file> <what it does> on the live database. A person approves it at <page>, then deploy again; nothing was applied.` With several files, `<n> destructive files wait for a person before they change the live database: ...` and each file's page. | Show the person that page, or each file's page, and deploy again once they say they approved. Never type the file's name or send a confirmation yourself. |
| `409` | `slug_taken`: `An app named <name> already exists in this organisation; choose another name.` Or, for an app that was deleted, `An app named <name> was deleted on <date>; its data is kept until <date>.` and the way back. | Choose another name. A deleted app comes back only if the person asks for it: ask before you run `npx wirl apps restore <name>`. |
| `409` | `migrations_busy`: a sentence about applies of migrations under way, ending `Try again in a minute; nothing was applied.` (or `Try again after that; nothing was applied.`), and a `Retry-After`. | Wait as long as the sentence says, then deploy again. |
| `409` | `preview_busy`: `A preview named <name> is being deployed right now. Try again in a moment.` Or `too_many_previews`: `This app already has <n> previews. Delete the oldest first: wirl preview --delete <name>.` | For the first, wait a moment. For the second, ask the person before you delete a preview you did not make: `npx wirl previews` lists them. |
| `413` | `too_large`: `Bundle is larger than 100MB.` or `The body is over 1 MiB, the most wirl reads of a request other than a deploy's or a preview's upload, so the request was refused.` Or `manifest_too_large`: `wirl.json is larger than 262144 bytes.` | Send less. For a deploy, leave out what the app does not serve, or list it in `.wirlignore` ([Limits](https://docs.wirl.dev/limits.md)). |
| `429` | `rate_limited`: `Too many attempts. Try again shortly.` or a sentence saying what ran out. `migrations_rate_limited`: `This org is too close to the database calls wirl allows one org in five minutes for another apply to start. Try again in a minute; nothing was applied.` Each with a `Retry-After`. `npx wirl login` says `Could not start sign-in (429).` | Wait as long as the sentence says, or a minute when it says none, as `Too many attempts. Try again shortly.` and `npx wirl login` do. Then try again once, and do not retry in a loop; if it is refused again, tell the person. |
| `502` | `app_answers_full` or `answers_full`: `npx wirl egress` lists it as `502 app_answers_full` for an outgoing call of the app: the vendor answered, but wirl could not hold the answer. `The vendor may have acted on the call: send it again only if repeating it is safe.` No `Retry-After`. | This is the app's own outgoing call, not your request to wirl. Send it again only if repeating the call is safe. |
| `503` | `too_many_uploads`: `... nothing changed. Send it again once one of them has finished.` (or `... once the other has finished.`) `uploads_busy`: `... nothing changed. Send it again in 30 seconds.` `database_unavailable`: `The control plane could not get a database connection, so nothing was changed. Try again in a few seconds.` Each with a `Retry-After`. | For `too_many_uploads`, wait for your other deploy or preview to finish. For the others, wait as long as the sentence says. Then send the same request again. If the sentence says part of it may have been made (`so this request did not finish, and part of it may have been made. Check the app before you send it again.`), check with `npx wirl versions` or `wirl_list_versions` first. |

Any other refusal comes with a sentence, a code or at least a status: read it before you send the request again.

## 6. Where to go next

- [Quickstart](https://docs.wirl.dev/quickstart.md): connect an agent, build, run, deploy and share.
- [wirl.json and the CLI](https://docs.wirl.dev/reference.md): every key and every command.
- [The contract](https://docs.wirl.dev/contract.md): what runs on wirl, and [what waits for a person](https://docs.wirl.dev/contract.md#a-person-confirms-each-one) before it changes live data.
- [Limits](https://docs.wirl.dev/limits.md): the sizes, counts and waits wirl holds uploads, calls and migrations to.
- [Connections and keys](https://docs.wirl.dev/connections.md#tokens): what a `deploy` token and a `full` token cover.
- [llms-full.txt](https://docs.wirl.dev/llms-full.txt): every page of this site as one Markdown file.

# Setup prompt

Source: https://docs.wirl.dev/setup.md

Copy everything below into your coding agent (Claude Code, Codex, Cursor or another) and press enter: it installs wirl, signs you in and deploys a starter app.

Help me get started with wirl (https://wirl.dev), where my team's internal tools live. Install the wirl CLI, sign me in, and deploy a starter app as fast as possible. Then tell me the app's address and what it can do.

wirl hosts the apps a coding agent builds for a team: each one gets an address, company sign-in, a database and schedules. The starter is a small team noticeboard that shows what wirl does. Everything you need is below, so you need not read any docs first. Follow the steps in order.

1. **How to work.** Show me each command you run and what it prints. If you have wirl tools connected, such as `wirl_deploy`, don't use them for this setup: use the commands below, since those tools may make the app open to my whole organisation rather than private to me. Don't open a browser to try the app. Never print, read or copy a wirl token, or the file `~/.config/wirl/credentials.json`, and never put one in a message, a file or a log. Never type, enter or approve the sign-in code yourself: I approve it in my browser. Leave who can open the app as it is. If you can't run commands here, say so, and tell me to paste this into a coding agent that can (Claude Code, Codex or Cursor), or to run the commands myself in a terminal.

2. **Check Node.** Run `node --version`. wirl needs Node 22.13 or later. If Node is missing or older, tell me, and ask before you install it; use a version manager I already have (nvm, fnm or volta) if there is one. Carry on once `node --version` shows 22.13 or later.

3. **Sign in.** Run `npx -y wirl@latest whoami`. If it prints a scope line and then my organisations, one per line, I'm signed in already: go to step 4. If it says "Not signed in.", sign me in: `npx -y wirl@latest login` prints a link and a code, waits while I approve them in my browser, and then saves the sign-in on this computer. Run it where you can read its first lines while it waits (in the background, if your shell tool returns only when a command ends). Show me the link and the code as soon as they appear, then wait until it prints "Signed in". The code expires after 10 minutes: if it does, or the sign-in fails, run `npx -y wirl@latest login` again and show me the new link and code. Once I'm signed in, if `npx -y wirl@latest whoami` lists more than one organisation, ask me which, and pass `--org <slug>` to the `apps`, `pull`, `init` and `deploy` commands below.

4. **Make the starter.** Work in the folder you were started in. If that folder is already a project (it has a `package.json` or `.git`), ask me which folder to use before you make anything. Never write outside the folder you were started in unless you say so and I say yes. Then look for a starter an earlier run made:
   - If a folder here whose name ends in `-starter` has a `wirl.json`, an earlier run made it: don't make a new app; deploy that folder in step 5.
   - Otherwise run `npx -y wirl@latest apps`. If it lists an app named `starter`, or one whose name ends in `-starter` and begins with my name (the part of my email before the @, which `npx -y wirl@latest whoami --json` shows), I have a starter already: don't make a new app. Show me its address, and ask me where its folder is, or whether to fetch its files into a new folder here with `npx -y wirl@latest pull <app>` (`<app>` is its name); then deploy that folder in step 5.
   - If neither, run `npx -y wirl@latest init --starter`. It makes a folder named after me, such as `dana-starter`, with the starter in it, and the app takes the folder's name. Its last line says how to deploy it.

5. **Deploy it.** In the starter's folder, run `npx -y wirl@latest deploy`. It takes about a minute, and prints the app's address and who can open it: a line beginning "Private". If it refuses, show me its sentence. If the sentence says to wait or to try again, do that, once; otherwise ask me what to do.

6. **Set up for later.** The starter's folder already has wirl's skill file, `.claude/skills/wirl/SKILL.md`, which tells a coding agent working there how to use wirl. For another project, `npx -y wirl@latest skill` in its folder writes the same file, and `npx -y wirl@latest skill --print` prints it for an agent that keeps its instructions elsewhere. Tell me I can run any of these myself as `npx wirl deploy` and so on, or install the command once with `npm install -g wirl` and then run plain `wirl deploy`.

7. **Tell me what you did**, in a few lines:
   - the app's address, as the deploy printed it, and what the app does, in a sentence;
   - that it is private, and who can open it, in the words of the deploy's "Private" line; if the deploy said anything else about who can open it, tell me exactly what it said;
   - the full path of the starter's folder;
   - two or three things I could ask you for next, from "Changes to ask your agent for" in the starter's `README.md`.

# Quickstart

Source: https://docs.wirl.dev/quickstart.md

Connect an agent, or sign in yourself, then build, run, deploy and share.

Before building, a coding agent should fetch [llms-full.txt](https://docs.wirl.dev/llms-full.txt): every page of this site as one Markdown file, so it need not read the pages one by one.

The fastest start is the [setup prompt](https://docs.wirl.dev/setup.md): copy it into your coding agent, and it installs wirl, signs you in and deploys a starter app.

## Connect an agent

If your agent speaks MCP over HTTP, run this once, no install and nothing to download:

    claude mcp add --transport http wirl https://app.wirl.dev/mcp

`claude mcp add` only saves the server's configuration. Then sign in: run `/mcp` inside Claude Code and follow the steps in the browser, or run `claude mcp login wirl` in a terminal.

If your agent only supports local MCP servers, run this instead. `npx` fetches wirl's MCP server from npm the first time, so there is nothing to install beyond Node 20 or later:

    claude mcp add wirl -- npx -y @wirl/mcp

The local server signs in the first time a wirl tool needs it: the agent calls `wirl_login`, which returns a link and a code, and the agent shows both to the person. The person approves the code in the browser, and the agent's next wirl tool call works. A machine already signed in with `npx wirl login` skips this.

With no agent at all, sign in from the terminal. `npx` fetches the CLI from npm the first time, so there is nothing to install beyond Node 22.13 or later (in a terminal, npm first asks "Ok to proceed? (y)": answer y):

    npx wirl login

It prints a link and a short code. Approve the code in the browser and the terminal is signed in: there is nothing to copy back. From here on this site writes a command as `wirl <command>`, for short: run it as `npx wirl <command>`, or run `npm install -g wirl` once and drop the `npx`. If your npm registry does not carry wirl, or to run exactly the build this control plane serves, install from the control plane instead: `npm install -g https://app.wirl.dev/cli/wirl.tgz` for the CLI (then run plain `wirl`), and `npx -y https://app.wirl.dev/cli/wirl-mcp.tgz` for the MCP server. [Installing the CLI](https://docs.wirl.dev/reference.md#installing-the-cli) says how the CLI and the control plane are versioned.

## Build something

Scaffold a server-backed app:

    wirl init my-tool --server

This writes a `wirl.json`, a working `server.mjs`, and `migrations/0001_init.sql`, which makes the table it keeps its notes in. Run it locally:

    cd my-tool
    wirl dev

`wirl dev` runs the module under Node, signed in as a stub user, after applying `migrations/0001_init.sql` to the local database, as a deploy applies it to the live one. The local database enforces D1's limits, and a call to a host the app has not declared is refused, as it is deployed. Node's built-ins and the npm packages you have installed work in both places: see [The contract](https://docs.wirl.dev/contract.md).

## Deploy it

    wirl deploy

This builds, scans and deploys the directory, creating the app if it is new, and prints its URL. A new app starts visible to everyone in the org when the org has a verified company email domain. In any other org, including a personal one or one made with `wirl orgs create`, it starts private: only you, the organisation's owners and admins, and the people you share it with can open it.

## Share it

    wirl share dana@acme.test

That person can now open the app. Add `--role editor` if they should be able to deploy it too.

## What you see

Anyone who signs in at `app.wirl.dev` sees a hub listing the apps they can open. Each deployed app also gets its own address on `wirl.run` — for example `https://acme--churn-dashboard.wirl.run` — which is the link you actually share.

Need it to run itself on a timer? See [Schedules](https://docs.wirl.dev/schedules.md). Need it to wait for a person, try again tomorrow or keep working for an hour? See [Flows](https://docs.wirl.dev/flows.md). Need Slack, Stripe or GitHub to reach it? See [Public paths](https://docs.wirl.dev/public-paths.md).

# The contract

Source: https://docs.wirl.dev/contract.md

A wirl app is a static folder, or a static folder plus one backend module. A static folder needs nothing else: point `wirl deploy` at it and it is served as it is.

A server-side app adds one JavaScript or TypeScript module exporting `fetch(request, env)`, named by `server` in `wirl.json`. The module can return its own pages, or a built front end can sit beside it (next section) with the module as its API.

## A front end beside the module

Name the folder of built files in `wirl.json`:

    { "app": "board", "server": "server/index.js", "static": "dist" }

- A file in that folder is served at its own path under the root: `dist/assets/app.js` is `/assets/app.js`, and `/` is `dist/index.html`. A file wins over the module for its exact path.
- A path names one file exactly, with one rule added: a path ending in `/` serves the `index.html` in that folder (`/guide/` is `dist/guide/index.html`). Nothing else is tried, so `/about` never finds `about.html`, nor `about/index.html`, and there is no `404.html` convention. In an app that is only a static folder the same mapping applies, and a path that names no file gets the folder's top `index.html` with a `200`, unless the file it names ends in a dot and one to eight letters or digits (`/logo.png`, or `/guide/`, which names `guide/index.html`): that gets a plain `404`.
- The module answers everything else: every path that is no file, and every request that is not a `GET` or a `HEAD`, even at a file's path. So `/api/...` is the module's with no configuration.
- Add `"spa": true` when the front end has a client-side router. A page navigation that matches no file (the browser asks for `text/html`, and the path has no extension) then gets `index.html` and never reaches the module. A `fetch()` from the page, and a missing asset, still reach the module.
- Nothing outside that folder is ever served: not the module's source, not `wirl.json`. The module cannot sit inside the folder, and the folder cannot be the whole deploy; both are refused.
- A schedule's path, a flow's steps and a [public path](https://docs.wirl.dev/public-paths.md) always go to the module, whatever files exist.

wirl runs no build step, so build first and deploy the project folder: the module, the built folder, and whatever else is there are uploaded, and only the built folder is served. A deploy that names a folder with nothing in it is refused, naming the folder, and so is `wirl dev`. `wirl dev` serves the folder by the same rules.

A Next.js app is deployed as a static export, since `next start` cannot run: set `output: "export"` and `trailingSlash: true` in `next.config`, so that `next build` writes `out/` with each page as a folder's `index.html`, which the rule above serves at the page's path ending in `/`. Name `out` under `"static"`, and move the API routes into the module, on `env.DB`; with no API routes, deploy `out` alone (`"dir": "out"`).

## What does not run

No language other than JavaScript or TypeScript, and no long-running process: wirl calls your module's `fetch` for each request, it does not start a program or open a port.

npm packages work in the server module. Install them in the project as usual (`npm install pdf-lib`), import them, and `wirl deploy` bundles the module on your machine, where the packages are, and sends the result up with the source. `node_modules` itself is never uploaded and nothing is installed on the server. The local MCP server's deploy tool does the same.

What will not work is a package that needs something the platform does not have: native code (`sharp`, `better-sqlite3`, `bcrypt`; use a pure-JavaScript equivalent), a real disk, or a long-lived process. A missing package is refused on your machine before anything is sent, naming it. The bundled module may be at most 10 MB.

Node's built-ins do work, deployed and under `wirl dev` alike: `node:crypto`, `node:buffer`, `node:path`, `node:events`, `node:util`, `node:stream` and the rest of what Cloudflare's Node compatibility provides, with `Buffer` and `process` as globals. Import them with or without the `node:` prefix. The one to be careful of is `node:fs`: a write succeeds, and the file is gone by the next request, because there is no disk. Keep data in `env.DB`.

wirl runs no build step. Deploy built output, not source a browser cannot run. For a static-only app, point `wirl.json`'s `dir` at `dist`, not `src`. With a server module and a `static` folder, leave `dir` at the project root, because the module has to be uploaded too. The CLI reads `dir`; the MCP `wirl_deploy` tool is given its directory as an argument. A Vite or Parcel project (a `vite.config` file, or `vite`, a `@vitejs/` package or `parcel` among the `dependencies` or `devDependencies` of a `package.json`, either one at the top of the deploy or directly in the folder that holds the page) whose `index.html` still loads source is refused on your machine before anything is sent, when wirl would serve that page as it is: with no server module, or with a `static` folder that holds it. Source here is a module script whose `src` ends in `.ts`, `.tsx`, `.jsx`, `.vue` or `.svelte`, or starts with `src/` or `/src/`. The refusal says to run the build and deploy the folder it writes (`wirl deploy dist`) or, beside a server module, to name that folder under `static`. With a server module and no `static` folder, wirl serves none of the project's files, so the deploy goes ahead and says so in a warning, with what to do.

There is no filesystem beyond `env.DB`. `wirl preview` puts a build up at its own address, behind the same sign-in as the app, before you deploy it live; see [Previews](https://docs.wirl.dev/previews.md). `wirl dev` runs it here instead, under Node with your local packages, which can pass where the deploy then fails.

## Data: env.DB

`env.DB` is Cloudflare D1: a SQLite database that belongs to this one app, in D1's prepared-statement shape. It is created on the app's first deploy and stays with the app, not with any one deployment — a redeploy and a `wirl rollback` both see the same rows, because rolling back code never rolls back data.

Make its tables with numbered SQL files in a `migrations/` folder, which wirl applies to the database before the code that reads them runs (see [Tables: migrations/](https://docs.wirl.dev/contract.md#tables-migrations), below). A first file, `migrations/0001_init.sql`, one statement a line:

    create table issues (id integer primary key, title text not null, state text not null, updated_at text not null)
    create table stars (user_id text not null, issue_id integer not null, primary key (user_id, issue_id))
    create index issues_state on issues (state)

Two limits are worth knowing before you hit them:

- A prepared statement takes at most 100 bound values with `.bind()`. Past that it fails when it runs.
- `exec()` runs one statement per line, exactly as D1 does, so a statement written across several lines for readability fails on its first line:

Correct, one statement on one line:

    await env.DB.exec('create table notes (id text primary key, body text)');

Wrong, the same statement split across lines for readability:

    await env.DB.exec(`
      create table notes (
        id text primary key,
        body text
      )
    `);

A file in `migrations/` is read the same way: one statement a line.

Use `.prepare()` and `.bind()` for anything with a value in it.

What each call gives back, the same here as under `wirl dev`:

- `.all()` and `.run()`: `{ success, results, meta }`. `results` is an array of row objects (empty for a write). `meta.last_row_id` is the id an insert just made, and `meta.changes` is how many rows a write touched.
- `.first()`: the first row as an object, or `null`. `.first("column")`: that one value, or `null`.
- `.raw()`: rows as arrays of values, not objects.
- `env.DB.batch([...])`: an array of those results, one per statement, in order.
- `env.DB.exec(sql)`: `{ count, duration }`, where `count` is how many statements ran.

## Tables: migrations/

Keep an app's tables in numbered SQL files in `migrations/`. wirl applies the files a database has not recorded yet, in number order: to a preview's own database when the preview is put up, to the live database before a new version goes live, and to `.wirl/local.sqlite` when `wirl dev` starts. Anyone who may deploy the app may change its tables this way, and a file that can lose data also waits for a person before it changes the live database (below).

The folder sits at the top of what is deployed. In the layout `wirl init --server` writes, that is beside `wirl.json`. When you deploy build output (`wirl deploy dist`), your build must copy `migrations/` to the top of that folder with its files as they are, byte for byte: `wirl deploy` and `wirl preview` refuse an upload whose copy differs from the `migrations/` beside `wirl.json`, which is the one `wirl dev` applies. A `.wirlignore` that keeps a migration file out is refused, since `migrations/` goes up whole, and so is a `migrations/` that is a symbolic link, as wirl reads and uploads no links. So are files in a `Migrations/` folder (or any other spelling), and files named like migrations in a `migrations/` beside a server module in a subfolder, each with a sentence of its own.

A file's name is four digits, an underscore, and 1 to 60 lowercase letters, digits or underscores, ending `.sql`: `0001_init.sql`, `0002_add_status.sql`. Each number is used once; gaps are fine. A file directly in `migrations/` whose name ends `.sql` in any case, or starts with four digits, and is not such a name, is refused by name, so a mistyped one is never skipped. Other files there, such as a `README.md`, and subfolders are left alone. A file must be UTF-8 text holding at least one statement, with no NUL character and no carriage return that does not end a line. See [Limits](https://docs.wirl.dev/limits.md#migrations) for how many files and bytes the folder may hold.

Each statement is one line, as `exec()` takes it: a statement written across lines, or two statements on one line, is refused, naming the line. A `;` at the end is optional. Blank lines, lines that are only a comment, a comment after a statement, indentation and line endings are not part of what wirl compares, so they can change after a file has applied without counting as an edit.

wirl records each file it applies, with a hash of its statements, in a `_wirl_migrations` table inside that database, and for a hosted database checks that record against its own records of it before applying anything. A file that has applied is never edited and never removed: a deploy whose copy of an applied file differs is refused, naming the next number to make the change in instead; one that lacks an applied file is refused until it is put back; and a new file numbered below one already applied is refused. Leave `_wirl_migrations` to wirl: a migration may not name it, and a change the app's own code makes to it can stop wirl applying migrations to that database until someone looks, or make a file run a second time.

On a deploy the files apply after the upload and its checks, and before the new version goes live; the version already live, if any, keeps serving meanwhile. A file that fails fails the deploy: nothing in that file applies, files before it stay applied and are named, and the version that was live, if any, keeps serving. So each migration must be safe for the code already live: add tables and columns, and never rename or drop what that code reads. To rename or drop something live code reads, take two deploys: first one whose code no longer reads it (with any table or column that replaces it added in its own migration), then one whose migration renames or drops it.

A preview's database is built the same way, from every file in `migrations/` the first time that name is put up, with no rows but the ones those files insert, and keeps what it has applied when the same name is put up again. A file edited after it applied there is refused, saying to put it back as it was or to start over: delete the preview (`wirl preview --delete <name>`) and put it up again. Under `wirl dev` the files apply only when it starts: a change to `migrations/` is said on stderr and restarts nothing, so a new file needs `wirl dev` run again. A local database it refuses (an applied file edited, or tables that no migration made) is one to move aside, since it holds only what you made locally.

One apply at a time touches a database: a deploy that finds another applying to the same database is turned away with `409`, and a `Retry-After` saying when that apply's hold ends at the latest. At most 2 of an org's databases are applied to at once, at most 2 applies run for one person at once, whatever the org, and at most 16 on one control plane; a deploy over any of these is turned away with `409` and a `Retry-After` of a minute. One app applies to one of its databases at a time, its live one and its previews' together: a deploy to another of its databases meanwhile is turned away the same way. A deploy that brings no file its database has not applied does not wait for the org's, the app's or the person's limit: it reads that database's record instead and goes on with nothing applied, unless another apply holds that database before it is done reading, an earlier apply's answer there is still to be settled, or the record cannot be read then. A deploy turned away applies nothing, and an adopt is turned away the same way.

D1 gives each request 30 seconds. On a database already on migrations wirl sends one file a request, so one file's statements share those 30 seconds; on a new database it sends as many files together as fit in 500 statements and 4 MB, never splitting a file. A request that runs longer, as one statement touching hundreds of thousands of rows can, is cut off by Cloudflare with nothing of it applied, and the deploy fails. wirl sends no more files once it has been applying for 2 minutes, and says how to apply the rest.

`PRAGMA defer_foreign_keys = true` (or `on`, or `1`) is the one pragma a migration may use: D1 enforces foreign keys, and this holds their checks until the transaction ends, so a file can rebuild a table that others point at.

A rollback never undoes a migration: `wirl rollback` changes which version serves, never the database, so the version it returns to meets the tables as the later files left them. It says so, naming the files the database keeps that the version it returns to does not have, and which of them needed the `"destructive"` list: a rollback cannot bring back what they dropped, renamed, replaced, emptied or rewrote. Before it applies anything to the live database, wirl takes a restore point (a Time Travel bookmark of the database) and keeps it with its record of the deploy. If it cannot take one, a file that can lose data does not go, and the deploy says to try again in a minute; a file that only adds goes without one. Restoring one puts the whole database back as it was then, so it is a step wirl's operator takes, with the app owner's consent.

### Files that can lose data

A statement that can lose data is refused unless `wirl.json` names its file, so that one written by accident never runs:

    { "app": "notes", "server": "server.mjs", "migrations": { "destructive": ["0007_drop_legacy.sql"] } }

- `drop table`: drops a table.
- `alter table ... drop`: drops a column.
- `alter table ... rename`: renames a table or a column, which the code already live still reads by its old name.
- `replace into`, `insert or replace`, `update or replace`, and a constraint with `on conflict replace`: each replaces rows, deleting the ones it replaces.
- An `insert ... on conflict ... do update`: overwrites the rows it conflicts with. An `insert ... on conflict ... do nothing` keeps them, and needs no name.
- `delete` with no `where`: deletes every row of a table.
- `update` with no `where`: changes every row of a table. A backfill is the exception: an `update` that sets only columns an `alter table ... add` earlier in the same file made, with nothing after its `set` list, loses nothing and needs no name. `update or replace` is never one.
- An `insert` whose column list names its own table, which is how a full-text table takes a command, such as `'delete-all'`, which empties its index.

`where 1` counts as a `where`: the rule is there to catch a forgotten one. The list is an accident guard, not a permission: anyone who can deploy the app can add a file to it, and the app's own code can drop a table at run time anyway. A name in it that is not a file in `migrations/` is refused. A rollback never brings the data back.

On the live database, once it is on migrations (it has applied or adopted a file), a file holding such a statement goes alone: a deploy where it waits beside another new file is refused before anything applies, naming both. Deploy the others first (that file has not applied, so moving it aside is allowed), then deploy it on its own. A preview's database, the local one, and a live database that has applied nothing yet take it with the rest.

### A person confirms each one

A deploy that would apply a destructive file (one wirl.json's `"destructive"` list must name) to the live database waits for a person, whatever signed it in: a person's own sign-in, the local MCP tools, an agent's shell, CI or a deploy token. A first deploy of an app that already carries such a file asks too: wirl cannot tell a new database from an old one without asking it, and it asks a person before it asks the database anything. Previews and `wirl dev` never ask.

A person comes in one of two ways:

- **The approve page.** The refused deploy (`409`, `migration_needs_approval`) names a page for each file it waits on. Someone who may deploy the app opens it, reads the file's statements and the lines that can lose data, and approves that exact text; then deploy again. Someone not signed in who opens the link signs in and lands on the page. A token can read the page, but approving needs a person signed in at a browser. An app keeps at most 5 files waiting; past that, the refusal says the rest get their pages once those are approved. For a program, the refusal's `waiting` lists each file with its sha256 and, once it has one, its page.
- **A terminal.** Run at a terminal, signed in as yourself (a full-scope sign-in, not a deploy token: tick full access on the approval page when you sign in) and without `--json`, `wirl deploy` shows the file's destructive lines and where to read the whole file, and asks the person to type its name; that one deploy then goes on. Anything else typed, Ctrl-C or Ctrl-D stops it, and wirl sends nothing more. No flag skips the person, `--yes` included. A deploy token, a pipe, `--json` and the MCP tools always get the page.

An approval holds for one apply of that exact text (a change to any statement needs another; comments and blank lines do not), and only in the org the app was in when it was given. The first live apply that sends the file uses it up, whether the file then applies or not: if that apply fails, or its answer is lost, a person approves again. Once a deploy sends the file, it no longer waits on the page, and while the live database holds it applied, the page says so and approves nothing. When wirl's operator restores a database and clears its rows, any approval of the files the restore undid is taken back, so the next deploy that would apply one asks a person again.

This is a guard against accidents, not a security boundary. It stops an agent's or a CI job's deploy from dropping, renaming or emptying live data by mistake: a deploy token never gets past it but through a person's approval on the page. It does not stop an agent, or any program, that holds a person's own full-scope sign-in and means to get through. Such a program can fake a terminal, or send the confirmation itself, since that is the file's name and its sha256, which anyone with the file can work out. Nor does it stop the app's own code, which can delete or rewrite rows at run time. The restore point wirl takes before a live apply is the way back, through wirl's operator.

### What a migration cannot hold

Each of these is refused, with a sentence naming the line:

- A statement that starts `begin`, `commit`, `end`, `rollback`, `savepoint` or `release`, and an `or rollback` or `on conflict rollback` clause: wirl runs each file as one transaction already.
- `attach` and `detach`: a migration works on this app's own database only.
- Every `pragma` statement but `pragma defer_foreign_keys`, above.
- `vacuum`, `reindex`, `analyze` and `explain`, and `pragma_optimize` wherever it appears, which runs `analyze`.
- `create temp` (or `temporary`): such a table, view or index would be gone when the request that made it ends.
- `create trigger`: Cloudflare's D1 HTTP API, which wirl applies migrations through, cannot create one, so do that work in the app's code.
- `select`, and any statement with `returning`: a migration has no use for rows, and they would come back to wirl.
- A parameter (`?`, `?1`, `:name`, `@name`, `$name`, `#name`): nothing is bound, so write the value itself.
- `load_extension`.
- Any name starting `sqlite_`, `_wirl_` or `_cf_`, and any quoted text that mentions one: those are SQLite's, wirl's and D1's own.
- A statement of any other kind: wirl runs only what it can place.

### Apps that make their tables in code

Making tables in the app's own code, with `env.DB.exec(...)` when the module starts, keeps working: a deploy with no migration file applies nothing and makes no `_wirl_migrations` table. Such an app moves onto migrations once, before its first migration file, because a live database with tables that no migration made refuses a deploy with files in `migrations/`, saying to run `wirl migrations adopt`.

`wirl migrations adopt` reads the live database's shape (its tables, views and indexes, never a row), writes it as `migrations/0001_init.sql` beside `wirl.json`, one statement a line, and records that file as applied on the live database, and in `.wirl/local.sqlite` too when that has the same shape and no record of other files, so `wirl dev` keeps your local rows. It needs `migrations/` to hold no `.sql` file yet. Then take the `create table` lines out of the code, and put the next change in `migrations/0002_<what>.sql`. On wirl the adopted file runs only on a new database: a new preview's, or a new local one. Adopting again replaces the record of an earlier adopt, and is refused once a later file has applied.

Some shapes cannot be written as a migration, and adopt refuses them by name, recording nothing, among them a trigger, a statement a deploy would take only with its file named under `"destructive"` (such as a constraint with `on conflict replace`), a line break inside quotes, and anything that looks like a credential. Change or remove that object in the app's code first, then run `wirl migrations adopt` again. A schema of more than 500 tables, views, indexes and triggers, or more than 512 KiB of names and SQL, cannot be adopted: keep making its tables in code, and keep `migrations/` free of `.sql` files.

## Who is asking

Six headers arrive on every request, set by wirl after it verified the visitor: `x-wirl-user-id`, `x-wirl-user-email`, `x-wirl-user-name`, `x-wirl-role`, `x-wirl-app-id`, and `x-wirl-org`. The browser cannot forge them. The name is percent-encoded, because a header cannot carry anything outside Latin-1 and a name can be anything: read it with `decodeURIComponent(request.headers.get("x-wirl-user-name") || "")`. An email address in plain ASCII, as nearly all are, arrives as it is; one that is not arrives percent-encoded like the name, so with no `@` in it until it is decoded. The first four (`x-wirl-user-id`, `x-wirl-user-email`, `x-wirl-user-name`, `x-wirl-role`) are empty strings for an anonymous visitor to a public app, a schedule run, a flow step, or a public path. `x-wirl-app-id` and `x-wirl-org` are set on every request regardless, so test `x-wirl-user-id` for whether anyone is signed in at all.

`x-wirl-role` is the visitor's role on this app: `owner` for the app's owner while they are a member of its organisation, and for an owner or admin of the organisation; otherwise `editor` or `viewer` for someone the app is shared with in that role; otherwise `viewer` for any other member of the organisation when the app is visible to the whole org. An owner the organisation has removed is not `owner`: they get only what a share gives them. It is empty for anyone who holds none of those: an anonymous visitor, a schedule run, a flow step, a public path, and, on a public app, someone signed in with no role on it, a plain member of its organisation included. Deployed, it is never anything else, so there is no `admin`; under `wirl dev` it is whatever `--role` says, `owner` unless told, or what a request sends itself in `x-wirl-role`.

On a preview host, every request the module gets carries one more header, `x-wirl-preview`, holding the preview's name — not one of the six, since it names the build the request reached, not the visitor — and wirl marks the preview's HTML pages (by the badge's rules: a 200, and never a page the app gave its own `Content-Security-Policy`) saying which preview it is and when it goes (see [Previews](https://docs.wirl.dev/previews.md#it-says-it-is-a-preview)). An `x-wirl-preview` header that arrived with a request is removed with the other `x-wirl-*` headers; `wirl dev` sets none of it.

The visitor's IP address arrives as `cf-connecting-ip`, on signed-in requests and public paths alike. Cloudflare sets it and refuses any request that tries to send one, so it cannot be forged. It is what to key a rate limit on for a [public path](https://docs.wirl.dev/public-paths.md).

A rule that actually matters — "you only see your own notes" — has to be a line of SQL against these headers, because a check written into the page is only a suggestion to whoever is holding the browser:

    export default {
      async fetch(request, env) {
        const userId = request.headers.get("x-wirl-user-id") || "";
        const { results } = await env.DB
          .prepare("select id, body from notes where user_id = ?")
          .bind(userId)
          .all();
        return new Response(JSON.stringify(results), {
          headers: { "content-type": "application/json" },
        });
      },
    };

The same prepared-statement shape gives you `.run()` for an insert or an update, bound the same way, and `env.DB.batch([...])` takes an array of bound statements and runs them as one transaction: the fast way to write many rows. Each statement in a batch still takes at most 100 bound values. The SQL is SQLite's, so `insert ... on conflict (...) do update` works.

## Calling out

A third-party API is `fetch("https://api.hubspot.com/...")`, once the connection is declared in `wirl.json`. wirl intercepts the call on its way out, attaches the key, and forwards it — the key never arrives in the app. [Connections](https://docs.wirl.dev/connections.md) says what each kind of connection adds to the request.

One request, or one schedule run, may make up to 10,000 outgoing calls: that is Cloudflare's default for a Worker's subrequests, which wirl does not change for an app's Worker. Six outgoing calls are open at a time, so a larger `Promise.all` is not refused, it queues. A run that fetched 200 URLs one by one has been measured at about ten seconds.

Database queries are bounded in practice by time. Cloudflare's D1 limits page lists 1,000 queries per Worker invocation on paid plans; measured on wirl on 2026-10-07, one request ran 2,000 `env.DB` queries in a row and every one succeeded, at about 35 ms each (900 in 31.8 seconds, 1,001 in 35.1 seconds, 2,000 in 71.4 seconds), and a run of 5,000 was still going when the caller stopped waiting. Each query is a round trip, so put many statements in one `env.DB.batch([...])` rather than looping single queries.

## Streaming a response

A response goes to the visitor as your module sends it. Return a `ReadableStream` as the body and each piece arrives when you enqueue it: server-sent events, a chat answer as it is written, a large download. The same happens under `wirl dev`.

    export default {
      async fetch(request) {
        const encoder = new TextEncoder();
        let n = 0;
        const body = new ReadableStream({
          async pull(controller) {
            if (n === 5) return controller.close();
            if (n > 0) await new Promise((done) => setTimeout(done, 1000));
            n += 1;
            controller.enqueue(encoder.encode(`data: ${JSON.stringify({ n })}\n\n`));
          },
        });
        return new Response(body, {
          headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
        });
      },
    };

In a browser, `new EventSource("/events")` reads it. To look at a stream in a preview, `wirl fetch /events --preview <name>` shows what arrived in the first 10 seconds, or in as many as `--wait <seconds>` says, up to 300 (5 minutes), each line with the time it arrived, and says whether it was still going. There are no WebSockets, and a request's body is read whole before your module is called. The responses still read whole are an HTML page that carries the wirl badge, and a preview's HTML page that carries its preview marker; see [Limits](https://docs.wirl.dev/limits.md) for that and the other details.

A model's reply can stream through your module the same way. Ask the vendor for a stream, and pass its body straight on: a `2xx` answer sent as `text/event-stream` or `application/x-ndjson` reaches your module as the vendor sends it, not when the vendor has finished, and returning that body hands each piece to the visitor as it comes. Here with Anthropic, through a connection for the host `api.anthropic.com` of kind `http_header`, whose key goes in the `x-api-key` header (see [Connections](https://docs.wirl.dev/connections.md)):

    const MODEL = "<a model name from the vendor's documentation>";

    export default {
      async fetch(request) {
        const { messages } = await request.json();
        const answer = await fetch("https://api.anthropic.com/v1/messages", {
          method: "POST",
          headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
          body: JSON.stringify({ model: MODEL, max_tokens: 1024, messages, stream: true }),
        });
        return new Response(answer.body, {
          status: answer.status,
          headers: {
            "content-type": answer.headers.get("content-type") ?? "application/json",
            "cache-control": "no-cache",
          },
        });
      },
    };

For `MODEL`, take a current name from Anthropic's [models overview](https://platform.claude.com/docs/en/about-claude/models/overview), which lists each model's name for the API; this page names none, so that it does not go stale.

`stream: true` is what asks this vendor for a stream, and most model APIs spell it the same way; Gemini's is its `:streamGenerateContent` method with `alt=sse` on the URL (`alt=sse` on `:generateContent` is not a stream). An answer that is not an event stream, including the vendor's error, is read whole before your module gets it, so the example passes that on too, all at once. To reshape the events rather than pass them on, read the body yourself, and split what you have read on line ends before parsing it: an event can arrive split across two reads. A streamed answer has 60 seconds to start, then 13 minutes for the rest, is cut after about two minutes with nothing arriving (4 minutes under `wirl dev`), and an app holds at most 20 at once. A cut can look like an ordinary end, so look for the vendor's own last event (Anthropic's stream ends with `event: message_stop`) before treating the answer as complete. When wirl itself cuts an event stream it writes a last `event: error` first, which Anthropic's and OpenAI's SDKs throw on, but that does not replace the check. [Limits](https://docs.wirl.dev/limits.md) has the rest.

## Live updates between people

There are no WebSockets, and nothing but `env.DB` is shared between requests: another person's request may run in another copy of your module, so a variable is no way to tell it something, and one request cannot wake another. A page that shows what other people change, such as a board two people edit at once, uses a server-sent event stream that watches `env.DB`: every write also bumps a change counter, the `/events` stream reads the counter once a second and sends an event when it moves, and the page fetches what it shows again on each event.

The cards and the counter's one row, in `migrations/0001_init.sql`:

    create table cards (id integer primary key, title text not null)
    create table changes (id integer primary key check (id = 1), n integer not null)
    insert into changes (id, n) values (1, 0)

The module:

    const encoder = new TextEncoder();
    const sleep = (ms) => new Promise((done) => setTimeout(done, ms));

    // The write and the counter in one batch, so the event never comes before the change.
    async function addCard(env, title) {
      await env.DB.batch([
        env.DB.prepare("insert into cards (title) values (?)").bind(title),
        env.DB.prepare("update changes set n = n + 1 where id = 1"),
      ]);
    }

    // An event at once, then one each time the counter moves; closed after 120 seconds.
    function changes(env) {
      const opened = Date.now();
      let seen = null;
      let stopped = false;
      return new ReadableStream({
        async pull(controller) {
          while (!stopped) {
            if (Date.now() - opened >= 120_000) return controller.close();
            const n = await env.DB.prepare("select n from changes where id = 1").first("n");
            if (n !== seen) {
              seen = n;
              return controller.enqueue(encoder.encode(`data: ${n}\n\n`));
            }
            await sleep(1000);
          }
        },
        cancel() {
          stopped = true;
        },
      });
    }

    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
        if (url.pathname === "/events") {
          return new Response(changes(env), {
            headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
          });
        }
        if (url.pathname === "/cards" && request.method === "POST") {
          const { title } = await request.json();
          await addCard(env, String(title));
          return new Response(null, { status: 204 });
        }
        if (url.pathname === "/cards") {
          const { results } = await env.DB.prepare("select id, title from cards order by id").all();
          return Response.json(results);
        }
        return new Response("Not found", { status: 404 });
      },
    };

And in the page:

    async function showCards() {
      const cards = await (await fetch("/cards")).json();
      document.querySelector("#cards").textContent = cards.map((card) => card.title).join("\n");
    }
    new EventSource("/events").onmessage = showCards;

- The stream closes itself after 120 seconds, and the browser's `EventSource` reconnects by itself a few seconds later. Each read of the counter is a database query, a round trip of about 35 ms (measured on 2026-10-07, see [Calling out](https://docs.wirl.dev/contract.md#calling-out)). One request has run 2,000 queries, and how many more one may run has not been measured, so a stream that read the counter for ever could still meet a ceiling; 120 seconds is about 120 queries. wirl may end the stream sooner, once the visit it opened on is 8 minutes old ([Limits](https://docs.wirl.dev/limits.md)), and the browser reconnects the same way.
- The first event on each connection is the counter as it stands, so the page fetches again after every reconnect, and a change made while it was away is not missed.
- Each `pull` waits, on its own timer, until it has something to send: a `pull` that returns having sent nothing is not called again, and the stream hangs there, open and silent. With no sign-in behind the visit, as on a public app, the page then stops updating for good; otherwise it waits until wirl ends the stream at the visit's 8 minutes above, and the browser reconnects.
- The 20 streamed answers an app may have open at once are answers from vendors, through a connection; the streams your module sends its own visitors are not counted, however many pages are open.

## Schedules

wirl fires a schedule as a `POST` to `/__wirl/schedule/<name>`, and wirl signs it, so your app does not need to check anything to know it is legitimate. A run gets 5 minutes unless that schedule's own `timeout` says otherwise, and 15 minutes at most. See [Schedules](https://docs.wirl.dev/schedules.md) for what the request carries and how retries and overlap work.

## Flows

Work that outlives one request (waiting for a person, trying again tomorrow, a long loop) is a flow. Declare it under `flows` in `wirl.json`, and wirl posts each of its steps to `/__wirl/flow/<name>`, one request at a time, with what the last step answered; between steps it sleeps, or waits for an event, for as long as the step asked. See [Flows](https://docs.wirl.dev/flows.md).

## Running it locally

`wirl dev` runs the module under Node, with a stub identity in the headers so the first run is already signed in; pass `--user` and `--role` to see what someone else would see. To try two people on one app, run `wirl dev` a second time in the same folder with another `--user`: the two share `.wirl/local.sqlite`, so each sees what the other writes. When the module or any file it imports changes, `wirl dev` rebuilds it and restarts it at the same address, with a line on stderr (the app's own write to a file does not count); an edit that does not build leaves the last build running and says why, and it tries again every second, and whenever you save a file in the project, until one builds, so a new file or a package installed with npm counts. `wirl.json`, `.wirl/mocks.json` and `migrations/` are read once, at start, when `migrations/` is applied to `.wirl/local.sqlite`, so a change to any of them needs `wirl dev` run again; a change to `migrations/` is said once on stderr and restarts nothing. That is the `migrations/` beside `wirl.json`, applied as [Tables: migrations/](https://docs.wirl.dev/contract.md#tables-migrations) says. `--port <n>` keeps one address across restarts, `--verbose` prints a line per request when its answer ends (method, path without its query, status and ms), and a flag `wirl dev` does not take is refused rather than ignored. A request to a schedule's path, a flow's path or a declared public path arrives the way it does deployed, with the four visitor headers empty; a public path's signature is not checked locally, so `x-wirl-verified` is never set there. Nothing runs a flow from one step to the next here: `wirl dev` prints a `curl` for each flow's first step, and a flow runs for real in a [preview](https://docs.wirl.dev/flows.md#trying-a-flow). `env` holds `DB` and nothing else, the same as production, and the local database enforces D1's own two limits (above), so a query that trips either limit here would have tripped it deployed too. It also refuses a single bound value over 2,000,000 bytes, the size Cloudflare publishes for D1, which deployed D1 has been measured to exceed, and it hands a BLOB back as a plain array of numbers, as deployed D1 does: [Limits](https://docs.wirl.dev/limits.md) has the sizes and the shape. Keys for outgoing calls are read from `.wirl/keys.json`, which git ignores, so you can exercise a real outbound call before you deploy, and `.wirl/mocks.json` lets you try one with no key, by sending a connection's calls to a server of your own (see [Connections](https://docs.wirl.dev/connections.md)). A call to a host no connection declares is refused here as it is deployed, except the app's own call to `127.0.0.1`, `localhost` or `[::1]`, which goes there directly, as it always has. A connection that is still waiting on an admin works here and is refused deployed until it is approved. If a `.wirlignore` would keep files out of a deploy, `wirl dev` says how many, because it runs them all the same. `wirl dev` bundles the module with the same packages a deploy would. One more difference: a file written with `node:fs` is still there on the next request locally, and is gone deployed (see "What does not run", above), so the first time the app writes one, `wirl dev` says so on stderr, naming the path.

## What wirl keeps

An app's owner, while still a member of the organisation, or an organisation owner or admin, deletes it with Delete on its Settings page, with `wirl delete <name>`, or with an agent's `wirl_delete_app` tool, each with a full-scope sign-in. Its address and its previews stop answering at once, its schedules stop, and its unfinished runs are cancelled.

Delete an app and its data — bundles, its server module, its database — stays for 30 days before it is removed. Inside that window its owner, while still a member of the organisation, or an organisation owner or admin, can bring it back with `wirl apps restore <name>`, or from the Recently deleted list on the organisation's tools page. An app that was public when it was deleted comes back only from that list: public is a person's click, never a token's. It returns at the same address, exactly as wide as it was when it was deleted and no wider, with its live version, shares and connections as they were (a share revoked while it was deleted stays revoked), and its schedules resume from their next time, so none that came due while it was deleted runs late. Runs that were unfinished when it was deleted stay cancelled, and previews that have not expired open again. A name stays taken after its app is deleted, so a new app cannot have it, and a deploy under it is refused with the command that restores the app (the Recently deleted list, for one that was public), or who can; once the 30 days are over, the app cannot be restored and the name stays taken for good.

A live app keeps the bundle and server module of its newest 10 versions. Older ones are pruned: the files are gone and a `wirl rollback` can no longer return to them. The version currently live, and the one a rollback would return to, are always kept regardless of age.

Audit rows are kept for a year.

Every app and version the job removes is recorded in the org's activity, so a pruned version or a reaped app is something you can find, not something that quietly stopped being there. Two things nobody could ever open are cleared without a record: the leftover files of a preview build whose deploy failed, or that a newer build of the same preview replaced, and the server module of a deploy that failed after uploading it (that version's files are kept like any other version's).

# wirl.json and the CLI

Source: https://docs.wirl.dev/reference.md

Everything `wirl.json` can declare, and every command the `wirl` CLI has.

## Installing the CLI

Run it with `npx`, which fetches the CLI from npm the first time and needs no install beyond Node 22.13 or later (in a terminal, npm first asks "Ok to proceed? (y)": answer y):

    npx wirl login

Every command on this site is written `wirl <command>`, for short: run it as `npx wirl <command>`, or run `npm install -g wirl` once and use plain `wirl`. If your npm registry does not carry wirl, or to run exactly the build this control plane serves, install from the control plane instead: `npm install -g https://app.wirl.dev/cli/wirl.tgz` for the CLI (then run plain `wirl`), and `npx -y https://app.wirl.dev/cli/wirl-mcp.tgz` for the MCP server. See [Quickstart](https://docs.wirl.dev/quickstart.md) for connecting an agent instead, which can need no install at all.

The npm package and the control plane are versioned separately. `npx wirl` runs the latest CLI published to npm, and app.wirl.dev runs the build it was last deployed with, so either can be the newer. The tarball is built into the control plane's own image, so it is the CLI that control plane was released with. A CLI newer than the control plane says so where it can tell: `its control plane is older than this copy of wirl` (from `wirl migrations` and `wirl migrations adopt`), or `this control plane is older than sending wirl.json in the body, so it cannot take it` when a `wirl.json` is too large for a header. A CLI older than the control plane sends your whole `wirl.json` and says of a key it does not read that it `is not a key this version of wirl reads, so it is ignored`, though the control plane may read it; it checks `wirl.json` against its own rules before it uploads, so update the CLI (`npm install -g wirl@latest`, or `npx wirl@latest <command>`) when a deploy is refused on your machine for something these pages allow.

## wirl.json

`wirl.json` sits beside your project, where you run `wirl deploy`.

    {
      "app": "churn-dashboard",
      "org": "acme",
      "server": "dist/server.js",
      "description": "Accounts at risk this month, from HubSpot and Stripe",
      "accent": "sun",
      "connections": [
        "hubspot",
        { "slug": "stripe", "hosts": ["api.stripe.com"], "kind": "http_bearer" }
      ]
    }

- **`app`** — the app's slug. Combined with the org, it forms the app's address: `<org>--<app>.wirl.run`.
- **`org`** — which organisation to deploy to. Without it, `wirl` uses your only organisation, and when you belong to more than one it stops and names them.
- **`static`** — a folder of built files served beside the server module, e.g. `"dist"`. Its files win for their exact paths and the module answers everything else. Needs `server`; the module cannot be inside it. See [The contract](https://docs.wirl.dev/contract.md).
- **`spa`** — `true` when the front end has a client-side router: a page navigation that matches no file gets `static`'s `index.html`. Needs `static`.
- **`dir`** — the directory `wirl deploy` uploads when you give it none, e.g. `"dist"`. Only the CLI reads it: the MCP `wirl_deploy` tool takes its directory as an argument. Give `wirl deploy` or `wirl preview` a folder and it depends on what is in it: a folder with a `wirl.json` of its own is a project, so that `wirl.json` is the one used and its `dir` is relative to it; a folder without one is build output, so the `wirl.json` where you ran the command is used and that folder goes up.
- **`server`** — the module exporting `{ fetch }`. Without it, the bundle is served as static files.
- **`connections`** — what the app needs from the organisation. Each entry is a connection's name, or an object narrowing it:
  - `slug` — the connection's name.
  - `hosts` — hostnames the app may call through this connection. It can only narrow the connection's own allowed hosts, never widen them.
  - `kind` — the shape of the credential: `http_bearer` (the default), `http_header`, `basic`, `none` (a public host that needs no key), or `webhook_secret` (one secret, no hosts — holds the signing secret a `public_paths` entry checks incoming requests against; the outbound broker refuses to use it).
- **`description`** — shown under the app's name on its hub card. At most 160 characters.
- **`accent`** — the hub card's color: one of `blue`, `green`, `sun`, `purple`, `red`, `sky`, `orange`, `lime`, `mint`, `butter`, `blush`, `lilac`. Without one, a new app gets one of the five pastels (`mint`, `sky`, `butter`, `blush`, `lilac`), chosen from its name, so the same name is always the same color, and a redeploy that says nothing about `description` or `accent` leaves whatever was set before alone.
- **`visibility`** — `"private"` is the only value. It makes the app private when a deploy creates it, even in an organisation whose apps are otherwise open to everyone in it. It applies only when a deploy creates the app: an app that already exists keeps the visibility it has, and `wirl visibility` is how you change it. Anything but `"private"` is refused.
- **`schedules`** — cron jobs wirl fires into the app. Each entry is an object:
  - `name` — 1-40 lowercase letters, digits or dashes. Unique within the app.
  - `cron` — a standard five-field cron expression.
  - `timezone` — an IANA zone, e.g. `America/Los_Angeles`. Defaults to `UTC`.
  - `timeout` — how long a fire may run before wirl gives up on it, `"30s"` to `"15m"`. Defaults to `"5m"`.
  - `retries` — 0 to 3 more attempts after a failure. Defaults to `0`.
  - `overlap` — `"skip"` (the default) leaves a still-running fire alone; `"allow"` starts another anyway.
  - `flow`: a flow the app declares under `flows`, which the schedule then starts a run of instead of posting to its path. Such a schedule has no `timeout` of its own.

  See [Schedules](https://docs.wirl.dev/schedules.md) for what your app receives and how retries and overlap actually behave.

- **`flows`** declares work that outlives one request, which wirl posts to `/__wirl/flow/<name>` one step at a time. Each entry is an object:
  - `name`: 1 to 40 lowercase letters, digits or dashes, unique within the app. At most 20 per app.
  - `step_timeout`: how long one step may take, `"30s"` to `"15m"`. Defaults to `"5m"`.
  - `retries`: 0 to 10 more tries of a step that fails. Defaults to `5`.

  Any other key is refused, naming it. The app's own code starts its runs, answers their waits and reads them at `https://flows.wirl`, with no connection: `GET https://flows.wirl/runs/<run or key>` answers `{ status, step, waiting, output, outputTruncated, error, pausedUntil, run, resumeAt }`. See [Flows](https://docs.wirl.dev/flows.md) for what each step receives and answers, and what the read's keys hold.

- **`public_paths`** — paths the internet may call with no sign-in, checked before your app is. Each entry is an object:
  - `path` — starts with `/`, and is either exact or ends in one trailing `/*` covering everything below it. No leading wildcard, no bare `/*`, and anything under `/__wirl` is reserved for wirl.
  - `verify` — `slack`, `stripe`, `github`, or `none`.
  - `connection` — the `webhook_secret` connection holding the signing secret. Required unless `verify` is `none`, and refused if given when it is.

  At most 10 per app, service apps only. See [Public paths](https://docs.wirl.dev/public-paths.md) for what your app receives, what each provider signs, and what a `none` path costs you.

- **`migrations`** — `{ "destructive": ["0007_drop_legacy.sql"] }`: the files in `migrations/` allowed to drop or rename a table or a column, replace or overwrite rows, delete or update every row, or give a full-text table a command. Any other file that would is refused, but for a backfill, and so is a name here that is no file in `migrations/`. A deploy that would apply one to the live database still waits for a person: see [The contract](https://docs.wirl.dev/contract.md#a-person-confirms-each-one).

## The CLI

    wirl — deploy an app somewhere real, behind a sign-in

    wirl login                    Sign in from this terminal (or --token wirl_...)
    wirl init [dir] [--server]    Create wirl.json, and a working server module with --server
    wirl init [dir] --starter     Write your first app: a small team noticeboard, ready to deploy
    wirl skill [dir] [--print]    Write the skill file a coding agent reads (init does this too; --no-skill to skip)
    wirl dev [dir]                Run the app here, signed in as a stub user
    wirl deploy [dir]             Build, scan and deploy (creates the app if new)
    wirl preview [dir] [--name n] Build, scan and put up a preview: never live, its own database
    wirl preview --delete <name>  Remove a preview
    wirl preview open [--name n]  Print a link that signs in and opens a preview
    wirl previews                 List an app's previews
    wirl fetch <path> --preview <name>   Call a preview directly and print what it answers
    wirl apps [--org <slug>]      List apps you can open
    wirl apps restore <name>      Bring a deleted app back, inside its 30 days (its owner while a member, or an organisation owner or admin)
    wirl delete [app] [--yes]     Delete an app: restorable for 30 days, and its name stays taken
    wirl share <email> [--role viewer|editor]
    wirl unshare <email>          Take a person's share back (an invitation waiting for them too)
    wirl visibility [private|org] See or change who can open the app (public is a person's click on its Access page)
    wirl transfer <email>         Hand the app to a new owner (you keep editor access)
    wirl move --to <org>          Move the app to another organisation you belong to
    wirl orgs list | create <slug>   Your organisations; make one for a team
    wirl members list | add <email> --role | remove <email>   The roster (--org)
    wirl versions                 List the app's deploys, never its previews
    wirl rollback <deployment-id>
    wirl migrations [dir]         List what this app's database has applied
    wirl migrations adopt [dir]   Write migrations/0001_init.sql from the live database's shape, once
    wirl schedules                List the app's schedules with their last and next run
    wirl run <schedule> [--preview <name>]   Run one now and wait for the outcome
    wirl flows [--preview n]      List the app's flows with their unfinished runs and last run
    wirl flows start <flow> [--input json] [--key k] [--preview n] [--fast]   Start a run of a flow
    wirl runs [<flow>] [--status s] [--preview n]   List runs, newest first
    wirl runs show|send|cancel|retry <run or key> [--preview n]   Look at a run, answer a preview's run, cancel or retry one
    wirl logs [--since 1h] [--errors] [--follow] [--limit n] [--cursor n] [--preview <name>]   What the app printed, per request; --kind, --run, --ray, --search narrow it
    wirl egress [--since 1h] [--run <id>] [--limit n] [--cursor n]   What the app called through its connections, call by call
    wirl pull <app> [dir] [--force]  Fetch a deployed app's source and keep building
    wirl connections list                          Every connection in the org: who made it, and whether it holds a key
    wirl connections create <slug> --secret-file f.json [--name n] [--kind k] [--hosts a,b]
    wirl connections rotate <slug> --secret-file f.json
    wirl connections delete <slug>
    wirl connections pending                       Apps waiting on an owner or admin
    wirl connections grants                        Every app and what it uses (admins)
    wirl connections show <app>                    What an app has been granted
    wirl connections approve|revoke|request <app> <slug>
    wirl whoami

    wirl <command> --help for a command's flags and an example.

A few of these are worth a sentence:

- `wirl preview [dir]` puts a build up at its own address, never live, with its own database (built from `migrations/` the first time that name is used, with no rows but the ones those files insert, never production's), open to the same people as the app. `wirl fetch <path> --preview <name>` shows what it answers; `wirl preview open` prints a link for a browser instead. See [Previews](https://docs.wirl.dev/previews.md).
- `wirl apps restore <name>` brings a deleted app back inside the 30 days its data is kept: its owner, while still a member of the organisation, or an organisation owner or admin, may, with a full-scope sign-in as for sharing. An app that was public comes back only from the Recently deleted list on the dashboard, since only a person makes an app public. It comes back exactly as wide as it was when it was deleted, never wider, and its schedules resume from their next time. `wirl apps` lists live apps only, so a deleted one is not in it, and its name stays taken: see [the contract](https://docs.wirl.dev/contract.md#what-wirl-keeps).
- `wirl delete [app]` deletes an app: its address and its previews stop answering, its schedules stop and its unfinished runs are cancelled, and its data is kept for the 30 days in which `wirl apps restore <name>` brings it back (an app that was public comes back only from the Recently deleted list on the dashboard); its name stays taken. It asks for the app's name typed back unless given `--yes`, takes the app from `wirl.json` when none is named but never guesses it from the folder's name, and needs a full-scope sign-in: a deploy-scoped one is refused before anything is asked.
- `wirl share <email>` shares the app with that person. Role defaults to viewer, so add `--role editor` when they should be able to deploy it too. What sharing again does to a role they already have is under [Roles on an app](https://docs.wirl.dev/sharing.md#roles-on-an-app). `wirl unshare <email>` takes that person's share back, one person at a time, and withdraws an invitation still waiting for them; it says what it found, and finding nothing is not an error. Its `--json` answer has `shareRevoked`, `invitationWithdrawn` and `appDeleted`; `revoked` is always `true` (it says only that the request was carried out).
- `wirl versions` lists the app's newest 50 deploys, newest first, failed ones included. `wirl rollback <deployment-id>` makes an earlier one that went live serve again, unless the reaper has pruned it. A preview takes a version number from the same count and is never listed, so the numbers can skip, and an app previewed before its first deploy has no v1: see [Previews](https://docs.wirl.dev/previews.md). Under `wirl versions --json`, each deploy's `createdBy` is the user id of whoever deployed it if you are a member of the app's organisation or deployed it yourself, and null otherwise, as it is in the version `wirl rollback --json` answers with.
- `wirl migrations [dir]` lists what the app's live database has applied, read only: its own record of migrations (`_wirl_migrations`), read now, and wirl's ledger of what it sent there, one line a file, saying where the two differ. It needs the access a deploy needs; with `--preview <name>` it reads that preview's database instead.
- `wirl migrations adopt [dir]` is for an app whose own code made its tables, once, before its first migration file: it writes the live database's shape (its tables, views and indexes, never a row) as `migrations/0001_init.sql` beside `wirl.json`, and records it as applied there. `migrations/` must hold no `.sql` file yet. See [The contract](https://docs.wirl.dev/contract.md#apps-that-make-their-tables-in-code).
- `wirl run <schedule>` fires a declared schedule now, outside its cron, and waits for the outcome, the fast way to check one works right after deploying it. With `--preview <name>`, fires one of a preview's own declared schedules instead, and answers at once with no queue to wait on.
- `wirl flows start <flow>` starts a run of a flow and prints its id: `--input` is the run's input, `--key` the app's own name for the run, and `--fast`, for a preview's run only, makes every sleep and wait short. `wirl runs show <run or key>` prints where a run is, the newest 50 of its tries and of its events, and up to 50 earlier tries that went wrong (the run's page and `--json` have every one); `wirl runs send` answers a preview's run, `wirl runs cancel` stops a run, and `wirl runs retry` runs a failed one again. See [Flows](https://docs.wirl.dev/flows.md).
- `wirl logs` prints what the deployed app printed, one block per request, newest first, with the status and any exception. `--errors` keeps only failures, `--follow` keeps watching, `--ray <id>` finds the request behind a `cf-ray` header someone copied from a broken page, `--cursor <n>` reads the page before a "More before this" line, and `--preview <name>` reads a preview's own script instead of the live app's history. See [Logs](https://docs.wirl.dev/logs.md).
- `wirl egress` lists the calls the app made out through wirl, newest first: each one's method, host, connection, status and whether it was let through, and for a flow step's call its run, `n`, attempt and path. See [Logs](https://docs.wirl.dev/logs.md#what-the-app-called).
- `wirl connections create` reads the secret from a file, `--secret-file`, rather than a command-line argument. `wirl connections rotate` reads a replacement the same way.
- `wirl connections approve|revoke|request <app> <slug>` decides, or asks to decide, whether an app may use a connection.
- `wirl whoami` prints who you are signed in as: your organisations and your token's scope.

### Options

    --org <slug>    Organisation. Defaults to wirl.json, or your only organisation.
    --app <slug>    App. Defaults to wirl.json or the directory name.
    --no-create     Fail instead of creating an app that does not exist yet.
    --json          Machine-readable output.
    --version       Print the version of wirl you are running, as the first word: wirl --version.

## .wirlignore

`wirl deploy` already leaves out what is never the app: `.git`, `node_modules`, `.env` and every `.env.*`, editor and agent folders, and wirl's own `.wirl`. A `.wirlignore` at the root of the deployed directory keeps out more, one pattern per line, in the shape of a `.gitignore`:

    # notes and fixtures are not the app
    notes.md
    /scratch
    fixtures/
    *.log
    docs/**/*.psd

- A bare name matches at any depth. A leading `/` anchors it to the root, and a trailing `/` matches directories only.
- `*` stays inside one path segment; `**` crosses them. A pattern may hold at most four `**`; several in a row count as one.
- A line starting with `!` is refused, naming the line: a `.wirlignore` can only keep more out, never bring back something wirl excludes.
- Only the file at the deployed root is read, and it is never uploaded itself. It is not `.gitignore`, because build output is usually git-ignored and is exactly what you deploy.

The deploy says how many files it kept out. `wirl dev` runs the files as they are on disk, so it prints the same count as a warning: an ignored file the module imports works locally and fails the deploy.

# Connections, keys and tokens

Source: https://docs.wirl.dev/connections.md

## Connections

A connection is a key or token plus the hosts it may be sent to. Apps never see the secret: wirl attaches it on the way out and forwards the request, so the call reaches the vendor with the credential in place while the app itself never holds it. A vendor has 60 seconds to answer, whole, unless it streams: ask a model for a stream. [Limits](https://docs.wirl.dev/limits.md) has the rest.

Declare what an app needs in `wirl.json`:

    {
      "connections": [
        "hubspot",
        { "slug": "stripe", "hosts": ["api.stripe.com"], "kind": "http_bearer" }
      ]
    }

A `hosts` list can only narrow a connection's own allowed hosts, never widen them. `kind` says what shape the credential takes, which decides what wirl adds to the outgoing request and what the person is asked for on the keys page:

| `kind` | What wirl adds to the request | What the person types |
|---|---|---|
| `http_bearer` (the default) | `Authorization: Bearer <token>` | the token |
| `http_header` | one header, under the name they give | the header's name and its value |
| `basic` | `Authorization: Basic <username:password, base64>` | a username and a password |
| `none` | nothing: the hosts are the whole connection | nothing |
| `webhook_secret` | nothing, ever: it checks incoming webhooks and has no hosts | the signing secret |

Pick `http_header` for a vendor that wants its key in a header of its own. Anthropic is one: the header is `x-api-key`, and any other header the vendor needs (`anthropic-version`, a content type) is the app's to send as usual. wirl removes any `Authorization` header the app sets itself before adding its own. Every other header your app sets reaches the vendor as it set it, `User-Agent` included, so a vendor that asks for a descriptive one gets yours. The exceptions are the ones wirl owns or that mean something on one hop only: `Authorization`, `Proxy-Authorization`, `Cookie`, `Host`, `Content-Length`, any header starting `x-wirl-`, and `Connection`, `Keep-Alive`, `Transfer-Encoding`, `Upgrade`, `TE` and `Trailer`. wirl also drops the headers the hops in front of it add on the way in, which would tell the vendor wirl's own address. Any starting `X-Forwarded-` or `X-Railway-`, and `Forwarded`, `X-Real-IP`, `True-Client-IP` and `Via`, are dropped whoever set them. So are the ones Cloudflare adds itself: `CF-Connecting-IP`, `CF-Connecting-IPv6`, `CF-Pseudo-IPv4`, `CF-Ray`, `CF-Visitor`, `CF-Worker`, its visitor-location headers `CF-IPCountry`, `CF-IPCity`, `CF-IPContinent`, `CF-IPLatitude`, `CF-IPLongitude`, `CF-Region`, `CF-Region-Code`, `CF-Metro-Code`, `CF-Postal-Code` and `CF-Timezone`, its bot verdicts `CF-Bot-Score`, `CF-Verified-Bot`, `CF-JA3-Hash` and `CF-JA4`, its client-certificate headers (any starting `CF-Cert-`), and `Exposed-Credential-Check` and `Malicious-Uploads-Detection`. Any other `CF-` header your app sets reaches the vendor as it set it, such as AI Gateway's `cf-aig-cache-ttl` or Cloudflare Access's `CF-Access-Client-Id`, and so do Cloudflare's loop guards, `CDN-Loop` and `CF-EW-Via`, so that Cloudflare can still stop a loop when one wirl app calls another through wirl. A connection's own key header is added after all this, so it is always sent.

Two older kinds, `env` and `postgres`, are refused: nothing ever delivered them. wirl never hands a secret to an app, so instead of `env`, declare the vendor's host and `fetch` it, and wirl attaches the key on the way out. An app cannot open a database connection, so instead of `postgres`, use `env.DB`, or call the database's HTTP API through an ordinary connection.

If the connection does not exist yet, deploying creates it, keyless, and hands back a link to the app's keys page:

    wirl deploy
    # Deployed crm-sync v1
    # Needs a key: hubspot. Add it here, and the app starts working:
    #   https://app.wirl.dev/apps/acme/crm-sync/connections

The person opens that link and types the key into their own browser session; it goes straight into the vault. Until a key is set, calls on that connection are refused with a message saying the key is missing and pointing at the same link, rather than failing silently. A call to a host with no declared connection at all is refused outright.

Every deploy and preview says, for each connection `wirl.json` declares, what it can do now, on one of three lines: `Connections usable now:` names those the app is granted that have their key or need none; `Waiting on an admin:` names those it is not granted yet, which wait until whoever made the connection, or an org admin, approves them (a `deploy` token never grants one, so a deploy made with one leaves every connection the app is not yet granted waiting); and `Needs a key:` names those granted with no key yet, with the link above.

A slug the org already has is that connection, not a new one. The deploy binds the app to it and says so on a line of its own, such as `httpbin: the org's existing connection (http_bearer, holds a key), made by Ann Lee <ann@acme.test>; also granted to reply-drafter.` The maker's name and address are there only when you are a member of the app's org or made the connection yourself: anyone else, such as someone with a share on the app and no seat in the org, reads `made by someone else`, and in `--json` a `name` and an `email` of null. The maker's user id, `createdBy` in `--json`, is null for them too. The apps it names are the others granted that connection that you can open, and a key added for it is sent for each of them, unless its grant records another kind. In `--json`, `connections.resolved` says the same of each declaration.

Each deploy that goes live records on the app's grant the kind it declared for each connection, or, with no kind declared, the kind the connection had then, in the same step that puts it live, so a deploy refused before that records nothing on a grant its app already had. The hosts it declared for each connection are recorded in that same step, or, with no `hosts` declared, every host the connection allows, so neither a preview nor a deploy refused before it goes live changes the hosts on a grant its app already had. A preview shares the app's grant and records nothing on one the app already has, neither a kind nor hosts, and a rollback changes nothing recorded: the grant keeps the kind and the hosts on record, whichever version is live. A preview's own calls through a connection its `wirl.json` declares may reach the hosts declared there, or, with none declared, every host the connection allows, whatever hosts the grant has on record. A grant a deploy or a preview opens, for a connection its app had no grant of, granted, waiting or revoked, records the kind declared for it, or, with no kind declared, the kind the connection had when it was checked, and keeps it if the deploy is refused; should another deploy change that kind, or the connection be deleted, after it was checked and before the grant is opened, the deploy or preview is refused, saying so: after a change it asks to be run again, and after a delete to use another slug, since a deleted connection's name stays taken. Such a grant records no hosts: until a deploy of the app declaring the connection goes live, which records the hosts it declared, a call through it reaches no host, except a preview's own call, which reaches the hosts the preview declared. Moving an app to another organisation and back drops its grants, so a grant a preview or a deploy opens for it afterwards, even once approved, lets its live version reach no host until a deploy of the app declaring the connection goes live. wirl attaches a key only while the connection is of the kind recorded: a call on a connection of another kind is refused as `kind_mismatch`, with nothing sent. A grant with no kind on record is used as it always was: one made before wirl recorded kinds, one opened by `wirl connections request` or the request API, one made from the dashboard's add-a-connection form, and one a preview or a refused deploy opened before wirl recorded kinds on those too. It gets a kind when a deploy of its app goes live declaring the connection, or the old kind when another deploy changes the connection's kind, so approving it later sends nothing until its app declares the new one. A waiting or revoked one also gets the kind declared when a deploy of its app that would change the connection's kind asks for it again, as below. So a deploy refuses a declared kind the connection does not have: declare the connection's kind, or use another slug. The one exception changes the connection instead: when a deploy, never a preview, by its maker or an org admin declares another kind for one with no key yet, no other app is granted it, and neither kind is `webhook_secret`, the connection takes the kind declared as the deploy goes live, and the deploy says so on a `Changed:` line; while another app is granted it, the deploy is refused, naming the other apps you can open. A deploy that would make that change but is refused before it goes live leaves the grant it opened recording the kind it declared, so a call that reaches that grant is refused as `kind_mismatch`, with nothing sent, until a deploy of the app declaring the connection goes live, or another deploy changes the connection to the kind it declared. The same holds for a waiting or revoked grant of the app with no kind on record that such a deploy asks for again: it records the kind the deploy declared. A grant such a deploy opens, unlike one it asks for again, has no hosts until a deploy of the app declaring the connection goes live, as above, so until then the only calls that reach it are those that name the connection and those of a preview that declares it, and after such a change only that preview's calls reach a host through it: every other call is refused, with nothing sent.

A host that needs no key (a public API, a company service that checks nothing) is declared with `"kind": "none"` and its hosts; nothing is ever asked for, and the request goes out as the app sent it, to those hosts only:

    { "connections": [{ "slug": "openweather", "hosts": ["api.openweathermap.org"], "kind": "none" }] }

Locally under `wirl dev` the same rules apply to every call that goes through wirl, whether or not the app declares any connection: a call to an undeclared host is refused, and so is a host that resolves to a private address, unless `.wirl/mocks.json` sends its calls to a server of your own (see "Trying a connection without its key", below). Keys come from `.wirl/keys.json` there, and nobody has to approve anything, so a connection still waiting on an admin works locally and is refused deployed until it is approved.

## Vendor SDKs, and wirl's refusals

A vendor's own SDK that sends its key in a header works through a declared connection, because wirl takes its calls on the way out like any other `fetch`. Give the SDK any placeholder as its key (`new Stripe("placeholder")`), since wirl puts the real one in on the way out, by the connection's kind:

- `http_bearer` and `basic`: wirl removes the `Authorization` header the SDK sends and sets its own.
- `http_header`: wirl sets each header the connection names, replacing the SDK's header of that name, so the placeholder an Anthropic SDK sends in `x-api-key` never reaches Anthropic. A placeholder the SDK sends in any other header reaches the vendor as it is, so name the header the SDK uses.
- `none`: wirl adds nothing, and still removes an `Authorization` header the SDK sends.

An SDK that signs each request with its key, as AWS's do, cannot use a placeholder: wirl sets a header, it does not sign.

`wirl deploy` and `wirl dev` bundle a package through its `workerd`, `worker` or `browser` export, the builds made for Cloudflare Workers and for browsers, so an SDK that has one uses it, with `fetch` underneath, and needs no HTTP client of your own: Stripe's needs no `createFetchHttpClient()`.

When wirl refuses a call itself, the answer is JSON in one shape, `{"error": "<code>", "message": "<what is wrong>"}`, with a status to match, and the message is written for the person reading your app's error. `no_key` is a `503`: the connection has no key yet, and the message holds the link where someone adds it. No retry can succeed until a person has, so do not retry it: show the message. Some SDKs read a string `error` as an error of their own and drop the message: Stripe's reads it as an OAuth error, keeps only `no_key`, and retries the `503` by itself, which only makes the same refused call again. When an SDK says no more than the code, make the same call with `fetch` and read the body for wirl's message. A `502` from wirl comes after the call may have reached the vendor: `too_large` and `app_answers_full`, `owner_answers_full` or `answers_full`, where the vendor received the call and answered it and wirl could not pass the answer on, and `upstream_error`. An SDK that retries a `5xx` by itself sends such a call again, and the vendor may act on it twice: for a call that must not happen twice, such as a charge or a message, turn the SDK's retries off, or make the call with `fetch`, and send it again yourself only once you know that is safe.

## The org Connections page

`/orgs/<org>/connections` lists every connection an admin holds for the organisation: its name and kind, the hosts it may reach, who added it, and which apps are using it. A connection with no key yet shows a warning. Below the table, "What apps called" shows, by host, how many calls each app made through the gate in the last 30 days and how many were refused or did not complete. A streamed answer counts as a call when its headers arrive, even if it is cut part-way. Of the refused ones, some never left wirl, such as a call whose own body was too large or too slow to send, or one wirl stopped because the bodies it holds at once were at their limit. A call refused because its answer was too large, or as busy, had already reached the vendor, which may bill for it, and one that failed upstream or that its app gave up on may have reached it too. A call whose answer wirl cut because the answers it holds at once were at their limit had reached the vendor too. One wirl did not send, because those answers left too little room for one more, never left wirl.

From that page an admin can **revoke** an app's grant on a connection (that app's calls to it start being refused at once; the key itself, and every other app's grant on it, are unaffected), **grant it again** later, or **delete** the connection outright. Deleting asks you to type the connection's slug to confirm, because there is no undo: every app using it loses access immediately. Its slug stays taken: a deploy or a preview that still declares it is refused, saying so, and a new connection needs another slug. There is no organisation-level form for adding a connection; add one from an app's Connections page, or with `wirl connections create`.

## Trying a connection without its key

Under `wirl dev`, a connection's calls can go to a server you run instead of to the vendor, so the app's own code, a vendor's SDK and a streamed answer can all be tried before anyone adds the key. Map the connection to that server's base URL in `.wirl/mocks.json`, beside `wirl.json`:

    { "anthropic": "http://127.0.0.1:4010" }

A call the app makes to one of that connection's declared hosts then goes to the base URL instead. The call's path is added to the base URL's own path and its query is kept, so `https://api.anthropic.com/v1/messages` goes to `http://127.0.0.1:4010/v1/messages`. The method, the body and every header wirl sends but `Host` are what the vendor would get. A streamed answer reaches the app as it arrives, within the bounds a vendor's has, and a redirect your server answers with is handed back to the app and never followed, as a vendor's is.

Under `wirl dev`, a `none` connection sends your server no key. Any other sends it the key in `.wirl/keys.json` when that file has one for it, in the shape `wirl dev` prints for each connection that has none: `{ "stripe": { "token": "..." } }` for `http_bearer`, a `username` and a `password` for `basic`, and for `http_header` each header's name and its value, such as `{ "anthropic": { "x-api-key": "..." } }`. When the file has none, your server gets the placeholder `wirl-dev-mock` in the shape of the connection's kind, so it sees the headers the vendor would: `Authorization: Bearer wirl-dev-mock` for `http_bearer`, and `wirl-dev-mock` as both the username and the password for `basic`. An `http_header` connection sends no key header then, since only `.wirl/keys.json` names the header.

The checks stay as they are. A host the connection does not declare is refused, mapped or not. A connection with no mapping still goes to its vendor, and one with no key is still refused as `no_key`: the placeholder never goes to it. Through wirl, an address on your own machine or network is reached only as a base URL you mapped, and any other host that resolves to one is still refused. Under `wirl dev` the app's own call to `127.0.0.1`, `localhost` or `[::1]` does not go through wirl: it goes there directly, as it always has.

`wirl dev` reads `.wirl/mocks.json` once, at start, and says for each mapping where its calls go and which key they carry. It refuses to start, and says why, when the file is not a JSON object of connection names to base URLs or is over 65,536 bytes; when a name in it is not a connection `wirl.json` declares, or is one that declares no hosts or shares a host with another connection; or when a base URL is not `http` or `https`, or has a user name, a password, a query or a fragment in it.

The mapping is `wirl dev`'s alone. A deploy and a preview never upload `.wirl/mocks.json` or `.wirl/keys.json`, and nothing deployed reads either.

## Tokens

A token carries one of two scopes. A browser session always issues **full**. `wirl login` issues **deploy** unless the person ticks full access on the approval page.

- **`deploy`** covers shipping: create an app on first deploy, deploy, roll back, pull source, list apps and versions and connections, set the description and accent, declare a connection the app needs, and read who it is signed in as.
- **`full`** covers everything `deploy` does, plus anything that widens or hands over access, or touches credentials or people: sharing an app, making it private or org, transferring or moving it, deleting it, creating or rotating or deleting a connection, deciding a grant, adding or removing members, and revoking tokens.

Two things no token does, whatever its scope: make an app public, and mint a token. Both are a person's, signed in at a browser on the dashboard: public on the app's Access page ([Sharing](https://docs.wirl.dev/sharing.md)), and a token on the **Connect an agent** page, by approving `wirl login`, or by allowing an MCP client on its consent page. The dashboard's forms answer a token with a 403, so the token an agent holds cannot reach either through them.

Connecting an agent through MCP issues a `deploy` token unless the person ticks the box on the consent page: "Also give it full access".

The **Connect an agent** page (`/settings/tokens`) shows the MCP install command and lists every token with its scope and a revoke link.

If an agent holding a `deploy` token tries something only `full` can do, it gets back a 403 saying which action it cannot do and where to do it instead; the CLI and MCP surface that as a plain message telling the person to do it in the dashboard, or to run `wirl login` again and tick full access on the approval page.

# Schedules

Source: https://docs.wirl.dev/schedules.md

## What a schedule is

A schedule is a cron job wirl fires into your app on your behalf. You declare it in `wirl.json`; there is nothing to install, no separate worker to run, and no clock of your own to keep — the control plane's clock checks every schedule across every app and fires the ones that are due.

## Declaring one

    {
      "app": "job-board-sync",
      "server": "server.mjs",
      "schedules": [
        { "name": "poll-jobs", "cron": "0 9 * * 1-5", "timezone": "America/Los_Angeles", "timeout": "2m", "retries": 1 }
      ]
    }

Every field but `name` and `cron` has a default:

- **`name`** — 1-40 lowercase letters, digits or dashes. Identifies the schedule in `wirl schedules`, `wirl run` and the dashboard; unique within the app.
- **`cron`** — a standard five-field cron expression (minute, hour, day of month, month, day of week).
- **`timezone`** — an IANA zone, e.g. `America/Los_Angeles`. Defaults to `UTC`. `cron`'s fields are read in this zone, including across a daylight-saving change.
- **`timeout`** — how long a fire may run before wirl gives up on it: `"30s"` to `"15m"`. Defaults to `"5m"`. See below.
- **`retries`** — 0 to 3 further attempts after a failure. Defaults to `0`.
- **`overlap`** — `"skip"` (the default) leaves a fire alone if the previous one for this schedule is still running; `"allow"` starts another anyway. See below.
- **`flow`**: a flow the app declares under `flows`. The schedule then starts a run of that flow at each time due instead of posting to its path. See [Starting a flow](https://docs.wirl.dev/schedules.md#starting-a-flow).

An app may declare up to 10 schedules.

## What your app receives

At the time due, wirl sends your app a `POST` to a path reserved for this:

    if (url.pathname.startsWith('/__wirl/schedule/')) {
      const name = url.pathname.slice('/__wirl/schedule/'.length);
      // ... do the work ...
      return new Response('checked 40 postings');
    }

The request is authenticated the same way every other request to your app is — wirl signs it, your app does not need to check anything to know it is legitimate — and it carries no body. Alongside the usual `x-wirl-app-id` and `x-wirl-org`, it carries:

- **`x-wirl-schedule`** — the schedule's `name`.
- **`x-wirl-run`** — this run's id, useful in your own logs to line up with `wirl run` or the Overview.
- **`x-wirl-attempt`** — `1` on the first try, `2` or higher on a retry.

`x-wirl-user-id`, `x-wirl-user-email`, `x-wirl-user-name` and `x-wirl-role` are present but empty: nobody is there, so there is nobody to name. Nothing fires a schedule on a clock under `wirl dev`: it prints a `curl -X POST` command for each one that does not start a flow, and a request you send to this path arrives the same way, with `x-wirl-run` set to `dev`.

Whatever your response body says, up to 1 KB, becomes that run's note. `wirl run` and the `wirl_run` MCP tool print it after the outcome, and `wirl run --json` carries it as `note`; `wirl schedules --json` carries the last run's as `lastRun.note`; and the app's Overview shows it beside each of the app's last ten runs. Plain `wirl schedules` and the `wirl_list_schedules` tool print the last run's outcome without it. A 2xx response is `ok`; anything else is `failed`, with the status code kept alongside it. A note is for the people who can deploy the app, as its logs are: anyone else who can open the Overview sees each run's outcome, a failure's status code included, and no note.

## Timeouts and the fifteen-minute ceiling

wirl aborts a fire that outruns its `timeout`, marking the run `timed_out` — a schedule can declare at most 15 minutes (`"15m"`), because that is the longest a request to your app is allowed to take at all.

Work that takes longer than that does not fit in one fire; the fix is to chunk it across several. Keep a cursor in `env.DB` — the last id processed, an offset, a page token — and have the fire do one bounded slice of the work per invocation, moving the cursor forward before it returns:

    const cursor = await env.DB.prepare('select value from sync_state where key = ?').bind('jobs_cursor').first();
    const page = await fetchNextPage(cursor?.value ?? null);
    await env.DB.prepare('insert into sync_state (key, value) values (?, ?) on conflict (key) do update set value = excluded.value')
      .bind('jobs_cursor', page.nextCursor)
      .run();
    return new Response(`processed ${page.items.length}, cursor now ${page.nextCursor}`);

A `*/5 * * * *` schedule then works through an arbitrarily large job a page at a time, and every page is its own run with its own note — so if item 40,000 breaks something, the Overview points at the run that broke, not a 4-hour fire that failed somewhere in the middle.

A [flow](https://docs.wirl.dev/flows.md) does the same with less to keep: the cursor rides in the run's `state`, each step is one slice, and a schedule can start the run each night (below).

## Starting a flow

A schedule that names a `flow` starts a run of it at each time due, instead of posting to `/__wirl/schedule/<name>`:

    {
      "flows": [{ "name": "import", "step_timeout": "2m" }],
      "schedules": [{ "name": "nightly", "cron": "0 2 * * *", "flow": "import" }]
    }

- The run starts with `null` as its input, and each of its steps is a request of its own, as [Flows](https://docs.wirl.dev/flows.md) says. A `timeout` on such a schedule is refused: each step has its flow's `step_timeout`, and a run as a whole has no time limit.
- Each time due starts one run, keyed `schedule:<name>:<the time it was due>`, so a fire tried again finds the run its first try started rather than starting a second. No other start may use a key beginning `schedule:`.
- The fire is recorded `ok` as soon as the run has started, with the note `started run <id>`. A start wirl refuses is recorded `failed`, with the refusal's sentence as its note, and tried again as `retries` says.
- Under `overlap: "skip"`, the default, a fire is skipped while a run this schedule started is still unfinished. A run of the same flow started any other way does not count.
- `wirl run <schedule>` starts a run the same way, keyed on its own fire, so it never takes the place of the next one the clock makes. With `--preview <name>`, it starts the run on that preview, and posts nothing.
- `wirl dev` does not run flows, so it prints no `curl` for such a schedule. It says so, and points at the flow's own `curl` for its first step, and at `wirl run <schedule> --preview <name>` to start a run on a preview.

## Retries and overlap

A failed or timed-out fire retries only when the schedule declares `retries` and the trigger was the clock, not a person: a manual `wirl run` is watched by whoever asked for it, so it never queues a retry behind their back. Retries wait longer each time — about a minute, then five, then fifteen — rather than immediately, so a flaky dependency gets room to recover instead of being hammered.

`overlap` decides what happens when a fire is due while the previous one for the same schedule is still running. The default, `"skip"`, leaves it alone and records that run as `skipped` — the right choice for anything that assumes only one copy of itself runs at a time, such as the cursor pattern above. `"allow"` starts the new one anyway, for work that's fine running concurrently with itself.

Only one manual run of a schedule goes at a time; cron occurrences still follow the overlap rule. A manual run that is still going counts as running for the overlap rule, so the next cron occurrence of that schedule is skipped under `skip`.

## Seeing runs

Every fire — cron or manual — is a run, shown newest first on the app's Overview, alongside its trigger and outcome, and its note for people who can deploy the app. `wirl schedules` (and the `wirl_list_schedules` MCP tool) shows each schedule's next scheduled time and its last run's outcome without opening the dashboard:

    $ wirl schedules
      poll-jobs               Weekdays at 09:00 America/Los_Angeles    last ok 2m ago    next in 3h

A time of day is shown with its zone, `UTC` included, in `wirl schedules`, the tool and the Overview: a schedule with no `timezone` reads `Every day at 02:00 UTC`. An interval such as `Every 15 minutes` holds no time of day, and names no zone when it is UTC. Under `--json`, `words` leaves `UTC` out, as it always has, and `timezone` names the zone.

A run that fails is also written to the app's audit log, so it shows up wherever the rest of an app's activity does.

## Testing with `wirl run`

Waiting for the clock to confirm a schedule works is slow. `wirl run <schedule>` fires it immediately and waits for the outcome, the same as the `wirl_run` MCP tool:

    $ wirl run poll-jobs
    Queued poll-jobs (run a1b2c3).
    ok in 4.1s: checked 40 postings

A non-`ok` outcome — `failed`, `timed_out` — prints the same way and exits 1, so it fails a script or a CI step the way any other broken command would:

    $ wirl run poll-jobs
    Queued poll-jobs (run d4e5f6).
    failed (502) after 1.0s

## After a rollback

Schedules belong to the app, not the deployment: rolling back does not rewrite them. If the older code lacks a handler for a schedule, its runs fail until you redeploy or remove the schedule.

# Flows

Source: https://docs.wirl.dev/flows.md

A flow is work that outlives one request: waiting for a person, trying again tomorrow, a long loop. wirl calls your module one step at a time and keeps the run's place in between.

Four things a person asks their agent for show what a flow is for, and this page builds each of them:

1. "When someone asks for a refund over $500, wait for a manager to approve it, then issue it."
2. "If the vendor's API is down, try that step again tomorrow."
3. "Import these 5,000 rows a few hundred at a time."
4. "Let the agent keep working on this for an hour."

A schedule run gets fifteen minutes at most and keeps nothing between runs. Each of these is a flow instead: a few steps of ordinary code, with no library to install.

## How a flow runs

Declare the flow in `wirl.json`:

    { "app": "refunds", "server": "server.js", "flows": [{ "name": "refund", "step_timeout": "5m", "retries": 5 }] }

- **`name`**: 1 to 40 lowercase letters, digits or dashes. An app declares at most 20 flows.
- **`step_timeout`**: how long one step may take, `"30s"` to `"15m"`. Defaults to `"5m"`. A run as a whole has no time limit: it goes on for as many steps as it takes, within the [limits](https://docs.wirl.dev/flows.md#limits).
- **`retries`**: how many more times a step that fails is tried, 0 to 10. Defaults to 5.

A flow refuses any other key, naming it, so a misspelt limit is never dropped in silence.

wirl then posts each step of a run to your module at `/__wirl/flow/<name>`, with a JSON body:

    { "run": "run_4vq…", "step": "start", "n": 0, "attempt": 1, "input": { "orderId": "41" }, "state": null, "event": null }

- `run` is the run's id. `step` is `"start"` the first time, and after that whatever your last answer named. `n` numbers the run's steps from 0, and `attempt` counts the tries of this one step from 1.
- `input` is what the run was started with, on every call. Nobody is signed in during a step, since a flow is not a person, so who asked for the run belongs in `input` or in the row the run is about.
- `state` is whatever your last answer sent, and `null` when it sent none. `event` is what a wait heard, at the step after the wait, and `null` everywhere else.

The request carries `x-wirl-app-id` and `x-wirl-org` as every request does, the four visitor headers empty, and six of its own:

- `x-wirl-flow`: the flow's name. wirl sets it and removes any a caller sent, so refuse a call whose `x-wirl-flow` is not the flow you answer.
- `x-wirl-run`, `x-wirl-step` and `x-wirl-attempt`: the run, the step's name and the try, as in the body.
- `x-wirl-n`: the step's `n`, as in the body.
- `x-wirl-idempotency-key`: `<app id>:<run>:<n>`, the same on every try of one step, for your own database writes. The `failed` and `cancelled` calls (below) have their step's name after it.

A module that answers one flow starts like this:

    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
        if (url.pathname === "/__wirl/flow/refund") {
          if (request.headers.get("x-wirl-flow") !== "refund") return new Response("not this flow", { status: 404 });
          return Response.json(await refundStep(await request.json(), request, env));
        }
        // ... the app's pages
      },
    };

A step still running at its `step_timeout` is cancelled, and from 5 seconds after that wirl refuses every call the step makes out, to a connection or to `https://flows.wirl`, with `401` and `"error": "deadline_passed"`. That holds for work the module hands to `ctx.waitUntil` too: `fetch`'s third argument is Cloudflare's own context, whose `waitUntil` work can go on after the step has answered (under `wirl dev` it is `{}`, with no `waitUntil`). So finish what a step does before it answers.

## What a step answers

Answer `200` with one of these as JSON:

| Answer | What wirl does |
|---|---|
| `{ "next": "charge", "state": {...} }` | runs `charge` now, with this `state` |
| the same, with `"sleep": "1d"` | runs `charge` a day later |
| the same, with `"until": "2027-01-04T09:00:00Z"` | runs `charge` at that time, which must name its zone (`Z`, or an offset like `+02:00`) |
| the same, with `"wait": { "event": "decision", "timeout": "3d", "otherwise": "expire" }` | runs `charge` when an event named `decision` arrives, handing it over as `event`; if three days pass first, runs `expire` instead |
| `{ "retry": "1d", "state": {...} }` | runs this same step again a day later: the same `n`, the same idempotency key, and `attempt` one more |
| `{ "done": true, "output": {...} }` | finishes the run |
| `{ "fail": "why" }` | stops the run, failed, with that reason |

- A `next` answer takes at most one of `sleep`, `until` and `wait`. A duration is a whole number and a unit: `"30s"`, `"10m"`, `"2h"`, `"1d"`. A sleep, a wait's timeout and a `retry` delay are each at least 30 seconds and at most 30 days. An `until` may be at most 30 days away, and an `until` sooner than 30 seconds away is taken as 30 seconds.
- `otherwise` is required: an answer that waits with nothing to run at its timeout fails the run, saying so. A timeout never runs `next`, so the step after a wait only ever sees the event it waited for.
- A step's name is 1 to 100 letters, digits, dashes or underscores, and never `failed` or `cancelled`, which are wirl's (below). An event's name is 1 to 100 of the same characters.
- A key an answer does not take is refused by name: `{ "next": "charge", "slep": "1d" }` stops the run rather than running `charge` a day early.
- `state` is only what an answer sends, so send it with every `next` and every `retry`: left out, the next call gets `null`. Keep it to ids and cursors, since the whole answer must stay under 1 MiB. Rows, messages and anything secret go in `env.DB`. An answer, a run's input and an event's body each nest at most 100 levels of objects and arrays.
- A step may answer `retry` at most 30 times; one more fails the run.

A step that does not answer one of these ends one of two ways:

- **A blip.** It throws, runs past its `step_timeout`, or answers `5xx`. wirl tries it again, `retries` times, after 10 seconds, 1 minute, 10 minutes, 1 hour and 6 hours, and 6 hours before each try after those. A `Retry-After` on a `5xx` answer is honoured instead, held between 10 seconds and 6 hours. After a try that ran out of time, the next waits at least 90 seconds, since the first may still be running. When the tries run out, the run fails with the last error. A step that throws can reach wirl as an answer of `500` with no body, which wirl cannot tell from a module that answered exactly that, so such a try's error says both and names the command that shows the run's logs: `wirl logs --run <run> --since 7d`, with `--preview <name>` for a preview's run (while that preview still serves the build the run started on), shows a throw as `exception`, with its error.
- **A mistake.** It answers a status that is neither `2xx` nor `5xx`, a `2xx` that is not JSON, JSON that is none of the answers above, or more than 1 MiB. The run fails at once, and its error says what came back.

When a run fails or is cancelled, your module gets one more call, with `step` `"failed"` or `"cancelled"` and a `reason`, so it can mark its own rows: `state` is `null` on a cancel, `input` is there as ever, and the answer is ignored. Each comes as `attempt` 1. The `failed` call's `n` is one past the step the run failed at, and the `cancelled` call's one past the run's current step (the last step that reported, or the step a retry began from, and 1 before any step has reported), so it can be the `n` of the step the cancel cut off: key a row these calls write on `step` as well as `n`, or on `x-wirl-idempotency-key`, which has the step's name after it. `reason` is the error the run failed with, or `This run was cancelled.` Neither call is made once the run's code is gone: when the app is deleted, when a preview is deployed again, removed or expires, or when a deploy no longer declares the flow (below). Answer `fail` to any other step you do not know, since a deploy between two steps can hand new code a step name only the old code knew.

## A step can run twice

A try that ran out of time may still be running when the next begins, a try whose answer was lost is made again, a step in flight when wirl itself is redeployed is called again about a minute after its `step_timeout` is up, and a retried run (below) runs its steps again under the same keys. The call after a redeploy can come after the run has finished: wirl stops it for a run it has on record as finished or stopped, and not for one whose finish it has not heard of. So:

- write to `env.DB` with upserts (`insert ... on conflict (...) do update`), whose `on conflict` names the table's primary key or a unique index, or key a write on the step's `x-wirl-idempotency-key`;
- give a vendor call an `Idempotency-Key` named for the operation (`refund-41`), not for the try, so the vendor acts once however many times the step runs, for as long as the vendor keeps the key (see [The vendor that is down](https://docs.wirl.dev/flows.md#the-vendor-that-is-down)). wirl never passes a vendor a header starting `x-wirl-`, so this is one your code sends;
- answer a repeat as the first try did. The run carries on from the repeat's answer, so a step that answers `done` once and `next` the second time sends a finished run on.

## The refund approval

> When someone asks for a refund over $500, wait for a manager to approve it, then issue it.

Four pieces: a page that takes the request and starts a run, a first step that emails the manager and waits, an approval page the manager answers on, and the steps after the wait. The refunds are rows in `env.DB`, one per order, and the connections are Stripe's and a mail provider's (a person adds each one's key on the app's keys page; see [Connections](https://docs.wirl.dev/connections.md)):

    {
      "app": "refunds",
      "server": "server.js",
      "connections": [
        { "slug": "stripe", "hosts": ["api.stripe.com"] },
        { "slug": "resend", "hosts": ["api.resend.com"] }
      ],
      "flows": [{ "name": "refund", "step_timeout": "1m" }]
    }

The order is the table's key, which the first step's `on conflict (order_id)` needs. The table is made in `migrations/0001_init.sql`, which wirl applies to the app's database before the version that ships it goes live, and to a new preview's database before the preview is put up (see [Tables: migrations/](https://docs.wirl.dev/contract.md#tables-migrations)):

    create table refunds (order_id text primary key, payment_intent text not null, amount integer not null, requested_by text, status text not null, decided_by text)

A preview's database starts with every file in `migrations/` applied, and no rows but the ones those files insert, so the tables are there before the first request, a step's included. The module's `fetch` hands each request to its piece, each of them below:

    export default {
      async fetch(request, env) {
        const url = new URL(request.url);
        if (url.pathname === "/__wirl/flow/refund") {
          if (request.headers.get("x-wirl-flow") !== "refund") return new Response("not this flow", { status: 404 });
          return Response.json(await refundStep(await request.json(), request, env));
        }
        const posted = request.method === "POST";
        const approval = /^\/approve\/([^/]+)$/.exec(url.pathname);
        if (posted && url.pathname === "/refunds/new") return askForRefund(request, env);
        if (posted && approval) return answerRefund(request, env, approval[1]);
        // ... the app's pages: the request form at /refunds/new, how a request is going at /refunds/<order>, and the
        // approval page at /approve/<order>
      },
    };

**Starting the run.** The request form posts to the app, which starts the run from its own code at `https://flows.wirl`, which needs no connection. The input is the request, who asked included, since nobody is signed in during a step. The key names the run in the app's own terms, so a form sent twice finds the run the first one started. The app takes the form only from its own page, and answers a post from any other page with a redirect back to the form, starting nothing, for the reasons [Posts from another page](https://docs.wirl.dev/flows.md#posts-from-another-page) gives, below. It starts nothing when nobody is signed in either, as can be so on a public app, since a refund is asked for by a person:

    // From the app's own page a browser sends `sec-fetch-site: same-origin` (`none` from its address bar) and, with a post,
    // the app's `origin`, since wirl serves the app's pages with `Referrer-Policy: same-origin`. A post another page makes,
    // a sibling app's or another site's, says `same-site` or `cross-site`, or names that page's `origin` or `null`, in any
    // browser that sends either header. A request from outside a browser, such as `wirl fetch`'s, has neither.
    const fromOwnPage = (request) =>
      [null, "same-origin", "none"].includes(request.headers.get("sec-fetch-site")) &&
      [null, new URL(request.url).origin].includes(request.headers.get("origin"));

    // Who is asking. An address that is not plain ASCII arrives percent-encoded, as every name does, and so with no "@"
    // in it; a plain one arrives as it is.
    const emailOf = (request) => {
      const sent = request.headers.get("x-wirl-user-email") || "";
      return sent.includes("@") ? sent : decodeURIComponent(sent);
    };

    async function askForRefund(request, env) {
      if (!fromOwnPage(request)) return Response.redirect(request.url, 303);
      // A request from outside a browser passes that check, and on a public app it can come with nobody signed in.
      const requestedBy = emailOf(request);
      if (requestedBy === "") return new Response("Sign in to ask for a refund", { status: 403 });
      const form = await request.formData();
      const orderId = String(form.get("order"));
      const input = {
        orderId,
        paymentIntent: String(form.get("payment_intent")),
        // The form asks in dollars; the run, the row and Stripe count cents.
        amount: Math.round(Number(form.get("amount")) * 100),
        requestedBy,
      };
      const started = await fetch("https://flows.wirl/flows/refund", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ input, key: `refund-${orderId}` }),
      });
      // The request's page shows how the request is going, from its run, and says to ask again when there is none; the log
      // keeps why a start was refused.
      if (!started.ok) console.log(`The refund for order ${orderId} could not start: ${await started.text()}`);
      return Response.redirect(new URL(`/refunds/${orderId}`, request.url), 303);
    }

A new run answers `201` with `{ "run": "run_…" }`, and a key already used for this flow answers `200` with the same run (`409` with `"error": "key_in_use"` for another flow's). A key is 1 to 100 letters, digits, dots, colons, dashes or underscores, unique among the live app's runs or one preview's, and never begins with `run_` or `schedule:`, which are wirl's.

A key keeps naming its run until 30 days after the run ends, however it ended. So `refund-${orderId}` asks once for each order: once that run has ended, the same order sent again finds it and starts nothing, and the page shows how it went rather than asking a manager again. Where a request may be made again after its run has ended, key the run on the request rather than on what it is about: give each request a row of its own, and put its id in the key and in the approval link.

**The first step writes the row, emails the manager, then waits.** Amounts are in cents. A refund of $500 or less is issued at once; a larger one waits up to three days for a decision:

    const MANAGERS = ["dana@acme.test"];

    const refundOf = (env, order) => env.DB.prepare("select * from refunds where order_id = ?").bind(order).first();
    const mark = (env, order, status, by) =>
      env.DB.prepare("update refunds set status = ?, decided_by = coalesce(?, decided_by) where order_id = ?").bind(status, by, order).run();

    async function refundStep({ step, input, state, event }, request, env) {
      // A run started with no input gets `null`, and has no order to refund: fail it, saying so, where a throw on
      // `input.orderId` would be tried again for hours.
      if (!input?.orderId) return { fail: "a refund run needs an order" };
      switch (step) {
        case "start": {
          // However often this step runs, the row is written once.
          await env.DB
            .prepare("insert into refunds (order_id, payment_intent, amount, requested_by, status) values (?, ?, ?, ?, 'asked') on conflict (order_id) do nothing")
            .bind(input.orderId, input.paymentIntent, input.amount, input.requestedBy)
            .run();
          const about = { orderId: input.orderId, amount: input.amount };
          if (input.amount <= 50000) return { next: "issue", state: about };
          // A step arrives at the app's own address (a preview's run, at the preview's), so the link opens the right page.
          const page = new URL(`/approve/${input.orderId}`, request.url);
          const mailed = await fetch("https://api.resend.com/emails", {
            method: "POST",
            headers: { "content-type": "application/json", "Idempotency-Key": `refund-${input.orderId}-ask` },
            body: JSON.stringify({
              from: "refunds@acme.test",
              to: MANAGERS,
              subject: `Refund of $${input.amount / 100} for order ${input.orderId}`,
              text: `${input.requestedBy} asks for a refund of $${input.amount / 100}. Approve or reject it here: ${page}`,
            }),
          });
          if (!mailed.ok) throw new Error(`the mail provider answered ${mailed.status}`);
          return { next: "decide", state: about, wait: { event: "decision", timeout: "3d", otherwise: "expire" } };
        }
        case "decide": {
          // The manager's answer, with a `from` wirl wrote: who sent it, never what the page claimed.
          const by = event.from.email;
          // Only a person who is a manager, and not the one asking, decides, and only for the amount this run is about.
          if (by === null || !MANAGERS.includes(by) || by === input.requestedBy || event.amount !== state.amount) {
            await mark(env, state.orderId, "needs_review", by);
            return { done: true, output: { refunded: false } };
          }
          if (event.approve !== true) {
            await mark(env, state.orderId, "rejected", by);
            return { done: true, output: { refunded: false } };
          }
          await mark(env, state.orderId, "approved", by);
          return { next: "issue", state };
        }
        case "issue": {
          const refund = await refundOf(env, state.orderId);
          const answer = await fetch("https://api.stripe.com/v1/refunds", {
            method: "POST",
            headers: { "content-type": "application/x-www-form-urlencoded", "Idempotency-Key": `refund-${state.orderId}` },
            body: new URLSearchParams({ payment_intent: refund.payment_intent, amount: String(state.amount) }),
          });
          if (answer.status >= 500) throw new Error(`Stripe answered ${answer.status}`);
          if (!answer.ok) return { fail: `Stripe refused the refund: ${answer.status}` };
          await mark(env, state.orderId, "issued", null);
          return { done: true, output: { refunded: true } };
        }
        case "expire":
          await mark(env, state.orderId, "expired", null);
          return { done: true, output: { refunded: false } };
        case "failed":
        case "cancelled":
          await mark(env, input.orderId, step, null);
          return {};
        default:
          return { fail: `the refund flow has no step ${step}` };
      }
    }

**The approval page.** The manager opens the link signed in, so each manager must be able to open the app, and its preview to try the flow there: share it with them (`wirl share dana@acme.test`), or make it visible to the org (see [Sharing](https://docs.wirl.dev/sharing.md)). The app shows the request and how it stands, from its row, and, while the row says `asked`, to a manager who is not the one asking, a form that carries the amount on the page:

    <form method="post">
      <input type="hidden" name="amount" value="80000">
      <textarea name="note"></textarea>
      <button name="approve" value="yes">Approve $800.00</button>
      <button name="approve" value="no">Reject</button>
    </form>

The form posts back to the app, which checks that its own page sent the form and who is asking, then sends the decision to the run, by its key. It answers with a redirect back to the page however the send went, and gives a post another page sent the same redirect, sending nothing. Someone who is not a manager, or is the one asking, is answered `404`, as is an order with no row:

    async function answerRefund(request, env, order) {
      if (!fromOwnPage(request)) return Response.redirect(request.url, 303);
      const email = emailOf(request);
      const refund = await refundOf(env, order);
      if (!refund || !MANAGERS.includes(email) || email === refund.requested_by) return new Response("Not found", { status: 404 });
      const form = await request.formData();
      const sent = await fetch(`https://flows.wirl/runs/refund-${order}/events/decision`, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ approve: form.get("approve") === "yes", note: String(form.get("note") ?? ""), amount: Number(form.get("amount")) }),
      });
      // The row says a decision was sent until the run marks it, a moment later, so the page shows no form meanwhile.
      if (sent.status === 202) await env.DB.prepare("update refunds set status = 'sent' where order_id = ? and status = 'asked'").bind(order).run();
      return Response.redirect(request.url, 303);
    }

What each path does:

- **Approve.** The wait takes the event, and `decide` runs with it as `event`: the page's `approve`, `note` and `amount`, and a `from` wirl wrote. `decide` checks who sent it and that the amount is the one this run is about, and `issue` refunds once, under `Idempotency-Key: refund-41`, however many times it runs while Stripe keeps the key, about a day (see [The vendor that is down](https://docs.wirl.dev/flows.md#the-vendor-that-is-down)). A failed run can be retried for longer than that, so look at the payment in Stripe before retrying one that failed at `issue`.
- **Reject.** `decide` marks the row `rejected`, and the run ends with no refund. An answer that fails the checks is neither: the row says `needs_review`, and a person looks.
- **Nobody decides.** After three days `expire` runs instead of `decide`, and nothing is refunded.
- **A late click.** Once the run has stopped waiting, a send answers `409` with `"error": "not_waiting"` (or `404` once the run has ended), and the manager is sent back to the page, which shows the refund as its row has it, `expired` once `expire` has run. A first answer that arrives in the last 5 seconds before a wait closes is refused `not_waiting` too, with `"closing": true` and a sentence saying the run will go on as if nobody answered, since the run is about to take `otherwise`; the wait still reads as open until its timeout, and once a wait has taken its answer, any further answer to it is `already_sent`, in these last seconds as at any other time. An event sent before its wait opens is refused the same way, never kept for later.
- **A double click.** The first send is taken (`202`); the second answers `409` with `"error": "already_sent"`. Only the first answer to a wait counts.
- **Who approved.** `event.from` is wirl's record of who sent the event, set from who wirl let in, never from the body: `{ "via": "visitor", "userId": "…", "email": "dana@acme.test", "session": true }`. Its `email` is `null` when no person sent it, as from a public path, a schedule or another flow's step, and such an event is never an approval. `wirl runs show`, the run's page and the audit log name the same person, to whoever may know who they are (see [Seeing runs](https://docs.wirl.dev/flows.md#seeing-runs)).
- **A person answers a live run only from a browser session, never with a token.** A send whose sign-in was made with an API token is refused with `403` and `"error": "person_only"`, so an agent or script holding a token cannot answer as a person. A send that names nobody (a webhook on a public path, a schedule, another flow's step) still reaches a live run, with `from.email` `null`, and anyone who may deploy the app can answer a waiting run on its page in the dashboard: so `decide` takes a decision only from a named manager, checking `from` rather than trusting whoever answered. A post another page sends through the manager's browser cannot answer one as the manager either ([Posts from another page](https://docs.wirl.dev/flows.md#posts-from-another-page), below). On a preview, anyone who can reach the run may answer it, with a token too, which is how you try the flow (below).

Other refusals a send can meet: `400` for a body that is not a JSON object, `413` for one over 64 KiB, `429` for more than 10 sends a second to one wait, and `409` with `"error": "too_many_pending"` when the run already holds 10 events not yet delivered.

### Posts from another page

Another page, another app's on `wirl.run` included, can make a signed-in person's browser post the request form or the approval form, and the browser says which page sent a post in its `sec-fetch-site` and `origin` headers. wirl reads them before the app does and tells such a post from the person's own by them, the rule `fromOwnPage` follows, in a browser that sends either header: an app that is not public never sees such a post, outside its public paths, since wirl answers it with sign-in, as if the person were signed out, and a public app gets it signed out, with the identity headers empty. So a post that another page sent through the manager's browser cannot answer a live run as the manager: a send made for it would reach the run with a `from` that names nobody, which is never an approval. [Sharing](https://docs.wirl.dev/sharing.md#who-can-open-it) has the rule, and what it leaves to any app.

Two things are left to the refund's pages, and the code above does both:

- **Check `fromOwnPage` all the same.** On a public app the same post still arrives, signed out, and could start a run that names nobody as asking or change the app's own rows. A request from outside a browser sends neither header and passes the check, so the request form also starts nothing unless someone is signed in: a refund of $500 or less waits for no decision.
- **Answer another page's post with a redirect, never a page.** A browser can send a post again when the person reloads the page the post was answered with, and Chromium sends another page's post again as `same-origin`, with the app's own `origin`, when that page reloads itself from script, which no check of the headers, wirl's included, can tell from the manager's own: sent again, a post a public app got signed out arrives signed in. After a redirect, a reload asks for the page and sends nothing.

## The vendor that is down

> If the vendor's API is down, try that step again tomorrow.

A throw is for blips: with the default five retries, a step that keeps throwing fails the run a little over seven hours later. For "tomorrow", answer `retry`:

    case "send": {
      const order = await env.DB.prepare("select * from orders where id = ?").bind(input.orderId).first();
      // Before the `try`, whose `catch` would take a missing order for a vendor that is down.
      if (order === null) return { fail: `there is no order ${input.orderId}` };
      let answer = null;
      try {
        answer = await fetch("https://api.vendor.example/orders", {
          method: "POST",
          headers: { "content-type": "application/json", "Idempotency-Key": `order-${order.id}` },
          body: JSON.stringify({ sku: order.sku, quantity: order.quantity }),
        });
      } catch {
        // The vendor did not answer at all: treated as down, below.
      }
      if (answer === null || answer.status >= 500) {
        const days = (state?.days ?? 0) + 1;
        if (days > 7) return { fail: "the vendor was down for a week" };
        return { retry: "1d", state: { days } };
      }
      if (!answer.ok) return { fail: `the vendor refused the order: ${answer.status}` };
      await env.DB.prepare("update orders set status = 'sent' where id = ?").bind(order.id).run();
      return { done: true, output: { sent: true } };
    }

- `retry` runs the same step again after the delay, with the same `n`, so the same `x-wirl-idempotency-key`, and `attempt` one more. Every try sends the vendor the same `Idempotency-Key`, named for the order, so an order the vendor took on a try whose answer was lost is not placed twice. That holds only while the vendor keeps the key. Many keep one for about a day (Stripe, for one), which is when a `retry: "1d"` comes back. Where the vendor forgets keys sooner than your delay, send the order a reference of your own and, on a later try, ask the vendor for an order with that reference before placing it again.
- Send `state` with every `retry`: it is all the next try gets. Here it counts the days.
- Answering `503` with a `Retry-After` of your own is a blip too: the step is tried again after that delay, up to 6 hours, and it spends one of the flow's `retries`.

## Importing 5,000 rows

> Import these 5,000 rows a few hundred at a time.

The rows go into `env.DB`, in a table keyed on each row's own id, which the upsert's `on conflict (id)` needs, made in `migrations/0001_init.sql` as the refunds table is:

    create table customers (id text primary key, name text, email text)

`state` carries only a cursor. Here the rows come from a vendor that hands them out a page at a time; a file read a slice at a time works the same way:

    case "start":
    case "page": {
      const url = new URL("https://api.vendor.example/rows?limit=250");
      if (state?.cursor) url.searchParams.set("after", state.cursor);
      const answer = await fetch(url);
      if (!answer.ok) throw new Error(`the vendor answered ${answer.status}`);
      const { rows, next } = await answer.json();
      if (rows.length > 0) {
        await env.DB.batch(rows.map((row) =>
          env.DB
            .prepare("insert into customers (id, name, email) values (?, ?, ?) on conflict (id) do update set name = excluded.name, email = excluded.email")
            .bind(row.id, row.name, row.email)));
      }
      const imported = (state?.imported ?? 0) + rows.length;
      if (next === null) return { done: true, output: { imported } };
      return { next: "page", state: { cursor: next, imported } };
    }

- That is about twenty steps for 5,000 rows, each answer a few bytes. Carried in `state` instead, the rows would make every answer bigger than the one before, and a larger import, or one with wider rows, would pass 1 MiB and fail the run.
- The upsert makes a step that runs twice harmless: a page lands once however often it is written. With a plain `insert`, a step run twice would add its page twice, or fail on the key.
- A page the vendor fails to serve is a blip: the step throws, and the next try starts from the same cursor.
- A schedule can start the import every night: see [Schedules](https://docs.wirl.dev/schedules.md#starting-a-flow).

## An agent that works for an hour

> Let the agent keep working on this for an hour.

One turn of the model is one step. The conversation lives in `env.DB`, in a table keyed by the run and the step's `n`, which the upsert's `on conflict (run, n)` needs, made in `migrations/0001_init.sql`:

    create table turns (run text not null, n integer not null, reply text not null, primary key (run, n))

`state` holds only when to stop. The task, in the run's input, asks the model to end its last reply with `[finished]`:

    async function agentStep({ run, step, n, input, state }, env) {
      switch (step) {
        case "start":
          return { next: "turn", state: { until: Date.now() + 60 * 60 * 1000 } };
        case "turn": {
          const kept = () => env.DB.prepare("select reply from turns where run = ? and n = ?").bind(run, n).first("reply");
          // A step that runs again finds the reply it kept, and neither asks the model nor acts on it twice.
          let reply = await kept();
          if (reply === null) {
            // The last ten replies go back to the model, so however long the hour runs, the conversation stays inside its context.
            const { results } = await env.DB
              .prepare("select reply from (select n, reply from turns where run = ? order by n desc limit 10) order by n")
              .bind(run)
              .all();
            const messages = [{ role: "user", content: input.task }];
            for (const turn of results) messages.push({ role: "assistant", content: turn.reply }, { role: "user", content: "Go on." });
            await env.DB
              .prepare("insert into turns (run, n, reply) values (?, ?, ?) on conflict (run, n) do nothing")
              .bind(run, n, await askModel(messages))
              .run();
            // Two tries of one step can race; both go on from the reply that was kept.
            reply = await kept();
          }
          if (reply.includes("[finished]") || Date.now() > state.until) return { done: true, output: { turns: n } };
          return { next: "turn", state };
        }
        case "failed":
        case "cancelled":
          return {};
        default:
          return { fail: `the agent flow has no step ${step}` };
      }
    }

The model is asked for a stream, since a vendor has 60 seconds to answer whole and a long turn takes longer. A streamed answer has 60 seconds to start and 13 minutes for the rest, so the flow gives each step the most it may. The model's key is a connection of kind `http_header`: on the app's keys page the person gives the header's name, `x-api-key` for Anthropic, and the key, and wirl adds it to each call on the way out, so `askModel` never holds it (see [Connections](https://docs.wirl.dev/connections.md)):

    {
      "app": "agent",
      "server": "server.js",
      "connections": [{ "slug": "anthropic", "hosts": ["api.anthropic.com"], "kind": "http_header" }],
      "flows": [{ "name": "agent", "step_timeout": "15m" }]
    }

The step reads the stream to the end and checks it finished, since a cut stream can look like an ordinary end:

    const MODEL = "<a model name from the vendor's documentation>";

    async function askModel(messages) {
      const answer = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
        body: JSON.stringify({ model: MODEL, max_tokens: 4096, messages, stream: true }),
      });
      if (!answer.ok) throw new Error(`the model answered ${answer.status}`);
      const decoder = new TextDecoder();
      let text = "";
      let pending = "";
      let finished = false;
      for await (const chunk of answer.body) {
        pending += decoder.decode(chunk, { stream: true });
        const lines = pending.split("\n");
        pending = lines.pop();
        for (const line of lines) {
          if (!line.startsWith("data: ")) continue;
          const data = JSON.parse(line.slice(6));
          if (data.type === "content_block_delta" && data.delta.type === "text_delta") text += data.delta.text;
          if (data.type === "message_stop") finished = true;
        }
      }
      if (!finished) throw new Error("the model's stream ended before its last event");
      return text;
    }

For `MODEL`, take a current name from Anthropic's [models overview](https://platform.claude.com/docs/en/about-claude/models/overview), which lists each model's name for the API; this page names none, so that it does not go stale.

- An hour is a few hundred steps, far below the 9,900 a run may take.
- Only the last ten replies go back to the model. Sent whole, a few hundred turns can pass the model's context, and every turn after that fails as a blip until the run fails. Where a turn needs more than the last few, keep a running summary in `env.DB` and send it with them.
- A reply that asks for a tool with an effect (a message sent, a row written) is acted on in the same step, keyed the same way: by the run and `n`, or by an `Idempotency-Key` named for the effect.
- A stream that ends early is a blip: the step throws, and the turn is asked again.

## Trying a flow

`wirl dev` does not run flows. It prints a `curl` for each flow that posts a first step to your module, so you can check one step's answer by hand, and `https://flows.wirl` answers there with `404` and `"error": "no_flows"`. The `curl` posts `input` as `null`, which is what a run started with no input gets, so the refund answers it `fail`, for want of an order: put the run's input in the `curl`'s `-d` to try a step that reads it. Flows run for real in a preview, as the live app's do:

    wirl preview --name try-refund
    wirl flows start refund --preview try-refund --fast --key refund-41 \
      --input '{"orderId":"41","paymentIntent":"pi_test","amount":80000,"requestedBy":"sam@acme.test"}'
    wirl runs show refund-41 --preview try-refund
    wirl runs send refund-41 decision --preview try-refund --data '{"approve":true,"note":"ok","amount":80000}'
    wirl logs --run <the run's id> --preview try-refund

- `--fast`, for a preview's run only, makes every sleep, `until` and `retry` delay 5 seconds, every wait's timeout 60 seconds, and every rung of the retry ladder 10 seconds, a `Retry-After` on a `5xx` answer included. The 90 seconds after a try that ran out of time stays, as do the 4 minutes before a failed run can be retried. So the refund's three-day wait times out in a minute, and `expire` runs: under `--fast`, a send has less than 60 seconds from when the wait opens. The refund has no sleep for `--fast` to shorten, so to try Approve at your own pace, start the run without it.
- `wirl runs send` answers a preview's run, from a token as well as a person, since a preview is you trying things. It never answers a live run.
- A send answers as you, from `wirl runs send` and `wirl_send_event` alike: the event's `from.email` is your own address, as is the `x-wirl-user-email` a page gets from `wirl fetch` or `wirl_preview_fetch`. The refund takes a decision only from a manager who is not the one asking: unless your address is in `MANAGERS`, the send above ends the run `needs_review`, with nothing refunded, and a decision you post on the approval page is answered `404` by its own check. That is the check doing its job, so keep it. To walk Approve, put your own address in `MANAGERS` in the build you preview (and take it out before `wirl deploy`), with `requestedBy` in `--input` someone else's, as `sam@acme.test` is here; or have a manager on the list open the approval link, signed in.
- A preview calls the app's real connections with their real keys (see [Previews](https://docs.wirl.dev/previews.md)), so try a flow that pays or mails someone against the vendor's test mode, or a stand-in.
- A preview's own pages start and answer the preview's runs through `https://flows.wirl` as the live app's do, and never reach a live run. A run a page starts is never fast.
- A preview's run stays on the build it started on. Deploying the preview again stops its unfinished runs, and so does removing it or letting it expire. Its keys stay taken all the same: a key names its run until 30 days after the run ends, on the preview's next build, and on a preview made again under the same name. Run the commands above a second time and `--key refund-41` finds the first run, whatever became of it, and starts nothing. Give each try a key of its own, here with a new order: `--key refund-42`, with `"orderId":"42"` in `--input`.
- An agent does the same with `wirl_preview`, then `wirl_start_flow` with `preview` and `fast`, and `wirl_show_run`, `wirl_send_event` and `wirl_logs` with the run's id: each takes `preview` too, since a preview's run is found, answered and logged only on its preview.
- An app's previews together have pools of their own, apart from the live runs': 10,000 steps a day, 10 steps at once and 1,000 unfinished runs, against the live runs' 50,000, 50 and 10,000. A `--fast` loop, or a loop that never sleeps, can use them up, and never touches what the live app has. Each try of a step counts toward the steps a day, whatever came of it ([Limits](https://docs.wirl.dev/flows.md#limits)).

## Seeing runs

- `wirl flows` lists an app's flows, each with its step timeout, retries, unfinished runs and last run. `wirl runs [<flow>]` lists its runs, newest first, and `--status` narrows them.
- `wirl runs show <run or key>` prints a run's status and where it is (waiting for which event until when, sleeping until when, or, while wirl is still creating it, when wirl tries again), who started it, its output or its error, its page on the dashboard, the newest 50 of its tries, each with the version of the app that answered it, with up to 50 earlier tries that went wrong before them, and the newest 50 of its events, each with who sent it and whether it reached the run. It says how many it leaves out, and the run's page and `wirl runs show --json` have every one.
- A step reports only when it ends, so what a run's record says between reports is where it was at its last one. `wirl runs show` works out from the record, when you run it, the step the run goes on with. Its status line says where the run is now rather than where its last report left it: `running step <step>`; `sleeping until <time>` for a sleep or a `retry` answer still ahead; between two tries of a step that failed, `waiting to try step <step> again at about <time>`, with `waiting to try step <step> again` alone where a `5xx` answer's `Retry-After` could have set another time; and `failing at step <step>` while wirl calls the app's `failed` step. A `Next:` line under it says which step is next, why it is due and since or from when, and, for a step that is due, that it has not reported yet, for example `Next: The wait for confirm took no answer by 2026-10-03T03:53:34.814Z: step 4, second-timed-out, has been due since then and has not reported yet.` A step that a `next` answer named has no name in wirl until it reports, so the line numbers it and names the step whose answer named it. The time of the next try after a failed one is the retry ladder's; a `5xx` answer's `Retry-After`, which wirl does not keep, can set another, and the line says so. Each try has a `deadline` beside its start, in a column between `started` and `took`, which moves the columns after it in the printed table (`--json` only gains a field): the start plus the flow's step timeout, by which wirl had stopped it. A try that reached no deployment, because the flow's code was gone, had none: its `deadline` is blank and its `deadlineAt` is `null`. wirl's call to the app's `failed` step, the `final` row, is sent to the app as any step is, and has one. The run's page says the same, its status tag included, and `wirl runs show --json` gives them as `due` (`null` for a run starting, `unknown`, finished, or waiting inside its wait's time) and each try's `deadlineAt`. `wirl runs show`, its `--json` and the run's page leave out the input the run was started with, and a run that ended `done` or `cancelled` no longer has it: wirl drops a run's input when the run ends, except a failed run's, which it keeps 3 days for a retry ([Limits](https://docs.wirl.dev/limits.md#flows)).
- On the dashboard, the app's Overview has a Runs card for people who may deploy it: the newest ten runs, a form to start one, and each run's own page, with Cancel and Retry. A person signed in on a waiting run's page can also answer its wait there.
- `wirl runs show`, `wirl_show_run` and a run's page name who started the run and who sent each of its events, and the Runs card who started each run, if you are a member of the app's organisation or are that person. Anyone else who can deploy the app, such as someone with an editor's share on it and no seat in the organisation, reads `someone else`, as in `someone else on the dashboard`, as they read `made by someone else` of a [connection](https://docs.wirl.dev/connections.md)'s maker. Under `wirl runs show --json`, `starter` and each event's `sender` give that person's `name` and `email`, with `name` null for someone who has none; for anyone else, `name` is null and `email` is empty, so that a CLI from before this rule prints a blank for such a sender rather than failing. The `userId` in `startedBy` and in each event's `sentBy`, and in each run's `startedBy` under `wirl runs --json`, is that person's user id if you are a member of the app's organisation or are that person, and null for anyone else, to whom a CLI from before that change prints a starter who came on the dashboard or through the API as `the dashboard` or `the API`. If wirl no longer has an account for them, `starter` or `sender` is null, and the run's page reads `someone who has left`, to anyone. `wirl runs show` and `wirl_show_run` read the same and never print their id, except that to anyone outside the organisation they read one who came from the app's own page as `someone`, as they read a visitor who was not signed in.
- From the app itself, `GET https://flows.wirl/runs/<run or key>` answers `{ status, step, waiting, output, outputTruncated, error, pausedUntil, run, resumeAt }`, for a page that shows how a request is going. `waiting` is `{ event, until }` while a wait is open. `pausedUntil` is when the pause holding the run for the day ends, and `null` while none does. A `null` output with `outputTruncated: true` was over 64 KiB, and wirl kept none of it. `run` is the run's id, whether you asked for it by id or by key. `resumeAt` is when the run goes on by itself: a sleeping run's wake, from a sleep or a `retry` answer, or a waiting run's wait timing out, the same time as `waiting.until`; `null` for any other status. So a page that says "we try again tomorrow" can say when.
- `wirl logs --run <run>` prints what the run's steps printed in the last hour, or as far back as `--since` says, up to the seven days logs are kept, with `--preview <name>` for a preview's run.

A run is `starting`, `running`, `sleeping`, `waiting`, `done`, `failed`, `cancelled`, or `unknown` while Cloudflare cannot be asked about it. A paused run keeps its status, and says until when: `wirl runs show` adds a `Paused:` line under it (see [Limits](https://docs.wirl.dev/flows.md#limits)), its page says the same, and the read above gives the time as `pausedUntil`. A run stays `starting` until wirl knows Cloudflare has it. When wirl's create of a run has not landed and wirl is to try again, `wirl runs show` adds a line under its status once the run is 15 seconds old and wirl's clock has taken it up, `wirl is still creating this run. It tries again by about 05:13:20 (UTC).`, or `… It tries again shortly.` once that time has passed; its page says the same, and `wirl runs show --json` gives the time as `nextCreateTryAt` (`null` otherwise). A run Cloudflare no longer has is not left `unknown`: it ends `failed`, with the error "Cloudflare no longer has this run, so wirl cannot tell how it ended. Start a new run instead.", and cannot be retried.

Starting a run from wirl's own surfaces (`wirl flows start`, `wirl_start_flow`, the Overview's form), and cancelling or retrying one, take a person or a token that may deploy the app, as `wirl run` does. The dashboard's own forms (the Overview's start form, and the cancel and retry buttons) take only a person at a browser; a token does the same through `wirl flows start`, `wirl runs cancel` and `wirl runs retry`, or the MCP tools. A schedule can start one too: see [Schedules](https://docs.wirl.dev/schedules.md#starting-a-flow).

## Retrying a failed run

`wirl runs retry <run or key>` (or Retry on the run's page, or `wirl_retry_run`) runs a failed run again from the step it failed at, or with `--from <n>` from an earlier step. The steps before that are not called again; each step from there runs again under the same `n` and the same idempotency key, which is why a step must be safe to run twice. Retried from a step that opened a wait, the run waits again and takes a new answer.

A retry works from 4 minutes after the run failed until 3 days after, which is how long wirl has Cloudflare keep a failed run; after that, start a new run. The 4 minutes are there so that nothing the failed go sent can still land in the new one. A failed run of a preview cannot be retried once the preview has been deployed again or removed, or has expired, since a preview's run stays on the build it started on: start a new run instead. Its page then offers no Retry, and says why. A run that ended failed because Cloudflare no longer has it (above) cannot be retried at all, and its page offers no Retry. A retried run numbers its steps again from where it starts, so each try is marked with the retry it belongs to: once a run has been retried, `wirl runs show` adds a `gen` column, 0 for the run's first go and 1 after its first retry, and the run's page tags the same tries `retry 1`.

## Cancelling a run

`wirl runs cancel <run or key>` (or Cancel on the run's page, or `wirl_cancel_run`) stops a run. A step already running is stopped where it is, and your module gets one `cancelled` call, with 30 seconds to mark its rows.

Deleting an app cancels its unfinished runs, and removing a preview, deploying it again or letting it expire cancels the preview's. These are carried out in the background, and your module is not called, since its code is going; deleting an app says how many runs it cancelled. Restoring a deleted app does not bring its runs back.

## When a run fails

Your module gets the `failed` call once, with the `reason` and the `state` the failing step was given; a run that ended failed because Cloudflare no longer has it gets no `failed` call: it comes from the run itself, and the run is gone. The run's page and `wirl runs show` say why it failed. For a live run, the app's owner is emailed, at most once a day for each flow, with a link to the run's page; the mail carries nothing your app wrote.

## Deploys and rollbacks

A deploy between two steps runs the next step on the new code, with the `state` the old code wrote. `wirl deploy` says how many runs of each flow are waiting and will resume on the new version, and, when a deploy no longer declares a flow, how many of its runs will fail at their next step, with no `failed` call, since no code answers the flow any more. `wirl deploy` and `wirl preview` (and `wirl_deploy` and `wirl_preview`) also name the flows the build declares, each with the step timeout and retries it keeps: `declaredFlows` in `--json`, an empty list when it declares none.

A rollback, by hand or automatic, can hand a newer version's `state` and step names to older code that was never written to read them. So it lists the runs whose last step the version rolled away from answered, oldest first, with the count of all: up to 100 of them in the rollback's answer, and fewer on the Overview, each linked to its page. Look at those.

## Limits

The ones a flow meets first. [Limits](https://docs.wirl.dev/limits.md#flows) has every figure.

- A step has its `step_timeout`, 30 seconds to 15 minutes. An answer stays under 1 MiB, and a run's input, an event's body and the output wirl keeps are each at most 64 KiB.
- A step gets 30 seconds of CPU, as a request does, which wirl does not raise, whatever its `step_timeout`.
- A run takes at most 9,900 steps, where Cloudflare's default is 10,000. Every try of a step counts, and so does every wait; a sleep does not.
- An app's live runs take at most 50 steps at once. The next waits its turn, trying again after 15 seconds and then twice as long each time, up to 10 minutes, each wait up to a tenth longer, and each of those waits counts as a step of the run and toward the day's steps.
- An app's live runs take at most 50,000 steps a day, where each try of a step counts one, as do each wait for a slot or for the day's pause and each `failed` call. Past it they pause, never fail, until 00:00 UTC, and the app's owner is told once. A paused run keeps its status, and `wirl runs show` says why under it: `Paused: the app's live runs used today's 50,000 flow steps, so this run's next step waits until <the next 00:00 UTC>.`
- An app's previews together have pools of their own, apart from the live runs': 10,000 steps a day, 10 steps at once and 1,000 unfinished runs, against the live runs' 50,000, 50 and 10,000.
- An org holds at most 50,000 unfinished runs, live and preview together.

# Public paths

Source: https://docs.wirl.dev/public-paths.md

## What a public path is

Every other request to an app requires signing in with Google, and the person's identity arrives as `x-wirl-user-id` and the rest. A public path is a narrow, named exception: one path on your app — or one path prefix — that the internet may call with no sign-in at all, because the caller is not a person. Slack, Stripe, GitHub and a form on a public site all work the same way: they POST to a URL you give them, on their own schedule, and expect an answer. Before a public path could be declared, that URL returned a sign-in redirect and the whole class of integration was impossible to build.

wirl checks the sender's signature before your app is ever called, using a secret you declare but never handle in code — the same shape as any other connection.

It is not a way to make the whole app public. Visibility (private, org, or public) is a separate setting and is unaffected: a dashboard only Finance can open can still have one webhook endpoint Stripe reaches, and that endpoint gives away nothing about the rest of the app. It is also not a general-purpose ingress rule: a path is one exact string or one path ending in a single `/*`, at most ten per app, and only on a service app — a static bundle declaring one is refused at deploy with `Public paths need a server.`

## Declaring one

In `wirl.json`, beside the `connections` it names:

    {
      "app": "support-bot",
      "server": "server.mjs",
      "connections": [
        { "slug": "slack-signing", "kind": "webhook_secret" }
      ],
      "public_paths": [
        { "path": "/slack/*", "verify": "slack", "connection": "slack-signing" }
      ]
    }

Each entry in `public_paths`:

- **`path`** — starts with `/`, and is either exact or ends in one trailing `/*` covering everything below it. No leading wildcard, no regex, and no bare `/*` — that is the whole app, which is what visibility is for. Anything under `/__wirl` is reserved for wirl. Required.
- **`verify`** — `slack`, `stripe`, `github`, or `none`. Required.
- **`connection`** — the `webhook_secret` connection holding the signing secret. Required for every provider; refused if given alongside `verify: none`, which checks nothing and so needs nothing to check against.

The secret itself is a connection, declared exactly where every other credential is. `connections` gains a `webhook_secret` kind: a connection that holds one secret and no hosts. The outbound broker — the thing that attaches a connection's credential to your app's own outgoing calls — refuses to hand this one out; only wirl's verifier reads it. `public_paths` only names the connection by its slug, so `wirl.json` never carries the secret itself and stays safe to commit.

## Where the secret goes

If the `webhook_secret` connection named in `public_paths` does not exist yet, deploying creates it empty, grants it to the app at once, and hands back a link to the app's Connections page — the same keyless-placeholder pattern an egress connection with no key yet uses. Whoever has that link opens it in their own browser and pastes the signing secret Slack, Stripe or GitHub gave them into the "webhook signing secret" field; it goes straight into wirl's vault and nowhere else — not through the CLI, not through an agent, not through this documentation.

Until the secret is there, the path answers every request with **503**: wirl is not ready, which is a different thing from the sender being wrong, so it gets a different status than a bad signature does. The app itself is never called either way.

## What your app receives

The request arrives as sent — same method, same headers, same body — with these added:

    x-wirl-public: 1
    x-wirl-verified: slack        (absent when verify is none)
    x-wirl-app-id / x-wirl-org    as on every request
    x-wirl-user-id / x-wirl-user-email / x-wirl-user-name / x-wirl-role
                                  present, but empty

The sender's IP address is in `cf-connecting-ip`, which cannot be forged (see [The contract](https://docs.wirl.dev/contract.md)). A `verify: none` path has a ceiling you get without asking (see below): 120 requests a minute from one address, 600 a minute for the app. That stops a flood, not a patient stranger, so if a form needs something tighter (three submissions a day per person, say), keep that count yourself in `env.DB`, keyed on that header.

`wirl dev` serves a declared public path the same way, except that it checks no signature, so `x-wirl-verified` is never set locally.

Any HTTP method can reach a public path, because a provider's own handshake may not be a POST — Slack's URL verification challenge, for instance, arrives the same way its events do. Treat `x-wirl-public: 1` as meaning only "this did not come from inside a session" and nothing more. In particular, `x-wirl-user-id`, `x-wirl-user-email`, `x-wirl-user-name` and `x-wirl-role` being present-but-empty is not the same as an anonymous or a stub user — there is no user here at all, and code that treats an empty identity as some kind of guest account is treating a webhook as a person by accident. An app must not do that: check `x-wirl-verified` for which provider vouched for the request, and otherwise handle it as what it is — a message from a machine, not a visit from someone signed in.

## The three providers, and what each one signs

All three sign an HMAC-SHA256 over the raw request body with the connection's secret; they differ only in what else goes into the signed message, where the result is written, and whether a timestamp is involved at all:

- **slack** — the header `x-slack-request-timestamp` and `x-slack-signature` (`v0=<hex>`). wirl signs `v0:{timestamp}:{body}` and compares. A timestamp more than five minutes from wirl's own clock, either direction, is refused as stale even if the signature is otherwise correct.
- **stripe** — the header `stripe-signature`, a comma-separated `t=...,v1=...`. wirl signs `{t}.{body}` and compares against `v1`. Same five-minute tolerance on `t`.
- **github** — the header `x-hub-signature-256` (`sha256=<hex>`). wirl signs the body alone — nothing else goes into the message. GitHub's scheme carries no timestamp, so a signature that was valid once stays valid forever: a captured, correctly-signed request can be replayed at any later time and wirl cannot tell. If that matters for what the handler does, defend against it yourself — GitHub's own delivery id, checked against ones you have already processed, is the usual way.

## `none`, and why it deserves a second thought

    { "path": "/ping", "verify": "none", "connection": null }

opens a path with no signature check and no connection at all. It exists for the cases that cannot sign (a health check some monitor polls) or where nothing happens as a result of the call, but it comes with none of the protections a provider gets. It is reachable by anyone who learns the URL. What it does have is a ceiling of its own: 120 requests a minute from one sender address (an IPv6 sender is counted by its /56, which is what one customer of an ISP is ordinarily given and can use any address of) and 600 a minute for the app, across all of its `none` paths together. Over either, the answer is 429 and your app is never called. These are ceilings against a flood, counted at the edge in each Cloudflare location and approximate by design, not an exact meter. The count catches up a moment after the requests do, so a sudden burst overshoots: when we flooded a path from one address, a little under three hundred requests got through in the first few seconds before refusals began, and about one in seven after that. A sender spread over many addresses and many places can still reach the app-wide number. If what the path does is expensive or must happen only so often, count it yourself as well. The Settings page marks every `none` row with a warning for exactly this reason. If the thing calling this path can sign its requests at all, use a real `verify` value instead; reach for `none` only when there is truly nothing to check.

## Limits and refusals

- **Body cap: 1 MB.** A request over it is answered with **413** before your app is called, on every path, `none` included.
- **Verify rate: 600 requests a minute per app**, counted across every path that carries a real `verify` value together — not per path. Over it, **429**. A `none` path has its own ceiling, next.
- **Unsigned rate: 120 requests a minute per sender address, and 600 a minute per app**, on `none` paths only, counted per Cloudflare location. Over either, **429** with `Retry-After: 60`, before your app is called and before the body is read.
- **401** — the signature did not check out: wrong secret, a tampered or mismatched body, a stale timestamp, or a header shaped wrong to even attempt the comparison. The app is never called.
- **503** — wirl itself was not ready to answer: no secret set yet, the named connection is not granted to this app, it does not exist, it is not a `webhook_secret` connection, or the control plane could not be reached at all to check. Never the sender's fault.
- **429** — over the verify rate limit, or over a `none` path's ceiling.
- **413** — the body was over the 1 MB cap.

A signature that does not check out (the 401 above) is written to the app's audit log as `public.refused`, naming the path, the provider and the reason, at most once a minute per app so a flood of bad requests cannot fill the log with copies of the same thing. A 429 from a `none` path's ceiling is counted instead, as `public.throttled` ([When the ceiling refuses](https://docs.wirl.dev/public-paths.md#when-the-ceiling-refuses), below). The other refusals here (413, the verify rate's 429, 503) are not recorded.

A request to a public path is always treated as coming from nobody, even when the person making it is signed in to the app: it arrives with the visitor headers empty, and on a `none` path it counts against the ceiling like any other.

## When the ceiling refuses

The ceiling refuses at the edge, before your app is called, so a refused request is never one of your app's invocations: `wirl logs`, the `wirl_logs` tool and the app's Logs page never show it. The Logs page says so in one line, and so do `wirl logs` and `wirl_logs` when they have nothing to show. The edge counts what it refused instead and tells wirl, which records it as a `public.throttled` line, at most one a minute for an app:

    public.throttled   form-intake   paths: /forms/* (sender) 270, /forms/* (app) 12 · refused: 282 · started: 2026-10-08T08:50:14Z · through: 2026-10-08T08:50:29Z

- **`paths`** lists each public path that refused, as `wirl.json` declares it (its first 256 characters), `/forms/*` here, never the address a sender asked for; then which ceiling refused, `(sender)` when one sender address (an IPv6 sender's /56) went over 120 a minute and `(app)` when everyone together went over 600; then how many it turned away. A line shows the first eight and says how many more there are.
- **`refused`** is all of them together.
- **`started`** and **`through`** are the first and the last of them, in UTC, at most a minute apart.

The line never names a sender or its address. If someone says they were turned away, look for their path near the time they give.

It is a count, not a meter. wirl's edge runs as separate copies in every Cloudflare location that serves a request, sometimes more than one in a location, and each copy sends at most one report a minute about an app: 15 seconds after the first refusal it counts, so that a burst is one report, and never sooner than a minute after that copy's last report about the app. wirl writes the first report about an app that reaches it in each minute as that minute's line, and nothing for any later one in the same minute, so a line counts what one copy turned away, and a flood refused by more than one copy at once is undercounted. Unless a report is held (below), its line appears between about 15 seconds and a minute after the first refusal it counts. A refusal that comes too soon after a report for its request to wait for the next is held until a request reaches that copy of the edge once that report is due, and is lost if none does before Cloudflare recycles the copy. A report that does not reach wirl, while it is being redeployed say, is not sent again. One copy counts for at most 1,000 apps at once.

Anyone who can deploy the app sees its own lines on its **Logs** page, under Turned away at the edge: the latest 20 from the last 7 days, newest first, and none about any other app. The organisation's **Activity** page, for its owners and admins, shows each app's latest line, for the 5 apps refused most recently in the last 7 days, under a heading of the same name and apart from the record itself, so that a flood of them can never push the rest of the record off the page, nor a flood on one app hide another; each links to its app's Logs page.

## Where people see it

The app's **Settings** page lists every public path the live deployment actually serves: its full URL, how it is checked, and — for a verified one — which connection it checks against. It is read-only there, because `wirl.json` is the source of truth and a deploy is the only way to change it. A `none` row carries the line "Anyone who knows this URL can call it."

The Connections page shows a `webhook_secret` connection with "Checks incoming webhooks" in place of a host list, since it has none.

What the edge refuses before your app is called is never in `wirl logs` or the Logs page, which show only requests your app was called for. A `none` path's 429s are counted as `public.throttled` lines, one a minute at most, on the app's Logs page, for anyone who can deploy it, and each app's latest in the organisation's Activity (see [When the ceiling refuses](https://docs.wirl.dev/public-paths.md#when-the-ceiling-refuses)); bad signatures are in Activity as `public.refused`, at most once a minute.

## After a rollback

Public paths belong to the **deployment**, the same way the app's runtime kind and upstream do — not to the app the way schedules do. Rolling back restores exactly the public paths the older deployment declared, replacing whatever the newer one had, so the edge's behavior for public traffic matches the code you rolled back to rather than the manifest you rolled back from.

## A worked Slack app, end to end

Say `support-bot` wants Slack to push events at it — a message posted, a reaction added — rather than polling for them. `wirl.json`:

    {
      "app": "support-bot",
      "server": "server.mjs",
      "connections": [
        { "slug": "slack-signing", "kind": "webhook_secret" }
      ],
      "public_paths": [
        { "path": "/slack/events", "verify": "slack", "connection": "slack-signing" }
      ]
    }

Deploy it. `acme` here is a company's Google Workspace domain org, where a new app defaults to org visibility — shown below — or the private one, if you deployed into a personal org:

    $ wirl deploy
    Deployed support-bot v1 — 3 files
    https://acme--support-bot.wirl.run
    Visible to everyone in the organisation. Share by email to add someone outside it.
    Public: https://acme--support-bot.wirl.run/slack/events  (slack)
    Needs a key: slack-signing. Add it here, and the app starts working:
      https://app.wirl.dev/apps/acme/support-bot/connections

A wildcard entry like `/slack/*` prints as written; give the provider a concrete path under it.

The `Public:` line is the exact address to give Slack. `slack-signing` did not exist before this deploy, so it is created and granted to `support-bot` in the same step, but with no secret in it yet, which is what `Needs a key` says. Open that link, and under "webhook signing secret" paste the Signing Secret from the Slack app's **Basic Information** page. That value goes into wirl's vault; `support-bot` never sees it and neither does whatever agent ran the deploy.

In the Slack app's **Event Subscriptions** page, paste the same URL from the `Public:` line into "Request URL." Slack immediately POSTs a `url_verification` challenge to it to confirm you control the endpoint — this is one of the requests a public path answers with no session, method and all — and once the secret is in place your handler answers it:

    export default {
      async fetch(request) {
        const url = new URL(request.url);
        if (url.pathname !== '/slack/events') return new Response('not found', { status: 404 });

        // wirl already checked the signature; this header says which provider vouched for it.
        if (request.headers.get('x-wirl-verified') !== 'slack') return new Response('forbidden', { status: 403 });

        const body = await request.json();
        if (body.type === 'url_verification') return new Response(body.challenge);

        // body.event is the real payload once Slack starts sending events.
        return new Response('ok');
      },
    };

From here, every event Slack sends arrives signed, gets checked before `support-bot` sees it, and shows up with `x-wirl-public: 1` and `x-wirl-verified: slack` — while who else can open `support-bot` itself is exactly whatever it was before any of this was declared, unchanged either way.

# Previews

Source: https://docs.wirl.dev/previews.md

A preview is a build of your app put up at its own address, open to the same people the app is, before anything goes live. It gets its own database (its tables from `migrations/`, and no rows but the ones those files insert, the first time that name is used), uses your app's real connections, and goes away on its own after two weeks if you never touch it again.

## Making one

    $ wirl preview
    Preview my-feature of dashboard — 12 files
    https://acme--dashboard--my-feature.wirl.run
    Expires 2026-10-05T09:00:00.000Z
    Look at it: wirl fetch / --preview my-feature

`wirl preview [dir]` bundles and scans the folder exactly like `wirl deploy` does (the same `.wirlignore`, the same credential scan before a byte is uploaded), but the result never goes live. The address is `<org>--<app>--<name>.wirl.run`.

The name comes from `--name <name>`, or, absent that, your current git branch (unless it is `main`, `master` or `HEAD`), or else the part of your signed-in email before the `@`. Either way it is slugified the same way any other name is, and if `org--app--name`, the first part of the address, would run past a DNS label's 63-character limit, the name is shortened with a short hash on the end so two long branch names never collide.

A preview can come before the first deploy. If the app is not there yet, `wirl preview` makes it, exactly as `wirl deploy` would (`--no-create` refuses instead), and the app's own address answers 404 until you deploy: look at the preview, fix what needs fixing, then `wirl deploy`.

Asking again with the same `--name` replaces what that address serves: the old script and files go, a fresh build takes their place, and the database keeps whatever the last one wrote, since the name did not change.

## Who can open it

A preview is open to exactly the people who can already open the app: share, revoke or change the app's visibility and every one of its previews follows at once, because the gate reads the app's own access rule for a preview host too, with no separate list to keep in sync. A revoke reaches a visit already under way on a preview as it does on the app itself ([Limits](https://docs.wirl.dev/limits.md)), and does not take back a `wirl preview open` link printed before it (see [Looking at what you built](https://docs.wirl.dev/previews.md#looking-at-what-you-built)). A preview is never public on its own. If the app itself is open to anyone with the link, its previews are not: only your organisation, and anyone individually shared on the app, can open one, because a build an agent put up an hour ago has not been vetted for the public the way the live app has. The wirl badge never appears on a preview, whatever the app's own badge setting is.

## It says it is a preview

A page a preview serves carries a small marker wirl adds, where the badge sits on the live app: it names the preview and the app, says it is not the live app, and says when the preview expires. By the badge's own rules: only an HTML page answered 200, and never one the app gave its own `Content-Security-Policy` to, where an added style would be blocked — such a page streams exactly as the app answered it ([Limits](https://docs.wirl.dev/limits.md)). It cannot be dismissed, it is not on the live app's pages, and it carries no script and asks for nothing: it is only words on the page, so feedback filed against a stale build and a preview's empty database say what they are. Someone who can deploy the app also gets a link to the app's Overview from the marker; nobody else does, and wirl decides that from the access it has already checked, not by looking again.

The app is told too: every request wirl dispatches to a preview's module — pages, schedule fires, everything — carries `x-wirl-preview: <name>`, so a module can tell a preview run from a live one the way it reads the [identity headers](https://docs.wirl.dev/contract.md#who-is-asking). It is not one of those six: it names the build, not the visitor, and it is set on preview hosts only. An `x-wirl-preview` header that arrived with the request is removed with the rest of the `x-wirl-*` headers, so a caller cannot name a preview to itself. `wirl dev` sets none of this — locally there is no preview, no header is added and none is stripped — so the module runs as the live one would, reading whatever its local caller sent.

## Its own database

Each preview gets its own `env.DB`, made the first time that name is deployed, with every file in [`migrations/`](https://docs.wirl.dev/contract.md#tables-migrations) applied to it before the preview is put up: the tables production has from those files, plus whatever the preview's new files add, and no rows but the ones those files insert. It is never seeded from production (production holds real people's data, and a preview link is opened casually), so a page that expects rows may look blank until something writes to them. Redeploying the same name keeps the same database and applies to it only the files it has not recorded yet; a file edited after it applied there is refused: put it back as it was, or delete the preview and put it up again. Deleting the preview, or letting it expire, takes the database with it.

## Real connections, real keys

A preview calls out through the app's own granted connections, with their real keys, through the same broker a live deploy uses: that is what makes a preview of a HubSpot dashboard actually show HubSpot data. A connection newly declared in the previewed `wirl.json` is ratified exactly as a deploy's would be, and `wirl preview` says of each connection, as `wirl deploy` does, whether it is usable now, waiting on an admin, or needs a key. None of this is sandboxed, so a preview is exactly as trusted as the code in it: review what you are about to preview the way you would review what you are about to deploy.

## Schedules, held

Schedules declared in the previewed `wirl.json` are read but never fire on a clock, so nothing calls a vendor's API on a timer from a preview sitting untouched for two weeks. Fire one by hand instead:

    $ wirl run poll-jobs --preview my-feature
    ok in 4.1s: checked 40 postings

`wirl run <schedule> --preview <name>` (or the `wirl_run` MCP tool's `preview` argument) posts to the preview's own address and prints the outcome at once; the schedule has to be one the previewed `wirl.json` actually declares. A schedule's run fired this way is answered on the spot and kept nowhere as a run: a preview has no schedule of its own, so there is no run history for it in `wirl schedules` or on the Overview. The organisation's audit log does keep a `preview.schedule_run` row for each, naming who fired it and how it ended, which an admin reads on the Activity page. A schedule that starts a [flow](https://docs.wirl.dev/flows.md) starts a run of it on the preview instead, and posts nothing; that run is kept, and `wirl runs --preview <name>` lists it.

## Flows, for real

A preview runs [flows](https://docs.wirl.dev/flows.md) as the live app does, which makes it the place to try one: `wirl flows start <flow> --preview <name> --fast` starts a run whose sleeps take seconds and whose waits time out in a minute, and `wirl runs show` and `wirl runs send` with the same `--preview` follow it and answer its waits. A preview's pages reach only the preview's own runs. A run stays on the build it started on, so deploying the preview again stops its unfinished runs, as removing it or letting it expire does. An app's previews together draw on pools of their own for steps and runs, apart from the live app's. See [Trying a flow](https://docs.wirl.dev/flows.md#trying-a-flow).

## Public paths, live

A public path declared in the manifest is live on a preview exactly as it is on the live app: the same signature check runs before your code does, and whatever it writes lands in the preview's own database, never production's. This is what lets you point a real webhook at a preview and watch it work before it ever reaches the live address.

## Looking at what you built

An agent has no browser, so `wirl fetch <path> --preview <name>` (or the `wirl_preview_fetch` MCP tool) makes one request as whoever runs it and prints the status, the content type and the body: the page source for HTML, capped at 100 KB and said so past that. A stream (server-sent events, newline-delimited JSON, or any text still arriving when it stops watching) is shown with the time each line arrived, so you can see it came piece by piece and not in one burst at the end. A line is dated by the read that completed it, so lines that arrived together share a time. It watches for 10 seconds; `wirl fetch --wait <seconds>` watches longer, up to 300 (5 minutes), long enough to see an event stream reconnect, and a stream still open at the end says how long it was watched. `wirl_preview_fetch` always watches for 10. It also gives a stream back as data, in the result's `structuredContent`: each line as `{ atMs, text }`, `atMs` being whole milliseconds from sending the request, the first 2,000 of them or the first 16 KB of their text, whichever comes first (the first line is always listed), with `linesLeftOut` counting any more (the text shows them all). Anything else is the text alone.

To send a file, `wirl fetch --data @file` sends its bytes, with no content type unless `--header` gives one. `--output <file>` saves the answer as bytes instead of showing it, and prints its size and sha256, so a script can check an upload came back byte for byte. Each takes at most 100,000,000 bytes: Cloudflare publishes 100 MB as the most a request body may be on its Free and Pro plans, and a preview is served through Cloudflare. Nothing is saved unless the whole answer arrived within the wait, and `--output` replaces a plain file already there that you can write, but refuses one you cannot write, a folder or a symbolic link. `wirl_preview_fetch` does neither: its body is text, and a binary answer reports only its type and size.

    $ wirl fetch / --preview my-feature
    200
    content-type: text/html; charset=utf-8

    <h1>Accounts at risk this month</h1>...

`--data` sends a POST unless `--method` names another, so a form or an API that writes is checked the same way; give the content type with `--header`. What it writes to `env.DB` lands in the preview's own database, never the live app's, but a call it makes through a connection still reaches the real service:

    $ wirl fetch /api/items --preview my-feature --header content-type:application/json --data '{"name":"Projector"}'
    201
    content-type: application/json

    {"id":1,"name":"Projector"}

For a person with a browser, `wirl preview open` prints a link that signs its opener in. It works for fifteen minutes, and once per browser is enough: opening it gives that browser a 12-minute pass of its own, so a browser that opens the link near the end of its fifteen minutes can go on using the preview for up to 12 minutes after the link has stopped working. What the link is comes first, on stderr, and the link itself is the only thing on stdout:

    $ wirl preview open --name my-feature
    Whoever holds this link is signed in as you when they open it, like a password: it works for 15 minutes, and the sign-in it gives a browser lasts 12 minutes. Open it in your browser, and do not share it, save it or put it in a file. To check a page or a stream from a script, use wirl fetch.
    Lasts 15 minutes and works once per browser: after the first open, that browser already holds the grant.
    https://acme--dashboard--my-feature.wirl.run/?__wirl_visit=…

The link is a sign-in, so treat it like a password for its fifteen minutes: anyone who opens it is you, on this preview. It is for a person's browser; a script or an agent checks a preview with `wirl fetch`, which never shows the token. Taking away your access to the app does not take back a link already printed: whoever opens it within its fifteen minutes is still let in as you, and keeps the pass it gives. Unlike a visit that began with signing in, a pass from a link never renews: 12 minutes after a browser last opened the link, the preview asks that browser to sign in like anyone else.

`wirl logs --preview <name>` (or the `wirl_logs` MCP tool's `preview` argument) reads that preview's own script instead of the live app's history, with the same `--since`, `--errors` and search flags `wirl logs` always takes. It reads the preview's current build only: once the name is deployed again, the earlier build's lines are no longer shown, though `wirl runs --preview <name>` still lists that build's runs.

## Limits

An app holds at most 5 previews at once; a sixth distinct name is refused, naming the oldest one and how to remove it. Each one expires 14 days after it was last deployed (redeploying the same name resets the clock), and is then taken the same way a deleted app's data is: its script, its files, its database and its address all go.

Moving an app to another organisation removes its previews first: a preview's address names the organisation it was made in, so it does not follow the app. If one cannot be removed just then (it is mid-deploy), the move is refused and nothing moves.

A preview takes a version number from the app's own sequence, so the numbers `wirl versions` lists can skip: v3, then v5, when v4 was a preview. Previews themselves are listed by `wirl previews`, never by `wirl versions`. An app previewed before its first deploy has no v1 there: its first deploy is v2 or later, and has no earlier version to roll back to.

## Deleting one

    $ wirl preview --delete my-feature
    Deleted preview my-feature.

`wirl preview --delete <name>` (the `wirl_delete_preview` MCP tool, or Delete on the app's Overview page) removes it at once: script, files and database, and the address stops answering. Deleting one already gone still succeeds. If removing it takes too long, the command says so; the address has already stopped answering, and running it again finishes the removal. If nobody does, the retention job that takes expired previews finishes it on a later pass.

## `wirl previews`

Lists what an app currently holds, the same shape the `wirl_list_previews` MCP tool answers with:

    $ wirl previews
      my-feature               https://acme--dashboard--my-feature.wirl.run   by Dana   expires in 12d

Each line names who deployed that preview's current build, by first name or the start of their email address, if you are a member of the app's organisation or deployed it yourself. Anyone else who can deploy the app, such as someone with an editor's share on it and no seat in the organisation, reads `by someone else`, as they read `made by someone else` of a [connection](https://docs.wirl.dev/connections.md)'s maker. Under `--json`, `createdBy` is that person's user id and `builder` gives their `name` and `email`, all three null for anyone else, and `name` null too if they have none. If wirl no longer has an account for them, the line reads `by someone who has left` and `builder` is null. The Previews card on the app's Overview names them the same way.

# Sharing and who can open what

Source: https://docs.wirl.dev/sharing.md

A new app's starting visibility depends on the organisation it is deployed to. It starts visible to everyone in the org when the org has a verified company email domain. In any other org, including a personal one or one made with `wirl orgs create`, it starts private: only its owner, the organisation's owners and admins, and the people it is shared with can open it. Sharing by email only ever adds one named person. The visibility level itself is changed with `wirl visibility private` or `wirl visibility org` (the `wirl_visibility` tool for an agent), or on the app's Access page; making an app public is only ever done there, by a person.

## Who can open it

Three levels of visibility:

- **Private** — only its owner, the organisation's owners and admins, and the people it is shared with by email.
- **Everyone in the org** — anyone signed in at the owning organisation.
- **Anyone with the link** — anyone at all, signed in or not.

`wirl visibility` with no level prints which of the three an app has. `wirl visibility private` and `wirl visibility org` change it, and so does an agent through `wirl_visibility`; both need a [full-scope sign-in](https://docs.wirl.dev/connections.md#tokens), as sharing does. Public is different on purpose: it opens the app to people nobody chose, so it is a decision a person takes with their own click, on the app's Access page in the dashboard. Neither the CLI nor any tool an agent can call will do it, and there is no flag that makes them: asked to, they answer with that page's address.

`wirl deploy` and `wirl_deploy` end by saying which of the three the app has (`visibility` in `--json`): the level the edge was given when the deploy finished, read as it wrote the app's address, so a change someone made while the deploy ran is the one it reports.

A form post, or any other request but a `GET`, `HEAD` or `OPTIONS`, that another app's page or another site sends to an app arrives as if the person were signed out: an app that is not public never sees it, since wirl answers it with sign-in, and a public app gets it with `x-wirl-user-id` and the other identity headers empty. A [public path](https://docs.wirl.dev/public-paths.md) is served signed out whoever asks, so there such a request reaches an app that is not public too. The app's own pages, links into the app and scripts outside a browser are unaffected. A page that sets its own `no-referrer` referrer policy makes its own form posts arrive signed out too, and in some browsers its beacons and its `fetch` calls in `no-cors` or `same-origin` mode, so leave that policy to wirl.

Three things are left to the app. Answer a post with a redirect, never a page: on a public app, Chromium sends another page's post again as the app's own, with the person signed in, when the page it was answered with reloads itself from script, and after a redirect a reload asks for the page and sends nothing. A service worker should leave page loads, and every request but a `GET`, to the network. Another page reaches an app's service worker only through a page load, and a worker that passes one on sends it as the app's own, with the person signed in: some browsers do so for `fetch(event.request)`, and every browser for a request the worker builds or changes. A worker that answers page loads also keeps wirl's sign-in from finishing. And a cookie the app sets for itself arrives with another app's post whatever its `SameSite`, because every app is the same site, so an app that keeps its own session in a cookie must check that a post's `Origin` is its own.

A cookie the app sets in a `Set-Cookie` header is kept to the host that set it. wirl removes any `Domain` attribute from the app's `Set-Cookie` headers and passes every other attribute on as the app wrote it, so the browser sends the cookie back to that host alone: not to another app on wirl.run, and not from an app to one of its previews or back. A page's own script is not held to this. Until wirl.run is on the Public Suffix List, another app's page script can still set a cookie for all of wirl.run (`document.cookie` with `domain=wirl.run`), and this app will receive it, so never treat a cookie's presence as proof the app issued it: check a session cookie against what the app stored, for example by signing it or looking it up. A cookie set for all of wirl.run before wirl began removing the `Domain` stays until it expires unless page script clears it, because wirl removes the `Domain` from a `Set-Cookie` that deletes a cookie too. Page script can clear one that is not HttpOnly (`document.cookie = 'name=; Max-Age=0; domain=wirl.run; path=/'`), but in Chromium and Firefox an HttpOnly one stays until it expires, so give a session cookie a new name rather than reuse one that was set for all of wirl.run.

For the same reason wirl takes the `"cookies"` and `"*"` directives out of an app's `Clear-Site-Data` header, and drops the header when nothing else is left, so the header clears no cookies: an app clears its own by expiring them with `Set-Cookie`. Chromium clears cookies for the whole registrable domain, which is all of wirl.run until it is on the Public Suffix List, so one app's header would clear every app's cookies and sign the person out of each. `"cache"`, `"storage"` and `"executionContexts"` clear only the app's own origin and arrive as the app sent them; an app that sent `"*"` and wants those cleared names them.

## Roles on an app

Sharing an app names someone as an editor or a viewer:

    wirl share dana@company.com

That person can now open the app, as a viewer; add `--role editor` when they should be able to deploy it too. An agent does the same with the `wirl_share` tool, and a person with the share form on the app's Access page. Sharing again with someone who already has access, or whose invitation is waiting for their first sign-in, leaves the role they have unless you name one; naming a role sets it, an editor lowered to viewer included, and replaces any invitation still waiting for them. When a share keeps a role or changes one, the answer says so, and from what when it changed one, and the organisation's Activity page shows the role and the one it was before. A role that is neither `viewer` nor `editor` is refused with a sentence naming them, and nothing is shared. The command line and the local MCP server do this from 0.2.2 (`npx wirl` fetches the newest), as do the API, the dashboard and the hosted `/mcp`; an older command line names `viewer` itself when you name no role, which lowers an editor. The roles:

- **Owner** — can view it, deploy it, and everything administrative on top: change who can open it, share and unshare, transfer it, move it to another org, delete it.
- **Editor** — can view it and deploy it. Cannot change who can open it, share it, transfer it, move it, or delete it. The code an editor deploys is handed the address and name of everyone signed in who opens the app, in the [identity headers](https://docs.wirl.dev/contract.md#who-is-asking), so anyone you make an editor can learn who uses it.
- **Viewer** — can view it. Nothing else.

Taking a share back names the person too:

    wirl unshare dana@company.com

An agent does the same with the `wirl_unshare` tool, and a person with the app's Access page. It takes the whole share away, one person at a time — to change a role instead, share again with a role — and it withdraws an invitation that is still waiting for the address. On a deleted app, while it can still be restored, the same people may take a share back, and a restore brings none of it back. On a live app, whoever may share may unshare: the app's owner while a member of its organisation, or an organisation owner or admin, always with a full-scope sign-in. Finding nothing to take back is said as nothing, and the organisation's Activity page shows what was taken. A script reads what was found from `wirl unshare --json`: `shareRevoked` and `invitationWithdrawn` are each `true` only for what was taken, and `appDeleted` says the app was deleted when this was asked. `revoked` is always `true`, kept for older callers: it says only that the request was carried out, so read the other two.

## Org roles

An organisation has owners, admins and members. An org's owners and admins each count as an owner of every app in that org, whether or not anyone shared it with them by name — so putting an app in an org puts it within reach of whoever administers that org. A plain member gets no standing role on an app unless it is shared with them, except that an org-visible app is readable by every member; reading it grants no right to change or deploy it.

An organisation made for a company's email domain is made by the first person who signs in with a verified account on that domain, as its owner, and everyone who signs in from the domain after them joins it at that sign-in: as a member, or as an admin if an invitation waiting for them says so. Someone removed from it, or who leaves it, does not rejoin by signing in: they stay out until an owner or admin adds them back, and their own sign-in lands them in an organisation of their own.

## Which organisation a sign-in lands in

wirl puts people together by the company domain Google vouches for, which only a Google Workspace (or Cloud Identity) account carries. Everyone who signs in from a Workspace domain such as `acme.com` lands in that domain's organisation, as above. A Gmail address, or any Google account that is not on Google Workspace, carries no company domain, so its first sign-in makes it an organisation of its own, with the person as its owner and a short address-style name taken from the part of the address before the `@`. Two people on gmail.com are never put together by it.

The same happens at a company whose mail is not on Google Workspace, Microsoft 365 say, when its people sign in with Google accounts made for their work addresses: Google does not vouch for the domain, so each lands in an organisation of their own. To work together, one of them makes an organisation with `wirl orgs create <slug>`, which makes them its owner, and adds the others with `wirl members add <email>`, or shares each app by email. Both need a [full-scope sign-in](https://docs.wirl.dev/connections.md#tokens). Someone added who has not signed in yet joins at their first sign-in, and everyone added keeps their own organisation too. A new app in an organisation made that way, or in a personal one, starts private, as the top of this page says, so the people added see what is shared with them or set to everyone in the org.

Removing someone from an organisation, or leaving it, ends the membership and what it gave: the apps there open to everyone in it, its connections, and an owner's or admin's rights over every app there. It revokes no share: an app shared with them by name stays shared, and still opens for them, and an editor's share still deploys it, with the connections the app is granted, until the share is revoked, as on the app's Access page. For an owner or admin, the removal's confirmation page and `wirl members remove` list those apps, and the deleted apps there that are shared with them by name too, while those can still be restored: deleting an app revokes none of its shares, and a restore brings each back. Each deleted one is marked "deleted; a restore brings this share back", with the day its data is kept until, and linked to a page that revokes its share, before the removal or after it.

An app's owner holds the owner's rights only while they are a member of the app's organisation. Removed from it, they lose them at once: a sign-in or token they kept no longer lets them administer the app, deploy it or open it as its owner, and a visit already open in their browser keeps the owner's role only until its next renewal, as any access taken away does ([Limits](https://docs.wirl.dev/limits.md)). A preview's visit link they printed before they were removed is the exception: it still lets whoever opens it into that preview as its owner, for up to 29 minutes after it was printed. The app stays with the organisation's owners and admins, who can transfer it to someone else. An app shared with them by email stays shared: a share is not ownership.

## Sharing before someone has signed in

Sharing works even for a colleague who has never used wirl: the invitation waits for their first sign-in and is theirs the moment they arrive.

## Transfer and move

    wirl transfer dana@company.com

Hands the app to a new owner. The previous owner keeps editor access, as a share, if they are a member of its organisation when it is handed over; an owner the organisation has removed gets none.

    wirl move --to acme

Moves the app to another organisation you belong to. Its owner holds the owner's rights there only if they belong to that organisation too. When they do not, the move form on the app's Settings page says so before you move it, and `wirl move` says so after.

Its connection grants do not go with it: a connection belongs to the organisation that made it, so the move drops every grant the app holds, requested and revoked ones included, and `wirl move` names the connections it dropped. The app's next deploy asks for the connections its `wirl.json` declares in the new organisation, as any deploy does. A grant never opens a connection for an app outside the connection's organisation, so a deploy, a preview or a connection added on the app's page while the app is being moved leaves it nothing in the organisation it left: a grant written before the move is dropped by it, and one asked for after it is refused, saying the app was moved.

## Where this lives in the dashboard

An app's **Access** page holds its visibility setting, the people it is shared with and their roles, and the share form. Its **Settings** page holds the transfer form and, once you belong to more than one organisation, the move form. Both are visible only to someone who administers the app. Settings also holds the switch for the wirl badge: the badge on the app's pages reads "Built with wirl" and names no one, the owner included, and someone who administers the app can turn it off there. Settings also holds Delete, which asks for the app's name typed back; a deleted app can be restored for 30 days from the Recently deleted list on the organisation's tools page. An organisation's **Members** page is its roster: who belongs, their role, and a form to change either.

# Logs

Source: https://docs.wirl.dev/logs.md

## What is captured

Everything your server prints with `console.log`, `console.info`, `console.warn`, `console.error` and `console.debug`, and every uncaught exception, for every request: a page load, a webhook, a schedule run, a flow's step. Nothing to install and nothing to declare. The scaffold's server prints one line per request so a fresh app is never silent.

Lines are kept for seven days, grouped by the request that printed them, with what wirl knew about the request: who made it, whether a webhook was verified, which schedule run it was, the status the app answered, and the deployment version that was live.

A request made by someone signed in names them by first name, or the start of their email address, if you are a member of the app's organisation or made the request yourself. Anyone else who can read the logs, such as someone with an editor's share on the app and no seat in the organisation, reads `someone else`, as they read `made by someone else` of a [connection](https://docs.wirl.dev/connections.md)'s maker. Under `--json`, `who` says the same, and `userId` is that person's user id if you are a member of the app's organisation or made the request yourself, and `null` otherwise, as it is when nobody was signed in. If wirl no longer has an account for them, it reads `someone who has left`. The Logs page and a request's own page on the dashboard name them the same way.

Deployments made before logging existed have no lines; deploy again and they start.

A request wirl refuses before your app is called is not an invocation, so it is not in the logs: a [public path](https://docs.wirl.dev/public-paths.md#when-the-ceiling-refuses)'s 429 over its ceiling, for one, which is counted as `public.throttled` instead, one line a minute at most, under Turned away at the edge on the app's Logs page, for anyone who can deploy it, and on the organisation's Activity page.

Logs are what your app printed, as far as Cloudflare kept them. Cloudflare's Workers Logs can miss lines, all of a request's among them: in one of wirl's own tests, a flow run of 3,001 steps left no line at all for 7 of them, though every step ran and the run recorded each one. So a count taken from logs can come up short, and the error counts the dashboard shows and wirl mails an app's owner are taken from them. A run's record is `wirl runs show`, whose `--json` has every attempt, and the run's page, not its logs: count a run's steps there, never from `wirl logs`.

## Reading them

    wirl logs                 the last hour, newest first
    wirl logs --errors        only requests that failed or printed at error level
    wirl logs --since 24h     further back: 15m, 1h, 24h, 7d
    wirl logs --follow        keep watching: every two seconds, everything since the last look
    wirl logs --ray <id>      the one request behind a cf-ray header
    wirl logs --run <id>      the one schedule run, or every step of a flow run
    wirl logs --kind flow     one kind of request: page, webhook, schedule, flow or other
    wirl logs --search text   requests whose path or lines contain the text, or whose status it is
    wirl logs --cursor <n>    the page before a 'More before this' line

`--follow` starts with the newest five pages of the window `--since` opens, not all of it, then reads back to the last request it printed each time it looks, up to twenty pages. Whatever either bound leaves, it names on stderr, with the `wirl logs --since ... --cursor ...` command that reads it.

`--search` looks at three things the list shows of each request: its path, its status when the text is three digits such as `404`, and the text of each of its lines, though not the fields your app logged beside that text (shown after it as JSON) nor a thrown error's stack. It never searches who made a request, nor the rest of what Cloudflare keeps about it, such as its query string and its headers, so it finds a person's email address only in a path or in a line your app printed. A search is at most 1,024 bytes. The Logs page's search box and the `wirl_logs` tool's `search` work the same way.

Each request is one block:

    2026-09-17T05:57:04Z  POST /boom   Anyone   500 · exception · 0 ms   v3   ray a3c5df0f3a665163
      log    probe: handling POST /boom
      error  probe: boom on purpose
             at Object.fetch (worker.mjs:7:13)

Times are UTC. A flow's step names its run where a request names its ray: wirl sends a step from inside Cloudflare, not through the edge, so a step has no ray. After the run come the step's `n`, from the `x-wirl-n` header wirl sends with each step, and its name, `failed` or `cancelled` for wirl's own last calls to the app: `run run_4vqabcdefghijklmnopqrstuvw n 2 step decide`. A step wirl sent before it sent `x-wirl-n` shows no `n`, as `run run_4vqabcdefghijklmnopqrstuvw step decide`: Cloudflare's logs hide the value of the one other header that holds it. A step's page on the dashboard names it the same way.

A thrown error's stack names `worker.mjs`, the one file wirl bundles your server module and everything it imports from your files and packages into, with that file's line and column, not your `server.mjs`'s: `worker.mjs:7:13` above is line 7 of the bundle. wirl makes no source map, so it does not map a frame back to your own files; search them for the function the frame names, or for the message above it. `wirl dev` runs a bundle of its own, `.wirl/worker.mjs`, whose lines can differ from the deployed one's.

A coding agent reads the same thing through the `wirl_logs` MCP tool, and should, before changing code, whenever a deploy misbehaves or a schedule or webhook failed.

On the dashboard, the app's **Logs** page shows the same list with filters, and its Overview shows the last errors. Both are for people who can deploy the app: a line can contain whatever the app chose to print.

## When the app does not answer

When wirl gets no answer from your app that it can pass on, as when the app runs out of CPU time, it answers `502` itself. The cause it was given can be your app's own exception text, so that page names it only to someone whose pass or visit link says they may deploy the app. A pass says what its holder could do when it was made or last renewed, so someone moved from editor to viewer is still told until it renews or runs out, within 13 minutes, as when their access is removed ([Limits](https://docs.wirl.dev/limits.md)). A preview's visit link is a sign-in as whoever made it, so anyone who opens it is told on that preview. The pass it gives says what its maker could do when the link was printed, not when the pass was made, so a link printed before a move from editor to viewer still tells its opener for up to 29 minutes after it was printed, as [Limits](https://docs.wirl.dev/limits.md) says of a removal. Everyone else gets only `The app is not responding.` and, when it has one, the request's ray: an anonymous visitor, a viewer, every caller of a public path, whoever they are, and a schedule run, whose note is that answer. When it is wirl's own call into the app that failed, everyone gets `The app did not answer.`, with the ray when it has one, and no cause. Look the ray up with `wirl logs --ray <id>`. A public app never sends a visitor to sign in, so on a public app you are told the cause only if you hold a pass for it already.

## What the app called

`wirl egress` lists the calls the app made out through wirl, newest first, whether wirl let them through or refused them:

    wirl egress               the last hour
    wirl egress --since 24h   further back: 15m, 1h, 24h, 7d
    wirl egress --run <id>    one flow run's calls, or one schedule run's
    wirl egress --cursor <n>  the page before a 'More before this' line

Each call is one line:

    2026-10-03T04:30:22Z  POST api.stripe.com/v1/refunds/re_42   stripe   200 allowed   run run_4vqabcdefghijklmnopqrstuvw n 2 attempt 2

A line says when (UTC), the method and host, the connection, the status, and `allowed` or the code the call was refused with, such as `not_allowed` for a host the connection does not cover or `no_key` for a connection still waiting for its key. A flow step's call also names its run, the step's `n`, its attempt, and the path it called, so two calls of a retried step are told apart. The path is never the query string, where some vendors take a key, and no header or body of a call is recorded. A vendor that takes its key in the path itself, as a webhook URL does, has it recorded. A preview's calls are listed with the app's, and `--run` tells them apart. A flow step's call refused because its deadline had passed is not recorded at all.

Only the app's own calls, for the people who can deploy it, as its logs are, and at most seven days back. A coding agent reads the same through the `wirl_egress` MCP tool. The organisation's Activity page lists them too, for an admin.

## What not to log

A secret, a whole request body, or a person's data you would not want sitting in a seven-day-old line. A line per request is free; a line per loop iteration is not, and the plan's monthly allowance is shared by every app in the installation.

# What wirl does not do yet

Source: https://docs.wirl.dev/limits.md

What wirl cannot do today, so you find out here and not at your first deploy.

- **JavaScript or TypeScript only.** Server-side apps run as Workers: no Python, no other language, no processes, and no disk (Node's built-ins work, but what `node:fs` writes is gone by the next request).
- **No packages with native code** (`sharp`, `better-sqlite3`, `bcrypt`): the server module is bundled from pure JavaScript, 10 MB at most. Ordinary npm packages work.
- **A deploy's bundle has size limits.** wirl takes a bundle of at most 100 MB, which may unpack to at most 300 MB (counted as the archive's bytes, so a little more than the files' own sizes), with at most 5,000 files in at most 10,000 folders and 50 MB a file; the bundled server module is at most 10 MB. A hard link counts as a whole copy of the file it names. Past any of them the deploy is refused before anything is stored, and the message says which: leave out what the app does not serve, or list it in `.wirlignore`. A bundle `wirl deploy` did not build is also refused for a path that appears twice, a hard link to anything but a file before it, more than 64 KB in one tar extended header or 4 MB in all of them, a file given two different sizes in its headers, or one size for every file in a global header.
- **2 uploads at once from one person, and room for 4 large deploys.** app.wirl.dev takes at most 2 deploys and previews at once from one person, whatever credential each comes with. Once its body is in, a deploy needs room for the most one can hold (450 MiB: the bundle, its files, and the largest file once more), and there is room for 4; it keeps that room until its files are counted, then what they come to until they are stored, then only its bundle and its migrations, so slow deploys and slow migrations leave room for others. One person's deploys hold at most 550 MiB of that room at once (one largest deploy, and one largest bundle beside it): a deploy that would pass it waits behind their own, and holds up nobody else. A deploy without room waits its turn, in order, for up to 60 seconds. Past any of these an upload is refused with `503`, `"error": "too_many_uploads"` (one person's) or `"uploads_busy"`, and a `Retry-After` of 30 seconds, before anything is ratified or deployed.
- **At most about 401 MiB of uploads arriving at once, about 100 MiB of them one person's.** Counted as the bytes arrive, so a slow sender holds only what it has sent; one person's uploads arriving together hold at most one largest upload (the 100 MiB bundle beside its wirl.json), a quarter of the whole. Past either, the upload is refused part-way with `503`, `"error": "too_many_uploads"` (one person's share) or `"uploads_busy"` (the whole), and a `Retry-After` of 30 seconds, nothing ratified.
- **At most 2 pulls at once to one person, and 4 in all.** `wirl pull` and `wirl_pull` ask app.wirl.dev for an app's source, and it is read from storage and sent file by file, so one pull holds one file at a time (50 MB at most, and about three times that while storage reads it) and a small compressor, never the whole archive, and writes nothing to disk. Past 2 under way for one person, or 4 on the control plane, a pull is refused before any file is read, with `503`, `"error": "too_many_pulls"` (one person's) or `"pulls_busy"`, and a `Retry-After` of 30 seconds, and the refusal is not recorded as a pull. A pull is cut 10 minutes after it began, however it is being read, and a client that takes none of what is waiting to be sent for about 2 minutes is dropped. A pull cut either way ends short of a whole archive, so `wirl pull` and `wirl_pull` say the pull was cut, write nothing to the folder, and fail instead of finishing; run it again. Who may pull is decided when the pull begins.
- **An upload that sends nothing for 30 seconds is stopped** with `408` and `"error": "upload_stalled"`, before anything is ratified or deployed. Any request to app.wirl.dev must arrive whole within 5 minutes, so a 100 MB bundle needs about 3 Mbit/s.
- **Other requests to app.wirl.dev's API and dashboard carry at most 1 MiB.** A route that reads a body past it, JSON, a form or anything else, refuses the request with `413` and `"error": "too_large"` (in plain text outside `/api/`, and as a JSON-RPC error on `/mcp`); only a deploy's and a preview's upload are larger.
- **30 seconds of CPU per request, schedule run or flow step**, which wirl does not raise for an app's Worker.
- **10,000 outgoing calls per request, schedule run or flow step**, with six open at once (more queue). That is Cloudflare's default for a Worker's subrequests, which wirl does not change for an app's Worker. wirl adds its own to each outgoing call: the vendor has 60 seconds to start answering, and an answer is at most 25 MB (one that says up front it is larger is refused with a `502` before any of it is read). Every answer except an event stream (next point) is read whole before your app gets it, so for those the 60 seconds cover the whole answer, and one that grows past 25 MB is refused with a `502` too.
- **Database queries per request are bounded in practice by time.** Cloudflare's D1 limits page lists 1,000 queries per Worker invocation on paid plans. Measured on wirl on 2026-10-07, one request ran 2,000 `env.DB` queries in a row and every one succeeded, at about 35 ms each (900 in 31.8 seconds, 1,001 in 35.1 seconds, 2,000 in 71.4 seconds); a run of 5,000 was still going when the caller stopped waiting. Each query is a round trip, and a batch is one call, so write many rows with `env.DB.batch([...])` rather than a loop of single queries.
- **A vendor's event stream reaches your app as it arrives.** A `2xx` answer sent as `text/event-stream` or `application/x-ndjson` (server-sent events from Anthropic, OpenAI, Mistral, Groq and Gemini's `streamGenerateContent` with `alt=sse`, and Ollama's newline-delimited JSON) is handed to your app piece by piece, so a model's reply can be passed on to the visitor as it is written. An answer with any other status is read whole, whatever its type. A streamed answer has 60 seconds to start, then 13 minutes for the rest, and is cut once it passes 25 MB. It is also cut when nothing arrives on it for about two minutes. That bound is Cloudflare's, not wirl's: the call from your app out to wirl is closed after about 125 seconds with nothing on it (measured; Cloudflare does not document it). wirl's own, when nothing arrives on it for 4 minutes, is the one that applies under `wirl dev`. Either also happens when your app stops reading the stream for that long, because the vendor then waits for your app. An app may have 20 streamed answers open at once: the next call that would stream is refused with `429` and `"error": "busy"`, and since only the vendor's answer says it is a stream, that call has already reached the vendor by then. How a cut looks to your app depends on where it happens, and the status arrived long before, so it cannot say so. When wirl cuts a stream (past 25 MB, or at 13 minutes), the end your app reads can look as if the vendor had finished, as it did on a deployed app before wirl wrote the event below. When the call goes quiet for about two minutes, your app's read fails with a network error. Before it ends an event stream (`text/event-stream`) that it has cut itself, wirl writes one last event. It starts with two line ends, the first ending a half-written line and the second dispatching the half-written event as far as it got, then `event: error` and `data: {"type":"error","error":{"type":"api_error","message":"wirl cut this stream: it reached wirl's limit of 25 MiB for one answer."}}`. The message names the bound that fired: the 25 MiB (the 25 MB above), 13 minutes after the vendor began answering, 14 minutes after wirl began passing it on (when nothing is reading the stream), or, under `wirl dev`, 4 minutes with nothing on the stream. It has the form Anthropic's API sends for an error in a stream. When the cut falls between events, Anthropic's and OpenAI's SDKs read it as an error from the API and throw it (checked against their public source in October 2026); a size cut usually falls inside an event, and then they throw a JSON `SyntaxError` on the half-written event first. Either way the read fails. In a browser an `EventSource` receives it in `addEventListener('error', ...)` as a message, apart from the plain `error` event it gets when the connection fails: a handler written for connection failures runs for it too, so tell them apart by `event.data`, and call `close()` on this one, or the browser reconnects and your app asks the vendor again. wirl counts the event inside the 25 MiB, so an event stream is cut once it passes 25 MiB less 512 bytes. It waits up to 5 seconds for the event to leave the server before it ends the stream, which it then does as it did before, so a client that reads more slowly than that can still lose the event. The event is not written for newline-delimited JSON, for a compressed stream (one that declares a `content-encoding` other than `identity`), or when the vendor finishes or breaks off, your app has gone, Cloudflare cuts the call (above), or an update ends the stream. So do not take the end of the stream for the end of the answer, and do not rely on the event alone: look for the vendor's own last event (Anthropic's `message_stop`, OpenAI's `data: [DONE]`), and treat a stream that ends without it as cut. Under `wirl dev`, every cut ends the read with an error. An update to wirl or to Cloudflare's runtime can also end a long stream early, so a reply worth keeping should be one your app can ask for again. Server-sent events can arrive split across reads, and several can arrive in one read: keep what you have read, split it on line ends, and treat a blank line as the end of an event, rather than taking each read as one event.
- **What your app sends on an outgoing call is at most 100 MB.** wirl's control plane refuses a larger body with a `413` and `"error": "body_too_large"`, and nothing of it reaches the vendor. How that answer reaches a deployed app, through the call from your app out to wirl, has not been measured yet: the app may see a `502` or a closed connection instead. The body is held whole on its way out, by a Worker with 128 MB of memory, so a smaller one may still fail; wirl has not measured where.
- **The bodies of an app's outgoing calls held at once are at most 128 MiB, those of every app one owner has together at most 256 MiB, and all apps' together at most 512 MiB.** They are counted as the bytes arrive, from a call's first byte until wirl has answered it, or, when the answer is streamed, until the stream has ended, so a slow sender counts only what it has sent, and a call that will be refused for its grant counts too. An app's owner is the person who owns it, while they are still a member of the app's organisation, read from wirl's database before the call's body is read and remembered for 30 seconds, so an app that is transferred, or an owner who leaves the organisation, reaches calls that begin more than 30 seconds later; a call already under way is given back under the owner it began with. The apps of one owner count together in however many organisations they are, so one person's apps can hold at most half of the whole. A call whose app has no such owner (it is gone altogether, or its owner has left its organisation) is counted in its app's share and the whole's only, and `wirl dev`, which runs one app, counts no owner's share. When that read cannot get a database connection, because none comes free in time or the database refuses, the call is answered as any request to app.wirl.dev is that cannot, a plain-text `503` with a `Retry-After` of 5 seconds that says nothing was changed, before any of its body is read and with nothing sent to the vendor. Past any of the three, the call is stopped part-way with a `503`, `"error": "app_bodies_full"` (the app's own, its previews' calls with them), `"owner_bodies_full"` (its owner's apps') or `"bodies_full"` (all apps'), and a `Retry-After` of 10 seconds, and nothing of it reaches the vendor. A body that sends nothing for 30 seconds, or has not all arrived after 120 seconds, is stopped part-way too, with a `408`, no `Retry-After`, and `"error": "body_stalled"` or `"body_too_slow"`, and nothing of it reaches the vendor. How these answers reach a deployed app has not been measured yet: as with the `413` above, the app may see a `502` or a closed connection instead. Like the limits under One control plane (below), they are counted in that one instance's memory.
- **The answers an app's outgoing calls hold at once are at most 64 MiB, those of every app one owner has together at most 128 MiB, and all apps' together at most 256 MiB.** Every answer but an event stream is read whole before your app gets it (above), so wirl counts its bytes as they arrive from the vendor, until wirl's server has taken the last of them to send to your app, or your app has gone. A call, but a `HEAD`, whose answer carries nothing, is sent only while its app's answers, its owner's apps' answers, and all apps' answers leave room for one more answer at the cap, 25 MiB: with less, it is refused with a `503`, `"error": "app_no_answer_room"` (the app's own, its previews' calls with them), `"owner_no_answer_room"` (its owner's apps') or `"no_answer_room"` (all apps'), and a `Retry-After` of 10 seconds, and nothing of it reaches the vendor, so it is safe to send again. Answers arriving at the same time can still take that room first: an answer that would take its app's answers, its owner's apps', or all apps', past the most is then stopped part-way with a `502`, `"error": "app_answers_full"`, `"owner_answers_full"` or `"answers_full"`, and no `Retry-After`, and none of it reaches your app. That refusal comes after the vendor has received the call and may have acted on it, which it may bill for: send it again only if repeating it is safe, and see [Connections](https://docs.wirl.dev/connections.md) for SDKs that send a call again by themselves. An answer that wirl's server has not taken to its last byte 120 seconds after handing it back, because your app reads it slowly or not at all, is cut there, deployed and under `wirl dev` alike: your app's read fails, rather than ending as if the answer were whole. An event stream holds a little at a time, and is not counted. An app's owner is counted as for the bodies, above. Like the limits under One control plane (below), they are counted in that one instance's memory.
- **Responses stream; requests do not, and there are no WebSockets.** A response is handed on as your app sends it, so server-sent events and large downloads your app produces itself work, deployed and under `wirl dev` alike. An answer your app relays from another service streams the same way when the vendor sends it as an event stream (see above); any other answer from an outgoing call is read whole first, so passing it on sends it all at once. Two kinds of response are still read whole: an HTML page that carries the wirl badge, because the badge goes before the page's closing tag (at most 25 MB). Turn the badge off in the app's Settings, or send a `Content-Security-Policy` header of your own, and that page streams too; a preview never carries the badge. The other kind is a preview's own HTML page, which carries its preview marker by the same rule — a preview page you gave your own `Content-Security-Policy`, or that is not a 200, streams too. A `Content-Length` header your app sets is not passed on: Cloudflare announces the length of a body whose length it knows (a string, a buffer) and sends anything else in chunks, so a number your app got wrong cannot leave a visitor waiting. Everything a private or org app answers goes out with `Cache-Control: private, no-store`; on a public app, send `Cache-Control: no-cache` on a stream yourself, so that nothing between you and the visitor holds it back to cache it. If your stream throws part-way, the visitor's response simply ends early: the status has already gone, so there is no error page. A `2xx` event stream or NDJSON answer (`text/event-stream`, `application/x-ndjson`) with no `Content-Length` of yours still counts towards rolling a broken version back automatically, once, as a `5xx` answer does, unless it breaks 100 seconds or more after the last piece of it wirl received, its headers included: that is how Cloudflare cutting a quiet stream looks, so such a break is not counted. A break while your stream is still sending is counted, a Cloudflare restart included; if your app relays a vendor's stream and keeps sending heartbeats of its own, end your stream cleanly when the vendor's call is cut, or that break counts too. A visitor who leaves part-way does not count either. Any other body that breaks part-way after its status has gone does not count: one with a `Content-Length` you set, and a stream of any other type, such as a chat answer the AI SDK streams as `text/plain` or a CSV export, are handed on exactly as your app answers them, so that a body whose length Cloudflare knows keeps it. The exception, with your stack, is in the [logs](https://docs.wirl.dev/logs.md). A request's body is read whole before your app is called. A WebSocket upgrade is not passed through.
- **A signed-in visit lasts 12 minutes, and renews itself while the page is in use.** Once a visit is 8 minutes old, the next request the page makes to its own app asks app.wirl.dev whether the person may still open it, and if they may, the visit starts again. Neither the page nor your app has to do anything. So a live feed or a dashboard that refreshes keeps working for as long as it makes a request at least every 16 minutes; an `EventSource` reconnecting after a stream ended counts. An `EventSource` whose stream your app keeps open needs nothing either: once the visit it opened on is 8 minutes old, wirl ends that stream cleanly between two events, and the browser reconnects by itself a few seconds later, which renews the visit (give your events an `id:`, and the reconnect's `Last-Event-ID` header says where to carry on). wirl knows an `EventSource` by what the browser sends for one, a `GET` whose `Accept` header is exactly `text/event-stream`, so a `fetch` that sends exactly that is treated the same way; a `POST`, or an `Accept` that lists other types too, is never ended. Only the page's own requests renew a visit (its `fetch` and `EventSource` calls to the same app, a page load, or a client outside a browser), never a request from another app's page. Removing someone's access still takes effect within 13 minutes: their next renewal is refused and sends them to sign in, which no longer lets them in. Signing out of app.wirl.dev, or signing in to it again in the same browser, stops renewals the same way, and no visit renews past 14 days after the sign-in that began it. The exception to the 13 minutes is a preview's visit link the person printed before their access was removed, since opening one asks app.wirl.dev nothing: whoever opens it is let in as them until 16 minutes after it was printed (its fifteen, and the minute of clock skew every check allows), and keeps the pass it gives for up to 13 minutes (its 12, and that minute again), so that preview can be opened as them for up to 29 minutes after the link was printed. A visit that does run out (the page made no request for longer than that, it came from a preview's visit link, whose visit lasts 12 minutes and never renews, or app.wirl.dev could not be reached to renew it) sends the next request to sign in, on app.wirl.dev. A page load follows that and comes straight back signed in. A request a page makes by itself does not: a `fetch` fails, and an `EventSource` cannot get through. So a page meant to stay open should still reload itself when such a request fails, rather than retrying it for ever. Any other stream that is already open, such as one a `fetch` reads, is not cut when the visit runs out.
- **One control plane.** The rate limits it keeps (the verify step for signed public paths, the reports of what a `none` path's ceiling refused (ten an app a minute read, one written), sharing and inviting, and a flow's starts a second and the sends a second to one wait, under Flows below), an org's calls to D1 for its migrations, each person's listings of them and the count of applies of migrations under way (under Migrations below), the count of an app's open streamed answers, the uploads under way (2 a person, about 401 MiB arriving and about 100 MiB of it a person's, room for 4 large deploys), the pulls under way (2 a person, 4 in all), and the auto-rollback health count live in memory on a single instance. Running more than one would split them. The ceiling on `none` public paths is not one of these: it is counted at the edge.
- **Schedules have one-minute granularity; the clock ticks every 30 seconds, so a run can start up to 30 seconds after its minute.** See [Schedules](https://docs.wirl.dev/schedules.md).
- **No email triggers.** An app cannot be invoked by an incoming email.
- **No blob storage.** Beyond `env.DB`, an app has nowhere to put files, so a file goes in `env.DB`. Cloudflare publishes D1's limit as 2,000,000 bytes in one string, BLOB or row: design to that, and store a larger file in chunks, one row each. What deployed D1 did when wirl measured it on 2026-10-07, which is not a promise: it stored a single value of 8,000,000 bytes, refused one of 9,000,000 with `D1_ERROR: string or blob too big: SQLITE_TOOBIG`, and failed one of 20,000,000 sooner, with `Serialized RPC arguments or return values are limited to 32MiB`, a limit on what one call to `env.DB` sends or gets back. A batch is one call, so write a file of up to a few MB in one `env.DB.batch([...])`, and it is whole or absent: 8,000,000 bytes is the most one call has been measured to carry. A larger file goes in several batches of a few chunks each, then a last write that marks it complete (a row in a `files` table, say), and whatever reads files ignores one not yet marked. Read a file back a chunk or a few at a time. `wirl dev` refuses a single bound value over 2,000,000 bytes, so a single value over the published size fails there first. It does not add up a row, a value grown in SQL (`data = data || ?`), or what one call sends or gets back, so a row of several large values, a value appended to, or a batch or a read that carries many chunks at once can still pass there and fail deployed. A BLOB bound as a `Uint8Array` came back from `.first()` and `.all()` as a plain array of numbers, not a `Uint8Array` or an `ArrayBuffer`, and the 20,000,000-byte failure suggests an array of numbers is sent at about twice the bytes it holds, or more. `wirl dev` hands a BLOB back the same way, as a plain array of numbers, from `.first()`, `.all()`, `.raw()` and `.run()`. `new Uint8Array(row.data)` gives the bytes, and binding either it or `row.data` itself stores the same BLOB again. `wirl dev` also refuses two values D1's bind takes: a view other than a `Uint8Array` or `Uint8ClampedArray` (a `DataView` or another typed array), which D1 binds as its elements rather than its bytes, and an array of numbers holding a fraction. What D1 stores for either has not been measured.
- **A request's body has no cap of wirl's own, except on a public path.** wirl sets no limit on the body of any request to your app but a [public path](https://docs.wirl.dev/public-paths.md)'s (below), so an upload is bounded only by Cloudflare's own limits: Cloudflare publishes 100 MB for a request body on its Free and Pro plans, and more for Business and Enterprise, and 128 MB of memory for a Worker, which matters because a request's body is read whole (above). wirl has not measured where an upload fails.
- **No organisation-level form to add a connection.** Add one from an app's Connections page, or with `wirl connections create`.
- **Undeclared hosts are refused.** An outbound call to a host with no declared connection never leaves wirl.
- **A connection with no key yet refuses every call**, until someone adds the key.
- **Sharing and inviting are metered.** Sharing an app with, or inviting to an org, somebody who has no wirl account yet sends them an email, so it is limited: 50 such invitations a day per person and 200 a day per organisation. Past either, the share is refused with a message that says which limit was met, and the first refusal in an hour shows in Activity as `invitation.refused`. Separately, no address is sent more than 5 invitation emails a day, whoever sends them: past that the share still happens and waits for their first sign-in, but no email goes, and the answer says so (`mailed: false` from the API) so you can tell them yourself; those shares are counted too, 50 a day per person, apart from the ones that send mail. Sharing with somebody who already has an account sends nothing and counts against none of those. Every share or invitation of either kind is limited to 60 in ten minutes per person.
- **A public path's body is capped at 1 MB**, a verified one is rate-limited to 600 requests a minute per app, and a `none` path to 120 a minute per sender address and 600 a minute per app (counted per Cloudflare location, approximate by design). See [Public paths](https://docs.wirl.dev/public-paths.md).
- **An app holds at most 5 previews at once**, each gone 14 days after it was last deployed unless redeployed under the same name. A preview's own database starts with its tables from `migrations/` and no rows but the ones those files insert, and is never seeded from production. See [Previews](https://docs.wirl.dev/previews.md).

## Flows

What a [flow](https://docs.wirl.dev/flows.md) may do, and what happens past each limit. An app's previews together draw on pools of their own, apart from its live runs', so a flow tried out on a preview never uses up the live app's steps a day, steps at once or unfinished runs. Of the limits below, two count live and preview together: an org's unfinished runs, and its share of wirl's starts a second (Starts, below). One above does too: the 20 streamed answers an app may have open at once count a preview's with the live app's, so a preview's step that reads a model's stream holds one of them while it does.

- **A step is one request.** `step_timeout` is 5 minutes unless the flow says otherwise, at least 30 seconds and at most 15 minutes. wirl cuts a step off at its `step_timeout`, 60 seconds before Cloudflare would give up on it, and refuses the step's outgoing calls from 5 seconds after that. A step with less than 5 seconds of its time left is not sent, and wirl tries it again.
- **An answer** is at most 1 MiB (1,048,576 bytes) of JSON, and what wirl keeps of a step, your answer with its own record of the step, at most 1,044,480 bytes, so an answer within a few KiB of 1 MiB can still be refused: keep well under it. Past either, the run fails at once, saying to keep rows in `env.DB`. An answer, a run's input and an event's body nest at most 100 levels of objects and arrays.
- **A run's input** and **an event's body** are each at most 64 KiB. A finished run's output is kept up to 64 KiB; past that the run still finishes, and its `output` reads `null` with `outputTruncated: true`.
- **A step that fails** (it throws, runs out of time, or answers `5xx`) is tried again `retries` times: 5 unless the flow says otherwise, at most 10. The tries wait 10 seconds, 1 minute, 10 minutes, 1 hour and 6 hours, and 6 hours before each try after those. A `Retry-After` the step answers with is honoured, held between 10 seconds and 6 hours, and after a step that ran out of time the next try waits at least 90 seconds, since the first may still be running.
- **A sleep, a wait's timeout and a `retry` delay** are each at least 30 seconds and at most 30 days. A step may answer `retry` at most 30 times; one more fails the run.
- **Steps in a run.** Every try of a step counts, and so does every wait for an event, for a slot (below) or for the day's pause to end; a sleep does not. Cloudflare's default is 10,000; wirl stops a run at 9,900 and says why: end it, and start another that carries on from its state.
- **What a run keeps.** Cloudflare keeps at most a gigabyte for one run. wirl stops a run before its answers and the events its waits heard come to that, and says why: keep rows and messages in `env.DB`, and ids in `state` and in event bodies.
- **Unfinished runs** (every run that is not done, failed or cancelled): 10,000 for an app's live runs, 1,000 for all of its previews together, and 50,000 for an org, which counts both. A start past one is refused with `409`, saying which.
- **Steps at once:** 50 for an app's live runs, and 10 for all of its previews together. A step past them waits its turn, trying again after 15 seconds and then twice as long each time, up to 10 minutes, each wait up to a tenth longer; each of those waits counts as a step of the run, and toward the day's steps.
- **Starts:** 10 a second for an app's live runs and 10 a second for its previews together. Across everyone, wirl takes at most 21 starts a second today, shared so that no org holds more than about half: an org that has started more than 10 in the current second, live and preview together, gets no more in it, so one org holds at most 11. Past any of these a start is refused with `429` and a `Retry-After` in seconds: wait that long, then start it again. The refusal says which it met: `"error": "too_fast_app"` for the app's, and `"too_fast_all"` for wirl's, an org past its share of it included. A start that repeats a flow's key is answered with the run it found and counts toward none of these.
- **Steps a day:** 50,000 for an app's live runs and 10,000 for all of its previews together, counted apart, by UTC day. Each try of a step counts one, whatever came of it, and so does each wait for a slot or for the day's pause to end, and each `failed` call; a sleep, a wait for an event and the `cancelled` call count none. Past either, the runs it counts pause, never fail, until 00:00 UTC, and the app's owner is told once that day.
- **Events:** at most 10 a run has taken and not yet delivered; at most 10 sends a second to one wait, of the event it waits for; a body of at most 64 KiB; a name of 1 to 100 letters, digits, dashes or underscores. A wait takes no send in the 5 seconds before its timeout.
- **The `cancelled` call** has 30 seconds.
- **A failed run** can be retried from 4 minutes after it failed until 3 days after, while the run is still kept (below). The 4 minutes are there so that nothing the failed go sent can still land in the new one.
- **Flows:** at most 20 an app, each named with 1 to 40 lowercase letters, digits or dashes.
- **`--fast`**, on a preview's runs only: every sleep, `until` and `retry` delay becomes 5 seconds, every wait's timeout 60 seconds, and every rung of the retry ladder 10 seconds, a `Retry-After` on a `5xx` answer included. The 90 seconds after a step that ran out of time stays, as do the 4 minutes before a failed run can be retried.
- **What is kept:** wirl has Cloudflare keep a finished run 1 day and a failed one 3 days. wirl keeps a run's record, with its steps and events, for 30 days after it ends, and a failed run's input for 3 days. The input of a run that finished or was cancelled is dropped when it ends.

## Migrations

How much the files in [`migrations/`](https://docs.wirl.dev/contract.md#tables-migrations) may hold, and how wirl applies them.

- **The folder** holds at most 200 files and 2 MiB (2,097,152 bytes) in all, counted before any file is read. A file holds at most 500 statements, and a statement at most 100,000 bytes of UTF-8, D1's own limit.
- **Each request to D1 has 30 seconds**, Cloudflare's limit, for everything in it. On a database already on migrations, wirl sends one file a request, so one file's statements share the 30 seconds; on a new database it sends as many files together as fit in 500 statements and 4,000,000 bytes, wirl's own statements included and counted as the request carries them. A file is never split: one that does not fit beside others goes alone, and one too large to go even alone is refused before anything is sent. A request that runs past 30 seconds is cut off by Cloudflare with nothing of it applied, and the deploy fails.
- **An apply** sends no more files once it has been applying for 2 minutes, and never cuts off a request it has sent; the deploy then fails, naming what applied and how to apply the rest.
- **At once:** one apply at a time touches a database, at most 2 of an org's databases are applied to at once, at most 2 applies run for one person, whatever the org, and at most 16 on one control plane. Of the org's 2, one app takes at most 1 at a time, its live database and its previews' together. A deploy that brings no file its database has not applied does not wait for the org's 2, the app's 1 or the person's 2: it reads the database's record instead and goes on with nothing applied, unless another apply holds that database before it is done reading, an earlier apply's answer there is still to be settled, or the read cannot be made: at most 2 of an org's such reads run at once. A deploy or adopt turned away by any of these applies nothing, and answers `409` with a `Retry-After`: when the database's hold ends at the latest, or a minute. An apply whose answer from D1 was lost holds its database for up to 10 minutes, so the next one starts only once D1 has finished or dropped that request. That hold is its database's alone: it counts toward neither the org's 2 nor its app's 1.
- **Calls:** an org's applies and adopts are held to 300 calls to D1 in five minutes, a quarter of what Cloudflare allows wirl's account. So are the calls of a deploy with nothing new to apply that does not wait (above): its lookup of the database, and each read twice, for the second try a read may take. When the budget cannot take them, the deploy is turned away as it would have been. An apply or adopt that would start too close to that is refused before it starts, with `429` and a `Retry-After` of a minute, and nothing is applied. One already under way sends no request that would pass it: the deploy fails there, naming the files that applied before it, and says to deploy again in a few minutes. `wirl migrations` takes nothing from it: each person may list 20 times in five minutes, and the next is refused with `429` and a `Retry-After` until the oldest of those is five minutes old.
- **A failed statement's message**, D1's own words about it, reaches the person deploying cut to 2,000 characters; wirl keeps it nowhere.
