# Building a fuast capsule
fuast runs small full-stack apps called capsules and shares them with a team.
If you are an AI agent: this file is the complete contract. Follow it exactly —
capsules that import npm packages or Node builtins fail the build.
## Shape
```
my-app/
server/index.ts the entire backend: schema, queries, mutations, endpoints
client/index.tsx the entire frontend: exports App (Preact)
tsconfig.json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" } }
```
Rules:
- `server/` files may import ONLY `fuast/server` and relative files.
- `client/` files may import ONLY `fuast/client`, `fuast/ui`, and relative files.
- No npm packages. No Node builtins. No `fetch` in the client to external hosts
(server-side fetch is allowed).
- Relative imports are fine — split big apps into modules.
## Server contract (`server/index.ts`)
```ts
import { boolean, capsule, endpoint, json, mutation, query, string, table, text } from "fuast/server";
export default capsule({
schema: {
posts: table({
title: string(),
body: text(),
authorId: string(),
}).index("by_author", ["authorId"]),
},
queries: {
posts: query(async (ctx) => ctx.db.posts.order("desc").collect()),
mine: query(async (ctx) =>
ctx.db.posts.withIndex("by_author", (q) => q.eq("authorId", ctx.auth.userId)).collect(),
),
},
mutations: {
createPost: mutation(async (ctx, title: string, body: string) => {
if (ctx.auth.isGuest) throw new Error("sign in first");
return ctx.db.posts.insert({ title, body, authorId: ctx.auth.userId });
}),
deletePost: mutation(async (ctx, id: string) => {
const post = await ctx.db.posts.get(id);
if (!post || post.authorId !== ctx.auth.userId) throw new Error("not yours");
await ctx.db.posts.delete(id);
}),
},
endpoints: {
webhook: endpoint({ method: "POST", path: "/hook" }, async (ctx, req) => {
const payload = await req.json<{ msg: string }>();
return json({ ok: true });
}),
},
});
```
Handler context `ctx`:
- `ctx.auth` — `{ userId, isGuest, email?, displayName? }`. The platform verifies
identity; NEVER accept a user id or email as a mutation argument. Filter rows
by `ctx.auth.userId` for per-user data and re-check ownership inside
mutations before update/delete.
- `ctx.db.
` — `insert(doc) -> row`, `get(id)`, `update(id, patch)`,
`delete(id)`, `withIndex(name, q => q.eq(field, value))`, `order("asc"|"desc")`,
`collect()`, `take(n)`, `first()`. Rows carry `id`, `createdAt`, `updatedAt`.
- `ctx.env` — server-only values from `.env.fuast.server` (optional file,
`KEY=value` lines). Never exposed to the client.
- `ctx.storage` — `url(key)` (access-controlled serving URL), `get(key)`,
`delete(key)`. Files arrive via the client's `uploadFile`.
- `ctx.email.send({ to, subject, text?, html? })` — send a transactional email.
Delivery is handled by the platform (console log in dev; Resend in prod).
- `ctx.log(...)` — structured logs, visible in the dashboard and `fuast logs`.
## Scheduled jobs
```ts
import { capsule, schedule } from "fuast/server";
export default capsule({
schema: { /* ... */ },
schedules: {
dailyDigest: schedule("0 9 * * *", async (ctx) => { // 09:00 UTC daily
const rows = await ctx.db.items.collect();
await ctx.email.send({ to: "team@you.com", subject: "Daily digest", text: `${rows.length} items` });
}),
},
});
```
- `cron` is a standard 5-field expression (min hour dom mon dow), UTC, minute
granularity. `* * * * *` = every minute; `0 9 * * 1` = 09:00 UTC Mondays.
- The handler runs with a system identity (`ctx.auth.userId === "system"`).
- Schedules are re-synced from the capsule on every deploy; view them (and set
secrets) on the app's manage page.
## Environment & secrets
`ctx.env` merges the deploy-time `.env.fuast.server` file with dashboard-set
variables (manage page → Environment). Dashboard values win, are write-only in
the UI, and are the right place for API keys (e.g. set `RESEND_API_KEY` +
`RESEND_FROM` to send real email in prod).
Field types for `table()`: `string()`, `text()`, `number()`, `boolean()`.
Store file references as the storage `key` string. Endpoints are reachable at
`/a//e/` and are NOT access-walled — check secrets yourself.
## Client contract (`client/index.tsx`)
```tsx
import { h, uploadFile, useAuth, useMutation, useQuery, useState } from "fuast/client";
import { Badge, Button, Card, EmptyState, ErrorText, Field, Input, Page, PageHeader, Textarea } from "fuast/ui";
type Post = { id: string; title: string; body: string; authorId: string; createdAt: number };
export function App() {
const auth = useAuth(); // { isLoading, isGuest, userId, email, displayName }
const posts = useQuery("posts"); // live-ish (poll + refetch on mutation)
const createPost = useMutation<[string, string]>("createPost");
const [title, setTitle] = useState("");
if (auth.isLoading) return
Loading…
;
return (
{(posts ?? []).map((p) => {p.title})}
{posts?.length === 0 ? : null}
);
}
```
- `fuast/client`: `h`, all Preact hooks, `useQuery(name, ...args)`,
`useMutation(name)`, `useAuth()`, `signOut()`,
`uploadFile(file) -> { key, url, name, type, size }`.
- `fuast/ui` (pre-styled; use these instead of hand-rolling): `Page`,
`PageHeader`, `Card`, `Button` (variants `primary|ghost|danger`), `Input`,
`Textarea`, `Label`, `Field`, `Badge` (tones `neutral|green|blue`), `Avatar`,
`EmptyState`, `Spinner`, `ErrorText`.
- Tailwind classes work everywhere (`class=`, not `className=`, though both
are accepted by fuast/ui components).
- Simple views: hold the current view in `useState`; there is no router yet.
- File uploads: `` → `uploadFile(file)` → store the returned
`key` via a mutation; render with the `url` the server returns from
`ctx.storage.url(key)`. 20MB/file, 500MB/app.
## CLI
```
bun run cli new --template scaffold (includes this file as CLAUDE.md)
bun run cli login browser hand-off (code + approve page);
--email/--password flags for CI
bun run cli check [dir] typecheck + contract check, no deploy
bun run cli dev [dir] [--port 4321] local preview: watches + rebuilds on edit;
fake identities via ?fuast_user=alice;
local db/files in .fuast/dev/
bun run cli deploy [dir] [--name "My App"] build + deploy; prints the app URL
bun run cli share --visibility [--emails a@x.com,b@x.com]
bun run cli list apps you can see (JSON)
bun run cli logs recent logs (owner only)
bun run cli db dump all tables as JSON (owner only)
```
(Installed as `fuast ` when the CLI is on PATH; in the repo use `bun run cli`.)
Deploy is idempotent per (owner, name): redeploying the same name updates the
same app and keeps its data. The first deploy writes `fuast.json` ({app, name})
into the folder — after that, bare `fuast deploy` / `fuast share` target the
same app with no flags. Commit it. Scaffolds include `fuast-env.d.ts`, so
editors and `fuast check` typecheck the capsule standalone. The web dashboard offers the same controls:
sharing, logs, and template-based creation at /apps/new.
## Calling a deployed app over HTTP (headless verification)
Every deployed app exposes its queries/mutations at a JSON endpoint —
authenticate with a platform session cookie or a CLI Bearer token:
```
POST /a//api/call
Content-Type: application/json
{"kind": "query" | "mutation", "name": "", "args": [ ... ]}
```
- Success: the handler's return value as JSON (HTTP 200).
- Handler threw: `{"error": ""}` with HTTP 500.
- Not signed in / not shared with you: `{"error": "sign_in_required"}` 401 /
`{"error": "no_access"}` 403.
- `GET /a//api/me` returns the caller's identity as the app sees it.
**New apps deploy PRIVATE** — only the owner can open or call them. Run
`fuast share --visibility workspace` (or set it in the dashboard)
before other users can access the app.
Concurrency: mutations are plain async handlers over SQLite — there are no
transactions or unique constraints yet. Read-then-write patterns (e.g.
"one vote per user") are safe against double-submits from one client but not
guaranteed under truly concurrent writes; keep invariants tolerant.
fuast/ui prop reference: `Button {variant, disabled, type, onClick}` ·
`Field {label, hint}` · `Badge {tone}` · `Avatar {name, size: "sm"|"md"}` ·
`EmptyState {title, hint, children}` · `Card {onClick}` ·
`PageHeader {title, subtitle, actions}`. Inputs/Textarea pass props through.
## Working loop for agents
1. Scaffold or edit the capsule files.
2. Iterate locally with `fuast dev` — edits rebuild automatically; exercise
queries/mutations at http://localhost:4321/api/call (same JSON shape as
deployed apps) and switch identities with ?fuast_user=.
3. `bun run cli deploy --name "..."` — build errors print inline; fix and rerun.
4. Verify with `db dump` / `logs` after exercising the app, not by assumption.
5. `share` it and return the app URL.
Common failures: importing anything bare (react, lodash — forbidden; `preact`
via `fuast/client` only), using `ctx.db` outside a handler, trusting client-
supplied identity, forgetting `e.preventDefault()` on form submit.