Drive Attack Tree Desk from your own code
The two paid things the web page does are available over HTTP. One system prompt serves both, and
the task field of the request picks the lane:
build— you send an attacker goal and a description of the system; you get back a drafted, scored attack tree in the page's outline grammar, the assumptions it rests on, a note per step citing the description, and open questions.review— you send the analysis of a tree (roll-ups, scenarios, best paths, cut sets, flags); you get back a verdict, a response to each flag, gate checks, estimate challenges, missing branches, a defence plan and a stakeholder brief.
Runs are metered. /run and /run-stream need a signed-in
(personal) token and bill that account. A guest token can call /me and
/estimate. Credits convert at 10,000 credits = $1 (the page's own
usd() divides by 10,000).
The model never does the arithmetic in a review. The page reads the tree and
computes every number in the browser with atree.js, then sends the result as
facts, a JSON string. A direct API caller has to build that string too. There
is no endpoint that computes it; see building facts.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api. A success carries its
payload in data; a failure has a non-2xx HTTP status and an error object:
HTTP 2xx { "data": { ... } }
HTTP 4xx { "error": { "code": "...", "message": "...", "details": { ... } } }
This is how the vendored SDK (sdk.js, _reqFull and
apiError) reads it: when res.ok is false it throws an error carrying the
HTTP status, error.code, error.message (or the status text) and
error.details; otherwise it returns data. So branch on the HTTP status.
Every call sends Content-Type: application/json and, once you hold a token,
Authorization: Bearer YOUR_TOKEN. No slug header is sent; the token belongs to this app.
The input object is the request body. The SDK posts
JSON.stringify(input) as the body of /estimate, /run and
/run-stream; there is no {"input": ...} wrapper. Always send a JSON
object: the page passes every input through Atree.mustBeObject, which throws
unless it is a non-array object. This app declares an input schema (task required), and
the page shows any warnings list that /estimate returns ("the service
warned: ..."), so treat a warning as a bug in your body.
Error handling
This table lists only what sdk.js and app.js actually handle. Any
other status arrives the same way, as the error object above; log
error.code and error.message as given.
| where | what you see | what the page does / what to do |
|---|---|---|
| any call | non-2xx status, {error:{code,message,details}} | The SDK throws with status, code, message and details. On a run the page shows "The run failed: message"; on /estimate, "Could not price this run: message". |
| run | 401 | The page treats it as an expired session ("Your session expired - sign in again") and forgets the user. Get a fresh token from the token page. |
| run | 402 | "Not enough credits for this run", with a top-up link. The page tries never to send into a 402: it disables the run button when the /me balance is below min_credits from /estimate. |
| /run-stream | event: error with data {code, message, job_id} | The SDK throws with that code, message (default "job failed") and job_id. The page keeps any partial JSON that already streamed. |
| /jobs/{job_id} | status: "failed" | A terminal state (the SDK's waitForJob stops on succeeded or failed). Read error on the job. |
| polling | no terminal state in time | waitForJob gives up with "job timed out" after 180 s by default (polling every 1 s). That is a client-side limit, not a server error. |
| the reply | output.output is not one JSON object | The page retries once (one extra run) with a retry_note and the attempt suffix :a2, then shows the raw text. See reading the reply. |
The task field and the two lanes
task is the only required field (input-schema.json). It is
"build" or "review". The system prompt says that when
task is missing or neither value, the model answers review if a
facts field is present and build otherwise, and names the lane it chose
in lane. Send it anyway. All fields are strings.
Lane build: input
Exactly what Atree.buildBuildInput puts in the body, in this order:
| field | sent | what the page does to it |
|---|---|---|
task | always | "build" |
goal | always | The attacker's objective. Whitespace collapsed, at most 240 characters (longer is cut and ends in ~). The page will not price or run until it is at least 5 characters. |
description | always | The system in your words. Lines kept; over 12,000 characters the middle is cut on whole lines and replaced by a [... N characters cut from the middle ...] marker. The page needs it non-empty. |
attacker | only when non-empty | Who is attacking and what they can do. Whitespace collapsed, at most 400 characters. |
question | only when non-empty | Answered inside summary. Whitespace collapsed, at most 600 characters. |
retry_note | only on the page's one automatic retry | Added by run() after a reply that did not parse. Do not send it on a first attempt. |
Lane review: input
Exactly what Atree.buildReviewInput puts in the body, in this order:
| field | sent | what the page does to it |
|---|---|---|
task | always | "review" |
facts | always | JSON.stringify(Atree.buildFacts(analysis)) — a JSON string, not an object. See building facts. |
question | only when non-empty | Trimmed by the page, then whitespace collapsed, at most 600 characters. Answered in headline and summary. |
context | only when non-empty | Your notes on the system. Trimmed; lines kept; over 4,000 characters the middle is cut with the same marker as description. |
retry_note | only on the page's one automatic retry | As for build. |
Building facts for a review
facts is produced by the page's own analyzer, atree.js
(Atree.analyze, then Atree.buildFacts). Every number in it is exact
arithmetic over the tree, and the system prompt tells the model that every number is authoritative
and never to recompute one. So a direct caller must build this JSON string itself. No endpoint
computes it for you. Its top-level keys, from buildFacts:
| key | contents |
|---|---|
goal, root, title | The root step's name and id, and the tree's title (the first # comment line of an outline, or ""). |
scales | Text describing the difficulty, cost, detection and time_h scales. |
rules | Text: OR/AND semantics, scenario difficulty and detection are the MAX over its steps, cost and time the SUM, and what a minimal cut set is. |
counts | nodes, leaves, and_gates, or_gates, depth, nodes_sent, scenarios, scenarios_listed, minimal_cut_sets, smallest_cut, fewest_new_controls_to_cut, open_scenarios, steps_with_in_place_control, steps_with_no_mitigation |
nodes | Every gate: id, type (or/and), name, parent, depth, children, rollup{difficulty,cost,detection,time_h}. Every step (type:"leaf"): id, name, parent, depth, difficulty, cost, detection, time_h, insider/physical when true, defaulted when attributes were missing, in_place, not_in_place (planned ones end in (planned)), marked_gate for a gate line with nothing under it, and scenarios. At most 160 nodes are sent: every gate, then every step on a best path, named by a flag or in one of the first 8 cuts, then the rest while there is room. |
best_paths | cheapest, easiest, stealthiest, fastest, each {leaves, steps, cost, difficulty, detection, time_h, insider, physical, meets_in_place_control}. |
cheapest_open | The same shape for the cheapest scenario that meets no in-place control, or null. |
cuts | Up to 8 minimal cut sets: {id, leaves, size, new_controls}. |
critical | Up to 8 steps by share of scenarios: {leaf, scenarios, share_pct}. |
flags | {id, code, severity, nodes, detail}; severity is block, warn or info. |
verdict_floor | restructure if any flag is block, else revise_first if any is warn, else ready_for_review. |
Three ways to get it:
- Run
atree.jsyourself. It is plain JavaScript with no DOM and no network, served at /atree.js, and it exports itself as a CommonJS module. The sketch below, run under Node against the page's "Account takeover" example, produced a body byte-for-byte identical to the one the page sends. - Use the page. Paste the tree into the app: the analysis is free
and runs in your browser. Report .md and Copy report export it as
a Markdown report for people. After a review, Download .json saves a record whose
factsmember is the analysis that was sent, as an object. Re-serialise it withJSON.stringifyto get the string back. - Do not hand-write it. The page checks the review against these facts (ids, flags, cuts), and the system prompt forbids the model from stating any count that is not in them.
// Node 18+. curl -sSO https://attack-tree-desk.skillsafe.ai/atree.js
const fs = require("node:fs");
const Atree = require("./atree.js");
const tree = fs.readFileSync("tree.txt", "utf8"); // the outline below, or the skill's export_json JSON
const analysis = Atree.analyze(tree);
if (!analysis.ok) throw new Error(analysis.errors.join(" "));
const input = Atree.buildReviewInput(analysis, {
context: "Consumer web app with email + password login, optional TOTP, and a phone support desk that can reset accounts.",
question: "", // optional; empty means not sent
});
fs.writeFileSync("review.json", JSON.stringify(input)); // the body for /estimate, /run, /run-stream
// The build lane goes through the same kind of builder:
const build = Atree.buildBuildInput({ goal: "...", description: "...", attacker: "", question: "" });
The tree grammar
atree.js reads an indented outline (or the JSON that the attack-tree-construction
skill's export_json() writes). The build lane's tree and a review's
missing_branches[].lines use the same outline, as SKILL.md defines it:
- One node per line; children indented two spaces under their parent. Blank lines are skipped;
lines starting with
#or//are comments (the first#line becomes the title). - A gate:
ID [OR] nameorID [AND] name. OR means any one child reaches the parent; AND means every child is required. - A step:
ID name | difficulty=D cost=C detection=R time=T | mitigations: m1; m2, with D intrivial low medium high expert, C infree low medium high very_high, R innone low medium high certain, and T in hours (4h,2d,1walso read; a day is 24 h, a week 168 h). The analysis reportstime=astime_h. Addinsider=yesorphysical=yesin the attribute segment where it applies. - A mitigation ending in
(in place)is a live control; one ending in(planned)is planned; a bare one is neither. - A missing attribute is scored at the default (medium, medium, medium, 8 h) and raises a
defaulted_attributesflag.
# Account takeover - adapted from the attack-tree-construction skill's worked example
G1 [OR] Take over a user account
S1 [OR] Steal credentials
A1 Phishing attack | difficulty=low cost=low detection=medium time=4h | mitigations: security awareness training (in place); email filtering (in place)
A2 Credential stuffing | difficulty=trivial cost=low detection=high time=2h | mitigations: rate limiting (in place); MFA (planned); password breach monitoring
A3 Keylogger malware | difficulty=medium cost=medium detection=medium time=24h | mitigations: endpoint protection; MFA (planned)
S2 [OR] Bypass authentication
A4 Session hijacking | difficulty=medium cost=low detection=low time=8h | mitigations: secure session management (in place); HTTPS only (in place)
A5 Authentication bypass vulnerability | difficulty=high cost=low detection=low time=40h | mitigations: security testing; code review; WAF
S3 [OR] Social engineering
S3.1 [AND] Account recovery attack
A6 Gather personal information | difficulty=low cost=free detection=none time=4h
A7 Call the support desk | difficulty=medium cost=free detection=medium time=1h | mitigations: support verification procedures; security questions
Worked example: build
The page's "payroll" example, exactly as the page sends it. This whole object is the request body:
{
"task": "build",
"goal": "Divert employees' net pay to accounts the attacker controls",
"description": "Quellwage is an HR and payroll portal for companies of 50-2,000 staff.\nEmployees sign in with email + password; SSO via the customer's identity provider is optional and about 40% of tenants use it. TOTP is optional for password users.\nAn employee can change their own bank account in Profile > Direct deposit. The change sends a confirmation email to the address on file and takes effect on the next pay run; there is no waiting period.\nHR administrators can edit any employee's bank details and can bulk-import changes from a CSV upload.\nPayroll is approved by a customer payroll admin, then Quellwage generates an ACH file that is sent to the partner bank by SFTP from a batch server.\nQuellwage support agents can reset an employee's password and email address after a phone call; they verify the caller with date of birth and the last 4 digits of the national ID.\nThe public API has a token per tenant with full HR scope; tokens do not expire.",
"attacker": "Financially motivated outsider; may recruit or impersonate an insider."
}
The reply. This is the shape of the object the model writes, with SKILL.md's field
descriptions in place of values; it is not a real reply. Every field of the build contract
(Recon.normalize) is listed:
{
"lane": "build",
"goal": "the root goal, restated as one attacker objective",
"assumptions": ["an assumption the tree depends on that the description does not settle"],
"out_of_scope": ["an attack family deliberately left out, and why"],
"tree": "G1 [OR] ...\n S1 [OR] ...\n A1 ... | difficulty=... cost=... detection=... time=...h | mitigations: ...",
"step_notes": [{ "node": "A1", "basis": "the fact in the description that makes this step possible" }],
"open_questions": ["something the description does not say that would change a branch or an estimate"],
"summary": "2-3 sentences: the shape of the tree and where it looks weakest"
}
| field | type | notes |
|---|---|---|
lane | string | "build". The page notes a mismatch if it names the other lane. |
goal | string | The root goal restated. |
assumptions | string[] | |
out_of_scope | string[] | Also where the model must say why insiders are excluded, if they are. |
tree | string | The whole tree in the grammar, lines joined by \n. The root is G1 [OR], gates S1, S2 (nested S1.1), steps A1, A2... The prompt asks for 2 to 4 levels and 6 to 16 steps. The page re-reads it with atree.js, and Analyze and review this tree moves it into the review lane. |
step_notes | {node, basis}[] | One per step, in tree order. |
open_questions | string[] | |
summary | string | Also carries the answer to question. |
Worked example: review
The page's "Account takeover" example: the outline above plus a
context, with no question. The real facts string is 5,542 characters. Here
it is shortened, and every ... inside it marks something cut for this page. Do not send
it as shown:
{
"task": "review",
"facts": "{\"goal\":\"Take over a user account\",\"root\":\"G1\",\"title\":\"Account takeover - adapted from the attack-tree-construction skill's worked example\",\"scales\":{...},\"rules\":\"OR = any child reaches the parent; AND = every child is required. ...\",\"counts\":{\"nodes\":12,\"leaves\":7,\"and_gates\":1,\"or_gates\":4,\"depth\":3,\"nodes_sent\":12,\"scenarios\":6,...},\"nodes\":[...],\"best_paths\":{...},\"cheapest_open\":{...},\"cuts\":[...],\"critical\":[...],\"flags\":[...],\"verdict_floor\":\"revise_first\"}",
"context": "Consumer web app with email + password login, optional TOTP, and a phone support desk that can reset accounts."
}
A few members of that facts string, decoded (values exactly as sent):
"nodes": [
{ "id": "S3", "type": "or", "name": "Social engineering", "parent": "G1", "depth": 1,
"children": ["S3.1"], "rollup": { "difficulty": "medium", "cost": 0, "detection": "medium", "time_h": 5 } },
{ "id": "A2", "type": "leaf", "name": "Credential stuffing", "parent": "S1", "depth": 2,
"difficulty": "trivial", "cost": "low", "detection": "high", "time_h": 2,
"in_place": ["rate limiting"], "not_in_place": ["MFA (planned)", "password breach monitoring"], "scenarios": 1 },
...
],
"best_paths": { "cheapest": { "leaves": ["A6", "A7"], "steps": 2, "cost": 0, "difficulty": "medium", "detection": "medium",
"time_h": 5, "insider": false, "physical": false, "meets_in_place_control": false }, ... },
"cuts": [ { "id": "C1", "leaves": ["A1", "A2", "A3", "A4", "A5", "A6"], "size": 6, "new_controls": 3 }, ... ],
"critical": [ { "leaf": "A1", "scenarios": 1, "share_pct": 16.67 }, ... ],
"flags": [
{ "id": "X1", "code": "single_child_gate", "severity": "warn", "nodes": ["S3"],
"detail": "A gate with exactly one child does not choose or combine anything: S3. Either the node is a label or a sibling step is missing." },
{ "id": "X2", "code": "no_mitigation", "severity": "warn", "nodes": ["A6"], "detail": "1 step(s) name no mitigation at all: A6." },
...
],
"verdict_floor": "revise_first"
The reply. Again this is the shape, with SKILL.md's descriptions and enums in place of
values, not a real reply. Every field of the review contract (Recon.normalize):
{
"lane": "review",
"verdict": "ready_for_review | revise_first | restructure",
"headline": "one sentence: can this tree go in front of a security review or stakeholders, and why",
"flag_responses": [{ "flag": "X1", "explanation": "...", "action": "..." }],
"gate_checks": [{ "node": "S3", "now": "OR", "should_be": "AND | OR | collapse | keep", "reason": "..." }],
"estimate_challenges": [{ "node": "A2", "attribute": "difficulty | cost | detection | time_h", "now": "trivial", "suggest": "low", "reason": "..." }],
"missing_branches": [{ "parent": "S3", "lines": "new tree lines in the grammar", "category": "technical | social | process | supply_chain | insider | physical", "reason": "..." }],
"defense_plan": [{ "cut": "C1", "steps": ["A1", "A2", "A3", "A4", "A5", "A6"], "controls": ["..."], "why": "..." }],
"stakeholder_brief": "3-5 sentences for a non-security reader",
"summary": "2-3 sentences for the reviewer who reads nothing else"
}
| field | type | notes |
|---|---|---|
lane | string | "review". |
verdict | enum | ready_for_review, revise_first or restructure. The prompt forbids a verdict less strict than facts.verdict_floor. The page's normaliser turns anything else into restructure. |
headline | string | Also carries the answer to question. |
flag_responses | {flag, explanation, action}[] | The prompt requires every block and warn flag exactly once. |
gate_checks | {node, now, should_be, reason}[] | now is OR/AND; should_be is AND, OR, collapse or keep. |
estimate_challenges | {node, attribute, now, suggest, reason}[] | At most 6. attribute is difficulty, cost, detection or time_h (the normaliser also accepts time). For time_h, suggest is a number of hours. |
missing_branches | {parent, lines, category, reason}[] | 0 to 5. lines uses new ids. category is one of technical, social, process, supply_chain, insider, physical. |
defense_plan | {cut, steps, controls, why}[] | 1 to 4 items. When facts.cuts is not empty, the first names a cut and its steps are exactly that cut's leaves. Later items may have cut: "". |
stakeholder_brief | string | |
summary | string |
Reading the reply
The model returns ONE JSON object as text. It is in the job's
output.output: the page reads done.output.output from the stream's final
payload, and the SDK reads job.output.output from a polled job. Parse it the way
Recon.parseResult does: take the text from the first { to the last
} (that also drops any code fence) and JSON.parse it. Treat missing arrays
as empty. The page's normaliser does.
If it does not parse, the page sends the same input once more with a retry_note (for
example: "Your previous reply was not the single valid JSON object the instructions require
(...). Reply again with ONLY the JSON object for task 'review' - no prose, no code fences; every
array present (empty arrays where there is nothing to say).") and the attempt suffix
:a2 on the Idempotency-Key. That retry is a second, separately charged run.
1. A tiny client
One helper that sends Content-Type: application/json and
Authorization: Bearer, sends an already-serialised JSON body, unwraps
data and raises on a non-2xx status with error.code and
error.message. That is the same contract as the SDK's _req. The later
steps assume it is in scope. The token comes from step 2.
# bash; needs curl 7.76+ (--fail-with-body) and jq.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}" # from https://attack-tree-desk.skillsafe.ai/tokens.html
# api METHOD PATH [BODY_FILE] [extra curl args...]
# Prints the response body; exits non-zero on a 4xx/5xx (the body is still printed).
api() {
local method="$1" path="$2" body="${3:-}"
shift 2; [ $# -gt 0 ] && shift
local args=(-sS --fail-with-body -X "$method" "$BASE$path" -H "Authorization: Bearer $TOKEN")
[ -n "$body" ] && args+=(-H "Content-Type: application/json" --data-binary "@$body")
curl "${args[@]}" "$@"
}
import json, os, urllib.error, urllib.parse, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__(f"{status} {code}: {message}")
self.status, self.code, self.details = status, code, details
def _error(e):
try:
err = json.load(e).get("error") or {}
except ValueError:
err = {}
return ApiError(e.code, err.get("code"), err.get("message") or e.reason, err.get("details"))
def call(method, path, body=None, headers=None):
"""body is an already-serialised JSON string (or None). Returns `data`."""
req = urllib.request.Request(BASE + path, method=method,
data=body.encode("utf-8") if body is not None else None)
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r).get("data")
except urllib.error.HTTPError as e:
raise _error(e) from None
// Node 18+ as an ES module (.mjs, for top-level await), or a modern browser.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const token = "YOUR_TOKEN"; // from https://attack-tree-desk.skillsafe.ai/tokens.html
// body is an already-serialised JSON string (or undefined). Returns `data`.
async function call(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: { "Content-Type": "application/json", Authorization: "Bearer " + token, ...headers },
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // from /tokens.html
// The later snippets are functions in this same package; call them from your main().
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
// call sends one request (body is serialised JSON or nil) and returns the `data` member.
func call(method, path string, body []byte, extra map[string]string) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, base+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
e := env.Error
if e == nil {
e = &APIError{Message: res.Status}
}
e.Status = res.StatusCode
return nil, e
}
return env.Data, nil
}
// Java 17+, Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
// The later snippets are static members of this class; plain statements go in main().
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class AttackTreeDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static class ApiError extends RuntimeException {
final int status; final String code;
ApiError(int status, String code, String message) {
super(status + " " + code + ": " + message);
this.status = status; this.code = code;
}
}
static HttpRequest.Builder request(String path, Map<String, String> headers) {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + TOKEN);
headers.forEach(b::header);
return b;
}
// body is an already-serialised JSON string, or null. Returns the `data` node.
static JsonNode call(String method, String path, String body, Map<String, String> headers) throws Exception {
HttpRequest req = request(path, headers)
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
JsonNode json;
try { json = JSON.readTree(res.body()); } catch (Exception e) { json = JSON.createObjectNode(); }
if (res.statusCode() < 200 || res.statusCode() > 299) {
JsonNode err = json.path("error");
throw new ApiError(res.statusCode(), err.path("code").asText(null), err.path("message").asText("HTTP " + res.statusCode()));
}
return json.path("data");
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
class ApiError < StandardError
attr_reader :status, :code, :details
def initialize(status, code, message, details = nil)
super("#{status} #{code}: #{message}")
@status, @code, @details = status, code, details
end
end
# body is an already-serialised JSON string (or nil). Returns `data`.
def call(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
headers.each { |k, v| req[k] = v }
req.body = body if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 120) { |h| h.request(req) }
json = (JSON.parse(res.body.to_s) rescue {})
unless res.is_a?(Net::HTTPSuccess)
err = json["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message, err["details"])
end
json["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // from /tokens.html
class ApiError extends Exception {
public $status; public $errCode; public $details;
function __construct($status, $code, $message, $details = null) {
parent::__construct("$status $code: $message");
$this->status = $status; $this->errCode = $code; $this->details = $details;
}
}
// $body is an already-serialised JSON string (or null). Returns `data` as an array.
function call($method, $path, $body = null, $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json", "Authorization: Bearer $TOKEN"];
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $h,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
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 ($status < 200 || $status > 299) {
$e = $json["error"] ?? [];
throw new ApiError($status, $e["code"] ?? null, $e["message"] ?? "HTTP $status", $e["details"] ?? null);
}
return $json["data"] ?? null;
}
// .NET 6+ top-level program. Later snippets are statements and local functions after this one.
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // from /tokens.html
var http = new HttpClient { Timeout = TimeSpan.FromMinutes(5) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// body is an already-serialised JSON string (or null). Returns `data`.
async Task<JsonElement> Call(HttpMethod method, string path, string? body = null, Dictionary<string, string>? headers = null)
{
using var req = new HttpRequestMessage(method, Base + path);
if (body != null) req.Content = new StringContent(body, Encoding.UTF8, "application/json");
foreach (var kv in headers ?? new()) req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
using var res = await http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
JsonElement json = default;
try { json = JsonDocument.Parse(text).RootElement.Clone(); } catch (JsonException) { }
if (!res.IsSuccessStatusCode)
{
string? code = null, message = null;
if (json.ValueKind == JsonValueKind.Object && json.TryGetProperty("error", out var e))
{
if (e.TryGetProperty("code", out var c)) code = c.GetString();
if (e.TryGetProperty("message", out var m)) message = m.GetString();
}
throw new HttpRequestException($"{(int)res.StatusCode} {code}: {message ?? res.ReasonPhrase}");
}
return json.GetProperty("data").Clone();
}
2. Get a token
The easiest route is the token page. It shows the token this browser
already holds for attack-tree-desk, with Copy token and
Copy shell export buttons, and a Sign in with SkillSafe button for a
personal token.
There are two kinds. A guest token is enough for /me and
/estimate. You can mint one yourself: the SDK's guest() posts
{"slug":"attack-tree-desk"} to /guest with no Authorization header and
gets back {token, guest_id}. Drafting or reviewing a tree is metered,
so /run and /run-stream need a personal token from signing
in on the token page. The page itself only starts a run for a guest when /estimate
reports sponsor_enabled: true; otherwise it asks for sign-in first. A personal token
bills your own balance. Treat it like a password.
# Personal token (needed for runs): sign in at
# https://attack-tree-desk.skillsafe.ai/tokens.html
# and press "Copy shell export".
#
# Guest token (enough for /me and /estimate):
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"attack-tree-desk"}' | jq '.data | {token, guest_id}'
# Personal token: sign in at https://attack-tree-desk.skillsafe.ai/tokens.html and copy it.
# Guest token (enough for /me and /estimate). No Authorization header on this call:
req = urllib.request.Request(
BASE + "/guest",
data=json.dumps({"slug": "attack-tree-desk"}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req) as r:
guest = json.load(r)["data"]
print(guest["token"], guest["guest_id"])
// Personal token: sign in at https://attack-tree-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate). No Authorization header on this call:
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "attack-tree-desk" }),
});
const { data: guest } = await res.json();
console.log(guest.token, guest.guest_id);
// Personal token: sign in at https://attack-tree-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate). No Authorization header on this call:
func guestToken() (string, error) {
res, err := http.Post(base+"/guest", "application/json",
bytes.NewBufferString(`{"slug":"attack-tree-desk"}`))
if err != nil {
return "", err
}
defer res.Body.Close()
var env struct {
Data struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return "", err
}
return env.Data.Token, nil
}
// Personal token: sign in at https://attack-tree-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate). No Authorization header on this call:
static String guestToken() throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"attack-tree-desk\"}"))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return JSON.readTree(res.body()).path("data").path("token").asText();
}
# Personal token: sign in at https://attack-tree-desk.skillsafe.ai/tokens.html and copy it.
# Guest token (enough for /me and /estimate). No Authorization header on this call:
res = Net::HTTP.post(URI(BASE + "/guest"), { slug: "attack-tree-desk" }.to_json,
"Content-Type" => "application/json")
guest = JSON.parse(res.body)["data"]
puts guest["token"], guest["guest_id"]
// Personal token: sign in at https://attack-tree-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate). No Authorization header on this call:
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "attack-tree-desk"]),
CURLOPT_RETURNTRANSFER => true,
]);
$guest = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
echo $guest["token"], " ", $guest["guest_id"], PHP_EOL;
// Personal token: sign in at https://attack-tree-desk.skillsafe.ai/tokens.html and copy it.
// Guest token (enough for /me and /estimate). No Authorization header on this call:
using var anon = new HttpClient();
var guestRes = await anon.PostAsync(Base + "/guest",
new StringContent("{\"slug\":\"attack-tree-desk\"}", Encoding.UTF8, "application/json"));
var guest = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync()).RootElement.GetProperty("data");
Console.WriteLine(guest.GetProperty("token").GetString());
3. Who am I: GET /me
GET /v1/app-api/me (the SDK's user()) returns the caller: the SDK
documents { subject_type, subject_id, credits, profile? }. The page treats the token as
signed in only when subject_type is "user". It shows
credits as the balance and compares it with /estimate before a run.
api GET /me | jq '.data | {subject_type, subject_id, credits}'
me = call("GET", "/me")
signed_in = me.get("subject_type") == "user" # what the page checks before a run
print(me.get("subject_type"), me.get("credits"), signed_in)
const me = await call("GET", "/me");
const signedIn = me.subject_type === "user"; // what the page checks before a run
console.log(me.subject_type, me.credits, signedIn);
type Me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits *float64 `json:"credits"`
}
func whoAmI() (*Me, error) {
raw, err := call("GET", "/me", nil, nil)
if err != nil {
return nil, err
}
var me Me
err = json.Unmarshal(raw, &me)
return &me, err // me.SubjectType == "user" means signed in
}
JsonNode me = call("GET", "/me", null, Map.of());
boolean signedIn = "user".equals(me.path("subject_type").asText()); // what the page checks
System.out.println(me.path("subject_type").asText() + " " + me.path("credits") + " " + signedIn);
me = call("GET", "/me")
signed_in = me["subject_type"] == "user" # what the page checks before a run
puts me["subject_type"], me["credits"], signed_in
$me = call("GET", "/me");
$signedIn = ($me["subject_type"] ?? "") === "user"; // what the page checks before a run
echo $me["subject_type"], " ", $me["credits"] ?? "", " ", $signedIn ? "signed in" : "not signed in", PHP_EOL;
var me = await Call(HttpMethod.Get, "/me");
var signedIn = me.GetProperty("subject_type").GetString() == "user"; // what the page checks
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {(me.TryGetProperty("credits", out var cr) ? cr.ToString() : "")} {signedIn}");
4. Price it: POST /estimate
POST /v1/app-api/estimate takes the same body as a run and returns the worst-case
price without charging and without creating a job (the SDK: "Worst-case credit cost of a run with
this input (no charge, no job)"). A guest token may call it. The page reads four fields:
hold_credits— what the run reserves. The hold is a reservation, not the price: the settled charge is reported after the run ascharged_credits.min_credits— the page will not start a run when the balance from/meis below this. Betweenmin_creditsandhold_creditsit warns that the reply may be cut short.sponsor_enabled— the page lets a guest run only when this is true.warnings— a list the page prints as "the service warned: ...". This app declares a schema withtaskrequired, so a missingtaskor an unknown field should show up here. Fix the body rather than run it.
Write the body once to review.json (built with atree.js as above)
or build.json (the build example). The samples use
review.json; a build body works the same way.
api POST /estimate review.json | jq '.data | {hold_credits, min_credits, sponsor_enabled, warnings}'
body = open("review.json", encoding="utf-8").read()
est = call("POST", "/estimate", body)
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"))
for w in est.get("warnings") or []:
print("warning:", w) # a warning means the body is wrong: fix it before running
import { readFileSync } from "node:fs"; // imports go at the top of the module
const body = readFileSync("review.json", "utf8");
const est = await call("POST", "/estimate", body);
console.log("hold", est.hold_credits, "min", est.min_credits, "sponsored", est.sponsor_enabled);
for (const w of est.warnings || []) console.warn("warning:", w);
type Estimate struct {
HoldCredits float64 `json:"hold_credits"`
MinCredits float64 `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
Warnings []any `json:"warnings"`
}
func estimate(body []byte) (*Estimate, error) {
raw, err := call("POST", "/estimate", body, nil)
if err != nil {
return nil, err
}
var est Estimate
err = json.Unmarshal(raw, &est)
return &est, err
}
// body, _ := os.ReadFile("review.json"); est, err := estimate(body)
String body = java.nio.file.Files.readString(java.nio.file.Path.of("review.json"));
JsonNode est = call("POST", "/estimate", body, Map.of());
System.out.println("hold " + est.path("hold_credits") + ", min " + est.path("min_credits")
+ ", sponsored " + est.path("sponsor_enabled").asBoolean(false));
est.path("warnings").forEach(w -> System.err.println("warning: " + w.asText()));
body = File.read("review.json")
est = call("POST", "/estimate", body)
puts "hold #{est['hold_credits']}, min #{est['min_credits']}, sponsored #{est['sponsor_enabled']}"
(est["warnings"] || []).each { |w| warn "warning: #{w}" }
$body = file_get_contents("review.json");
$est = call("POST", "/estimate", $body);
echo "hold ", $est["hold_credits"] ?? "", ", min ", $est["min_credits"] ?? "", PHP_EOL;
foreach ($est["warnings"] ?? [] as $w) fwrite(STDERR, "warning: $w" . PHP_EOL);
var body = File.ReadAllText("review.json");
var est = await Call(HttpMethod.Post, "/estimate", body);
Console.WriteLine($"hold {est.GetProperty("hold_credits")}, min {(est.TryGetProperty("min_credits", out var mn) ? mn.ToString() : "-")}");
if (est.TryGetProperty("warnings", out var ws) && ws.ValueKind == JsonValueKind.Array)
foreach (var w in ws.EnumerateArray()) Console.Error.WriteLine("warning: " + w.GetString());
5. Run it: POST /run, then poll the job
POST /v1/app-api/run with the body and an Idempotency-Key header starts a
metered job. The data it returns carries job_id. Poll
GET /v1/app-api/jobs/{job_id} until status is succeeded or
failed; the SDK's waitForJob polls every 1 s and gives up after 180 s by
default. On the finished job, read:
output.output— the model's reply, ONE JSON object as text (see reading the reply);charged_credits— the settled charge;truncated—truewhen the reply was cut short. The page calls it "cut short by the available balance" and offers a top-up;error— on a failed job.
Idempotency-Key. The page sends
attack-tree-desk:{lane}:{hash}:a{attempt}, where {hash} is a hash of the
exact input (a 32-bit djb2 of JSON.stringify(input) in hex, a dash, and the length in
hex) and {attempt} is 1, or 2 for its one parse-failure
retry. The comment in app.js gives the reason: "so a reformat retry is distinguishable from a
deliberate re-run and a network blip never double-bills". The samples below keep that shape but use
a SHA-256 prefix of the body instead of djb2. Reuse the key
only to resend the same run.
LANE=$(jq -r .task review.json)
KEY="attack-tree-desk:$LANE:$(shasum -a 256 review.json | cut -c1-16):a1"
JOB=$(api POST /run review.json -H "Idempotency-Key: $KEY" | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB")
S=$(printf '%s' "$J" | jq -r '.data.status')
if [ "$S" = succeeded ] || [ "$S" = failed ]; then break; fi
sleep 1
done
printf '%s' "$J" | jq '.data | {status, charged_credits, truncated, error}'
# The reply is ONE JSON object as text in data.output.output; cut from the first { to the last }:
printf '%s' "$J" | jq '.data.output.output | .[index("{"):rindex("}")+1] | fromjson'
import hashlib, time
def parse_reply(text):
a, b = text.find("{"), text.rfind("}")
if a < 0 or b < a:
raise ValueError("no JSON object in the reply")
return json.loads(text[a:b + 1])
body = open("review.json", encoding="utf-8").read()
lane = json.loads(body)["task"]
key = "attack-tree-desk:%s:%s:a1" % (lane, hashlib.sha256(body.encode()).hexdigest()[:16])
job_id = call("POST", "/run", body, {"Idempotency-Key": key})["job_id"]
deadline = time.time() + 180
while True:
job = call("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)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
print("charged", job.get("charged_credits"), "truncated", job.get("truncated") is True)
reply = parse_reply((job.get("output") or {}).get("output") or "")
print(reply.get("verdict") or reply.get("tree"))
import { createHash } from "node:crypto"; // imports go at the top of the module
import { readFileSync } from "node:fs";
const parseReply = (text) => {
const a = text.indexOf("{"), b = text.lastIndexOf("}");
if (a < 0 || b < a) throw new Error("no JSON object in the reply");
return JSON.parse(text.slice(a, b + 1));
};
const body = readFileSync("review.json", "utf8");
const lane = JSON.parse(body).task;
const key = `attack-tree-desk:${lane}:${createHash("sha256").update(body).digest("hex").slice(0, 16)}:a1`;
const { job_id } = await call("POST", "/run", body, { "Idempotency-Key": key });
const deadline = Date.now() + 180000;
let job;
for (;;) {
job = await call("GET", "/jobs/" + encodeURIComponent(job_id));
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error("job failed: " + JSON.stringify(job.error));
console.log("charged", job.charged_credits, "truncated", job.truncated === true);
const reply = parseReply((job.output && job.output.output) || "");
console.log(reply.verdict || reply.tree);
// add to the imports: "crypto/sha256", "encoding/hex", "net/url", "strings", "time"
type 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"`
}
func idempotencyKey(body []byte) string {
var in struct {
Task string `json:"task"`
}
_ = json.Unmarshal(body, &in)
sum := sha256.Sum256(body)
return "attack-tree-desk:" + in.Task + ":" + hex.EncodeToString(sum[:])[:16] + ":a1"
}
func parseReply(text string) (map[string]any, error) {
a, b := strings.Index(text, "{"), strings.LastIndex(text, "}")
if a < 0 || b < a {
return nil, fmt.Errorf("no JSON object in the reply")
}
var out map[string]any
err := json.Unmarshal([]byte(text[a:b+1]), &out)
return out, err
}
func runAndWait(body []byte) (map[string]any, *Job, error) {
raw, err := call("POST", "/run", body, map[string]string{"Idempotency-Key": idempotencyKey(body)})
if err != nil {
return nil, nil, err
}
var started struct {
JobID string `json:"job_id"`
}
_ = json.Unmarshal(raw, &started)
deadline := time.Now().Add(180 * time.Second)
var job Job
for {
raw, err = call("GET", "/jobs/"+url.PathEscape(started.JobID), nil, nil)
if err != nil {
return nil, nil, err
}
job = Job{}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
if time.Now().After(deadline) {
return nil, &job, fmt.Errorf("job timed out")
}
time.Sleep(time.Second)
}
if job.Status == "failed" {
return nil, &job, fmt.Errorf("job failed: %s", job.Error)
}
reply, err := parseReply(job.Output.Output)
return reply, &job, err
}
static String idempotencyKey(String body) throws Exception {
String lane = JSON.readTree(body).path("task").asText();
byte[] d = java.security.MessageDigest.getInstance("SHA-256")
.digest(body.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return "attack-tree-desk:" + lane + ":" + java.util.HexFormat.of().formatHex(d).substring(0, 16) + ":a1";
}
static JsonNode parseReply(String text) throws Exception {
int a = text.indexOf('{'), b = text.lastIndexOf('}');
if (a < 0 || b < a) throw new IllegalStateException("no JSON object in the reply");
return JSON.readTree(text.substring(a, b + 1));
}
static JsonNode runAndWait(String body) throws Exception {
String jobId = call("POST", "/run", body, Map.of("Idempotency-Key", idempotencyKey(body))).path("job_id").asText();
long deadline = System.currentTimeMillis() + 180_000;
JsonNode job;
while (true) {
job = call("GET", "/jobs/" + java.net.URLEncoder.encode(jobId, java.nio.charset.StandardCharsets.UTF_8), null, Map.of());
String s = job.path("status").asText();
if (s.equals("succeeded") || s.equals("failed")) break;
if (System.currentTimeMillis() > deadline) throw new IllegalStateException("job timed out");
Thread.sleep(1000);
}
if (job.path("status").asText().equals("failed")) throw new IllegalStateException("job failed: " + job.path("error"));
System.out.println("charged " + job.path("charged_credits") + ", truncated " + job.path("truncated").asBoolean(false));
return parseReply(job.path("output").path("output").asText(""));
}
require "digest"
def parse_reply(text)
a, b = text.index("{"), text.rindex("}")
raise "no JSON object in the reply" if a.nil? || b.nil? || b < a
JSON.parse(text[a..b])
end
body = File.read("review.json")
lane = JSON.parse(body)["task"]
key = "attack-tree-desk:#{lane}:#{Digest::SHA256.hexdigest(body)[0, 16]}:a1"
job_id = call("POST", "/run", body, { "Idempotency-Key" => key })["job_id"]
deadline = Time.now + 180
job = nil
loop do
job = call("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise "job failed: #{job['error']}" if job["status"] == "failed"
puts "charged #{job['charged_credits']}, truncated #{job['truncated'] == true}"
reply = parse_reply(job.dig("output", "output").to_s)
puts reply["verdict"] || reply["tree"]
function parse_reply($text) {
$a = strpos($text, "{"); $b = strrpos($text, "}");
if ($a === false || $b === false || $b < $a) throw new RuntimeException("no JSON object in the reply");
return json_decode(substr($text, $a, $b - $a + 1), true, 512, JSON_THROW_ON_ERROR);
}
$body = file_get_contents("review.json");
$lane = json_decode($body, true)["task"];
$key = "attack-tree-desk:$lane:" . substr(hash("sha256", $body), 0, 16) . ":a1";
$jobId = call("POST", "/run", $body, ["Idempotency-Key" => $key])["job_id"];
$deadline = time() + 180;
while (true) {
$job = call("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job["status"], ["succeeded", "failed"], true)) break;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
if ($job["status"] === "failed") throw new RuntimeException("job failed: " . json_encode($job["error"] ?? null));
echo "charged ", $job["charged_credits"] ?? "", ", truncated ", (($job["truncated"] ?? false) === true) ? "yes" : "no", PHP_EOL;
$reply = parse_reply($job["output"]["output"] ?? "");
echo $reply["verdict"] ?? $reply["tree"] ?? "", PHP_EOL;
static JsonElement ParseReply(string text)
{
int a = text.IndexOf('{'), b = text.LastIndexOf('}');
if (a < 0 || b < a) throw new FormatException("no JSON object in the reply");
return JsonDocument.Parse(text[a..(b + 1)]).RootElement.Clone();
}
static string IdempotencyKey(string body)
{
var lane = JsonDocument.Parse(body).RootElement.GetProperty("task").GetString();
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant()[..16];
return $"attack-tree-desk:{lane}:{hash}:a1";
}
var runBody = File.ReadAllText("review.json");
var started = await Call(HttpMethod.Post, "/run", runBody, new() { ["Idempotency-Key"] = IdempotencyKey(runBody) });
var jobId = started.GetProperty("job_id").GetString()!;
var deadline = DateTime.UtcNow.AddSeconds(180);
JsonElement job;
while (true)
{
job = await Call(HttpMethod.Get, "/jobs/" + Uri.EscapeDataString(jobId));
var s = job.GetProperty("status").GetString();
if (s == "succeeded" || s == "failed") break;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
if (job.GetProperty("status").GetString() == "failed")
throw new Exception("job failed: " + (job.TryGetProperty("error", out var er) ? er.ToString() : ""));
var text = job.TryGetProperty("output", out var o) && o.ValueKind == JsonValueKind.Object
&& o.TryGetProperty("output", out var t) ? t.GetString() ?? "" : "";
var reply = ParseReply(text);
Console.WriteLine(reply.TryGetProperty("verdict", out var v) ? v.GetString() : reply.GetProperty("tree").GetString());
6. Or stream it: POST /run-stream (SSE)
This is what the page itself uses. POST /v1/app-api/run-stream takes the same body and
headers as /run (including Idempotency-Key) and answers
text/event-stream. Use it instead of step 5, not after it: it is a run of
its own. The SDK's _readSse reads blocks separated by a blank line, each with an
event: line and data: JSON:
| event | data | what the SDK does |
|---|---|---|
job | job info | Passes it to onJob. The page moves its progress list on. |
delta | {text} | Passes text to onDelta. The page concatenates it as a fallback copy of the reply. |
done / pending | the final payload: {job_id, status, charged_credits, output} | Keeps it as the result. The page reads done.output.output (the reply text), charged_credits and truncated. |
error | {code, message, job_id} | Throws once the stream ends. |
Two details from the code. If the response is not text/event-stream, the SDK
reads it as the ordinary JSON envelope instead: its comment says this happens on idempotent replays.
An error status arrives the same way. And the page's own comment says browsers receive ticks, not
deltas, from /run-stream, so it takes the reply from done.output.output and
uses the concatenated deltas only when that is empty. Do the same. If the final payload's
status is not succeeded or it carries no output, you can still poll
/jobs/{job_id} as in step 5.
LANE=$(jq -r .task review.json)
KEY="attack-tree-desk:$LANE:$(shasum -a 256 review.json | cut -c1-16):a1"
# -N turns off buffering so events print as they arrive.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
--data-binary @review.json
# Each block is "event: NAME" then "data: {JSON}", then a blank line.
# NAME is job, delta, done / pending (the final payload with output.output) or error.
def run_stream(body, key):
"""Returns (final_payload, concatenated_deltas)."""
req = urllib.request.Request(BASE + "/run-stream", data=body.encode("utf-8"), method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Idempotency-Key", key)
try:
r = urllib.request.urlopen(req, timeout=300)
except urllib.error.HTTPError as e:
raise _error(e) from None
with r:
if "text/event-stream" not in (r.headers.get("Content-Type") or ""):
return json.load(r).get("data"), "" # plain JSON (idempotent replay)
event, data, done, deltas = "message", "", None, []
for raw in r:
line = raw.decode("utf-8").rstrip("\r\n")
if line == "":
if data:
payload = json.loads(data)
if event == "delta":
deltas.append(payload.get("text") or "")
elif event in ("done", "pending"):
done = payload
elif event == "error":
raise ApiError(0, payload.get("code"), payload.get("message") or "job failed", payload)
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
return done, "".join(deltas)
done, deltas = run_stream(body, key) # body and key as in step 5
text = ((done or {}).get("output") or {}).get("output") or deltas
reply = parse_reply(text)
print((done or {}).get("status"), (done or {}).get("charged_credits"), (done or {}).get("truncated") is True)
// body, key and parseReply as in step 5.
async function runStream(body, key) {
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + token, "Idempotency-Key": key },
body,
});
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
const json = await res.json().catch(() => ({})); // plain JSON (idempotent replay, or an error)
if (!res.ok) throw Object.assign(new Error((json.error && json.error.message) || res.statusText), { status: res.status });
return { done: json.data, deltas: "" };
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", deltas = "", done = null, failure = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let i;
while ((i = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, i);
buffer = buffer.slice(i + 2);
let event = "message", data = "";
for (const line of block.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 payload = JSON.parse(data);
if (event === "delta") deltas += payload.text || "";
else if (event === "done" || event === "pending") done = payload;
else if (event === "error") failure = payload;
}
}
if (failure) throw Object.assign(new Error(failure.message || "job failed"), failure);
return { done, deltas };
}
const { done, deltas } = await runStream(body, key);
const streamedReply = parseReply((done && done.output && done.output.output) || deltas);
console.log(done && done.status, done && done.charged_credits, !!(done && done.truncated));
// add to the imports: "bufio"; idempotencyKey and parseReply as in step 5.
// runStream returns the final payload (done or pending) and the concatenated deltas.
func runStream(body []byte) (map[string]any, string, error) {
req, err := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
if err != nil {
return nil, "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idempotencyKey(body))
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, "", err
}
defer res.Body.Close()
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
var env struct {
Data map[string]any `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode > 299 {
if env.Error == nil {
env.Error = &APIError{Message: res.Status}
}
env.Error.Status = res.StatusCode
return nil, "", env.Error
}
return env.Data, "", nil // plain JSON (idempotent replay)
}
var done map[string]any
var deltas strings.Builder
event, data := "message", ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case line == "":
if data != "" {
var p map[string]any
if json.Unmarshal([]byte(data), &p) == nil {
switch event {
case "delta":
if s, ok := p["text"].(string); ok {
deltas.WriteString(s)
}
case "done", "pending":
done = p
case "error":
return nil, deltas.String(), fmt.Errorf("stream error %v: %v", p["code"], p["message"])
}
}
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
return done, deltas.String(), sc.Err()
}
// The reply text: done["output"].(map[string]any)["output"].(string), else the deltas.
// idempotencyKey and parseReply as in step 5. Returns the final payload (done or pending).
static JsonNode runStream(String body) throws Exception {
HttpRequest req = request("/run-stream", Map.of("Idempotency-Key", idempotencyKey(body)))
.POST(HttpRequest.BodyPublishers.ofString(body)).build();
HttpResponse<java.util.stream.Stream<String>> res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
java.util.Iterator<String> lines = res.body().iterator();
if (!res.headers().firstValue("content-type").orElse("").contains("text/event-stream")) {
StringBuilder all = new StringBuilder();
lines.forEachRemaining(all::append);
JsonNode json = JSON.readTree(all.toString());
if (res.statusCode() > 299) {
throw new ApiError(res.statusCode(), json.path("error").path("code").asText(null), json.path("error").path("message").asText(""));
}
return json.path("data"); // plain JSON (idempotent replay)
}
JsonNode done = null;
StringBuilder deltas = new StringBuilder(), data = new StringBuilder();
String event = "message";
while (lines.hasNext()) {
String line = lines.next();
if (line.isEmpty()) {
if (data.length() > 0) {
JsonNode p = JSON.readTree(data.toString());
switch (event) {
case "delta" -> deltas.append(p.path("text").asText(""));
case "done", "pending" -> done = p;
case "error" -> throw new ApiError(0, p.path("code").asText(null), p.path("message").asText("job failed"));
default -> { }
}
}
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
data.append(line.substring(5).trim());
}
}
// Reply text: done.path("output").path("output").asText(""), falling back to deltas.
return done;
}
# body, key and parse_reply as in step 5. Returns [final_payload, concatenated_deltas].
def run_stream(body, key)
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = key
req.body = body
done, deltas, buf, failure, plain = nil, +"", +"", nil, nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |h|
h.request(req) do |res|
unless res["Content-Type"].to_s.include?("text/event-stream")
json = (JSON.parse(res.read_body.to_s) rescue {})
raise ApiError.new(res.code.to_i, json.dig("error", "code"), json.dig("error", "message")) unless res.is_a?(Net::HTTPSuccess)
plain = json["data"] # plain JSON (idempotent replay)
next
end
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n\n"))
block = buf.slice!(0, i + 2)
event, data = "message", +""
block.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
next if data.empty?
payload = JSON.parse(data)
case event
when "delta" then deltas << payload["text"].to_s
when "done", "pending" then done = payload
when "error" then failure = payload
end
end
end
end
end
raise ApiError.new(0, failure["code"], failure["message"] || "job failed") if failure
plain ? [plain, ""] : [done, deltas]
end
done, deltas = run_stream(body, key)
text = done&.dig("output", "output") || deltas
reply = parse_reply(text.to_s)
// $body, $key and parse_reply as in step 5. Returns [final_payload, concatenated_deltas].
function run_stream($body, $key) {
global $TOKEN;
$buf = ""; $deltas = ""; $done = null; $failure = null; $plain = ""; $isSse = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_HTTPHEADER => ["Content-Type: application/json", "Authorization: Bearer $TOKEN", "Idempotency-Key: $key"],
CURLOPT_TIMEOUT => 300,
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$deltas, &$done, &$failure, &$plain, &$isSse) {
if ($isSse === null) $isSse = str_contains((string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE), "text/event-stream");
if (!$isSse) { $plain .= $chunk; return strlen($chunk); }
$buf .= $chunk;
while (($i = strpos($buf, "\n\n")) !== false) {
$block = substr($buf, 0, $i); $buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $block) as $line) {
if (str_starts_with($line, "event:")) $event = trim(substr($line, 6));
elseif (str_starts_with($line, "data:")) $data .= trim(substr($line, 5));
}
if ($data === "") continue;
$p = json_decode($data, true);
if ($event === "delta") $deltas .= $p["text"] ?? "";
elseif ($event === "done" || $event === "pending") $done = $p;
elseif ($event === "error") $failure = $p;
}
return strlen($chunk);
},
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($failure) throw new ApiError(0, $failure["code"] ?? null, $failure["message"] ?? "job failed");
if (!$isSse) {
$json = json_decode($plain, true) ?: [];
if ($status < 200 || $status > 299) throw new ApiError($status, $json["error"]["code"] ?? null, $json["error"]["message"] ?? "HTTP $status");
return [$json["data"] ?? null, ""]; // plain JSON (idempotent replay)
}
return [$done, $deltas];
}
[$done, $deltas] = run_stream($body, $key);
$reply = parse_reply($done["output"]["output"] ?? $deltas);
// runBody, IdempotencyKey and ParseReply as in step 5.
async Task<(JsonElement? done, string deltas)> RunStream(string body)
{
using var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
req.Headers.TryAddWithoutValidation("Idempotency-Key", IdempotencyKey(body));
using var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
var plain = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode) throw new HttpRequestException($"{(int)res.StatusCode}: {plain}");
return (JsonDocument.Parse(plain).RootElement.GetProperty("data").Clone(), ""); // idempotent replay
}
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
JsonElement? done = null;
var deltas = new StringBuilder();
var data = new StringBuilder();
var ev = "message";
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.Length == 0)
{
if (data.Length > 0)
{
var p = JsonDocument.Parse(data.ToString()).RootElement.Clone();
if (ev == "delta" && p.TryGetProperty("text", out var tx)) deltas.Append(tx.GetString());
else if (ev == "done" || ev == "pending") done = p;
else if (ev == "error") throw new Exception("stream error: " + p);
}
ev = "message";
data.Clear();
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data.Append(line[5..].Trim());
}
return (done, deltas.ToString());
}
var (final, streamed) = await RunStream(runBody);
var replyText = final is JsonElement f && f.TryGetProperty("output", out var fo) && fo.ValueKind == JsonValueKind.Object
&& fo.TryGetProperty("output", out var ft) ? ft.GetString() ?? streamed : streamed;
var streamedReply = ParseReply(replyText);
Checklist
- Body is a JSON object with
taskset tobuildorreview; no wrapper. - For
review,factsis a string built byatree.js. /estimatefirst: nowarnings, and a balance of at leastmin_credits.- A personal token for
/runand/run-stream, and a freshIdempotency-Keyper new run. - The reply is text in
output.output: parse the first{to the last}, and checktruncated.