Reference

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-only

There 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 events

llm.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 one

Manifest: 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 DSN

Read-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-app

Set 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;
}
StatusMeans
403Undeclared authority
409Not connected, or cron-incompatible
429A 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/refresh
railcode manifest validate        # strict local parse, before deploying
railcode manifest show <app>      # the ratified doc + who ratified it

Declare 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.

On this page