← dxapp Desk / API
Tokens

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

tasksendyou get back
reviewfacts, manifest; context and question optionalstatus (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.
fixfacts, manifest; context, question and review optionalstatus (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.

fieldtyperequiredmeaning
taskstringyes"review" or "fix".
factsstringyesThe validator report, the page's summary and the numbered flags, as JSON text (below).
manifeststringyesThe 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).
contextstringnoThe region, what the app does at run time, how long jobs take - never a token. Up to about 3,000 characters.
questionstringnoA question to answer in the reply.
reviewstringnoFix lane: an earlier review as text (the page fills it from the review's status, stances and findings).
retry_notestringnoOnly 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

statuscodewhat to do
400validation_errorA field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that sends the token, unwraps data and raises on ok: false. The token comes from the token page (Copy token or Copy shell export); step 2 covers the kinds of token and minting one from code.

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

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

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

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.

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

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}

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

Costs

Invariants worth asserting