Platform
Hosting & deploysPush to a branch, get a live linkDatabaseReal Postgres, one per appSign-inEmail and Google, users includedFile storageUploads, photos and documentsAI gatewayEvery model, with a hard spend cap
Resources
DocsGuides written for people who are newFor AI agentsClaude Code, Cursor, MCP, JSON CLICompareCamplax beside the stack it replacesPricingFrom $25/month plus prepaid creditsSupportA person answers, not a queue botPoliciesTerms, privacy and how we handle data Get started
Open in ChatGPT Open in Claude Last updated 2026-11-07

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.

Running it

A Plax app runs with the plax command, the way a Next.js app runs with next. The starter's scripts already use it:

plax dev     # run the app and restart it when a file changes
plax build   # check the types and the entry, as every deploy does
plax start   # run the app for production

The app starts from src/server.ts. There is no compile step: Node runs the TypeScript directly (Node 22.18 or newer; Camplax builds use 24), and plax start runs it in a single process with nothing wrapped around it. camplax dev runs plax dev with your project's database, AI and storage.

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. With a threads store it also mounts the user's chat history:

RouteReturns
GET /api/<name>/threads{ threads: [{ id, title, createdAt }] }, newest first; ?limit 1–100, default 50
GET /api/<name>/threads/:id{ thread: { id, title, createdAt, messages: [{ role, content }] } } — user and assistant text, the latest 500
DELETE /api/<name>/threads/:id{ id } — the thread and its messages

Each one is owner-scoped in the same query, so another user's thread is 404, exactly like a missing one.

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. lookupOrder calls orders.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_exceeded when spent, and your account's prepaid credits still apply beneath it.
  • AI off and empty credits are states, not crashes. While AI is off for the project, ai.chat (and an agent) answers 409 ai_off; when the prepaid credits run out, 402 no_credits; when the project's own AI spend cap is reached, 402 ai_spend_cap. Each carries href, the console page that fixes it, so the app's page can say so in plain words and link there. isAiStateRefusal(code) tells these apart from failures. ai.available() asks the gateway whether AI is on without a model call (it does not see the credit balance or the cap; ai.chat reports those when it meets them).
  • Dangerous tools pause. approval: { refundOrder: "user" } returns pendingApprovals; the client POSTs approve/deny to resume. Threads persist, so the loop survives the round trip.

camplax add agent <name> writes the agent file and the threads/usage migration.

Per-user prompt and model. system and model also take a function of the signed-in user and the input, resolved once per request — every step of that turn sees the same values: system: async (user) => "Facts about this user: " + (await memories.forPrompt(user)), model: (user) => (flags.smart(user) ? "openai/gpt-4.1" : "openai/gpt-4.1-mini"). No need to build a new agent per request. If a resolver throws, the request fails with 500 failed before a thread is created.

Images. Send images with a message — https:// or data:image/…;base64, URLs, up to 8 — and a vision model sees them for that whole turn, tool steps included: support.respond(user, { message: "What is this?", images: [dataUrl] }), or the same fields in the POST body. The thread stores the text and an [N images attached] note, not the image, so a photo never lands in your database or gets re-sent on every later turn. The chat route caps JSON bodies at 256 KB; set maxBodyBytes on the agent if browsers POST photos as data URLs. For a one-off call, ai.chat takes the same OpenAI-style parts directly: content: [{ type: "text", text }, { type: "image_url", image_url: { url } }].

Streaming. POST "stream": true to /api/<name>/chat and the reply is server-sent events, token by token:

EventCarries
threadthe thread id, first
reasoning_deltaa chunk of the model's thinking, as it arrives
message_deltaa chunk of the reply text, as it arrives
tool_call / tool_resulta tool starting / finishing, with args and result
approval_requiredthe loop paused on an approval-gated tool
messagethe assembled reply text
errora typed failure
donethe end of the stream, with usage for the turn

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.

Reasoning and usage. reasoning sets how hard a reasoning model thinks: an effort — reasoning: "high", or "none" to switch thinking off — the full object ({ max_tokens: 2000 }, { effort: "medium", exclude: true }), or (user, input) => … like model. It is sent on every step of the turn, tool calls included; unset, the model's default applies. done carries usage — promptTokens, completionTokens, reasoningTokens, cachedTokens and costUsd in dollars, summed over every model call in the turn, plus calls — and the JSON reply carries the same usage. costUsd is null when the provider reported no price. 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, 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 check validates the app against it — declared env present, resources wired, typecheck green — in one --json report.
  • 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.

Camplax

Hosting, database, sign-in and storage in one project. One login, one bill.

Platform
More
Legal
Company
© 2026 Camplax From $25 / month · credits stop at zero