Agent Run State
SaveTypes for runs, steps, approvals, messages, and artifacts, plus an event reducer that de-duplicates, reorders, and recovers from snapshots.
- Category
- Agent
- Version
- 0.1.1
- Frameworks
- Next.js, Vite + React, Vite + Vue
- Styling
- Tailwind CSS v4
Preview
Agent Run State preview
{
"status": "running",
"sequence": 3,
"steps": [
"search_docs: succeeded"
]
}Installation
npx @seamlessui/cli add agent-runPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add agent-run --dry-runUsage
agent-run-example.tsx
import { type AgentRunEvent, applyEvents, fromSnapshot } from "@/components/seamless/agent/agent-run";
const events: AgentRunEvent[] = [
{ id: "e1", runId: "run_1", sequence: 1, timestamp: "2026-11-14T10:00:00.000Z", type: "run.status", payload: { status: "running" } },
{ id: "e3", runId: "run_1", sequence: 3, timestamp: "2026-11-14T10:00:02.000Z", type: "step.updated", payload: { stepId: "s1", status: "succeeded", output: "Found 3 pages." } },
{ id: "e2", runId: "run_1", sequence: 2, timestamp: "2026-11-14T10:00:01.000Z", type: "step.started", payload: { step: { id: "s1", name: "search_docs", kind: "tool" } } },
{ id: "e2", runId: "run_1", sequence: 2, timestamp: "2026-11-14T10:00:01.000Z", type: "step.started", payload: { step: { id: "s1", name: "search_docs", kind: "tool" } } },
];
export default function AgentRunExample() {
// Out of order and duplicated delivery still produces one consistent run.
const store = applyEvents(fromSnapshot({ id: "run_1", title: "Research", status: "queued", steps: [], approvals: [], messages: [], artifacts: [] }), events);
return (
<pre className="w-full max-w-md overflow-x-auto rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed text-zinc-800 dark:border-white/[0.08] dark:bg-transparent dark:text-zinc-300">
{JSON.stringify({ status: store.run.status, sequence: store.sequence, steps: store.run.steps.map((s) => `${s.name}: ${s.status}`) }, null, 2)}
</pre>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| fromSnapshot(run, sequence?) | (AgentRun, number) => RunStore | — | Starts a store from a run snapshot from your API and the event sequence it reflects. |
| applyEvents(store, events) | (RunStore, AgentRunEvent[]) => RunStore | — | Applies events: ignores duplicate ids and other runs, buffers early events, and applies them in sequence order. Pure. |
| hasGap(store) | (RunStore) => boolean | — | True when events are waiting behind a missing sequence. Refresh from a snapshot if the gap does not fill. |
| AgentRunEvent | { id, runId, sequence, timestamp, type, payload } | — | Event contract: run.status, step.started, step.updated, approval.requested, approval.resolved, message.started, message.delta, message.completed, artifact.updated. |
Accessibility
- No UI. The agent components that render this state announce status changes with live regions.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/agent/agent-run.tsagent-run- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/agent-run/0.1.1.json.
agent/agent-run.tsShowHide
agent/agent-run.ts
// Normalized agent run state and the event contract that updates it.
// Framework- and provider-independent: map your backend's stream to these events,
// or replace a run wholesale with a snapshot from your API.
export type RunStatus = "queued" | "running" | "waiting-for-approval" | "succeeded" | "failed" | "cancelled";
export type StepStatus = "queued" | "running" | "succeeded" | "failed" | "skipped";
export type ApprovalStatus = "pending" | "approved" | "rejected" | "expired";
export type MessageStatus = "streaming" | "complete" | "failed";
export type ArtifactStatus = "generating" | "ready" | "failed";
export interface RunStep {
id: string;
/** Tool name for tool calls, or a short label for other steps. */
name: string;
kind: "tool" | "step";
status: StepStatus;
summary?: string;
input?: unknown;
output?: string;
error?: string;
startedAt?: string;
finishedAt?: string;
}
export interface RunApproval {
id: string;
title: string;
description?: string;
/** What will happen if approved, e.g. a command or diff. Rendered as text, never as HTML. */
detail?: string;
status: ApprovalStatus;
expiresAt?: string;
decidedAt?: string;
}
export interface RunMessage {
id: string;
role: "user" | "assistant";
content: string;
status: MessageStatus;
error?: string;
}
export interface RunArtifact {
id: string;
name: string;
/** MIME type, e.g. text/markdown, text/csv, image/png. */
type: string;
status: ArtifactStatus;
/** Where the artifact can be opened or downloaded. */
path?: string;
/** Inline text content for previews of text artifacts. */
content?: string;
sizeBytes?: number;
error?: string;
}
export interface AgentRun {
id: string;
title: string;
status: RunStatus;
steps: RunStep[];
approvals: RunApproval[];
messages: RunMessage[];
artifacts: RunArtifact[];
createdAt?: string;
finishedAt?: string;
error?: string;
}
interface EventBase<Type extends string, Payload> {
/** Unique event id; repeated deliveries are ignored. */
id: string;
runId: string;
/** Increases by one per event within a run. */
sequence: number;
/** ISO 8601 time the backend produced the event. */
timestamp: string;
type: Type;
payload: Payload;
}
export type AgentRunEvent =
| EventBase<"run.status", { status: RunStatus; error?: string }>
| EventBase<"step.started", { step: Omit<RunStep, "status" | "startedAt"> }>
| EventBase<"step.updated", { stepId: string; status: StepStatus; output?: string; error?: string }>
| EventBase<"approval.requested", { approval: Omit<RunApproval, "status"> }>
| EventBase<"approval.resolved", { approvalId: string; status: Exclude<ApprovalStatus, "pending"> }>
| EventBase<"message.started", { message: Omit<RunMessage, "status" | "content"> & { content?: string } }>
| EventBase<"message.delta", { messageId: string; delta: string }>
| EventBase<"message.completed", { messageId: string; status: Exclude<MessageStatus, "streaming">; error?: string }>
| EventBase<"artifact.updated", { artifact: RunArtifact }>;
/** A run plus the bookkeeping needed to apply events safely. */
export interface RunStore {
run: AgentRun;
/** Highest sequence applied without a gap. */
sequence: number;
/** Event ids already applied or buffered, for de-duplication. */
seen: string[];
/** Events that arrived ahead of a missing sequence, waiting for the gap to fill. */
pending: AgentRunEvent[];
/** True when events are missing; refresh from a snapshot (see `fromSnapshot`). */
stale: boolean;
}
/** How many out-of-order events to hold before declaring the store stale. */
const MAX_PENDING = 50;
/** Starts a store from a run snapshot and the sequence it reflects. */
export function fromSnapshot(run: AgentRun, sequence = 0): RunStore {
return { run, sequence, seen: [], pending: [], stale: false };
}
/**
* Applies events: ignores duplicates (by id) and other runs, buffers events that
* arrive early, applies them in sequence order, and marks the store stale if a
* gap does not fill. Pure: returns a new store and never mutates its input.
*/
export function applyEvents(store: RunStore, events: AgentRunEvent[]): RunStore {
const seen = new Set(store.seen);
let pending = [...store.pending];
for (const event of events) {
if (event.runId !== store.run.id || seen.has(event.id) || event.sequence <= store.sequence) continue;
seen.add(event.id);
pending.push(event);
}
pending.sort((a, b) => a.sequence - b.sequence);
let { run, sequence } = store;
while (pending.length && pending[0]!.sequence === sequence + 1) {
run = reduce(run, pending.shift()!);
sequence += 1;
}
// Drop stale duplicates of already applied sequences that slipped in under another id.
pending = pending.filter((event) => event.sequence > sequence);
return { run, sequence, seen: [...seen].slice(-500), pending, stale: store.stale || pending.length > MAX_PENDING };
}
/** True when events after `sequence` are buffered behind a missing one. */
export function hasGap(store: RunStore): boolean {
return store.pending.length > 0;
}
function reduce(run: AgentRun, event: AgentRunEvent): AgentRun {
const at = event.timestamp;
switch (event.type) {
case "run.status":
return {
...run,
status: event.payload.status,
error: event.payload.error ?? run.error,
finishedAt: ["succeeded", "failed", "cancelled"].includes(event.payload.status) ? at : run.finishedAt,
};
case "step.started":
return upsert(run, "steps", { ...event.payload.step, status: "running", startedAt: at });
case "step.updated":
return patch(run, "steps", event.payload.stepId, (step) => ({
...step,
status: event.payload.status,
output: event.payload.output ?? step.output,
error: event.payload.error ?? step.error,
finishedAt: event.payload.status === "running" ? step.finishedAt : at,
}));
case "approval.requested":
return upsert(run, "approvals", { ...event.payload.approval, status: "pending" });
case "approval.resolved":
return patch(run, "approvals", event.payload.approvalId, (approval) => ({ ...approval, status: event.payload.status, decidedAt: at }));
case "message.started":
return upsert(run, "messages", { content: "", ...event.payload.message, status: "streaming" });
case "message.delta":
return patch(run, "messages", event.payload.messageId, (message) => ({ ...message, content: message.content + event.payload.delta }));
case "message.completed":
return patch(run, "messages", event.payload.messageId, (message) => ({ ...message, status: event.payload.status, error: event.payload.error }));
case "artifact.updated":
return upsert(run, "artifacts", event.payload.artifact);
}
}
type Collection = "steps" | "approvals" | "messages" | "artifacts";
type Entry<K extends Collection> = AgentRun[K][number];
function upsert<K extends Collection>(run: AgentRun, key: K, entry: Entry<K>): AgentRun {
const list = run[key] as Entry<K>[];
const index = list.findIndex((item) => item.id === entry.id);
const next = index === -1 ? [...list, entry] : list.map((item, i) => (i === index ? { ...item, ...entry } : item));
return { ...run, [key]: next };
}
function patch<K extends Collection>(run: AgentRun, key: K, id: string, update: (entry: Entry<K>) => Entry<K>): AgentRun {
const list = run[key] as Entry<K>[];
return { ...run, [key]: list.map((item) => (item.id === id ? update(item) : item)) };
}Vue
A native Vue 3 implementation ships in the Vue pilot. Run the same command in a Vite + Vue project; the CLI detects Vue and installs these files instead. The live preview above is the React version; the Vue example below is built in a clean Vite + Vue app by CI.
agent-run-example.vue
<script setup lang="ts">
import { type AgentRunEvent, applyEvents, fromSnapshot } from "@/components/seamless/agent/agent-run";
const events: AgentRunEvent[] = [
{ id: "e1", runId: "run_1", sequence: 1, timestamp: "2026-11-14T10:00:00.000Z", type: "run.status", payload: { status: "running" } },
{ id: "e2", runId: "run_1", sequence: 2, timestamp: "2026-11-14T10:00:01.000Z", type: "step.started", payload: { step: { id: "s1", name: "search_docs", kind: "tool" } } },
];
const store = applyEvents(fromSnapshot({ id: "run_1", title: "Research", status: "queued", steps: [], approvals: [], messages: [], artifacts: [] }), events);
</script>
<template>
<pre class="rounded-lg border border-zinc-950/[0.07] bg-zinc-950/[0.02] px-3 py-2.5 font-mono text-xs leading-relaxed dark:border-white/[0.08] dark:bg-transparent">{{ JSON.stringify({ status: store.run.status, steps: store.run.steps.length }, null, 2) }}</pre>
</template>src/components/seamless/agent/agent-run.tsvue/agent-run@0.1.1
agent/agent-run.tsShowHide
agent/agent-run.ts
// Normalized agent run state and the event contract that updates it.
// Framework- and provider-independent: map your backend's stream to these events,
// or replace a run wholesale with a snapshot from your API.
export type RunStatus = "queued" | "running" | "waiting-for-approval" | "succeeded" | "failed" | "cancelled";
export type StepStatus = "queued" | "running" | "succeeded" | "failed" | "skipped";
export type ApprovalStatus = "pending" | "approved" | "rejected" | "expired";
export type MessageStatus = "streaming" | "complete" | "failed";
export type ArtifactStatus = "generating" | "ready" | "failed";
export interface RunStep {
id: string;
/** Tool name for tool calls, or a short label for other steps. */
name: string;
kind: "tool" | "step";
status: StepStatus;
summary?: string;
input?: unknown;
output?: string;
error?: string;
startedAt?: string;
finishedAt?: string;
}
export interface RunApproval {
id: string;
title: string;
description?: string;
/** What will happen if approved, e.g. a command or diff. Rendered as text, never as HTML. */
detail?: string;
status: ApprovalStatus;
expiresAt?: string;
decidedAt?: string;
}
export interface RunMessage {
id: string;
role: "user" | "assistant";
content: string;
status: MessageStatus;
error?: string;
}
export interface RunArtifact {
id: string;
name: string;
/** MIME type, e.g. text/markdown, text/csv, image/png. */
type: string;
status: ArtifactStatus;
/** Where the artifact can be opened or downloaded. */
path?: string;
/** Inline text content for previews of text artifacts. */
content?: string;
sizeBytes?: number;
error?: string;
}
export interface AgentRun {
id: string;
title: string;
status: RunStatus;
steps: RunStep[];
approvals: RunApproval[];
messages: RunMessage[];
artifacts: RunArtifact[];
createdAt?: string;
finishedAt?: string;
error?: string;
}
interface EventBase<Type extends string, Payload> {
/** Unique event id; repeated deliveries are ignored. */
id: string;
runId: string;
/** Increases by one per event within a run. */
sequence: number;
/** ISO 8601 time the backend produced the event. */
timestamp: string;
type: Type;
payload: Payload;
}
export type AgentRunEvent =
| EventBase<"run.status", { status: RunStatus; error?: string }>
| EventBase<"step.started", { step: Omit<RunStep, "status" | "startedAt"> }>
| EventBase<"step.updated", { stepId: string; status: StepStatus; output?: string; error?: string }>
| EventBase<"approval.requested", { approval: Omit<RunApproval, "status"> }>
| EventBase<"approval.resolved", { approvalId: string; status: Exclude<ApprovalStatus, "pending"> }>
| EventBase<"message.started", { message: Omit<RunMessage, "status" | "content"> & { content?: string } }>
| EventBase<"message.delta", { messageId: string; delta: string }>
| EventBase<"message.completed", { messageId: string; status: Exclude<MessageStatus, "streaming">; error?: string }>
| EventBase<"artifact.updated", { artifact: RunArtifact }>;
/** A run plus the bookkeeping needed to apply events safely. */
export interface RunStore {
run: AgentRun;
/** Highest sequence applied without a gap. */
sequence: number;
/** Event ids already applied or buffered, for de-duplication. */
seen: string[];
/** Events that arrived ahead of a missing sequence, waiting for the gap to fill. */
pending: AgentRunEvent[];
/** True when events are missing; refresh from a snapshot (see `fromSnapshot`). */
stale: boolean;
}
/** How many out-of-order events to hold before declaring the store stale. */
const MAX_PENDING = 50;
/** Starts a store from a run snapshot and the sequence it reflects. */
export function fromSnapshot(run: AgentRun, sequence = 0): RunStore {
return { run, sequence, seen: [], pending: [], stale: false };
}
/**
* Applies events: ignores duplicates (by id) and other runs, buffers events that
* arrive early, applies them in sequence order, and marks the store stale if a
* gap does not fill. Pure: returns a new store and never mutates its input.
*/
export function applyEvents(store: RunStore, events: AgentRunEvent[]): RunStore {
const seen = new Set(store.seen);
let pending = [...store.pending];
for (const event of events) {
if (event.runId !== store.run.id || seen.has(event.id) || event.sequence <= store.sequence) continue;
seen.add(event.id);
pending.push(event);
}
pending.sort((a, b) => a.sequence - b.sequence);
let { run, sequence } = store;
while (pending.length && pending[0]!.sequence === sequence + 1) {
run = reduce(run, pending.shift()!);
sequence += 1;
}
// Drop stale duplicates of already applied sequences that slipped in under another id.
pending = pending.filter((event) => event.sequence > sequence);
return { run, sequence, seen: [...seen].slice(-500), pending, stale: store.stale || pending.length > MAX_PENDING };
}
/** True when events after `sequence` are buffered behind a missing one. */
export function hasGap(store: RunStore): boolean {
return store.pending.length > 0;
}
function reduce(run: AgentRun, event: AgentRunEvent): AgentRun {
const at = event.timestamp;
switch (event.type) {
case "run.status":
return {
...run,
status: event.payload.status,
error: event.payload.error ?? run.error,
finishedAt: ["succeeded", "failed", "cancelled"].includes(event.payload.status) ? at : run.finishedAt,
};
case "step.started":
return upsert(run, "steps", { ...event.payload.step, status: "running", startedAt: at });
case "step.updated":
return patch(run, "steps", event.payload.stepId, (step) => ({
...step,
status: event.payload.status,
output: event.payload.output ?? step.output,
error: event.payload.error ?? step.error,
finishedAt: event.payload.status === "running" ? step.finishedAt : at,
}));
case "approval.requested":
return upsert(run, "approvals", { ...event.payload.approval, status: "pending" });
case "approval.resolved":
return patch(run, "approvals", event.payload.approvalId, (approval) => ({ ...approval, status: event.payload.status, decidedAt: at }));
case "message.started":
return upsert(run, "messages", { content: "", ...event.payload.message, status: "streaming" });
case "message.delta":
return patch(run, "messages", event.payload.messageId, (message) => ({ ...message, content: message.content + event.payload.delta }));
case "message.completed":
return patch(run, "messages", event.payload.messageId, (message) => ({ ...message, status: event.payload.status, error: event.payload.error }));
case "artifact.updated":
return upsert(run, "artifacts", event.payload.artifact);
}
}
type Collection = "steps" | "approvals" | "messages" | "artifacts";
type Entry<K extends Collection> = AgentRun[K][number];
function upsert<K extends Collection>(run: AgentRun, key: K, entry: Entry<K>): AgentRun {
const list = run[key] as Entry<K>[];
const index = list.findIndex((item) => item.id === entry.id);
const next = index === -1 ? [...list, entry] : list.map((item, i) => (i === index ? { ...item, ...entry } : item));
return { ...run, [key]: next };
}
function patch<K extends Collection>(run: AgentRun, key: K, id: string, update: (entry: Entry<K>) => Entry<K>): AgentRun {
const list = run[key] as Entry<K>[];
return { ...run, [key]: list.map((item) => (item.id === id ? update(item) : item)) };
}