← Apify Desk / API

Apify Desk API

Everything the web app does over the model is one HTTP call. Base URL https://api.skillsafe.ai/v1/app-api. Every request carries Authorization: Bearer <token> and every response uses the same envelope.

The response envelope

Success is {"ok": true, "data": {...}}. Failure is {"ok": false, "error": {"code": "...", "message": "...", "details": {...}}}. Always branch on ok, never on the HTTP status alone.

CodeHTTPWhat it means here
UNAUTHORIZED401Missing, malformed or expired token. Mint a new one on the token page.
PAYMENT_REQUIRED402Balance below min_credits for this lane. Call /estimate first and top up.
VALIDATION_ERROR400The input object is the wrong shape — usually a missing source or an unknown task.
RATE_LIMITED429Back off and retry. Do not tight-loop.
NOT_FOUND404Wrong job id, or a job that belongs to another subject.
INTERNAL500Retry once with the same idempotency key.

The task field comes first

Apify Desk is one app with four lanes over one work object — an Apify Actor project. Every request must set task; it selects the lane, the prompt section, the output body shape and the price. If task is missing the model picks the closest lane and reports lane_inferred: true — usable, but never what you want from a script.

taskWhat that lane returnsbody keys
actorizeThe gap table between the script and an Actor, the file plan, the ordered migration steps, and the rewritten entrypoint as the artifact.gap_table, file_plan, migration_steps
inputEvery input field with its type, editor, requiredness and prefill, grouped into sections, plus a complete INPUT_SCHEMA.json as the artifact.fields, sections, validation_notes
outputThe dataset fields with types and provenance, the table views, the key-value records, plus a complete dataset_schema.json as the artifact.dataset_fields, views, kv_records, output_schema
clientThe apify-client call plan, the input mapping, result and error handling, plus a runnable client module as the artifact.call_plan, input_mapping, result_handling, error_handling

Input fields

Taken from readForm() in app.js — this is exactly what the web app sends.

FieldTypeRequiredNotes
taskstringyesOne of actorize, input, output, client.
sourcestringyesThe pasted Actor project. Separate files with a // file: path line (or # file: path for Python and Dockerfiles). Clipped from the middle at 52,000 characters, both ends kept.
notesstringnoFree text about what the Actor is for. The client lane leans on it hardest — the caller's language, whether it waits or polls, what it does with the dataset — because none of that is in the Actor.
runtimestringnonode, python, mixed or unknown. Omit it and the model reads the runtime off the source itself.
prescanobjectnoWhat the browser reader found: documents, datasetFields, input_properties, checks and numbered flags. Omit it and the model simply has fewer facts — but coverage_check then comes back empty, because there are no flags to reconcile.
clip_notestringnoSend it when you clipped source yourself, so the model writes around the gap rather than inventing across it.

Step 1 — get a token

Open the token page, reveal your token and copy the shell export. It is the same token the web app holds in this browser, so a script and the page share one identity, one balance and one history. Keep it out of source control — export it as APIFY_DESK_TOKEN and read it from there, the way every sample below does.

For an unattended script with no browser, mint a guest token instead. This is the only endpoint that names the app: every other call identifies the app from the token itself, so there is no per-slug path to call.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug": "apify-desk"}'

A guest subject has its own wallet and its own history. Records written under one guest token are invisible to the next one, so keep the token if you want the runs back.

Step 2 — confirm the session and the balance

GET /me is free. It tells you whether the token is a personal or a guest subject and how many credits it can spend.

curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $APIFY_DESK_TOKEN"

Step 3 — price the run before you make it

POST /estimate is free, makes no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits and min_credits for this exact input. The hold differs per lane, so estimate the lane you are about to run — never reuse another lane's number.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $APIFY_DESK_TOKEN" \
  -H "Content-Type: application/json" \
  -d @input.json

Step 4 — run it

POST /run is metered. It returns {"job_id": "..."} immediately; poll GET /jobs/{job_id} until status is succeeded or failed.

Pass Idempotency-Key on every run. The web app derives it from (slug, task, hash(task+source+notes), attempt), so two lanes over the same project are two distinct runs and a retried network call can never bill twice.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $APIFY_DESK_TOKEN" \
  -H "Content-Type: application/json" \
  -d @input.json

Step 5 — poll the job

curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID" \
  -H "Authorization: Bearer $APIFY_DESK_TOKEN"

Step 6 — or stream it

POST /run-stream is the same call with an SSE response: delta events carry output as it is produced, and a final done event carries the whole reply plus charged_credits and truncated. The web app uses this one, and advances its progress card on the section headings arriving in the stream.

curl -sSN -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $APIFY_DESK_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: apify-desk:output:$(date +%s)" \
  -d @input.json

The output contract

Every lane returns the same outer envelope and differs only inside body. This is what normalize() in app.js enforces, so anything below is safe to rely on.

{
  "lane": "output",
  "lane_inferred": false,
  "title": "Example Shop Scraper - dataset and key-value output",
  "posture": "fix-first",
  "verdict": "The Actor pushes seven well-shaped fields but declares no dataset schema.",
  "runtime": "node",
  "actor_name": "example-shop-scraper",
  "summary": "...",
  "assumptions": ["..."],
  "open_questions": ["..."],
  "findings": [
    {"id": "AD-001", "title": "...", "severity": "medium", "area": "output-schema",
     "file": "src/main.js", "line": 16, "evidence": "...", "why": "...",
     "fix": "...", "fix_code": "..."}
  ],
  "coverage_check": [
    {"flag_id": "S11", "status": "confirmed", "finding_id": "AD-001", "note": ""}
  ],
  "artifact": {"kind": "json", "filename": ".actor/dataset_schema.json", "content": "{ ... }"},
  "next_lane": {"lane": "client", "reason": "..."},
  "body": { "dataset_fields": [], "views": [], "kv_records": [] }
}

Unknown values are coerced rather than rejected: an unrecognised posture becomes fix-first, an unrecognised runtime becomes unknown, an unrecognised severity becomes medium, and an artifact whose content is empty is downgraded to kind: "none". A reply that does not name a known lane is the one thing that is rejected outright.

One worked example per lane

task: "actorize" — Actorize the script

{"task": "actorize", "source": "// file: scrape.js\nconst axios = require('axios');\n// ... your script ...", "notes": ""}

Returns posture: "not-actor-yet" for a plain script, a gap row per missing piece, and artifact.kind: "javascript" holding src/main.js rewritten around Actor.init().

task: "input" — Author the input schema

{"task": "input", "source": "// file: src/main.js\nimport { Actor } from 'apify';\n// ...", "notes": "Store-bound; the form must be usable by a non-developer"}

Returns one body.fields entry per option the code really reads, and artifact.content holding a complete .actor/input_schema.json.

task: "output" — Generate the output schemas

{"task": "output", "source": "// file: src/main.js\n// ... await Actor.pushData(item) ...", "notes": "priceCents is minor units"}

Returns one body.dataset_fields entry per field the code pushes — starting from the reader's list — and a complete .actor/dataset_schema.json as the artifact.

task: "client" — Wire the client call

{"task": "client", "source": "// file: src/main.js ... // file: .actor/input_schema.json ...", "notes": "Node caller, runs nightly, writes to Postgres"}

Returns the call plan and a mapping of every required input property, with artifact.kind: "javascript" holding a runnable run-actor.js.

Reconciling the reader

If you send prescan, every flag id in prescan.flags comes back as exactly one coverage_check entry. A flag with no entry is the model ignoring a deterministic fact, and the web app renders that as not accounted for rather than hiding it. Scripts should assert the same thing.