Plax framework
The app framework Camplax ships — routes, owner-scoped data, sign-in, in-app agents, and a manifest agents can read.
Plax is the framework every camplax create app is built on — and the framework your coding agent writes in when it works on your repo. It is deliberately small: a Node HTTP server, owner-scoped resources, Better Auth sign-in, server-only AI, and in-app agents. What makes it different is not the feature list but the shape: there is one way to do each thing, the unsafe way fails closed, and the app can describe itself.
Routes and auth
createPlaxApp takes a route table. Keys look like GET /api/notes or GET /api/notes/:id — :param segments capture into ctx.params. A route is either a plain handler (public) or { auth: true, handler }, which resolves the Better Auth session before the handler runs and guarantees ctx.user.
const app = createPlaxApp({
session: sessionLookup,
staticDir: "public",
routes: {
"GET /probe": (_req, res) => sendOk(res, { ok: true }),
"GET /api/me": {
auth: true,
handler: (_req, res, ctx) => sendOk(res, { user: ctx.user }),
},
},
});
An auth: true route without a session lookup throws at boot, not at request time. Errors thrown in handlers return a generic 500 — internal details are logged, never sent to the client.
Resources — data with an owner
defineResource is the only way a Plax app touches a table. Every read, update and delete carries ownerField = session.user.id inside the query itself; the client cannot set id or the owner; only fields are writable; required and validate gate creates. A row you cannot see answers 404, not 403.
resource.routes() mounts the whole REST surface — GET/POST on /api/<name>, GET/PATCH/DELETE on /api/<name>/:id — already auth-guarded, with ?limit/?offset pagination capped by the store. postgresStore parameterizes every value and validates every configured identifier, so hand-built identifiers cannot inject SQL.
const notes = defineResource<Note>({
name: "notes",
fields: ["title"],
required: ["title"],
store: postgresStore<Note>({ client: db, table: "notes", columns: { /* … */ } }),
});
routes: { ...notes.routes() }
camplax add resource <name> writes the whole slice — the migration, the resource, the mount instructions — in one step.
Agents inside your app
agent() defines an in-app agent: a name, a system prompt, tools, a thread store, and a budget. agent.routes() mounts POST /api/<name>/chat, auth-guarded like everything else.
const support = agent({
name: "support",
ai,
system: "You help users with their orders.",
tools: {
lookupOrder: tool({
args: { id: "string" },
run: ({ id }, { user }) => orders.get(user, id),
}),
},
threads: postgresThreads(db),
budget: { capUsdPerDay: 0.10, store: postgresBudget(db) },
});
The loop is the standard one — model, tool calls, validated arguments, results, repeat — capped by maxSteps. Three properties come from the platform, not the framework:
- Tools cannot cross users.
lookupOrdercallsorders.get(user, id)— the resource layer scopes it, so user A's agent cannot return user B's rows. - Spend is capped per end-user. The budget table is checked before every model call;
429 budget_exceededwhen spent, and your project's credit wallet still applies beneath it. - Dangerous tools pause.
approval: { refundOrder: "user" }returnspendingApprovals; the client POSTsapprove/denyto resume. Threads persist, so the loop survives the round trip.
camplax add agent <name> writes the agent file and the threads/usage migration.
Streaming. POST "stream": true to /api/<name>/chat and the reply is server-sent events, token by token:
| Event | Carries |
|---|---|
thread | the thread id, first |
reasoning_delta | a chunk of the model's thinking, as it arrives |
message_delta | a chunk of the reply text, as it arrives |
tool_call / tool_result | a tool starting / finishing, with args and result |
approval_required | the loop paused on an approval-gated tool |
message | the assembled reply text |
error / done | a typed failure / the end of the stream |
Reasoning models stream their thinking through reasoning_delta, and the reasoning is persisted on the thread and replayed on later turns so the model keeps its context. Without "stream", the route answers one JSON reply — same loop, same envelope. Either way, closing the connection aborts the in-flight model call, so a disconnected client cannot keep burning credits.
Any provider. The ai in agent({ ai }) is createPlaxAi — the Camplax gateway by default, but chatUrl + token point the same agent at any OpenAI-compatible endpoint (OpenAI, OpenRouter, Groq, Ollama, a LiteLLM proxy). The key stays server-side either way.
The manifest
app.manifest() emits plax.manifest.json — routes with auth flags, resources with fields and tables, agents with tools and budgets, required and injected env vars, cron, plugins. It is the app's contract with everything around it:
- Coding agents read it instead of guessing the surface.
camplax checkvalidates the app against it — declared env present, resources wired, typecheck green — in one--jsonreport.- The platform can diff what an app says it needs against what the project provides.
The starter writes it at boot via writeManifest(app.manifest(), root).
Why it looks like this
Plax is built for the agent era in both directions. For the agents writing your app: one way per thing, a manifest to read, and camplax check as the feedback loop. For the agents inside your app: tools that cannot leak across owners, spend that stops at zero, and threads that are just rows in your own database. And it stays Camplax-shaped throughout — Better Auth on your own Postgres, AI through the gateway on prepaid credits, observability through the same-origin proxy.