Drive dxapp Desk from your own code
dxapp Desk checks the dxapp.json of a DNAnexus app or applet before dx build.
In the page, a JavaScript port of validate_dxapp.py - the offline validator bundled with
the dnanexus-integration agent skill - runs on your manifest with the same report and exit status as
the Python. The same script runs on your machine
(uv run python scripts/validate_dxapp.py path/to/dxapp.json --kind app --strict --json),
and the page's "Copy commands" button gives you the exact lines.
The metered lanes read the manifest (with values under credential-looking keys replaced by
[redacted by dxapp Desk]), the validator's report and the page's numbered flags:
review judges what blocks a build and lists the changes; fix returns the
corrected manifest, which you should re-validate (the page does). Neither can see your DNAnexus
account: nothing confirms that an instance type, asset or project exists.
Two lanes: the task field
| task | send | you get back |
|---|---|---|
review | facts, manifest; context and question optional | status (blocked, fix_before_build, ready_to_build), a response to every flag, findings (ref, area, severity, evidence, action), build_steps (at most 6), open_questions and assumptions. |
fix | facts, manifest; context, question and review optional | status (fixed, fixed_with_gaps, cannot_fix), flag responses, changes (ref, path, change, why), manifest (the whole corrected dxapp.json as a JSON object), to_confirm, open_questions and assumptions. |
Worked example: review
The legacy applet on the page: a capitalised parameter class and a missing AEE version (two validator errors), Ubuntu 20.04, deprecated runSpec.systemRequirements, unpinned execDepends, a retried AppError, network: ["*"], an embedded token (redacted before sending), no timeout and a local-path file default.
{
"task": "review",
"facts": "{\"tool\":{\"script\":\"validate_dxapp.py\",\"python\":\"3.14\",\"kind_option\":\"auto\",\"strict\":false,\"exit\":1,\"kind\":\"applet\",\"valid\":false,\"errors\":2,\"warnings\":7,\"issues\":[{\"severity\":\"error\",\"code\":\"parameter-class\",\"path\":\"$.inputSpec[2].class\",\"message\":\"class must be one of: applet, array:applet, array:boolean, array:file, array:float, array:hash, array:int, array:record, array:string, boolean, file, float, hash, int, record, string\"},{\"severity\":\"warning\",\"code\":\"legacy-release\",\"path\":\"$.runSpec.release\",\"message\":\"Ubuntu 20.04 remains supported but 24.04 is preferred for new apps\"},{\"severity\":\"error\",\"code\":\"aee-version\",\"path\":\"$.runSpec.version\",\"message\":\"current supported AEE version is the string \\\"0\\\"\"},{\"severity\":\"warning\",\"code\":\"deprecated-system-requirements\",\"path\":\"$.runSpec.systemRequirements\",\"message\":\"runSpec.systemRequirements is deprecated in dxapp.json; use regionalOptions.<region>.systemRequirements\"},{\"severity\":\"warning\",\"code\":\"floating-dependency\",\"path\":\"$.runSpec.execDepends[0]\",\"message\":\"runtime dependency is not version/tag pinned; prefer a pinned asset or bundled dependency for production\"},{\"severity\":\"warning\",\"code\":\"floating-dependency\",\"path\":\"$.runSpec.execDepends[1]\",\"message\":\"runtime dependency is not version/tag pinned; prefer a pinned asset or bundled dependency for production\"},{\"severity\":\"warning\",\"code\":\"unknown-restart-reason\",\"path\":\"$.runSpec.executionPolicy.restartOn.AppError\",\"message\":\"failure reason is not in the current documented restartable set\"},{\"severity\":\"warning\",\"code\":\"broad-network\",\"path\":\"$.access.network\",\"message\":\"unrestricted network access needs explicit justification\"},{\"severity\":\"warning\",\"code\":\"embedded-secret\",\"path\":\"$.details.upload_token\",\"message\":\"possible credential value is embedded in dxapp.json; use an explicit protected input or secret mechanism\"}],\"parse_error\":null,\"crash\":null},\"kind\":\"applet\",\"summary\":{\"name\":\"bam-qc\",\"kind\":\"applet\",\"version\":null,\"title\":\"BAM QC\",\"runtime\":{\"interpreter\":\"bash\",\"distribution\":\"Ubuntu\",\"release\":\"20.04\",\"aee_version\":null,\"entry\":\"file\"},\"inputs\":[{\"index\":0,\"name\":\"bam\",\"class\":\"file\",\"patterns\":[\"*.bam\"]},{\"index\":1,\"name\":\"reference\",\"class\":\"file\",\"has_default\":true},{\"index\":2,\"name\":\"min_mapq\",\"class\":\"Int\",\"has_default\":true}],\"outputs\":[{\"index\":0,\"name\":\"report\",\"class\":\"file\",\"patterns\":[\"*.txt\"]}],\"regions\":[],\"exec_depends\":[{\"name\":\"samtools\",\"pinned\":false,\"package_manager\":null},{\"name\":\"pysam\",\"pinned\":false,\"package_manager\":\"pip\"}],\"restart_on\":{\"AppError\":2,\"UnresponsiveWorker\":2},\"max_restarts\":null,\"timeout\":null,\"restartable_entry_points\":null,\"legacy_placement\":[\"runSpec.systemRequirements\"],\"access\":{\"network\":[\"*\"],\"project\":\"CONTRIBUTE\",\"allProjects\":\"(not declared)\",\"developer\":\"(not declared)\"},\"https_ports\":null},\"flags\":[{\"id\":\"F1\",\"source\":\"tool\",\"code\":\"parameter-class\",\"severity\":\"high\",\"area\":\"io_spec\",\"path\":\"$.inputSpec[2].class\",\"text\":\"class must be one of: applet, array:applet, array:boolean, array:file, array:float, array:hash, array:int, array:record, array:string, boolean, file, float, hash, int, record, string\"},{\"id\":\"F2\",\"source\":\"tool\",\"code\":\"legacy-release\",\"severity\":\"medium\",\"area\":\"runtime\",\"path\":\"$.runSpec.release\",\"text\":\"Ubuntu 20.04 remains supported but 24.04 is preferred for new apps\"},{\"id\":\"F3\",\"source\":\"tool\",\"code\":\"aee-version\",\"severity\":\"high\",\"area\":\"runtime\",\"path\":\"$.runSpec.version\",\"text\":\"current supported AEE version is the string \\\"0\\\"\"},{\"id\":\"F4\",\"source\":\"tool\",\"code\":\"deprecated-system-requirements\",\"severity\":\"medium\",\"area\":\"resources\",\"path\":\"$.runSpec.systemRequirements\",\"text\":\"runSpec.systemRequirements is deprecated in dxapp.json; use regionalOptions.<region>.systemRequirements\"},{\"id\":\"F5\",\"source\":\"tool\",\"code\":\"floating-dependency\",\"severity\":\"medium\",\"area\":\"dependencies\",\"path\":\"$.runSpec.execDepends[0]\",\"text\":\"runtime dependency is not version/tag pinned; prefer a pinned asset or bundled dependency for production\"},{\"id\":\"F6\",\"source\":\"tool\",\"code\":\"floating-dependency\",\"severity\":\"medium\",\"area\":\"dependencies\",\"path\":\"$.runSpec.execDepends[1]\",\"text\":\"runtime dependency is not version/tag pinned; prefer a pinned asset or bundled dependency for production\"},{\"id\":\"F7\",\"source\":\"tool\",\"code\":\"unknown-restart-reason\",\"severity\":\"medium\",\"area\":\"retry_timeout\",\"path\":\"$.runSpec.executionPolicy.restartOn.AppError\",\"text\":\"failure reason is not in the current documented restartable set\"},{\"id\":\"F8\",\"source\":\"tool\",\"code\":\"broad-network\",\"severity\":\"medium\",\"area\":\"access\",\"path\":\"$.access.network\",\"text\":\"unrestricted network access needs explicit justification\"},{\"id\":\"F9\",\"source\":\"tool\",\"code\":\"embedded-secret\",\"severity\":\"medium\",\"area\":\"secrets\",\"path\":\"$.details.upload_token\",\"text\":\"possible credential value is embedded in dxapp.json; use an explicit protected input or secret mechanism\"},{\"id\":\"F10\",\"source\":\"page\",\"code\":\"no-timeout-policy\",\"severity\":\"medium\",\"area\":\"retry_timeout\",\"path\":\"$.runSpec.timeoutPolicy\",\"text\":\"No timeoutPolicy: a job may run to the platform's 30-day maximum. The skill's reference: set a shorter workload-specific timeout, and define timeouts and launch-time cost limits before publishing.\"},{\"id\":\"F11\",\"source\":\"page\",\"code\":\"restart-without-ceiling\",\"severity\":\"low\",\"area\":\"retry_timeout\",\"path\":\"$.runSpec.executionPolicy.maxRestarts\",\"text\":\"restartOn is set but maxRestarts is not, so the ceiling defaults to 9 restarts. The skill's reference: set a smaller explicit bound for cost control.\"},{\"id\":\"F12\",\"source\":\"page\",\"code\":\"retry-deterministic\",\"severity\":\"medium\",\"area\":\"retry_timeout\",\"path\":\"$.runSpec.executionPolicy.restartOn.AppError\",\"text\":\"AppError: 2 retries a deterministic failure. The skill's reference: retry only failures that can plausibly recover - retrying AppError or invalid input wastes money.\"},{\"id\":\"F13\",\"source\":\"page\",\"code\":\"default-local-path\",\"severity\":\"medium\",\"area\":\"io_spec\",\"path\":\"$.inputSpec[1].default\",\"text\":\"The default for 'reference' (class file) is a plain string. The skill's reference: file and record defaults use DNAnexus links ({\\\"$dnanexus_link\\\": ...}), not raw local paths.\"},{\"id\":\"F14\",\"source\":\"page\",\"code\":\"pip-exec-depends\",\"severity\":\"low\",\"area\":\"dependencies\",\"path\":\"$.runSpec.execDepends\",\"text\":\"execDepends installs from pip at run time. On the Ubuntu 24.04 AEE, PyPI packages can conflict with APT-managed Python and fail with DXExecDependencyError; the skill prefers a pinned virtual environment built into an asset.\"}],\"flags_not_sent\":0,\"browser_status\":\"blocked\",\"manifest_sent\":{\"mode\":\"reserialized\",\"redactions\":1,\"chars_cut\":0,\"redaction_marker\":\"[redacted by dxapp Desk]\"}}",
"manifest": "{\n \"name\": \"bam-qc\",\n \"title\": \"BAM QC\",\n \"summary\": \"Runs samtools flagstat and a coverage summary on one BAM\",\n \"inputSpec\": [\n {\n \"name\": \"bam\",\n \"class\": \"file\",\n \"patterns\": [\n \"*.bam\"\n ],\n \"help\": \"Coordinate-sorted BAM\"\n },\n {\n \"name\": \"reference\",\n \"class\": \"file\",\n \"default\": \"/data/refs/GRCh38.fa\",\n \"help\": \"Reference FASTA\"\n },\n {\n \"name\": \"min_mapq\",\n \"class\": \"Int\",\n \"default\": 20,\n \"help\": \"Minimum mapping quality\"\n }\n ],\n \"outputSpec\": [\n {\n \"name\": \"report\",\n \"class\": \"file\",\n \"patterns\": [\n \"*.txt\"\n ]\n }\n ],\n \"runSpec\": {\n \"interpreter\": \"bash\",\n \"file\": \"src/bam-qc.sh\",\n \"distribution\": \"Ubuntu\",\n \"release\": \"20.04\",\n \"systemRequirements\": {\n \"main\": {\n \"instanceType\": \"mem1_ssd1_v2_x4\"\n }\n },\n \"execDepends\": [\n {\n \"name\": \"samtools\"\n },\n {\n \"name\": \"pysam\",\n \"package_manager\": \"pip\"\n }\n ],\n \"executionPolicy\": {\n \"restartOn\": {\n \"AppError\": 2,\n \"UnresponsiveWorker\": 2\n }\n }\n },\n \"access\": {\n \"network\": [\n \"*\"\n ],\n \"project\": \"CONTRIBUTE\"\n },\n \"details\": {\n \"upload_token\": \"[redacted by dxapp Desk]\"\n }\n}\n",
"context": "Applet we have run since 2022 for BAM QC in project-alpha, region aws:us-east-1. It downloads nothing at run time. The upload token was for an S3 sync step we removed. Jobs finish in under 2 hours on a 30x WGS BAM.",
"question": "What has to change before we rebuild it on Ubuntu 24.04?"
}
The reply's status is blocked: a validator error can only be fixed, not argued away. Load the example on the page to see the saved reply in full, free.
Worked example: fix
The production app on the page, checked with --kind app --strict: three validator warnings (all entry points restartable, a duplicated instance type, cross-project access) and three low page flags.
{
"task": "fix",
"facts": "{\"tool\":{\"script\":\"validate_dxapp.py\",\"python\":\"3.14\",\"kind_option\":\"app\",\"strict\":true,\"exit\":1,\"kind\":\"app\",\"valid\":false,\"errors\":0,\"warnings\":3,\"issues\":[{\"severity\":\"warning\",\"code\":\"restart-idempotency\",\"path\":\"$.runSpec.restartableEntryPoints\",\"message\":\"all entry points must be idempotent before enabling all\"},{\"severity\":\"warning\",\"code\":\"duplicate-instance-type\",\"path\":\"$.regionalOptions.aws:us-east-1.systemRequirements.main.instanceTypeSelector.allowedInstanceTypes\",\"message\":\"duplicate instance types do not provide an additional fallback\"},{\"severity\":\"warning\",\"code\":\"all-projects-access\",\"path\":\"$.access.allProjects\",\"message\":\"cross-project access needs explicit justification\"}],\"parse_error\":null,\"crash\":null},\"kind\":\"app\",\"summary\":{\"name\":\"somatic-caller\",\"kind\":\"app\",\"version\":\"2.3.0\",\"title\":\"Somatic SNV caller\",\"runtime\":{\"interpreter\":\"python3\",\"distribution\":\"Ubuntu\",\"release\":\"24.04\",\"aee_version\":\"0\",\"entry\":\"file\"},\"inputs\":[{\"index\":0,\"name\":\"tumor_bam\",\"class\":\"file\",\"patterns\":[\"*.bam\"]},{\"index\":1,\"name\":\"normal_bam\",\"class\":\"file\",\"patterns\":[\"*.bam\"]},{\"index\":2,\"name\":\"min_af\",\"class\":\"float\",\"optional\":true,\"has_default\":true}],\"outputs\":[{\"index\":0,\"name\":\"vcf\",\"class\":\"file\",\"patterns\":[\"*.vcf.gz\"]}],\"regions\":[{\"region\":\"aws:us-east-1\",\"system_requirements\":true,\"entry_points\":[{\"entry_point\":\"main\",\"selectors\":[\"instanceTypeSelector\"],\"allowed_instance_types\":[\"mem2_ssd1_v2_x8\",\"mem2_ssd1_v2_x16\",\"mem2_ssd1_v2_x8\"]}]},{\"region\":\"azure:westus\",\"system_requirements\":true,\"entry_points\":[{\"entry_point\":\"main\",\"selectors\":[\"instanceType\"],\"instance_type\":\"azure:mem2_ssd1_x8\"}]}],\"exec_depends\":[{\"name\":\"bcftools\",\"pinned\":true,\"package_manager\":null}],\"restart_on\":{\"ExecutionError\":1,\"SpotInstanceInterruption\":3,\"AppInsufficientResourceError\":1},\"max_restarts\":4,\"timeout\":{\"main\":{\"hours\":12}},\"restartable_entry_points\":\"all\",\"legacy_placement\":[],\"access\":{\"network\":\"(not declared)\",\"project\":\"VIEW\",\"allProjects\":\"VIEW\",\"developer\":\"(not declared)\"},\"https_ports\":null},\"flags\":[{\"id\":\"F1\",\"source\":\"tool\",\"code\":\"restart-idempotency\",\"severity\":\"medium\",\"area\":\"retry_timeout\",\"path\":\"$.runSpec.restartableEntryPoints\",\"text\":\"all entry points must be idempotent before enabling all\"},{\"id\":\"F2\",\"source\":\"tool\",\"code\":\"duplicate-instance-type\",\"severity\":\"medium\",\"area\":\"resources\",\"path\":\"$.regionalOptions.aws:us-east-1.systemRequirements.main.instanceTypeSelector.allowedInstanceTypes\",\"text\":\"duplicate instance types do not provide an additional fallback\"},{\"id\":\"F3\",\"source\":\"tool\",\"code\":\"all-projects-access\",\"severity\":\"medium\",\"area\":\"access\",\"path\":\"$.access.allProjects\",\"text\":\"cross-project access needs explicit justification\"},{\"id\":\"F4\",\"source\":\"page\",\"code\":\"upgrade-needs-policy\",\"severity\":\"low\",\"area\":\"retry_timeout\",\"path\":\"$.runSpec.executionPolicy.restartOn.AppInsufficientResourceError\",\"text\":\"Automatic upgrade after AppInsufficientResourceError needs this restartOn count AND an organization policy that permits instance upgrade on restart AND a larger instance in the same family.\"},{\"id\":\"F5\",\"source\":\"page\",\"code\":\"selector-license\",\"severity\":\"low\",\"area\":\"resources\",\"path\":\"$.regionalOptions\",\"text\":\"instanceTypeSelector (dynamic instance selection) is available where licensed and may require an organization license; each allowed type gets 10 minutes in list order before the next.\"},{\"id\":\"F6\",\"source\":\"page\",\"code\":\"app-project-access\",\"severity\":\"low\",\"area\":\"access\",\"path\":\"$.access\",\"text\":\"An app with default permissions already gets CONTRIBUTE in its temporary workspace. The skill's reference: omit project and allProjects unless the app must read, modify or delete existing project objects directly.\"}],\"flags_not_sent\":0,\"browser_status\":\"fix_first\",\"manifest_sent\":{\"mode\":\"reserialized\",\"redactions\":0,\"chars_cut\":0,\"redaction_marker\":\"[redacted by dxapp Desk]\"}}",
"manifest": "{\n \"name\": \"somatic-caller\",\n \"title\": \"Somatic SNV caller\",\n \"summary\": \"Tumour-normal SNV calling with a panel of normals\",\n \"version\": \"2.3.0\",\n \"inputSpec\": [\n {\n \"name\": \"tumor_bam\",\n \"class\": \"file\",\n \"patterns\": [\n \"*.bam\"\n ]\n },\n {\n \"name\": \"normal_bam\",\n \"class\": \"file\",\n \"patterns\": [\n \"*.bam\"\n ]\n },\n {\n \"name\": \"min_af\",\n \"class\": \"float\",\n \"default\": 0.05,\n \"optional\": true\n }\n ],\n \"outputSpec\": [\n {\n \"name\": \"vcf\",\n \"class\": \"file\",\n \"patterns\": [\n \"*.vcf.gz\"\n ]\n }\n ],\n \"runSpec\": {\n \"interpreter\": \"python3\",\n \"file\": \"src/caller.py\",\n \"distribution\": \"Ubuntu\",\n \"release\": \"24.04\",\n \"version\": \"0\",\n \"timeoutPolicy\": {\n \"main\": {\n \"hours\": 12\n }\n },\n \"executionPolicy\": {\n \"restartOn\": {\n \"ExecutionError\": 1,\n \"SpotInstanceInterruption\": 3,\n \"AppInsufficientResourceError\": 1\n },\n \"maxRestarts\": 4\n },\n \"restartableEntryPoints\": \"all\",\n \"execDepends\": [\n {\n \"name\": \"bcftools\",\n \"version\": \"1.19-1\"\n }\n ]\n },\n \"regionalOptions\": {\n \"aws:us-east-1\": {\n \"systemRequirements\": {\n \"main\": {\n \"instanceTypeSelector\": {\n \"allowedInstanceTypes\": [\n \"mem2_ssd1_v2_x8\",\n \"mem2_ssd1_v2_x16\",\n \"mem2_ssd1_v2_x8\"\n ]\n }\n }\n }\n },\n \"azure:westus\": {\n \"systemRequirements\": {\n \"main\": {\n \"instanceType\": \"azure:mem2_ssd1_x8\"\n }\n }\n }\n },\n \"access\": {\n \"project\": \"VIEW\",\n \"allProjects\": \"VIEW\"\n }\n}\n",
"context": "Entry point main is idempotent: it writes only to the job workspace. allProjects VIEW was added to read the panel of normals in project-pon; we could pass it as an input instead. Our org has the instance-upgrade policy enabled."
}
Re-run the validator on the returned manifest with the same --kind and --strict: fixed is only honest when it passes and holds no [to fill: ...] placeholder.
Input fields
Every field is a string; facts is JSON text.
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | "review" or "fix". |
facts | string | yes | The validator report, the page's summary and the numbered flags, as JSON text (below). |
manifest | string | yes | The dxapp.json text. The page sends it re-serialised when it parses (duplicate keys collapsed the way json.load does) and as written when it does not, with credential-looking values replaced and at most 60,000 characters (a longer one loses its middle, marked). |
context | string | no | The region, what the app does at run time, how long jobs take - never a token. Up to about 3,000 characters. |
question | string | no | A question to answer in the reply. |
review | string | no | Fix lane: an earlier review as text (the page fills it from the review's status, stances and findings). |
retry_note | string | no | Only on a reformat retry. |
The facts string
tool (script, python, kind_option, strict, exit = 0 valid, 1 invalid or crashed, 2 unreadable JSON,
kind, valid, errors, warnings, issues as severity/code/path/message, parse_error, crash);
kind (the profile used); summary (name, version, runtime, inputs and outputs, regions with entry points and instance types, execDepends, restart and
timeout policy, access, HTTPS ports - or null when it does not parse); flags (id F1..., source tool or page, code,
severity high/medium/low, area, path, text); flags_not_sent; browser_status
(blocked, fix_first, build_ready); and manifest_sent (mode, redactions, chars_cut,
redaction_marker). Build it from the skill's own script output, or copy "Download .json" from a page run (it includes facts_sent).
The output
One JSON object, serialised as a string at data.output.output. Keys in both lanes: task, status, headline, flag_responses (ref, stance = confirmed | explained | dismissed | needs_owner, note), open_questions, assumptions. Lane keys are listed in the table above; areas are metadata, io_spec, runtime, resources, retry_timeout, dependencies, access, secrets, https, other.
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":"dxapp-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ....
The input object IS the request body. There is no {"input": ...}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your text.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 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. A guest cannot run; 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.
# 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="dxapp-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://dxapp-desk.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 = "dxapp-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://dxapp-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
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")
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"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "dxapp-desk";
// Paste the token from https://dxapp-desk.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 {}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
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 = "dxapp-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://dxapp-desk.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"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
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
}
import java.net.URI;
import java.net.http.*;
public class EdaDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "dxapp-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
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());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "dxapp-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://dxapp-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
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
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "dxapp-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
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"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class EdaDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "dxapp-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
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":"dxapp-desk"}, can call /me and
/estimate; the run is metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://dxapp-desk.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 "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"dxapp-desk"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://dxapp-desk.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.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "dxapp-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://dxapp-desk.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.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "dxapp-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://dxapp-desk.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.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"dxapp-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://dxapp-desk.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.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"dxapp-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://dxapp-desk.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.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: "dxapp-desk" }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://dxapp-desk.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.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "dxapp-desk"]));
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);
echo $guest["data"]["token"];
// Open https://dxapp-desk.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.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"dxapp-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await EdaDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra.
hold_credits is a reservation, not the price. min_credits is
the least balance that can start a run. 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
review or fix, a facts that is a JSON string parsing to an object, and a non-empty manifest string.
# body.json is the input object itself - no {"input": ...} wrapper. estimate does
# not validate it, so check the shape first:
python3 -c '
import json
b = json.load(open("body.json"))
assert isinstance(b, dict) and b.get("task") in ("review", "fix")
assert all(isinstance(v, str) for v in b.values())
assert isinstance(json.loads(b["facts"]), dict) and b["manifest"].strip()
'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":...,"hold_credits":...,"min_credits":...,"sponsor_enabled":false}}
# hold_credits is RESERVED, not the price; charged_credits after the run is the cost.
INPUT = json.load(open("body.json")) # the input object itself, no wrapper
assert isinstance(INPUT, dict) and INPUT.get("task") in ("review", "fix")
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"
est = call("estimate", INPUT)
print(est["model_alias"], "reserves", est["hold_credits"], "credits (not the price)")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // no {input: ...} wrapper
if (!INPUT || typeof INPUT !== "object" || !["review", "fix"].includes(INPUT.task)) throw new Error("task must be review or fix");
if (!Object.values(INPUT).every((v) => typeof v === "string")) throw new Error("every field is a string");
if (typeof JSON.parse(INPUT.facts) !== "object") throw new Error("facts is a JSON string of an object");
const est = await call("estimate", INPUT);
console.log(est.model_alias, "reserves", est.hold_credits, "credits (not the price)");
raw, _ := os.ReadFile("body.json")
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())
}
if input["task"] != "review" && input["task"] != "fix" {
panic("task must be review or fix")
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string of an object")
}
est := call("estimate", input)
fmt.Println(est["model_alias"], "reserves", est["hold_credits"], "credits (not the price)")
String input = Files.readString(Path.of("body.json")); // the input object itself
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(review|fix)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task review or fix");
if (!input.contains("\"facts\""))
throw new IllegalStateException("both lanes need facts");
System.out.println(call("estimate", input)); // hold_credits is a reservation, not the price
INPUT = JSON.parse(File.read("body.json")) # no {"input": ...} wrapper
raise "task must be review or fix" unless %w[review fix].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)
est = call("estimate", INPUT)
puts "#{est['model_alias']} reserves #{est['hold_credits']} credits (not the price)"
$input = json_decode(file_get_contents("body.json"), true); // no {"input": ...} wrapper
if (!is_array($input) || !in_array($input["task"] ?? "", ["review", "fix"], true)) { throw new Exception("task must be review or fix"); }
foreach ($input as $v) { if (!is_string($v)) { throw new Exception("every field is a string"); } }
if (!is_array(json_decode($input["facts"] ?? "", true))) { throw new Exception("facts is a JSON string of an object"); }
$est = call("estimate", $input);
echo $est["model_alias"], " reserves ", $est["hold_credits"], " credits (not the price)\n";
var input = File.ReadAllText("body.json"); // the input object itself
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "review" && lane != "fix") throw new Exception("task must be review or fix");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception("every field is a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // throws unless facts is JSON
Console.WriteLine(await Call("estimate", input)); // hold_credits is a reservation, 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: JSON.parse
it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the
attempt number, dxapp-desk:<lane>:<hash>:a<attempt>, so a retried
request returns the same job instead of billing a second run. Use one key per distinct input: edited
facts, manifest, context, review or question are 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" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])') # review or fix
KEY="dxapp-desk:$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" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["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\":\"review\",\"status\":\"blocked\",\"headline\":\"...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"dxapp-desk:{INPUT['task']}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["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"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `dxapp-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("dxapp-desk:%s:%x:a1", input["task"], sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "dxapp-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "dxapp-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(INPUT.to_json)[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = INPUT.to_json
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "dxapp-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"dxapp-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await EdaDesk.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 output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
6. Or stream it
POST /run-stream takes the same body and headers and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). 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}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
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\":\"review\",\"status\":\"blocked\",\"headline\":\"The"}
# 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)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = 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),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
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();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
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
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply, and re-validate a fix
The reply is one JSON object serialised as a string. Parse it and check that task is the
lane you asked for. For fix, write manifest to a file and run the skill's
validator on it with the same options before you replace your dxapp.json.
# The reply is a JSON string inside data.output.output (saved as reply.json in step 5):
python3 -c 'import json;r=json.load(open("reply.json"));print(r["task"],r["status"],r["headline"])'
python3 -c '
import json
r = json.load(open("reply.json"))
if r["task"] == "review":
for f in r["findings"]: print("-", f["severity"], f["area"], f["action"])
else:
json.dump(r["manifest"], open("dxapp.fixed.json", "w"), indent=2)
for c in r["changes"]: print(c["ref"], c["path"], c["change"])
'
# then, from the skill folder, with the same options as facts.tool:
uv run python scripts/validate_dxapp.py dxapp.fixed.json --kind app --strict --json
import subprocess
reply = json.loads(job["output"]["output"])
assert reply["task"] == INPUT["task"], "the model answered as another lane"
print(reply["status"], reply["headline"])
if reply["task"] == "review":
for f in reply["findings"]:
print("-", f["severity"], f["area"], f["evidence"], "->", f["action"])
else:
with open("dxapp.fixed.json", "w") as fh:
json.dump(reply["manifest"], fh, indent=2)
check = subprocess.run(["python3", "scripts/validate_dxapp.py", "dxapp.fixed.json", "--kind", "app", "--strict", "--json"],
capture_output=True, text=True)
print("validator exit", check.returncode, check.stdout)
const reply = JSON.parse(job.output.output);
if (reply.task !== INPUT.task) throw new Error("the model answered as another lane");
console.log(reply.status, reply.headline);
if (reply.task === "review") {
for (const f of reply.findings) console.log("-", f.severity, f.area, f.action);
} else {
writeFileSync("dxapp.fixed.json", JSON.stringify(reply.manifest, null, 2)); // then run validate_dxapp.py on it
for (const c of reply.changes) console.log(c.ref, c.path, c.change);
}
var reply map[string]any
json.Unmarshal([]byte(job["output"].(map[string]any)["output"].(string)), &reply)
if reply["task"] != input["task"] {
panic("the model answered as another lane")
}
fmt.Println(reply["status"], reply["headline"])
if reply["task"] == "review" {
for _, f := range reply["findings"].([]any) {
fmt.Println("-", f.(map[string]any)["severity"], f.(map[string]any)["action"])
}
} else {
b, _ := json.MarshalIndent(reply["manifest"], "", " ")
os.WriteFile("dxapp.fixed.json", b, 0o644) // then run validate_dxapp.py on it
}
// With any JSON library, parse data.output.output (a string) into an object, then:
// reply.task must equal the task you sent;
// review -> reply.status, reply.findings[].action, reply.build_steps[]
// fix -> reply.status, reply.changes[], reply.manifest (write it out, then run validate_dxapp.py)
String out = job.replaceAll("(?s).*\"output\"\\s*:\\s*\\{\\s*\"output\"\\s*:\\s*(\".*?(?<!\\\\)\").*", "$1");
System.out.println(out.substring(0, Math.min(200, out.length())));
reply = JSON.parse(job["output"]["output"])
raise "the model answered as another lane" unless reply["task"] == INPUT["task"]
puts reply["status"], reply["headline"]
if reply["task"] == "review"
reply["findings"].each { |f| puts "- #{f['severity']} #{f['area']}: #{f['action']}" }
else
File.write("dxapp.fixed.json", JSON.pretty_generate(reply["manifest"])) # then run validate_dxapp.py on it
reply["changes"].each { |c| puts "#{c['ref']} #{c['path']}: #{c['change']}" }
end
$reply = json_decode($job["output"]["output"], true);
if ($reply["task"] !== $input["task"]) { throw new Exception("the model answered as another lane"); }
echo $reply["status"], " ", $reply["headline"], "\n";
if ($reply["task"] === "review") {
foreach ($reply["findings"] as $f) { echo "- ", $f["severity"], " ", $f["action"], "\n"; }
} else {
file_put_contents("dxapp.fixed.json", json_encode($reply["manifest"], JSON_PRETTY_PRINT)); // then run validate_dxapp.py on it
}
using var rdoc = JsonDocument.Parse(outputString); // data.output.output
var reply = rdoc.RootElement;
if (reply.GetProperty("task").GetString() != lane) throw new Exception("the model answered as another lane");
Console.WriteLine($"{reply.GetProperty("status")} {reply.GetProperty("headline")}");
if (lane == "review")
{
foreach (var f in reply.GetProperty("findings").EnumerateArray())
Console.WriteLine("- " + f.GetProperty("action").GetString());
}
else
{
File.WriteAllText("dxapp.fixed.json", reply.GetProperty("manifest").GetRawText()); // then run validate_dxapp.py on it
}
Costs
- The validator is free and runs in the page; 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, not the price.- A run is billed only for what it uses:
charged_creditson the finished job and in thedoneevent, usually far below the hold. - Sponsorship is off: every run is paid from the caller's own balance.
- Runs need a signed-in user token. A guest token can call
/meand/estimateonly; sign in for a personal token on the token page. - A reformat retry (with
retry_note) is a new attempt with its own key 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. - Every flag id in
facts.flagsis answered once inflag_responses, and no other id is; atoolflag of severityhigh(a validator error or crash) is never dismissed or explained. - Review: the status is no looser than the stances imply (an open high flag means
blocked, an open medium flag means at mostfix_before_build); every confirmed or open flag has a finding; build steps usedx build <dir>for an applet and--create-appfor an app, and neverdx envor--yes. - Fix: the corrected manifest, re-validated with the same options, passes when the status is
fixedand holds no[to fill: ...]placeholder; no parameter name, region, instance type or host appears that you did not give; the redaction marker never appears in it. - Every number in the evidence appears in
facts,manifest,contextorquestion.