Every council in CouncilEx is inspectable as plain data — both its static structure (what members, rounds, chair, and providers it's made of) and any live run (what a council is doing right now). Nothing is hidden behind macros at runtime: the DSL lowers to a struct you can read, walk, serialize, and render.
This doc covers three layers:
- Structural reflection — what is this council made of?
- Graph export — turn the structure into nodes/edges for a UI.
- Live run introspection — what is a running council doing?
For rendering the graph (Mermaid / ASCII / Mix task), see
DIAGRAMS.md. For the full PubSub/telemetry event surface, see
OBSERVABILITY.md.
1. Structural reflection
Module-form councils
A council defined with use CouncilEx gets two generated, public-callable
functions:
MyApp.MyCouncil.__council__()
# => %CouncilEx.Spec{
# council: MyApp.MyCouncil,
# members: [
# {:advocate, MyApp.Members.Advocate, [provider: :openrouter, model: "openai/gpt-4o-mini"]},
# {:skeptic, MyApp.Members.Skeptic, [provider: :openrouter, model: "anthropic/claude-sonnet-4-6"]}
# ],
# rounds: [
# {CouncilEx.Rounds.PeerReview, []},
# {CouncilEx.Rounds.Synthesis, []} # appended automatically when a chair is set
# ],
# chair: {:chair, MyApp.Members.Synthesizer, [provider: :openrouter, model: "openai/gpt-4o"]},
# router: nil,
# opts: []
# }
MyApp.MyCouncil.__providers__()
# => #MapSet<[:openrouter]> # recurses into sub-council members%CouncilEx.Spec{} (defined in lib/council_ex/runner.ex) is the canonical
compiled form. Its fields:
| Field | Shape | Notes |
|---|---|---|
council | module() | String.t() | the council module (or dynamic id) |
members | [{id :: atom(), module() | %DynamicMember{}, opts :: keyword()}] | opts are the resolved member opts (profile merged in) |
rounds | [{round_module :: module(), opts :: keyword()}] | round atoms already resolved to modules |
chair | {id, module, opts} | nil | optional final synthesis member |
router | module() | fun | nil | per-step member selection |
opts | keyword() | council-level opts |
Walk it like any data structure:
spec = MyApp.MyCouncil.__council__()
# Every member id + which model it runs on
for {id, _mod, opts} <- spec.members do
{id, opts[:provider], opts[:model] || opts[:profile]}
end
# => [{:advocate, :openrouter, "openai/gpt-4o-mini"}, {:skeptic, :openrouter, "anthropic/claude-sonnet-4-6"}]
# Round pipeline as module names
Enum.map(spec.rounds, fn {mod, _opts} -> mod end)
# => [CouncilEx.Rounds.PeerReview, CouncilEx.Rounds.Synthesis]
__council__/0and__providers__/0are__-prefixed because they're compiler-generated, but they are public and safe to call. If you want one reflection path that works identically for both module and dynamic councils, preferCouncilEx.Diagram.to_ir/1— it lowers either form to the same normalized graph.
Dynamic-form councils
A %CouncilEx.DynamicCouncil{} is already data — no reflection function
needed. Read its fields directly:
dyn.members # => [%CouncilEx.DynamicMember{...}, ...]
dyn.rounds # => [%CouncilEx.DynamicRound{...}, ...]
dyn.chair # => %CouncilEx.DynamicMember{} | nil
dyn.tools # => [tool_ref]
dyn.metadata # => %{}Struct fields: id, name, version, default_profile, members, rounds,
chair, router, tools, metadata. It also serializes to JSON via
CouncilEx.DynamicCouncil.to_json/2 and restores with from_json/1.
To get a uniform %Spec{} from a dynamic council (same shape as
__council__/0):
CouncilEx.DynamicCouncil.to_spec(dyn) # => %CouncilEx.Spec{}Validate before you trust it
Both forms validate to a structured error list (no exceptions):
CouncilEx.validate(MyApp.MyCouncil)
# => :ok
# | {:error, [%{path: [...], code: atom(), message: String.t()}, ...]}CouncilEx.start/3 gates on this and returns {:error, {:invalid_council, errs}} before spawning any process or spending a token — so validation is both
a reflection tool and a runtime guard.
2. Graph export
CouncilEx.Diagram.to_ir/1 normalizes either council form into a canonical
node/edge graph — the right primitive for feeding a UI (React Flow, D3, a
LiveView SVG, an HTTP endpoint):
ir = CouncilEx.Diagram.to_ir(MyApp.MyCouncil) # module OR %DynamicCouncil{}
# => %CouncilEx.Diagram.IR{council: ..., nodes: [...], edges: [...]}
Jason.encode!(ir) # ready for an HTTP response or socket assignNode shape (%{id, kind, label, attrs}), where kind is one of:
:input | :output | :round | :member | :sub_council | :chair | :router | :tool.
Edge shape (%{from, to, kind, label}), where kind is one of:
:feeds | :aggregates | :contains | :routes_to | :invokes.
ir.nodes |> Enum.map(& {&1.kind, &1.label})
ir.edges |> Enum.map(& {&1.from, &1.kind, &1.to})For dynamic councils there's also a React-Flow-shaped helper:
CouncilEx.DynamicCouncil.to_flow_graph(dyn)
# => %{nodes: [%{"id" => "member:seo", "type" => "member", ...}, ...], edges: [...]}To render the IR as Mermaid or ASCII (CLI, iex, or the mix council.diagram task), see DIAGRAMS.md.
3. Live run introspection
Async runs (CouncilEx.start/3) are GenServers you can query mid-flight.
{:ok, pid} = CouncilEx.start(MyApp.MyCouncil, input)
run_id = CouncilEx.RunServer.run_id(pid)
CouncilEx.RunServer.state(run_id)
# => {:ok, %{
# run_id: "run-…",
# council: MyApp.MyCouncil,
# status: :running, # :running | :completed | :failed | …
# current_round: %{name: :peer_review, idx: 0} | nil,
# rounds_completed: 1,
# started_at: ~U[...]
# }}
# | {:error, :not_found}List every active run (across the configured registry):
CouncilEx.list_active_runs()
# => [%{run_id: ..., council: ..., status: ..., current_round: ..., rounds_completed: ..., started_at: ...}, ...]Handle conversion both ways:
CouncilEx.RunServer.run_id(pid) # pid -> run_id
CouncilEx.pid_for(run_id) # run_id -> {:ok, pid} | {:error, :unknown_run | :runner_dead}
RunServer.state/1returns a summary (status, current round, counts) — it is intentionally cheap and does not include per-member outputs mid-run. For live per-member data (tokens, partial results, tool calls), subscribe to the run's PubSub topic and consume the 10-event stream — seeOBSERVABILITY.md. After a run finishes, the returned%CouncilEx.Result{}(with%RoundResult{}/%MemberResult{}) carries the full per-member output, confidence, token usage, durations, and themetadata.autorouting decision.
Worked example: a council catalog endpoint
Expose every council in your app as an inspectable graph + live-run status.
defmodule MyAppWeb.CouncilController do
use MyAppWeb, :controller
@councils [MyApp.MyCouncil, MyApp.Councils.SEO, MyApp.Councils.CodeReview]
# GET /councils — structure of every council as a graph
def index(conn, _params) do
graphs =
Map.new(@councils, fn c ->
{inspect(c), CouncilEx.Diagram.to_ir(c)}
end)
json(conn, graphs)
end
# GET /councils/:mod/providers — which providers each council touches (audit)
def providers(conn, %{"mod" => mod}) do
council = String.to_existing_atom("Elixir." <> mod)
json(conn, %{providers: MapSet.to_list(council.__providers__())})
end
# GET /runs — live snapshot of everything in flight
def runs(conn, _params) do
json(conn, CouncilEx.list_active_runs())
end
endAt a glance
| Need | API | Both forms? |
|---|---|---|
| Council structure as data | Mod.__council__/0 / %DynamicCouncil{} / DynamicCouncil.to_spec/1 | ✅ (via to_spec) |
| Providers referenced | Mod.__providers__/0 | module form |
| Validity check | CouncilEx.validate/1 | ✅ |
| Normalized graph (nodes/edges) | CouncilEx.Diagram.to_ir/1 | ✅ |
| React-Flow JSON | DynamicCouncil.to_flow_graph/1 | dynamic form |
| Live run summary | CouncilEx.RunServer.state/1 | ✅ |
| All active runs | CouncilEx.list_active_runs/0 | ✅ |
| Live per-member data | PubSub event stream | ✅ |
See also: DIAGRAMS.md (rendering), OBSERVABILITY.md
(events & telemetry), DYNAMIC_COUNCILS.md (data-form councils).