← NB Shift / API
Your token

Driving NB Shift from your own code

Everything the web page does is one HTTP API. The base URL is https://api.skillsafe.ai/v1/app-api, every call carries Authorization: Bearer <token>, and every response is the same envelope.

The task field comes first

NB Shift is one app with four lanes over the same notebook. Every request must carry a task field; it is what routes the run. The four values are:

taskWhat it returnsArtifactBuilt on
auditEight reproducibility checks and a repaired running ordercell-order.md@openai/jupyter-notebook
portSix port checks and a complete marimo notebooknotebook.py@marimo-team/jupyter-to-marimo
wasmFive browser-compatibility checks and a substitution listwasm-changes.md@marimo-team/wasm-compatibility
batchSix schedulability checks and a parameters/runner/failure documentbatch_run.md@marimo-team/marimo-batch

If task is missing or unrecognised the model picks the closest lane, names the lane it chose in lane, and says so in the first sentence of summary. The web UI renders such a reply under the lane you asked for and warns you. Send the field.

The envelope

Success and failure have the same shape, so one branch handles both:

{ "ok": true,  "data": { ... } }
{ "ok": false, "error": { "code": "VALIDATION_ERROR", "message": "..." } }
HTTPerror.codeWhat it means here
401UNAUTHORIZEDMissing, malformed or expired token. Get a fresh one from the token page.
402INSUFFICIENT_CREDITSBalance below min_credits. Call /estimate first; it is free.
403FORBIDDENA guest token on a metered run. Sign in for a personal token.
404NOT_FOUNDWrong slug in the path, or a job id that never existed.
400VALIDATION_ERRORThe input did not match the shape below - most often a missing task or notebook.
429RATE_LIMITEDBack off and retry. Never tight-loop.
5xxINTERNALRetry once with the same Idempotency-Key so you are not billed twice.

Step 1 — get a token

Open the token page, sign in, and press Copy shell export. That gives you NB_TOKEN in your shell. A guest token is enough for /me and /estimate; running a lane is metered and needs a personal token. Never paste a token into source control - read it from your own secret store and keep the placeholder "YOUR_TOKEN" in the samples below.

Step 2 — check the session and the balance

GET /me is free. It tells you whether the token is a guest or a person, and what the balance is - which is what the web UI uses to disable the run button before a 402 can happen.

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

Step 3 — price the run

POST /estimate is free, creates no job, and returns model, model_alias, markup_bps, hold_credits and min_credits. The hold differs per lane, because the prompts and output caps differ - estimate the lane you are about to run, never a different one.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $NB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "audit",
  "notebook": "# document: ipynb (nbformat 4.5)\n# kernel: Python 3 (ipykernel)\n# language: python 3.9.6\n# cells: 5 total, 4 code, 1 markdown, 18 lines\n# stored output: 3 KB (not included below)\n\n# --- cell 0 [markdown]\n# Churn experiments\n\n# --- cell 1 [code] exec=[1] defines=pd,np\nimport pandas as pd\nimport numpy as np\n\n# --- cell 2 [code] exec=[2] defines=df needs=pd\ndf = pd.read_csv(\"/Users/priya/Downloads/churn.csv\")\n\n# --- cell 3 [code] exec=[7] needs=scores\nprint(scores.mean())\n\n# --- cell 4 [code] exec=[4] defines=scores needs=np\nscores = np.random.rand(len(df))\n",
  "depth": "standard",
  "audience": "author",
  "python": "3.12",
  "context": "",
  "prescan_facts": {
    "flags": [
      {
        "id": "nb-hidden-state",
        "severity": "critical",
        "detail": "1 cell references a name defined only in a LATER cell (scores: used in cell 3, defined in cell 4), so the notebook cannot run top to bottom as written"
      },
      {
        "id": "nb-out-of-order",
        "severity": "high",
        "detail": "1 cell carries an execution count lower than the cell above (cell 4 ran as [4] after cell 3 ran as [7]), so this notebook was not executed top to bottom"
      },
      {
        "id": "nb-no-seed",
        "severity": "high",
        "detail": "randomness is used in cell 4 with no seed set anywhere, so two runs cannot be compared"
      },
      {
        "id": "nb-absolute-path",
        "severity": "high",
        "detail": "1 absolute path (/Users/priya/Downloads/churn.csv) ties this notebook to one machine"
      }
    ],
    "resources": [
      {
        "id": "nb-format",
        "detail": "document read as ipynb (4.5)"
      },
      {
        "id": "nb-cells",
        "detail": "4 code cells, 1 markdown cells, 18 lines"
      }
    ],
    "stats": {
      "cellCount": 5,
      "codeCells": 4,
      "importCount": 2,
      "topLevelNames": 4
    }
  }
}'

Step 4 — run a lane and poll for the result

POST /run returns {"job_id": "job_..."} immediately; poll GET /jobs/{id} until status is succeeded or failed. Always send an Idempotency-Key header - a content hash of the notebook plus the lane plus an attempt counter. Retrying with the same key returns the same job instead of billing a second one, and because the key includes the lane, two lanes over the same notebook are correctly two distinct runs.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $NB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "audit",
  "notebook": "# document: ipynb (nbformat 4.5)\n# kernel: Python 3 (ipykernel)\n# language: python 3.9.6\n# cells: 5 total, 4 code, 1 markdown, 18 lines\n# stored output: 3 KB (not included below)\n\n# --- cell 0 [markdown]\n# Churn experiments\n\n# --- cell 1 [code] exec=[1] defines=pd,np\nimport pandas as pd\nimport numpy as np\n\n# --- cell 2 [code] exec=[2] defines=df needs=pd\ndf = pd.read_csv(\"/Users/priya/Downloads/churn.csv\")\n\n# --- cell 3 [code] exec=[7] needs=scores\nprint(scores.mean())\n\n# --- cell 4 [code] exec=[4] defines=scores needs=np\nscores = np.random.rand(len(df))\n",
  "depth": "standard",
  "audience": "author",
  "python": "3.12",
  "context": "",
  "prescan_facts": {
    "flags": [
      {
        "id": "nb-hidden-state",
        "severity": "critical",
        "detail": "1 cell references a name defined only in a LATER cell (scores: used in cell 3, defined in cell 4), so the notebook cannot run top to bottom as written"
      },
      {
        "id": "nb-out-of-order",
        "severity": "high",
        "detail": "1 cell carries an execution count lower than the cell above (cell 4 ran as [4] after cell 3 ran as [7]), so this notebook was not executed top to bottom"
      },
      {
        "id": "nb-no-seed",
        "severity": "high",
        "detail": "randomness is used in cell 4 with no seed set anywhere, so two runs cannot be compared"
      },
      {
        "id": "nb-absolute-path",
        "severity": "high",
        "detail": "1 absolute path (/Users/priya/Downloads/churn.csv) ties this notebook to one machine"
      }
    ],
    "resources": [
      {
        "id": "nb-format",
        "detail": "document read as ipynb (4.5)"
      },
      {
        "id": "nb-cells",
        "detail": "4 code cells, 1 markdown cells, 18 lines"
      }
    ],
    "stats": {
      "cellCount": 5,
      "codeCells": 4,
      "importCount": 2,
      "topLevelNames": 4
    }
  }
}'

The reply text is on data.output.output as a string. Parse the substring from the first { to the last }; the model is instructed to return no prose, but stripping a stray code fence costs nothing.

Step 5 — stream it instead

POST /run-stream is the same request with Accept: text/event-stream. Concatenate the text of every delta event; the concatenation is the JSON object. This is what the web UI uses to advance its progress card on real signal - each stage watches for the section key that starts it.

curl -N -s -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $NB_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: nb-shift:audit:$(date +%s)" \
  -d '{
  "task": "audit",
  "notebook": "# document: ipynb (nbformat 4.5)\n# kernel: Python 3 (ipykernel)\n# language: python 3.9.6\n# cells: 5 total, 4 code, 1 markdown, 18 lines\n# stored output: 3 KB (not included below)\n\n# --- cell 0 [markdown]\n# Churn experiments\n\n# --- cell 1 [code] exec=[1] defines=pd,np\nimport pandas as pd\nimport numpy as np\n\n# --- cell 2 [code] exec=[2] defines=df needs=pd\ndf = pd.read_csv(\"/Users/priya/Downloads/churn.csv\")\n\n# --- cell 3 [code] exec=[7] needs=scores\nprint(scores.mean())\n\n# --- cell 4 [code] exec=[4] defines=scores needs=np\nscores = np.random.rand(len(df))\n",
  "depth": "standard",
  "audience": "author",
  "python": "3.12",
  "context": "",
  "prescan_facts": {
    "flags": [
      {
        "id": "nb-hidden-state",
        "severity": "critical",
        "detail": "1 cell references a name defined only in a LATER cell (scores: used in cell 3, defined in cell 4), so the notebook cannot run top to bottom as written"
      },
      {
        "id": "nb-out-of-order",
        "severity": "high",
        "detail": "1 cell carries an execution count lower than the cell above (cell 4 ran as [4] after cell 3 ran as [7]), so this notebook was not executed top to bottom"
      },
      {
        "id": "nb-no-seed",
        "severity": "high",
        "detail": "randomness is used in cell 4 with no seed set anywhere, so two runs cannot be compared"
      },
      {
        "id": "nb-absolute-path",
        "severity": "high",
        "detail": "1 absolute path (/Users/priya/Downloads/churn.csv) ties this notebook to one machine"
      }
    ],
    "resources": [
      {
        "id": "nb-format",
        "detail": "document read as ipynb (4.5)"
      },
      {
        "id": "nb-cells",
        "detail": "4 code cells, 1 markdown cells, 18 lines"
      }
    ],
    "stats": {
      "cellCount": 5,
      "codeCells": 4,
      "importCount": 2,
      "topLevelNames": 4
    }
  }
}'

Step 6 — one worked request per lane

Same notebook, same everything, one field different. That is the whole router.

task: "audit" — Reproducibility audit

Eight named checks in a fixed order, findings ranked by severity, and artifact.content as a repaired running order in markdown.

The flags sent are the ones relevant to this lane. nb-marimo-redefinition would come back as not-applicable here, because Jupyter permits it - it is the port lane's problem.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $NB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "audit",
  "notebook": "# document: ipynb (nbformat 4.5)\n# kernel: Python 3 (ipykernel)\n# language: python 3.9.6\n# cells: 5 total, 4 code, 1 markdown, 18 lines\n# stored output: 3 KB (not included below)\n\n# --- cell 0 [markdown]\n# Churn experiments\n\n# --- cell 1 [code] exec=[1] defines=pd,np\nimport pandas as pd\nimport numpy as np\n\n# --- cell 2 [code] exec=[2] defines=df needs=pd\ndf = pd.read_csv(\"/Users/priya/Downloads/churn.csv\")\n\n# --- cell 3 [code] exec=[7] needs=scores\nprint(scores.mean())\n\n# --- cell 4 [code] exec=[4] defines=scores needs=np\nscores = np.random.rand(len(df))\n",
  "depth": "standard",
  "audience": "author",
  "python": "3.12",
  "context": "",
  "prescan_facts": {
    "flags": [
      {
        "id": "nb-hidden-state",
        "severity": "critical",
        "detail": "1 cell references a name defined only in a LATER cell (scores: used in cell 3, defined in cell 4), so the notebook cannot run top to bottom as written"
      },
      {
        "id": "nb-out-of-order",
        "severity": "high",
        "detail": "1 cell carries an execution count lower than the cell above (cell 4 ran as [4] after cell 3 ran as [7]), so this notebook was not executed top to bottom"
      },
      {
        "id": "nb-no-seed",
        "severity": "high",
        "detail": "randomness is used in cell 4 with no seed set anywhere, so two runs cannot be compared"
      },
      {
        "id": "nb-absolute-path",
        "severity": "high",
        "detail": "1 absolute path (/Users/priya/Downloads/churn.csv) ties this notebook to one machine"
      }
    ],
    "resources": [
      {
        "id": "nb-format",
        "detail": "document read as ipynb (4.5)"
      },
      {
        "id": "nb-cells",
        "detail": "4 code cells, 1 markdown cells, 18 lines"
      }
    ],
    "stats": {
      "cellCount": 5,
      "codeCells": 4,
      "importCount": 2,
      "topLevelNames": 4
    }
  }
}'

task: "port" — Port to marimo

Six checks, and artifact.content is a complete runnable marimo notebook as plain Python - no fences, no ellipsis.

Two cells defining the same name at the top level is a hard marimo error, and the defines= headers in the digest are how the model knows which ones collide.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $NB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "port",
  "notebook": "# document: ipynb (nbformat 4.5)\n# kernel: Python 3 (ipykernel)\n# language: python 3.9.6\n# cells: 5 total, 4 code, 1 markdown, 18 lines\n# stored output: 3 KB (not included below)\n\n# --- cell 0 [markdown]\n# Churn experiments\n\n# --- cell 1 [code] exec=[1] defines=pd,np\nimport pandas as pd\nimport numpy as np\n\n# --- cell 2 [code] exec=[2] defines=df needs=pd\ndf = pd.read_csv(\"/Users/priya/Downloads/churn.csv\")\n\n# --- cell 3 [code] exec=[7] needs=scores\nprint(scores.mean())\n\n# --- cell 4 [code] exec=[4] defines=scores needs=np\nscores = np.random.rand(len(df))\n",
  "depth": "standard",
  "audience": "author",
  "python": "3.12",
  "context": "",
  "prescan_facts": {
    "flags": [
      {
        "id": "nb-hidden-state",
        "severity": "critical",
        "detail": "1 cell references a name defined only in a LATER cell (scores: used in cell 3, defined in cell 4), so the notebook cannot run top to bottom as written"
      },
      {
        "id": "nb-out-of-order",
        "severity": "high",
        "detail": "1 cell carries an execution count lower than the cell above (cell 4 ran as [4] after cell 3 ran as [7]), so this notebook was not executed top to bottom"
      },
      {
        "id": "nb-no-seed",
        "severity": "high",
        "detail": "randomness is used in cell 4 with no seed set anywhere, so two runs cannot be compared"
      },
      {
        "id": "nb-absolute-path",
        "severity": "high",
        "detail": "1 absolute path (/Users/priya/Downloads/churn.csv) ties this notebook to one machine"
      }
    ],
    "resources": [
      {
        "id": "nb-format",
        "detail": "document read as ipynb (4.5)"
      },
      {
        "id": "nb-cells",
        "detail": "4 code cells, 1 markdown cells, 18 lines"
      }
    ],
    "stats": {
      "cellCount": 5,
      "codeCells": 4,
      "importCount": 2,
      "topLevelNames": 4
    }
  }
}'

task: "wasm" — WASM check

Five checks, and artifact.content is a substitution list in markdown, one section per blocker.

The import availability ratings arrive in prescan_facts.flags as nb-wasm-blocked-package, nb-wasm-limited-package and nb-unknown-package.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $NB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "wasm",
  "notebook": "# document: ipynb (nbformat 4.5)\n# kernel: Python 3 (ipykernel)\n# language: python 3.9.6\n# cells: 5 total, 4 code, 1 markdown, 18 lines\n# stored output: 3 KB (not included below)\n\n# --- cell 0 [markdown]\n# Churn experiments\n\n# --- cell 1 [code] exec=[1] defines=pd,np\nimport pandas as pd\nimport numpy as np\n\n# --- cell 2 [code] exec=[2] defines=df needs=pd\ndf = pd.read_csv(\"/Users/priya/Downloads/churn.csv\")\n\n# --- cell 3 [code] exec=[7] needs=scores\nprint(scores.mean())\n\n# --- cell 4 [code] exec=[4] defines=scores needs=np\nscores = np.random.rand(len(df))\n",
  "depth": "standard",
  "audience": "author",
  "python": "3.12",
  "context": "",
  "prescan_facts": {
    "flags": [
      {
        "id": "nb-hidden-state",
        "severity": "critical",
        "detail": "1 cell references a name defined only in a LATER cell (scores: used in cell 3, defined in cell 4), so the notebook cannot run top to bottom as written"
      },
      {
        "id": "nb-out-of-order",
        "severity": "high",
        "detail": "1 cell carries an execution count lower than the cell above (cell 4 ran as [4] after cell 3 ran as [7]), so this notebook was not executed top to bottom"
      },
      {
        "id": "nb-no-seed",
        "severity": "high",
        "detail": "randomness is used in cell 4 with no seed set anywhere, so two runs cannot be compared"
      },
      {
        "id": "nb-absolute-path",
        "severity": "high",
        "detail": "1 absolute path (/Users/priya/Downloads/churn.csv) ties this notebook to one machine"
      }
    ],
    "resources": [
      {
        "id": "nb-format",
        "detail": "document read as ipynb (4.5)"
      },
      {
        "id": "nb-cells",
        "detail": "4 code cells, 1 markdown cells, 18 lines"
      }
    ],
    "stats": {
      "cellCount": 5,
      "codeCells": 4,
      "importCount": 2,
      "topLevelNames": 4
    }
  }
}'

task: "batch" — Scheduled batch run

Six checks, and artifact.content carries exactly three ## sections: Parameters, The runner, Failure handling.

Set audience: "ops" for this lane and the answer leads with what breaks unattended rather than with code style.

curl -s -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $NB_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "batch",
  "notebook": "# document: ipynb (nbformat 4.5)\n# kernel: Python 3 (ipykernel)\n# language: python 3.9.6\n# cells: 5 total, 4 code, 1 markdown, 18 lines\n# stored output: 3 KB (not included below)\n\n# --- cell 0 [markdown]\n# Churn experiments\n\n# --- cell 1 [code] exec=[1] defines=pd,np\nimport pandas as pd\nimport numpy as np\n\n# --- cell 2 [code] exec=[2] defines=df needs=pd\ndf = pd.read_csv(\"/Users/priya/Downloads/churn.csv\")\n\n# --- cell 3 [code] exec=[7] needs=scores\nprint(scores.mean())\n\n# --- cell 4 [code] exec=[4] defines=scores needs=np\nscores = np.random.rand(len(df))\n",
  "depth": "standard",
  "audience": "ops",
  "python": "3.12",
  "context": "",
  "prescan_facts": {
    "flags": [
      {
        "id": "nb-hidden-state",
        "severity": "critical",
        "detail": "1 cell references a name defined only in a LATER cell (scores: used in cell 3, defined in cell 4), so the notebook cannot run top to bottom as written"
      },
      {
        "id": "nb-out-of-order",
        "severity": "high",
        "detail": "1 cell carries an execution count lower than the cell above (cell 4 ran as [4] after cell 3 ran as [7]), so this notebook was not executed top to bottom"
      },
      {
        "id": "nb-no-seed",
        "severity": "high",
        "detail": "randomness is used in cell 4 with no seed set anywhere, so two runs cannot be compared"
      },
      {
        "id": "nb-absolute-path",
        "severity": "high",
        "detail": "1 absolute path (/Users/priya/Downloads/churn.csv) ties this notebook to one machine"
      }
    ],
    "resources": [
      {
        "id": "nb-format",
        "detail": "document read as ipynb (4.5)"
      },
      {
        "id": "nb-cells",
        "detail": "4 code cells, 1 markdown cells, 18 lines"
      }
    ],
    "stats": {
      "cellCount": 5,
      "codeCells": 4,
      "importCount": 2,
      "topLevelNames": 4
    }
  }
}'

The input contract

FieldTypeRequiredNotes
taskstringyesaudit, port, wasm or batch. The router field.
notebookstringyesThe cell-structured digest. See below.
depthstringnostandard (default), exhaustive, triage.
audiencestringnoauthor (default), reviewer, ops.
pythonstringno3.12, 3.11, 3.13 or unchanged.
contextstringnoAnything the notebook does not say. Capped at 2000 characters.
prescan_factsobjectno{flags:[{id,severity,detail}], resources:[{id,detail}], stats:{}}. Every flag id sent must come back in coverage_check.

Building the notebook digest yourself

The web UI never sends raw .ipynb JSON. It sends a digest, because stored outputs are usually the bulk of a notebook and are usually base64 images that carry no information the model can use. If you are calling the API directly, produce the same shape:

# document: ipynb (nbformat 4.5)
# kernel: Python 3 (ipykernel)
# language: python 3.11.4
# cells: 12 total, 9 code, 3 markdown, 340 lines
# stored output: 812 KB (not included below)

# --- cell 0 [markdown]
...markdown source...

# --- cell 3 [code] exec=[7] defines=df,scaler needs=raw_path outputs=2 outputs/image
...python source...

The headers are the model's evidence, not decoration: without defines= it cannot tell you which cells collide, and without exec= it cannot tell you the notebook ran backwards.

The output contract

One JSON object. The same envelope for every lane; only the inner content differs.

FieldTypeNotes
lanestringEquals the task that was sent.
title, headline, summarystringShort, specific, about this notebook.
verdictstringready, needs-work or blocked. Anything else is coerced to needs-work.
checks[]array{check, status, evidence, requirement}; status in pass|warn|fail|unknown. A row with an empty check is dropped.
findings[]array{id, severity, area, cell_ref, problem, impact, fix, code}; severity in critical|high|medium|low. A finding with neither problem nor fix is dropped.
artifactobject{filename, language, note, content}. The lane's deliverable, in full.
steps[]arrayStrings, in the order to do them.
coverage_check[]array{flag_id, status, note}; status in confirmed|cleared|not-applicable.
assumptions[], open_questions[]arrayStrings.

A reply with empty findings, empty checks and an empty artifact.content is rejected by the client as unrenderable and retried once with a reformat note - reusing an idempotency key derived from the same input, so the retry cannot double-bill.

Check the reconciliation. Cross-reference coverage_check[].flag_id against the flag ids you sent. A flag that was sent and never answered is unreviewed, not cleared - the web UI prints exactly that, and your own client should too.