Getting started
This is what your coding agent reads before it builds on Whisk. You don't need to read it yourself: give your agent whisk.run/skill and it does the rest. It's here so you can see exactly what it's told.
From SKILL.md, the same document your agent reads.
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: 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.
- Listen on
PORT(always 8080) on all interfaces. - Answer
GET <health.path>with 200 when ready to serve. Default/health. - Log to stdout and stderr. JSON lines are indexed by field; plain lines are indexed as text.
- Write files only under
/tmp. Everything else is read-only./tmpis emptied on every restart; files that must last go to storage (§9, files a job keeps)./tmpis memory: what you keep there and what the process uses shareWHISK_MEMORY_BYTES(256 MiB on Free), and a run that goes over is killed without SIGTERM. - Exit cleanly on SIGTERM within the grace period (default 28 seconds).
- Read configuration and secrets from the environment. Never from files in the repository.
- Trust identity only from the
X-Whisk-*headers, which only the platform can set. - Treat every queue delivery and webhook as possibly repeated; use the provided IDs to dedupe.
- 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 workIf 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 NEEDSHUMAN block whose link (details.url, details.reason `BUSINESSNOTINLOGIN) 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.
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 lastsstatic 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:
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.
- Store the owner on every record a person creates: their
X-Whisk-User-Id, in a column such asowner_idorauthor_id. Never take the owner from the body, the query string or a hidden form field. - Filter in the query, not afterwards. The template's
whiskmodule hasscopeFor(scope_for,ScopeForin 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. - Check every record fetched by id with
canSee, thencanChangebefore changing or deleting it (can_seeandcan_change;CanSeeandCanChange). A record the person may not see answers 404, as if it did not exist; one they may see but not change answers 403. - 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. - 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):
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:
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.sqlruns a script. Statements stop after 30 seconds; at most--limitrows 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 asCONFIRM_REQUIREDsaying what it would do and carrying a code. Check that the effect is what you meant, then runwhisk db query --confirm <code>. A restore point is taken first, and the result names thewhisk restore --atthat undoes it, on a plan with self-service restore. On Free nothing undoes it (details.restorableisfalse): copy the rows it changes first withwhisk db query "select …" --json. - Do not put
beginorcommitin 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.
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_SECRETat 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
{"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.
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.yamlexport 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.
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-poIds 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.
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/stripewhisk 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:
calls: [orders]
env: { ORDERS_APP_ID: "01J..." } # whisk apps info orders --json prints it as app.idthen 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:
<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:
<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.
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
/tmpwhen 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_URLand relative links. - Print secrets, tokens or the environment.
- Set cookies with a
Domainattribute 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>withSecure; Path=/, or the platform drops it. A client that reads a CSRF token cookie (axios'sXSRF-TOKEN, Django'scsrftoken) is pointed at the__Host-name, for example axiosxsrfCookieName: "__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 andSET, 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, nottsxor anode_modulestree; run migrations throughmigratein 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.