Browse the docs

Build

Capsule API

The complete author-facing API of fuast/server and fuast/client, with handler capabilities, endpoint responses, logging, errors, and types.

This page is the API an agent uses to write a fuast app. The database has its own page: database.

File layout

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.
schema Record<string, TableDefinition> 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.

Handler names must be unique within their map. A query and a mutation may share a name, but a name used as a tool must be unique across all kinds.

Define the server

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<string, Field> } }. The tool option also exposes the handler to browser agents. See agent tools.

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) 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) 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

ctx.auth        // caller identity and guards. See auth.md.
ctx.db          // tables. See database.md.
ctx.env         // Record<string, string>
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.

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 <app url><path>, for example https://my-app.fuast.com/webhooks/stripe. From your client, call it with fetch(appUrl("/webhooks/stripe")) (see calling your endpoints). <app url>/e/<path> 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<string, string> from :param segments
text(), json<T>(), 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.

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: <url>.

Every helper takes { status?, headers? }. Return one of these, not a global Response. A handler that throws returns 500 with { "error": "<message>" } and logs the error.

Logging

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.

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

// 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

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<typeof app>();

createClient<typeof app>() 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<result>. Chain .withOptimisticUpdate(update) to predict the result. See optimistic updates.
client.useAction(name) Returns (...args) => Promise<result>.
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.
client.storage upload(file, { public? }) and delete(key). See storage.

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 <SignInButton />.

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:

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

function Todos() {
  const todos = client.useQuery("todos");
  if (todos === undefined) return null;
  if (todos.length === 0) return <EmptyState title="No todos yet" hint="Add one above." />;
  return (
    <ul className="transition-opacity duration-200 starting:opacity-0">
      {todos.map((t) => <li key={t.id}>{t.text}</li>)}
    </ul>
  );
}

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

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.
  • <SignInButton /> 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.

Calling your endpoints

Build endpoint URLs with appUrl(path), never with a literal path:

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://<slug>.fuast.com/, fuast dev) and /a/<slug>/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/<slug>/. 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/<slug>, never ending in /.

Routes

function ItemPage() {
  const { id } = useParams<{ id: string }>();
  return <main>Item {id}</main>;
}

export function App() {
  return (
    <Router>
      <nav><Link to="/">Home</Link> <Link to="/items/42">Item 42</Link></nav>
      <Routes>
        <Route path="/" element={<main>Home</main>} />
        <Route path="/items/:id" element={<ItemPage />} />
        <Route path="*" element={<main>Not found</main>} />
      </Routes>
    </Router>
  );
}

Paths are app-relative, so they work in dev, on https://<slug>.fuast.com/, and under /a/<slug>/. 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:

<ErrorBoundary fallback={(error, retry) => (
  <div><p>{error.message}</p><button type="button" onClick={retry}>Retry</button></div>
)}>
  <TodoList />
</ErrorBoundary>

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.

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<TApp>, JobContext, JobId, JobOptions ctx.scheduler (ctx.scheduler.typed<typeof app>() checks names and arguments), ctx.job. See jobs.
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<typeof app> (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.