← Board File Fixer / API
Tokens

Drive Board File Fixer from your own code

Board File Fixer checks a JSON Canvas file (.canvas, the open format Obsidian saves canvases in). In the page, and free, a strict JSON reader reports the first syntax error with its line and column, every node and edge is checked against JSON Canvas 1.0 and the @kepano/json-canvas skill's checklist (unique ids, required fields per node type, edges that point at real nodes, sides, ends, colours, subpaths), layout problems are flagged (overlapping cards, cards half inside a group, groups listed after their cards), and the canvas is drawn as SVG.

The metered lanes read what the page found: fix answers every finding and returns the corrected canvas; build makes a new canvas from an outline, a list of steps or a description. The page runs any canvas in a reply back through the same checks and draws it again; from the API, re-check it yourself (step 7) or paste it into the page.

Two lanes: the task field

tasksendyou get back
fixcanvas, facts; symptom optionalheadline, verdict on the original file (broken, fix_first, ok), a stance on every finding (finding_responses), changes (where, what, why), fixed_canvas (the complete corrected canvas as a JSON object, or null when nothing needs to change) and to_check.
buildrequest, facts; canvas optional (a canvas to extend)headline, canvas (the complete canvas as a JSON object), explanation (one entry per part the canvas uses), assumptions and to_check.

If task is missing or unrecognised, the model picks the closer lane and says which in the reply's task; always send it, and check the reply's task against it.

Worked example: fix

A three-card launch board with four findings from the page: the Brief card's color is the number 4, one edge has fromSide "Right", the Design-to-plan edge points at an id that matches no node (two characters swapped), and the Design card holds a literal backslash-n (a warning). The file is small, so it is sent verbatim. This is the exact body the page sends:

{
 "task": "fix",
 "canvas": "{\n \"nodes\": [\n  {\"id\":\"a1c9e0f2b7d34e51\",\"type\":\"text\",\"x\":-380,\"y\":-120,\"width\":260,\"height\":120,\"text\":\"## Brief\\nShip the pricing page by Oct 20.\",\"color\":4},\n  {\"id\":\"b2d8f1e3c6a45f62\",\"type\":\"text\",\"x\":-60,\"y\":-120,\"width\":260,\"height\":120,\"text\":\"## Design\\nOwner: Mia\\\\nReview on Tuesday\"},\n  {\"id\":\"c3e7a2f4d5b56a73\",\"type\":\"file\",\"x\":260,\"y\":-120,\"width\":300,\"height\":200,\"file\":\"Launch/Pricing plan.md\",\"subpath\":\"#Goals\"}\n ],\n \"edges\": [\n  {\"id\":\"f6b4d5c7a2e89da6\",\"fromNode\":\"a1c9e0f2b7d34e51\",\"fromSide\":\"Right\",\"toNode\":\"b2d8f1e3c6a45f62\",\"toSide\":\"left\"},\n  {\"id\":\"07c3e6d8b1f9aeb7\",\"fromNode\":\"b2d8f1e3c6a45f62\",\"fromSide\":\"right\",\"toNode\":\"c3e7a2f4d5b56a37\",\"toSide\":\"left\"}\n ]\n}\n",
 "facts": "{\"checker\":\"Board File Fixer (strict JSON reader, JSON Canvas 1.0 rules, the json-canvas skill's checklist, layout checks)\",\"status\":\"broken\",\"file_name\":\"Pricing launch.canvas\",\"findings\":[{\"id\":\"F1\",\"severity\":\"error\",\"source\":\"spec\",\"code\":\"color-type\",\"where\":\"nodes[0] (a1c9e0f2b7d34e51)\",\"line\":3,\"text\":\"color is the number 4; canvasColor must be a string - write \\\"4\\\".\",\"quote\":\"4\",\"refs\":[\"a1c9e0f2b7d34e51\"]},{\"id\":\"F2\",\"severity\":\"error\",\"source\":\"spec\",\"code\":\"side-value\",\"where\":\"edges[0] (f6b4d5c7a2e89da6)\",\"line\":8,\"text\":\"fromSide \\\"Right\\\" must be top, right, bottom or left (lowercase).\",\"quote\":\"Right\",\"refs\":[\"f6b4d5c7a2e89da6\"]},{\"id\":\"F3\",\"severity\":\"error\",\"source\":\"spec\",\"code\":\"edge-dangling\",\"where\":\"edges[1] (07c3e6d8b1f9aeb7)\",\"line\":9,\"text\":\"toNode \\\"c3e7a2f4d5b56a37\\\" does not match any node id, so the edge has nothing to attach to.\",\"quote\":\"c3e7a2f4d5b56a37\",\"refs\":[\"07c3e6d8b1f9aeb7\"]},{\"id\":\"F4\",\"severity\":\"warning\",\"source\":\"skill\",\"code\":\"text-literal-newline\",\"where\":\"nodes[1] (b2d8f1e3c6a45f62)\",\"line\":4,\"text\":\"The text contains a literal backslash followed by n. Per the skill, Obsidian shows that as the two characters \\\\ and n, not a line break - in the file, a line break is written \\\\n, not \\\\\\\\n (unless you meant to show a backslash).\",\"quote\":\"## Design\\nOwner: Mia\\\\nReview on Tuesday\",\"refs\":[\"b2d8f1e3c6a45f62\"]}],\"findings_total\":4,\"stats\":{\"nodes\":3,\"edges\":2,\"edges_resolving\":1,\"by_type\":{\"text\":2,\"file\":1,\"link\":0,\"group\":0,\"other\":0},\"groups\":[],\"isolated_nodes\":[\"c3e7a2f4d5b56a73\"],\"connected_parts\":2,\"bounds\":{\"x\":-380,\"y\":-120,\"width\":940,\"height\":200}},\"canvas_sent\":{\"mode\":\"verbatim\",\"original_chars\":697,\"note\":\"the file exactly as the user has it\"}}",
 "symptom": "The arrow from Design to the pricing plan is missing."
}

A reply in the lane's shape (illustrative; the page re-checked this fixed_canvas: all four findings answered, no errors, no warnings):

{
 "task": "fix",
 "headline": "The Design-to-plan edge pointed at an id with two characters swapped, the Brief colour was a number, one side was capitalised and the Design card held a literal backslash-n; the fixed canvas connects all three cards.",
 "verdict": "broken",
 "finding_responses": [
  {"ref": "F1", "stance": "fixed", "note": "color on node a1c9e0f2b7d34e51 is now the string \"4\" (green)."},
  {"ref": "F2", "stance": "fixed", "note": "fromSide on edge f6b4d5c7a2e89da6 is now lowercase right."},
  {"ref": "F3", "stance": "fixed", "note": "toNode c3e7a2f4d5b56a37 differs from the file node c3e7a2f4d5b56a73 only by two swapped characters, so the edge now points there."},
  {"ref": "F4", "stance": "fixed", "note": "The backslash-n in node b2d8f1e3c6a45f62 is now a real line break."}
 ],
 "changes": [
  {"where": "node a1c9e0f2b7d34e51", "what": "color 4 -> \"4\"", "why": "canvasColor is a string."},
  {"where": "edge f6b4d5c7a2e89da6", "what": "fromSide \"Right\" -> \"right\"", "why": "Sides are lowercase."},
  {"where": "edge 07c3e6d8b1f9aeb7", "what": "toNode \"c3e7a2f4d5b56a37\" -> \"c3e7a2f4d5b56a73\"", "why": "The old id matches no node; this is the pricing plan file node."},
  {"where": "node b2d8f1e3c6a45f62", "what": "text \"Owner: Mia\\\\nReview on Tuesday\" -> a line break between the two lines", "why": "Obsidian shows a literal backslash-n as two characters."}
 ],
 "fixed_canvas": {
  "nodes": [
   {"id": "a1c9e0f2b7d34e51", "type": "text", "x": -380, "y": -120, "width": 260, "height": 120, "text": "## Brief\nShip the pricing page by Oct 20.", "color": "4"},
   {"id": "b2d8f1e3c6a45f62", "type": "text", "x": -60, "y": -120, "width": 260, "height": 120, "text": "## Design\nOwner: Mia\nReview on Tuesday"},
   {"id": "c3e7a2f4d5b56a73", "type": "file", "x": 260, "y": -120, "width": 300, "height": 200, "file": "Launch/Pricing plan.md", "subpath": "#Goals"}
  ],
  "edges": [
   {"id": "f6b4d5c7a2e89da6", "fromNode": "a1c9e0f2b7d34e51", "fromSide": "right", "toNode": "b2d8f1e3c6a45f62", "toSide": "left"},
   {"id": "07c3e6d8b1f9aeb7", "fromNode": "b2d8f1e3c6a45f62", "fromSide": "right", "toNode": "c3e7a2f4d5b56a73", "toSide": "left"}
  ]
 },
 "to_check": [
  "That Launch/Pricing plan.md exists in your vault and has a Goals heading."
 ]
}

verdict describes the original file: broken when it is not valid JSON or any real finding is an error, fix_first when only warnings are real, ok when nothing needs to change. Each change's where names a node or edge id ("node <id>", "edge <id>") or "file" for a JSON syntax repair, and every node or edge the fix removes, renames or alters appears in a change.

Worked example: build

A mind map from an outline (the page's own build example). facts.request_items holds the outline lines the page found in the request (list items and headings, Markdown emphasis stripped); each one must end up on the canvas. Nothing is being extended, so there is no canvas field:

{
 "task": "build",
 "request": "A mind map of my thesis plan, with the title in the middle:\n\n# Thesis plan\n- Research question\n  - Which datasets to use\n  - Baseline models\n- Literature review\n- Experiments\n  - Pilot study in November\n  - Main study in February\n- Writing\n  - Draft chapters\n  - Defense slides\n\nColour the Experiments branch green because it is the riskiest part.",
 "facts": "{\"file_name\":\"Thesis plan.canvas\",\"request_items\":[\"Thesis plan\",\"Research question\",\"Which datasets to use\",\"Baseline models\",\"Literature review\",\"Experiments\",\"Pilot study in November\",\"Main study in February\",\"Writing\",\"Draft chapters\",\"Defense slides\"]}"
}

A reply in the lane's shape (illustrative; the page re-checked this canvas: 11 nodes, 10 edges, no errors or warnings, and all 11 outline items on it):

{
 "task": "build",
 "headline": "A mind map with Thesis plan in the middle, four branches around it and each sub-item one step further out; the Experiments branch is green.",
 "canvas": {
  "nodes": [
   {"id": "0a1b2c3d4e5f6071", "type": "text", "x": -130, "y": -40, "width": 260, "height": 80, "text": "# Thesis plan"},
   {"id": "1b2c3d4e5f607182", "type": "text", "x": -520, "y": -240, "width": 260, "height": 80, "text": "Research question"},
   {"id": "2c3d4e5f60718293", "type": "text", "x": -900, "y": -340, "width": 260, "height": 80, "text": "Which datasets to use"},
   {"id": "3d4e5f60718293a4", "type": "text", "x": -900, "y": -200, "width": 260, "height": 80, "text": "Baseline models"},
   {"id": "4e5f60718293a4b5", "type": "text", "x": -520, "y": 160, "width": 260, "height": 80, "text": "Literature review"},
   {"id": "5f60718293a4b5c6", "type": "text", "x": 260, "y": -240, "width": 260, "height": 80, "text": "Experiments", "color": "4"},
   {"id": "60718293a4b5c6d7", "type": "text", "x": 640, "y": -340, "width": 260, "height": 80, "text": "Pilot study in November", "color": "4"},
   {"id": "718293a4b5c6d7e8", "type": "text", "x": 640, "y": -200, "width": 260, "height": 80, "text": "Main study in February", "color": "4"},
   {"id": "8293a4b5c6d7e8f9", "type": "text", "x": 260, "y": 160, "width": 260, "height": 80, "text": "Writing"},
   {"id": "93a4b5c6d7e8f90a", "type": "text", "x": 640, "y": 100, "width": 260, "height": 80, "text": "Draft chapters"},
   {"id": "a4b5c6d7e8f90a1b", "type": "text", "x": 640, "y": 240, "width": 260, "height": 80, "text": "Defense slides"}
  ],
  "edges": [
   {"id": "e0f1e2d3c4b5a697", "fromNode": "0a1b2c3d4e5f6071", "fromSide": "left", "toNode": "1b2c3d4e5f607182", "toSide": "right"},
   {"id": "e1f2e3d4c5b6a798", "fromNode": "1b2c3d4e5f607182", "fromSide": "left", "toNode": "2c3d4e5f60718293", "toSide": "right"},
   {"id": "e2f3e4d5c6b7a8a9", "fromNode": "1b2c3d4e5f607182", "fromSide": "left", "toNode": "3d4e5f60718293a4", "toSide": "right"},
   {"id": "e3f4e5d6c7b8a9ba", "fromNode": "0a1b2c3d4e5f6071", "fromSide": "left", "toNode": "4e5f60718293a4b5", "toSide": "right"},
   {"id": "e4f5e6d7c8b9aacb", "fromNode": "0a1b2c3d4e5f6071", "fromSide": "right", "toNode": "5f60718293a4b5c6", "toSide": "left"},
   {"id": "e5f6e7d8c9babbdc", "fromNode": "5f60718293a4b5c6", "fromSide": "right", "toNode": "60718293a4b5c6d7", "toSide": "left"},
   {"id": "e6f7e8d9cabacced", "fromNode": "5f60718293a4b5c6", "fromSide": "right", "toNode": "718293a4b5c6d7e8", "toSide": "left"},
   {"id": "e7f8e9dabbcbddfe", "fromNode": "0a1b2c3d4e5f6071", "fromSide": "right", "toNode": "8293a4b5c6d7e8f9", "toSide": "left"},
   {"id": "e8f9eadbccdceef0", "fromNode": "8293a4b5c6d7e8f9", "fromSide": "right", "toNode": "93a4b5c6d7e8f90a", "toSide": "left"},
   {"id": "af0bfcedeef0f012", "fromNode": "8293a4b5c6d7e8f9", "fromSide": "right", "toNode": "a4b5c6d7e8f90a1b", "toSide": "left"}
  ]
 },
 "explanation": [
  {"part": "nodes", "text": "One card per outline item, 11 in all, each carrying the item's words."},
  {"part": "edges", "text": "Thesis plan connects to its four branches; each branch connects to its own sub-items."},
  {"part": "layout", "text": "Research question and Literature review sit left of the title, Experiments and Writing right, with sub-items in an outer column."},
  {"part": "colors", "text": "Green (\"4\") marks the Experiments branch and its two studies, the riskiest part."}
 ],
 "assumptions": [
  "Literature review has no sub-items in the outline, so it has none on the canvas."
 ],
 "to_check": [
  "That the branch order around the title reads the way you want."
 ]
}

Every id is 16 lowercase hex characters and unique across nodes and edges together. A file node may only point at a path the input gives and a link node only at a URL the input gives; any other path or URL on the canvas should be listed in assumptions.

Input fields

Every field is a string; facts is JSON text, not an object.

fieldtyperequiredmeaning
taskstringyes"fix" or "build".
factsstringyesWhat the page's checker established, as JSON text (below). "{}" is accepted.
canvasstringfix; optional for buildThe .canvas file as text, which may be invalid JSON in the fix lane. Build: an existing canvas to extend instead of starting over (it must parse). The page sends a file of up to 36,000 characters verbatim; a larger file that parses is re-serialised without whitespace and, if still too large, its long text nodes are shortened with a [...Board File Fixer cut marker (recorded in canvas_sent). A larger file that does not parse is not sent.
symptomstringnoFix lane: what you saw in Obsidian (a missing arrow, an empty canvas). At most 2,000 characters in the page. Addressed in headline or a change.
requeststringbuildWhat the canvas should show: an outline, a list of steps, or a description of the board. The page asks for at least 15 characters and sends at most 6,000.
retry_notestringnoOnly on a reformat retry, when a reply was not one JSON object.

The facts string

Fix lane (from the page's fixFacts):

{"checker": "Board File Fixer (strict JSON reader, JSON Canvas 1.0 rules, the json-canvas skill's checklist, layout checks)",
 "status": "broken|fix_first|ok",
 "file_name": "Pricing launch.canvas",
 "findings": [{"id": "F1", "severity": "error|warning|info", "source": "json|spec|skill|layout",
               "code": "edge-dangling", "where": "edges[1] (07c3e6d8b1f9aeb7)", "line": 9,
               "text": "...", "quote": "...", "refs": ["07c3e6d8b1f9aeb7"]}],
 "findings_total": 4,
 "stats": {"nodes": 3, "edges": 2, "edges_resolving": 1,
           "by_type": {"text": 2, "file": 1, "link": 0, "group": 0, "other": 0},
           "groups": [{"id": "...", "label": "...", "direct_children": 0}],
           "isolated_nodes": ["c3e7a2f4d5b56a73"], "connected_parts": 2,
           "bounds": {"x": -380, "y": -120, "width": 940, "height": 200}} | null,
 "canvas_sent": {"mode": "verbatim|compact|shortened", "original_chars": 697, "note": "..."}}

findings holds at most the first 60 (findings_total is the full count), sorted errors first, then warnings, then info, and by line; ids run F1, F2, ... in that order, and the reply answers each exactly once, in id order. where is nodes[i] (id), edges[i] (id), file, nodes or a top-level key; line is the line in the user's original file, or null; quote is the offending value; refs lists the node or edge ids involved.

source: json (the strict reader: the first syntax error, a byte-order mark, duplicate keys), spec (JSON Canvas 1.0: ids, types, geometry, colours, required fields per node type, subpaths, edge ends, sides and end markers), skill (the skill's conventions: a literal backslash-n in text, an id shared by a node and an edge, ids that are not 16-character hex) and layout (overlapping nodes, nodes half inside a group, groups listed after their cards, zero-size nodes). A json or spec error is treated as true unless the file proves otherwise; layout findings are judgement calls. When the file does not parse, there is one json error (the first one) and stats is null: the model is told to read the whole file and repair every syntax problem.

canvas_sent.mode is verbatim (the file as the user has it), compact (re-serialised without whitespace; line numbers still refer to the original) or shortened (compact, and long text nodes cut with the [...Board File Fixer cut marker; the model is told to leave those texts unchanged, and the page puts the full text back into any canvas it returns). groups and isolated_nodes list at most 40 each.

Build lane (from the page's buildFacts):

{"file_name": "Thesis plan.canvas",
 "request_items": ["Thesis plan", "Research question", "..."],
 "current_canvas": {"status": "broken|fix_first|ok", "errors": 0, "warnings": 0,
                    "stats": {...}, "canvas_sent": {...}}}

request_items (at most 80) are the request's list lines (-, *, +, 1., with or without a [ ] checkbox) and Markdown headings, with **, __ and backticks removed; it is [] for a request written as prose. current_canvas is present only when a canvas is being extended, and then the input also carries that canvas as canvas.

You can build facts yourself. If you have not run the browser checker, send "{}": the model then works from the canvas (or the request) alone and has no findings to answer, and a build has no outline items to cover. The page's checks are what make a fix accountable, so for the full picture paste the file into the page; after a run, Download .json saves the exact facts it sent as facts_sent.

The output

One JSON object, serialised as a string at data.output.output. Parse it and check that task is the lane you sent.

fix:
{"task": "fix", "headline": "", "verdict": "broken|fix_first|ok",
 "finding_responses": [{"ref": "F1", "stance": "fixed|not_a_problem|needs_you", "note": ""}],
 "changes": [{"where": "node <id>|edge <id>|file", "what": "", "why": ""}],
 "fixed_canvas": {"nodes": [], "edges": []} | null,
 "to_check": [""]}

build:
{"task": "build", "headline": "",
 "canvas": {"nodes": [], "edges": []},
 "explanation": [{"part": "groups|nodes|edges|layout|colors", "text": ""}],
 "assumptions": [""], "to_check": [""]}

stance: fixed (fixed_canvas resolves it), not_a_problem (the finding does not apply, with the reason), needs_you (it depends on your vault or intent: which node an edge was meant to reach, whether a file path exists). An info finding is fixed if the canvas changed for it, otherwise not_a_problem. A dangling edge is repaired only when one node is clearly meant; otherwise the edge is removed and the finding answered needs_you. Every canvas in a reply is complete, not a diff, and is a JSON object rather than a string (the page also accepts a string of JSON, so a robust client parses a string if it gets one).

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}

The token is minted for this app (the guest endpoint takes {"slug":"boardfile-fixer"} in its body), so there is no slug header afterwards. Send it as Authorization: Bearer ....

The input object IS the request body. There is no {"input": ...} wrapper: a wrapped body reaches the model as an object with an input key and no task, so it cannot route the request. Note that /estimate does not validate the body at all - a clean estimate does not prove the input is right.

Error codes

statuscodewhat to do
400validation_errorThe request was rejected as malformed. Send a JSON object; keep every field a string (facts is a JSON-encoded string, not an object, and canvas is the file as text), which is the shape the app itself sends.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe token is valid but not for this app, or a guest token tried a metered run the app does not sponsor. Sign in for a personal token.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that sends the token, unwraps data and raises on ok: false. The token comes from the token page (Copy token or Copy shell export); step 2 covers the kinds of token and minting one from code. In every language the steps below continue the same script: Go and Java put steps 2 to 7 inside main, in order.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="boardfile-fixer"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # from https://boardfile-fixer.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

2. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. A guest token, minted with POST /guest and {"slug":"boardfile-fixer"}, can call /me and /estimate; both lanes are metered, so /run and /run-stream need a personal token unless /estimate reports sponsor_enabled: true.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://boardfile-fixer.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" -d '{"slug":"boardfile-fixer"}'
# {"ok":true,"data":{"token":"...","guest_id":"..."}}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","credits":...}}
# subject_type is "user" for a personal token and "guest" for a guest token.

4. Price the run (free)

/estimate returns the credits a run with this input would reserve (its worst case). It creates no job and charges nothing. Board File Fixer runs on the gpt-terra model alias. hold_credits is a reservation, not the price. min_credits is the least balance that can start a run; with a balance between min_credits and hold_credits the run starts but the reply may be cut short (truncated: true). What you pay is charged_credits, reported on the finished job, usually far lower. The body is the input object itself, with no {"input": ...} wrapper. /estimate does no input validation, so check the shape yourself: an object whose every value is a string, task equal to fix or build, a facts that is a JSON string parsing to an object, and a non-empty canvas (fix) or request (build). Save one of the worked examples above as body.json to try it.

# body.json is the input object itself - no {"input": ...} wrapper. estimate does
# not validate it, so check the shape first (jq 1.6+):
jq -e '
  type == "object"
  and (.task == "fix" or .task == "build")
  and ([.[] | type == "string"] | all)
  and ((.facts | fromjson | type) == "object")
  and ((if .task == "fix" then .canvas else .request end) // "" | test("\\S"))
' body.json > /dev/null || { echo "body.json is not a valid Board File Fixer input" >&2; exit 1; }
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"hold_credits":...,"min_credits":...,"sponsor_enabled":false, ...}}
# hold_credits is RESERVED, not the price; charged_credits after the run is the cost.

5. Run it, then poll

POST /run returns a job_id; poll GET /jobs/{id} until it is terminal. The reply is a string at data.output.output: parse it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the attempt number, boardfile-fixer:<lane>:<hash>:a<attempt>, so a retried request returns the same job instead of billing a second run. Use one key per distinct input: an edited canvas, facts, symptom or request is a new hash, and replaying an old key with a different body is a 409. Any stable digest of the body works. Leave retry_note out of the hash and bump the attempt instead.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
LANE=$(printf '%s' "$INPUT" | jq -r .task)   # fix or build
KEY="boardfile-fixer:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | jq -r .data.job_id)

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | jq -r .data.status)
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"task\":\"fix\",\"headline\":\"...\",\"verdict\":\"broken\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | jq -r .data.output.output > reply.txt   # the reply, as text

6. Or stream it

POST /run-stream takes the same body and headers as /run (use it instead of step 5, not after it with the same key; build the key as step 5 does) and answers with server-sent events: job (the job id), delta (chunks of the reply), done (the status, charged_credits, truncated and, when present, the full output) and error (code, message, job_id) when the job fails. The app's SDK also accepts a pending event in place of done. A browser page may receive only tick heartbeats and then done, never a delta, so take the reply from done.output.output when it is there, fall back to the concatenated deltas, and fall back again to GET /jobs/{id} (the id from the job event).

Two details the samples handle: the event: and data: prefixes may or may not be followed by a space, so strip the prefix and trim; and a request that replays an earlier Idempotency-Key may come back as an ordinary JSON envelope instead of an event stream (check Content-Type), with the job in data and the reply at data.output.output.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag; `error` means the job failed.
# Ignore `tick` heartbeats. A replayed key may answer with a plain JSON envelope.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"task\":\"fix\",\"headline\":\"The Design-to-plan edge"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply, and re-check the canvas

The reply is one JSON object serialised as a string (text from step 5, or streamed from step 6). The app takes the first balanced JSON object in that text: skip to the first {, count braces while skipping over string literals (and the escapes inside them), and parse up to the brace that closes it, so a stray code fence or a line of prose around the object does not break parsing. Then check that task is the lane you asked for.

The page re-checks every reply before showing it; from the API, re-check it yourself:

An empty (null) fixed_canvas means nothing needed to change. If the job reports truncated: true, the reply was cut short (the page repairs it only to show the sections that arrived); treat any canvas in it as incomplete. For the full check (layout, overlaps, the skill's conventions) and a drawing, paste the canvas into the page. The Python and JavaScript tabs check the whole list above; the cURL tab (jq) checks finding coverage, ids, edges, node types, required fields and integer geometry; the Go, Java, Ruby, PHP and C# tabs check finding coverage, unique ids and edges that resolve.

# The reply should be one bare JSON object (the app's house rule). jq needs exactly
# that; if the model wrapped it in a code fence or prose, use the slicer in the Python tab.
jq . reply.txt > reply.json || { echo "not bare JSON - use the Python slicer" >&2; exit 1; }
LANE=$(jq -r .task body.json)
jq -e --arg lane "$LANE" '.task == $lane' reply.json > /dev/null || echo "the model answered as the other lane"

# Fix: every finding answered exactly once, no unknown refs, recognised stances.
[ "$LANE" = fix ] && jq -r --argjson facts "$(jq -r .facts body.json)" '
  [($facts.findings // [])[].id] as $want
  | [.finding_responses[]?.ref] as $got
  | ($want[] | . as $id | select(([$got[] | select(. == $id)] | length) != 1) | "not answered exactly once: \(.)"),
    ($got - $want | .[] | "answers an unknown finding: \(.)"),
    (.finding_responses[]? | select(.stance | IN("fixed", "not_a_problem", "needs_you") | not) | "no recognised stance: \(.ref)")
' reply.json

# The canvas (a fix may return null: nothing needed to change).
jq 'if .task == "fix" then .fixed_canvas else .canvas end
    | if type == "string" then fromjson else . end
    | select(. != null)' reply.json > out.canvas

# JSON Canvas 1.0 basics: unique ids, edges that resolve, required fields.
jq -r '
  (.nodes // []) as $n | (.edges // []) as $e | [$n[].id] as $ids
  | ([$ids[], $e[].id] | group_by(.)[] | select(length > 1) | "duplicate id: \(.[0])"),
    ($e[] | select(((.fromNode | IN($ids[])) and (.toNode | IN($ids[]))) | not) | "edge \(.id) does not resolve"),
    ($n[] | select(.type | IN("text", "file", "link", "group") | not) | "node \(.id): bad type"),
    ($n[] | select(.type == "text" and (.text | type) != "string") | "node \(.id): a text node needs text"),
    ($n[] | select(.type == "file" and (.file | type) != "string") | "node \(.id): a file node needs file"),
    ($n[] | select(.type == "link" and (.url | type) != "string") | "node \(.id): a link node needs url"),
    ($n[] | select([.x, .y, .width, .height] | map(type == "number" and . == floor) | all | not)
          | "node \(.id): x, y, width and height must be integers")
' out.canvas
# Paste out.canvas into https://boardfile-fixer.skillsafe.ai/ for the full check and a drawing.

Costs

Invariants worth asserting