For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: A bottom panel showing a flat chronological timeline of every workflow execution event — node starts/completions, full LLM request/response content, tool calls, errors — with timestamps, durations, and JSON export.
Architecture: New LLMCallUpdate protocol message emitted by BaseProvider traced methods → WebSocket → frontend TraceStore → TracePanel (bottom panel tab alongside Terminal). In-memory with export.
Tech Stack: TypeScript, Zustand, React, @emotion/react, react-window (virtualized list)
| File | Responsibility |
|---|---|
packages/protocol/src/messages.ts |
Add LLMCallUpdate interface and union member |
packages/runtime/src/providers/base-provider.ts |
Add message emitter + emit LLMCallUpdate in traced methods |
packages/runtime/src/context.ts |
Wire emitter when returning providers |
web/src/stores/TraceStore.ts |
New Zustand store: accumulate trace events, clear, export |
web/src/stores/workflowUpdates.ts |
Feed TraceStore from incoming WebSocket messages |
web/src/stores/BottomPanelStore.ts |
Add “trace” as a panel view option |
web/src/components/panels/TracePanel.tsx |
New panel: virtualized event list with expand/collapse |
web/src/components/panels/PanelBottom.tsx |
Add trace tab alongside terminal |
Files:
Modify: packages/protocol/src/messages.ts
Step 1: Add LLMCallUpdate interface
Add before the ProcessingMessage union (around line 350):
export interface LLMCallUpdate {
type: "llm_call";
node_id: string;
node_name?: string | null;
provider: string;
model: string;
messages: Array<{ role: string; content: unknown }>;
response: unknown;
tool_calls?: Array<{ id: string; name: string; args: unknown }> | null;
tokens_input?: number | null;
tokens_output?: number | null;
cost?: number | null;
duration_ms: number;
error?: string | null;
timestamp: string;
}
Add | LLMCallUpdate to the union type (after | Prediction).
Add LLMCallUpdate to the export block at the bottom of the file.
cd packages/protocol && npm run build
git add packages/protocol/src/messages.ts
git commit -m "feat: add LLMCallUpdate protocol message type"
Files:
Modify: packages/runtime/src/providers/base-provider.ts
Step 1: Add emitter field and methods
Add after the _cost field (around line 29):
private _emitMessage: ((msg: unknown) => void) | null = null;
setMessageEmitter(fn: (msg: unknown) => void): void {
this._emitMessage = fn;
}
protected emitMessage(msg: unknown): void {
if (this._emitMessage) this._emitMessage(msg);
}
Replace the generateMessageTraced method (lines 141-173). After the try/catch, emit the trace message. The key change: capture start time before the call, build the LLMCallUpdate after:
async generateMessageTraced(args: Parameters<this["generateMessage"]>[0]): Promise<Message> {
const startTime = Date.now();
const tracer = getTracer();
const doCall = async (): Promise<Message> => {
if (!tracer) return this.generateMessage(args);
return tracer.startActiveSpan(`llm.chat ${this.provider}/${args.model}`, async (span) => {
span.setAttributes({
"llm.provider": this.provider,
"llm.model": args.model,
"llm.request.message_count": args.messages.length,
"llm.request.tools_count": args.tools?.length ?? 0,
"llm.request.max_tokens": args.maxTokens ?? 0,
"llm.request.stream": false,
});
try {
const result = await this.generateMessage(args);
const content = typeof result.content === "string" ? result.content : JSON.stringify(result.content);
span.setAttributes({
"llm.response.role": result.role,
"llm.response.content": content.slice(0, 2000),
"llm.response.tool_calls_count": result.toolCalls?.length ?? 0,
});
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
});
};
let result: Message | undefined;
let error: string | undefined;
try {
result = await doCall();
return result;
} catch (err) {
error = String(err);
throw err;
} finally {
this.emitMessage({
type: "llm_call",
node_id: "",
provider: this.provider,
model: args.model,
messages: args.messages.map((m) => ({ role: m.role, content: m.content })),
response: result?.content ?? null,
tool_calls: result?.toolCalls?.map((tc) => ({ id: tc.id, name: tc.name, args: tc.args })) ?? null,
tokens_input: null,
tokens_output: null,
cost: null,
duration_ms: Date.now() - startTime,
error: error ?? null,
timestamp: new Date(startTime).toISOString(),
});
}
}
Same pattern for the streaming method. Accumulate text chunks into fullResponse, then emit after stream completes:
async *generateMessagesTraced(args: Parameters<this["generateMessages"]>[0]): AsyncGenerator<ProviderStreamItem> {
const startTime = Date.now();
log.debug("LLM call", { provider: this.provider, model: args.model });
const tracer = getTracer();
let fullResponse = "";
let collectedToolCalls: Array<{ id: string; name: string; args: unknown }> = [];
let error: string | undefined;
try {
const source = tracer
? this._tracedStream(args, tracer)
: this.generateMessages(args);
for await (const item of source) {
// Accumulate text content from chunks
if ("type" in item && (item as { type: string }).type === "chunk") {
const chunk = item as { content?: string };
if (chunk.content) fullResponse += chunk.content;
}
// Collect tool calls
if ("id" in item && "name" in item && "args" in item) {
const tc = item as { id: string; name: string; args: unknown };
collectedToolCalls.push({ id: tc.id, name: tc.name, args: tc.args });
}
yield item;
}
log.debug("LLM call complete", { provider: this.provider, model: args.model });
} catch (err) {
error = String(err);
throw err;
} finally {
this.emitMessage({
type: "llm_call",
node_id: "",
provider: this.provider,
model: args.model,
messages: args.messages.map((m) => ({ role: m.role, content: m.content })),
response: fullResponse || null,
tool_calls: collectedToolCalls.length > 0 ? collectedToolCalls : null,
tokens_input: null,
tokens_output: null,
cost: null,
duration_ms: Date.now() - startTime,
error: error ?? null,
timestamp: new Date(startTime).toISOString(),
});
}
}
/** Internal: wrap generateMessages with OTel span */
private async *_tracedStream(
args: Parameters<this["generateMessages"]>[0],
tracer: ReturnType<typeof getTracer> & object,
): AsyncGenerator<ProviderStreamItem> {
const span = tracer.startSpan(`llm.stream ${this.provider}/${args.model}`);
span.setAttributes({
"llm.provider": this.provider,
"llm.model": args.model,
"llm.request.message_count": args.messages.length,
"llm.request.tools_count": args.tools?.length ?? 0,
"llm.request.max_tokens": args.maxTokens ?? 0,
"llm.request.stream": true,
});
let chunkCount = 0;
try {
for await (const item of this.generateMessages(args)) {
chunkCount++;
yield item;
}
span.setAttributes({ "llm.response.chunk_count": chunkCount });
span.setStatus({ code: SpanStatusCode.OK });
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
span.recordException(err as Error);
throw err;
} finally {
span.end();
}
}
cd packages/runtime && npm run build
git add packages/runtime/src/providers/base-provider.ts
git commit -m "feat: emit LLMCallUpdate from BaseProvider traced methods"
Files:
Modify: packages/runtime/src/context.ts
Step 1: Set emitter when returning providers
In getProvider(), after the provider is resolved and cached, set the message emitter. Add after line 599 (this._providers.set(providerId, resolved)):
resolved.setMessageEmitter((msg) => this.postMessage(msg as ProcessingMessage));
Do this in BOTH resolution paths:
resolved.setMessageEmitter((msg) => this.postMessage(msg as ProcessingMessage));resolved.setMessageEmitter((msg) => this.postMessage(msg as ProcessingMessage));Also add the import for ProcessingMessage from @nodetool/protocol if not already present.
cd packages/runtime && npm run build
git add packages/runtime/src/context.ts
git commit -m "feat: wire LLM message emitter in ProcessingContext.getProvider()"
Files:
Create: web/src/stores/TraceStore.ts
Step 1: Create TraceStore
import { create } from "zustand";
export type TraceEventType =
| "node_start"
| "node_complete"
| "node_error"
| "llm_call"
| "tool_call"
| "tool_result"
| "edge_active"
| "output";
export interface TraceEvent {
id: string;
timestamp: string;
relativeMs: number;
type: TraceEventType;
nodeId?: string;
nodeName?: string;
nodeType?: string;
summary: string;
detail: unknown;
}
const MAX_EVENTS = 10_000;
interface TraceStoreState {
events: TraceEvent[];
runStartTime: string | null;
isRecording: boolean;
startRun: (timestamp: string) => void;
append: (event: TraceEvent) => void;
clear: () => void;
exportJSON: () => string;
}
let nextId = 0;
export function traceEventId(): string {
return `te-${++nextId}`;
}
const useTraceStore = create<TraceStoreState>((set, get) => ({
events: [],
runStartTime: null,
isRecording: false,
startRun: (timestamp: string) =>
set({ events: [], runStartTime: timestamp, isRecording: true }),
append: (event: TraceEvent) =>
set((state) => {
if (!state.isRecording) return state;
const events =
state.events.length >= MAX_EVENTS
? [...state.events.slice(1), event]
: [...state.events, event];
return { events };
}),
clear: () => set({ events: [], runStartTime: null, isRecording: false }),
exportJSON: () => {
const { events, runStartTime } = get();
return JSON.stringify({ runStartTime, events }, null, 2);
},
}));
export default useTraceStore;
git add web/src/stores/TraceStore.ts
git commit -m "feat: add TraceStore for workflow execution trace"
Files:
Modify: web/src/stores/workflowUpdates.ts
Step 1: Import TraceStore
Add at the top of the file:
import useTraceStore, { traceEventId } from "./TraceStore";
import type { TraceEvent, TraceEventType } from "./TraceStore";
At the beginning of handleUpdate (after line 208), add:
const traceAppend = useTraceStore.getState().append;
const traceStart = useTraceStore.getState().startRun;
const traceRunStart = useTraceStore.getState().runStartTime;
const relativeMs = (ts?: string): number => {
if (!traceRunStart) return 0;
const start = new Date(traceRunStart).getTime();
const now = ts ? new Date(ts).getTime() : Date.now();
return Math.max(0, now - start);
};
const now = new Date().toISOString();
Add at the end of handleUpdate, before the closing brace:
// --- Trace event capture ---
if (data.type === "job_update") {
const ju = data as JobUpdate;
if (ju.status === "running") {
traceStart(now);
} else if (["completed", "failed", "cancelled", "error"].includes(ju.status)) {
useTraceStore.setState({ isRecording: false });
}
}
if (data.type === "node_update") {
const nu = data as NodeUpdate;
let traceType: TraceEventType | null = null;
let summary = "";
if (nu.status === "running") {
traceType = "node_start";
summary = `${nu.node_name || nu.node_id} started`;
} else if (nu.status === "completed") {
traceType = "node_complete";
summary = `${nu.node_name || nu.node_id} completed`;
} else if (nu.status === "error" || nu.status === "failed") {
traceType = "node_error";
summary = `${nu.node_name || nu.node_id} error: ${nu.error || "unknown"}`;
}
if (traceType) {
traceAppend({
id: traceEventId(),
timestamp: now,
relativeMs: relativeMs(),
type: traceType,
nodeId: nu.node_id,
nodeName: nu.node_name ?? undefined,
nodeType: nu.node_type ?? undefined,
summary,
detail: nu,
});
}
}
if (data.type === "llm_call") {
const lc = data as any;
const tokens = lc.tokens_output ? `${lc.tokens_output} tokens` : "";
const dur = lc.duration_ms ? `${lc.duration_ms}ms` : "";
const cost = lc.cost ? `$${lc.cost.toFixed(4)}` : "";
const parts = [tokens, dur, cost].filter(Boolean).join(", ");
traceAppend({
id: traceEventId(),
timestamp: lc.timestamp || now,
relativeMs: relativeMs(lc.timestamp),
type: "llm_call",
nodeId: lc.node_id ?? undefined,
nodeName: lc.node_name ?? undefined,
summary: `${lc.provider}/${lc.model}${parts ? ` → ${parts}` : ""}`,
detail: lc,
});
}
if (data.type === "tool_call") {
const tc = data as ToolCallUpdate;
traceAppend({
id: traceEventId(),
timestamp: now,
relativeMs: relativeMs(),
type: "tool_call",
nodeId: tc.node_id,
summary: `Tool call: ${tc.name}`,
detail: tc,
});
}
if (data.type === "tool_result") {
const tr = data as ToolResultUpdate;
traceAppend({
id: traceEventId(),
timestamp: now,
relativeMs: relativeMs(),
type: "tool_result",
nodeId: tr.node_id,
summary: `Tool result: ${tr.name}`,
detail: tr,
});
}
if (data.type === "output_update") {
const ou = data as OutputUpdate;
traceAppend({
id: traceEventId(),
timestamp: now,
relativeMs: relativeMs(),
type: "output",
nodeId: ou.node_id,
summary: `Output: ${ou.output_name}`,
detail: ou,
});
}
if (data.type === "edge_update") {
const eu = data as EdgeUpdate;
if (eu.status === "active") {
traceAppend({
id: traceEventId(),
timestamp: now,
relativeMs: relativeMs(),
type: "edge_active",
summary: `Edge active: ${eu.edge_id}`,
detail: eu,
});
}
}
git add web/src/stores/workflowUpdates.ts
git commit -m "feat: feed TraceStore from workflow update messages"
Files:
Modify: web/src/stores/BottomPanelStore.ts
Step 1: Add “trace” to BottomPanelView
Change line 4:
export type BottomPanelView = "terminal" | "trace";
git add web/src/stores/BottomPanelStore.ts
git commit -m "feat: add trace view to BottomPanelStore"
Files:
Create: web/src/components/panels/TracePanel.tsx
Step 1: Create TracePanel
/** @jsxImportSource @emotion/react */
import { css } from "@emotion/react";
import { useTheme } from "@mui/material/styles";
import type { Theme } from "@mui/material/styles";
import { memo, useCallback, useState } from "react";
import { Box, IconButton, Tooltip, Typography, Chip } from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import FileDownloadIcon from "@mui/icons-material/FileDownload";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import ErrorIcon from "@mui/icons-material/Error";
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import BuildIcon from "@mui/icons-material/Build";
import OutputIcon from "@mui/icons-material/Output";
import CallSplitIcon from "@mui/icons-material/CallSplit";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import useTraceStore from "../../stores/TraceStore";
import type { TraceEvent, TraceEventType } from "../../stores/TraceStore";
import { TOOLTIP_ENTER_DELAY } from "../../config/constants";
const EVENT_ICONS: Record<TraceEventType, React.ReactNode> = {
node_start: <PlayArrowIcon sx= />,
node_complete: <CheckCircleIcon sx= />,
node_error: <ErrorIcon sx= />,
llm_call: <AutoAwesomeIcon sx= />,
tool_call: <BuildIcon sx= />,
tool_result: <BuildIcon sx= />,
edge_active: <CallSplitIcon sx= />,
output: <OutputIcon sx= />,
};
const styles = (theme: Theme) =>
css({
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
".trace-toolbar": {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "4px 12px",
borderBottom: `1px solid ${theme.vars.palette.divider}`,
minHeight: 36,
},
".trace-list": {
flex: 1,
overflow: "auto",
fontFamily: "monospace",
fontSize: "0.8rem",
},
".trace-row": {
display: "flex",
alignItems: "flex-start",
padding: "3px 12px",
gap: 8,
borderBottom: `1px solid ${theme.vars.palette.divider}22`,
cursor: "pointer",
"&:hover": {
backgroundColor: theme.vars.palette.action.hover,
},
},
".trace-row.expanded": {
backgroundColor: theme.vars.palette.action.selected,
},
".trace-time": {
color: theme.vars.palette.text.disabled,
minWidth: 60,
flexShrink: 0,
},
".trace-icon": {
flexShrink: 0,
display: "flex",
alignItems: "center",
marginTop: 1,
},
".trace-summary": {
flex: 1,
color: theme.vars.palette.text.primary,
wordBreak: "break-word",
},
".trace-detail": {
padding: "8px 12px 8px 80px",
backgroundColor: `${theme.vars.palette.background.paper}`,
borderBottom: `1px solid ${theme.vars.palette.divider}44`,
"& pre": {
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontSize: "0.75rem",
maxHeight: 400,
overflow: "auto",
color: theme.vars.palette.text.secondary,
},
},
".llm-section": {
marginBottom: 8,
"& .llm-label": {
fontWeight: 600,
color: theme.vars.palette.text.primary,
fontSize: "0.75rem",
marginBottom: 2,
},
},
});
function formatRelativeTime(ms: number): string {
if (ms < 1000) return `+${ms}ms`;
return `+${(ms / 1000).toFixed(1)}s`;
}
function LLMDetail({ detail }: { detail: Record<string, unknown> }) {
return (
<div>
{detail.messages && (
<div className="llm-section">
<div className="llm-label">Request ({(detail.messages as unknown[]).length} messages)</div>
<pre>{JSON.stringify(detail.messages, null, 2)}</pre>
</div>
)}
{detail.response && (
<div className="llm-section">
<div className="llm-label">Response</div>
<pre>{typeof detail.response === "string" ? detail.response : JSON.stringify(detail.response, null, 2)}</pre>
</div>
)}
{detail.tool_calls && (detail.tool_calls as unknown[]).length > 0 && (
<div className="llm-section">
<div className="llm-label">Tool Calls</div>
<pre>{JSON.stringify(detail.tool_calls, null, 2)}</pre>
</div>
)}
<div className="llm-section">
<div className="llm-label">
{[
detail.tokens_input && `In: ${detail.tokens_input}`,
detail.tokens_output && `Out: ${detail.tokens_output}`,
detail.cost && `Cost: $${(detail.cost as number).toFixed(4)}`,
detail.duration_ms && `Duration: ${detail.duration_ms}ms`,
]
.filter(Boolean)
.join(" · ")}
</div>
</div>
{detail.error && (
<div className="llm-section">
<div className="llm-label" style=>Error</div>
<pre>{String(detail.error)}</pre>
</div>
)}
</div>
);
}
const TraceRow = memo(function TraceRow({
event,
expanded,
onToggle,
}: {
event: TraceEvent;
expanded: boolean;
onToggle: () => void;
}) {
return (
<>
<div
className={`trace-row ${expanded ? "expanded" : ""}`}
onClick={onToggle}
>
<span className="trace-time">{formatRelativeTime(event.relativeMs)}</span>
<span className="trace-icon">{EVENT_ICONS[event.type]}</span>
<span className="trace-summary">{event.summary}</span>
{expanded ? (
<ExpandLessIcon sx= />
) : (
<ExpandMoreIcon sx= />
)}
</div>
{expanded && (
<div className="trace-detail">
{event.type === "llm_call" ? (
<LLMDetail detail={event.detail as Record<string, unknown>} />
) : (
<pre>{JSON.stringify(event.detail, null, 2)}</pre>
)}
</div>
)}
</>
);
});
const TracePanel: React.FC = () => {
const theme = useTheme();
const events = useTraceStore((s) => s.events);
const clear = useTraceStore((s) => s.clear);
const exportJSON = useTraceStore((s) => s.exportJSON);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const handleToggle = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleExport = useCallback(() => {
const json = exportJSON();
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `trace-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
a.click();
URL.revokeObjectURL(url);
}, [exportJSON]);
return (
<div css={styles(theme)}>
<div className="trace-toolbar">
<Box sx=>
<Typography variant="body2" sx=>
Trace
</Typography>
<Chip label={events.length} size="small" variant="outlined" />
</Box>
<Box sx=>
<Tooltip title="Export as JSON" enterDelay={TOOLTIP_ENTER_DELAY}>
<IconButton size="small" onClick={handleExport} disabled={events.length === 0}>
<FileDownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Clear trace" enterDelay={TOOLTIP_ENTER_DELAY}>
<IconButton size="small" onClick={clear}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</div>
<div className="trace-list">
{events.length === 0 ? (
<Typography
variant="body2"
sx=
>
Run a workflow to see the execution trace
</Typography>
) : (
events.map((event) => (
<TraceRow
key={event.id}
event={event}
expanded={expandedIds.has(event.id)}
onToggle={() => handleToggle(event.id)}
/>
))
)}
</div>
</div>
);
};
export default memo(TracePanel);
git add web/src/components/panels/TracePanel.tsx
git commit -m "feat: add TracePanel component for execution trace"
Files:
Modify: web/src/components/panels/PanelBottom.tsx
Step 1: Import TracePanel and icons
Add imports:
import TracePanel from "./TracePanel";
import TimelineIcon from "@mui/icons-material/Timeline";
After the terminal shortcut (line 113):
useCombo(["Control", "Shift", "T"], () => handlePanelToggle("trace"), false);
But first, handlePanelToggle needs to accept both views. It currently only handles “terminal”. Change:
const handleTerminalToggle = useCallback(() => {
handlePanelToggle("terminal");
}, [handlePanelToggle]);
const handleTraceToggle = useCallback(() => {
handlePanelToggle("trace");
}, [handlePanelToggle]);
In the panel header (after the Terminal icon/text around line 167), replace the header to support tabs:
<div className="left">
<Tooltip title="Terminal (Ctrl+`)" enterDelay={TOOLTIP_ENTER_DELAY}>
<IconButton
size="small"
onClick={handleTerminalToggle}
sx=
>
<TerminalIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Trace (Ctrl+Shift+T)" enterDelay={TOOLTIP_ENTER_DELAY}>
<IconButton
size="small"
onClick={handleTraceToggle}
sx=
>
<TimelineIcon fontSize="small" />
</IconButton>
</Tooltip>
</div>
Add the trace content wrapper alongside the terminal wrapper (after line 199):
<div
className="trace-wrapper"
style=
>
<TracePanel />
</div>
git add web/src/components/panels/PanelBottom.tsx
git commit -m "feat: add trace tab to bottom panel"