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"
endThe 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_councilregistry, - a module atom: a
defmodule … use CouncilEx … endcouncil, - 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
endCouncil-level routers apply to every round. Per-round override:
round :independent_analysis, router: fn input, _ctx -> [:m1] endExcluded 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"}}