Managed agents

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() and db in the app; app_files: [<slug>] and app_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 declaring agents: [<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:

  1. agents.start() returns the queued run immediately. Hand request_id to the frontend and let it poll a route that calls agents.get(). There is no backend call that waits, and a get() loop is not a substitute: it spends a subrequest per poll, and its token expires before a long run ends.
  2. The app must declare the agent. agents: [<name>] in manifest.yaml. A missing declaration is a 403 refusal rather than pass-through, even for a caller who could invoke it from the dashboard.
  3. 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.
  4. 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:

ExampleWhat it isShows
agents/pitch-deckAn app for uploading company materials, paired with an agent that writes a pitch-deck PDF from themapp_data / app_files access, code execution, and a run started from the backend function because a backend function cannot hold one open
agents/proposalsAn agent that watches meetings on a cron and drafts an editable .docx proposal, paired with an app that displays themA 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/proposals

Then make the copy your own before you write behavior:

  • Rename each agents/<name>/ directory and the manifest's name, so it does not collide with an agent that already exists.
  • Point app_data / app_files / app_data_write at your renamed app slug, and keep visibility right 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 limits for the new workload.
  • Rewrite the system prompt for the new contract. The example's prompt encodes its own procedure.
  • In the app: set app in railcode.json, update the agents: list in manifest.yaml, and update every agents.start / agents.get call.

On this page