Pond Server API Reference

Use this as the quick contract when building a Pond capsule. If you are an agent writing a capsule, this page is the authoritative list of the primitives you may call inside server/index.ts. Do not invent helpers that aren't here. Do not import arbitrary npm packages — capsule modules are bundled with esbuild and only pond/server, pond/client, plain relative TypeScript, and the Node built-ins that ship with the runtime are available.

This is the surface exported from pond/server. A capsule's server/index.ts calls into it to define schema, queries, mutations, and endpoints.

import { capsule, query, mutation, endpoint, table, string, number, boolean, json, text } from "pond/server"

Column types

Returned by helpers and used inside table().

string(): ColumnType

A TEXT column.

number(): ColumnType

A REAL column. (SQLite is flexible — both integers and floats round-trip.)

boolean(): ColumnType

An INTEGER column. JS booleans are coerced to 0/1 on write and stay numeric on read; convert at the boundary if you need true booleans in your application code.


Schema

table(columns: T): T

Identity function used as a marker. The shape of columns is what the runtime introspects to issue CREATE TABLE. Every table automatically gets:

So a definition like:

schema: {
  messages: table({
    body: string(),
    author: string(),
  }),
}

produces a messages(id, body, author, createdAt, updatedAt) table.

Validation. Table names and column names must match /^[A-Za-z_][A-Za-z0-9_]*$/, be at most 64 chars, not begin with _pond_, and not be a SQLite reserved word.


Capsule context

Every handler receives a CapsuleContext as its first argument.

interface CapsuleContext {
  auth: CapsuleAuth
  db: CapsuleDb
  env: Record<string, string>
  log: CapsuleLog
  ai: CapsuleAi
  blob: CapsuleBlob
  shopify: CapsuleShopify
}

ctx.auth

Identity of the caller.

interface CapsuleAuth {
  isGuest: boolean
  userId: string
  displayName?: string
  picture?: string
  email?: string
}

isGuest: true means the caller has no signed session — either no cookie, an invalid JWT, or an expired session. The runtime fills in a guest identity via the dev server's guest switcher or a default { userId: "guest", displayName: "Guest" } in production.

Sign-in providers, all opt-in via env vars on .env.pond.server:

You can enable any combination — sessions are JWT-signed against POND_SESSION_SECRET so they survive provider swaps.

ctx.db

Proxy with one property per schema table:

ctx.db.messages.insert({ body: "hi", author: "alice" })
ctx.db.messages.get(id)
ctx.db.messages.update(id, { body: "edited" })
ctx.db.messages.delete(id)
ctx.db.messages.where("author", "alice").orderBy("createdAt", "desc").limit(50).all()
ctx.db.messages.all()

Each CapsuleDbTable exposes:

Method Description
where(column, value) Chainable WHERE clause. Multiple calls AND together.
orderBy(column, "asc" | "desc") Chainable ORDER BY. Multiple calls compose.
limit(n) LIMIT clause.
all() Executes the query. Returns any[].
get(id) SELECT by id. Returns the row or undefined.
insert(data) INSERT with a generated UUID. Returns the inserted row.
update(id, data) UPDATE by id; sets updatedAt. Returns the updated row.
delete(id) DELETE by id.

All column references go through the same identifier validation as schema definitions.

ctx.env

Plain object loaded from .env.pond.server (KEY=VALUE format, # comments). Includes a derived GOOGLE_REDIRECT_URI when not set explicitly.

ctx.ai

Built-in LLM primitive. Routes to the first provider configured in .env.pond.server:

  1. ANTHROPIC_API_KEY → Claude
  2. OPENAI_API_KEY → OpenAI
  3. HERMES_BASE_URL → a local OpenAI-compatible endpoint (Hermes / vLLM / Ollama)

Force a provider with AI_PROVIDER=anthropic|openai|hermes.

const text = await ctx.ai.complete({ prompt: "Summarize this:", system: "Be terse." })
for await (const chunk of ctx.ai.stream({ prompt: "...", model: "claude-sonnet-4-6" })) {
  // yields one full string today; streaming wire format will land per-provider.
}

ctx.blob

Per-deploy key/value blob store. Files live under <deploy-dir>/.pond/blobs/. Metadata (key, contentType, size) is mirrored in the _pond_blobs table.

await ctx.blob.put("avatars/me.png", bytes, { contentType: "image/png" })
const got = await ctx.blob.get("avatars/me.png")
await ctx.blob.delete("avatars/me.png")
const all = await ctx.blob.list("avatars/")

Capsule users can read blobs via GET /api/blob/<key> (subject to whatever auth you wrap on top — there is no built-in ACL).

ctx.shopify

Built-in Shopify Admin API helper. Lets your capsule talk to a Shopify store using a Custom App's Admin API access token — no OAuth, no Partner registration required.

interface CapsuleShopify {
  graphql<T = any>(query: string, variables?: Record<string, unknown>): Promise<T>
}

Env vars

All read from .env.pond.server (or set via pond env set for hosted deploys):

Var Required Default Description
SHOPIFY_SHOP yes Your store's .myshopify.com domain. Accepts my-store (bare name) or my-store.myshopify.com or https://my-store.myshopify.com/.
SHOPIFY_TOKEN yes The Admin API access token from a Shopify Custom App (Settings → Apps and sales channels → Develop apps).
SHOPIFY_API_VERSION no 2025-01 A stable Shopify Admin API version.

Example

const { products } = await ctx.shopify.graphql<{
  products: { edges: Array<{ node: { id: string; title: string } }> }
}>(`{ products(first: 10) { edges { node { id title } } } }`)

Error handling

Deploy requirement

Anonymous deploys block outbound fetch by design (see the security model in deploy/HARDENING.md). A Shopify capsule must be claimed (authenticated) before ctx.shopify.graphql() can reach the Shopify Admin API. After claiming with pond claim <deployId>, set env vars via pond env set <deployId> SHOPIFY_SHOP=... SHOPIFY_TOKEN=... and restart the deploy.

Scaffolding

pond new <name> --template shopify creates a capsule with a products query, a Preact product-table UI, and placeholder env vars in .env.pond.server. See the generated server/index.ts for the full instructions.

ctx.log

ctx.log.info("user signed up", { userId: u.id })
ctx.log.error("payment failed", { reason: err.message })

In dev, entries stream over SSE on /__pond/logs and stay in memory (most recent 200). In prod (pond start), entries also append to <deploy-dir>/.pond/logs.ndjson with rotation at 5 MB.


Handlers

query<TArgs, TResult>(handler: (ctx: CapsuleContext, ...args: TArgs) => TResult | Promise): QueryHandler<TArgs, TResult>

Wraps a function to mark it as a query. The return value is JSON-serialized. Mounted at two routes:

queries: {
  // No-arg read — hit via useQuery("messages").
  messages: query((ctx) => ctx.db.messages.orderBy("createdAt", "desc").all()),

  // Parameterized read — hit via useQuery("postById", id).
  postById: query((ctx, id: string) => ctx.db.posts.get(id)),

  // Multiple args work the same way — useQuery("search", keyword, limit).
  search: query((ctx, keyword: string, limit: number) =>
    ctx.db.posts.where("title", keyword).limit(limit).all()
  ),
}

On the client, useQuery(name, ...args) picks GET when called with no args and POSTs when args are passed. Args are part of the cache key, so changing them refetches automatically.

mutation<TArgs, TResult>(handler: (ctx: CapsuleContext, ...args: TArgs) => TResult | Promise): MutationHandler<TArgs, TResult>

Wraps a function to mark it as a mutation. Mounted at POST /api/mutation/:name. Arguments come from the JSON request body's args field (an array).

mutations: {
  sendMessage: mutation((ctx, body: string) =>
    ctx.db.messages.insert({ body, author: ctx.auth.userId })
  ),
}

endpoint(opts: { method: string; path: string }, handler: EndpointHandler): EndpointDefinition

For custom HTTP routes — Stripe webhooks, REST APIs, etc. The handler receives the context and an EndpointRequest, and returns an EndpointResponse.

endpoints: {
  webhook: endpoint({ method: "POST", path: "/webhooks/stripe" }, async (ctx, req) => {
    const body = await req.text()
    // verify signature, dispatch...
    return json({ ok: true })
  }),
}

EndpointRequest

interface EndpointRequest {
  headers: Headers
  query: Record<string, string>
  json<T>(): Promise<T>
  text(): Promise<string>
  bytes(): Promise<ArrayBuffer>
}

Response helpers

init is { status?: number; headers?: Record<string, string> }.


capsule(def): CapsuleDefinition

The top-level wrapper. Every server/index.ts exports the result as default.

export default capsule({
  schema: { ... },
  queries: { ... },
  mutations: { ... },
  endpoints: { ... },        // optional
  sockets: {                 // optional — WebSocket handlers
    chat: socket((ctx, ws) => {
      ws.on("message", (data) => ws.send(`heard: ${data}`))
    }),
  },
  allowedOrigins: ["..."],   // optional — additional CORS origins
  rateLimit: {               // optional — enforced before the handler runs
    addItem: { perMinute: 60, by: "user" },
    "/api/webhook": { perMinute: 1000, by: "ip" },
  },
  public: true,              // optional — opt into /gallery + pond fork
  static: true,              // optional — serve as a pure static site (no worker)
  title: "...",              // optional — public listing title
  description: "...",        // optional — public listing description
})

By default the capsule's HTTP endpoints reject cross-origin browser requests. List extra origins in allowedOrigins to opt them in.

Static deploys (static: true)

Set static: true to deploy as a pure static site: the host serves your prebuilt client/index.tsx (as client.html) straight from disk and never boots a capsule worker. There is no SQLite, auth, AI, blob storage, or queries/mutations/endpoints/api/* returns 404. In return a static deploy costs no running process, doesn't count against the host's max-active-capsules cap, and scales infinitely at rest. Use it for landing pages, docs, and other content-only sites. A static: true deploy requires a client/index.tsx; the schema/queries/mutations fields are still required by the format but may be empty ({}).

WebSockets

Each entry in sockets mounts at /api/socket/<name> on the same port as the rest of the capsule. The handler receives the same ctx your queries and mutations get (so ctx.auth, ctx.db, ctx.ai, ctx.blob all work) plus a SocketLike:

interface SocketLike {
  send(data: string): void
  close(code?: number, reason?: string): void
  on(event: "message", listener: (data: string) => void): void
  on(event: "close", listener: () => void): void
}

Clients connect at wss://<deployId>.pond.run/api/socket/<name>. The hosted control plane forwards the HTTP Upgrade to the deploy via a raw TCP pipe — no special config needed.

Public capsules + fork

When you mark a capsule public: true, the host exposes:

Anyone with the URL can run pond fork https://<id>.pond.run to scaffold a local copy ready for pond dev.

Rate limits

rateLimit[routeName] matches query/mutation/endpoint names. by: "user" keys on the session cookie; by: "ip" (default) keys on the client IP. Buckets are rolling, in-process — restart resets them. Over-limit requests get a 429 Rate limited with Retry-After: 60.

Schema migrations

The runtime tracks tables in _pond_migrations. On boot:

Schema additive change example

// Before
schema: {
  items: table({ body: string() })
}
// After — adds `done` automatically the next time the capsule boots
schema: {
  items: table({ body: string(), done: boolean() })
}

/__pond/metrics

Every running capsule exposes a Prometheus text-format snapshot at /__pond/metrics. Counters: pond_route_requests_total, pond_route_errors_total, pond_route_duration_ms (rolling p50/p95/p99). No auth — wrap with whatever proxy auth you use externally.


Lifecycle

  1. pond dev or pond start esbuilds server/index.ts into .pond/server.mjs (dev) or imports .pond/deploy-bundle.mjs (prod).
  2. The runtime creates / opens SQLite at .pond/data.db with WAL mode.
  3. For each table in def.schema, the runtime runs CREATE TABLE IF NOT EXISTS … (only the first time per table; tracked in _pond_migrations).
  4. Routes get mounted:
    • GET /api/query/<name> for each entry in def.queries
    • POST /api/mutation/<name> for each entry in def.mutations
    • <method> <path> for each entry in def.endpoints
    • Plus the built-in /auth/google, /auth/google/callback, /auth/me, /auth/signout.
  5. The runtime serves on the chosen port (default 3000).

There is no migration system beyond "create on first sight" right now. Schema changes that aren't additive require manual SQL against .pond/data.db.