jacob-bd/the-ai-counsel · Archived

the-ai-counsel-api

The AI Counsel — MCP-first (10 action-based tools) when The AI Counsel MCP server is connected; REST/curl fallback when MCP is unavailable, for cron scripts, or raw SSE.

First seen Jun 1, 2026

Installation

$ npx skills add jacob-bd/the-ai-counsel --skill the-ai-counsel-api

Summary

  • The AI Counsel — MCP-first (10 action-based tools) when The AI Counsel MCP server is connected; REST/curl fallback when MCP is unavailable, for cron scripts, or raw SSE.
  • Triggers on "ask the council", "run a debate", "configure models", "run a deliberation", "check council health", "import relay-ai keys", "disconnect providers", etc.

Stronger alternatives

This repository is archived — consider an actively maintained alternative.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Declared
Cursor Not declared
Codex Declared
GitHub Copilot Declared
Windsurf Not declared
Gemini CLI Declared
Cline Not declared
OpenCode Declared

Repository health

Stars 4
License LICENSE
Default branch main
Open issues 0
Status Archived

Skill metadata

Parsed from SKILL.md frontmatter.

Version0.12.1
Declared agents claude-code codex github-copilot gemini opencode

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 68,489 B
  • docs SUMMARY.md 363 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 1 installs

SKILL.md

The AI Counsel — API & MCP Skill

Overview

The AI Counsel has two operating modes:

  • Council mode — 3-stage multi-LLM deliberation: individual responses → anonymous peer ranking → chairman synthesis
  • Advisor mode — Named personas debate a question across configurable rounds, reaching consensus or delivering a structured verdict

Use Council for direct answers, creative prompts, factual questions, and "give me the best response" synthesis. Use Advisor only when the user wants named personas to debate a decision, tradeoff, risk review, prioritization, strategy, ethics, or genuine disagreement. Simple prompts can drift off-topic in Advisor mode because advisor prompts intentionally force positions, rebuttals, consensus scoring, and verdicts.

Transport rule (read first): If The AI Counsel MCP tools are available in your session, call them — do not shell out to curl for the same operation. This skill’s REST sections are the fallback reference when MCP is missing, the SSE session is stale, or you need raw SSE/admin export.

MCP server (v0.12.1): Built-in SSE at http://localhost:8001/mcp/sse (stdio: python -m theaicounselmcp). Exposes 10 action-based tools (not 25). Verify via GET /api/health → "mcp": {"tools": 10, "sseurl": "..."}.

The server's connect message is minimal by design — it does not list the tools. Use the roster below.

Default base URL (REST fallback only): http://localhost:8001 Remote server: replace with http://<server-ip>:8001


MCP-first routing

When to use MCP (preferred)

Use MCP when your tool list includes any of these 10 tools (server may appear as the-ai-counsel, ai-counsel, or user-the-ai-counsel):

You want to… MCP tool Action(s) Do not use curl
Check server / providers providers health GET /api/health
Test an API key providers test POST /api/settings/test-provider
List models providers list_models GET /api/models…
Read council config (+ presets) council_settings get GET /api/settings (council fields)
Update council members/chairman/mode council_settings update PUT /api/settings (council fields)
Council preset CRUD council_settings listpresets, savepreset, deletepreset, setdefault_preset PUT /api/settings
Set search provider / API key providers setsearch, setapi_key PUT /api/settings
Backup / restore / reset config config_backup export, import, reset export/import/reset endpoints
Full deliberation council_deliberate full /api/ask or message stream
Stage 1 / 2 / 3 only council_deliberate stage1, stage2, stage3 stage stream endpoints
One-shot model chat model_chat quick POST /api/ask
Multi-turn chat with a model model_chat multi_turn conversation message endpoints
List / read conversations conversations list, get conversation GETs
Check active run progress conversations progress GET /api/conversations/{id}/progress
List / read / edit personas personas list, get, update, reset /api/personas
Create / delete custom personas (no MCP action) (Advisor Setup or REST) POST /api/personas / DELETE /api/personas/{id}
Read advisor defaults (+ presets) advisor_settings get GET /api/settings (advisor fields)
Update advisor defaults advisor_settings update PUT /api/settings (advisor fields)
Advisor preset CRUD advisor_settings listpresets, savepreset, deletepreset, setdefault_preset PUT /api/settings
Run advisor debate advisor_debate (direct params) debate/stream
Run multi-round debate runiterativedebate (direct params) debate message endpoints

Breaking change (v0.5.2): Legacy 25-tool names (rundeliberation, getcouncilconfig, checkhealth, etc.) were removed. Always use the 10 tools above with action parameters.

MCP Tool Catalog (10 tools)

Tool Actions / usage
council_deliberate stage1, stage2, stage3, full
model_chat quick, multi_turn
advisor_debate Direct params: question, personaids (2–4), optional maxrounds, models
runiterativedebate Direct params: query, optional debaterounds (1–5), critiquemode (freeform/paragraph/claim), autoconverge (bool), convergencethreshold (1–3), web_search, models
council_settings get, update (members/chairman/temps/mode/prompts/provider toggles/debate config), listpresets, savepreset, deletepreset, setdefault_preset
advisor_settings Same preset actions + get, update
personas list, get, update, reset (custom create/delete via UI or REST)
conversations list, get, progress
providers listmodels, health, test, setapikey, setsearch
config_backup export, import, reset

In Claude Code, tools appear as mcpthe-ai-counsel<name> (server identifier may vary). Full parameters: [docs/mcp/TOOLS.md](../../docs/mcp/TOOLS.md).

Document inputs: councildeliberate, modelchat, advisordebate, and runiterativedebate accept optional documents. Pass already extracted text as {name, mimetype, text} or source files as {name, mimetype, database64}. Base64 documents are extracted by the backend before model calls; providers receive normalized text context, not raw file bytes.

Agent checklist before running curl:

  1. Are MCP tools for this server visible in my tool list?
  2. Is there a row in the table above for this task?
  3. If yes to both → call the MCP tool. Only use REST if the MCP call fails or the task is in “REST only” below.

When to use REST (fallback)

Scenario Why REST, not MCP
Cron / CI / non-MCP scripts No MCP transport
MCP errors (connection refused, stale SSE, tool not found) Fallback per this skill
Raw SSE event parsing (custom UIs) MCP deliberation tools return consolidated results, not per-event SSE
Admin export with bearer token GET /api/settings/export — manual admin action
Disconnect all providers POST /api/settings/disconnect-all-providers — no MCP action yet
Credential storage / relay-ai import / OAuth device login REST only (see Credentials section below)

See [docs/mcp/TOOLS.md](../../docs/mcp/TOOLS.md) for MCP parameters and [docs/mcp/EXAMPLES.md](../../docs/mcp/EXAMPLES.md) for walkthroughs.


Credentials & secrets (v0.11.0)

User guide: [docs/CREDENTIALS.md](../../docs/CREDENTIALS.md).

Rules for agents:

  1. Secrets live in the credential store (data/credentials.json or OS keystore service the-ai-counsel) — not in settings.json.
  2. GET /api/settings returns apikeyset / oauth_connected only — never plaintext keys.
  3. Set a key via PUT /api/settings with the *apikey field, or MCP providers → setapikey. Empty string = Disconnect (clears store + ignores env for that secret until a new key is saved).
  4. Retest with an empty apikey body reads the credential store (resolveapi_key) — do not assume keys are still on the Settings model.
  5. relay-ai import copies from Keychain service relay-ai into Counsel’s store; it does not share or overwrite the relay-ai service. Switching Counsel storage to keychain writes service the-ai-counsel only.
  6. Disconnect All Providers: POST /api/settings/disconnect-all-providers (admin/loopback). Clears all secrets + disables provider toggles; keeps council/prompts.
  7. Docker/containers always use file storage — OS keystore is unavailable.

Quick Reference (REST fallback)

Use this table only when MCP tools are unavailable or the operation has no MCP equivalent (see routing above).

Operation Method Endpoint
Health check GET /api/health (includes "mcp": {"tools": 10})
One-shot query (persisted, no prior history) POST /api/ask
Get settings (council + advisor config) GET /api/settings
Update settings PUT /api/settings
List all models GET /api/models + /api/models/direct + /api/ollama/tags + /api/custom-endpoint/models
List conversations GET /api/conversations
Create conversation POST /api/conversations
Get conversation GET /api/conversations/{id}
Get live run progress GET /api/conversations/{id}/progress
Extract uploaded documents POST /api/documents/extract
Extract JSON/base64 documents POST /api/documents/extract-json
Send message (sync JSON) POST /api/conversations/{id}/message
Send message (SSE stream) POST /api/conversations/{id}/message/stream
Run council debate (SSE stream) POST /api/conversations/{id}/message/debate
Run advisor debate (SSE stream) POST /api/conversations/{id}/debate/stream
List all personas GET /api/personas
Create a custom persona POST /api/personas
Update a persona PATCH /api/personas/{id}
Reset persona to defaults DELETE /api/personas/{id}/override
Delete a custom persona DELETE /api/personas/{id}
Test a provider POST /api/settings/test-provider
Export settings (backup) GET /api/settings/export
Import settings (restore) POST /api/settings/import
Reset settings to defaults POST /api/settings/reset
Disconnect all providers (keys + OAuth) POST /api/settings/disconnect-all-providers

Model ID prefix format:

openrouter:anthropic/claude-sonnet-4   → Cloud via OpenRouter
ollama:llama3.1:latest                 → Local Ollama
anthropic:claude-sonnet-4              → Direct Anthropic API
openai:gpt-4.1                         → Direct OpenAI API
custom:nvidia/nemotron-3-super-120b    → Custom endpoint
groq:llama3-70b-8192                   → Groq fast inference
opencode-zen:glm-5.1                   → Direct OpenCode Zen (chat/completions only, v1)
opencode-go:kimi-k2.5                  → Direct OpenCode Go (chat/completions only, v1; subscription)
xai-oauth:grok-4                       → xAI SuperGrok (subscription OAuth)
openai-oauth:gpt-5                     → ChatGPT Plus/Pro (subscription OAuth; Codex Responses)
github-copilot:gpt-4.1                 → GitHub Copilot (subscription OAuth)

Subscription OAuth (device-code login):

Action Method Path
Start login POST /api/oauth/{provider_id}/start (xai-oauth \ openai-oauth \ github-copilot)
Poll status GET /api/oauth/{providerid}/status?sessionid=
Disconnect DELETE /api/oauth/{provider_id}
Credential storage mode POST /api/settings/credential-storage body `{mode: "file"\ "keyring"}`
Discover relay-ai keys GET /api/credentials/import/relay-ai/discover
Import relay-ai keys POST /api/credentials/import/relay-ai body {ids:[], replace_existing?}

GET /api/settings exposes oauthconnected booleans, credentialstorage fields, and (when Copilot is connected) githubcopilotplan / githubcopilotisfree_plan; secrets are never returned. OS keystore mode is desktop-only (not available in Docker).

Disconnect API keys: PUT /api/settings with an empty string for any *apikey field clears that secret from the credential store and ignores a matching process env override (e.g. OPENCODEAPIKEY) until a new non-empty key is saved. Applies to OpenRouter, Groq, OpenCode, direct providers, custom endpoint, and search provider keys.

Disconnect all: POST /api/settings/disconnect-all-providers — wipe credential store + OAuth, set disabledsecretids for all known secrets, disable all provider toggles. Returns {status, cleared, message, ...settings}.

OpenCode note (v0.8.0): The OpenCode provider only exposes models that route to /v1/chat/completions. GPT Responses, Anthropic Messages, and per-model Gemini are not supported in v1 and are filtered out of /v1/models. A single shared opencodeapikey field covers both products; Go users can also use Zen's free models. Direct Go requests automatically carry the current Counsel conversation ID as x-opencode-session across all turns, stages, and retries, plus the identifying the-ai-counsel/<version> user agent; standalone provider calls generate one fallback session ID per logical query. Use POST /api/settings/test-opencode to validate both products at once.


Choosing the Right Endpoint

Scenario Endpoint Why
One-shot query, no history needed POST /api/ask Simplest path. One call; the completed run is saved and returns conversation_id.
One-shot query with web search POST /api/ask with web_search: true Same simplicity, adds search context.
Full deliberation, don't need live progress POST /api/ask with execution_mode: "full" Returns all stages in one JSON response.
Multi-turn conversation with follow-ups POST /api/conversations/{id}/message Models see full prior context. JSON response.
Multi-turn with live SSE progress POST /api/conversations/{id}/message/stream Real-time stage updates + multi-turn context.
Persona-driven debate POST /api/conversations/{id}/debate/stream Named advisors argue across rounds; returns verdict.
Multi-round council debate POST /api/conversations/{id}/message/debate Iterative debate with critique modes; streams council debate rounds.
Monitor an active run GET /api/conversations/{id}/progress Poll partial results of a run started by another client.

Key principles:

  • Never mutate global config for ad-hoc queries. Use per-request models / councilmodels / chairmanmodel overrides instead.
  • Use optional documents on /api/ask, conversation message endpoints, council debate, and advisor debate when prompts need file context.
  • Use conversation endpoints when you need follow-up questions — models automatically receive prior turns as context.
  • /api/ask does not load prior history. Each successful call creates a new saved conversation visible in the UI and returns its conversation_id.
  • Advisor debates always require a conversation — create one first, then stream the debate to it.
  • Use GET /api/conversations/{id}/progress to check on an active run started by another client (MCP, UI, or another script) — returns {active: false} when no run is in progress.

Provider & model availability

Provider toggles are global:

enabledproviders and directprovider_toggles (Settings → Council Config) control which providers appear in all model pickers — Council Setup, Advisor Setup, and Settings temperature controls. A provider must be both configured (API key set / Ollama connected) and enabled (toggle on) for its models to appear. By default, providers are enabled when first configured.

REST/MCP agents listing models should call the model list endpoints directly (/api/models, /api/models/direct, /api/ollama/tags, /api/custom-endpoint/models). Availability depends on credentials, not UI toggles.


Cost reporting

All council runs, iterative council debates, advisor debates, /api/ask responses, saved conversation metadata, and MCP deliberation outputs expose cost data:

  • Per model call: usage (normalized token counts) and cost (provider, tokens, USD cost, pricing source, confidence, status).
  • Per run: cost_report with total USD cost, input/output/total token totals, call totals, known/unknown/estimated/free counts, breakdown by model and stage, and raw call rows.

Token semantics:

  • input_tokens are prompt/context tokens.
  • output_tokens are visible generated output tokens.
  • reasoning_tokens are preserved inside usage and call rows when providers report them. When providers bill reasoning as output, the estimated output cost includes those reasoning tokens.
  • total_tokens is the provider-reported total when available; otherwise it falls back to input plus output.

Pricing order:

  1. Provider-reported cost when available. OpenRouter usage.cost / usage.total_cost is treated as known.
  2. Known-free rules report $0: ollama:, nvidia:, OpenRouter models ending in :free, subscription OAuth prefixes (xai-oauth:, openai-oauth:, github-copilot:), the known free opencode-zen: models, and custom endpoints whose configured endpoint_url contains the official opencode.ai host.
  3. OpenCode hardcoded pricing table for paid OpenCode Go and Zen models (pricingsource: "table:opencode", coststatus: "estimated").
  4. Catalog estimate from https://ai-model-pricing.com/api/v1/pricing.json, cached locally in data/modelpricingcache.json.
  5. Fallback catalog estimate from LiteLLM's modelpricesandcontextwindow.json.
  6. If usage is present but pricing cannot be matched, the report preserves token usage and marks cost as unknown.

Environment overrides:

Variable Default
LLMCOUNCILPRICINGSOURCEURL https://ai-model-pricing.com/api/v1/pricing.json
LLMCOUNCILLITELLMPRICINGURL https://raw.githubusercontent.com/BerriAI/litellm/main/modelpricesandcontextwindow.json
LLMCOUNCILPRICINGCACHETTL_SECONDS 86400

Custom endpoint note: custom OpenAI-compatible endpoints do not have a universal billing API. OpenCode Zen and OpenCode Go are first-class direct providers (opencode-zen: and opencode-go: prefixes) with their own pricing table in costs.py — see "OpenCode note" above. Other custom endpoints use catalog estimates only when the upstream model ID can be matched, otherwise cost is unknown.


Document uploads and extraction

Document inputs are converted to plain text before model calls so they work consistently across OpenRouter, Ollama, Groq, direct providers, custom endpoints, REST, MCP, and the UI.

Supported v1 formats:

  • PDFs
  • Text-like files: .txt, .md, .csv, .json, .yaml, .xml, .html
  • Logs, source code, and common config files

REST endpoints:

  • POST /api/documents/extract accepts multipart uploads from the UI and returns extracted document payloads plus warnings.
  • POST /api/documents/extract-json accepts JSON documents with data_base64 and returns extracted document payloads plus warnings.

Request bodies that accept documents:

  • POST /api/ask
  • POST /api/conversations/{id}/message
  • POST /api/conversations/{id}/message/stream
  • POST /api/conversations/{id}/message/debate
  • POST /api/conversations/{id}/debate/stream

Document payload shape:

{
  "name": "notes.txt",
  "mime_type": "text/plain",
  "text": "Meeting notes..."
}

For source files over MCP/JSON, use data_base64 instead of text; the MCP client extracts those files through /api/documents/extract-json before starting the model run.

Conversation history stores attachment metadata only: file name, MIME type, byte size, extracted character count, page count when available, and warnings. It does not store raw file bytes or extracted text.

PDF handling:

  • Embedded text extraction uses pdfplumber.
  • OCR is optional. Set LLMCOUNCILOCR_ENABLED=1 and install OCRmyPDF, Tesseract, Ghostscript, and qpdf in the backend runtime.
  • If OCR is disabled or unavailable, extraction continues with embedded text and warnings.

Examples (REST fallback)

1. One-Shot Query (scripts / REST-only environments)

The simplest way to query a model. Each successful call creates a new conversation visible in the UI and returns its conversation_id; no prior conversation history is loaded.

curl -X POST http://localhost:8001/api/ask \
  -H "Content-Type: application/json" \
  -d '{
    "content": "What is the capital of France?",
    "models": ["custom:moonshotai/kimi-k2.6"],
    "execution_mode": "chat_only"
  }'
# → {"conversation_id": "...", "response": "The capital of France is Paris.", "model": "custom:moonshotai/kimi-k2.6", "error": null}
import httpx

async def ask(query, model, web_search=False, base_url="http://localhost:8001"):
    async with httpx.AsyncClient(timeout=120) as client:
        r = await client.post(f"{base_url}/api/ask", json={
            "content": query,
            "models": [model],
            "web_search": web_search,
            "execution_mode": "chat_only",
        })
        return r.json()["response"]

# Usage:
# answer = await ask("Explain quantum tunneling", "openai:gpt-4.1")

Request body:

Field Type Required Default Description
content string Yes — The question/prompt
models array of strings No Global council config 1+ model IDs to query
chairman_model string No Global chairman config Override chairman for full mode
web_search boolean No false Enable web search context
execution_mode string No "chat_only" chatonly, chatranking, or full
documents array No [] Extracted document payloads from /api/documents/extract or /api/documents/extract-json

Response shapes by mode:

  • chatonly + 1 model: {"conversationid": "...", "response": "...", "model": "...", "error": null, "usage": {...}, "cost": {...}, "cost_report": {...}}
  • chatonly + N models: {"conversationid": "...", "responses": [{model, response, error, usage, cost}, ...], "cost_report": {...}}
  • chatranking: {"conversationid": "...", "responses": [...], "rankings": [...], "aggregaterankings": [...], "labeltomodel": {...}, "costreport": {...}}
  • full: {"conversationid": "...", "response": "...", "chairmanmodel": "...", "responses": [...], "rankings": [...], "aggregaterankings": [...], "labeltomodel": {...}, "costreport": {...}}

conversationid identifies the saved UI conversation. costreport is always in USD. It summarizes totalcost, inputtokens, outputtokens, totaltokens, totalcalls, knowncostcalls, unknowncostcalls, estimatedcalls, freecalls, bymodel, by_stage, and raw calls.


2. One-Shot with Multiple Models

curl -X POST http://localhost:8001/api/ask \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Compare REST vs GraphQL",
    "models": ["openai:gpt-4.1", "anthropic:claude-sonnet-4", "custom:moonshotai/kimi-k2.6"],
    "execution_mode": "chat_only"
  }'
# → {"responses": [{model, response, error}, {model, response, error}, ...]}

3. One-Shot Full Deliberation

async def deliberate(query, models, base_url="http://localhost:8001"):
    async with httpx.AsyncClient(timeout=300) as client:
        r = await client.post(f"{base_url}/api/ask", json={
            "content": query,
            "models": models,
            "execution_mode": "full",
            "web_search": True,
        })
        data = r.json()
        return data["response"]  # Chairman's synthesized answer

No conversation setup. No config mutation. One call; use the returned conversation_id to inspect the saved run.


4. Streaming with Per-Request Overrides (REST-only — live SSE progress)

When you need SSE events for real-time progress (stage1progress, stage2progress, etc.), use the streaming endpoint with per-request model overrides:

import asyncio, httpx, json

async def stream_deliberation(query, models, chairman=None, web_search=False, base_url="http://localhost:8001"):
    async with httpx.AsyncClient(timeout=300) as client:
        # Create conversation (only needed for stream endpoint)
        conv = (await client.post(f"{base_url}/api/conversations", json={})).json()
        conv_id = conv["id"]

        # Stream with per-request overrides — global config untouched
        payload = {
            "content": query,
            "web_search": web_search,
            "execution_mode": "full",
            "council_models": models,        # per-request override
            "chairman_model": chairman,       # per-request override
        }

        stage3 = {}
        async with client.stream("POST", f"{base_url}/api/conversations/{conv_id}/message/stream", json=payload) as resp:
            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                event = json.loads(line[6:])
                t = event.get("type")
                if t == "stage3_complete":
                    stage3 = event["data"]

        return stage3.get("response")

Per-request override fields (available on both /message and /message/stream):

Field Type Description
council_models array of strings Override which models run in Stage 1+2
chairman_model string Override which model runs Stage 3 synthesis

These fields are optional. If omitted, the global config is used. They never mutate settings.


5. Multi-Turn Conversations (Follow-Up Questions)

Conversation endpoints automatically pass prior turns as context to the models. The models see the full chat history, so follow-up questions work naturally.

import httpx

async def multi_turn_chat(base_url="http://localhost:8001"):
    async with httpx.AsyncClient(timeout=120) as client:
        # Create conversation once
        conv = (await client.post(f"{base_url}/api/conversations", json={})).json()
        conv_id = conv["id"]

        # First question
        r1 = await client.post(f"{base_url}/api/conversations/{conv_id}/message", json={
            "content": "What is a monad in functional programming?",
            "execution_mode": "chat_only",
            "council_models": ["openai:gpt-4.1"],
        })
        print("A1:", r1.json()["stage1"][0]["response"])

        # Follow-up — the model remembers the previous exchange
        r2 = await client.post(f"{base_url}/api/conversations/{conv_id}/message", json={
            "content": "Can you give me a concrete example in Python?",
            "execution_mode": "chat_only",
            "council_models": ["openai:gpt-4.1"],
        })
        print("A2:", r2.json()["stage1"][0]["response"])

        # Third turn — full context of turns 1+2 is available
        r3 = await client.post(f"{base_url}/api/conversations/{conv_id}/message", json={
            "content": "How does this compare to Rust's Result type?",
            "execution_mode": "chat_only",
            "council_models": ["openai:gpt-4.1"],
        })
        print("A3:", r3.json()["stage1"][0]["response"])

How context works:

  • Each message sent to a conversation endpoint includes all prior user/assistant turns as chat history
  • For assistant context, the system uses the chairman synthesis (stage3) when available, otherwise the first successful model response from stage1
  • /api/ask creates a new saved conversation per call but has no multi-turn memory (use conversation message endpoints for follow-ups)
  • You can reuse the same conversation_id across sessions — history is persisted to disk

When to use multi-turn vs one-shot:

Scenario Endpoint Multi-turn?
Independent questions, no follow-up needed POST /api/ask No
Research session with follow-ups POST /api/conversations/{id}/message Yes
Interactive exploration with live progress POST /api/conversations/{id}/message/stream Yes

6. Sync Conversation Endpoint (JSON, saves to history)

For when you want conversation history but don't need SSE streaming:

# Create conversation first
CONV_ID=$(curl -s -X POST http://localhost:8001/api/conversations -H "Content-Type: application/json" -d '{}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")

# Send message (returns JSON, saves to conversation)
curl -X POST "http://localhost:8001/api/conversations/$CONV_ID/message" \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Explain monads in simple terms",
    "execution_mode": "chat_only",
    "council_models": ["openai:gpt-4.1"]
  }'

Response includes all stages that were executed:

{
  "stage1": [{"model": "openai:gpt-4.1", "response": "...", "error": null, "usage": {...}, "cost": {...}}],
  "stage2": null,
  "stage3": null,
  "aggregate_rankings": null,
  "label_to_model": null,
  "cost_report": {...}
}

7. Health Check

curl http://localhost:8001/api/health
# → {"status": "ok", "service": "LLM Council API"}

8. Get Current Council Configuration

curl http://localhost:8001/api/settings | python3 -m json.tool

Key fields returned:

  • council_models — list of model IDs in the council
  • chairman_model — model that synthesizes the final answer
  • executionmode — "full" / "chatranking" / "chat_only" (persisted; omitted from some GET responses — use export for full blob)
  • search_provider — active search provider
  • enabled_providers — global provider toggles (openrouter, ollama, groq, direct, custom) — apply to all model pickers (Council, Advisors, Settings)
  • directprovidertoggles — per-direct-provider toggles (also global)
  • date_format — display date format ("auto", "MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD")
  • font_size — global UI text scale ("default" = 110%, "large" = 150%); applies to existing and future chats
  • response_language — language for council/advisor model responses (default "English")
  • validresponselanguages — read-only list of allowed responselanguage values (canonical source: VALIDRESPONSE_LANGUAGES in backend/prompts.py)
  • responselanguagedefault — default language string ("English")
  • advisor_presets — saved advisor lineups (see §18)
  • council_presets — saved council lineups (members + chairman; see §18b)
  • *apikey_set — boolean flags (never returns actual keys)
  • customendpointname / customendpointurl — custom provider details

9. Update Global Council Configuration

curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{
    "council_models": ["custom:z-ai/glm-5.1", "ollama:granite4.1:8b", "custom:moonshotai/kimi-k2.6"],
    "chairman_model": "custom:nvidia/nemotron-3-super-120b-a12b",
    "execution_mode": "full"
  }'

All fields are optional — only provided fields are updated. Requires minimum 1 model.

Valid execution_mode values:

  • "full" — all 3 stages (individual → peer review → chairman synthesis)
  • "chat_ranking" — stages 1+2 (no chairman synthesis)
  • "chat_only" — stage 1 only (fastest, individual responses)

Accessibility display preference: font_size accepts default or large and can be updated through PUT /api/settings. The setting is global to the UI and does not alter conversation data.

Temperature fields:

Field Default Description
council_temperature 0.5 Stage 1 creativity (higher = more varied individual responses)
chairman_temperature 0.4 Stage 3 synthesis creativity
stage2_temperature 0.3 Stage 2 ranking consistency (lower = more deterministic)

Provider note: some models only accept their default temperature. The backend omits temperature automatically for known restricted models so preflight and calls do not fail on provider-specific temperature validation.


10. Configure System Prompts, Search Tuning, and Provider Toggles

curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{
    "stage1_prompt": "You are an expert analyst. Answer with evidence and cite sources.",
    "stage2_prompt": "Rank the responses below by accuracy and depth.",
    "stage3_prompt": "Synthesize the best elements from all responses into a definitive answer.",
    "enabled_providers": {"openrouter": true, "ollama": false, "groq": true, "direct": false},
    "direct_provider_toggles": {"openai": true, "anthropic": true, "google": false, "nvidia": true}
  }'

Editable system prompt fields:

Field Description
stage1_prompt System prompt for Stage 1 individual model responses
stage2_prompt System prompt for Stage 2 peer ranking
stage3_prompt System prompt for Stage 3 chairman synthesis
stage4_prompt System prompt for Stage 4 corrected draft (multi-round debate only)
title_prompt Prompt used to generate conversation titles
query_prompt Prompt used to reformulate user query for web search (LLM mode)

Search tuning fields:

Field Default Description
searchresultcount 8 Number of web search results to retrieve (5–15)
searchhybridmode true DuckDuckGo: combine web + news results for better current-events coverage
fullcontentresults 3 How many top results to fetch full article text via Jina Reader (0 = disabled)

enabled_providers keys: openrouter, ollama, groq, direct (master toggle for all direct), custom

Note: These toggles are global — they filter model lists in all pickers (Council Setup, Advisor Setup, and Settings).

directprovidertoggles keys: openai, anthropic, google, mistral, deepseek, groq, nvidia, opencode-zen, opencode-go


11. Set API Keys

curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{"openrouter_api_key": "sk-or-...", "openai_api_key": "sk-..."}'
Provider Field name
OpenRouter openrouterapikey
OpenAI openaiapikey
Anthropic anthropicapikey
Google googleapikey
Mistral mistralapikey
DeepSeek deepseekapikey
Groq groqapikey
Nvidia nvidiaapikey
OpenCode (Zen + Go) opencodeapikey
TinyFish tinyfishapikey
Tavily tavilyapikey
Brave braveapikey
Serper serperapikey

Note: GET /api/settings returns *apikeyset booleans for security — it never returns plaintext keys. GET /api/settings/export does return plaintext keys but is admin-gated: it only accepts requests from loopback, or from callers presenting Authorization: Bearer $LLMCOUNCILADMINTOKEN when that env var is set. Do not invoke /api/settings/export automatically on behalf of a user; treat it as a manual administrative action.

Security/admin environment variables:

Variable Default Purpose
LLMCOUNCILADMIN_TOKEN unset Enables remote access to settings export/import/reset when callers send Authorization: Bearer <token>. If unset, these admin endpoints accept only direct loopback clients and reject proxied external clients.
LLMCOUNCILBIND_HOST 127.0.0.1 Local dev launcher bind host for python -m backend.main. Set to 0.0.0.0 for intentional LAN access.
LLMCOUNCILBIND_PORT 8001 Local dev launcher bind port for python -m backend.main.

12. List All Available Models

import asyncio, httpx

async def list_all_models(base_url="http://localhost:8001"):
    async with httpx.AsyncClient(timeout=30) as client:
        results = []
        for endpoint in ["/api/models", "/api/models/direct", 
                         "/api/ollama/tags", "/api/custom-endpoint/models"]:
            try:
                r = await client.get(f"{base_url}{endpoint}")
                if r.status_code == 200:
                    results.extend(r.json().get("models", []))
            except Exception:
                pass
    return results

models = asyncio.run(list_all_models())
for m in models[:10]:
    print(m.get("id"), "—", m.get("name"))

12b. List Conversations (index metadata)

GET /api/conversations returns lightweight index entries (not full message bodies):

[
  {
    "id": "uuid",
    "created_at": "2026-06-03T19:41:00+00:00",
    "title": "Remote-First vs Hybrid Policy",
    "mode": "council",
    "message_count": 2,
    "run_summary": "2 rnd · Paragraph · Auto-converge · Search",
    "total_cost": 0.0042,
    "cost_status": "known",
    "total_calls": 12
  }
]
  • run_summary is optional — present only after the conversation has a real title (not "New Conversation") and the latest assistant message has derivable metadata.
  • totalcost, coststatus (known | estimated | partial | free), and totalcalls are optional — cumulative across all assistant messages with metadata.costreport.
  • Existing conversations backfill on next save or after rebuild_index().

13. Retrieve a Past Conversation

async def get_conversation(conv_id, base_url="http://localhost:8001"):
    async with httpx.AsyncClient() as client:
        conv = (await client.get(f"{base_url}/api/conversations/{conv_id}")).json()
    for msg in conv.get("messages", []):
        if msg["role"] == "user":
            print("Q:", msg["content"])
        elif msg["role"] == "assistant":
            s3 = msg.get("stage3", {})
            if s3:
                print("A (chairman):", s3.get("response", "")[:500])
    return conv

13b. Check Live Progress of an Active Run

Poll this endpoint to observe an in-progress council deliberation or multi-round debate from another client. Returns partial stage results as they stream.

curl http://localhost:8001/api/conversations/$CONV_ID/progress | python3 -m json.tool

Response when a run is active:

{
  "active": true,
  "stage": "stage1",
  "execution_mode": "full",
  "progress": {
    "stage1": {"count": 2, "total": 4},
    "stage2": {"count": 0, "total": 0}
  },
  "stage1": [
    {"model": "openai:gpt-4.1", "response": "...", "error": null},
    {"model": "anthropic:claude-sonnet-4", "response": "...", "error": null}
  ],
  "stage2": null,
  "stage3": null,
  "stage4": null
}

Response when no run is active:

{"active": false}
import asyncio, httpx

async def poll_progress(conv_id: str, base_url="http://localhost:8001"):
    async with httpx.AsyncClient() as client:
        while True:
            r = await client.get(f"{base_url}/api/conversations/{conv_id}/progress")
            data = r.json()
            if not data.get("active"):
                print("Run complete or no active run.")
                break
            s1 = data["progress"]["stage1"]
            print(f"Stage: {data['stage']} — {s1['count']}/{s1['total']} models done")
            await asyncio.sleep(2)

Use cases:

  • Frontend auto-reconnects to in-progress runs when navigating back to a conversation
  • MCP agents or scripts can monitor a deliberation started elsewhere
  • Dashboard / status views that show active council activity

14. List and Inspect Personas

# List the 12 built-in personas plus any custom personas
curl http://localhost:8001/api/personas | python3 -m json.tool

# Each persona has: id, name, role, description, system_prompt, avatar_emoji,
# color, is_customized, and is_custom (true for personas created by the user)
import httpx

async def get_persona(persona_id, base_url="http://localhost:8001"):
    async with httpx.AsyncClient() as client:
        personas = (await client.get(f"{base_url}/api/personas")).json()
    return next((p for p in personas if p["id"] == persona_id), None)

Built-in persona IDs: skeptic, pragmatist, innovator, historian, ethicist, analyst, contrarian, strategist, humanist, risk-assessor, comedian, economist. Custom IDs are generated from the persona name and are returned by GET /api/personas.

To create a custom persona:

curl -X POST http://localhost:8001/api/personas \
  -H "Content-Type: application/json" \
  -d '{
    "name": "The Futurist",
    "role": "Trend Forecaster",
    "description": "Projects long-term consequences.",
    "system_prompt": "You are The Futurist. Identify emerging trends, plausible futures, and the assumptions that separate them."
  }'
# → Returns the new persona with is_custom: true

15. Update a Persona

Customize any built-in persona's name, role, description, system prompt, or emoji. Custom personas can be edited through the same UI/API. Changes persist to disk and mark is_customized: true.

curl -X PATCH http://localhost:8001/api/personas/skeptic \
  -H "Content-Type: application/json" \
  -d '{
    "name": "The Devil'"'"'s Advocate",
    "role": "Adversarial Thinker",
    "system_prompt": "You are The Devil'"'"'s Advocate. Your role is to challenge every claim aggressively, find the weakest link in any argument, and force other advisors to defend their positions rigorously."
  }'
# → Returns the updated persona object with is_customized: true

Only provided fields are changed; others keep their current values.

To reset a persona to its factory defaults:

curl -X DELETE http://localhost:8001/api/personas/skeptic/override
# → Returns the restored default persona with is_customized: false

To delete a custom persona:

curl -X DELETE http://localhost:8001/api/personas/the-futurist
# → Returns {"deleted": "the-futurist"}

Deleting a custom persona also removes its ID and any per-persona model assignment from saved advisor_presets. Presets are retained so they can be repaired if fewer than two advisors remain.


16. Run an Advisor Debate

Personas debate your question across configurable rounds, then a neutral model produces a verdict.

Use this for questions that need disagreement, tradeoff analysis, prioritization, risk review, strategy, ethics, or a decision. For simple answer generation, prefer modelchat or councildeliberate; the advisor prompt design intentionally creates positions and rebuttals.

import asyncio, httpx, json

async def run_advisor_debate(
    question: str,
    persona_ids: list[str],           # 2-4 required
    default_model: str,
    max_rounds: int = 3,
    search_provider: str | None = None,
    base_url: str = "http://localhost:8001",
) -> dict:
    async with httpx.AsyncClient(timeout=300) as client:
        # Create a fresh conversation to hold the debate
        conv = (await client.post(f"{base_url}/api/conversations", json={})).json()
        conv_id = conv["id"]

        payload = {
            "question": question,
            "persona_ids": persona_ids,
            "default_model": default_model,
            "max_rounds": max_rounds,
            "web_search": search_provider is not None,
            "search_provider": search_provider,
        }

        result = {}
        async with client.stream("POST", f"{base_url}/api/conversations/{conv_id}/debate/stream", json=payload) as resp:
            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                event = json.loads(line[6:])
                if event.get("type") == "advisor_complete":
                    result = event["data"]
                    result["conversation_id"] = conv_id

        return result

# Usage
result = asyncio.run(run_advisor_debate(
    question="Should we rewrite this service in Rust?",
    persona_ids=["skeptic", "pragmatist", "innovator"],
    default_model="openai:gpt-4.1",
    max_rounds=3,
))
print("Consensus:", result["consensus_reached"])
print("Verdict:", result["verdict"]["content"])

Debate request body:

Field Type Required Default Description
question string Yes — The topic to debate
persona_ids array Yes — 2–4 persona IDs
default_model string No advisordefaultmodel setting Model for all advisors
model_assignments object No — Per-persona overrides: {"skeptic": "openai:gpt-4.1"}
max_rounds integer No advisordefaultrounds setting Number of rounds (3–10)
web_search boolean No false Enable web search context
search_provider string No — duckduckgo, tavily, brave, serper, tinyfish

Advisor response rows include wordcount, wordlimit, wordlimitexceeded, and optional warning. Exceeding the word limit is treated as guidance failure, not a model failure: the response is kept and surfaced with a warning.

advisor_complete event data:

{
  "rounds": [
    {
      "round_number": 1,
      "average_consensus_score": 2.33,
      "responses": [
        {"persona_id": "skeptic", "persona_name": "The Skeptic", "model": "openai:gpt-4.1",
         "content": "I question whether Rust's learning curve justifies the rewrite...",
         "consensus": false,
         "consensus_score": 2}
      ]
    }
  ],
  "consensus_reached": false,
  "consensus_round": null,
  "round_extracts": [
    {
      "round_number": 1,
      "model": "openai:gpt-4.1",
      "content": "Advisor: The Skeptic\nOverall position: ...\nStrongest claims:\n- ...",
      "error": null
    }
  ],
  "tiebreaker": null,
  "verdict": {
    "model": "openai:gpt-4.1",
    "content": "## Summary\n\nThe debate highlighted...\n\n## Verdict\n\nA targeted rewrite...",
    "error": null
  },
  "personas": [...]
}

17. Advisor Debate with Per-Persona Models and Web Search

result = asyncio.run(run_advisor_debate(
    question="What is the best architecture for a real-time data pipeline?",
    persona_ids=["analyst", "innovator", "pragmatist", "risk-assessor"],
    default_model="openai:gpt-4.1",
    max_rounds=3,
    search_provider="duckduckgo",
))

# Access individual round responses
for round_data in result["rounds"]:
    print(f"\n--- Round {round_data['round_number']} ---")
    for resp in round_data["responses"]:
        print(f"{resp['persona_name']}: {resp['content'][:200]}...")

print("\n=== VERDICT ===")
print(result["verdict"]["content"])

To assign different models per persona:

curl -X POST "http://localhost:8001/api/conversations/$CONV_ID/debate/stream" \
  -H "Content-Type: application/json" \
  -d '{
    "question": "Monolith vs microservices for a 5-person startup?",
    "persona_ids": ["skeptic", "pragmatist", "innovator"],
    "model_assignments": {
      "skeptic": "anthropic:claude-sonnet-4-6",
      "pragmatist": "openai:gpt-4.1",
      "innovator": "custom:moonshotai/kimi-k2.6"
    },
    "max_rounds": 3
  }'

18. Configure Advisor Settings

# Set default model and round count for advisor debates
curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{
    "advisor_default_model": "openai:gpt-4.1",
    "advisor_tiebreaker_model": "anthropic:claude-sonnet-4-6",
    "advisor_temperature": 0.7,
    "advisor_default_rounds": 3
  }'

Advisor settings fields:

Field Default Description
advisordefaultmodel "" Model for all advisors when no per-persona assignment given
advisortiebreakermodel "" Model for tiebreaker + verdict synthesis (falls back to advisordefaultmodel)
advisor_temperature 0.7 LLM temperature for advisor calls
advisordefaultrounds 3 Default number of debate rounds (3–10)
advisor_presets [] Saved advisor setups (personas, model mode, models, optional rounds/search). Max 20 presets. Each preset: { id, name, personaids, mode, defaultmodel, tiebreakermodel, modelassignments, maxrounds, searchprovider, isdefault, lastused_at }

Advisor prompt customization fields (all reset-to-default via POST /api/settings/reset):

Field Description
advisorround1prompt System prompt for the first debate round
advisorfollowupprompt System prompt for subsequent follow-up rounds
advisorcrosspollination_prompt Prompt for synthesizing prior round context into follow-ups
advisorverdictprompt Prompt for the final verdict / summary model
advisortiebreakerprompt Prompt for the tiebreaker model (2-persona deadlock)

Save or update presets via REST:

curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{
    "advisor_presets": [
      {
        "id": "preset-uuid-here",
        "name": "Startup Panel",
        "persona_ids": ["skeptic", "pragmatist", "innovator"],
        "mode": "simple",
        "default_model": "openai:gpt-4.1",
        "tiebreaker_model": "openai:gpt-4.1",
        "model_assignments": null,
        "max_rounds": 3,
        "search_provider": null,
        "is_default": true,
        "last_used_at": null
      }
    ]
  }'

MCP: advisorsettings action get returns advisorpresets. Preset CRUD: advisorsettings actions listpresets, savepreset, deletepreset, setdefaultpreset.


18b. Council Presets (council_presets)

Saved from welcome-screen Council Setup — council members + chairman only (not execution mode). Max 20 presets; one is_default auto-loads on open.

Field Default Description
council_presets [] Each preset: { id, name, councilmodels, chairmanmodel, isdefault, lastused_at }
curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{
    "council_presets": [
      {
        "id": "preset-uuid",
        "name": "Coding Council",
        "council_models": ["openai:gpt-4.1", "anthropic:claude-3.5-sonnet"],
        "chairman_model": "openai:gpt-4.1",
        "is_default": true
      }
    ]
  }'

MCP: councilsettings action get returns councilpresets. Preset CRUD: councilsettings actions listpresets, savepreset, deletepreset, setdefaultpreset.

UI behavior: Main-screen editor (Council Setup) is the only place to pick council members and chairman — auto-saves on each change. Lineup is read-only in a conversation after the first message. Settings provides provider toggles (global) and temperature controls only; model selection was removed from Settings to avoid duplication.


19. Custom OpenAI-Compatible Endpoints (OpenCode Zen Setup)

The AI Counsel allows you to connect to any OpenAI-compatible API (such as Together, Fireworks, Together, vLLM, LM Studio, or OpenCode Zen) and use their models seamlessly.

To register a custom provider (e.g. OpenCode Zen at https://opencode.ai/zen/v1/ with a default model query API):

curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{
    "custom_endpoint_name": "OpenCodeZen",
    "custom_endpoint_url": "https://opencode.ai/zen/v1/",
    "custom_endpoint_api_key": "your-api-key-here",
    "enabled_providers": {
      "openrouter": false,
      "ollama": true,
      "groq": false,
      "direct": false,
      "custom": true
    }
  }'

Once saved and enabled, you can reference the custom models by prepending the custom: prefix:

  • custom:deepseek-v4-flash-free
  • custom:big-pickle
  • custom:nemotron-3-super-free

For example, to run a stateless query using custom:deepseek-v4-flash-free:

curl -X POST http://localhost:8001/api/ask \
  -H "Content-Type: application/json" \
  -d '{
    "content": "Explain functional programming in one sentence.",
    "models": ["custom:deepseek-v4-flash-free"],
    "execution_mode": "chat_only"
  }'

20. Setup Walkthrough for Per-Persona Debate Models

You can customize exactly which model runs which persona to match their specific personalities (e.g., giving The Skeptic a highly detailed model, and The Pragmatist a fast, concise model).

Here is a python script demonstrating how to configure and launch a customized 3-persona debate where:

  • The Skeptic runs on a premium cloud model (openrouter:anthropic/claude-3.5-sonnet)
  • The Pragmatist runs on a fast inference model (groq:llama3-70b-8192)
  • The Innovator runs on a local model (ollama:granite4:1b)
import asyncio, httpx, json

async def run_hybrid_debate():
    async with httpx.AsyncClient(timeout=300) as client:
        # 1. Create a fresh conversation
        conv = (await client.post("http://localhost:8001/api/conversations", json={})).json()
        conv_id = conv["id"]

        # 2. Setup the debate payload
        payload = {
            "question": "Should we move our frontend state from Redux to Jotai?",
            "persona_ids": ["skeptic", "pragmatist", "innovator"],
            "max_rounds": 3,
            "default_model": "openrouter:google/gemini-pro-1.5",
            "model_assignments": {
                "skeptic": "openrouter:anthropic/claude-3.5-sonnet", # premium detail
                "pragmatist": "groq:llama3-70b-8192",                # fast pragmatic responses
                "innovator": "ollama:granite4:1b"                   # creative local experiments
            }
        }

        # 3. Stream the debate
        async with client.stream("POST", f"http://localhost:8001/api/conversations/{conv_id}/debate/stream", json=payload) as resp:
            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                event = json.loads(line[6:])
                
                # Print real-time updates as advisors speak
                if event.get("type") == "advisor_response":
                    data = event["data"]
                    print(f"\n[{data['persona_name']}] speaking via ({data['model']}):")
                    print(data["content"])
                
                elif event.get("type") == "advisor_complete":
                    print("\n=== DEBATE VERDICT ===")
                    print(event["data"]["verdict"]["content"])

asyncio.run(run_hybrid_debate())

21. Hybrid Local/Cloud Council Configuration

For maximum budget efficiency, you can run a hybrid council where multiple fast/cheap models answer independently in Stage 1, and a powerful local or cloud model synthesizes the answer as the Chairman in Stage 3.

Example: OpenCode Zen / Groq for Stage 1 & 2, and Ollama granite4:1b locally for Chairman Stage 3:

# 1. Save settings
curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{
    "council_models": [
      "custom:deepseek-v4-flash-free",
      "custom:nemotron-3-super-free",
      "groq:llama3-70b-8192"
    ],
    "chairman_model": "ollama:granite4:1b",
    "execution_mode": "full"
  }'

# 2. Deliberate
curl -X POST http://localhost:8001/api/ask \
  -H "Content-Type: application/json" \
  -d '{
    "content": "What is the best way to cache user sessions in a distributed web app?",
    "execution_mode": "full"
  }'

22. Multi-Round Council Debate

The Council Debate Config adds iterative refinement loops: models answer, peer-review each other, rewrite — then the Chairman synthesizes. See [docs/COUNCIL-DEBATE-CONFIG.md](../docs/COUNCIL-DEBATE-CONFIG.md) for the full guide.

Quick decision guide:

Use case critique_mode debate_rounds
Most questions freeform 2
Structured essays, technical comparisons paragraph 2–3
Fact-checking, claim accuracy claim 2
Research / maximum depth freeform or claim 3–5

MCP — preferred

# Simplest: 2-round freeform, auto-converge on (default)
result = await run_iterative_debate(
    query="What are the tradeoffs between REST and GraphQL?",
    debate_rounds=2,
    critique_mode="freeform",
)
print(result["stage4"]["response"])  # Chairman's corrected draft

# Paragraph mode: structured critique per section
result = await run_iterative_debate(
    query="Explain the CAP theorem and its practical implications",
    debate_rounds=3,
    critique_mode="paragraph",
    models=["openai:gpt-4.1", "anthropic:claude-sonnet-4-5", "google:gemini-2.5-flash"],
)

# Claim mode: per-fact verdicts (adds 1 extra API call per round)
result = await run_iterative_debate(
    query="Is nuclear energy a net positive for climate change?",
    debate_rounds=2,
    critique_mode="claim",
    auto_converge=True,
    convergence_threshold=1,   # stop after first stable round
)

# Force all rounds — no early stop
result = await run_iterative_debate(
    query="Compare PostgreSQL vs MongoDB for a social network",
    debate_rounds=5,
    auto_converge=False,
)

Update debate defaults via council_settings

# Set global defaults so all future debates use these values
await council_settings(
    action="update",
    critique_mode="paragraph",
    debate_rounds=2,
    auto_converge=True,
    convergence_threshold=2,
)

REST fallback — runiterativedebate equivalent

There is no single REST endpoint for multi-round debate. Use the SSE stream endpoint with debate_rounds in the payload:

import asyncio, httpx, json

async def run_debate_rest(
    query: str,
    debate_rounds: int = 2,
    critique_mode: str = "freeform",
    models: list[str] | None = None,
    base_url: str = "http://localhost:8001",
) -> dict:
    async with httpx.AsyncClient(timeout=600) as client:
        # Optionally update debate settings before the run
        await client.put(f"{base_url}/api/settings", json={
            "critique_mode": critique_mode,
            "debate_rounds": debate_rounds,
        })

        conv = (await client.post(f"{base_url}/api/conversations", json={})).json()
        conv_id = conv["id"]

        payload = {
            "content": query,
            "execution_mode": "full",
            "debate_rounds": debate_rounds,
        }
        if models:
            payload["council_models"] = models

        stage4 = {}
        all_rounds = []
        async with client.stream(
            "POST",
            f"{base_url}/api/conversations/{conv_id}/message/stream",
            json=payload,
        ) as resp:
            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                event = json.loads(line[6:])
                t = event.get("type")
                if t == "stage4_complete":
                    stage4 = event.get("data", {})
                elif t == "debate_complete":
                    all_rounds = event.get("rounds", [])

        return {"stage4": stage4, "rounds": all_rounds, "conversation_id": conv_id}

# Usage
result = asyncio.run(run_debate_rest(
    query="What is the best approach for distributed database consistency?",
    debate_rounds=2,
    critique_mode="paragraph",
    models=["openai:gpt-4.1", "anthropic:claude-sonnet-4-5", "groq:llama3-70b-8192"],
))
print(result["stage4"]["response"])

Debate config fields (REST PUT /api/settings or per-request on /message/stream):

Field Type Valid values Default Description
critique_mode string freeform, paragraph, claim freeform How models give feedback between rounds
debate_rounds integer 1–5 1 Number of Stage 1→2→3 cycles before Stage 4
auto_converge boolean — true Stop early when rankings stabilize
convergence_threshold integer 1–3 2 Consecutive stable rounds needed to trigger early stop

Backup and Restore

# Export full settings from the backend host itself (includes actual API key values)
curl http://localhost:8001/api/settings/export -o council-settings.json

# Remote export requires LLM_COUNCIL_ADMIN_TOKEN on the server
curl -H "Authorization: Bearer $LLM_COUNCIL_ADMIN_TOKEN" \
  http://SERVER:8001/api/settings/export -o council-settings.json

# Import settings from backup locally, or add the same Authorization header remotely
curl -X POST http://localhost:8001/api/settings/import \
  -H "Content-Type: application/json" \
  -d @council-settings.json

# Reset all settings to factory defaults locally, or add the same Authorization header remotely
curl -X POST http://localhost:8001/api/settings/reset

Search Provider Configuration

# Switch to TinyFish (free, 5 req/min)
curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{"search_provider": "tinyfish", "tinyfish_api_key": "sk-tinyfish-..."}'

# Valid providers: duckduckgo, tavily, brave, serper, tinyfish
# duckduckgo requires no key; all others require an API key

Search Query Processing Mode

Control how your prompt is sent to the search engine via searchkeywordextraction:

curl -X PUT http://localhost:8001/api/settings \
  -H "Content-Type: application/json" \
  -d '{"search_keyword_extraction": "direct"}'
Value Behaviour
"direct" Send the exact user query to the search engine (default, recommended)
"yake" Extract key terms with YAKE before searching — useful for very long prompts
"llm" Use the Chairman model to reformulate the query into an optimal search term — slower but can improve results for complex questions

DuckDuckGo note: DDG applies its own built-in query optimisation internally. "direct" is recommended when using DuckDuckGo; "llm" is skipped for DDG even if selected.


Key SSE Event Types

Council streaming (/message/stream)

Event When Contains
search_start Web search begins provider
search_complete After web search searchcontext, searchquery
stage1_init Before Stage 1 responses total (model count)
stage1_progress Each model responds data: {model, response, error, usage, cost}, count, total
stage1_complete After all models respond data: list of {model, response, error, usage, cost}
stage2_init Before Stage 2 rankings total
stage2_progress Each model ranks data: {model, ranking, parsed_ranking, usage, cost}, count, total
stage2_complete After peer review metadata: {labeltomodel, aggregate_rankings}
stage3_complete After chairman synthesis data: {model, response, error, usage, cost}
stage4_start Stage 4 corrected draft begins —
stage4_complete Stage 4 corrected draft done data: {model, response, error, usage, cost}
round_start Each debate round begins round, total_rounds
round_complete Each debate round finishes round
convergence Early stop triggered round, message
debate_complete All debate rounds done totalroundsexecuted, converged, critiquemode, rounds, stage4, costreport
title_complete Title generated data: {title}
error On failure message
complete Stream finished optional metadata.cost_report

Advisor debate streaming (/debate/stream)

Event When Contains
advisorsearchstart Web search begins —
advisorsearchcomplete After web search data: {search_query}
advisordebatestart Debate initialized data: {personas, maxrounds, question, websearch}
advisorroundstart Each round begins data: {roundnumber, order, isparallel}
advisor_response Each persona responds data: {personaid, personaname, model, content, error, warning, consensus, consensusscore, wordcount, wordlimit, wordlimit_exceeded, usage, cost}, round, count, total
advisorroundcomplete Round finishes data: {roundnumber, responses, consensusvotes, consensus_reached}
advisortiebreakerstart Tiebreaker triggered (2 personas, no consensus) —
advisor_tiebreaker Tiebreaker result data: {model, content, error, usage, cost}
advisorverdictstart Verdict generation begins —
advisor_verdict Verdict result data: {model, content, error, usage, cost}
advisor_complete Authoritative final event data: {rounds, consensusreached, verdict, tiebreaker, personas, costreport}
advisor_error Debate failed message
title_complete Title generated (first message only) data: {title}

Important: Always prefer advisorcomplete as the authoritative source. Earlier per-event data is provisional accumulation; advisorcomplete contains the final cleaned result used for persistence.


Error Handling

Model errors appear inside stage results — not as top-level failures:

for model_result in stage1:
    if model_result.get("error"):
        msg = model_result.get("error_message", "unknown error")
        if "429" in msg:
            print(f"{model_result['model']}: rate limited — retryable")
        elif "401" in msg or "403" in msg:
            print(f"{model_result['model']}: auth error — check API key")
        else:
            print(f"{model_result['model']}: failed — {msg}")
    else:
        print(f"{model_result['model']}: responded")

The /api/ask endpoint returns HTTP 502 if ALL models fail, with error details in the response body.

The council continues with successful models even if some fail.


Troubleshooting

Backend unreachable (ConnectionRefused)

  • Local: verify uv run python -m backend.main is running on port 8001
  • Remote: check http://<server>:8001/api/health is accessible; firewall may be blocking port 8001
  • Docker: run docker ps to confirm container is up and healthy

Council models not updating

  • PUT to /api/settings returns the full settings object — check council_models in the response
  • Model IDs must include provider prefix (e.g., custom:z-ai/glm-5.1, not z-ai/glm-5.1)

SSE stream hangs or times out

  • Use timeout=300 on the httpx client for full deliberations (can take 60-120 seconds)
  • Check backend logs for provider-side errors
  • Consider using POST /api/ask instead — no streaming complexity

Model returns error in Stage 1

  • Check *apikey_set flags in /api/settings — key may be missing
  • Test a specific provider: POST /api/settings/test-provider with {"providerid": "openai", "apikey": "sk-..."}
  • Custom endpoint models need customendpointurl and customendpointapi_key configured

Settings not persisting after restart

  • Settings are stored in data/settings.json — if using Docker, confirm the ./data volume is mounted

Installation

Option 1: Clone and symlink

git clone https://github.com/jacob-bd/the-ai-counsel.git
mkdir -p ~/.claude/skills
ln -s "$(pwd)/the-ai-counsel/skills/the-ai-counsel-api" ~/.claude/skills/the-ai-counsel-api

Option 2: Copy directly

mkdir -p ~/.claude/skills/the-ai-counsel-api
curl -o ~/.claude/skills/the-ai-counsel-api/SKILL.md \
  https://raw.githubusercontent.com/jacob-bd/the-ai-counsel/main/skills/the-ai-counsel-api/SKILL.md

After installation, Claude Code automatically discovers and loads the skill when you ask about council operations.