diff --git a/.fabro/workflows/context-demo/workflow.fabro b/.fabro/workflows/context-demo/workflow.fabro
new file mode 100644
index 000000000..017795744
--- /dev/null
+++ b/.fabro/workflows/context-demo/workflow.fabro
@@ -0,0 +1,16 @@
+digraph ContextDemo {
+ graph [goal="Emit structured outputs to demonstrate the stage Context tab"]
+ rankdir=LR
+
+ start [shape=Mdiamond, label="Start"]
+ exit [shape=Msquare, label="Exit"]
+
+ emit [
+ shape=tab,
+ label="Emit Context",
+ prompt="Reply with ONLY the following JSON object and nothing else — no prose, no markdown, no code fences. The exact text of your entire reply must be: {\"context_updates\": {\"demo.greeting\": \"Hello from the Context tab\", \"demo.answer\": 42, \"demo.payload\": {\"nested\": true, \"items\": [1, 2, 3]}}, \"preferred_next_label\": \"Done\", \"suggested_next_ids\": [\"exit\"]}"
+ ]
+
+ start -> emit
+ emit -> exit [label="Done"]
+}
diff --git a/.fabro/workflows/context-demo/workflow.toml b/.fabro/workflows/context-demo/workflow.toml
new file mode 100644
index 000000000..0cdce6b94
--- /dev/null
+++ b/.fabro/workflows/context-demo/workflow.toml
@@ -0,0 +1,4 @@
+_version = 1
+
+[workflow]
+graph = "workflow.fabro"
diff --git a/apps/fabro-web/app/components/stage-context.tsx b/apps/fabro-web/app/components/stage-context.tsx
new file mode 100644
index 000000000..8360c11f4
--- /dev/null
+++ b/apps/fabro-web/app/components/stage-context.tsx
@@ -0,0 +1,68 @@
+import type { StageContextData } from "./stage-renderers/helpers";
+import { CodeBlock, JsonBlock } from "./stage-renderers/primitives";
+
+function ContextValue({ value }: { value: unknown }) {
+ if (typeof value === "string") {
+ return {value};
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return {String(value)};
+ }
+ if (value === null) {
+ return null;
+ }
+ return ;
+}
+
+/**
+ * Renders the workflow's deliberate outputs for a single stage visit: the
+ * routing hints it emitted and the context keys it set. Engine bookkeeping
+ * keys are already filtered out by `extractStageContext`.
+ */
+export function StageContext({ data }: { data: StageContextData }) {
+ const { preferredLabel, suggestedNextIds } = data.routing;
+ const hasRouting = preferredLabel != null || suggestedNextIds.length > 0;
+ const updateKeys = Object.keys(data.updates).sort();
+
+ return (
+
+ {hasRouting && (
+
+
+ Routing
+
+
+ {preferredLabel != null && (
+ <>
+ - Preferred edge
+ - {preferredLabel}
+ >
+ )}
+ {suggestedNextIds.length > 0 && (
+ <>
+ - Suggested next
+ - {suggestedNextIds.join(", ")}
+ >
+ )}
+
+
+ )}
+
+ {updateKeys.length > 0 && (
+
+
+ Context writes
+
+
+ {updateKeys.map((key) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts
index f4b6a3158..6c03541c0 100644
--- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts
+++ b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import {
+ extractStageContext,
extractStageNotes,
parseFanInOutcome,
parseHumanInterviewPairs,
@@ -206,3 +207,65 @@ describe("extractStageNotes", () => {
expect(extractStageNotes([])).toBeNull();
});
});
+
+describe("extractStageContext", () => {
+ test("keeps author-set keys and drops engine bookkeeping keys", () => {
+ const events: EventEnvelope[] = [
+ envelope(1, {
+ event: "stage.completed",
+ properties: {
+ context_updates: {
+ "plan.summary": "ship the thing",
+ review_score: 8,
+ last_stage: "implement",
+ last_response: "done",
+ "response.implement": "full text",
+ "internal.run_id": "run-1",
+ "current.preamble": "...",
+ "command.output": "blob:abc",
+ "human.gate.selected": "A",
+ "parallel.results": [],
+ },
+ },
+ }),
+ ];
+ const ctx = extractStageContext(events);
+ expect(ctx).not.toBeNull();
+ expect(ctx?.updates).toEqual({
+ "plan.summary": "ship the thing",
+ review_score: 8,
+ });
+ });
+
+ test("extracts routing hints from preferred_label and suggested_next_ids", () => {
+ const events: EventEnvelope[] = [
+ envelope(1, {
+ event: "stage.completed",
+ properties: {
+ preferred_label: "approve",
+ suggested_next_ids: ["review", "merge", 7],
+ },
+ }),
+ ];
+ const ctx = extractStageContext(events);
+ expect(ctx?.routing.preferredLabel).toBe("approve");
+ expect(ctx?.routing.suggestedNextIds).toEqual(["review", "merge"]);
+ expect(ctx?.updates).toEqual({});
+ });
+
+ test("returns null when the stage only wrote engine keys", () => {
+ const events: EventEnvelope[] = [
+ envelope(1, {
+ event: "stage.completed",
+ properties: {
+ context_updates: { last_stage: "implement", "command.output": "blob:x" },
+ },
+ }),
+ ];
+ expect(extractStageContext(events)).toBeNull();
+ });
+
+ test("returns null when the stage has not completed", () => {
+ expect(extractStageContext([])).toBeNull();
+ });
+});
diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts
index b1d157f68..561779a55 100644
--- a/apps/fabro-web/app/components/stage-renderers/helpers.ts
+++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts
@@ -248,6 +248,63 @@ export function extractStageNotes(events: EventEnvelope[]): string | null {
return null;
}
+export interface StageContextData {
+ routing: { preferredLabel: string | null; suggestedNextIds: string[] };
+ /** `context_updates` keys the workflow deliberately set (engine keys removed). */
+ updates: Record;
+}
+
+// Engine/auto-populated context keys. These are bookkeeping or already shown in
+// a stage's primary tab (command output, human answers, fan-in results), so the
+// Context tab hides them and surfaces only what the workflow deliberately wrote.
+const ENGINE_CONTEXT_KEYS = new Set(["last_stage", "last_response", "command.output"]);
+const ENGINE_CONTEXT_PREFIXES = [
+ "response.",
+ "internal.",
+ "current.",
+ "human.gate.",
+ "parallel.",
+];
+
+function isEngineContextKey(key: string): boolean {
+ if (ENGINE_CONTEXT_KEYS.has(key)) return true;
+ return ENGINE_CONTEXT_PREFIXES.some((prefix) => key.startsWith(prefix));
+}
+
+/**
+ * Extract the workflow's deliberate outputs from the `stage.completed` event:
+ * author-set `context_updates` (minus engine keys) plus the routing hints
+ * (`preferred_label`, `suggested_next_ids`). Returns null when the stage hasn't
+ * finished or produced nothing worth showing — which hides the Context tab.
+ */
+export function extractStageContext(events: EventEnvelope[]): StageContextData | null {
+ for (const event of events) {
+ if (event.event !== "stage.completed") continue;
+ const props: UnknownRecord = event.properties ?? {};
+
+ const rawUpdates = getObject(props, "context_updates") ?? {};
+ const updates: Record = {};
+ for (const [key, value] of Object.entries(rawUpdates)) {
+ if (!isEngineContextKey(key)) updates[key] = value;
+ }
+
+ const preferredLabel = getString(props, "preferred_label") ?? null;
+ const suggestedNextIds = (getArray(props, "suggested_next_ids") ?? []).filter(
+ (v): v is string => typeof v === "string",
+ );
+
+ if (
+ Object.keys(updates).length === 0 &&
+ !preferredLabel &&
+ suggestedNextIds.length === 0
+ ) {
+ return null;
+ }
+ return { routing: { preferredLabel, suggestedNextIds }, updates };
+ }
+ return null;
+}
+
export interface EdgeSelection {
fromNode: string;
toNode: string;
diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx
index 33d753091..6087955d5 100644
--- a/apps/fabro-web/app/routes/run-stages.tsx
+++ b/apps/fabro-web/app/routes/run-stages.tsx
@@ -24,13 +24,17 @@ import type {
ThreadDnaItem,
ThreadDnaSelection,
} from "../components/event-debug";
+import { StageContext } from "../components/stage-context";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { EmptyState } from "../components/state";
import { Tooltip } from "../components/ui";
import { ConditionalDecision } from "../components/stage-renderers/conditional-decision";
import { FanInResults } from "../components/stage-renderers/fan-in-results";
-import { extractStageNotes } from "../components/stage-renderers/helpers";
+import {
+ extractStageContext,
+ extractStageNotes,
+} from "../components/stage-renderers/helpers";
import { HumanQA } from "../components/stage-renderers/human-qa";
import { ParallelChildren } from "../components/stage-renderers/parallel-children";
import {
@@ -105,7 +109,7 @@ const EVENT_KIND_LABEL: Record = {
command: "Command",
};
-const EVENTS_TABS = ["primary", "debug"] as const;
+const EVENTS_TABS = ["primary", "context", "debug"] as const;
type EventsTab = (typeof EVENTS_TABS)[number];
const PRIMARY_TAB_LABEL: Record = {
@@ -121,6 +125,7 @@ const PRIMARY_TAB_LABEL: Record = {
export function eventsTabLabel(tab: EventsTab, renderer: StageRenderer): string {
if (tab === "debug") return "Debug";
+ if (tab === "context") return "Context";
return PRIMARY_TAB_LABEL[renderer];
}
@@ -1107,10 +1112,12 @@ function ToolGroupDetailsPanel({
function EventsTabToggle({
tab,
renderer,
+ availableTabs,
onTabChange,
}: {
tab: EventsTab;
renderer: StageRenderer;
+ availableTabs: readonly EventsTab[];
onTabChange: (tab: EventsTab) => void;
}) {
return (
@@ -1119,7 +1126,7 @@ function EventsTabToggle({
aria-label="View"
className="inline-flex rounded-md bg-panel p-0.5 outline-1 -outline-offset-1 outline-line-strong"
>
- {EVENTS_TABS.map((value) => {
+ {availableTabs.map((value) => {
const active = tab === value;
return (