← DAG Desk / API
Get a token

Driving DAG Desk over HTTP

Everything the page does, you can do from a script. One endpoint does the work; the rest is authentication and polling. The app takes one Airflow DAG file and a task field naming which of five jobs to run over it, and returns a single JSON envelope.

Base URL

https://api.skillsafe.ai/v1/app-api

Every request carries Authorization: Bearer <token>. The token is scoped to this app when it is minted, so no slug header is needed on later calls - POST /guest is the one call that names the slug, in its JSON body. Get a token from the token page without opening a developer console.

The response envelope

Every endpoint returns the same wrapper. Success carries data; failure carries error. Nothing returns a bare value, so a client can branch on the presence of error alone.

{"ok": true,  "data":  { ... }}
{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "…", "details": { ... }}}

Error codes

CodeHTTPWhat it meansWhat to do
UNAUTHORIZED401Missing, malformed or expired token.Mint a new one. Guest tokens expire; personal tokens outlive them.
FORBIDDEN403The token is valid but not for this app.Mint the token against this app: POST /guest with {"slug":"dag-desk"}.
VALIDATION_ERROR400The input did not match the app's shape.Read error.details; it names the offending field.
PAYMENT_REQUIRED402Balance below the run's minimum.Call /estimate first and compare against /me.
RATE_LIMITED429Too many requests.Back off and retry; never tight-loop.
JOB_FAILED200The job reached a terminal failed state.Returned inside a job payload, not as an HTTP error. Check status.

The task field comes first

DAG Desk is a five-lane app. Every run must name its lane in task, because the lanes share one system prompt and one model and are routed by that field. If it is missing or unrecognised the model picks the closest lane and says so by setting lane_inferred to true — which is a fallback, not a feature to rely on.

taskWhat it doesExtra inputartifact.kind
auditReviews the DAG against Airflow authoring practice across twelve named checks.none
migrateReports what breaks on Airflow 3 and rewrites the file for it.python
lineageInfers the data assets the DAG reads and writes, and annotates it.python
testsWrites the pytest suite that guards the DAG.python
diagnoseRoot-causes a failing task run.logpython

Input fields

FieldTypeRequiredNotes
taskstringyesOne of the five lane ids above.
dagstringyesThe full text of the DAG .py file. Several files may be concatenated with # file: path marker lines.
logstringdiagnose onlyThe Airflow task log of the failing run. Send the tail if it is long — the diagnosis is in the last lines.
airflow_sourcestringno2.x, 3.x or unknown. The page infers this from the imports.
airflow_targetstringnoDefaults to 3.x.
prescanobjectno{resources: [], flags: []} — deterministic facts the model must reconcile. See below.
clip_notestringnoTells the model the input was truncated so it writes around the gap instead of inventing it. The page clips from the middle and keeps the head and the tail.
retry_notestringnoSent only on an automatic re-ask after a malformed reply. It quotes the parse error and restates the contract; the model answers the same lane on the same input and returns only the JSON object. The page reuses an idempotency key derived from the same input with the attempt number appended, so a retry cannot double-bill.

The prescan contract

The browser app runs a deterministic scanner before every run and passes its findings in prescan.flags, each with a stable id. The model must return exactly one coverage_check entry per flag id sent, and none for ids that were not sent. That is what makes the free lane able to hold the paid lane accountable — anything unaccounted for is a defect you can detect programmatically:

sent    = {f["id"] for f in payload["prescan"]["flags"]}
covered = {c["flag_id"] for c in result["coverage_check"]}
assert sent == covered, f"unreconciled: {sent - covered}"

Each flag is {id, label, severity, line, occurrences}. Flags are deduplicated by rule id before they are sent, so line is the first line the rule fired on and occurrences is how many times it fired across the whole file. An occurrences of 7 means seven sites need the same change — one coverage_check entry still covers them all.

prescan.resources is a separate array of {id, label} entries naming the connections, hooks, variables, pools and datasets the scanner saw the DAG reference. They are context, not findings: they need no coverage_check entry and the model is instructed not to manufacture a finding just to mention one.

You may send an empty prescan — the lane still works, it simply has fewer facts to ground itself in and nothing to reconcile.

1. A tiny client

Six lines of setup that every later step reuses: the base URL, the app slug header, the bearer token, and a JSON post that raises on the error branch of the envelope. Put your token in an environment variable rather than a literal in the file.

2. Get a token

A guest token is minted on demand and is enough for /me and /estimate. Running a lane is metered, so it needs a personal token — sign in at the token page and copy it from there. Note that every POST /guest mints a new guest identity, so reuse one token across a session rather than minting per request.

3. Check who you are and what you can spend

GET /me is free and tells you the subject type and the credit balance. Compare that balance against the estimate in the next step before you run anything — a 402 after submitting is a failure of the client, not of the user.

4. Price the run before you make it

POST /estimate is free, creates no job and charges nothing. It returns the model, the markup and — the number that matters — hold_credits, the amount reserved for the run. The hold differs per lane, because the prompts and output caps differ, so re-estimate whenever task changes. Present it as reserved, never as the price: the actual charge is usually far lower.

5. Run a lane and poll for the result

POST /run returns a job_id immediately; poll GET /jobs/{id} until status is terminal. Always send an Idempotency-Key derived from the lane plus the input — two lanes over the same DAG are two distinct runs and must not collide on one key, and a retried request with the same key will never bill twice.

6. Stream it instead, for anything interactive

POST /run-stream is the same call over Server-Sent Events. Deltas arrive as they are generated, which is what the page uses to advance its progress stages. The same Idempotency-Key rule applies. Accumulate the deltas and parse the JSON once the stream closes — the envelope is only valid complete.

7. One worked example per lane

Each lane sends the same dag and differs only in task. The envelope is identical across all five; only body and artifact change. Fields shown as ... follow the shapes documented above.

task: "audit" — Audit

Twelve named checks, an inventory, findings with fixes and quick wins. This is the only lane that emits no file.

Request

{
  "task": "audit",
  "dag": "<the DAG source>",
  "airflow_target": "3.x",
  "prescan": {
    "resources": [
      {
        "id": "DAG-1",
        "label": "dag_id orders_etl (context style, line 32)"
      }
    ],
    "flags": [
      {
        "id": "DS-TOPLEVEL-VARIABLE",
        "label": "Variable.get() runs on every scheduler parse",
        "severity": "high",
        "line": 12
      }
    ]
  }
}

Response data.output.output, parsed

{
  "lane": "audit",
  "dag_id": "orders_etl",
  "posture": "blocked",
  "verdict": "A module-level Variable.get() puts a database query on the scheduler's parse loop.",
  "findings": [
    {"id": "DD-001", "title": "Move the Variable lookup out of module scope",
     "severity": "critical", "area": "performance", "task_id": "", "line": 12,
     "evidence": "WAREHOUSE = Variable.get(\"warehouse_conn\")",
     "why": "Module-level code runs on every parse, not once per run.",
     "fix": "Read it inside the task, or template it as {{ var.value.warehouse_conn }}.",
     "fix_code": "def load(**context):\n    warehouse = Variable.get(\"warehouse_conn\")"}
  ],
  "coverage_check": [
    {"flag_id": "DS-TOPLEVEL-VARIABLE", "status": "confirmed", "finding_id": "DD-001", "note": ""}
  ],
  "artifact": {"kind": "none", "filename": "", "content": ""},
  "next_lane": {"lane": "migrate", "reason": "This file will not parse on Airflow 3 at all."},
  "body": {
    "checks": [
      {"name": "Top-level code", "status": "fail",
       "evidence": "Variable.get at line 12",
       "requirement": "Module scope holds only imports, constants and definitions."}
    ],
    "inventory": [
      {"kind": "task", "name": "extract_orders", "value": "PythonOperator",
       "role": "Reads the raw orders CSV from S3."}
    ],
    "quick_wins": ["Delete line 12 - nothing reads WAREHOUSE."]
  }
}

task: "migrate" — Migrate to Airflow 3

A breakage table with the exact replacement for each symbol, an ordered plan, and the whole file rewritten. artifact.content is the complete file, ready to save over the original.

Request

{
  "task": "migrate",
  "dag": "<the DAG source>",
  "airflow_source": "2.x",
  "airflow_target": "3.x"
}

Response data.output.output, parsed

{
  "lane": "migrate",
  "dag_id": "orders_etl",
  "posture": "hardening-recommended",
  "verdict": "Every Airflow 3 breakage here is a rename or an import move.",
  "findings": [ ... ],
  "coverage_check": [ ... ],
  "artifact": {
    "kind": "python",
    "filename": "orders_etl.py",
    "content": "import pendulum\nfrom airflow.sdk import DAG\n..."
  },
  "next_lane": {"lane": "tests", "reason": "The rewrite changed what extract returns."},
  "body": {
    "breakages": [
      {"id": "BR-01", "symbol": "schedule_interval", "removed_in": "3.0",
       "replacement": "schedule", "severity": "critical", "task_id": "", "line": 35,
       "evidence": "schedule_interval=\"@daily\",", "mechanical": true,
       "note": "Pure rename; accepted values unchanged."}
    ],
    "steps": [
      {"order": 1, "title": "Add the standard provider",
       "detail": "The core operator modules moved out of airflow itself.",
       "commands": ["uv add apache-airflow-providers-standard"]}
    ],
    "compat_notes": ["catchup is set explicitly because the default flipped between 2 and 3."]
  }
}

task: "lineage" — Annotate lineage

The data assets the DAG consumes and produces, each with a confidence and the evidence that justified it, plus the annotated file.

Request

{
  "task": "lineage",
  "dag": "<the DAG source>",
  "airflow_target": "3.x"
}

Response data.output.output, parsed

{
  "lane": "lineage",
  "dag_id": "orders_etl",
  "posture": "hardening-recommended",
  "verdict": "This DAG reads one S3 prefix and writes one Postgres table; Airflow knows neither.",
  "findings": [ ... ],
  "coverage_check": [ ... ],
  "artifact": {"kind": "python", "filename": "orders_etl_lineage.py", "content": "..."},
  "next_lane": {"lane": "audit", "reason": "Re-audit the annotated file."},
  "body": {
    "assets": [
      {"name": "orders_raw", "uri": "s3://acme-lake/raw/orders/", "direction": "consumed",
       "task_id": "extract_orders", "confidence": "high",
       "evidence": "pd.read_csv(\"s3://acme-lake/raw/orders/orders.csv\")",
       "reasoning": "A literal S3 URI passed to a read call."}
    ],
    "edges": [{"from": "load_orders", "to": "(consumers)", "asset": "postgres://warehouse/analytics.daily_orders"}],
    "schedule_suggestion": {
      "recommended": true,
      "schedule": "[Asset(\"s3://acme-lake/raw/orders/\")]",
      "reason": "The real trigger is the arrival of the raw file, not the clock."
    },
    "unresolved": ["The file name inside the prefix varies by day."]
  }
}

task: "tests" — Write the tests

Cases split into integrity, unit and behaviour, plus a complete pytest file implementing every one of them. Every body.cases[].name has a matching def in the artifact.

Request

{
  "task": "tests",
  "dag": "<the DAG source>",
  "airflow_target": "3.x"
}

Response data.output.output, parsed

{
  "lane": "tests",
  "dag_id": "orders_etl",
  "posture": "hardening-recommended",
  "verdict": "Testable at the integrity level today; the callables need their I/O separated.",
  "findings": [ ... ],
  "coverage_check": [ ... ],
  "artifact": {"kind": "python", "filename": "test_orders_etl.py", "content": "import pytest\n..."},
  "next_lane": {"lane": "migrate", "reason": "T-01 fails until the import is fixed."},
  "body": {
    "cases": [
      {"id": "T-01", "name": "test_dag_imports_without_errors", "kind": "integrity",
       "target": "orders_etl", "asserts": "DagBag reports no import errors.",
       "patched": [], "why": "The file does not import on Airflow 3 today."}
    ],
    "fixtures": [{"name": "dagbag", "scope": "session", "purpose": "Parse the folder once."}],
    "coverage_gaps": ["Nothing asserts the S3 object exists - that is a data-quality check."],
    "run_command": "pytest tests/test_orders_etl.py -q"
  }
}

task: "diagnose" — Diagnose a failure

The only lane taking a second input. Send log alongside dag — and send the tail if it is long, because a traceback carries its diagnosis in the last lines.

Request

{
  "task": "diagnose",
  "dag": "<the DAG source>",
  "log": "<the Airflow task log>",
  "airflow_target": "3.x"
}

Response data.output.output, parsed

{
  "lane": "diagnose",
  "dag_id": "orders_etl",
  "posture": "hardening-recommended",
  "verdict": "row[\"amount\"] raised TypeError because the rows pulled from XCom are strings.",
  "findings": [ ... ],
  "coverage_check": [ ... ],
  "artifact": {"kind": "python", "filename": "orders_etl_patch.py", "content": "..."},
  "next_lane": {"lane": "tests", "reason": "Pin the fix with a test before it ships."},
  "body": {
    "failure_class": "task-exception",
    "root_cause": {
      "statement": "transform iterated a DataFrame round-tripped through XCom.",
      "confidence": "high",
      "evidence_lines": ["TypeError: string indices must be integers, not 'str'"],
      "dag_line": 27
    },
    "timeline": [{"at": "2026-02-11T03:14:02.881", "event": "Task failed, 1.9s in."}],
    "hypotheses": [
      {"rank": 1, "statement": "The DataFrame did not survive XCom.", "likelihood": "high",
       "check": "airflow tasks test orders_etl extract_orders 2026-02-11"}
    ],
    "blast_radius": ["analytics.daily_orders has no row for that date."],
    "prevention": ["A unit test asserting extract returns a key, not a frame."]
  }
}

Notes that will save you a support round trip