# Building and shipping on Whisk

Conventions version 1. This is the document a coding agent reads before touching a Whisk app.
Every file it refers to is published beside it at `https://skill.whisk.run/<file>`:
`whisk.schema.json`, `graph.schema.json`, `headers.md`, `environment.md`,
`webhook-presets.yaml`, `errors.md`, `doctor-rules.md`. Working starter apps come with the CLI
(`whisk init --template`).

## 1. What Whisk is

Whisk hosts business apps written by coding agents. An app is a container that speaks HTTP; the
platform adds login, a Postgres database, a queue with durable workflows, secrets, webhooks,
storage, email and backups, all by convention rather than by SDK. Tenant apps live at
`<app>.<org>.whisk.page` on a paid plan, `<app>--<org>.whisk.page` on the free plan; the
platform is at `whisk.run`. Use the hostname the API returns rather than building one.

What you will do: `whisk login`, `whisk init`, `whisk doctor`, `whisk deploy`, then tell the
human which secrets to set and where. The human's part is a few screens in the dashboard. Yours
is everything else, and one more thing: whenever Whisk gets in your way, tell us with
`whisk feedback` (section 12). You are the user Whisk is built for, so your feedback is how it
gets better.

### Whisk already does this

Use the built-in. Do not add an outside service, SDK account or library for any of these unless
the human asks for that service by name; if a built-in falls short, say so with `whisk feedback`
(§12) rather than working around it.

| Need | Whisk's built-in | Instead of |
|---|---|---|
| Sign-in, users, roles | the platform's login and `X-Whisk-*` headers (§4) | Auth0, Clerk, NextAuth, Supabase Auth, a password page |
| Database and backups | Postgres per app, `DATABASE_URL`, backups and restore points (§5) | Supabase, Neon, Firebase, SQLite files |
| Scheduled and background work | functions, steps, retries, approvals (§7) | node-cron, Celery, BullMQ, an outside cron, Zapier |
| Secrets | declared names, set in the dashboard (§6) | `.env` files, Doppler, Vault |
| Incoming webhooks | `webhooks:` with verification, retries, replay (§8) | a relay service, your own signature code |
| Files and uploads | `storage: true` (§9) | an S3 or R2 account, Cloudinary, UploadThing |
| Video and audio | `<whisk-video>` (§9) | Mux, Vimeo, YouTube embeds |
| Email | `email: true` (§9) | SendGrid, Resend, Mailgun, SMTP |
| Cache, counters, locks | `kv: true` (§9) | Upstash, Redis Cloud |
| Errors | `SENTRY_DSN`, Whisk's own error store, `whisk errors` (§3) | a Sentry account, Bugsnag, Rollbar |
| Logs and request tracing | stdout, `X-Whisk-Request-Id`, `whisk logs`, OpenTelemetry traces to `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`, `whisk traces` (§2, §11) | Datadog, Logtail, LogRocket, Honeycomb, an APM agent |
| Bot protection | `routes.challenge` (§4) | reCAPTCHA, hCaptcha, Turnstile |
| Hosting, HTTPS, domains, caching | the platform (§3) | Vercel, Cloudflare, a CDN |

### One app or two

To the people you work for, an app is one tool their team uses in a web browser, with its own
web address, its own data and its own list of who can use it. Size is not a reason to split: a
whole CRM with many screens is one app. When asked for something new, decide whether it belongs
in an existing app or is a new one:

- Same people, same information: add it to the existing app. A report on the CRM's customers
  belongs in the CRM.
- Different people should see it (a client portal beside the staff tools): a new app, because
  access is set per app.
- Something they would switch off, roll back or hand to someone else on its own: a new app.
- Someone else maintains it: a new app, so their changes do not collide with yours.
- Needing the other app's data is not a reason to merge: use a shared database (§5).

Say which you chose and why in one sentence. When the rules do not settle it, ask the human.

## 2. The rules

An app on Whisk is a stateless HTTP server in a container.

1. Listen on `PORT` (always 8080) on all interfaces.
2. Answer `GET <health.path>` with 200 when ready to serve. Default `/health`.
3. Log to stdout and stderr. JSON lines are indexed by field; plain lines are indexed as text.
4. Write files only under `/tmp`. Everything else is read-only. `/tmp` is emptied on every
   restart; files that must last go to storage (§9, files a job keeps). `/tmp` is memory: what you
   keep there and what the process uses share `WHISK_MEMORY_BYTES` (256 MiB on Free), and a run
   that goes over is killed without SIGTERM.
5. Exit cleanly on SIGTERM within the grace period (default 28 seconds).
6. Read configuration and secrets from the environment. Never from files in the repository.
7. Trust identity only from the `X-Whisk-*` headers, which only the platform can set.
8. Treat every queue delivery and webhook as possibly repeated; use the provided IDs to dedupe.
9. Start fast. Under five seconds from process start to health 200 is the target, because
   sleeping apps wake on demand.

**Keep it light.** Memory is what an app costs to run, and a small app wakes fast and is never
killed for running out. Stream large files and query results in chunks (storage reads, CSV and
spreadsheet parsing, database cursors) instead of loading them whole into memory or `/tmp`.
Process a big job as many small steps, each handling one batch. Let the database filter, sort and
total rather than fetching every row to do it in code. React to an event or webhook when
something changes instead of a schedule that checks every few minutes, and choose the longest
schedule the business can live with. Load heavy libraries only in the code path that needs them.
Pages and API reads that can be a little old should send `Cache-Control: private,
max-age=<seconds>` (`public` for a page anyone may see): the edge answers repeat requests from its
cache, per person and for up to an hour, without waking the app. A response that sets a cookie is
never cached.

## 3. Getting an app live

If `whisk` is not on the PATH, install it first; neither script needs root:

```
curl -fsSL https://whisk.run/install.sh | sh        # linux, macOS
irm https://whisk.run/install.ps1 | iex             # windows PowerShell
powershell -NoProfile -Command "irm https://whisk.run/install.ps1 | iex"   # windows Git Bash or cmd
```

If `whisk` is already installed, run `whisk update` once at the start of a session: this
document describes the CLI the platform serves, and an older one lacks commands it names. Later,
any `--json` output that carries `cli_update`, or an `UNKNOWN_COMMAND` error, means the same:
run `whisk update` and retry.

```
whisk login --agent "<your name>"
                         name yourself as people know you ("Claude Code", "Codex", "Cursor"):
                         the approval page, deploys and token lists show it. Exits 2 at once
                         with a URL and a code in the NEEDS_HUMAN block (also under --json):
                         show both to the human, then run the resume command it names
                         (whisk login --resume <device_code>), which waits for approval.
                         The token works only from this computer: use it through whisk,
                         never copy it elsewhere
whisk init --template ts|py|go      writes whisk.yaml, .whisk/app.json, and the template
whisk doctor             checks the repository; exit 3 means fix the listed problems
whisk deploy -m "…"      commits, pushes, builds, migrates, health-checks, switches traffic
whisk logs -f            tails the app
whisk clone <org>/<app>  copies an existing app's code here; git pull and push then work
```

If `whisk login --resume` is cut off by your shell's time limit, run it again: the code stays
good until it expires and the waiting key is kept.

**Following and recovering.** These are the commands to reach for when something takes a while
or goes wrong:

```
whisk deploy --no-wait -m "…"   prints the deploy id at once; a deploy can take up to 25 minutes
whisk deploys info <id>          its status and phase; deploys cancel <id> stops a stuck one
whisk deploys log <id>           its whole build log and, for a failed start, the app's last lines
whisk status                     running, sleeping or broken, and why it stopped answering
whisk whoami · whisk orgs        who you are and which businesses this token reaches
whisk use <org>/<app>            binds this folder to an app (the NOT_FOUND fix names it)
whisk secrets list               declared names, set or unset; never values
whisk functions list             declared functions, their triggers and drift from the code
whisk events send <name> --data @event.json    sends an event to test an event function
whisk runs show <id> · whisk runs replay <id> · whisk runs cancel <id>
whisk restore show <id> --wait   follows a restore (whisk restore list lists them)
```

Every command takes `--org <slug>` and `--app <slug>` instead of `.whisk/app.json`, and
`--quiet` to drop progress lines. In CI, where nobody can approve a login, a person runs
`whisk agent-token create --label "ci"` and puts the token in `WHISK_TOKEN`.

Run `whisk login` in the app's folder, or with `--org` (and `--app`): the approval page then
starts on that app or business, the narrowest login that fits. The person can choose all their
businesses instead; that login acts as them in the businesses they belong to when they approve
it, never one they join later: `whisk orgs` lists them and `--org <slug>` picks one when the
folder names none. When a login meets a business it does not cover, the command exits 2 with a
NEEDS_HUMAN block whose link (`details.url`, `details.reason` `BUSINESS_NOT_IN_LOGIN`) is where
the person, signed in, adds that business to this login with one tap: show them the link, then
run the command again. Do not run `whisk login` again for it. A person adds a new business
themselves at `https://whisk.run/new`.

An agency builds for its clients with the login it already has: `whisk clients` lists its client
businesses, `whisk clients add <name> --contact <email>` adds one, `whisk init --org <client>`
starts an app in it, and `whisk clients handover <client>` prints the link its contact accepts it
with. The agency's own business needs a paid plan first (`PLAN_FEATURE`); each client business
is on Team.

If you speak MCP rather than a shell, run `whisk mcp` as a stdio server: this document and
every error code are its resources, every command is a tool.

In a chat with no shell, such as ChatGPT or Claude, the person connects Whisk's assistant
connector (`https://api.whisk.run/mcp`) and signs in once. Its tools cover deploying (`deploy_app`
takes the files themselves), rolling back, logs, runs (and replaying one), the database's tables
and queries, access and feedback; `whisk_build_guide` names which command each one stands for and
what a chat cannot do (cron runs on demand, webhook deliveries, errors and traces, restores,
previews, domains, export), which needs the dashboard or a developer with the CLI.

Every command takes `--json`. Exit codes: 0 success; 1 an error, including a refused request
such as a bad slug or uncommitted files (`INVALID_REQUEST`), read `code` and `fix`; 2 needs a
human, show them the printed URL and wait; 3 doctor or the manifest found problems, fix the
listed ones and retry; 4 not signed in or not allowed: read `code` (for `AUTH_REQUIRED` or
`TOKEN_EXPIRED` run `whisk login`; for `AGENT_CATEGORY`, `AGENT_PAUSED`, `FORBIDDEN_ROLE` or
`TOKEN_SCOPE` logging in again does nothing, so tell the human what to grant or resume); 5
network or Whisk's side, retry with backoff and do not change the code; 6 deploy failed in the
app, read the log excerpt.

Start from a template unless the human already has code: `whisk init --template ts` (Hono,
Drizzle), `--template py` (FastAPI, SQLAlchemy, Alembic), `--template go` (chi, pgx, goose). Each is
a plain starter (a notes page, a health route, one event function; a few hundred lines, the Go
one about 900) that passes doctor with zero warnings, deploys with nothing for a person to set,
and shows the conventions below working. `whisk init --template` works in a folder holding only
dot-files and `CLAUDE.md` or `AGENTS.md`. Existing code deploys too: add a `whisk.yaml`, make it
follow the rules, run doctor.

**Everything `whisk.yaml` takes.** Unknown keys are errors; `whisk schema` prints the JSON schema.
Settings marked paid are refused with `PLAN_FEATURE` on the free app, except `kv`, which
deploys without a cache and a `W080` line instead (§9). Storage, email and customer identity
are on every plan, the free app included, within its allowances.

```yaml
whisk: 1                          # required
name: job-tracker                 # required; the app's address derives from it
routes:
  public: ["/", "/health"]        # everything else needs a signed-in person (§4)
  challenge: ["/contact"]         # public POSTs here need a solved bot challenge (§4)
  csrf_off: []                    # rare: routes exempt from the CSRF check, for embeds
  headers: { X-Frame-Options: DENY }   # add or override response headers
health: { path: /health, timeout: 30 } # seconds from start to the first 200; default 90
database: app                     # app | shared:<name> | none (§5)
migrate: "node dist/migrate.js"   # runs before traffic switches, at most 600 seconds (§5)
build:
  dockerfile: Dockerfile          # optional; the stack is detected without one
  secrets: [NPM_TOKEN]            # available only while building, never at run time
secrets: [XERO_CLIENT_SECRET]     # names only (§6)
env: { LOG_LEVEL: info }          # plain configuration, visible in logs
queue: { endpoint: /.whisk/inngest }
functions: []                     # cron and event functions (§7)
webhooks: []                      # incoming webhooks (§8)
static:                           # committed folders served by the edge, cached for a year
  - { dir: public/assets, path: /assets }
storage: false                    # files, uploads, images, video (§9) · every plan
kv: false                         # a Redis of its own (§9) · paid
email: false                      # sending email (§9) · every plan
always_on: false                  # never sleeps · paid
calls: []                         # other apps of the business this one calls (§8)
customer_identity: none           # none | app | org: the app's own users (§4) · every plan
network: internal                 # Whisk On-Premise only: internal (default) | public; not on whisk.run
previews: { database: empty, ttl_days: 3 }   # previews start empty; days a preview lasts
```

`static` folders are read from the commit, not from the built image: a folder the commit does
not hold, such as git-ignored build output like `dist/`, fails the deploy with `MANIFEST_INVALID`
naming it (`whisk doctor` warns first, `W004`). Serve build output from the app itself, or
commit the folder. Static files never wake the app and are cached for a year, so give them
content-hashed names. `always_on` is for apps that must answer at once or hold WebSockets open;
everything else should sleep. A sleeping app has 60 seconds to answer its health check when a
visitor wakes it, whatever `health.timeout` allows a deploy, so an app that needs longer sets
`always_on` (`W005`). A sleeping app starts again on its next request, so
start fast: the TypeScript template bundles each entry point into one file with esbuild
(`build.mjs`), so its image holds no `node_modules` and Node reads a few files instead of thousands.
A package that must stay a file on disk (a native addon, or one that reads its own files at run
time) goes in esbuild's `external` list, and the Dockerfile copies it into the image.
A preview starts with an empty database of its own; seed what testing needs from a migration or
a script, never from production.

**Say what changed.** Every deploy shows the subject line of its commit to the business, on the
app's Deploys page and in `whisk deploys list`. Write it for the people who use the app, in one
plain sentence about what they will notice: `whisk deploy -m "Add a CSV export to the jobs
page"`, not "fix bug", "wip" or "whisk deploy". `-m` commits uncommitted changes with that
message; when you commit yourself, the first line of your commit message is what shows. Keep it
under 72 characters and leave file names and internals out.

`whisk deploy` from `main` or `master` deploys production, and nothing else does. From any
other branch it starts a preview at its own URL, says so, and leaves production alone; to put a
branch live, merge it into `main` and run `whisk deploy` from `main` (`PRODUCTION_NEEDS_MAIN`
otherwise). Work on `main` unless the human asks to try something out first. Check `environment` in the `--json` summary before telling the
human something is live. A plain `git push` of a branch only stores it: start its preview with
`whisk envs start <branch>` when someone needs to see it, and from then on every push to that
branch updates it. Each app may have one preview on the free app and three on a paid plan
(`PLAN_LIMIT_PREVIEWS`); remove one you are done with using `whisk envs delete
preview:<branch>`. Previews sleep when idle and go a few days after their last push or visit.
A preview runs no scheduled functions, workflows or events (`PREVIEW_NO_WORKFLOWS`); skip
sending events when `WHISK_ENV` starts with `preview:`, and test functions with `whisk dev`.
`whisk apps delete <slug> --yes` needs a human's say first; a deleted app keeps its data for 7
days, and `whisk apps deleted` then `whisk apps restore <id>` bring it back stopped.

`whisk deploy` ends with the URL, the address of each declared webhook, and any `warnings`: a
deploy that went live with something wrong, such as `FUNCTIONS_NOT_REGISTERED` (a function name
in `whisk.yaml` the code does not use), whose fix says what to change. If declared secrets
(`build.secrets` included) have no value yet it also prints a `NEEDS_HUMAN` block naming them and
the dashboard URL. Relay that block to the human verbatim. Do not ask them for the values; the
app restarts on its own when they are set.

**Their own domain.** On a paid plan, `whisk domains add jobs.acme.com` prints two DNS records
for the human to create: a TXT that proves they own the name and a CNAME to the app's address.
A bare domain (`acme.com`) cannot have a CNAME, so it takes A (and AAAA) records to the
addresses printed instead. Relay the records, then `whisk domains verify jobs.acme.com` once they
are in place; until then it answers `DOMAIN_UNVERIFIED` naming what is missing. The certificate
is issued on verification and the app answers on the name with no deploy. Prefer relative
links; `WHISK_PUBLIC_URL` stays the platform address, and the `Host` header is the name the
visitor used.

To put every app on the business's domain at once (crm.acme.com, billing.acme.com, and each new
app with no further DNS), an owner or admin's session runs `whisk domains business add acme.com`
instead: a TXT and one wildcard CNAME (`*.acme.com`), then `whisk domains business verify`. The
human's existing records (www, mail) keep working.

**Serving from near the visitors.** On Team and Business, `whisk cdn on` puts the app's address
and its CNAME'd domains behind a CDN (`whisk cdn` shows the status and each hostname's route).
It helps public pages, sites and downloads. A public page is cached only as long as the app's
`Cache-Control` says, so set it on public pages and assets you want cached; a response that sets
a cookie is never cached.

**Looking after a live app.** `whisk status` says whether it is running, sleeping or broken,
which deploy is live and which secrets are unset, and, when it stopped answering, why: the exit
code and last lines of a crash, or a wake that failed. `whisk rollback` puts the previous deploy back
(or a named one from `whisk deploys list`). `whisk errors` lists the app's grouped exceptions,
reported through `SENTRY_DSN`, which every template already wires. It points at Whisk's own error
store, not sentry.io: initialise any Sentry SDK with it and only the DSN, without Sentry's tracing,
profiling or session replay, which cost memory and start time and which Whisk already covers.
**Traces** show where a request's time went, step by step: the platform sets the `OTEL_*`
variables (`environment.md`), so any OpenTelemetry SDK's OTLP/HTTP trace exporter sends them with
no configuration, and the edge's `traceparent` makes each request's trace id its request id, so a
log line's `request_id` opens its trace. The templates already start tracing when
`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` is set; in another app, start the SDK at that check, continue
the incoming context, and instrument incoming HTTP, database queries and outgoing calls. Traces
are kept 7 days, 100,000 spans per app per day. `whisk open <page>` prints the dashboard link for the human (`logs`, `runs`, `secrets`,
`domains`, `access`, `errors`). `whisk github` shows the app's optional two-way copy on the
business's GitHub; a person links it in the app's settings.

**Known vulnerabilities in packages (Business plan).** Whisk checks the live app's packages after
every production deploy and daily: the image (base image packages and everything installed in it)
and every lockfile in the commit. `whisk status` says how many findings need fixing now; `whisk
doctor` lists them as `W063`, one line per package: it checks the local lockfiles as they are,
before you deploy, so run it after each change and the list shrinks as you fix; `whisk scan` lists
each one with its severity, the installed version, the version that fixes it (`fixed`) and where it
was found (a lockfile, or a path in the image). Fix the critical and high ones that have a fix: move
the package to that version or newer in the lockfile (a base image package by moving to a newer base
image), deploy, then `whisk scan --now` to check again. A finding with no `fixed` has no fix yet;
leave it. Most image findings come from the operating system in a full base image: the TypeScript
and Go templates run on a distroless image (`gcr.io/distroless/nodejs24-debian13:nonroot` for Node,
the Python one on `python:3.13-slim`), which has no shell or package manager, so keep `migrate` a plain command such as `node dist/migrate.js`, not `npm run`
or shell syntax (`whisk doctor` warns, `W062`). An app that needs a system program (ffmpeg,
ImageMagick, a browser) moves the Dockerfile's last stage to `FROM node:24-trixie-slim`, installs it
with `RUN apt-get update && apt-get install -y --no-install-recommends <package> && rm -rf
/var/lib/apt/lists/*`, and sets `USER node` and `CMD ["node", "dist/index.js"]`, since only the
distroless image starts node itself. Deploys are never blocked by findings. On another plan `whisk
scan` says scanning is on the Business plan.

## 4. Identity

The platform signs people in. Your app never sees a password, a token or a login form. On
every request, read who it is from headers (full table in `headers.md`):

| Header | Meaning |
|---|---|
| `X-Whisk-Audience` | `anonymous`, `team`, `customer` or `service` |
| `X-Whisk-User-Id` | stable ULID; store this, never the email, as the key |
| `X-Whisk-Email`, `X-Whisk-Name` | verified email; display name (may be empty) |
| `X-Whisk-Org` | org ULID |
| `X-Whisk-Groups` | comma-separated group names (team only) |
| `X-Whisk-Roles` | comma-separated org roles: `owner`, `admin`, `developer`, `billing`, `member`, plus `guest` (team only) |
| `X-Whisk-Request-Id` | trace ID; put it in every log line |

Routes are private unless listed under `routes.public` in the manifest. A private route only
ever receives requests with a `team` or `customer` identity (or `service`, for platform
deliveries and app-to-app calls); the platform has already redirected browsers to login and
answered API clients 401. A public route receives `anonymous` when nobody is signed in and the
full identity when someone is.

Authorise inside the app by role or group:

```ts
const roles = (c.req.header("X-Whisk-Roles") ?? "").split(",").filter(Boolean);
if (!roles.includes("admin") && !roles.includes("owner")) return c.text("Forbidden", 403);
```

Never read identity from a cookie, a query parameter, a body, or an `Authorization` header a
browser sent; the headers are the only source and the platform strips any copy arriving from the
internet. To sign someone out, redirect to `/.whisk/logout?return=/`. To force login from a
public page, redirect to `/.whisk/login?return=<path>`. Both are handled in front of the app.

`customer` identities are the app's own users (portals, public apps), enabled with
`customer_identity: app | org`. They arrive in the same headers without roles or groups, and
`X-Whisk-User-Id` names the customer. The way in is an invitation until an owner opens
registration; onboard your own with
`POST /v1/orgs/<org>/apps/<app>/customers {email, name}` and your service token, which answers
the link to send them. `whisk customers list`, `invite`, `block` and `unblock` do the same from
the CLI. An owner can remove a customer: they are signed out and can no longer sign in. Invited
again (or signing up again) within 30 days they come back under the same `X-Whisk-User-Id`, so
keep their records rather than deleting them when a request stops naming them; after 30 days
the platform deletes the person, and a later invitation makes a new id.

Who may open the app (groups, people, everyone in the business) is an owner's or admin's choice
in the dashboard: `whisk access show` reads it and `whisk open access` gives the link to relay.

**Public pages.** The edge rejects a cross-site POST to the app (`CSRF_REJECTED`), so forms work
from the app's own pages with no token. Put a public form that strangers fill in (contact, quote
request, sign-up) under `routes.challenge`: a POST without a solved proof-of-work challenge gets
`CHALLENGE_REQUIRED` carrying one; solve it with the Altcha widget and resend with the solution in
the `altcha` form field. `routes.headers` adds or replaces response headers on every route, such
as a `Content-Security-Policy`. The edge sends `Cross-Origin-Opener-Policy: same-origin`, so a
link to the app from inside a sandboxed iframe (a CRM tile, a widget on another site) opens to
Chrome's `ERR_BLOCKED_BY_RESPONSE`; when the app is meant to be opened that way, set
`Cross-Origin-Opener-Policy: unsafe-none` in `routes.headers`.

### Who may see and change what

The platform decides who may open the app. The app decides which records each person may see
and change, and that is where most real security holes in business apps are: one customer
opening another's invoice by changing the number in the address. Do it the same way every time.

1. **Store the owner on every record a person creates**: their `X-Whisk-User-Id`, in a column
   such as `owner_id` or `author_id`. Never take the owner from the body, the query string or a
   hidden form field.
2. **Filter in the query, not afterwards.** The template's `whisk` module has `scopeFor`
   (`scope_for`, `ScopeFor` in Go): the business's team sees every record, a customer only their
   own, anyone else nothing. Every list, search, count, export and report goes through it.
3. **Check every record fetched by id** with `canSee`, then `canChange` before changing or
   deleting it (`can_see` and `can_change`; `CanSee` and `CanChange`). A record the person may not
   see answers 404, as if it did not exist; one they may see but not change answers 403.
4. **Change the rules in one place.** When a business wants something different (members see
   only their own records, a group sees a department's), change the helpers, not each route. The
   template's access tests (`access.test.ts`, `test_access.py`, `access_test.go`) check the rules
   over thousands of generated people and records; keep them passing and add a case for the new
   rule.
5. **Test each kind of record as the wrong person.** For every route that reads or changes one,
   add a test that calls it as a second customer, as a team member who is not an owner or admin,
   and with no identity at all, and expects 404, 403 or an empty list, never the record. Ids in
   the address and in the body both count.

The same habits close the other common holes. Send SQL with parameters (the ORM, or `$1`), never
by joining strings. Let the framework escape HTML; never insert text a person typed as raw HTML.
Keep keys on the server, never in code sent to the browser. Fetch only addresses the app chose,
never a URL a person typed without checking its host first. Keep uploads in storage (§9) and serve
them from there. Before pushing, run the tests and `whisk doctor`: W094 warns when an app with
customer sign-in never limits anything to the signed-in person, and W096 to W099 flag SQL built
from text, raw HTML, a list of people's records with no owner filter, and public routes that
write.

## 5. Database

`DATABASE_URL` is a pooled Postgres connection in transaction mode. Use it as-is. No
session-level state: no `SET` outside a transaction, no advisory locks held across statements,
no `LISTEN`. Prepared statements are fine inside a transaction.

Migrations run from the manifest's `migrate` command before traffic switches, with a direct
connection the platform provides for that step and a snapshot taken first. The previous deploy
stays live and keeps writing while the migration runs. A migration that runs longer than 600
seconds is stopped; split a long backfill into a function instead. A failed migration that
changed no tables, columns, indexes or functions leaves the database as it is, so nothing users
wrote is lost; one that did change them is rolled back to the snapshot, and the error names the
replaced database, kept for 7 days, where anything users wrote meanwhile can be read back with
`whisk db query --database`. So run each migration in one transaction (most tools do on
Postgres), and keep migrations forward-only and idempotent where the tool allows. `whisk dev`
also runs the `migrate` command on your own machine before it starts the app.

`database: app` (default) gives the app its own database. `database: shared:<name>` gives the
app its own schema inside a database shared across the org, for apps that must join across each
other. `database: none` for apps without one.

In a shared database each app connects as its own role and owns one schema of the same name,
`app_<app id in lower case>`, which is first on its `search_path`, so unqualified tables land
there. Every schema is private to its app until that app grants access; nothing in `whisk.yaml`
opens it. The owning app grants read access in one of its migrations, naming the reader's role
(`whisk apps info <reader> --json` prints the reader's id as `app.id`; inside an app,
`select current_user` names its own role):

```sql
GRANT USAGE ON SCHEMA app_<owner id> TO app_<reader id>;
GRANT SELECT ON ALL TABLES IN SCHEMA app_<owner id> TO app_<reader id>;
ALTER DEFAULT PRIVILEGES IN SCHEMA app_<owner id> GRANT SELECT ON TABLES TO app_<reader id>;
```

The first two cover the tables that exist; the third covers tables the owner creates later. The
reader then queries `app_<owner id>.<table>` and joins it with its own tables. Grant `INSERT`,
`UPDATE` or `DELETE` the same way only when the reader must write; `REVOKE` takes access back.

**Row-level security: one customer never sees another's rows.** When the app serves customers
(`customer_identity`), or people who must not see each other's records, every table holding rows
that belong to someone gets a policy, so a query that forgets its `where` still answers only the
caller's rows. Add it in a migration, naming the column that holds the owner's
`X-Whisk-User-Id`:

```sql
create index orders_owner_id on orders (owner_id);
alter table orders enable row level security;
alter table orders force row level security;
create policy orders_by_audience on orders using (
    current_setting('whisk.audience', true) in ('team', 'system')
    or owner_id = nullif(current_setting('whisk.user_id', true), '')
);
```

`force` matters: the app's role owns the table, and an owner skips policies without it. The
policy lets the team and the app's own work see every row and a customer only their own. Then
every query on such a table goes through the template's helper, which tells Postgres who is
asking at the start of one short transaction: `dbFor(who(c), (tx) => …)` in TypeScript,
`with db_for(identity(request.headers)) as session:` in Python, `db.For(ctx, callerOf(identityFrom(r.Header)), func(tx pgx.Tx)
error {…})` in Go. Functions and webhook deliveries, which run for nobody in particular, use
`asSystem` (`as_system`, `db.AsSystem`); never use it to answer a person's request. A query that
skips the helper sees none of the rows and cannot write them, which is the safe way to fail.
Keep slow work (calls to other services) outside the helper, since the transaction holds a
pooled connection until it ends.

To tailor it: a table every signed-in customer may read but only the team may change (a price
list) needs no policy; a team member who should see only their own rows drops `'team'` from the
list; rows shared by a customer's company use a column holding that company's id instead of
the person's. A migration that reads or changes rows of a policy table starts with
`select set_config('whisk.audience', 'system', true);` (DDL needs nothing). `whisk db query`
runs as `system` and sees every row. Another app reading a shared schema is filtered by the
owner's policies too: it sets `system` the same way when it should see every row. `whisk dev`
connects as a superuser, which policies never filter, so prove isolation on a deploy.

**Looking at the data from your machine.** The database's own address is inside the platform, so
connecting to it from outside does not work (`whisk db url` says so). Use these instead; they
run on the platform, need nothing installed, and are audited:

- `whisk db schema --json`: every table with its columns, keys and estimated row count. Read it
  first when planning an import or a migration.
- `whisk db query "select … limit 20" --json`: the rows as JSON, values as PostgreSQL prints
  them. `--file script.sql` runs a script. Statements stop after 30 seconds; at most `--limit`
  rows come back (1000 by default).
- Reads and additions run at once. SQL that would change or remove existing data (`update`,
  `delete`, upserts, `drop`, `alter`, `truncate`) does not run: it comes back as
  `CONFIRM_REQUIRED` saying what it would do and carrying a code. Check that the effect is what
  you meant, then run `whisk db query --confirm <code>`. A restore point is taken first, and
  the result names the `whisk restore --at` that undoes it, on a plan with self-service restore.
  On Free nothing undoes it (`details.restorable` is `false`): copy the rows it changes first
  with `whisk db query "select …" --json`.
- Do not put `begin` or `commit` in the SQL: each call is already one transaction.

**Undoing a mistake.** Backups are continuous, and `whisk restore` returns the database to any
minute in the plan's window: the last 7 days on Starter, Team and Agency, 30 on Business; Free
has no self-service restore. `whisk db snapshot` marks a restore point before risky work and
prints the command that returns to it, or says the plan cannot. `whisk restore --at <time or 2h>`
restores into a new database beside the live one, which `whisk db query --database <name>` reads,
so you can check it first; `--swap` puts it live. A restore runs in the background: add `--wait`
to follow it, or `whisk restore show <id> --wait` later. An owner or admin downloads all of the
business's data with `whisk export --wait`; its link works for five minutes, and
`whisk export show <id>` signs a new one while the archive is kept (seven days).

## 6. Secrets

Declare names in the manifest; humans set values in the dashboard.

```yaml
secrets: [XERO_CLIENT_SECRET, SLACK_WEBHOOK_URL]
```

The value arrives in the environment under that name. Never write a value into code, a file, a
commit message or a log line; the push is rejected if a commit contains one, and every value is
scrubbed from logs. Never ask the human for a value in chat. When a secret is needed, say:

> Set `XERO_CLIENT_SECRET` at https://whisk.run/o/<org>/secrets. I have declared it; the app
> restarts when you save it.

`whisk secrets declare NAME` creates the slot before the first deploy if you want the human to
fill it early. `whisk secrets set NAME1 NAME2 …` never takes a value: it prints one link that
opens the secrets page with a paste box ready for those names, so the human pastes them all at
once (a `.env`-style `NAME=value` block works). Relay that link. Org-scoped secrets are
shared by every app in the org; app-scoped ones shadow them.

Previews get a secret only when a person has shared it with previews, so by default a preview
runs without production's keys and must cope with them missing. If a preview needs one, run
`whisk secrets set NAME --previews` and relay the link; turning it on is the human's decision.

When a deploy waits on a name another app of the business already has a value for, the
`SECRET_UNSET` error says which app (`details.elsewhere`). Ask the human whether this app may use
the same value; if they agree, run `whisk secrets share NAME --from <app>`. The value becomes the
business's, this app starts with it, and nobody pastes it again. Values shorter than 8 characters
are refused (`SECRET_TOO_SHORT`) because they cannot be scrubbed; put those under `env`.

## 7. Jobs and workflows

**Enqueue.** `POST $WHISK_QUEUE_URL` with `Authorization: Bearer $WHISK_SERVICE_TOKEN` and

```json
{"name": "po.created", "data": {"po_id": 1042}, "dedupe_key": "po-1042"}
```

Returns `{"id": "..."}`. Events with the same `dedupe_key` within 24 hours are dropped.

**Functions** are declared in the manifest with a trigger and a graph, and implemented with the
Inngest SDK for the language (the templates wire it). The platform delivers runs to
`queue.endpoint` (default `/.whisk/inngest`) with the service identity; the SDK verifies each
request with `WHISK_INNGEST_SIGNING_KEY`.

```yaml
functions:
  - name: nightly-margin
    cron: "0 6 * * *"
    tz: Pacific/Auckland
    graph: workflows/nightly-margin.graph.yaml
  - name: po-approval
    event: po.created
    graph: workflows/po-approval.graph.yaml
```

```ts
export const poApproval = inngest.createFunction(
  { id: "po-approval", triggers: [{ event: "po.created" }], retries: 3 },
  async ({ event, step, runId }) => {
    const po = await step.run("fetch-po", () => fetchPo(event.data.po_id));
    if (po.total > 5000) {
      const ok = await approval(step, runId, "request-approval", { to: "owner", title: `PO ${po.id}`, data: po });
      if (ok.decision !== "approved") return;
    }
    await step.run("post-po", () => post(po));
  },
);
```

Steps are the unit of retry and memoisation: each `step.run` runs once and its result is kept,
so a run resumes after the last completed step. Do work only inside steps; code between steps
re-executes on every resume.

**Approvals.** `approval(step, runId, name, {to, title, data})` is a helper each template ships
(under 20 lines). It sends the event `whisk/approval.requested` in a step named `<name>/request`
with `{approval_id, to, title, data}` where `approval_id` is `<runId>:<name>`, then waits in a
step named `<name>` for `whisk/approval.decided` matching that id, for up to 7 days. The
platform renders the approval page, notifies `to` (an org role such as `owner`, `group:<name>`,
or `user:<email>`) and completes the wait with `{decision, actor, at, note}` where `decision` is
`approved` or `rejected`; a timeout resolves to `{decision: "expired"}`. An owner may always
decide; anyone else only when `to` names them: a role covers that role and the roles above it (`owner` is above all), so
name the lowest role that should decide.

**Graphs.** Each function has a declared graph so humans can see the workflow and the platform
can overlay real runs on it. Step ids are the step names in code.

```yaml
steps:
  - id: fetch-po
  - id: over-limit?
    branches: { yes: request-approval, no: post-po }
  - id: request-approval
    kind: approval
    next: approved?
  - id: approved?
    branches: { yes: post-po, no: end }
  - id: post-po
```

Ids ending `?` are decisions and need not exist in code. Doctor warns when a graph id has no
step in code or a step in code is not in the graph.

**Cron** is `cron:` on a function, in `tz` (default UTC). Nothing runs inside the container on a
timer; sleeping apps wake for the run, and an app nobody visits meanwhile sleeps again about 30
seconds after the run's last step rather than after its plan's idle time. After a deploy, check a cron function without waiting for
its schedule: `whisk cron run <function>` starts one run now, exactly as the schedule would, and
prints the run id to follow with `whisk runs show <id>`. The run's event is
`whisk/cron.run.<function id>` with `data.cron`, so do not branch on the event name. The platform
adds that trigger to every cron function; do not send or listen for `whisk/cron.run.*` yourself.
On the Free plan a schedule runs at most every 10 minutes: `*/5` runs at minutes 0, 10, 20, 30,
40 and 50, and `whisk cron list` shows the schedule each function runs as. Free apps also sleep
after 2 idle minutes and get half a core; one that uses all of it for ten minutes is put to
sleep (`APP_CPU_SLEEP` in its logs), so keep heavy work in small batches.

**Engine features.** It is Inngest itself (v1.44.0): write standard Inngest code with plain
event names (`"po.created"` in triggers, `cancelOn`, `step.waitForEvent` and sends) and
everything works as Inngest documents it, `priority` included.
Event names are your app's own: another app's `po.created` never reaches you. Listen for
failures with `onFailure` (a trigger on `inngest/function.*` never fires), and invoke only your
own app's functions (`INVOKE_FOREIGN`). Runs that sleep or wait count toward your plan's runs in
flight, so set `concurrency` at or below it. The full table, with limits:
https://skill.whisk.run/workflows.md.

**Idempotency.** Runs retry and events can repeat. Key inserts on an id from the event, use
`ON CONFLICT DO NOTHING`, and make every step safe to run twice. Retries default to 3 with
backoff; a run that still fails is parked (`RUN_PARKED`) and the owner is notified; fix and
`whisk runs replay <id>`. `whisk runs list <function>` shows a function's latest runs, cron and
debounced ones included; `--event <id>` narrows to the runs one sent event started.

## 8. Webhooks, other apps and outside systems

Declare the source; the platform receives, verifies and stores every delivery, then calls your
handler with a clean request.

```yaml
webhooks:
  - name: stripe
    preset: stripe                 # see webhook-presets.yaml; or hmac with settings; or token
    secret: STRIPE_WEBHOOK_SECRET  # must also be under secrets
    handler: /hooks/stripe
```

`whisk deploy` (and `whisk webhooks list`) prints `https://hooks.whisk.run/<org>/<app>/stripe`.
Tell the human to paste it into the provider and to set the signing secret. The handler
receives `POST` with the original raw body, the original headers as `X-Whisk-Webhook-Orig-*`,
plus `X-Whisk-Webhook-Id`, `X-Whisk-Webhook-Source` and `X-Whisk-Webhook-Received-At`, with
`X-Whisk-Audience: service`. Respond 2xx within 30 seconds; anything else is retried at 1m, 5m,
30m, 2h and 12h, then dead-lettered for replay. Dedupe on `X-Whisk-Webhook-Id`: a retry or replay
carries the same id. The handler does not need to be in `routes.public` and cannot be reached
from the internet. `ip_allowlist` (CIDRs) also refuses deliveries from anywhere else; a provider
with no preset uses `preset: hmac` with its signature settings under `hmac` (the fields of a
preset in `webhook-presets.yaml`). `whisk webhooks events <name>` lists recent deliveries with why
the last attempt failed (the handler's status and answer), and `whisk webhooks replay <name>
<id>` sends one to the handler again.

**Calling another of the business's apps.** When one app needs an answer from another now (the
website asking the stock app for today's price, a customer portal asking the orders app where
an order is), call it directly instead of copying its data. Name the apps it calls in
`whisk.yaml`:

```yaml
calls: [orders]
env: { ORDERS_APP_ID: "01J..." }   # whisk apps info orders --json prints it as app.id
```

then send ordinary HTTP to `http://<app id>.internal.whisk:8443/<path>` with
`Authorization: Bearer $WHISK_SERVICE_TOKEN` (read the token per request; it rotates daily). The
call never leaves the platform. The other app receives it with `X-Whisk-Audience: service` and
`X-Whisk-Service-App` naming the caller's app id, so it can refuse callers it does not expect.
Calling an app not listed in `calls` is `FORBIDDEN_ROLE`, and a call without the token is
`AUTH_REQUIRED`. A preview calls the other app's production. Events stay inside the app that
sent them (§7), so between apps: read another app's tables through a shared database (§5); call
it to ask something or to have it act by its own rules; to hand over work that can wait, call a
route that sends an event to the other app's own queue and answers `202` at once.

**Services outside Whisk.** An API key or token your app uses to call a service outside Whisk
is a secret (§6): declare its name and a person sets the value in the dashboard.

## 9. Storage, email, key-value

**Storage** (`storage: true`). Use any S3 client with `WHISK_STORAGE_ENDPOINT`,
`WHISK_STORAGE_BUCKET`, `WHISK_STORAGE_ACCESS_KEY`, `WHISK_STORAGE_SECRET_KEY`,
`WHISK_STORAGE_REGION`, writing under `WHISK_STORAGE_PREFIX`, which is your app's own. Use
path-style addressing (`forcePathStyle: true` in the AWS SDK for JavaScript,
`addressing_style: "path"` in boto3, `BucketLookupPath` in minio-go): the bucket goes in the
path, not the hostname. The endpoint is reachable from your app and from browsers alike. For
browser uploads, `POST /v1/orgs/<org>/apps/<app>/uploads` with the service token and
`{filename, content_type, max_bytes}` answers `{id, key, url, fields, expires_at}`: post the
file to `url` as `multipart/form-data` with every entry of `fields` first and the file last.
The store refuses anything larger than `max_bytes` or of another type, and the bytes never pass
through your app. `GET /v1/orgs/<org>/apps/<app>/uploads/<id>` gives you a link to read one
back. Objects are virus-scanned; an infected one moves to `quarantine/` and reading it answers
`OBJECT_QUARANTINED`. One upload may be up to 5 GB (`max_bytes` 5368709120). The business's storage
allowance counts its files, its databases and its apps' git repositories alike, so images and
media committed to the repository use the allowance too; put what visitors or staff add in
storage, not in git.

**Video and audio.** Never stream video through your app or hand out the 15-minute read link
for it. Authorise the upload as above with its real `content_type` (`video/mp4`,
`video/quicktime`, `audio/mpeg`…) and `"visibility": "public"` if anyone may watch it (a
product video on a website) or leave it `private` (only people signed in to the app). Whisk
converts it for every browser and phone, makes a poster image, and serves it from the app's own
address, which never expires. Store the upload `id`; to show it, load the player and use the
element:

```html
<script src="/.whisk/player.js" defer></script>
<whisk-video media="UPLOAD_ID"></whisk-video>      <!-- or <whisk-audio media="UPLOAD_ID"> -->
```

The player picks a quality for the viewer's screen and connection and plays the original while
the conversion runs. To put a public video on another website, use the absolute script URL
(`https://<app host>/.whisk/player.js`) with the same element, or an iframe of
`https://<app host>/.whisk/media/<id>/embed`. `GET /.whisk/media/<id>` (from the page) says the
status, duration, size and sources; `PATCH /v1/orgs/<org>/apps/<app>/uploads/<id>`
`{"visibility": "public"}` changes who may watch. Converting is a monthly allowance (minutes of
media); past it, new videos play as uploaded until the month turns.

**Images.** Never resize pictures in your app or serve the original of a photo. Authorise the
upload as above with its real `content_type` (`image/jpeg`, `image/png`, `image/heic`…), `public`
if anyone may see it (a product photo on a website) or left `private` (only people signed in to
the app), store the upload `id`, and put the image's own address in the page:

```html
<img src="/.whisk/img/UPLOAD_ID?w=800" width="800" alt="…">
<img src="/.whisk/img/UPLOAD_ID?w=96&h=96&fit=cover" width="96" height="96" alt="…">  <!-- a square crop -->
<img srcset="/.whisk/img/UPLOAD_ID?w=400 400w, /.whisk/img/UPLOAD_ID?w=800 800w, /.whisk/img/UPLOAD_ID?w=1600 1600w"
     sizes="(max-width: 600px) 100vw, 800px" src="/.whisk/img/UPLOAD_ID?w=800" alt="…">
```

`w` and `h` are 1 to 4096 pixels; it fits inside them, or with `fit=cover` fills both and crops
the edges. It is never made larger than the original. Each browser gets AVIF or WebP when it
shows them; add `format=jpeg` (or `png`, `webp`, `avif`) where something else needs one format,
such as an email or an `og:image`. Each size is made the first time it is asked for and kept, so
use a few fixed sizes rather than the exact width of every screen. The address works the moment
the upload finishes and never expires; on another website use the absolute
`https://<app host>/.whisk/img/<id>?w=800` of a public image. Errors: `IMAGE_NOT_READY` (the
upload has not finished; retry), `IMAGE_UNREADABLE` (not a picture it can read, or over 50
megapixels), `NOT_AN_IMAGE` (the upload is not `image/*`). `GET …/uploads/<id>` gives the address
as `image.path`.

**Files a job keeps between runs.** Do not rebuild a directory tree in the database. Set
`storage: true` and use the template's tree helper: pull the tree into a directory under `/tmp`
at the start of a run, work on ordinary files, and push as you go and at the end. It moves only
what changed, so a tree of thousands of files syncs in seconds.

```ts
const s3 = storageClient()!;
await pullTree(s3, "transcripts", "/tmp/work");   // Go: PullTree, Python: pull_tree
// ... the job reads and writes /tmp/work ...
await pushTree(s3, "transcripts", "/tmp/work");   // push after each batch, not only at the end
```

Take a lease row in the database first (`insert ... on conflict do nothing`, with an expiry) so
two runs never work on the same tree, and release it when done. `/tmp` is memory: the pulled tree and the process
share `WHISK_MEMORY_BYTES` (256 MiB on Free, 512 MiB on Team). Keep a pulled tree under half of
it; for anything bigger, pull only the part a run needs or read and write objects by key
instead of copying the tree. Near the limit (85 %) the app's log shows `APP_MEMORY_HIGH` with
the numbers. An app over its limit is killed mid-run with no SIGTERM: `whisk logs` shows a
`whisk:` line from the platform at that moment and the next run's log starts with
`APP_OUT_OF_MEMORY`, the function runs it cut off are failed with that code, and the org's
owners are told (at most once a day per app). `whisk apps info` shows the app's memory now and its peak
against the limit; check it after a job's first real run. There is no persistent disk, by design: the app can move, sleep and wake
anywhere, and everything in storage is backed up.

**Email** (`email: true`). `POST /v1/orgs/<org>/apps/<app>/email/send` with the service token
and `{to, subject, html, text, from}`; `to` is one address or a list. Leave `from` out and the
mail goes from the business's own address on Whisk's domain, `<app>.<org>@whisk.page`, under the
app's name, with no setup; `from: "Training <training@whisk.page>"` sends as
`training.<org>@whisk.page` instead. To send from the
business's own domain, an owner verifies it under Settings, Email, and `from` names an address on
it; any other domain gets `EMAIL_DOMAIN_UNVERIFIED` naming the domains that are. Set `reply_to`
when people should be able to answer.
Transactional only: at most 20 recipients a message and 100 a minute. The plan includes a daily
allowance; past it a paid plan keeps sending and is billed, and the free app stops (it also stops
at 50 a month). `EMAIL_RATE_LIMITED` says which limit with `details.period`.

**Key-value** (`kv: true`). Any Redis client with `WHISK_KV_URL`. The instance is the app's own,
so no prefix is needed. For counters, short caches and locks. Not durable: it empties when the
app sleeps. Starter and above include it; the free app does not, so there the app deploys
without a cache, `WHISK_KV_URL` is unset, and the push and the app's logs say so with `W080`.
Remove `kv` or ask an owner to move to Starter. A free app that already had a cache keeps it.

## 10. Local development

`whisk dev` needs Docker with the compose plugin. It starts Postgres, the workflow engine and a
local edge that injects identity, runs the migration, then runs the command after `--` with the
same environment the app has in production:

```
whisk dev --as ana@acme.example --groups finance --roles owner -- npm run dev       # ts
uv run whisk dev --as ana@acme.example --roles owner -- uvicorn app.main:app --port 8080   # py
whisk dev --as ana@acme.example --roles owner -- go run .                          # go
```

Without `-- <command>` it prints the environment and expects the app on port 8080, started by
you. Requests to `localhost:3000` carry the headers for that person; public routes behave as in
production; `/.whisk/login` and `/.whisk/logout` work. Secrets for local runs come from
`.whisk/dev/secrets.env`, which is git-ignored and filled by the human. Webhooks locally:
`whisk dev tunnel stripe` prints the URL to paste into the provider and forwards every verified
delivery to the app running here for an hour.

Before the CLI is installed, `whisk-stub` (`contract/cmd/whisk-stub` in the public whisk CLI
repository, `go run` it from there) does the same job with an existing Postgres and the Inngest
dev server.

## 11. When something fails

Every error is `{"error": {"code", "message", "fix", "docs", "details"}}`. Read `code`, do what
`fix` says. A code starting `PLATFORM_`, or `details.fault: "platform"` on a deploy or a run, is
Whisk's side: do not change your code or write workarounds for it. Deploy again, or wait. A run
Whisk interrupted (`PLATFORM_RUN_INTERRUPTED`) before any step finished is run again by Whisk
once, and `rerun_run_id` names the new run; one with a finished step is yours to replay. Set
`retries` in the function's options in code and the engine retries a lost step inside the same
run itself; `retries` in `whisk.yaml` only describes it for people. `details.fault: "app"` is yours to fix. The ones you will meet most:

| Code | Do this |
|---|---|
| `MANIFEST_INVALID` | fix each problem in `details.problems`; `whisk doctor` lists them |
| `SECRET_IN_COMMIT` | remove the value, declare a name, rewrite the commit, rotate the value |
| `SECRET_UNSET` | relay the names and URL to the human; nothing else |
| `HEALTH_CHECK_FAILED` | listen on `PORT` on `0.0.0.0`; make the health route return 200; read the log excerpt |
| `CONTAINER_CRASHED` | the app exited; read the log lines (`whisk deploys log <id>`), usually a missing variable or file |
| `FUNCTIONS_NOT_REGISTERED` | a warning on a live deploy: make each function name in `whisk.yaml` the id the code uses |
| `BUILD_FAILED` | read the log excerpt; usually a dependency or compile error |
| `MIGRATE_FAILED` | fix the migration and redeploy; the message says whether the database was rolled back and where users' writes were kept |
| `AUTH_REQUIRED` | the route is private; list it under `routes.public` or sign in; from the CLI, `whisk login` |
| `FORBIDDEN_ROLE` | your token's role cannot do this; tell the human which permission it needs, do not log in again |
| `AGENT_CATEGORY` | the human has not allowed agents this kind of action (`details` names it); ask them to allow it in the dashboard or do it themselves |
| `AGENT_PAUSED` | the human paused this agent; stop and tell them, nothing else works until they resume it |
| `CSRF_REJECTED` | POST from the app's own pages, or use a bearer token |
| `RUN_PARKED` | `whisk runs show <id>`, fix the step, deploy, `whisk runs replay <id>` |
| `PLATFORM_DEPLOY_FAILED` | Whisk's side; run `whisk deploy` again, and wait a few minutes if it repeats |
| `RATE_LIMITED` | wait `Retry-After` |

`whisk logs --since 1h --grep <request id>` finds a request. `whisk runs show <id>` shows a
workflow run step by step. `whisk errors <CODE>` prints the full entry from `errors.md`.
`whisk traces --slowest` lists slow requests; `whisk traces show <request id>` shows where one request's time went, span by span.
`whisk logs forwarding` shows whether the business also sends its logs to its own log service
(Business plan) and how that is going; an owner adds one in the dashboard, never you.
If an error's `fix` did not work, was unclear, or you had to guess, send feedback (section 12)
once you are past it.

## 12. Tell Whisk what got in your way

Whisk is built for coding agents, and you meet its rough edges first. The Whisk team reads every
piece of feedback and turns it into fixes. Send it at any point, without asking the human first:
when something looks like a bug (a `fix` that did not help, a 500, docs that disagree with
behaviour), when something was hard (you retried, guessed or needed the human), when something
is missing, and when something worked well.

```
whisk feedback "deploy said HEALTH_CHECK_FAILED with an empty excerpt; the app listened on 3000. Naming the port would have saved three deploys." --kind bug --code HEALTH_CHECK_FAILED
```

`--kind` is `bug`, `difficulty` (default), `idea` or `praise`; `--code` and `--command` name what
you hit. Say what you did, what happened, what you expected and what would have helped. If the
CLI will not install or login fails, post it without a token:
`curl -fsS https://api.whisk.run/v1/feedback -H 'Content-Type: application/json' -d '{"message":"..."}'`,
or in PowerShell, where `curl` is another command:
`Invoke-RestMethod -Method Post -Uri https://api.whisk.run/v1/feedback -ContentType application/json -Body (@{message='...'} | ConvertTo-Json)`.

Every piece gets an id; keep it. Come back to what you sent:

```
whisk feedback list          # what you and your org sent, open and resolved, with the team's notes
whisk feedback show <id>     # one piece in full: its status and what the Whisk team said
```

Run `whisk feedback list` at the start of a session in a project where you sent feedback before.
A resolved piece usually means what got in your way is fixed, and its note says how or what to
do now: retry what failed, or drop the workaround you used. Do not send the same feedback again
while it is open; if a resolved problem is still there, send a new piece naming the old id.
Feedback sent without a login is read by its id alone, with `whisk feedback show <id>` or
`curl -fsS https://api.whisk.run/v1/feedback/<id>` (`Invoke-RestMethod https://api.whisk.run/v1/feedback/<id>`).

Never include a secret value, a token or personal data; name the secret instead. At most 20 an
hour signed in, 5 without a login; past that, combine them.

## 13. Do not

- Add an outside service for something Whisk already does (§1, Whisk already does this).
- Run cron, timers or long-lived worker loops inside the app. Declare a function (doctor
  warns, W023).
- Load a whole file, export or table into memory or `/tmp` when it can be streamed in chunks.
- Poll on a schedule for something a webhook or event can tell you.
- Write to disk outside `/tmp`. Use the database or storage.
- Hard-code hostnames. Use `WHISK_PUBLIC_URL` and relative links.
- Print secrets, tokens or the environment.
- Set cookies with a `Domain` attribute or read them in page script by their bare name. In the
  browser every app cookie is `__Host-<name>`, bound to the app's own address; the app's server
  sees `<name>`. Script that sets a cookie the server should read sets `__Host-<name>` with
  `Secure; Path=/`, or the platform drops it. A client that reads a CSRF token cookie (axios's
  `XSRF-TOKEN`, Django's `csrftoken`) is pointed at the `__Host-` name, for example axios
  `xsrfCookieName: "__Host-XSRF-TOKEN"`; the platform already refuses cross-app form posts.
- Fetch another app's routes from page script. Those requests arrive without cookies; call the
  other app from your server with a service token.
- Accept `X-Whisk-*` values from anywhere but the request the platform delivered.
- Build a login page, a password field, a session store or a password reset. The platform owns
  sign-in; doctor warns on a password field (W090), and a public page asking for a password in
  another company's name is held for review.
- Hold a database connection open across requests with session state; the pool is
  transactional (doctor warns on `LISTEN`, advisory locks and `SET`, W024).
- Make the app slow to start: a sleeping app's visitor waits for it. Bundle a Node app into one
  file in the build (`esbuild src/index.ts --bundle --platform=node --format=esm
  --outfile=dist/index.mjs`) and start that, not `tsx` or a `node_modules` tree; run migrations
  through `migrate` in whisk.yaml, not at start; import heavy libraries inside the function that
  uses them. Doctor warns on each (W101 to W104) and on a measured start over 3 seconds (W100).
- Ask the human for a secret value. Declare the name and point them at the dashboard.
