Capsule API

This page shows the API shape an agent should use when authoring a Lakebed app.

File Layout

server/index.ts
client/index.tsx
shared/

Use server/index.ts for schema, queries, mutations, actions, and external endpoints. Use client/index.tsx for the Preact UI. Put validation helpers, types, and constants in shared/ when both sides need them.

Define The Server

import { action, boolean, capsule, mutation, query, string, table, userId } from "lakebed/server";
import { cleanTodoText } from "../shared/todo";

export default capsule({
  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 cleanText = cleanTodoText(text);
      if (!cleanText) {
        return;
      }

      await ctx.db.todos.insert({
        text: cleanText,
        done: false,
        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) {
        return;
      }

      await ctx.db.todos.update(id, { done });
    })
  },

  actions: {
    summarizeTodos: action(async (ctx, label: string) => {
      const { userId } = ctx.auth.requireIdentity();
      const todos = await ctx.db.todos
        .withIndex("by_owner", (q) => q.eq("ownerId", userId))
        .collect();
      return { count: todos.length, label };
    })
  }
});

The important pattern is server authority:

userId() marks a Lakebed user reference. Those references transfer when a protected guest signs in. The field does not replace owner filters or authorize access by itself.

Handler capabilities

CapabilityQueryMutationActionEndpoint
Read ctx.dbYesYesYesYes
Write ctx.dbNoYesNoWritable endpoints only
Global fetch()Local or claimed deployLocal or claimed deployLocal or claimed deployLocal or claimed deploy
ctx.envLocal or claimed deployLocal or claimed deployLocal or claimed deployLocal or claimed deploy
ctx.auth, ctx.logYesYesYesYes
Hosted daily quotaRequestRequest + mutationRequest + mutationRequest, plus mutation for writable endpoints
Execution budgetTransactionTransactionAction and database limitsTransaction

Use global fetch(url, options), not ctx.fetch. A writable endpoint can fetch external data and write rows in the same handler. No browser or action-to-mutation handoff is needed. See the dashboard ingest example.

Every database handler runs in a transaction. A fetch inside a mutation or writable endpoint uses its time budget and can hold up other writes on that deploy. Keep each ingest call small. Database rollback cannot undo an external request. Queries can fetch, but an external data change does not invalidate a query subscription. Fetch into stored rows when the UI needs reactive updates.

The mutation quota counts handler calls, not individual row writes. A writable endpoint uses it even if that invocation writes no rows. Actions also use it despite having read-only database access. The limits page defines all numeric budgets and errors.

ctx.auth describes the caller and can have no identity for an external HTTP caller. Keep auth.requireSignIn off for a public dashboard, and protect its ingest endpoint with an app secret. A deploy token from lakebed token create authorizes CLI operations, not app endpoint identity. See endpoint credentials.

Anonymous deploys have no hosted server env and cannot fetch. Claim and redeploy before using either. Local dev reads .env.lakebed.server. Fetch must run inside a handler, not at module scope. The auth.onGuestUpgrade hook cannot fetch even on a claimed deploy.

Lakebed does not yet provide scheduled handlers, delayed jobs, or a server API for calling another handler. For periodic ingest, an external scheduler can call a protected endpoint. Return a cursor from each bounded call and let that caller request the next batch. See dashboard counts for the current pattern and its limits.

Use Shared Code Carefully

Good shared code:

export function cleanTodoText(value: string): string {
  return value.trim().slice(0, 160);
}

Keep shared/ pure. Do not import lakebed/server, lakebed/client, Preact, DOM APIs, Node built-ins, env values, or secrets from shared files.

Build The Client

import { canAccessApp, createClient, retryAuth, SignInWithGoogle, signOut, useAuth } from "lakebed/client";
import type app from "../server/index";
import { cleanTodoText } from "../shared/todo";

const client = createClient<typeof app>();

export function App() {
  const auth = useAuth();
  if (auth.isLoading) {
    return <main className="min-h-screen bg-black p-6 text-white">Checking session</main>;
  }
  if (!canAccessApp()) {
    return (
      <main className="min-h-screen bg-black p-6 text-white">
        {auth.error ? <p role="alert">{auth.error}</p> : <p>Sign in to use this app.</p>}
        {auth.error ? <button type="button" onClick={() => void retryAuth()}>Retry</button> : null}
        <SignInWithGoogle />
      </main>
    );
  }
  return <TodoApp />;
}

function TodoApp() {
  const auth = useAuth();
  const todos = client.useQuery("todos") ?? [];
  const addTodo = client.useMutation("addTodo");
  const setTodoDone = client.useMutation("setTodoDone");
  const authLabel = auth.displayName;
  const authStatus = auth.isSignedIn ? `Signed in as ${authLabel}` : "Using this browser";

  async function onSubmit(event: SubmitEvent) {
    event.preventDefault();
    const form = event.currentTarget as HTMLFormElement;
    const data = new FormData(form);
    const text = cleanTodoText(String(data.get("text") ?? ""));
    if (!text) {
      return;
    }

    await addTodo(text);
    form.reset();
  }

  return (
    <main className="min-h-screen bg-black px-6 py-10 text-white">
      <section className="mx-auto max-w-2xl">
        <div className="mb-3 flex items-center justify-between gap-3">
          <div className="flex min-w-0 items-center gap-2">
            {auth.picture ? (
              <img alt="" className="h-7 w-7 rounded-full" referrerPolicy="no-referrer" src={auth.picture} />
            ) : null}
            <p className="min-w-0 truncate font-mono text-sm text-neutral-500">{authStatus}</p>
          </div>
          {auth.isGuest ? (
            <SignInWithGoogle className="border border-neutral-700 px-3 py-1.5 text-sm text-neutral-200" />
          ) : (
            <button type="button" onClick={() => signOut()}>
              Sign out
            </button>
          )}
        </div>

        {auth.isGuest ? <p className="mb-4">Sign in to keep your todos with your account.</p> : null}

        <form className="mb-8 flex gap-3" onSubmit={(event) => void onSubmit(event)}>
          <input name="text" className="min-w-0 flex-1 border border-neutral-700 bg-black px-3 py-2" />
          <button type="submit" className="border border-white px-4 py-2">
            Add
          </button>
        </form>

        <ul>
          {todos.map((todo) => (
            <li key={todo.id}>
              <label>
                <input
                  checked={todo.done}
                  type="checkbox"
                  onChange={(event) => void setTodoDone(todo.id, event.currentTarget.checked)}
                />
                {todo.text}
              </label>
            </li>
          ))}
        </ul>
      </section>
    </main>
  );
}

Client rules:

Client routes use Preact components and app-relative paths:

import { Link, Route, Router, Routes, useParams } from "lakebed/client";

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

export function App() {
  return (
    <Router>
      <Link to="/todos/123">Open todo</Link>
      <Routes>
        <Route path="/" element={<main>Home</main>} />
        <Route path="/todos/:id" element={<TodoPage />} />
        <Route path="*" element={<main>Not found</main>} />
      </Routes>
    </Router>
  );
}

Use server endpoints for HTTP APIs and webhooks. If a GET endpoint and a client route use the same path, opening that URL or reloading it returns the endpoint response instead of the app shell. A <Link> navigation within an already loaded app stays in the client router. Use separate paths, such as /api/summary for the endpoint and /summary for the page, so reloads and shared links work.

Handle client errors

Lakebed wraps the generated app root in <ErrorBoundary>. Add your own around a subtree when you want a different fallback:

import { ErrorBoundary } from "lakebed/client";

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

ErrorBoundaryProps is { children, fallback? }. fallback receives the error and a retry function that re-renders the subtree. Without it, the boundary shows Lakebed's default retry message.

Return endpoint responses

An endpoint handler returns one of four response helpers from lakebed/server:

HelperResult
json(value, options?)JSON body, status 200, Content-Type: application/json; charset=utf-8.
text(value, options?)Plain text body, status 200, Content-Type: text/plain; charset=utf-8.
empty(options?)No body, status 204.
redirect(url, options?)No body, status 302, Location: <url>.

Every helper takes the same EndpointResponseOptions: { status?, headers? }. Set status to override the default code, and headers to add or replace headers.

import { empty, endpoint, redirect } from "lakebed/server";

endpoints: {
  ping: endpoint({ method: "POST", path: "/api/ping" }, async (ctx) => {
    const { userId } = ctx.auth.requireIdentity();
    await ctx.db.pings.insert({ ownerId: userId });
    return empty();
  }),

  docsLink: endpoint({ method: "GET", path: "/go/docs", readOnly: true }, () =>
    redirect("https://docs.lakebed.dev", { status: 301 })
  )
}

Log from server handlers

ctx.log writes structured entries that npx lakebed logs reads back. It has three levels and an optional JSON data argument:

ctx.log.info("order created", { id: order.id });
ctx.log.warn("payment retried", { attempt });
ctx.log.error("payment failed", { reason });

Lakebed keeps a bounded log buffer per deploy and drops the oldest entries when it fills. See limits.md for the entry, byte, and per-entry sizes. npx lakebed dev keeps the same number of entries in memory and applies no byte limits. /__lakebed/logs and npx lakebed logs return the last 100 entries on both.

Run actions

Actions run server code with read-only database access. Call an action from a client component:

const summarizeTodos = client.useAction("summarizeTodos");
const summary = await summarizeTodos("today");

The server handler receives ctx.auth, read-only ctx.db, ctx.env, and ctx.log. Action arguments and results must be JSON-compatible and fit the action size and runtime limits.

Hosted actions count against request and mutation limits. Anonymous deployments cannot use outbound fetch or hosted server env. Claim the deployment before an action uses either capability.

Auth

Use auth through Lakebed APIs only.

Server:

const identity = ctx.auth.requireIdentity(); // Guest or signed-in account.
identity.userId;
identity.displayName;
identity.picture;
identity.email;

const account = ctx.auth.requireSignedIn(); // Rejects guests and missing sessions.

Client:

const auth = useAuth();
auth.isGuest;
auth.isSignedIn;

Default guests have separate protected browser sessions. Their user IDs are not credentials. isGuest and isSignedIn are both false without a session. isAuthenticated is a compatibility alias for isSignedIn.

Set auth: { requireSignIn: true } to block all app data operations before sign-in. The shell and auth routes still load. Use auth.requireSignIn on the client to choose which UI to show. The server policy enforces access.

Store user references with userId(), not string(). Lakebed transfers these references during a verified guest upgrade. Use auth.onGuestUpgrade for app-specific conflicts before automatic transfer. See the auth guide for the transaction contract and an example.

To test named local users, use separate URLs:

http://localhost:3000/?lakebed_guest=alice
http://localhost:3000/?lakebed_guest=bob

Named guests are local test overrides and cannot upgrade. Use the default browser session to test guest sign-in and transfer.

To add Google sign-in, render <SignInWithGoogle /> or call signInWithGoogle() from a custom button. Use auth.error and retryAuth() to show and retry a failed session setup or guest upgrade. Keep sign-in available with the error. Retry cannot renew an expired or revoked token.

Server Env

Add server-only values at the capsule root:

# .env.lakebed.server
OPENAI_API_KEY=sk-...

Read them only from server handlers:

queries: {
  hasOpenAiKey: query((ctx) => Boolean(ctx.env.OPENAI_API_KEY))
}

External endpoints can use the same env binding for webhook secrets:

import { endpoint, json, text } from "lakebed/server";

endpoints: {
  webhook: endpoint({ method: "POST", path: "/webhooks/incoming" }, async (ctx, req) => {
    if (req.headers.get("x-webhook-secret") !== ctx.env.WEBHOOK_SECRET) {
      return text("unauthorized", { status: 401 });
    }
    return json({ ok: true });
  })
}

Do not put secrets in client/ or shared/.

Custom endpoints can read app-owned Bearer or Basic credentials from Authorization. Lakebed account identity uses the separate reserved X-Lakebed-Token header. See endpoint credentials for the request format and guest cookie behavior.

Types

lakebed/server and lakebed/client export types as well as values. You need them only when you write a helper that takes ctx, a request, or a client result. Everything else is inferred.

Type a helper that runs inside a handler:

import { mutation, type ServerContext } from "lakebed/server";

async function markDone(ctx: ServerContext, id: string): Promise<void> {
  await ctx.db.todos.update(id, { done: true });
}

mutations: {
  finish: mutation((ctx, id: string) => markDone(ctx, id))
}

lakebed/server types:

TypeWhat it describes
QueryServerContextThe ctx a query() handler receives, and the ctx a readOnly: true endpoint() handler receives.
ServerContextThe ctx a mutation() handler or a writable endpoint() handler receives. Its db can write.
ActionServerContextThe ctx an action() handler receives.
AuthContextctx.auth: the caller plus the requireIdentity() and requireSignedIn() guards.
CapsuleAuthThe auth key of capsule(): { requireSignIn?, onGuestUpgrade? }.
GuestUpgradeThe { guestUserId, userId } an onGuestUpgrade handler receives.
DbContextRead-only ctx.db, used by queries and actions.
MutationDbContextWritable ctx.db, used by mutations and endpoints that are not readOnly.
LogContextctx.log, with info, warn, and error.
EndpointRouteThe first argument of endpoint(): { method, path, readOnly? }.
EndpointRequestThe req an endpoint handler receives.
EndpointHeadersreq.headers, with get, has, and entries.
EndpointResponseWhat json, text, empty, and redirect return.
EndpointResponseOptions{ status?, headers? } for those four helpers.
EndpointDefinitionWhat endpoint() returns, so an endpoints map can be typed.
IdA row id tagged with the table it points at.
FieldOne column: what string(), boolean(), number(), id(), and userId() return.
TableDefinitionWhat table() returns.
SchemaThe schema map of table definitions.
DefaultSchemaThe schema a capsule gets when it declares none.
PaginationOptions{ cursor, numItems }, the argument of .paginate().
PaginationResult{ page, isDone, continueCursor }, the result of .paginate().
CapsuleWhat capsule() returns.
CapsuleFaviconThe favicon key, a relative path string.
SchemaOf, ReadDatabaseOf, WriteDatabaseOfPull the schema or a database shape out of a capsule definition.
QueryName, QueryArgs, QueryResultThe declared query names and their argument and result types.
MutationName, MutationArgs, MutationResultThe same for mutations.
ActionName, ActionArgs, ActionResultThe same for actions.

lakebed/client types:

TypeWhat it describes
LakebedClientWhat createClient<typeof app>() returns.
PaginatedQueryResultWhat usePaginatedQuery returns: a page plus loadMore() and reset().
ErrorBoundaryPropsThe props of <ErrorBoundary>.
StorageClientclient.storage. See storage.md.
UploadedObjectWhat client.storage.upload resolves to.
AuthValueWhat useAuth() returns: the identity plus isLoading, error, and requireSignIn.
IdentityClaimsThe claims decodeIdentityClaims returns. See auth.md.

Run And Inspect

npx lakebed dev
npx lakebed db list --port 3000
npx lakebed db dump --port 3000
npx lakebed logs --port 3000

The database is local and in-memory during npx lakebed dev. Restarting dev resets it.

Hosted inspection is private by default. Run hosted inspection commands from the capsule directory so Lakebed can find lakebed.json or .lakebed/deploy.json in the working directory tree and send developer auth from the binding or saved anonymous claim token. Non-private hosted manifests expose only non-sensitive deploy metadata.

Deploy

npx lakebed deploy

If the app uses .env.lakebed.server or outbound server-side fetch, claim the deploy and run npx lakebed deploy again so Lakebed can publish the source-backed server path.

For a portable owned deploy, run npx lakebed auth login before the first deploy. Commit the generated root-level lakebed.json, which contains only the deploy id.

Use npx lakebed deploy --public-inspect only for demos where making hosted data and logs public is intentional.

After you sign in, manage owned deployments with:

npx lakebed deploy list
npx lakebed deploy archive <deploy-id>
npx lakebed deploy restore <deploy-id>
npx lakebed deploy terminate <deploy-id> --yes

An archived app stops serving until you restore it. A terminated app stops serving, cannot receive updates, and cannot be restored. Both commands retain the deploy record, data, logs, usage records, server env, domain mappings, and referenced artifact.

Unclaimed deploys expire and are deleted about one week after expiry. Claimed deploys do not expire.