# fuast docs fuast is a small cloud for team software. An agent writes a little full-stack TypeScript app called a capsule, fuast runs it with a database, sign-in, storage, schedules, and email built in, and you share it with coworkers like a doc. If you are an agent, treat the capsule directory as the whole app. Write the server contract, write the Preact client, run the fuast CLI, read the runtime state, and deploy without leaving the terminal. The [agent start guide](https://fuast.com/docs/agents.md) is the short version of this site. An agent without a terminal can do the same through the [MCP server](https://fuast.com/docs/mcp.md) at `https://fuast.com/mcp`: Claude, ChatGPT, Cursor, and VS Code connect to it and deploy from file contents. ## Start here ```sh bunx @revnu/fuast new my-app --template todo cd my-app bunx @revnu/fuast dev ``` `fuast create` is an alias for `fuast new`. To turn the current, empty directory into a capsule, run `fuast init`. Templates: `todo`, `guestbook`, `signature`, `approvals`, `live-demo`. See [examples](https://fuast.com/docs/examples.md). The dashboard's **New app** button deploys the same templates without a terminal. A capsule has this shape: ```txt server/index.ts default export capsule({...}): schema, queries, mutations, actions, endpoints, schedules client/index.tsx export function App(): the Preact UI shared/ pure TypeScript both sides import .env.fuast.server optional server-only env (KEY=value lines) favicon.svg optional (.ico also works) fuast.json {"app": ""}, written by the first deploy. Commit it. fuast-env.d.ts type declarations for fuast/server, fuast/client, fuast/ui tsconfig.json AGENTS.md instructions for coding agents (CLAUDE.md imports it) ``` Import rules: - `server/` imports `fuast/server` and relative files. - `client/` imports `fuast/client`, `preact`, `preact/hooks`, `preact/jsx-runtime`, and relative files (and, optionally, the `fuast/ui` component kit). - `shared/` imports only relative files. - No npm packages and no Node built-ins anywhere. ## Server contract ```ts import { boolean, capsule, endpoint, json, mutation, query, string, table, userId } from "fuast/server"; export default capsule({ name: "Notes", schema: { notes: table({ body: string(), pinned: boolean().default(false), ownerId: userId() }).index("by_owner", ["ownerId"]) }, queries: { notes: query(async (ctx) => { const { userId } = ctx.auth.requireIdentity(); return ctx.db.notes .withIndex("by_owner", (q) => q.eq("ownerId", userId)) .order("desc") .collect(); }) }, mutations: { addNote: mutation(async (ctx, body: string) => { const { userId } = ctx.auth.requireIdentity(); const clean = body.trim().slice(0, 500); if (!clean) throw new Error("Write something first."); return ctx.db.notes.insert({ body: clean, ownerId: userId }); }) }, endpoints: { health: endpoint({ method: "GET", path: "/api/health" }, () => json({ ok: true })) } }); ``` Handlers receive `ctx`: - `ctx.auth`: who is calling. `requireIdentity()` returns a guest or account, `requireSignedIn()` returns an account, both throw otherwise. - `ctx.db`: async table access through declared indexes. Read-only in queries, actions, and read-only endpoints. - `ctx.env`: server-only values from `.env.fuast.server` and the dashboard. - `ctx.log`: structured logs you read with `fuast logs`. - `ctx.storage`, `ctx.email`, `ctx.team`, `ctx.workspace`: files, email, workspace-shared tables, and workspace members. - Global `fetch()` for outbound HTTP. Details: [capsule API](https://fuast.com/docs/capsule-api.md), [database](https://fuast.com/docs/database.md). ## Client contract ```tsx import { canAccessApp, createClient, retryAuth, SignInButton, useAuth } from "fuast/client"; import { useState } from "preact/hooks"; import type app from "../server/index"; const client = createClient(); export function App() { const auth = useAuth(); if (auth.isLoading) return null; if (!canAccessApp()) { return (
{auth.error ?

{auth.error}

:

Sign in to use this app.

} {auth.error ? : null}
); } return ; } function Notes() { const notes = client.useQuery("notes"); const addNote = client.useMutation("addNote"); const [draft, setDraft] = useState(""); return (
{ e.preventDefault(); void addNote(draft).then(() => setDraft("")); }}> setDraft(e.currentTarget.value)} />
{notes === undefined ? null : notes.length === 0 ? (

No notes yet. Write one above.

) : (
    {notes.map((n) =>
  • {n.body}
  • )}
)}
); } ``` `client.useQuery` returns `undefined` until the first result, then re-renders whenever a write changes the data it read. Mutations and actions return promises. See [capsule API](https://fuast.com/docs/capsule-api.md#client). ## Auth and env Every app belongs to a workspace (your company, by verified email domain). Sharing decides who can open it: `private` (you and people you add), `workspace` (your coworkers), or `public` (anyone with the link, including guests). Public apps give visitors a guest session until they sign in, and their `userId()` data follows them to their account. See [auth](https://fuast.com/docs/auth.md) and [teams](https://fuast.com/docs/teams.md). Server secrets go in `.env.fuast.server`: ```txt OPENAI_API_KEY=sk-... WEBHOOK_SECRET=whsec_... ``` Read them only in handlers with `ctx.env.OPENAI_API_KEY`. `fuast dev` loads the file. `fuast deploy` uploads it. Keep it out of git (the starter `.gitignore` does). ## Inspect the runtime While `fuast dev` runs: ```sh fuast check fuast run notes fuast run addNote '"first note"' fuast inspect --port 3000 fuast inspect --query notes fuast db dump --port 3000 fuast logs --port 3000 ``` `fuast run` calls a handler headlessly and prints its result. `inspect --query` shows how many rows one query read for what it returned. Locally, handler errors in `fuast logs` carry stacks mapped to your source. See [test and debug](https://fuast.com/docs/testing.md). For a deployed app, pass its slug (from `fuast.json`) or URL. Without one, these commands target `fuast dev`: ```sh fuast inspect my-app fuast run notes my-app fuast db dump my-app fuast logs my-app --follow ``` Dev and hosted apps serve the same `/__fuast/*` inspection routes with the same JSON. Use them before guessing. See [reference](https://fuast.com/docs/reference.md#inspection-routes). ## Deploy ```sh fuast check fuast deploy ``` `deploy` needs no account. Without a login it puts the app online as a preview for 72 hours and prints a preview link and a claim link. The person the app is for opens the claim link, signs in, and the app is theirs: private, with the data it already holds. See [deploy without an account](https://fuast.com/docs/reference.md#deploy-without-an-account). ```sh fuast login fuast deploy fuast share --visibility workspace ``` `fuast login` opens a browser page where a person signs in and approves the CLI, once per machine. CI sets `FUAST_TOKEN` instead. Logged in, `deploy` goes straight to that account, and after a claim it keeps deploying the claimed app. `deploy` uploads source, type checks it (type errors block the deploy), builds it, and swaps it in with the data intact. Every deploy is an immutable record: `fuast deploy list` shows them and `fuast deploy rollback ` returns to one. See [reference](https://fuast.com/docs/reference.md#deploy). ## Current limits - One server entry (`server/index.ts`) and one client entry (`client/index.tsx`). - No npm packages, no Node built-ins, no CSS files. Tailwind classes compile automatically. - Per handler: 1000 rows returned, 5000 rows read, 4 MiB read, 1000 writes, 5 s of CPU, 10 s wall clock. - Per app: 100000 rows, 64 MiB of rows, 100000 requests and 20000 mutations per UTC day, 100 emails per day. - Files: 25 MiB each, 1 GiB per workspace. Every limit, its error, and what to change: [limits](https://fuast.com/docs/limits.md). ## Read next - [Agent start guide](https://fuast.com/docs/agents.md): the build, verify, deploy loop for coding agents. - [Capsule API](https://fuast.com/docs/capsule-api.md): server, client, handler capabilities, types. - [Database](https://fuast.com/docs/database.md): indexes, field types, pagination, schema changes, ingest. - [Auth](https://fuast.com/docs/auth.md): accounts, guests, sign-in policy, endpoint credentials. - [Teams](https://fuast.com/docs/teams.md): workspaces, sharing, invites, members, team tables. - [Custom domains](https://fuast.com/docs/domains.md): serve an app at a domain you own, with DNS verification and HTTPS. - [Storage](https://fuast.com/docs/storage.md): user uploads and server-side files. - [Schedules](https://fuast.com/docs/schedules.md): cron jobs. - [Jobs](https://fuast.com/docs/jobs.md): run a mutation or action later with `ctx.scheduler`, durably and transactionally. - [Email](https://fuast.com/docs/email.md): transactional email from handlers. - [Agent tools](https://fuast.com/docs/agent-tools.md): expose handlers to browser agents with WebMCP. - [MCP server](https://fuast.com/docs/mcp.md): connect Claude, ChatGPT, Cursor, VS Code, or Claude Code and build without a terminal. - [Test and debug](https://fuast.com/docs/testing.md): `fuast check`, `fuast run`, `inspect --query`, logs, `run-many`. - [Security](https://fuast.com/docs/security.md): what fuast guarantees and what your capsule must check. - [Limits](https://fuast.com/docs/limits.md): every limit and error. - [Reference](https://fuast.com/docs/reference.md): CLI, inspection routes, HTTP API, env vars. - [Examples](https://fuast.com/docs/examples.md): complete starter apps. - [Migration to v2](https://fuast.com/docs/migration-v2.md): upgrade a capsule written for the old fuast API. - [Support](https://fuast.com/docs/support.md): platforms, compatibility, persistence. - [Isolation](https://fuast.com/docs/isolation.md): how fuast keeps apps apart. - [llms.txt](https://fuast.com/llms.txt): an index of every page for agents. [/agents.md](https://fuast.com/agents.md) is the agent start guide, and [/docs.json](https://fuast.com/docs.json) the index as JSON. --- # fuast agent instructions fuast runs small full-stack TypeScript apps called capsules and shares them with your coworkers like a doc. One CLI gives you a typed server, a Preact client, a reactive database, sign-in, workspaces, storage, schedules, email, and hosting. You write two files, run `fuast dev`, check the data, then `fuast deploy`. You do not need an account, and you never wait for a person to log in. `fuast deploy` puts the app online as a preview and prints a claim link. Give that link to the person you are building for. They open it, sign in, and the app is theirs. If you have no terminal, connect to the [MCP server](https://fuast.com/docs/mcp.md) at `https://fuast.com/mcp` instead: its tools check, deploy, test, and share an app from file contents you send. To have fuast at hand in every later session, the person can run `curl -fsSL fuast.com/install | sh`. It connects the MCP server to Claude Code, Codex, and Gemini CLI and saves the [fuast skill](https://fuast.com/docs/mcp.md#the-fuast-skill). It is optional: nothing on this page needs it. ## Start here You need [Bun](https://bun.sh) 1.2 or later. Capsules do not need `node_modules`, and you never install app dependencies. If the current directory is empty or a fresh repository, make it the capsule: ```sh bunx @revnu/fuast init ``` Otherwise create the capsule in a new directory: ```sh bunx @revnu/fuast new my-app cd my-app ``` Pick a starter with `--template todo|guestbook|signature|approvals|live-demo` (default `todo`). `init` keeps hidden files, `README.md`, `LICENSE`, `AGENTS.md`, and `CLAUDE.md`, and appends fuast's lines to an existing `AGENTS.md`, `CLAUDE.md`, and `.gitignore`. If the directory holds anything else, `init` stops and writes nothing, because fuast treats the whole directory as one app. New capsules get a git repository and first commit unless they are already inside one or you pass `--no-git`. Read the capsule's `AGENTS.md` before editing. `CLAUDE.md` imports it with `@AGENTS.md`. For an existing capsule, skip creation and follow its `AGENTS.md`. The rest of this page uses `fuast` for the CLI. If it is not on your `PATH`, use `bunx @revnu/fuast`. ## Limits to check first - fuast is in early access. Read [support](https://fuast.com/docs/support.md) for supported platforms, compatibility, and persistence. - Read [limits](https://fuast.com/docs/limits.md) before sizing an app. A handler can return at most 1000 rows and read at most 5000. An app stores at most 100000 rows and 64 MiB by default. - App code cannot import npm packages or Node built-ins. Do not install dependencies, add a bundler, or add CSS files. - Database fields: `string()`, `number()`, `boolean()`, `id("table")`, `userId()`, and `json()`. Chain `.optional()` or `.default(value)`. `json()` cannot be indexed. - Type errors block deploys. `fuast check` and `fuast deploy` run the TypeScript checker on the whole capsule. - Nothing needs an account: not building, not running locally, not the first deploy. Without a login, `fuast deploy` creates an unclaimed preview that lasts 72 hours. A person makes it permanent by opening the claim link. See [deploy, hand over, and verify](#deploy-hand-over-and-verify). - An unclaimed preview has no owner yet, so it has no workspace: everyone who opens it is a guest, `ctx.email`, `ctx.team`, and `ctx.workspace` fail with "Claim this app to use …", and schedules and jobs wait until it is claimed. Its quotas are smaller. See [limits](https://fuast.com/docs/limits.md#unclaimed-previews). - Once claimed, an app is private. Only the owner can open it until you run `fuast share`. - Guests exist only on public apps and unclaimed previews. Private and workspace apps always have a signed-in account behind every browser call. - Local data lives in `.fuast/dev` and survives restarts. `fuast dev --reset` wipes it. ## App structure and APIs - `server/index.ts` default-exports `capsule({...})`. Server files import only `fuast/server` and relative files. - `client/index.tsx` exports `function App()`. Client files import only `fuast/client`, `preact`, `preact/hooks`, `preact/jsx-runtime`, and relative files. - `shared/` is pure TypeScript used by both sides: validation, constants, types. No fuast imports, DOM, Node, or env there. - In the client, `import type app from "../server/index"` and call `createClient()` once at module scope. Its `useQuery`, `useMutation`, `useAction`, and `usePaginatedQuery` infer names, arguments, and results. - Database calls are async. Await or return every one. Declare indexes with `.index(name, fields)` and read with `withIndex(name, q => ...)`, then `.order("asc" | "desc")` and one terminal: `collect()`, `take(n)`, `first()`, `paginate(opts)`, or `count()`. Use the built-in `by_creation` index for unfiltered lists. Do not use the legacy `where`, `orderBy`, `limit`, or `all` methods: they do not exist in v2, and the type check in `fuast check` rejects them. - `insert` returns the new row id. Rows carry `id`, `createdAt`, and `updatedAt`. - Queries and actions cannot write. Write in mutations, writable endpoints, and schedules. Each mutation is one transaction that rolls back if it throws. - Get the caller with `ctx.auth.requireIdentity()` (guest or account) or `ctx.auth.requireSignedIn()` (account only). Never accept a user id, email, or name as an argument. Filter user-owned rows by the caller's `userId` and re-check ownership before every update or delete. - Declare user references with `userId()`, never `string()`. When a guest signs in, every `userId()` value moves to their account. `userId()` does not grant access by itself. - Shared data is a query without an owner filter. Never invent a fake shared user like `"global"`. - Set `auth: { requireSignIn: true }` in `capsule()` to block guests everywhere. On the client, gate data components on `canAccessApp()`. Hiding a button is not an access check. - If `auth.error` is set, show it with a `retryAuth()` button and ``. Keep data components unmounted until access works. - Server secrets live in `.env.fuast.server` and are read with `ctx.env.NAME` inside handlers. Never put secrets in `client/` or `shared/`. - Team features: `ctx.workspace.members()` lists the people in the app's workspace, `ctx.team.table(name)` reads and writes tables every app in the workspace shares, `ctx.email.send()` sends email, `ctx.storage` stores files on the server, and `schedules` run cron jobs. See [teams](https://fuast.com/docs/teams.md), [email](https://fuast.com/docs/email.md), [storage](https://fuast.com/docs/storage.md), and [schedules](https://fuast.com/docs/schedules.md). - Use the router from `fuast/client` for pages (`Router`, `Routes`, `Route`, `Link`, `useParams`). There is no file-based routing. - Use `endpoint({ method, path }, handler)` for webhooks and other HTTP clients. Keep endpoint paths (`/api/...`) separate from page paths. `/api/call`, `/api/me`, `/storage/...`, and `/__fuast/...` are reserved. - Endpoint credentials: same-origin browser `fetch` calls carry the app's cookie automatically. Other callers send the fuast identity token from `getIdentity().token`, or a personal token from `fuast token create` (headless agents), in the `X-Fuast-Token` header. App-scoped deploy tokens never identify a caller. The `Authorization` header belongs to your app: fuast passes it through untouched and never treats it as a fuast identity on an endpoint, so use it for webhook secrets you check against `ctx.env`. Endpoints run for any caller, even on private apps. See [endpoint credentials](https://fuast.com/docs/auth.md#endpoint-credentials). - To let browser agents use the app, pass `{ tool: { description, args } }` as the second argument to `query`, `mutation`, or `action`. Build `args` from `string()`, `number()`, `boolean()`, and `id("table")`. With `args`, the handler takes one object and the client calls it with that object. Write tool descriptions for an agent: say what the tool does, what it returns, and where its inputs come from ("Get todo ids from the todos tool"). See [agent tools](https://fuast.com/docs/agent-tools.md). - For instant-feeling interactions, `client.useMutation(name).withOptimisticUpdate((store, ...args) => store.setQuery(query, queryArgs, updater))` predicts the result until the server confirms it. Most forms do not need it. See [optimistic updates](https://fuast.com/docs/capsule-api.md#optimistic-updates). - The only static asset is the favicon: `favicon.svg` or `favicon.ico` at the root. Use `client.storage.upload()` for user files. ## UI rules - Style with complete Tailwind class names in JSX. fuast compiles the CSS from your client files. Write `done ? "line-through" : ""`, never `` `text-${color}-500` ``, and use inline styles for values computed at runtime. Do not add CSS files, CSS modules, PostCSS, or a separate Tailwind build. - The design is yours and the person's. fuast has no house style and nothing to match. If the app should follow the visitor's light or dark setting, pair your colours with `dark:` variants. - A query hook returns `undefined` until its first result, usually within a few milliseconds. While it is `undefined`, render nothing for that section. Do not render "Loading...", spinners, or skeleton rows. - When data arrives, render it inside `
` so it fades in. Put that wrapper only in the loaded branch. - Treat an empty array as its own state. Say what is missing and how to add it. Never write `?? []` before rendering a list, because it flashes the empty state while data loads. - Keep the form input when a write fails, and show the error next to the form. After a successful write, clear the input only if it still holds the text you submitted, so typing during the round trip is not lost. - Query results are read-only and shared. Copy before sorting: `[...rows].sort(...)`. - Do not show a global loading screen while `useAuth().isLoading` is true. Render nothing. ## External data and schedules Call global `fetch(url, init)` inside a handler, never at module scope. Every handler kind can fetch. A mutation, writable endpoint, or schedule can fetch and write rows in the same call, so external data never needs to pass through the browser. Fetch time counts against the handler's 10 s wall-clock limit (5 s for actions) and holds the app's write lock, so ingest one small batch per call. A database rollback cannot undo an external request. For periodic work, declare a schedule: `schedules: { sync: schedule("*/15 * * * *", async (ctx) => { ... }) }`. Cron is five fields in UTC. Schedules run as the system identity, so `ctx.auth.userId` is `null`. Keep a cursor row in a table so each run continues where the last one stopped. Read budgets apply to the whole handler, and a loop of `paginate()` calls does not escape them. For totals larger than one handler can read, keep summary rows updated during ingest. Store timestamps as `number()` epoch milliseconds. See the [schedule-driven ingest example](https://fuast.com/docs/database.md#dashboard-counts). ## Run and verify Run every command from the capsule directory. Start dev in a terminal that stays open: ```sh fuast dev ``` It prints the local URL (default `http://localhost:3000`) and rebuilds the server and client on every save. Type errors show in that terminal and as an overlay on the app page. In another terminal: ```sh fuast check # build + type check + contract check. Run it before every deploy. fuast run todos # call a query, mutation, or action headlessly; prints JSON fuast run addTodo '{"text":"Buy milk"}' # one JSON value per argument; tool handlers take one object fuast db dump --port 3000 # current rows in every table fuast logs --port 3000 # ctx.log output and handler errors with source-mapped stacks fuast inspect --port 3000 # handlers, schema, tools, endpoints, schedules, read metrics fuast inspect --query todos # rows read vs returned for one query ``` Fix every error from `fuast check` and every handler error in `fuast logs` before deploying. Exercise each handler you wrote with `fuast run` or the browser, then confirm the rows with `fuast db dump` instead of assuming a write happened. If `inspect --query` shows a query reading far more rows than it returns, add an index that matches its filter. Locally you are the signed-in user `dev`. Test other people with `?fuast_user=alice` and `?fuast_user=bob` in separate tabs, and test guests with `?fuast_guest=1`. For headless checks, `fuast auth as alice` makes `fuast run` call as alice, and `fuast auth reset` restores `dev`. For private per-user data, check that alice cannot see bob's rows. See [test and debug](https://fuast.com/docs/testing.md). ## Deploy, hand over, and verify Deploy as soon as `fuast check` passes. Do not ask the person to log in first: ```sh fuast check fuast deploy ``` `deploy` repeats the local checks, uploads the source, type checks and builds it on the platform, applies schema changes, and puts the app online. If a schema change would delete a populated table, the deploy stops and tells you the `--delete-table TABLE=COUNT` flag to confirm it. Without a login, the app goes online as an **unclaimed preview**, and `deploy` prints: ```txt ✓ deployed Lunch Orders as an unclaimed preview (dep_4k2…) Preview: https://preview-k3v9q2m7xa.fuast.com/?fuast_preview=fup_… Claim: https://fuast.com/claim/fuc_… Expires: 2026-11-14T14:26:35.267Z (in 72 hours) unless claimed Give the claim link to the person you are building for: they open it, sign in, and the app becomes theirs. Inspect it from this folder with its slug: fuast logs preview-k3v9q2m7xa ``` Give the person both links, and say which is which: - **The claim link makes the app theirs.** They open it, sign in or create an account, and choose Claim. The app becomes a private app in their workspace, with the data it already holds. It works once. Give it only to the person the app is for. - **The preview link shows the app.** It first opens a fuast page that says this is an unclaimed preview and when it is deleted, then the app. It cannot claim anything, so it is safe to pass on. `fuast deploy --json` returns the same as `{ ok, unclaimed: true, url, claimUrl, expiresAt, slug, deployId, limits, redeploy, next }`, where `url` is the preview link and `next` is the instruction above. While the app is unclaimed: - It is deleted 72 hours after the first deploy unless someone claims it. Redeploying does not extend that. - It is not public and not listed anywhere. Only the preview link opens it. - Its address is random (`preview-…`). You cannot choose it. The person can pick the real address when they claim. - Everyone who opens it is a guest. Sign-in is off, so `ctx.auth.requireSignedIn()` fails, and an app with `auth: { requireSignIn: true }` cannot be tried until it is claimed. Use `ctx.auth.requireIdentity()` for data a guest should own. - `ctx.email.send()`, `ctx.team.table()`, and `ctx.workspace.members()` fail with an error that starts "Claim this app to use" (code `unclaimed`). Catch it or expect it in your checks. - Schedules and delayed jobs are registered but do not run. Jobs queued while the app is unclaimed are cancelled when it is claimed, so do not rely on one to finish later. - Claiming does not switch all of that on. See [after the person claims the app](#after-the-person-claims-the-app). - Stored rows, storage, daily requests, and outbound `fetch` have smaller limits. See [limits](https://fuast.com/docs/limits.md#unclaimed-previews). `deploy` saves an edit token for the preview in `~/.fuast/previews.json` on this machine, filed under the folder's path. It is what lets this machine redeploy and inspect the preview. Do not print it or send it to anyone. Nothing is written to the project folder, and the CLI never reads a credential from it: a `.fuast/unclaimed.json` that arrives with a repository is ignored with a warning. Run every later command from the same folder on the same machine: ```sh fuast deploy # updates the same preview: same links, same expiry fuast claim # prints the claim link and the preview link again fuast status # logged in or not, and what this folder is deployed as ``` Verify the preview the same way you verified dev. Inspection commands target `fuast dev` unless you name the app, so pass the slug `deploy` printed: ```sh fuast inspect preview-k3v9q2m7xa fuast run todos preview-k3v9q2m7xa fuast logs preview-k3v9q2m7xa fuast db dump preview-k3v9q2m7xa ``` `fuast run` calls a preview as a guest. Exercise the feature you built and read the logs. Then report the claim link, the preview link, when the preview expires, and the checks you ran. ### After the person claims the app The app now deploys with their account. Your next `fuast deploy` stops and says so: ```txt fuast: This app was claimed by ana@co.com and now deploys with that account. Run `fuast login`, then `fuast deploy` again: it will update preview-k3v9q2m7xa and link this folder to it in fuast.json. ``` Run `fuast login`. It prints a short code and opens a browser page. A person must sign in there, check that the code matches, and approve. You cannot approve it yourself: tell the person what to do and wait for the command to finish (it waits up to 10 minutes). Set `BROWSER=none` to print the URL without opening a browser. Then run `fuast deploy` again. It deploys to the claimed app and writes `fuast.json` with its slug. Commit `fuast.json` so later deploys update the same app. From here the app is an ordinary one: verify it with its slug from `fuast.json`. A claimed app starts **restricted**. The person owns it, but fuast does not assume they trust code an anonymous agent deployed. Until they do: - `ctx.email.send()`, `ctx.team.table()`, and `ctx.workspace.members()` fail with "This app is restricted and cannot …" (code `restricted`). - Schedules and delayed jobs do not run. - Everything else is normal: the app is private to its owner and has the normal limits. Deploying does not end the restriction, not even with the person's login. Every `fuast deploy` to a restricted app still works, and ends with a line that says so: ```txt ! preview-k3v9q2m7xa is restricted: workspace features are off (email, team tables, the member list, schedules and jobs). Deploying does not turn them on. The owner or a workspace admin turns them on with `fuast trust preview-k3v9q2m7xa`, or with "Trust this app with my workspace" at https://fuast.com/dashboard/apps/preview-k3v9q2m7xa. ``` `--json` has the same as `restricted: true`, `restrictedNotice`, and `trust: { command, dashboard }`. Only an explicit choice by the owner or a workspace admin ends it: - The person ticks "Let this app use my workspace" under Options on the claim page. - The person presses "Trust this app with my workspace" on the app's page in the dashboard. - `fuast trust `, run with the owner's login. If you are logged in as the person and the app needs email, team tables, the member list, schedules, or jobs, tell them what it grants and run it when they agree. `fuast untrust ` turns it off again. If the app needs none of those, leave it restricted. It works the same. The app is private after the claim. Share when the person asks: ```sh fuast share --visibility workspace # everyone at their company fuast share --visibility private --emails ana@co.com # specific people fuast share --visibility public # anyone with the link, guests allowed ``` ### When you are already logged in Check with `fuast auth status`. With a login, or with `FUAST_TOKEN` set, `fuast deploy` creates the app in that account straight away: private, with no preview and no claim link. The first deploy writes `fuast.json`. Commit it. - If this machine deployed an unclaimed preview from the folder earlier, a logged-in `fuast deploy` stops with code `preview_unclaimed` and asks you to choose. `fuast deploy --claim` claims that preview for the logged-in account, keeps its data, and deploys to it. `fuast deploy --new` leaves the preview alone and deploys the folder as a new app. It never claims by itself. - In CI or another headless environment, set `FUAST_TOKEN` to a token the person created with `fuast token create --name ci --app `. - `fuast deploy --no-unclaimed` (or `FUAST_UNCLAIMED=0`) never deploys without an account. It stops with `Not logged in` instead. - If the platform has previews turned off, `deploy` says so and tells you to run `fuast login`. ## Read when needed - [Capsule API](https://fuast.com/docs/capsule-api.md): every server and client API. - [Database](https://fuast.com/docs/database.md): indexes, sort rules, pagination, schema changes. - [Auth](https://fuast.com/docs/auth.md): accounts, guests, sign-in policy, endpoint credentials. - [Teams](https://fuast.com/docs/teams.md): workspaces, sharing, members, team tables. - [Custom domains](https://fuast.com/docs/domains.md): serve an app at a domain the user owns (`fuast domains add`). - [Test and debug](https://fuast.com/docs/testing.md): `fuast run`, `inspect --query`, logs, `run-many`. - [Agent tools](https://fuast.com/docs/agent-tools.md): expose handlers to browser agents. - [MCP server](https://fuast.com/docs/mcp.md): build and deploy from an agent without a terminal (Claude, ChatGPT, Cursor, VS Code). - [Limits](https://fuast.com/docs/limits.md): every limit, its error, and what to change. - [Security](https://fuast.com/docs/security.md): what fuast guarantees and what your code must check. [Unclaimed previews](https://fuast.com/docs/security.md#unclaimed-previews) says what a preview can and cannot do. - [Support](https://fuast.com/docs/support.md): platforms, compatibility, persistence, getting help. - [Storage](https://fuast.com/docs/storage.md), [schedules](https://fuast.com/docs/schedules.md), [email](https://fuast.com/docs/email.md). - [Reference](https://fuast.com/docs/reference.md): every CLI command, inspection route, and env var. - [Migration to v2](https://fuast.com/docs/migration-v2.md): for capsules written against the old fuast API. - [Docs index](https://fuast.com/llms.txt): every page, so you can fetch only the one you need. --- # MCP server fuast has a remote [Model Context Protocol](https://modelcontextprotocol.io) server at: ```txt https://fuast.com/mcp ``` An agent that connects to it can read the docs, start from a template, check an app, deploy it, test it, read its logs and data, and share it. The agent needs no terminal and no file system: it sends and receives file contents. Agents that do have a terminal can keep using the CLI. See the [agent start guide](https://fuast.com/docs/agents.md). On a self-hosted platform the URL is `/mcp`. ## Connect your agent The server URL is the same for every client. The first time a tool needs your account, the client opens a fuast sign-in page in your browser. [fuast.com/agents](https://fuast.com/agents) has a page per client, with one-click links for Claude, Cursor, and VS Code. ### One command for coding agents ```sh curl -fsSL fuast.com/install | sh ``` The script connects this server to Claude Code, Codex, and Gemini CLI, whichever are installed, and saves the [fuast skill](#the-fuast-skill) where each one reads skills. It creates no account and deploys nothing, and you can [read it](https://fuast.com/install) first. Run it again to update the skill. ### Claude (web, desktop, mobile) 1. Open **Customize > Connectors** and choose **Add custom connector**, or use the link on [fuast.com/agents/claude](https://fuast.com/agents/claude), which fills in the dialog. 2. Enter `https://fuast.com/mcp`. 3. Ask Claude to build something. When it reaches a tool that needs your account, choose **Connect** and approve. ### Claude Code ```sh claude mcp add --transport http --scope user fuast https://fuast.com/mcp ``` `--scope user` makes the server available in every folder. Without it, Claude Code adds the server for the current folder only. Run `/mcp` inside Claude Code to sign in. To use a token instead of the browser sign-in: ```sh claude mcp add --transport http --scope user fuast https://fuast.com/mcp \ --header "Authorization: Bearer $FUAST_TOKEN" ``` ### Codex ```sh codex mcp add fuast --url https://fuast.com/mcp ``` To sign in, run `codex mcp login fuast`. It opens your browser. ### Gemini CLI ```sh gemini mcp add --transport http --scope user fuast https://fuast.com/mcp ``` To sign in, run `/mcp auth fuast` inside Gemini CLI. ### Cursor Use the **Add to Cursor** link on [fuast.com/agents/cursor](https://fuast.com/agents/cursor), or add this to `.cursor/mcp.json` in a project, or `~/.cursor/mcp.json` for every project: ```json { "mcpServers": { "fuast": { "url": "https://fuast.com/mcp" } } } ``` With a token instead of the browser sign-in: ```json { "mcpServers": { "fuast": { "url": "https://fuast.com/mcp", "headers": { "Authorization": "Bearer ${env:FUAST_TOKEN}" } } } } ``` ### VS Code Use the **Add to VS Code** link on [fuast.com/agents/vscode](https://fuast.com/agents/vscode), or add this to `.vscode/mcp.json`: ```json { "servers": { "fuast": { "type": "http", "url": "https://fuast.com/mcp" } } } ``` ### ChatGPT 1. Open **Plugins**, choose **+**, then **Add custom MCP server**. 2. Enter `https://fuast.com/mcp` and pick OAuth as the authentication. 3. Create it, then sign in to fuast when ChatGPT asks. ### Any other client Use Streamable HTTP with the URL above. The client needs to support OAuth for remote MCP servers, or let you set an `Authorization` header. The server is described for the [MCP registry](https://registry.modelcontextprotocol.io) at [fuast.com/server.json](https://fuast.com/server.json). ## The fuast skill A skill is a short instruction file a coding agent loads when a request matches it. The fuast skill tells the agent when fuast fits ("make a little app for my team"), how to build and deploy with the CLI or with this server, and the rules agents most often get wrong. Read it at [fuast.com/skill.md](https://fuast.com/skill.md). The [install script](#one-command-for-coding-agents) saves it for Claude Code, Codex, and Gemini CLI. For Cursor, GitHub Copilot, and other agents that read skills: ```sh npx skills add https://fuast.com ``` To add it by hand, save the file as `fuast-build-and-host-small-apps/SKILL.md` in the agent's skills folder, for example `~/.claude/skills/fuast-build-and-host-small-apps/SKILL.md` for Claude Code. The long name is on purpose: an agent with many skills installed often sees only their names, so the name has to say what the skill is for. A capsule made with `fuast new` or `fuast init` also carries an `AGENTS.md` with the rules for that version of fuast. The skill gets the agent to fuast; `AGENTS.md` is what it follows once it is in the project. ## Three ways to authenticate The server reads who is calling from the `Authorization: Bearer` header only. It never reads cookies. ### No account A connection without credentials can still ship something: - `fuast_guide`, `fuast_docs`, `list_templates`, `get_template`: read the docs and the starter apps. - `check_app`: build and type check files without deploying. - `deploy_app`: deploy a temporary preview. It returns `preview_url`, `claim_url`, `edit_token`, and `expires_at`. Give the claim link to the person the app is for. They open it, sign in to fuast, and the app becomes theirs. The agent keeps the edit token and passes it as `edit_token` to `update_app_files`, `get_app_source`, `get_app`, `get_logs`, `inspect_app`, `call_app`, and `get_claim_link` to keep working on that preview. A preview is not a full app. It is deleted at `expires_at` (72 hours after it was created, by default) unless someone claims it. It has no accounts, so every visitor is a guest. Its schedules and jobs wait until it is claimed. Its upload and data limits are smaller: the deploy result lists them under `limits`, and [limits](https://fuast.com/docs/limits.md#unclaimed-previews) explains them. The edit token is a secret: keep it in the conversation, never in the app's files. After a claim it stops working, and the app is reached through the owner's account. Previews exist only on platforms that serve each app on its own subdomain, which fuast.com does. ### Sign in with your fuast account (OAuth) Tools that work on your apps need your account. The first call to one of them answers HTTP `401` with a `WWW-Authenticate` header, and the client starts sign-in: you sign in to fuast, read a consent screen that names the app, where it sends you back to, your workspace, and what it may do, and approve. The client then repeats the call. To sign in before deploying, ask the agent to call `whoami`. A connection gets only the scopes you approved: | Scope | Lets the agent | | --- | --- | | `apps:read` | List your apps and read their source, logs, data, and usage. Run queries with `call_app`. | | `apps:write` | Deploy, roll back, and delete apps, set their env vars, and run mutations and actions with `call_app`. | | `apps:share` | Change who can open an app and invite people by email. | The challenge names the scopes the call needs, so a client that follows it asks for the least. A request without any scope gets `apps:read`. When a later call needs a scope the connection lacks, the server answers `403` with `error="insufficient_scope"` and the full list to ask for, and the client asks you to approve the larger set. A signed-in connection acts as you, with your permissions, and only through these tools. Its token is not accepted by the fuast API or by any app. ### API token Clients that take a static header can send a fuast API token: ```sh fuast token create --name my-agent # personal: everything you can do fuast token create --name ci --app my-app # app deploy token: one app only ``` Send it as `Authorization: Bearer `. A personal token can use every tool. An app deploy token can deploy, roll back, and read the status of its own app, and nothing else: it sees a shorter tool list, `deploy_app` and `update_app_files` must name its app, and `inspect_app` gives it `manifest`, `usage`, and `schedules` only. The session that `fuast login` saves is not accepted here. See [account and tokens](https://fuast.com/docs/reference.md#account-and-tokens). Deploying into an account needs a verified email address. Until then deploys answer `Verify your email address first. Check your inbox for the link.` ## Tools Every tool returns text and the same data as `structuredContent`, with an `outputSchema`. Arguments are checked against the tool's `inputSchema`, and unknown arguments are refused. A failure the agent can fix comes back as a tool result with `isError: true` and a message that says what to do. "Account" is what the tool needs when it is called with `app`. Tools that also take `edit_token` work on a preview without an account. | Tool | What it does | Account | Kind | | --- | --- | --- | --- | | `fuast_guide` | The guide for connected agents and the capsule rules. Call it first. | No | Read-only | | `fuast_docs` | Read one docs page (`page`), search every page (`search`), or list the pages. | No | Read-only | | `list_templates` | The starter apps and what each one shows. | No | Read-only | | `get_template` | One template's files as `{path: content}`, named with `name`. | No | Read-only | | `check_app` | Build, type check, and load `files` in a sandbox without deploying. Returns the handlers and tables the app declares, or diagnostics with file, line, and message. | No | Read-only | | `deploy_app` | Deploy `files`. Creates a private app in your workspace, updates `app`, or (without an account) deploys a preview. Updates a preview with `edit_token`. | `apps:write`, or none for a preview | Destructive | | `get_app_source` | The live deploy's source as `{path: content}` and its `deploy_id`. Apps over 400 KiB return the file list: ask again with `paths`. Takes `app` or `edit_token`. | `apps:read` | Read-only | | `update_app_files` | Redeploy with some files changed: `files` maps a path to new content, or to `null` to delete it. `expected_deploy_id` must be the live deploy. Takes `app` or `edit_token`. | `apps:write` | Destructive | | `list_apps` | The apps you can use: yours, your workspace's, and apps shared with you. | `apps:read` | Read-only | | `get_app` | One app: URL, status, sharing, handlers, the last 10 deploys, today's usage, env var names. Takes `app` or `edit_token`. | `apps:read` | Read-only | | `get_logs` | Recent log entries, oldest first, with a `cursor` for the next call. `level`, `since`, `limit` (at most 200). Takes `app` or `edit_token`. | `apps:read` | Read-only | | `inspect_app` | `what`: `manifest`, `tables`, `rows` (one `table`, at most 100 rows per call), `usage`, `schedules`, or `jobs`. Takes `app` or `edit_token`. | `apps:read` | Read-only | | `call_app` | Run one query, mutation, or action and return its result and the log lines it wrote. Takes `app` or `edit_token`. | `apps:read` for a query, `apps:write` for a mutation or action | Destructive | | `share_app` | Set `visibility` (`private`, `workspace`, `public`), `invite` people by email, `remove` people. | `apps:share` | Destructive | | `set_env` | `set` and `unset` env vars of an app. Values are write-only. | `apps:write` | Destructive | | `rollback_app` | Make an earlier deploy live again (`deploy_id`, or the previous ready deploy). | `apps:write` | Destructive | | `delete_app` | Delete an app and everything in it. Owner only, and `confirm` must repeat the slug. | `apps:write` | Destructive | | `get_claim_link` | The claim link, preview link, and expiry of a preview, from its `edit_token`. | No | Read-only | | `whoami` | The connected account, workspace, role, and scopes. Starts sign-in when nothing is connected. | `apps:read` | Read-only | Who may do what is the same as in the dashboard and the CLI: - Opening an app follows its [sharing](https://fuast.com/docs/teams.md#sharing). `list_apps`, `get_app`, and `get_app_source` work on any app you can open. - Deploying, logs, inspection, `call_app`, `share_app`, `set_env`, and `rollback_app` need the app's owner or a workspace admin. - `delete_app` needs the owner. - An app you cannot see answers exactly like an app that does not exist. Notes on single tools: - **`deploy_app` and `update_app_files`** run the normal deploy pipeline: the same checks, the same sandboxed build, and a type check that cannot be skipped. A failed build deploys nothing. If a schema change would delete a table that holds rows, the error names the tables and the `delete_tables` argument that confirms it. A redeploy of an app in your account keeps the env values the previous deploy carried. A redeploy of a preview by its edit token starts with no env. - **`call_app`** runs as the connected person (`as: "me"`, the default): the handler sees your identity in `ctx.auth`, exactly as if you had opened the app. It never runs as another user. `as: "guest"` runs it as a guest visitor, and works only on public apps that do not require sign-in. On a preview every call runs as one guest. Calls count against the app's daily quota, and mutations and actions change real data. - **Restricted apps.** An app that was claimed from a preview is [restricted](https://fuast.com/docs/security.md#restricted-apps) until its owner trusts it: email, team tables, the member list, schedules, and jobs are off. `get_app` and `list_apps` report it as `restricted: true`. `deploy_app` and `update_app_files` work on it like on any app you may manage, and they never lift the restriction: their result has `restricted: true`, a `restricted_hint` that says workspace features are off, and `trust_command` and `trust_url` for the person. No tool trusts an app. The owner or a workspace admin does that outside MCP, with `fuast trust ` or "Trust this app with my workspace" on the app's page in the dashboard. A preview's edit token stops working at the claim. - **`get_app_source`** never returns `.env` files, and no tool returns env values. - **`inspect_app`** returns the same manifest and usage documents as `fuast inspect`. Rows are capped per call and open to the owner and workspace admins only, like the dashboard's Data tab. ## Guide for connected agents You are connected to fuast through MCP. You have no terminal and no file system here: you build an app by sending its files to these tools. A fuast app (a capsule) is a small full-stack TypeScript app with `server/index.ts`, `client/index.tsx`, and optional `shared/` files. It uses no npm packages. Work in this order: 1. Start from a template. `list_templates`, then `get_template({id, name})`, returns a complete working app as `{path: content}`. Keep that file map and edit it. You do not need `tsconfig.json` or type declaration files. 2. Read what you need. The rules below cover the essentials. `fuast_docs({page: "capsule-api"})` has every API, and `fuast_docs({search: "..."})` finds a topic. 3. Call `check_app({files})`. It builds and type checks without deploying. Fix every diagnostic (each has a file, a line, and a message) and check again until `ok` is true. 4. Call `deploy_app({files, name})`. It returns the URL and a `deploy_id`. Send every file. 5. Verify the deployed app. `call_app` runs a query, mutation, or action and returns its result and logs. `get_logs` shows handler errors. `inspect_app` with `what: "tables"` or `"rows"` shows the data. Read a write back instead of assuming it happened. 6. For a later change, call `update_app_files({app, files, expected_deploy_id})` with only the files that change. A `null` value deletes a file. If it answers that the app changed, call `get_app_source` and apply your change to what it returns. 7. Tell the person the URL, who can open the app, and what you checked. Accounts: - Without a connected account, `deploy_app` deploys a temporary preview and returns `preview_url`, `claim_url`, `edit_token`, and `expires_at`. Give the person the claim link: they open it, sign in, and the app becomes theirs. Say when the preview is deleted if nobody claims it. - Keep `edit_token` in this conversation and pass it instead of `app` to `update_app_files`, `get_app_source`, `get_app`, `get_logs`, `inspect_app`, `call_app`, and `get_claim_link`. Never write it into the app's files. Once the preview is claimed the token stops working: use `app` with the slug. - A preview has guests only, and its schedules and jobs wait until it is claimed. - A claimed preview is restricted (`restricted: true` in `get_app` and in every deploy result): email, team tables, the member list, schedules, and jobs stay off until the owner trusts the app. You can keep deploying to it with `deploy_app` and `update_app_files`; that does not turn them on, and no tool does. If the app needs them, tell the person to press "Trust this app with my workspace" on the app's page (`trust_url`) or to run `fuast trust `. - If the person has a fuast account, or wants the app in their own workspace, call `whoami`. The first call asks them to sign in. After that `deploy_app` creates private apps in their workspace. Rules for these tools: - New apps are private. Call `share_app` only when the person asks, and pick the narrowest mode that works. - Set secrets with `set_env({app, set: {NAME: "value"}})` and read them as `ctx.env.NAME` in handlers. It works on apps in an account, not on previews. Never put a secret in `client/`, `shared/`, a tool description, or a log message. - `call_app` runs as the connected person. Mutations and actions change real data. - Call `delete_app`, and pass `delete_tables`, only when the person explicitly asks for data to be deleted. - The sections below were written for the fuast CLI. Here the tools replace it: `check_app` is `fuast check`, `deploy_app` is `fuast deploy`, `share_app` is `fuast share`, `call_app` is `fuast run`, `get_logs` is `fuast logs`, and `inspect_app` is `fuast inspect` and `fuast db dump`. There is no local dev server, no `fuast login`, and no `.env.fuast.server` file: you check with `check_app` and test against the deployed app. ## Limits | Limit | Value | | --- | --- | | Requests without an account | 60 per minute per client address | | Requests with an account | 600 per minute per account | | `check_app`, `deploy_app`, `update_app_files` | 30 per minute per client address | | `check_app` without an account | 12 per minute per client address, 4 running at once across the platform | | Builds running at once (`check_app`, `deploy_app`, `update_app_files`) | 3 per account, 2 per client address without an account | | `call_app` | 600 per minute per client address, plus the app's [daily quotas](https://fuast.com/docs/limits.md) | | Requests with a wrong token | 30 per 10 minutes per client address, then `429` | | Request body, calls without a file map | 256 KiB | | File map, signed in | The deploy limits: 1000 files, 20 MiB in total, 1 MiB per file | | File map, no account | The smaller preview limits. The error states them. | | `get_app_source` without `paths` | 400 KiB of source. Larger apps return the file list only. | | `call_app` result | 200 KiB of JSON. Larger results are cut and marked `truncated`. | A client address is an IPv4 address, or a whole IPv6 `/64`. Over one of the first two limits the server answers `429` with `Retry-After`, and a platform operator can change them. Over a per-tool limit the tool returns an error that says to wait. See [limits](https://fuast.com/docs/limits.md) for what an app itself may use. ## Protocol - **Transport.** Streamable HTTP on one endpoint. Send each JSON-RPC message as its own `POST` with `Content-Type: application/json`. `GET` and `DELETE` answer `405`: there is no server-to-client stream and no session to end. A JSON-RPC batch (an array) answers `400` and nothing in it runs. That includes revision `2025-03-26`, the one revision that allowed batches. - **Revisions.** `2026-07-28` (stateless: the version, client info, and capabilities travel in every request's `_meta` and in the `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` headers), and `2025-11-25`, `2025-06-18`, and `2025-03-26` through the `initialize` handshake. An unsupported version answers `400` with the list of supported ones. - **No sessions.** The server never sends `Mcp-Session-Id` and remembers nothing between requests. - **Methods.** `initialize`, `ping`, `server/discover`, `tools/list`, `tools/call`. `subscriptions/listen` is acknowledged and closed at once, because the tool list does not change while a client listens. There are no resources and no prompts. - **Responses.** A single JSON object. A build can take a while, so `check_app`, `deploy_app`, and `update_app_files` switch to a `text/event-stream` response after 5 seconds when the client's `Accept` header allows it. The stream carries keep-alive comments, then the one JSON-RPC response, and ends. - **Sign-in signals.** A call that needs an account answers `401` with `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="...", scope="..."`. A call that needs a scope the connection lacks answers `403` with `error="insufficient_scope"`. Both bodies are also a tool result with the challenge under `_meta["mcp/www_authenticate"]`. Each entry of `tools/list` carries `_meta.securitySchemes` (`noauth`, `oauth2` with scopes). - **Browsers.** A request with an `Origin` header from another website is refused with `403`. See [security](https://fuast.com/docs/security.md#mcp-and-oauth). ## OAuth for client builders | | | | --- | --- | | Protected resource metadata | `https://fuast.com/.well-known/oauth-protected-resource/mcp` and `https://fuast.com/.well-known/oauth-protected-resource` | | Authorization server metadata | `https://fuast.com/.well-known/oauth-authorization-server` | | Issuer | `https://fuast.com` | | Resource | `https://fuast.com/mcp` | | Endpoints | `/oauth/authorize`, `/oauth/token`, `/oauth/register`, `/oauth/revoke` | - **Grants.** Authorization code and refresh token. - **Public clients only.** There are no client secrets. `token_endpoint_auth_methods_supported` is `["none"]`. - **PKCE is required**, with `code_challenge_method=S256`. - **Client identity.** Either a Client ID Metadata Document (the `client_id` is an `https` URL with a path, and the document must name the same `client_id` and allow the `none` auth method), or dynamic client registration at `POST /oauth/register` (JSON, `redirect_uris` required, `token_endpoint_auth_method` `none`). - **Redirect URIs.** `https`, or `http` on `localhost`, `127.0.0.1`, or `[::1]`. They are matched exactly. A loopback URI matches with any port, so a native client can bind a free one. No wildcards, fragments, or credentials. A redirect URI on fuast's own addresses (`fuast.com`, any `*.fuast.com`, a custom domain of a fuast app) is refused: use your own site. - **Errors before consent are shown to the person, not redirected.** A request with a missing `code_challenge`, a wrong `response_type`, or a foreign `resource` gets a fuast error page that names the OAuth error code. Your redirect URI is only ever called with a `code`, or with `error=access_denied` when the person chose Cancel. - **`resource`.** When sent, it must be the MCP URL above. Tokens are issued for that resource and for nothing else. - **`state`** is echoed. Every authorization response, errors included, carries `iss` (RFC 9207). - **Authorization codes** work once and for 60 seconds, and only with the same client, redirect URI, and PKCE verifier. - **Access tokens** last 1 hour. **Refresh tokens** last 30 days and rotate: each use returns a new one and ends the old one. Using an old one again ends the whole connection, and the token endpoint answers `invalid_grant`. - **`POST /oauth/token` and `/oauth/revoke`** take `application/x-www-form-urlencoded`. Revoking either token of a connection ends the connection. - **Consent** is asked on every authorization. There is no silent approval. The screen shows your redirect host as a checked fact and your `client_name` as a name you chose. A client whose metadata document is on a well-known host is shown as verified by that host. ## Disconnect an agent Open **API tokens** in the dashboard and find **Connected agents**, or open [/dashboard/connect](https://fuast.com/dashboard/connect). Each connection shows the app's name, where it sends you back to, its scopes, and when it was last used. **Disconnect** ends it at once: its access and refresh tokens stop working. Removing the connector in the agent's own settings also works when the client tells fuast. API tokens are revoked on the same page, or with `fuast token revoke `. Resetting your fuast password disconnects every agent and revokes every API token at once. Do that if you think a connection or a token leaked, then connect again. What agents did through MCP is recorded: every deploy, share, env change, rollback, delete, and mutation or action run, plus each connect and disconnect. Deploys and other changes also show in the app's logs with the client's name. ## Troubleshooting - **The client cannot reach the server.** Check the URL: `https://fuast.com/mcp`, on the dashboard's host. An app's address such as `https://my-app.fuast.com/mcp` is the app, not this server. A `404` on the right host means the platform has the MCP server turned off. - **A tool answers "Sign in to fuast to use ..." and no sign-in page opens.** The client does not act on the `401` challenge. Connect with an API token header instead. - **`401` after it worked before.** The connection was revoked, the account's password was reset, or the refresh token ran out after 30 days without use. Connect again. - **"needs the apps:write permission".** The connection was approved with fewer scopes. Approve the larger set when the client asks, or remove the connector and add it again. - **"Verify your email address first."** Open the link fuast mailed you, then deploy again. - **"changed since you read it".** Someone deployed between your read and your update. Call `get_app_source` and apply the change to those files. - **"This edit token does not open a preview".** The preview expired, was deleted, or was claimed. Deploy a new preview, or sign in as the owner and use `app`. - **"Unsupported protocol version".** The error's `data.supported` lists the revisions this server speaks. - **`403` from a web page.** Browser pages on other sites cannot call the server. Use an MCP client. - **A `fuast login` token does not work as a header.** Create an API token with `fuast token create`. --- # Test and debug An agent can verify a capsule end to end from the terminal. The loop is: `fuast check` until it is clean, `fuast dev` in one terminal, then `fuast run`, `fuast db dump`, and `fuast logs` in another to prove each feature works. Use a browser for the UI, not to find out whether a mutation wrote a row. ## Check before you deploy ```sh fuast check fuast check --json ``` `check` builds the capsule, runs the TypeScript checker on every file, and checks the capsule contract: import rules, `capsule()` keys, schema, index declarations, cron expressions, tools, and the favicon. It exits with code 1 on any error. `fuast deploy` runs the same checks before it uploads, and the platform runs them again, so a capsule that fails `check` never deploys. Each error names the file, line, and column with the source line underneath. `--json` prints `{ ok: false, error, code, diagnostics }` where every diagnostic has `file`, `line`, `column`, `code`, `category`, `message`, and a `frame`. Fix the first error first: later ones are often caused by it. ## Type errors in the browser While `fuast dev` runs, it type checks the capsule after every save. Build errors and type errors appear in the terminal and as an overlay on the open app page, with the file, line, and source frame. Fix the code and save, and the overlay updates on its own. A type error does not stop `fuast dev`: the app keeps running so you can keep testing. It does stop `fuast deploy`, so clear the overlay before you deploy. ## Call handlers headlessly `fuast run` calls one query, mutation, or action the way a client would and prints the result as JSON: ```sh fuast run todos # query with no arguments fuast run addNote '"Buy milk"' 2 # each argument is one JSON value fuast run addTodo '{"text":"milk"}' # a handler with tool args takes one object fuast run setTodoDone '{"todoId":"01j9…","done":true}' fuast run todos --json # result plus logs and read metrics ``` - Without a slug it calls `fuast dev` on `--port` (default 3000) as your local identity: `dev`, or the name from `fuast auth as ` (no restart needed). Switch to `alice`, run the same call, and confirm alice cannot see dev's rows. `--as `, `--guest`, and `--anon` change the identity for one call. - With a slug (`fuast run todos my-app`) it calls the deployed app as your logged-in account, with the same sharing rules and daily quotas as the browser. - The handler kind comes from the app's manifest, so you never name it. A last argument that is not JSON is the slug or app URL, so quote string arguments: `'"milk"'`. A slug and `--port` together are an error, and so is a bare word that cannot be a slug (`Hello`): the CLI tells you to quote it instead of calling the wrong app. - A handler error prints the message and its `code` and exits with code 1. That makes `fuast run` usable in scripts and CI. - `--json` prints `{ ok, kind, name, result, ms, logs, metrics }`: the result, the log entries the call wrote, and its database read metrics (the operations of that one run). A mutation run this way is a real write. Subscribed browser tabs update, and the row is there for `fuast db dump`. The same call over HTTP, for scripts that do not use the CLI, is `POST /api/call`. See [reference](https://fuast.com/docs/reference.md#http-call-api). ## Measure one query ```sh fuast inspect --query todos --port 3000 fuast inspect --query todos my-app --json ``` `inspect --query ` prints the database read metrics recorded for that query, per run: the totals of the last run (rows read, rows returned, bytes read, index scans, summed over every database call the run made), then the last few runs, each with its database calls (table and index, rows returned, rows read, bytes read, and whether it was a full scan). With `--json`, `queryExecution.runs` holds the same data and `observed` counts runs. Run the query first (open the page, or `fuast run `) so there is something to show. Compare `read` with `returned`: if a query reads far more rows than it returns, add an index that matches its filter. See [limits](https://fuast.com/docs/limits.md#checking-usage). ## Read logs and stacks ```sh fuast logs --port 3000 fuast logs my-app --follow fuast logs --json ``` Every `ctx.log` call and every handler error lands in the app's log. A handler error entry has `level: "error"`, the message ` failed: `, the handler name, and `data.stack`. Stacks are mapped back to your capsule source in `fuast dev` and on hosted apps alike (every deploy keeps its server source map), so frames read `server/index.ts:42:11`, not a line in a bundle. Go to the first frame inside `server/` or `shared/`. `fuast dev` also prints each log line and error in its own terminal as it happens. Errors from a query reach the client too: `useQuery` throws to the nearest `ErrorBoundary`, and mutation promises reject with the message and `code`. See [errors](https://fuast.com/docs/capsule-api.md#errors). ## Check the data ```sh fuast db list --port 3000 # tables and row counts fuast db dump --port 3000 # rows, bounded by the 1000-row return limit fuast db dump --table todos --json ``` After every mutation you test, confirm the rows changed the way you expected. Do not assume a write happened because the UI looks right. ## Test several people Locally you are the signed-in account `dev`. Add `?fuast_user=alice` to the URL for a second person in another tab, `?fuast_guest=1` for a guest on a public app. For headless checks, `fuast auth as alice`, then `fuast run`, then `fuast auth reset`. See [local identities](https://fuast.com/docs/auth.md#local-identities). ## Many dev servers at once ```sh fuast run-many --count 5 --base-port 4000 ``` `run-many [dir]` starts `--count` independent dev servers of the same capsule (default 20) on consecutive ports from `--base-port` (default 4000) and prints their URLs. Each has its own database, so parallel test runs or several agents can work against the same code without sharing state. Stop them all with Ctrl-C. ## Before you report done 1. `fuast check` passes. 2. Every handler you wrote was exercised with `fuast run` or in the browser, and `fuast db dump` shows the expected rows. 3. `fuast logs` has no handler errors you did not expect. 4. Private data was checked with two identities. 5. After deploy, the same checks pass against the slug: `fuast run `, `fuast logs `. --- # Capsule API This page is the API an agent uses to write a fuast app. The database has its own page: [database](https://fuast.com/docs/database.md). ## File layout ```txt server/index.ts default export capsule({...}) client/index.tsx export function App() shared/ pure TypeScript for both sides ``` Split big apps into more files under `server/`, `client/`, and `shared/` with relative imports. `server/` may import `fuast/server`. `client/` may import `fuast/client`, `fuast/ui`, `preact`, `preact/hooks`, and `preact/jsx-runtime`. `shared/` may import only relative files. Nothing may import npm packages or Node built-ins. `fuast check` rejects violations. ## capsule() `server/index.ts` default-exports one `capsule()` call. It accepts these keys and nothing else: | Key | Type | Purpose | | --- | --- | --- | | `name` | `string` | Browser tab title. Defaults to `"fuast app"`. | | `favicon` | `string` | Relative path to an `.svg` or `.ico` file, at most 64 KiB. Without it, fuast uses `favicon.svg` or `favicon.ico` at the capsule root, if present. | | `auth` | `{ requireSignIn?, onGuestUpgrade? }` | App-wide sign-in policy and guest merge hook. See [auth](https://fuast.com/docs/auth.md). | | `schema` | `Record` | Tables declared with `table()`. | | `queries` | handler map | Read-only, reactive handlers declared with `query()`. | | `mutations` | handler map | Read-write transactional handlers declared with `mutation()`. | | `actions` | handler map | Read-only, non-reactive handlers declared with `action()`. | | `endpoints` | endpoint map | HTTP routes declared with `endpoint()`. | | `schedules` | schedule map | Cron jobs declared with `schedule()`. See [schedules](https://fuast.com/docs/schedules.md). | Handler names must be unique within their map. A query and a mutation may share a name, but a name used as a [tool](https://fuast.com/docs/agent-tools.md) must be unique across all kinds. ## Define the server ```ts import { action, boolean, capsule, mutation, query, string, table, userId } from "fuast/server"; import { cleanTodoText } from "../shared/todo"; export default capsule({ name: "Todos", auth: { requireSignIn: false }, schema: { todos: table({ text: string(), done: boolean().default(false), ownerId: userId() }).index("by_owner", ["ownerId"]) }, queries: { todos: query(async (ctx) => { const { userId } = ctx.auth.requireIdentity(); return ctx.db.todos .withIndex("by_owner", (q) => q.eq("ownerId", userId)) .order("desc") .collect(); }) }, mutations: { addTodo: mutation(async (ctx, text: string) => { const { userId } = ctx.auth.requireIdentity(); const clean = cleanTodoText(text); if (!clean) throw new Error("Write a todo first."); return ctx.db.todos.insert({ text: clean, ownerId: userId }); }), setTodoDone: mutation(async (ctx, id: string, done: boolean) => { const { userId } = ctx.auth.requireIdentity(); const todo = await ctx.db.todos.get(id); if (!todo || todo.ownerId !== userId) throw new Error("Todo not found."); await ctx.db.todos.update(id, { done }); }) }, actions: { summarize: action(async (ctx) => { const { userId } = ctx.auth.requireIdentity(); const open = await ctx.db.todos .withIndex("by_owner", (q) => q.eq("ownerId", userId)) .collect(); return { open: open.filter((t) => !t.done).length }; }) } }); ``` The server is the authority: - Queries decide which rows a caller may read. - Mutations validate every argument before writing. - Mutations re-check ownership before changing or deleting an existing row. - Identity comes from `ctx.auth`, never from arguments. ## Handlers `query(fn, opts?)`, `mutation(fn, opts?)`, and `action(fn, opts?)` wrap a handler so TypeScript infers its `ctx` and arguments. - Without `opts.tool.args`, the handler is `(ctx, ...args) => result`. Clients call it with the same positional arguments: `addTodo("milk")`. - With `opts.tool.args`, the handler is `(ctx, args) => result`, where `args` is one object typed from the declared fields. Clients call it with that object: `addTodo({ text: "milk" })`. `opts = { tool?: { description: string, args?: Record } }`. The `tool` option also exposes the handler to browser agents. See [agent tools](https://fuast.com/docs/agent-tools.md). Arguments and results must be JSON values. Results go through JSON on the way to the client, so a `Date` arrives as a string and `undefined` object fields disappear. Return plain objects, arrays, strings, numbers, booleans, and `null`. ## Handler capabilities | Capability | Query | Mutation | Action | Endpoint | Schedule | | --- | --- | --- | --- | --- | --- | | Read `ctx.db` | Yes | Yes | Yes | Yes | Yes | | Write `ctx.db` | No | Yes | No | Writable only | Yes | | Transaction | Snapshot | One write transaction | Snapshot per read | Like query or mutation | One write transaction | | Reactive (clients re-run on change) | Yes | No | No | No | No | | Global `fetch()` | Yes | Yes | Yes | Yes | Yes | | `ctx.env`, `ctx.log`, `ctx.auth` | Yes | Yes | Yes | Yes | Yes | | `ctx.storage.url()` | Yes | Yes | Yes | Yes | Yes | | `ctx.storage.get/stat/delete` | No | Yes | Yes | Yes | Yes | | `ctx.storage.put` | No | Yes | Yes | Writable only | Yes | | `ctx.email.send()` | No | Yes | Yes | Yes | Yes | | `ctx.team.table(name).all()` | Yes | Yes | Yes | Yes | Yes | | `ctx.team` writes | No | Yes | Yes | Writable only | Yes | | `ctx.workspace.members()` | Yes | Yes | Yes | Yes | Yes | | `ctx.scheduler` ([jobs](https://fuast.com/docs/jobs.md)) | No | In the transaction | Saved when it ends | In the transaction (read-only: saved when it ends) | In the transaction | | Daily quota | Request | Request + mutation | Request + mutation | Request (+ mutation if writable) | Not counted | | Time limit | 5 s CPU, 10 s wall clock | 5 s CPU, 10 s wall clock | 5 s ([action limit](https://fuast.com/docs/limits.md#action-limits)) | 5 s CPU, 10 s wall clock | 5 s CPU, 10 s wall clock | Endpoints are writable unless `readOnly` is true, which is the default for `GET` and `HEAD`. The `auth.onGuestUpgrade` hook gets a writable `ctx.db` and nothing that leaves the database: no `fetch`, email, storage writes, or team writes. Writes on one app are serialized. A mutation, writable endpoint, or schedule holds the app's write lock for its whole run, including time spent in `fetch`. Keep outbound calls short and ingest small batches. Rollback covers only the database. If a mutation sends an email, uploads a file, writes a team row, or calls an API and then throws, those side effects stay. Do them last. Queries can fetch, but an external change does not re-run a subscription. Store external data in rows (from a mutation, endpoint, or schedule) when the UI should update reactively. ## Handler context ```ts ctx.auth // caller identity and guards. See auth.md. ctx.db // tables. See database.md. ctx.env // Record ctx.log // structured logs ctx.storage // server-side files. See storage.md. ctx.email // send email. See email.md. ctx.team // workspace-shared tables. See teams.md. ctx.workspace // workspace members. See teams.md. ctx.scheduler // run a mutation or action later (not in queries). See jobs.md. ctx.job // { id, name, attempt, maxAttempts, scheduledAt, scheduledBy } when running as a job ``` `ctx.auth` fields: | Field | Type | Notes | | --- | --- | --- | | `userId` | `string \| null` | `null` without a session (external HTTP callers, schedules, jobs). | | `isGuest` | `boolean` | Guest session on a public app. | | `isSignedIn` | `boolean` | fuast account. `isAuthenticated` is an alias. | | `provider` | `"guest" \| "fuast" \| null` | | | `displayName` | `string` | Always present. `"Guest"` for guests, `"system"` for schedules and jobs. | | `email`, `emailVerified`, `picture` | optional | Profile data for accounts. Not a credential. | | `workspace` | `{ id, name }` optional | The caller's workspace, for accounts. | | `requireIdentity()` | method | Returns the identity with a non-null `userId`. Throws code `unauthenticated` with `Sign in or start a guest session first.` | | `requireSignedIn()` | method | Same, but guests also throw, with code `sign_in_required`. | ## Endpoints Use endpoints for webhooks, health checks, and anything that calls your app over plain HTTP. ```ts import { endpoint, json, text } from "fuast/server"; endpoints: { stripe: endpoint({ method: "POST", path: "/webhooks/stripe" }, async (ctx, req) => { if (req.headers.get("x-webhook-secret") !== ctx.env.STRIPE_WEBHOOK_SECRET) { return text("unauthorized", { status: 401 }); } const event = await req.json<{ type: string }>(); await ctx.db.events.insert({ type: event.type }); return json({ ok: true }); }), item: endpoint({ method: "GET", path: "/api/items/:id" }, async (ctx, req) => { const item = await ctx.db.items.get(req.params.id); return item ? json(item) : text("not found", { status: 404 }); }) } ``` `endpoint(route, handler)`: - `route.method`: any HTTP method, upper-cased for you. - `route.path`: starts with `/`. Segments like `:id` become `req.params.id`. - `route.readOnly`: defaults to `true` for `GET` and `HEAD`, `false` otherwise. A non-boolean value throws at build time. Set `readOnly: false` explicitly for a `GET` that writes. The endpoint is served at ``, for example `https://my-app.fuast.com/webhooks/stripe`. From your client, call it with `fetch(appUrl("/webhooks/stripe"))` (see [calling your endpoints](#calling-your-endpoints)). `/e/` is an alias for older callers. A request whose method and path match an endpoint gets the endpoint, even when a client route has the same path. Keep API paths (`/api/...`) apart from page paths so reloads and shared links work. The paths `/api/call`, `/api/me`, `/storage/...`, and `/__fuast/...` are reserved. The request object: | Member | Type | | --- | --- | | `method` | `string` | | `path` | `string`, without the app base | | `url` | `string`, the full URL | | `headers` | `{ get(name), has(name), entries() }` | | `query` | `URLSearchParams` | | `params` | `Record` from `:param` segments | | `text()`, `json()`, `bytes()` | body readers, each a `Promise` | Endpoints run for any caller. The app's sharing mode controls the UI and the query, mutation, and action calls, not endpoints, so webhooks work on private apps. `ctx.auth` is set only when the caller sends a fuast identity the app accepts (same-origin session cookie or `X-Fuast-Token`). Check webhook secrets yourself. With `auth.requireSignIn: true`, endpoints reject callers without a signed-in identity. See [endpoint credentials](https://fuast.com/docs/auth.md#endpoint-credentials). ### Endpoint responses | Helper | Result | | --- | --- | | `json(value, opts?)` | Status 200, `Content-Type: application/json; charset=utf-8`. | | `text(value, opts?)` | Status 200, `Content-Type: text/plain; charset=utf-8`. `httpText` is an alias. | | `empty(opts?)` | Status 204, no body. | | `redirect(url, opts?)` | Status 302, `Location: `. | Every helper takes `{ status?, headers? }`. Return one of these, not a global `Response`. A handler that throws returns `500` with `{ "error": "" }` and logs the error. ## Logging ```ts ctx.log("plain message", { any: "data" }); // same as info ctx.log.info("order created", { id }); ctx.log.warn("payment retried", { attempt }); ctx.log.error("payment failed", { reason }); ``` Entries are `{ at, level, message, data, source, handler }`. Handler errors are logged automatically with `source: "system"` and the stack in `data.stack`. In `fuast dev` the stack is mapped back to your source files (`server/index.ts:42:11`). Read them with `fuast logs`. The buffer keeps the newest 1000 entries or 256 KiB per app. See [limits](https://fuast.com/docs/limits.md). ## Errors Throw an `Error` to reject a call. The message reaches the client: `useQuery` throws it to the nearest `ErrorBoundary`, and mutation and action promises reject with it. Write messages a person can act on: `"Todo not found."`, not `"invalid"`. Errors carry a `code` when fuast raised them: `unauthenticated`, `sign_in_required`, `forbidden`, `fuast_quota_exceeded`, `DESTRUCTIVE_SCHEMA_CHANGE`. Branch on codes, not message text. ## Shared code ```ts // shared/todo.ts export const MAX_TODO = 160; export function cleanTodoText(value: string): string { return value.trim().slice(0, MAX_TODO); } ``` Use the same helper on the client for instant feedback and on the server for the real check. ## Client ```tsx import { appUrl, basePath, canAccessApp, createClient, ErrorBoundary, getIdentity, Link, navigate, retryAuth, Route, Router, Routes, SignInButton, signIn, signOut, useAuth, useLocation, useNavigate, useParams } from "fuast/client"; import type app from "../server/index"; const client = createClient(); ``` `createClient()` returns `{ useQuery, useMutation, useAction, usePaginatedQuery, storage }` with handler names, arguments, and results inferred from the server. Import the server with `import type` so no server code reaches the browser. | API | Behavior | | --- | --- | | `client.useQuery(name, ...args)` | Subscribes over the app's WebSocket. Returns `undefined` until the first result, then re-renders when a write changes what the query read. Throws query errors to the nearest `ErrorBoundary`. | | `client.useMutation(name)` | Returns `(...args) => Promise`. Chain `.withOptimisticUpdate(update)` to predict the result. See [optimistic updates](#optimistic-updates). | | `client.useAction(name)` | Returns `(...args) => Promise`. | | `client.usePaginatedQuery(name, args, { initialNumItems })` | Returns `{ page, isDone, continueCursor, loadMore(), reset(), status }`. `status` is `"loading"` (no first page yet), `"canLoadMore"`, `"loadingMore"` (`loadMore()` in flight), or `"exhausted"` (`isDone`). See [pagination](https://fuast.com/docs/database.md#pagination). | | `client.storage` | `upload(file, { public? })` and `delete(key)`. See [storage](https://fuast.com/docs/storage.md). | Standalone `useQuery`, `useMutation`, `useAction`, and `usePaginatedQuery` are also exported for callers that supply their own types. Query results are deep-frozen in `fuast dev` to catch accidental mutation. Copy before sorting or editing. A connection runs at most 4 mutations and actions at once. Later calls queue in order, up to 1000. Calls that run at the same time can finish in any order, so await a call before one that depends on it. A queued call that was not sent yet fails with an error ending in "the server did not apply it" if the connection closes or the signed-in user changes. Read the current state and call again if it still applies. Batch many row writes into one mutation instead of one call per row. After a mutation succeeds, the queries it affected on that connection update before the mutation's promise resolves, so the next render already shows the write. Query updates are incremental. When a write changes a subscribed result, the server sends only the changed part, and the client rebuilds the full result before your component renders. Your code always sees a complete, fresh value. Nothing to configure. If the session ends or the person loses access to the app (signed out elsewhere, removed from sharing), the server closes the connection. The client clears cached query data, does not reconnect on its own, and sets `useAuth().error`. Show it with `retryAuth()` and ``. ### Optimistic updates A mutation normally shows its effect once the server confirms it, usually within tens of milliseconds. For interactions that must feel instant (checking a box, reordering, sending a chat message), predict the result on the client: ```tsx const setTodoDone = client .useMutation("setTodoDone") .withOptimisticUpdate((store, id: string, done: boolean) => { store.setQuery("todos", [], (todos) => todos?.map((t) => (t.id === id ? { ...t, done } : t))); }); // Call it exactly like the plain mutation. await setTodoDone(todo.id, true); ``` `withOptimisticUpdate(update)` returns the same callable with a prediction attached. `update(store, ...args)` receives the mutation's arguments and edits the client's cached query results: | `store` method | Does | | --- | --- | | `getQuery(name, args?)` | The current result of a subscribed query, including earlier predictions, or `undefined`. `args` is the query's arguments as an array, `[]` for none. | | `setQuery(name, args, valueOrUpdater)` | Replaces that result for display. Pass a function to derive it from the current value. Queries this page has not subscribed to are ignored. | | `getAllQueries(name)` | Every subscribed instance of a query as `{ args, value }`, for example each page of a paginated list. | How predictions behave: - The prediction shows as soon as you call the mutation, even if it is still queued behind other writes. Nothing reaches the server except the mutation itself. - The server stays authoritative. When the mutation commits, the real results replace the prediction. If it fails, the prediction is removed and the promise rejects as usual. Keep your normal error handling. - Several pending predictions stack in call order. While a sent mutation is pending, newer server results for the queries its prediction touched are held back and shown once it settles, so the prediction never flickers or applies twice. - A `useMutation` result takes one `withOptimisticUpdate`. Calling it twice throws. - Write `update` as a pure function of the store and the arguments. Do not mutate the value you read: return a new array or object. Results are frozen in `fuast dev`, so in-place edits throw there. - An `update` that throws is skipped with a console warning. The mutation still runs. - New rows have no server `id` yet. Use a temporary id such as `"optimistic:" + Date.now()` and do not let the user act on that row until the real one arrives. Only add a prediction where the wait is visible. Most forms do not need one. ### Loading, empty, and loaded ```tsx function Todos() { const todos = client.useQuery("todos"); if (todos === undefined) return null; if (todos.length === 0) return ; return (
    {todos.map((t) =>
  • {t.text}
  • )}
); } ``` Render nothing while the result is `undefined`, fade the loaded branch in, and give the empty state its own branch. No spinners or skeletons for query loads. ### Forms ```tsx const [draft, setDraft] = useState(""); const [error, setError] = useState(""); async function onSubmit(event: Event) { event.preventDefault(); const submitted = draft; try { await addTodo(submitted); setError(""); setDraft((current) => (current === submitted ? "" : current)); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } } ``` On failure, keep the input and show the error beside the form. On success, clear only if the input still holds what was submitted. ### Auth on the client `useAuth()` returns `{ isLoading, error, requireSignIn, userId, isGuest, isSignedIn, displayName, email?, picture?, workspace? }`. The page ships with its session, so `isLoading` is normally `false` on the first render. Render nothing while it is `true`. - `canAccessApp()`: `true` when the session is ready, there is a guest or account, and the app's sign-in policy is met. Gate data components on it. - `` and `signIn()`: send the user to fuast sign-in and back. `SignInWithGoogle` is an alias of `SignInButton`. - `signOut()`: ends the account session. Public apps fall back to a fresh guest. - `retryAuth()`: retries a failed session setup or guest upgrade shown in `auth.error`. - `getIdentity()`: the current auth state plus `token` (and `expired`) for custom `fetch` calls to your own endpoints. The client refreshes the token before it expires, so call `getIdentity()` right before each request instead of caching the token. `decodeIdentityClaims(token)` reads its unverified claims (`userId`, `appId`, `exp`). See [endpoint credentials](https://fuast.com/docs/auth.md#endpoint-credentials). ### Calling your endpoints Build endpoint URLs with `appUrl(path)`, never with a literal path: ```tsx import { appUrl, getIdentity } from "fuast/client"; const token = getIdentity().token; const res = await fetch(appUrl("/api/status"), { headers: token ? { "X-Fuast-Token": token } : {} }); ``` `appUrl("/api/status")` is `/api/status` on the app's own origin (`https://.fuast.com/`, `fuast dev`) and `/a//api/status` when the app is served under a path. A page-relative `fetch("api/status")` breaks on nested routes (`/items/42` would request `/items/api/status`), and a root path `fetch("/api/status")` breaks under `/a//`. `appUrl` keeps query strings and fragments, treats `"api/status"` like `"/api/status"`, and returns absolute URLs (`https://…`) unchanged. `basePath()` returns the base itself: `""` or `/a/`, never ending in `/`. ### Routes ```tsx function ItemPage() { const { id } = useParams<{ id: string }>(); return
Item {id}
; } export function App() { return ( Home} /> } /> Not found} /> ); } ``` Paths are app-relative, so they work in dev, on `https://.fuast.com/`, and under `/a//`. `useNavigate()` returns a `navigate(to)` function. `navigate(to)` also works outside components. `useLocation()` returns `{ pathname, search, hash }`. ### Error boundaries fuast wraps `App` in an `ErrorBoundary`. Add your own for a narrower fallback: ```tsx (

{error.message}

)}>
``` ### Deploy reloads When a new deploy goes live, connected tabs receive a `refresh` message and reload at the next safe moment, so nobody runs an old client against a new server for long. ## fuast/ui An optional component kit. Nothing requires it and the starter templates do not use it: an app's design is its own, written in Tailwind classes. It stays available for apps that already import it. It renders in light and dark mode and follows `prefers-color-scheme`. | Component | Props worth knowing | | --- | --- | | `Page` | Centered page container. | | `AppHeader` | The app's top bar. `title`, `subtitle?`, `icon?`, `actions?`, `nav?` (links shown underneath), `user?: { name, picture?, email? } \| null`, `onSignOut?`, `signOutLabel?` (default "Sign out"). Pass `user` and `onSignOut` for the standard signed-in block. | | `PageHeader` | `title`, `subtitle?`, `actions?` | | `Card` | `onClick?` | | `ListRow` | One row of a list: `title`, `description?`, `leading?` (avatar or icon), `trailing?` (badges, buttons), `selected?`, `onClick?` (then the row is focusable and activates on Enter/Space). | | `Stat` | A labelled number: `label`, `value`, `sub?` for the unit or context ("of 100 a day"). | | `Button` | `variant?: "primary" \| "secondary" \| "ghost" \| "danger"`, `size?: "sm" \| "md"`, `type`, `disabled`, `onClick` | | `Input`, `Textarea`, `Select`, `Checkbox` | Pass-through of the native element's props. | | `FileInput` | A drop zone around a native file input: `label?`, `hint?`, `accept?`, `multiple?`, `disabled?`, `name?`, `id?`, `buttonLabel?`, `onFiles?(files: File[])` (also called after a drop), `onChange?`. Upload the files with `client.storage.upload`. | | `Label`, `Field` | `Field`: `label`, `hint?`, children. | | `Badge` | `tone?: "neutral" \| "green" \| "blue" \| "amber" \| "red"` | | `Avatar` | `name`, `size?: "sm" \| "md"` | | `EmptyState` | `title`, `hint?`, children for an action. | | `ErrorText` | Renders nothing when empty. | | `Tr` | A table row. `onClick?` makes it focusable and activates it on Enter/Space; `label?` names it for screen readers (give clickable rows one); `selected?`. | | `Table`, `Td`, `Tabs`, `Dialog`, `Toast`, `Spinner` | Layout and feedback. Use `Spinner` for a button that is waiting, never for query loads. | Everything else: complete Tailwind class names in JSX. Classes compile automatically during `dev`, `check`, and `deploy`. There are no CSS files, CSS modules, or Tailwind config. ## Types You rarely need these. They help when a helper takes `ctx` or a request. ```ts import { mutation, type ServerContext } from "fuast/server"; async function markDone(ctx: ServerContext, id: string) { await ctx.db.todos.update(id, { done: true }); } ``` `fuast/server` exports: | Type | Describes | | --- | --- | | `QueryServerContext` | `ctx` in queries and read-only endpoints. | | `ServerContext` | `ctx` in mutations, writable endpoints, schedules, and `onGuestUpgrade`. Its `db` can write. | | `ActionServerContext` | `ctx` in actions. | | `AuthContext` | `ctx.auth`. | | `CapsuleAuth`, `GuestUpgrade` | The `auth` key and the `{ guestUserId, userId }` hook argument. | | `EndpointRequest`, `EndpointResponse`, `EndpointResponseOptions`, `EndpointDefinition` | Endpoint pieces. | | `ScheduleDefinition` | What `schedule()` returns. | | `Scheduler`, `JobContext`, `JobId`, `JobOptions` | `ctx.scheduler` (`ctx.scheduler.typed()` checks names and arguments), `ctx.job`. See [jobs](https://fuast.com/docs/jobs.md). | | `Field`, `TableDefinition`, `Schema`, `Id` | Schema pieces. | | `PaginationOptions`, `PaginationResult` | `{ cursor, numItems }` and `{ page, isDone, continueCursor }`. | | `QueryName`, `QueryArgs`, `QueryResult`, and the same for mutations and actions | Handler signatures pulled from a capsule. | `fuast/client` exports `FuastClient`, `PaginatedQueryResult`, `ErrorBoundaryProps`, `StorageClient`, `UploadedObject`, `AuthValue`, `IdentitySnapshot`, `IdentityClaims`, `OptimisticLocalStore` (the typed `store` of an optimistic update, for helpers), `UntypedOptimisticLocalStore`, and `OptimisticMutationFunction`. `fuast-env.d.ts` in the capsule root declares all three modules, so editors and `fuast check` work with no `node_modules`. `fuast new` writes it. After upgrading the CLI, run `fuast types` to refresh it. See [migration](https://fuast.com/docs/migration-v2.md#regenerate-fuast-env-d-ts). --- # Database Every fuast app has its own database. Calls are async, reads go through declared indexes, and every handler runs inside one transaction. The API matches Lakebed's database API v1, plus a `json()` field type. ## Tables and fields ```ts import { boolean, capsule, id, json, number, string, table, userId } from "fuast/server"; export default capsule({ schema: { rooms: table({ name: string() }), messages: table({ roomId: id("rooms"), authorId: userId(), body: string(), pinned: boolean().default(false), editedAt: number().optional(), meta: json().optional() }) .index("by_room", ["roomId"]) .index("by_room_pinned", ["roomId", "pinned"]) .index("by_author", ["authorId"]) } }); ``` ## Field types | Helper | Row type | Notes | | --- | --- | --- | | `string()` | `string` | No NUL characters or unpaired surrogates. `text()` is an alias. | | `number()` | `number` | Any finite float64. `NaN` and `Infinity` are rejected on write. | | `boolean()` | `boolean` | | | `id("table")` | `string` | A row id from the named table. | | `userId()` | `string` | A fuast user reference. Follows a guest to their account on sign-in. See [user references](#user-references). | | `json()` | any JSON value | fuast extra. Objects, arrays, and scalars. Cannot be indexed. | Chain `.optional()` to let a field be absent and `.default(value)` to fill it when an insert omits it. They chain in either order. Every row also has `id` (string), `createdAt`, and `updatedAt` (epoch milliseconds), set by fuast. A single field value is at most 64 KiB. Put larger payloads in [storage](https://fuast.com/docs/storage.md) and keep the key in the row. ### Numbers and time `number()` sorts numerically in indexes: `-10` before `2` before `10`. Store timestamps as epoch milliseconds from `Date.now()` or `Date.parse(value)`, and reject a non-finite result before writing. Numeric ranges then compare times without string formats or time zones. ### Optional fields ```ts todos: table({ text: string(), dueAt: number().optional(), priority: number().optional().default(0) }).index("by_due", ["dueAt"]) ``` An absent optional field has no key in the stored row, so it reads as `undefined`. Pass `null` to `insert` or `update` to mean "no value". An `update` with `null` removes the field. Stored rows never contain `null` for an optional field. An optional field with a default always has a value. Clearing it with `null` restores the default. ## Reading and writing ```ts const id = await ctx.db.todos.insert({ text: "milk", ownerId: userId }); // returns the new id const todo = await ctx.db.todos.get(id); // row or null await ctx.db.todos.update(id, { done: true }); // merge a patch await ctx.db.todos.replace(id, { text: "oat milk", ownerId: userId }); // replace all fields await ctx.db.todos.delete(id); ``` Await or return every call. A database call that is not awaited may run after the transaction ends and fail. Validation errors use these exact messages: - `Expected . to be a .` - `Missing value for
.` - `Unknown field
.` - `Value for
. exceeds 65536 bytes.` ## Indexes Every read uses an index. Declare one per access pattern with `.index(name, fields)`: ```ts const recent = await ctx.db.messages .withIndex("by_room", (q) => q.eq("roomId", roomId)) .order("desc") .take(20); ``` A range is zero or more `eq` clauses in index field order, then at most one range field with `gt` or `gte` and/or `lt` or `lte`: ```ts ctx.db.scores.withIndex("by_game_points", (q) => q.eq("gameId", g).gte("points", 100).lt("points", 500)) ``` Out-of-order clauses throw `Index range must follow index field order.` A missing index throws `No index named on
.` Every index has an implicit `createdAt, id` suffix, so rows with equal field values come back in a stable creation order. Every table has a built-in `by_creation` index with no declared fields. Range on it with `createdAt`: ```ts ctx.db.events.withIndex("by_creation", (q) => q.gte("createdAt", since)) ``` `ctx.db.todos.order("desc").take(10)`, `.collect()`, `.take()`, and `.first()` directly on a table use `by_creation`. Indexes do not enforce uniqueness. To keep one row per key, look it up with `first()` and insert only when it is missing. Writes on one app are serialized, so that check is safe as long as every writer does it. ### Terminals After `withIndex(...)` and an optional `.order("asc" | "desc")` (default `asc`), call exactly one: | Terminal | Returns | | --- | --- | | `collect()` | Every matching row. | | `take(n)` | At most `n` rows. | | `first()` | The first row, or `null`. Use `.order("desc").first()` for the latest. | | `paginate({ cursor, numItems })` | `{ page, isDone, continueCursor }`. | | `count()` | The number of matching rows, without loading them. | ## Sort rules Within one index field, values sort in this order: 1. Absent (an optional field with no value). First in `asc`, last in `desc`. 2. `false`, then `true`. 3. Strings, by UTF-8 byte order. Uppercase sorts before lowercase. 4. Numbers, by value. `-0` and `0` are the same key. `q.eq("priority", null)` matches rows where the field is absent. `gt`, `gte`, `lt`, and `lte` only match present values. ## Collect or paginate `collect()` returns every match, as long as the handler stays inside its budget. A handler can return at most 1000 rows across all its database calls. One `collect()` that finds more throws `collect() exceeded the 1000 row return limit. Use paginate().` Several calls that add up to more throw `Database rowsReturned limit exceeded (1000).` Either one rolls back the transaction. Use `collect()` only for result sets your app keeps small. For a list that grows, use `take(n)` for a fixed window or `paginate()` for scrolling. A handler can also read at most 5000 rows and 4 MiB, so a broad scan can fail before it returns 1000 rows. Pagination loops inside one handler share the same budget and do not bypass it. ## Count ```ts const openCount = await ctx.db.todos .withIndex("by_owner_done", (q) => q.eq("ownerId", userId).eq("done", false)) .count(); ``` `count()` charges one row read per match against the 5000-row read budget and nothing against rows returned or bytes read. If the remaining budget is not enough, it throws instead of returning a partial count. For totals larger than that, keep summary rows. See [dashboard counts](#dashboard-counts). ## Pagination Server: accept an argument with a `pagination` property and pass it to `paginate()`. ```ts import { query, type PaginationOptions } from "fuast/server"; messages: query(async (ctx, args: { roomId: string; pagination: PaginationOptions }) => ctx.db.messages .withIndex("by_room", (q) => q.eq("roomId", args.roomId)) .order("desc") .paginate(args.pagination) ) ``` Client: pass the other arguments. The hook adds `pagination`. ```tsx const messages = client.usePaginatedQuery("messages", { roomId }, { initialNumItems: 25 }); return ( <> {messages.page.map((m) =>

{m.body}

)} {messages.isDone ? null : } ); ``` The hook returns `{ page, isDone, continueCursor, loadMore(), reset(), status }`. `page` holds the items of every loaded page. `status` is one of: | `status` | Meaning | | --- | --- | | `"loading"` | The first page has not arrived yet. `page` is empty. | | `"canLoadMore"` | Pages are loaded and there are more. `loadMore()` fetches the next one. | | `"loadingMore"` | `loadMore()` was called and the next page has not arrived yet. | | `"exhausted"` | The last page is loaded (`isDone` is `true`). `loadMore()` does nothing. | ```tsx {messages.status === "loading" ? : null} {messages.status === "canLoadMore" ? : null} {messages.status === "loadingMore" ? : null} ``` `numItems` must be an integer from 1 to 1000, or the call throws `pagination numItems must be an integer between 1 and 1000.` `cursor` is `null` for the first page. Cursors are signed and bound to the app, schema, table, index, range, order, query name, and arguments. A cursor that does not match throws `Malformed pagination cursor.` Each page is its own snapshot. The hook keeps every loaded page subscribed and starts over from the first page when the data changes underneath it. ## Dashboard counts To show totals over more rows than one handler can read, store a count per UTC day next to the source rows and update both in the same transaction. Because fuast has schedules, the ingest can run on a timer inside the app. No external scheduler and no browser involved. This app pulls merged pull requests from GitHub every 10 minutes, one page per run, and keeps daily counts: ```ts import { capsule, number, query, schedule, string, table } from "fuast/server"; import type { ServerContext } from "fuast/server"; const DAY = 86_400_000; const PAGE_SIZE = 25; const REPO = "fuast/fuast"; export default capsule({ name: "Merged PRs", auth: { requireSignIn: false }, schema: { prs: table({ number: number(), mergedAt: number() }) .index("by_number", ["number"]) .index("by_merged", ["mergedAt"]), days: table({ day: number(), count: number() }).index("by_day", ["day"]), cursors: table({ name: string(), page: number(), lastRunAt: number() }).index("by_name", ["name"]) }, schedules: { ingest: schedule("*/10 * * * *", (ctx) => ingestPage(ctx)) }, queries: { daily: query(async (ctx, start: number) => { if (!Number.isSafeInteger(start) || start % DAY !== 0) throw new Error("Use a UTC day boundary."); return ctx.db.days .withIndex("by_day", (q) => q.gte("day", start).lt("day", start + 14 * DAY)) .take(14); }), lastRun: query(async (ctx) => ctx.db.cursors.withIndex("by_name", (q) => q.eq("name", "github")).first() ) } }); async function ingestPage(ctx: ServerContext) { const token = ctx.env.GITHUB_TOKEN; if (!token) throw new Error("Set GITHUB_TOKEN in .env.fuast.server."); // ServerContext is not tied to this schema, so check stored fields before using them. const cursor = await ctx.db.cursors.withIndex("by_name", (q) => q.eq("name", "github")).first(); const page = typeof cursor?.page === "number" ? cursor.page : 1; const res = await fetch( `https://api.github.com/repos/${REPO}/pulls?state=closed&sort=updated&direction=desc&per_page=${PAGE_SIZE}&page=${page}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } } ); if (!res.ok) throw new Error(`GitHub returned ${res.status}.`); const items: unknown = await res.json(); if (!Array.isArray(items) || items.length > PAGE_SIZE) throw new Error("Invalid GitHub page."); let inserted = 0; for (const item of items) { if (typeof item !== "object" || item === null) throw new Error("Invalid PR."); const { number: prNumber, merged_at: mergedAtRaw } = item as { number?: unknown; merged_at?: unknown }; if (mergedAtRaw === null) continue; if (typeof prNumber !== "number" || !Number.isSafeInteger(prNumber) || typeof mergedAtRaw !== "string") { throw new Error("Invalid merged PR."); } const mergedAt = Date.parse(mergedAtRaw); if (!Number.isFinite(mergedAt)) throw new Error("Invalid merge time."); const existing = await ctx.db.prs.withIndex("by_number", (q) => q.eq("number", prNumber)).first(); if (existing) continue; const day = Math.floor(mergedAt / DAY) * DAY; const bucket = await ctx.db.days.withIndex("by_day", (q) => q.eq("day", day)).first(); await ctx.db.prs.insert({ number: prNumber, mergedAt }); if (bucket) { if (typeof bucket.count !== "number") throw new Error("Invalid daily count."); await ctx.db.days.update(bucket.id, { count: bucket.count + 1 }); } else await ctx.db.days.insert({ day, count: 1 }); inserted += 1; } // Walk older pages during the first backfill, then keep re-reading page 1. const nextPage = items.length === PAGE_SIZE && page < 20 ? page + 1 : 1; if (cursor) await ctx.db.cursors.update(cursor.id, { page: nextPage, lastRunAt: Date.now() }); else await ctx.db.cursors.insert({ name: "github", page: nextPage, lastRunAt: Date.now() }); ctx.log.info("GitHub ingest", { page, inserted, nextPage }); } ``` How it fits the limits: - The schedule is one transaction. If it throws, no rows or counts change, and the next run retries the same page. - Each PR costs at most two indexed lookups and two writes, so a 25-item page stays far inside 100 scans and 1000 writes per handler. - The `fetch` holds the app's write lock while it waits. One small page per run keeps other writes fast. - Re-reading a page never double-counts, because the `by_number` lookup skips PRs already stored. - `lastRun` lets the UI say when data was last refreshed. Set `GITHUB_TOKEN` in `.env.fuast.server` locally and in the dashboard Env tab for the hosted app. On the client, pass a UTC midnight to `client.useQuery("daily", start)`, fill missing days with zero, and draw the counts. Change `start` when the visible window moves, because the clock alone does not re-run a subscription. To test the ingest without waiting for the clock, call the same `ingestPage` helper from a mutation or a protected endpoint while you develop. See [schedules](https://fuast.com/docs/schedules.md#test-a-schedule). This keeps PR numbers and merge times forever. For a long-running app, add retention: delete old source rows and their expired day buckets in the same transaction. A query cannot read unlimited rows by selecting fewer fields, because there is no projection API. ## Relationships Use `id("table")` fields and traverse them in bounded loops. There are no joins. ```ts const page = await ctx.db.messages.withIndex("by_room", (q) => q.eq("roomId", roomId)).take(20); const withRooms = []; for (const m of page) withRooms.push({ ...m, room: await ctx.db.rooms.get(m.roomId) }); ``` A handler can make at most 1000 direct `get` calls. Model many-to-many relationships with a join table indexed in both directions. ## User references Use `userId()` for a fuast user and `id("table")` for a row in your own table, even if your app has a table named `users`. ```ts todos: table({ text: string(), ownerId: userId() }).index("by_owner", ["ownerId"]) ``` `userId()` stores an opaque string and marks the field for transfer: when a guest signs in on a public app, every `userId()` value equal to the guest's id becomes the account's id, in one transaction. Plain strings, JSON values, and text that contains an id do not transfer. The field does not authorize anything. Get the caller with `ctx.auth.requireIdentity()`, filter by its `userId`, and check ownership before updates and deletes. Always write the caller's current `userId`, never a stored or client-supplied one. See [auth](https://fuast.com/docs/auth.md#guest-upgrade). ## Consistency - A query sees one repeatable snapshot and cannot write. - A mutation, writable endpoint, or schedule runs in one write transaction. Its writes commit together or not at all, and it reads its own writes. - Writes are serialized per app. - An action reads with read-only access and does not hold the write lock. - A throw, a timeout, or a limit error rolls the transaction back. - After a write commits, every subscribed query that read a table the write touched re-runs, and clients receive the new result only if it changed. The client that made the write gets its updated queries before the mutation's promise resolves. ## Schema changes Each deploy compares the new schema with the stored rows: - Tables that remain keep their rows. Removing a field does not delete stored values. - A new index is backfilled from existing rows. - A new required field without a default on a table with rows rejects the deploy with `Missing value for
.`. Make it `.optional()` or give it a `.default()`. - Changing a field's type on a populated table is allowed only if every stored row validates against the new type. - Removing or renaming a table that has rows rejects the deploy with code `DESTRUCTIVE_SCHEMA_CHANGE` and lists every such table with its row count. Empty tables can be dropped freely. To delete populated tables on purpose, confirm each one with its exact count: ```sh fuast deploy --delete-table archived=12 --delete-table drafts=3 ``` The confirmations must match the current omitted tables and counts exactly, or the deploy is rejected with fresh counts. A rejected deploy changes nothing. Reverting the code after a confirmed deletion does not bring the rows back, so export first with `fuast db export --out before.json`. `fuast dev` applies the same rules on hot reload and keeps the previous build running when a change is rejected. `fuast dev --reset` wipes local data instead. ### Rename a table fuast does not guess renames. Migrate in steps: 1. Add the new table next to the old one, with an indexed `sourceId: string()` field. 2. Deploy handlers that stop writes to the old table. 3. `fuast db export --out before.json`. 4. Copy rows in bounded mutations. Look up `sourceId` before each insert so retries are safe. 5. Rewrite references to the new ids, then switch reads and writes to the new table. 6. Remove the old table from the schema and deploy with `--delete-table old=COUNT`. ## Export and import ```sh fuast db export --out backup.json # every table, paged, written atomically fuast db import backup.json # owner only, into empty tables only ``` The export is `{ fuastBackup: 1, appId, schemaHash, consistency: "per_page", ordering: { tables: "name_asc", rows: "by_creation_asc" }, tables: [...] }`. Each page is a separate snapshot, so a running app can change between pages. Stop writes first when you need an exact copy. --- # Auth fuast signs people in for you. You never configure OAuth, store passwords, or verify tokens. Handlers read the verified caller from `ctx.auth`, and clients read it from `useAuth()`. ## Accounts A fuast account is a person with a verified email address. They sign in on the fuast platform with email and password or Google. An app never sees passwords or provider tokens, only the identity: | Field | Value for an account | | --- | --- | | `userId` | Stable, opaque id. The same in every app. | | `provider` | `"fuast"` | | `isSignedIn` | `true` | | `displayName` | The account's name. | | `email`, `emailVerified`, `picture` | Profile data. | | `workspace` | `{ id, name }`, the account's workspace. | `userId` is the authorization key. Email, name, and picture are profile data: they can change, and they are not proof of access. Never key ownership on email. ## Workspaces Every account belongs to one workspace. An account with a company email joins the workspace for that email domain after verifying the address. Personal email domains (gmail.com and friends) get a workspace of one. Every app belongs to the workspace of the account that first deployed it. See [teams](https://fuast.com/docs/teams.md). ## Who can open an app The app's sharing mode decides who reaches the UI and can call its queries, mutations, and actions: | Sharing | Who gets in | Guests | | --- | --- | --- | | `private` (default) | The owner, workspace admins, and people on the app's allow-list. | No | | `workspace` | Every member of the app's workspace, plus the allow-list. | No | | `public` | Anyone with the link. | Yes, unless `requireSignIn` | Someone without access sees a fuast page that lets them sign in or request access. Requests show up in `fuast users list`. Change sharing with `fuast share --visibility ...`. Private and workspace apps only ever see signed-in accounts from the browser, so `ctx.auth.isSignedIn` is `true` for every UI call. Write handlers with `requireIdentity()` or `requireSignedIn()` anyway, so the app stays correct if you make it public later. ## Guests on public apps On a public app without `requireSignIn`, a visitor with no session gets a guest identity automatically. The browser holds it in an HttpOnly cookie for that app only, valid for 30 days. An [unclaimed preview](https://fuast.com/docs/reference.md#deploy-without-an-account) works the same way, except that everyone is a guest: an app nobody owns yet has no sign-in, so `requireSignedIn()` always fails there and a `requireSignIn` app cannot be used until it is claimed. | State | `userId` | `provider` | `isGuest` | `isSignedIn` | | --- | --- | --- | --- | --- | | No session | `null` | `null` | `false` | `false` | | Guest | string | `"guest"` | `true` | `false` | | Account | string | `"fuast"` | `false` | `true` | Check `isGuest` and `isSignedIn` separately. Neither is true without a session. A user id is not a credential: never create your own guest ids, accept one as an argument, or infer anything from its shape. Clearing browser data, another browser, or another device means a new guest. Signing in before that keeps the data. ## Require an identity ```ts const { userId } = ctx.auth.requireIdentity(); // guest or account const me = ctx.auth.requireSignedIn(); // account only ``` `requireIdentity()` returns the identity with a non-null `userId`, or throws an error with code `unauthenticated` and the message `Sign in or start a guest session first.`. `requireSignedIn()` also rejects guests, with code `sign_in_required`. The guard identifies the caller. It does not authorize access to rows. Filter user-owned reads by `userId` and re-check ownership before updates and deletes. ## Require sign-in for the whole app ```ts export default capsule({ auth: { requireSignIn: true }, // ... }); ``` With `requireSignIn: true`, guests and callers without a session cannot run any query, mutation, action, or endpoint, or use the app's storage routes. The app shell and the sign-in page still load. The default is `false`. Use `requireSignedIn()` inside specific handlers when only some operations need an account. On a public app that accepts webhooks, leave `requireSignIn` off and check the webhook secret in the endpoint. ## Auth in the client ```tsx import { canAccessApp, retryAuth, SignInButton, useAuth } from "fuast/client"; import type { ComponentChildren } from "preact"; function Gate({ children }: { children: ComponentChildren }) { const auth = useAuth(); if (auth.isLoading) return null; if (canAccessApp()) return <>{children}; return (
{auth.error ?

{auth.error}

:

Sign in to use this app.

} {auth.error ? : null}
); } ``` - `useAuth()` returns `{ isLoading, error, requireSignIn, userId, isGuest, isSignedIn, displayName, email?, picture?, workspace? }`. The page ships with its session, so `isLoading` is normally `false` on the first render. - `canAccessApp()` is `true` when session setup finished without an error, a guest or account exists, and the app's sign-in policy is met. Mount data components only when it is true. - `` or `signIn()` sends the user to fuast sign-in and back to the same page. `SignInWithGoogle` is an alias, kept for code written against Lakebed. - `signOut()` ends the account session in this app. On a public app the user continues as a fresh guest. - `retryAuth()` retries a failed session setup or guest upgrade. Show it next to `auth.error`, with ``. Client checks only choose what to render. The server enforces access. ## Guest upgrade When a guest signs in on a public app in the same browser, fuast upgrades them: 1. fuast verifies both the guest session and the account. 2. Your `auth.onGuestUpgrade` hook runs, if you declared one. 3. Every `userId()` field in every table that equals the guest's id is rewritten to the account's id. Steps 2 and 3 are one transaction. If anything fails, nothing changes and `auth.error` lets the user retry. A guest is upgraded at most once. Rows the account already had stay. Only `userId()` fields move: plain strings, `json()` values, and snapshots like a stored author name do not. Use the hook for app-specific merge rules, for example keeping the account's settings when both have a row: ```ts import { capsule, string, table, userId } from "fuast/server"; export default capsule({ schema: { settings: table({ ownerId: userId(), theme: string() }).index("by_owner", ["ownerId"]) }, auth: { onGuestUpgrade: async (ctx, { guestUserId, userId }) => { const mine = await ctx.db.settings.withIndex("by_owner", (q) => q.eq("ownerId", userId)).first(); if (!mine) return; const guest = await ctx.db.settings.withIndex("by_owner", (q) => q.eq("ownerId", guestUserId)).collect(); for (const row of guest) await ctx.db.settings.delete(row.id); } } }); ``` The hook can read and write `ctx.db` and nothing else that leaves the database: no `fetch`, email, storage writes, or team writes. Its transaction can roll back and retry, and outside effects cannot. ## Endpoint credentials Two headers, two owners. Keep them straight: | Header | Belongs to | On endpoints | On `/api/call`, `/__fuast/*`, storage | | --- | --- | --- | --- | | `X-Fuast-Token` | fuast | The caller's fuast identity. Verified, then removed before your handler runs. | Also accepted. | | `Authorization` | Your app | Passed through untouched. Never creates a fuast identity. Use it for webhook secrets, Bearer, or Basic auth you check yourself. | `Bearer ` is the fuast credential. | A fuast identity reaches an endpoint in one of two ways: - **Same-origin browser requests** carry the app's session or guest cookie automatically. `fetch(appUrl("/api/status"))` from your client is identified with no extra header. - **`X-Fuast-Token`**, sent raw without a `Bearer` prefix. It accepts exactly these credentials: | Credential | Accepted in `X-Fuast-Token` | Caller becomes | | --- | --- | --- | | Identity token from `getIdentity().token` | Yes, for the app it was issued for only | That person, or that guest | | Personal token from `fuast token create` (no `--app`) | Yes | The token's account | | The token `fuast login` saved (`FUAST_TOKEN` from a login) | Yes | The logged-in account | | App-scoped deploy token (`fuast token create --app`) | Never | No identity | | Anything else, expired, or forged | No | No identity | Use the identity token for cross-origin browser callers, for example a browser extension or another tab you control. Use a personal token for headless agents and scripts that should act as you, for example `curl -H "X-Fuast-Token: $FUAST_TOKEN" https://my-app.fuast.com/api/status`. The account still needs access to the app for `ctx.auth` to be set, exactly as in the browser. ```ts import { appUrl, getIdentity } from "fuast/client"; const { token } = getIdentity(); const res = await fetch(appUrl("/api/status"), { headers: token ? { "X-Fuast-Token": token } : {} }); ``` `getIdentity()` returns the auth state with `token` and `expired`. The identity token works only for this app and expires after 12 hours; the client refreshes it from `GET /__fuast/auth/token` (same-origin cookie only, returns `{ token, expiresAt }`, 401 without a session, 403 without access), so read it right before each request. Send it only to this app's origin, never to a third party. A personal token can do anything you can on the platform: send it only to apps you trust. Both headers can appear together: ```http X-Fuast-Token: Authorization: Bearer ``` Callers without either have `ctx.auth.userId === null`. Webhooks and other machines usually have neither, so protect machine-called endpoints with your own secret from `ctx.env`. Any fuast token in the `Authorization` header is not a fuast identity on an endpoint: it reaches your handler like any other header value. `X-Fuast-Token` is always removed before your handler runs. ## The HTTP call API Scripts and tests can call queries, mutations, and actions without a browser: ```sh curl -X POST https://my-app.fuast.com/api/call \ -H "Authorization: Bearer $FUAST_TOKEN" \ -H "Content-Type: application/json" \ -d '{"kind":"query","name":"todos","args":[]}' ``` This route accepts the app session cookie or `Authorization: Bearer ` and applies the same sharing rules as the browser. `GET /api/me` returns the identity the app sees. See [reference](https://fuast.com/docs/reference.md#http-call-api). ## App origins Each hosted app runs on its own origin, `https://./` (on fuast.com, `https://.fuast.com/`). That origin gets its own session cookie through a one-time code redirect from the platform at sign-in. Platform cookies are host-only and never reach an app origin, so one app's JavaScript cannot act as you on the platform or read another app's pages. All apps share the apps domain, and until that domain is on the [Public Suffix List](https://publicsuffix.org/), browsers treat every app on it as the **same site**: - `SameSite` cookies do not separate your app from other apps. If a writable endpoint relies on cookies, check the `Origin` header and accept only your app's origin. - Another app can set a cookie for the parent domain. If you set your own cookie, use a `__Host-` name with `Secure; Path=/` and no `Domain`, so no other subdomain can overwrite it. - Treat every other app as untrusted, including your own. `fuast domains add ` adds `.` as an extra hostname for the app. It gets the same origin rules. Self-hosted platforms without a wildcard domain serve apps under `/a//` on the platform origin instead. There every app shares one origin, so path mode suits only platforms where every app is trusted. Client routes from `fuast/client` work in both modes. Build endpoint URLs with `appUrl("/api/...")` from `fuast/client` so they work in both modes; a root-relative `fetch("/api/...")` targets the app only on an app origin. See [security](https://fuast.com/docs/security.md#app-origins-and-cookies). ## Local identities `fuast dev` needs no auth setup: - By default you are the signed-in account `dev`. - `?fuast_user=alice` in the URL makes that tab the signed-in account `alice`. Use two tabs to test two people. - `?fuast_guest=1` makes the tab a guest, so you can test public-app flows and guest upgrade. - `fuast auth as alice` changes the default identity for the browser and for CLI commands that call `fuast dev`. `fuast auth reset` restores `dev`. All local accounts share one local workspace, so `ctx.workspace.members()` lists every name you have used. --- # Storage Every app has object storage for files: avatars, attachments, PDFs, exports. Browsers upload with `client.storage`, and handlers read and write files with `ctx.storage`. Keep the returned `key` in your own tables and enforce ownership in your queries. ## Upload from the client ```ts const object = await client.storage.upload(file, { public: true }); // { key: "public/", url: "https://my-app.fuast.com/storage/public/", size, contentType } await client.storage.delete(object.key); ``` `upload(file, opts?)` takes a `File` or `Blob`. Without `{ public: true }` the object is private. It resolves to an `UploadedObject`: - `key`: `"public/"` or `"private/"`. Store it to delete or serve the file later. - `url`: absolute URL of the object on the app's origin. In `fuast dev` it uses the host you opened the app on (`http://localhost:3000/storage/...` when you browse `localhost`), so `` stays same-origin and sends the session cookie. - `size`, `contentType`: stored byte length and MIME type. Uploads need a signed-in account. Guests on public apps cannot upload. `uploadFile(file)` from older fuast apps still works and uploads a private object. ## Server-side storage `ctx.storage` reads and writes the same objects from a handler: ```ts const { key, url } = await ctx.storage.put(csv, { contentType: "text/csv" }); // private const avatar = await ctx.storage.put(bytes, { contentType: "image/png", public: true }); const data = await ctx.storage.get(key); // Uint8Array, or null when missing const info = await ctx.storage.stat(key); // metadata, or null when missing ctx.storage.url(key); // absolute URL string await ctx.storage.delete(key); ``` - `put(body, { contentType, public? })` takes a `Uint8Array` or a string and returns `{ key, url }`. The object is attributed to the caller (`ctx.auth.userId`), or to no one in schedules and jobs. - `stat(key)` returns `{ key, size, contentType, public, uploadedBy, uploadedAt }` or `null` for a missing or malformed key. `uploadedBy` is the uploader's `ctx.auth.userId` (`null` for objects stored by the system) and `uploadedAt` is epoch milliseconds. Use it to check a key a client sends you before you save it. - `url(key)` is available in every handler and returns an absolute URL on the app's origin. `get`, `stat`, `put`, and `delete` work in mutations, actions, endpoints, and schedules, not in queries. - Storage is outside the database transaction. If a mutation stores a file and then throws, the file stays. Write files after the checks that can fail, or delete them in a `catch`. A schedule that writes a nightly CSV export, an endpoint that receives a webhook attachment, or an action that renders a PDF all use `ctx.storage`. ## Who can read a file | Object | Readable by | | --- | --- | | `public/` | Anyone who can open the app. On a public app without `requireSignIn`, anyone with the URL. | | `private/` | Signed-in users who can open the app. | Private URLs work in ``, ``, and `` inside your app, because the browser sends the app's session cookie with same-origin requests. They do not work for someone without access. Every user with access to the app can fetch every private object whose key they know, and object ids are unguessable. Per-user privacy therefore comes from your queries: store keys in rows with an owner, and return a key or URL only to callers allowed to see it. ## HTTP routes ```http POST /storage[?public=true] upload; body is the file, Content-Type sets the type GET /storage/public/ GET /storage/private/ DELETE /storage/public/ DELETE /storage/private/ ``` These live on the app origin. Uploads return `201` with `{ key, url, size, contentType }`. Deletes are idempotent and allowed for the uploader, the app owner, and workspace admins. Routes accept the app session cookie or `Authorization: Bearer `. Public responses on public apps use `Cache-Control: public, max-age=3600`. Everything else uses `Cache-Control: private, no-store`. ## Blocked types and safe serving Storage is for files people use, not code. Uploads fail with `415 blocked_type` when: - the declared `Content-Type` is an executable, installer, script, HTML, or JavaScript type, or - the first bytes identify an executable (Windows PE, ELF, Mach-O, Java class, shebang script) or an HTML document, whatever the declared type. Every download is served with `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`. Anything stored as HTML or JavaScript is served as `application/octet-stream` with `Content-Disposition: attachment`. SVG is allowed and safe in ``. ## Limits - 25 MiB per file. - 1 GiB stored per workspace, across all its apps. Deleting files frees space. Both are configurable on self-hosted platforms. See [limits](https://fuast.com/docs/limits.md). ## Errors Failures return `{ "error": { "code", "message" } }`: | Code | Status | Meaning | | --- | --- | --- | | `invalid_request` | 400 | Malformed request or empty body. | | `unauthenticated` | 401 | No signed-in account. Guests cannot upload. | | `forbidden` | 403 | No access to this app or object. | | `not_found` | 404 | No such object. | | `too_large` | 413 | File over 25 MiB. | | `quota_exceeded` | 413 | Workspace over 1 GiB. | | `blocked_type` | 415 | Executables, HTML, and scripts are rejected. | `client.storage.upload` rejects with the message. `ctx.storage.put` throws it. ## Example: attachment on a document The client uploads, then sends only the `key`. The server checks that the caller uploaded that object, stores the key, and computes the URL itself whenever it returns the document. Never store or return a URL the client sent you: a client can send any URL, including one that points at another site, and a key it did not upload may belong to someone else. ```ts // server/index.ts documents: table({ title: string(), authorId: userId(), fileKey: string().optional(), fileType: string().optional() }).index("by_author", ["authorId"]) createDocument: mutation(async (ctx, title: string, fileKey: string | null) => { const { userId } = ctx.auth.requireSignedIn(); let fileType: string | null = null; if (fileKey) { const info = await ctx.storage.stat(fileKey); if (!info || info.uploadedBy !== userId) throw new Error("Upload the file before attaching it."); fileType = info.contentType; } return ctx.db.documents.insert({ title: title.trim(), authorId: userId, fileKey, fileType }); }), myDocuments: query(async (ctx) => { const { userId } = ctx.auth.requireSignedIn(); const docs = await ctx.db.documents.withIndex("by_author", (q) => q.eq("authorId", userId)).collect(); return docs.map((d) => ({ ...d, fileUrl: d.fileKey ? ctx.storage.url(d.fileKey) : null })); }) ``` ```tsx // client/index.tsx async function create(title: string, file: File | null) { const uploaded = file ? await client.storage.upload(file) : null; await createDocument(title, uploaded?.key ?? null); } ``` Computing the URL in the query also keeps it right when the app moves to a custom domain, or when you open `fuast dev` on a different host. The [signature template](https://fuast.com/docs/examples/signature.md) is a complete version. ## Local development `fuast dev` stores files in `.fuast/dev` next to the database. They survive restarts and are wiped by `fuast dev --reset`. The same routes, blocked types, and limits apply. ## From the CLI ```sh fuast storage list --port 3000 fuast storage put ./logo.png --public fuast storage get private/ --out ./file.pdf fuast storage delete public/ ``` Without a slug, commands talk to `fuast dev` on `--port` (default 3000). For a deployed app, pass its slug last: `fuast storage list my-app`, `fuast storage put ./logo.png my-app --public`. Every command takes `--json`. Hosted `list`, `get`, and `delete` need owner or workspace admin credentials. `put` uploads as you. The dashboard Storage tab lists every object with its size, type, uploader, and age, and can delete any of them. --- # Schedules A schedule runs a handler on a timer: a morning digest, a sync every 15 minutes, a nightly cleanup. Declare it in the capsule. fuast runs it on the hosted app with no external scheduler. For work that an event starts (a reminder one day after a request), use [jobs](https://fuast.com/docs/jobs.md) instead. ```ts import { capsule, schedule } from "fuast/server"; export default capsule({ // schema, queries, mutations... schedules: { cleanup: schedule("0 3 * * *", async (ctx) => { const cutoff = Date.now() - 30 * 86_400_000; const old = await ctx.db.events.withIndex("by_creation", (q) => q.lt("createdAt", cutoff)).take(500); for (const row of old) await ctx.db.events.delete(row.id); ctx.log.info("cleanup", { deleted: old.length }); }) } }); ``` `schedule(cron, handler)` takes a cron expression and a handler `(ctx) => void | Promise`. The schedule's name is its key in `schedules`. ## Cron syntax Five fields, evaluated in UTC at minute resolution: ```txt minute hour day-of-month month day-of-week 0-59 0-23 1-31 1-12 0-6 (0 = Sunday) ``` Each field accepts `*`, a number, a list (`1,15`), a range (`1-5`), and a step (`*/15`, `0-30/10`). | Expression | Runs | | --- | --- | | `*/15 * * * *` | Every 15 minutes | | `0 9 * * *` | 09:00 UTC daily | | `0 9 * * 1-5` | 09:00 UTC on weekdays | | `30 17 * * 5` | 17:30 UTC on Fridays | | `0 0 1 * *` | Midnight UTC on the first of the month | When both day-of-month and day-of-week are restricted, a day matches if either matches, as in standard cron. `0 9 1 * 1` runs on the 1st and on every Monday. An invalid expression fails `fuast check` and the deploy. ## What a schedule can do A schedule runs like a mutation: - It reads and writes `ctx.db` in one transaction. A throw rolls back every write. - It can `fetch`, send email, use `ctx.storage`, read and write team tables, and list `ctx.workspace.members()`. - It has the same time limits (5 s of CPU, 10 s wall clock including `fetch`) and database limits as any handler. - Its writes update every subscribed client, like a mutation. It runs as the system, not as a person: `ctx.auth.userId` is `null`, `isSignedIn` and `isGuest` are `false`, and `displayName` is `"system"`. `requireIdentity()` and `requireSignedIn()` throw. Do not call shared helpers that expect a caller. Pass ids explicitly instead. Schedules do not count against the daily request or mutation quotas. ## Timing and catch-up - Each schedule's next run time is stored, so a restart does not skip or repeat a run. - After downtime, a schedule that missed runs executes once to catch up, then returns to its normal times. Missed runs are never replayed one by one. - One run of a schedule never overlaps another run of the same schedule. - Runs start within a few seconds of the minute. Do not depend on sub-minute precision. - In an [unclaimed preview](https://fuast.com/docs/reference.md#deploy-without-an-account), and in a claimed app that is still [restricted](https://fuast.com/docs/security.md#restricted-apps), schedules are registered but do not run. They start at their next time once the owner trusts the app (`fuast trust`). Each run is logged with `source: "system"` and the schedule's name, including its error if it threw. A failed run is not retried early. The next scheduled run happens as usual. ## Work in batches A run has 5 s of CPU, 10 s of wall-clock time, and the normal read and write budgets. For big jobs, do one bounded batch per run and keep a cursor in a table: ```ts cursors: table({ name: string(), after: number() }).index("by_name", ["name"]) ``` The [schedule-driven ingest example](https://fuast.com/docs/database.md#dashboard-counts) pulls one page of an external API per run and keeps daily counts. ## Test a schedule Run any schedule now, without waiting for its time, as the system identity (its next run time does not change): ```sh fuast run schedule digest --port 3000 # local fuast dev fuast run schedule digest my-app # deployed app (owner or workspace admin) ``` The dashboard Schedules tab has the same **Run now** button. You can also put the work in a helper that takes `ctx`, call it from the schedule, and call it from a mutation while you develop: ```ts import { capsule, mutation, schedule, type ServerContext } from "fuast/server"; async function sendDigest(ctx: ServerContext) { // ... } export default capsule({ schedules: { digest: schedule("0 9 * * 1-5", (ctx) => sendDigest(ctx)) }, mutations: { runDigestNow: mutation(async (ctx) => { ctx.auth.requireSignedIn(); await sendDigest(ctx); }) } }); ``` Remember that the mutation runs as you, while the schedule runs as the system. Keep the helper independent of `ctx.auth`, or guard the test mutation so only an admin can run it. `fuast dev` runs schedules on their real times while it is running. Short expressions like `* * * * *` are handy while testing. Change them back before deploying. `FUAST_DEV_CLOCK_OFFSET_MS` shifts the clock `fuast dev` evaluates cron expressions against (for example, so an automated test reaches the next minute boundary in a few seconds). ## Inspect schedules ```sh fuast inspect --port 3000 # local: each schedule with its cron and next run fuast inspect my-app # deployed app, by slug fuast logs my-app --follow # watch runs and errors ``` The dashboard Schedules tab shows each schedule's cron, last run, result, and next run. Schedules are re-read from the capsule on every deploy: adding, removing, or changing one takes effect with the deploy. --- # Jobs A job runs one of your mutations or actions later: a reminder 24 hours after a request, a follow-up email, a retry of a flaky API call, a long task split into steps. Schedule it from a handler with `ctx.scheduler`. fuast stores the job in the app's database and runs it on time, after restarts and deploys too. ```ts import { capsule, mutation, number, string, table } from "fuast/server"; const app = capsule({ schema: { requests: table({ title: string(), status: string(), ownerId: string() }), }, mutations: { submit: mutation(async (ctx, title: string) => { const me = ctx.auth.requireSignedIn(); const id = await ctx.db.requests.insert({ title, status: "pending", ownerId: me.userId }); // One day later, remind the approver if it is still pending. await ctx.scheduler.runAfter(24 * 3_600_000, "remind", id); return id; }), remind: mutation(async (ctx, id: string) => { const r = await ctx.db.requests.get(id); if (!r || r.status !== "pending") return; // decided in the meantime: nothing to do ctx.log.info("reminder", { id, scheduledBy: ctx.job?.scheduledBy?.userId }); }), }, }); export default app; ``` Use [schedules](https://fuast.com/docs/schedules.md) for work on a fixed clock (every morning, every 15 minutes). Use jobs for work that one event starts. ## Schedule, cancel | Call | Does | | --- | --- | | `ctx.scheduler.runAfter(delayMs, name, ...args)` | Runs mutation or action `name` with `args` after `delayMs` milliseconds. `0` runs it as soon as possible. | | `ctx.scheduler.runAt(timestamp, name, ...args)` | Runs it at `timestamp` (epoch milliseconds or a `Date`). A time in the past runs as soon as possible. | | `ctx.scheduler.cancel(jobId)` | Cancels a pending job. Resolves `false` when it already ran, is running, or does not exist. | | `ctx.scheduler.withOptions({ retry })` | The same scheduler with options for the jobs it schedules. | `runAfter` and `runAt` resolve to the job id (a string). Store it if you may cancel the job later. Arguments are positional JSON values, like `fuast run` and `useMutation`. They are copied when you schedule: the job sees the values from scheduling time, not later edits. ### Typed names and arguments `ctx.scheduler` accepts any name and checks it when the job is scheduled (an unknown name throws `ctx.scheduler: no mutation or action named .`). For compile-time checks, take a typed view of your capsule: ```ts const app = capsule({ mutations: { submit: mutation(async (ctx, title: string) => { const jobs = ctx.scheduler.typed(); await jobs.runAfter(60_000, "remind", title); // name and args are checked // jobs.runAfter(60_000, "remind", 42); // type error }), // A handler that RETURNS a typed job id needs a return type: the capsule's type refers to itself. later: mutation(async (ctx, title: string): Promise => ctx.scheduler.typed().runAfter(5000, "remind", title)), remind: mutation(async (ctx, title: string) => { /* ... */ }), }, }); ``` ## Where you can schedule | Handler | Scheduling is | | --- | --- | | Mutations, writable endpoints, schedules, `onGuestUpgrade`, jobs | **Transactional.** The job is written in the same transaction as the handler's database writes. If the handler throws, times out, or hits a limit, the job is never scheduled. | | Actions, read-only endpoints | **Saved when the handler ends.** There is no transaction, so the jobs are saved whether the action returns or throws. Use this to chain external work: fetch in an action, then schedule a mutation to store the result. | | Queries | Not available. Queries are read-only. `ctx.scheduler` is missing from the query type and throws `not_allowed`. | A job can schedule more jobs, including itself, to work through a big task in batches. ## How jobs run - A job runs as the **system**, like a schedule: `ctx.auth.userId` is `null` and `displayName` is `"system"`. Check permissions when you schedule, and pass ids explicitly. - `ctx.job` is set while a handler runs as a job: `{ id, name, attempt, maxAttempts, scheduledAt, scheduledBy }`. `scheduledBy` is a snapshot `{ userId, provider, displayName, email? }` of who scheduled it, or `null` for the system (schedules). Jobs scheduled by a job keep the original `scheduledBy`. - `ctx.job` is `undefined` when the same mutation is called directly, so one mutation can serve both. - Jobs have the same limits as any handler: 5 s of CPU, 10 s wall clock (actions 5 s), the database budgets. - In an [unclaimed preview](https://fuast.com/docs/reference.md#deploy-without-an-account), jobs are saved but do not run, and they are cancelled when a person claims the app. In a [restricted](https://fuast.com/docs/security.md#restricted-apps) app, jobs wait until the owner trusts it (`fuast trust`), then run in due order. - Their writes update every subscribed client, like any mutation. - Each run is logged with `source: "system"` and the handler name: `job remind () finished in 12ms`, or the error. - Jobs do not count as requests. Each run counts as one mutation in the daily quota. When the mutation quota is used up, due jobs wait until it resets. - Due jobs start within about a second. Do not depend on sub-second timing. ### Delivery and retries | Kind | Delivery | Retries on a thrown error | | --- | --- | --- | | Mutation job | **Exactly once.** The job is marked done in the same transaction as the mutation's writes. | None by default. `withOptions({ retry: n })` allows `n` more attempts. | | Action job | **At least once.** If the runtime stops during a run, the job runs again after its lease (2 minutes) expires. | 3 by default (4 attempts). `withOptions({ retry: n })` sets it, 0 to 10. | Retries back off: 1 s, 4 s, 16 s, 64 s, then 4× longer each time, up to 1 hour. `ctx.job.attempt` is 1 on the first run. Make action jobs **idempotent**: an action can run twice, for example when the runtime restarts in the middle of it. Give external APIs an idempotency key (the job id works: `ctx.job.id`), or record what you did in a mutation and check it first. A job whose handler no longer exists in the running deploy (renamed or deleted) fails without retrying. Keep old job handlers around until their jobs have run, or cancel them. ## Limits | Limit | Value | Error | | --- | --- | --- | | Pending jobs per app (pending + running) | 10000 (`FUAST_LIMIT_JOBS_PENDING`) | `Pending job limit exceeded (10000). Cancel jobs or let them finish first.` | | Jobs scheduled by one handler call | 1000 (`FUAST_LIMIT_JOBS_PER_CALL`) | `Job limit exceeded: at most 1000 jobs per handler call.` | | Arguments per job | 64 KiB of JSON (`FUAST_LIMIT_JOB_ARGS_BYTES`); action jobs also have the 16 KiB action argument limit | `Job arguments exceed 65536 bytes.` | | Delay | 0 to 1 year | `ctx.scheduler.runAfter() needs a delay in milliseconds (0 or more).` / `... at most 31536000000 ms (1 year) ahead.` | | Retries | 0 to 10 | `ctx.scheduler: retry must be an integer between 0 and 10.` | | Job runs per app (hosted) | about 20 per second | Extra due jobs wait for the next round. | Limit errors from scheduling are thrown in the handler, so a mutation that hits one rolls back. The newest 1000 finished jobs are kept for inspection. Jobs are stored next to your tables but are not rows: they do not count toward the state row and byte limits and are not part of `db export`. ## Inspect and cancel ```sh fuast jobs list --port 3000 # local: pending first, then recent finished jobs fuast jobs list my-app --status failed # deployed app, by slug fuast jobs cancel my-app # cancel a pending job fuast logs my-app --follow # watch runs and errors ``` `GET /__fuast/jobs` returns `{ jobs, counts, nextRunAt }` in `fuast dev` and hosted (owner and workspace admins), with `?status=` and `?limit=`. The dashboard's Schedules tab lists jobs under the cron schedules and can cancel pending ones. ## Test jobs `fuast dev` runs jobs with the same rules as hosted: same transactions, retries, `ctx.job`, logs, and inspection. Schedule with a delay of `0` while testing, then read the result: ```sh fuast run submit '"Laptop"' --port 3000 fuast jobs list --port 3000 ``` To test the job body itself, call the mutation directly with `fuast run remind '""'`. `ctx.job` is `undefined` in that call. Run a cron schedule without waiting for its time: `fuast run schedule [slug]` (or **Run now** in the dashboard). --- # Email Apps can send email: an approval request, a "your document was signed" note, a daily digest. fuast handles delivery. You do not need an email provider or API key. ```ts await ctx.email.send({ to: "ana@acme.com", subject: "Approval needed: New monitor", text: "Bo asked for a new monitor ($349).\n\nOpen the app to decide: https://approvals.fuast.com/" }); ``` `ctx.email.send(message)`: | Field | Type | Notes | | --- | --- | --- | | `to` | `string` | One address. Loop for several recipients. | | `subject` | `string` | Required. | | `text` | `string` | Plain-text body. | | `html` | `string` | HTML body. Send `text` as well for clients that prefer it. | At least one of `text` and `html` is required. The promise resolves to `{ id }` once fuast has accepted the message for delivery, and rejects if the message is invalid or the app is over its daily email limit. Mail comes from fuast's sending address with the app's name as the sender name. Replies go to the app owner. ## Where it works `ctx.email.send` works in mutations, actions, endpoints, and schedules. It does not work in queries, which must not have side effects, or in `onGuestUpgrade`. It does not work in an [unclaimed preview](https://fuast.com/docs/reference.md#deploy-without-an-account) either. An app deployed without an account cannot send mail until a person claims it: the call fails with `Claim this app to use email: ctx.email.send is off in an unclaimed preview.` (code `unclaimed`). After the claim the app is [restricted](https://fuast.com/docs/security.md#restricted-apps) and email stays off (code `restricted`) until its owner trusts it with the workspace. ## Email and transactions An email is sent when you call `send`. A database rollback does not unsend it. If a mutation sends an email and then throws, the email still goes out and the rows do not change. Send email as the last step, after every check that can fail: ```ts decide: mutation(async (ctx, id: string, approve: boolean) => { const me = ctx.auth.requireSignedIn(); const request = await ctx.db.requests.get(id); if (!request || request.status !== "pending") throw new Error("This request was already decided."); await ctx.db.requests.update(id, { status: approve ? "approved" : "rejected", deciderId: me.userId }); if (request.submitterEmail) { await ctx.email.send({ to: request.submitterEmail, subject: `${request.title} was ${approve ? "approved" : "rejected"}`, text: `${me.displayName} decided your request.` }); } }) ``` ## Who to email Use addresses you got from a trusted place: - `ctx.auth.email` for the caller, when `emailVerified` is true. - `ctx.workspace.members()` for coworkers. - A stored email from a row that a signed-in user wrote about themselves. Do not email arbitrary addresses typed into a form by a guest on a public app. That turns your app into a spam relay, and it uses your daily limit. ## Limits Each app can send 100 emails per UTC day by default. Over the limit, `send` throws an error with code `fuast_quota_exceeded` and `bucket: "email"`. Batch notifications into a digest (see [schedules](https://fuast.com/docs/schedules.md)) instead of one email per event. ## Local development `fuast dev` does not deliver email. Each message is written to the app log with its recipient, subject, and body, so you can check it: ```sh fuast logs --port 3000 ``` --- # Agent tools (WebMCP) A fuast app can let an agent in the user's browser use it directly. Tag a query, mutation, or action with a `tool` option, and the app page registers it as a [WebMCP](https://webmachinelearning.github.io/webmcp/) tool. The agent calls the tool instead of clicking through the UI. WebMCP is a draft standard. Today a user turns on `chrome://flags/#enable-webmcp-testing` in Chrome for agents to see tools. Other browsers ignore them, and the app works as before. ## Tag a handler Pass `{ tool: { description, args } }` as the second argument: ```ts import { boolean, capsule, id, mutation, query, string, table, userId } from "fuast/server"; export default capsule({ schema: { todos: table({ text: string(), done: boolean().default(false), ownerId: userId() }) .index("by_owner", ["ownerId"]) }, queries: { todos: query( async (ctx) => { const { userId } = ctx.auth.requireIdentity(); return ctx.db.todos.withIndex("by_owner", (q) => q.eq("ownerId", userId)).order("desc").collect(); }, { tool: { description: "List the current user's todos, newest first. Each has id, text, and done." } } ) }, mutations: { setTodoDone: mutation( async (ctx, args) => { const { userId } = ctx.auth.requireIdentity(); const todo = await ctx.db.todos.get(args.todoId); if (!todo || todo.ownerId !== userId) throw new Error("Todo not found."); await ctx.db.todos.update(args.todoId, { done: args.done }); }, { tool: { description: "Mark one of the current user's todos done or not done. Get todo ids from the todos tool.", args: { todoId: id("todos"), done: boolean() } } } ) } }); ``` The tool name is the handler name, so it must be unique across queries, mutations, and actions. ## Arguments Build `args` from `string()`, `number()`, `boolean()`, and `id("table")`. Chain `.optional()` for inputs the agent may leave out. Tool arguments cannot use `.default()`, `userId()`, or `json()`. The caller always comes from `ctx.auth`, never from an argument. With `args`, the handler takes one object typed from those fields, and your client calls it the same way: ```ts const setTodoDone = client.useMutation("setTodoDone"); await setTodoDone({ todoId: todo.id, done: true }); ``` Without `args`, the tool takes no input, and the handler takes nothing after `ctx`. ## How a tool call runs The call runs in the user's tab, over the app's own connection: - `ctx.auth` is whoever has the tab open. - Sharing rules and `requireSignIn` apply as for any call. - Your validation and ownership checks run as usual. fuast checks the agent's input against `args` before calling: unknown keys, missing required keys, and wrong types are rejected. Calls from your own client code are not checked against `args`, so validate inside the handler as always. A query tool reads the current result once and is marked read-only to the browser. Mutation and action tools return the handler's result. ## Write descriptions for agents The agent chooses tools by description. Say what the tool does, what it returns, and where its inputs come from. "Get todo ids from the todos tool" tells the agent how to chain calls. Tool names, descriptions, and argument shapes ship in the client bundle. Do not put secrets in them. Tool results are read by an agent. If a result contains text other people wrote, that text can try to steer the agent. Return only the fields the agent needs. ## Inspect tools ```sh fuast inspect --port 3000 fuast inspect my-app --json # full tools object ``` `GET /__fuast/tools` returns the same list, and the dashboard Tools tab shows it. --- # Teams fuast apps are built to be shared with coworkers. Every app lives in a workspace, sharing works like a doc, handlers can list the people in the workspace, and team tables let several apps share one source of truth. ## Workspaces A workspace is a company. Accounts join it by verifying an email address on the company's domain: everyone who verifies an `@acme.com` address lands in the Acme workspace. Personal email domains such as gmail.com get a workspace of one. Every app belongs to the workspace of the account that first deployed it. The deploying account is the app's owner. An app deployed without an account is an [unclaimed preview](https://fuast.com/docs/reference.md#deploy-without-an-account): it has no workspace, so `ctx.workspace.members()` and team tables fail there, until a person claims it and it joins their workspace. A claimed app stays [restricted](https://fuast.com/docs/security.md#restricted-apps) until its owner trusts it: team tables and the member list stay off, because every app in a workspace shares its team tables. ### Roles | Role | Can | | --- | --- | | `admin` | Everything a member can, plus manage members and roles, workspace tokens, team tables, and settings, and inspect and manage every app in the workspace (deploys, logs, data, sharing). | | `member` | Open apps shared with the workspace, deploy their own apps, and create team tables. | The first verified account in a workspace is its admin. Admins promote others from the dashboard (Workspace, then Members). App owners and workspace admins can read an app's inspection routes, logs, and data, and change its sharing. Other members can only use the app. ## Sharing ```sh fuast share --visibility private # owner, admins, and the allow-list fuast share --visibility workspace # everyone in the workspace fuast share --visibility public # anyone with the link fuast share --visibility private --emails ana@acme.com,bo@partner.io ``` `share` targets the app in `fuast.json`, or pass a slug: `fuast share my-app --visibility workspace`. | Mode | Who can open it | Guests | | --- | --- | --- | | `private` | Owner, workspace admins, allow-list. New apps start here. | No | | `workspace` | Every workspace member, plus the allow-list. | No | | `public` | Anyone. | Yes, unless `auth.requireSignIn` | The allow-list is per app and can hold any email, inside or outside the workspace. That is how you share a workspace app with a contractor. ### Invites and access requests - `--emails` adds addresses to the allow-list and sends each one an invite email with the app link. If they have no account yet, they create one with that address and land in the app. - Someone who opens an app they cannot use sees a "Request access" page. The request appears in `fuast users list` and the dashboard Sharing tab, and the owner gets an email. ```sh fuast users list # allow-list and pending requests fuast users approve ana@acme.com # add to the allow-list (or approve a request) fuast users deny ana@acme.com # reject a pending request fuast users remove ana@acme.com # take someone off the allow-list ``` Each command takes an optional slug after the email. Sharing gates the UI and query, mutation, and action calls. It does not gate [endpoints](https://fuast.com/docs/capsule-api.md#endpoints), which stay reachable so webhooks work. ## Workspace members `ctx.workspace.members()` lists the people in the app's workspace: ```ts const members = await ctx.workspace.members(); // [{ userId, name, email, picture? }, ...] ``` Use it to pick an assignee, notify a team, or show who can act. It returns the current members every time it is called. It is available in every handler kind except `onGuestUpgrade`. The caller must be a signed-in member of the app's workspace, or a schedule. Anyone else gets an error with code `forbidden`, so guests on a public app cannot list your coworkers. ```ts queries: { assignees: query(async (ctx) => { ctx.auth.requireSignedIn(); const people = await ctx.workspace.members(); return people.map((p) => ({ userId: p.userId, name: p.name })); }) } ``` Return only what the UI needs. Do not send every coworker's email to a client that does not show it. Member ids are the same `userId` values that `ctx.auth` returns, so you can store them in `userId()` fields: ```ts tasks: table({ title: string(), assigneeId: userId() }).index("by_assignee", ["assigneeId"]) ``` A query that calls `members()` re-runs when its database reads change, not when someone joins the workspace. Reload to see new members. ### Members in `fuast dev` `fuast dev` has no workspace. There, `members()` returns every signed-in identity that has called the app locally (opened it, or made a query, mutation, or action call), sorted by name. It starts with only the dev user. Guests and `?fuast_anon=1` callers are never listed. To test an assignee picker with several people, call the app once as each of them, for example by opening `http://localhost:3000/?fuast_user=alice` and `?fuast_user=bob` in the browser, or from the CLI: ```sh fuast run assignees --as alice fuast run assignees --as bob ``` Each name becomes a member with `userId` `user_`. They stay listed until `fuast dev --reset`. ## Team tables Team tables hold rows that every app in the workspace can read and write: a shared customer list, a list of offices, an on-call rota. One app writes, others read, and admins can edit rows by hand in the dashboard (Workspace, then Team tables). ```ts const customers = ctx.team.table("customers"); const rows = await customers.all(); // every row, newest first const id = await customers.insert({ name: "Acme", tier: "gold" }); await customers.update(id, { tier: "platinum" }); // merge a patch await customers.remove(id); ``` Rows are plain JSON objects plus an `_id` string. A table is created on first insert. There is no schema and no index: validate what you read, and keep each table small (a few thousand rows at most), because `all()` returns everything. Rules: - Reads work in every handler. Writes work in mutations, actions, writable endpoints, and schedules. - Team tables are outside the app's database transaction. A mutation that writes a team row and then throws keeps the team write. Write team rows last. - Queries that read team tables are not reactive to team writes. If a UI must update live, copy the data into the app's own tables from a mutation or schedule. - Every app in the workspace sees the same rows, regardless of which app created them. Do not store secrets or one person's private data there. For data that belongs to one app, use the app's own [database](https://fuast.com/docs/database.md). Reach for team tables only when a second app needs the same rows. ## Putting it together The [approvals template](https://fuast.com/docs/examples/approvals.md) uses all of this: requests are stored per app, approvers come from `ctx.workspace.members()`, decisions send [email](https://fuast.com/docs/email.md), and a [schedule](https://fuast.com/docs/schedules.md) sends a morning digest. --- # Custom domains A hosted app lives at `https://./`. You can also serve it at a domain you own, such as `tools.acme.com`. The app behaves the same there: sign-in, live queries, endpoints, storage, and inspection all work, and nothing in the capsule changes. ```sh fuast domains add tools.acme.com # prints the DNS records to create fuast domains check tools.acme.com # check DNS now; exits 1 until the domain is live fuast domains list # status and records of every domain (alias: ls) fuast domains remove tools.acme.com # alias: rm ``` Each command targets the app in `fuast.json`, or takes a slug after the hostname: `fuast domains add tools.acme.com my-app`. Add `--json` for machine-readable output. In the dashboard, open the app and use the **Domains** tab. Only the app owner and workspace admins can add, check, or remove domains. App deploy tokens (`fuast token create --app`) cannot. ## DNS records Create two records at your DNS provider. `fuast domains add` and the Domains tab show the exact values, with copy buttons. | Type | Name | Value | Why | | --- | --- | --- | --- | | `TXT` | `_fuast.tools.acme.com` | `fuast-verify=` | Proves you control the domain. The token is unique to this domain and app. | | `CNAME` | `tools.acme.com` | the platform's target, e.g. `fuast-production.up.railway.app` | Sends the domain's traffic to fuast. | - **Apex domains** (`acme.com`) cannot have a CNAME. Use your provider's `ALIAS`, `ANAME`, or flattened CNAME record pointing at the same target. fuast accepts the domain when its addresses match the target's. - **Cloudflare:** set the record to *DNS only* (grey cloud) unless the platform operator tells you otherwise. A proxied record hides the CNAME and the check fails. - When the platform issues certificates through its host (see [HTTPS](#https)), the list includes one or two more records from that provider: usually a `TXT` record that lets it issue a certificate, and its own `CNAME` value. Create every record in the list. Keep the records in place after the domain is live. fuast re-checks live domains every few hours. ## Verification After `add`, the domain is `pending`. fuast checks DNS in the background: often at first, then less often (backing off to once an hour). `fuast domains check` or **Check now** checks immediately. DNS changes can take minutes, sometimes hours, to show up. | Status | Meaning | | --- | --- | | `pending` | Waiting for the records. The response says which record is missing or wrong. | | `active` | Both records are right. The app is served on the domain. | | `error` | The certificate provider refused the domain, or a live domain lost its records (two failed checks in a row). The `error` field says why. Fix DNS and check again. | A domain that is never verified is released after 7 days, so nobody can hold a hostname they don't control. A DNS timeout never takes a live domain down; only definite answers ("no such record") do. Rules for hostnames: - A full hostname with a dot. Internationalized names are converted to punycode. Uppercase and a trailing dot are fine. - Not an IP address, not `localhost`, not a reserved name (`.local`, `.test`, `.internal`, ...), no wildcards (`*.acme.com`). - Not the platform's own hosts, and nothing under the apps domain (for `.`, use an [alias](#aliases)). - One app per hostname. A hostname connected to another app is refused until it is removed there. - At most 5 custom domains per app (the platform can change this). ## HTTPS Custom domains are served over HTTPS. Who issues the certificate depends on the platform: - **On fuast.com and Railway-hosted platforms**, fuast registers the domain with Railway, which issues a Let's Encrypt certificate once DNS is in place, usually within an hour. The Domains tab shows the certificate status (`issuing`, `valid`, `failed`). - **Other self-hosted platforms** terminate TLS in front of fuast (for example Cloudflare for SaaS). The operator handles certificates. ## What changes for the app - **Primary domain.** The first domain that is live with a valid certificate becomes the app's primary domain. It is the app's URL in the dashboard, in `fuast deploy` output, in share emails, and in the URLs `ctx.storage.url()` returns. - **The old address keeps working.** Pages opened at `.` redirect to the primary domain. The call API, WebSocket, endpoints, storage, and inspection routes keep answering on the old address too, so webhooks and scripts don't break. - **Each hostname is its own origin.** Sign-in works on every domain through the same one-time-code handshake as the subdomain, and each domain gets its own app session cookie (`__Host-` prefixed on HTTPS). Someone signed in on `.` signs in once more on `tools.acme.com` (one click if they are signed in to fuast). Guests get a separate guest session per hostname. - **Origin checks** accept requests from the hostname the request arrived on. A page on another domain cannot call the app's API with a visitor's cookies. - `req.url` in [endpoints](https://fuast.com/docs/capsule-api.md#endpoints) uses the hostname the request arrived on. In the UI, build links with `appUrl(path)` from `fuast/client`: it returns a path on whatever origin the page was opened on. Removing a domain stops serving it at once. The app falls back to its next live domain, or to the subdomain. ## Aliases A name without a dot adds an alias under the platform's apps domain instead: `fuast domains add my-tool` serves the app at `my-tool.` too, with no DNS work. Aliases appear in `fuast domains list` next to custom domains. ## HTTP API The CLI and dashboard call these platform routes with your session or a personal token (`Authorization: Bearer `). | Method | Path | Body | Returns | | --- | --- | --- | --- | | `GET` | `/api/apps//domains` | | `{ custom: CustomDomain[], domains: Alias[], url, config }` | | `POST` | `/api/apps//domains` | `{ "hostname": "tools.acme.com" }` | `{ ok, domain: CustomDomain }` | | `POST` | `/api/apps//domains//check` | | `{ ok, domain: CustomDomain }` | | `DELETE` | `/api/apps//domains/` | | `{ ok, warning? }` | ```ts type CustomDomain = { hostname: string; url: string; status: "pending" | "active" | "error"; primary: boolean; error: string | null; records: { type: string; name: string; value: string; purpose: "ownership" | "routing" | "provider-verification" | "acme"; status: "ok" | "pending"; current: string | null; note?: string }[]; tls: { provider: "manual" | "railway"; certificate: "pending" | "issuing" | "valid" | "failed" | "external"; message: string | null }; verifiedAt: number | null; lastCheckedAt: number | null; nextCheckAt: number | null; createdAt: number; }; ``` Errors use the usual shape (`{ "error": "...", "code": "..." }`): `invalid_hostname`, `ip_address`, `wildcard`, `reserved` (400), `limit` (400), `taken` and `already_added` (409), `not_found` (404), `rate_limited` (429). ## Running your own platform Custom domains are on by default. Set these on the platform (and `FUAST_BASE_URL` on the web process, which already gets it from `start.sh`): | Variable | Effect | | --- | --- | | `FUAST_CUSTOM_DOMAIN_TARGET` | Where users point their CNAME, e.g. your Railway public domain. Comma-separate several hostnames or IPs. Unset: only the TXT record is checked. | | `FUAST_CUSTOM_DOMAIN_TLS` | `manual` (default) or `railway` (default when `RAILWAY_API_TOKEN` is set). | | `RAILWAY_API_TOKEN` | Railway account or workspace token (sent as `Authorization: Bearer`). A project token works as `RAILWAY_TOKEN` with `FUAST_CUSTOM_DOMAIN_TLS=railway` (sent as `Project-Access-Token`). | | `RAILWAY_PROJECT_ID`, `RAILWAY_ENVIRONMENT_ID`, `RAILWAY_SERVICE_ID` | The service domains are attached to. Railway injects these into running services. | | `FUAST_RAILWAY_TARGET_PORT` | Optional port Railway routes custom domains to (default: the service's). | | `FUAST_CUSTOM_DOMAIN_LIMIT` | Custom domains per app (5). | | `FUAST_CUSTOM_DOMAIN_PENDING_DAYS` | Days before a never-verified domain is released (7; `0` keeps them). | | `FUAST_WEB_HOSTS` | Extra hostnames that serve the dashboard (never treated as custom domains). | | `FUAST_CUSTOM_DOMAINS` | `off` turns the feature off. | With `railway`, fuast calls Railway's public GraphQL API (`customDomainCreate` when a domain is added, `customDomain` on each check, `customDomainDelete` on removal or when the app is deleted or terminated) and shows the records Railway asks for. Railway limits custom domains per service (2 on Hobby, 20 on Pro by default), and that limit applies to all apps on the platform together. With `manual`, put something in front of fuast that terminates TLS for any hostname that reaches it (Cloudflare for SaaS custom hostnames, Caddy on-demand TLS, ...) and forwards the original `Host`. The web front end passes every hostname that is not the dashboard's own through to the platform, which serves the app that owns the verified domain or a "Domain not connected" page. --- # Approvals example ```sh bunx @revnu/fuast new approvals --template approvals ``` Anyone in the workspace submits a request with an amount and a reason. An approver approves or rejects it with an optional note. Both sides get email, and a weekday digest reminds approvers of anything still pending. ## What it shows - `auth: { requireSignIn: true }` for a workspace tool. - Approvers from `ctx.workspace.members()`, optionally narrowed by `APPROVER_EMAILS` in env. - `submit` emails approvers. `decide` checks that the caller is an approver, is not the submitter, and that the request is still pending, then emails the submitter. Email is always the last step. - A `digest` schedule at `0 9 * * 1-5` (09:00 UTC on weekdays), plus `sendDigestNow`, which runs the same helper on demand for testing. - WebMCP tools on `submit`, `decide`, `mine`, and `queue`, so a browser agent can file and decide requests. - `submitterId` and `deciderId` declared as `userId()`, and names stored as snapshots. ## Workspace members as approvers ```ts async function approvers(ctx: TeamContext): Promise { const members = await ctx.workspace.members(); const allowed = parseEmailList(ctx.env.APPROVER_EMAILS); if (allowed.length === 0) return members; return members.filter((m) => allowed.includes(m.email.toLowerCase())); } ``` The same helper runs in queries (to show the queue), mutations (to authorize decisions), and the schedule (to address the digest). Schedules may call `ctx.workspace.members()` even though they have no caller. ## The digest ```ts schedules: { digest: schedule("0 9 * * 1-5", async (ctx) => { await sendDigest(ctx); }) } ``` `sendDigest` reads up to 100 pending requests through the `by_status` index and emails each approver once. It never calls `ctx.auth` guards, because schedules run as the system. See [schedules](https://fuast.com/docs/schedules.md). ## Configure ```txt # .env.fuast.server APPROVER_EMAILS=finance@acme.com,cto@acme.com ``` Without it, every workspace member can decide anyone else's request. For the hosted app, set it in the dashboard Env tab or deploy the file. ## Try it ```sh bunx @revnu/fuast dev ``` Submit as `dev`, approve as `?fuast_user=alice`, and read the emails with `bunx @revnu/fuast logs --port 3000`. Click "Email digest now" to test the digest. Then: ```sh bunx @revnu/fuast deploy bunx @revnu/fuast share --visibility workspace ``` --- # Guestbook example ```sh bunx @revnu/fuast new my-guestbook --template guestbook ``` A shared feed. Everyone who can open the app reads the same entries, and guests and accounts can both post. ## What it shows - An `entries` table with `body`, `authorId: userId()`, and `authorName` and `authorPicture` snapshots. - A shared `entries` query: the newest 50 by `by_creation`, with no owner filter on purpose. - A `sign` mutation that cleans and bounds input and takes authorship from `ctx.auth`, never from the client. - A `removeEntry` mutation that lets authors delete only their own entries. - Live updates: a post in one tab appears in every other tab. ## Server pattern Shared reads are a query without an owner filter: ```ts entries: query(async (ctx) => ctx.db.entries.withIndex("by_creation").order("desc").take(50)) ``` Writes stay server-authoritative: ```ts const author = ctx.auth.requireIdentity(); await ctx.db.entries.insert({ body, authorId: author.userId, authorName: author.displayName, ...(author.picture ? { authorPicture: author.picture } : {}) }); ``` Never accept `authorId`, `authorName`, or `authorPicture` as arguments. Sharing comes from the query, not from a fake shared user. When a guest signs in, `authorId` moves to their account, so they can still remove their entries. `authorName` and `authorPicture` are snapshots from the moment of posting and do not change. The entries tool description warns agents that entries were written by other people. Text in a shared feed can try to steer an agent that reads it. ## Variations - Accounts only: call `ctx.auth.requireSignedIn()` in `sign`. - No guests anywhere: set `auth: { requireSignIn: true }`. - Longer history: switch `entries` to `paginate()` and the client to `client.usePaginatedQuery("entries", {}, { initialNumItems: 20 })`. See [pagination](https://fuast.com/docs/database.md#pagination). ## Try it ```sh bunx @revnu/fuast dev ``` Post from `?fuast_user=alice` and `?fuast_user=bob` in two tabs, and from `?fuast_guest=1`. Deploy and share publicly with `bunx @revnu/fuast share --visibility public`. --- # Live board example ```sh bunx @revnu/fuast new my-board --template live-demo ``` A shared checklist of sticky notes. Every window on the same board sees adds, checks, and deletes as they happen, plus "pane 2 is typing…". It is the app embedded twice, side by side, on the fuast.com homepage. ## What it shows - One `board` query per window. It returns the room's notes and who is typing, so a single subscription keeps the whole screen live. - Optimistic updates on every mutation, so your own window changes before the server answers. - A typing indicator made of ordinary rows: one write when typing starts, one every 4 seconds while it continues, none when the note is sent. - Data scoped by a `room` id from the URL instead of by user, because guests on a public link have no lasting identity. - Limits for a public app: 140-character plain text, 30 notes per room, a per-visitor write budget, and a `sweep` schedule that deletes everything older than an hour. - An `embed=1` mode that hides the page chrome for use in an iframe. ## URL parameters | Parameter | Meaning | | --- | --- | | `room` | The board: 8 to 64 letters, digits, `-`, or `_`. Without it the app starts a new board with a random id. | | `who` | `1` or `2`. Which pane this window is. It picks the note colour and the typing label, nothing else. | | `embed=1` | Fill the frame and hide the title and footer. | ## Server pattern The room id is the only key to a board. Every handler validates it and reads through the room's index, and an id from the client only counts inside that room: ```ts board: query(async (ctx, room: string) => { const key = requireRoom(room); // 8-64 url-safe characters, or it throws const items = await ctx.db.items.withIndex("by_room", (q) => q.eq("room", key)).take(MAX_ITEMS + 5); const typing = await ctx.db.typing.withIndex("by_room", (q) => q.eq("room", key)).take(4); return { items: items.map(/* id, text, done, who, createdAt */), typing: typing.map(/* who, at */) }; }) ``` ```ts const item = await ctx.db.items.get(id); if (!item || item.room !== key) throw new Error("That note is gone."); ``` `add` inserts, then trims the room to the newest 30 in the same transaction, so a room can never hold more: ```ts const all = await ctx.db.items.withIndex("by_room", (q) => q.eq("room", key)).take(MAX_ITEMS + 10); for (const old of all.slice(0, Math.max(0, all.length - MAX_ITEMS))) await ctx.db.items.delete(old.id); ``` fuast has no built-in per-user rate limit for handlers, so the write budget is a row per visitor, checked first in every mutation: ```ts const row = await ctx.db.writers.withIndex("by_user", (q) => q.eq("user", caller)).first(); // new minute: reset the count. Over 60 this minute: throw. Otherwise count + 1. ``` It stops a runaway client, not a determined one: a guest who drops their cookie gets a new identity and a new budget. The room cap, the hourly sweep, and the app's [daily quotas](https://fuast.com/docs/limits.md#app-limits) are what bound abuse. The cleanup is a [schedule](https://fuast.com/docs/schedules.md) that does one bounded batch per run: ```ts sweep: schedule("*/5 * * * *", async (ctx) => { const old = await ctx.db.items.withIndex("by_creation", (q) => q.lt("createdAt", Date.now() - ROOM_TTL_MS)).take(500); for (const row of old) await ctx.db.items.delete(row.id); }) ``` ## Client pattern Each mutation predicts its result on the cached `board` query. See [optimistic updates](https://fuast.com/docs/capsule-api.md#optimistic-updates). ```tsx const setDone = client.useMutation("setDone").withOptimisticUpdate((store, room: string, id: string, done: boolean) => { store.setQuery("board", [room], (current) => current && { ...current, items: current.items.map((item) => (item.id === id ? { ...item, done } : item)) }); }); ``` New notes get a temporary id (`"optimistic:…"`), and their buttons stay disabled until the real row arrives. The typing label is timed from when the other pane's ping arrives, not from the ping's timestamp, so the two clocks never need to agree. It hides 7 seconds after the last ping, which also covers a tab that closed while typing. ## What it costs Every write to a table re-runs the live queries that read that table, for every open board, and each re-run counts toward the app's daily requests. That is why the board has one query, typing sends a ping every few seconds instead of every keystroke, and nothing is written until a visitor does something. ## Try it ```sh bunx @revnu/fuast dev ``` Open `http://localhost:3000/`, then use "Open a second window to see it sync". Check the limits from the terminal: ```sh bunx @revnu/fuast run add '"my-board-01"' 1 '"hello"' --port 3000 bunx @revnu/fuast run board '"my-board-01"' --port 3000 bunx @revnu/fuast run schedule sweep --port 3000 ``` Deploy and make it public with `bunx @revnu/fuast share --visibility public`. A public app can be framed by the fuast site it is hosted on and by no other site. See [embedding](https://fuast.com/docs/security.md#embedding). --- # Signature example ```sh bunx @revnu/fuast new nda --template signature ``` Share an agreement or PDF with your team and collect signatures. Each signature is tied to the signer's verified fuast account. ## What it shows - `auth: { requireSignIn: true }`: no guests, because a signature needs a real identity. - `documents` and `signatures` tables linked by `docId: id("documents")`, with a `by_doc_signer` index that enforces one signature per person. - Attachments and signature images uploaded with `client.storage.upload`. Rows store only the storage key, and the `document` query turns keys into URLs with `ctx.storage.url()`. - Client-sent keys checked with `ctx.storage.stat()` before a row points at them: the object must exist and the caller must have uploaded it, so nobody can attach (and later delete) someone else's file. - A `signatureCount` field updated in the same transaction as each signature, so the list never counts rows. - An email to the author for every new signature, sent as the last step of the mutation. - `deleteDocument` removes rows first and storage objects last, because storage is outside the transaction. - Client routes `/` and `/docs/:id`, and a dependency-free canvas signature pad. ## Upload, then write ```tsx const uploaded = await client.storage.upload(png); // private by default await sign(docId, uploaded.key); ``` ```ts async function uploadedBy(ctx: { storage: StorageContext }, key: string, userId: string) { if (!isStorageKey(key)) return false; const info = await ctx.storage.stat(key); // null when the object does not exist return info !== null && info.uploadedBy === userId; } sign: mutation(async (ctx, docId: string, imageKey: string) => { const me = ctx.auth.requireSignedIn(); if (!(await uploadedBy(ctx, imageKey, me.userId))) throw new Error("Draw your signature again."); const doc = await ctx.db.documents.get(docId); if (!doc) throw new Error("This document no longer exists."); const existing = await ctx.db.signatures .withIndex("by_doc_signer", (q) => q.eq("docId", docId).eq("signerId", me.userId)) .first(); if (existing) throw new Error("You already signed this document."); await ctx.db.signatures.insert({ docId, signerId: me.userId, name: me.displayName, imageKey }); await ctx.db.documents.update(docId, { signatureCount: doc.signatureCount + 1 }); // email the author last }) ``` The signer's name and email come from `ctx.auth`, so nobody can sign as someone else. `createDocument` checks the attachment key the same way. ## Try it ```sh bunx @revnu/fuast dev ``` Create a document as `dev`, then open it as `?fuast_user=alice` and sign. The author's email appears in `bunx @revnu/fuast logs --port 3000`. Deploy and share with your workspace: ```sh bunx @revnu/fuast deploy bunx @revnu/fuast share --visibility workspace ``` Add an outside signer with `--emails them@partner.com`. --- # Todo example ```sh bunx @revnu/fuast new my-todos --template todo ``` A personal todo list. Each person, guest or account, sees only their own todos. On a public app, a guest's todos move to their account when they sign in. ## What it shows - A `todos` table with `text`, `done: boolean().default(false)`, and `ownerId: userId()`, indexed `by_owner` and `by_owner_done`. - A `todos` query filtered by the caller from `ctx.auth.requireIdentity()`. - `addTodo`, `setTodoDone`, `removeTodo`, and `clearDone` mutations that check ownership before every change. - WebMCP `tool` options on every handler, so a browser agent can list, add, complete, and clear todos. Handlers with tool `args` take one object, and the client calls them with one object. - A `GET /api/status` endpoint that counts the caller's open todos with `count()`. - Two client routes: `/` for the list and `/status`, which calls the endpoint with `X-Fuast-Token`. - `shared/todo.ts`, a text cleanup helper both sides use. ## Server pattern ```ts todos: query( async (ctx) => { const { userId } = ctx.auth.requireIdentity(); return ctx.db.todos.withIndex("by_owner", (q) => q.eq("ownerId", userId)).order("desc").collect(); }, { tool: { description: "List the current user's todos, newest first." } } ) ``` ```ts setTodoDone: mutation( async (ctx, args) => { const { userId } = ctx.auth.requireIdentity(); const todo = await ctx.db.todos.get(args.todoId); if (!todo || todo.ownerId !== userId) throw new Error("Todo not found."); await ctx.db.todos.update(args.todoId, { done: args.done }); }, { tool: { description: "Mark a todo done or not done.", args: { todoId: id("todos"), done: boolean() } } } ) ``` Use this shape whenever rows belong to one person. The client never receives rows it does not own, and every write re-checks the owner. ## Client pattern ```tsx const todos = client.useQuery("todos"); {todos === undefined ? null : todos.length === 0 ? ( ) : (
...
)} ``` The add form keeps the draft if the write fails and clears it on success only if it still holds the submitted text. ## Try it ```sh bunx @revnu/fuast dev ``` Open `?fuast_user=alice` and `?fuast_user=bob` in two tabs: their lists are separate. Open `?fuast_guest=1` to try a guest. Then: ```sh bunx @revnu/fuast db dump --port 3000 curl http://localhost:3000/api/status ``` Set `auth: { requireSignIn: true }` to require an account. Use `ctx.auth.requireSignedIn()` instead to require one for a single operation. --- # Reference The quick contract for the fuast CLI and HTTP surface. For the code-level API see [capsule API](https://fuast.com/docs/capsule-api.md) and [database](https://fuast.com/docs/database.md). ## CLI Run `bunx @revnu/fuast `, or `fuast ` when it is installed. Every command that prints data accepts `--json`. `fuast --help` (or `fuast help `) prints one command's usage and options. ### Targeting an app Inspection commands (`inspect`, `run`, `db`, `logs`, `storage`) pick their target like this: 1. `--port `: the `fuast dev` server on that port, even if a slug is also given. 2. A slug or app URL argument: the deployed app. 3. Neither: `fuast dev` on port 3000 (or `FUAST_PORT_DEV`). With a slug, the CLI talks only to the platform (`FUAST_API`), which forwards to the app, so your token never goes to the app's own origin. With an app URL, pass `--inspect-token`. They do not read `fuast.json`. To inspect a deployed app, pass its slug (the `app` value in `fuast.json`) or its URL. An [unclaimed preview](#deploy-without-an-account) is inspected the same way, by the slug `deploy` printed, on the machine that deployed it. The CLI finds the preview's edit token in `~/.fuast/previews.json` and sends it instead of an account token. `inspect`, `logs`, `db list|dump|export`, `jobs list`, `storage list`, and `run` work. Commands that change an app outside its handlers (`db import`, `storage put|delete`, `jobs cancel`, `run schedule`, `share`, `domains`) need an owner: claim the app first. Platform commands (`share`, `users`, `domains`, `deploy archive|restore|terminate`) take a slug argument or fall back to `fuast.json` in the current directory. `deploy` reads `fuast.json` from the capsule directory. ### Create ```txt fuast new [--template todo|guestbook|signature|approvals|live-demo] [--no-git] fuast create ... alias of new fuast init [--template ...] [--no-git] scaffold into the current directory ``` `new` writes the template, `fuast-env.d.ts`, `AGENTS.md`, `CLAUDE.md`, `.gitignore`, and `tsconfig.json`, then creates a git repository with a first commit unless you are inside one or pass `--no-git`. `init` refuses when the directory has files other than hidden files, `README*`, `LICENSE*`, `AGENTS.md`, and `CLAUDE.md`, and appends fuast's lines to existing `AGENTS.md`, `CLAUDE.md`, and `.gitignore`. ### Develop ```txt fuast dev [dir] [--port 3000] [--host 127.0.0.1] [--reset] [--no-typecheck] [--allow-host ]... fuast run-many [dir] [--count 20] [--base-port 4000] fuast check [dir] [--no-typecheck] [--json] fuast build [dir] [--out artifact.json] [--no-typecheck] [--json] fuast types [dir] fuast templates ``` - `dev` runs the app locally with hot reload for server and client, the same WebSocket protocol, database engine, limits, and inspection routes as hosted apps. Data and files persist in `.fuast/dev`. `--reset` wipes them. `--host 0.0.0.0` exposes the server, including its open inspection routes, to your network. `--allow-host ` accepts another `Host` header (for a tunnel or a LAN name); repeat it per name. `--no-typecheck` turns off the type check after each save. - `run-many` starts `--count` independent dev servers of the capsule on consecutive ports from `--base-port`, each with its own data, and prints their URLs. Ctrl-C stops them all. - `check` builds the capsule, runs the TypeScript checker, and verifies the contract (imports, `capsule()` keys, schema, cron expressions, favicon). Exit code 1 on any error. Run it before every deploy. - `check --no-typecheck` skips only the type check. `check --json` prints `{ ok: true, manifest, ... }` or `{ ok: false, error, code, diagnostics }`. Each diagnostic has `file`, `line`, `column`, `code`, `category`, `message`, and a source `frame` when the file is known. - `build` builds without deploying and prints bundle sizes. `--out` writes one JSON artifact with the manifest, bundles, CSS, and favicon. - `types` rewrites `fuast-env.d.ts` (and `tsconfig.json` if missing) from the installed CLI. Run it after upgrading. - `templates` lists the starter templates. Local identities: `?fuast_user=` and `?fuast_guest=1` in the URL, or `fuast auth as `. See [auth](https://fuast.com/docs/auth.md#local-identities). ### Deploy ```txt fuast deploy [dir] [--name "My App"] [--delete-table TABLE=COUNT]... [--no-typecheck] [--no-unclaimed] [--claim | --new] [--json] fuast claim [dir] fuast status [dir] fuast deploy list fuast deploy rollback fuast deploy archive [slug|deployId] fuast deploy restore [slug|deployId] fuast deploy terminate [slug|deployId] --yes ``` - `deploy` uploads the capsule source and `.env.fuast.server`. The platform type checks it (errors block the deploy unless `--no-typecheck`), builds it, describes it in a sandbox, applies schema changes, and makes it live. - Logged in (`fuast login`, or `FUAST_TOKEN` in CI), it deploys to your account and prints the app URL. The first deploy creates the app and writes `fuast.json` (`{"app": ""}`). Commit it. - Not logged in, it deploys an unclaimed preview and prints a preview link, a claim link, and when the preview is deleted. See [deploy without an account](#deploy-without-an-account). - Before uploading, `deploy` runs the same build, contract check, and type check as `fuast check` locally, and stops on the first error. - `--name` sets the app's display name on first deploy. Without it, the CLI uses `capsule({ name })`, then the directory name. - `deploy list` prints each app you own with its status, live deploy id, and URL. - `--delete-table TABLE=COUNT` confirms dropping a populated table removed from the schema. Repeat it per table. See [schema changes](https://fuast.com/docs/database.md#schema-changes). - A rejected destructive change returns code `DESTRUCTIVE_SCHEMA_CHANGE` (HTTP 409) with `tables: [{ table, rowCount }]`. - Every deploy is an immutable record with its source snapshot. `rollback` makes an earlier deploy live again with the current data. It fails if that deploy's schema cannot accept the current rows. - `archive` stops serving the app until `restore`. `terminate` stops it permanently. Both keep its data, logs, and records. ### Deploy without an account `fuast deploy` works before anyone has a fuast account. It puts the app online as an **unclaimed preview** and prints two links: - The **preview link** (`https://preview-./?fuast_preview=…`) shows the app, after a fuast page that says what a preview is. Anyone who has the link can look. It cannot claim the app. - The **claim link** (`https://fuast.com/claim/…`) makes the app someone's. They open it, sign in with a verified account, and choose Claim. It works once. What the CLI does: - It saves `{ api, slug, editToken, expiresAt, url, claimUrl }` in `~/.fuast/previews.json` (mode 600), under the real path of the project folder. The edit token lets this machine redeploy and inspect the preview. The CLI never prints it and sends it only to the platform that issued it. Nothing is written to the project folder: `fuast.json` is for apps that have an owner. - It never reads a credential from the project folder. A repository can ship any file, so a `.fuast/unclaimed.json` in the working tree is ignored, with the warning `ignoring .fuast/unclaimed.json in this folder`. A folder this machine has no record for gets a new preview. Copying or moving the folder does the same. - `fuast deploy` again redeploys the same preview. The links and the expiry stay the same. - `fuast claim` prints the claim link, the preview link, and the expiry again. `fuast status` prints the platform, the account, and what the folder is: not deployed, an unclaimed preview, a preview someone claimed, or an app linked in `fuast.json`. `fuast whoami` and `fuast auth status` mention the preview too. - After the claim, a deploy without a login stops with `This app was claimed by and now deploys with that account. Run \`fuast login\`, then \`fuast deploy\` again`. Once you are logged in as an account that may deploy the claimed app, `deploy` updates it, writes `fuast.json`, and forgets the preview record. - A logged-in `deploy` in a folder whose preview is still unclaimed does not claim it. It stops with code `preview_unclaimed` (`--json`: `{ ok: false, code, slug, claimUrl, choices }`) and asks you to choose: `fuast deploy --claim` claims this machine's preview for the logged-in account, keeps its data, and deploys to it; `fuast deploy --new` leaves the preview alone and deploys the folder as a new app. - When the preview has expired, `deploy` says so and creates a new one. - `--no-unclaimed`, or `FUAST_UNCLAIMED=0` in the environment, turns all of this off: without a login, deploy stops with `Not logged in to . Run \`fuast login\`, or set FUAST_TOKEN for CI.` - A folder with `fuast.json` is linked to an owned app and always needs a login. What a preview is: | | Unclaimed preview | Claimed, restricted | Trusted | | --- | --- | --- | --- | | Address | Random `preview-…` slug | The same, or one the claimer picks | The same | | Who can open it | Anyone with the preview link, after the fuast page | Private to the owner until shared | By sharing mode | | Identity | Guests only, no sign-in | Accounts and guests, by sharing mode | The same | | `ctx.email`, `ctx.team`, `ctx.workspace` | Fail with code `unclaimed` | Fail with code `restricted` | Work | | Schedules and jobs | Registered, not run | Registered, not run | Run | | Limits | [Preview limits](https://fuast.com/docs/limits.md#unclaimed-previews) | Normal limits | Normal limits | | Lifetime | 72 hours from the first deploy | Until deleted | Until deleted | Claiming keeps the app's rows and files and cancels every job that was queued while it was a preview. The preview link, the claim link, and the edit token stop working. A claimed app is **restricted** until its owner or a workspace admin trusts it with the workspace: the box under Options on the claim page, "Trust this app with my workspace" in the dashboard, or `fuast trust`. A deploy never does that. `deploy` to a restricted app works, prints that workspace features are off and the `fuast trust ` command that turns them on, and `--json` carries `restricted: true`, `restrictedNotice`, and `trust: { command, dashboard }`. See [share](#share) for the commands and [security](https://fuast.com/docs/security.md#restricted-apps) for the reasons behind each rule. `preview-…` addresses are reserved for unclaimed previews. An account cannot create an app or an alias with such a name. ### Share ```txt fuast share [slug] --visibility private|workspace|public [--emails a@x.com,b@y.com] fuast users list [slug] fuast users approve [slug] fuast users deny [slug] fuast users remove [slug] fuast trust [slug] fuast untrust [slug] fuast list ``` See [teams](https://fuast.com/docs/teams.md#sharing). `list` prints every app you can see with its slug, URL, owner, and sharing mode. - `trust` grants an app your workspace: it may send email, list the workspace's members, read and write team tables (every app in the workspace shares them), and run its schedules and delayed jobs. An app claimed from an [unclaimed preview](#deploy-without-an-account) starts without that, and deploying to it never changes it: `trust`, the dashboard button, or the box on the claim page does. It needs the login of the app's owner or a workspace admin; deploy tokens (`--app`) and preview edit tokens cannot do it. Trust an app when you know who wrote its code. - `untrust` takes it away again, from any app. The app keeps working with its own data; email, team tables, and the member list are refused with code `restricted`, and its schedules and jobs stop until it is trusted. - `fuast inspect` and `fuast status` say when an app is restricted. ### Account and tokens ```txt fuast login [--token ] [--email --password ] fuast logout fuast whoami fuast auth status fuast auth as local dev identity fuast auth reset fuast token create --name [--app ] fuast token list fuast token revoke ``` - `login` prints a short code and opens a browser approval page. A person signs in, checks the code, and approves. The CLI polls for up to 10 minutes, then saves the session in `~/.fuast/config.json`. Set `BROWSER=none` to print the URL without opening a browser. `--token` checks an existing token with the platform and saves it; a rejected token is not saved. `--email` with `--password` (or `FUAST_PASSWORD`) logs in without a browser, for test platforms. - `auth status` prints the platform, the logged-in account, and the local dev identity. `whoami` prints the account and exits 1 when there is none. Both say so when the current folder has an unclaimed preview. - `token create` prints a token once. Without `--app` it is a personal token that can do anything you can. With `--app` it can only deploy and inspect that app. Use it as `FUAST_TOKEN` in CI. ### Inspect and operate ```txt fuast inspect [slug|url] [--port 3000] [--usage] [--query ] [--inspect-token ] [--json] fuast run [arg...] [slug|url] [--port 3000] [--as | --guest | --anon] [--json] fuast run schedule [slug|url] [--port 3000] [--json] fuast jobs list [slug] [--port 3000] [--status ] [--limit 100] fuast jobs cancel [slug] [--port 3000] fuast db list [slug] [--port 3000] fuast db dump [slug] [--port 3000] [--table ] fuast db export [slug] [--port 3000] [--out backup.json] fuast db import [slug] [--port 3000] fuast logs [slug] [--port 3000] [--follow] [--limit 100] fuast storage list [slug] [--port 3000] [--prefix

] [--limit ] fuast storage get [slug] [--port 3000] [--out ] fuast storage put [slug] [--port 3000] [--public] [--content-type ] fuast storage delete [slug] [--port 3000] fuast domains add [slug] fuast domains list [slug] fuast domains check [slug] fuast domains remove [slug] ``` - `run` calls one query, mutation, or action and prints the result as JSON. Each argument is one JSON value (`'"milk"'`, `3`, `'{"text":"milk"}'`); a last argument that is not JSON is the slug or URL. The kind comes from the app's manifest. Locally it runs as your dev identity (`fuast auth as`), or as `--as `, `--guest`, or `--anon` for one call. Against a slug it runs as your logged-in account with the same sharing rules and quotas as the browser. `--json` prints `{ ok, kind, name, result, ms, logs, metrics }` with the call's log entries and read metrics. A handler error exits with code 1. See [test and debug](https://fuast.com/docs/testing.md#call-handlers-headlessly). - `run schedule ` runs a cron schedule now as the system identity; its next run time does not change. Hosted, it needs the app owner or a workspace admin. - `jobs list` prints durable jobs (`ctx.scheduler`): pending and running ones by due time, then recent finished ones, with attempts and errors. `--status pending|running|done|failed|canceled` filters. `jobs cancel ` cancels a pending job and exits 1 when it is no longer pending. See [jobs](https://fuast.com/docs/jobs.md). - `inspect --query ` prints the database read metrics recorded for that query, grouped by run: totals per run (rows read, rows returned, bytes, index scans) and each run's database calls with their index and full-scan flag. - `inspect` prints the app name, URL, deploy id, sharing, handlers by kind, schema with indexes and row counts, endpoints, schedules with next run times, tools, and database read metrics. `--usage` adds daily counters and stored totals. - `db list` prints table names and row counts. `db dump` prints rows, bounded by the 1000-row return limit. `db export` walks every table in pages and writes a complete JSON backup atomically. `db import` loads a backup into empty tables (owner only). - `logs` prints the newest 100 entries (`--limit` changes it). `--follow` keeps polling and prints new entries as they arrive. - Inspection commands send your fuast token only to the platform API origin. For an app served from its own subdomain, pass `--inspect-token ` (or set `FUAST_INSPECT_TOKEN`) with a token from `fuast token create --name inspect --app `. - `domains add app.example.com` connects a custom domain. It prints the two DNS records to create (a `TXT` record at `_fuast.app.example.com` and a `CNAME` to the platform), and fuast checks them in the background until the domain is live. `domains check ` checks now and exits 1 until the domain is active. `ls` and `rm` are aliases of `list` and `remove`. See [custom domains](https://fuast.com/docs/domains.md). - `domains add my-tool` (a name without a dot) adds `my-tool.` as an extra hostname for the app. ## Inspection routes `fuast dev` and hosted apps serve the same `GET` routes with the same JSON. The CLI reads them, and you can `curl` them directly. | Route | Returns | | --- | --- | | `/__fuast/manifest` | Name, deploy id, URL, `runtimeVersion`, `artifactHash`, `inspectPolicy`, domains, handlers by kind, schema, index state (`database.indexes`, `failedIndexes`, `fullScanWarning`), endpoints, schedules, tools, auth flags, favicon, limits, read metrics. Add `?query=` for `queryExecution` (what `inspect --query` prints). | | `/__fuast/usage` | `limits`, `state` (`stateRows`, `stateBytes`), `usage` (today's `requests`, `mutations`, `email`). | | `/__fuast/url` | `{ basePath, url }`. | | `/__fuast/db/tables` | Table names, row counts, index state. | | `/__fuast/db` | Bounded row dump per table, with `truncated`. | | `/__fuast/db/export` | Backup metadata. Add `?table=&schemaHash=&cursor=` for pages. | | `/__fuast/logs` | Recent log entries `{ at, level, message, data, source, handler }`. | | `/__fuast/storage` | Stored objects with size, type, visibility, and uploader. | | `/__fuast/tools` | WebMCP tools with kind, description, and argument schema. | | `/__fuast/jobs` | Durable jobs `{ jobs, counts, nextRunAt }`. `?status=` and `?limit=` filter. `POST /__fuast/jobs//cancel` cancels a pending job and `POST /__fuast/schedules//run` runs a schedule now (owner or workspace admin). | Locally they are open. `fuast dev` binds to `127.0.0.1` by default. Hosted, they require the app owner or a workspace admin, by app session cookie or `Authorization: Bearer `. `/__fuast/manifest` has `restricted: true` for a [restricted app](https://fuast.com/docs/security.md#restricted-apps). An unclaimed preview has no owner. Its `GET` inspection routes answer the preview's edit token in the `x-fuast-edit-token` header, on the app's own origin and at `GET /api/deploy/unclaimed/inspect/` (what the CLI uses). The token is never accepted in a URL, and the `POST` routes are not available until the app is claimed. One more route is for the app's own client, not for inspection: hosted `GET /__fuast/auth/token` returns `{ token, expiresAt }`, a fresh identity token for the caller's same-origin app session (401 without a session, 403 without access). `fuast/client` calls it to refresh `getIdentity().token`. See [endpoint credentials](https://fuast.com/docs/auth.md#endpoint-credentials). ## HTTP call API ```http POST /api/call Content-Type: application/json Authorization: Bearer (or the app session cookie) {"kind": "query" | "mutation" | "action", "name": "todos", "args": []} ``` - `args` is the array of positional arguments after `ctx`. For a handler with tool `args`, send one object: `"args": [{"text": "milk"}]`. - Success: HTTP 200 with the handler's JSON result. - Handler error: HTTP 500 with `{ "error": "", "code"?: "" }`. - Not signed in: 401 `{ "error": "...", "code": "sign_in_required" }`. No access: 403 `{ "code": "forbidden" }`. Quota: 429, see [limits](https://fuast.com/docs/limits.md#reading-a-quota-error). `GET /api/me` returns the identity the app sees for the caller. The same sharing rules and quotas apply as in the browser. A token with `--app` scope only works for its app. ## Realtime protocol Clients talk to `/__fuast/ws` with JSON messages that have an `op`. `fuast/client` handles this. It is documented for debugging, not as an app API. | Direction | `op` | Fields | | --- | --- | --- | | client | `query.subscribe` | `id`, `name`, `args`, `patches?` (`true` to receive `query.patch`) | | client | `query.unsubscribe` | `id` | | client | `mutation.run`, `action.run` | `id`, `name`, `args`. At most 4 run at once per connection; later ones queue in order. | | client | `client.timings` | Client-side timing samples. No reply. | | client | `ping` | | | server | `hello` | `session`, `deployId`, `features` | | server | `query.result` | `id`, `data`, `version?` | | server | `query.patch` | `id`, `base`, `version`, `operations`: JSON patch against version `base`. | | server | `query.error` | `id`, `error`, `code?` | | server | `mutation.result`, `action.result` | `id`, `ok`, `data?`, `error?`, `code?` | | server | `refresh` | `deployId`. A new deploy is live. | | server | `error` | `error`, `code`, `quota?`. Malformed messages get code `invalid_request`. | The server closes the socket with code `4401` when the session ends (sign-out) and `4403` when the caller loses access to the app. `fuast dev` and hosted apps speak the same protocol. ### Operate a platform For the person who runs a fuast platform. These commands use the platform's operator API, which exists only when the platform sets `FUAST_OPERATOR_TOKEN`. ```txt fuast operator overview fuast operator apps [--workspace ] [--search ] [--unclaimed] [--limit 50] [--cursor ] fuast operator reports [--limit 100] fuast operator release-preview fuast operator users [--search ] [--limit 100] [--cursor ] fuast operator workspaces [--search ] [--limit 100] [--cursor ] fuast operator delete-app [--yes] fuast operator verify-user fuast operator audit [--limit 100] [--cursor ] fuast operator agents [--limit 100] fuast operator disconnect-agent fuast operator agent-audit [--limit 100] [--cursor ] ``` - The CLI reads the operator token from the `FUAST_OPERATOR_TOKEN` environment variable. There is no flag for it and it is never saved to `~/.fuast/config.json`. It is sent only to the platform origin (`FUAST_API` or the saved login), over https unless the platform is on this machine. `fuast help` lists these commands only when the variable is set. - `overview` prints counts of users, workspaces, apps, and deploys, plus stored file bytes and the size of all app databases on disk. - `apps` lists every app on the platform, newest first, with its owner, workspace, sharing, status, row and byte usage, last deploy, and custom domains. `--workspace` takes a workspace id or an email domain. `--search` matches the slug, name, or owner email. When there is more, the output ends with the `--cursor` value for the next page. - `apps --unclaimed` lists only previews deployed without an account, with their status (`unclaimed`, `suspended`, `expired`), expiry, and report count. `delete-app` takes a preview down like any app. - `reports` lists the previews people reported, with each report's address and reason. A preview that enough different addresses report is suspended: it stops serving until you run `release-preview ` (reports are kept) or `delete-app `, or until it expires. - `users` and `workspaces` list accounts (email, verified, workspace, role, app count) and workspaces (owner, members, apps, storage). - `delete-app` deletes an app with its database, stored files, deploys, schedules, and custom domains. It is the same deletion the app's owner can do, and there is no undo. It shows what will be deleted and asks you to type the slug. `--yes` skips the question. - `verify-user` marks an account's email as verified, for someone whose verification email never arrived. It does what the emailed link does, so the account joins its company workspace under the usual rules. Only do this for an address you know the person controls. - `audit` prints what was done with the operator token: action, target, time, address, and result. - `agents` lists the agents people connected through the [MCP server](https://fuast.com/docs/mcp.md) across every account. `disconnect-agent` ends one connection, and `agent-audit` prints what agents did through MCP. - The operator API cannot sign in as a user, create tokens for a user, or read app data, env values, or secrets. See [security](https://fuast.com/docs/security.md#platform-operators). ## Environment variables ### App env `.env.fuast.server` holds `KEY=value` lines for `ctx.env`. Lines starting with `#` are comments. `fuast dev` reads it on every reload, and `fuast deploy` uploads it, replacing the previous deploy's file values. Values set in the dashboard Env tab override file values and survive deploys. Use the dashboard for production secrets you do not want in a file. Never commit the file. Env values never reach client code. ### CLI env | Name | Effect | | --- | --- | | `FUAST_TOKEN` | Token from `fuast token create`. Used instead of the saved login, for CI. | | `FUAST_API` | Platform origin. Defaults to `https://fuast.com`. | | `FUAST_UNCLAIMED` | `0` makes `fuast deploy` need a login, like `--no-unclaimed`. By default a deploy without a login creates an [unclaimed preview](#deploy-without-an-account). | | `FUAST_OPERATOR_TOKEN` | Operator token for `fuast operator` commands, for people running their own platform. Read from the environment only. | ```sh FUAST_TOKEN= bunx @revnu/fuast deploy ``` ### Platform environment For people running their own fuast platform: | Name | Effect | | --- | --- | | `FUAST_BASE_URL` | Public origin of the platform. | | `FUAST_APPS_DOMAIN` | Serve each app at `.`. Required in production. Set to `off` for `/a//` path mode. | | `FUAST_ALLOW_SAME_ORIGIN_APPS` | `1` lets a production platform run in path mode, where apps share the platform origin. Only for platforms where every app is trusted. See [security](https://fuast.com/docs/security.md#app-origins-and-cookies). | | `FUAST_TRUST_PROXY` | `1` to read client IPs from proxy headers for rate limits. | | `FUAST_TRUSTED_PROXY_SECRET` | Secret the web front end sends when it forwards app-subdomain requests. Unset: forwarded app headers are honoured only from loopback. | | `FUAST_RUNTIME_SECRET` | Shared secret between platform and runtime (formerly `FUAST_EXECUTOR_SECRET`). Required in production: the platform and runtime refuse to start with the default. | | `FUAST_PERSONAL_DOMAINS` | Extra comma-separated email domains treated as personal (a workspace of one). | | `FUAST_OPERATOR_TOKEN` | Turns on the operator API (`/api/operator/*`, used by `fuast operator`). At least 32 random characters, for example `openssl rand -hex 32`. Unset: the routes do not exist. A production platform refuses to start with a shorter value. See [operate a platform](#operate-a-platform). | | `FUAST_UNCLAIMED_DEPLOYS` | `off` refuses deploys without an account (`403`, with a message that says to run `fuast login`). Previews that already exist keep working until they are claimed or expire. On by default, and only on platforms with `FUAST_APPS_DOMAIN`. The caps and quotas of previews are `FUAST_UNCLAIMED_*` variables, listed in [limits](https://fuast.com/docs/limits.md#unclaimed-previews). | | `FUAST_CUSTOM_DOMAIN_TARGET` | Hostname (or comma-separated hostnames/IPs) users point custom domains at, e.g. your Railway public domain. See [custom domains](https://fuast.com/docs/domains.md#running-your-own-platform). | | `FUAST_CUSTOM_DOMAIN_TLS` | `manual` (default) or `railway` (default when `RAILWAY_API_TOKEN` is set): who issues certificates for custom domains. | | `FUAST_MCP` | `off` turns the [MCP server](https://fuast.com/docs/mcp.md) off: `/mcp`, `/oauth/*`, and the OAuth metadata answer 404, and tokens issued to connected agents stop working. On by default. | | `FUAST_MCP_ALLOWED_ORIGINS` | Comma-separated web origins that may call `/mcp` from a browser page, with a bearer token. Empty by default: other websites are refused. See [security](https://fuast.com/docs/security.md#mcp-and-oauth). | | `FUAST_MCP_ANON_PER_MINUTE`, `FUAST_MCP_USER_PER_MINUTE` | MCP requests per minute without an account, per client address (60), and with one, per account (600). | | `FUAST_MCP_ACCESS_TTL_SECONDS`, `FUAST_MCP_REFRESH_TTL_DAYS` | Lifetime of the access tokens (3600 seconds) and refresh tokens (30 days) of connected agents. | | `FUAST_CUSTOM_DOMAINS` | `off` turns custom domains off. Also `FUAST_CUSTOM_DOMAIN_LIMIT` (per app, 5), `FUAST_CUSTOM_DOMAIN_PENDING_DAYS` (7), `FUAST_WEB_HOSTS` (extra dashboard hostnames). | Limit defaults can be overridden by environment variables on the platform and runtime. Each variable, and the process that reads it, is listed in [limits](https://fuast.com/docs/limits.md#self-hosted-overrides). ## Deploys and data - Deploys are immutable. Data is not tied to a deploy: every deploy of an app shares its database, storage, logs, env, and schedules. - Archive and terminate keep data. Restore resumes an archived app. Terminated apps cannot be restored. - `fuast db export` is the backup. There is no automatic point-in-time restore. --- # Limits Every app runs with resource limits. This page lists each one, its default, the error you get when you cross it, and what to change in your capsule. Limits apply the same way in `fuast dev` and on hosted apps, except where a row says otherwise. Self-hosted platforms can change some defaults with environment variables, listed at the end. ## Handler limits These apply to every query, mutation, action, endpoint, and schedule call. Crossing one fails the call, and a mutation, writable endpoint, or schedule rolls back its whole transaction. | Limit | Default | Error | What to do | | --- | --- | --- | --- | | Rows returned | 1000 | `collect() exceeded the 1000 row return limit. Use paginate().` for one read, `Database rowsReturned limit exceeded (1000).` across reads | `paginate()`, or `take(n)` with a small `n`. | | Rows read | 5000 | `Database rowsRead limit exceeded (5000).` | Add an index that matches the filter so the scan stops early. `count()` spends this budget too. | | Bytes read | 4 MiB | `Database bytesRead limit exceeded (4194304).` | Return fewer rows. Move large text to storage. | | Direct `get` calls | 1000 | `Database directGets limit exceeded (1000).` | Replace per-row `get` loops with one indexed query. | | Index scans | 100 | `Database scanCalls limit exceeded (100).` | Fold repeated queries into one range. | | Writes | 1000 | `Database writes limit exceeded (1000).` | Split the work across mutations or schedule runs. | | Field value size | 64 KiB | `Value for

. exceeds 65536 bytes.` | Store the payload with storage and keep the key. Any value within this limit can be indexed. | | `take(n)` | 0 to 1000 | `take count must be an integer between 0 and 1000.` | Paginate instead. | | Page size | 1 to 1000 | `pagination numItems must be an integer between 1 and 1000.` | Use 10 to 100 for UI lists. | | Database time | 5000 ms per call | `Database transaction timed out.` | Do less per call. Keep `fetch` calls short. | | CPU time | 5000 ms | `Handler exceeded the 5000ms CPU time limit.` | Avoid heavy loops. Precompute during writes. | | Wall-clock time | 10 s | `Handler exceeded the 10000ms wall-clock limit.` | Same as above. Slow `fetch` calls count. | | Memory | 128 MB per isolate | The call fails. | Do not build huge arrays or strings. | | Waiting for the write lock | 30 s | `The app is busy with other writes. Retry shortly.` (code `busy`) | Keep mutations short so they do not queue behind each other. | Hosted CPU, wall-clock, memory, and write-lock limits come from the isolate runtime. `fuast dev` runs handlers in Bun workers. It enforces the database budgets, database time, the 10 s wall-clock limit, and the action limits, but not the separate CPU and memory limits, so a CPU-heavy handler that works locally can still fail once deployed. ## Outbound fetch | Limit | Default | Error | | --- | --- | --- | | Timeout per request | 10 s | `fetch timed out after 10000ms.` (code `timeout`) | | Request body | 5 MiB | `fetch request body exceeds 5242880 bytes.` (code `too_large`) | | Response body | 5 MiB | `fetch response body exceeds 5242880 bytes.` (code `too_large`) | | Redirects | 5 | `fetch: too many redirects (max 5).` | | Private addresses | Blocked | Requests to loopback, private, and link-local addresses fail with code `ssrf`. | Fetch time also counts toward the handler's wall-clock limit and holds the app's write lock in a mutation. ## Action limits | Limit | Default | Error | | --- | --- | --- | | Arguments | 16 KiB | `Action arguments exceed 16384 bytes.` | | Result | 48 KiB | `Action result exceeds 49152 bytes.` | | Runtime | 5000 ms | `Action exceeded 5000ms.` | Pass an id and read the data inside the action instead of passing the data. Return only the fields the client needs. A failed action does not undo its outbound effects, so do not assume it is safe to retry. ## Endpoint and connection limits | Limit | Default | Behavior | | --- | --- | --- | | Endpoint request body | 5 MiB | `413` with `Request body is too large (5 MiB max).` (code `too_large`) | | WebSocket message | 1 MiB | The connection closes. Send large data through storage. | | Mutations and actions in flight per connection | 4 | Later calls queue in order. | | Queued calls per connection | 1000 | Further calls fail. Batch into one mutation. | ## App limits | Limit | Default | Error | What to do | | --- | --- | --- | --- | | Stored rows | 100000 | `State row limit exceeded (100000).` | Delete rows you no longer need. Keep summaries instead of raw events. | | Stored row bytes | 64 MiB | `State byte limit exceeded (67108864).` | Shorten values. Move blobs to storage. | | Requests per UTC day | 100000 | HTTP 429, `bucket: "requests"` | Cut round trips. One live subscription beats polling. | | Mutations per UTC day | 20000 | HTTP 429, `bucket: "mutations"` | Batch writes into one mutation. Actions and writable endpoints count here too. | | Emails per UTC day | 100 | `fuast_quota_exceeded`, `bucket: "email"` | Send a digest from a schedule instead of one email per event. | | Pending jobs | 10000 | `Pending job limit exceeded (10000). Cancel jobs or let them finish first.` | Batch work into fewer jobs, or cancel stale ones (`fuast jobs cancel`). See [jobs](https://fuast.com/docs/jobs.md#limits). | | Jobs scheduled per handler call | 1000 | `Job limit exceeded: at most 1000 jobs per handler call.` | Schedule one job that fans out in batches. | | Job arguments | 64 KiB | `Job arguments exceed 65536 bytes.` | Pass ids, not documents. | | Log buffer | 1000 entries or 256 KiB | Oldest entries are dropped. No error. | Log less, or read logs sooner. | | Log entry size | 16 KiB | Long entries are cut and `data` becomes `{ truncated: true }`. | Log a summary, not the whole object. | | Favicon | 64 KiB, `.svg` or `.ico` | Build error: `favicon is bytes; the limit is 65536 bytes (64 KiB).` | Use a smaller icon. | Stored row limits fail the write that crosses them. They have no reset: delete data to get under them again. ## Email limits | Limit | Default | Error | | --- | --- | --- | | Emails per app per UTC day | 100 | `fuast_quota_exceeded`, `bucket: "email"` | | Recipients per call | 1 | `ctx.email.send needs \`to\` as one email address.` | | Subject | 998 characters | `The email subject is too long.` | | Body (`text` + `html`) | 256 KiB | `The email body is too large (256 KiB max).` | `fuast dev` logs emails instead of sending them. See [email](https://fuast.com/docs/email.md). ## Storage limits | Limit | Default | Error | | --- | --- | --- | | File size | 25 MiB | `413`, `code: "too_large"` | | Stored per workspace | 1 GiB | `413`, `code: "quota_exceeded"` | | Blocked types | Executables, HTML, JavaScript | `415`, `code: "blocked_type"` | | Uploads per IP | 60 per minute | `429`, `code: "rate_limited"` | See [storage](https://fuast.com/docs/storage.md#errors). ## Team table limits | Limit | Default | Error | | --- | --- | --- | | Row size | 64 KiB of JSON | `A team table row can be at most 65536 bytes.` | | Rows per team table | 10000 | `Team table is full (10000 rows).` | | Rows added at once | 1000 | `Add at most 1000 rows at once.` | | Table name | 1 to 64 letters, digits, `_`, `-`, `.` | `Team table names use 1-64 letters, digits, ...` | See [teams](https://fuast.com/docs/teams.md#team-tables). ## Server env limits | Limit | Default | Notes | | --- | --- | --- | | Key format | `[A-Za-z_][A-Za-z0-9_]*`, at most 128 characters | Enforced for keys set in the dashboard Env tab. | | Value size | 32 KiB | Enforced for values set in the dashboard. Keep `.env.fuast.server` values under the same size. | | Where it is readable | `ctx.env` in handlers | Never in client code, shared code, or at build time. | `.env.fuast.server` travels with the deploy, so it is limited to 1 MiB like any source file. ## Deploy limits | Limit | Default | Error | | --- | --- | --- | | Source files per deploy | 1000 | `413`, `A deploy can have at most 1000 files.` | | Source bytes per deploy | 20 MiB | `413`, `Deploy source is bytes; the limit is 20971520.` | | One source file (and `.env.fuast.server`) | 1 MiB | `413`, ` is bytes; files can be at most 1048576 bytes.` | | Build time | 120 s | The deploy fails with a build error. | | Macro imports (`with { type: "macro" }`) | Not allowed | `422`, code `macro_import` | | Deploys per IP | 30 per minute | `429`, `Too many deploys. Wait a minute.` | Deploys upload source, not build output, so these limits are generous for a capsule. Large data belongs in storage or the database, not in source files. ## Unclaimed previews An app deployed without an account is an [unclaimed preview](https://fuast.com/docs/reference.md#deploy-without-an-account) until a person claims it. It runs with the limits below instead of the app limits above. Claiming lifts the quotas at once. Email, team tables, the member list, schedules, and jobs stay off after the claim until the owner trusts the app. See [restricted apps](https://fuast.com/docs/security.md#restricted-apps). | Limit | Default | Error | What to do | | --- | --- | --- | --- | | Lifetime | 72 hours from the first deploy. Redeploys do not extend it. | The preview stops serving and is deleted. | Give the person the claim link. | | New previews per client address | 5 a day. Deleting a preview does not give one back. | `429`, code `unclaimed_ip_limit` | Claim one, or run `fuast login`. | | Redeploys of previews per client address | 30 a day | `429`, code `unclaimed_ip_limit` | Batch your changes. Check locally with `fuast check` and `fuast dev` first. | | Deploy requests per client address, failed ones included | 100 per 24 hours | `429`, code `rate_limited` | Fix build errors locally with `fuast check`. | | Previews on the platform at once | 500 | `503`, code `unclaimed_capacity`, with `Retry-After` | Try later, or run `fuast login` and deploy to an account. | | Source per deploy | 200 files, 1 MiB | `413`, code `too_large` | Keep data out of source files. | | Stored rows | 2000 | `State row limit exceeded (2000).` | Claim the app. | | Stored row bytes | 2 MiB | `State byte limit exceeded (2097152).` | Claim the app. | | Stored files | 2 MiB per preview | `An unclaimed preview can store 2097152 bytes. Claim this app to use your workspace's storage.` | Claim the app. | | Requests per UTC day | 2000 | HTTP 429, `bucket: "requests"` | Claim the app. | | Mutations per UTC day | 500 | HTTP 429, `bucket: "mutations"` | Claim the app. | | Outbound `fetch` calls | 10 per handler call, 100 per UTC day | `This unclaimed preview is out of outbound fetch calls: it gets 10 per handler call and 100 a day. Claim the app to lift the limit.` (code `fuast_quota_exceeded`) | Claim the app. A call sets its share of the day's budget aside when it starts and gives back what it did not use, so calls that overlap near the end of the budget may get fewer than 10. | | Pending jobs | 100 | `Pending job limit exceeded (100). …` | Jobs do not run until the claim anyway. | | Emails | 0 | `Claim this app to use email: ctx.email.send is off in an unclaimed preview.` (code `unclaimed`) | Claim the app, then trust it. | | Team tables, workspace members | Not available | `Claim this app to use team tables: …`, `Claim this app to use ctx.workspace: …` (code `unclaimed`) | Claim the app, then trust it. | | Schedules and jobs | Registered, not run | No error. `fuast inspect` shows them. | They start when the claimed app is trusted. Jobs queued before the claim are cancelled. | | Reports before suspension | 3 different client addresses | The preview stops serving (`403`, code `suspended`, on deploy and claim). | The platform's operator reviews it. | `fuast inspect --usage` shows these limits beside the current counts, and the response of the deploy lists them under `limits`. ## Rate limits The platform limits some routes per client IP in a sliding window. Crossing one returns HTTP 429. | Route | Limit | | --- | --- | | `POST /api/call` | 600 per minute | | Storage uploads | 60 per minute | | Deploys | 30 per minute | | Sign-in | 10 per minute | | Sign-up | 5 per minute | | Password token login (`fuast login --email`) | 10 per minute | | `fuast login` browser approval start | 10 per minute | | `fuast login` approval polling | 120 per minute | | Resend verification email | 3 per 10 minutes | | Request access to a private app | 5 per hour per app | | Report an unclaimed preview | 10 per hour | | Claim an app (`POST /api/claims//claim`) | 30 per minute | WebSocket calls from the app's own client are covered by the daily quotas, not by these per-minute limits. ## Reading a quota error Daily quota errors on HTTP routes return status 429 with this body: ```json { "code": "fuast_quota_exceeded", "error": "requests quota exceeded. Limit: 100000.", "bucket": "requests", "current": 100001, "limit": 100000, "resetAt": "2026-11-11T00:00:00.000Z", "retryAfterSeconds": 41230, "suggestion": "Retry after the reset, or reduce request frequency.", "command": "fuast inspect my-app --usage" } ``` The response also carries `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`. Do not retry in a tight loop. Wait `retryAfterSeconds`. Over the WebSocket, the error arrives as `{ op: "error", error, code: "fuast_quota_exceeded", quota: { ...same body } }`. Mutation and action promises reject with an error whose `code` is `fuast_quota_exceeded`. | `bucket` | Counts | Resets | | --- | --- | --- | | `requests` | Queries, mutations, actions, endpoint calls, HTTP calls | 00:00 UTC | | `mutations` | Mutations, actions, writable endpoint calls | 00:00 UTC | | `email` | `ctx.email.send` calls | 00:00 UTC | Owners and workspace admins can still read usage, logs, and data exports after an app runs out of quota. Those reads do not spend the app's budget. ## Checking usage ```sh fuast inspect my-app --usage # hosted app, by slug fuast inspect my-app --usage --json fuast inspect --port 3000 --usage # local ``` Usage shows stored rows and bytes and today's request, mutation, and email counts beside their limits. `fuast dev` counts the same way but never rejects on daily quotas. Handler, action, and stored-row limits do apply locally, so local testing catches them. `fuast inspect` also prints per-read database metrics: ```txt read by_owner: returned=20 read=20 bytes=4096 read by_creation: returned=50 read=4800 bytes=1900334 full-scan ``` `returned`, `read`, and `bytes` are the counters behind the handler limits. `full-scan` marks a read that walked `by_creation` on a large table without a range. It is the usual cause of a `rowsRead` failure: add an index that matches the filter. To see the metrics of one query with your own arguments, run it with `fuast inspect --query`. See [test and debug](https://fuast.com/docs/testing.md#measure-one-query). ## Self-hosted overrides Operators running their own platform can change these defaults. Every limit uses one variable family, `FUAST_LIMIT_*`, and the platform, the runtime, and `fuast dev` all read it the same way, so set the same values for every process (the platform sends the database budgets with each call, the runtime enforces the isolate limits). Values must be positive integers; anything else is ignored. | Variable | Changes | Default | | --- | --- | --- | | `FUAST_LIMIT_ROWS_RETURNED` | Rows returned per handler call | 1000 | | `FUAST_LIMIT_ROWS_READ` | Rows read per handler call | 5000 | | `FUAST_LIMIT_BYTES_READ` | Bytes read per handler call | 4194304 | | `FUAST_LIMIT_DIRECT_GETS` | Direct `get` calls per handler call | 1000 | | `FUAST_LIMIT_SCAN_CALLS` | Index scans per handler call | 100 | | `FUAST_LIMIT_WRITES` | Writes per handler call | 1000 | | `FUAST_LIMIT_FIELD_BYTES` | Size of one field value | 65536 | | `FUAST_LIMIT_INDEX_KEY_BYTES` | Encoded size of one indexed field. The default fits any value within the field limit. | 131136 | | `FUAST_LIMIT_TRANSACTION_MS` | Database time per handler call | 5000 | | `FUAST_LIMIT_STATE_ROWS`, `FUAST_LIMIT_STATE_BYTES` | Stored rows and row bytes per app | 100000, 67108864 | | `FUAST_LIMIT_JOBS_PENDING`, `FUAST_LIMIT_JOBS_PER_CALL`, `FUAST_LIMIT_JOB_ARGS_BYTES` | Pending jobs per app, jobs scheduled per handler call, job argument bytes ([jobs](https://fuast.com/docs/jobs.md)) | 10000, 1000, 65536 | | `FUAST_LIMIT_DAILY_REQUESTS`, `FUAST_LIMIT_DAILY_MUTATIONS`, `FUAST_LIMIT_DAILY_EMAIL` | Daily quotas per app | 100000, 20000, 100 | | `FUAST_LIMIT_CPU_MS`, `FUAST_LIMIT_WALL_MS`, `FUAST_LIMIT_MEMORY_MB` | Isolate CPU time, wall-clock time, memory. `fuast dev` applies the wall-clock limit only. | 5000, 10000, 128 | | `FUAST_LIMIT_ACTION_MS` | Action runtime | 5000 | | `FUAST_LIMIT_ACTION_ARGS_BYTES`, `FUAST_LIMIT_ACTION_RESULT_BYTES` | Action argument and result size | 16384, 49152 | | `FUAST_LIMIT_WRITE_LOCK_WAIT_MS` | How long a write waits for the app's write lock | 30000 | `fuast inspect` and `/__fuast/manifest` show the limits in effect under `limits`, so you can check what a process picked up. Older names still work but print a deprecation warning once: `FUAST_DB_ROWS_RETURNED`, `FUAST_DB_ROWS_READ`, `FUAST_DB_BYTES_READ`, `FUAST_DB_DIRECT_GETS`, `FUAST_DB_SCAN_CALLS`, `FUAST_DB_WRITES`, `FUAST_DB_VALUE_BYTES` (and `FUAST_LIMIT_VALUE_BYTES`), `FUAST_DB_INDEX_KEY_BYTES`, `FUAST_DB_TRANSACTION_MS`, `FUAST_STATE_ROWS`, `FUAST_STATE_BYTES`, `FUAST_QUOTA_REQUESTS`, `FUAST_QUOTA_MUTATIONS`, `FUAST_QUOTA_EMAIL`, `FUAST_CPU_MS`, `FUAST_WALL_MS` and `FUAST_HANDLER_TIMEOUT_MS`, `FUAST_MEM_MB`, `FUAST_ACTION_TIMEOUT_MS`, `FUAST_WRITE_LOCK_WAIT_MS`. When both an old and a new name are set, the `FUAST_LIMIT_*` name wins. Unclaimed previews have their own family, read by the platform. Values must be positive whole numbers: | Variable | Changes | Default | | --- | --- | --- | | `FUAST_UNCLAIMED_DEPLOYS` | `off` refuses deploys without an account. | on | | `FUAST_UNCLAIMED_TTL_HOURS` | Lifetime of a preview, from its first deploy | 72 | | `FUAST_UNCLAIMED_NEW_PER_IP`, `FUAST_UNCLAIMED_REDEPLOYS_PER_IP`, `FUAST_UNCLAIMED_ATTEMPTS_PER_IP` | New previews and redeploys per client address per UTC day (kept in the database, so a restart does not reset them), and deploy requests per 24 hours | 5, 30, 100 | | `FUAST_UNCLAIMED_MAX_LIVE` | Previews that may exist at once | 500 | | `FUAST_UNCLAIMED_MAX_IN_FLIGHT`, `FUAST_UNCLAIMED_BUILD_CONCURRENCY` | Anonymous deploys queued or building at once, and building at once. Keep the second below `FUAST_BUILD_CONCURRENCY` so signed-in deploys always have a build slot. | 8, 1 | | `FUAST_UNCLAIMED_DEPLOY_MAX_FILES`, `FUAST_UNCLAIMED_DEPLOY_MAX_BYTES` | Source files and bytes per anonymous deploy | 200, 1048576 | | `FUAST_UNCLAIMED_STATE_ROWS`, `FUAST_UNCLAIMED_STATE_BYTES`, `FUAST_UNCLAIMED_JOBS_PENDING` | Stored rows, row bytes, and pending jobs per preview | 2000, 2097152, 100 | | `FUAST_UNCLAIMED_STORAGE_BYTES` | Stored file bytes per preview | 2097152 | | `FUAST_UNCLAIMED_DAILY_REQUESTS`, `FUAST_UNCLAIMED_DAILY_MUTATIONS`, `FUAST_UNCLAIMED_DAILY_RERUNS` | Daily quotas per preview | 2000, 500, 20000 | | `FUAST_UNCLAIMED_DAILY_FETCH`, `FUAST_UNCLAIMED_FETCH_PER_RUN` | Outbound `fetch` calls per preview per UTC day (`0` turns outbound fetch off for previews), and per handler call | 100, 10 | | `FUAST_UNCLAIMED_REPORTS_TO_SUSPEND` | Reports from different client addresses that suspend a preview | 3 | | `FUAST_UNCLAIMED_SWEEP_INTERVAL_MS`, `FUAST_UNCLAIMED_SWEEP_BATCH` | How often expired previews are deleted, and how many per run | 60000, 20 | Other operator settings are not limits on what a handler may do, so they keep their own names: | Variable | Process | Changes | | --- | --- | --- | | `FUAST_MAX_ISOLATES`, `FUAST_RUNTIME_MAX_APPS` | runtime | Cached isolates and open app databases. | | `FUAST_ALLOW_PRIVATE_FETCH` | runtime | `1` lets handlers fetch private addresses. Never set it on a shared platform. | | `FUAST_QUOTA_WORKSPACE_EMAIL`, `FUAST_QUOTA_INVITES` | platform | Emails per workspace per day across its apps, and share invites per workspace per day. | | `FUAST_STORAGE_MAX_FILE_BYTES`, `FUAST_STORAGE_WORKSPACE_BYTES` | platform | File size and storage per workspace. | | `FUAST_DEPLOY_MAX_FILES`, `FUAST_DEPLOY_MAX_BYTES`, `FUAST_DEPLOY_MAX_FILE_BYTES` | platform | Deploy upload size. | | `FUAST_BUILD_TIMEOUT_MS`, `FUAST_BUILD_CONCURRENCY` | platform | Sandboxed build time limit and parallel builds. | | `FUAST_DISABLE_RATE_LIMITS` | platform | `1` turns off per-IP rate limits. For tests only. | | `FUAST_DEV_WRITE_LOCK_TIMEOUT_MS`, `FUAST_DEV_LOAD_TIMEOUT_MS` | `fuast dev` | Local write-lock wait (defaults to `FUAST_LIMIT_WRITE_LOCK_WAIT_MS`) and the time a server bundle may take to load (10000). | | `FUAST_DEV_CLOCK_OFFSET_MS` | `fuast dev` | Milliseconds added to the clock cron schedules are evaluated against (tests; default 0). Jobs use the real clock. | The endpoint body size, log sizes, email sizes, team table sizes, and the per-IP rate limits are fixed. A per-IP limit counts an IPv4 address, or a whole IPv6 `/64`, as one client. See [reference](https://fuast.com/docs/reference.md#platform-environment) for the rest of the platform environment. --- # Security This page is the short threat model for people who build and share fuast apps. It says what fuast protects for you, what your capsule code must do itself, and where the edges are. The mechanics behind it are in [isolation](https://fuast.com/docs/isolation.md). ## What fuast guarantees - **Deploy builds read only your capsule.** The upload is bundled in a separate, unprivileged process that cannot follow imports, `package.json` mappings, symlinks, or loader attributes outside the capsule. See [isolation](https://fuast.com/docs/isolation.md#deploy-path). - **Capsule code never runs in the platform.** Server code runs in a V8 isolate in a separate runtime process, one per app deploy, with a CPU, memory, and time budget. A capsule cannot read platform data, another app's database, or another workspace's files. - **Identity comes from fuast.** Handlers read the caller from `ctx.auth`, which the platform fills from a verified session, guest cookie, or token. A capsule cannot forge it, and a client cannot pass it in. - **Sharing is enforced on the server.** Queries, mutations, actions, the WebSocket, the HTTP call API, and storage routes all check the app's sharing mode and `requireSignIn` before your code runs. See [teams](https://fuast.com/docs/teams.md#sharing). - **Each app has its own origin.** Hosted apps run at `https://./` with their own session cookie. Platform cookies never reach an app. See [app origins](#app-origins-and-cookies). - **Secrets stay on the server.** `ctx.env` exists only in handlers. Env values never reach client bundles, `shared/` code, inspection routes, or deploy listings for people who cannot manage the app. - **Stored files are served safely.** Uploads of executables, HTML, and JavaScript are refused. Files are served with `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`. See [storage](https://fuast.com/docs/storage.md#blocked-types-and-safe-serving). - **Outbound fetch cannot reach private networks.** Handler `fetch` refuses loopback, private, and link-local addresses, including after redirects. - **Inspection is private.** Hosted `/__fuast/*` routes (manifest, rows, logs, usage, storage) answer only the app owner and workspace admins. - **Other sites cannot frame your app.** App pages are served with `frame-ancestors`, so a hostile page cannot overlay your app and trick a signed-in person into clicking. See [embedding](#embedding). - **State-changing routes check `Origin`.** Platform and app routes that change data reject cross-site requests. `fuast login` approval is a POST with an explicit confirm page. - **Accounts are verified.** An account joins its company workspace only after verifying its email address. Google sign-in tokens are verified against Google's keys. - **Abuse is bounded.** Per-IP rate limits on sign-in, calls, uploads, and deploys, and daily quotas per app. A whole IPv6 `/64` counts as one address, so a client cannot get a fresh allowance by moving to a neighbouring address. See [limits](https://fuast.com/docs/limits.md). - **Apps without an owner are fenced in.** An app deployed without an account is a preview: reachable only through its preview link, guests only, no email or workspace data, small quotas, and gone after 72 hours unless someone claims it. See [unclaimed previews](#unclaimed-previews). - **Claiming an app does not hand it your workspace, and neither does deploying to it.** A claimed app stays cut off from email, team tables, the member list, and unattended runs until you trust it explicitly. See [restricted apps](#restricted-apps). ## What your capsule must do fuast identifies the caller. It does not decide which rows that caller may see or change. That is your code. - **Authorize every row.** Filter user-owned reads by `ctx.auth.userId` through an index, and re-check ownership in every mutation before `update`, `replace`, or `delete`. An id from the client is a request, not a permission. - **Never take identity as an argument.** No `userId`, email, or name parameters for "who is calling". Use `ctx.auth.requireIdentity()` or `ctx.auth.requireSignedIn()`. - **Validate every argument.** TypeScript types are not checked at runtime for positional handler arguments. Check types, lengths, and ranges in the handler, ideally with a helper in `shared/`. - **Protect machine-called endpoints yourself.** Endpoints run for any caller, even on private apps, so webhooks work. Check a secret from `ctx.env` or verify the sender's signature. See [endpoint credentials](https://fuast.com/docs/auth.md#endpoint-credentials). - **Keep secrets in `ctx.env`.** Never in `client/`, `shared/`, tool descriptions, or log messages. Anything in the client bundle is public to everyone who can open the app. - **Do side effects last.** A rollback undoes database writes only. Email, storage writes, team table writes, and `fetch` calls stay done if the handler throws afterwards. - **Render user content as text.** Preact escapes strings. Do not put user content into `dangerouslySetInnerHTML`, `href="javascript:..."`, or inline styles you build from strings. - **Treat tool results as untrusted.** Text other people wrote can try to steer a browser agent that reads it. Return only the fields an agent needs. See [agent tools](https://fuast.com/docs/agent-tools.md#write-descriptions-for-agents). - **Pick the narrowest sharing mode.** Apps start `private`. `public` lets anyone with the link in, as a guest unless `requireSignIn` is set. ## App origins and cookies Each hosted app has its own origin, so one app's JavaScript cannot read another app's pages or call the platform's account APIs as the signed-in person. - The platform session cookie is host-only on the platform origin, with the `__Host-` prefix on https, so no app can overwrite it even when apps share the platform's domain. It is never sent to an app origin, and the platform API does not answer on app hosts. - An app's session cookie is minted on the app's origin through a one-time code redirect at sign-in. It is HttpOnly, host-only, and `SameSite=Lax`. - Guest cookies are signed, HttpOnly, and scoped to one app. - Identity tokens from `getIdentity()` are scoped to one app and to the sign-in they came from. Signing out of the app, revoking the session, or revoking the personal token they were minted through ends them at once. Send them only to your app's own origin. Every app shares the apps domain with every other app. Until that domain is on the [Public Suffix List](https://publicsuffix.org/), browsers treat all apps on it as the **same site**. Two consequences: - `SameSite` cookies do not separate one app from another. A request from `other.` to your app counts as same-site. fuast's own routes check `Origin` for this reason. Do the same on any writable endpoint that relies on cookies. - A page on a sibling subdomain can set a cookie for the parent domain. If you set your own cookies, use the `__Host-` prefix with `Secure; Path=/` and no `Domain`, which browsers refuse to let other subdomains overwrite. An app on a [custom domain](https://fuast.com/docs/domains.md) gets the same protections on that domain: its own session cookie through the same handshake (the platform hands the one-time code only to a domain verified for that app), `__Host-` cookies, and `Origin` checks against the domain the request arrived on. A custom domain is not under the shared apps domain, so other apps there are cross-site to it (two custom domains under the same registrable domain, like `a.acme.com` and `b.acme.com`, are still same-site to each other). Self-hosted platforms without a wildcard domain serve apps under `/a//` on the platform origin. In that mode apps share an origin with the platform and with each other, so any app can act as its viewers on the platform. A production platform refuses to start in path mode unless the operator sets `FUAST_ALLOW_SAME_ORIGIN_APPS=1`. Use it only when every app on the platform is trusted. ## Embedding An app page can be shown in an `