← Raffle Desk / API
Your token

Raffle Desk API

This page covers the two paid lanes of Raffle Desk for callers who want them from their own code. Both lanes go to one endpoint and are told apart by the task field of the request body.

Overview

The model never picks a winner. The check (parsing the list, flagging clusters, building the pool) and the draw itself are free and run only in the browser, in drawkit.js. No API does either of them. A caller who wants to reproduce or audit a draw re-runs it locally from the draw record, as described in Reproducing a draw.

Runs use the app's model alias gpt-terra. Both lanes are metered, so a run needs a signed-in (personal) token.

Base URL and envelope

https://api.skillsafe.ai/v1/app-api

Send JSON with Content-Type: application/json and the token as Authorization: Bearer YOUR_TOKEN. A successful response wraps its payload in data:

{"ok": true, "data": { ... }}

A failed call answers with a non-2xx status and an error object:

{"error": {"code": "...", "message": "...", "details": ...}}

The app's SDK takes data from a success. On a failure it builds its error from error.message (or the HTTP status text when there is none), plus error.code, error.details and the status. If a body is not JSON, it is read as an empty object, so your client should also handle an error with no error field.

Errors and odd outcomes

This list covers only what the app's code handles. Other statuses and codes can occur, so treat any non-2xx response as an error and show its error.message.

WhereWhat you getWhat the app does, and what you should do
Any callNon-2xx with {"error":{code,message,details}}Shows the message. Log code and details.
Any call401, for exampleThe app treats the session as expired and asks the person to sign in again. Get a fresh token from /tokens.html.
/run, /run-stream402, for exampleThe app says there are not enough credits and links to top-up. Before enabling its Run button, it compares credits from /me with the min_credits its estimate returned.
GET /jobs/{id}status: "failed"This is a terminal state, like succeeded. Read the job's error.
/run-streamAn error event with {message, code, job_id}The SDK fails the run with that message and code. Keep the job_id.
Job or done payloadtruncated: trueThe reply was cut short. The app tells the person it was cut short by the available balance, links to top-up, and shows the sections that arrived.
Your parserThe reply text is not one JSON objectThe app retries once with retry_note set and tells the person it is one extra run (see Input fields).

Step 1. A tiny client and a token

Every snippet below uses the same small helper. It sends JSON, adds the bearer token, returns data, and raises an error on a non-2xx response. The request body is always passed as a JSON string, so what you send is exactly the bytes in your file.

A guest token comes from POST /guest with {"slug":"raffle-desk"} and no Authorization header. Its data carries token and guest_id. Screening and announcing need a personal token. Sign in on the token page and copy it from there. That page's "Copy shell export" button gives you export SKILLSAFE_TOKEN="...", and every helper here reads that variable. In JavaScript, paste it in place of YOUR_TOKEN.

Go, Java and C#: each later snippet is the body of main (or the top-level statements) under this helper. Any import a later snippet needs is named in its first comment.

BASE=https://api.skillsafe.ai/v1/app-api

# A guest token (no Authorization header):
curl -s -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"raffle-desk"}'
# {"ok":true,"data":{"token":"...","guest_id":"..."}}

# screen and announce are metered: use a personal token from /tokens.html
export SKILLSAFE_TOKEN="YOUR_TOKEN"
import json, os, time, urllib.error, urllib.parse, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "")

def api(method, path, body=None, headers=None):
    """body is a JSON string or None; returns the envelope's data."""
    h = {"Content-Type": "application/json", **(headers or {})}
    if TOKEN:
        h["Authorization"] = "Bearer " + TOKEN
    data = body.encode("utf-8") if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, headers=h, method=method)
    try:
        with urllib.request.urlopen(req) as res:
            return json.load(res)["data"]
    except urllib.error.HTTPError as e:
        try:
            err = json.loads(e.read() or b"{}").get("error") or {}
        except ValueError:
            err = {}
        raise RuntimeError(f"{e.code} {err.get('code')}: {err.get('message')}") from None

if not TOKEN:  # no personal token yet: mint a guest one
    TOKEN = api("POST", "/guest", json.dumps({"slug": "raffle-desk"}))["token"]
// Node 18+. Save as raffle.mjs (top-level await).
import { readFileSync } from "node:fs";

const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = ""; // paste YOUR_TOKEN from /tokens.html for metered runs

async function api(method, path, body, headers = {}) {
  const h = { "Content-Type": "application/json", ...headers };
  if (TOKEN) h.Authorization = "Bearer " + TOKEN;
  const res = await fetch(BASE + path, { method, headers: h, body });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    const e = json.error || {};
    throw new Error(`${res.status} ${e.code}: ${e.message || res.statusText}`);
  }
  return json.data;
}

if (!TOKEN) {
  TOKEN = (await api("POST", "/guest", JSON.stringify({ slug: "raffle-desk" }))).token;
}
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

const base = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN")

type envelope struct {
	Data  json.RawMessage `json:"data"`
	Error *struct {
		Code    string `json:"code"`
		Message string `json:"message"`
	} `json:"error"`
}

// api sends body (a JSON string, "" for none) and returns the envelope's data.
func api(method, path, body string, hdr map[string]string) (json.RawMessage, error) {
	req, err := http.NewRequest(method, base+path, strings.NewReader(body))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	for k, v := range hdr {
		req.Header.Set(k, v)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	raw, _ := io.ReadAll(res.Body)
	var env envelope
	json.Unmarshal(raw, &env)
	if res.StatusCode >= 300 {
		if env.Error != nil {
			return nil, fmt.Errorf("%d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
		}
		return nil, fmt.Errorf("HTTP %d", res.StatusCode)
	}
	return env.Data, nil
}

func main() {
	if token == "" { // no personal token yet: mint a guest one
		d, err := api("POST", "/guest", `{"slug":"raffle-desk"}`, nil)
		if err != nil {
			panic(err)
		}
		var g struct {
			Token string `json:"token"`
		}
		json.Unmarshal(d, &g)
		token = g.Token
	}
	// the later steps go here
}
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Pattern;

public class Raffle {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final HttpClient HTTP = HttpClient.newHttpClient();
  static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "");

  // body is a JSON string or null; returns the whole envelope as text
  static String api(String method, String path, String body, String... hdr) throws Exception {
    var b = HttpRequest.newBuilder(URI.create(BASE + path))
        .header("Content-Type", "application/json")
        .method(method, body == null ? HttpRequest.BodyPublishers.noBody()
            : HttpRequest.BodyPublishers.ofString(body));
    if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
    for (int i = 0; i + 1 < hdr.length; i += 2) b.header(hdr[i], hdr[i + 1]);
    var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() >= 300)
      throw new RuntimeException(res.statusCode() + " " + str(res.body(), "code")
          + ": " + str(res.body(), "message"));
    return res.body();
  }

  // The JDK has no JSON parser. This reads the first "key":"string" value;
  // use Jackson or Gson in real code.
  static String str(String json, String key) {
    var m = Pattern.compile("\"" + key + "\"\\s*:\\s*\"").matcher(json);
    if (!m.find()) return null;
    var sb = new StringBuilder();
    for (int i = m.end(); i < json.length(); i++) {
      char c = json.charAt(i);
      if (c == '"') return sb.toString();
      if (c != '\\') { sb.append(c); continue; }
      char e = json.charAt(++i);
      switch (e) {
        case 'n': sb.append('\n'); break;
        case 't': sb.append('\t'); break;
        case 'r': sb.append('\r'); break;
        case 'b': sb.append('\b'); break;
        case 'f': sb.append('\f'); break;
        case 'u': sb.append((char) Integer.parseInt(json.substring(i + 1, i + 5), 16)); i += 4; break;
        default: sb.append(e);
      }
    }
    return null;
  }

  public static void main(String[] args) throws Exception {
    if (token.isEmpty()) token = str(api("POST", "/guest", "{\"slug\":\"raffle-desk\"}"), "token");
    // the later steps go here
  }
}
require "json"
require "net/http"
require "uri"

BASE = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "")

# body is a JSON string or nil; returns the envelope's data
def api(method, path, body = nil, headers = {})
  uri = URI(BASE + path)
  hdr = { "Content-Type" => "application/json" }.merge(headers)
  hdr["Authorization"] = "Bearer #{$token}" unless $token.empty?
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
    http.send_request(method, uri.request_uri, body, hdr)
  end
  json = (JSON.parse(res.body) rescue {})
  unless res.is_a?(Net::HTTPSuccess)
    err = json["error"] || {}
    raise "#{res.code} #{err["code"]}: #{err["message"]}"
  end
  json["data"]
end

# no personal token yet: mint a guest one
$token = api("POST", "/guest", JSON.generate(slug: "raffle-desk"))["token"] if $token.empty?
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SKILLSAFE_TOKEN") ?: "";

// $body is a JSON string or null; returns the envelope's data
function api(string $method, string $path, ?string $body = null, array $headers = []): array
{
    global $token;
    $h = array_merge(["Content-Type: application/json"], $headers);
    if ($token !== "") $h[] = "Authorization: Bearer " . $token;
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => $h,
    ]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    $json = json_decode((string) $raw, true) ?: [];
    if ($raw === false || $status >= 300) {
        $e = $json["error"] ?? [];
        throw new RuntimeException("$status " . ($e["code"] ?? "") . ": " . ($e["message"] ?? ""));
    }
    return $json["data"];
}

// no personal token yet: mint a guest one
if ($token === "") $token = api("POST", "/guest", json_encode(["slug" => "raffle-desk"]))["token"];
// Program.cs (.NET 6+): top-level statements first, the helper class at the end.
using System;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;

if (Api.Token == "") // no personal token yet: mint a guest one
{
    var guest = await Api.Call("POST", "/guest", "{\"slug\":\"raffle-desk\"}");
    Api.Token = guest.GetProperty("token").GetString();
}
// the later steps go here

static class Api
{
    public const string Base = "https://api.skillsafe.ai/v1/app-api";
    public static readonly HttpClient Http = new HttpClient();
    public static string Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "";

    // body is a JSON string or null; returns the envelope's data
    public static async Task<JsonElement> Call(string method, string path, string body = null,
        params (string Name, string Value)[] headers)
    {
        var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
        if (body != null) req.Content = new StringContent(body, Encoding.UTF8, "application/json");
        if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
        foreach (var (name, value) in headers) req.Headers.Add(name, value);
        var res = await Http.SendAsync(req);
        var text = await res.Content.ReadAsStringAsync();
        JsonElement root = default;
        try { root = JsonDocument.Parse(text).RootElement.Clone(); } catch (JsonException) { }
        if (!res.IsSuccessStatusCode)
        {
            var err = root.ValueKind == JsonValueKind.Object && root.TryGetProperty("error", out var e)
                ? e.ToString() : text;
            throw new HttpRequestException($"{(int)res.StatusCode} {err}");
        }
        return root.GetProperty("data");
    }
}

Step 2. Who am I: GET /me

GET /me returns the token's subject: subject_type, subject_id, credits and, when there is one, profile. The app treats subject_type "user" as signed in and shows credits as the balance. It calls /me again after a paid run to refresh the balance.

curl -s "$BASE/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
d, err := api("GET", "/me", "", nil)
if err != nil {
	panic(err)
}
var me struct {
	SubjectType string  `json:"subject_type"`
	Credits     float64 `json:"credits"`
}
json.Unmarshal(d, &me)
fmt.Println(me.SubjectType, me.Credits)
var me = api("GET", "/me", null);
System.out.println(str(me, "subject_type") + " " + me);
me = api("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");

Step 3. Price a run: POST /estimate

Send the same body you are going to run. Save it as body.json: either lane's request from the worked examples works. The app sends its estimate with exactly the input it would run.

What we observed on 2026-09-28 with a guest token: /estimate answered all three of the app's examples with model gpt-5.6-terra, model_alias gpt-terra, markup_bps 1000, input_checked: true, no warnings, a hold_credits of 1,288 (Harbor screen), 1,268 (Discord screen) and 1,255 (Harbor announce), and a min_credits between 113 and 146. It checks the body against the app's declared fields and reports problems in warnings, but still returns a hold: wrapping the body as {"input":{...}} warned about the missing task and facts and the unknown input; sending facts as an object warned field 'facts' should be string, got object; an empty {} warned about both missing fields. A bare JSON string was refused with HTTP 400 (Body must be a JSON object of the app's input fields). So treat a non-empty warnings array as a bug in your request. The hold is an amount reserved, not the price; the app shows it as "reserves" and shows charged_credits after a run. The numbers above change with the input and over time, so read them from your own estimate.

curl -s -X POST "$BASE/estimate" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @body.json
body = open("body.json", encoding="utf-8").read()
print(json.dumps(api("POST", "/estimate", body), indent=2))
const body = readFileSync("body.json", "utf8");
console.log(await api("POST", "/estimate", body));
body, _ := os.ReadFile("body.json")
est, err := api("POST", "/estimate", string(body), nil)
if err != nil {
	panic(err)
}
fmt.Println(string(est))
var body = Files.readString(Path.of("body.json"));
System.out.println(api("POST", "/estimate", body));
body = File.read("body.json")
puts JSON.pretty_generate(api("POST", "/estimate", body))
$body = file_get_contents("body.json");
print_r(api("POST", "/estimate", $body));
var body = File.ReadAllText("body.json");
Console.WriteLine(await Api.Call("POST", "/estimate", body));

Step 4. Run and poll: POST /run, then GET /jobs/{id}

POST /run takes the same body and returns a job_id. Send an Idempotency-Key header with every attempt. The app builds its key as raffle-desk:{lane}:{hash of the input}:a{attempt}. The hash is computed before any retry_note is added, and a reformat retry uses the next attempt number. The app's own comments give the reason: a network blip must never bill twice, and a deliberate retry must be distinguishable from a replay. Keep one key per logical run and resend that same key if you have to repeat the request after a network failure.

curl -s -X POST "$BASE/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: raffle-desk:announce:quorlby-2026-03-16:a1" \
  --data-binary @body.json
# {"ok":true,"data":{"job_id":"..."}}
body = open("body.json", encoding="utf-8").read()
key = "raffle-desk:announce:quorlby-2026-03-16:a1"
job_id = api("POST", "/run", body, {"Idempotency-Key": key})["job_id"]
print(job_id)
const body = readFileSync("body.json", "utf8");
const key = "raffle-desk:announce:quorlby-2026-03-16:a1";
const { job_id } = await api("POST", "/run", body, { "Idempotency-Key": key });
console.log(job_id);
body, _ := os.ReadFile("body.json")
key := "raffle-desk:announce:quorlby-2026-03-16:a1"
d, err := api("POST", "/run", string(body), map[string]string{"Idempotency-Key": key})
if err != nil {
	panic(err)
}
var run struct {
	JobID string `json:"job_id"`
}
json.Unmarshal(d, &run)
fmt.Println(run.JobID)
var body = Files.readString(Path.of("body.json"));
var run = api("POST", "/run", body,
    "Idempotency-Key", "raffle-desk:announce:quorlby-2026-03-16:a1");
System.out.println(str(run, "job_id"));
body = File.read("body.json")
key = "raffle-desk:announce:quorlby-2026-03-16:a1"
job_id = api("POST", "/run", body, { "Idempotency-Key" => key })["job_id"]
puts job_id
$body = file_get_contents("body.json");
$key = "raffle-desk:announce:quorlby-2026-03-16:a1";
$jobId = api("POST", "/run", $body, ["Idempotency-Key: $key"])["job_id"];
echo $jobId, "\n";
var body = File.ReadAllText("body.json");
var run = await Api.Call("POST", "/run", body,
    ("Idempotency-Key", "raffle-desk:announce:quorlby-2026-03-16:a1"));
Console.WriteLine(run.GetProperty("job_id").GetString());

Poll GET /jobs/{job_id} until status is succeeded or failed. The SDK polls once a second and gives up after 180 seconds. A finished job carries charged_credits, truncated, error, and output.output, which is the model's reply as text. You parse that text in step 6. After a run, the app shows charged_credits next to the result.

# needs jq
JOB_ID="JOB_ID"   # from the /run response
while :; do
  JOB=$(curl -s "$BASE/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(printf '%s' "$JOB" | jq -r '.data.status')
  [ "$STATUS" = succeeded ] || [ "$STATUS" = failed ] && break
  sleep 1
done
printf '%s' "$JOB" | jq '.data | {status, charged_credits, truncated, error}'
deadline = time.time() + 180
while True:
    job = api("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
    if job["status"] in ("succeeded", "failed"):
        break
    if time.time() > deadline:
        raise TimeoutError("job timed out")
    time.sleep(1)
print(job["status"], job.get("charged_credits"), job.get("truncated"), job.get("error"))
text = (job.get("output") or {}).get("output") or ""
const start = Date.now();
let job;
for (;;) {
  job = await api("GET", "/jobs/" + encodeURIComponent(job_id));
  if (job.status === "succeeded" || job.status === "failed") break;
  if (Date.now() - start > 180000) throw new Error("job timed out");
  await new Promise((r) => setTimeout(r, 1000));
}
console.log(job.status, job.charged_credits, job.truncated, job.error);
const text = (job.output && job.output.output) || "";
// add "net/url" and "time" to the imports
jobID := "JOB_ID" // from step 4
var job struct {
	Status         string          `json:"status"`
	ChargedCredits float64         `json:"charged_credits"`
	Truncated      bool            `json:"truncated"`
	Error          json.RawMessage `json:"error"`
	Output         struct {
		Output string `json:"output"`
	} `json:"output"`
}
start := time.Now()
for {
	d, err := api("GET", "/jobs/"+url.PathEscape(jobID), "", nil)
	if err != nil {
		panic(err)
	}
	json.Unmarshal(d, &job)
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	if time.Since(start) > 180*time.Second {
		panic("job timed out")
	}
	time.Sleep(time.Second)
}
fmt.Println(job.Status, job.ChargedCredits, job.Truncated, string(job.Error))
text := job.Output.Output
var jobId = "JOB_ID"; // from step 4
long start = System.currentTimeMillis();
String job;
for (;;) {
  job = api("GET", "/jobs/" + URLEncoder.encode(jobId, StandardCharsets.UTF_8), null);
  var status = str(job, "status");
  if ("succeeded".equals(status) || "failed".equals(status)) break;
  if (System.currentTimeMillis() - start > 180_000) throw new RuntimeException("job timed out");
  Thread.sleep(1000);
}
System.out.println(job);
var text = str(job, "output"); // job.output.output: the reply text
job_id = "JOB_ID" # from step 4
job = nil
start = Time.now
loop do
  job = api("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
  break if %w[succeeded failed].include?(job["status"])
  raise "job timed out" if Time.now - start > 180
  sleep 1
end
puts job.values_at("status", "charged_credits", "truncated", "error").inspect
text = job.dig("output", "output").to_s
$jobId = "JOB_ID"; // from step 4
$start = time();
while (true) {
    $job = api("GET", "/jobs/" . rawurlencode($jobId));
    if (in_array($job["status"], ["succeeded", "failed"], true)) break;
    if (time() - $start > 180) throw new RuntimeException("job timed out");
    sleep(1);
}
echo $job["status"], " ", $job["charged_credits"] ?? "", "\n";
$text = $job["output"]["output"] ?? "";
var jobId = "JOB_ID"; // from step 4
var start = DateTime.UtcNow;
JsonElement job;
while (true)
{
    job = await Api.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId));
    var status = job.GetProperty("status").GetString();
    if (status == "succeeded" || status == "failed") break;
    if (DateTime.UtcNow - start > TimeSpan.FromSeconds(180)) throw new TimeoutException("job timed out");
    await Task.Delay(1000);
}
Console.WriteLine(job);
var text = job.TryGetProperty("output", out var o) && o.ValueKind == JsonValueKind.Object
    ? o.GetProperty("output").GetString() : "";

Step 5. Stream instead: POST /run-stream

POST /run-stream takes the same body and headers as /run. When the response's Content-Type is text/event-stream, it is a server-sent event stream. Frames are separated by a blank line. Each frame has an event: line and one or more data: lines that together hold one JSON value. The SDK acts on these event names:

EventDataMeaning
jobjob infoThe job exists. The app uses it to advance its progress display.
delta{"text": "..."}A piece of output. Append it.
done{job_id, status, charged_credits, output}The final payload. The reply text is output.output.
pendinga payloadThe SDK keeps it as the result, exactly as it does done. If it has no output yet, poll GET /jobs/{job_id} (step 4).
error{message, code, job_id}The run failed.

If the response is not an event stream, it is the ordinary JSON envelope. The SDK documents that case as an idempotent replay. Do not rely on delta events arriving at all: the app notes that browsers receive ticks rather than deltas, so it takes the reply from done's output.output and uses the joined deltas only as a fallback. If the stream ends early, the app closes the partial JSON and shows the sections that did arrive.

curl -sN -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: raffle-desk:announce:quorlby-2026-03-16:s1" \
  --data-binary @body.json
# event: NAME
# data: {...JSON...}
# (blank line between frames)
body = open("body.json", encoding="utf-8").read()
req = urllib.request.Request(BASE + "/run-stream", data=body.encode("utf-8"), method="POST",
    headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
             "Idempotency-Key": "raffle-desk:announce:quorlby-2026-03-16:s1"})
final, text = None, ""
with urllib.request.urlopen(req) as res:
    if "text/event-stream" not in res.headers.get("Content-Type", ""):
        final = json.load(res)["data"]  # plain JSON envelope (replay)
    else:
        event, data = "message", ""
        for raw in res:
            line = raw.decode("utf-8").rstrip("\r\n")
            if line.startswith("event:"):
                event = line[6:].strip()
            elif line.startswith("data:"):
                data += line[5:].strip()
            elif line == "":
                if data:
                    msg = json.loads(data)
                    if event == "delta":
                        text += msg.get("text", "")
                    elif event in ("done", "pending"):
                        final = msg
                    elif event == "error":
                        raise RuntimeError(f"{msg.get('code')}: {msg.get('message')}")
                event, data = "message", ""
text = ((final or {}).get("output") or {}).get("output") or text
const res = await fetch(BASE + "/run-stream", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
    "Idempotency-Key": "raffle-desk:announce:quorlby-2026-03-16:s1" },
  body: readFileSync("body.json", "utf8"),
});
let final = null, deltas = "";
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
  const json = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${res.status} ${json.error && json.error.message}`);
  final = json.data; // plain JSON envelope (replay)
} else {
  const decoder = new TextDecoder();
  let buf = "";
  for await (const chunk of res.body) {
    buf += decoder.decode(chunk, { stream: true });
    let i;
    while ((i = buf.indexOf("\n\n")) >= 0) {
      const frame = buf.slice(0, i);
      buf = buf.slice(i + 2);
      let event = "message", data = "";
      for (const line of frame.split("\n")) {
        if (line.startsWith("event:")) event = line.slice(6).trim();
        else if (line.startsWith("data:")) data += line.slice(5).trim();
      }
      if (!data) continue;
      const msg = JSON.parse(data);
      if (event === "delta") deltas += msg.text || "";
      else if (event === "done" || event === "pending") final = msg;
      else if (event === "error") throw new Error(`${msg.code}: ${msg.message}`);
    }
  }
}
const text = (final && final.output && final.output.output) || deltas;
// add "bufio" to the imports
body, _ := os.ReadFile("body.json")
req, _ := http.NewRequest("POST", base+"/run-stream", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "raffle-desk:announce:quorlby-2026-03-16:s1")
res, err := http.DefaultClient.Do(req)
if err != nil {
	panic(err)
}
defer res.Body.Close()
var final json.RawMessage
text := ""
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
	var env envelope // plain JSON envelope (replay)
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 300 {
		panic(fmt.Sprintf("HTTP %d", res.StatusCode))
	}
	final = env.Data
} else {
	sc := bufio.NewScanner(res.Body)
	sc.Buffer(make([]byte, 64*1024), 16*1024*1024)
	event, data := "message", ""
	for sc.Scan() {
		line := strings.TrimRight(sc.Text(), "\r")
		switch {
		case strings.HasPrefix(line, "event:"):
			event = strings.TrimSpace(line[6:])
		case strings.HasPrefix(line, "data:"):
			data += strings.TrimSpace(line[5:])
		case line == "":
			if data != "" {
				switch event {
				case "delta":
					var m struct {
						Text string `json:"text"`
					}
					json.Unmarshal([]byte(data), &m)
					text += m.Text
				case "done", "pending":
					final = json.RawMessage(data)
				case "error":
					panic("stream error: " + data)
				}
			}
			event, data = "message", ""
		}
	}
}
var done struct {
	Output struct {
		Output string `json:"output"`
	} `json:"output"`
}
json.Unmarshal(final, &done)
if done.Output.Output != "" {
	text = done.Output.Output
}
var req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", "raffle-desk:announce:quorlby-2026-03-16:s1")
    .POST(HttpRequest.BodyPublishers.ofString(Files.readString(Path.of("body.json"))))
    .build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
var ctype = res.headers().firstValue("content-type").orElse("");
var lines = res.body().iterator();
String fin = null, event = "message", data = "";
var deltas = new StringBuilder();
if (!ctype.contains("text/event-stream")) {
  var sb = new StringBuilder(); // plain JSON envelope (replay)
  while (lines.hasNext()) sb.append(lines.next()).append('\n');
  if (res.statusCode() >= 300) throw new RuntimeException(res.statusCode() + " " + sb);
  fin = sb.toString();
} else {
  while (lines.hasNext()) {
    var line = lines.next();
    if (line.startsWith("event:")) event = line.substring(6).trim();
    else if (line.startsWith("data:")) data += line.substring(5).trim();
    else if (line.isEmpty()) {
      if (!data.isEmpty()) {
        if (event.equals("delta")) { var t = str(data, "text"); if (t != null) deltas.append(t); }
        else if (event.equals("done") || event.equals("pending")) fin = data;
        else if (event.equals("error")) throw new RuntimeException(str(data, "message"));
      }
      event = "message";
      data = "";
    }
  }
}
var out = fin == null ? null : str(fin, "output");
var text = out != null ? out : deltas.toString();
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
  "Authorization" => "Bearer #{$token}",
  "Idempotency-Key" => "raffle-desk:announce:quorlby-2026-03-16:s1")
req.body = File.read("body.json")
final, text = nil, +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
  http.request(req) do |res|
    unless res["Content-Type"].to_s.include?("text/event-stream")
      final = JSON.parse(res.body)["data"] # plain JSON envelope (replay)
      next
    end
    buf, event, data = +"", "message", +""
    res.read_body do |chunk|
      buf << chunk
      while (i = buf.index("\n"))
        line = buf.slice!(0..i).chomp
        if line.start_with?("event:") then event = line[6..].strip
        elsif line.start_with?("data:") then data << line[5..].strip
        elsif line.empty?
          unless data.empty?
            msg = JSON.parse(data)
            case event
            when "delta" then text << msg["text"].to_s
            when "done", "pending" then final = msg
            when "error" then raise "#{msg["code"]}: #{msg["message"]}"
            end
          end
          event, data = "message", +""
        end
      end
    end
  end
end
text = final.dig("output", "output") if final && final.dig("output", "output")
$final = null; $error = null; $text = ""; $raw = ""; $buf = "";
$event = "message"; $data = "";
$onLine = function (string $line) use (&$event, &$data, &$final, &$error, &$text) {
    if (strncmp($line, "event:", 6) === 0) { $event = trim(substr($line, 6)); return; }
    if (strncmp($line, "data:", 5) === 0) { $data .= trim(substr($line, 5)); return; }
    if ($line !== "") return;
    $msg = $data !== "" ? json_decode($data, true) : null;
    if (is_array($msg)) {
        if ($event === "delta") $text .= $msg["text"] ?? "";
        elseif ($event === "done" || $event === "pending") $final = $msg;
        elseif ($event === "error") $error = $msg;
    }
    $event = "message"; $data = "";
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => file_get_contents("body.json"),
    CURLOPT_HTTPHEADER => ["Content-Type: application/json", "Authorization: Bearer $token",
        "Idempotency-Key: raffle-desk:announce:quorlby-2026-03-16:s1"],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$raw, $onLine) {
        $raw .= $chunk;
        $buf .= $chunk;
        while (($i = strpos($buf, "\n")) !== false) {
            $onLine(rtrim(substr($buf, 0, $i), "\r"));
            $buf = substr($buf, $i + 1);
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
$ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) {
    $final = json_decode($raw, true)["data"] ?? null; // plain JSON envelope (replay)
}
if ($error) throw new RuntimeException(($error["code"] ?? "") . ": " . ($error["message"] ?? ""));
$text = $final["output"]["output"] ?? $text;
var req = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream")
{
    Content = new StringContent(File.ReadAllText("body.json"), Encoding.UTF8, "application/json")
};
req.Headers.Add("Authorization", "Bearer " + Api.Token);
req.Headers.Add("Idempotency-Key", "raffle-desk:announce:quorlby-2026-03-16:s1");
using var res = await Api.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
JsonElement? done = null;
var deltas = new StringBuilder();
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
    var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
    if (!res.IsSuccessStatusCode) throw new HttpRequestException(env.ToString());
    done = env.GetProperty("data"); // plain JSON envelope (replay)
}
else
{
    using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
    string evt = "message", data = "", line;
    while ((line = await reader.ReadLineAsync()) != null)
    {
        if (line.StartsWith("event:")) evt = line[6..].Trim();
        else if (line.StartsWith("data:")) data += line[5..].Trim();
        else if (line.Length == 0)
        {
            if (data.Length > 0)
            {
                var msg = JsonDocument.Parse(data).RootElement;
                if (evt == "delta" && msg.TryGetProperty("text", out var t)) deltas.Append(t.GetString());
                else if (evt == "done" || evt == "pending") done = msg;
                else if (evt == "error") throw new Exception(msg.GetProperty("message").GetString());
            }
            evt = "message"; data = "";
        }
    }
}
var text = done is JsonElement d && d.TryGetProperty("output", out var o)
    && o.ValueKind == JsonValueKind.Object ? o.GetProperty("output").GetString() : deltas.ToString();

Step 6. Read the reply text

Whichever way you ran it, text is the model's reply as a string. The app reads it in three moves. It trims the text and strips a leading ``` fence (with an optional language) and a trailing one. It then cuts from the first { to the last }. Finally it parses that slice as JSON. If there is no { or the parse fails, the app retries once with a retry_note. If the second reply also fails, it shows the raw reply. After parsing, apply the lane's output contract and checks from below.

# needs jq; job.json is the GET /jobs/{id} response from step 4
jq -r '.data.output.output' job.json \
  | sed -e '1s/^```[A-Za-z]*//' -e '$s/```$//' \
  | jq '{task, headline}'
# jq fails if prose surrounds the object; the other tabs cut from the first { to the last }
import re

def parse_reply(text):
    t = re.sub(r"^```[a-z]*\s*", "", text.strip(), flags=re.I)
    t = re.sub(r"```\s*$", "", t)
    a, b = t.find("{"), t.rfind("}")
    if a == -1:
        raise ValueError("no JSON object in the reply")
    return json.loads(t[a:] if b == -1 else t[a:b + 1])

reply = parse_reply(text)
print(reply.get("task"), reply.get("headline"))
function parseReply(text) {
  const t = String(text || "").trim().replace(/^```[a-z]*\s*/i, "").replace(/```\s*$/, "");
  const a = t.indexOf("{"), b = t.lastIndexOf("}");
  if (a === -1) throw new Error("no JSON object in the reply");
  return JSON.parse(t.slice(a, b === -1 ? t.length : b + 1));
}

const reply = parseReply(text);
console.log(reply.task, reply.headline);
// add "regexp" to the imports
t := strings.TrimSpace(text)
t = regexp.MustCompile("(?i)^```[a-z]*\\s*").ReplaceAllString(t, "")
t = regexp.MustCompile("```\\s*$").ReplaceAllString(t, "")
a, b := strings.Index(t, "{"), strings.LastIndex(t, "}")
if a == -1 {
	panic("no JSON object in the reply")
}
if b < a {
	b = len(t) - 1
}
var reply map[string]any
if err := json.Unmarshal([]byte(t[a:b+1]), &reply); err != nil {
	panic(err)
}
fmt.Println(reply["task"], reply["headline"])
var t = text.strip().replaceFirst("(?i)^```[a-z]*\\s*", "").replaceFirst("```\\s*$", "");
int a = t.indexOf('{'), b = t.lastIndexOf('}');
if (a == -1) throw new RuntimeException("no JSON object in the reply");
var replyJson = t.substring(a, b == -1 ? t.length() : b + 1);
// hand replyJson to Jackson or Gson; str() is enough for the flat string fields
System.out.println(str(replyJson, "task") + ": " + str(replyJson, "headline"));
def parse_reply(text)
  t = text.to_s.strip.sub(/\A```[a-z]*\s*/i, "").sub(/```\s*\z/, "")
  a, b = t.index("{"), t.rindex("}")
  raise "no JSON object in the reply" if a.nil?
  JSON.parse(t[a..(b || -1)])
end

reply = parse_reply(text)
puts "#{reply["task"]}: #{reply["headline"]}"
function parse_reply(string $text): array
{
    $t = preg_replace('/^```[a-z]*\s*/i', "", trim($text));
    $t = preg_replace('/```\s*$/', "", $t);
    $a = strpos($t, "{");
    $b = strrpos($t, "}");
    if ($a === false) throw new RuntimeException("no JSON object in the reply");
    $len = ($b === false ? strlen($t) : $b + 1) - $a;
    return json_decode(substr($t, $a, $len), true, 512, JSON_THROW_ON_ERROR);
}

$reply = parse_reply($text);
echo $reply["task"], ": ", $reply["headline"] ?? "", "\n";
// add: using System.Text.RegularExpressions;
var s = Regex.Replace(text.Trim(), "^```[a-z]*\\s*", "", RegexOptions.IgnoreCase);
s = Regex.Replace(s, "```\\s*$", "");
int a = s.IndexOf('{'), b = s.LastIndexOf('}');
if (a == -1) throw new Exception("no JSON object in the reply");
var reply = JsonDocument.Parse(s.Substring(a, (b == -1 ? s.Length : b + 1) - a)).RootElement;
Console.WriteLine($"{reply.GetProperty("task")}: {reply.GetProperty("headline")}");

Input

The task field comes first

One system prompt serves both lanes, and task picks the lane:

taskLaneWhat facts must describe
"screen"Judge the flagged clusters and read the rules for gapsThe checked list: settings, counts and the flagged clusters with their rows
"announce"Write the announcementA finished draw: winners' labels, runner-up count and the draw's numbers

The instructions tell the model what to do when task is missing or unknown: pick the lane the facts fit (a clusters array means screen, a winners array means announce), name it in the reply's task, and not blend the two lanes. Always send task yourself anyway. When a reply names the other lane, the app still reads it as the lane it asked for and reports the mismatch as a disagreement.

Input fields

FieldTypeRequiredNotes
taskstringyes"screen" or "announce".
factsstringyesA JSON-encoded string, not an object: JSON.stringify(facts). The field lists for each lane are below.
questionstringnoSomething the organiser wants answered. The answer goes in summary. The app always sends it, as "" when empty. It collapses whitespace and cuts the question to 600 characters.
retry_notestringnoSent only when the previous reply could not be read. The app's wording is: Your previous reply was not the single valid JSON object the instructions require (PARSE ERROR). Reply again with ONLY the JSON object for task 'LANE' - no prose, no code fences; every key present (empty strings or arrays where there is nothing to say).

So a request body looks like this:

{"task":"announce","facts":"{\"giveaway\":\"...\",...}","question":""}

facts for screen

KeyTypeMeaning
giveawaystringGiveaway name, whitespace collapsed, at most 120 characters.
prizestringPrize, at most 300 characters.
rulesstringThe organiser's rules text. Past 8,000 characters, the middle is cut and replaced with a marker line [... N characters of the rules cut from the middle ...]. It can be empty.
rules_clipped_charsnumberHow many characters were cut (0 when none).
settings.entries_per_personstring"one entry per person" or "weighted by the tickets column".
settings.max_ticketsnumber | nullTicket cap.
settings.deadlinestring | nullThe deadline as read, "unreadable: ..." when it could not be read, or null.
settings.exclusion_linesnumberNumber of exclusion-list lines.
countsobjectrows, people (rows joined by a shared email or handle), with_email, with_handle, flagged_rows, clusters, tickets_raw_total.
clustersarrayAt most 60 are sent. When there are more, the codes earlier in this order win: SAME_MAILBOX, PLUS_ALIAS, SAME_NAME, SAME_CONTACT, DISPOSABLE, NO_CONTACT, BAD_TICKETS, NO_TIME, EXCLUDED, LATE. The sent clusters then go back into id order.
clusters[].idstringC1, C2, ... numbered in order of each cluster's first row.
clusters[].code, .title, .detailstringThe flag (table below), its title, and what the browser found.
clusters[].hard_rulebooleanTrue for EXCLUDED and LATE.
clusters[].default_actionstringOne of keep_all, keep_one, disqualify, disqualify_some.
clusters[].default_keep_rownumberPresent only when the default is keep_one.
clusters[].exclusion_linestringThe exclusion line that matched, when there is one.
clusters[].rows_totalnumberRows in the cluster.
clusters[].rowsarrayAt most 8 rows: row, name, email, handle. In weighted mode they also carry tickets and, when the count had to be corrected, ticket_issue. entered (at most 40 characters) appears when the row had an entry time.
clusters_total, clusters_sentnumberClusters found and clusters sent.
notesstring[]At most 12 notes from reading the list.
CodeTitleDefaultHard rule
SAME_CONTACTSame email or handle entered more than oncekeep_oneno
SAME_MAILBOXGmail dot or plus variants of one inboxkeep_oneno
PLUS_ALIASPlus-tag variants of one address (non-Gmail)keep_allno
SAME_NAMESame full name under different contactskeep_allno
EXCLUDEDMatches your exclusion listdisqualifyyes
LATEEntered after the deadlinedisqualifyyes
NO_TIMENo readable entry time (a deadline is set)keep_allno
NO_CONTACTNo email or handle to notifykeep_allno
BAD_TICKETSTicket count missing, invalid or over the capkeep_allno
DISPOSABLEDisposable email domainkeep_allno

facts for announce

KeyTypeMeaning
giveaway, prizestringCopied from the draw record.
channelstringWhere the post goes. Defaults to "social post".
claim_windowstringAt most 120 characters. Can be "".
contact_methodstringAt most 200 characters. Can be "".
name_runner_upsbooleanWhether runner-ups may be named.
voicestringTone, at most 400 characters.
winnersarray{order, label} for each winner, where label is the display label from the pool.
runner_up_countnumberHow many runner-ups were drawn.
runner_upsarray{order, label}, only when name_runner_ups is true. Otherwise [].
drawobjectdrawn_at (ISO time), entries, tickets, weighted, rows_read, removed_before_draw, commit, pool_hash, seed, beacon ("" when none), method.

The announce facts carry no contact details, only display labels. Everything in draw comes straight from the draw record (see Reproducing a draw). Screen facts do include the name, email and handle of the flagged rows, and only those rows.

Worked examples

These two inputs are exactly what the app sends for two of its examples. The replies were written against the app's system prompt by a stand-in model during testing, not by gpt-terra, and each one passes the app's checks. Where something is shortened, a bare ... line marks the cut, and every value that is shown is unchanged.

screen: a Discord art raffle

Weighted tickets with a cap of 5, two exclusion lines, and 15 rows, of which 9 fall in 5 flagged clusters. The body sends facts as one JSON string, and it starts like this:

{"task":"screen","facts":"{\"giveaway\":\"Pixel Guild October raffle\",\"prize\":\"a drawing tablet\",...","question":""}

Here is that facts string decoded, with clusters C2 to C4 left out:

{
  "giveaway": "Pixel Guild October raffle",
  "prize": "a drawing tablet",
  "rules": "Pixel Guild October raffle: every member gets 1 ticket, plus 1 more for each artwork posted in #showcase this month, up to 5 tickets. Mods can't enter. The winner gets the drawing tablet.",
  "rules_clipped_chars": 0,
  "settings": {
    "entries_per_person": "weighted by the tickets column",
    "max_tickets": 5,
    "deadline": null,
    "exclusion_lines": 2
  },
  "counts": {
    "rows": 15,
    "people": 14,
    "with_email": 0,
    "with_handle": 14,
    "flagged_rows": 9,
    "clusters": 5,
    "tickets_raw_total": 35
  },
  "clusters": [
    {
      "id": "C1",
      "code": "SAME_CONTACT",
      "title": "Same email or handle entered more than once",
      "detail": "2 rows share the handle @nightowl.",
      "hard_rule": false,
      "default_action": "keep_one",
      "rows_total": 2,
      "rows": [
        {
          "row": 1,
          "name": "Alex Kim",
          "email": "",
          "handle": "@nightowl",
          "tickets": 3
        },
        {
          "row": 3,
          "name": "Alex Kim",
          "email": "",
          "handle": "@nightowl",
          "tickets": 2
        }
      ],
      "default_keep_row": 1
    },
    ...
    {
      "id": "C5",
      "code": "EXCLUDED",
      "title": "Matches your exclusion list",
      "detail": "Matches the exclusion line \"@mod_tessa\".",
      "hard_rule": true,
      "default_action": "disqualify",
      "rows_total": 1,
      "rows": [
        {
          "row": 11,
          "name": "Tessa Grant",
          "email": "",
          "handle": "@mod_tessa",
          "tickets": 3
        }
      ],
      "exclusion_line": "@mod_tessa"
    }
  ],
  "clusters_total": 5,
  "clusters_sent": 5,
  "notes": []
}

Reply (C2 to C4, three rule gaps and four checks left out):

{
  "task": "screen",
  "headline": "5 clusters were flagged across 9 of 15 rows: one duplicate account to resolve, three keep-all groups where you should weigh in, and one moderator excluded under your rules.",
  "decisions": [
    {
      "cluster": "C1",
      "action": "keep_one",
      "keep_row": 1,
      "rows": [],
      "needs_organizer": false,
      "reason": "#1 and #3 are both Alex Kim entering under the handle @nightowl, the same account entered twice; #1 came first and stays, #3 is removed as the duplicate.",
      "rule_quote": ""
    },
    ...
    {
      "cluster": "C5",
      "action": "disqualify",
      "keep_row": null,
      "rows": [],
      "needs_organizer": false,
      "reason": "#11 matches the exclusion line you added for @mod_tessa, and the handle is consistent with the rule that mods can't enter.",
      "rule_quote": "Mods can't enter"
    }
  ],
  "rule_gaps": [
    {
      "gap": "No deadline or time zone for entries and #showcase posts",
      "why": "Without a cutoff, it isn't clear which entries and artwork posts should count toward this draw.",
      "suggested_wording": "Entries and #showcase posts close on [date and time, with time zone]; only those received before that count."
    },
    ...
  ],
  "before_the_draw": [
    "Confirm whether #1 and #3 (both @nightowl, Alex Kim) are really the same person before removing #3 as the duplicate.",
    "Confirm whether #2 (@pixelpush) and #12 (@pixelpush2) are the same person or two different people.",
    ...
  ],
  "summary": "Out of 15 rows across 5 clusters, one pair is a clear duplicate account (#1 and #3, kept #1) and one entry is excluded under your mods rule (#11). Three clusters are kept in the pool but need your judgment: a same-name pair with related handles, three rows whose ticket counts had to be corrected, and one entrant with no way to be contacted if they win. The rules also don't set a deadline, a claim window, or how the winner is chosen, which are noted as gaps to add before the next raffle."
}

announce: a coffee shop giveaway

Three winners, two runner-ups who are not to be named, and a 72-hour claim window. This is the full request body, exactly as the app sends it (save it as body.json for the steps above):

{"task":"announce","facts":"{\"giveaway\":\"Quorlby Coffee Spring Giveaway\",\"prize\":\"three $50 Quorlby Coffee gift cards, one per winner\",\"channel\":\"Instagram post\",\"claim_window\":\"72 hours\",\"contact_method\":\"reply to the email from hello@quorlbycoffee.example\",\"name_runner_ups\":false,\"voice\":\"Warm and brief, like the shop's usual posts.\",\"winners\":[{\"order\":1,\"label\":\"Hannah W.\"},{\"order\":2,\"label\":\"Chloe M.\"},{\"order\":3,\"label\":\"Aiko T.\"}],\"runner_up_count\":2,\"runner_ups\":[],\"draw\":{\"drawn_at\":\"2026-03-16T15:00:00.000Z\",\"entries\":17,\"tickets\":17,\"weighted\":false,\"rows_read\":25,\"removed_before_draw\":8,\"commit\":\"09fcbcd03facd833f68c4a77f4b88da5c2804b05c9fcdba8a788e1d50b1652c1\",\"pool_hash\":\"b2fe877f1e94896a8dea1f5b4deb6bb8a93ea0945168d5ed9ad2f80477f07c5f\",\"seed\":\"5f0c2a9e41d7b3860e2f94c1a7d05b3e98c6f21a4d7e0b5c3f8a1d6e29b4c07a\",\"beacon\":\"\",\"method\":\"SHA-256 counter mode: h = SHA-256(\\\"raffle-desk/v1|\\\" + seed + \\\"|\\\" + beacon + \\\"|\\\" + pool_hash + \\\"|\\\" + counter); x = first 13 hex digits of h; reject x >= floor(2^52/T)*T; ticket = x mod T (tickets numbered in pool order); skip a ticket whose holder was already drawn; counter starts at 0 and rises by one per attempt.\"}}","question":""}

The same facts, decoded:

{
  "giveaway": "Quorlby Coffee Spring Giveaway",
  "prize": "three $50 Quorlby Coffee gift cards, one per winner",
  "channel": "Instagram post",
  "claim_window": "72 hours",
  "contact_method": "reply to the email from hello@quorlbycoffee.example",
  "name_runner_ups": false,
  "voice": "Warm and brief, like the shop's usual posts.",
  "winners": [
    {
      "order": 1,
      "label": "Hannah W."
    },
    {
      "order": 2,
      "label": "Chloe M."
    },
    {
      "order": 3,
      "label": "Aiko T."
    }
  ],
  "runner_up_count": 2,
  "runner_ups": [],
  "draw": {
    "drawn_at": "2026-03-16T15:00:00.000Z",
    "entries": 17,
    "tickets": 17,
    "weighted": false,
    "rows_read": 25,
    "removed_before_draw": 8,
    "commit": "09fcbcd03facd833f68c4a77f4b88da5c2804b05c9fcdba8a788e1d50b1652c1",
    "pool_hash": "b2fe877f1e94896a8dea1f5b4deb6bb8a93ea0945168d5ed9ad2f80477f07c5f",
    "seed": "5f0c2a9e41d7b3860e2f94c1a7d05b3e98c6f21a4d7e0b5c3f8a1d6e29b4c07a",
    "beacon": "",
    "method": "SHA-256 counter mode: h = SHA-256(\"raffle-desk/v1|\" + seed + \"|\" + beacon + \"|\" + pool_hash + \"|\" + counter); x = first 13 hex digits of h; reject x >= floor(2^52/T)*T; ticket = x mod T (tickets numbered in pool order); skip a ticket whose holder was already drawn; counter starts at 0 and rises by one per attempt."
  }
}

Reply:

{
  "task": "announce",
  "headline": "Three winners are ready to announce for the Quorlby Coffee Spring Giveaway, along with DM drafts and the numbers to verify the draw.",
  "public_post": "Our Spring Giveaway winners are in! Congrats to Hannah W., Chloe M., and Aiko T., each of you has won a $50 Quorlby Coffee gift card. We're reaching out to you directly by email with the details. This draw's numbers are published and can be checked. Thank you to everyone who entered!",
  "winner_message": "Hi [Name], congratulations, you're one of the winners of the Quorlby Coffee Spring Giveaway! You've won a $50 Quorlby Coffee gift card. To claim it, please reply to the email from hello@quorlbycoffee.example within 72 hours. No password, payment, card details, or fees are ever needed, just your reply. Thanks so much for entering, and congrats again!",
  "runner_up_message": "Hi [Name], thank you so much for entering the Quorlby Coffee Spring Giveaway! You weren't picked as a winner this time, but you're on our runner-up list. If a winner doesn't claim their prize in time, we may reach out to you. We'll let you know either way, thanks again for taking part!",
  "verification_note": "This draw ran on 2026-03-16 at 15:00 UTC. 25 rows were read and 8 were removed before the draw, leaving 17 entries (17 tickets, one per entry, not weighted). Three winners and two runner-ups were selected. The commit is 09fcbcd03facd833f68c4a77f4b88da5c2804b05c9fcdba8a788e1d50b1652c1, which is SHA-256 of the seed. The pool hash is b2fe877f1e94896a8dea1f5b4deb6bb8a93ea0945168d5ed9ad2f80477f07c5f. The seed is 5f0c2a9e41d7b3860e2f94c1a7d05b3e98c6f21a4d7e0b5c3f8a1d6e29b4c07a. No beacon was used. Winners were picked using a SHA-256 counter-mode draw over the seed and pool hash, skipping any ticket already drawn.",
  "checklist": [
    "Reply-check that each winner's message went out to the right person.",
    "Track the 72-hour claim window for all three winners.",
    "If a winner does not respond in time, contact the next runner-up instead.",
    "Keep the commit, pool hash, seed, and draw record on file for reference.",
    "Publish the Instagram post only after winners have been notified privately."
  ],
  "summary": "The draw picked Hannah W., Chloe M., and Aiko T. as winners from 17 entries, with two runner-ups on standby. All the text you need to post, message winners, and verify the draw is ready below."
}

Output contracts

Each lane replies with one JSON object and nothing else. Every key in the lane's contract must be present, with "" or [] when there is nothing to say. The instructions ask for plain text inside every string (no Markdown). Only the announce lane's long text fields may contain line breaks.

screen

KeyTypeContract
taskstring"screen".
headlinestringOne sentence.
decisionsarrayExactly one per cluster in facts.clusters, in cluster order.
decisions[].clusterstringA cluster id such as C1. The app also accepts id, upper-cases the value, and drops a decision whose id is not of the form C followed by digits.
decisions[].actionstringkeep_all, keep_one, disqualify or disqualify_some. The app lower-cases the value and turns spaces and hyphens into _.
decisions[].keep_rownumber | nullA row of the cluster when the action is keep_one, otherwise null. The app also accepts "#12".
decisions[].rowsnumber[]The rows to remove when the action is disqualify_some, otherwise [].
decisions[].needs_organizerbooleantrue (or "true") when a person should decide.
decisions[].reasonstringThe evidence, naming rows as #N.
decisions[].rule_quotestringA verbatim quote from facts.rules, or "".
rule_gapsarray{gap, why, suggested_wording}. The app drops items without a gap.
before_the_drawstring[]Checks to make before pressing Draw.
summarystringTwo to four sentences. Answers question if one was asked.

The app rejects a screen reply that is not an object, or that has no decisions array and neither a headline nor a summary. It then checks each decision against the facts. Do the same before you act on one:

announce

KeyTypeContract
taskstring"announce".
headlinestringOne sentence for the organiser.
public_poststringThe announcement for channel. It names every winner by label exactly and nobody else. Runner-ups are named only if allowed.
winner_messagestringA direct message with [Name] where the name goes. It includes the claim window and contact method when they are given.
runner_up_messagestringWith [Name]. "" when runner_up_count is 0.
verification_notestringWhen the draw ran, the entry and ticket counts, the full commit, pool_hash and seed, the beacon if any, and one sentence of method.
checkliststring[]Four to seven next steps.
summarystringOne to three sentences. Answers question if one was asked.

The app rejects an announce reply that is not an object, or that has none of public_post, headline and summary. It then checks the reply against the draw. Some of these checks need data that is not in facts: the runner-ups' labels and the other entrants' labels, both taken from the draw record's pool file. Keep the record so that you can run them too.

Reproducing a draw

No endpoint runs the draw. The browser does, and it saves a draw record (format: "raffle-desk/draw-record/v1"). The record holds seed, beacon, commit, pool_hash, pool_text, total_entries, total_tickets, winners_wanted, runners_wanted, rows_read, removed_before_draw, picks, attempts, rejected, repeats and method. Anyone can check it with nothing but SHA-256. Every hash is taken over the UTF-8 bytes of a string and written as lower-case hex:

  1. pool_text is the line raffle-desk pool v1 followed by one line per entry, row<TAB>tickets<TAB>label, joined with \n. pool_hash must equal SHA-256(pool_text).
  2. commit must equal SHA-256(seed).
  3. Let T be the total tickets. Number the tickets 0 to T−1 in pool order, so each entry holds a consecutive range.
  4. For counter = 0, 1, 2, ...: h = SHA-256("raffle-desk/v1|" + seed + "|" + beacon + "|" + pool_hash + "|" + counter), with counter written in decimal. Let x be the first 13 hex digits of h, read as an integer. If x ≥ floor(252 / T) × T, reject it and move on. Otherwise t = x mod T, and the pick is the entry whose range holds t. If that entry was already picked, skip it.
  5. Stop after winners_wanted + runners_wanted picks, or earlier if every entry has been picked. The first winners_wanted picks are winners and the rest are runner-ups, in order. Each pick records order, role, row, label, tickets, ticket (that is, t + 1) and counter.

The page's own verifier passes a record only when all of these hold: the format is right, both hashes match, pool_text parses, the entry and ticket totals match, and re-running the draw gives the same row, order, role, counter and ticket for every pick. The app makes its seed from 32 random bytes written as hex, but any non-empty string works as a seed.