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 orphan running row is worse than no row.
  • Pair with notify on 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 owner column; every query filters on it. Users see only their own runs.
  • Poll integration — every mutation calls recordChange() so templates using useDbSync auto-invalidate without any extra wiring.
  • Table name — the framework also has an agent_runs table for internal agent-chat turn lifecycle tracking. The progress primitive uses progress_runs to 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=complete to tell the user when work finishes
  • Automations — watchdog slow runs via run.progress.updated
  • ClientuseDbSync for real-time cache invalidation