An agent that goes quiet for ten minutes does not feel autonomous. It feels broken. A spinner and a made-up “82% complete” label are not much better: they encourage users to trust a number your system cannot justify.
The fix is not to stream the model's hidden reasoning. It is to publish a small, evidence-backed account of the work: what phase the run is in, the last verified milestone, whether it needs a person, and what will happen next. This article shows how to build that contract for long-running, tool-using AI agents.
The progress problem is really a trust problem
Users ask three practical questions while an agent runs:
- Is it still alive?
- Did it make meaningful progress?
- Do I need to do anything?
A token stream answers none of these reliably. It can continue while a tool is stuck, and it can stop even though a durable workflow will resume later. Likewise, an LLM-generated status such as “almost finished” is a prediction, not evidence.
Treat status as a product contract. The user-facing timeline should be derived from workflow events your system can verify: a job was accepted, a connector returned records, validation passed, an approval is waiting, or a final artifact was stored. The model may suggest a label, but it should not be the source of truth for state.
This distinction also protects privacy. A useful update is “Checked 42 invoices; 3 need review,” not a transcript of the agent's scratch work, retrieved customer text, or chain of thought.
Start with a state machine, not a progress bar
Long-running agent work has more states than running and failed. Model the states explicitly and make transitions durable.
StateMeaning to the userRequired evidenceUseful UI action
queuedWork is accepted but has not startedrun record createdCancel
runningA worker currently owns the runlease heartbeatStop
waiting_on_toolThe run is awaiting a bounded external calltool request ID and deadlineCancel
waiting_on_userA decision or missing input is requiredapproval or question IDReview / answer
retry_scheduledThe system will retry laterretry time and error classCancel / view details
succeededThe requested result was verified and savedoutput receiptOpen result
failedThe run cannot continue automaticallyfailure class and last checkpointRetry / contact support
cancelledWork was intentionally stoppedcancellation receiptRestart
Avoid a vague paused state. If the workflow is paused because it needs an address, show waiting_on_user. If it is paused because a vendor rate-limited it, show retry_scheduled. Specific state names let a user decide what to do without reading logs.
Here is a compact TypeScript event shape. It is deliberately about observable work, not hidden reasoning.
type RunEvent = {
runId: string;
sequence: number; // monotonic per run
at: string;
type: "phase_started" | "milestone" | "approval_needed" |
"retry_scheduled" | "completed" | "failed";
phase?: "preparing" | "collecting" | "verifying" | "delivering";
message: string; // reviewed, user-safe copy
evidence?: {
kind: "artifact" | "tool_receipt" | "checkpoint" | "approval";
id: string;
};
action?: { label: string; url: string };
};
Persist these events in the same transaction that changes workflow state, or attach them to an outbox. A UI event that says “report ready” before the report is stored is worse than no event at all.
Publish milestones, not a narrated thought process
Choose milestones at boundaries where the system can prove something changed. Good milestones are stable across model versions and useful if a run resumes tomorrow.
For a document-analysis agent, the timeline might be:
- Request accepted
- Source file scanned and indexed
- 42 records extracted
- Policy checks completed
- Three exceptions need a decision
- Final report saved
Notice what is absent: “thinking about clause 7” or “I believe this source is probably relevant.” Those claims are neither a dependable progress signal nor an appropriate audit record.
Each milestone needs an owner. The workflow engine owns checkpoint messages; the connector owns data-fetch receipts; the policy service owns validation results. If no component can produce evidence for an update, leave it out.
That rule prevents a common failure mode: a chat interface keeps emitting cheerful prose while the actual job stopped moving. Your supervisor should detect stale progress independently of the agent. For example, mark a run stalled internally when its lease heartbeat is fresh but neither a checkpoint nor a tool receipt has advanced within the expected phase window. The user-facing text can then say, “Still working longer than expected; no action needed yet,” or escalate honestly to “This task needs attention.”
Design an honest progress model
Percent complete works only when units of work are known. A migration over 10,000 rows has a denominator. An open-ended research task generally does not.
Use one of these models instead:
Work shapeBest indicatorExampleFixed batchcompleted / total
318 of 500 files checkedKnown workflowcompleted phase
Verifying findings (3 of 4 phases)Open-ended workelapsed time plus latest verified event
Last verified update: sources indexedApproval flowblocked state and decision impact
Needs approval before sending 3 emailsRetryable external worknext retry and preserved checkpoint
Retrying connector at 14:05; 18 items saved
Do not convert phase count into a false percentage. Four phases do not necessarily take equal time, and the model can loop inside one phase. If you show an estimate, label it as a range derived from similar completed runs, and hide it when the sample is weak.
function statusCopy(run: Run) {
if (run.status === "waiting_on_user")
return "Your decision is needed before this task can continue.";
if (run.completedUnits != null && run.totalUnits != null)
return `${run.completedUnits} of ${run.totalUnits} items verified`;
return `Last verified update: ${run.latestMilestone.message}`;
}
The word verified matters. It teaches users what the timeline represents and sets a standard your team can test.
Make waiting visible and actionable
The most damaging silence is not a slow model call. It is an agent waiting for a person while the product looks busy.
An approval event should include the decision, the consequence, the safe default, and an expiry rule. Keep the payload concise and reviewable:
{
"type": "approval_needed",
"message": "Three customer records have conflicting addresses.",
"question": "Which address should be used for the renewal notice?",
"options": ["CRM record", "latest support ticket", "skip these records"],
"default": "skip these records",
"expires_at": "2026-10-26T15:00:00Z",
"evidence": { "kind": "approval", "id": "apr_8f2" }
}
Never bury an approval in a scrolling chat. Surface it as a run state, send a notification only when action is required, and ensure a resumed run re-reads the approved decision from durable storage. This pairs well with approval gates, but it solves a different problem: making the wait understandable to the person who initiated the work.
Transport updates without building a fragile realtime system
Start with the simplest transport that fits your product.
- Polling is enough for minute-scale background jobs. Return
status, latest_event, and etag; back off when nothing changes. - Server-sent events work well for one-way timelines and reconnect cleanly with
Last-Event-ID. - WebSockets fit collaborative workspaces where users can also approve, stop, or redirect a run live.
- Notifications should be sparse: completion, failure, and
waiting_on_user are normally worth interrupting someone; routine milestones are not.
Regardless of transport, the client must tolerate duplicate and out-of-order events. Use the sequence field, make the event stream append-only, and fetch the authoritative run snapshot after reconnecting. The UI is a projection; the workflow database remains the authority.
Build the API around a durable run record
A small API surface is easier to secure and test than a channel full of ad hoc text.
GET /runs/run_123
{
"status": "waiting_on_user",
"phase": "verifying",
"latest_event_sequence": 17,
"latest_event": {
"message": "42 records verified; 3 need a decision",
"at": "2026-10-26T12:04:12Z"
},
"actions": [{ "kind": "approve", "approval_id": "apr_8f2" }]
}
Keep sensitive tool arguments, raw documents, and internal exception stacks behind a permissioned diagnostics view. The default run page should disclose only the minimum a user needs to understand progress and take the next action.
Test the status contract like an API
Status is part of the user experience, so add tests beyond the happy path:
- A run cannot become
succeeded without an output receipt. - A
waiting_on_user run always exposes a decision action and cannot spend tools until it resumes. - Replayed events create the same final timeline.
- A reconnect receives no duplicate visible milestones.
- A cancelled run cannot later publish a success event.
- A stale worker lease produces an honest recovery state, not permanent “Working…” copy.
- User-safe messages contain no prompt, secret, customer text, or chain of thought.
it("does not claim success before output exists", async () => {
await workflow.emit({ type: "completed", evidence: undefined });
expect(await runs.get("run_123")).toMatchObject({ status: "failed" });
});
This is also where observability earns its keep. Measure time to first verified milestone, time spent waiting for users, stale-run recovery rate, and the share of runs that finish with a useful artifact. Avoid treating the number of progress messages as a health metric; a noisy timeline can conceal a poor workflow.
A practical rollout plan
Do not retrofit every agent at once. Start with one workflow where users currently refresh a page or ask support whether a job is stuck.
- Define the state machine and ownership of each transition.
- Add durable run records and one evidence-backed milestone per phase.
- Replace generic spinners with the latest verified update.
- Promote approval waits to a clear, resumable state.
- Add stale-run detection and a recovery message.
- Instrument completion quality, not just elapsed time.
The result is deliberately less theatrical than a stream of simulated thinking. It is far more useful: people can leave a long-running agent alone, return with context, and intervene exactly when their judgment is needed.
Keep the language stable across releases
Treat visible status copy as part of your API. Changing waiting_on_user from “Needs your decision” to “Paused” may seem cosmetic, yet it can break notification rules, customer runbooks, and accessibility labels. Version the event schema, localize messages at the presentation layer, and keep a small compatibility test suite for every public state. That discipline pays off when new models, tools, and workflow engines arrive: the user still receives the same clear promise about what has happened and what they can do next.
FAQ
Should an AI agent show its chain of thought as progress?
No. Show concise, evidence-backed milestones instead. They are more reliable, safer for sensitive data, and easier for users to act on.
How often should an agent send status updates?
Update on meaningful state changes, verified milestones, and action-required moments. For long silent phases, add a bounded heartbeat that says the job is still owned, not a stream of filler messages.
Is a percentage complete useful for AI agents?
Only when the work has a known denominator, such as files processed or records checked. Otherwise show the phase, elapsed time, and most recent verified milestone.
What is the best state for an agent waiting on a person?
Use a specific waiting_on_user state with the exact question, a safe default, and a resumable approval ID. Do not leave the run labeled running.
Can polling work for long-running AI workflows?
Yes. Polling is simple and reliable for background tasks that update every few seconds or minutes. Use ETags and backoff, then move to SSE or WebSockets only when the interaction requires it.
How do you detect an AI agent that is stuck?
Track worker leases, tool deadlines, and the time since the last verified milestone. A supervisor outside the model should classify stale runs and surface a recovery state.#ai #ux #programming #devops #software #coding #development #engineering #inclusive #community
AI Agent Status Updates: Build Progress Users Can Trust Without Exposing Chain of Thought