← Py Shift / API
Tokens

Drive Py Shift from your own code

Everything the web page does is available over HTTP: paste a Python project's packaging and tooling configuration in, get the same structured migration plan onto uv, ruff, ty, pytest, prek and a real security lane back. The natural use is a CI job that re-plans whenever pyproject.toml changes and fails the build when the posture regresses, or a script that runs the same twelve-check pass across every repository in an organisation and reports which ones are still on Poetry.

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":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

Send your app slug as X-App-Slug: py-shift and your token as Authorization: Bearer … on every call.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Check the X-App-Slug header.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The input object is missing a required field — files is the usual one — or a field is the wrong type. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure, reported as server_error on a plain 500. Retry with the SAME Idempotency-Key so you are not billed twice.

1. 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. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. Planning a migration is metered, so it needs a personal token from signing in.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://py-shift.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; planning a migration needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "X-App-Slug: py-shift"
# {"ok":true,"data":{"token":"sk_guest_...","subject_type":"guest"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# 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="py-shift"
TOKEN="$SKILLSAFE_TOKEN"   # from https://py-shift.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "X-App-Slug: $SLUG" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG"
  fi
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is guest or user — a guest can price a run but cannot start one — and credits is the wallet balance in credits. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

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

4. Price the run — free

The input object is exactly what the app's own form submits:

fieldtypemeaning
filesstring, requiredThe pasted configuration — pyproject.toml, setup.py, setup.cfg, requirements*.txt, Pipfile, .pre-commit-config.yaml, mypy.ini, .flake8, tox.ini, a Makefile, a CI workflow, a Python source file or two. Any subset. Put a # file: pyproject.toml marker line above each one so they can be told apart. This is the plan's only evidence. A file whose middle has been removed should say so with a # [... clipped ...] comment.
targetstringWhat the project is: package (distributable, published), application (deployed, not published), library (internal, imported by other repositories) or script (one or a few standalone files). It changes the src/ layout recommendation, whether the lockfile argument applies, and whether extras-versus-groups has a user-facing consequence.
floorstringThe Python floor to plan toward: 3.11, 3.12, 3.13, or keep to plan around the current floor without raising it. With keep and a floor below 3.11, the plan says in assumptions which parts of the template that rules out.
focusstringgeneral, dependencies, lint, types, testing, security or scripts. Emphasis, not exclusivity: a critical step from another phase is never suppressed.
contextstring, optionalFree-form notes: team size, CI provider, deadline, what has already been tried, what the team refuses to change. An explicit refusal — "we are keeping Poetry", "we cannot move off 3.9" — is respected and recorded in assumptions rather than argued with.
prescan_factsobject{resources: [{id,label}], flags: [{id,label}]} — see below.
retry_notestring, optionalSend only on a retry, when a previous reply failed to parse or came back truncated. The instruction is obeyed exactly.

prescan_facts, honestly

In the browser, prescan_facts is computed for free before the run by a local TOML and INI reader: resources is what it read — the sections, the build backend, the declared dependencies, the floor, the layout, the tools each config file configures — and flags is the set of deterministic checks that fired. An API caller does not have to reproduce any of that. Sending {"resources": [], "flags": []} is legitimate and the plan still works; the model reads files either way.

What makes it worth sending is the reconciliation contract: every id you send in flags must come back exactly once in coverage_check. That turns a fact your own tooling already established into something the plan is held to. An entry with addressed: false is a correct answer — the model deliberately setting a flag aside, with the reason in note — and is a different thing from silence. A flag that never appears at all is a failed run, not a passing one. Ids you did not send should not appear either.

These are the flag ids an API caller is most likely to want to raise by hand:

idfires when
MGR-POETRYPoetry manages the project — [tool.poetry], a poetry.lock, or a poetry-core build backend.
SETUP-PYA setup.py still carries metadata or a setup() call.
REQ-TXTDependencies are declared in a requirements*.txt rather than in pyproject.toml.
EXTRAS-DEVDev tooling is declared under [project.optional-dependencies], so it ships to users.
NO-GROUPSNo [dependency-groups] table exists at all.
FLOOR-LOWrequires-python is below the planned floor, or is missing.
TOOL-BLACKblack is configured or declared as a dependency.
TOOL-ISORTisort is configured or declared as a dependency.
TOOL-FLAKE8flake8 is configured — .flake8, setup.cfg or tox.ini.
TOOL-MYPYmypy is configured — [tool.mypy] or mypy.ini.
NO-RUFFruff is nowhere in the paste.
RUFF-NARROWruff is present but select is a short list such as ["E", "F"] — the flake8 defaults wearing a new name.
NO-TYNo [tool.ty] configuration.
TY-ENV-KEYpython-version sits at the top of [tool.ty] instead of under [tool.ty.environment], where it is silently ignored.
LAYOUT-FLATThe package sits at the repository root rather than under src/.
NO-LOCKNo uv.lock, or only a foreign lockfile.
NO-COV-FLOORCoverage runs without --cov-fail-under.
PRECOMMIT-LEGACYHooks run through pre-commit rather than prek.
NO-SECURITYNone of pip-audit, detect-secrets, actionlint or zizmor appears.
NO-DEPENDABOTNo Dependabot configuration or equivalent update automation.
UV-PIP-INSTALLuv pip install is used as the workflow rather than as a compatibility shim.
VENV-ACTIVATEA source .venv/bin/activate line appears in a Makefile, a README or a CI job.
SCRIPT-NO-PEP723A standalone script imports third-party packages without PEP 723 inline metadata.

/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, and sponsor_enabled says whether the app is covering the run. The hold is a reservation, not the price. It prices the full output cap, so the charged_credits you see after settlement is usually far lower — often a small fraction of the hold. Budget against hold_credits, report against charged_credits.

INPUT='{"files": "# file: pyproject.toml\n[tool.poetry]\nname = \"acme-cli\"\nversion = \"1.4.0\"\n\n[tool.poetry.dependencies]\npython = \"^3.9\"\nrequests = \"^2.31\"\n\n[tool.poetry.group.dev.dependencies]\nblack = \"^24.3\"\nflake8 = \"^7.0\"\nmypy = \"^1.9\"\n\n[build-system]\nrequires = [\"poetry-core\"]\nbuild-backend = \"poetry.core.masonry.api\"\n\n# file: Makefile\nlint:\n\tsource .venv/bin/activate; black .; flake8 acme_cli", "target": "package", "floor": "3.12", "focus": "dependencies", "context": "Four engineers, GitHub Actions, published to an internal index. We are willing to move off Poetry this quarter.", "prescan_facts": {"resources": [{"id": "RES-PYPROJECT", "label": "pyproject.toml, 4 tables, no [project]"}, {"id": "RES-MAKEFILE", "label": "Makefile with a lint target"}], "flags": [{"id": "MGR-POETRY", "label": "[tool.poetry] with a poetry-core build backend"}, {"id": "FLOOR-LOW", "label": "python = ^3.9 is below the 3.12 floor"}, {"id": "TOOL-BLACK", "label": "black declared in the dev group"}, {"id": "VENV-ACTIVATE", "label": "source .venv/bin/activate in Makefile:lint"}]}}'

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":2140,"min_credits":310,"sponsor_enabled":false}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The plan JSON is the string at data.output.output. The terminal job also carries charged_credits — the real price — and the truncated flag.

Always send an Idempotency-Key. It is not formally required by the endpoint, and it is required in practice: derive it from the input as the web app does, a content hash plus an attempt counter (py-shift:<hash>:a<attempt>). A retried request carrying the same key returns the same job instead of billing a second run, which is what makes a CI retry safe after a network blip. Replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever the input actually changed.

# 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.
KEY="py-shift:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until the job reaches a terminal status.
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

# The terminal job looks like this:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"plan_name\":\"acme-cli — Poetry to uv migration\", ...}"},
#   "charged_credits":486,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

6. Or stream it

POST /run-stream is the same call over server-sent events. Each delta event carries {"text": "..."}, a chunk of the plan JSON, and the final done event carries status, charged_credits — the real price, normally a fraction of the hold — and the truncated flag.

The practical tip: the web app does not parse the partial JSON to drive its progress display, it watches for key names arriving in the accumulating text. The appearance of "steps" or "new_pyproject" is what advances the stage from working the twelve checks to ordering the migration steps and then to rewriting pyproject.toml. Substring matching on the quoted key name is enough, and it costs nothing.

# Server-sent events. Each `delta` carries a chunk of the JSON plan; the final
# `done` event carries the status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-App-Slug: $SLUG" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"plan_name\":\"acme-cli"}
# event: delta  {"text":" — Poetry to uv migration\","}
# event: done   {"status":"succeeded","charged_credits":486,"truncated":false}

7. Parse the plan

data.output.output is a string holding one JSON object. The web app strips an optional code fence, takes everything from the first { to the last }, parses that, and then normalizes it. Doing the same two things — the slice and the normalization — is what makes a caller robust against the small variations a model produces.

Here is an abbreviated plan for the Poetry project above, structurally complete:

{
  "plan_name": "acme-cli — Poetry to uv migration",
  "posture": "legacy",
  "verdict": "Everything else waits on the manager: the floor, the dev tooling and the lockfile are all declared in tables uv does not read.",
  "python_floor": "^3.9 (from [tool.poetry.dependencies])",
  "toolchain": "poetry + black + flake8 + mypy",
  "project_kind": "distributable-package",
  "target_python": "3.12",
  "exec_summary": "acme-cli is a Poetry project with a caret floor of 3.9 and the black/flake8/mypy shelf declared as a Poetry dev group...",
  "assumptions": ["CI is GitHub Actions; the paste shows a Makefile but no workflow."],
  "open_questions": ["Does anything outside this repository install acme-cli from the index?"],
  "inventory": [
    { "kind": "section", "name": "[tool.poetry]", "value": "name=acme-cli, version=1.4.0",
      "role": "package metadata, moves to [project]" },
    { "kind": "backend", "name": "poetry.core.masonry.api", "value": "requires = [\"poetry-core\"]",
      "role": "build backend, replaced by uv_build" },
    { "kind": "tool", "name": "black", "value": "^24.3, dev group", "role": "formatter, replaced by ruff format" }
  ],
  "checks": [
    { "check": "dependency manager", "status": "fail",
      "evidence": "[tool.poetry] plus a poetry-core build backend",
      "requirement": "uv init --bare, then uv add each runtime dependency." },
    { "check": "dependency groups", "status": "partial",
      "evidence": "[tool.poetry.group.dev.dependencies] exists, but it is Poetry's dialect, not PEP 735",
      "requirement": "Declare dev tooling under [dependency-groups]." }
  ],
  "steps": [
    {
      "id": "MP-001",
      "phase": "dependencies",
      "risk": "medium",
      "effort": "medium",
      "priority": "critical",
      "target": "pyproject.toml [tool.poetry.dependencies]",
      "problem": "requests is declared under [tool.poetry.dependencies], which uv does not read.",
      "impact": "uv sync installs nothing, so every later step is untestable.",
      "action": "Initialise uv and re-add the one runtime dependency, then delete the Poetry tables.",
      "commands": ["uv init --bare", "uv add requests"],
      "snippet": "[project]\nname = \"acme-cli\"\nrequires-python = \">=3.12\"\ndependencies = [\"requests>=2.31\"]"
    },
    {
      "id": "MP-002",
      "phase": "lint",
      "risk": "low",
      "effort": "low",
      "priority": "high",
      "target": "pyproject.toml [tool.poetry.group.dev.dependencies]",
      "problem": "black and flake8 are two tools where ruff is one.",
      "impact": "Two config surfaces, two CI steps, and pyupgrade-class fixes nobody is running.",
      "action": "Replace black and flake8 with ruff, selecting ALL with a short explicit ignore list.",
      "commands": ["uv remove --group dev black flake8", "uv add --group dev ruff"],
      "snippet": "[tool.ruff.lint]\nselect = [\"ALL\"]\nignore = [\"D203\", \"D213\", \"COM812\"]"
    }
  ],
  "coverage_check": [
    { "id": "MGR-POETRY",    "addressed": true,  "note": "Confirmed; it is MP-001." },
    { "id": "FLOOR-LOW",     "addressed": true,  "note": "MP-003 raises requires-python to >=3.12." },
    { "id": "TOOL-BLACK",    "addressed": true,  "note": "MP-002 replaces it with ruff format." },
    { "id": "VENV-ACTIVATE", "addressed": false, "note": "The Makefile goes away with MP-002; no separate step." }
  ],
  "new_pyproject": "[project]\nname = \"acme-cli\"\nversion = \"1.4.0\"\nrequires-python = \">=3.12\"\ndependencies = [\"requests>=2.31\"]\n\n[build-system]\nrequires = [\"uv_build>=0.9\"]\nbuild-backend = \"uv_build\"\n\n[dependency-groups]\ndev = [\"ruff>=0.14\", \"ty>=0.0.1a\", \"pytest>=8.1\", \"pytest-cov>=5.0\"]\n\n[tool.ruff.lint]\nselect = [\"ALL\"]\nignore = [\"D203\", \"D213\", \"COM812\"]\n\n[tool.ty.environment]\npython-version = \"3.12\"\n\n[tool.pytest.ini_options]\naddopts = \"--cov=acme_cli --cov-fail-under=72\"",
  "deletions": ["poetry.lock — superseded by uv.lock once uv sync has run."],
  "commands": ["uv sync --all-groups", "uv run ruff check", "uv run ty check src/", "uv run pytest"],
  "quick_wins": ["Swap pre-commit for prek: the same .pre-commit-config.yaml, no Python runtime of its own."],
  "focus_areas": [
    { "area": "Getting off Poetry", "why": "Nothing else can be verified until uv owns the environment.",
      "step_ids": ["MP-001", "MP-002"] }
  ],
  "summary": "Do MP-001 and MP-002 in one afternoon; the security lane can follow next sprint."
}

What the normalizer does to it

The web app does not trust the reply verbatim, and neither should a caller. These are the behaviours you will actually hit:

Invariants worth asserting in CI

# The plan JSON is a string inside the envelope, so unwrap it twice.
PLAN=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')

printf '%s' "$PLAN" | python3 -c '
import sys, json
p = json.load(sys.stdin)
print(p["posture"], "|", p["verdict"])
print(p["toolchain"], "->", p["target_python"])
for c in p["checks"]:
    print("  %-8s %s" % (c["status"], c["check"]))
for s in p["steps"]:
    print("  %s %-9s %-12s %s" % (s["id"], s["priority"], s["phase"], s["target"]))
'

# Every prescan flag id must come back exactly once in coverage_check.
printf '%s' "$PLAN" | python3 -c '
import sys, json
seen = [c["id"] for c in json.load(sys.stdin)["coverage_check"]]
want = ["MGR-POETRY", "FLOOR-LOW", "TOOL-BLACK", "VENV-ACTIVATE"]
missing = [i for i in want if seen.count(i) != 1]
if missing:
    raise SystemExit("unreconciled prescan flags: " + ", ".join(missing))
print("coverage_check reconciles")
'

# The rewritten pyproject.toml must not carry a legacy table forward.
printf '%s' "$PLAN" | python3 -c '
import sys, json
tables = ["[tool.poetry]", "[tool.black]", "[tool.isort]", "[tool.flake8]", "[tool.mypy]", "[tool.pyright]"]
body = json.load(sys.stdin)["new_pyproject"]
left = [t for t in tables if t in body]
if left:
    raise SystemExit("new_pyproject still contains: " + ", ".join(left))
print("new_pyproject is clean")
'

The output contract

Every key in the object, as the web app reads it:

keytypemeaning
plan_namestringShort title naming the project and the migration, e.g. "acme-cli — Poetry to uv migration". Empty becomes Untitled migration plan.
postureenummodern, partial or legacy. Anything else normalizes to partial. The single value a CI gate should branch on.
verdictstringOne sentence naming the single thing that decides the posture.
python_floorstringThe floor the paste declares, quoted with where it came from — "^3.9 (from [tool.poetry.dependencies])". Empty becomes unknown.
toolchainstringThe tooling the paste actually declares, as a short chain: "poetry + black + isort + flake8 + mypy". Empty becomes unknown.
project_kindstringWhat the project turned out to be, in the plan's own words — distributable-package, deployed-application, internal-library, standalone-script. Empty becomes unknown.
target_pythonstringThe floor the plan is written against, echoing floor unless it was keep. Empty becomes unknown.
exec_summarystringTwo to five sentences a tech lead can act on without reading the rest.
assumptionsstring[]What had to be assumed because the paste is silent — the CI provider, whether a tests/ directory exists off-screen, whether the package is published. An explicit refusal in context is recorded here too.
open_questionsstring[]Questions whose answers would change the plan or its ordering.
inventoryobject[]{kind, name, value, role} — the configuration sections, the build backend, the declared runtime and dev dependencies, the tools each config file configures, the layout and the floor. An entry with neither kind nor name is dropped.
checksobject[]{check, status, evidence, requirement}. Always the same twelve entries in the same fixed order — render by index, do not search by name. An entry with no check string is dropped.
stepsobject[]{id, phase, risk, effort, priority, target, problem, impact, action, commands, snippet}. Ids are sequential MP-001, MP-002, … in execution order. commands is the exact shell for that step; snippet is the corrected configuration fragment, or "".
coverage_checkobject[]{id, addressed, note}. One entry per prescan_facts.flags id, exactly once, and no ids the prescan did not send. addressed: false means deliberately set aside, with the reason in note.
new_pyprojectstringThe whole rewritten pyproject.toml, as pasteable TOML in one JSON string: [project], [build-system] on uv_build where appropriate, [dependency-groups], [tool.ruff], [tool.pytest.ini_options], [tool.ty.environment]. Never a legacy table. "" when the paste gives nothing to base it on.
deletionsstring[]Files to delete once the migration is verified, one per line, each with its reason.
commandsstring[]Top-level verification commands, in the order they should be run against the repository after the steps are done — uv sync --all-groups, uv run ruff check, uv run ty check src/, uv run pytest, uv build.
quick_winsstring[]Changes worth doing today regardless of the rest, one line each.
focus_areasobject[]{area, why, step_ids}. Ids that match no step are dropped silently; an entry with no area is dropped entirely.
summarystringOne paragraph closing the plan.

The enums

fieldvaluesnotes
posturemodern, partial, legacymodern: already on uv, ruff and ty, and the plan spends itself on what is left — a wider select, zizmor, a committed lockfile, a coverage floor. partial: some of the stack has landed and the rest has not. legacy: the manager or the lint-and-type shelf is still the old one. Anything the model returns outside these three normalizes to partial.
steps[].phasedependencies, layout, build, lint, types, testing, security, ci, scripts, cleanupRoughly the execution order: the manager and dependencies first, because everything else is installed by them; cleanup and deletions last, because deleting setup.py before uv init --bare has written a pyproject.toml loses metadata. An unrecognised phase normalizes to cleanup.
steps[].risk
steps[].effort
low, medium, highRisk is what the change could break; effort is how long it takes. Both default to medium when unrecognised.
steps[].prioritycritical, high, medium, lowcritical is reserved for something that blocks the rest of the migration or is actively wrong today — dependencies declared where uv will not read them, a [tool.ty] python-version at the top level so type checking silently runs against the wrong Python, a secret committed in a workflow. Unrecognised values normalize to medium.
checks[].statuspass, fail, partial, unknownunknown is a legitimate answer and is preferred over a guess: a paste with no CI workflow cannot prove dependency automation. partial means the practice is present but incomplete — ruff configured with a narrow select, a dev group that is Poetry's dialect rather than PEP 735. Unrecognised values normalize to unknown.

The twelve checks

checks always carries these twelve check strings, in this order, on every run — so a table can be rendered by index and two plans for the same repository are diffable row by row:

 1.  dependency manager        7.  lint and format
 2.  dependency groups         8.  type checking
 3.  python floor              9.  testing and coverage
 4.  project layout           10.  pre-commit hooks
 5.  build backend            11.  security scanning
 6.  lockfile                 12.  dependency automation

The plan never echoes a secret value. If the paste contains a PyPI upload token, an API key in a workflow or a password in a URL, the step names the setting and says to rotate it — the value itself does not appear in problem, snippet or new_pyproject.

8. Use it in CI

The worked example: a job reads pyproject.toml out of the repository, runs a plan, and exits non-zero when posture is anything but modern or when any step comes back critical. Derive the Idempotency-Key from the file contents so a re-run of the same commit replays the same job instead of re-billing, and only bump the attempt suffix when the configuration actually changed. Point the gate at a scheduled job as well as at pull requests: the posture can regress without pyproject.toml changing at all, because a new advisory or a new ruff release moves what pass means.

#!/bin/sh
# py-shift-gate.sh - fail the build when the packaging posture regresses.
set -eu

BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="py-shift"
TOKEN="$SKILLSAFE_TOKEN"   # from https://py-shift.skillsafe.ai/tokens.html

# 1. Collect whatever the repository has, each behind a marker line.
FILES=$(
  for f in pyproject.toml requirements-dev.txt .pre-commit-config.yaml Makefile; do
    [ -f "$f" ] || continue
    printf '# file: %s\n' "$f"
    cat "$f"
    printf '\n'
  done
)

# 2. Build the input. An API caller may send empty prescan facts; the plan still works.
INPUT=$(FILES="$FILES" python3 -c '
import json, os
print(json.dumps({
    "files": os.environ["FILES"],
    "target": "package",
    "floor": "3.12",
    "focus": "general",
    "context": "CI gate on every pull request.",
    "prescan_facts": {"resources": [], "flags": []},
}))')

KEY="py-shift:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG" \
  -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=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG")
  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

# 3. Gate on the posture and on any critical step.
printf '%s' "$OUT" | python3 -c '
import sys, json
job = json.load(sys.stdin)["data"]
if job.get("truncated"):
    raise SystemExit("::error::py-shift: reply was truncated, plan is incomplete")
plan = json.loads(job["output"]["output"])
crit = [s["id"] for s in plan["steps"] if s["priority"] == "critical"]
print(plan["posture"], "-", plan["verdict"])
if plan["posture"] != "modern" or crit:
    raise SystemExit("::error::py-shift: posture=%s, critical=%s" % (plan["posture"], ",".join(crit) or "none"))
print("py-shift: modern, nothing critical")
'

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and comes back with truncated: true on the finished job and on the streaming done event. What you hold then is a prefix of the plan, not the plan — the twelve checks may be complete while new_pyproject, deletions and summary are missing or cut mid-string.

Check the flag before you treat a plan as complete. The right response is a retry, not a repair: resubmit with a retry_note asking for fewer, denser steps and a shorter new_pyproject, and with the attempt suffix on the Idempotency-Key incremented so the new body is not a replay of the old key. Repairing truncated JSON by appending closing braces produces something that parses and is not what the model meant.