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 structDiagram-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:
| Var | Effect |
|---|---|
DIAGRAM=ascii (or 1 / true) | print ASCII tree before the run |
DIAGRAM=mermaid | print Mermaid flowchart TB |
DIAGRAM=sequence | print Mermaid sequenceDiagram (interaction over time) |
DIAGRAM=ir | inspect raw IR struct |
DIAGRAM_ONLY=1 (or true) | print diagram + description, then halt: script does not run (no API call, no tokens) |
| both unset | silent, 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:
| Function | Returns | Use |
|---|---|---|
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.putsExample 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.putsIf 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 assignOutput 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
└─
│
OutputMarkers:
| Marker | Meaning |
|---|---|
• | 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_chairMermaid node-shape conventions used:
| Shape | Kind |
|---|---|
([...]) | 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: trueif set) - Rounds in sequential order
- Chair as a distinct node, only wired to the synthesis round
- Sub-councils expanded as nested IR with a
delegatesdashed edge - Routers as a node with dashed
selectsedges to every possible member - Tools per member as parallelogram nodes with
invokesedges
Not yet (deferred)
- Iterate-wrapped rounds in the flowchart: the
flowchart TB/ IR still shows an Iterate round as a single node (thesequenceDiagramfromsequence/2does render it as aloop) - Custom round member subsets: only
Synthesis ← chairis modeled; rounds with arbitrary member filters would over-edge - Live runtime overlay: in-flight member status, token counts, tool-call progress. The
to_ir/1shape 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:
| Event | Overlay |
|---|---|
:run_started | input node lights up |
:round_started | round node :active |
:member_started | member node :running |
:member_token | member node accumulator + edge animation |
:tool_call_request | tool node pending |
:tool_call_result | tool 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
.exsscripts, use iex +Code.require_file. - Loading an
.exsexample also runs the example's demo. Copy the councildefmoduleinto iex if you want the diagram alone. - Static IR is frozen-at-load; if a council module is hot-reloaded, call
to_ir/1again 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/2for the context-flow contract and the runtime view for real timing / tool loops.