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, 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 { boolean, capsule, mutation, query, string, table } from "lakebed/server";
import { cleanTodoText } from "../shared/todo";

export default capsule({
  schema: {
    todos: table({
      text: string(),
      done: boolean().default(false),
      ownerId: string()
    })
  },

  queries: {
    todos: query((ctx) =>
      ctx.db.todos
        .where("ownerId", ctx.auth.userId)
        .orderBy("createdAt", "desc")
        .all()
    )
  },

  mutations: {
    addTodo: mutation((ctx, text: string) => {
      const cleanText = cleanTodoText(text);
      if (!cleanText) {
        return;
      }

      ctx.db.todos.insert({
        text: cleanText,
        done: false,
        ownerId: ctx.auth.userId
      });
    }),

    setTodoDone: mutation((ctx, id: string, done: boolean) => {
      const todo = ctx.db.todos.get(id);
      if (!todo || todo.ownerId !== ctx.auth.userId) {
        return;
      }

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

The important pattern is server authority:

Anonymous deploys preserve this model by running the bundled server JavaScript in a restricted source runtime. IR should be treated as a future optimization only when it can preserve the full handler semantics.

Use Shared Code Carefully

Good shared code:

export type Todo = {
  id: string;
  text: string;
  done: boolean;
  ownerId: string;
  createdAt: string;
  updatedAt: string;
};

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 { SignInWithGoogle, signOut, useAuth, useMutation, useQuery } from "lakebed/client";
import { cleanTodoText, type Todo } from "../shared/todo";

export function App() {
  const auth = useAuth();
  const todos = useQuery<Todo[]>("todos");
  const addTodo = useMutation<[text: string], void>("addTodo");
  const setTodoDone = useMutation<[id: string, done: boolean], void>("setTodoDone");
  const authLabel = auth.displayName;
  const authStatus = auth.isLoading && auth.isGuest ? "checking session" : `signed in as ${authLabel}`;

  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.isLoading && 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.isLoading && auth.isGuest ? (
            <SignInWithGoogle className="border border-neutral-700 px-3 py-1.5 text-sm text-neutral-200" />
          ) : !auth.isLoading ? (
            <button type="button" onClick={() => signOut()}>
              Sign out
            </button>
          ) : null}
        </div>

        <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, direct HTTP requests hit the endpoint first.

Auth

Use auth through Lakebed APIs only.

Server:

ctx.auth.userId;
ctx.auth.displayName;
ctx.auth.picture;
ctx.auth.email;

Client:

const auth = useAuth();

Guest auth is available immediately. To test multiple local users, use separate URLs:

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

To add Google sign-in, render <SignInWithGoogle /> or call signInWithGoogle() from a custom button. After sign-in, server handlers receive the verified identity on ctx.auth.

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

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 send the saved claim token from .lakebed/deploy.json; 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.

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