MindAttic.Legion
Multi-LLM consensus engine for .NET 10. Turn a panel of frontier models — Claude, ChatGPT, Gemini, DeepSeek, and ten more — into a single trustworthy answer with quorum, reasoning, and confidence.
MindAttic.Legion
Multi-LLM consensus engine for .NET 10. Turn a panel of frontier models — Claude, ChatGPT, Gemini, DeepSeek, and ten more — into a single trustworthy answer with quorum, reasoning, and confidence. Vote, decide, score, poll, generate, or persona-wear. One panel for the calls you can't afford to get wrong.
One LLM is one opinion. When a contradiction, a misclassification, or a bad route is expensive, you don't want a single model that bluffs — you want a panel that votes. Legion is the panel: unified transport across fourteen provider connections, a voting layer with quorum and dissent, tiered model selection that survives version drift, automatic failover when a provider blips, a 1024-persona library, and a CLI (legion.exe) that lets shell scripts, CI jobs, and other coding agents call the panel directly.
Portable: Legion has no dependency on any specific MindAttic project. Drop it into a csproj, register it via DI, hand it your API keys (or point it at the shared MindAttic keyring), and you have the panel.
See also: docs/BIBLE.md (architecture canon and Laws), docs/AMENDMENTS.md (change log), docs/USER_STORIES.md (test-cited stories).
Why Legion
A single LLM is a single opinion. When the cost of a wrong answer is real — a story contradiction, a misclassified record, a bad route — you want a panel that votes, not one model that bluffs.
Legion is the panel:
- Multi-provider transport — one
LegionClienttalks to fourteen provider connections: Claude via a direct Anthropic API key (claude-api), Claude via the Claude Code CLI's own OAuth session (claude-team), ChatGPT, Gemini, DeepSeek, Mistral, xAI/Grok, Groq, Together AI, OpenRouter, Fireworks AI, Cohere, Kimi (Moonshot AI), and Perplexity — plus an explicit-URL escape hatch for self-hosted OpenAI-compatible endpoints (Ollama, vLLM, RunPod, …). - Voting — call every active provider in parallel, tally their answers, return the consensus with reasoning + dissent.
- Decision-making —
DecideAsync(question, options)picks one option from a fixed list with confidence. - Scoring — multi-dimensional rubric evaluation (1–10 per dimension), aggregate scores, weakest-dimension feedback, ready-to-inject improvement directives.
- Personas — every voter can wear a persona (a markdown system prompt). Use the bundled 1024-persona library, build a panel of N unique voices, or wrap a fictional character's psychology to vote as them.
- Psychometric profiles — score the whole persona library on five instruments (OCEAN/Big Five, HEXACO, MBTI-style, Enneagram-style, DISC-style), persisted as one faithful JSON file per persona. The model only answers items in-character; scoring is deterministic in code. Use the profiles to build trait-diverse panels and to segment a vote by composition. See Psychometric persona profiles.
- Tiered model selection — every provider exposes a Low / Medium / High / Higher / Highest tier. The five providers Legion explicitly maps (
claude-api,claude-team,openai,gemini,deepseek) resolve to a concrete model per tier; every other provider falls back to its singleDefaultModelat any tier. Pick the tier that fits the work: Low for bulk polls, Medium for creative generation, High for architectural decisions. The catalog hides specific model versions behind tier names so a model-id rotation doesn't break callers. - Autonomous architectural decisions —
legion.exe askis purpose-built for the loop where another coding CLI (Claude Code, Codex) blocks on a user prompt: an outer monitor pipes the question toask, the panel deliberates on the High tier, and the bare answer flows back to the blocked CLI. Architect-framed voters, auto-pullsCLAUDE.md/README/git as context, default panel is a four-provider trust list (claude-api,openai,gemini,deepseek) with automatic refill on outages. - Bulk distribution sampling —
legion.exe pollround-robins N voters across the trusted four at a chosen tier (Low by default), reports a count-sorted distribution + plurality winner. The cheap fast tool for "how does the panel split on this?" - Bulk creative generation —
legion.exe generatefans out one batched call per provider asking for that provider's share of N items, deduplicates across the merge, and emits newline-separated results to stdout. Built forlegion generate "100 hero-vibe names" | head -25 > names.txt. - On-demand connectivity probe —
legion.exe tiersprobes every (trusted-provider, tier) cell with a tiny prompt and prints a matrix. Use it before a critical session to confirm the panel is healthy. - Per-project panels via
legion.json— drop alegion.jsonat a project's root to declare that project's voter list, judge, model overrides, API keys, and concurrency cap without touching code. Currently consumed by the CLI'svotesubcommand (walks up from cwd looking for the file); library consumers apply it explicitly viaLegionConfig.LoadFromDirectory()?.ApplyTo(config). - Direct transport for non-voting calls —
LegionClientalso does multi-turn chat, Anthropic prompt caching, OpenAI embeddings, OpenAI (DALL·E) image generation, Claude document input (native PDF, extracted DOCX/EPUB/text), and a fallback chain that tries providers in order until one answers. - CLI —
legion.exe status,providers,models,personas,panel,health,ping,vote,ask,poll,generate,tiers,psychometrics— same engine, no .NET app required.
Install
The library is published as a NuGet package (MindAttic.Legion, MIT-licensed) and also consumable as a project reference from a sibling repo:
<ItemGroup>
<ProjectReference Include="..\..\MindAttic.Legion\MindAttic.Legion\MindAttic.Legion.csproj" />
</ItemGroup>
Target framework: net10.0. The package depends on MindAttic.Vault and Microsoft.Extensions.{DependencyInjection.Abstractions, Http, Logging.Abstractions} only — no dependency on any specific MindAttic application (see docs/BIBLE.md#LEG-LAW-1).
Quick start
using MindAttic.Legion;
using MindAttic.Legion.Providers;
using Microsoft.Extensions.DependencyInjection;
// 1) Configure
var services = new ServiceCollection();
services.AddLogging();
services.AddLLMVoting(new VotingConfiguration
{
ApiKeys =
{
["claude-api"] = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") ?? "",
["openai"] = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "",
},
JudgeProviderId = "claude-api",
});
var sp = services.BuildServiceProvider();
// 2) Vote
var voting = sp.GetRequiredService<LlmVotingService>();
var result = await voting.VoteAsync(
question: "Should Kyle take the contract?",
context : "Contract details: ...",
quorum : Quorum.SimpleMajority);
Console.WriteLine($"Consensus: {result.Consensus} ({result.ConsensusStrength:P0})");
Console.WriteLine(result.NarrativeSummary);
AddLLMVoting (see ServiceCollectionExtensions) registers LlmVotingService, LlmVotingProvider, and a LegionClient that shares the same key resolution as the voting layer, so a service that injects LegionClient directly sees the same keys as the voting service. Call services.AddLegionClient() instead if you only want the transport (health checks, direct provider calls) without the voting machinery.
Zero-config also works: new VotingConfiguration() with empty ApiKeys resolves every key from the shared MindAttic credential store (UseSharedCredentials defaults to true).
Voting modes
VoteAsync — open-ended consensus
The simplest call: ask a question, every voter writes a free-form answer, a judge LLM synthesizes the consensus.
var r = await voting.VoteAsync("What weapon does Kyle carry?", canonContext, Quorum.Plurality);
// r.Consensus == "Silence (a corundum-edged tantō)"
VoteAsync with Options — choice vote
When you want a vote among fixed options (much cheaper to tally — exact-match wins).
var req = new VoteRequest
{
Question = "Severity of this canon contradiction?",
Context = chapterPlusCanon,
Options = new() { "low", "medium", "high" },
};
var r = await voting.VoteAsync(req, Quorum.SimpleMajority);
// r.Consensus is one of "low" | "medium" | "high"
DecideAsync — judgment call with reasoning
Sugar over choice voting. Use this when an automated workflow has to pick one option and move on (route a request, fill in a field, resolve a tie). Returns a DecisionResult with Choice, Reasoning, Confidence, and QuorumReached.
var d = await voting.DecideAsync(
question: "Which field in this entity record stores Kyle's primary weapon carry location?",
options : new[] { "personality", "equipment", "tags", "story_hooks" },
context : kyleEntityFileJson,
quorum : Quorum.Plurality);
if (d.QuorumReached)
{
Console.WriteLine($"Use field: {d.Choice} ({d.Confidence:P0})");
Console.WriteLine($"Why: {d.Reasoning}");
}
else
{
// Panel was too divided — escalate to a human, or rerun with stricter quorum.
}
DecideAsync is the right entry point any time your code would otherwise have to hard-code a branch or guess. Hand the decision to the panel.
ScoreAsync — multi-dimensional rubric
Rate something across multiple dimensions (1–10 each). Returns aggregate scores, failing dimensions, per-dimension consensus strengths/failures, and synthesized improvement directives.
var req = new ScoredVoteRequest
{
Question = "Score this scene against the rubric.",
Context = sceneText,
Dimensions = new() { "voice", "tension", "specificity", "clichéness" },
FailureThreshold = 6,
};
var r = await voting.ScoreAsync(req);
foreach (var (dim, score) in r.AggregateScores)
Console.WriteLine($" {dim,-15} {score:0.0}");
foreach (var directive in r.ImprovementDirectives)
Console.WriteLine($" → {directive}");
A dimension that every voter omitted has no aggregate entry at all (never a phantom 0.0), so it can't wrongly get flagged as failing or drag the average down. ConsensusStrengths/ConsensusFailures surface flags that at least half the panel raised.
VoteWithPersonasAsync — character / panel voices
Build a panel of unique voices (or a single character's psychology) and have them vote.
// Generic 5-voice panel spread across active providers
var panel = voting.CreatePanel(count: 5, fallbackProviderId: "claude-api");
var r = await voting.VoteWithProfilesAsync(req, Quorum.TwoThirds, panel);
// Or vote as a character
var kylePsychology = File.ReadAllText("kyle-psychology.md");
var kyleVoter = VoterProfile.ForCharacter("Kyle", kylePsychology, "claude-api", apiKey: claudeKey);
var rk = await voting.VoteWithPersonasAsync(
"Would Kyle accept this contract?",
contractContext,
Quorum.Unanimous,
new[] { kyleVoter });
Quorum
Quorum controls how strict the agreement threshold is.
| Value | Threshold | Use when |
|---|---|---|
Plurality |
Any winning answer counts | Cheapest. The vote will return something — even a 1-of-4 answer wins. Good for surfacing all viewpoints. |
SimpleMajority |
> 50% must agree | Default for most decisions. A 2-of-4 tie fails (strict agree*2 > total, not >=). |
TwoThirds |
≥ 66.7% must agree | Computed as agree*3 >= total*2 (exact integer arithmetic), so a 2-of-3 panel clears it. |
Unanimous |
100% must agree | Use for irreversible / canonical actions. |
If quorum isn't reached, result.QuorumReached == false and result.Consensus == "". Your code decides whether to escalate, retry with a different quorum, or accept the plurality answer anyway.
Providers and models
Legion knows how to call 14 provider connections, all through the single LegionClient. Configure them via VotingConfiguration.ApiKeys. A provider is "active" for voting when it has a non-empty key (explicit or from the shared store) and passes the AllowedProviderIds whitelist (see Trust tiers below). GetActiveProviderIds() lists which providers are actually voting.
| Provider id | Vendor | Auth | Default model | Dashboard |
|---|---|---|---|---|
claude-api |
Anthropic | API key (x-api-key / Authorization: Bearer sk-ant-oat...) |
claude-sonnet-5 |
console.anthropic.com |
claude-team |
Anthropic | OAuth via the Claude Code CLI session (no API key) | claude-sonnet-5 |
claude.ai/settings |
openai |
OpenAI | API key | gpt-5.4-mini |
platform.openai.com |
gemini |
API key (x-goog-api-key header) |
gemini-3.5-flash |
aistudio.google.com | |
deepseek |
DeepSeek AI | API key | deepseek-v4-flash |
platform.deepseek.com |
mistral |
Mistral AI | API key | mistral-large-latest |
console.mistral.ai |
xai |
xAI | API key | grok-4.3 |
console.x.ai |
groq |
Groq | API key | llama-3.3-70b-versatile |
console.groq.com |
together |
Together AI | API key | meta-llama/Llama-3-70b-chat-hf |
api.together.xyz |
openrouter |
OpenRouter | API key | meta-llama/llama-3.1-8b-instruct:free |
openrouter.ai |
fireworks |
Fireworks AI | API key | accounts/fireworks/models/llama-v3p1-70b-instruct |
app.fireworks.ai |
cohere |
Cohere | API key | command-r-plus |
dashboard.cohere.com |
kimi |
Moonshot AI | API key | kimi-k3 |
platform.moonshot.cn |
perplexity |
Perplexity AI | API key | sonar |
perplexity.ai/settings/api |
Source of truth: LlmProviderCatalog (metadata, dashboard/keys URLs, per-provider known-model lists) and LegionClient.DefaultModels/Endpoints (wire endpoints and fallback models). Use legion.exe providers from the CLI for the live list, or legion.exe models <provider> for a provider's full known-model catalog.
claude-api and claude-team are the same model family through two different doors: claude-api requires an Anthropic API key and bills against it; claude-team reads and auto-refreshes the OAuth token from the Claude Code CLI's own ~/.claude/.credentials.json, so it authenticates as whatever Claude Code account is already logged in on that machine — no separate key, but it shares that session's rate limit (set "maxConcurrency": 3 in legion.json when a panel includes claude-team alongside an active Claude Code session).
Default model is what each provider falls back to when no model override is supplied and no model field is recorded in providers.json. For tier-aware selection (Low / Medium / High / Higher / Highest), use LlmProviderCatalog.GetTieredModel(providerId, ModelTier) — see Tier system.
To override the model for a specific provider:
config.ModelOverrides["claude-api"] = "claude-opus-4-8";
To restrict voting to a subset:
var r = await voting.VoteAsync(req, quorum, new[] { "claude-api", "openai" });
Self-hosted / local models (Ollama, vLLM, RunPod, …)
Providers outside the catalog aren't voter-panel members, but LegionClient can still call them directly at an explicit URL using the OpenAI-compatible chat-completions shape:
var client = new LegionClient(httpClient);
var reply = await client.CallAsync(
providerId: "local", // any stable id — used only for circuit-breaker tracking
apiKey: "ollama", // any non-empty string for auth-less local servers
model: "llama3.1:8b",
systemPrompt:"You are a helpful assistant.",
userMessage: "Summarize this paragraph...",
endpointUrl: "http://localhost:11434/v1/chat/completions");
Tier system
ModelTier is the Legion abstraction for "the cheap one" / "the strong one" without naming model versions that drift. Only the five providers below have an explicit tier mapping in LlmProviderCatalog — every other provider (mistral, xai, groq, together, openrouter, fireworks, cohere, kimi, perplexity) has no tier table and GetTieredModel simply returns that provider's DefaultModel at any tier:
| Tier | claude-api / claude-team |
openai |
gemini |
deepseek |
|---|---|---|---|---|
Low |
claude-haiku-4-5-20251001 |
gpt-4.1-nano |
gemini-2.5-flash-lite |
deepseek-v4-flash |
Medium |
claude-sonnet-5 |
gpt-5.4-mini |
gemini-2.5-flash |
deepseek-v4-flash |
High |
claude-opus-4-7 |
gpt-5.4 |
gemini-2.5-pro |
deepseek-v4-pro |
Higher |
claude-opus-4-8 |
gpt-5.5 |
gemini-3.1-flash-lite |
deepseek-v4-pro |
Highest |
claude-fable-5 |
gpt-5.6-sol |
gemini-3.5-flash |
deepseek-v4-pro |
When a tier isn't directly mapped for a provider, GetTieredModel walks down the ladder (Highest → Higher → ... → Low) and returns the closest available model — so asking for Highest against a 3-tier provider gives you High, not null. Asking for a tier that's lower than every entry walks back up for symmetry. When the provider has no tier table at all, it returns the provider's DefaultModel unconditionally.
// Pick the strong reasoning model for an architectural decision:
var arch = LlmProviderCatalog.GetTieredModel("claude-api", ModelTier.High);
// → "claude-opus-4-7"
// Pick the cheap one for a 100-voter poll:
var bulk = LlmProviderCatalog.GetTieredModel("claude-api", ModelTier.Low);
// → "claude-haiku-4-5-20251001"
// A provider with no tier table — always the default, regardless of tier:
var m = LlmProviderCatalog.GetTieredModel("mistral", ModelTier.Highest);
// → "mistral-large-latest"
The CLI commands embed sensible defaults: legion ask defaults to High (architecture wants flagship reasoning), legion poll defaults to Low (bulk distribution wants cheap), legion generate defaults to Medium (creative balance), legion psychometrics score defaults to High (Opus-class administering lens). All accept --tier <t> to override.
To pin a whole panel to a tier inside the .NET API:
config.ModelOverrides = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["claude-api"] = LlmProviderCatalog.GetTieredModel("claude-api", ModelTier.High)!,
["openai"] = LlmProviderCatalog.GetTieredModel("openai", ModelTier.High)!,
["gemini"] = LlmProviderCatalog.GetTieredModel("gemini", ModelTier.High)!,
["deepseek"] = LlmProviderCatalog.GetTieredModel("deepseek", ModelTier.High)!,
};
AskCommand.BuildTierModelOverrides(ModelTier) is the canonical helper for this in CLI code — copy its shape if you're building a similar command.
Provider quirks handled automatically
LegionClient absorbs a handful of per-model wire-shape differences so callers never see them:
- Temperature-deprecating models. Claude's Fable/Mythos families, Sonnet 5+/Haiku 5+, and Opus 4.7+ all reject the
temperaturefield (HTTP 400). OpenAI's o-series (o1/o2/o3/o4…) and the GPT-5 family rejecttemperaturetoo and requiremax_completion_tokensinstead ofmax_tokens. Both are detected by parsing the model id (not a hard-coded list), so a future point release doesn't silently break. - Adaptive "thinking" on Claude 5+.
claude-sonnet-5+/claude-haiku-5+run adaptive extended thinking by default when thethinkingfield is omitted, which can consume the entire token budget on reasoning and leave zero text. Legion explicitly sendsthinking: {type: "disabled"}for ordinary (non-thinking) calls to these models. Fable/Mythos always think and reject that field, so they're excluded; Opus 4.7+ correctly default to no-thinking without it. - Gemini 2.5+ thinking budget. For
gemini-*-2.5*models, Legion sendsthinkingConfig: { thinkingBudget: 0 }so the fullmaxOutputTokensbudget goes to the actual response instead of being eaten by internal reasoning tokens. - Claude response parsing. Thinking-tier Claude models put a
thinkingcontent block before thetextblock; Legion concatenates everytype: "text"block rather than assumingcontent[0]. - Gemini response parsing. Skips any part flagged
"thought": trueand concatenates the remaining text parts; returns""(never throws) on a safety-blocked orMAX_TOKENScandidate with no content, so a benign refusal doesn't trip the circuit breaker.
Trust tiers: which list applies where
Legion has three different "which providers are eligible" lists, and they don't all agree — this matters when you're narrowing a panel:
| List | Members | Applies to |
|---|---|---|
LlmProviderCatalog.All / AllIds |
All 14 providers | Everything Legion can call — legion.exe providers/models, direct LegionClient calls. |
LlmProviderCatalog.Default / DefaultIds |
claude-api, claude-team, openai, deepseek, gemini (5) |
The first-party set apps are expected to surface by default in settings UIs. |
VotingConfiguration.AllowedProviderIds (default) |
claude-api, claude-team, openai, gemini, deepseek (5) |
Library voting (LlmVotingService) and the CLI's vote subcommand — the whitelist ActiveProviderIds intersects against. |
CLI TrustedProviderIds (hard-coded per command) |
claude-api, openai, gemini, deepseek (4 — no claude-team) |
legion ask, legion poll, legion generate, legion tiers. --providers can only narrow within this set; an id outside it is silently dropped, even for callers who ask for it explicitly. |
In short: the library's default voting whitelist includes claude-team, but the autonomous-decision CLI commands (ask/poll/generate/tiers) deliberately exclude it from their hard-coded trust list — those commands are meant to run unattended (CI, monitored agent loops) where an interactive Claude Code OAuth session may not exist.
When a trusted provider errors mid-vote (network blip, rate limit, transient 5xx), LlmVotingService.RefillFailedVotersAsync automatically dispatches a fresh call to one of the surviving allowed providers (round-robin), so the panel never shrinks below quorum size. A failed Gemini slot becomes a second Claude or DeepSeek call rather than a missing vote. Refilled slots intentionally drop any persona overlay so a surviving voter doesn't get to "vote twice as the same character."
To run the library with a different shortlist:
config.AllowedProviderIds = new(StringComparer.OrdinalIgnoreCase) { "claude-api", "openai" };
Or via the CLI:
legion.exe ask "..." --providers claude-api,openai,gemini,deepseek
Set AllowedProviderIds to an empty set to disable filtering and let every provider with a key vote.
Credential storage
Legion can read keys from the shared credential store at %APPDATA%/MindAttic/LLM/ (backed by MindAttic.Vault) so every MindAttic app shares one keyring. Set VotingConfiguration.UseSharedCredentials = true (the default) to opt in.
Resolution order (see docs/BIBLE.md#LEG-LAW-2):
- A per-voter
VoterProfile.ApiKeyOverride. - An explicit entry in
VotingConfiguration.ApiKeys. - The shared credential store — User Secrets / App Service settings / Azure Key Vault when a host has called
MindAtticCredentialStore.UseConfiguration(IConfiguration), falling back to the%APPDATA%/MindAttic/LLM/providers.jsonfile.
claude-team is the one exception: it never reads from providers.json or ApiKeys. Its credential is the OAuth access token in ~/.claude/.credentials.json (the Claude Code CLI's own session), resolved and auto-refreshed by ClaudeCodeOAuthSource. Refresh is thread-safe — concurrent callers that all see a token within 60 seconds of expiry block on a single refresh attempt.
MindAtticCredentialStore is a static backward-compatible facade over MindAttic.Vault's LlmCredentialStore/CompositeCredentialStore; new code may inject those types directly via DI instead of calling the static facade. The CLI always uses the shared store (plus environment variables for containerized deployments, e.g. MindAttic__Vault__LLM__claude-api__apiKey).
Per-project panels: legion.json
Drop a legion.json file at a project's root to declare that project's voter panel without touching code:
// ThinkTank (every provider Legion knows):
{ "voters": ["claude-api","openai","gemini","deepseek","mistral","xai",
"groq","together","openrouter","fireworks","cohere"] }
// Tutor (two-vendor panel, explicit judge):
{ "voters": ["openai","claude-api"], "judge": "claude-api" }
// A panel that includes claude-team, sharing its Claude Code session's rate limit:
{ "voters": ["claude-team","openai"], "maxConcurrency": 3 }
Fields: voters (replaces the default AllowedProviderIds whitelist), judge (JudgeProviderId override), models (per-provider ModelOverrides), apiKeys (per-project keys — win over the shared store), maxConcurrency (caps simultaneous ballot calls; use 3 when the panel includes claude-team, which shares its quota with the Claude Code CLI session it authenticates through).
LegionConfig.LoadFromDirectory() walks up from a starting directory (default: cwd) looking for legion.json, up to 12 levels, and returns null (falling back to VotingConfiguration defaults) when none is found or the file is malformed. Apply it with LegionConfig.LoadFromDirectory()?.ApplyTo(config).
Current wiring: the CLI's legion vote subcommand calls this automatically before voting. legion ask/poll/generate/tiers build their VotingConfiguration directly and do not consult legion.json — those commands' provider set is always the hard-coded CLI trust list (see Trust tiers). Library consumers (a MindAttic app's own DI wiring) call LegionConfig.LoadFromDirectory()?.ApplyTo(config) explicitly wherever they want project-local configuration honored.
CLI: legion.exe
The CLI exposes the same engine for shell scripts, CI, and rapid iteration. Every subcommand accepts -h / --help / help / /?.
legion — MindAttic.Legion CLI
Commands:
health Probe every provider with a 'Hello World!' test
ping <provider> Probe a single provider
status [opts] [provider...] Show model inventory, config, and connectivity
providers List supported providers + dashboard URLs
models <provider> Show known models for a provider
personas <count> Sample N personas from the 1024-persona library
panel <count> [provider...] Build a voter panel: spread across providers, backfill claude-api.
--diverse picks personas that maximize psychometric spread.
vote <question> [opts] Multi-LLM consensus vote on a question; outputs JSON.
ask <question> [opts] Architect-framed decision; stdout = bare answer (or --json).
poll <question> [opts] Bulk vote: N voters round-robined across trusted providers.
generate <prompt> [opts] Bulk creative output: N distinct items, deduped, to stdout.
tiers [opts] Probe trusted providers × tier mapping (Low/Medium/High).
psychometrics <sub> [opts] Score the persona library; subcommands init|score|rescore|show|stats|history|diff
All commands read keys from the shared store at %APPDATA%/MindAttic/LLM/.
Discovery commands
legion.exe status # model inventory, config, and connectivity
legion.exe status --no-probe # list live/static models without sending prompts
legion.exe status --json # machine-readable status output
legion.exe status --timeout 30 claude-api openai # narrow to specific providers, custom timeout
legion.exe providers # table of all 14 providers + vendor + default model + dashboard URL
legion.exe models <provider> # a provider's full known-model catalog, default marked, live endpoint if any
legion.exe personas 10 # sample 10 personas from the 1024-persona library (id, name, first line)
legion.exe panel 5 # build a 5-voter panel + show provider mix
legion.exe panel 5 --diverse # panel chosen to maximize psychometric spread (needs scored profiles)
legion.exe panel 5 claude-api openai --store <dir> # explicit provider list / psychometrics store dir
status cross-references three signals per provider: the static catalog (known models), a live query against the provider's /models-style endpoint (live models), and — unless --no-probe is passed — an actual prompt-level connectivity probe. It prints the effective model (configured override, else providers.json, else the catalog default) and, on failure, an actionable next step from LlmHealthDiagnoser (e.g. "key looks revoked, mint a new one at …").
panel spreads voters across the supplied provider list (or every provider that currently has a key, when none is given), backfilling with claude-api once every provider has at least one voter. --diverse requires a psychometrics store with scored profiles (legion psychometrics score must have run first) and picks personas via greedy farthest-point selection over the OCEAN+HEXACO+DISC trait vector instead of random sampling.
Health & connectivity
legion.exe health # probe every one of the 14 providers' DefaultModel with a hello-world
legion.exe ping claude-api # one-provider probe (DefaultModel)
legion.exe tiers # probe trusted-four × Low/Medium/High = 12 cells
Vote (returns JSON on stdout) — one-voter-per-provider consensus
legion.exe vote "Is the sky blue today?" \
--context "Cloud cover is 100%." \
--quorum simplemajority \
--options yes,no,unclear \
--max-tokens 256 \
--no-narrative
vote is the only CLI subcommand that honors a per-project legion.json (see Per-project panels) — it applies the file on top of the default VotingConfiguration before dispatching, so a project can declare its own voter list without --providers.
Ask (architect-framed; stdout = bare answer, --json for full audit)
# Default tier = High (Opus-class / GPT-5.4 / Gemini 2.5 Pro / DeepSeek v4 Pro).
legion.exe ask "Which DI lifetime for the new HttpClient wrapper?" \
--options "Singleton,Scoped,Transient"
# → Singleton
legion.exe ask "Best way to stream LLM tokens through SignalR without buffering?" --json
# Override tier for cheaper one-shot decisions
legion.exe ask "Use tabs or spaces?" --options "tabs,spaces" --tier low
Poll — N voters round-robined across providers at a chosen tier
legion.exe poll "Pick 1, 2, or 3" --options "1,2,3" --count 100 --tier low
# → distribution table + plurality winner
Generate — N distinct creative items, deduped, newline-separated
legion.exe generate "100 hero-vibe character names" --count 100 --tier medium
# → newline list to stdout (pipe into head/shuf/grep/file)
Exit codes (vote/ask):
0— quorum reached1— quorum not reached2— pipeline error
The JSON shape on stdout matches VotingResult/ScoredVotingResult, so other languages can parse it directly.
legion.exe ask — autonomous decisions for monitored agents
ask is the variant tuned for the loop where you want a panel-voted answer to flow back into another coding CLI without a human in between.
Differences from vote:
- Stdout = bare answer by default. Choice mode prints exactly the picked option. Free-form mode prints the synthesized consensus prose. Add
--jsonto get the full audit blob (votes, reasoning, confidence, dissent). - Architect-framed voters. Each voter is told to act as a senior software architect on this project: be decisive, prefer the boring/reversible/conventional choice, flag irreversible decisions, optimize for the developer's next 30 minutes.
- Auto-context. When invoked inside a repo,
askprependsCLAUDE.md,README.md, andgit status -s/git log --oneline -10to every voter's context so the panel sees the project shape. Disable with--no-auto-context. Each piece is independently capped (8 KB / 8 KB / 4 KB / 1 KB) so a 200 KB README can't blow the prompt budget. - Default quorum is
Plurality.askalways emits some answer rather than blocking. Raise the bar with--quorum twothirdswhen dissent should fail closed. - Fixed 4-provider trust list. The panel is always the intersection of
--providers(if given) withclaude-api, openai, gemini, deepseek—claude-teamis never included here, even if requested.
legion.exe ask <question> [opts]
| Option | Meaning |
|---|---|
--options A,B,C |
Force choice mode; voters must pick exactly one. |
--context <text> |
Extra context appended after auto-context. |
--context-file <path> |
Read extra context from a file (e.g. the file you're about to edit). |
--project-dir <path> |
Where to look for CLAUDE.md/README/git (default: cwd). |
--no-auto-context |
Skip the auto-include. |
--quorum <q> |
plurality | simplemajority | twothirds | unanimous (default plurality). |
--max-tokens N |
Per-voter cap (default 1024, must be > 0). |
--timeout S |
Per-provider timeout in seconds (default 60). |
--providers a,b,c |
Narrow the panel within the trusted set (claude-api, openai, gemini, deepseek). Untrusted ids are silently dropped — the panel can never include a non-trusted provider, even if you ask. |
--tier <t> |
low | medium | high | higher | highest (default high). High = flagship reasoning — the right tool for architectural decisions. Drop tier for cheaper one-shot calls. |
--must-answer |
On 0/N voter failure, retry with doubled budget and no auto-context; on second failure, fall back to a single-provider chain (claude-api → openai → gemini → deepseek) calling raw text (no JSON, no persona) until one replies. Use when the calling agent can't tolerate "no answer". |
--json |
Emit full vote audit JSON instead of bare answer. |
Output contract:
| stdout | exit | meaning |
|---|---|---|
| answer | 0 |
panel agrees, act on it |
| best-guess answer | 1 |
panel split — re-ask with more context or escalate |
| (empty) | 2 |
unhandled error (network, etc.) |
With --must-answer, exit 0 also covers the recovery cases (phase-2 retry, phase-3 single-provider chain). stderr will tell you which phase delivered (ask: recovered in phase 3 via claude-api) — log that line if you want a record of the degraded path. stderr carries warnings only; never parse it for the answer.
legion.exe psychometrics — score the persona library
Legion can administer five personality instruments to every persona and persist the results, so panels can be composed and votes read by trait composition, not just by provider. This is a separate subsystem from the decision commands (ask/vote/poll do not touch it) — it powers persona panels.
How scoring works. A single trusted model (default tier High / Opus-class) answers each instrument's 1–5 Likert items in character as the persona; the LLM never computes a score. Scoring is done deterministically in C# (PsychometricScorer) from those raw answers, so the same answers always yield the same profile and the maths is unit-tested. The five instruments are public-domain-derived (IPIP Big Five & HEXACO, OEJTS-style Jungian axes, open DISC- and Enneagram-style banks); the trademarked questionnaires are never used, hence "-style".
| Framework | Output |
|---|---|
| OCEAN / Big Five | O, C, E, A, N — each 0–100 |
| HEXACO | Honesty-Humility, Emotionality, eXtraversion, Agreeableness, Conscientiousness, Openness — 0–100 |
| MBTI-style | 4-letter type + per-axis lean (E/I, S/N, T/F, J/P) |
| Enneagram-style | dominant type 1–9, wing, triad (Gut/Heart/Head) |
| DISC-style | D, I, S, C scores + primary style |
Storage. Each persona is one faithful JSON file under the store's personas/ directory (identity + structured traits + the full nested assessment history), with a small runs.json index alongside — no database. A persona is fully reconstructable from its single file: portable, git-diffable, human-readable. The administering LLM is recorded on each assessment, not on the persona — so re-scoring through a different provider records a new variant of the same person rather than overwriting it. Each run is stamped with the model and an instrument-set version (PsychometricInstruments.SetVersion) so re-runs stay comparable. Store location: an explicit --store <dir>, then the MINDATTIC_LEGION_STORE environment variable, then %APPDATA%/MindAttic/Legion. Writes are atomic (temp file + move); the store assumes a single writer at a time.
legion.exe psychometrics init # create the store + seed the persona files
legion.exe psychometrics score --limit 8 # pilot: score 8 personas (resumable; default tier high)
legion.exe psychometrics score # score everything still missing a current-version profile
legion.exe psychometrics show persona-0000 # a persona's latest profile (--json for the raw record)
legion.exe psychometrics stats # MBTI/DISC/Enneagram distribution + mean OCEAN/HEXACO
legion.exe psychometrics history persona-0000 # every assessment run recorded for one persona
legion.exe psychometrics rescore # fresh full versioned run — point cron/Task Scheduler at this
legion.exe psychometrics diff 1 2 # per-framework drift between two runs
score / rescore option |
Meaning |
|---|---|
--provider <id> |
Administering lens (default claude); must be in the CLI's trusted set (claude-api, openai, gemini, deepseek) or the command errors out. |
--tier <t> |
low…highest (default high = Opus class). |
--limit N |
Score at most N personas — use for a cheap pilot first. |
--concurrency N |
Personas assessed in parallel (default 4). |
--timeout S |
Per-provider timeout in seconds (default 120). |
--store-raw |
Also persist every raw item answer for audit. |
--notes <text> |
Free-form note recorded on the run (rescore sets this to "rescore" automatically). |
--store <dir> |
Override the store directory. |
Caveat — check the
--providerdefault. The command's default--providervalue is the literal stringclaude, but the CLI's trust check (AskCommand.TrustedProviderIds) only recognizesclaude-api(not bareclaude) alongsideopenai,gemini,deepseek. Runninglegion psychometrics score/rescorewith no--providerflag will therefore reporterror: 'claude' is not a trusted providerrather than silently defaulting to Claude — always pass--provider claude-api(or another trusted id) explicitly until this default is reconciled with the trust list.
score is resumable: it skips personas that already have a profile at the current instrument-set version for this provider/lens, so re-running continues where it left off (and scoring through a different --provider produces a fresh variant instead of skipping). rescore forces a brand-new run scoring everyone (for drift tracking) and is the command an external scheduler should invoke. Scoring the full library is ≈ personas × 5 model calls — pilot with --limit before committing to the whole run.
Using the profiles. Once scored:
legion.exe panel N --diversebuilds a panel chosen to maximize psychometric spread (greedy farthest-point over the OCEAN+HEXACO+DISC vector) instead of sampling at random, and tags each voter with its type.- In code,
VoterFactory.GenerateDiverseVoters(count, providers, profiles)does the same and attaches eachPsychometricProfileto itsVoterProfile;PsychometricVoteAnalysis.Segment(voters, result, selector)then splits a completedVotingResultby trait (built-in selectors:ByMbtiType,ByDiscPrimary,ByEnneagramTriad,ByOpennessHalf) to see, e.g., whether high-Openness voters split from low-Openness ones.
Caveat — self-report skew. Because a model answering "in character" still drifts toward dutiful, agreeable, conscientious self-presentation, LLM-administered profiles cluster toward a corner of the trait space (expect lots of *_STJ types and high Conscientiousness). Personas are still differentiated, just less than real humans would be. Sharper separation would need forced-choice/ipsative items or per-trait anchoring — a future instrument-set version, comparable to the current one via
diff.
legion.exe poll — bulk distribution sampling
poll is a fan-out command, not a consensus command. It round-robins N independent voters across the trusted four, all on a single tier, and reports a count-sorted distribution + plurality winner. Use it for "how does the panel split on this?" when you want a sample, not a verdict.
Distinct from vote (one voter per provider, requires quorum) and ask (one architect-framed answer): poll reports raw distributions, no quorum concept, plurality winner is whichever option got the most votes — even by 1.
legion.exe poll <question> [opts]
| Option | Meaning |
|---|---|
--count N |
Total voters across the panel (default 10). With four providers, count=100 → exactly 25 per provider; count=10 → 3,3,2,2. |
--tier <t> |
low | medium | high | higher | highest (default low). Low scales cheaply for the "100 voters" use case. |
--options A,B,C |
Force choice mode; off-ballot replies count as errors (excluded from the distribution). Free-form mode is allowed when omitted. |
--providers a,b,c |
Narrow within the trusted set (untrusted ids are silently dropped). |
--context <text> |
Extra context appended to every voter's prompt. |
--max-tokens N |
Per-voter cap (default 200 — voters reply briefly). |
--timeout S |
Per-voter timeout in seconds (default 30). |
--concurrency N |
In-flight call cap (default 8) — prevents 100 voters from bursting all at once. |
--json |
Emit full poll record (per-voter, distribution, summary) as JSON instead of a table. |
Round-robin distribution: voter i goes to providers[i % providers.Count]. With four providers and count=100, each gets 25; with count=10 each gets 3,3,2,2 (front buckets get the remainder). Failures don't shift the index — we'd rather have an uneven distribution that's reproducible than a "rebalance on failure" rule that drifts under retry.
# 100 voters at Low — quick, cheap distribution
legion.exe poll "Should this PR ship today?" --options "yes,no,not-yet" --count 100 --tier low
# 30 free-form voters at Medium — cluster their answers afterward
legion.exe poll "One word that describes this codebase" --count 30 --tier medium --json
# 50 voters at High but only Claude+OpenAI (more careful sampling)
legion.exe poll "Severity?" --options "low,medium,high,critical" --count 50 --tier high --providers claude-api,openai
Exit codes:
0— at least one voter replied; a winner was chosen1— every voter errored (or usage error)
legion.exe generate — bulk creative output
generate produces N distinct creative items by fanning out one batched call per trusted provider, extracting line-separated items from each reply, deduping case-insensitively across all batches, and emitting newline-separated results to stdout (Unix-pipe convention). Built for legion generate "100 hero-vibe names" | head -25 > names.txt.
Distinct from poll (which counts votes) and ask (which seeks one decision): generate produces many distinct items on a single prompt — names, taglines, alternatives, function names, scenario hooks.
legion.exe generate <prompt> [opts]
| Option | Meaning |
|---|---|
--count N |
Total distinct items (default 10). Each provider gets a round-robin share via SplitCount — 100 → [25, 25, 25, 25]. |
--tier <t> |
low | medium | high | higher | highest (default medium). Medium = creative balance; Low produces flat output for creative bulk. |
--providers a,b,c |
Narrow within the trusted set. Pass a single provider for stylistic consistency; default round-robin maximizes variety. |
--max-tokens N |
Per-batch cap (default 1500 ≈ 50 short items per provider). |
--timeout S |
Per-call timeout in seconds (default 60). |
--temperature T |
Sampling temperature (default 0.9 — favors creative variance, not consensus). |
--no-dedup |
Emit duplicates from across providers; default is dedup case-insensitively, first-seen wins. |
--json |
Emit JSON record (prompt, requested, returned, items, per-provider batches) instead of newline list. |
Item extraction is defensive: a model that ignores the "no markers" instruction still yields clean items because ExtractItems strips:
- Numbered markers:
1.,12.,1),99) - Bulleted markers:
-,*,• - Wrapping quotes:
",',“…”,‘…’
Diagnostics go to stderr so stdout stays clean for piping. The summary line ("19 unique item(s) from 4/4 provider(s) (1 dup/empty trimmed)") is informational only.
# 100 fantasy character names, deduped, fed straight into a names file
legion.exe generate "single-word hero-vibe character names for a fantasy CLI" --count 100 > names.txt
# 30 product taglines on High tier (slower but more polished)
legion.exe generate "product taglines for a calm-tech tea brand" --count 30 --tier high
# Stylistic consistency: only Claude, smaller temperature
legion.exe generate "function names for queue.dequeue helpers" --count 20 --providers claude-api --temperature 0.4
# Pipe through standard tools
legion.exe generate "fictional country names" --count 50 | shuf | head -10
Exit codes:
0— at least one item was produced1— every provider's batch errored (or usage error)
legion.exe tiers — connectivity probe across the tier matrix
tiers answers "is the panel ready to vote on High right now?" without spinning up a real ask or vote. It probes every (trusted-provider, tier) cell with a tiny "reply OK" prompt and prints a connectivity table, defaulting to the trusted four × Low/Medium/High = 12 calls. Distinct from legion health, which only probes per-provider DefaultModel, missing tier-mapping breakage.
legion.exe tiers [opts]
| Option | Meaning |
|---|---|
--providers a,b,c |
Narrow within the trusted set (untrusted ids dropped). |
--tiers low,medium,high |
Narrow the tier sweep. Default: low,medium,high. |
--all-tiers |
Shorthand for all five tiers (Low, Medium, High, Higher, Highest). |
--max-tokens N |
Token budget per probe (default 400 — large enough for thinking models like gemini-2.5-pro to actually emit text after reasoning). |
--timeout S |
Per-probe timeout in seconds (default 45). |
--json |
Emit JSON record (one entry per probe + summary) instead of a table. |
Output is a one-row-per-probe table:
PROVIDER TIER MODEL STATUS TIME DETAIL
────────────────────────────────────────────────────────────────────────────
claude-api Low claude-haiku-4-5-20251001 OK 2600ms OK
claude-api Medium claude-sonnet-5 OK 999ms OK
claude-api High claude-opus-4-7 OK 1404ms OK
...
summary: 12/12 ok
Exit codes:
0— every probe succeeded1— at least one probe failed
Use it before a critical session, after a model-id rotation, or as a lightweight CI smoke-test (paid, so wire it behind a manual workflow_dispatch).
Direct LegionClient usage (no voting)
Apps that don't need voting/quorum can call AddLegionClient() and inject LegionClient directly for the same connection scaffolding (endpoints, auth headers, request/response shape, model defaults, shared-credential lookup, retry, circuit breaking) without pulling in the voting machinery:
services.AddLegionClient();
// ...
public class MyService(LegionClient legion)
{
public Task<string> AskClaude(string prompt) =>
legion.CallAsync("claude-api", systemPrompt: "...", userMessage: prompt);
}
LegionClient covers more than single-turn text completions:
// Multi-turn chat (shared-credential overload)
var reply = await legion.CallChatAsync("openai",
new[] { new ChatTurn("user", "Hi"), new ChatTurn("assistant", "Hello!"), new ChatTurn("user", "What's 2+2?") },
systemPrompt: "Be terse.");
// Anthropic prompt caching — cache a large stable prefix (e.g. story canon) once,
// then reuse it across many calls that vary only the trailing instructions.
var cached = await legion.CallAsync("claude-api", apiKey, model,
systemPrompt: dynamicInstructions, userMessage: userPrompt,
cachedSystemPrefix: stableCanonText, cacheUserMessage: false);
// Fallback chain — try providers in order until one succeeds
var (providerId, text) = await legion.CallWithFallbackAsync(
new[] { "claude-api", "openai", "gemini", "deepseek" },
systemPrompt: "...", userMessage: "...");
// OpenAI embeddings
var vectors = await legion.EmbedAsync("openai", apiKey, "text-embedding-3-small",
new[] { "first passage", "second passage" });
// OpenAI (DALL·E) image generation
var urls = await legion.GenerateImageAsync("openai", apiKey, "dall-e-3", "a lighthouse at dusk, watercolor");
var bytes = await legion.GenerateImageBytesAsync("openai", apiKey, "dall-e-3", "a lighthouse at dusk, watercolor");
// Claude document input — native PDF, or extracted text for DOCX/EPUB/plain text
var summary = await legion.CallWithDocumentAsync(apiKey, "claude-sonnet-5",
documentBytes: pdfBytes, mediaType: "application/pdf",
userPrompt: "Summarize this contract in 3 bullets.");
IsProviderConfigured(providerId) reports whether a credential currently resolves (OAuth for claude-team, credential-store key for everything else); LegionClient.IsSupported(providerId) / the static LegionClient.DefaultModels dictionary are useful for building settings screens without an HTTP round-trip. LlmProviderRuntimeConfigurationResolver.Get(providerId) reads the optional apiKey/type/model/maxTokens fields recorded for a provider in providers.json without requiring callers to parse that file themselves.
Resilience
LegionClientOptions tunes retry and circuit-breaker behavior per LegionClient instance:
| Setting | Default | Meaning |
|---|---|---|
MaxRetries |
2 | Extra attempts after the first failure, on transient errors only (network errors, HTTP 408/429/5xx). |
InitialBackoff |
500ms | Delay before the first retry. |
BackoffMultiplier |
2.0 | Multiplier applied to the backoff after each retry. |
CircuitBreakerThreshold |
5 | Consecutive failures that open the per-provider breaker. |
CircuitBreakerCooldown |
2 minutes | How long the breaker stays open before allowing another attempt. |
LegionClientOptions.NoResilience (no retries, breaker effectively disabled) is what the CLI's health/ping/poll/generate/tiers commands use — those already fan out many parallel calls and want a clean per-call pass/fail rather than retry noise. The CircuitBreaker itself is static and process-wide: "Claude is down" means the same thing to every LegionClient instance in the process, so a sick provider fails fast everywhere rather than per-instance. Non-transient failures (e.g. 401 auth) and client-side validation errors (unsupported provider) are never retried.
Health checks and diagnosis
LlmHealthCheck.CheckAsync/CheckOneAsync/CheckAllAsync send a tiny "Reply with exactly the two words: Hello World!" probe and classify the outcome via LlmHealthDiagnoser into an LlmHealthDiagnosis:
Healthy · ResponseMismatch · BadResponse · MissingCredential · AuthInvalid (401) · AuthForbidden (403) · QuotaExhausted (402 / 429-with-quota) · RateLimited (429) · BadRequest (400) · NotFound (404) · PayloadTooLarge (413) · ServerError (5xx) · ServiceUnavailable (503) · GatewayTimeout (504/408) · Timeout · Offline (network error, no HTTP status) · CircuitOpen · CancelledByUser
Each LlmHealthResult carries an ActionableMessage — a human-readable next step ("rotate your key at …", "top up your account at …") built from the diagnosis plus the provider's dashboard/keys URLs, so a settings page can render a fix instead of a stack trace.
LlmModelDiscovery.DiscoverAsync/DiscoverOneAsync/DiscoverAllAsync queries a provider's live /models-style endpoint (when it has one) and normalizes the response shape (data[], models[], bare arrays, legacy model_id, …) into a plain model-id list, independent of LegionClient — useful for building a status screen without sending an actual prompt.
Public API at a glance
// Voting
Task<VotingResult> VoteAsync(string question, string context, Quorum, CT)
Task<VotingResult> VoteAsync(VoteRequest, Quorum, CT)
Task<VotingResult> VoteAsync(VoteRequest, Quorum, IEnumerable<string> providerIds, CT)
Task<VotingResult> VoteWithProfilesAsync(VoteRequest, Quorum, IEnumerable<VoterProfile>, CT)
Task<VotingResult> VoteWithPersonasAsync(string question, string context, Quorum, IEnumerable<VoterProfile>, CT)
// Decisions
Task<DecisionResult> DecideAsync(string question, IEnumerable<string> options, string context, Quorum, int maxTokens, CT)
// Scoring
Task<ScoredVotingResult> ScoreAsync(ScoredVoteRequest, CT)
Task<ScoredVotingResult> ScoreWithProfilesAsync(ScoredVoteRequest, IEnumerable<VoterProfile>, CT)
// Panel construction
List<string> GetActiveProviderIds()
IReadOnlyList<VoterProfile> CreatePanel(int count, string fallbackProviderId = "claude-api", Random?)
VoterProfile.ForCharacter(name, psychologyMarkdown, providerId, apiKey?, model?) wraps a character's psychology into a voter profile suitable for in-story decisions.
LegionClient (direct transport, no voting):
Task<string> CallAsync(providerId, apiKey, model, systemPrompt, userMessage, maxTokens, temperature, CT, cachedSystemPrefix?, cacheUserMessage, userCancelToken?)
Task<string> CallAsync(providerId, systemPrompt, userMessage, maxTokens, temperature, modelOverride?, CT, cachedSystemPrefix?, cacheUserMessage) // shared-credential lookup
Task<string> CallAsync(providerId, apiKey, model, systemPrompt, userMessage, endpointUrl, maxTokens, temperature, CT) // explicit-URL / self-hosted
Task<string> CallChatAsync(providerId, apiKey, model, IEnumerable<ChatTurn>, systemPrompt?, maxTokens, temperature, CT)
Task<string> CallChatAsync(providerId, IEnumerable<ChatTurn>, systemPrompt?, maxTokens, temperature, modelOverride?, CT) // shared-credential lookup
Task<(string ProviderId, string Response)> CallWithFallbackAsync(IEnumerable<string> fallbackChain, systemPrompt, userMessage, maxTokens, temperature, CT)
Task<string> CallWithDocumentAsync(apiKey, model, documentBytes, mediaType, userPrompt, systemPrompt?, maxTokens, temperature, CT) // Claude only
Task<IReadOnlyList<float[]>> EmbedAsync(providerId, apiKey, model, IReadOnlyList<string> inputs, dimensions?, CT) // OpenAI only
Task<IReadOnlyList<string>> GenerateImageAsync(providerId, apiKey, model, prompt, size, n, CT) // OpenAI (DALL·E) only
Task<IReadOnlyList<byte[]>> GenerateImageBytesAsync(providerId, apiKey, model, prompt, size, quality, n, CT) // OpenAI (DALL·E) only
bool IsProviderConfigured(providerId)
static bool IsSupported(providerId)
static string? GetClaudeTeamOAuthToken()
static IReadOnlyDictionary<string,string> DefaultModels
Architecture
┌─ Your app / legion.exe CLI
│ └─ LlmVotingService public API: VoteAsync / DecideAsync / ScoreAsync
│ └─ VoterFactory builds VoterProfile lists (CreatePanel, personas, diverse panels)
│ └─ LlmVotingProvider
│ └─ LegionClient universal LLM transport
│ ├─ Claude wire shape (claude-api key auth / claude-team OAuth)
│ ├─ OpenAI-compatible wire shape (openai, deepseek, mistral, xai,
│ │ groq, together, openrouter, fireworks, kimi, perplexity,
│ │ and any self-hosted endpoint via an explicit URL)
│ ├─ Gemini wire shape
│ └─ Cohere wire shape
│ ├─ CircuitBreaker (process-static, per-provider)
│ └─ ClaudeCodeOAuthSource (~/.claude/.credentials.json, auto-refresh)
├─ LegionConfig (legion.json — per-project voters/judge/models/apiKeys/maxConcurrency)
└─ MindAtticCredentialStore (facade over MindAttic.Vault; shared keyring at %APPDATA%/MindAttic/LLM/)
LegionClient owns the socket pool, retry policy, and circuit breaker. LlmVotingProvider adds vote-specific shaping and per-voter key resolution. LlmVotingService is the public API — you almost never need to touch the lower layers. See docs/BIBLE.md §4 for the canonical architecture diagram and project layout, and docs/BIBLE.md §5 for the Laws this design must never violate.
Personas: the 1024-persona library
PersonaLibrary (MindAttic.Legion/Services/PersonaLibrary.cs) is not a hand-authored list — it's a deterministic sample from a diversity skeleton:
- 40 vocational archetypes (retired schoolteacher, ER nurse, trial lawyer, software engineer, parish priest, long-haul truck driver, …)
- 16 worldviews (cautious traditionalist, dry-witted skeptic, data-driven empiricist, blunt populist, …)
- 16 cultural backgrounds (rural Midwestern, coastal urban, first-generation immigrant, Appalachian, Gulf Coast bayou, …)
That 40×16×16 = 10,240-point cube is sampled down to a fixed 1024 personas using a hard-coded seed (SampleSeed = 0x9E3779B1), so the library is exactly reproducible build-to-build. Each persona is further enriched with a deterministic age, one of two pronoun sets (she/her, he/him), and one of 50 signature quirks (~20 personas per quirk) so neighboring entries in the catalog still read as distinct people. Every persona has a unique id and name (PersonaLibraryShapeTests asserts both). There are no per-provider "default" personas — a bare LLM voter with no persona is simply a VoterProfile with empty PersonalityMarkdown.
PersonaLibrary.Sample(count, rng?) draws without replacement via a partial Fisher-Yates shuffle, so a panel built through VoterFactory never repeats a persona in the same batch; pass a seeded Random for reproducible test panels. PersonaLibrary.Profiles embeds the persona library's latest psychometric scores directly in the NuGet package (Resources/psychometric-profiles.json), so consumers get profile-carrying personas with zero external data source — pair with VoterFactory.GenerateDiverseVoters to build trait-diverse panels out of the box.
VoterFactory.GenerateUniqueVoters(count, providerIds, fallbackProviderId = "claude-api", rng?) spreads voters across every supplied provider at least once before doubling up, backfilling extra slots with the fallback provider. VoterFactory.GenerateDiverseVoters(count, providerIds, profiles, fallbackProviderId, rng?) instead does greedy farthest-point selection over the 15-dimensional OCEAN+HEXACO+DISC trait vector — seeding on the persona farthest from the panel's centroid, then repeatedly adding whichever remaining candidate is farthest from everyone already chosen — so a small panel spans the trait space instead of clustering.
Testing
MindAttic.Legion.Tests/ is a two-tier suite: a unit suite (461 tests, runs on every dotnet test, no network) and a live integration suite (21 explicit tests across four categories, runs only when filtered).
Unit suite (461 tests)
Covers, with no network calls:
- Vote tally correctness (plurality, simple-majority, two-thirds, unanimous) and quorum enforcement at threshold edges
- Persona injection (system-prompt wrapping)
- Provider failover and refill (one voter erroring doesn't break the vote; failed slots are reissued against the surviving providers)
- Choice-option exact-match matching
- Scored-vote dimension aggregation
- Wire-format adapters per provider (Claude / OpenAI-compatible / Gemini / Cohere) — including the Opus 4.7+/Sonnet 5+/Haiku 5+ temperature-omission invariants and the Claude 5+ adaptive-thinking-disable invariant
- Resilience policy: retry / circuit-breaker / fallback-chain
- Health-check diagnosis classification (auth / quota / rate-limit / offline / wrong-reply)
- Live model discovery + JSON shape normalization (every wire shape Legion has met: OpenAI
data[], Geminimodels[]withmodels/prefix trim, Cohere/Anthropic variants, bare arrays,model_idlegacy, mixed-type arrays, malformed JSON soft-failure) VotingConfiguration.ActiveProviderIdsgating: explicit keys, blank-key filtering, default trusted-set whitelist, untrusted-provider rejection, shared-credential merging, dedupAskCommandhelpers: trust-list intersection, choice-mode option snapping, auto-context assembly + caps, architect-prompt heuristics, help-flag recognition, tier override map (BuildTierModelOverridesLow/Medium/High/Higher), default tier pin (High)TiersCommandhelpers: trust-list parity withask, default tier sweep (Low/Medium/High), provider resolution, table truncationPollCommandhelpers: round-robin assignment math (AssignRoundRobinat counts 8, 10, 100), tier model resolution per assignment, case-insensitive distribution aggregation, off-ballot handlingGenerateCommandhelpers: count-splitting math (with a property test that totals always equal N), every list-marker shape (1.,1),-,*,•), every quote variant including curly, order preservation, case-insensitive dedup with first-seen-wins- Persona-library shape (exactly 1024 personas, unique ids/names), persona-store round-tripping, psychometric instrument item parsing/scoring/model-shape tests
Run from the repo root:
dotnet test MindAttic.Legion.Tests/MindAttic.Legion.Tests.csproj
Typical wall time: ~2s. Verified locally: 461 passed / 0 failed / 0 skipped.
Live integration suite (21 explicit tests, four categories)
The live suite spans four categories, all marked [Explicit] so they do not run on normal dotnet test invocations (no surprise spend on CI). Run on demand to verify wire-shape, tier mapping, key validity, and psychometric scoring against live providers.
LiveApi (17 tests) — LiveApiIntegrationTests.cs: End-to-end tests that hit the real trusted-provider APIs.
- 12 per-(provider, tier) connectivity tests, one per cell of the trusted × Low/Medium/High matrix. A failure points at the exact cell.
- 1 whole-matrix sanity check using
TiersCommand.ProbeMatrixAsync— the "panel is healthy" assertion in one line. - 1 override-vs-catalog parity guard — pins that
BuildHighTierModelOverridesmatchesLlmProviderCatalog.GetTieredModel(..., High)for every trusted provider. - 3 end-to-end smoke tests of
legion ask,legion poll,legion generateat Low tier with tiny budgets.
LiveKeys (3 tests) — LiveKeyValidationTests.cs: Verify the shared Vault keys actually authenticate against each provider they're present for — data-driven over whatever keys are currently in the store, so adding/removing a key adds/removes a test case with no edit required. One of the three is additionally tagged LiveKeysTrusted (a narrower run scoped to the trusted set).
LivePsychometrics (1 test) — PsychometricLiveTests.cs: End-to-end persona scoring through a real provider (≈5 Opus-class calls).
Run on demand:
# All live tests in a specific category
dotnet test --filter "Category=LiveApi"
dotnet test --filter "Category=LiveKeys"
dotnet test --filter "Category=LiveKeysTrusted"
dotnet test --filter "Category=LivePsychometrics"
# One specific cell — useful when a single provider is flaky
dotnet test --filter "FullyQualifiedName~LiveApi.Claude_High"
# All Claude tiers
dotnet test --filter "FullyQualifiedName~LiveApi.Claude_"
Cost is small (~12 tiny probes + 3 small smoke tests on Low tier for LiveApi) but real — wire it behind a manual GitHub Actions workflow_dispatch if you want it in CI without paying every run.
The full offline filter used in CI/pre-release verification:
dotnet test MindAttic.Legion.Tests -c Release --filter "Category!=LiveKeys&Category!=LiveKeysTrusted&Category!=LiveApi&Category!=LivePsychometrics"
Build, pack, publish
# Build the whole solution (library + CLI + tests)
dotnet build MindAttic.Legion.slnx -c Release
# Run the offline test suite
dotnet test MindAttic.Legion.Tests/MindAttic.Legion.Tests.csproj
# Pack the library as a NuGet package (+ .snupkg symbols)
dotnet pack MindAttic.Legion/MindAttic.Legion.csproj -c Release -o pack-out
# Build the CLI executable
dotnet build MindAttic.Legion.Cli/MindAttic.Legion.Cli.csproj -c Release
# → MindAttic.Legion.Cli/bin/Release/net10.0/legion.exe (also legion.dll, runnable via `dotnet legion.dll`)
Versioning is whole-number/major-only across every MindAttic project (see MindAttic.HouseRules.md, inherited via docs/BIBLE.md §5): the package's <Version> bumps by a whole major number per release (N.0.0) and nothing else.
docs/ canon has its own tooling, unrelated to the library build:
powershell -File tools/codex.ps1 digest # regenerate docs/BIBLE.digest.md after editing BIBLE §1/§3/§5/§9 or the latest amendment
powershell -File tools/codex.ps1 doctor # validate IDs, links, front-matter, cited tests/paths, digest freshness — must exit 0
There is also a separate, unrelated Node-based landing-page renderer (package.json, scripts/cli/, index.htm) that turns this README into the mindattic.com landing page for the project; it is not part of the library/CLI build and is deployed by the sibling MindAttic.Deploy repo. tools/build-readme.ps1 (this repo's copy of the shared Codex README.md → README.htm engine) is a separate, simpler doc-preview artifact and does not touch index.htm.
Directory layout
MindAttic.Legion/ the library (net10.0, PackageId MindAttic.Legion)
Models/ Quorum, ModelTier, VoteRequest/Result, VoterProfile,
VotingConfiguration, LegionConfig, ChatTurn, Persona(Detail),
Psychometrics/ (OceanScores, HexacoScores, MbtiResult,
EnneagramResult, DiscResult, PersonaDocument, PsychometricProfile)
Providers/ LlmVotingProvider — fans a vote across voters, resolves keys
Services/ LlmVotingService, LegionClient, LlmProviderCatalog,
CircuitBreaker, ClaudeCodeOAuthSource, LegionClientOptions,
LlmHealthCheck, LlmHealthDiagnosis, LlmModelDiscovery,
MindAtticCredentialStore, LlmProviderRuntimeConfiguration,
PersonaLibrary, PersonaNames, VoterFactory, LegionJson,
Psychometrics/ (PersonaStore, PsychometricInstruments,
PsychometricScorer, LlmPsychometricAssessor, PsychometricVoteAnalysis)
Resources/psychometric-profiles.json embedded id → latest-profile map shipped with the package
MindAttic.Legion.Cli/ legion.exe host
Program.cs, LegionCli.cs entry point + health/ping/status/providers/models/personas/panel/vote
AskCommand.cs, PollCommand.cs, GenerateCommand.cs, TiersCommand.cs, PsychometricsCommand.cs
MindAttic.Legion.Tests/ NUnit 4 test project (461 offline + 21 live-explicit)
docs/ Codex canon — BIBLE.md (L0), AMENDMENTS.md (L1),
USER_STORIES.md (L2), rfc/, generated BIBLE.digest.md
tools/codex.ps1 digest + doctor tooling for docs/
tools/build-readme.ps1 thin wrapper around the shared codex-standard README→HTML engine
scripts/, package.json, index.htm separate Node-based mindattic.com landing-page renderer (not part
of the library/CLI build)
Glossary
- Panel — the set of active voters (provider × persona) participating in a vote.
- Quorum — agreement threshold:
Plurality(any winner) ·SimpleMajority(>50%) ·TwoThirds(≥66.7%) ·Unanimous(100%). - Voter — one
VoterProfile: a provider id + optional persona + optional key/model/max-tokens override. - Persona — a markdown system-prompt worldview from the 1024-member
PersonaLibrary. - Tier — a
ModelTier(Low…Highest) the catalog maps to a concrete model per provider (five providers have an explicit map; the rest fall back to their single default model at every tier). - Trusted set(s) — see Trust tiers: the library's default voting whitelist is 5 providers (including
claude-team); the CLI's autonomous-decision commands (ask/poll/generate/tiers) hard-code a stricter 4-provider list that excludesclaude-team. - Judge — the LLM that synthesizes free-form votes into a consensus (
JudgeProviderId). - Consensus / dissent — the agreed answer and the recorded minority positions.
- Circuit breaker — per-provider fast-fail after repeated failures, shared process-wide.
legion.json— optional per-project config file (voters/judge/models/apiKeys/maxConcurrency), currently auto-applied only by the CLI'svotesubcommand.- Psychometric instrument — one of five trait batteries (OCEAN, HEXACO, MBTI-style, Enneagram-style, DISC-style) scored deterministically from in-character answers.
License
MIT — see LICENSE. Copyright (c) 2026 MindAttic.
Briefing another coding agent
When you want another LLM-driven CLI (Claude Code, Codex, Cursor agent, etc.) to reach for Legion instead of blocking on you, paste the block below into its memory or system prompt. It's self-contained and assumes nothing about the host except that it can run a .exe.
You now have a Legion panel for hard calls. Use it instead of blocking on me.
Legion is a multi-LLM CLI at:
D:\Projects\MindAttic\MindAttic.Legion\MindAttic.Legion.Cli\bin\Release\net10.0\legion.exe
The panel is HARDCODED to Claude (API key), ChatGPT, Gemini, DeepSeek — the four providers
I trust for autonomous decisions. No untrusted provider can ever be added, even via --providers
(untrusted ids get silently dropped). If a trusted provider is unreachable, Legion automatically
refills the empty slot with another working provider so the panel never falls below quorum.
CHOOSING THE COMMAND
You have four primary commands. Pick by what shape of answer you need:
legion ask <q> ONE decision, panel-voted, architect-framed.
Default tier: HIGH (flagship reasoning class for every trusted provider).
Use for: architectural choices, breaking-change tradeoffs, ambiguous specs.
legion poll <q> N independent voters, count-sorted distribution, plurality winner.
Default tier: LOW. Default count: 10.
Use for: "how does the panel split?" sampling, sentiment polls, sanity
checks where you want a count, not a verdict.
legion generate <p> N distinct creative items, deduped, newline to stdout.
Default tier: MEDIUM. Default count: 10.
Use for: name lists, taglines, alternatives, function-name brainstorms.
legion tiers Probe trusted-four × Low/Medium/High = 12 cells. No question, just connectivity.
Use for: "is the panel healthy right now?" before a critical session,
or after a model deprecation in the wild.
WHEN TO CALL `ask`
Whenever you would otherwise pause to ask me:
- An architectural choice (DI lifetime, library pick, schema shape, layering decision).
- A breaking-change tradeoff (rename now vs. soft-deprecate, migrate now vs. shim).
- An ambiguous spec where two reasonable readings exist and the next file you write depends on which one is right.
- Anything hard to reverse.
Don't call `ask` for mechanical edits, formatting, or things where you already know the answer — each call costs ~3-8s and four flagship-tier LLM-API requests. For cheap one-shot decisions, use --tier low.
HOW TO CALL `ask`
legion.exe ask "<question>" [opts]
Modes:
- Choice (recommended when possible):
legion.exe ask "Pick the JSON serializer" --options "System.Text.Json,Newtonsoft.Json"
→ stdout = exactly one option, exit 0 on quorum.
- Free-form:
legion.exe ask "Best way to stream LLM tokens through SignalR without buffering?"
→ stdout = the synthesized answer.
- Audit: add --json to get votes, reasoning, confidence, dissent. Use this when you want to surface tradeoffs back to me.
- Strict consensus: add --quorum twothirds to fail closed (exit 1) if the panel splits. Use for irreversible decisions.
- Tier override: add --tier low|medium|high|higher|highest. Default is high (architecture). Drop tier for cheap one-shot decisions where flagship reasoning is overkill.
- MUST-ANSWER: add --must-answer when you absolutely cannot tolerate "no answer". Phase-2 retry doubles budget and drops auto-context; phase-3 falls back to a single-provider chain (claude-api → openai → gemini → deepseek) calling raw text. Always emits an answer if any one provider is reachable.
Auto-context: by default `ask` reads CLAUDE.md, README.md, and `git status -s` / `git log --oneline -10` from cwd and prepends them so voters know the project. Pass --no-auto-context for a clean prompt, or --context-file <path> to inject a specific file (e.g. the file you're about to edit).
`ask` OUTPUT CONTRACT
stdout | exit | meaning
------------------- | ---- | -------------------------------------------------------
answer | 0 | panel agrees, act on it
best-guess answer | 1 | panel split — re-ask with more context or escalate to me
(empty) | 2 | unhandled error (network, etc.)
With --must-answer, exit 0 also covers the recovery cases (phase-2 retry, phase-3 single-provider chain). stderr will tell you which phase delivered ("ask: recovered in phase 3 via claude-api") — log that line if you want a record of the degraded path. stderr carries warnings only; never parse it for the answer.
WHEN TO CALL `poll`
When you want to *sample* the panel rather than reach a verdict — e.g. "how confidently do they all agree?" — or when you want to feed a question to many voters at the cheapest tier and tally the result.
# 100 voters at Low — quick distribution
legion.exe poll "Should we ship today?" --options "yes,no,not-yet" --count 100 --tier low
`poll` writes a distribution table to stdout (or full JSON via --json). Plurality winner is whichever option got the most votes; no quorum.
WHEN TO CALL `generate`
When you need many *distinct creative items* on a single prompt — names, taglines, alternatives, function-name brainstorms.
legion.exe generate "single-word hero-vibe character names" --count 100 > names.txt
`generate` writes newline-separated items to stdout (Unix-pipe convention) and diagnostics to stderr. Pipe through `head`, `shuf`, `grep`, or redirect to a file.
WHEN TO CALL `tiers`
Use it ONCE at the start of a session that depends on the panel being live, or after a model rotation in the wild. It probes 12 cells (4 providers × Low/Medium/High) with a tiny "reply OK" prompt.
legion.exe tiers
# → 12-row table; exit 0 if every cell is OK, 1 otherwise.
Don't call it before every `ask` — that's wasted spend. Once per session is plenty.
EXAMPLES
# Decide a DI lifetime
legion.exe ask "Which DI lifetime for the new HttpClient wrapper?" --options "Singleton,Scoped,Transient"
# Pick between two refactors with full reasoning
legion.exe ask "Should we extract Persona-rendering into a separate service?" --json
# Conservative: only act if 2/3+ agree
legion.exe ask "Migrate the credential store to DPAPI now?" --quorum twothirds
# Cheap one-shot, no need for flagship reasoning
legion.exe ask "Should the new flag default true or false?" --options "true,false" --tier low
# I can't proceed without an answer — pull every lever
legion.exe ask "Pick the cache key format" --options "user:{id},u-{id},user_{id}" --must-answer
# Bulk distribution sample
legion.exe poll "Severity?" --options "low,medium,high,critical" --count 50 --tier low
# Bulk creative generation
legion.exe generate "fictional country names with calm vibes" --count 30 --tier medium
# Pre-flight panel check
legion.exe tiers
--providers exists but you almost never need it: it can only NARROW within the trusted four (e.g. --providers claude-api,openai). Passing untrusted ids is harmless (they're dropped) but pointless. Don't reach for it unless I specifically ask you to scope a panel.
If `legion ask` exits 1 (no quorum) WITHOUT --must-answer, don't silently pick its best-guess answer for a structural decision — surface the dissent (re-run with --json, summarize the disagreement, ask me). If you used --must-answer and still got exit 1 or 2, the trusted panel is genuinely down — escalate to me, don't guess.
Contributing notes (for sibling repos using Legion)
- Always wrap judgment calls in
DecideAsync. If your code has a hard-coded branch that picks among options based on heuristics, replace the heuristic with a Legion decision and pass the relevant context. The panel is cheap; bad decisions are expensive. - Prefer
Pluralityfor surfacing all viewpoints,SimpleMajorityfor routine decisions,TwoThirds+ for canon-affecting actions. Don't reach forUnanimousunless the cost of a single dissent is real. - Pass real context. A vote without context is just a popularity contest. Bundle the canon, the prior chapters, the schema, the rubric — whatever the panel needs to be informed.
- Watch the cost dial. A panel of 5 means 5× tokens. Use the
providerIdsoverload to scope votes to 2–3 providers when you don't need the full panel. QuorumReached == falseis a signal, not a failure. It means the panel saw a real ambiguity. Surface it to a human or escalate the question.- Know which trusted list you're in. The library's default
AllowedProviderIdsincludesclaude-team; the CLI'sask/poll/generate/tierscommands don't. If you're porting logic from one surface to the other, re-check which provider set actually applies (see Trust tiers).