Introspection: Inspect a Council as Data at Runtime

Copy Markdown View Source

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:

  1. Structural reflection — what is this council made of?
  2. Graph export — turn the structure into nodes/edges for a UI.
  3. 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:

FieldShapeNotes
councilmodule() | 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} | niloptional final synthesis member
routermodule() | fun | nilper-step member selection
optskeyword()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__/0 and __providers__/0 are __-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, prefer CouncilEx.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 assign

Node 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/1 returns 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 — see OBSERVABILITY.md. After a run finishes, the returned %CouncilEx.Result{} (with %RoundResult{} / %MemberResult{}) carries the full per-member output, confidence, token usage, durations, and the metadata.auto routing 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
end

At a glance

NeedAPIBoth forms?
Council structure as dataMod.__council__/0 / %DynamicCouncil{} / DynamicCouncil.to_spec/1✅ (via to_spec)
Providers referencedMod.__providers__/0module form
Validity checkCouncilEx.validate/1
Normalized graph (nodes/edges)CouncilEx.Diagram.to_ir/1
React-Flow JSONDynamicCouncil.to_flow_graph/1dynamic form
Live run summaryCouncilEx.RunServer.state/1
All active runsCouncilEx.list_active_runs/0
Live per-member dataPubSub event stream

See also: DIAGRAMS.md (rendering), OBSERVABILITY.md (events & telemetry), DYNAMIC_COUNCILS.md (data-form councils).