Two ways to scale a council beyond a flat list of members: nest a council inside another council (sub-councils), and gate which members participate per round (routers).

Hierarchical councils (SubCouncil)

A council member can itself be a council. The outer council sees the sub-council's Result.final as that member's response.

defmodule InnerCouncil do
  use CouncilEx
  member :seo,     MyApp.Members.Seo,     provider: :openai, model: "gpt-4o-mini"
  member :tech,    MyApp.Members.Tech,    provider: :openai, model: "gpt-4o-mini"
  round :independent_analysis
  chair MyApp.Members.SeoTechSynth, provider: :openai, model: "gpt-4o"
end

defmodule OuterCouncil do
  use CouncilEx
  member :strategist, MyApp.Members.Strategist, provider: :openai, model: "gpt-4o-mini"
  member :marketing, council: InnerCouncil, input_mapper: &(&1)
  round :independent_analysis
  chair MyApp.Members.OuterSynth, provider: :openai, model: "gpt-4o"
end

The sub-council runs as a synchronous nested CouncilEx.run/3. Sub-run events relay on the parent topic with a member suffix: "council_ex:run:#{parent_run_id}:sub:#{member_id}". The outer member's MemberResult.metadata carries :sub_run_id and :sub_result for downstream inspection.

input_mapper is a 1-arity function (or remote capture for dynamic sub-councils) that transforms the outer input into the inner input. Default is identity.

Dynamic form

%DynamicMember{} accepts the same :sub_council field. The sub-council target can be:

  • a registered name string: looked up in the :sub_council registry,
  • a module atom: a defmodule … use CouncilEx … end council,
  • a nested %DynamicCouncil{}: fully data-form composition.

input_mapper likewise accepts a registered name string or a remote function capture. See DYNAMIC_COUNCILS.md for the full data-form composition surface and JSON ser/de.

For a runnable two-level example — investment-memo committee driving a market-research sub-council and a competitor-intel sub-council, three providers mixed across the tree, input_mapper reshaping between layers — see examples/composition_example.exs.

See also examples/sub_council_example.exs and examples/dynamic_sub_council_example.exs.

Adaptive Router

A router filters which members participate per round, based on the input or the current run context. Implement CouncilEx.Router:

defmodule MyApp.Router do
  @behaviour CouncilEx.Router

  @impl true
  def select(input, _ctx) do
    if input[:legal_review?], do: [:legal, :content], else: [:seo, :content]
  end
end

defmodule RoutedCouncil do
  use CouncilEx

  member :seo,     MyApp.Members.Seo,     provider: :openai, model: "gpt-4o-mini"
  member :content, MyApp.Members.Content, provider: :openai, model: "gpt-4o-mini"
  member :legal,   MyApp.Members.Legal,   provider: :openai, model: "gpt-4o-mini"

  router MyApp.Router
  round :independent_analysis
end

Council-level routers apply to every round. Per-round override:

round :independent_analysis, router: fn input, _ctx -> [:m1] end

Excluded members appear in MemberResult with status: :skipped and metadata: %{reason: :router_excluded}: they still show up in RoundResult.member_results, just not invoked.

Dynamic form

A %DynamicCouncil{} accepts a registered router name string in the :router field. Per-round override is the same shape:

%DynamicRound{type: :independent_analysis, opts: %{router: "my_router"}}

See examples/router_example.exs.