Skip to content

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 with npm install -g @flatstackdev/cli and run flatstack <command>. The examples use npx.
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 exits 0 and stores a token in the OS keychain (or a file only the user can read). A denied request exits 1 with forbidden.
  • 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
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:

  • --yes on env unset, secrets unset, domains remove and storage rm. Without it they change nothing and answer confirmation_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