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
| task | send | you get back |
|---|---|---|
fix | canvas, facts; symptom optional | headline, 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. |
build | request, 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.
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | "fix" or "build". |
facts | string | yes | What the page's checker established, as JSON text (below). "{}" is accepted. |
canvas | string | fix; optional for build | The .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. |
symptom | string | no | Fix 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. |
request | string | build | What 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_note | string | no | Only 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
| status | code | what to do |
|---|---|---|
| 400 | validation_error | The 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. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The 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. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A 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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "boardfile-fixer"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://boardfile-fixer.skillsafe.ai/tokens.html
def call(path, body=None, key=None):
"""Returns the unwrapped `data`, or raises with the API error code.
`key`, when given, is sent as the Idempotency-Key header."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
if key:
req.add_header("Idempotency-Key", key)
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
// Node 18+, saved as an ES module (client.mjs) so top-level await works.
import { readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "boardfile-fixer";
// Paste the token from https://boardfile-fixer.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
// Returns the unwrapped `data`, or throws with the API error code.
// `key`, when given, is sent as the Idempotency-Key header.
async function call(path, body, key) {
const headers = { Authorization: `Bearer ${TOKEN}` };
if (body) headers["Content-Type"] = "application/json";
if (key) headers["Idempotency-Key"] = key;
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "boardfile-fixer"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://boardfile-fixer.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// call returns the unwrapped data. A non-empty key is sent as the Idempotency-Key header.
func call(path string, body any, key string) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, base+"/"+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
// must turns a call result into a map, or stops the program.
func must(raw json.RawMessage, err error) map[string]any {
if err != nil {
panic(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
panic(err)
}
return m
}
func main() {
// steps 2 to 7 go here, in order
}
// Java 17+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON:
// the JDK has no JSON parser of its own.
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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Iterator;
import java.util.Map;
public class BoardFileFixer {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "boardfile-fixer";
static String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
// Returns the unwrapped data, or throws with the API error code. key may be null.
static JsonNode call(String path, String jsonBody, String key) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (key != null) b.header("Idempotency-Key", key);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
JsonNode env = JSON.readTree(res.body());
if (!env.path("ok").asBoolean()) {
JsonNode e = env.path("error");
throw new RuntimeException(e.path("code").asText() + ": " + e.path("message").asText());
}
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2 to 7 go here, in order
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "boardfile-fixer"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://boardfile-fixer.skillsafe.ai/tokens.html
# Returns the unwrapped data, or raises with the API error code.
# key, when given, is sent as the Idempotency-Key header.
def call(path, body = nil, key = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{$token}"
req["Idempotency-Key"] = key if key
if body
req["Content-Type"] = "application/json"
req.body = body.to_json
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
// PHP 8+. The later steps continue this file.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "boardfile-fixer";
$GLOBALS["token"] = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // from /tokens.html
// Returns the unwrapped data, or throws with the API error code.
// $key, when given, is sent as the Idempotency-Key header.
function call(string $path, ?array $body = null, ?string $key = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . $GLOBALS["token"]];
if ($key !== null) {
$headers[] = "Idempotency-Key: " . $key;
}
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
// Program.cs (.NET 6+, top-level statements). The later steps continue this file.
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "boardfile-fixer";
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient();
// Returns the unwrapped data, or throws with the API error code.
// json is the request body as JSON text; key, when given, is the Idempotency-Key.
async Task<JsonElement> Call(string path, string? json = null, string? key = null)
{
var req = new HttpRequestMessage(json == null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {token}");
if (key != null) req.Headers.Add("Idempotency-Key", key);
if (json != null) req.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var res = await http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var payload = doc.RootElement;
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data").Clone();
}
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":"..."}}
# Open https://boardfile-fixer.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
if TOKEN == "YOUR_TOKEN":
req = urllib.request.Request(f"{BASE}/guest", data=json.dumps({"slug": SLUG}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://boardfile-fixer.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if (TOKEN === "YOUR_TOKEN") {
const guestRes = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG }),
});
TOKEN = (await guestRes.json()).data.token;
}
// Open https://boardfile-fixer.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if token == "" {
gres, err := http.Post(base+"/guest", "application/json",
strings.NewReader(`{"slug":"`+slug+`"}`))
if err != nil {
panic(err)
}
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(gres.Body).Decode(&guest)
gres.Body.Close()
token = guest.Data.Token
}
// Open https://boardfile-fixer.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if (TOKEN.equals("YOUR_TOKEN")) {
HttpRequest guestReq = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"" + SLUG + "\"}"))
.build();
String guest = HTTP.send(guestReq, HttpResponse.BodyHandlers.ofString()).body();
TOKEN = JSON.readTree(guest).path("data").path("token").asText();
}
# Open https://boardfile-fixer.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
if $token == "YOUR_TOKEN"
uri = URI("#{BASE}/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: SLUG }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
$token = JSON.parse(res.body)["data"]["token"]
end
// Open https://boardfile-fixer.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if ($GLOBALS["token"] === "YOUR_TOKEN") {
$ch = curl_init(BASE . "/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => SLUG]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
$GLOBALS["token"] = $guest["data"]["token"];
}
// Open https://boardfile-fixer.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
if (token == "YOUR_TOKEN")
{
var guestReq = new HttpRequestMessage(HttpMethod.Post, $"{Base}/guest");
guestReq.Content = new StringContent("{\"slug\":\"" + Slug + "\"}", Encoding.UTF8, "application/json");
using var guestRes = await http.SendAsync(guestReq);
using var guestDoc = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync());
token = guestDoc.RootElement.GetProperty("data").GetProperty("token").GetString()!;
}
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.
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
me := must(call("me", nil, ""))
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = call("me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("credits"));
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
$me = call("me");
echo $me["subject_type"], " ", $me["credits"] ?? "-", PHP_EOL;
var me = await Call("me");
var credits = me.TryGetProperty("credits", out var c) ? c.ToString() : "-";
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits}");
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.
with open("body.json") as fh:
INPUT = json.load(fh) # the input object itself, no wrapper
assert isinstance(INPUT, dict) and INPUT.get("task") in ("fix", "build"), "task must be fix or build"
assert all(isinstance(v, str) for v in INPUT.values()), "every field is a string"
assert isinstance(json.loads(INPUT["facts"]), dict), "facts is a JSON string of an object"
need = "canvas" if INPUT["task"] == "fix" else "request"
assert INPUT.get(need, "").strip(), f"{INPUT['task']} needs {need}"
est = call("estimate", INPUT)
print("reserves", est["hold_credits"], "credits (not the price); minimum balance", est.get("min_credits"))
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // no {input: ...} wrapper
if (!INPUT || typeof INPUT !== "object" || !["fix", "build"].includes(INPUT.task)) throw new Error("task must be fix or build");
if (!Object.values(INPUT).every((v) => typeof v === "string")) throw new Error("every field is a string");
const facts = JSON.parse(INPUT.facts);
if (!facts || typeof facts !== "object" || Array.isArray(facts)) throw new Error("facts is a JSON string of an object");
const need = INPUT.task === "fix" ? "canvas" : "request";
if (!(INPUT[need] || "").trim()) throw new Error(`${INPUT.task} needs ${need}`);
const est = await call("estimate", INPUT);
console.log("reserves", est.hold_credits, "credits (not the price); minimum balance", est.min_credits);
raw, err := os.ReadFile("body.json")
if err != nil {
panic(err)
}
var input map[string]string // every field is a string; Unmarshal fails otherwise
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
lane := input["task"]
if lane != "fix" && lane != "build" {
panic("task must be fix or build")
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil || facts == nil {
panic("facts must be a JSON string of an object")
}
need := "request"
if lane == "fix" {
need = "canvas"
}
if strings.TrimSpace(input[need]) == "" {
panic(lane + " needs " + need)
}
est := must(call("estimate", input, ""))
fmt.Println("reserves", est["hold_credits"], "credits (not the price); minimum balance", est["min_credits"])
String input = Files.readString(Path.of("body.json")); // the input object itself, sent as is
JsonNode in = JSON.readTree(input);
String lane = in.path("task").asText();
if (!in.isObject() || !(lane.equals("fix") || lane.equals("build")))
throw new IllegalStateException("task must be fix or build");
for (Iterator<Map.Entry<String, JsonNode>> it = in.fields(); it.hasNext(); )
if (!it.next().getValue().isTextual()) throw new IllegalStateException("every field is a string");
if (!JSON.readTree(in.path("facts").asText()).isObject())
throw new IllegalStateException("facts is a JSON string of an object");
String need = lane.equals("fix") ? "canvas" : "request";
if (in.path(need).asText().isBlank()) throw new IllegalStateException(lane + " needs " + need);
JsonNode est = call("estimate", input, null);
System.out.println("reserves " + est.path("hold_credits")
+ " credits (not the price)");
INPUT = JSON.parse(File.read("body.json")) # no {"input": ...} wrapper
raise "task must be fix or build" unless INPUT.is_a?(Hash) && %w[fix build].include?(INPUT["task"])
raise "every field is a string" unless INPUT.values.all? { |v| v.is_a?(String) }
raise "facts is a JSON string of an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
need = INPUT["task"] == "fix" ? "canvas" : "request"
raise "#{INPUT['task']} needs #{need}" if INPUT[need].to_s.strip.empty?
est = call("estimate", INPUT)
puts "reserves #{est['hold_credits']} credits (not the price); minimum balance #{est['min_credits']}"
$input = json_decode(file_get_contents("body.json"), true); // no {"input": ...} wrapper
if (!is_array($input) || !in_array($input["task"] ?? "", ["fix", "build"], true)) { throw new Exception("task must be fix or build"); }
foreach ($input as $v) { if (!is_string($v)) { throw new Exception("every field is a string"); } }
if (!is_object(json_decode($input["facts"] ?? ""))) { throw new Exception("facts is a JSON string of an object"); }
$need = $input["task"] === "fix" ? "canvas" : "request";
if (trim($input[$need] ?? "") === "") { throw new Exception($input["task"] . " needs " . $need); }
$est = call("estimate", $input);
echo "reserves ", $est["hold_credits"], " credits (not the price); minimum balance ", $est["min_credits"] ?? "-", "\n";
var input = File.ReadAllText("body.json"); // the input object itself, sent as is
using var inputDoc = JsonDocument.Parse(input);
var root = inputDoc.RootElement;
if (root.ValueKind != JsonValueKind.Object) throw new Exception("body.json must be an object");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception("every field is a string");
var lane = root.TryGetProperty("task", out var t) ? t.GetString() : null;
if (lane != "fix" && lane != "build") throw new Exception("task must be fix or build");
using (var factsDoc = JsonDocument.Parse(root.GetProperty("facts").GetString()!))
if (factsDoc.RootElement.ValueKind != JsonValueKind.Object) throw new Exception("facts is a JSON string of an object");
var need = lane == "fix" ? "canvas" : "request";
if (!root.TryGetProperty(need, out var nv) || string.IsNullOrWhiteSpace(nv.GetString()))
throw new Exception($"{lane} needs {need}");
var est = await Call("estimate", input);
Console.WriteLine($"reserves {est.GetProperty("hold_credits")} credits (not the price)");
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
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"boardfile-fixer:{INPUT['task']}:{digest}:a1"
job_id = call("run", INPUT, key)["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `boardfile-fixer:${INPUT.task}:${digest}:a1`;
const jobId = (await call("run", INPUT, key)).job_id;
let job;
do {
if (job) await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${jobId}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log("charged", job.charged_credits, "truncated", job.truncated);
body, _ := json.Marshal(input) // Go sorts map keys, so this digest is stable
sum := sha256.Sum256(body)
key := fmt.Sprintf("boardfile-fixer:%s:%x:a1", lane, sum[:8])
jobID := must(call("run", input, key))["job_id"].(string)
var job map[string]any
for {
job = must(call("jobs/"+jobID, nil, ""))
if job["status"] == "succeeded" || job["status"] == "failed" {
break
}
time.Sleep(2 * time.Second)
}
if job["status"] == "failed" {
panic(fmt.Sprint(job["error"]))
}
text := job["output"].(map[string]any)["output"].(string) // the reply, as a string
fmt.Println("charged", job["charged_credits"], "truncated", job["truncated"])
String hash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8))).substring(0, 16);
String key = "boardfile-fixer:" + lane + ":" + hash + ":a1";
String jobId = call("run", input, key).path("job_id").asText();
JsonNode job;
while (true) {
job = call("jobs/" + jobId, null, null);
String status = job.path("status").asText();
if (status.equals("succeeded")) break;
if (status.equals("failed")) throw new RuntimeException(job.toString());
Thread.sleep(2000);
}
String text = job.path("output").path("output").asText(); // the reply, as a string
System.out.println("charged " + job.path("charged_credits") + " truncated " + job.path("truncated"));
require "digest"
key = "boardfile-fixer:#{INPUT['task']}:#{Digest::SHA256.hexdigest(INPUT.to_json)[0, 16]}:a1"
job_id = call("run", INPUT, key)["job_id"]
job = nil
loop do
job = call("jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts "charged #{job['charged_credits']} truncated #{job['truncated']}"
$key = "boardfile-fixer:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$jobId = call("run", $input, $key)["job_id"];
while (true) {
$job = call("jobs/" . $jobId);
if (in_array($job["status"], ["succeeded", "failed"], true)) { break; }
sleep(2);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo "charged ", $job["charged_credits"] ?? "?", PHP_EOL;
var hash = BitConverter.ToString(SHA256.Create().ComputeHash(Encoding.UTF8.GetBytes(input)))
.Replace("-", "").ToLowerInvariant()[..16];
var key = $"boardfile-fixer:{lane}:{hash}:a1";
var started = await Call("run", input, key);
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var text = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
Console.WriteLine($"charged {job.GetProperty("charged_credits")}");
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}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
deltas, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
if "text/event-stream" not in stream.headers.get("Content-Type", ""):
done = json.load(stream)["data"] # an idempotent replay: the job itself
else:
for line in stream:
line = line.decode().rstrip("\r\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:") and event == "delta":
deltas += json.loads(line[5:]).get("text", "")
elif line.startswith("data:") and event in ("done", "pending"):
done = json.loads(line[5:])
elif line.startswith("data:") and event == "error":
raise RuntimeError(line[5:].strip())
streamed = (done.get("output") or {}).get("output") or deltas # browsers may get only ticks + done
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const sres = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
let buf = "", deltas = "", event = null, doneEvent = null;
if (!(sres.headers.get("content-type") || "").includes("text/event-stream")) {
doneEvent = (await sres.json()).data; // an idempotent replay: the job itself
}
const reader = doneEvent ? null : sres.body.getReader();
const dec = new TextDecoder();
while (reader) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).replace(/\r$/, "");
buf = buf.slice(i + 1);
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:") && event === "delta") deltas += JSON.parse(line.slice(5)).text || "";
else if (line.startsWith("data:") && (event === "done" || event === "pending")) doneEvent = JSON.parse(line.slice(5));
else if (line.startsWith("data:") && event === "error") throw new Error(line.slice(5).trim());
}
}
const streamed = doneEvent?.output?.output || deltas; // browsers may get only ticks + done
console.log(doneEvent?.status, streamed.length);
// If the response is not text/event-stream (a replayed key), it is the JSON
// envelope: read data.output.output. An `error` event means the job failed.
sreq, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
sreq.Header.Set("Authorization", "Bearer "+token)
sreq.Header.Set("Content-Type", "application/json")
sreq.Header.Set("Idempotency-Key", key)
sreq.Header.Set("Accept", "text/event-stream")
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
var deltas strings.Builder
event, streamed := "", ""
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 1<<20), 4<<20)
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:") && event == "delta":
var d struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(line[5:]), &d)
deltas.WriteString(d.Text)
case strings.HasPrefix(line, "data:") && event == "done":
var d struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
_ = json.Unmarshal([]byte(line[5:]), &d)
streamed = d.Output.Output
fmt.Println("done:", d.Status)
}
}
if streamed == "" {
streamed = deltas.String() // browsers may get only ticks + done
}
fmt.Println(len(streamed), "characters streamed")
// If the response is not text/event-stream (a replayed key), it is the JSON
// envelope: read data.output.output. An `error` event means the job failed.
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
StringBuilder deltas = new StringBuilder();
String[] event = {""};
String[] fromDone = {""};
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event:")) {
event[0] = line.substring(6).trim();
} else if (line.startsWith("data:")) {
try {
JsonNode d = JSON.readTree(line.substring(5));
if (event[0].equals("delta")) deltas.append(d.path("text").asText());
if (event[0].equals("done")) fromDone[0] = d.path("output").path("output").asText();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
});
String streamed = fromDone[0].isEmpty() ? deltas.toString() : fromDone[0]; // browsers may get only ticks + done
System.out.println(streamed.length() + " characters streamed");
# If the response is not text/event-stream (a replayed key), it is the JSON
# envelope: read data.output.output. An `error` event means the job failed.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{$token}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = INPUT.to_json
buf, deltas, event, streamed = +"", +"", nil, nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n"))
line = buf.slice!(0, i + 1).chomp
if line.start_with?("event:")
event = line[6..-1].strip
elsif line.start_with?("data:") && event == "delta"
deltas << JSON.parse(line[5..-1])["text"].to_s
elsif line.start_with?("data:") && event == "done"
streamed = (JSON.parse(line[5..-1])["output"] || {})["output"]
end
end
end
end
end
streamed = deltas if streamed.to_s.empty? # browsers may get only ticks + done
puts "#{streamed.length} characters streamed"
// If the response is not text/event-stream (a replayed key), it is the JSON
// envelope: read data.output.output. An `error` event means the job failed.
$buf = ""; $deltas = ""; $event = null; $streamed = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $GLOBALS["token"], "Content-Type: application/json",
"Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$deltas, &$event, &$streamed) {
$buf .= $chunk;
while (($i = strpos($buf, "\n")) !== false) {
$line = rtrim(substr($buf, 0, $i), "\r");
$buf = substr($buf, $i + 1);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $event === "delta") {
$deltas .= json_decode(substr($line, 5), true)["text"] ?? "";
} elseif (str_starts_with($line, "data:") && $event === "done") {
$streamed = json_decode(substr($line, 5), true)["output"]["output"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
if ($streamed === "") { $streamed = $deltas; } // browsers may get only ticks + done
echo strlen($streamed), " characters streamed\n";
// If the response is not text/event-stream (a replayed key), it is the JSON
// envelope: read data.output.output. An `error` event means the job failed.
var sreq = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {token}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(input, Encoding.UTF8, "application/json");
using var sres = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var deltas = new StringBuilder();
string? ev = null, line, fromDone = null;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:") && ev == "delta")
{
using var d = JsonDocument.Parse(line[5..]);
if (d.RootElement.TryGetProperty("text", out var tx)) deltas.Append(tx.GetString());
}
else if (line.StartsWith("data:") && ev == "done")
{
using var d = JsonDocument.Parse(line[5..]);
if (d.RootElement.TryGetProperty("output", out var o) && o.ValueKind == JsonValueKind.Object
&& o.TryGetProperty("output", out var oo)) fromDone = oo.GetString();
}
}
var streamed = string.IsNullOrEmpty(fromDone) ? deltas.ToString() : fromDone; // browsers may get only ticks + done
Console.WriteLine($"{streamed.Length} characters streamed");
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:
- Fix: every id in
facts.findingsis answered exactly once infinding_responses, no other id is answered, and everystanceisfixed,not_a_problemorneeds_you. - The canvas (
fixed_canvas, orcanvasfor a build) is valid JSON Canvas 1.0: an object with onlynodesandedges; ids unique across nodes and edges together; every node has a stringid, atypeoftext,file,linkorgroup, integerx,y,width,height, and the field its type requires (text,file,url); every edge'sfromNodeandtoNodeis an existing node id; sides aretop,right,bottomorleft, endsnoneorarrow, colours a string"1"-"6"or#RRGGBB, subpaths start with#. - Build: every
facts.request_itemsentry appears in some node's text.
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.
import re
def first_object(text):
"""The first balanced {...} in the reply; a code fence or prose around it is ignored."""
start = text.find("{")
if start < 0:
raise ValueError("no JSON object in the reply")
depth, in_str, esc = 0, False, False
for i in range(start, len(text)):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
elif c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return json.loads(text[start:i + 1])
raise ValueError("the JSON object in the reply is not closed")
COLOR = re.compile(r"[1-6]|#[0-9a-fA-F]{6}")
NEEDS = {"text": "text", "file": "file", "link": "url"}
def canvas_problems(c):
"""JSON Canvas 1.0 basics: unique ids, required fields, edges that resolve."""
if not isinstance(c, dict):
return ["the canvas is not a JSON object"]
out = [f"top-level key {k!r} is not nodes or edges" for k in c if k not in ("nodes", "edges")]
nodes, edges = c.get("nodes", []), c.get("edges", [])
ids = [x.get("id") for x in nodes + edges]
out += [f"id {i!r} is used more than once" for i in {i for i in ids if ids.count(i) > 1}]
node_ids = {n.get("id") for n in nodes}
for n in nodes:
nid = n.get("id")
if not isinstance(nid, str) or not nid:
out.append(f"a node has no string id: {n}")
if n.get("type") not in ("text", "file", "link", "group"):
out.append(f"node {nid}: type {n.get('type')!r} is not text, file, link or group")
for k in ("x", "y", "width", "height"):
v = n.get(k)
if isinstance(v, bool) or not isinstance(v, (int, float)) or v != int(v):
out.append(f"node {nid}: {k} must be an integer")
need = NEEDS.get(n.get("type"))
if need and not isinstance(n.get(need), str):
out.append(f"node {nid}: a {n['type']} node needs a {need} string")
if "subpath" in n and not str(n["subpath"]).startswith("#"):
out.append(f"node {nid}: subpath must start with #")
for e in edges:
eid = e.get("id")
out += [f"edge {eid}: {k} {e.get(k)!r} matches no node" for k in ("fromNode", "toNode") if e.get(k) not in node_ids]
out += [f"edge {eid}: {k} {e[k]!r} is not a side" for k in ("fromSide", "toSide")
if k in e and e[k] not in ("top", "right", "bottom", "left")]
out += [f"edge {eid}: {k} {e[k]!r} is not none or arrow" for k in ("fromEnd", "toEnd")
if k in e and e[k] not in ("none", "arrow")]
for x in nodes + edges:
if "color" in x and not (isinstance(x["color"], str) and COLOR.fullmatch(x["color"])):
out.append(f"{x.get('id')}: color {x['color']!r} is not a string \"1\"-\"6\" or #RRGGBB")
return out
reply = first_object(text) # or `streamed` from step 6
assert reply["task"] == INPUT["task"], "the model answered as the other lane"
facts = json.loads(INPUT["facts"])
if reply["task"] == "fix":
want = [f["id"] for f in facts.get("findings", [])]
got = [r.get("ref") for r in reply.get("finding_responses", [])]
for fid in want:
if got.count(fid) != 1:
print(f"{fid} is answered {got.count(fid)} times, not once")
for r in reply.get("finding_responses", []):
if r.get("ref") not in want:
print("answers a finding the page never raised:", r.get("ref"))
if r.get("stance") not in ("fixed", "not_a_problem", "needs_you"):
print(r.get("ref"), "has no recognised stance:", r.get("stance"))
canvas = reply.get("fixed_canvas") # None: nothing needed to change
else:
canvas = reply.get("canvas")
if isinstance(canvas, str):
canvas = json.loads(canvas) # the page also accepts a canvas sent as a string of JSON
if canvas is not None:
for problem in canvas_problems(canvas):
print("canvas:", problem)
if reply["task"] == "build": # every outline item should be on the canvas (a rough check)
words = " ".join(str(n.get(k, "")) for n in canvas.get("nodes", []) for k in ("text", "label")).lower()
for item in facts.get("request_items", []):
if item.lower() not in words:
print("outline item not on the canvas:", item)
with open(f"{reply['task']}.canvas", "w") as fh:
json.dump(canvas, fh, indent="\t", ensure_ascii=False)
print(reply["headline"])
for x in reply.get("to_check", []):
print("check:", x)
// The first balanced {...} in the reply; a code fence or prose around it is ignored.
function firstObject(t) {
const start = t.indexOf("{");
if (start < 0) throw new Error("no JSON object in the reply");
let depth = 0, inStr = false, esc = false;
for (let i = start; i < t.length; i++) {
const c = t[i];
if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; }
else if (c === '"') inStr = true;
else if (c === "{") depth++;
else if (c === "}" && --depth === 0) return JSON.parse(t.slice(start, i + 1));
}
throw new Error("the JSON object in the reply is not closed");
}
// JSON Canvas 1.0 basics: unique ids, required fields, edges that resolve.
function canvasProblems(c) {
if (!c || typeof c !== "object" || Array.isArray(c)) return ["the canvas is not a JSON object"];
const out = Object.keys(c).filter((k) => k !== "nodes" && k !== "edges").map((k) => `top-level key ${k} is not nodes or edges`);
const nodes = c.nodes || [], edges = c.edges || [];
const seen = {};
for (const x of [...nodes, ...edges]) seen[x.id] = (seen[x.id] || 0) + 1;
for (const [id, n] of Object.entries(seen)) if (n > 1) out.push(`id ${id} is used more than once`);
const nodeIds = new Set(nodes.map((n) => n.id));
const needs = { text: "text", file: "file", link: "url" };
for (const n of nodes) {
if (typeof n.id !== "string" || !n.id) out.push(`a node has no string id: ${JSON.stringify(n)}`);
if (!["text", "file", "link", "group"].includes(n.type)) out.push(`node ${n.id}: type ${n.type} is not text, file, link or group`);
for (const k of ["x", "y", "width", "height"]) if (!Number.isInteger(n[k])) out.push(`node ${n.id}: ${k} must be an integer`);
const need = needs[n.type];
if (need && typeof n[need] !== "string") out.push(`node ${n.id}: a ${n.type} node needs a ${need} string`);
if ("subpath" in n && !String(n.subpath).startsWith("#")) out.push(`node ${n.id}: subpath must start with #`);
}
for (const e of edges) {
for (const k of ["fromNode", "toNode"]) if (!nodeIds.has(e[k])) out.push(`edge ${e.id}: ${k} ${e[k]} matches no node`);
for (const k of ["fromSide", "toSide"]) if (k in e && !["top", "right", "bottom", "left"].includes(e[k])) out.push(`edge ${e.id}: ${k} ${e[k]} is not a side`);
for (const k of ["fromEnd", "toEnd"]) if (k in e && !["none", "arrow"].includes(e[k])) out.push(`edge ${e.id}: ${k} ${e[k]} is not none or arrow`);
}
for (const x of [...nodes, ...edges]) {
if ("color" in x && !(typeof x.color === "string" && /^([1-6]|#[0-9a-fA-F]{6})$/.test(x.color))) out.push(`${x.id}: color ${JSON.stringify(x.color)} is not a string "1"-"6" or #RRGGBB`);
}
return out;
}
const reply = firstObject(text); // or `streamed` from step 6
if (reply.task !== INPUT.task) throw new Error("the model answered as the other lane");
let canvas;
if (reply.task === "fix") {
const want = (facts.findings || []).map((f) => f.id); // facts: parsed in step 4
const got = (reply.finding_responses || []).map((r) => r.ref);
for (const id of want) { const n = got.filter((g) => g === id).length; if (n !== 1) console.log(`${id} is answered ${n} times, not once`); }
for (const r of reply.finding_responses || []) {
if (!want.includes(r.ref)) console.log("answers a finding the page never raised:", r.ref);
if (!["fixed", "not_a_problem", "needs_you"].includes(r.stance)) console.log(r.ref, "has no recognised stance:", r.stance);
}
canvas = reply.fixed_canvas; // null: nothing needed to change
} else {
canvas = reply.canvas;
}
if (typeof canvas === "string") canvas = JSON.parse(canvas); // the page also accepts a string of JSON
if (canvas) {
for (const p of canvasProblems(canvas)) console.log("canvas:", p);
if (reply.task === "build") { // every outline item should be on the canvas (a rough check)
const words = (canvas.nodes || []).map((n) => `${n.text || ""} ${n.label || ""}`).join(" ").toLowerCase();
for (const item of facts.request_items || []) if (!words.includes(item.toLowerCase())) console.log("outline item not on the canvas:", item);
}
writeFileSync(`${reply.task}.canvas`, JSON.stringify(canvas, null, "\t"));
}
console.log(reply.headline);
for (const x of reply.to_check || []) console.log("check:", x);
// The first balanced {...} in the reply; a code fence or prose around it is ignored.
firstObject := func(t string) (map[string]any, error) {
start := strings.Index(t, "{")
if start < 0 {
return nil, fmt.Errorf("no JSON object in the reply")
}
depth, inStr, esc := 0, false, false
for i := start; i < len(t); i++ {
c := t[i]
switch {
case inStr && esc:
esc = false
case inStr && c == '\\':
esc = true
case inStr && c == '"':
inStr = false
case inStr:
case c == '"':
inStr = true
case c == '{':
depth++
case c == '}':
depth--
if depth == 0 {
var m map[string]any
err := json.Unmarshal([]byte(t[start:i+1]), &m)
return m, err
}
}
}
return nil, fmt.Errorf("the JSON object in the reply is not closed")
}
reply, err := firstObject(text) // or streamed from step 6
if err != nil {
panic(err)
}
if reply["task"] != lane {
panic("the model answered as the other lane")
}
if lane == "fix" { // every finding answered exactly once
var sent struct {
Findings []struct {
ID string `json:"id"`
} `json:"findings"`
}
_ = json.Unmarshal([]byte(input["facts"]), &sent)
count := map[string]int{}
rs, _ := reply["finding_responses"].([]any)
for _, x := range rs {
r, _ := x.(map[string]any)
ref, _ := r["ref"].(string)
count[ref]++
}
for _, f := range sent.Findings {
if count[f.ID] != 1 {
fmt.Println(f.ID, "is answered", count[f.ID], "times, not once")
}
}
}
field := "canvas"
if lane == "fix" {
field = "fixed_canvas" // null: nothing needed to change
}
c, _ := reply[field].(map[string]any)
if s, ok := reply[field].(string); ok { // the page also accepts a string of JSON
_ = json.Unmarshal([]byte(s), &c)
}
if c != nil { // unique ids, edges that resolve
seen, nodeIDs := map[string]int{}, map[string]bool{}
nodes, _ := c["nodes"].([]any)
edges, _ := c["edges"].([]any)
for _, x := range nodes {
n, _ := x.(map[string]any)
id, _ := n["id"].(string)
seen[id]++
nodeIDs[id] = true
}
for _, x := range edges {
e, _ := x.(map[string]any)
id, _ := e["id"].(string)
seen[id]++
for _, k := range []string{"fromNode", "toNode"} {
if to, _ := e[k].(string); !nodeIDs[to] {
fmt.Println("edge", id, k, "matches no node")
}
}
}
for id, n := range seen {
if n > 1 {
fmt.Println("id", id, "is used more than once")
}
}
out, _ := json.MarshalIndent(c, "", "\t")
if err := os.WriteFile(lane+".canvas", out, 0o644); err != nil {
panic(err)
}
}
fmt.Println(reply["headline"])
// The first balanced {...} in the reply; a code fence or prose around it is ignored.
String t = text; // or streamed from step 6
int start = t.indexOf('{'), depth = 0, end = -1;
boolean inStr = false, esc = false;
for (int i = Math.max(start, 0); start >= 0 && i < t.length() && end < 0; i++) {
char ch = t.charAt(i);
if (inStr) { if (esc) esc = false; else if (ch == '\\') esc = true; else if (ch == '"') inStr = false; }
else if (ch == '"') inStr = true;
else if (ch == '{') depth++;
else if (ch == '}' && --depth == 0) end = i;
}
if (end < 0) throw new IllegalStateException("no complete JSON object in the reply");
JsonNode reply = JSON.readTree(t.substring(start, end + 1));
if (!reply.path("task").asText().equals(lane)) throw new IllegalStateException("the model answered as the other lane");
if (lane.equals("fix")) { // every finding answered exactly once
Map<String, Integer> count = new java.util.HashMap<>();
for (JsonNode r : reply.path("finding_responses")) count.merge(r.path("ref").asText(), 1, Integer::sum);
for (JsonNode f : JSON.readTree(in.path("facts").asText()).path("findings"))
if (count.getOrDefault(f.path("id").asText(), 0) != 1)
System.out.println(f.path("id").asText() + " is not answered exactly once");
}
JsonNode c = reply.path(lane.equals("fix") ? "fixed_canvas" : "canvas"); // null: nothing to change
if (c.isTextual()) c = JSON.readTree(c.asText()); // the page also accepts a string of JSON
if (c.isObject()) { // unique ids, edges that resolve
Map<String, Integer> seen = new java.util.HashMap<>();
java.util.Set<String> nodeIds = new java.util.HashSet<>();
for (JsonNode n : c.path("nodes")) {
seen.merge(n.path("id").asText(), 1, Integer::sum);
nodeIds.add(n.path("id").asText());
}
for (JsonNode e : c.path("edges")) {
seen.merge(e.path("id").asText(), 1, Integer::sum);
for (String k : new String[] {"fromNode", "toNode"})
if (!nodeIds.contains(e.path(k).asText()))
System.out.println("edge " + e.path("id").asText() + ": " + k + " matches no node");
}
seen.forEach((id, n) -> { if (n > 1) System.out.println("id " + id + " is used more than once"); });
Files.writeString(Path.of(lane + ".canvas"), JSON.writerWithDefaultPrettyPrinter().writeValueAsString(c));
}
System.out.println(reply.path("headline").asText());
# The first balanced {...} in the reply; a code fence or prose around it is ignored.
def first_object(t)
start = t.index("{") or raise "no JSON object in the reply"
depth, in_str, esc = 0, false, false
(start...t.length).each do |i|
c = t[i]
if in_str
if esc then esc = false elsif c == "\\" then esc = true elsif c == '"' then in_str = false end
elsif c == '"' then in_str = true
elsif c == "{" then depth += 1
elsif c == "}"
depth -= 1
return JSON.parse(t[start..i]) if depth.zero?
end
end
raise "the JSON object in the reply is not closed"
end
reply = first_object(text) # or streamed from step 6
raise "the model answered as the other lane" unless reply["task"] == INPUT["task"]
if INPUT["task"] == "fix" # every finding answered exactly once
refs = (reply["finding_responses"] || []).map { |r| r["ref"] }.group_by(&:itself).transform_values(&:size)
JSON.parse(INPUT["facts"]).fetch("findings", []).each do |f|
puts "#{f['id']} is not answered exactly once" unless refs[f["id"]] == 1
end
end
c = reply[INPUT["task"] == "fix" ? "fixed_canvas" : "canvas"] # nil: nothing needed to change
c = JSON.parse(c) if c.is_a?(String) # the page also accepts a string of JSON
if c.is_a?(Hash) # unique ids, edges that resolve
nodes, edges = c["nodes"] || [], c["edges"] || []
(nodes + edges).map { |x| x["id"] }.group_by(&:itself).transform_values(&:size).each { |id, n| puts "id #{id} is used more than once" if n > 1 }
node_ids = nodes.map { |n| n["id"] }
edges.each do |e|
%w[fromNode toNode].each { |k| puts "edge #{e['id']}: #{k} matches no node" unless node_ids.include?(e[k]) }
end
File.write("#{INPUT['task']}.canvas", JSON.pretty_generate(c))
end
puts reply["headline"]
// The first balanced {...} in the reply; a code fence or prose around it is ignored.
function first_object(string $t): array {
$start = strpos($t, "{");
if ($start === false) { throw new Exception("no JSON object in the reply"); }
$depth = 0; $inStr = false; $esc = false;
for ($i = $start; $i < strlen($t); $i++) {
$c = $t[$i];
if ($inStr) {
if ($esc) { $esc = false; } elseif ($c === "\\") { $esc = true; } elseif ($c === '"') { $inStr = false; }
} elseif ($c === '"') { $inStr = true; }
elseif ($c === "{") { $depth++; }
elseif ($c === "}" && --$depth === 0) {
return json_decode(substr($t, $start, $i - $start + 1), true, 512, JSON_THROW_ON_ERROR);
}
}
throw new Exception("the JSON object in the reply is not closed");
}
$reply = first_object($text); // or $streamed from step 6
if (($reply["task"] ?? "") !== $input["task"]) { throw new Exception("the model answered as the other lane"); }
if ($input["task"] === "fix") { // every finding answered exactly once
$refs = array_count_values(array_column($reply["finding_responses"] ?? [], "ref"));
foreach (json_decode($input["facts"], true)["findings"] ?? [] as $f) {
if (($refs[$f["id"]] ?? 0) !== 1) { echo $f["id"], " is not answered exactly once\n"; }
}
}
$c = $reply[$input["task"] === "fix" ? "fixed_canvas" : "canvas"] ?? null; // null: nothing to change
if (is_string($c)) { $c = json_decode($c, true); } // the page also accepts a string of JSON
if (is_array($c)) { // unique ids, edges that resolve
$nodes = $c["nodes"] ?? []; $edges = $c["edges"] ?? [];
$nodeIds = array_column($nodes, "id");
foreach (array_count_values(array_merge($nodeIds, array_column($edges, "id"))) as $id => $n) {
if ($n > 1) { echo "id $id is used more than once\n"; }
}
foreach ($edges as $e) {
foreach (["fromNode", "toNode"] as $k) {
if (!in_array($e[$k] ?? null, $nodeIds, true)) { echo "edge ", $e["id"] ?? "?", ": $k matches no node\n"; }
}
}
file_put_contents($input["task"] . ".canvas", json_encode($c, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
echo $reply["headline"], "\n";
// The first balanced {...} in the reply; a code fence or prose around it is ignored.
static string FirstObject(string src)
{
int start = src.IndexOf('{'), depth = 0;
bool inStr = false, esc = false;
for (int i = Math.Max(start, 0); start >= 0 && i < src.Length; i++)
{
char ch = src[i];
if (inStr) { if (esc) esc = false; else if (ch == '\\') esc = true; else if (ch == '"') inStr = false; }
else if (ch == '"') inStr = true;
else if (ch == '{') depth++;
else if (ch == '}' && --depth == 0) return src[start..(i + 1)];
}
throw new Exception("no complete JSON object in the reply");
}
using var replyDoc = JsonDocument.Parse(FirstObject(text)); // or streamed from step 6
var reply = replyDoc.RootElement;
if (reply.GetProperty("task").GetString() != lane) throw new Exception("the model answered as the other lane");
if (lane == "fix") // every finding answered exactly once
{
var count = new Dictionary<string, int>();
foreach (var r in reply.GetProperty("finding_responses").EnumerateArray())
{
var rf = r.GetProperty("ref").GetString() ?? "";
count[rf] = count.GetValueOrDefault(rf) + 1;
}
using var sentFacts = JsonDocument.Parse(root.GetProperty("facts").GetString()!);
if (sentFacts.RootElement.TryGetProperty("findings", out var fs))
foreach (var f in fs.EnumerateArray())
if (count.GetValueOrDefault(f.GetProperty("id").GetString() ?? "") != 1)
Console.WriteLine($"{f.GetProperty("id")} is not answered exactly once");
}
reply.TryGetProperty(lane == "fix" ? "fixed_canvas" : "canvas", out var ce); // null: nothing to change
using var canvasDoc = JsonDocument.Parse(ce.ValueKind == JsonValueKind.String ? ce.GetString()! // a string of JSON
: ce.ValueKind == JsonValueKind.Object ? ce.GetRawText() : "null");
var cv = canvasDoc.RootElement;
if (cv.ValueKind == JsonValueKind.Object) // unique ids, edges that resolve
{
var seen = new Dictionary<string, int>();
var nodeIds = new HashSet<string>();
if (cv.TryGetProperty("nodes", out var nodesEl))
foreach (var n in nodesEl.EnumerateArray())
{
var id = n.GetProperty("id").GetString() ?? "";
seen[id] = seen.GetValueOrDefault(id) + 1;
nodeIds.Add(id);
}
if (cv.TryGetProperty("edges", out var edgesEl))
foreach (var e in edgesEl.EnumerateArray())
{
var id = e.GetProperty("id").GetString() ?? "";
seen[id] = seen.GetValueOrDefault(id) + 1;
foreach (var k in new[] { "fromNode", "toNode" })
if (!e.TryGetProperty(k, out var end) || !nodeIds.Contains(end.GetString() ?? ""))
Console.WriteLine($"edge {id}: {k} matches no node");
}
foreach (var kv in seen) if (kv.Value > 1) Console.WriteLine($"id {kv.Key} is used more than once");
File.WriteAllText($"{lane}.canvas", JsonSerializer.Serialize(cv, new JsonSerializerOptions { WriteIndented = true }));
}
Console.WriteLine(reply.GetProperty("headline").GetString());
Costs
- The checks are free and run in the page: the strict JSON reader, the JSON Canvas 1.0 and skill checks, the layout checks and the SVG preview, plus every export. Nothing is metered until you start a lane.
/estimateis free. It creates no job and returnshold_credits: a reservation held against your balance while the run executes (the run's worst case), not the price. Read the amounts from/estimatefor your own input; they depend on it.- A run is billed only for what it uses:
charged_creditson the finished job and in thedoneevent, usually far below the hold. - Who pays: the caller's own balance, unless
/estimatereturnssponsor_enabled: true. - Runs need a signed-in user token unless the app sponsors them. A guest token can call
/meand/estimate; sign in for a personal token on the token page. - A reformat retry (with
retry_note) is a new attempt with its own key (:a2) and its own charge.
Invariants worth asserting
- The reply is one JSON object whose
taskequals thetaskyou sent, with every key of that lane's contract present. - Fix: every finding id in
facts.findingsis answered exactly once infinding_responses, and no other id is;stanceisfixed,not_a_problemorneeds_you; ajsonorspecerror is not callednot_a_problem. - Fix:
verdictisbroken,fix_firstorok, and notokwhilefacts.findingsholds an error; when the page found errors, afixed_canvasis returned and it differs from the original. - Fix:
fixed_canvasparses and has no errors when re-checked, a finding markedfixedis gone from it, and every node or edge of the original that is missing or changed is named (by id) inchangesor in the refs of a finding markedfixed. - Build:
canvasparses, has at least one node and no errors, no two non-group nodes overlap and no node sits half inside a group; everyfacts.request_itemsentry is on it. - Build: every
filepath orlinkURL on the canvas comes from the request (or the canvas being extended) or is listed inassumptions; when extending, every existing node is still there with its id. - Every canvas in a reply is complete (not a diff), and every
explanation[].partisgroups,nodes,edges,layoutorcolors.