← Attack Tree Desk / API
Tokens

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:

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.

wherewhat you seewhat the page does / what to do
any callnon-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".
run401The 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.
run402"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-streamevent: 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.
pollingno terminal state in timewaitForJob 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 replyoutput.output is not one JSON objectThe 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:

fieldsentwhat the page does to it
taskalways"build"
goalalwaysThe 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.
descriptionalwaysThe 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.
attackeronly when non-emptyWho is attacking and what they can do. Whitespace collapsed, at most 400 characters.
questiononly when non-emptyAnswered inside summary. Whitespace collapsed, at most 600 characters.
retry_noteonly on the page's one automatic retryAdded 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:

fieldsentwhat the page does to it
taskalways"review"
factsalwaysJSON.stringify(Atree.buildFacts(analysis)) — a JSON string, not an object. See building facts.
questiononly when non-emptyTrimmed by the page, then whitespace collapsed, at most 600 characters. Answered in headline and summary.
contextonly when non-emptyYour notes on the system. Trimmed; lines kept; over 4,000 characters the middle is cut with the same marker as description.
retry_noteonly on the page's one automatic retryAs 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:

keycontents
goal, root, titleThe root step's name and id, and the tree's title (the first # comment line of an outline, or "").
scalesText describing the difficulty, cost, detection and time_h scales.
rulesText: 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.
countsnodes, 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
nodesEvery 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_pathscheapest, easiest, stealthiest, fastest, each {leaves, steps, cost, difficulty, detection, time_h, insider, physical, meets_in_place_control}.
cheapest_openThe same shape for the cheapest scenario that meets no in-place control, or null.
cutsUp to 8 minimal cut sets: {id, leaves, size, new_controls}.
criticalUp to 8 steps by share of scenarios: {leaf, scenarios, share_pct}.
flags{id, code, severity, nodes, detail}; severity is block, warn or info.
verdict_floorrestructure if any flag is block, else revise_first if any is warn, else ready_for_review.

Three ways to get it:

// 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:

# 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"
}
fieldtypenotes
lanestring"build". The page notes a mismatch if it names the other lane.
goalstringThe root goal restated.
assumptionsstring[]
out_of_scopestring[]Also where the model must say why insiders are excluded, if they are.
treestringThe 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_questionsstring[]
summarystringAlso 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"
}
fieldtypenotes
lanestring"review".
verdictenumready_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.
headlinestringAlso 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_briefstring
summarystring

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[@]}" "$@"
}

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}'

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}'

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:

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}'

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:

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'

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:

eventdatawhat the SDK does
jobjob infoPasses 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 / pendingthe 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.

Checklist