Skip to content

Dynamic Workflows

See application_model.md for the system overview. Issue #174; landed as PR #191 (nested DOM), #203 (parsing), #205 (tool-input rendering),

210 (tree rendering) plus visual-polish follow-ups, then the

invocation-shape-variety follow-up (scriptPath/name/args/resume shapes, snapshot script recovery, failed-run surfacing).

A dynamic workflow is Claude Code's Workflow tool: the assistant submits a JavaScript orchestrator script that fans out into many side-channel sub-agents, grouped into phases. claude-code-log renders the whole run — orchestrator script, phases, per-agent cards, and each agent's full side-channel transcript — as a nested sub-tree at the Workflow tool_use site.

flowchart LR
    subgraph disk["on disk"]
        J["journal.jsonl"]
        A["agent-<id>.jsonl ×N"]
        S["<runId>.json snapshot"]
    end
    subgraph parse["workflow.py"]
        R["WorkflowRun<br/>phases → agents → entries"]
    end
    subgraph link["converter / renderer"]
        L["SessionTree.workflow_runs<br/>+ workflow_links"]
        X["_link_workflow_runs"]
    end
    SP["_splice_workflow_runs<br/>(last render pass)"]
    OUT["nested DOM / Markdown tree"]
    J --> R
    A --> R
    S -.enrich.-> R
    R --> L --> X --> SP --> OUT

1. On-disk layout

A run under a trunk session <sid>.jsonl leaves:

<sid>/subagents/workflows/<runId>/
    journal.jsonl                 live spine: started/result events, keyed by agentId
    agent-<agentId>.jsonl         per-agent side-channel transcript
    agent-<agentId>.meta.json     {"agentType": "workflow-subagent"}
<sid>/workflows/<runId>.json      terminal snapshot: phases + per-agent metadata
                                  + the script that ran, scriptPath, args,
                                  summary, error, run totals
<sid>/workflows/scripts/<name>-<runId>.js   the JS orchestrator source

journal.jsonl exists from the start of the run and carries the full per-agent results; <runId>.json appears only on completion. A running workflow therefore parses with agents in journal order and no phase grouping (has_snapshot=False).

The invocation comes in several shapes: inline script, scriptPath (source in a file — the tool_use input carries NO source at all), or a saved-workflow name, optionally with args and resumeFromRunId. Only the inline shape embeds the orchestrator in the transcript; for the others the snapshot's script field is the recovery route (never the scriptPath file itself — it may have been edited or deleted since).

A run that fails before launching any agent (script error on an early line) leaves a snapshot (status: failed + error) but no run dir/journal — a snapshot-only run.

2. Parse model (workflow.py)

parse_workflow_run is journal-led, snapshot-enriched:

  • WorkflowRunrun_id, task_id, workflow_name, status, phases, flat agents (journal launch order), run result, token totals, has_snapshot, plus the snapshot enrichment: script (the source that ran), script_path, args, summary (the tool's digest of the meta description), error, default_model, duration_ms, total_tool_calls.
  • WorkflowPhaseindex, title, detail, member agents (the same WorkflowAgent objects as the flat list).
  • WorkflowAgentagent_id, label, phase membership, model, state, tokens, tool_calls, result (a dict for StructuredOutput agents, a string for plain-text agents, None while in flight), and entries: the agent's side-channel transcript loaded via load_transcript.

Two real-data quirks the parser absorbs:

  • Phase-index base mismatch: the snapshot's phases[] array is 0-based but each agent's phaseIndex (and workflow_phase progress nodes) is 1-based. _group_into_phases therefore assigns agents to phases by phaseTitle (authoritative), falling back to the index only when the title is missing/unmatched.
  • agentCount undercounts: the snapshot counts only agents that produced a result; the journal lists every launched agent (retries/abandoned included), so len(run.agents) can exceed run.agent_count.

Discovery (discover_workflow_runs) is run-dir-led but also yields snapshot-only runs: any <sid>/workflows/<runId>.json with no matching run dir (the failed-before-launch case). parse_workflow_run accepts a missing journal when the snapshot loads, producing a run with an empty agents list — so the failure stays linkable to its tool_use.

load_workflow_runs(directory) walks every session dir under a project; load_session_workflow_runs(<sid>.jsonl) derives the sibling <sid>/subagents/workflows/ for a single-file render. Both share _runs_in_session_dir.

3. Linking a run to its tool_use

The runId is not recoverable from the rendered tool_result — it lives only in the structured toolUseResult that the factory layer drops. The durable join key is the taskId: the Workflow tool_result content carries Task ID: <taskId>, which equals the snapshot's taskId (WorkflowRun.task_id).

Linking is resolved at full-session scope, before pagination: map_workflow_runs_by_tool_use scans the raw entries for Workflow tool_uses and their paired tool_results, producing a {tool_use_id: WorkflowRun} map stored on SessionTree.workflow_links (next to workflow_runs, keyed by runId). _link_workflow_runs (renderer link pass) prefers that map — which is what keeps the linkage working when pagination puts the tool_use and its tool_result on different pages — and falls back to scanning the current render's tool_results for Task ID: when no map is supplied (e.g. a direct generate_template_messages call). Either way the run lands on WorkflowToolInput.workflow_run.

load_directory_transcripts populates both SessionTree fields; convert_jsonl_to's single-file branch builds a SessionTree carrying them only when runs exist, so a no-workflow single-file render keeps session_tree=None and is byte-identical to before.

4. The Workflow tool_use header (snapshot-first)

format_workflow_input renders a meta header (name, description, phase pills), an invocation line, then the syntax-highlighted JS orchestrator.

The script shown is the effective script (resolve_workflow_script): the inline script input when present, else the snapshot's stored copy — so scriptPath/name invocations still show their orchestrator once the run completes. The invocation line (workflow-invocation) surfaces the non-inline shapes: the saved-workflow name, the scriptPath reference, and resumeFromRunId; args renders through the hybrid params table (HTML) / a fenced JSON block (Markdown), wrapped under an explicit args key.

resolve_workflow_header sources the header snapshot-first: when the linked run has a snapshot, workflowName and the snapshot phase titles win over the best-effort export const meta = {...} regex (parse_workflow_meta), which remains the fallback for a running workflow. The meta parse runs on the effective script; the description comes from it (the snapshot has no description field), falling back to the snapshot summary. The meta string regexes accept any JS quote style ('/"/backtick) with backslash-escape support — real descriptions contain \'. A drift warning fires only when a non-empty script fails a parse the snapshot can answer; an absent script (the non-inline shapes pre-snapshot) is not drift.

Failure surfacing: a non-completed terminal status renders as a chip next to the workflow name (workflow-status-<status>), and the snapshot error (typically a JS stack trace) as a collapsed workflow-error fold — a snapshot-only failed run launched no agents, so the tree below is empty and this chrome is the only failure signal.

Each phase pill is an anchor link to its spliced phase card: the splice records the phase cards' message_index values on WorkflowToolInput.phase_anchor_indices (snapshot-phase order, parallel to the pill list), and the hashchange handler in transcript.html unfolds the folded target on click.

5. The splice (_splice_workflow_runs)

The run tree is built as a self-contained sub-tree after _build_message_tree and attached via .children — it never touches _build_message_hierarchy / _relocate_subagent_blocks (the 0–5 level-stack cannot express phase→agent→sidechain, and the blast radius on non-workflow rendering would be high). Key mechanics:

  • Runs LAST in generate_template_messages (after _link_task_id_consumers): it appends nodes through ctx.register, so it must follow every pass that iterates ctx.messages.
  • Index allocation is ctx.register itself (message_index = len(ctx.messages), append) — an inherently session-wide monotonic allocator, collision-free across several (even concurrent) workflows in one session. message_id (d-{N}) is a property of message_index, so anchors come for free.
  • Attaches to the paired tool_result (falling back to the tool_use for a running workflow with no result yet): the tool_use/tool_result pair renders as one visually joined unit (pair_first flat bottom + pair_last flat top), so hanging the tree off the tool_use would wedge it between the two cards. Off the result, the pair stays adjacent and the tree reads as the run's outcome below it.
  • Side-channel grafting (_graft_agent_sidechannel): each agent's entries are re-rendered through a nested generate_template_messages call, then every produced node is re-registered into the main ctx (fresh monotonic indices), tagged in_workflow_sidechannel, and its pairing references (pair_first/pair_middle/pair_last) remapped into the new index space. The side-channel renders at HOOK depth regardless of the main render's level (see § 7).
  • Side-channel user prompts (format_workflow_sidechannel_user_content in html/user_formatters.py, gated on the graft tag): these prompts are large prose+JSON hybrids, so they render as escaping collapsible Markdown with embedded JSON blocks extracted first (extract_embedded_json): a lone {/[ on its own line, through a lone matching closer followed by a blank line (or EOF), accepted only when json.loads parses it to a non-empty dict/list — empty {}/[] stay inline (nothing to tabulate), as do blocks inside fenced code. Each block is substituted with a z-prefixed UUID placeholder (every uuid group gets a z so the SHA→commit-URL linkifier can't match inside it), the remainder renders as Markdown, and the placeholders are swapped for the generic params-table rendering of the parsed value (so hybrid-renderer upgrades apply automatically). Blocks wider than _EMBEDDED_JSON_MAX_ITEMS fall back to an escaped <pre> fold — generation-side breadth discipline. A placeholder landing in the fold's preview becomes a compact {…} hint; the table renders once, in the body.
  • Counts: has_children/is_paired are derived properties, and the stock _mark_messages_with_children ran pre-splice, so a bottom-up helper (_recount_spliced_children) computes the synthetic nodes' descendant counts and increments the attach node's and its ancestors' totals (correct even when the host already had children).
flowchart TD
    A["assistant"] --> TU["tool_use · Workflow<br/>(meta header + JS script)"]
    A --> TR["tool_result · pair_last<br/>'launched · Task ID: …'"]
    TR --> P1["workflow_phase · 'Phase: Map'"]
    TR --> P2["workflow_phase · 'Phase: Synthesize'"]
    P1 --> AG1["workflow_agent · 'Agent map:…'"]
    P1 --> AG2["workflow_agent …"]
    P2 --> AG3["workflow_agent …"]
    AG1 --> U["user (side-channel prompt)"]
    AG1 --> AS["assistant + tool_use/result …"]

6. Rendering the synthetic nodes

Two MessageContent subclasses in models.py:

Node message_type Title Body
WorkflowPhaseMessage workflow_phase Phase: <title> (🧩) phase detail + agent count
WorkflowAgentMessage workflow_agent Agent <label> (🤖) meta line (model/state/tokens/tool calls) + result
  • Titles live on the shared base Renderer (format-neutral, like title_ThinkingMessage); format_* methods exist on both HtmlRenderer and MarkdownRenderer.
  • Agent results: a dict renders through the generic render_params_table key/value table (so generic-tool renderer upgrades apply automatically); a list keeps the pretty-printed, Pygments-highlighted JSON view (the render_async_result_body {"-heuristic would mis-route [...]); a string renders as collapsible Markdown. Markdown output fences dict/list as ```json.
  • CSS: both types register as ["tool_use", "workflow_phase"] / ["tool_use", "workflow_agent"] in CSS_CLASS_REGISTRY — the tool_use class keeps them governed by the runtime "Tool Use" filter toggle; the modifier drives styling and the timeline.
  • Indentation is depth-driven with aligned group borders (message_styles.css): each workflow node's .children container carries the same margin-left as the cards (2em, mirrored as 2% inside the ≤1280px responsive block), so the container's border-left lands at the exact x of its parent card's border — the group border reads as the card's border continuing down its subtree. Colors pair per level — the group line continues its parent card's border color: a phase card + its agents group are dark green (--workflow-phase-color), an agent card + its side-channel group are grey (--workflow-agent-color), and a standard sub-agent's sidechain line is tool-green (continuing the spawning tool_result card's border). The Workflow-level phases group keeps its indent but draws no line (suppressed at 0px — two levels of lines already distinguish a workflow from a standard sub-agent's single line). Depth accumulates through DOM nesting, so arbitrarily deep future nests (a sub-agent spawning its own sub-agents) indent with no new rules.
  • Timeline (components/timeline.html): dedicated workflow_phase / workflow_agent lanes, with detection branches placed before the generic tool_use match (same pattern as teammate / task-notification). Like the other tool lanes they have no filter toggle, so they're always visible in the timeline.
  • Fold labels: _format_type_counts maps the types to "phase(s)" / "agent(s)" so fold bars read "2 phases", "3 agents".

7. Detail levels

The splice only materialises at full / high: the Workflow tool_use is dropped at low (it's not in _LOW_KEEP_TOOLS) and below, taking the attach point with it. Within a spliced tree, the agents' side-channel transcripts are rendered by a nested generate_template_messages(entries) call at HOOK depth (everything) regardless of the main render's depth — at --depth tool an agent's side-channel may therefore still show HOOK-only content (system/hook entries). Accepted behaviour: the side-channel is an opt-in deep-dive under a fold.

8. Known limitations

  • Side-channel backlinks: jump-to-call backlinks computed inside an agent's sub-render (e.g. cron/task-id cross-links) are not remapped into the main index space — only pairing references are. Agent transcripts are typically simple read-heavy chains; revisit if that changes.
  • Fold-label counts: _recount_spliced_children counts a tool_use+tool_result pair as 2 (the stock counter's pairing-skip convention is deliberately dropped inside the run tree), so "N descendants" labels can read slightly high on tool-heavy side-channels.

9. Key files & tests

  • workflow.py — parse + discovery + header resolution + full-scope linkage map.
  • converter.py — populates SessionTree.workflow_runs / workflow_links (directory and single-file paths).
  • dag.py — the two SessionTree fields.
  • renderer.py_link_workflow_runs, _splice_workflow_runs, _graft_agent_sidechannel, _recount_spliced_children, titles.
  • models.pyWorkflowToolInput (+ workflow_run, phase_anchor_indices), WorkflowPhaseMessage, WorkflowAgentMessage.
  • html/tool_formatters.pyformat_workflow_input, format_workflow_phase_content, format_workflow_agent_content.
  • markdown/renderer.pyformat_WorkflowToolInput, format_WorkflowPhaseMessage, format_WorkflowAgentMessage.
  • Fixture: test/test_data/workflow_basic/ (generated by scripts/gen_workflow_fixture.py) — run wf_demo01, 2 phases, 3 agents (two StructuredOutput dicts + one Markdown string), each with a 3-entry side-channel. Tests in test/test_workflow_rendering.py (parse, linkage, splice, rendering, single-file, pagination boundary) and test/test_workflow_browser.py (Playwright fold).
  • Fixture: test/test_data/workflow_scriptpath/ (generated by scripts/gen_workflow_scriptpath_fixture.py) — the non-inline invocation shapes: wf_sp01, a scriptPath+args run whose snapshot carries the script (meta description with an escaped quote), and wf_fail01, a snapshot-only failed run with no run dir.