Sprint Desk over HTTP
Everything the page does, your script can do: paste a backlog and a scenario, get back the commitment call, the sprint goal, the capacity arithmetic, the committed backlog, the cut list, the risks, the actions and the open questions. Six steps, each shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#. Pick a language once and the whole page follows.
Base URL, envelope and errors
Every route lives under https://api.skillsafe.ai/v1/app-api. There is
no /apps/{slug}/ segment — the app is bound to the token,
not to the path, so a token minted for Sprint Desk can only ever run Sprint Desk.
Every response is the same envelope: {"ok":true,"data":{...}} on success,
{"ok":false,"error":{"code":"...","message":"..."}} on failure. Check
ok, never the HTTP status alone.
| Code | Meaning | What to do |
|---|---|---|
unauthorized | Missing, stale or revoked token. | Mint a new one — POST /guest, or sign in on the token page. |
payment_required | Balance below the run's minimum. | Compare me.credits against estimate.hold_credits before submitting; top up. |
not_found | Usually a wrong path. | There is no /apps/{slug}/ segment. Check the base URL above. |
rate_limited | Too many calls too fast. | Back off and retry; do not tight-loop a poll. |
validation_error | The input object was malformed. | Send the input object directly, not wrapped in {"input": ...}. |
1. Get a token
Every call carries Authorization: Bearer <token>. The easiest token is the one
this browser already holds: open the token page and press
Copy shell export — never open the DevTools console for it. For a fully
scripted client with no browser at all, mint a guest token with POST /guest.
Guest tokens can call /me and the free /estimate; a metered
/run needs a personal token so the credits bill your account.
# The token this page's scripts use. Get one from the app's token page:
# https://sprint-desk.skillsafe.ai/tokens.html -> "Copy shell export"
export SKILLSAFE_TOKEN="sk_app_..."
# Or mint a scripted guest token with no browser at all. Guest tokens can call
# /me and the free /estimate; metered runs need a personal token.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"sprint-desk"}'
# -> {"ok":true,"data":{"token":"sk_app_...","subject_type":"guest"}}
import json, os, urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
# Paste the token from https://sprint-desk.skillsafe.ai/tokens.html, or export SKILLSAFE_TOKEN first.
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(method, path, body=None, token=TOKEN):
req = urllib.request.Request(API + path, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
data = json.dumps(body).encode() if body is not None else None
with urllib.request.urlopen(req, data) as r:
payload = json.load(r)
if not payload.get("ok"):
raise RuntimeError(payload.get("error"))
return payload["data"]
# A scripted guest token, no browser involved:
guest = call("POST", "/guest", {"slug": "sprint-desk"}, token=None)
print(guest["token"], guest["subject_type"])
const API = "https://api.skillsafe.ai/v1/app-api";
// Paste the token from https://sprint-desk.skillsafe.ai/tokens.html.
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body, token = TOKEN) {
const res = await fetch(API + path, {
method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json();
if (!payload.ok) throw new Error(payload.error?.message || res.statusText);
return payload.data;
}
// A scripted guest token, no browser involved:
const guest = await call("POST", "/guest", { slug: "sprint-desk" }, null);
console.log(guest.token, guest.subject_type);
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
)
const api = "https://api.skillsafe.ai/v1/app-api"
func call(method, path string, body any, token string) (map[string]any, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, api+path, rdr)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var payload struct {
OK bool `json:"ok"`
Data map[string]any `json:"data"`
Error map[string]any `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
return nil, err
}
if !payload.OK {
return nil, errors.New(fmt.Sprint(payload.Error))
}
return payload.Data, nil
}
func main() {
token := os.Getenv("SKILLSAFE_TOKEN") // or paste "YOUR_TOKEN"
guest, err := call("POST", "/guest", map[string]string{"slug": "sprint-desk"}, "")
if err != nil {
panic(err)
}
fmt.Println(guest["token"], guest["subject_type"], token != "")
}
import java.net.URI;
import java.net.http.*;
public class SprintDesk {
static final String API = "https://api.skillsafe.ai/v1/app-api";
// Paste the token from https://sprint-desk.skillsafe.ai/tokens.html, or set SKILLSAFE_TOKEN.
static final String TOKEN =
System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String body, String token)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json")
.method(method, body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body));
if (token != null) b.header("Authorization", "Bearer " + token);
HttpResponse<String> res = HTTP.send(b.build(),
HttpResponse.BodyHandlers.ofString());
return res.body(); // {"ok":true,"data":{...}} - decode with your JSON library
}
public static void main(String[] args) throws Exception {
// A scripted guest token, no browser involved:
System.out.println(call("POST", "/guest", "{\"slug\":\"sprint-desk\"}", null));
}
}
require "json"
require "net/http"
require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
# Paste the token from https://sprint-desk.skillsafe.ai/tokens.html, or set SKILLSAFE_TOKEN.
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def call(method, path, body = nil, token = TOKEN)
uri = URI(API + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise payload["error"].to_s unless payload["ok"]
payload["data"]
end
# A scripted guest token, no browser involved:
guest = call("POST", "/guest", { "slug" => "sprint-desk" }, nil)
puts guest["token"], guest["subject_type"]
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
// Paste the token from https://sprint-desk.skillsafe.ai/tokens.html, or set SKILLSAFE_TOKEN.
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
function call(string $method, string $path, ?array $body = null, ?string $token = null): array {
$headers = ["Content-Type: application/json"];
if ($token) { $headers[] = "Authorization: Bearer " . $token; }
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) { throw new RuntimeException(json_encode($payload["error"] ?? null)); }
return $payload["data"];
}
// A scripted guest token, no browser involved:
$guest = call("POST", "/guest", ["slug" => "sprint-desk"], null);
echo $guest["token"], " ", $guest["subject_type"], PHP_EOL;
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class SprintDesk {
const string Api = "https://api.skillsafe.ai/v1/app-api";
// Paste the token from https://sprint-desk.skillsafe.ai/tokens.html, or set SKILLSAFE_TOKEN.
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(HttpMethod method, string path,
object body = null, string token = null) {
var req = new HttpRequestMessage(method, Api + path);
if (token != null) req.Headers.Add("Authorization", "Bearer " + token);
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var payload = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!payload.GetProperty("ok").GetBoolean())
throw new Exception(payload.GetProperty("error").ToString());
return payload.GetProperty("data");
}
static async Task Main() {
// A scripted guest token, no browser involved:
var guest = await Call(HttpMethod.Post, "/guest", new { slug = "sprint-desk" });
Console.WriteLine(guest.GetProperty("token").GetString());
}
}
2. Check the session and the balance
GET /me tells you whether the token is a personal or a guest one and how many
credits it can spend. Compare that balance against hold_credits from step 3
before you submit a run: a 402 after submit is a client bug, not a user error. The app
itself does exactly this and disables its run button with the shortfall named.
curl -s "https://api.skillsafe.ai/v1/app-api/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","subject_id":"usr_...","credits":184920}}
me = call("GET", "/me")
print(me["subject_type"], me["credits"], "credits")
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits, "credits");
me, err := call("GET", "/me", nil, token)
if err != nil {
panic(err)
}
fmt.Println(me["subject_type"], me["credits"])
System.out.println(call("GET", "/me", null, TOKEN));
me = call("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]} credits"
$me = call("GET", "/me", null, $TOKEN);
echo $me["subject_type"], " ", $me["credits"], " credits", PHP_EOL;
var me = await Call(HttpMethod.Get, "/me", null, Token);
Console.WriteLine(me.GetProperty("credits").GetInt32());
3. Estimate — free, no job, no charge
POST /estimate takes the same input object a run does and returns the model binding
and the reservation. It creates no job and charges nothing, which makes it the right smoke
test for a new client. Assert the three binding fields:
model is gpt-5.6-terra, model_alias is
gpt-terra, and markup_bps is 1000.
hold_credits is what a run reserves, priced against the full output cap
— it is not the price. The charge is usually far lower.
curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-d @plan-input.json
# -> {"ok":true,"data":{
# "model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
# "hold_credits":1599,"min_credits":170,"sponsor_enabled":false}}
#
# plan-input.json holds the input object itself:
# {
"data": "PAY-412: Refund webhook retry queue, 5 pts (owner Devin)\nPAY-415: Split-tender checkout, 13 pts (owner Maya, depends on PAY-414)\nTeam of 4. Two-week sprint, 10 working days. Velocity over the last four sprints: 38, 42, 35, 41. Devin is on PTO for 3 days.",
"notes": "Sales already promised split-tender to one account for this sprint.",
"task": "Full sprint plan",
"facts": "Mechanical scan of the paste (arithmetic, not judgement):\n- 2 story lines, 18 pts estimated: PAY-412 5pt (Devin); PAY-415 13pt (Maya)."
}
INPUT = {
"data": "PAY-412: Refund webhook retry queue, 5 pts (owner Devin)\nPAY-415: Split-tender checkout, 13 pts (owner Maya, depends on PAY-414)\nTeam of 4. Two-week sprint, 10 working days. Velocity over the last four sprints: 38, 42, 35, 41. Devin is on PTO for 3 days.",
"notes": "Sales already promised split-tender to one account for this sprint.",
"task": "Full sprint plan",
"facts": "Mechanical scan of the paste (arithmetic, not judgement):\n- 2 story lines, 18 pts estimated: PAY-412 5pt (Devin); PAY-415 13pt (Maya)."
}
est = call("POST", "/estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserves up to", est["hold_credits"], "credits; minimum", est["min_credits"])
# Nothing is charged and no job is created by /estimate.
const INPUT = {
"data": "PAY-412: Refund webhook retry queue, 5 pts (owner Devin)\nPAY-415: Split-tender checkout, 13 pts (owner Maya, depends on PAY-414)\nTeam of 4. Two-week sprint, 10 working days. Velocity over the last four sprints: 38, 42, 35, 41. Devin is on PTO for 3 days.",
"notes": "Sales already promised split-tender to one account for this sprint.",
"task": "Full sprint plan",
"facts": "Mechanical scan of the paste (arithmetic, not judgement):\n- 2 story lines, 18 pts estimated: PAY-412 5pt (Devin); PAY-415 13pt (Maya)."
};
const est = await call("POST", "/estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserves up to", est.hold_credits, "credits; minimum", est.min_credits);
// Nothing is charged and no job is created by /estimate.
input := map[string]any{
"data": "PAY-412: Refund webhook retry queue, 5 pts (owner Devin)\nTeam of 4. Velocity: 38, 42, 35, 41.",
"task": "Full sprint plan",
}
est, err := call("POST", "/estimate", input, token)
if err != nil {
panic(err)
}
fmt.Println(est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"])
String input = """
{"data":"PAY-412: Refund webhook retry queue, 5 pts (owner Devin)",
"task":"Full sprint plan"}
""";
System.out.println(call("POST", "/estimate", input, TOKEN));
// -> model gpt-5.6-terra, model_alias gpt-terra, markup_bps 1000, hold_credits ...
input = {
"data" => "PAY-412: Refund webhook retry queue, 5 pts (owner Devin)\nVelocity: 38, 42, 35, 41.",
"task" => "Full sprint plan"
}
est = call("POST", "/estimate", input)
puts est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"]
$input = [
"data" => "PAY-412: Refund webhook retry queue, 5 pts (owner Devin)",
"task" => "Full sprint plan",
];
$est = call("POST", "/estimate", $input, $TOKEN);
echo $est["model"], " ", $est["model_alias"], " ", $est["hold_credits"], PHP_EOL;
var input = new {
data = "PAY-412: Refund webhook retry queue, 5 pts (owner Devin)",
task = "Full sprint plan"
};
var est = await Call(HttpMethod.Post, "/estimate", input, Token);
Console.WriteLine(est.GetProperty("model").GetString());
4. Run it and poll
POST /run queues a job. The body is the input object directly —
not wrapped in {"input": {...}}. Send an Idempotency-Key on every
run, including any automatic retry: without one, a network blip on the way to the server can
bill the same plan twice. Derive the key from a hash of the input plus an attempt counter.
Then poll GET /jobs/{job_id} until status is
succeeded, failed or canceled. The plan text is at
data.output.output; truncated: true means the balance capped the
output and what you have is incomplete.
# The body is the input object DIRECTLY - not {"input": {...}}.
# Idempotency-Key makes a retried POST return the first job instead of billing twice.
JOB=$(curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: sprint-desk:9f21c4:a1" \
-d '{"data":"PAY-412: Refund webhook retry queue, 5 pts (owner Devin)","task":"Full sprint plan"}' \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until the job is terminal.
curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# -> {"ok":true,"data":{"status":"succeeded","output":{"output":"COMMITMENT: ..."},
# "charged_credits":412,"truncated":false}}
import time, hashlib
key = "sprint-desk:" + hashlib.sha256(
json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16] + ":a1"
req = urllib.request.Request(API + "/run", method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key) # a retry returns the same job, never a second charge
with urllib.request.urlopen(req, json.dumps(INPUT).encode()) as r:
job = json.load(r)["data"]
while job["status"] not in ("succeeded", "failed", "canceled"):
time.sleep(2)
job = call("GET", "/jobs/" + job["job_id"])
print(job["output"]["output"]) # the plain-text plan
print(job["charged_credits"], "credits charged, truncated:", job["truncated"])
const key = `sprint-desk:${Date.now().toString(36)}:a1`;
const res = await fetch(`$https://api.skillsafe.ai/v1/app-api/run`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": key, // a retry returns the same job, never a second charge
},
body: JSON.stringify(INPUT), // the input object directly
});
let job = (await res.json()).data;
while (!["succeeded", "failed", "canceled"].includes(job.status)) {
await new Promise((r) => setTimeout(r, 2000));
job = await call("GET", `/jobs/${job.job_id}`);
}
console.log(job.output.output);
console.log(job.charged_credits, "credits, truncated:", job.truncated);
job, err := call("POST", "/run", input, token) // body is the input object directly
if err != nil {
panic(err)
}
id := job["job_id"].(string)
for {
job, err = call("GET", "/jobs/"+id, nil, token)
if err != nil {
panic(err)
}
s := job["status"].(string)
if s == "succeeded" || s == "failed" || s == "canceled" {
break
}
time.Sleep(2 * time.Second)
}
fmt.Println(job["output"].(map[string]any)["output"])
String job = call("POST", "/run", input, TOKEN); // body is the input object directly
// Read data.job_id, then poll GET /jobs/{job_id} until
// data.status is succeeded, failed or canceled.
// The plan text is at data.output.output.
System.out.println(job);
require "digest"
uri = URI(API + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = "sprint-desk:#{Digest::SHA256.hexdigest(input.to_json)[0, 16]}:a1"
req.body = JSON.dump(input) # the input object directly
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed canceled].include?(job["status"])
sleep 2
job = call("GET", "/jobs/#{job["job_id"]}")
end
puts job["output"]["output"]
$job = call("POST", "/run", $input, $TOKEN); // the input object directly
while (!in_array($job["status"], ["succeeded", "failed", "canceled"], true)) {
sleep(2);
$job = call("GET", "/jobs/" . $job["job_id"], null, $TOKEN);
}
echo $job["output"]["output"], PHP_EOL;
echo $job["charged_credits"], " credits", PHP_EOL;
var job = await Call(HttpMethod.Post, "/run", input, Token); // input object directly
var id = job.GetProperty("job_id").GetString();
string status;
do {
await Task.Delay(2000);
job = await Call(HttpMethod.Get, "/jobs/" + id, null, Token);
status = job.GetProperty("status").GetString();
} while (status != "succeeded" && status != "failed" && status != "canceled");
Console.WriteLine(job.GetProperty("output").GetProperty("output").GetString());
5. Or stream it
POST /run-stream returns Server-Sent Events: an event: job with the
job id, a run of event: delta frames each carrying {"text": "..."},
and a terminal event: done with the whole output, the charge and the truncation
flag. Treat done as authoritative and prefer its
output.output over your accumulated deltas — a stream can drop its tail.
If the stream dies mid-body, keep what arrived: it may already have been paid for, and the
plan format parses partially.
curl -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: text/event-stream" \
-H "Idempotency-Key: sprint-desk:9f21c4:a1" \
-d '{"data":"PAY-412: Refund webhook retry queue, 5 pts","task":"Full sprint plan"}'
# event: job data: {"job_id":"job_..."}
# event: delta data: {"text":"COMMITMENT: Overcommitted"}
# event: done data: {"output":{"output":"COMMITMENT: ..."},"charged_credits":412,
# "truncated":false}
req = urllib.request.Request(API + "/run-stream", method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
raw, event = "", None
with urllib.request.urlopen(req, json.dumps(INPUT).encode()) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = json.loads(line[5:].strip())
if event == "delta":
raw += payload["text"]
elif event == "done":
raw = payload["output"]["output"] or raw # done is authoritative
print(raw)
const res = await fetch(`$https://api.skillsafe.ai/v1/app-api/run-stream`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
Authorization: `Bearer ${TOKEN}`,
"Idempotency-Key": key,
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = JSON.parse(line.slice(5).trim());
if (event === "delta") raw += payload.text;
if (event === "done") raw = payload.output.output || raw; // done is authoritative
}
}
}
console.log(raw);
req, _ := http.NewRequest("POST", api+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "sprint-desk:9f21c4:a1")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var event, raw string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:") && event == "delta":
var d struct {
Text string `json:"text"`
}
json.Unmarshal([]byte(line[5:]), &d)
raw += d.Text
}
}
fmt.Println(raw)
HttpRequest req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Authorization", "Bearer " + TOKEN)
.header("Idempotency-Key", "sprint-desk:9f21c4:a1")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(System.out::println); // event: / data: lines, SSE framing
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = "sprint-desk:9f21c4:a1"
req.body = JSON.dump(input)
raw = ""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
event = line[6..].strip if line.start_with?("event:")
raw += JSON.parse(line[5..])["text"] if line.start_with?("data:") && event == "delta"
end
end
end
end
puts raw
$ch = curl_init(API . "/run-stream");
$raw = "";
$event = null;
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"Accept: text/event-stream",
"Authorization: Bearer " . $TOKEN,
"Idempotency-Key: sprint-desk:9f21c4:a1",
],
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event:")) { $event = trim(substr($line, 6)); }
elseif (str_starts_with($line, "data:") && $event === "delta") {
$raw .= json_decode(substr($line, 5), true)["text"];
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
echo $raw, PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream");
req.Headers.Add("Authorization", "Bearer " + Token);
req.Headers.Add("Accept", "text/event-stream");
req.Headers.Add("Idempotency-Key", "sprint-desk:9f21c4:a1");
req.Content = new StringContent(JsonSerializer.Serialize(input),
Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string line, evt = null, raw = "";
while ((line = await reader.ReadLineAsync()) != null) {
if (line.StartsWith("event:")) evt = line.Substring(6).Trim();
else if (line.StartsWith("data:") && evt == "delta")
raw += JsonDocument.Parse(line.Substring(5)).RootElement
.GetProperty("text").GetString();
}
Console.WriteLine(raw);
6. Parse the plan
The reply is plain text, not JSON. Four tag lines, then six
## sections in a fixed order. Bullets under Sprint backlog and
Deferred and at risk carry exactly four fields separated by | ; every
other section is plain bullets. A section with nothing to report is the single bullet
- None. If COMMITMENT is Not plannable, both row
sections are - None. and Open questions says what to paste.
# The reply is plain text, not JSON. Split it on the four tag lines and the
# six "## " headings, then split each backlog / deferred bullet on " | ".
python3 - plan.txt <<'EOF'
import re, sys
text = open(sys.argv[1]).read()
head = dict(re.findall(r"^(COMMITMENT|GOAL|CONFIDENCE):\s*(.+)$", text, re.M))
print(head["COMMITMENT"], "|", head["CONFIDENCE"])
for line in text.splitlines():
if line.startswith("- ") and line.count(" | ") == 3:
story, points, third, fourth = line[2:].split(" | ")
print(story, points, third, fourth, sep=" / ")
EOF
import re
def parse_plan(text):
head = dict(re.findall(r"^(COMMITMENT|GOAL|CONFIDENCE):\s*(.+)$", text, re.M))
summary = re.search(r"^SUMMARY:\s*(.+?)(?:\n\s*\n)", text, re.M | re.S)
sections, current = {}, None
for line in text.splitlines():
m = re.match(r"^##\s+(.*)$", line)
if m:
current = m.group(1).strip()
sections[current] = []
elif current and line.startswith("- "):
sections[current].append(line[2:].strip())
return {
"commitment": head.get("COMMITMENT", "").strip(),
"goal": head.get("GOAL", "").strip(),
"confidence": int(head.get("CONFIDENCE", "0")),
"summary": " ".join(summary.group(1).split()) if summary else "",
"sections": sections,
}
plan = parse_plan(job["output"]["output"])
assert plan["commitment"] in ("Committable", "Overcommitted - cuts needed", "Not plannable")
for bullet in plan["sections"]["Sprint backlog"]:
if bullet == "None.":
continue
story, points, owner, why = [c.strip() for c in bullet.split(" | ")]
print(story, points, owner, why, sep=" / ")
# Worth doing yourself: total the points and check them against the velocity
# history in your own paste before acting on a "Committable" tag.
function parsePlan(text) {
const head = {};
for (const [, k, v] of text.matchAll(/^(COMMITMENT|GOAL|CONFIDENCE):\s*(.+)$/gm)) {
head[k] = v.trim();
}
const sections = {};
let current = null;
for (const line of text.split("\n")) {
const m = line.match(/^##\s+(.*)$/);
if (m) { current = m[1].trim(); sections[current] = []; }
else if (current && line.startsWith("- ")) sections[current].push(line.slice(2).trim());
}
return { ...head, confidence: Number(head.CONFIDENCE), sections };
}
const plan = parsePlan(raw);
let committed = 0;
for (const bullet of plan.sections["Sprint backlog"]) {
if (bullet === "None.") continue;
const [story, points, owner, why] = bullet.split(" | ").map((s) => s.trim());
committed += Number.parseFloat(points) || 0;
console.log(story, points, owner, why);
}
console.log("committed total:", committed, "pts");
// The reply is plain text. Read the four tag lines with a regexp, then walk
// the "## " headings and collect the "- " bullets under each. Bullets under
// Sprint backlog and Deferred and at risk split on " | " into exactly four
// fields. A section with nothing to report is the single bullet "- None."
tag := regexp.MustCompile(`(?m)^(COMMITMENT|GOAL|CONFIDENCE):\s*(.+)$`)
for _, m := range tag.FindAllStringSubmatch(raw, -1) {
fmt.Println(m[1], "=", m[2])
}
for _, line := range strings.Split(raw, "\n") {
if strings.HasPrefix(line, "- ") && strings.Count(line, " | ") == 3 {
fields := strings.Split(strings.TrimPrefix(line, "- "), " | ")
fmt.Println(fields)
}
}
// The reply is plain text. Read the tag lines, then walk the "## " headings
// and collect the "- " bullets under each. Rows split on " | " into four fields.
for (String line : raw.split("\n")) {
if (line.startsWith("- ")) {
String[] fields = line.substring(2).split(" \\| ", -1);
if (fields.length == 4) {
System.out.println(String.join(" / ", fields));
}
}
}
head = raw.scan(/^(COMMITMENT|GOAL|CONFIDENCE):\s*(.+)$/).to_h
sections = Hash.new { |h, k| h[k] = [] }
current = nil
raw.each_line do |line|
if (m = line.match(/^##\s+(.*)$/))
current = m[1].strip
elsif current && line.start_with?("- ")
sections[current] << line[2..].strip
end
end
sections["Sprint backlog"].each do |bullet|
next if bullet == "None."
story, points, owner, why = bullet.split(" | ").map(&:strip)
puts [story, points, owner, why].join(" / ")
end
preg_match_all("/^(COMMITMENT|GOAL|CONFIDENCE):\s*(.+)$/m", $raw, $m, PREG_SET_ORDER);
$head = [];
foreach ($m as $pair) { $head[$pair[1]] = trim($pair[2]); }
$sections = [];
$current = null;
foreach (explode("\n", $raw) as $line) {
if (preg_match("/^##\s+(.*)$/", $line, $hm)) { $current = trim($hm[1]); $sections[$current] = []; }
elseif ($current !== null && str_starts_with($line, "- ")) { $sections[$current][] = trim(substr($line, 2)); }
}
foreach ($sections["Sprint backlog"] ?? [] as $bullet) {
if ($bullet === "None.") { continue; }
[$story, $points, $owner, $why] = array_map("trim", explode(" | ", $bullet));
echo "$story | $points | $owner | $why", PHP_EOL;
}
// The reply is plain text. Read the tag lines, then walk the "## " headings
// and collect the "- " bullets. Row bullets split on " | " into four fields.
foreach (var line in raw.Split('\n')) {
if (!line.StartsWith("- ")) continue;
var fields = line.Substring(2).Split(" | ");
if (fields.Length == 4)
Console.WriteLine($"{fields[0]} | {fields[1]} | {fields[2]} | {fields[3]}");
}
The input object and the output contract
Input fields
| Field | Type | Meaning |
|---|---|---|
data | string, required | The paste: story lines like PAY-412: Refund webhook retry queue, 5 pts (owner Devin, depends on PAY-411), plus prose about the team, sprint dates, velocity history, PTO, carry-over and priorities. The app clips very long pastes through the middle, keeping both ends, and declares the cut in-band. |
notes | string, optional | Business context: what the product owner wants, hard commitments, team-health worries, what you are being pressured to squeeze in. |
task | string | One of Full sprint plan, Estimation review, Capacity check, Goal setting, Scope negotiation. |
facts | string, optional | A mechanical scan of the paste — detected stories, the point total, unestimated and off-Fibonacci flags, any velocity numbers. A hint for cross-checking, never a verdict; the model trusts its own reading of the paste over a misparsed line. |
retry_note | string, optional | Only for a reformat retry: instructions restating the output shape after a reply that did not parse. Send it with the same idempotency-key base as the first attempt and a bumped attempt counter. |
Output contract
Plain text. Four tag lines, a blank line, then six ## sections in exactly this
order. Anything that breaks the shape should be retried once with retry_note.
COMMITMENT: Committable | Overcommitted - cuts needed | Not plannable GOAL: <one sentence, or exactly: Not identified> CONFIDENCE: <bare integer 0-100> SUMMARY: <2-4 sentences, ends at the first blank line> ## Capacity assessment - <plain bullets, with the arithmetic shown> ## Sprint backlog - <story> | <points> | <owner> | <why it makes the cut> ## Deferred and at risk - <story> | <points> | <why it waits> | <what unblocks it> ## Risks and dependencies - <plain bullets> ## Recommended actions - <plain bullets, each starting with a verb> ## Open questions - <plain bullets>
Committable whose own rows do not fit the stated velocity
is the exact failure a sprint planner exists to catch, and the arithmetic is free. Check too
that every story you pasted appears in one of the two tables — a ticket in neither was
neither committed nor cut.
What you do not need the API for
The story scan, the tracker-export conversion, the point totals, the off-Fibonacci and unestimated flags, the capacity calculator and the velocity averager all run in the browser with no account and no network. If all you want is arithmetic over a backlog, the app itself is free. The API is for the judgement part — the goal, the commitment call and the reasoned cut list.