A run that takes ninety seconds and shows nothing feels broken. This page covers what the platform tracks, what you should report, and how to render it.
1. Run states
queued ──→ running ──┬──→ completed
├──→ failed
├──→ cancelled
└──→ waiting ──→ running ──→ …
waiting is the human-in-the-loop state: the task has paused and is waiting for a person. It is only available on runtimes that support waitpoints — Trigger.dev does, n8n does not.
Every run also records how it started — manually, on a schedule, from an external call, or as the result of another agent's action — which is what lets a customer tell an unexpected 3 a.m. run from one somebody asked for.
2. Reporting progress from the task
All of this is optional. A task that reports nothing at all is still tracked, because the platform watches the run from the outside:
- Status comes from polling. Each run gets its own poll job at the agent's status poll interval — 60 seconds by default — and the platform asks the runtime how the run is doing. On the transition to a terminal state it fetches the result and records the output, the duration, and the
outputscount. - Failures also arrive by alert webhook. The Trigger.dev alert you configured with the runtime environment in Platform setup fires the moment a run fails, so a failure is usually recorded before the next poll tick.
- Stuck runs are caught too. A run that never leaves
queued, or that exceeds the agent's Max run duration, is failed by the platform rather than left hanging forever.
So the question this section answers is not how do I get my run tracked — that is already handled — but what does reporting add. Two things: immediacy, and states the platform cannot observe from outside. Progress is the clearest example, since between the start and the end of a run polling sees nothing at all.
The rest of this page is worth the effort when a person is watching the run. For an unattended job like nightly-etl, skip to what the platform gives you for free — the run history page is the whole UI it needs. See also Reporting state back for the terminal-state half of the same trade.
Two mechanisms, and they answer different questions.
Intermediate output goes to the platform and is durable:
await cxpa.output({ output: { step: 'querying warehouse', rowsScanned: 12_400 } })
Use it at meaningful milestones. The value is stored on the run, so it survives a page reload and is visible on the run detail page.
Runtime metadata is for fine-grained progress that only the live UI cares about:
import { metadata } from '@trigger.dev/sdk'
metadata.set('step', 'planning')
metadata.append('steps', { at: Date.now(), label: 'executed 3 queries' })
This never reaches the platform — it streams to whoever is subscribed to the run on the runtime. It is the right tool for a chat UI showing "thinking…", and the wrong tool for anything you need after the run ends.
A useful division: metadata for the spinner, output() for the record. Do not push large row sets through either — a bloated run output slows every read of that run, and the run detail page has to render it.
Batch your output() calls. The callback endpoint is limited to 20 requests per minute per agent, shared with waitpoint completions, so a task reporting once per processed item exceeds it on any batch bigger than about twenty a minute — and then progress reporting starts failing while the run itself is fine. Report every N items, or every few seconds, rather than every item. Runtime metadata has no such limit, which is another reason to prefer it for fine-grained progress. See Limits and quotas.
3. What the platform gives you for free
The run detail page at /acme/revenue-chat/runs/{runId} is the universal output surface for every agent on the platform. It renders input and output JSON, timing, trigger type, status, and errors. Status updates arrive live — no polling on your part.
Execution logs are fetched on demand from the runtime, not stored by the platform. The UI polls while a run is in flight and fetches once for a historical run. The runtime is the log database; if it is unavailable the page degrades to showing status from the platform's own record. Platform admins also get a deep link to the Trigger.dev dashboard for full traces.
That is enough for nightly-etl — a scheduled data job needs no bespoke UI, and the run history page is exactly the right surface for it. Only build more when a user is actually waiting on the result.
4. Subscribing from a zone app
For an interactive agent, the zone shows progress itself. With Trigger.dev that means subscribing to the run:
'use client'
import { useRealtimeRun } from '@trigger.dev/react-hooks'
const { run, error } = useRealtimeRun(runId, { accessToken })
The access token is minted server-side, scoped to a single run:
import { auth } from '@trigger.dev/sdk'
export async function mintRunToken(runId: string) {
return auth.createPublicToken({
scopes: { read: { runs: [runId] } },
expirationTime: '1h',
})
}
Minting per run matters: a client that did not originate the trigger — a reloaded page, a second tab, a user returning later — still needs to subscribe, and a token scoped to one run is safe to hand to the browser.
Always add a polling fallback
This is the lesson that costs the most to learn in production. A realtime error is a transport failure, not a run failure. The stream can drop while the run completes perfectly well. If your UI treats error as "the run failed", users see failures that did not happen.
Treat the two independently: on a stream error, fall back to polling a server action that reads the authoritative state.
export async function getRunResult(runId: string) {
const run = await runs.retrieve(runId)
return { status: run.status, output: run.output }
}
Guard against double resolution — realtime may recover mid-poll — with a single "settled" flag that the first terminal answer sets, and let both paths check it before writing state. Poll on an interval of a few seconds with an overall ceiling; give up and show a "still running, check back" state rather than spinning forever.
The runtime's terminal statuses are worth listing explicitly, because missing one leaves a UI spinning forever:
const TERMINAL = new Set([
'COMPLETED', 'CANCELED', 'INTERRUPTED', 'CRASHED',
'SYSTEM_FAILURE', 'FAILED', 'EXPIRED', 'TIMED_OUT',
])
Persist the result in the task, not the client
If the answer matters, write it from the task, not from the browser on completion. A user who navigates away mid-run should still find the result waiting. Have the task write to its own database idempotently — keyed on the run id, so a retry does not duplicate — and let the UI read from there. The realtime stream then becomes an optimisation rather than the only delivery path.
5. Human-in-the-loop
Some runs cannot finish on their own. nightly-etl detects that last night's revenue is 40% below trend — is that a real collapse, or a broken upstream feed? Loading it silently corrupts every report downstream; failing outright means someone has to notice and re-run by hand.
A waitpoint is the third option: the run pauses mid-execution until a person decides, then continues from where it stopped. The task is checkpointed, so the pause itself costs nothing. Trigger.dev supports this; n8n does not.
First, decide whether to pause at all
This is the decision that matters, and getting it wrong produces a failure that only appears in production, days later.
The pause is free. The run's clock is not. A run's elapsed time is measured from when it first started, and that stamp is never reset — so the pause counts. A run that waits three days and then resumes is three days over its Max run duration, and the platform fails it on the next poll tick with "Run exceeded running timeout". Max run duration is capped at 24 hours, so there is no setting that rescues a multi-day pause.
| How long might the human take? | What to do |
|---|---|
| Minutes to a few hours — someone is at their desk, watching | Pause. Use a waitpoint, and set the agent's Max run duration comfortably above the worst case. |
| Overnight, over a weekend, "whenever they get to it" | Do not pause. Finish the run and start a new one when the decision arrives. |
Our nightly-etl anomaly check is the first kind: it fires at 3am, someone reviews it that morning, and a Max run duration of 12 hours covers it. A vehicle operator confirming a resource request over a weekend is the second kind, and modelling it as a waitpoint would fail every time.
For long waits: two runs, not one pause
Do not hold a run open across a human's weekend. Instead:
- Run A finishes. It writes the pending item to your own database and returns it in
output_data— request id, what needs deciding, whatever the reviewer must see. The run completes normally. - The human decides, whenever. Your zone app renders the queue from your own table; nothing on the platform is waiting.
- Run B does the post-approval work, started fresh with a full clock and new credentials.
Run B can be started two ways. A zone app can trigger it directly when the reviewer clicks Approve, passing the decision as input. Or configure an agent action of type trigger another agent on completed, which hands run A's output_data to run B as its input — useful when the decision comes from outside your UI. One constraint: that handoff carries output_data and nothing else, so run A must put everything run B needs into its output.
This is more moving parts than a waitpoint, and it is the only shape that survives a human taking their time.
The shape
Three steps in the task — create a token, tell the platform, then pause on it:
import { wait } from '@trigger.dev/sdk'
type Decision =
| { decision: 'approve'; reviewed_by: string }
| { decision: 'reject'; reviewed_by: string; note?: string }
// 1. Persist what the reviewer will look at, in YOUR database, keyed by runId.
const reviewId = await savePendingReview({ runId: payload.runId, anomaly })
// 2. Create the waitpoint token, with a deadline.
const token = await wait.createToken({ timeout: '7d' })
// 3. Announce it. The run moves to `waiting` and notifications fire.
await cxpa.waiting({
tokenId: token.id,
description: `Revenue is 40% below trend for ${anomaly.date} — load anyway?`,
url: `https://agents.collectivexp.ai/acme/nightly-etl/app?run=${payload.runId}`,
output: { stage: 'awaiting_review', anomaly },
})
// 4. Suspend. Nothing runs until someone decides — or the timeout expires.
const result = await wait.forToken<Decision>(token)
Then handle both outcomes:
if (!result.ok || !result.output) {
await markReviewExpired(reviewId)
await cxpa.fail({ message: 'Waitpoint timed out — no decision received' })
return { status: 'expired', outputs: 0 }
}
if (result.output.decision === 'reject') {
await markReviewRejected(reviewId, result.output)
await cxpa.complete({ output: { loaded: false, reason: 'rejected' }, outputs: 0 })
return { loaded: false, outputs: 0 }
}
const rows = await loadAnomalousBatch(anomaly)
await cxpa.complete({ output: { loaded: true, rows }, outputs: rows })
return { loaded: true, rows, outputs: rows }
What each piece is doing
timeout is not optional in practice. A waitpoint with no deadline is a run that waits forever if everyone ignores it. Pick a window the decision actually deserves — a week for an approval queue, an hour for something blocking a user — and handle expiry as its own outcome. result.ok is false when the timeout fires, and result.output is undefined; treating that as a decision is the classic bug here.
The decision payload is yours. The platform passes it through untouched, so type it as a discriminated union and branch on it. Record who decided — a reviewer id in the payload is the only audit trail of why the run resumed the way it did.
Persist the pending item in your own database. The platform stores JSONB on the run and nothing else, so a review queue — "all requests awaiting approval" — has to live in your infrastructure. Key it by runId so the review screen can find the run and the run can find the review. The output you pass to waiting() is for display alongside the run; it is not a queryable work queue.
url deep-links the notification. Without it, {{run_url}} in the waiting email resolves to the generic run detail page (or the agent's zone app when it has one). With it, reviewers land directly on your approval screen. It must be an absolute http(s) URL — the platform rejects a malformed one with 400, so build it conditionally if the base URL comes from an environment variable that might be unset.
Credentials do not survive a pause
Two separate things expire while a run waits, and a task that resolved everything up front — the rule everywhere else in these docs — is still caught out.
The credentials token dies after 60 minutes, measured from the start of the run. A run that resumes after even a couple of hours cannot call resolveCredential any more.
The credential itself may die too. An OAuth access token resolved before the pause is the provider's own short-lived token, typically good for an hour. Holding it in memory across a long pause leaves you with a string that looks fine and is rejected.
So a resumed run that needs a credential has to re-resolve, and needs a live token to do it. The in-run way to get one is to call registerRun with the task's own runtime run id: the platform recognises the existing run, does not duplicate it, and returns a fresh 60-minute token with the current connection map. That costs something, though — it needs Allow external triggers enabled on the agent and the ingest key present in the runtime, which is a second key in a place that did not need one.
If that seems like a lot of machinery to survive a pause, it is — and it is another reason the two-run pattern above is the better shape for anything long. A new run arrives with a fresh token and fresh connections and needs none of this.
Resuming
Three callers, one operation — whoever holds the right credential can complete the waitpoint:
| Resumed from | Call |
|---|---|
| The platform's run detail page | Built in — nothing to write |
| A zone app | cxpa.runs.completeWaitpoint(runId, decision) |
| An external system holding the agent API key | cxpa.completeWaitpoint(decision) |
The token id is resolved server-side from the run, so a caller only needs the run — not the token.
Restricting who may approve is your job
The platform does not do it. Any member of the org can complete any waiting run, in either membership role — approving is an operator action, like triggering a run. So can anyone holding the agent API key. The userId you may pass when completing a waitpoint is attribution for the audit log only: it is recorded, and silently dropped if the value is not a member of the org. It authorises nothing.
If your process says a specific person signs off — a named approver, a duty manager, whoever holds a shift — you build that:
- By role, if "any owner" is a good enough rule: check
claims.rolein the zone's server action before callingcompleteWaitpoint. - By identity, when it has to be a particular person: store the intended approver on the review row you already wrote before pausing, and compare it to
claims.userIdbefore resuming.
const review = await getReview(runId)
if (review.assigned_to !== session.claims.userId) {
return { error: 'This request is assigned to someone else' }
}
await cxpa.runs.completeWaitpoint(runId, decision)
Enforce it in the server action, not in the component that renders the Approve button — the button is presentation, the action is the boundary.
None of this appears in the run history: the platform records that the waitpoint was completed and, if you supplied it, an attributed user. The reasoning for why that person was allowed to lives in your own records.
Tell someone it is waiting
Nothing notifies anyone by default. A run that pauses silently is a run nobody resumes, and it will sit there until the timeout expires. Configure an email action on the waiting state with the reviewers in its recipient list — that is the next section, and for a HITL agent it is not optional.
Finally, remember that waiting is not a terminal state. A poller in a zone app that stops there will never show the result — see Build the zone app.
6. Acting on transitions
Each agent can have any number of actions, configured by members at /acme/revenue-chat/actions. An action fires on one run state transition, optionally restricted to certain trigger types.
| Type | What it does |
|---|---|
| Sends a templated email to a list of recipients | |
| Slack message | Posts a Block Kit message via an incoming webhook |
| Slack via email | Posts to a channel through Slack's email integration |
| Webhook | POSTs the run output to an external URL (no auth, bearer, or HMAC) |
| Trigger another agent | Starts a run on another agent in the org with this output as its input |
Actions run fire-and-forget: a failed action never fails the run that triggered it. Every dispatch is recorded with status, attempts, and error, and failed ones can be retried from the run detail page — so a silently undelivered notification is visible rather than lost.
Two configurations worth adding for this integration:
emailonfailedfornightly-etl. A data pipeline that fails quietly is worse than one that fails loudly.emailonwaitingfor any human-in-the-loop agent, with the reviewers in the recipient list. Nothing notifies them otherwise.
Notification links resolve to the agent's zone app when it has one, or the run detail page when it does not — or to the URL your task supplied when it paused.