Skip to content

Spontaneous AI councils

lazytools.skills.council provides a free-form multi-agent council built on LazyBridge AgentPool. It is intended for questions where several independent perspectives should challenge one another before a recommendation is written.

Unlike a fixed debate pipeline, the council does not schedule speakers or manufacture rounds. Members choose whom to engage next, may update their vote at any point, and invite the moderator when they believe the discussion can close. Only the moderator receives the closing tool, and that tool refuses to close until the configured quorum is actually present.

Generic council

from lazybridge import Agent, LLMEngine
from lazytools.skills import WizengAImot

optimist = Agent(
    name="optimist",
    engine=LLMEngine(
        "medium",
        provider="anthropic",
        system="Find opportunities without concealing risks.",
    ),
)
critic = Agent(
    name="critic",
    engine=LLMEngine(
        "medium",
        provider="deepseek",
        system="Test assumptions and surface material downside.",
    ),
)

result = (
    WizengAImot("Should we enter this market?", quorum=0.7)
    .add(optimist)
    .add(critic)
    .run()
)
print(result.text())

Research and opening positions use Agent.parallel. The discussion itself is an AgentPool: participants call route(agent_name, task) based on the conversation rather than a predetermined order.

Shared debate history

Every debater and the moderator has a private Memory for their own engine turns, but that alone would force them to recap each other from scratch — nobody automatically sees what anyone else said, so the natural move is to restate it, which duplicates the same content across several members' memories and inflates cost on a long debate.

route() is wrapped per member so every exchange is also recorded once to a single shared Memory (strategy="summary", same summarizer as the private memories). That shared history is attached to every participant's sources=[...] — LazyBridge's "shared use" mode for Memory/Store, read live on every call, never a stale snapshot. The council protocol tells members not to restate it. Memory.text() only ever renders a running summary plus the last 5 exchanges, so sharing it doesn't reopen unbounded growth. The final transcript is built from this same recording as it happens, so it reflects the debate's real chronological order — not just each agent's own turn order.

Reasoning level

Every member's engine is your own Agent, so you control its reasoning directly (LLMEngine(thinking=...), ClaudeCodeEngine(reasoning_effort=...)). For the default moderator and synthesiser — used whenever you don't pass moderator=/synthesiser= yourself — WizengAImot(..., reasoning=True) enables extended thinking on both. It's ignored once you supply your own moderator/synthesiser agents; configure their engines directly instead.

standard_council(..., reasoning=True) forwards the same switch to its four built-in members (not their researchers, which stay fast/cheap) and to the default moderator/synthesiser.

deepseek_claude_news_council(..., reasoning="high") takes a graduated level — "low", "medium" (default), "high", "xhigh", or "max" — applied to the debater, moderator, and synthesiser only; the two fast evidence/risk analysts always run with reasoning disabled, by design. DeepSeek's thinking only distinguishes off ("low") from on (everything else); Claude's reasoning_effort receives the level directly.

DeepSeek + Claude subscription news council

The current-news preset combines API-backed DeepSeek models with Claude Code agents authenticated through a Claude.ai subscription:

from lazytools.skills import deepseek_claude_news_council

council = deepseek_claude_news_council(
    "How could the latest geopolitical developments affect European energy?",
    news_db="/data/news.db",
)
result = council.run()

The roster is:

Role Engine Reasoning
Evidence analyst DeepSeek V4 Flash disabled
Risk analyst DeepSeek V4 Flash disabled
Fast analyst Claude Code Haiku disabled
Senior debater DeepSeek V4 Flash max
Senior debater Claude Code Sonnet medium, adaptive thinking
Moderator DeepSeek V4 Flash max
Synthesiser Claude Code Sonnet medium, adaptive thinking

The debater/moderator/synthesiser rows reflect the default reasoning="medium" — pass reasoning="low"/"high"/"xhigh"/"max" to deepseek_claude_news_council to change all four at once; the two analyst rows never change (see Reasoning level).

Every participant receives the same LazyCrawler tool set backed by the same SQLite news database. Claude Code agents additionally have native WebSearch/WebFetch enabled.

Requirements

Install current LazyBridge with Claude Code support and LazyTools with web support:

pip install "lazybridge[claude-code]"
pip install "lazytoolkit[web] @ git+https://github.com/selvaz/LazyTools.git"

ClaudeCodeEngine release status

ClaudeCodeEngine is merged to LazyBridge main but not yet in a tagged PyPI release as of this writing (the latest tag, 1.0.2, predates it). Until a release ships, install LazyBridge from source — e.g. pip install "lazybridge[claude-code] @ git+https://github.com/selvaz/LazyBridge.git@main" — or pin to whichever tag first includes it once released.

Configure DeepSeek normally:

export DEEPSEEK_API_KEY="..."

For Claude, do not set ANTHROPIC_API_KEY when subscription billing is intended. Install Claude Code, run claude, and choose the Claude App / Claude.ai subscription login. ClaudeCodeEngine then reuses that local login through the Claude Agent SDK.

Set the news database path or pass it explicitly:

export LAZYCRAWLER_NEWS_DB="/absolute/path/news.db"

The preset fails loudly when the database is missing; it never replaces the requested current-news source with an empty in-memory cache.

Haiku availability

Claude Code exposes the haiku alias through the Agent SDK, but actual availability depends on the installed Claude Code version and subscription. Validate it with the target account before production use.

Debate memory

A free-form debate has no fixed number of turns — route() hops until the moderator closes it. Left uncompressed, that can push the final synthesis call past the model's context window on a long debate. Every debater's memory and the moderator's memory therefore run Memory(strategy="summary", summarizer=...), compressing older turns once past 10 while keeping the last 10 verbatim. The summarizer defaults to a cheap, non-reasoning DeepSeek agent (WizengAImot._default_memory_summarizer); pass your own with memory_summarizer=:

from lazybridge import Agent, LLMEngine

fast_summarizer = Agent(
    engine=LLMEngine("super_cheap", provider="deepseek", thinking=False),
    name="summarizer",
)
council = WizengAImot(question, memory_summarizer=fast_summarizer)

Knowledge bases

Use knowledge(..., mode="static") for direct document context or mode="skill" for a BM25 documentation bundle:

council.knowledge("./briefing", name="briefing", mode="skill")

Static mode supports text, PDF, DOCX, and HTML through LazyTools document readers. Skill mode indexes text-oriented formats and exposes the resulting retriever to every participant.

Result contract

CouncilResult contains:

  • synthesis: final decision-ready report;
  • quorum_reached: whether the latest votes meet the configured threshold;
  • votes: latest structured vote from each member that voted;
  • transcript: assistant contributions captured from council memories;
  • question: the original question.

The recursion max_depth is a safety brake, not a debate schedule. The legacy max_rounds option is retained only to derive a default depth when max_depth is omitted.

API reference

lazytools.skills.council.WizengAImot

WizengAImot(question: str = '', *, quorum: float = 0.7, max_rounds: int = 3, max_depth: int | None = None, synthesiser: Agent | None = None, moderator: Agent | None = None, reasoning: bool = False, memory_summarizer: Agent | None = None, session: Session | None = None)

Run a free-form council of pre-built LazyBridge agents.

max_rounds is retained for compatibility, but it does not schedule rounds. Unless max_depth is supplied, it only helps derive a generous AgentPool recursion limit. The conversation itself has no speaker order.

reasoning enables extended thinking on the default moderator and synthesiser only (ignored once moderator=/synthesiser= supply your own agents — configure their engines directly instead).

memory_summarizer compresses the free-form debate as it runs (every debater's memory and the moderator's memory use Memory(strategy="summary", summarizer=...)). A long, unscripted debate has no fixed number of turns, so leaving it uncompressed risks the final synthesis call exceeding the model's context window. Defaults to a cheap, non-reasoning DeepSeek agent.

Source code in src/lazytools/skills/council.py
def __init__(
    self,
    question: str = "",
    *,
    quorum: float = 0.7,
    max_rounds: int = 3,
    max_depth: int | None = None,
    synthesiser: Agent | None = None,
    moderator: Agent | None = None,
    reasoning: bool = False,
    memory_summarizer: Agent | None = None,
    session: Session | None = None,
) -> None:
    """``reasoning`` enables extended thinking on the *default* moderator
    and synthesiser only (ignored once ``moderator=``/``synthesiser=``
    supply your own agents — configure their engines directly instead).

    ``memory_summarizer`` compresses the free-form debate as it runs
    (every debater's memory and the moderator's memory use
    ``Memory(strategy="summary", summarizer=...)``). A long, unscripted
    debate has no fixed number of turns, so leaving it uncompressed
    risks the final synthesis call exceeding the model's context
    window. Defaults to a cheap, non-reasoning DeepSeek agent.
    """
    if not 0.0 < quorum <= 1.0:
        raise ValueError("quorum must be greater than 0 and at most 1.")
    if max_rounds < 1:
        raise ValueError("max_rounds must be at least 1.")
    if max_depth is not None and max_depth < 1:
        raise ValueError("max_depth must be at least 1.")

    self.question = question
    self.quorum = quorum
    self.max_rounds = max_rounds
    self.max_depth = max_depth
    self.reasoning = reasoning
    self.session = session
    self._members: list[Agent] = []
    self._researchers: list[Agent] = []
    self._kbs: list[dict] = []
    self._synthesiser = synthesiser
    self._moderator = moderator
    self._memory_summarizer = memory_summarizer

add

add(member: Agent, *, researcher: Agent | None = None) -> WizengAImot

Add a member and, optionally, a separate research agent.

Source code in src/lazytools/skills/council.py
def add(self, member: Agent, *, researcher: Agent | None = None) -> WizengAImot:
    """Add a member and, optionally, a separate research agent."""
    if not member.name or not str(member.name).strip():
        raise ValueError("Council members require an explicit non-empty name.")
    if member.name == "moderator":
        raise ValueError("'moderator' is reserved for the council moderator.")
    if any(existing.name == member.name for existing in self._members):
        raise ValueError(f"Duplicate council member name: {member.name!r}.")
    self._members.append(member)
    self._researchers.append(researcher or member)
    return self

knowledge

knowledge(path: str | list[str], *, name: str = 'knowledge', description: str = 'Council knowledge base.', mode: str = 'skill', output_root: str = '/tmp/wizengaimot_skills', rebuild: bool = False, extensions: str = 'txt,md,pdf,docx,html', max_chars: int = 20000) -> WizengAImot

Attach grounded knowledge to every participant.

Source code in src/lazytools/skills/council.py
def knowledge(
    self,
    path: str | list[str],
    *,
    name: str = "knowledge",
    description: str = "Council knowledge base.",
    mode: str = "skill",
    output_root: str = "/tmp/wizengaimot_skills",
    rebuild: bool = False,
    extensions: str = "txt,md,pdf,docx,html",
    max_chars: int = 20_000,
) -> WizengAImot:
    """Attach grounded knowledge to every participant."""
    if mode not in {"skill", "static"}:
        raise ValueError("mode must be 'skill' or 'static'.")
    if not name.strip():
        raise ValueError("knowledge name must not be empty.")
    if max_chars < 1:
        raise ValueError("max_chars must be at least 1.")
    paths = [path] if isinstance(path, str) else list(path)
    if not paths:
        raise ValueError("knowledge path must contain at least one item.")
    self._kbs.append(
        {
            "paths": paths,
            "name": name,
            "description": description,
            "mode": mode,
            "output_root": output_root,
            "rebuild": rebuild,
            "extensions": extensions,
            "max_chars": max_chars,
        }
    )
    return self

lazytools.skills.council.CouncilResult dataclass

CouncilResult(question: str, quorum_reached: bool, votes: list[dict], transcript: list[dict], synthesis: str)

lazytools.skills.council.standard_council

standard_council(question: str = '', *, reasoning: bool = False, **kwargs) -> WizengAImot

Return a ready-made four-member, multi-provider council.

reasoning enables extended thinking on the four debating members (not their researchers, which stay fast/cheap) and on the default moderator and synthesiser.

Source code in src/lazytools/skills/council.py
def standard_council(
    question: str = "", *, reasoning: bool = False, **kwargs
) -> WizengAImot:
    """Return a ready-made four-member, multi-provider council.

    ``reasoning`` enables extended thinking on the four debating members
    (not their researchers, which stay fast/cheap) and on the default
    moderator and synthesiser.
    """
    gpt_search = Agent(
        engine=LLMEngine(
            "super_cheap",
            provider="openai",
            system="Search the web and return a concise, sourced summary.",
            max_turns=4,
        ),
        native_tools=[NativeTool.WEB_SEARCH],
        name="gpt_web_search",
    ).as_tool("web_search", description="Search the web via OpenAI.")

    def make(
        name: str,
        council_provider: str,
        research_provider: str,
        research_tier: str,
        system: str,
        *,
        search: bool = True,
        extra_tools: list[Any] | None = None,
    ) -> tuple[Agent, Agent]:
        member = Agent(
            engine=LLMEngine(
                "medium",
                provider=council_provider,
                system=system,
                max_turns=6,
                thinking=reasoning,
            ),
            name=name,
        )
        researcher = Agent(
            engine=LLMEngine(
                research_tier,
                provider=research_provider,
                system=f"Research assistant for {name}.",
                max_turns=8,
            ),
            tools=extra_tools or [],
            native_tools=[NativeTool.WEB_SEARCH] if search else None,
            name=f"{name}_researcher",
        )
        return member, researcher

    pairs = [
        make(
            "optimist", "anthropic", "anthropic", "cheap",
            "Optimistic strategist. Find opportunities without hiding risks.",
        ),
        make(
            "devil_advocate", "openai", "openai", "super_cheap",
            "Devil's advocate. Surface risks and weak assumptions.",
        ),
        make(
            "analyst", "google", "google", "cheap",
            "Data-driven analyst. Ground conclusions in evidence.",
        ),
        make(
            "visionary", "deepseek", "openai", "super_cheap",
            "Visionary. Identify long-term and second-order implications.",
            search=False,
            extra_tools=[gpt_search],
        ),
    ]

    council = WizengAImot(question, reasoning=reasoning, **kwargs)
    for member, researcher in pairs:
        council.add(member, researcher=researcher)
    return council

lazytools.skills.council.deepseek_claude_news_council

deepseek_claude_news_council(question: str = '', *, news_db: str | Path | None = None, reasoning: str = 'medium', **kwargs) -> WizengAImot

Build the DeepSeek + Claude Code subscription council.

Composition:

  • two DeepSeek V4 Flash analysts with reasoning disabled;
  • one Claude Haiku analyst via Claude Agent SDK with reasoning disabled;
  • one DeepSeek V4 Flash debater with maximum reasoning;
  • one Claude Sonnet debater via Claude Agent SDK with medium reasoning.

Every member, moderator, and synthesiser receives the same LazyCrawler tools backed by the same current-news database. Claude uses the local Claude Code/Claude.ai subscription login, never ANTHROPIC_API_KEY.

reasoning (one of "low", "medium", "high", "xhigh", "max") controls the debater, moderator, and synthesiser only — the two fast evidence/risk analysts always run with reasoning disabled, by design.

Requires current LazyBridge with the claude-code extra, LazyTools with the web extra, an authenticated Claude Code CLI, DEEPSEEK_API_KEY, and either news_db=... or LAZYCRAWLER_NEWS_DB.

Source code in src/lazytools/skills/council.py
def deepseek_claude_news_council(
    question: str = "",
    *,
    news_db: str | Path | None = None,
    reasoning: str = "medium",
    **kwargs,
) -> WizengAImot:
    """Build the DeepSeek + Claude Code subscription council.

    Composition:

    - two DeepSeek V4 Flash analysts with reasoning disabled;
    - one Claude Haiku analyst via Claude Agent SDK with reasoning disabled;
    - one DeepSeek V4 Flash debater with maximum reasoning;
    - one Claude Sonnet debater via Claude Agent SDK with medium reasoning.

    Every member, moderator, and synthesiser receives the same LazyCrawler
    tools backed by the same current-news database. Claude uses the local
    Claude Code/Claude.ai subscription login, never ``ANTHROPIC_API_KEY``.

    ``reasoning`` (one of ``"low"``, ``"medium"``, ``"high"``, ``"xhigh"``,
    ``"max"``) controls the debater, moderator, and synthesiser only — the
    two fast evidence/risk analysts always run with reasoning disabled,
    by design.

    Requires current LazyBridge with the ``claude-code`` extra, LazyTools with
    the ``web`` extra, an authenticated Claude Code CLI, ``DEEPSEEK_API_KEY``,
    and either ``news_db=...`` or ``LAZYCRAWLER_NEWS_DB``.
    """
    deepseek_thinking, claude_reasoning_effort, claude_thinking = _reasoning_settings(
        reasoning
    )

    try:
        from lazybridge import ClaudeCodeEngine
        from lazycrawler import CrawlerDB, CrawlerTools, DBConfig, LLMConfig
        from lazycrawler.config import resolve_news_db_path

        from lazytools.connectors.web import WebTools
    except ImportError as exc:
        raise ImportError(
            "This preset requires current lazybridge[claude-code] and "
            "lazytoolkit[web] with LazyCrawler."
        ) from exc

    resolved_news_db = resolve_news_db_path(str(news_db) if news_db else None)
    if not resolved_news_db:
        raise FileNotFoundError(
            "Current news database is not configured. Pass news_db=... or set "
            "LAZYCRAWLER_NEWS_DB to its absolute path."
        )
    news_path = Path(resolved_news_db).expanduser().resolve()
    if not news_path.is_file():
        raise FileNotFoundError(f"Current news database not found: {news_path}")

    crawler = CrawlerTools(
        db=CrawlerDB(DBConfig(db_path=str(news_path))),
        llm_cfg=LLMConfig(model="deepseek-v4-flash"),
        content="smart",
        links="pure",
    )
    shared_tools = WebTools(provider=crawler).as_tools()

    def deepseek_agent(
        name: str,
        system: str,
        *,
        thinking: bool | str,
        max_turns: int = 8,
    ) -> Agent:
        return Agent(
            engine=LLMEngine(
                "deepseek-v4-flash",
                provider="deepseek",
                # str thinking values ("max", "adaptive", ...) need a LazyBridge
                # release beyond the 1.0.1 currently on PyPI; LLMEngine.thinking
                # is still typed `bool` there. Same floor issue as ClaudeCodeEngine.
                thinking=thinking,  # type: ignore[arg-type]
                system=system,
                max_turns=max_turns,
            ),
            tools=shared_tools,
            name=name,
        )

    deepseek_evidence = deepseek_agent(
        "deepseek_evidence_analyst",
        "Evidence analyst. Establish facts, dates, sources, and uncertainty.",
        thinking=False,
    )
    deepseek_risk = deepseek_agent(
        "deepseek_risk_analyst",
        "Risk analyst. Test assumptions, downside cases, and missing evidence.",
        thinking=False,
    )
    claude_haiku = Agent(
        engine=ClaudeCodeEngine(
            model="haiku",
            reasoning_effort=None,
            thinking="disabled",
            web=True,
            system=(
                "Fast evidence analyst. Use the shared crawler and news database; "
                "extract relevant facts without extended reasoning."
            ),
            max_turns=8,
        ),
        tools=shared_tools,
        name="claude_haiku_analyst",
    )
    deepseek_debater = deepseek_agent(
        "deepseek_max_debater",
        "Senior debater. Integrate the evidence, challenge weak claims, and revise openly.",
        thinking=deepseek_thinking,
        max_turns=12,
    )
    claude_debater = Agent(
        engine=ClaudeCodeEngine(
            model="sonnet",
            reasoning_effort=claude_reasoning_effort,
            thinking=claude_thinking,
            web=True,
            system=(
                "Senior final-stage debater. Integrate the full discussion, challenge "
                "remaining uncertainty, and seek a defensible recommendation."
            ),
            max_turns=12,
        ),
        tools=shared_tools,
        name="claude_sonnet_debater",
    )

    moderator = deepseek_agent(
        "news_council_moderator",
        "Neutral moderator. Preserve dissent, check genuine convergence, and avoid premature closure.",
        thinking=deepseek_thinking,
        max_turns=10,
    )
    synthesiser = Agent(
        engine=ClaudeCodeEngine(
            model="sonnet",
            reasoning_effort=claude_reasoning_effort,
            thinking=claude_thinking,
            web=True,
            system=(
                "Council scribe. Produce a sourced, decision-ready synthesis that "
                "distinguishes facts, interpretations, agreement, and dissent."
            ),
            max_turns=10,
        ),
        tools=shared_tools,
        name="news_council_synth",
    )

    council = WizengAImot(
        question,
        moderator=moderator,
        synthesiser=synthesiser,
        **kwargs,
    )
    for member in (
        deepseek_evidence,
        deepseek_risk,
        claude_haiku,
        deepseek_debater,
        claude_debater,
    ):
        council.add(member)
    return council