+ );
+ }
+ // Each chosen option adds its own sentence; with none written, the generic reply stands in.
+ const replies = question.questions.flatMap((item) => item.options.filter((option) => answer[item.id]?.includes(option.id) && option.reply).map((option) => option.reply!));
+ const reply = replies.length ? replies.join(' ') : question.answeredText;
+ return (
+
+ [id, { option }])) }}
+ />
+
+
+ );
+}
+
+/**
+ * The card a run stops on when it cannot go on by itself: a status line, a
+ * title, the body, and its actions on the right, like the approval and
+ * question cards.
+ */
+function StopCard({
+ eyebrow,
+ title,
+ actions,
+ children,
+}: {
+ eyebrow: React.ReactNode;
+ title: React.ReactNode;
+ actions: React.ReactNode;
+ children?: React.ReactNode;
+}) {
+ return (
+
+
+
{eyebrow}
+
{title}
+
+ {children}
+
{actions}
);
}
-function CommandBar({ script, status, onStop }: { script: AgentRunScript; status: 'running' | 'awaiting' | 'done'; onStop: () => void }) {
+/** Where a failed run stopped: the call that failed, the error, its raw output, and a retry. */
+function FailureBlock({ failure, step, onRetry }: { failure: RunFailure; step?: ToolStep; onRetry?: () => void }) {
+ return (
+
+
+ {step ? `Failed at ${step.label}` : 'Run failed'}
+ >
+ }
+ title={failure.message}
+ actions={
+
+ }
+ >
+ {failure.log ? : null}
+
+ );
+}
+
+/** A run waiting for its slot: when it starts, the plan it will follow, and a way to start it now. */
+function QueuedBlock({ note, plan, onStart }: { note?: string; plan?: string[]; onStart?: () => void }) {
+ return (
+
+ Run now
+
+ }
+ >
+ {plan?.length ? (
+
+
Up next
+ {/* Hung from one rail, like the tool trace, with every step still to come. */}
+
+ {plan.map((step, index) => (
+
- ) : state.settled ? null : (
+ ) : state.settled || queued || awaitingApproval || awaitingAnswer ? null : (
= {
+ idle: null,
+ queued: 'Queued',
+ running: 'Running',
+ approval: 'Waiting for your approval',
+ input: 'Waiting for your answer',
+ failed: 'Last run failed',
+};
+
+/** Sentence-case state for tooltips; `null` for an idle agent with nothing new. */
+function statusPhrase(agent: AgentSummary): string | null {
+ const status = agentStatus(agent);
+ return STATUS_PHRASE[status] ?? (agent.unread ? 'New result' : null);
+}
+
+/** Accessible name: "Revenue Analyst, waiting for your approval". */
+function agentLabel(agent: AgentSummary) {
+ const phrase = statusPhrase(agent);
+ return phrase ? `${agent.name}, ${phrase.toLowerCase()}` : agent.name;
+}
+
+const TAG_WORD = { approval: 'Approve', input: 'Answer', failed: 'Failed', queued: 'Queued' } as const;
+type TaggedStatus = keyof typeof TAG_WORD;
+
+/** States that show a word at the end of the row: the three that need you, and a queued run. */
+function hasStatusTag(status: AgentStatus): status is TaggedStatus {
+ return status in TAG_WORD;
+}
+/** The state's colour, only ever mixed in faintly: the tag stays nearly monochrome. */
+const TAG_TONE = {
+ approval: '[--tag:var(--warning)]',
+ input: '[--tag:var(--primary)]',
+ failed: '[--tag:var(--destructive)]',
+ queued: '[--tag:var(--muted-foreground)]',
+} as const;
+
+/**
+ * What an agent needs from you, as one word: a tag shaped like a drop, sharp
+ * at the corner nearest the name or initials it belongs to, with off-white
+ * text and a faint wash of the state's colour. Light theme fills it with a
+ * translucent mid grey so the light text holds; dark theme keeps it dark
+ * with a tinted edge. An open run's border shimmers like its name; a failed
+ * run's border holds still. A queued run gets the same shape as an outline
+ * only, quieter than anything that needs you. Nothing for other agents.
+ */
+function AgentStatusTag({ status }: { status: AgentStatus }) {
+ if (!hasStatusTag(status)) return null;
+ if (status === 'queued') {
+ return (
+
+ {TAG_WORD.queued}
+
+ );
+ }
+ return (
+
+ {/* The border: a ring masked to its 1px padding, so it sweeps like text shimmer without showing through the fill. */}
+
+ {/* Inner radius = outer radius - the 1px ring, so the border keeps an even width round the curve. */}
+
+ {TAG_WORD[status]}
+
+
+ );
+}
+
+/**
+ * The tag's word on its own, for the collapsed rail, where a boxed tag
+ * crowds the initials: small type under them, faintly tinted with the state's
+ * colour. It shimmers while the run waits on you, like the initials above it,
+ * and holds still once the run has failed or while it is queued.
+ */
+function AgentStatusWord({ status }: { status: AgentStatus }) {
+ if (!hasStatusTag(status)) return null;
+ return (
+
+ {TAG_WORD[status]}
+
+ );
+}
+
+/** Most pressing first: a decision, then a question, then a failure, then a queued run. */
+const TAG_PRIORITY: TaggedStatus[] = ['approval', 'input', 'failed', 'queued'];
+
+/** The tag a folded group of agents shows: its most pressing agent's, or `null` when none has one. */
+function mostUrgentTag(agents: AgentSummary[]): TaggedStatus | null {
+ let best: TaggedStatus | null = null;
+ for (const agent of agents) {
+ const status = agentStatus(agent);
+ if (hasStatusTag(status) && (best === null || TAG_PRIORITY.indexOf(status) < TAG_PRIORITY.indexOf(best))) best = status;
+ }
+ return best;
+}
+
+/**
+ * The first `limit` agents in roster order, except that an agent needing
+ * attention past the limit takes the place of the last calm row above it.
+ * Only rows that need a person move, so the list never reshuffles on its own,
+ * and it never grows past `limit`: once every row needs you, the rest wait
+ * behind "More agents".
+ */
+function visibleRoster(agents: AgentSummary[], limit: number): AgentSummary[] {
+ if (agents.length <= limit) return agents;
+ const head = agents.slice(0, limit);
+ const calm = head.filter((agent) => !needsAttention(agentStatus(agent))).length;
+ const promoted = agents
+ .slice(limit)
+ .filter((agent) => needsAttention(agentStatus(agent)))
+ .slice(0, calm);
+ let room = promoted.length;
+ const kept: AgentSummary[] = [];
+ for (const agent of head.reverse()) {
+ if (room > 0 && !needsAttention(agentStatus(agent))) room -= 1;
+ else kept.unshift(agent);
+ }
+ return [...kept, ...promoted];
+}
+
+export { AgentStatusTag, AgentStatusWord, agentLabel, agentStatus, hasStatusTag, isInFlight, isWaitingOnYou, mostUrgentTag, needsAttention, statusPhrase, visibleRoster };
diff --git a/packages/ui/src/components/agents-builder/agent-view.tsx b/packages/ui/src/components/agents-builder/agent-view.tsx
index fdca441..97c8d2d 100644
--- a/packages/ui/src/components/agents-builder/agent-view.tsx
+++ b/packages/ui/src/components/agents-builder/agent-view.tsx
@@ -6,26 +6,149 @@ import * as React from 'react';
import { cn } from '../../lib/utils';
import { Button } from '../button';
import { AgentCanvas } from './agent-canvas';
-import { AgentRunPanel } from './agent-run-panel';
-import { useAgentsBuilder } from './agents-builder-context';
+import { AgentRunPanel, type RunPhase } from './agent-run-panel';
+import { agentStatus } from './agent-status';
+import { AgentsBuilderContext, useAgentsBuilder } from './agents-builder-context';
import { focusRingClass, NavMenuButton, pressClass, TooltipIconButton, useInert } from '../workspace-kit/primitives';
+import { useLatest } from '../workspace-kit/use-latest';
+import type { AgentRunScript, AgentStatus } from './types';
import { useAgentRun } from './use-agent-run';
import { startViewTransition, ViewAnimation } from '../workspace-kit/view-transition';
-/**
- * Agent detail: breadcrumb header, the live run panel, and the agent canvas.
- * The run replays from the start each time the view opens.
- */
-function AgentView({ autoplay = true }: { autoplay?: boolean }) {
- const { data, emit } = useAgentsBuilder();
- const { state, stop } = useAgentRun(data.run, { autoplay });
- const [panelOpen, setPanelOpen] = React.useState(true);
+/** How a run opens. Replay plays it from the start; settled jumps to where it stopped; resolved shows a finished run. */
+type RunStart = 'replay' | 'settled' | 'resolved' | 'queued';
+
+function runStart(status: AgentStatus, autoplay: boolean): RunStart {
+ switch (status) {
+ case 'queued':
+ return 'queued';
+ case 'running':
+ return autoplay ? 'replay' : 'settled';
+ case 'idle':
+ return 'resolved';
+ default:
+ // Approval, input, and failed: open where the run stopped, at the decision or the error.
+ return 'settled';
+ }
+}
+
+/** What the sidebar row says while the run waits or after it fails. */
+function phaseNote(script: AgentRunScript, phase: RunPhase) {
+ if (phase === 'approval') return script.approval?.title;
+ if (phase === 'input') return script.question?.title;
+ if (phase === 'failed') return script.failure?.message;
+ return undefined;
+}
+
+type AgentRunSessionProps = {
+ script: AgentRunScript;
+ start: RunStart;
+ active: boolean;
+ queuedNote?: string;
+ panelOpen: boolean;
+ onTogglePanel: () => void;
+ onRestart: () => void;
+};
+
+/** One run of the agent: its panel and canvas share the replayed state, and its phase feeds the sidebar row. */
+function AgentRunSession({ script, start, active, queuedNote, panelOpen, onTogglePanel, onRestart }: AgentRunSessionProps) {
+ const { currentAgent, emit, updateAgent } = useAgentsBuilder();
+ const agentId = currentAgent.agent.id;
+ const activeRef = useLatest(active);
+ const [queued, setQueued] = React.useState(start === 'queued');
+ const { state, stop } = useAgentRun(script, { autoplay: start === 'replay' || start === 'queued', paused: queued });
const panelRef = useInert(!panelOpen);
+ const onPhaseChange = (phase: RunPhase) => {
+ // A queued agent keeps what the host said about it, e.g. when it starts.
+ if (phase === 'queued') return;
+ updateAgent(agentId, {
+ status: phase === 'done' ? 'idle' : phase,
+ statusNote: phase === 'done' || phase === 'running' ? undefined : phaseNote(script, phase),
+ // A run that finishes off screen leaves a result to read.
+ unread: phase === 'done' && !activeRef.current,
+ });
+ };
+
const layout = panelOpen
? { panel: 'w-full opacity-100 @4xl/view:w-[395px]', canvas: 'hidden @4xl/view:block' }
: { panel: 'hidden w-0 opacity-0 @4xl/view:flex', canvas: 'block' };
+ return (
+
+ );
+}
+
+type AgentViewProps = {
+ /** Replays a running agent's run from the start. Default true. */
+ autoplay?: boolean;
+ /** The agent to show. Defaults to the open agent. */
+ agentId?: string;
+ /** False while the view stays mounted off screen; a run finishing then leaves the agent unread. */
+ active?: boolean;
+};
+
+/**
+ * Agent detail: breadcrumb header, the run panel, and the agent canvas. The
+ * agent opens as it stands: a running agent replays its run, one waiting on
+ * you or failed opens where the run stopped, a queued one waits for its start,
+ * and an idle one shows its finished run. A retry plays the run again without
+ * its failure.
+ */
+function AgentView({ agentId, ...props }: AgentViewProps) {
+ const context = useAgentsBuilder();
+ const detail = (agentId === undefined ? undefined : context.agentDetail(agentId)) ?? context.currentAgent;
+ // Everything inside reads this agent as the current one, even while another agent is open.
+ const scoped = React.useMemo(() => ({ ...context, currentAgent: detail }), [context, detail]);
+ return (
+
+
+
+ );
+}
+
+function AgentViewBody({ autoplay = true, active = true }: Omit) {
+ const { data, currentAgent, agents, emit } = useAgentsBuilder();
+ const { agent, run } = currentAgent;
+ // The breadcrumb's scope: the agent's project when it has one.
+ const projectId = agents.find((candidate) => candidate.id === agent.id)?.projectId;
+ const scope = data.projects?.find((project) => project.id === projectId)?.name ?? 'Agents';
+ const [session, setSession] = React.useState(() => {
+ const summary = agents.find((candidate) => candidate.id === agent.id);
+ return { attempt: 0, start: runStart(summary ? agentStatus(summary) : 'idle', autoplay), queuedNote: summary?.statusNote };
+ });
+ const [panelOpen, setPanelOpen] = React.useState(true);
+
return (
);
}
export { AgentView };
+export type { AgentViewProps };
diff --git a/packages/ui/src/components/agents-builder/agents-builder-context.tsx b/packages/ui/src/components/agents-builder/agents-builder-context.tsx
index 0d97e09..7ba34de 100644
--- a/packages/ui/src/components/agents-builder/agents-builder-context.tsx
+++ b/packages/ui/src/components/agents-builder/agents-builder-context.tsx
@@ -3,6 +3,8 @@
import * as React from 'react';
import type {
+ AgentDetail,
+ AgentSummary,
AgentsBuilderAction,
AgentsBuilderData,
AgentsBuilderTheme,
@@ -10,6 +12,8 @@ import type {
Integration,
} from './types';
+type AgentPatch = Partial>;
+
type ConnectRequest = {
integrationId: string;
onConnected?: () => void;
@@ -17,6 +21,14 @@ type ConnectRequest = {
type AgentsBuilderContextValue = {
data: AgentsBuilderData;
+ /** The agent the agent view shows: the featured agent until another one with a detail opens. */
+ currentAgent: AgentDetail;
+ /** Blueprint and run for an agent the template can open itself. */
+ agentDetail: (id: string) => AgentDetail | undefined;
+ /** `data.agents` with the template's own changes applied: run progress and results seen. */
+ agents: AgentSummary[];
+ /** Records what the template learned about an agent, e.g. its run now waits on approval. */
+ updateAgent: (id: string, patch: AgentPatch) => void;
view: AgentsBuilderView;
setView: (view: AgentsBuilderView) => void;
integration: (id: string) => Integration | undefined;
@@ -24,7 +36,7 @@ type AgentsBuilderContextValue = {
/** Opens the connect flow for an integration. */
requestConnect: (request: ConnectRequest) => void;
agentName: (id: string) => string;
- /** Opens the agent view for the blueprint agent; asks the host for any other. */
+ /** Opens the agent view for an agent with a detail (the featured one or `agentDetails`); asks the host for any other. */
openAgent: (id: string) => void;
emit: (action: AgentsBuilderAction) => void;
theme: AgentsBuilderTheme;
@@ -42,4 +54,4 @@ function useAgentsBuilder() {
}
export { AgentsBuilderContext, useAgentsBuilder };
-export type { AgentsBuilderContextValue, ConnectRequest };
+export type { AgentPatch, AgentsBuilderContextValue, ConnectRequest };
diff --git a/packages/ui/src/components/agents-builder/agents-builder.tsx b/packages/ui/src/components/agents-builder/agents-builder.tsx
index 393e709..080838a 100644
--- a/packages/ui/src/components/agents-builder/agents-builder.tsx
+++ b/packages/ui/src/components/agents-builder/agents-builder.tsx
@@ -5,7 +5,7 @@ import * as React from 'react';
import { useControllableState } from '../../lib/use-controllable-state';
import { WorkspaceShell } from '../workspace-kit/shell';
import { AgentView } from './agent-view';
-import { AgentsBuilderContext, type AgentsBuilderContextValue, type ConnectRequest } from './agents-builder-context';
+import { type AgentPatch, AgentsBuilderContext, type AgentsBuilderContextValue, type ConnectRequest } from './agents-builder-context';
import { ChatView } from './chat-view';
import { ConnectIntegrationDialog } from './connect-integration-dialog';
import { InboxView } from './inbox-view';
@@ -14,6 +14,8 @@ import { IntegrationsView } from './integrations-view';
import { AgentsBuilderSidebar } from './sidebar';
import { SkillsView } from './skills-view';
import type {
+ AgentDetail,
+ AgentSummary,
AgentsBuilderAction,
AgentsBuilderData,
AgentsBuilderTheme,
@@ -39,11 +41,16 @@ type AgentsBuilderProps = {
defaultSidebarCollapsed?: boolean;
/** Shows the workspace as a rounded frame on a canvas instead of filling its container. */
framed?: boolean;
- /** Replays the agent run when the agent view opens. Defaults to true. */
+ /**
+ * Replays a running agent's run when its view opens. Defaults to true. Other
+ * agents open as they stand: queued, failed, waiting at the decision, or done.
+ */
autoplayRun?: boolean;
className?: string;
};
+const NO_PATCHES: Record = {};
+
/** Keeps the latest callback without re-creating anything that closes over it. */
function useLatest(value: T) {
const ref = React.useRef(value);
@@ -90,6 +97,12 @@ function AgentsBuilder({
const [request, setRequest] = React.useState(null);
const [dialogOpen, setDialogOpen] = React.useState(false);
const [usageDismissed, setUsageDismissed] = React.useState(false);
+ // Template-side agent changes, dropped whenever the host sends a new roster.
+ const [agentPatches, setAgentPatches] = React.useState<{ source: AgentSummary[]; byId: Record }>(() => ({
+ source: data.agents,
+ byId: {},
+ }));
+ const patches = agentPatches.source === data.agents ? agentPatches.byId : NO_PATCHES;
const onActionRef = useLatest(onAction);
const integrations = React.useMemo(
@@ -97,11 +110,41 @@ function AgentsBuilder({
[data.integrations],
);
const agentNames = React.useMemo(() => new Map(data.agents.map((agent) => [agent.id, agent.name])), [data.agents]);
+ const details = React.useMemo(
+ () =>
+ new Map([
+ [data.agent.id, { agent: data.agent, run: data.run }],
+ ...(data.agentDetails ?? []).map((detail): [string, AgentDetail] => [detail.agent.id, detail]),
+ ]),
+ [data.agent, data.agentDetails, data.run],
+ );
+ const [openAgentId, setOpenAgentId] = React.useState(data.agent.id);
+ const currentAgent = details.get(openAgentId) ?? details.get(data.agent.id)!;
+ // Agents opened so far. Their views stay mounted, so a run keeps going while you look elsewhere. They hide
+ // with display:none, not : a hidden Activity tears down effects, which would pause the run's clock.
+ const [keptAgentIds, setKeptAgentIds] = React.useState([]);
+ if (view === 'agent' && !keptAgentIds.includes(currentAgent.agent.id)) setKeptAgentIds([...keptAgentIds, currentAgent.agent.id]);
+ const agents = React.useMemo(
+ () => data.agents.map((agent) => (patches[agent.id] ? { ...agent, ...patches[agent.id] } : agent)),
+ [data.agents, patches],
+ );
const context = React.useMemo(() => {
const emit = (action: AgentsBuilderAction) => onActionRef.current?.(action);
+ const updateAgent = (id: string, patch: AgentPatch) =>
+ setAgentPatches((current) => {
+ const byId = current.source === data.agents ? current.byId : {};
+ const base = { ...data.agents.find((agent) => agent.id === id), ...byId[id] };
+ // A run reports its phase on every mount; an unchanged patch keeps the state, so nothing re-renders.
+ if (current.source === data.agents && (Object.keys(patch) as (keyof AgentPatch)[]).every((key) => base[key] === patch[key])) return current;
+ return { source: data.agents, byId: { ...byId, [id]: { ...byId[id], ...patch } } };
+ });
return {
data,
+ currentAgent,
+ agentDetail: (id) => details.get(id),
+ agents,
+ updateAgent,
view,
setView,
integration: (id) => integrations.get(id),
@@ -111,14 +154,21 @@ function AgentsBuilder({
setDialogOpen(true);
},
agentName: (id) => agentNames.get(id) ?? id,
- openAgent: (id) => (id === data.agent.id ? setView('agent') : emit({ type: 'open-agent', agentId: id })),
+ openAgent: (id) => {
+ // Opening an agent is reading its latest result.
+ if (agents.find((agent) => agent.id === id)?.unread) updateAgent(id, { unread: false });
+ if (details.has(id)) {
+ setOpenAgentId(id);
+ setView('agent');
+ } else emit({ type: 'open-agent', agentId: id });
+ },
emit,
theme,
setTheme,
usageDismissed,
dismissUsage: () => setUsageDismissed(true),
};
- }, [agentNames, connected, data, integrations, onActionRef, setTheme, setView, theme, usageDismissed, view]);
+ }, [agentNames, agents, connected, currentAgent, data, details, integrations, onActionRef, setTheme, setView, theme, usageDismissed, view]);
const connect = async (integration: Integration, toolIds: string[]) => {
await onConnectIntegration?.(integration, toolIds);
@@ -150,7 +200,16 @@ function AgentsBuilder({
{view === 'schedules' ? : null}
{view === 'integrations' ? : null}
{view === 'skills' ? : null}
- {view === 'agent' ? : null}
+ {keptAgentIds
+ .filter((id) => details.has(id))
+ .map((id) => {
+ const shown = view === 'agent' && id === currentAgent.agent.id;
+ return (
+
+
+
+ );
+ })}
void }) {
- const { data, emit } = useAgentsBuilder();
+ const { currentAgent, emit } = useAgentsBuilder();
const [edges, setEdges] = React.useState({ top: false, bottom: true });
- const { instructions } = data.agent;
+ const { instructions } = currentAgent.agent;
const fade = 'pointer-events-none absolute inset-x-0 h-5 from-card to-transparent transition-opacity duration-(--duration-moderate)';
return (
@@ -186,7 +186,7 @@ export function Instructions({ open, onToggle }: { open: boolean; onToggle: () =
emit({ type: 'edit-instructions', agentId: data.agent.id })}
+ onClick={() => emit({ type: 'edit-instructions', agentId: currentAgent.agent.id })}
className={cn(
'pointer-events-none absolute top-0 end-2 cursor-pointer rounded-md bg-card px-1.5 py-0.5 text-xs text-foreground opacity-0 shadow-card hover:bg-muted',
'group-hover:pointer-events-auto group-hover:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 pointer-coarse:pointer-events-auto pointer-coarse:opacity-100',
diff --git a/packages/ui/src/components/agents-builder/demo-agents.ts b/packages/ui/src/components/agents-builder/demo-agents.ts
new file mode 100644
index 0000000..036f76d
--- /dev/null
+++ b/packages/ui/src/components/agents-builder/demo-agents.ts
@@ -0,0 +1,517 @@
+import type { AgentBlueprint, AgentDetail, AgentRunScript } from './types';
+
+// Roster agents the demo opens on its own: each blueprint and its latest run.
+
+const CHURN_RADAR: AgentBlueprint = {
+ id: 'churn-radar',
+ name: 'Churn Radar',
+ title: 'Churn Radar Agent',
+ tagline: 'Finds accounts going quiet before they renew',
+ instructions: {
+ heading: 'Churn Radar Agent',
+ meta: 'Full system prompt. Version 1.6 · owner: Theo · last change: billing changes dropped before scoring.',
+ paragraphs: [
+ 'You are Churn Radar. Every weekday you score the accounts renewing in the next 90 days on how their usage moved over the last 30, and hand customer success a short list of the ones that need a call.',
+ 'A 25% drop in active usage is a flag; 40% is high. Weigh seat cuts and open support tickets, but never score on revenue alone: a large account holding steady is not at risk.',
+ 'Check every Stripe cancellation against billing-exceptions and the CRM before counting it. A move to annual invoicing or to a reseller is a billing change, not churn.',
+ 'Post the list to #customer-success with each account’s owner. Never contact a customer, and never change a subscription.',
+ ],
+ },
+ schedule: { when: 'Weekdays 10:00 AM', label: 'at-risk scan', cron: 'cron 0 10 * * 1-5', zone: 'UTC' },
+ trigger: { integrationId: 'stripe', label: 'Stripe webhook', conditions: ['type=subscription.updated', 'seats_delta<0'] },
+ channelsNotice: 'Set an agent identity to configure channels',
+ memory: ['risk-thresholds', 'billing-exceptions'],
+ tools: [
+ { id: 'snowflake-usage', label: 'Snowflake Query usage_30d', integrationId: 'snowflake' },
+ { id: 'stripe-renewals', label: 'Stripe List upcoming renewals', integrationId: 'stripe' },
+ { id: 'hubspot-owners', label: 'HubSpot Read account owners', integrationId: 'hubspot' },
+ { id: 'zendesk-tickets', label: 'Zendesk Read open tickets', integrationId: 'zendesk' },
+ { id: 'amplitude-seats', label: 'Amplitude Read active seats', integrationId: 'amplitude' },
+ ],
+ subagents: ['usage_scorer', 'renewal_matcher', 'billing_change_filter', 'owner_router'],
+ skills: ['churn-scoring', 'cohort-analysis'],
+};
+
+const CHURN_RADAR_RUN: AgentRunScript = {
+ command: { verb: 'Run', skill: 'at-risk-scan', scope: 'for Sep 24' },
+ reasoning: {
+ seconds: 11,
+ text: 'Kestrel Bank cut 12 seats in Stripe this morning, which is what started this run. A seat cut alone is not risk; usage falling ahead of a renewal is. So score everyone renewing in the next 90 days on 30-day usage, then drop billing changes like Halcyon before anything reaches customer success.',
+ },
+ plan: [
+ 'Load risk thresholds and exceptions',
+ 'Pull usage, renewals and owners',
+ 'Score accounts renewing in 90 days',
+ 'Drop billing changes and route owners',
+ 'Write the at-risk list',
+ ],
+ paragraphs: [
+ 'Starting the at-risk scan for Sep 24. Kestrel Bank cut 12 seats in Stripe at 08:52, which started this run early; the scan still covers every account renewing before Dec 23, not only Kestrel.',
+ 'risk-thresholds flags a 25% usage drop over 30 days and calls 40% high. billing-exceptions lists accounts whose Stripe cancellations are invoicing changes, so those come off before anyone is asked to call.',
+ ],
+ steps: CHURN_RADAR.tools,
+ query: {
+ name: 'at_risk_accounts.sql',
+ sql: [
+ 'select a.name as account,',
+ ' u.usage_change_30d,',
+ ' r.renews_on,',
+ ' r.mrr,',
+ ' s.risk',
+ 'from analytics.usage_30d u',
+ 'join billing.upcoming_renewals r using (account_id)',
+ 'join analytics.accounts a using (account_id)',
+ 'join analytics.churn_scores s using (account_id)',
+ 'where r.renews_on < current_date + 90',
+ ' and u.usage_change_30d <= -0.25',
+ ' and not r.billing_change',
+ 'order by s.score desc;',
+ ].join('\n'),
+ columns: ['Account', 'Usage 30d', 'Renews', 'MRR', 'Risk'],
+ rows: [
+ ['Brightline Freight', '−52%', 'Nov 14', '$9,800', 'High'],
+ ['Corvid Labs', '−47%', 'Dec 2', '$7,200', 'High'],
+ ['Pelican Dental', '−41%', 'Nov 30', '$6,450', 'High'],
+ ['Kestrel Bank', '−33%', 'Oct 19', '$12,600', 'Medium'],
+ ['Orchard Schools', '−29%', 'Dec 18', '$4,150', 'Medium'],
+ ],
+ caption: '$40,200 MRR renewing by Dec 23 · 5 rows · 486 ms',
+ },
+ groups: [
+ { id: 'subagents', label: 'Sub-agents', items: CHURN_RADAR.subagents },
+ { id: 'skills', label: 'Skills', items: CHURN_RADAR.skills },
+ ],
+ findings: {
+ paragraphs: [
+ 'Five accounts renewing by Dec 23 lost 25% or more of their usage, $40.2k MRR between them. The three high-risk ones were on Tuesday’s list. Kestrel Bank is new and renews first, Oct 19: 19 of its 58 seats stopped logging in after Sep 8.',
+ 'Kestrel’s admin contact in HubSpot has bounced since Sep 9, so the drop likely follows a champion leaving. Halcyon Logistics is left off: its Stripe cancellation is the move to annual invoicing, already on billing-exceptions.',
+ ],
+ sources: [
+ { title: 'usage_30d', description: 'Snowflake · analytics.usage_30d, refreshed 09:00 UTC' },
+ { title: 'Upcoming renewals', description: 'Stripe · 112 subscriptions renewing by Dec 23' },
+ { title: 'Kestrel Bank', description: 'HubSpot company · admin contact bounced Sep 9' },
+ ],
+ },
+ thinking: { seconds: 48, tokens: 2700 },
+};
+
+const DATA_QUALITY_MONITOR: AgentBlueprint = {
+ id: 'data-quality-monitor',
+ name: 'Data Quality Monitor',
+ title: 'Data Quality Monitor Agent',
+ tagline: 'Catches late tables before the dashboards do',
+ instructions: {
+ heading: 'Data Quality Monitor Agent',
+ meta: 'Full system prompt. Version 2.0 · owner: Jonas · last change: late tables traced to a change before alerting.',
+ paragraphs: [
+ 'You are the Data Quality Monitor. Every hour you check the 14 tables in freshness-slas against their SLA and say which are late, by how much, and why.',
+ 'Read freshness from the load column each table declares. When a column is renamed upstream, map it in column-mappings and say so; never skip a table to make a check pass.',
+ 'Trace anything late to the dbt run, merge or upstream incident behind it before alerting. An alert without a cause is a page someone has to repeat.',
+ 'Post to #data-alerts only. Never rerun a job or merge a fix without approval.',
+ ],
+ },
+ schedule: { when: 'Every hour', label: 'freshness checks', cron: 'cron 0 * * * *', zone: 'UTC' },
+ trigger: { integrationId: 'dbt', label: 'dbt job finished', conditions: ['status=error', 'job=nightly'] },
+ channelsNotice: 'Set an agent identity to configure channels',
+ memory: ['freshness-slas', 'column-mappings'],
+ tools: [
+ { id: 'dbt-runs', label: 'dbt Read model runs', integrationId: 'dbt' },
+ { id: 'postgres-slas', label: 'Postgres Read freshness SLAs', integrationId: 'postgres' },
+ { id: 'postgres-freshness', label: 'Postgres Check loaded_at on 14 tables', integrationId: 'postgres' },
+ { id: 'github-merges', label: 'GitHub Read merged PRs · warehouse', integrationId: 'github' },
+ { id: 'datadog-monitors', label: 'Datadog Read ingestion monitors', integrationId: 'datadog' },
+ ],
+ subagents: ['freshness_checker', 'lineage_tracer', 'change_matcher'],
+ skills: ['anomaly-detection', 'sql-review', 'schema-migration'],
+};
+
+const DATA_QUALITY_MONITOR_RUN: AgentRunScript = {
+ command: { verb: 'Run', skill: 'freshness-check', scope: 'for Sep 24 · 08:00' },
+ reasoning: {
+ seconds: 8,
+ text: 'The 08:00 freshness check is due. Fourteen tables, each with its own SLA in freshness-slas, from 1h for payments to 6h for invoices. Read the newest load per table, compare it with the SLA, and if anything is late, match it to the dbt run or merge behind it before posting, so the alert says why and not only what.',
+ },
+ plan: [
+ 'Read the overnight dbt runs',
+ 'Check freshness against each SLA',
+ 'Rank tables by age',
+ 'Trace anything late to its change',
+ 'Summarise for #data-alerts',
+ ],
+ paragraphs: [
+ 'Starting the 08:00 freshness check. Reading the overnight dbt runs first, so a late table can be tied to the job that should have loaded it rather than reported on its own.',
+ 'freshness-slas covers 14 tables. Each check reads the newest value of the table’s load column against its SLA; anything late gets traced to the change behind it before an alert goes to #data-alerts.',
+ ],
+ steps: DATA_QUALITY_MONITOR.tools,
+ query: {
+ name: 'table_freshness.sql',
+ sql: [
+ 'select table_name,',
+ ' max_loaded_at,',
+ ' now() - max_loaded_at as age,',
+ ' sla',
+ 'from monitoring.table_freshness',
+ 'order by age desc',
+ 'limit 5;',
+ ].join('\n'),
+ columns: ['Table', 'Last loaded', 'Age', 'SLA'],
+ rows: [
+ ['invoices', '07:45 UTC', '1h 33m', '6h'],
+ ['payments', '08:47 UTC', '31m', '1h'],
+ ['subscriptions', '08:58 UTC', '20m', '2h'],
+ ['orders_daily', '09:06 UTC', '12m', '2h'],
+ ['events_hourly', '09:10 UTC', '8m', '1h'],
+ ],
+ caption: '14 of 14 tables inside SLA · 5 rows · 208 ms',
+ },
+ groups: [
+ { id: 'subagents', label: 'Sub-agents', items: DATA_QUALITY_MONITOR.subagents },
+ { id: 'skills', label: 'Skills', items: DATA_QUALITY_MONITOR.skills },
+ ],
+ findings: {
+ paragraphs: [
+ 'All 14 tables are inside their SLA. The 08:00 check failed because PR #412 renamed orders_daily.loaded_at to ingested_at. The check now reads ingested_at for orders_daily, which last loaded at 09:06 UTC, 12 minutes ago against a 2h SLA.',
+ 'The oldest load is invoices at 1h 33m, well inside its 6h SLA. column-mappings now records the rename, so the next column change on orders shows up as a mapping to confirm instead of a failed run.',
+ ],
+ sources: [
+ { title: 'table_freshness', description: 'Postgres · monitoring.table_freshness, 14 tables' },
+ { title: 'PR #412', description: 'GitHub · Rename order columns, merged Sep 24' },
+ { title: 'orders_daily', description: 'dbt Cloud · last build 09:06 UTC' },
+ ],
+ },
+ failure: {
+ stepId: 'postgres-freshness',
+ message: 'Column orders_daily.loaded_at no longer exists.',
+ log: [
+ '08:00:41 UTC freshness analytics.orders_daily ERROR',
+ 'ERROR: column "loaded_at" does not exist',
+ 'LINE 1: select max(loaded_at) as last_loaded from analytics.orders_daily',
+ ' ^',
+ 'HINT: Perhaps you meant to reference the column "orders_daily.ingested_at".',
+ ].join('\n'),
+ retryLabel: 'Retry run',
+ },
+ thinking: { seconds: 35, tokens: 1900 },
+};
+
+const SCHEMA_STEWARD: AgentBlueprint = {
+ id: 'schema-steward',
+ name: 'Schema Steward',
+ title: 'Schema Steward Agent',
+ tagline: 'Keeps every migration in the right schema',
+ instructions: {
+ heading: 'Schema Steward Agent',
+ meta: 'Full system prompt. Version 1.4 · owner: Ines · last change: billing tables live in billing with RLS on.',
+ paragraphs: [
+ 'You are the Schema Steward. Nightly, you compare production with the migrations on main; on every pull request that touches migrations/, you check the new migration before it merges.',
+ 'Every table belongs to a domain schema. Tables holding customer or billing data need RLS on and grants only to their service role; public is for shared lookups.',
+ 'Use schema-migration to check locks, backfills and RLS. When the branch has a migrated preview database, read it instead of parsing the SQL.',
+ 'Comment on the pull request and file drift in Linear · Data platform. Ask before suggesting a schema the author did not choose, and never push to someone else’s branch.',
+ ],
+ },
+ schedule: { when: 'Daily 02:00 AM', label: 'nightly drift check', cron: 'cron 0 2 * * *', zone: 'UTC' },
+ trigger: { integrationId: 'github', label: 'GitHub pull request', conditions: ['path=migrations/**', 'base=main'] },
+ channelsNotice: 'Set an agent identity to configure channels',
+ memory: ['schema-conventions', 'drift-history'],
+ tools: [
+ { id: 'github-pr', label: 'GitHub Read pull request #427', integrationId: 'github' },
+ { id: 'github-migrations', label: 'GitHub Read migrations on main', integrationId: 'github' },
+ { id: 'postgres-schema', label: 'Postgres List schemas', integrationId: 'postgres' },
+ { id: 'postgres-grants', label: 'Postgres Read grants and RLS policies', integrationId: 'postgres' },
+ { id: 'dbt-sources', label: 'dbt Read sources', integrationId: 'dbt' },
+ ],
+ subagents: ['migration_reader', 'drift_differ', 'grant_auditor', 'lock_checker'],
+ skills: ['schema-migration', 'sql-review'],
+};
+
+const SCHEMA_STEWARD_RUN: AgentRunScript = {
+ command: { verb: 'Run', skill: 'migration-review', scope: 'for PR #427' },
+ reasoning: {
+ seconds: 16,
+ text: 'PR #427 adds a migration that creates three billing_ tables. Every other billing table lives in the billing schema with RLS on and only billing_service granted; if these land in public, app_user can read invoices across tenants. Check main and production for the convention first, then ask, since where the tables live is the author’s call.',
+ },
+ plan: [
+ 'Read the migration in PR #427',
+ 'Compare with main and production',
+ 'Check schemas, grants and RLS',
+ 'Review locks and backfills',
+ 'Write up the drift',
+ 'Ask where the tables belong',
+ ],
+ paragraphs: [
+ 'Reviewing PR #427 (feat/usage-billing) against the schema rules. Its migration, 0142_usage_billing.sql, creates three tables and grants nothing explicitly, so where they land is the first thing to check.',
+ 'Main and production matched at last night’s drift check, so anything new comes from this branch. The preview database already has the migration applied, which shows the real result instead of a guess from the SQL.',
+ ],
+ steps: SCHEMA_STEWARD.tools,
+ query: {
+ name: 'billing_tables.sql',
+ sql: [
+ 'select c.relname as table_name,',
+ ' n.nspname as schema,',
+ ' c.relrowsecurity as rls,',
+ " string_agg(g.grantee, ', ') as grants",
+ 'from pg_class c',
+ 'join pg_namespace n on n.oid = c.relnamespace',
+ 'left join information_schema.role_table_grants g',
+ ' on g.table_schema = n.nspname and g.table_name = c.relname',
+ "where c.relkind = 'r'",
+ " and (c.relname like 'billing_%' or n.nspname = 'billing')",
+ 'group by 1, 2, 3',
+ 'order by 2 desc, 1;',
+ ].join('\n'),
+ columns: ['Table', 'Schema', 'RLS', 'Grants'],
+ rows: [
+ ['billing_credits', 'public', 'off', 'app_user'],
+ ['billing_invoices', 'public', 'off', 'app_user'],
+ ['billing_line_items', 'public', 'off', 'app_user'],
+ ['payment_methods', 'billing', 'on', 'billing_service'],
+ ['subscriptions', 'billing', 'on', 'billing_service'],
+ ],
+ caption: '3 of 5 billing tables outside billing · 5 rows · 94 ms',
+ },
+ groups: [
+ { id: 'subagents', label: 'Sub-agents', items: SCHEMA_STEWARD.subagents },
+ { id: 'skills', label: 'Skills', items: SCHEMA_STEWARD.skills },
+ ],
+ findings: {
+ paragraphs: [
+ 'Three new tables, billing_invoices, billing_line_items and billing_credits, land in public with RLS off and a grant to app_user. The other billing tables live in billing with RLS on, so as written any signed-in user could read every tenant’s invoices.',
+ 'The rest of the migration is safe: no locks on existing tables and no backfill. Moving the tables is a two-line change, but it changes what the app queries, so it is Leo’s call where they belong.',
+ ],
+ sources: [
+ { title: 'PR #427', description: 'GitHub · feat/usage-billing, 0142_usage_billing.sql' },
+ { title: 'Schema conventions', description: 'Notion · Data platform / Schemas' },
+ { title: 'Preview database', description: 'Postgres · pr-427 branch, migrated 08:51 UTC' },
+ ],
+ },
+ question: {
+ title: 'Which schema should own the new billing tables?',
+ questions: [
+ {
+ id: 'owner-schema',
+ question: 'Where should billing_invoices, billing_line_items and billing_credits live?',
+ options: [
+ {
+ id: 'billing',
+ label: 'Move them to billing',
+ reply: 'Moving all three to billing, next to subscriptions and payment_methods. The comment on #427 has the edited migration ready to commit.',
+ },
+ {
+ id: 'public',
+ label: 'Keep them in public',
+ reply: 'Keeping them in public as Leo wrote them. The comment still asks for RLS, since app_user can read every row as it stands.',
+ },
+ {
+ id: 'usage-billing',
+ label: 'New usage_billing schema',
+ reply: 'Proposing a usage_billing schema with its own service role. It is a bigger change, so it goes on #427 as a suggestion.',
+ },
+ ],
+ },
+ {
+ id: 'rls',
+ question: 'Turn on row-level security in the same migration?',
+ options: [
+ { id: 'same-pr', label: 'Yes, in this PR', reply: 'RLS goes into 0142 itself, so the tables never exist without it.' },
+ {
+ id: 'follow-up',
+ label: 'In a follow-up PR',
+ reply: 'Filed the RLS work in Linear and linked it from #427, so it cannot merge unnoticed.',
+ },
+ ],
+ },
+ ],
+ steps: [
+ { id: 'github-comment', label: 'GitHub Comment on PR #427', integrationId: 'github' },
+ { id: 'linear-issue', label: 'Linear Create issue · Data platform', integrationId: 'linear' },
+ ],
+ answeredText:
+ 'Commented on PR #427 with the change and filed it in Linear · Data platform. Tonight’s drift check flags it again if it merges as is.',
+ skippedText: 'Left PR #427 without a comment. The three tables stay on tonight’s drift report until someone picks a schema.',
+ },
+ thinking: { seconds: 52, tokens: 3000 },
+};
+
+const METRICS_LIBRARIAN: AgentBlueprint = {
+ id: 'metrics-librarian',
+ name: 'Metrics Librarian',
+ title: 'Metrics Librarian Agent',
+ tagline: 'One name for every metric, the one finance uses',
+ instructions: {
+ heading: 'Metrics Librarian Agent',
+ meta: 'Full system prompt. Version 1.2 · owner: Mira · last change: old names kept as aliases for 90 days.',
+ paragraphs: [
+ 'You are the Metrics Librarian. You keep metric names in dbt, Metabase and Notion matching the finance glossary, which is the source of truth.',
+ 'When the glossary changes, rename the dbt metric to the glossary term and keep the old name as an alias for 90 days. Count every Metabase question on the old name first.',
+ 'A rename never changes what a metric counts. If the glossary definition differs from dbt, stop and raise it in #data-team instead of renaming.',
+ 'Open renames as pull requests and record each one in Metrics / Glossary.',
+ ],
+ },
+ schedule: { when: 'Fri 04:00 PM', label: 'weekly glossary sync', cron: 'cron 0 16 * * 5', zone: 'UTC' },
+ trigger: { integrationId: 'notion', label: 'Notion page updated', conditions: ['page=Finance / Glossary', 'status=approved'] },
+ channelsNotice: 'Set an agent identity to configure channels',
+ memory: ['metric-definitions', 'finance-glossary'],
+ tools: [
+ { id: 'notion-glossary', label: 'Notion Read Finance / Glossary', integrationId: 'notion' },
+ { id: 'dbt-metrics', label: 'dbt Read metric definitions', integrationId: 'dbt' },
+ { id: 'metabase-questions', label: 'Metabase List questions by metric', integrationId: 'metabase' },
+ { id: 'github-pr', label: 'GitHub Open PR · metric renames', integrationId: 'github' },
+ { id: 'notion-metrics', label: 'Notion Update Metrics / Glossary', integrationId: 'notion' },
+ ],
+ subagents: ['glossary_reader', 'name_differ', 'usage_counter', 'alias_writer'],
+ skills: ['metric-definitions', 'glossary-sync'],
+};
+
+const METRICS_LIBRARIAN_RUN: AgentRunScript = {
+ command: { verb: 'Run', skill: 'glossary-sync', scope: 'for Sep 23' },
+ reasoning: {
+ seconds: 9,
+ text: 'Finance approved the new glossary, which is what the paused sync was waiting on. Three dbt metric names no longer match it. A rename is safe only if nothing downstream breaks, so count every Metabase question on the old names, keep aliases, and confirm each definition is unchanged before touching a name.',
+ },
+ plan: [
+ 'Read the approved glossary',
+ 'Compare dbt metric names',
+ 'Count questions on each old name',
+ 'Rename with 90-day aliases',
+ 'Update Metrics / Glossary',
+ ],
+ paragraphs: [
+ 'Syncing metric names with the finance glossary Ana approved on Sep 23. Reading the glossary first, then the five revenue metrics in dbt it covers, so each name is compared with the term finance signs off on.',
+ 'Renames are cheap in dbt and expensive in dashboards. Every Metabase question on an old name gets counted before anything changes, and the old names stay as aliases for 90 days so nothing goes blank.',
+ ],
+ steps: METRICS_LIBRARIAN.tools,
+ query: {
+ name: 'metric_names.sql',
+ sql: [
+ 'select m.name as dbt_metric,',
+ ' g.term as glossary_term,',
+ ' count(q.id) as questions,',
+ " case when m.name = g.term then 'match' else 'rename' end as status",
+ 'from governance.dbt_metrics m',
+ 'join governance.finance_glossary g using (metric_key)',
+ 'left join governance.metabase_questions q using (metric_key)',
+ 'group by 1, 2',
+ 'order by status desc, questions desc;',
+ ].join('\n'),
+ columns: ['dbt metric', 'Glossary term', 'Questions', 'Status'],
+ rows: [
+ ['nrr', 'net_revenue_retention', '14', 'Rename'],
+ ['gross_churn', 'gross_revenue_churn', '9', 'Rename'],
+ ['active_customers', 'paying_accounts', '6', 'Rename'],
+ ['mrr', 'mrr', '31', 'Match'],
+ ['arr', 'arr', '12', 'Match'],
+ ],
+ caption: '3 renames · 29 questions affected · 5 rows · 141 ms',
+ },
+ groups: [
+ { id: 'subagents', label: 'Sub-agents', items: METRICS_LIBRARIAN.subagents },
+ { id: 'skills', label: 'Skills', items: METRICS_LIBRARIAN.skills },
+ ],
+ findings: {
+ paragraphs: [
+ 'Renamed 3 metrics to match the finance glossary: nrr, gross_churn and active_customers are now net_revenue_retention, gross_revenue_churn and paying_accounts in PR #431. The old names stay as aliases until Dec 22, so all 29 Metabase questions keep working.',
+ 'Each glossary term has the same definition as the metric it replaces, so no number changes. Metrics / Glossary in Notion lists the old names under each term until the aliases drop.',
+ ],
+ sources: [
+ { title: 'Finance glossary', description: 'Notion · Finance / Glossary, approved Sep 23' },
+ { title: 'PR #431', description: 'GitHub · Rename 3 metrics to glossary terms' },
+ { title: 'Metabase', description: '29 questions on the renamed metrics' },
+ ],
+ },
+ thinking: { seconds: 41, tokens: 2300 },
+};
+
+const FORECAST_PLANNER: AgentBlueprint = {
+ id: 'forecast-planner',
+ name: 'Forecast Planner',
+ title: 'Forecast Planner Agent',
+ tagline: 'Forecasts MRR with a range, every month',
+ instructions: {
+ heading: 'Forecast Planner Agent',
+ meta: 'Full system prompt. Version 3.1 · owner: Sana · last change: signed renewals treated as kept.',
+ paragraphs: [
+ 'You are the Forecast Planner. On the 1st of each month you forecast MRR to the end of the next quarter, month by month, with an 80% range and the gap to the FY26 plan.',
+ 'Fit on MRR history as metric-definitions defines it. Count pipeline at stage-weighted odds and treat signed renewals as kept, whatever Stripe shows.',
+ 'Explain every move from last month’s forecast larger than $5k, and name the cohort or deal behind it.',
+ 'Post to #finance. Never change the plan, and never share a forecast outside finance without approval.',
+ ],
+ },
+ schedule: { when: '1st 08:00 AM', label: 'monthly forecast', cron: 'cron 0 8 1 * *', zone: 'UTC' },
+ trigger: { integrationId: 'hubspot', label: 'HubSpot deal stage', conditions: ['stage=closed_won', 'amount>50000'] },
+ channelsNotice: 'Set an agent identity to configure channels',
+ memory: ['metric-definitions', 'fy26-plan'],
+ tools: [
+ { id: 'snowflake-history', label: 'Snowflake Query mrr_history', integrationId: 'snowflake' },
+ { id: 'hubspot-pipeline', label: 'HubSpot Read pipeline', integrationId: 'hubspot' },
+ { id: 'stripe-renewals', label: 'Stripe List upcoming renewals', integrationId: 'stripe' },
+ { id: 'google-drive-plan', label: 'Google Drive Read FY26 operating plan', integrationId: 'google-drive' },
+ { id: 'notion-pricing', label: 'Notion Read Pricing log', integrationId: 'notion' },
+ ],
+ subagents: ['history_loader', 'pipeline_weigher', 'scenario_builder', 'variance_explainer'],
+ skills: ['metric-definitions', 'scenario-modeling', 'board-report'],
+};
+
+const FORECAST_PLANNER_RUN: AgentRunScript = {
+ command: { verb: 'Run', skill: 'monthly-forecast', scope: 'for Oct–Dec' },
+ reasoning: {
+ seconds: 18,
+ text: 'Last month put Q4 exit MRR at $1.46M, 2.8% above plan. Since then Halcyon moved to annual invoicing and the 2025-Q2 Team cohort started contracting. Halcyon’s renewal is signed, so it should not move the forecast; the contraction should, if it holds. Refit on actuals through Sep 23, weigh the pipeline, then compare with the plan month by month.',
+ },
+ plan: [
+ 'Load MRR history and the FY26 plan',
+ 'Pull pipeline and renewals',
+ 'Fit the forecast by month',
+ 'Run scenarios and explain variance',
+ 'Write the forecast for #finance',
+ ],
+ paragraphs: [
+ 'Starting the monthly forecast for Oct–Dec. Loading MRR history through Sep 23 and the FY26 operating plan, so each month is set against the number finance committed to, not against last month’s forecast.',
+ 'metric-definitions spreads annual plans monthly, so Halcyon’s move to annual invoicing nets to zero here. Pipeline counts only at stage-weighted odds, and renewals signed in HubSpot are treated as kept.',
+ ],
+ steps: FORECAST_PLANNER.tools,
+ query: {
+ name: 'mrr_forecast.sql',
+ sql: [
+ 'select month,',
+ ' forecast_mrr,',
+ ' lower_80,',
+ ' upper_80,',
+ ' forecast_mrr / plan_mrr - 1 as vs_plan',
+ 'from planning.mrr_forecast',
+ 'where run_date = current_date',
+ 'order by month;',
+ ].join('\n'),
+ columns: ['Month', 'Forecast MRR', '80% range', 'vs plan'],
+ rows: [
+ ['Sep', '$1.400M', '$1.394M–$1.406M', '+2.0%'],
+ ['Oct', '$1.412M', '$1.391M–$1.433M', '+1.7%'],
+ ['Nov', '$1.431M', '$1.395M–$1.467M', '+1.9%'],
+ ['Dec', '$1.452M', '$1.401M–$1.503M', '+2.3%'],
+ ],
+ caption: 'Dec exit $1.452M (±3.5%) · 4 rows · 1.2 s',
+ },
+ groups: [
+ { id: 'subagents', label: 'Sub-agents', items: FORECAST_PLANNER.subagents },
+ { id: 'skills', label: 'Skills', items: FORECAST_PLANNER.skills },
+ ],
+ findings: {
+ paragraphs: [
+ 'Q4 exit MRR is forecast at $1.452M (80% range $1.401M–$1.503M), 2.3% above plan and $8k below last month’s $1.46M. The $8k is the Team-plan contraction in the 2025-Q2 cohort, carried forward at its September rate.',
+ 'Halcyon nets to zero: its renewal is signed and annual billing is spread monthly. Weighted enterprise pipeline is $188k new MRR across 14 deals, unchanged since Sep 1, so the range widens with time, not with new risk.',
+ ],
+ sources: [
+ { title: 'mrr_history', description: 'Snowflake · finance.mrr_history, through Sep 23' },
+ { title: 'Q4 pipeline', description: 'HubSpot · 14 open deals, stage-weighted' },
+ { title: 'FY26 operating plan', description: 'Google Drive · Finance / Plans' },
+ ],
+ },
+ thinking: { seconds: 74, tokens: 4100 },
+};
+
+export const DEMO_AGENT_DETAILS: AgentDetail[] = [
+ { agent: CHURN_RADAR, run: CHURN_RADAR_RUN },
+ { agent: DATA_QUALITY_MONITOR, run: DATA_QUALITY_MONITOR_RUN },
+ { agent: SCHEMA_STEWARD, run: SCHEMA_STEWARD_RUN },
+ { agent: METRICS_LIBRARIAN, run: METRICS_LIBRARIAN_RUN },
+ { agent: FORECAST_PLANNER, run: FORECAST_PLANNER_RUN },
+];
diff --git a/packages/ui/src/components/agents-builder/fixtures.ts b/packages/ui/src/components/agents-builder/fixtures.ts
index 7af83ce..d870d51 100644
--- a/packages/ui/src/components/agents-builder/fixtures.ts
+++ b/packages/ui/src/components/agents-builder/fixtures.ts
@@ -1,8 +1,10 @@
import { DEMO_FALLBACK_REPLY, DEMO_FOLLOW_UP_REPLY, DEMO_RECOMMENDATIONS } from './demo-conversations';
import { DEMO_INBOX } from './demo-inbox';
+import { DEMO_AGENT_DETAILS } from './demo-agents';
import { DEMO_CLOCK, DEMO_SCHEDULES } from './demo-schedules';
import { DEMO_INTEGRATIONS } from './demo-integrations';
import type {
+ AgentProject,
AgentBlueprint,
AgentRunScript,
AgentsBuilderData,
@@ -14,7 +16,7 @@ import type {
StarterPack,
} from './types';
-export { DEMO_CLOCK, DEMO_FALLBACK_REPLY, DEMO_FOLLOW_UP_REPLY, DEMO_INBOX, DEMO_INTEGRATIONS, DEMO_RECOMMENDATIONS, DEMO_SCHEDULES };
+export { DEMO_AGENT_DETAILS, DEMO_CLOCK, DEMO_FALLBACK_REPLY, DEMO_FOLLOW_UP_REPLY, DEMO_INBOX, DEMO_INTEGRATIONS, DEMO_RECOMMENDATIONS, DEMO_SCHEDULES };
// Demo workspace for docs, Storybook, and local exploration. Replace every
// collection with host data; nothing here is fetched or persisted.
@@ -119,20 +121,26 @@ export const DEMO_MODELS: ModelOption[] = [
},
];
+export const DEMO_PROJECTS: AgentProject[] = [
+ { id: 'revenue', name: 'Revenue' },
+ { id: 'data-platform', name: 'Data platform' },
+ { id: 'governance', name: 'Governance' },
+];
+
export const DEMO_AGENTS: AgentSummary[] = [
- { id: 'revenue-analyst', name: 'Revenue Analyst', running: true },
- { id: 'churn-radar', name: 'Churn Radar' },
- { id: 'data-quality-monitor', name: 'Data Quality Monitor', running: true },
- { id: 'schema-steward', name: 'Schema Steward' },
- { id: 'metrics-librarian', name: 'Metrics Librarian' },
- { id: 'forecast-planner', name: 'Forecast Planner' },
- { id: 'board-report-writer', name: 'Board Report Writer' },
- { id: 'query-optimizer', name: 'Query Optimizer' },
- { id: 'cohort-builder', name: 'Cohort Builder' },
- { id: 'access-auditor', name: 'Access Auditor' },
- { id: 'pipeline-doctor', name: 'Pipeline Doctor' },
- { id: 'dashboard-curator', name: 'Dashboard Curator' },
- { id: 'anomaly-watch', name: 'Anomaly Watch' },
+ { id: 'revenue-analyst', projectId: 'revenue', name: 'Revenue Analyst', status: 'approval', statusNote: 'Post the summary to #revenue?' },
+ { id: 'churn-radar', projectId: 'revenue', name: 'Churn Radar', status: 'running' },
+ { id: 'data-quality-monitor', projectId: 'data-platform', name: 'Data Quality Monitor', status: 'failed', statusNote: 'Column orders_daily.loaded_at no longer exists.' },
+ { id: 'schema-steward', projectId: 'data-platform', name: 'Schema Steward', status: 'input', statusNote: 'Which schema should own the new billing tables?' },
+ { id: 'metrics-librarian', projectId: 'data-platform', name: 'Metrics Librarian', unread: true, statusNote: 'Renamed 3 metrics to match the finance glossary.' },
+ { id: 'forecast-planner', projectId: 'revenue', name: 'Forecast Planner', status: 'queued', statusNote: 'The October forecast starts at 09:30 UTC.' },
+ { id: 'board-report-writer', projectId: 'revenue', name: 'Board Report Writer' },
+ { id: 'query-optimizer', projectId: 'data-platform', name: 'Query Optimizer' },
+ { id: 'cohort-builder', projectId: 'revenue', name: 'Cohort Builder' },
+ { id: 'access-auditor', projectId: 'governance', name: 'Access Auditor' },
+ { id: 'pipeline-doctor', projectId: 'data-platform', name: 'Pipeline Doctor' },
+ { id: 'dashboard-curator', projectId: 'governance', name: 'Dashboard Curator' },
+ { id: 'anomaly-watch', projectId: 'data-platform', name: 'Anomaly Watch' },
];
export const DEMO_SKILLS: Skill[] = [
@@ -352,6 +360,7 @@ export const AGENTS_BUILDER_DEMO: AgentsBuilderData = {
categories: DEMO_CATEGORIES,
featuredIntegrationIds: ['postgres', 'snowflake', 'stripe', 'dbt', 'hubspot', 'slack', 'notion', 'github'],
agents: DEMO_AGENTS,
+ projects: DEMO_PROJECTS,
inbox: DEMO_INBOX,
schedules: DEMO_SCHEDULES,
clock: DEMO_CLOCK,
@@ -362,6 +371,7 @@ export const AGENTS_BUILDER_DEMO: AgentsBuilderData = {
followUpReply: DEMO_FOLLOW_UP_REPLY,
agent: DEMO_AGENT,
run: DEMO_RUN,
+ agentDetails: DEMO_AGENT_DETAILS,
heroHighlights: DEMO_HERO_HIGHLIGHTS,
usage: { percent: 80 },
};
diff --git a/packages/ui/src/components/agents-builder/index.ts b/packages/ui/src/components/agents-builder/index.ts
index d115bda..50b562a 100644
--- a/packages/ui/src/components/agents-builder/index.ts
+++ b/packages/ui/src/components/agents-builder/index.ts
@@ -3,6 +3,7 @@
export type {
AgentBlueprint,
+ AgentDetail,
AgentSchedule,
AgentDraft,
AgentRunScript,
@@ -10,6 +11,8 @@ export type {
AgentsBuilderData,
AgentsBuilderTheme,
AgentsBuilderView,
+ AgentProject,
+ AgentStatus,
AgentSummary,
AskBeatOption,
AskBeatQuestion,
@@ -27,6 +30,8 @@ export type {
ModelOption,
PromptPart,
RunApproval,
+ RunFailure,
+ RunQuestion,
RunQuery,
RunSource,
ScheduleRunStatus,
@@ -44,9 +49,9 @@ export { InboxView } from './inbox-view';
export { SchedulesView } from './schedules-view';
export { IntegrationsView } from './integrations-view';
export { SkillsView } from './skills-view';
-export { AgentView } from './agent-view';
+export { AgentView, type AgentViewProps } from './agent-view';
export { AgentCanvas } from './agent-canvas';
-export { AgentRunPanel } from './agent-run-panel';
+export { AgentRunPanel, type AgentRunPanelProps, type RunPhase } from './agent-run-panel';
export { ConnectIntegrationDialog, type ConnectIntegrationDialogProps } from './connect-integration-dialog';
export { IntegrationMark, type IntegrationMarkProps } from './integration-mark';
export { Mascot } from './mascot';
@@ -70,6 +75,7 @@ export {
export {
AGENTS_BUILDER_DEMO,
DEMO_AGENT,
+ DEMO_AGENT_DETAILS,
DEMO_AGENTS,
DEMO_CLOCK,
DEMO_CATEGORIES,
diff --git a/packages/ui/src/components/agents-builder/sidebar.tsx b/packages/ui/src/components/agents-builder/sidebar.tsx
index 73d6f80..9b17747 100644
--- a/packages/ui/src/components/agents-builder/sidebar.tsx
+++ b/packages/ui/src/components/agents-builder/sidebar.tsx
@@ -5,6 +5,7 @@ import {
Bot,
CalendarClock,
ChartColumn,
+ ChevronRight,
GraduationCap,
Inbox,
type LucideIcon,
@@ -22,42 +23,167 @@ import { initials } from '../../lib/initials';
import { cn } from '../../lib/utils';
import { TextShimmer } from '../ai/text-shimmer';
import { Kbd, KbdGroup } from '../kbd';
-import { NavIcon, NavRow } from '../workspace-kit/nav';
-import { SearchField, TooltipIconButton } from '../workspace-kit/primitives';
+import { NavCount, NavIcon, NavRow } from '../workspace-kit/nav';
+import { focusRingClass, SearchField, TooltipIconButton } from '../workspace-kit/primitives';
+import { AgentStatusTag, AgentStatusWord, agentLabel, agentStatus, hasStatusTag, isInFlight, isWaitingOnYou, mostUrgentTag, needsAttention, statusPhrase, visibleRoster } from './agent-status';
import { AppPicker } from './app-picker';
import { useAgentsBuilder } from './agents-builder-context';
import { IntegrationMark } from './integration-mark';
-import type { AgentSummary } from './types';
+import type { AgentProject, AgentSummary } from './types';
import { WorkspaceMenu } from './workspace-menu';
/** Agents listed before "More agents". */
const SIDEBAR_AGENT_LIMIT = 6;
/**
- * Agent roster label. Focus reads through weight and colour alone; a running
- * agent's name shimmers, so activity never needs a coloured dot.
+ * Agent roster label. Focus and unread results read through weight and colour
+ * alone; an agent with an open run shimmers, so activity never needs a dot.
*/
function AgentLabel({ agent, open }: { agent: AgentSummary; open: boolean }) {
- return agent.running ? (
+ const status = agentStatus(agent);
+ const phrase = statusPhrase(agent);
+ // Waiting on you lifts the shimmering name a weight, like an unread one.
+ const weight = open || isWaitingOnYou(status) || agent.unread ? 'font-medium' : 'font-normal';
+ return (
<>
- {agent.name}
- , running
+ {isInFlight(status) ? (
+ {agent.name}
+ ) : (
+ {agent.name}
+ )}
+ {phrase ? , {phrase.toLowerCase()} : null}
>
- ) : (
- agent.name
);
}
-/** Collapsed-rail stand-in for an agent: its initials, styled like the label. */
+/** Collapsed-rail stand-in for an agent: its initials, styled like the label. Its state hangs below as a word. */
function AgentMonogram({ agent, open }: { agent: AgentSummary; open: boolean }) {
const text = initials(agent.name);
+ const status = agentStatus(agent);
+ const strong = open || isWaitingOnYou(status) || agent.unread;
return (
-
- {text === null ? : agent.running ? {text} : text}
+
+ {text === null ? (
+
+ ) : isInFlight(status) ? (
+ {text}
+ ) : (
+ text
+ )}
);
}
+/**
+ * Tooltip detail. Expanded, it leads with the full name, since a tooltip
+ * replaces the truncation title; the rail's label already names both.
+ */
+function AgentTip({ agent, collapsed }: { agent: AgentSummary; collapsed: boolean }) {
+ if (collapsed) return agent.statusNote ?? null;
+ return (
+ <>
+
+ {agent.name}
+
+ {statusPhrase(agent)}
+ {agent.statusNote ? {agent.statusNote} : null}
+ >
+ );
+}
+
+/** Agents an open project lists before its "Show N more" row. */
+const PROJECT_AGENT_LIMIT = 4;
+
+/** A project shows open by default when something in it is happening or open. */
+function isLiveProject(agents: AgentSummary[], openAgentId: string | null) {
+ return agents.some((agent) => agent.id === openAgentId || agent.unread || agentStatus(agent) !== 'idle');
+}
+
+/**
+ * One project in the expanded sidebar: a disclosure row, then its agents hung
+ * from a rail under the chevron, the way a tool trace hangs its calls. Folded,
+ * the row carries its most pressing agent's tag, so nothing waiting on you
+ * hides inside it. Past the limit, agents that need you still show and the
+ * rest wait behind "Show N more".
+ */
+function ProjectGroup({
+ project,
+ agents,
+ openAgentId,
+ renderAgent,
+}: {
+ project: AgentProject;
+ agents: AgentSummary[];
+ /** The agent on screen. Its project starts open, and unfolds whenever it is opened from elsewhere. */
+ openAgentId: string | null;
+ renderAgent: (agent: AgentSummary) => React.ReactNode;
+}) {
+ const [open, setOpen] = React.useState(() => isLiveProject(agents, openAgentId));
+ const [revealedFor, setRevealedFor] = React.useState(openAgentId);
+ if (openAgentId !== revealedFor) {
+ setRevealedFor(openAgentId);
+ if (agents.some((agent) => agent.id === openAgentId)) setOpen(true);
+ }
+ const [showAll, setShowAll] = React.useState(false);
+ const listId = React.useId();
+ const shown = showAll ? agents : visibleRoster(agents, PROJECT_AGENT_LIMIT);
+ const hidden = agents.length - shown.length;
+ const urgent = mostUrgentTag(agents);
+ const waiting = agents.filter((agent) => needsAttention(agentStatus(agent))).length;
+ return (
+
+ setOpen((value) => !value)}
+ // Instant like every sidebar row: a project is folded and unfolded far too often to animate.
+ className={cn('flex h-8 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-start text-sm text-sidebar-foreground hover:bg-overlay-hover pointer-coarse:h-10', focusRingClass)}
+ >
+
+
+ {project.name}
+ {waiting > 0 ? (
+
+ , {formatCount(waiting)} {waiting === 1 ? 'needs' : 'need'} you
+
+) : null}
+
+ {open ? null : urgent ? (
+
+ ) : (
+
+ {formatCount(agents.length)}
+
+ )}
+
+ {open ? (
+ // The rail sits on the chevron's centre line (8px padding + half of 14px), so names line up with the project's.
+
+ {shown.map(renderAgent)}
+ {hidden > 0 ? (
+ Show {formatCount(hidden)} more
}
+ collapsed={false}
+ muted
+ leading={null}
+ onClick={() => setShowAll(true)}
+ />
+ ) : null}
+
+ ) : null}
+
+ );
+}
+
type AgentsBuilderSidebarProps = {
/** Icon-rail mode. Ignored in the drawer, which is always expanded. */
collapsed?: boolean;
@@ -75,7 +201,7 @@ type AgentsBuilderSidebarProps = {
* expands it and focuses search.
*/
function AgentsBuilderSidebar({ collapsed: collapsedProp = false, onCollapsedChange, drawer = false, onNavigate, className }: AgentsBuilderSidebarProps) {
- const { data, view, setView, emit, integration, openAgent } = useAgentsBuilder();
+ const { data, agents, currentAgent, view, setView, emit, integration, openAgent } = useAgentsBuilder();
const asideRef = React.useRef(null);
const searchRef = React.useRef(null);
const [query, setQuery] = React.useState('');
@@ -83,6 +209,21 @@ function AgentsBuilderSidebar({ collapsed: collapsedProp = false, onCollapsedCha
const headingId = React.useId();
const connectId = React.useId();
const needsYou = data.inbox.filter((thread) => thread.status === 'needs-you').length;
+ const roster = visibleRoster(agents, SIDEBAR_AGENT_LIMIT);
+ const hiddenAgents = agents.length - roster.length;
+ // Grouped only in the expanded sidebar; the icon rail keeps the flat roster, where a heading has no room.
+ const projects = collapsed || !data.projects?.length ? null : data.projects;
+ const projectAgents = React.useMemo(() => {
+ const byProject = new Map((data.projects ?? []).map((project) => [project.id, []]));
+ const loose: AgentSummary[] = [];
+ for (const agent of agents) {
+ const group = agent.projectId ? byProject.get(agent.projectId) : undefined;
+ if (group) group.push(agent);
+ else loose.push(agent);
+ }
+ return { byProject, loose };
+ }, [agents, data.projects]);
+ const openAgentId = view === 'agent' ? currentAgent.agent.id : null;
const select = (action: () => void) => () => {
action();
@@ -105,6 +246,28 @@ function AgentsBuilderSidebar({ collapsed: collapsedProp = false, onCollapsedCha
return () => document.removeEventListener('keydown', onKeyDown);
}, [focusSearch]);
+ const agentRow = (agent: AgentSummary) => {
+ const open = agent.id === openAgentId;
+ const status = agentStatus(agent);
+ return (
+ }
+ collapsed={collapsed}
+ active={open}
+ muted={!agent.unread && status !== 'failed'}
+ tip={(collapsed ? agent.statusNote : statusPhrase(agent)) ? : undefined}
+ // The state reads at the end: a tag on the row, or in the rail its word, which NavRow stretches the square to hold.
+ // Expanded rows have no leading slot, so the name starts at the row's edge.
+ railBadge={hasStatusTag(status) ? : undefined}
+ trailing={hasStatusTag(status) ? : undefined}
+ leading={collapsed ? : null}
+ onClick={select(() => openAgent(agent.id))}
+ />
+ );
+ };
+
const nav = (label: string, icon: LucideIcon, onClick: () => void, active = false) => (
{collapsed ? null : (
diff --git a/packages/ui/src/components/agents-builder/types.ts b/packages/ui/src/components/agents-builder/types.ts
index 0a1b4de..6ca3dae 100644
--- a/packages/ui/src/components/agents-builder/types.ts
+++ b/packages/ui/src/components/agents-builder/types.ts
@@ -38,11 +38,31 @@ export type IntegrationCategory = {
label: string;
};
+/**
+ * The most urgent state across an agent's runs. Queued: about to start.
+ * Approval and input: a run is still open but paused on a person.
+ */
+export type AgentStatus = 'idle' | 'queued' | 'running' | 'approval' | 'input' | 'failed';
+
export type AgentSummary = {
id: string;
name: string;
- /** Running agents shimmer in the sidebar. */
+ /** Merged across the agent's runs by the host. Default `idle`. */
+ status?: AgentStatus;
+ /** A run finished since the agent was last opened. Cleared when the agent opens. */
+ unread?: boolean;
+ /** What the agent waits on or why it failed, shown in the row tooltip, e.g. "Post the summary to #revenue". */
+ statusNote?: string;
+ /** @deprecated Use `status: 'running'`. */
running?: boolean;
+ /** The project the agent belongs to. Agents without one list after the projects. */
+ projectId?: string;
+};
+
+/** A group of agents in the sidebar, e.g. a team or a product area. */
+export type AgentProject = {
+ id: string;
+ name: string;
};
export type SkillType = 'Analysis' | 'Modeling' | 'Governance' | 'Workflow';
@@ -153,6 +173,32 @@ export type RunApproval = {
skippedText: string;
};
+export type RunQuestion = {
+ /** One line for the roster tooltip, e.g. "Which schema should own the billing tables?" */
+ title: string;
+ questions: {
+ id: string;
+ question: string;
+ /** `reply` is the agent's sentence for this choice; chosen replies are joined, and `answeredText` stands in when none has one. */
+ options: { id: string; label: string; reply?: string }[];
+ }[];
+ /** Tool calls made once answered. */
+ steps: ToolStep[];
+ answeredText: string;
+ skippedText: string;
+};
+
+export type RunFailure = {
+ /** The tool call that fails. Calls after it never run. */
+ stepId: string;
+ /** One line for the error block and the roster tooltip. */
+ message: string;
+ /** Raw error output, shown as a log excerpt. */
+ log?: string;
+ /** The retry succeeds, as if the cause was fixed. Default "Retry run". */
+ retryLabel?: string;
+};
+
export type AgentRunScript = {
command: { verb: string; skill: string; scope: string };
/** Collapsed reasoning shown before the narration. */
@@ -166,6 +212,10 @@ export type AgentRunScript = {
findings?: { paragraphs: string[]; sources: RunSource[] };
/** Human-in-the-loop step that ends the run. */
approval?: RunApproval;
+ /** A question that ends the run instead of an approval. */
+ question?: RunQuestion;
+ /** Stops the run at one of its tool calls with an error. */
+ failure?: RunFailure;
thinking: { seconds: number; tokens: number };
};
@@ -184,6 +234,12 @@ export type AgentBlueprint = {
skills: string[];
};
+/** An agent the template can open on its own: its blueprint and its latest run. */
+export type AgentDetail = {
+ agent: AgentBlueprint;
+ run: AgentRunScript;
+};
+
export type InboxChannel = 'sms' | 'email' | 'slack';
/** Needs you: waiting on a person. Working: an agent is on it. Done: resolved. */
@@ -267,6 +323,8 @@ export type AgentsBuilderData = {
/** Integrations teased in the sidebar "Connect more apps" card. */
featuredIntegrationIds: string[];
agents: AgentSummary[];
+ /** Groups the sidebar roster by project. Without it the roster is one flat list. */
+ projects?: AgentProject[];
/** Agent-handled conversations for the Inbox view. */
inbox: InboxThread[];
schedules: AgentSchedule[];
@@ -279,8 +337,11 @@ export type AgentsBuilderData = {
fallbackReply: ConversationBeat[];
/** Reply to any follow-up after the first turn. */
followUpReply: ConversationBeat[];
+ /** The featured agent, opened by default. */
agent: AgentBlueprint;
run: AgentRunScript;
+ /** More agents the template opens itself; any other roster agent is handed to the host via `open-agent`. */
+ agentDetails?: AgentDetail[];
heroHighlights: HeroHighlight[];
usage: { percent: number };
};
@@ -301,6 +362,10 @@ export type AgentsBuilderAction =
| { type: 'send-follow-up'; surface: 'run'; text: string }
| { type: 'stop-run' }
| { type: 'approve-run'; agentId: string; approved: boolean }
+ /** `answers` maps question ids to the chosen option ids; `null` when skipped. */
+ | { type: 'answer-run'; agentId: string; answers: Record | null }
+ | { type: 'retry-run'; agentId: string }
+ | { type: 'start-run'; agentId: string }
| { type: 'send-reply'; threadId: string; text: string; drafted: boolean }
| { type: 'approve-thread-action'; threadId: string }
| { type: 'new-schedule' }
diff --git a/packages/ui/src/components/agents-builder/use-agent-run.ts b/packages/ui/src/components/agents-builder/use-agent-run.ts
index 7d1eec7..56365e4 100644
--- a/packages/ui/src/components/agents-builder/use-agent-run.ts
+++ b/packages/ui/src/components/agents-builder/use-agent-run.ts
@@ -29,6 +29,10 @@ export type AgentRunState = {
sources: boolean;
/** The approval card is on screen, waiting on a person. */
approval: boolean;
+ /** The question card is on screen, waiting on a person. */
+ question: boolean;
+ /** Index of the tool call that failed and stopped the run. */
+ failedStep: number | null;
nodes: { tools: NodeStage; subagents: NodeStage; skills: NodeStage };
settled: boolean;
};
@@ -65,6 +69,8 @@ const INITIAL: AgentRunState = {
typingFinding: null,
sources: false,
approval: false,
+ question: false,
+ failedStep: null,
nodes: { tools: 'absent', subagents: 'absent', skills: 'absent' },
settled: false,
};
@@ -74,8 +80,9 @@ type Frame = { delay: number; apply: (state: AgentRunState) => AgentRunState };
/**
* The whole run as a flat list of timed patches: reasoning, narration, the
* tool trace, the query and its rows, each group fanning out, the findings,
- * then the approval request. Canvas nodes move from absent to loading to
- * ready, and each phase ticks one plan item. Each frame waits `delay`.
+ * then the approval request or question. Canvas nodes move from absent to
+ * loading to ready, and each phase ticks one plan item. Each frame waits
+ * `delay`. A `failure` ends the timeline at its tool call.
*/
export function buildRunTimeline(script: AgentRunScript): Frame[] {
const frames: Frame[] = [];
@@ -99,10 +106,16 @@ export function buildRunTimeline(script: AgentRunScript): Frame[] {
at((state) => ({ ...state, nodes: { ...state.nodes, tools: 'loading' } }), RUN_TIMING.stepIn);
at((state) => ({ ...state, shownSteps: script.steps.length }), RUN_TIMING.stepsSpin + RUN_TIMING.stepWork);
- script.steps.forEach((_, index) => {
+ const failedStep = script.failure ? script.steps.findIndex((step) => step.id === script.failure!.stepId) : -1;
+ for (const [index] of script.steps.entries()) {
+ if (index === failedStep) {
+ at((state) => ({ ...state, failedStep: index, nodes: { ...state.nodes, tools: 'ready' } }), RUN_TIMING.stepMorph);
+ at((state) => ({ ...state, settled: true }));
+ return frames;
+ }
const last = index === script.steps.length - 1;
at((state) => ({ ...state, doneSteps: index + 1 }), RUN_TIMING.stepMorph + (last ? 0 : RUN_TIMING.stepWork));
- });
+ }
at((state) => tick({ ...state, nodes: { ...state.nodes, tools: 'ready' } }), RUN_TIMING.handover);
if (script.query) {
@@ -139,6 +152,7 @@ export function buildRunTimeline(script: AgentRunScript): Frame[] {
});
if (script.findings) at((state) => tick({ ...state, sources: true }), RUN_TIMING.handover);
if (script.approval) at((state) => ({ ...state, approval: true }), RUN_TIMING.afterParagraph);
+ else if (script.question) at((state) => ({ ...state, question: true }), RUN_TIMING.afterParagraph);
at((state) => ({ ...state, settled: true }));
return frames;
@@ -150,15 +164,16 @@ function settledState(script: AgentRunScript): AgentRunState {
/**
* Replays an agent run from its script. `stop` settles everything at once.
+ * `paused` holds the run before its first frame, e.g. while it is queued.
* Replace with live run events in production by producing the same state.
*/
-export function useAgentRun(script: AgentRunScript, { autoplay = true }: { autoplay?: boolean } = {}) {
+export function useAgentRun(script: AgentRunScript, { autoplay = true, paused = false }: { autoplay?: boolean; paused?: boolean } = {}) {
const timeline = React.useMemo(() => buildRunTimeline(script), [script]);
const [run, setRun] = React.useState(() =>
autoplay ? { state: INITIAL, next: 0 } : { state: settledState(script), next: timeline.length },
);
- usePlaybackClock(timeline[run.next], false, () =>
+ usePlaybackClock(timeline[run.next], paused, () =>
setRun((current) => {
const frame = timeline[current.next];
return frame ? { state: frame.apply(current.state), next: current.next + 1 } : current;
diff --git a/packages/ui/src/components/ai/approval-card.tsx b/packages/ui/src/components/ai/approval-card.tsx
index 14f1a38..ccc7b70 100644
--- a/packages/ui/src/components/ai/approval-card.tsx
+++ b/packages/ui/src/components/ai/approval-card.tsx
@@ -30,6 +30,8 @@ type ApprovalCardProps = {
destructive?: boolean;
/** Preview node under the title (tables, diffs, etc.). */
preview?: React.ReactNode;
+ /** A small line above the title in single-confirm mode, e.g. why the run paused. */
+ eyebrow?: React.ReactNode;
onConfirm?: (payload: { answers?: Record }) => void;
onSkip?: () => void;
onDismiss?: () => void;
@@ -49,6 +51,7 @@ function ApprovalCard({
skipLabel = 'Skip',
destructive = false,
preview,
+ eyebrow,
onConfirm,
onSkip,
onDismiss,
@@ -135,6 +138,9 @@ function ApprovalCard({
>