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:
id TEXT PRIMARY KEY(UUID, generated byinsert())createdAt TEXT DEFAULT (datetime('now'))updatedAt TEXT DEFAULT (datetime('now'))(touched on everyupdate())
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:
- Google — set
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_REDIRECT_URI. Users hit/auth/google.userIdbecomes<google-sub>. - GitHub — set
GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET, optionalGITHUB_REDIRECT_URI. Users hit/auth/github.userIdbecomesgithub:<gh-id>. - Email magic links — no extra config required. Capsule client
POSTs{ email }to/auth/email/request, the runtime stores a one-time 15-minute token in_pond_magic_links, then prints the verification link to logs unlessRESEND_API_KEY+EMAIL_FROMare set (in which case it ships the link via Resend). The link lands on/auth/email/verify?token=…and creates a session.userIdbecomesemail:<addr>.
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:
ANTHROPIC_API_KEY→ ClaudeOPENAI_API_KEY→ OpenAIHERMES_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
- If
SHOPIFY_SHOPorSHOPIFY_TOKENis missing, throws"Set SHOPIFY_SHOP and SHOPIFY_TOKEN via \pond env set` to use ctx.shopify.graphql()"`. - On a non-2xx HTTP response, throws with the Shopify error message (e.g.
"Shopify API error: Invalid API key or access token"). - On a GraphQL
errorsarray in a 200 response, throws with the first error message (e.g."Shopify GraphQL error: Field 'blah' doesn't exist on type 'QueryRoot'").
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:
GET /api/query/:name— for the no-arg case. Cacheable, shows up cleanly in the network panel.POST /api/query/:namewith{ args: [...] }— for parameterized reads. Arguments are spread into the handler.
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
json(body, init?)→application/jsonwith status 200 by default.text(body, init?)→text/plainwith status 200 by default.
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:
GET /api/public-deploys— listed at/gallery(the bare host renders a small JS client over this).GET /api/public-deploys/:id/source— unauthenticated source-tree export.
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:
- New tables are created with
CREATE TABLE IF NOT EXISTS …. - New columns are added via
ALTER TABLE ADD COLUMN. - Removing a column from your schema raises an error — to actually drop or rename a column you must run
pond db migrate --drop <table>.<col>(or--rename …) explicitly. The runtime refuses destructive changes silently.
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
pond devorpond startesbuildsserver/index.tsinto.pond/server.mjs(dev) or imports.pond/deploy-bundle.mjs(prod).- The runtime creates / opens SQLite at
.pond/data.dbwith WAL mode. - For each table in
def.schema, the runtime runsCREATE TABLE IF NOT EXISTS …(only the first time per table; tracked in_pond_migrations). - Routes get mounted:
GET /api/query/<name>for each entry indef.queriesPOST /api/mutation/<name>for each entry indef.mutations<method> <path>for each entry indef.endpoints- Plus the built-in
/auth/google,/auth/google/callback,/auth/me,/auth/signout.
- 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.
Related docs
docs/client-reference.md— client-side hooks (pond/client).README.md— overall introduction, quickstart, hosted control plane, security model.CONTRIBUTING.md— local dev loop, where to add code, PR checklist.