# 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 (
{ e.preventDefault(); void createPost(title, "…"); setTitle(""); }}> setTitle(e.currentTarget.value)} />
{(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.