Diagrams: Visualize Council Topology

Copy Markdown View Source

Static topology diagrams for any CouncilEx council module. Useful for:

  • Debugging a council you didn't write (or wrote three months ago)
  • Reviewing topology in a code review
  • Pasting a Mermaid block into a PR description, design doc, or wiki
  • Embedding a flowchart in your app's admin UI (Mermaid CDN script: no React Flow yet)

Built on top of the council module's generated __council__/0 reflection accessor. Available at runtime any time the module is loaded: no extra compile step.


TL;DR

# In a real project (compiled module)
mix council.diagram MyApp.MyCouncil --ascii

# Pipe Mermaid to clipboard, then paste into mermaid.live
mix council.diagram MyApp.MyCouncil | pbcopy
# In iex
iex -S mix

iex> CouncilEx.Diagram.topology(MyApp.MyCouncil, format: :ascii) |> IO.puts
iex> CouncilEx.Diagram.topology(MyApp.MyCouncil) |> IO.puts
iex> CouncilEx.Diagram.to_ir(MyApp.MyCouncil)      # raw IR struct

Diagram-on-run for examples

Each examples/*.exs script calls CouncilEx.Diagram.maybe_print/2 near the top with a short description. Two env vars control behavior:

VarEffect
DIAGRAM=ascii (or 1 / true)print ASCII tree before the run
DIAGRAM=mermaidprint Mermaid flowchart TB
DIAGRAM=sequenceprint Mermaid sequenceDiagram (interaction over time)
DIAGRAM=irinspect raw IR struct
DIAGRAM_ONLY=1 (or true)print diagram + description, then halt: script does not run (no API call, no tokens)
both unsetsilent, normal run

Examples:

# Print + run normally
DIAGRAM=ascii mix run examples/parallel_panel_example.exs

# Print + halt: useful for browsing topology without burning credits
DIAGRAM_ONLY=1 mix run examples/specialist_example.exs

# Mermaid + halt, copy to clipboard
DIAGRAM=mermaid DIAGRAM_ONLY=1 mix run examples/sub_council_example.exs | pbcopy

When DIAGRAM_ONLY=1 is set without DIAGRAM, the format defaults to ascii. The description string is only printed in DIAGRAM_ONLY mode so the live-run path stays uncluttered.

For your own councils, drop the same one-liner near the top of any script:

CouncilEx.Diagram.maybe_print(MyCouncil, """
Short explanation of what this council does and why.
""")

Public API

CouncilEx.Diagram exposes five functions:

FunctionReturnsUse
to_ir(module)%CouncilEx.Diagram.IR{nodes: [...], edges: [...]}JSON-encodable IR. Consume from a Phoenix LiveView or React Flow.
topology(council, opts)String.t()Mermaid flowchart TB by default; pass format: :ascii for a box-drawing-character tree.
sequence(council, opts)String.t()Mermaid sequenceDiagram of interaction over time (response-id data flow). See "Sequence diagrams" below.
sequence_from_events(events, council \| nil)String.t()Mermaid sequenceDiagram from a captured run's PubSub events (real ordering, tool calls, durations).

Mix task

mix council.diagram <Council.Module>: works for compiled project modules only (anything in lib/ or a dependency).

mix council.diagram MyApp.MyCouncil               # default: Mermaid
mix council.diagram MyApp.MyCouncil --ascii       # ASCII tree
mix council.diagram MyApp.MyCouncil --mermaid     # explicit

The Mix task doesn't reach examples/*.exs scripts: they're not part of compiled output. Use the iex recipe below for those.


iex recipes

Compiled project module

iex -S mix

iex> CouncilEx.Diagram.topology(MyApp.MyCouncil, format: :ascii) |> IO.puts
iex> CouncilEx.Diagram.topology(MyApp.MyCouncil) |> IO.puts

Example script in examples/*.exs

.exs files run their top-level code, so loading also fires any CouncilEx.run/3 calls in the script:

iex> Code.require_file("examples/parallel_panel_example.exs")
iex> CouncilEx.Diagram.topology(ExampleCouncil, format: :ascii) |> IO.puts

If you don't want the demo run side-effect, copy the defmodule ... use CouncilEx ... end block into iex directly.

Get raw IR for programmatic use

iex> ir = CouncilEx.Diagram.to_ir(MyApp.MyCouncil)
iex> ir.nodes
iex> ir.edges
iex> Jason.encode!(ir)   # JSON for an HTTP endpoint or LiveView assign

Output samples

ASCII (terminal-friendly)

Council: SpecialistDemo.Council
  Input
    
   Round 0: independent_analysis
      seo
       · provider: openai
       · model: gpt-4o-mini
      content
       · provider: openai
       · model: gpt-4o-mini
      tech
       · provider: openai
       · model: gpt-4o-mini
  
    
   Round 1: peer_review
      seo · provider: openai · model: gpt-4o-mini
      content · provider: openai · model: gpt-4o-mini
      tech · provider: openai · model: gpt-4o-mini
  
    
   Round 2: synthesis
      chair (chair) · provider: openai · model: gpt-4o
  
    
  Output

Markers:

MarkerMeaning
regular member
chair (synthesis member)
sub-council member (delegates to inner council)
router node
possible router branch
tool declared on the member

Mermaid (web-friendly)

flowchart TB
  input([Input])
  output([Output])
  r0["Round 0: independent_analysis"]
  r1["Round 1: synthesis"]
  m_optimist["optimist<br/>provider: openai<br/>model: gpt-4o-mini"]
  m_skeptic["skeptic<br/>provider: openai<br/>model: gpt-4o-mini"]
  m_chair{{"chair (chair)<br/>provider: openai<br/>model: gpt-4o-mini"}}
  input --> r0
  r0 -->|next| r1
  r1 --> output
  r0 --> m_optimist
  r0 --> m_skeptic
  r1 --> m_chair

Mermaid node-shape conventions used:

ShapeKind
([...])input / output
[...]round / member
{{...}}chair
[[...]]sub-council
{...}router
[/.../]tool

Edge styles:

  • --> solid: data feed (input → round → member → output)
  • -->|next| solid with label: round-to-round chain
  • -.->|selects| dashed: router-to-member (dynamic, runtime-decided)
  • -.->|invokes| dashed: member-to-tool
  • -.->|delegates| dashed: outer-member-to-inner-sub-council

What it shows / doesn't show

Static (always available)

  • Members with provider + model (and stream: true if set)
  • Rounds in sequential order
  • Chair as a distinct node, only wired to the synthesis round
  • Sub-councils expanded as nested IR with a delegates dashed edge
  • Routers as a node with dashed selects edges to every possible member
  • Tools per member as parallelogram nodes with invokes edges

Not yet (deferred)

  • Iterate-wrapped rounds in the flowchart: the flowchart TB / IR still shows an Iterate round as a single node (the sequenceDiagram from sequence/2 does render it as a loop)
  • Custom round member subsets: only Synthesis ← chair is modeled; rounds with arbitrary member filters would over-edge
  • Live runtime overlay: in-flight member status, token counts, tool-call progress. The to_ir/1 shape is JSON-friendly so a web UI can subscribe to the run's PubSub topic and overlay live state (see Web overlay below). It lives in the web project, not the library.

Web overlay (future)

to_ir/1 returns plain maps (already JSON-encodable). The web project consumes that as the static topology, then subscribes to "council_ex:run:#{run_id}" for live state:

EventOverlay
:run_startedinput node lights up
:round_startedround node :active
:member_startedmember node :running
:member_tokenmember node accumulator + edge animation
:tool_call_requesttool node pending
:tool_call_resulttool node ok/error

| :member_completed | member node :ok | :error | | :round_completed | round node :done | | :run_completed | output node lights up |

Mermaid or React Flow can both consume this. Pick when the web project is real.


Caveats

  • Mix task only sees compiled modules. For .exs scripts, use iex + Code.require_file.
  • Loading an .exs example also runs the example's demo. Copy the council defmodule into iex if you want the diagram alone.
  • Static IR is frozen-at-load; if a council module is hot-reloaded, call to_ir/1 again to pick up changes.

Sequence diagrams (interaction over time)

topology/2 shows static topology (who is wired to whom). sequence/2 shows the interaction over time: which seat receives what, and how each member response — assigned a stable id (R1, R2, …) — flows into later rounds. Seats are identified by their member id only; the model is never shown.

MyCouncil |> CouncilEx.Diagram.sequence() |> IO.puts()
# or in a Livebook:
MyCouncil |> CouncilEx.Diagram.sequence() |> Kino.Mermaid.new()

Each round declares its data-flow contract via the optional CouncilEx.Round.diagram_meta/0 callback (:input_scope, :sharing, :anonymized?, :sequential?, :summary). Built-in rounds implement it; custom rounds fall back to a generic "prior-round outputs" note. This is how the diagram knows, for example, that an anonymized peer-review round strips each member's own prior response and relabels peers.

For a real run, sequence_from_events/2 renders the captured PubSub event stream — real ordering, tool calls, durations, and statuses:

{:ok, pid} = CouncilEx.start(MyCouncil, input, subscribe: true)
events = drain_run_topic(pid)        # collect tuples until :run_completed/:run_failed
{:ok, _} = CouncilEx.await(pid)
CouncilEx.Diagram.sequence_from_events(events, MyCouncil) |> Kino.Mermaid.new()

Terminal parity: DIAGRAM=sequence mix run examples/foo.exs prints the static sequence diagram (alongside ascii | mermaid | ir).

The runtime view does not show the actual prompt text — assembled prompts are not part of the event stream. Use the static sequence/2 for the context-flow contract and the runtime view for real timing / tool loops.