A headless CLI that creates Kleros v2 disputes and submits evidence on Arbitrum One, and on the Arbitrum Sepolia testnet.
One-shot commands, no daemon, JSON in and JSON out.
package @kleros/kleros-disputant-cli · binary kleros-disputant
">https://img.shields.io/badge/node-%3E%3D22-3c873a.svg">
Important
This tool files a case; it does not build one. The claim, the evidence text, the court and the ruling options are always inputs. It never reads counterparty content, never fetches a URI it finds on chain, and never decides whether a dispute is worth creating. Case construction happens upstream — a human, an agent, your business logic.
Today, creating a Kleros v2 dispute means opening the Court web client and signing in a browser. That rules out the party who is a server, a cron job, or an autonomous agent.
This CLI makes the same two writes directly against the selected deployment — Arbitrum One by default:
create-dispute submit-evidence
────────────── ───────────────
DisputeResolver EvidenceModule
.createDisputeForTemplate .submitEvidence
payable — the arbitration fee gas only
template inline in calldata evidence JSON inline
│ │
▼ ▼
KlerosCore holds the dispute indexed against the dispute
and draws the jurors by the subgraph
DisputeResolver is the entry point on both deployments: it is the generic permissionless
arbitrable, so one write path and one payload builder serve either. On Arbitrum One it is also the
only path — the deployed core enforces arbitrableWhitelist unconditionally, so an EOA cannot
call KlerosCore.createDispute at all. That whitelist is a v2 Beta property rather than a Kleros v2
one, which is why it is how the constraint was discovered and not what holds it.
| It does | It does not |
|---|---|
| Quote the arbitration fee before you commit to it | Decide the claim, the court, or the ruling options |
| Create a dispute and register its template | Decide what to upload, or keep it pinned afterwards |
| Submit one evidence document, and upload its attachment | Read, parse or fetch a URI you hand it |
| Report which period a dispute is in | Discover which disputes you are party to |
| Refuse anything that looks wrong, before spending money | Broadcast anything without --broadcast |
Discovery — which disputes exist, what state they are in, what the other side filed — belongs to
@kleros/agentkit, not here. The only reads in this repo are
the ones that can change the decision to sign.
Pre-1.0. 0.1.0 is the first release, and the badge above tracks what the registry actually
has — a version here would only drift from it. The 0.x line says the surface may still move, not
that this is a prerelease. All five commands are built and tested. The read paths are verified live
against Arbitrum One; the second deployment's addresses, versions and wiring are verified live on
Arbitrum Sepolia. The full lifecycle has run against the live v2 testnet under
pnpm test:acceptance — quote, simulate, create, submit evidence, status, each through the built
binary in its own process — which created core dispute 128 in court 1 and filed evidence against it
under local dispute 78. Those two IDs are the record of one run, not a fixture: the suite creates a
new dispute every time it runs. Both write paths also broadcast on an Arbitrum One fork under
pnpm test:fork, and upload-file is measured against the live pinning endpoint.
The tool has broadcast three times, all on the v2 testnet. That count is the signing key's nonce on Arbitrum Sepolia rather than a tally kept by hand, so it cannot drift from what the chain says.
No transaction has been broadcast to Arbitrum One by this tool, and releasing does not change that. The count here is 0, and unlike the three above it is not a nonce: that key's nonce on Arbitrum One is 12, none of them ours, so the 0 is stated with the 12 or it misleads. The testnet rehearsal that exists to precede the first Beta write has happened; the write itself has not, and it will be made with the released package rather than from a working tree (ADR-0018). Treat that first live dispute as the shakedown run, on a cheap court, with a ceiling you can afford to lose.
| Command | Signing key | On-chain write |
|---|---|---|
arbitration-cost |
— | never |
status |
— | never |
create-dispute |
required | createDisputeForTemplate, only with --broadcast |
submit-evidence |
required | submitEvidence, only with --broadcast |
upload-file |
— | never — no chain at all, only with --publish |
- Node.js ≥ 22. pnpm as well, but only to build from source
- An RPC endpoint for the deployment you are acting on — Arbitrum One by default, Arbitrum
Sepolia with
--chain arbitrum-sepolia-testnet. The public ones work and are rate-limited; pass your own with--rpc-url(comma-separated for automatic failover), or exportKLEROS_RPC_URL_ARBITRUM_ONE/KLEROS_RPC_URL_ARBITRUM_SEPOLIA_TESTNET. The variable picks an endpoint and never a deployment (ADR-0016) - The private key of the party creating the dispute, in a file this tool is pointed at
- ETH on that deployment's chain in that account. It sends its own transactions and pays its own arbitration fee; there is no relayer
npm i -g @kleros/kleros-disputant-cli # puts `kleros-disputant` on your PATH
kleros-disputant --versionOr run it without installing: npx @kleros/kleros-disputant-cli arbitration-cost --court 1 --jurors 3.
From source, which is what you want if you intend to change it:
git clone https://github.com/kleros/kleros-disputant-cli.git
cd kleros-disputant-cli
pnpm install
pnpm build
pnpm link --global # puts `kleros-disputant` on your PATHOr skip the link and run it in place: pnpm dev arbitration-cost --court 1 --jurors 3.
mkdir -p ~/.kleros-disputant
printf '0x%s' "<64 hex chars>" > ~/.kleros-disputant/key
chmod 600 ~/.kleros-disputant/keyThen pass --key-file ~/.kleros-disputant/key.
The key is read only from a file. There is deliberately no --private-key flag and no
PRIVATE_KEY environment variable: this process is meant to be launched by an agent gateway that
also runs model-authored shell commands, and anything in the environment is inherited by every
child process. A file is not a security boundary against a compromised host — but it is not
ambient, which is the difference that matters here. The tool refuses to run if the file is
readable by group or others, and the key never appears in any output.
Note
The party creating the dispute must not be a juror in the same dispute. That is not cheaply detectable on chain and this tool does not check it. It is your responsibility, and nothing here should be read as a guarantee about it.
kleros-disputant arbitration-cost --court 1 --jurors 3{
"ok": true,
"command": "arbitration-cost",
"deployment": "arbitrum-one",
"chainId": 42161,
"requested": { "court": "1", "jurors": "3", "disputeKit": "1" },
"extraData": "0x00…0001",
"arbitrationCost": { "wei": "15000000000000000", "eth": "0.015" },
"warnings": [],
"message": "Creating a dispute in court 1 with 3 jurors costs 0.015 ETH. Nothing was sent…"
}Reads only, needs no key. It runs the same court, juror-count and kit checks as create-dispute,
because KlerosCore will happily quote a price for a court that does not exist.
The template is what jurors are actually asked. It is a JSON file you author, and it travels inline in the calldata — there is no CID and no upload step.
{
"title": "Late delivery under order #4417",
"description": "The seller did not deliver within the agreed window.",
"question": "Should the escrowed funds be released to the buyer?",
"answers": [
{ "id": "0x1", "title": "Yes, refund the buyer", "description": "The goods never arrived." },
{ "id": "0x2", "title": "No, pay the seller", "description": "Delivery was made on time." }
],
"policyURI": "/ipfs/QmWQV5ZFFhEJiW8Lm7ay2zLxC2XS4wx1b2W7FfdrLMyQQc",
"version": "1.0"
}Four things to know:
- You do not write the arbitrator. A template names the deployment it is filed on, in
arbitratorChainIDandarbitratorAddress— and the example above states neither, because both are derived from--chain. The same file then works on either deployment. If you do state them, they are checked: a value that disagrees with--chainis refused before anything is contacted, and the fix is to delete the field, not to retype an address. That refusal is what stops a template carried across from another deployment, and what stops one copied from Kleros's published examples — those name a different arbitrator, and spec/02 §3.1 prints the address so you can recognise it. - Answer
0x0is reserved for Refuse to Arbitrate and is never in the array. The number of ruling options is derived fromanswers, so there is no separate flag that could disagree with it. - The schema is strict. An unknown field is rejected by name, not silently ignored — a typo'd key would otherwise reach jurors as a missing one. Why it is strict rather than lenient: ADR-0010.
policyURIis recorded, never fetched. So is any--file-uriyou attach to evidence.
kleros-disputant create-dispute \
--court 1 --jurors 3 \
--template-file ./dispute.json \
--max-cost-eth 0.02 \
--key-file ~/.kleros-disputant/keyPlans, quotes, pre-flights and simulates — then stops. "status": "simulated", and the message
says in words that nothing was sent.
kleros-disputant create-dispute … --broadcast--broadcast is the confirmation. There is no prompt, because there is no human assumed to be
watching.
kleros-disputant upload-file --file ./delivery-photos.pdf # checks, uploads nothing
kleros-disputant upload-file --file ./delivery-photos.pdf --publish # uploads, prints the URISkip this if you already have a pinned URI — --file-uri still accepts one you produced yourself.
This is the only command that speaks HTTP, and the only one with no chain in it: it never signs,
never reads the chain and never loads a key. --publish is to it what --broadcast is to the
other two, for the same reason — content addressed by a CID cannot be withdrawn. It prints the
--file-uri and --file-type-extension for the next step, and says in words that nothing has been
submitted yet.
Uploads go to the unauthenticated Kleros pinning endpoint, so there is still exactly one credential
in this tool: the signing key. Point --upload-url at your own deployment of the same function if
you would rather not use it. What was measured against it, and what could not be, is in
docs/spec/06-attachment-upload.md and
ADR-0012.
# 215 stands in for a real core dispute ID here and below; it is an example, not a live dispute.
kleros-disputant status --dispute 215 # is the evidence period still open?
kleros-disputant submit-evidence \
--dispute 215 \
--name "Delivery photographs" \
--description @statement.md \
--file-uri /ipfs/QmWQV5… --file-type-extension pdf \
--key-file ~/.kleros-disputant/key --broadcast--name and --description each take a literal string, @path to read a file, or - for stdin.
Prefer @path for anything long: it keeps the text out of the process table.
Full option reference is in the tool itself — kleros-disputant <command> --help, or
kleros-disputant --llms-full for the machine-readable manifest. It is not repeated here, so it
cannot go stale here.
Output is JSON on stdout by default, because the primary consumer is a program. There is no
--json flag and no --verbose flag; --format selects another shape if you want one, and
--full-output reveals incur's outer {ok, data, meta} envelope.
Errors carry a stable machine-readable code, a message that says whether anything was sent, and
often a cta naming the next command to run:
{
"code": "INSUFFICIENT_BALANCE",
"message": "The account holds 0 ETH and the arbitration fee alone is 0.015 ETH, before any gas. Nothing was sent. Deployment: arbitrum-one (chain 42161).",
"cta": {
"description": "The arbitration fee is paid on creation and cannot be recovered.",
"commands": [
{
"command": "kleros-disputant arbitration-cost --chain arbitrum-one --court 1 --jurors 3 --kit 1",
"description": "Quote the fee without committing to it"
}
]
}
}Branch on code, not on the exit status. Exit codes are coarse buckets for shell callers —
0 success (including simulated and unknown) · 1 validation or refusal, nothing sent ·
2 chain or RPC failure · 3 the transaction or its outcome went wrong · 4 signer or key
failure — but the payload is the real contract.
Warning
If a broadcast returns "status": "unknown", the CLI stopped watching; the transaction may
still land. Run status before doing anything else. Never re-send blindly — a duplicate
create-dispute pays the arbitration fee a second time and creates a second dispute.
These are enforced in code, not left to the caller:
- A wrong court is refused locally, because the chain will not refuse it. A bad court ID, a zero
juror count or a malformed
extraDatadoes not revert: KlerosCore's decoder substitutes the General Court, its own juror count and the Classic kit, and creates a paid dispute in the wrong place.simulateContractcannot catch that. So the court is validated, kit support is re-read every time, and the effective court, juror count and kit are echoed back — a difference from what you asked for is an error, not a warning. - The fee is quoted with the byte-identical
extraData, immediately before sending, and exactly that amount is sent. The chain protects you against underpaying, not against overpaying.--max-cost-ethis enforced locally the moment the quote arrives, before anything is simulated. - Simulate first, always. Every state-changing call is simulated, and nothing is broadcast
without
--broadcast(ADR-0004). - Two deployments served —
arbitrum-one(v2 Beta, chain 42161), which is the default, andarbitrum-sepolia-testnet(v2 testnet, chain 421614) — selected by--chain. A chain ID does not identify a deployment; three answer 421614. A slug this tool does not serve is refused before anything is contacted. The chain ID is asserted with a liveeth_chainIdcall against that deployment's own expected value, and no contract call is made before it — resolving an address is local, using one on an unverified chain is the hazard (ADR-0015). The two refusals are distinct on purpose:CHAIN_NOT_SUPPORTEDis a slug this tool does not serve, refused before anything is contacted, andWRONG_CHAINis an endpoint answering a chain ID the selected deployment does not expect. Collapsing them would tell someone who mistyped a slug to go and check their endpoint. - Fees are paid in ETH. The ERC-20 path is unresolved, so there is no
--fee-tokenflag: the broken path cannot be asked for (ADR-0008). - Late evidence warns; it never refuses.
submitEvidencehas no period gate on chain, and the submission is indexed either way, so refusing would be this CLI inventing a rule the protocol does not have. The two hard refusals are both unreachability: a dispute ID that does not exist, and a dispute another arbitrable created (ADR-0011, ADR-0014). - Evidence never enters this process as data. It is bytes on the way to a transaction: never parsed, never interpolated into anything executable, never able to influence which call is made. Everything written in a dispute is authored by someone with an interest in the outcome (ADR-0007).
Caution
Creating a dispute spends money and cannot be undone. The arbitration fee is paid on creation and is not refundable to the creator. There is no cancel, no withdraw, and no second chance at choosing the court.
Every command is self-describing: --help for humans, --llms / --llms-full for a manifest,
--schema for JSON Schema. incur also gives the binary a mcp group (register it as an MCP
server) and a skills group.
kleros-disputant skills add # installs six skills: one per command, plus the hand-written oneincur generates a skill per command from the definitions themselves, so the flag tables can never
drift. skills/kleros-disputant/SKILL.md is the one written by
hand and carries what a generator cannot: the order to call things in, what each irreversible step
costs, and a troubleshooting table keyed on error code. It restates no flags, for the same reason
this file does not.
The framework-free core is importable too, if you would rather build the calls yourself — the same package, installed locally rather than globally:
npm i @kleros/kleros-disputant-cliimport { encodeExtraData, buildTemplate, checkPreflight } from "@kleros/kleros-disputant-cli";Everything under src/core/ is free of CLI concerns, never throws, and returns a KlerosResult<T>;
src/commands/ is the thin layer that owns argument parsing, exit codes and output. That split is
deliberate — it is what should let the core move into @kleros/agentkit as close to a file move as
possible (ADR-0001).
pnpm test # unit + guard tests; a suite whose prerequisite is absent self-skips loudly
pnpm test:fork # spawn an Arbitrum One fork on :8546 and run only the fork tests (needs anvil)
pnpm test:acceptance # full lifecycle against the live v2 testnet, through the built binary.
# Broadcasts permanently and creates real testnet disputes. Needs a funded
# testnet key. The opt-in is the name of this script, never a variable, so
# nothing else — `pnpm test` included — can arm it
pnpm typecheck
pnpm lint # biome check . (`pnpm exec biome check --write .` to fix)
pnpm build
pnpm dev status --dispute 215Addresses and ABIs are imported from @kleros/kleros-v2-contracts in
src/core/deployment.ts and never hand-copied; it is bundled at build
time, so it stays a devDependency and adds nothing to your install. The package ships the deployed
artifacts, which differ from what master compiles to — a fingerprint test asserts exactly that on
every run and fails the build if upstream drifts, rather than letting a transaction find out
(ADR-0006).
The runtime dependencies are exactly two: incur and viem. upload-file speaks HTTP through
Node's own fetch, FormData and Blob, so it adds no third.
| Where | What |
|---|---|
CONTEXT.md |
The glossary — read this first. Filing vs case construction, the three dispute IDs, ruling option vs choice, and the near-synonyms to avoid |
docs/spec/ |
The normative specification. Start at its README.md for the map, the reading order and the verification markers |
docs/adr/ |
One file per decision a reader would otherwise question. Numbers 0003 and 0005 are deliberately unused — juror-only decisions this repo never made, left as gaps so ADR-0004 means the same thing in both repos |
CLAUDE.md |
The invariants, as a guard-rail index for agents contributing to this repo |
CHANGELOG.md |
What changed in each released version, and what the release retired |
This repo inherited no specification: one was written here from the deployed contracts, and every
chain fact in it carries a marker saying how it was established — [live], [fork], [abi],
[computed], [client], [inferred], [maintainer], and [service] for the one thing here that
is not a contract. [client] and [inferred] claims must not be depended on without a fork
test, and [fork] is what a claim becomes once one has settled
it. [live] claims are stamped with a date and a block; re-run
docs/spec/05-verification.md §4 to refresh them.
Sibling repos, for orientation: kleros-juror-cli is
the other half of the write plane and the source of this architecture;
@kleros/agentkit is the read plane and a peer CLI the same
agent calls.
What is done is described under Status, not repeated here — a roadmap that carries completed items is a changelog with checkboxes, and the same fact asserted in two places goes stale in two places.
- Fund the signing key on Arbitrum One and make the first Beta write
- Upstreaming
src/core/into@kleros/agentkitonce its write milestone lands
Issues and pull requests are welcome. Three things to know before you start:
- Read
CONTEXT.mdand use its vocabulary. This domain is full of near-synonyms that quietly mean different things — core dispute ID vs local dispute ID, dispute template vs the v1 term for it, ruling option vs choice. The CLI's own surface is machine-checked against that glossary, so a wrong word fails the test suite. docs/spec/is normative. Read it before writing domain logic and cite it by section (spec/01 §4.4). If you establish a new chain fact, it goes there with a marker.- A change to the command surface, the option defaults or the JSON envelope is a change to this file too. If an ADR covers the behaviour you are changing, update the ADR in the same PR.
Please do not open a public issue for a vulnerability in key handling, payload construction, or the broadcast path. Use GitHub's private vulnerability reporting on this repository, or contact the maintainers directly.
This is pre-1.0 software that holds a key and spends real ETH on an irreversible action. Read the code before you point it at a real dispute.
MIT — see LICENSE.