Rehearse a debate motion from your own scripts
Debate Rounds coaches one person through one motion in four steps: a Toulmin audit of the case, a steel-manned opposition with three to six counters, a judge's read of timed rebuttals, and a ballot on the closing scored out of 100. Every step is one call to the SkillSafe App API: plain JSON in over HTTPS, JSON Lines out. Pick a language once and every code sample on the page follows.
Basics
Base URL https://api.skillsafe.ai/v1/app-api. Every call except
POST /guest sends Authorization: Bearer <token>; bodies are
JSON with Content-Type: application/json. The token is bound to this app, so
there is no app or slug header: the slug appears only in the
POST /guest body.
Responses use one envelope: {"ok":true,"data":{...}} on success and
{"ok":false,"error":{"code","message","details"}} on failure.
/estimate, /run and /run-stream take
the input object itself as the body. Do not wrap it as
{"input":{...}}: the server only warns unknown field 'input',
and the run is still charged.
Credits: 1 credit = $0.0001. /estimate is free and creates no job;
/run and /run-stream hold up to hold_credits and
charge charged_credits when the job finishes.
| Status | error.code | Meaning |
|---|---|---|
400 | validation_error | The body failed validation; details names the field. |
401 | unauthorized | Invalid or expired app session. Get a new token (step 1). |
402 | payment_required | Balance is below min_credits for this run. Top up, or sign in for a personal token. |
404 | not_found | Unknown job id, or a job that belongs to another token. |
409 | conflict | The Idempotency-Key was already used with a different body. |
429 | rate_limited | Too many requests. Back off and retry. |
5xx | - | Transient platform error. Retry with backoff and the same Idempotency-Key. |
Browsers enforce CORS for this API, so run the samples from a server, script or terminal, not from another website's frontend.
Input contract
Every field is a string or a number. Anything structured (the case, the counters, the
rebuttals, the self-scores) is JSON-encoded into a string field whose name ends in
_json.
task (string, required): which step of the sitting to run
One sitting is four steps run in this order. Each step's input carries what came before:
the case goes on every step, and the counters returned by oppose go into
rebut and close as counters_json. Each step has a
complete worked example below.
| task | What it does | Sends, on top of motion / side / format / level | Example |
|---|---|---|---|
case | Toulmin audit of up to 3 contentions: every slot rated strong / weak / missing, fallacies quoted, 1-3 rewrites, 4 scores. | case_json | 1 case |
oppose | Steel-mans the other side, then 3-6 counters against the case (at least one per contention), each with a threat rating, and the crux of the round. | case_json | 2 oppose |
rebut | Judges each timed rebuttal against the four-part rebuttal (acknowledge, flaw, evidence, weigh) and writes a stronger version. | case_json, counters_json, rebuttals_json | 3 rebut |
close | A judge's ballot on the closing and the whole sitting: a call, ten rubric scores, two strengths, two improvements, one drill. | case_json, counters_json, rebuttals_json, closing, closing_seconds, closing_limit, self_json | 4 close |
The other fields
| Field | Type | Steps | Notes |
|---|---|---|---|
motion | string, required | all | The motion being debated, up to 300 characters. |
side | string, required | all | proposition | opposition: the side being rehearsed. oppose argues the other one. |
format | string | all | rapid (60 s rebuttals, 2 min close) | extended (90 s, 3 min) | untimed (pacing not scored). |
level | string | all | Who is being judged, e.g. club, school, competitive. |
case_json | string (JSON array) | all | Up to 3 contentions {n, claim, grounds, warrant, backing, qualifier, rebuttal}; each slot up to 700 characters and may be empty. |
counters_json | string (JSON array) | rebut, close | {id:"C1", c:<contention n>, strategy:"evidence|reasoning|significance|counterexample", text, threat:1-5}, copied from the counter lines of the oppose reply. Keep threat: the close ballot calls loses when a threat-4/5 attack is answered nowhere. |
rebuttals_json | string (JSON array) | rebut, close | {id, text, seconds, limit, attempt}: the rebuttal to counter id, seconds used against the limit, and which attempt it is. |
closing | string | close | The closing statement, up to 5,000 characters. |
closing_seconds | number | close | Seconds the closing took. |
closing_limit | number | close | The time limit in seconds (120 for rapid, 180 for extended, 0 for untimed). |
self_json | string (JSON object) | close | The person's own 1-10 self-scores keyed by rubric category, possibly partial. The ballot explains any score more than two points away. |
prescan_json | string (JSON object) | all, optional | Facts from the app's free client-side linter (empty slots, absolute words, fallacy cues, rebuttal-part cues, timing). API callers may leave it out. |
retry_note | string, optional | all | A correction the app adds when it re-asks after a malformed reply. Leave it out. |
Output contract
The finished job's text is at output.output. It is JSON Lines:
one JSON object per line, each with a k kind. The first line is always
{"k":"meta","task":"<step>","headline":"..."} (it may also carry
"routed":"fallback" when the step was inferred) and the last is
{"k":"end"}. Split on newlines, JSON.parse each line and ignore
anything that does not parse; step 4 shows this in every language. If
{"k":"end"} is missing, or the job says truncated, the reply was
cut short.
| task | Lines between meta and end, in order |
|---|---|
case | slot x6 per contention {c, slot, status: strong|weak|missing, note}; fallacy {c, name, quote, fix} or exactly one {"k":"fallacy","none":true}; rewrite x1-3 {c, slot, before, after, why}; score x4 {cat, score, why} for evidence, logic, depth, organization; next {text}. |
oppose | steelman {text}; counter x3-6 {id, c, strategy, text, threat: 1-5}; crux {text}; next {text}. |
rebut | judge per rebuttal {id, ack, flaw, evidence, weigh, score, note, stronger} (four booleans); fallacy {id, name, quote, fix} or {"none":true}; score x5 for counter, clash, weighing, pacing, clarity; next. |
close | decision {call: carries|toss-up|loses, reason}; score x10 for evidence, logic, depth, counter, clarity, organization, pacing, clash, weighing, framing; strength x2 {text, quote}; improve x2 {text, example}; drill {text}. |
Scores are integers 1-10. On a close ballot the ten scores sum to a total out of
100: Content 40 (evidence, logic, depth, counter), Delivery 30 (clarity, organization,
pacing), Strategy 30 (clash, weighing, framing). The reply never carries a
total: add the scores yourself.
Step 1 - A tiny client, and a token
Start with a short helper that adds the auth header, sends JSON and unwraps the envelope;
every later step reuses it. For the token: open the token
page, sign in with SkillSafe and press Copy shell export. That puts
export SKILLSAFE_TOKEN="..." on your clipboard, and the samples read it from
there. A personal token bills metered runs to your account. Scripts with no browser at all
can mint a guest token with POST /guest and the body
{"slug":"debate-rounds"}, sent with no auth header; it answers 201 with
{token, guest_id, expires_at}.
export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN" # Copy shell export on tokens.html sets this for you
# No browser at all? Mint a guest token (this one call sends no Authorization header):
SKILLSAFE_TOKEN=$(curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"debate-rounds"}' | jq -r '.data.token')
# 201 -> {"ok":true,"data":{"token":"...","guest_id":"...","expires_at":"..."}}
# every later call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-H "Content-Type: application/json" -d @input.json]
# jq pulls fields out of the {"ok":true,"data":{...}} envelope
import json, os, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN") # copy it from tokens.html
def api(method, path, body=None, headers=None, auth=True):
"""Send JSON (a dict, or an already-encoded string), unwrap the envelope."""
h = {"Content-Type": "application/json", **(headers or {})}
if auth and TOKEN:
h["Authorization"] = f"Bearer {TOKEN}"
data = body if isinstance(body, str) or body is None else json.dumps(body)
res = requests.request(method, API + path, data=data, headers=h, timeout=180)
payload = res.json()
if not res.ok or payload.get("ok") is False:
err = payload.get("error", {})
raise RuntimeError(f'{res.status_code} {err.get("code")}: {err.get("message")}')
return payload["data"]
# No browser at all? Mint a guest token (no Authorization header on this call).
if not TOKEN:
TOKEN = api("POST", "/guest", {"slug": "debate-rounds"}, auth=False)["token"]
// Node 18+ (built-in fetch), run as an ES module (top-level await)
const API = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // paste it from tokens.html, or load it from your secret store
async function api(method, path, body, extraHeaders = {}, auth = true) {
const headers = { "Content-Type": "application/json", ...extraHeaders };
if (auth) headers.Authorization = `Bearer ${TOKEN}`;
const res = await fetch(API + path, {
method,
headers,
body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok === false) {
const e = json.error ?? {};
throw Object.assign(new Error(`${res.status} ${e.code}: ${e.message ?? res.statusText}`),
{ status: res.status, code: e.code, details: e.details });
}
return json.data;
}
// No browser at all? Mint a guest token (no Authorization header on this call).
if (TOKEN === "YOUR_TOKEN") {
({ token: TOKEN } = await api("POST", "/guest", { slug: "debate-rounds" }, {}, false));
}
package main
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"hash/fnv"
"log"
"net/http"
"os"
"strings"
"time"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // copy it from tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// call sends body (any value, or json.RawMessage for pre-encoded bytes) and
// decodes the envelope's data into out.
func call(method, path string, body any, headers map[string]string, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env envelope
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 || env.Error != nil {
if env.Error != nil {
return fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
}
return fmt.Errorf("HTTP %d", res.StatusCode)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// No browser at all? Mint a guest token (sent while token is still empty, so no auth header).
func ensureToken() {
if token != "" {
return
}
var g struct{ Token string `json:"token"` }
if err := call("POST", "/guest", map[string]string{"slug": "debate-rounds"}, nil, &g); err != nil {
log.Fatal(err)
}
token = g.Token
}
// Java 17+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class DebateRounds {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final ObjectMapper JSON = new ObjectMapper();
static final HttpClient HTTP = HttpClient.newHttpClient();
static String token = System.getenv("SKILLSAFE_TOKEN"); // copy it from tokens.html
/** body: any Jackson-serialisable value, or an already-encoded JSON String. */
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.method(method, body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body instanceof String s ? s : JSON.writeValueAsString(body)));
if (token != null) b.header("Authorization", "Bearer " + token);
headers.forEach(b::header);
var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
JsonNode env = JSON.readTree(res.body());
if (res.statusCode() >= 400 || !env.path("ok").asBoolean(true))
throw new RuntimeException(res.statusCode() + " " + env.path("error").path("code").asText()
+ ": " + env.path("error").path("message").asText());
return env.get("data");
}
// No browser at all? Mint a guest token (token is null, so no auth header is sent).
static void ensureToken() throws Exception {
if (token == null) token = api("POST", "/guest", Map.of("slug", "debate-rounds"), Map.of()).get("token").asText();
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
$token = ENV["SKILLSAFE_TOKEN"] # copy it from tokens.html
# body: a Hash, or an already-encoded JSON String
def api(method, path, body = nil, headers = {})
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{$token}" if $token
headers.each { |k, v| req[k] = v }
req.body = body.is_a?(String) ? body : body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 180) { |h| h.request(req) }
payload = (JSON.parse(res.body) rescue {})
unless res.is_a?(Net::HTTPSuccess) && payload["ok"] != false
err = payload["error"] || {}
raise "#{res.code} #{err["code"]}: #{err["message"] || res.message}"
end
payload["data"]
end
# No browser at all? Mint a guest token ($token is nil, so no auth header is sent).
$token ||= api("POST", "/guest", { slug: "debate-rounds" })["token"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: null; // copy it from tokens.html
// $body: an array, or an already-encoded JSON string
function api(string $method, string $path, $body = null, array $headers = []): array {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $h,
CURLOPT_TIMEOUT => 180,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS,
is_string($body) ? $body : json_encode($body, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
$payload = json_decode(curl_exec($ch), true) ?? [];
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400 || ($payload["ok"] ?? true) === false) {
$e = $payload["error"] ?? [];
throw new Exception("$status " . ($e["code"] ?? "") . ": " . ($e["message"] ?? "HTTP error"));
}
return $payload["data"];
}
// No browser at all? Mint a guest token ($TOKEN is null, so no auth header is sent).
$TOKEN = $TOKEN ?: api("POST", "/guest", ["slug" => "debate-rounds"])["token"];
// .NET 8+, Program.cs. Top-level statements come first; the helper class goes at the end of the file.
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
// No browser at all? Mint a guest token (Token is null, so no auth header is sent).
DebateRounds.Token ??= (await DebateRounds.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "debate-rounds" })).GetProperty("token").GetString();
// ... steps 2-5 go here ...
static class DebateRounds
{
public const string Api = "https://api.skillsafe.ai/v1/app-api";
public static readonly HttpClient Http = new() { Timeout = TimeSpan.FromMinutes(5) };
public static string? Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN"); // copy it from tokens.html
// body: any serialisable object, or an already-encoded JSON string
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null,
Dictionary<string, string>? headers = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (Token != null) req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
foreach (var (k, v) in headers ?? new()) req.Headers.Add(k, v);
if (body != null)
req.Content = new StringContent(body as string ?? JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var json = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!res.IsSuccessStatusCode)
{
var e = json.GetProperty("error");
throw new Exception($"{(int)res.StatusCode} {e.GetProperty("code")}: {e.GetProperty("message")}");
}
return json.GetProperty("data");
}
}
The app keeps this browser's token in localStorage under
skillsafe_app_token:debate-rounds, on the app's own origin. The
token page shows, copies and replaces it without developer tools.
Treat a token like a password: it can spend its credits.
Step 2 - Who am I, and what is my balance?
Returns subject_type ("user" for a personal token,
"guest" for a guest token), subject_id and credits.
There is no separate user id or guest flag: branch on subject_type. A
401 here means the token is stale.
curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
# {"subject_type":"guest","subject_id":"gst_...","credits":2000}
me = api("GET", "/me")
print(me["subject_type"], me["subject_id"], me["credits"], "credits",
f'(${me["credits"] * 0.0001:.2f})')
const me = await api("GET", "/me");
console.log(me.subject_type, me.subject_id, `${me.credits} credits ($${(me.credits * 0.0001).toFixed(2)})`);
ensureToken()
var me struct {
SubjectType string `json:"subject_type"` // "user" or "guest"
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
if err := call("GET", "/me", nil, nil, &me); err != nil {
log.Fatal(err)
}
fmt.Printf("%s %s: %d credits ($%.2f)\n", me.SubjectType, me.SubjectID, me.Credits, float64(me.Credits)*0.0001)
ensureToken();
JsonNode me = api("GET", "/me", null, Map.of());
System.out.printf("%s %s: %d credits%n",
me.get("subject_type").asText(), me.get("subject_id").asText(), me.get("credits").asLong());
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["subject_id"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']} {$me['subject_id']}: {$me['credits']} credits\n";
var me = await DebateRounds.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("subject_id")}: {me.GetProperty("credits")} credits");
Step 3 - Estimate a step before running it
Send exactly the body you would send to /run. The answer is
{hold_credits, min_credits, model, model_alias, markup_bps, input_checked, warnings[]}:
hold_credits is the most a run can cost, min_credits the balance
needed to start one, and warnings lists anything the server did not like
about the input (an unknown field, for instance). Nothing is charged and no job is created.
The sample estimates the first step, case, reading the contentions from
case.json (the array shown under Worked examples).
# case.json holds the contentions array (see "Worked examples" below).
# case_json must be a STRING, so jq's tojson encodes the array into one.
jq -n --slurpfile c case.json '{
task: "case",
motion: "This house would make public transport free at the point of use.",
side: "proposition", format: "rapid", level: "club",
case_json: ($c[0] | tojson)
}' > input.json
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data | {hold_credits, min_credits, model_alias, input_checked, warnings}'
with open("case.json", encoding="utf-8") as fh:
contentions = json.load(fh) # [{n, claim, grounds, warrant, backing, qualifier, rebuttal}, ...]
payload = {
"task": "case",
"motion": "This house would make public transport free at the point of use.",
"side": "proposition",
"format": "rapid",
"level": "club",
"case_json": json.dumps(contentions, separators=(",", ":"), ensure_ascii=False), # a STRING
}
est = api("POST", "/estimate", payload) # the input object itself, never {"input": ...}
print(est["hold_credits"], "credits held at most;", est["min_credits"], "needed to start")
for w in est["warnings"]:
print("warning:", w)
import { readFileSync } from "node:fs";
const contentions = JSON.parse(readFileSync("case.json", "utf8"));
const payload = {
task: "case",
motion: "This house would make public transport free at the point of use.",
side: "proposition",
format: "rapid",
level: "club",
case_json: JSON.stringify(contentions), // a STRING, not an array
};
const est = await api("POST", "/estimate", payload); // the input object itself, never { input: ... }
console.log(`${est.hold_credits} credits held at most, ${est.min_credits} needed to start`);
for (const w of est.warnings) console.log("warning:", w);
raw, err := os.ReadFile("case.json")
if err != nil {
log.Fatal(err)
}
var contentions []map[string]any
if err := json.Unmarshal(raw, &contentions); err != nil {
log.Fatal(err)
}
caseJSON, _ := json.Marshal(contentions)
payload := map[string]any{
"task": "case",
"motion": "This house would make public transport free at the point of use.",
"side": "proposition",
"format": "rapid",
"level": "club",
"case_json": string(caseJSON), // a STRING, not an array
}
var est struct {
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
ModelAlias string `json:"model_alias"`
Warnings []any `json:"warnings"`
}
if err := call("POST", "/estimate", payload, nil, &est); err != nil {
log.Fatal(err)
}
fmt.Println(est.HoldCredits, "credits held at most;", est.MinCredits, "needed to start")
for _, w := range est.Warnings {
fmt.Println("warning:", w)
}
var contentions = JSON.readTree(java.nio.file.Files.readString(java.nio.file.Path.of("case.json")));
var payload = JSON.createObjectNode()
.put("task", "case")
.put("motion", "This house would make public transport free at the point of use.")
.put("side", "proposition")
.put("format", "rapid")
.put("level", "club")
.put("case_json", JSON.writeValueAsString(contentions)); // a STRING, not an array
JsonNode est = api("POST", "/estimate", payload, Map.of());
System.out.println(est.get("hold_credits") + " credits held at most; "
+ est.get("min_credits") + " needed to start");
est.get("warnings").forEach(w -> System.out.println("warning: " + w));
contentions = JSON.parse(File.read("case.json"))
payload = {
task: "case",
motion: "This house would make public transport free at the point of use.",
side: "proposition",
format: "rapid",
level: "club",
case_json: contentions.to_json # a STRING, not an array
}
est = api("POST", "/estimate", payload)
puts "#{est["hold_credits"]} credits held at most; #{est["min_credits"]} needed to start"
est["warnings"].each { |w| puts "warning: #{w}" }
$contentions = json_decode(file_get_contents("case.json"), true);
$payload = [
"task" => "case",
"motion" => "This house would make public transport free at the point of use.",
"side" => "proposition",
"format" => "rapid",
"level" => "club",
"case_json" => json_encode($contentions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), // a STRING
];
$est = api("POST", "/estimate", $payload);
echo "{$est['hold_credits']} credits held at most; {$est['min_credits']} needed to start\n";
foreach ($est["warnings"] as $w) echo "warning: " . json_encode($w) . "\n";
var contentions = JsonDocument.Parse(await File.ReadAllTextAsync("case.json")).RootElement;
var payload = new Dictionary<string, object>
{
["task"] = "case",
["motion"] = "This house would make public transport free at the point of use.",
["side"] = "proposition",
["format"] = "rapid",
["level"] = "club",
["case_json"] = JsonSerializer.Serialize(contentions), // a STRING, not an array
};
var est = await DebateRounds.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("hold_credits")} credits held at most; {est.GetProperty("min_credits")} needed to start");
foreach (var w in est.GetProperty("warnings").EnumerateArray()) Console.WriteLine($"warning: {w}");
Step 4 - Run a step and poll the job
/run takes the same body, places the credit hold and returns
{job_id}. Poll GET /jobs/{job_id} every second or two until
status is succeeded or failed. The finished job carries
the reply text at output.output, plus charged_credits and
truncated. The samples parse the JSON Lines reply (split lines, parse each,
ignore anything that is not JSON), print the headline and the scores, and add the scores up.
Always send an Idempotency-Key, so a network retry cannot start a second,
double-charged run. The app's own key is
debate-rounds:<task>:<fnv1a hash of the input>:a<attempt>
(32-bit FNV-1a over the JSON body, as 8 hex digits). Because task is both in the
key and in the hashed body, two steps over the same case never collide; a deliberate re-run
of the same input bumps the attempt number. Reusing a key with a different body is a
409.
BODY=$(jq -c . input.json)
# The app hashes the body with FNV-1a; from a shell any stable hash of the exact body works.
HASH=$(printf %s "$BODY" | shasum | cut -c1-8)
KEY="debate-rounds:case:$HASH:a1"
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$BODY" | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
echo "$JOB" | jq '.data | {status, charged_credits, truncated}'
# The reply is JSON Lines: keep every line that parses as an object with "k", drop the rest.
echo "$JOB" | jq -r '.data.output.output' \
| jq -R -c 'fromjson? | select(type == "object" and has("k"))' > reply.jsonl
jq -r 'select(.k == "meta") | "\(.task): \(.headline)"' reply.jsonl
jq -r 'select(.k == "score") | " \(.cat)\t\(.score)\t\(.why)"' reply.jsonl
jq -s 'map(select(.k == "score") | .score) | add' reply.jsonl # the /100 total on a close ballot
import time
def fnv1a(s):
"""The app's 32-bit FNV-1a (it hashes UTF-16 code units; identical for ASCII)."""
h = 0x811C9DC5
for ch in s:
h ^= ord(ch)
h = (h * 0x01000193) & 0xFFFFFFFF
return f"{h:08x}"
def idem_key(task, body, attempt=1):
return f"debate-rounds:{task}:{fnv1a(body)}:a{attempt}"
def parse_lines(text):
"""JSON Lines -> list of dicts; anything that is not a JSON object with "k" is ignored."""
out = []
for line in (text or "").splitlines():
try:
obj = json.loads(line)
except ValueError:
continue
if isinstance(obj, dict) and "k" in obj:
out.append(obj)
return out
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False) # hash exactly what you send
job_id = api("POST", "/run", body,
headers={"Idempotency-Key": idem_key(payload["task"], body)})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error") or "run failed")
lines = parse_lines(job["output"]["output"])
meta = next((l for l in lines if l["k"] == "meta"), {})
print(f'{meta.get("task")}: {meta.get("headline")} ({job["charged_credits"]} credits'
+ (", truncated)" if job.get("truncated") else ")"))
for l in lines:
if l["k"] == "score":
print(f' {l["cat"]:<13} {l["score"]:>2} {l["why"]}')
elif l["k"] in ("counter", "judge"):
print(f' {l["id"]}: {l.get("text") or l.get("note")}')
print("total:", sum(l["score"] for l in lines if l["k"] == "score")) # /100 on a close ballot
function fnv1a(s) { // the app's own 32-bit FNV-1a
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
const idemKey = (input, attempt = 1) =>
`debate-rounds:${input.task}:${fnv1a(JSON.stringify(input))}:a${attempt}`;
function parseLines(text) { // JSON Lines -> objects; anything else is ignored
const out = [];
for (const line of String(text ?? "").split(/\r?\n/)) {
try {
const o = JSON.parse(line);
if (o && typeof o === "object" && o.k) out.push(o);
} catch { /* not JSON */ }
}
return out;
}
const { job_id } = await api("POST", "/run", payload, { "Idempotency-Key": idemKey(payload) });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const lines = parseLines(job.output.output);
const meta = lines.find((l) => l.k === "meta") ?? {};
console.log(`${meta.task}: ${meta.headline} (${job.charged_credits} credits${job.truncated ? ", truncated" : ""})`);
for (const l of lines) {
if (l.k === "score") console.log(` ${l.cat.padEnd(13)} ${String(l.score).padStart(2)} ${l.why}`);
else if (l.k === "counter" || l.k === "judge") console.log(` ${l.id}: ${l.text ?? l.note}`);
}
const total = lines.filter((l) => l.k === "score").reduce((n, l) => n + l.score, 0);
console.log("total:", total); // /100 on a close ballot
func idemKey(task string, body []byte, attempt int) string {
h := fnv.New32a() // the same 32-bit FNV-1a the app uses
h.Write(body)
return fmt.Sprintf("debate-rounds:%s:%08x:a%d", task, h.Sum32(), attempt)
}
// ... in main(), after building payload in step 3:
body, _ := json.Marshal(payload) // hash exactly the bytes you send
var started struct{ JobID string `json:"job_id"` }
err = call("POST", "/run", json.RawMessage(body),
map[string]string{"Idempotency-Key": idemKey("case", body, 1)}, &started)
if err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
ChargedCredits int64 `json:"charged_credits"`
Truncated bool `json:"truncated"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
log.Fatal("run failed")
}
total := 0
for _, line := range strings.Split(job.Output.Output, "\n") {
var l map[string]any
if json.Unmarshal([]byte(line), &l) != nil || l["k"] == nil {
continue // not a JSON line: ignore
}
switch l["k"] {
case "meta":
fmt.Printf("%v: %v (%d credits)\n", l["task"], l["headline"], job.ChargedCredits)
case "score":
if n, ok := l["score"].(float64); ok {
total += int(n)
}
fmt.Printf(" %-13v %v %v\n", l["cat"], l["score"], l["why"])
}
}
fmt.Println("total:", total) // /100 on a close ballot
static String fnv1a(String s) { // the app's own 32-bit FNV-1a
int h = 0x811c9dc5;
for (int i = 0; i < s.length(); i++) {
h ^= s.charAt(i);
h *= 0x01000193;
}
return String.format("%08x", h);
}
static String idemKey(String task, String body, int attempt) {
return "debate-rounds:" + task + ":" + fnv1a(body) + ":a" + attempt;
}
// ... after building payload in step 3:
String body = JSON.writeValueAsString(payload); // hash exactly what you send
String jobId = api("POST", "/run", body,
Map.of("Idempotency-Key", idemKey("case", body, 1))).get("job_id").asText();
JsonNode job;
do {
Thread.sleep(1500);
job = api("GET", "/jobs/" + jobId, null, Map.of());
} while (!job.get("status").asText().matches("succeeded|failed"));
if (job.get("status").asText().equals("failed")) throw new RuntimeException("run failed: " + job.get("error"));
int total = 0;
for (String line : job.path("output").path("output").asText("").split("\\R")) {
JsonNode l;
try { l = JSON.readTree(line); } catch (Exception e) { continue; } // not JSON: ignore
if (l == null || !l.has("k")) continue;
switch (l.get("k").asText()) {
case "meta" -> System.out.println(l.get("task").asText() + ": " + l.get("headline").asText()
+ " (" + job.get("charged_credits") + " credits)");
case "score" -> {
total += l.get("score").asInt();
System.out.printf(" %-13s %2d %s%n", l.get("cat").asText(), l.get("score").asInt(), l.get("why").asText());
}
default -> { }
}
}
System.out.println("total: " + total); // /100 on a close ballot
def fnv1a(s) # the app's own 32-bit FNV-1a, over UTF-16 code units
h = 0x811c9dc5
s.encode("UTF-16LE").unpack("v*").each { |u| h = ((h ^ u) * 0x01000193) & 0xffffffff }
format("%08x", h)
end
def idem_key(task, body, attempt = 1)
"debate-rounds:#{task}:#{fnv1a(body)}:a#{attempt}"
end
def parse_lines(text) # JSON Lines -> hashes; anything else is ignored
text.to_s.each_line.filter_map do |line|
obj = (JSON.parse(line) rescue nil)
obj if obj.is_a?(Hash) && obj["k"]
end
end
body = payload.to_json # hash exactly what you send
job_id = api("POST", "/run", body, { "Idempotency-Key" => idem_key(payload[:task], body) })["job_id"]
job = nil
loop do
job = api("GET", "/jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise "run failed: #{job["error"]}" if job["status"] == "failed"
lines = parse_lines(job.dig("output", "output"))
meta = lines.find { |l| l["k"] == "meta" } || {}
puts "#{meta["task"]}: #{meta["headline"]} (#{job["charged_credits"]} credits)"
lines.select { |l| l["k"] == "score" }.each { |l| puts format(" %-13s %2d %s", l["cat"], l["score"], l["why"]) }
puts "total: #{lines.sum { |l| l["k"] == "score" ? l["score"] : 0 }}" # /100 on a close ballot
function idem_key(string $task, string $body, int $attempt = 1): string {
return "debate-rounds:$task:" . hash("fnv1a32", $body) . ":a$attempt"; // built-in 32-bit FNV-1a
}
function parse_lines(?string $text): array { // JSON Lines -> arrays; anything else is ignored
$out = [];
foreach (preg_split('/\r?\n/', $text ?? "") as $line) {
$o = json_decode($line, true);
if (is_array($o) && isset($o["k"])) $out[] = $o;
}
return $out;
}
$body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); // hash what you send
$jobId = api("POST", "/run", $body, ["Idempotency-Key" => idem_key($payload["task"], $body)])["job_id"];
do {
usleep(1500000);
$job = api("GET", "/jobs/$jobId");
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") throw new Exception("run failed");
$total = 0;
foreach (parse_lines($job["output"]["output"] ?? "") as $l) {
if ($l["k"] === "meta") echo "{$l['task']}: {$l['headline']} ({$job['charged_credits']} credits)\n";
if ($l["k"] === "score") {
$total += $l["score"];
printf(" %-13s %2d %s\n", $l["cat"], $l["score"], $l["why"]);
}
}
echo "total: $total\n"; // /100 on a close ballot
static string Fnv1a(string s) // the app's own 32-bit FNV-1a
{
uint h = 0x811c9dc5;
foreach (char ch in s) { h ^= ch; h = unchecked(h * 0x01000193); }
return h.ToString("x8");
}
static string IdemKey(string task, string body, int attempt = 1) =>
$"debate-rounds:{task}:{Fnv1a(body)}:a{attempt}";
var body = JsonSerializer.Serialize(payload); // hash exactly what you send
var started = await DebateRounds.ApiAsync(HttpMethod.Post, "/run", body,
new() { ["Idempotency-Key"] = IdemKey("case", body) });
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
string status;
do
{
await Task.Delay(1500);
job = await DebateRounds.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
status = job.GetProperty("status").GetString()!;
} while (status is not ("succeeded" or "failed"));
if (status == "failed") throw new Exception("run failed");
var total = 0;
foreach (var line in job.GetProperty("output").GetProperty("output").GetString()!.Split('\n'))
{
JsonElement l;
try { l = JsonDocument.Parse(line).RootElement; } catch (JsonException) { continue; } // not JSON
if (l.ValueKind != JsonValueKind.Object || !l.TryGetProperty("k", out var k)) continue;
if (k.GetString() == "meta")
Console.WriteLine($"{l.GetProperty("task")}: {l.GetProperty("headline")} ({job.GetProperty("charged_credits")} credits)");
else if (k.GetString() == "score")
{
total += l.GetProperty("score").GetInt32();
Console.WriteLine($" {l.GetProperty("cat"),-13} {l.GetProperty("score"),2} {l.GetProperty("why")}");
}
}
Console.WriteLine($"total: {total}"); // /100 on a close ballot
Step 5 - Stream a step as it is written
/run-stream takes the same body and headers as /run and answers with
server-sent events. Each event is an event: line, a data: line of
JSON, and a blank line:
job:{job_id}, sent once when the job starts.delta:{"text"}, the next piece of the reply.tick:{"t"}, a heartbeat with seconds elapsed.done: the whole finished job, includingoutput.outputandcharged_credits.error:{code, message}; the stream ends.
# a new attempt (:a2); re-sending step 4's :a1 key with the same body replays that run
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: debate-rounds:case:$HASH:a2" \
-d "$BODY"
# event: job
# data: {"job_id":"job_..."}
#
# event: tick
# data: {"t":4}
#
# event: delta
# data: {"text":"{\"k\":\"meta\",\"task\":\"case\",\"headline\":\"..."}
#
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":...,"output":{"output":"...JSON Lines..."}}
res = requests.post(API + "/run-stream", data=body, stream=True, timeout=300, headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
"Idempotency-Key": idem_key(payload["task"], body, attempt=2), # :a1 would replay step 4
})
res.raise_for_status()
event, job = None, None
for raw in res.iter_lines(decode_unicode=True):
if raw.startswith("event:"):
event = raw[6:].strip()
elif raw.startswith("data:"):
data = json.loads(raw[5:].strip())
if event == "delta":
print(data["text"], end="", flush=True) # progress only
elif event == "tick":
pass # heartbeat: data["t"] seconds so far
elif event == "done":
job = data.get("job", data)
elif event == "error":
raise RuntimeError(data.get("message", data))
lines = parse_lines(job["output"]["output"]) # parse the final text, not the deltas
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": idemKey(payload, 2), // :a1 would replay step 4
},
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`run-stream: HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "", job = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value.replace(/\r\n/g, "\n");
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const frame = buf.slice(0, i);
buf = buf.slice(i + 2);
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const data = /^data:\s?(.*)$/m.exec(frame)?.[1];
if (!name || data === undefined) continue;
const d = JSON.parse(data);
if (name === "delta") console.log("+", d.text.length, "chars"); // progress only
else if (name === "tick") { /* heartbeat: d.t seconds so far */ }
else if (name === "done") job = d.job ?? d;
else if (name === "error") throw new Error(d.message ?? "stream error");
}
}
const lines = parseLines(job.output.output); // parse the final text, not the deltas
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idemKey("case", body, 2)) // :a1 would replay step 4
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var done struct {
Status string `json:"status"`
ChargedCredits int64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<22) // done carries the whole reply on one line
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data := []byte(strings.TrimSpace(line[5:]))
switch event {
case "delta":
var d struct{ Text string `json:"text"` }
json.Unmarshal(data, &d)
fmt.Print(d.Text) // progress only
case "done":
json.Unmarshal(data, &done)
case "error":
log.Fatalf("stream error: %s", data)
}
}
}
// done.Output.Output is the JSON Lines reply: parse it as in step 4
String body = JSON.writeValueAsString(payload);
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idemKey("case", body, 2)) // :a1 would replay step 4
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = "";
JsonNode job = null;
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) {
JsonNode d = JSON.readTree(line.substring(5).trim());
switch (event) {
case "delta" -> System.out.print(d.get("text").asText()); // progress only
case "done" -> job = d;
case "error" -> throw new RuntimeException(d.toString());
default -> { } // job, tick
}
}
}
String reply = job.path("output").path("output").asText(); // parse it as in step 4
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{$token}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem_key(payload[:task], body, 2) # :a1 would replay step 4
req.body = body
job = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
buf = +""
event = nil
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n"))
line = buf.slice!(0..i).strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
d = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then print d["text"] # progress only
when "done" then job = d
when "error" then raise d.to_s
end
end
end
end
end
end
lines = parse_lines(job.dig("output", "output")) # parse the final text, not the deltas
$buf = ""; $event = null; $job = null; $err = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_TIMEOUT => 300,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: " . idem_key($payload["task"], $body, 2), // :a1 would replay step 4
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$event, &$job, &$err) {
$buf .= $chunk;
while (($i = strpos($buf, "\n")) !== false) {
$line = trim(substr($buf, 0, $i));
$buf = substr($buf, $i + 1);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$d = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") echo $d["text"]; // progress only
elseif ($event === "done") $job = $d;
elseif ($event === "error") $err = $d;
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
if ($err) throw new Exception(json_encode($err));
$lines = parse_lines($job["output"]["output"] ?? ""); // parse the final text, not the deltas
var req = new HttpRequestMessage(HttpMethod.Post, DebateRounds.Api + "/run-stream")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", DebateRounds.Token);
req.Headers.Add("Idempotency-Key", IdemKey("case", body, 2)); // :a1 would replay step 4
using var res = await DebateRounds.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = line[5..].Trim();
if (evt == "delta") Console.Write(JsonDocument.Parse(data).RootElement.GetProperty("text").GetString());
else if (evt == "done") done = data;
else if (evt == "error") throw new Exception(data);
}
}
var final = JsonDocument.Parse(done!).RootElement;
var reply = final.GetProperty("output").GetProperty("output").GetString(); // parse it as in step 4
Treat delta as progress only and parse the reply from done: some
clients and proxies deliver only tick heartbeats until the end. In a browser,
EventSource only speaks GET, so read the fetch body as the
JavaScript sample does. On an idempotent replay the server may answer with a plain JSON
envelope instead of a stream, so check Content-Type before parsing frames.
Worked examples per step
One sitting on the motion "This house would make public transport free at the point of use.", rehearsed as the proposition in the
rapid format. Each input below is complete: send it as-is to
/estimate, /run or /run-stream. The replies are samples,
abbreviated where noted; real wording will differ.
case.json - the contentions array (encoded into case_json on every step)
[
{
"n": 1,
"claim": "Free fares take cars off the road, which cuts congestion and emissions.",
"grounds": "Cities that removed fares saw ridership rise in the first year.",
"warrant": "If the bus costs nothing, people making short trips by car will switch, because price is what tips the choice.",
"backing": "",
"qualifier": "This will always work, in any city.",
"rebuttal": "Some say service quality matters more than price, but price is the lever a council can pull tomorrow."
},
{
"n": 2,
"claim": "Fares are a flat charge that falls hardest on people who can least afford them.",
"grounds": "Low-income workers spend a larger share of their income on getting to work.",
"warrant": "A flat charge takes a bigger bite out of a small budget, so removing it is progressive.",
"backing": "We already fund roads and libraries from general taxation because everyone needs them; transport is the same kind of service.",
"qualifier": "In most cities, for most low-income riders.",
"rebuttal": ""
}
]
1 - case
The first step. Only the case is sent. Contention 1 leaves backing empty and uses an absolute qualifier; contention 2 leaves rebuttal empty.
Input
{
"task": "case",
"motion": "This house would make public transport free at the point of use.",
"side": "proposition",
"format": "rapid",
"level": "club",
"case_json": "[{\"n\":1,\"claim\":\"Free fares take cars off the road, which cuts congestion and emissions.\",\"grounds\":\"Cities that removed fares saw ridership rise in the first year.\",\"warrant\":\"If the bus costs nothing, people making short trips by car will switch, because price is what tips the choice.\",\"backing\":\"\",\"qualifier\":\"This will always work, in any city.\",\"rebuttal\":\"Some say service quality matters more than price, but price is the lever a council can pull tomorrow.\"},{\"n\":2,\"claim\":\"Fares are a flat charge that falls hardest on people who can least afford them.\",\"grounds\":\"Low-income workers spend a larger share of their income on getting to work.\",\"warrant\":\"A flat charge takes a bigger bite out of a small budget, so removing it is progressive.\",\"backing\":\"We already fund roads and libraries from general taxation because everyone needs them; transport is the same kind of service.\",\"qualifier\":\"In most cities, for most low-income riders.\",\"rebuttal\":\"\"}]"
}
Sample reply (output.output)
{"k":"meta","task":"case","headline":"Contention 2 is close to judge-ready; contention 1 rests on a ridership figure that does not show drivers switching."}
{"k":"slot","c":1,"slot":"claim","status":"strong","note":"A clear, debatable claim with two named effects you can be held to."}
{"k":"slot","c":1,"slot":"grounds","status":"weak","note":"Rising ridership does not show car trips falling; you need where the new riders came from."}
{"k":"slot","c":1,"slot":"warrant","status":"weak","note":"You assert price tips the choice but never say why that holds for drivers rather than walkers."}
{"k":"slot","c":1,"slot":"backing","status":"missing","note":"Nothing supports the warrant; one line on how drivers rank cost against time would do."}
{"k":"slot","c":1,"slot":"qualifier","status":"weak","note":"\"Always, in any city\" invites a single counterexample to sink the contention."}
{"k":"slot","c":1,"slot":"rebuttal","status":"strong","note":"You pre-empt the service-quality objection and give a reason to prefer price."}
{"k":"fallacy","c":1,"name":"hasty generalisation","quote":"This will always work, in any city.","fix":"Scope it to the trips and cities your grounds actually cover."}
{"k":"rewrite","c":1,"slot":"grounds","before":"Cities that removed fares saw ridership rise in the first year.","after":"Where fares were removed, a share of new riders had previously driven [find: share of new free-fare riders who previously drove, from a named city's evaluation].","why":"Your claim is about cars, so the grounds must be about drivers, not riders in general."}
{"k":"score","cat":"evidence","score":4,"why":"Contention 1 has one general observation and no source; contention 2 relies on a reasonable but unsourced claim."}
{"k":"score","cat":"logic","score":6,"why":"Contention 2's chain holds; contention 1 jumps from riders to drivers."}
{"k":"score","cat":"depth","score":5,"why":"You name effects but do not explain the mechanism for which trips switch."}
{"k":"score","cat":"organization","score":7,"why":"Two clearly separated contentions with distinct claims."}
{"k":"next","text":"Rewrite contention 1's grounds and qualifier before facing the opposition: they are the first thing a judge will see attacked."}
{"k":"end"}
Abbreviated: the six slot lines for contention 2 are left out here; a real reply has six per contention.
2 - oppose
Same body as case with task changed. The counter lines in the reply become counters_json for the next two steps (keep threat - the close decision rule reads it - along with id, c, strategy and text).
Input
{
"task": "oppose",
"motion": "This house would make public transport free at the point of use.",
"side": "proposition",
"format": "rapid",
"level": "club",
"case_json": "[{\"n\":1,\"claim\":\"Free fares take cars off the road, which cuts congestion and emissions.\",\"grounds\":\"Cities that removed fares saw ridership rise in the first year.\",\"warrant\":\"If the bus costs nothing, people making short trips by car will switch, because price is what tips the choice.\",\"backing\":\"\",\"qualifier\":\"This will always work, in any city.\",\"rebuttal\":\"Some say service quality matters more than price, but price is the lever a council can pull tomorrow.\"},{\"n\":2,\"claim\":\"Fares are a flat charge that falls hardest on people who can least afford them.\",\"grounds\":\"Low-income workers spend a larger share of their income on getting to work.\",\"warrant\":\"A flat charge takes a bigger bite out of a small budget, so removing it is progressive.\",\"backing\":\"We already fund roads and libraries from general taxation because everyone needs them; transport is the same kind of service.\",\"qualifier\":\"In most cities, for most low-income riders.\",\"rebuttal\":\"\"}]"
}
Sample reply (output.output)
{"k":"meta","task":"oppose","headline":"Your first contention is exposed: it treats new riders as former drivers without showing it."}
{"k":"steelman","text":"Public money for transport is limited, and fares are a large part of what pays for frequency and reach. Removing them spends that money on people who would ride anyway, while the riders who most need better service get a free ticket for a bus that now comes less often."}
{"k":"counter","id":"C1","c":1,"strategy":"evidence","text":"Ridership going up is not drivers switching. New riders may have walked or cycled before, so your congestion claim has no grounds yet.","threat":4}
{"k":"counter","id":"C2","c":1,"strategy":"reasoning","text":"People drive for speed and convenience, not the fare. A free bus that comes every thirty minutes still loses to the car.","threat":5}
{"k":"counter","id":"C3","c":2,"strategy":"significance","text":"Fares pay for service. Take them away and frequency is the first thing cut, which hurts low-income riders most.","threat":4}
{"k":"counter","id":"C4","c":2,"strategy":"counterexample","text":"A discounted pass for low-income riders delivers the same fairness without giving free rides to people who can easily pay.","threat":3}
{"k":"crux","text":"Whether price or service quality decides who rides; right now it favours the opposition, because your case never shows price moving drivers."}
{"k":"next","text":"Drill C2 first: it carries the highest threat and attacks the warrant both of your contentions lean on."}
{"k":"end"}
3 - rebut
The person drilled two counters against a 60-second clock: a full answer to C2 in 54 s and a one-line answer to C3 in 11 s.
Input
{
"task": "rebut",
"motion": "This house would make public transport free at the point of use.",
"side": "proposition",
"format": "rapid",
"level": "club",
"case_json": "[{\"n\":1,\"claim\":\"Free fares take cars off the road, which cuts congestion and emissions.\",\"grounds\":\"Cities that removed fares saw ridership rise in the first year.\",\"warrant\":\"If the bus costs nothing, people making short trips by car will switch, because price is what tips the choice.\",\"backing\":\"\",\"qualifier\":\"This will always work, in any city.\",\"rebuttal\":\"Some say service quality matters more than price, but price is the lever a council can pull tomorrow.\"},{\"n\":2,\"claim\":\"Fares are a flat charge that falls hardest on people who can least afford them.\",\"grounds\":\"Low-income workers spend a larger share of their income on getting to work.\",\"warrant\":\"A flat charge takes a bigger bite out of a small budget, so removing it is progressive.\",\"backing\":\"We already fund roads and libraries from general taxation because everyone needs them; transport is the same kind of service.\",\"qualifier\":\"In most cities, for most low-income riders.\",\"rebuttal\":\"\"}]",
"counters_json": "[{\"id\":\"C1\",\"c\":1,\"strategy\":\"evidence\",\"text\":\"Ridership going up is not drivers switching. New riders may have walked or cycled before, so your congestion claim has no grounds yet.\"},{\"id\":\"C2\",\"c\":1,\"strategy\":\"reasoning\",\"text\":\"People drive for speed and convenience, not the fare. A free bus that comes every thirty minutes still loses to the car.\"},{\"id\":\"C3\",\"c\":2,\"strategy\":\"significance\",\"text\":\"Fares pay for service. Take them away and frequency is the first thing cut, which hurts low-income riders most.\"},{\"id\":\"C4\",\"c\":2,\"strategy\":\"counterexample\",\"text\":\"A discounted pass for low-income riders delivers the same fairness without giving free rides to people who can easily pay.\"}]",
"rebuttals_json": "[{\"id\":\"C2\",\"text\":\"My opponent says people drive for convenience, and for some that is true. But that is a reason to fund frequency too, not a reason against free fares. For the short, cheap trips where the car only wins on cost, a free ticket changes the choice. Their point is about long commutes; ours is about the trips that make up the queue at rush hour.\",\"seconds\":54,\"limit\":60,\"attempt\":1},{\"id\":\"C3\",\"text\":\"They say frequency gets cut. It doesn't have to be.\",\"seconds\":11,\"limit\":60,\"attempt\":1}]"
}
Sample reply (output.output)
{"k":"meta","task":"rebut","headline":"Your C2 rebuttal turns the attack well; your C3 rebuttal is a denial, not an answer."}
{"k":"judge","id":"C2","ack":true,"flaw":true,"evidence":false,"weigh":true,"score":7,"note":"You concede the fair part and narrow the clash to short trips, but offer nothing showing those trips are price-sensitive.","stronger":"Convenience matters, which is why we fund frequency too. But for short trips the car wins only on cost [find: share of urban car trips under five kilometres]. Those are the trips that make the rush-hour queue, so even if long commuters keep driving, we win the congestion point."}
{"k":"judge","id":"C3","ack":false,"flaw":false,"evidence":false,"weigh":false,"score":2,"note":"\"It doesn't have to be\" asserts the opposite without a reason, and you stopped at 11 of 60 seconds.","stronger":"They say frequency is cut first. That is a funding choice, not a consequence: our model replaces fare revenue [find: fare share of your city's operating budget] from general taxation. And a fare is the most regressive way to fund service, so even on their terms our riders come out ahead."}
{"k":"fallacy","none":true}
{"k":"score","cat":"counter","score":5,"why":"One counter answered well, one barely touched."}
{"k":"score","cat":"clash","score":6,"why":"On C2 you engage their actual argument rather than a weaker one."}
{"k":"score","cat":"weighing","score":5,"why":"You weigh once, on C2; C3 has no reason it matters less."}
{"k":"score","cat":"pacing","score":4,"why":"54 of 60 seconds on C2 is good; 11 seconds on C3 is thin."}
{"k":"score","cat":"clarity","score":7,"why":"Short sentences and clear signposting on C2."}
{"k":"next","text":"Retry C3: name the funding source, then weigh fairness against frequency."}
{"k":"end"}
4 - close
The whole record of the sitting plus the closing, its timing and the person's self-scores.
Input
{
"task": "close",
"motion": "This house would make public transport free at the point of use.",
"side": "proposition",
"format": "rapid",
"level": "club",
"case_json": "[{\"n\":1,\"claim\":\"Free fares take cars off the road, which cuts congestion and emissions.\",\"grounds\":\"Cities that removed fares saw ridership rise in the first year.\",\"warrant\":\"If the bus costs nothing, people making short trips by car will switch, because price is what tips the choice.\",\"backing\":\"\",\"qualifier\":\"This will always work, in any city.\",\"rebuttal\":\"Some say service quality matters more than price, but price is the lever a council can pull tomorrow.\"},{\"n\":2,\"claim\":\"Fares are a flat charge that falls hardest on people who can least afford them.\",\"grounds\":\"Low-income workers spend a larger share of their income on getting to work.\",\"warrant\":\"A flat charge takes a bigger bite out of a small budget, so removing it is progressive.\",\"backing\":\"We already fund roads and libraries from general taxation because everyone needs them; transport is the same kind of service.\",\"qualifier\":\"In most cities, for most low-income riders.\",\"rebuttal\":\"\"}]",
"counters_json": "[{\"id\":\"C1\",\"c\":1,\"strategy\":\"evidence\",\"text\":\"Ridership going up is not drivers switching. New riders may have walked or cycled before, so your congestion claim has no grounds yet.\"},{\"id\":\"C2\",\"c\":1,\"strategy\":\"reasoning\",\"text\":\"People drive for speed and convenience, not the fare. A free bus that comes every thirty minutes still loses to the car.\"},{\"id\":\"C3\",\"c\":2,\"strategy\":\"significance\",\"text\":\"Fares pay for service. Take them away and frequency is the first thing cut, which hurts low-income riders most.\"},{\"id\":\"C4\",\"c\":2,\"strategy\":\"counterexample\",\"text\":\"A discounted pass for low-income riders delivers the same fairness without giving free rides to people who can easily pay.\"}]",
"rebuttals_json": "[{\"id\":\"C2\",\"text\":\"My opponent says people drive for convenience, and for some that is true. But that is a reason to fund frequency too, not a reason against free fares. For the short, cheap trips where the car only wins on cost, a free ticket changes the choice. Their point is about long commutes; ours is about the trips that make up the queue at rush hour.\",\"seconds\":54,\"limit\":60,\"attempt\":1},{\"id\":\"C3\",\"text\":\"They say frequency gets cut. It doesn't have to be.\",\"seconds\":11,\"limit\":60,\"attempt\":1}]",
"closing": "This round came down to one question: does price keep people off the bus? The opposition said convenience matters more, and we agreed it matters, which is why free fares come with protected frequency, not instead of it. They said fares fund service; but a flat fare is the most regressive way to raise that money, and a targeted pass means means-testing the people who most need to ride. Weigh it this way: our case helps every rider at once, theirs asks the poorest to prove it. Free at the point of use is the simpler, fairer system. Vote proposition.",
"closing_seconds": 112,
"closing_limit": 120,
"self_json": "{\"evidence\":5,\"logic\":7,\"depth\":6,\"counter\":6,\"clarity\":8,\"organization\":7,\"pacing\":7,\"clash\":6,\"weighing\":8,\"framing\":7}"
}
Sample reply (output.output)
{"k":"meta","task":"close","headline":"A clean frame and real weighing, but the funding counter is still only half answered."}
{"k":"decision","call":"toss-up","reason":"You weigh fairness against means-testing well, but C3, the highest-threat counter left, gets a promise of protected frequency with no funding source."}
{"k":"score","cat":"evidence","score":4,"why":"No sourced figure anywhere in the round; bracketed gaps remain open."}
{"k":"score","cat":"logic","score":6,"why":"The fairness chain holds; the congestion chain still skips the driver step."}
{"k":"score","cat":"depth","score":6,"why":"You explain why a flat fare is regressive, not just that it is."}
{"k":"score","cat":"counter","score":6,"why":"C2 and C4 answered; C3 asserted away."}
{"k":"score","cat":"clarity","score":8,"why":"One question framed at the top and answered at the end."}
{"k":"score","cat":"organization","score":7,"why":"Clear sequence: question, their points, weighing, call to vote."}
{"k":"score","cat":"pacing","score":7,"why":"112 of 120 seconds, used fully without running over."}
{"k":"score","cat":"clash","score":6,"why":"You engage their two main points by name."}
{"k":"score","cat":"weighing","score":7,"why":"\"Every rider at once\" against \"the poorest prove it\" is a real comparison; you rated it 8, but it is undercut by C3."}
{"k":"score","cat":"framing","score":7,"why":"Owning the price-versus-convenience question sets the round on your ground."}
{"k":"strength","text":"You turn their best counter into a reason to fund frequency, not a reason to keep fares.","quote":"we agreed it matters, which is why free fares come with protected frequency"}
{"k":"strength","text":"A memorable weighing line a judge can write down.","quote":"our case helps every rider at once, theirs asks the poorest to prove it"}
{"k":"improve","text":"Name where the money comes from instead of promising frequency is protected.","example":"Fare revenue is replaced from general taxation, the same way we fund roads, so frequency is a choice we keep, not one we lose."}
{"k":"improve","text":"Answer C1 in the closing: the judge has not heard why new riders are former drivers.","example":"Even if some new riders walked before, every driver who switches frees road space for the bus."}
{"k":"drill","text":"Rebut C3 three times against a 60-second clock, each time naming a funding source in the first sentence."}
{"k":"end"}
Total, computed client-side: Content 4+6+6+6 = 22/40, Delivery 8+7+7 = 22/30, Strategy 6+7+7 = 20/30, 64/100.