← ORM v7 Upgrade Check / API
Tokens

Drive ORM v7 Upgrade Check from your own code

ORM v7 Upgrade Check moves one project from Prisma ORM 6 to Prisma ORM 7. In the page, and free, a deterministic checker (p7scan.js with deskkit.js) reads your schema.prisma, your Prisma code, package.json, tsconfig.json and the variable names in .env, lists every Prisma 7 breaking change it can see as numbered findings, picks the driver adapter and module format, and drafts the new generator and datasource blocks, prisma.config.ts and a client file.

The two metered lanes read what the checker found: audit answers every finding and orders the work; rewrite writes the Prisma 7 files. This page documents calling those lanes over HTTP. Everything about the HTTP API below is taken from the platform SDK the app ships (/sdk.js); everything about the input and the reply is taken from the app's own code and its output contract.

Two lanes: the task field

Every request body is one JSON object, and its task field picks the lane.

tasksendyou get back
audittask, schema, facts; code and note optionalheadline, verdict (upgrade, stay_on_v6, already_v7), effort, a stance on every finding (finding_responses), extra_findings the checker could not see, an ordered plan with commands and files, and to_confirm.
rewritetask, schema, facts; code, note and audit optionalheadline, schema_blocks (the new generator and datasource blocks only), config (the whole prisma.config.ts), files (whole changed code files), package_changes, tsconfig_changes, finding_responses, changes, commands and to_confirm.

If task is missing or unrecognised, the model is told to pick the closer lane and say which in the reply's task. Always send it, and check the reply's task against it. A MongoDB project (facts.status stay_on_v6) is audit-only in the page: Prisma 7 has no MongoDB connector.

Input fields

Every field is a string. facts (and audit) are JSON text, not nested objects.

fieldrequiredmeaning
taskyes"audit" or "rewrite".
schemayesschema.prisma as text. The page sends it whole up to 30,000 characters; above that it sends the generator, datasource and enum blocks whole and lists the models by name after a [... ORM v7 Upgrade Check cut marker (a v7 upgrade does not change models).
factsyesWhat the browser checker established, as a JSON string (below).
codenoSource files, each introduced by a // file: <path> line. The page sends up to 24,000 characters, files that touch Prisma first; a file that does not fit is sent as the lines around its Prisma code, marked with [... ORM v7 Upgrade Check cut, and the rewrite lane then describes its edits instead of returning it.
notenoWhat you want or saw (at most 2,000 characters in the page).
auditno, rewrite onlyA JSON string summarising an earlier audit of the same project. The page sends {"verdict", "effort", "headline", "confirmed": [ids], "needs_you": [{"ref","note"}], "extra_findings": [{"title","severity","where","fix"}], "plan": ["title: detail"], "to_confirm": [...]}, at most 9,000 characters.
retry_notenoOnly on the page's one reformat retry, when a reply was not one JSON object. Leave it out normally.

The facts string

In the page, DeskKit.facts() builds this object and DeskKit.buildInput() stores it with JSON.stringify. An API caller must build facts itself: nothing on the server computes it. Its keys:

keywhat the browser puts there
statusThe page's overall reading: upgrade, stay_on_v6 (MongoDB) or already_v7.
findingsAt most 60 findings, ids F1, F2, ...: id, severity (blocker, high, medium, info), source (schema, code, package, tsconfig, env), code (the check, e.g. ds-url), file, line, text, quote and fix. findings_total is the full count. The reply answers every id exactly once.
contextThe checker's decisions: adapter, format (esm/cjs) and format_reason, schema_path, output, client_path, import_path (the import that reaches the generated client from client_path), bun, language, provider, config_seen, config_path.
adaptersThe chosen driver adapter: id, label, packages, class, import_from, why and constructor (the PrismaClient expression, reading the URL from the environment). null when no adapter applies.
versionslatest_7, latest_7_date, latest_6, node (prisma 7's engines range), typescript, checked.
packageA summary of package.json, or null: parses, type, engines_node, prisma_block, relevant_dependencies, relevant_scripts, bun, already_7.
tsconfig{module, moduleResolution, extends_only}, {parses: false}, or null.
envA summary of .env without secrets, or null: per variable its name, line, removed_in_v7, and for a URL only scheme, host_class, pool/SSL params, other_params names or sqlite_path. Never a host, user or password.
draftFiles the page generated mechanically: schema_blocks, config, client, client_path, commands. The model starts from them.
schema_sent{mode: "whole" | "config_blocks", original_chars, note}.
code_sent{mode: "none"} or {mode: "whole" | "clipped", files_sent, partial, not_sent}.
inventory{models, enums} counts.

A minimal facts object carries the parts the output contract refers to by name (status, findings, context, versions). Nothing checks it server-side, but the model is told to answer findings by id and to trust context unless the input proves it wrong, so a thinner object gives a thinner answer:

{"status": "upgrade",
 "findings": [],
 "context": {"adapter": "pg", "format": "esm", "schema_path": "prisma/schema.prisma",
             "output": "../generated/prisma", "client_path": "src/db.ts",
             "import_path": "../generated/prisma/client", "bun": false, "config_seen": false},
 "versions": {"latest_7": "7.10.0", "latest_6": "6.19.3", "node": "^20.19 || ^22.12 || >=24.0"}}

Produce the page's exact facts in Node

The checker is plain JavaScript and is served next to the page as /p7scan.js and /deskkit.js. Both attach to window, so give Node a stand-in with global.window = {} and load them in that order. buildInput then returns the exact body the page would send (or null when laneReady says the lane cannot run). The .env text is read locally; only the secret-free summary above ends up in facts.

// make-input.js - run: node make-input.js audit   (or rewrite)
// Download https://orm-v7-upgrade.skillsafe.ai/p7scan.js and /deskkit.js next to this file.
global.window = {};
require("./p7scan.js");
require("./deskkit.js");
const fs = require("fs");
const K = window.DeskKit;
const read = (p) => { try { return fs.readFileSync(p, "utf8"); } catch (_) { return ""; } };

const A = K.analyze({
  schema: read("prisma/schema.prisma"),
  code: ["src/db.ts"].map((f) => "// file: " + f + "\n" + read(f)).join("\n\n"),
  pkg: read("package.json"),
  tsconfig: read("tsconfig.json"),
  env: read(".env"),
  schemaPath: "prisma/schema.prisma",
  clientPath: "",      // empty: let the checker choose, as the page does
  format: "auto",      // or "esm" / "cjs"
  adapter: "auto",
});
const lane = process.argv[2] || "audit";
const input = K.buildInput(lane, A, { note: "We deploy behind PgBouncer." });
if (!input) throw new Error(K.laneReady(lane, A));
fs.writeFileSync("body.json", JSON.stringify(input));
console.log(lane, "->", A.findings.length, "findings; key hash", K.hashInput(input));

Worked example: audit

The page's Express + PostgreSQL example (a $use soft-delete middleware, Prisma.validator, $metrics, a pooled DATABASE_URL behind PgBouncer). This body was produced by running buildInput on the example in Node, then abbreviated for this page: facts keeps 5 of the 19 findings (without their fix text) and drops checker, draft, adapters.constructor, context.format_reason and two versions dates. It is still a valid input; facts is still a JSON string.

{
 "task": "audit",
 "schema": "generator client {\n  provider        = \"prisma-client-js\"\n  previewFeatures = [\"metrics\", \"fullTextSearchPostgres\"]\n  binaryTargets   = [\"native\", \"debian-openssl-3.0.x\"]\n}\n\ndatasource db {\n  provider  = \"postgresql\"\n  url       = env(\"DATABASE_URL\")\n  directUrl = env(\"DIRECT_URL\")\n}\n\nmodel User {\n  id        Int       @id @default(autoincrement())\n  email     String    @unique\n  name      String?\n  orders    Order[]\n  deletedAt DateTime?\n}\n\nmodel Order {\n  id        Int         @id @default(autoincrement())\n  userId    Int\n  user      User        @relation(fields: [userId], references: [id])\n  status    OrderStatus @default(PENDING)\n  total     Decimal     @db.Decimal(10, 2)\n  createdAt DateTime    @default(now())\n  deletedAt DateTime?\n}\n\nenum OrderStatus {\n  PENDING\n  PAID\n  SHIPPED\n}\n",
 "facts": "{\"status\":\"upgrade\",\"findings\":[{\"id\":\"F1\",\"severity\":\"blocker\",\"source\":\"schema\",\"code\":\"gen-output\",\"line\":1,\"text\":\"The generator has no output. With prisma-client the output path is required; the client is no longer generated into node_modules.\",\"quote\":\"generator client {\"},{\"id\":\"F2\",\"severity\":\"blocker\",\"source\":\"schema\",\"code\":\"ds-url\",\"line\":9,\"text\":\"datasource.url is still in schema.prisma. Prisma 7 reads connection URLs from prisma.config.ts; the official guide calls the schema field deprecated and the 7.x CLI rejects the schema with \\\"The datasource property url is no longer supported in schema files\\\".\",\"quote\":\"url = env(\\\"DATABASE_URL\\\")\"},{\"id\":\"F3\",\"severity\":\"blocker\",\"source\":\"schema\",\"code\":\"ds-directUrl\",\"line\":10,\"text\":\"directUrl is in schema.prisma. Prisma 7 does not read it there, and the Prisma Config reference says datasource.directUrl was removed in v7 in favour of url (the skill's prisma-config.md still shows directUrl - the official reference wins here).\",\"quote\":\"directUrl = env(\\\"DIRECT_URL\\\")\"},{\"id\":\"F4\",\"severity\":\"blocker\",\"source\":\"code\",\"code\":\"code-use\",\"file\":\"src/db.ts\",\"line\":7,\"text\":\"prisma.$use() middleware. The client middleware API was removed in Prisma 7.\",\"quote\":\"prisma.$use(async (params, next) => {\"},{\"id\":\"F5\",\"severity\":\"blocker\",\"source\":\"code\",\"code\":\"code-no-adapter\",\"file\":\"src/db.ts\",\"line\":4,\"text\":\"PrismaClient is created without a driver adapter. Prisma 7 requires one for SQL databases.\",\"quote\":\"export const prisma = new PrismaClient({ log: ['warn', 'error'] })\"}],\"findings_total\":19,\"context\":{\"adapter\":\"pg\",\"format\":\"cjs\",\"schema_path\":\"prisma/schema.prisma\",\"output\":\"../generated/prisma\",\"client_path\":\"src/db.ts\",\"import_path\":\"../generated/prisma/client\",\"bun\":false,\"language\":\"ts\",\"provider\":\"postgresql\",\"config_seen\":false,\"config_path\":null},\"adapters\":{\"id\":\"pg\",\"label\":\"PostgreSQL (node-postgres)\",\"packages\":[\"@prisma/adapter-pg\",\"pg\"],\"class\":\"PrismaPg\",\"import_from\":\"@prisma/adapter-pg\",\"why\":\"provider postgresql\"},\"versions\":{\"latest_7\":\"7.10.0\",\"latest_6\":\"6.19.3\",\"node\":\"^20.19 || ^22.12 || >=24.0\",\"typescript\":\">=5.4.0\"},\"package\":{\"parses\":true,\"type\":null,\"engines_node\":\">=18.18\",\"prisma_block\":{\"seed\":\"ts-node prisma/seed.ts\"},\"relevant_dependencies\":{\"@prisma/client\":\"^6.16.2\",\"prisma\":\"^6.16.2\",\"typescript\":\"^5.6.3\",\"ts-node\":\"^10.9.2\"},\"relevant_scripts\":{\"build\":\"tsc\",\"db:migrate\":\"prisma migrate dev --skip-generate\",\"db:deploy\":\"prisma migrate deploy\",\"db:seed\":\"prisma db seed\"},\"bun\":false,\"already_7\":false},\"tsconfig\":{\"module\":\"commonjs\",\"moduleResolution\":\"node\",\"extends_only\":false},\"env\":[{\"name\":\"DATABASE_URL\",\"line\":1,\"scheme\":\"postgresql\",\"host_class\":\"other\",\"params\":{\"pgbouncer\":\"true\",\"connection_limit\":\"5\",\"pool_timeout\":\"15\"}},{\"name\":\"DIRECT_URL\",\"line\":2,\"scheme\":\"postgresql\",\"host_class\":\"other\"},{\"name\":\"PRISMA_CLIENT_ENGINE_TYPE\",\"line\":3,\"removed_in_v7\":true}],\"schema_sent\":{\"mode\":\"whole\",\"original_chars\":798,\"note\":\"the whole schema.prisma\"},\"code_sent\":{\"mode\":\"whole\",\"files_sent\":[\"src/db.ts\",\"src/routes/health.ts\"],\"partial\":[],\"not_sent\":[]},\"inventory\":{\"models\":2,\"enums\":1}}",
 "code": "// file: src/db.ts\nimport { PrismaClient, Prisma } from '@prisma/client'\n\nexport const prisma = new PrismaClient({ log: ['warn', 'error'] })\n\n// Soft delete: turn deletes into updates and hide deleted rows.\nprisma.$use(async (params, next) => {\n  if (params.model === 'User' || params.model === 'Order') {\n    if (params.action === 'delete') {\n      params.action = 'update'\n      params.args.data = { deletedAt: new Date() }\n    }\n    if (params.action === 'findMany') {\n      params.args = params.args || {}\n      params.args.where = { ...params.args.where, deletedAt: null }\n    }\n  }\n  return next(params)\n})\n\nexport const orderWithUser = Prisma.validator<Prisma.OrderInclude>()({ user: { select: { email: true } } })\n\n\n// file: src/routes/health.ts\nimport { prisma } from '../db'\n\nexport async function health() {\n  const m = await prisma.$metrics.json()\n  return { ok: true, queries: m.counters.length }\n}\n",
 "note": "We deploy behind PgBouncer. Please check the soft-delete middleware carefully."
}

Worked example: rewrite

The page's Next.js + MySQL shop example, produced the same way and abbreviated the same way (facts keeps 5 of 13 findings; code also drops the NODE_ENV guard line from lib/prisma.ts). No earlier audit is attached, so there is no audit field.

{
 "task": "rewrite",
 "schema": "// Shop catalogue\ngenerator client {\n  provider        = \"prisma-client-js\"\n  previewFeatures = [\"fullTextIndex\", \"metrics\"]\n}\n\ndatasource db {\n  provider     = \"mysql\"\n  url          = env(\"DATABASE_URL\")\n  relationMode = \"prisma\"\n}\n\nmodel Product {\n  id          Int      @id @default(autoincrement())\n  slug        String   @unique\n  name        String\n  description String   @db.Text\n  priceCents  Int\n  category    Category @relation(fields: [categoryId], references: [id])\n  categoryId  Int\n\n  @@index([categoryId])\n  @@fulltext([name, description])\n}\n\nmodel Category {\n  id       Int       @id @default(autoincrement())\n  name     String    @unique\n  products Product[]\n}\n",
 "facts": "{\"status\":\"upgrade\",\"findings\":[{\"id\":\"F1\",\"severity\":\"blocker\",\"source\":\"schema\",\"code\":\"gen-output\",\"line\":2,\"text\":\"The generator has no output. With prisma-client the output path is required; the client is no longer generated into node_modules.\",\"quote\":\"generator client {\"},{\"id\":\"F2\",\"severity\":\"blocker\",\"source\":\"schema\",\"code\":\"ds-url\",\"line\":9,\"text\":\"datasource.url is still in schema.prisma. Prisma 7 reads connection URLs from prisma.config.ts; the official guide calls the schema field deprecated and the 7.x CLI rejects the schema with \\\"The datasource property url is no longer supported in schema files\\\".\",\"quote\":\"url = env(\\\"DATABASE_URL\\\")\"},{\"id\":\"F3\",\"severity\":\"blocker\",\"source\":\"code\",\"code\":\"code-no-adapter\",\"file\":\"lib/prisma.ts\",\"line\":6,\"text\":\"PrismaClient is created without a driver adapter. Prisma 7 requires one for SQL databases.\",\"quote\":\"export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ['query'] })\"},{\"id\":\"F6\",\"severity\":\"high\",\"source\":\"code\",\"code\":\"code-import\",\"file\":\"lib/prisma.ts\",\"line\":2,\"text\":\"Imports from '@prisma/client' (1 place in lib/prisma.ts). With the prisma-client generator the client lives in your output folder, so these imports stop resolving.\",\"quote\":\"import { PrismaClient } from '@prisma/client'\"},{\"id\":\"F7\",\"severity\":\"high\",\"source\":\"code\",\"code\":\"code-import\",\"file\":\"app/products/[slug]/page.tsx\",\"line\":12,\"text\":\"Imports from '@prisma/client' (1 place in app/products/[slug]/page.tsx). With the prisma-client generator the client lives in your output folder, so these imports stop resolving.\",\"quote\":\"import type { Product } from '@prisma/client'\"}],\"findings_total\":13,\"context\":{\"adapter\":\"mariadb\",\"format\":\"esm\",\"schema_path\":\"prisma/schema.prisma\",\"output\":\"../generated/prisma\",\"client_path\":\"lib/prisma.ts\",\"import_path\":\"../generated/prisma/client\",\"bun\":false,\"language\":\"ts\",\"provider\":\"mysql\",\"config_seen\":false,\"config_path\":null},\"adapters\":{\"id\":\"mariadb\",\"label\":\"MySQL / MariaDB\",\"packages\":[\"@prisma/adapter-mariadb\",\"mariadb\"],\"class\":\"PrismaMariaDb\",\"import_from\":\"@prisma/adapter-mariadb\",\"why\":\"provider mysql\"},\"versions\":{\"latest_7\":\"7.10.0\",\"latest_6\":\"6.19.3\",\"node\":\"^20.19 || ^22.12 || >=24.0\",\"typescript\":\">=5.4.0\"},\"package\":{\"parses\":true,\"type\":null,\"engines_node\":null,\"prisma_block\":null,\"relevant_dependencies\":{\"@prisma/client\":\"^6.14.0\",\"next\":\"15.5.4\",\"prisma\":\"^6.14.0\",\"typescript\":\"^5.9.2\"},\"relevant_scripts\":{\"build\":\"prisma generate && next build\",\"db:push\":\"prisma db push --skip-generate\",\"postinstall\":\"prisma generate\"},\"bun\":false,\"already_7\":false},\"tsconfig\":{\"module\":\"esnext\",\"moduleResolution\":\"bundler\",\"extends_only\":false},\"env\":[{\"name\":\"DATABASE_URL\",\"line\":1,\"scheme\":\"mysql\",\"host_class\":\"local\",\"params\":{\"connection_limit\":\"10\"}}],\"schema_sent\":{\"mode\":\"whole\",\"original_chars\":679,\"note\":\"the whole schema.prisma\"},\"code_sent\":{\"mode\":\"whole\",\"files_sent\":[\"lib/prisma.ts\",\"app/products/[slug]/page.tsx\"],\"partial\":[],\"not_sent\":[]},\"inventory\":{\"models\":2,\"enums\":0}}",
 "code": "// file: lib/prisma.ts\nimport { PrismaClient } from '@prisma/client'\n\nconst globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }\n\nexport const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ['query'] })\n\n\n\n// file: app/products/[slug]/page.tsx\nimport { notFound } from 'next/navigation'\nimport type { Product } from '@prisma/client'\nimport { prisma } from '@/lib/prisma'\n\nexport default async function Page({ params }: { params: Promise<{ slug: string }> }) {\n  const { slug } = await params\n  const product: Product | null = await prisma.product.findUnique({ where: { slug } })\n  if (!product) notFound()\n  return <h1>{product.name}</h1>\n}\n"
}

Save either body as body.json; steps 4 to 6 read it from there.

The output

One JSON object, delivered as a string at output.output of the finished job (and of the stream's done payload). Parse it and check that task is the lane you sent. The shapes, from the app's output contract (SKILL.md):

audit:
{"task": "audit",
 "headline": "one sentence: how big this upgrade is and what blocks it",
 "verdict": "upgrade | stay_on_v6 | already_v7",
 "effort": "small | medium | large",
 "finding_responses": [{"ref": "F1", "stance": "confirmed | not_applicable | needs_you", "note": ""}],
 "extra_findings": [{"title": "", "severity": "blocker | high | medium | info",
                     "where": "file:line", "evidence": "a line copied exactly from the input", "fix": ""}],
 "plan": [{"step": 1, "title": "", "detail": "", "commands": [""], "files": [""]}],
 "to_confirm": [""]}

rewrite:
{"task": "rewrite",
 "headline": "",
 "schema_blocks": "the new generator block(s) and datasource block, nothing else",
 "config": {"path": "prisma.config.ts", "content": "the whole file"} | null,
 "files": [{"path": "", "content": "the whole file"}],
 "package_changes": {"set": {}, "remove": [], "dependencies": {}, "devDependencies": {}, "scripts": {}} | null,
 "tsconfig_changes": {"compilerOptions": {}} | null,
 "finding_responses": [{"ref": "F1", "stance": "done | not_applicable | needs_you", "note": ""}],
 "changes": [{"file": "", "what": "old and new text", "why": ""}],
 "commands": [""],
 "to_confirm": [""]}

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api. Requests carry Content-Type: application/json and Authorization: Bearer <token>. A successful response wraps its payload in data (list calls add meta); a failed one (any non-2xx status) carries error:

{"data": {"job_id": "..."}}
{"error": {"code": "...", "message": "...", "details": ...}}

Decide success by the HTTP status, as the SDK does, then read data, or error.code and error.message. If an error body is not JSON, fall back to the status text.

The input object IS the request body for /estimate, /run and /run-stream: there is no {"input": ...} wrapper. A wrapped body reaches the model with no task, so it cannot route the request.

Errors

The SDK names no fixed list of error codes for these endpoints, so this table lists only what the SDK and the app's own page handle. Log error.code and error.message for everything else.

statuswhat it meanswhat to do
any non-2xxThe body is {"error": {"code", "message", "details"}}.Surface code and message; details may be absent.
401The page treats it as an expired session.Get a new token from the token page.
402The page treats it as "not enough credits for this run".Top up, then retry with the same Idempotency-Key.
200, event: errorOn /run-stream, a failed job arrives as an error event with code, message and job_id.Treat the run as failed; the job_id lets you fetch the job.
200, status: "failed"A polled job ended without a reply.Read the job's error.

Idempotency-Key

/run and /run-stream accept an Idempotency-Key header. The page sends orm-v7-upgrade:<lane>:<hash>:a<attempt>, where <hash> is DeskKit.hashInput(input) (a hash of the key-sorted input) and the attempt starts at 1. Reusing the key for a retry of the same input means a network blip never starts a second run; the SDK notes that /run-stream answers an idempotent replay with a plain JSON result instead of an event stream. Any stable digest of the body works for your own calls; the samples use SHA-256. A different input (other schema, code, facts, note or audit) is a new hash, and the page's reformat retry (with retry_note) is a new attempt, :a2, hashed without retry_note.

Estimate, hold and charge

1. A tiny client

One helper that sends the token, unwraps data and raises on a non-2xx status with the API's error.code. In every language the later steps continue the same script: Go and Java put steps 2 to 6 inside main, in order.

# curl 7.76+ (for --fail-with-body) and jq.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="orm-v7-upgrade"
TOKEN="YOUR_TOKEN"   # from https://orm-v7-upgrade.skillsafe.ai/tokens.html

call() {             # call <path> [json-body]  -> prints the whole response body
  if [ -n "$2" ]; then
    curl -sS --fail-with-body -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS --fail-with-body "$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 for orm-v7-upgrade, with Copy token and Copy shell export buttons. Paste it over YOUR_TOKEN in step 1. Sign in on the app first if you want a personal token: that is what the page requires for a metered run. You can also mint a guest token with POST /guest and {"slug":"orm-v7-upgrade"} (no Authorization header); it answers {"token", "guest_id"}. The page uses a guest token for /me and /estimate and asks a guest to sign in before an audit or rewrite unless /estimate reports sponsor_enabled: true.

# Shortest path: https://orm-v7-upgrade.skillsafe.ai/tokens.html -> "Copy shell export",
# paste it into your shell, then TOKEN="$SKILLSAFE_TOKEN" (or paste the token over YOUR_TOKEN).
#
# Or mint a guest token (enough for /me and /estimate):
if [ "$TOKEN" = "YOUR_TOKEN" ]; then
  TOKEN=$(curl -sS --fail-with-body -X POST "$BASE/guest" \
    -H "Content-Type: application/json" -d "{\"slug\":\"$SLUG\"}" | jq -r .data.token)
fi

3. Check the session and the balance: GET /me

Answers {"subject_type", "subject_id", "credits", "profile"} (profile may be absent). credits is the balance the hold in step 4 is compared with.

call me | jq .data
# {"subject_type": "...", "subject_id": "...", "credits": ...}

4. Price the run (free): POST /estimate

The body is the input object itself. /estimate checks nothing about it, so check the shape first: an object whose every value is a string, task equal to audit or rewrite, a non-empty schema, and a facts string that parses to an object. The page then shows hold_credits (reserved, not the price) and min_credits.

# body.json is the input object itself (a worked example above, or make-input.js output).
jq -e '
  type == "object"
  and (.task == "audit" or .task == "rewrite")
  and ([.[] | type == "string"] | all)
  and ((.schema // "") | test("\\S"))
  and ((.facts | fromjson | type) == "object")
' body.json > /dev/null || { echo "body.json is not a valid ORM v7 Upgrade Check input" >&2; exit 1; }
INPUT=$(cat body.json)

call estimate "$INPUT" | jq .data
# The page reads hold_credits (RESERVED, not the price), min_credits and sponsor_enabled.

5. Run it, then poll: POST /run and GET /jobs/{id}

POST /run takes the same body plus an Idempotency-Key and answers with a job_id. Poll GET /jobs/{id} until status is succeeded or failed (the SDK polls every second and gives up after 180 seconds). A finished job carries the reply as a string at output.output, plus charged_credits, truncated and, on failure, error.

# orm-v7-upgrade:<lane>:<hash>:a<attempt> - retry with the SAME key, never a new one.
LANE=$(jq -r .task body.json)
KEY="orm-v7-upgrade:$LANE:$(jq -cS . body.json | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS --fail-with-body -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | jq -r .data.job_id)

for i in $(seq 180); do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | jq -r .data.status)
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && { printf '%s\n' "$OUT" | jq .data.error; exit 1; }
  sleep 1
done
printf '%s' "$OUT" | jq '.data | {status, charged_credits, truncated}'
printf '%s' "$OUT" | jq -r .data.output.output > reply.txt   # the reply, as text

6. Or stream it: POST /run-stream

Same body and headers as /run; use it instead of step 5 (build the key the same way). The answer is server-sent events: blocks separated by a blank line, each with an event: line and one or more data: lines holding JSON (the SDK strips each prefix, trims, and joins the data lines). The events the SDK handles:

Ignore any other event name. If the response is not text/event-stream, it is a plain JSON envelope: an error on a non-2xx status, otherwise (an idempotent replay) the result in data. Take the reply from done.output.output when it is there, and fall back to the joined deltas. The page does the same; it notes that a browser may receive no deltas at all before done.

# -N turns off buffering so events print as they arrive.
curl -sS -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT"

# event: job
# data: {...}
#
# event: delta
# data: {"text":"{\"task\":\"audit\",\"headline\":\"..."}
#
# event: done
# data: {"job_id":"...","status":"succeeded","charged_credits":...,"output":{"output":"{...}"}}

After the run