Build and ship with an AI agent
This page is for coding agents, such as Claude Code, Codex and Cursor, and for the people who run them. It is the whole loop: check the sign-in, start from the template, create a project, write the app, deploy it, check it’s live, and recover when a step fails. Every command here takes --json, prints one JSON document on stdout, and exits with a code you can branch on.
To give your agent this page as a skill it loads by itself, see Install the Flatstack skill.
Before you start
- A Flatstack account. A person has to make it, because it needs a paid plan and checkout at app.flatstack.dev. An agent can’t create one.
- Node 22 or later, for
npx. - The CLI. Run it as
npx @flatstackdev/cli <command>, or install it withnpm install -g @flatstackdev/cliand runflatstack <command>. The examples usenpx.
| Address | What’s there |
|---|---|
https://app.flatstack.dev |
The dashboard: sign-up, billing, tokens, members |
https://api.flatstack.dev |
The API the CLI uses, and the MCP server at /mcp |
https://<slug>.flatstack.app |
Each deployed app |
https://flatstack.dev/docs |
These docs; /llms.txt and /llms-full.txt for agents |
The loop at a glance
npx @flatstackdev/cli whoami --json # 1. signed in?
npx @flatstackdev/cli init my-app --json # 2. starter app
cd my-app && npm install
npx @flatstackdev/cli projects create my-app --link --json # 3. project, linked here
# 4. edit flatstack/schema.ts, flatstack/functions/, public/
npx @flatstackdev/cli db diff --json # 5. migration from the schema
npx @flatstackdev/cli deploy --json # 6. scan, migrate, deploy
curl -fsS https://<slug>.flatstack.app/ # 7. check it's live
Exit codes are the same for every command:
| Exit code | Meaning | stdout with --json |
|---|---|---|
0 |
It worked | The result |
1 |
The command failed: the API refused, a check failed, or the network did | { "error": { "code", "message", "docsUrl", "details"? } } |
2 |
A usage mistake: unknown command or flag, a missing argument, an unlinked directory | { "error": { "code": "validation_failed", … } } |
Progress lines go to stderr, so stdout is always one JSON document. The examples below are abridged to the fields you need. Branch on error.code, never on the message. Each code has a page at its docsUrl. npx @flatstackdev/cli --help --json describes every command and flag.
1. Check you’re signed in
npx @flatstackdev/cli whoami --json
Signed in, exit 0:
{
"apiUrl": "https://api.flatstack.dev",
"accountId": "acc_…",
"role": "developer",
"tokenId": "tok_…",
"project": null
}
project is { "id", "slug" } when the directory is already linked to a project; then skip step 3.
Not signed in, exit 1, with error.code unauthenticated. There are two ways in:
- A person approves this computer once. Run
npx @flatstackdev/cli login --no-browser. It prints a code and a link to stderr, then waits up to 10 minutes. Show both to the person and ask them to open the link, check the code and choose Approve. The command then exits0and stores a token in the OS keychain (or a file only the user can read). A denied request exits1withforbidden. - A token in the environment. A person creates a personal access token in the dashboard and sets it as
FLATSTACK_TOKEN. It wins over a stored login. For CI and agents without a person nearby. See Authentication.
A token can do what the member’s role allows, and no more. No token can manage members, tokens or billing, or delete, restore or transfer a project: those need a person in the dashboard. If a command answers checkout_required, the account has no plan yet; ask the person to finish checkout.
2. Start from the starter
npx @flatstackdev/cli init my-app --json
Exit 0:
{
"template": "starter",
"dir": "/home/you/my-app",
"files": ["flatstack/schema.ts", "flatstack/functions/todos.ts", "flatstack/auth.ts", "public/index.html", "…"],
"next": ["cd my-app", "npm install", "…"]
}
The starter is a to-do list with email and password sign-in: an owner-only table, four functions that pass the security scan, and a plain page in public/. Build on it rather than starting empty. With no directory, init writes into the current one. The directory must be empty or not exist; otherwise it exits 1 with validation_failed, and --force writes over files with the same names.
Then install the dependencies:
cd my-app && npm install
3. Create and link a project
npx @flatstackdev/cli projects create my-app --link --json
Exit 0:
{
"id": "prj_…",
"name": "my-app",
"slug": "my-app",
"region": "eu",
"engine": "d1",
"status": "ready",
"url": "https://my-app.flatstack.app",
"linked": true
}
--link writes .flatstack/project.json, so later commands know the project. Commit it. Options: --slug <slug> for the subdomain, --region eu|us, --engine d1|postgres (Postgres needs Pro or Studio).
error.code |
What to do |
|---|---|
conflict |
The subdomain is taken. Run it again with another --slug. |
plan_limit_reached |
The plan’s project count is used up. Ask the person to remove a project or upgrade. Don’t delete one yourself. |
engine_not_on_plan |
Leave out --engine postgres, or ask the person to upgrade. |
If status is failed, run npx @flatstackdev/cli projects provision --json to finish setting it up. To use a project that already exists, find it with projects list --json and run npx @flatstackdev/cli link <slug> --json. Commands that act on a project in a directory that isn’t linked exit 2; pass --project <id or slug> or link first.
4. Write the app
flatstack.config.ts optional: hosting (SPA fallback, redirects, headers) and cors
flatstack/
schema.ts tables and who may read and write them
migrations/ written by db diff; commit them, never edit an applied one
functions/*.ts fn() exports, called with POST /fn/<name>
routes/*.ts Hono routers served under /api, for webhooks
auth.ts roles and sign-up hooks
cron.ts scheduled jobs
public/ your frontend (or dist/ or build/ after a build)
A table, owner-only by default because it has an owner() column (Data and access rules):
// flatstack/schema.ts
import { owner, table, text, timestamp } from "@flatstackdev/server/schema";
export const notes = table("notes", {
id: text().primaryKey().$defaultFn(() => crypto.randomUUID()),
userId: owner(),
body: text().notNull(),
createdAt: timestamp().defaultNow().notNull(),
});
A function that passes the scan the first time (Functions and routes):
// flatstack/functions/notes.ts
import { fn, z } from "@flatstackdev/server";
import { notes } from "../schema.js";
export const addNote = fn({
input: z.object({ body: z.string().trim().min(1).max(2000) }),
auth: "required",
handler: async ({ input, ctx }) => {
const [note] = await ctx.db.asUser().insert(notes, { body: input.body, userId: ctx.user.id });
return note;
},
});
The page calls it with fetch("/fn/addNote", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ body }) }). Sign-in is at /auth/* on the same address (Sign-in for your app’s users); the starter’s public/app.js shows both.
Rules that keep a deploy from being blocked
Every deploy is scanned. A critical finding blocks it; the rest come back as warnings. Write code this way and the scan passes:
| Write this | Not this | Check it fails |
|---|---|---|
ctx.db.asUser() in any function a client can call |
ctx.db.asAdmin() without auth: "admin" or a check on ctx.user.role |
admin-without-role-check, critical |
write: "owner" or "authenticated", or no access block on an owner() table |
write: "public", or a write rule that always returns true |
public-write-table, critical |
An access block or an owner() column on every table with expose: true |
expose: true with nothing limiting who reads or writes |
exposed-table-without-rules, critical |
Secrets set with secrets set and read with ctx.secrets.get() in a function |
Any secret key, service token or database URL in the frontend | frontend-secret, critical |
auth: "required" on functions that write |
auth: "none" on a function that inserts, updates or deletes |
unauthenticated-db-write, high |
Your app’s origins in cors |
* as an allowed origin in production |
weak-cors, high |
input: z.object({ … }) on every function, z.object({}) if it takes nothing |
A function without input |
missing-input-schema, medium |
A fixed origin, or a list of allowed hosts, for outbound fetch |
fetch of a URL taken from the request |
ssrf-fetch, medium |
Also: asUser() refuses a write that names someone else as a row’s owner, with 403 forbidden. Every exported fn() can be called by clients, including the ones cron runs, so use asAdmin() (for joins and raw SQL) only with auth: "admin" or after checking ctx.user.role. /api/data/* and /api/stripe/* are reserved, so don’t put routes there.
Variables and secrets
npx @flatstackdev/cli env set STORE_NAME "Corner shop" --json
printf %s "$STRIPE_KEY" | npx @flatstackdev/cli secrets set STRIPE_KEY --json
Both answer { "key", "environment", "appliesOn": "next_deploy" }. Pipe secret values in; a value on the command line ends up in shell history. Secret values are never printed back.
5. Write the migration
After any change to flatstack/schema.ts:
npx @flatstackdev/cli db diff --json
{ "migration": "0001_notes", "statements": ["CREATE TABLE `notes` (…);"], "destructive": [] }
It writes flatstack/migrations/<number>_<name>.sql and changes nothing on Flatstack. migration is null when the schema hasn’t changed. A non-empty destructive list means the migration drops or rewrites data: stop and ask the person before deploying it. If the diff can’t tell a rename from a drop and an add, it exits 1 with validation_failed; make the change in two steps. Pass --engine postgres on a Postgres project. More in Migrations.
6. Deploy
If the frontend has a build step (Vite, Astro and the like), run it first: deploy uploads the built files but doesn’t build them. Then:
npx @flatstackdev/cli deploy --json
deploy bundles flatstack/functions and flatstack/routes, runs the security scan, applies pending migrations, uploads the site files that changed, and waits until the new version is live. The site comes from --assets-dir <dir>, or else the first of dist, build and public that exists. Exit 0:
{
"id": "dep_…",
"status": "succeeded",
"environment": "production",
"url": "https://my-app.flatstack.app",
"functions": ["addNote", "createTodo", "deleteTodo", "listTodos", "setTodoDone"],
"routes": [],
"migrations": ["0000_todos", "0001_notes"],
"cronJobs": [],
"scan": { "blocking": false, "counts": { "critical": 0, "high": 0, "medium": 0, "low": 0 }, "findings": [], "skipped": [] },
"site": { "dir": "public", "files": 6, "bytes": 18211, "uploaded": 1, "spa": false, "skipped": [] },
"canRollBack": true,
"warnings": []
}
Read warnings and scan.findings even when it succeeds: high and medium findings don’t block, but they’re real. Each finding has rule, severity, message (what’s wrong and how to fix it), and file and line when it’s in a file.
A blocked deploy exits 1:
{
"error": {
"code": "deploy_blocked_by_scanner",
"message": "The security scan found a critical problem. Fix the findings in details and deploy again.",
"docsUrl": "https://flatstack.dev/docs/errors/deploy_blocked_by_scanner",
"details": { "deployId": "dep_…", "scan": { "blocking": true, "counts": { "critical": 1, … }, "findings": [ … ] } }
}
}
Fix each finding in details.scan.findings and deploy again. Nothing changed: the previous version is still live. --force-insecure --confirm <slug> deploys anyway; only use it when the person tells you to, and tell them which findings it overrides.
Rolling back: npx @flatstackdev/cli rollback --json puts the deploy before the live one back; rollback <deployId> picks one from deploys list --json. Migrations aren’t reversed.
7. Check it’s live
curl -fsS -o /dev/null -w "%{http_code}\n" https://my-app.flatstack.app/
curl -sS -X POST https://my-app.flatstack.app/fn/addNote -H "content-type: application/json" -d '{"body":"hi"}'
The page answers 200. A function with auth: "required", called without signing in, answers 401 with { "error": { "code": "unauthenticated", … } }, which shows it is deployed and checking sign-in. 404 not_found with “No function named …” means the function isn’t in the deploy: check the functions list in the deploy result. 503 not_deployed means nothing has been deployed to that environment.
To call it signed in, sign up with POST /auth/sign-up/email ({ "email", "password", "name" }) and send the session cookie it sets with each call.
To try things before deploying, npx @flatstackdev/cli dev runs the app at http://127.0.0.1:8788 with a local database. Emails sign-in would send are kept: GET /auth/fs/dev/outbox?to=<email> lists them with their links and codes (Local development).
8. When a step fails
Read error.code and act on it. Retrying the same command only helps where the table says so.
error.code |
Retry? | What to do |
|---|---|---|
unauthenticated |
No | Sign in (step 1) or set FLATSTACK_TOKEN. A stored token expires after 90 days. |
checkout_required |
No | The account has no plan yet. Ask the person to finish checkout. |
forbidden |
No | The role or token can’t do this. Ask the person for a role or token that can. |
validation_failed |
After a change | Fix the flag or input. details says which field and why. Exit code 2 is a usage mistake in the command itself. |
not_found |
No | Check the project, deploy or function name. projects list --json shows what exists. |
conflict |
After a change | Something already exists or changed, such as a taken subdomain. Read the message. |
project_provisioning |
Yes, soon | The project is still being set up. Wait a few seconds and try again. |
deploy_blocked_by_scanner |
After a change | Fix every finding in details.scan.findings. Don’t force it without the person. |
deploy_failed |
After a change | details.reason says why, such as a route under a reserved path or a startup error. The previous version is still live. |
migration_failed |
After a change | details.statement and details.error say which statement failed. The database is unchanged. Fix it with a new migration, not by editing an applied one. |
migration_conflict |
After a change | A migration that was applied is missing, changed or out of order. Restore the applied files as they were and add new changes as a new migration. |
destructive_migration |
After the person agrees | details.migrations lists what would lose data. Ask the person; with their yes, deploy with --allow-destructive. |
bundle_too_large |
After a change | The server bundle is over 10 MB compressed. Remove unused dependencies; put large files in storage. |
assets_too_large |
After a change | The site’s files are over a deploy limit (details says which). Move large media to file storage. |
assets_missing |
Yes | Some site files weren’t uploaded. Run deploy again; it uploads what’s missing. |
cron_interval_too_short |
After a change | A job in flatstack/cron.ts runs more often than the plan allows. details gives the minimum. |
plan_limit_reached |
No | A plan count is used up. Ask the person. |
account_locked |
No | A payment failed and the account is read-only. Ask the person to update the card. |
confirmation_required |
After the person agrees | The command can’t be undone. Ask, then repeat it with --yes. |
rate_limited |
Yes, after a wait | Wait for the Retry-After period. |
internal_error |
Yes, once | If it happens again, stop and tell the person. |
Every code and what it means: error reference.
When your function throws, its caller gets 500 internal_error and the details go to the project’s logs. Read them with npx @flatstackdev/cli logs --level error --since 15m --json (or the get_logs MCP tool); secrets are redacted. To step through it, reproduce the call under npx @flatstackdev/cli dev and read the terminal it runs in.
Actions that need the person
Ask before any of these, and only go ahead on a clear yes:
--yesonenv unset,secrets unset,domains removeandstorage rm. Without it they change nothing and answerconfirmation_required.--allow-destructive, for a migration that drops or rewrites data.--force-insecure --confirm <slug>, to deploy past a critical finding.- Anything to do with billing, members or tokens, which only the dashboard can do.
Using MCP instead of the CLI
The MCP server suits agents that can’t run commands, or a person working in Claude or Cursor. For a project on disk the CLI is simpler: it bundles the functions and uploads only the files that changed.
Connect Claude Code:
claude mcp add --transport http flatstack https://api.flatstack.dev/mcp
Then run /mcp, choose flatstack and Authenticate: a person signs in once and picks what the agent may do. Other clients, and connecting with a personal access token instead, are in Connecting AI tools over MCP.
| Tools | What they do |
|---|---|
list_projects, get_project, create_project |
Find or create projects. Most tools take projectId and an optional environment. |
park_project, promote_project, archive_project, restore_project, delete_project |
Move a project between active and parked slots, archive it, delete it, or bring it back. Only when the person asks; archiving and deleting need confirm: true. |
export_project |
A ZIP of the project: database, users, files, code and SELF_HOST.md. |
create_sandbox, claim_sandbox |
Make a parked prototype project, or move an unclaimed 24-hour sandbox into the account. |
deploy, get_deploy_status, rollback |
Deploy a bundled Worker with its migrations and, for small sites, staticFiles; check or undo a deploy. |
apply_schema, run_security_scan |
Apply migrations alone (dryRun: true first); scan without deploying. |
list_env, set_env, set_secret |
Variables and secrets. |
list_domains, add_domain, check_domain |
Custom domains. |
add_email_domain, get_email_domain, check_email_domain, remove_email_domain, send_test_email, get_email_log |
Email sending. |
import_project, get_import_status |
Import from Supabase, Lovable Cloud or Clerk (dryRun: true first). |
get_usage, get_activation, list_cron_runs, list_end_users |
Usage against limits, the setup checklist, cron history, your app’s users. |
get_auth_settings, set_auth_settings |
How your app’s users sign in: methods, OAuth providers, Turnstile. Client secrets are write-only. |
list_addons, buy_addon |
Add-ons on sale and held; buy one only when the person asks, with confirm: true. |
publish_site |
Publish static files as a parked project, no build. Not switched on yet: it answers feature_unavailable until it is. |
get_logs |
An environment’s logs, newest first: since, level, search, limit. |
search_docs |
Search these docs and the error reference; each result has a link and a snippet. |
Every result has a warnings list: limits at 80% or capped, and security scan findings. Show them to the person. A failed call is a tool error whose body is the same { "error": { "code", "message", "docsUrl", "details"? } } as the CLI’s, so the table in step 8 applies. Destructive tools refuse without confirm: true and answer confirmation_required; deploy and apply_schema need confirm: true for destructive migrations; forceInsecure: { confirm: "<slug>" } deploys past critical findings. Set these only after the person agrees.
Install the Flatstack skill
The skill is a short version of this page that your agent loads when you mention Flatstack, a backend, or deploying.
Claude Code, in your project:
mkdir -p .claude/skills/flatstack
curl -fsSL https://flatstack.dev/skills/flatstack/SKILL.md -o .claude/skills/flatstack/SKILL.md
Use ~/.claude/skills/flatstack/ instead to have it in every project.
Codex and other agents that read AGENTS.md: add the same rules to your project’s AGENTS.md:
curl -fsSL https://flatstack.dev/skills/flatstack/AGENTS.md >> AGENTS.md
Cursor:
mkdir -p .cursor/rules
curl -fsSL https://flatstack.dev/skills/flatstack/flatstack.mdc -o .cursor/rules/flatstack.mdc
All three are generated from one source, packages/skills/skills/flatstack/SKILL.md in the Flatstack repository.
As Markdown: agents/quickstart.md