Backend SDK
The complete @railcode/sdk surface: ctx, db, files, SQL, llm, agents, connectors, email, secrets, errors, and the app manifest.
The SDK for backend functions. Typed, dependency-free ES module with no secrets or addresses inside it. The platform injects power per invocation.
It runs in exactly two places: a deployed backend function and railcode dev. Anywhere else
(a browser bundle, a plain node script, a test harness) the first call throws an
instructive error. There is no browser build.
import { ctx, db, files, llm, agents, query, connector, email, secrets } from "@railcode/sdk";ctx: the verified caller
ctx.user // { id, email, name, is_admin, roles: [{uuid, name}] } | null
ctx.trigger // "http" | "cron"
ctx.invocationId // this invocation's id, and your idempotency key
ctx.waitUntil(p) // extend past the response (deployed backend functions only)ctx.user is unforgeable. The platform verified it at the proxy and embedded it in the
invocation token. App code cannot fake its caller. It is null only on cron triggers.
const user = ctx.user;
if (!user) return c.json({ error: "cron cannot do this" }, 409);
if (!user.is_admin) return c.json({ error: "forbidden" }, 403);ctx.user.roles carries the caller's own roles. There is no way to list the org's full role
set from a backend function.
db: the flat store
One flat key/value store per app, with no scopes, namespaces, or built-in per-user partition.
const notes = db.collection("notes");
await notes.put("key", { title: "hi" });
await notes.get("key"); // null when absent
await notes.delete("key");
await notes.query().where("status", "=", "open").order("created_at", "desc").page(1, 100);You own partitioning and access control. See data and files.
query() returns one page
Default 100, max 500. A large collection silently loses its tail unless you paginate:
const out = [];
for (let page = 1; ; page++) {
const rows = await db.collection(name).query().page(page, 500);
out.push(...rows);
if (rows.length < 500) break;
}Frozen scopes (migrated apps only)
After railcode migrate, the app's old user/role-scoped browser data is frozen. It is
readable for live migration and writable by nothing.
await db.scoped(userId).collection("drafts").query(); // read-only
await db.scopedRole(roleUuid).collection("shared").get(k); // read-onlyThere is no write path. Copy what you need into the flat store under your own keys.
files
await files.put("report.pdf", bytes, "application/pdf"); // string | ArrayBuffer | Blob | stream
const resp = await files.get("report.pdf"); // Response | null
await files.list();
await files.delete("report.pdf");
const one = await files.url("report.pdf"); // { name, url, expires_in }
const batch = await files.urls(["a.png", "b.png", "c.png"]); // { items, missing }Use urls() for more than one file. A loop of url() uses up the subrequest budget of
about 100. Names with no stored file come back under missing rather than throwing. Cap: 100
names per call (422 above it).
SQL and saved queries
await savedQueries(); // [{ name, description, params }]
await query("revenue_by_month", { year: 2026 });
await data("warehouse").runSQL("select * from orders where id = $1", [id]);
await postgres("warehouse").runSQL(...); // dialect-pinned variants
await bigquery("analytics").runSQL(...);
await turso("edge").runSQL(...);Manifest: saved_queries: for the first, adhoc_sql: for the second. Always bind
parameters.
llm
const r = await llm.generate({ messages: [{ role: "user", content: "..." }] });
const stream = await llm.stream({ messages });
await llmProviders();Text in, text out. There are no embeddings, vector search, or multimodal input. Manifest:
llm: true. Per-app daily token cap; exceeding it returns a typed 429.
Tool loops
Tools that carry a run make the SDK drive the loop. It validates each call's args against
the schema, executes run in your backend function, feeds summarize(result) back, and
repeats.
await llm.generate({ messages, tools }); // finished answer
for await (const ev of llm.stream({ messages, tools })) {} // text + step eventsllm.streamRaw() hands you the ndjson bytes to relay to a browser, so nobody is left in the
backend function to execute a run. It refuses tools that carry a run and accepts
definitions without one. Requires @railcode/sdk 0.3.0 or later.
toNdjson()
app.post("/api/chat", async (c) => {
const { messages } = await c.req.json();
return toNdjson(llm.stream(messages, { tools }));
});Never hand-roll the ReadableStream. See AI in an app.
Managed agents
const run = await agents.start("digest", { url }); // returns QUEUED immediately
run.request_id // the poll handle
const later = await agents.get(run.request_id);Runs are never awaited while the request is open. There is no agents.invoke() for
backend functions, and a get() loop is not a substitute: each poll spends a subrequest, and
the token expires before a long run finishes. Persist the handle instead:
const run = await agents.start("digest", input);
await db.collection("jobs").put(jobId, { requestId: run.request_id });
return c.json({ status: "running", requestId: run.request_id }, 202);The browser SDK does have agents.invoke(), and the asymmetry is deliberate. A page has
neither a subrequest budget nor an expiring token, so it can wait out a long run without
holding anything open on the server side.
Manifest: agents: [name]. A missing declaration is a 403 refusal rather than pass-through.
An agent that does not exist is a 404. A run is owned by (app, caller). Cron cannot start
or poll one (409 both ways).
Connectors
One surface for both a shared org credential and an account somebody linked personally. Ownership and access mode live on the row.
await serviceConnectors(); // what this app may call
await serviceConnectorDocs("stripe"); // how to call it
const r = await connector("stripe").fetch("/v1/charges", { method: "GET" });
await connector("gmail-jp").tools(); // mcp: callable tools
await connector("gmail-jp").call("send_email", { ... }); // mcp: run oneManifest: connectors: { stripe: ["GET /v1/charges"] }, bound per endpoint, or ["*"] for
a whole row. A missing declaration is a refusal rather than pass-through. An app that declares
gmail-jp: ["send_email"] can send as that account and cannot read its inbox.
Linking left the app. There is no connect() and no redirect_url to return to a
frontend. The person runs railcode connector link gmail or links from the dashboard. Do not
build a connect flow.
Names are not provider ids. Read the name from railcode connector list.
Connectors work under cron. The credential belongs to the row, not the caller.
appUsers and dataConnectors
await appUsers(); // [{ id, email, name, is_admin }]; id matches ctx.user.id
await dataConnectors(); // [{ name, engine }], never a DSNRead-only org discovery, for building pickers and showing names. Both require the ratified
run_as: app manifest.
email
await email.send({ to, subject, html });Send-only, with a platform-pinned sender and an appended disclaimer. You cannot receive email
or send from a custom address. Manifest: email: true. Per-app daily cap; exceeding it
returns a typed 429.
secrets
secrets.STRIPE_KEY // ambient, per-appSet them with the CLI, never in code. Write-only, and live app state rather than part of a deploy. Caps: 64 per app, 5 KB per value. See deploying.
Errors
import { ApiError, LlmRunError } from "@railcode/sdk";ApiError carries .status and the body. Relay it unchanged, because the frontend's
403/409/429 handling depends on the status surviving the extra hop:
try {
return c.json(await db.collection("notes").get(key));
} catch (err) {
if (err instanceof ApiError) return c.json(JSON.parse(err.message), err.status);
throw err;
}| Status | Means |
|---|---|
403 | Undeclared authority |
409 | Not connected, or cron-incompatible |
429 | A daily cap (typed quota) |
The app manifest
manifest.yaml sits beside railcode.json. run_as: app is mandatory: the backend function
is the principal, and there is no caller whose personal grants could stand in.
run_as: app
llm: true
email: true
saved_queries:
- revenue_by_month
adhoc_sql:
- warehouse
connectors:
stripe:
- GET /v1/charges
gmail-jp:
- send_email
agents:
- digest
egress:
- api.example.com
- "*.internal.example.com"
crons:
- schedule: "0 6 * * *"
path: /api/refreshrailcode manifest validate # strict local parse, before deploying
railcode manifest show <app> # the ratified doc + who ratified itDeclare only what the backend function uses. Two keys are refusals rather than pass-through
when absent: agents and connectors. adhoc_sql is intentionally scarce; prefer
saved_queries.
See deploying for how a diff is ratified.
Companion apps
Agents can't own storage, so they work through an app. The pairing pattern, and four things to know about driving a run from a backend function.
CLI reference
Every railcode command, grouped by what it operates on: apps, storage, agents, data, connectors, and organization administration.