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.
An agent often needs a companion app: a small app deployed alongside it. Agents cannot own files or storage directly, so they work through an app they have data access to. This is why most agent work takes this shape.
Use the pattern whenever the agent relies on files or records that someone must manage, or when people need a place to trigger it and see its output.
What the app provides
- Storage the agent relies on. The app is the UI for uploading and managing the files
and records the agent reads:
files.put()anddbin the app;app_files: [<slug>]andapp_data: [<slug>]in the agent's manifest. - A surface for results. The agent writes back through
app_data_write(app_kv_set,publish_artifact_to_app), and the app renders run outputs. - A way to trigger it. A backend route wired to
agents.start(name, input), with the app manifest declaringagents: [<name>], exercises the agent end to end far faster than hand-crafting CLI runs. It also doubles as the production trigger.
Driving a run from the backend function
app.post("/api/extract", async (c) => {
const run = await agents.start("extractor", { file: name });
await db.collection("jobs").put(run.request_id, {
owner: ctx.user!.id, status: "running", file: name,
});
return c.json({ requestId: run.request_id }, 202);
});
app.get("/api/extract/:id", async (c) => {
const job = await db.collection("jobs").get(c.req.param("id"));
if (!job || job.owner !== ctx.user!.id) return c.json({ error: "not found" }, 404);
const run = await agents.get(c.req.param("id"));
return c.json({ status: run.status, output: run.output_json });
});Four things to know:
agents.start()returns the queued run immediately. Handrequest_idto the frontend and let it poll a route that callsagents.get(). There is no backend call that waits, and aget()loop is not a substitute: it spends a subrequest per poll, and its token expires before a long run ends.- The app must declare the agent.
agents: [<name>]inmanifest.yaml. A missing declaration is a 403 refusal rather than pass-through, even for a caller who could invoke it from the dashboard. - A run is owned by
(app, caller). The app can only read runs it started, for the caller who started them. A dashboard-started run is invisible to it. - An app cron cannot start a run (409: no caller means no run owner). For scheduled work, give the agent its own schedule.
Prefer an org agent
An org agent's app_data_write lands in the app's shared scope, which is the app's flat
db store. Results appear in a collection the backend function already reads, with no bridge
at all.
A personal agent writes into its owner's user scope, which on a migrated app is frozen and read-only from the backend function.
Naming and shape
Name the app after the agent (agent report-extractor, app report-extractor-console) and
declare the narrowest slugs on both sides.
Worked examples
Both of these are a companion app plus its agent manifests, in the apps-v2 shape:
| Example | What it is | Shows |
|---|---|---|
agents/pitch-deck | An app for uploading company materials, paired with an agent that writes a pitch-deck PDF from them | app_data / app_files access, code execution, and a run started from the backend function because a backend function cannot hold one open |
agents/proposals | An agent that watches meetings on a cron and drafts an editable .docx proposal, paired with an app that displays them | A personally owned connector, an agent-owned cron schedule, and why that schedule cannot live on the app |
Copy one directory as plain files:
mkdir -p my-proposals
curl -fsSL https://github.com/Railcode-HQ/railcode-examples/archive/refs/heads/main.tar.gz \
| tar -xz --strip-components=3 -C my-proposals railcode-examples-main/agents/proposalsThen make the copy your own before you write behavior:
- Rename each
agents/<name>/directory and the manifest'sname, so it does not collide with an agent that already exists. - Point
app_data/app_files/app_data_writeat your renamed app slug, and keepvisibilityright for the tools declared. - Cut every tool the new agent does not need. A copied manifest carries the example's
authority rather than the narrowest set for your job. Re-size
limitsfor the new workload. - Rewrite the
systemprompt for the new contract. The example's prompt encodes its own procedure. - In the app: set
appinrailcode.json, update theagents:list inmanifest.yaml, and update everyagents.start/agents.getcall.