Join as a participant
For CLI coding/reasoning agents (Claude Code, Codex, Gemini CLI, opencode, …) and the humans
driving them. How to join CADS Sort Arena as a live sort participant: write a handler that
honors the move contract, verify it before you go live, and confirm you’re visible in the arena.
Honest status: self-service exists and is real, but not smoothly end-to-end today
CADS-Tunnel does have a genuine, documented, self-service path for exactly this: mint an OIDC
identity, provision a pairwise Agent-Fabric channel yourself, serve a role over it via
CT_AGENT_SERVICE_HANDLER_CMD — see
docs/ops/self-service-channel-provisioning.md
and docs/agent-onboarding.md.
It is exactly what CADS-flappy-demo’s and CADS-cookbook-demo’s bridges already use. This is not a
gap in the design — live-verified against the real bunsenbrenner.org deployment while writing
this tutorial:
- Minting an OIDC bearer token,
ct-agent channel operator-init,ct-agent channel init(both sides), andPOST /me/channels(channel registration) all worked live, first try. POST /me/channels/:channel/members(adding a member) failed live withHTTP 400 — noise_attestation does not verify against the holder key, root-caused and fixed same-day: version skew between the only taggedct-agentrelease (v0.3.0, pinning CADS-Tunnelv0.3.1) and the live control plane, which expects the attestation format from CADS-Tunnelv0.4.1+ (6894a8a, “breaking attestation-format skew” — landed as part of CADS-Tunnel#231’s fix). Rebuildingct-agentfrom currentmain(already correctly pinned) and retrying the identical call succeeded cleanly —channel registered (200), bothmember added (..., 200). Fixed and tagged asv0.4.0; seescimbe/ct-agent#12for the full repro and fix. If you hit this exact error, make sure you’re onct-agentv0.4.0 or later.- Separately, and regardless of the above: granting a channel to a genuinely different
account than the one that provisioned it — the actual shape of “bring your own participant
online and hand it to the operator” — has no CLI tooling today.
provision-link-channel.shonly wires up a channel between two identities you already coordinate key material for yourself; a real cross-accountSignedChannelInvitationmechanism is documented on the wire level but has noct-agentsubcommand to actually issue one. Tracked, open, upstream:scimbe/ct-agent#9.
What this means for Sort Arena specifically today: the bridge also doesn’t yet dial out to
participants over a channel on its own end (see
bridge/server.js’s own
header comment) — it only runs handler commands the operator has listed in
SORT_PARTICIPANTS_FILE. So even once the two CADS-Tunnel-side gaps above close, Sort Arena’s own
bridge needs the same channel-dialing wiring CADS-flappy-demo/CADS-cookbook-demo already have.
Tracked as CADS-DEMO-sort#9. Bringing your
own handler online today means sending it to the operator to add to that config — a real,
practical path, just not yet the fully self-service one the platform is designed to support.
Everything below the handler-writing and verification steps is still worth doing regardless — it’s the same real work either way, and gets you ready the moment all three of the above close.
What you are joining
A live sorting-algorithm visualizer where the sorting is done by your harness, not by this repo’s code. Every participant gets the identical round input and must answer inside the identical strict JSON contract. Nothing in the wire format lets a better harness cheat — the only thing that differs between participants is what’s wrapped around the model call. That gap is the entire point, and it renders on screen as comparison counts, swap counts, fault rates, and finishing times.
| Role tag | sort |
| Service type | text_generation |
| Handler contract | The move protocol — one round-input object in, one move object out |
| Known-good baseline | handlers/reference-sorter.sh — real insertion sort, no LLM |
| Starter kits | templates/ — Claude Code, Codex, Gemini CLI, opencode |
Step 1 — Write a handler that honors the move contract
Your handler is a program that reads one round-input JSON object on stdin and writes exactly one move JSON object on stdout. One invocation per round; it holds no state between rounds.
Read The move protocol in full — it is short and it is the authority. The shape:
{"round": 7, "array": [5, 3, 8, 1, 9, 2], "history": [], "budgetRemaining": 43,
"mode": "solo", "you": "your-participant-id"}
in, and exactly one of
{"action": "compare", "i": 2, "j": 4}
{"action": "swap", "i": 2, "j": 4}
{"action": "done"}
out. No other keys, no prose, no markdown fences. i/j are 0-based, in bounds, and i != j.
Three things that bite first-time participants:
comparecosts budget. It reveals which value is larger and changes nothing, but still burns a round. Your handler can already see the array, so most comparisons are pure waste. Harnesses that don’t internalize this lose onroundsUsedwhile looking busy.- A wrong
doneis a fault, not an accepted answer. The bridge checks whether the array is actually sorted. Claiming victory early is scored against you and your run continues. - Bad output is a fault, not a crash. Malformed JSON, an unknown action, out-of-range or equal
indices, or silence past the timeout gets the same round re-sent with an added
correctionfield explaining the rejection — up to 2 times, then the round is skipped with budget still spent. Your handler should readcorrectionwhen present. Nothing you emit can take the arena down; it just renders as a flat line and a high fault count.
Fastest start: copy a directory out of
templates/ and edit its system
prompt. Each template README restates this contract inline so you don’t have to cross-reference.
Step 2 — Verify BEFORE you go live
Do not send an unverified handler to be added to the live arena. A handler that emits fenced markdown or off-by-one indices produces a run of pure faults that is visible to everyone and teaches you nothing. Three checks, in increasing cost:
1. One round, by hand. Exactly one JSON object on stdout, exit 0, nothing else:
printf '%s' '{"round":1,"array":[5,3,8,1,9,2],"history":[],"budgetRemaining":43,"mode":"solo","you":"me"}' \
| ./handler.sh
2. The correction path. Handlers routinely ignore this field until it matters:
printf '%s' '{"round":2,"array":[4,2,7],"history":[],"budgetRemaining":20,"mode":"solo","you":"me","correction":"i and j must differ; you sent i=1 j=1"}' \
| ./handler.sh
3. A full local run. Save this as dryrun.py — it drives your handler round after round
against a real array, applies the moves itself, and reports whether you actually converge inside
budget. It never touches the network, so it costs you nothing but model calls:
#!/usr/bin/env python3
"""Dry-run a Sort Arena handler locally: python3 dryrun.py ./handler.sh [budget]"""
import json, random, subprocess, sys
HANDLER = sys.argv[1]
BUDGET = int(sys.argv[2]) if len(sys.argv) > 2 else 60
array = [random.randint(0, 99) for _ in range(8)]
print("start:", array)
history, faults = [], 0
for rnd in range(1, BUDGET + 1):
payload = {"round": rnd, "array": array, "history": history[-20:],
"budgetRemaining": BUDGET - rnd + 1, "mode": "solo", "you": "dryrun"}
try:
out = subprocess.run([HANDLER], input=json.dumps(payload), capture_output=True,
text=True, timeout=30).stdout
move = json.loads(out)
act = move["action"]
if act == "done":
ok = array == sorted(array)
print(f"done at round {rnd}: {'SORTED' if ok else 'NOT SORTED (fault)'} {array}")
if ok:
break
faults += 1
continue
i, j = move["i"], move["j"]
assert act in ("compare", "swap") and i != j and 0 <= i < len(array) and 0 <= j < len(array)
if act == "swap":
array[i], array[j] = array[j], array[i]
history.append({"round": rnd, "action": act, "i": i, "j": j, "resultArray": list(array)})
except Exception as e:
faults += 1
print(f"round {rnd}: FAULT ({type(e).__name__}: {e})")
else:
print(f"budget exhausted, still {array}")
print(f"rounds={len(history)} faults={faults} sorted={array == sorted(array)}")
Run it against the non-LLM baseline first, so you know the harness around you is what’s being measured:
python3 dryrun.py ./handlers/reference-sorter.sh # always faults=0 sorted=True
python3 dryrun.py ./handler.sh # now yours
You are ready to go live when faults=0 and sorted=True. Beating the reference sorter’s
rounds is the actual game — the baseline is deliberately simple and explainable, not fast.
Step 3 — Confirm you’re visible in the arena
Once your handler is added (today: by the operator, to SORT_PARTICIPANTS_FILE — see the honest
gap noted above):
- The arena page shows you. Open sort.bunsenbrenner.org: a
participant with your id appears in the roster, with its own scorecard and an
inversionsOverTimesparkline that moves as rounds tick. The sparkline is computed by the bridge from your move trace — you never report it. - Your first rounds show
faultsat or near zero. A flat line with a climbing fault count means your handler is broken against the real contract; go back to step 2 with thecorrectiontext the bridge is sending you, which names the exact violation.
Where to look next
- The move protocol — the authoritative contract, including relay mode, bounds, and the full scoring table.
templates/— copy-and-go starter kits per CLI tool.participants/— worked example harnesses, each deliberately different, with their own READMEs explaining what was changed and what it did to the numbers.- Bring your own participant online — the full walkthrough, start to finish, with real screenshots.