Before writing any code there are four decisions to make. Each one is recorded on the agent record and each one changes which parts of the platform you get.
1. Who controls the lifecycle?
This is the managed_by field, and it is the most consequential choice.
platform — Platform-Managed | standalone — Standalone | |
|---|---|---|
| Who starts runs | The platform, via the runtime's REST API | You do, however you like |
| Run Now button | Yes | No |
| Schedules | Yes, owned by the platform | No — use your own cron |
| Cancel | Yes | No |
| Input surface | Yes (zone app or JSON editor) | No |
| Credential management | Yes | No |
| Execution logs | Yes, fetched live from the runtime | No |
| Run history + run detail | Yes | Yes |
Choose platform unless you have a specific reason not to. It is what this tutorial builds, and it is the only mode where the platform can trigger, schedule, cancel, resolve credentials, or show logs.
Standalone: when you only need a monitoring window
Pick standalone when the agent already has a home — its own scheduler, its own triggers, its own secrets — and all you want from the platform is a shared place for the customer to see run history. The platform becomes a read-only window: your code POSTs run data to the ingest endpoint at the end of every run and the platform displays it.
POST /api/agents/{agentId}/runs/ingest
Authorization: Bearer {ingest_api_key}
{ "externalRunId": "run_abc123", "status": "completed",
"startedAt": "...", "completedAt": "...", "durationMs": 68000,
"output": { ... }, "outputs": 12 }
externalRunId is the idempotency key — call it repeatedly to move a run from running to completed. Full contract in the ingest reference, and the SDK wraps it as ingestRun().
If that is your case, most of this tutorial does not apply: you can skip credentials, triggering, schedules, and the executor entirely. You may still build a zone app for a bespoke view.
2. Which runtime?
| Trigger.dev | n8n | |
|---|---|---|
| Best for | Long-running code, complex logic, human-in-the-loop | Visual workflows, integrations assembled without code |
| Waitpoints (pause for a human) | Yes | No |
| Logs | Rich OpenTelemetry traces | Available, can be very large |
| Per-agent config | taskId | webhookUrl + optional webhookHeaders |
This tutorial uses Trigger.dev. One n8n rule is worth knowing even if you never use it: an n8n workflow on this platform must start with a Webhook Trigger node, never a Schedule Trigger — all scheduling is platform-side.
3. Zone app, or the built-in JSON editor?
Every platform-managed agent gets an input surface. There are exactly two kinds and you do not configure this on the agent — it follows from whether a zone is registered.
- No zone registered → the platform serves a generic JSON editor at
/{orgSlug}/{agentSlug}/input. Three buttons: Save & Run, Save, Run. The agent owns the shape of the JSON; the editor only validates syntax. Output is shown on the run detail page at/runs/{runId}. - Zone registered → a standalone Next.js app you build and deploy, served at
/{orgSlug}/{agentSlug}/appand reached from a single App link in the sidebar.
Start with the JSON editor. It costs nothing, and it is enough to get the agent working end to end before you invest in UI. Add a zone when the interaction genuinely needs it — review screens, multi-step input, dashboards, conversation.
In this tutorial nightly-etl never gets a zone (it has no interactive input at all), and revenue-chat does.
These are not exclusive choices, and neither is scheduling. One agent can have a zone app and schedules and accept external triggers — the surfaces are independent, and nothing on the agent record couples them. A common shape is exactly that: an agent that ingests on a schedule and also has a zone showing the results with a "run it now" button, both reaching the same task. The tutorial keeps its two examples disjoint only to keep each one small.
4. Where does the data live?
The platform persists JSONB and nothing else. There are no file buckets and no per-agent tables. Concretely:
- Input is a versioned JSON snapshot, linked to the run that used it.
- Output is JSON on
runs.output_data, delivered when the run completes. - Anything else — files, images, relational state, chat history — lives in your infrastructure, and the JSONB references it by URL or id.
So the revenue integration keeps its warehouse tables and chat history in its own database, and runs.output_data carries the answer plus enough metadata for the run detail page to be useful.
One exception worth knowing: an agent whose product is a generated file can set its output kind to file download and stream bytes straight through the platform to the caller. Even then the bytes are never stored — output_data holds only { kind, filename, mimeType, sizeBytes }.
How a run actually flows
Worth reading once before you write the task, because the payload contract falls straight out of it:
Trigger (Run Now / schedule / zone / external call)
│
▼
Platform
• creates the run row
• looks up connection ids for the agent's credential requirements
• signs a short-lived credentials token (60 min)
• calls the runtime's REST API
│ payload: { runId, input, connectionIds, credentialsToken }
▼
Your task
• resolves credentials: GET /api/credentials/{connectionId}/resolve
• Authorization: Bearer {agent_api_key}
• X-Credentials-Token: {credentialsToken}
• does the work
• returns its result, or POSTs /api/runs/{runId}/callback
│
▼
Platform
• marks the run terminal, stores output_data
• fires any configured actions (email, Slack, webhook, another agent)
Two things to notice.
Credential values never travel in the trigger payload. What the payload carries is a connection id per credential requirement and a credentialsToken — the two halves of a claim ticket. Exchanging them for the actual secret takes a third factor the payload does not contain: the agent's own API key, which your task reads from its environment. So the secret only ever materialises inside your task's execution context, and a leaked payload alone resolves nothing.
Calling back needs no extra plumbing. The payload has no callback URL and no callback-specific token, because the callback endpoint is addressed by the runId you were given and authenticated by that same agent API key. Note that credentialsToken plays no part here — it authorises credential resolution only, which is why it can expire after 60 minutes while a long run keeps reporting progress perfectly well.
Keys, and which is which
Three different secrets show up in this tutorial. Mixing them up is the single most common source of 401s.
| Key | Held by | Authenticates |
|---|---|---|
agent_api_key | Your task code | Resolving credentials, and the run callback / waitpoint endpoints |
ingest_api_key | Whatever starts runs from outside | Trigger, trigger-and-wait, register, ingest |
zone_jwt_secret | Your zone app | Verifying the per-request JWT the platform mints for each visitor |
Next
With those settled, check who needs to do what: Roles and permissions.