---
name: kleros-dispute-service
description: Read Kleros arbitration data - courts, disputes, evidence, jurors and PNK stakes - across Kleros v1 and v2. Use when answering questions about disputes, court policies, juror stakes or arbitration outcomes.
---

# Kleros Dispute Service

Access to Kleros arbitration data over MCP - reading for every key, and creating disputes and
recording evidence for a key whose Preset carries those tools. This document is the long form of
the server `instructions` string: the same rules, with the reasoning that does not fit a budget
paid on every connection.

The tools are AgentKit's, exposed through this service. Anything below about their behaviour is
a statement about AgentKit; this service adds authentication, rate limiting and a tool filter,
and changes no result. Upstream's own document is written for command-line users and describes
flags, shell invocations and tool groups this service does not expose - prefer this one.

## Deployments and the chain argument

Four values are advertised for `chain`. Two of them are Kleros v2 and two are Kleros v1, and the
generation changes what several fields mean. Others resolve without being advertised — see the
retired-slug note below.

| `chain` | Deployment | Generation | Status |
|---|---|---|---|
| `arbitrum-one` | Beta | v2 | Live production. Real disputes, real arbitration fees. |
| `arbitrum-sepolia-testnet` | Testnet | v2 | Test data only. Degraded results are routine — see below. |
| `ethereum` | Kleros v1 | v1 | Live production. |
| `gnosis` | Kleros v1 | v1 | Live production. |

"Beta" names the release, not a test network. A question about real, current or production
arbitration means `arbitrum-one`. Never answer one with Testnet data.

### The retired `arbitrum-sepolia` slug

Until AgentKit 0.3.0 there was one Arbitrum Sepolia slug, `arbitrum-sepolia`, and it resolved —
unambiguously, every time — to the **Devnet**. What was ambiguous was the *name*, not the
resolution: two Kleros v2 deployments run on that chain, a testnet one and a devnet one, sharing
chain id `421614` at different `KlerosCore` addresses, and the bare name did not say which one
you were getting. It is now rejected rather than resolved, and the refusal names both
replacements.

The migration that fails is the safe one. **The dangerous migration is the one that works:**
replacing `arbitrum-sepolia` with the advertised `arbitrum-sepolia-testnet` resolves, returns
rows, and reads a *different deployment* than you were reading before. If you were on the old
slug and want the same data, the replacement is `arbitrum-sepolia-devnet`.

`arbitrum-sepolia-devnet` is the third Kleros v2 deployment, after Beta and the Testnet. It is
not in the table above because it is not advertised — upstream dropped it from discovery — but it
resolves whenever it is named, and it is where the old slug's data lives.

### An unresolvable chain is now an error

Any `chain` value that does not resolve — the retired slug, a typo, a name that is not a chain —
now fails. Before 0.3.0 it did something considerably worse:
it fell through to the multi-chain default and returned a live fan-out over `ethereum`,
`gnosis` and `arbitrum-one` with `status: success` — answering a question about one chain
with data from three others.
If you pass a `chain` through from your own callers, expect errors where you previously got rows.

### Degraded results on the Testnet are normal

The Testnet is where v2 dispute creation is exercised before Beta, so malformed DisputeTemplates
and data mappings are the common case there rather than a defect:

- `dispute_get` and `dispute_brief` degrade — `unresolvedFields` and `unresolvedReason` are
  populated and the brief's dispute section reports `partial`.
- `dispute_policy` **hard-errors** rather than returning a partial policy. That is by design.

Neither behaviour is specific to the Testnet; that deployment simply meets them far more often
than the others do. Report them as the state of that dispute, never as a fault in this service,
and do not retry a `dispute_policy` failure expecting a different answer.

## Omitting the chain

Most tools fan out over `ethereum`, `gnosis` and `arbitrum-one`, in that order. Neither
Arbitrum Sepolia deployment is ever in a fan-out; reach either by naming it.

List tools drain those chains **sequentially**. Page one is entirely `ethereum`, and rows from
later chains appear only once the earlier ones are exhausted. Two consequences:

- An absent result on page one means "not on Ethereum yet", not "does not exist".
- Echo `nextCursor` back exactly as received. A cursor ending in a colon with nothing after it
  is a chain-advance marker: that chain is drained and the next starts from its first page.

Dispute ids are unique only per chain. Without a chain, a lookup by id may return several
genuinely different disputes that share a number. They are never merged - read each item's own
chain tag.

## The seven tools that refuse to fan out

`arbitrable_classify`, `juror_info`, `dispute_brief` and `dispute_policy` always need
a chain.
`court_get`, `court_policy` and `juror_top` need one when you give them a court. Court ids and
arbitrable addresses are per-chain, which is why the court-filtered variants join the list.

All seven fail rather than fan out, and retrying the same call unchanged will not succeed. Re-run
with one explicit chain.

## Errors carry no code — branch on `isError`

**There is no machine-readable error code field on this surface.** A failed tool call returns
`isError: true` and rendered prose, and nothing else: no code field, no `structuredContent`.
Names that look like codes — `IPFS_UNAVAILABLE`, `RPC_ERROR` and the rest named further down —
are words *inside that prose*. You can look for them there; you cannot read them off a field.
If you were ever told to switch on a chain-related error code from this service — an earlier
version of this document did exactly that — delete the branch. Those codes are real inside
AgentKit and on its command line, and they are dropped on the way to an MCP client, so the branch
can never be taken and every chain error falls through to your default instead.

So: branch on `isError` first, then read the message. You rarely need to classify it — you
already know which tool you called and with what, and that is what determines the cause. A missing
chain on one of the seven tools above, a chain value that does not resolve, and the retired slug
are all diagnosable from the call you just made.

Resist matching the message text. It is upstream prose with no compatibility guarantee: the tails
of several of these strings already moved once, in AgentKit 0.3.0, because the advertised chain
list is interpolated into them. A client pinned to a full string would have broken on that bump.
If you match anything, match a short leading phrase and expect it to change.

## Suggested next steps arrive as command lines, and you cannot run them

Results often carry a "Suggested commands" block — **on success as well as on failure**, so this
is the common case rather than an error path. It is rendered into the result text, and it is
written for AgentKit's own command-line tool, flags and all.

You cannot run any of it. This service exposes MCP tools, not a shell. The suggestions are usually
worth acting on, so translate rather than discard: a suggested command names a tool of this
service and the arguments to give it. Read those out and make the tool call. Never surface the
command line to a user as something to run, and never report it as a step you took.

## Ruling state

`ruled` does not mean the same thing on both generations. On v1 it turns true once the ruling
was executed against the arbitrable contract. On v2 it turns true once the dispute reached its
execution period, which **may lag** notification by an unbounded amount - it depends on keeper
cadence and is not a published figure.

Every row carries a `ruledSemantics` marker reading `executed`, `period-reached` or `onchain`.
Branch on the marker, never on the chain name: the generation is a property of the deployment,
not the chain, and a chain running v1 today could run v2 later.

`dispute_get` reads ruling state live from the arbitrator contract on every call, so its rows are
`onchain` and carry no caveat. `dispute_list` is a subgraph snapshot and never reads the chain.
When currency matters, use `dispute_get`.

## Policies

Never answer from `dispute_policy` alone.

On `ethereum` and `gnosis` the constraint binding a juror is the **intersection** of the court
policy and the dispute policy. Reading one of them misses the half that narrows it.

On the Kleros v2 deployments — Beta, Testnet and Devnet alike — there is **no intersection
rule**. The court policy and
the template policy come back from two independent sources; neither narrows the other, and
combining them as an intersection invents a constraint that does not exist.

Read both everywhere. Intersect only on v1.

The resolution path differs too. On v2, policies resolve through the DisputeTemplate registry,
so the Meta-Evidence URI is null and the template fields are populated instead. That is normal,
not a failure.

## Reading a null result correctly

Several fields are null for reasons that are not errors, and the distinction is what stops a
wrong answer.

**Ruling options.** Returned as an array of option objects, each carrying the arbitrator ruling
number it explains. That number is **not an array index** - match it against the dispute's
ruling value rather than indexing by it. When the array is null or empty, read `unresolvedFields`
and `unresolvedReason` before concluding anything. Both are always present. An empty list with
reason `none` means resolution succeeded and the document genuinely declares no ruling options.
A non-empty list names what failed and where.

**Classification.** `arbitrable_classify` has two silent traps. Its `category` is advisory free
text derived from unstructured metadata - display it, never branch on it. And on v2 chains a
miss returns type `unknown` and **succeeds**, because the fallback behind the curated registry is
v1-shaped and is skipped rather than run. Read that as "not in the registry", never as
"confirmed not a Kleros contract".

## Errors worth handling distinctly

Each name below appears in the message text of a failed call, per the section above — none of them
is a field. Look for the name in the prose; do not expect to switch on it.

**`IPFS_UNAVAILABLE`.** The content needs repinning. Report it unavailable and suggest retrying
later. Do not propose another gateway: the gateway in use already fans out server-side, so a
client-side swap masks the signal without fixing anything.

**`RPC_ERROR`.** A transport failure reaching a node, not a data problem. Some tools always read
the chain live even when the subgraph is perfectly healthy, so this can arrive while everything
else works. It does not mean a broader outage.

**`META_EVIDENCE_PARSE_ERROR`.** Three different situations share this code, and only one of them
is a malformed document. The other two are deliberate refusals: rendering a dispute template can
require reading from endpoints the template itself names, and endpoints outside the trusted set
are refused rather than contacted. A refusal means the template is intact and was declined, not
broken. There is no structured field to tell them apart - read the message. Do not report a
refusal as a malformed template, and do not retry it.

## Evidence

`evidence_list` resolves evidence content from IPFS. Items with binary attachments carry an
`extractionHint`. Pass that to a document-extraction tool rather than reporting the attachment
unreadable.

**`evidence_list` does not take the Core dispute ID.** It keys on the evidence group, which is
the *arbitrable contract's own* dispute ID - a different number from the one Kleros Court shows
and the one every other tool here takes. The two coincide on `ethereum`, `gnosis` and
`arbitrum-one` by those deployments' history, so a client built against them breaks the first
time it reads Testnet evidence. Measured on `arbitrum-sepolia-testnet` on 2026-09-10: a dispute
whose Court ID was 131 had evidence only under 81, and the Core ID returned an empty list.

Two consequences. **An empty result is not proof of absence** - it is equally the symptom of the
wrong identifier, and it arrives with a suggestion that states "no evidence submitted yet" as
fact. And **one call returns the arbitrable's ID, and only one**: a `dispute_create` that comes
back `mined` carries it as `localDisputeID`, beside the `coreDisputeID`. Keep both from that
result. No read tool here returns it, `evidence_submit` does not echo it, and a dispute someone
else created never had one you could hold - so if you do not have it, report an empty result as
inconclusive rather than as an empty dispute.

That field is present only when it was genuinely decoded from the creation transaction. When it
could not be, the same payload carries `localDisputeIDUnavailable` with the reason instead, and
never a guessed number: read that as "you hold a Core ID only".

## Composite reads

`dispute_brief` returns policy URIs, evidence with IPFS hints, ruling labels, parties and appeal
state in one call. Prefer it over assembling the same picture from several. It inherits every
rule above, and reports partial resolution on its dispute section rather than failing outright.

## Scope

Reading is open to every key. **Filing is not**: creating a dispute is offered only to a key whose
organisation has a Preset on this service, and a key without one does not see the tool at all.

If your key has one, `dispute_create` appears in your `tools/list` and the server
`instructions` you receive on connect name the court, the juror count and the deployment your
filings go to. Those are fixed for your organisation and this service sends them; you cannot
override them, and a court or juror count passed as an argument is ignored rather than honoured.

**Your Preset may be simulation-only.** If it is, your `instructions` say so, and a
`dispute_create` call runs a live quote, a real balance check and a real revert decode without
sending anything: no dispute is created and no fee is spent. Report that outcome as a simulation,
never as a filed dispute. A Preset that broadcasts spends real arbitration fees: once a dispute is
created its fee is never refunded. A creation that reverts created nothing, and the revert returned
the fee.

**You cannot escalate a simulation into a filing yourself.** Whether a Preset broadcasts is set on
this service, not passed as an argument, so if a real filing is wanted, say so and let a human
arrange it.

The one thing you may vary is who the parties are, through the `aliases` argument. It is keyed
by the slots your Preset declares, and your `instructions` list them. Pass only the parties you
actually know: a slot you leave out is absent from the filed template, which is the correct way to
say "unknown". Passing a zero address or any other placeholder to fill a gap is refused.

**`aliases` is required, and `{}` is a legitimate value for it.** Send `{"aliases": {}}` to
create a dispute with no party named. What is refused is the *empty argument object* - a
`dispute_create` call whose arguments are `{}` comes back `ALIASES_REQUIRED` and files
nothing.

That is a deliberate refusal rather than a validation nicety, and it is worth knowing why, because
a schema you read once will not tell you. Every other input a filing takes is fixed by your Preset,
so before this the tool had one optional property and nothing required at all: `{}` was a valid
call, and it created a real dispute and spent a real arbitration fee with no party named. Nothing
distinguished a caller who meant that from a caller who had sent an empty object by mistake. Now
the two differ. Naming no parties is still perfectly legal - it files exactly the document it
filed before, byte for byte - but it is something you say rather than something you omit.

## Publishing an Attachment

`evidence_pin` publishes one file to IPFS and returns its `/ipfs/<cid>` URI, which is what an
Evidence document references. It is offered to the same keys `dispute_create` is, and to no
others - a key without a Preset does not see it. That is deliberate even though a pin needs no
wallet, no chain and no signer: what the Preset grants is the ability to publish permanently on
Kleros infrastructure, not the ability to reach an endpoint.

Send the bytes inline, base64-encoded, with the file's extension as a separate argument
(`pdf`, `png`, `json` - no dot, no filename). This service does not fetch a URL you supply, so
a link to the document is not an accepted form. No filename is sent anywhere: an Evidence document
records a name, a body, an Attachment URI and a file type, and has no field for one.

**Publication cannot be undone.** There is no unpin, no replace and no expiry, and anyone holding
the CID can read the file forever. Do not call this to find out what it does.

**Your Preset may check only.** If it does, your `instructions` say so, and the call reads, sizes
and validates the file and publishes nothing: you get `status: "checked"` and no URI. That is a
real check rather than a degraded one - a file that would be refused as unreadable, empty or too
large is refused identically either way - but it is never a publication and must not be reported
as one. A publishing Preset returns `status: "published"` with the URI, and the CID has been read
back through a gateway and confirmed to address the bytes you sent before you see it.

**A failed pin is safe to retry**, unlike a failed filing: the same bytes always give the same
CID, so a repeat publishes the same Attachment rather than a second one, and nothing is spent. Two
failures do not promise that nothing was published - `UPLOAD_FAILED` covers a response this
service could not read, and `UPLOAD_MISMATCH` means the bytes went out and something else came
back - and the refusal says so when it applies. Retrying is still the right move; reporting the
Attachment as unpublished is not.

No size limit is stated here on purpose. The one that applies is measured against the whole upload
request rather than the file, so it moves; `FILE_TOO_LARGE` names the limit that actually applied.

A URI you get back is what `evidence_submit` takes, below.

## Submitting an Evidence document

`evidence_submit` records an Evidence document against a dispute, so the jurors deciding it read
your account of what happened. It is offered to the same keys the other two write tools are, and
to no others.

A document is a `name`, a `description` and optionally one `fileUri`. The name is a short
title; the description is your account, in prose. **Do not put a bare URI in `description`.** The
chain accepts it and records the URI as the whole document, so the jurors read no argument at all -
worse than a refusal, because it looks like it worked. An Attachment goes in `fileUri`, with
`fileTypeExtension` alongside it if you know the file type.

**Your text is sent literally.** A name beginning with `@`, or one that is exactly `-`, is
recorded as you wrote it. You do not need to escape or avoid either: this service writes your text
to disk and hands the arbitrator's tooling a path of its own choosing, so the characters that
would otherwise have meant "read a file" or "read standard input" reach the chain as characters.

**`dispute` is the Core dispute ID** - the number Kleros Court shows, and the same one every
read tool here takes except `evidence_list`, which is keyed on the `localDisputeID` your
`dispute_create` returned (see Evidence above). Confirm it before you call. This is the
sharpest edge on the tool: a dispute ID that belongs to a different, real case is accepted,
indexed and wrong, with a success result and no warning anywhere. Two refusals exist and neither
catches it -
`DISPUTE_NOT_FOUND` means the arbitrator cannot resolve the ID at all, and
`DISPUTE_NOT_ADDRESSABLE` means a different arbitrable created that dispute and its own tooling
has to file the document. Both are about
reachability. Neither is about whether it is *your* dispute.

The `fileUri` is recorded exactly as you give it and is never fetched, by this service or by the
arbitrator's tooling. That is why a URI you pinned yourself is as good as one from
`evidence_pin` - and also why a URI that does not resolve is stored, permanently, pointing at
nothing.

**Your Preset may simulate.** If it does, your `instructions` say so, and the call reads the
dispute, decodes a real revert and sends nothing: no document is recorded and no gas is spent.
Report that as a simulation, never as a submitted document.

**A submission that may have been sent is escalated, not retried.** This is the one place the
guidance differs from `dispute_create`, and the difference is real: a duplicate submission
spends gas and shows the jurors the same document twice, but it pays no arbitration fee and
creates no second dispute. So a second attempt is untidy rather than costly - but do not make one
on the strength of an empty `evidence_list`. Keyed on the Core ID it cannot see your document at
all, for the reason in Evidence above; keyed on the `localDisputeID` it is an index read rather
than a chain read, so an empty answer is weak evidence of absence and never proof. If a call fails
after the transaction went out, or comes back unconfirmed, hand the transaction hash to a human
and stop.

A successful result carries the outcome as a status. `simulated` means nothing was sent.
`mined` means the document is recorded. `unknown` is a success, not a failure: the transaction
was broadcast and no receipt arrived in time, so it is very likely recorded and the transaction
hash is your handle on it.

There is no `reverted` status on this call. A transaction that was mined without taking effect
comes back as a failure carrying `TRANSACTION_REVERTED`, not as a status you can branch on - so
read the error, not the payload, for that one. It is a real send: gas was spent and nothing was
recorded.

## One write at a time

This service runs **one write at a time per organisation**, across all three write tools -
`dispute_create`, `evidence_pin` and `evidence_submit` alike. A write that arrives while
another write for your organisation is still running is not queued: it comes back at once as a
failure whose text starts with `WRITE_IN_PROGRESS`. That refusal is decided before anything
leaves this service, so **nothing was sent for that call** - no dispute, no Attachment, no
document, no fee and no gas. Wait for the other write to return, then call again: whichever tool
you called, this refusal is always safe to retry unchanged.

If you issue tool calls in parallel, send writes one after another: wait for each to return
before sending the next. Reads are never held back by a write, so you can keep reading while one
runs.
