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
screentakes the clusters of entrant rows that the browser flagged (the same inbox twice, Gmail dot and plus variants, one name under two contacts, the exclusion list, late entries, bad ticket counts and so on) together with the organiser's pasted rules. It returns one decision per cluster, the gaps in the rules, and checks to make before the draw.announcetakes a summary of a finished draw record and the winners' display labels. It returns a public post, a winner message, a runner-up message, a verification note and a checklist.
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.
| Where | What you get | What the app does, and what you should do |
|---|---|---|
| Any call | Non-2xx with {"error":{code,message,details}} | Shows the message. Log code and details. |
| Any call | 401, for example | The app treats the session as expired and asks the person to sign in again. Get a fresh token from /tokens.html. |
/run, /run-stream | 402, for example | The 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-stream | An error event with {message, code, job_id} | The SDK fails the run with that message and code. Keep the job_id. |
Job or done payload | truncated: true | The 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 parser | The reply text is not one JSON object | The 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:
| Event | Data | Meaning |
|---|---|---|
job | job info | The 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. |
pending | a payload | The 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:
task | Lane | What facts must describe |
|---|---|---|
"screen" | Judge the flagged clusters and read the rules for gaps | The checked list: settings, counts and the flagged clusters with their rows |
"announce" | Write the announcement | A 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
| Field | Type | Required | Notes |
|---|---|---|---|
task | string | yes | "screen" or "announce". |
facts | string | yes | A JSON-encoded string, not an object: JSON.stringify(facts). The field lists for each lane are below. |
question | string | no | Something 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_note | string | no | Sent 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
| Key | Type | Meaning |
|---|---|---|
giveaway | string | Giveaway name, whitespace collapsed, at most 120 characters. |
prize | string | Prize, at most 300 characters. |
rules | string | The 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_chars | number | How many characters were cut (0 when none). |
settings.entries_per_person | string | "one entry per person" or "weighted by the tickets column". |
settings.max_tickets | number | null | Ticket cap. |
settings.deadline | string | null | The deadline as read, "unreadable: ..." when it could not be read, or null. |
settings.exclusion_lines | number | Number of exclusion-list lines. |
counts | object | rows, people (rows joined by a shared email or handle), with_email, with_handle, flagged_rows, clusters, tickets_raw_total. |
clusters | array | At 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[].id | string | C1, C2, ... numbered in order of each cluster's first row. |
clusters[].code, .title, .detail | string | The flag (table below), its title, and what the browser found. |
clusters[].hard_rule | boolean | True for EXCLUDED and LATE. |
clusters[].default_action | string | One of keep_all, keep_one, disqualify, disqualify_some. |
clusters[].default_keep_row | number | Present only when the default is keep_one. |
clusters[].exclusion_line | string | The exclusion line that matched, when there is one. |
clusters[].rows_total | number | Rows in the cluster. |
clusters[].rows | array | At 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_sent | number | Clusters found and clusters sent. |
notes | string[] | At most 12 notes from reading the list. |
| Code | Title | Default | Hard rule |
|---|---|---|---|
| SAME_CONTACT | Same email or handle entered more than once | keep_one | no |
| SAME_MAILBOX | Gmail dot or plus variants of one inbox | keep_one | no |
| PLUS_ALIAS | Plus-tag variants of one address (non-Gmail) | keep_all | no |
| SAME_NAME | Same full name under different contacts | keep_all | no |
| EXCLUDED | Matches your exclusion list | disqualify | yes |
| LATE | Entered after the deadline | disqualify | yes |
| NO_TIME | No readable entry time (a deadline is set) | keep_all | no |
| NO_CONTACT | No email or handle to notify | keep_all | no |
| BAD_TICKETS | Ticket count missing, invalid or over the cap | keep_all | no |
| DISPOSABLE | Disposable email domain | keep_all | no |
facts for announce
| Key | Type | Meaning |
|---|---|---|
giveaway, prize | string | Copied from the draw record. |
channel | string | Where the post goes. Defaults to "social post". |
claim_window | string | At most 120 characters. Can be "". |
contact_method | string | At most 200 characters. Can be "". |
name_runner_ups | boolean | Whether runner-ups may be named. |
voice | string | Tone, at most 400 characters. |
winners | array | {order, label} for each winner, where label is the display label from the pool. |
runner_up_count | number | How many runner-ups were drawn. |
runner_ups | array | {order, label}, only when name_runner_ups is true. Otherwise []. |
draw | object | drawn_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
| Key | Type | Contract |
|---|---|---|
task | string | "screen". |
headline | string | One sentence. |
decisions | array | Exactly one per cluster in facts.clusters, in cluster order. |
decisions[].cluster | string | A 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[].action | string | keep_all, keep_one, disqualify or disqualify_some. The app lower-cases the value and turns spaces and hyphens into _. |
decisions[].keep_row | number | null | A row of the cluster when the action is keep_one, otherwise null. The app also accepts "#12". |
decisions[].rows | number[] | The rows to remove when the action is disqualify_some, otherwise []. |
decisions[].needs_organizer | boolean | true (or "true") when a person should decide. |
decisions[].reason | string | The evidence, naming rows as #N. |
decisions[].rule_quote | string | A verbatim quote from facts.rules, or "". |
rule_gaps | array | {gap, why, suggested_wording}. The app drops items without a gap. |
before_the_draw | string[] | Checks to make before pressing Draw. |
summary | string | Two 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:
- A decision for a cluster that was not sent is ignored, and so is a second decision for the same cluster.
- An unknown action leaves the cluster's default in place.
- An EXCLUDED or LATE cluster (
hard_rule: true) staysdisqualifywhatever the reply says. The review may tighten these rules but never loosen them. - With
keep_one, akeep_rowthat is missing or not among the cluster's sent rows is replaced by the default keep row. - With
disqualify_some, rows outside the cluster are ignored. If none are left, the default stays. - A disqualification with an empty
reasonis flagged. - A
rule_quotemust occur infacts.rules, compared case-insensitively with quote marks and extra whitespace removed. It is flagged when no rules were pasted. - Every
#Nin areasonmust be a row of that cluster. - A cluster that got no decision keeps its default, as does any cluster that was not sent.
announce
| Key | Type | Contract |
|---|---|---|
task | string | "announce". |
headline | string | One sentence for the organiser. |
public_post | string | The announcement for channel. It names every winner by label exactly and nobody else. Runner-ups are named only if allowed. |
winner_message | string | A direct message with [Name] where the name goes. It includes the claim window and contact method when they are given. |
runner_up_message | string | With [Name]. "" when runner_up_count is 0. |
verification_note | string | When the draw ran, the entry and ticket counts, the full commit, pool_hash and seed, the beacon if any, and one sentence of method. |
checklist | string[] | Four to seven next steps. |
summary | string | One 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.
public_post,winner_messageandverification_notemust not be empty.runner_up_messagemust not be empty when there are runner-ups.- Every winner's
labelmust appear inpublic_postcharacter for character, with no letter or digit directly before or after it. - When
name_runner_upsis false, no runner-up label may appear in the post. No other entrant's label may appear either. Labels shorter than 5 characters andEntry #Nlabels are not checked. - Any run of 16 or more hex digits anywhere in the reply must be part of
commit,pool_hashorseed. verification_notemust contain the fullcommit,pool_hashandseed.- A count followed by entries, entrants, tickets, people, participants, names or submissions in
public_post,verification_noteorheadlinemust be one of:entries,tickets,rows_read,removed_before_draw, the number of winners,runner_up_count, orrows_readminusremoved_before_draw. - Any money amount (
$,€,£, or USD, EUR, GBP, CAD or AUD) must have its digits inprize. - When
claim_windowis set,winner_messagemust contain it, compared case-insensitively with quote marks and extra whitespace removed.
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:
pool_textis the lineraffle-desk pool v1followed by one line per entry,row<TAB>tickets<TAB>label, joined with\n.pool_hashmust equal SHA-256(pool_text).commitmust equal SHA-256(seed).- Let T be the total tickets. Number the tickets 0 to T−1 in pool order, so each entry holds a consecutive range.
- 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. - Stop after
winners_wanted + runners_wantedpicks, or earlier if every entry has been picked. The firstwinners_wantedpicks are winners and the rest are runner-ups, in order. Each pick recordsorder,role,row,label,tickets,ticket(that is, t + 1) andcounter.
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.