Progress
Long agent tasks shouldn't hide behind a spinner. progress_runs gives the agent a way to announce "I'm working on this, I'm 45% done, here's the current step" — which the UI renders as a floating runs tray with a percent bar.
import {
startRun,
updateRunProgress,
completeRun,
} from "@agent-native/core/progress";
const run = await startRun({
owner: "[email protected]",
title: "Triage 128 unread emails",
step: "Fetching inbox",
});
for (let i = 1; i <= total; i++) {
await updateRunProgress(run.id, run.owner, {
percent: Math.round((i / total) * 100),
step: `Classifying ${i}/${total}`,
});
}
await completeRun(run.id, run.owner, "succeeded");
Separate concern from notifications: notifications fire once ("X happened"), progress is continuous state ("X is 45% done"). The two compose — completeRun followed by notify(..., severity: "info") tells the user when the work finishes even if they weren't watching the tray.
The lifecycle
| Status | Transition |
|---|---|
running |
Initial — set by startRun |
succeeded |
Happy-path terminal |
failed |
Error terminal |
cancelled |
User interrupted |
Terminal statuses set completed_at. The UI tray shows only running rows; completed rows stay in the database for manage-progress --action=list queries.
API
startRun(input)
Create a run. Returns the full AgentRun with a generated id.
const run = await startRun({
owner: "[email protected]",
title: "Ingest 1M rows",
step: "Opening CSV",
metadata: { jobId: "abc123", artifactPath: "s3://..." },
});
Emits run.progress.started on the event bus.
updateRunProgress(id, owner, input)
Patch any field of a running run. Any omitted field stays unchanged.
await updateRunProgress(run.id, run.owner, {
percent: 75,
step: "Writing to target DB",
});
Emits run.progress.updated on the event bus. Returns the updated AgentRun, or null if the run doesn't exist or isn't owned by the caller.
completeRun(id, owner, status, extras?)
Transition to a terminal status. succeeded implicitly sets percent=100.
await completeRun(run.id, run.owner, "succeeded", {
step: "All 1M rows ingested",
metadata: { totalDurationMs: 98_123 },
});
Also emits run.progress.updated with the terminal status.
Listing
import { listRuns, getRun, deleteRun } from "@agent-native/core/progress";
const active = await listRuns("[email protected]", { activeOnly: true });
const run = await getRun("run-id", "[email protected]");
await deleteRun("run-id", "[email protected]");
HTTP API
Mounted at /_agent-native/runs/* by the core-routes plugin. Read-only over HTTP — writes go through the agent tools since the agent is the canonical writer. All routes are owner-scoped.
| Method | Path |
|---|---|
GET |
/_agent-native/runs?active=true |
GET |
/_agent-native/runs/:id |
DELETE |
/_agent-native/runs/:id |
UI component
import { RunsTray } from "@agent-native/core/client/progress";
export function HeaderBar() {
return (
<header className="flex items-center gap-2">
{/* … */}
<RunsTray />
</header>
);
}
Inline header widget — mount it next to the notifications bell. Shows a spinner icon + count badge when runs are active; click opens a dropdown with one live percent bar per run. Hides the trigger entirely when no active runs. Polls /_agent-native/runs?active=true every pollMs (default 3 s). Uses shadcn semantic tokens, adapts to light and dark themes.
Agent tool
A single manage-progress tool is registered in every template. The action parameter selects the operation:
| Action | Purpose |
|---|---|
start |
Call at the top of a long task. Returns a runId. |
update |
Call periodically during the task with percent and/or step. |
complete |
Terminal — one of succeeded, failed, cancelled. |
list |
Inspect recent runs (filter by active=true). |
When to start a run
- Use for anything > ~5 seconds. A spinner with no context feels frozen.
- Update at natural checkpoints, not every iteration. Every 5–10% is plenty.
- Always call
manage-progress --action=complete, including in error paths. An orphanrunningrow is worse than no row. - Pair with
notifyon completion so the user sees the outcome when they're not actively watching the tray.
Event bus
Two events emit on the event bus:
| Event | Payload |
|---|---|
run.progress.started |
{ runId, title, step? } |
run.progress.updated |
{ runId, percent, step, status } |
Automations can subscribe to these — for example, "if a run takes longer than 5 minutes, notify me":
---
triggerType: event
event: run.progress.updated
condition: "status is running and (now - started) > 5 minutes"
mode: agentic
---
Notify me that run {{runId}} has been running for a long time.
How it works
- Owner scoping — every row has an
ownercolumn; every query filters on it. Users see only their own runs. - Poll integration — every mutation calls
recordChange()so templates usinguseDbSyncauto-invalidate without any extra wiring. - Table name — the framework also has an
agent_runstable for internal agent-chat turn lifecycle tracking. The progress primitive usesprogress_runsto keep the two concerns separate. - Percent clamping — values are clamped to
[0, 100]and rounded to an integer on write.
What's next
- Notifications — pair with
manage-progress --action=completeto tell the user when work finishes - Automations — watchdog slow runs via
run.progress.updated - Client —
useDbSyncfor real-time cache invalidation