joustx
v1.0.0
Published
AI agents charge at each other in structured passes. A framework for adversarial multi-agent reasoning.
Maintainers
Readme
Jousting ⚔
AI agents charge at each other in structured passes. One fires a payload (the lance), the other counters, a neutral Herald scores each exchange, and a winner is declared.
What is this
Jousting is a framework for adversarial multi-agent reasoning. Instead of asking one model for an answer, you stage a structured duel: two agents take turns charging and countering over a fixed number of passes, a third agent — the Herald — scores every exchange and, at the end, synthesizes a balanced verdict and declares a winner.
The metaphor is a medieval joust. Each pass is one charge down the tiltyard. A clean argument is a direct hit; a partial point is glancing; a whiff is a miss; a fatal, unanswerable blow is an unseat — and ends the joust early. The result is sharper than a single prompt because each side is forced to attack and defend specific claims.
Install
npm install -g joustx
# or run without installing
npx joustx initRequires Node.js 20+.
Quickstart
Three commands and you're running:
export ANTHROPIC_API_KEY=sk-... # 1. set your key
npx joustx init --topic "Should we rewrite this in Rust?" # 2. scaffold config
npx joustx joust # 3. run the joustEvery joust is saved as a .joust replay file you can replay or re-score later:
joustx replay should-we-rewrite-this-in-rust-AbC123.joustJoust modes
Each mode defines how the two agents and the Herald behave.
| Mode | Agent A | Agent B | Herald does |
|-------------|-------------------|-----------------------|------------------------------------------|
| debate | Argues for | Argues against | Scores rebuttals; declares the winner |
| redteam | Attacker | Defender | Scores exploits vs. mitigations |
| review | Author | Critic | Scores critiques vs. defenses |
| synthesis | Supporting evidence | Contradicting evidence | Synthesizes a balanced view |
| plan | Planner (iterates) | Stress-tester | Evaluates the final, adapted plan |
Set the mode in jousting.toml or override per-run with --mode.
Config reference
A full annotated jousting.toml:
[tiltyard]
topic = "Should we rewrite this in Rust?" # the problem/question
passes = 5 # number of exchange rounds
mode = "debate" # debate|redteam|review|synthesis|plan
save = true # auto-save a .joust replay file
[agent_a]
name = "Lancer"
model = "claude-sonnet-4-6"
provider = "anthropic" # anthropic|openai|gemini|groq|openrouter|ollama
lance_style = "aggressive" # aggressive|methodical|socratic
persona = "senior engineer who loves Rust"
[agent_b]
name = "Paladin"
model = "claude-sonnet-4-6"
provider = "anthropic"
lance_style = "methodical"
persona = "pragmatic CTO who ships in Go"
[herald]
model = "claude-sonnet-4-6"
provider = "anthropic"
scoring = "strict" # strict|lenient|narrative
synthesize = true # produce a final synthesisLance styles shape how an agent fights: aggressive (forceful, leads with the strongest blow), methodical (systematic, builds case by case), socratic (probes assumptions with sharp questions).
Scoring styles shape the Herald: strict (reserves direct hits and unseats), lenient (rewards good points generously), narrative (scores the momentum and story of the duel).
Commands
joustx init # scaffold a jousting.toml (prompts for topic interactively)
--topic "..." --mode <mode> --force
joustx joust # run a single joust (reads ./jousting.toml by default)
--config <path> --topic "..." --passes <n> --mode <mode>
--dry-run # print resolved config + prompts, make no API calls
--no-save # don't write a .joust file
joustx tournament # run a bracket (requires a [tournament] block)
--config <path> --topic "..." --passes <n> --mode <mode>
joustx replay <file.joust>
--speed <1|2|3> # 1 slow, 2 medium, 3 instant
joustx herald <file.joust> # re-score a saved joust
--model <model> --scoring <mode> --saveAdd --no-splash (or set JOUSTING_NO_SPLASH=1) to skip the startup splash for CI/pipes.
The .joust format
Every joust is saved as JSON with a .joust extension, named {topic-slug}-{id}.joust:
{
"id": "abc123",
"version": "1.0",
"created_at": "2026-06-22T12:00:00.000Z",
"config": { "...full config object..." },
"passes": [
{
"pass": 1,
"a": { "agent": "Lancer", "payload": "..." },
"b": { "agent": "Paladin", "payload": "..." },
"score": {
"pass": 1,
"scores": {
"a": { "hit": "glancing", "reasoning": "..." },
"b": { "hit": "direct", "reasoning": "..." }
},
"unseat": false,
"unseated_agent": null,
"commentary": "Paladin landed a concrete operational counterpoint..."
}
}
],
"result": {
"winner": "Paladin",
"synthesis": "...",
"total_hits": { "a": 2, "b": 3 }
}
}Hit types are direct · glancing · miss · unseat. Hit weights (unseat 3, direct 2, glancing 1, miss 0) decide the winner; an unseat ends the joust immediately.
Tournament mode
Run several agents in a bracket. Add a [tournament] block listing the competitors:
[tournament]
format = "roundrobin" # roundrobin | elimination
[[tournament.agents]]
name = "Lancer"
model = "claude-sonnet-4-6"
provider = "anthropic"
lance_style = "aggressive"
persona = "Rust maximalist"
[[tournament.agents]]
name = "Paladin"
model = "claude-sonnet-4-6"
provider = "anthropic"
lance_style = "methodical"
persona = "pragmatic Go shipper"
# add as many [[tournament.agents]] as you likeThen:
joustx tournamentroundrobin— every agent jousts every other; most wins takes the crown.elimination— single-elimination bracket; last knight standing is champion.
The [tiltyard] and [herald] blocks supply the topic, passes, mode, and judge for every match.
Providers & auth
Each agent (and the Herald) picks a provider. Jousting ships adapters for six:
| provider | SDK / transport | Env key | Notes |
|--------------|---------------------------------------|----------------------|--------------------------------------------------|
| anthropic | @anthropic-ai/sdk | ANTHROPIC_API_KEY | default |
| openai | openai | OPENAI_API_KEY | |
| gemini | @google/generative-ai | GEMINI_API_KEY | optional peer dep |
| groq | groq-sdk | GROQ_API_KEY | optional peer dep |
| openrouter | openai (baseURL override) | OPENROUTER_API_KEY | model strings pass through, e.g. meta-llama/llama-3-70b |
| ollama | fetch → /api/chat | none | local; respects OLLAMA_HOST (default http://localhost:11434) |
Keys are read from the environment:
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
GEMINI_API_KEY=...
GROQ_API_KEY=gsk_...
OPENROUTER_API_KEY=sk-or-...
# ollama needs no key; set OLLAMA_HOST if not on the default portKeep them in a .env file and load it with Node's built-in flag — no extra dependency:
node --env-file=.env $(which joustx) joustUsing your Claude subscription (--use-subscription)
If you're logged in with the Claude Code CLI, you can run Anthropic agents on your Claude Pro/Max subscription instead of a metered ANTHROPIC_API_KEY:
claude /login # once, if you haven't already
npx joustx joust --use-subscriptionJousting reads the Claude Code OAuth token from your macOS keychain or ~/.claude/.credentials.json (or the CLAUDE_CODE_OAUTH_TOKEN env var). This applies to every anthropic agent and the Herald.
The access token is refreshed automatically when it's expired (or within 5 minutes of expiring), so a long joust never dies mid-run. Jousting exchanges the stored refresh token at platform.claude.com (falling back to the legacy console.anthropic.com endpoint), then writes the rotated credential back to its origin (keychain or ~/.claude/.credentials.json) so the next run — and Claude Code itself — stay logged in. If no refresh token is available and the access token has already expired, Jousting tells you to run claude /login.
[!IMPORTANT] Anthropic rotates the refresh token on every refresh — the old one dies the moment the new one is issued. To make sure this can never log you out, Jousting writes the new credential back, reads it back to verify it landed, and aborts loudly (telling you to run
claude /login) if the write can't be confirmed — rather than silently leaving Claude Code with a dead token. If you'd rather not risk it at all, useANTHROPIC_API_KEYinstead of--use-subscription.
[!WARNING] Personal use only — this may violate Anthropic's Terms of Service. Driving a Pro/Max subscription through OAuth outside the official Claude Code client is unsupported and could result in rate-limiting or account action. It is never used unless you explicitly pass
--use-subscription. For anything shared, automated, or production, use a realANTHROPIC_API_KEY.
The orchestrator and Herald never import a provider directly — they call getAdapter(provider), which dispatches to the right adapter, all behind a shared LLMAdapter interface. Provider SDKs are loaded lazily, so:
- A missing key fails with
Agent '<name>' uses <provider> but <KEY> is not set. - A missing optional SDK fails with
Install the <provider> adapter: npm install <package>. - An unreachable Ollama fails with
Jousting couldn't reach Ollama at <host>. Is it running?
@google/generative-ai and groq-sdk are optional peer dependencies — install them only if you use those providers.
Contributing
git clone https://github.com/chakra3301/AI-JOUSTING
cd AI-JOUSTING
npm install
npm run dev -- joust --dry-run # run from source via tsx
npm run build # bundle with tsupThe codebase is small and layered: config/ (load + validate), adapters/ (LLM providers), modes/ (the five duel formats), core/ (orchestrator, agent, herald, tournament), display/ (TTY rendering), and commands/ (CLI handlers). PRs welcome — especially new modes and adapters.
License
MIT
