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.
| task | send | you get back |
|---|---|---|
audit | task, schema, facts; code and note optional | headline, 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. |
rewrite | task, schema, facts; code, note and audit optional | headline, 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.
| field | required | meaning |
|---|---|---|
task | yes | "audit" or "rewrite". |
schema | yes | schema.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). |
facts | yes | What the browser checker established, as a JSON string (below). |
code | no | Source 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. |
note | no | What you want or saw (at most 2,000 characters in the page). |
audit | no, rewrite only | A 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_note | no | Only 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:
| key | what the browser puts there |
|---|---|
status | The page's overall reading: upgrade, stay_on_v6 (MongoDB) or already_v7. |
findings | At 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. |
context | The 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. |
adapters | The 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. |
versions | latest_7, latest_7_date, latest_6, node (prisma 7's engines range), typescript, checked. |
package | A 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. |
env | A 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. |
draft | Files 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": [""]}
- audit:
stay_on_v6is for MongoDB (2 to 4 steps to stay on the latest 6.x);already_v7has 1 to 3 verification steps; otherwise the plan has 4 to 10 steps, with no removed CLI flag. An extra finding'sevidencemust be copied exactly from the input; the page checks that it is. - rewrite:
schema_blocksis only the generator and datasource blocks; the page splices them back into your schema, so models and enums stay byte for byte (DeskKit.spliceSchema). Inpackage_changes,nulldeletes an entry. A file sent in part (a[... ORM v7 Upgrade Check cutmarker) is described inchanges, not returned. For MongoDB:schema_blocks"",confignull,files[]. - The page reads the reply with
Recon.parseResult(/recon.js): it strips a code fence, takes the first balanced JSON object (counting braces outside string literals), andRecon.normalizemaps an unknownverdicttoupgradeand an unknownstancetounclear. A truncated reply is closed withRecon.closeJsonso the sections that arrived still render.
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.
| status | what it means | what to do |
|---|---|---|
| any non-2xx | The body is {"error": {"code", "message", "details"}}. | Surface code and message; details may be absent. |
| 401 | The page treats it as an expired session. | Get a new token from the token page. |
| 402 | The page treats it as "not enough credits for this run". | Top up, then retry with the same Idempotency-Key. |
200, event: error | On /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
POST /estimatereturns the worst-case credit cost of a run with this input. It creates no job and charges nothing./estimatevalidates nothing about the body. The SDK posts whatever it is given (the page guards it withDeskKit.mustBeObject), so a clean estimate does not prove the input is right: send an object withtask, and check the shape yourself (step 4).- The page reads
hold_credits(credits reserved while the run executes, not the price),min_creditsandsponsor_enabledfrom the estimate. With a balance under the hold the run can start but the reply may be cut short (truncated: true). - What you pay is
charged_creditson the finished job and in thedoneevent: what the run used, usually far below the hold. - Both lanes are metered. The page asks a guest to sign in before a run unless the estimate reports
sponsor_enabled: true. The checker, the drafted files and every export stay free in the page.
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
}
import hashlib, json, time, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "orm-v7-upgrade"
TOKEN = "YOUR_TOKEN" # from https://orm-v7-upgrade.skillsafe.ai/tokens.html
def call(path, body=None, key=None):
"""Returns the unwrapped `data`; raises with the API error code on a non-2xx status.
`key`, when given, is sent as the Idempotency-Key header."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
if key:
req.add_header("Idempotency-Key", key)
try:
with urllib.request.urlopen(req) as r:
return json.load(r).get("data")
except urllib.error.HTTPError as e:
try:
err = json.load(e).get("error") or {}
except ValueError:
err = {}
raise RuntimeError(f"HTTP {e.code} {err.get('code')}: {err.get('message') or e.reason}") from None
// Node 18+, saved as an ES module (client.mjs) so top-level await works.
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "orm-v7-upgrade";
const PAGE_TOKEN = "YOUR_TOKEN"; // from https://orm-v7-upgrade.skillsafe.ai/tokens.html
let TOKEN = PAGE_TOKEN;
// Returns the unwrapped `data`; throws with the API error code on a non-2xx status.
// `key`, when given, is sent as the Idempotency-Key header.
async function call(path, body, key) {
const headers = { Authorization: `Bearer ${TOKEN}` };
if (body) headers["Content-Type"] = "application/json";
if (key) headers["Idempotency-Key"] = key;
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const e = payload.error || {};
throw new Error(`HTTP ${res.status} ${e.code}: ${e.message || res.statusText}`);
}
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "orm-v7-upgrade"
)
var token = "YOUR_TOKEN" // from https://orm-v7-upgrade.skillsafe.ai/tokens.html
type envelope struct {
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// call returns the unwrapped data. A non-empty key is sent as the Idempotency-Key header.
func call(path string, body any, key string) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, base+"/"+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
if env.Error != nil {
return nil, fmt.Errorf("HTTP %d %s: %s", res.StatusCode, env.Error.Code, env.Error.Message)
}
return nil, fmt.Errorf("HTTP %d", res.StatusCode)
}
return env.Data, nil
}
// must turns a call result into a map, or stops the program.
func must(raw json.RawMessage, err error) map[string]any {
if err != nil {
panic(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
panic(err)
}
return m
}
func main() {
// steps 2 to 6 go here, in order
}
// Java 17+, with Jackson (com.fasterxml.jackson.core:jackson-databind) for JSON.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
public class PrismaDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "orm-v7-upgrade";
static String TOKEN = "YOUR_TOKEN"; // from https://orm-v7-upgrade.skillsafe.ai/tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
// Returns the unwrapped data; throws with the API error code on a non-2xx status. key may be null.
static JsonNode call(String path, String jsonBody, String key) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (key != null) b.header("Idempotency-Key", key);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
JsonNode env;
try { env = JSON.readTree(res.body()); } catch (Exception e) { env = JSON.createObjectNode(); }
if (res.statusCode() / 100 != 2) {
JsonNode e = env.path("error");
throw new RuntimeException("HTTP " + res.statusCode() + " " + e.path("code").asText() + ": " + e.path("message").asText());
}
return env.path("data");
}
public static void main(String[] args) throws Exception {
// steps 2 to 6 go here, in order
}
}
require "digest"
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "orm-v7-upgrade"
$token = "YOUR_TOKEN" # from https://orm-v7-upgrade.skillsafe.ai/tokens.html
# Returns the unwrapped data; raises with the API error code on a non-2xx status.
# key, when given, is sent as the Idempotency-Key header.
def call(path, body = nil, key = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{$token}"
req["Idempotency-Key"] = key if key
if body
req["Content-Type"] = "application/json"
req.body = body.to_json
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body) rescue {}
unless res.is_a?(Net::HTTPSuccess)
err = payload["error"] || {}
raise "HTTP #{res.code} #{err['code']}: #{err['message'] || res.message}"
end
payload["data"]
end
<?php
// PHP 8+ with the curl extension. The later steps continue this file.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "orm-v7-upgrade";
$GLOBALS["token"] = "YOUR_TOKEN"; // from https://orm-v7-upgrade.skillsafe.ai/tokens.html
// Returns the unwrapped data; throws with the API error code on a non-2xx status.
// $key, when given, is sent as the Idempotency-Key header.
function call(string $path, ?array $body = null, ?string $key = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . $GLOBALS["token"]];
if ($key !== null) {
$headers[] = "Idempotency-Key: " . $key;
}
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$payload = json_decode((string) $raw, true) ?: [];
if ($status < 200 || $status > 299) {
$e = $payload["error"] ?? [];
throw new RuntimeException("HTTP $status " . ($e["code"] ?? "") . ": " . ($e["message"] ?? ""));
}
return $payload["data"] ?? null;
}
// Program.cs (.NET 6+, top-level statements). The later steps continue this file.
using System;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "orm-v7-upgrade";
var token = "YOUR_TOKEN"; // from https://orm-v7-upgrade.skillsafe.ai/tokens.html
var http = new HttpClient();
// Returns the unwrapped data; throws with the API error code on a non-2xx status.
// json is the request body as JSON text; key, when given, is the Idempotency-Key.
async Task<JsonElement> Call(string path, string? json = null, string? key = null)
{
var req = new HttpRequestMessage(json == null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {token}");
if (key != null) req.Headers.Add("Idempotency-Key", key);
if (json != null) req.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var res = await http.SendAsync(req);
var text = await res.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(text) ? "{}" : text);
var payload = doc.RootElement;
if (!res.IsSuccessStatusCode)
{
var code = payload.TryGetProperty("error", out var e) && e.TryGetProperty("code", out var c) ? c.ToString() : "";
var msg = payload.TryGetProperty("error", out var e2) && e2.TryGetProperty("message", out var m) ? m.ToString() : res.ReasonPhrase;
throw new Exception($"HTTP {(int)res.StatusCode} {code}: {msg}");
}
return payload.GetProperty("data").Clone();
}
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
# Paste the token from https://orm-v7-upgrade.skillsafe.ai/tokens.html ("Copy token") over
# YOUR_TOKEN, or mint a guest token here (enough for /me and /estimate).
if TOKEN == "YOUR_TOKEN":
req = urllib.request.Request(f"{BASE}/guest", data=json.dumps({"slug": SLUG}).encode(), method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Paste the token from https://orm-v7-upgrade.skillsafe.ai/tokens.html ("Copy token") into
// PAGE_TOKEN, or mint a guest token here (enough for /me and /estimate).
if (TOKEN === "YOUR_TOKEN") {
const guestRes = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG }),
});
if (!guestRes.ok) throw new Error(`guest: HTTP ${guestRes.status}`);
TOKEN = (await guestRes.json()).data.token;
}
// Paste the token from https://orm-v7-upgrade.skillsafe.ai/tokens.html ("Copy token") into
// token, or mint a guest token here (enough for /me and /estimate).
if token == "YOUR_TOKEN" {
gres, err := http.Post(base+"/guest", "application/json",
strings.NewReader(`{"slug":"`+slug+`"}`))
if err != nil {
panic(err)
}
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(gres.Body).Decode(&guest)
gres.Body.Close()
if gres.StatusCode != http.StatusOK && gres.StatusCode != http.StatusCreated {
panic(fmt.Sprint("guest: HTTP ", gres.StatusCode))
}
token = guest.Data.Token
}
// Paste the token from https://orm-v7-upgrade.skillsafe.ai/tokens.html ("Copy token") into
// TOKEN, or mint a guest token here (enough for /me and /estimate).
if (TOKEN.equals("YOUR_TOKEN")) {
HttpRequest guestReq = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"" + SLUG + "\"}"))
.build();
HttpResponse<String> guest = HTTP.send(guestReq, HttpResponse.BodyHandlers.ofString());
if (guest.statusCode() / 100 != 2) throw new RuntimeException("guest: HTTP " + guest.statusCode());
TOKEN = JSON.readTree(guest.body()).path("data").path("token").asText();
}
# Paste the token from https://orm-v7-upgrade.skillsafe.ai/tokens.html ("Copy token") over
# YOUR_TOKEN, or mint a guest token here (enough for /me and /estimate).
if $token == "YOUR_TOKEN"
uri = URI("#{BASE}/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: SLUG }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
raise "guest: HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
$token = JSON.parse(res.body)["data"]["token"]
end
// Paste the token from https://orm-v7-upgrade.skillsafe.ai/tokens.html ("Copy token") over
// YOUR_TOKEN, or mint a guest token here (enough for /me and /estimate).
if ($GLOBALS["token"] === "YOUR_TOKEN") {
$ch = curl_init(BASE . "/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => SLUG]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode((string) curl_exec($ch), true);
curl_close($ch);
$GLOBALS["token"] = $guest["data"]["token"] ?? throw new RuntimeException("guest token failed");
}
// Paste the token from https://orm-v7-upgrade.skillsafe.ai/tokens.html ("Copy token") over
// YOUR_TOKEN, or mint a guest token here (enough for /me and /estimate).
if (token == "YOUR_TOKEN")
{
var guestReq = new HttpRequestMessage(HttpMethod.Post, $"{Base}/guest");
guestReq.Content = new StringContent("{\"slug\":\"" + Slug + "\"}", Encoding.UTF8, "application/json");
using var guestRes = await http.SendAsync(guestReq);
guestRes.EnsureSuccessStatusCode();
using var guestDoc = JsonDocument.Parse(await guestRes.Content.ReadAsStringAsync());
token = guestDoc.RootElement.GetProperty("data").GetProperty("token").GetString()!;
}
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": ...}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
me := must(call("me", nil, ""))
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = call("me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("credits"));
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
$me = call("me");
echo $me["subject_type"], " ", $me["credits"] ?? "-", PHP_EOL;
var me = await Call("me");
var credits = me.TryGetProperty("credits", out var cr) ? cr.ToString() : "-";
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {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.
with open("body.json") as fh:
INPUT = json.load(fh) # the input object itself, no wrapper
assert isinstance(INPUT, dict) and INPUT.get("task") in ("audit", "rewrite"), "task must be audit or rewrite"
assert all(isinstance(v, str) for v in INPUT.values()), "every field is a string"
assert INPUT.get("schema", "").strip(), "schema is required"
assert isinstance(json.loads(INPUT["facts"]), dict), "facts is a JSON string of an object"
est = call("estimate", INPUT)
print("reserves", est.get("hold_credits"), "credits (not the price); minimum", est.get("min_credits"))
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // no {input: ...} wrapper
if (!INPUT || typeof INPUT !== "object" || Array.isArray(INPUT)) throw new Error("body must be an object");
if (!["audit", "rewrite"].includes(INPUT.task)) throw new Error("task must be audit or rewrite");
if (!Object.values(INPUT).every((v) => typeof v === "string")) throw new Error("every field is a string");
if (!(INPUT.schema || "").trim()) throw new Error("schema is required");
const facts = JSON.parse(INPUT.facts);
if (!facts || typeof facts !== "object" || Array.isArray(facts)) throw new Error("facts is a JSON string of an object");
const est = await call("estimate", INPUT);
console.log("reserves", est.hold_credits, "credits (not the price); minimum", est.min_credits);
raw, err := os.ReadFile("body.json")
if err != nil {
panic(err)
}
var input map[string]string // every field is a string; Unmarshal fails otherwise
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
lane := input["task"]
if lane != "audit" && lane != "rewrite" {
panic("task must be audit or rewrite")
}
if strings.TrimSpace(input["schema"]) == "" {
panic("schema is required")
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil || facts == nil {
panic("facts must be a JSON string of an object")
}
est := must(call("estimate", input, ""))
fmt.Println("reserves", est["hold_credits"], "credits (not the price); minimum", est["min_credits"])
String input = Files.readString(Path.of("body.json")); // the input object itself, sent as is
JsonNode in = JSON.readTree(input);
String lane = in.path("task").asText();
if (!in.isObject() || !(lane.equals("audit") || lane.equals("rewrite")))
throw new IllegalStateException("task must be audit or rewrite");
in.fields().forEachRemaining(f -> {
if (!f.getValue().isTextual()) throw new IllegalStateException("every field is a string");
});
if (in.path("schema").asText().isBlank()) throw new IllegalStateException("schema is required");
if (!JSON.readTree(in.path("facts").asText()).isObject())
throw new IllegalStateException("facts is a JSON string of an object");
JsonNode est = call("estimate", input, null);
System.out.println("reserves " + est.path("hold_credits") + " credits (not the price); minimum " + est.path("min_credits"));
INPUT = JSON.parse(File.read("body.json")) # no {"input": ...} wrapper
raise "task must be audit or rewrite" unless INPUT.is_a?(Hash) && %w[audit rewrite].include?(INPUT["task"])
raise "every field is a string" unless INPUT.values.all? { |v| v.is_a?(String) }
raise "schema is required" if INPUT["schema"].to_s.strip.empty?
raise "facts is a JSON string of an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts "reserves #{est['hold_credits']} credits (not the price); minimum #{est['min_credits']}"
$input = json_decode(file_get_contents("body.json"), true); // no {"input": ...} wrapper
if (!is_array($input) || !in_array($input["task"] ?? "", ["audit", "rewrite"], true)) { throw new Exception("task must be audit or rewrite"); }
foreach ($input as $v) { if (!is_string($v)) { throw new Exception("every field is a string"); } }
if (trim($input["schema"] ?? "") === "") { throw new Exception("schema is required"); }
if (!is_object(json_decode($input["facts"] ?? ""))) { throw new Exception("facts is a JSON string of an object"); }
$est = call("estimate", $input);
echo "reserves ", $est["hold_credits"] ?? "?", " credits (not the price); minimum ", $est["min_credits"] ?? "-", PHP_EOL;
var input = File.ReadAllText("body.json"); // the input object itself, sent as is
using var inputDoc = JsonDocument.Parse(input);
var root = inputDoc.RootElement;
if (root.ValueKind != JsonValueKind.Object) throw new Exception("body.json must be an object");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception("every field is a string");
var lane = root.TryGetProperty("task", out var t) ? t.GetString() : null;
if (lane != "audit" && lane != "rewrite") throw new Exception("task must be audit or rewrite");
if (!root.TryGetProperty("schema", out var sv) || string.IsNullOrWhiteSpace(sv.GetString()))
throw new Exception("schema is required");
using (var factsDoc = JsonDocument.Parse(root.GetProperty("facts").GetString()!))
if (factsDoc.RootElement.ValueKind != JsonValueKind.Object) throw new Exception("facts is a JSON string of an object");
var est = await Call("estimate", input);
Console.WriteLine($"reserves {(est.TryGetProperty("hold_credits", out var h) ? h.ToString() : "?")} credits (not the price)");
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
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"orm-v7-upgrade:{INPUT['task']}:{digest}:a1"
job_id = call("run", INPUT, key)["job_id"]
deadline = time.time() + 180
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(1)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
const stable = (v) => v === null || typeof v !== "object" ? JSON.stringify(v)
: "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + stable(v[k])).join(",") + "}";
const digest = createHash("sha256").update(stable(INPUT)).digest("hex").slice(0, 16);
const key = `orm-v7-upgrade:${INPUT.task}:${digest}:a1`;
const jobId = (await call("run", INPUT, key)).job_id;
const deadline = Date.now() + 180000;
let job;
for (;;) {
job = await call(`jobs/${encodeURIComponent(jobId)}`);
if (job.status === "succeeded" || job.status === "failed") break;
if (Date.now() > deadline) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, 1000));
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const text = job.output.output; // the reply, as a string
console.log("charged", job.charged_credits, "truncated", job.truncated);
body, _ := json.Marshal(input) // Go sorts map keys, so this digest is stable
sum := sha256.Sum256(body)
key := fmt.Sprintf("orm-v7-upgrade:%s:%x:a1", lane, sum[:8])
jobID := must(call("run", input, key))["job_id"].(string)
var job map[string]any
deadline := time.Now().Add(180 * time.Second)
for {
job = must(call("jobs/"+jobID, nil, ""))
if job["status"] == "succeeded" || job["status"] == "failed" {
break
}
if time.Now().After(deadline) {
panic("job timed out")
}
time.Sleep(time.Second)
}
if job["status"] == "failed" {
panic(fmt.Sprint(job["error"]))
}
text := job["output"].(map[string]any)["output"].(string) // the reply, as a string
fmt.Println("charged", job["charged_credits"], "truncated", job["truncated"], len(text))
String hash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8))).substring(0, 16);
String key = "orm-v7-upgrade:" + lane + ":" + hash + ":a1";
String jobId = call("run", input, key).path("job_id").asText();
JsonNode job;
long deadline = System.currentTimeMillis() + 180_000;
while (true) {
job = call("jobs/" + jobId, null, null);
String status = job.path("status").asText();
if (status.equals("succeeded")) break;
if (status.equals("failed")) throw new RuntimeException(job.path("error").toString());
if (System.currentTimeMillis() > deadline) throw new RuntimeException("job timed out");
Thread.sleep(1000);
}
String text = job.path("output").path("output").asText(); // the reply, as a string
System.out.println("charged " + job.path("charged_credits") + " truncated " + job.path("truncated"));
key = "orm-v7-upgrade:#{INPUT['task']}:#{Digest::SHA256.hexdigest(INPUT.sort.to_h.to_json)[0, 16]}:a1"
job_id = call("run", INPUT, key)["job_id"]
deadline = Time.now + 180
job = nil
loop do
job = call("jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep 1
end
raise job["error"].inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts "charged #{job['charged_credits']} truncated #{job['truncated']}"
$sorted = $input;
ksort($sorted);
$key = "orm-v7-upgrade:" . $input["task"] . ":" . substr(hash("sha256", json_encode($sorted)), 0, 16) . ":a1";
$jobId = call("run", $input, $key)["job_id"];
$deadline = time() + 180;
while (true) {
$job = call("jobs/" . rawurlencode($jobId));
if (in_array($job["status"], ["succeeded", "failed"], true)) { break; }
if (time() > $deadline) { throw new RuntimeException("job timed out"); }
sleep(1);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job["error"] ?? null)); }
$text = $job["output"]["output"]; // the reply, as a string
echo "charged ", $job["charged_credits"] ?? "?", PHP_EOL;
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))).ToLowerInvariant()[..16];
var key = $"orm-v7-upgrade:{lane}:{hash}:a1";
var started = await Call("run", input, key);
var jobId = started.GetProperty("job_id").GetString();
var deadline = DateTime.UtcNow.AddSeconds(180);
JsonElement job;
while (true)
{
job = await Call($"jobs/{Uri.EscapeDataString(jobId!)}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
if (DateTime.UtcNow > deadline) throw new Exception("job timed out");
await Task.Delay(1000);
}
var text = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
Console.WriteLine($"charged {job.GetProperty("charged_credits")}");
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:
job: the job has been created (the SDK passes its data toonJob).delta:{"text": "..."}, a chunk of the reply.done(orpending): the final payload,{"job_id", "status", "charged_credits", "output"}, with the full reply atoutput.output.error:{"code", "message", "job_id"}, the job failed.
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":"{...}"}}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"), ("Idempotency-Key", key)):
req.add_header(h, v)
deltas, done = "", None
def handle(event, data):
global deltas, done
if event == "delta":
deltas += data.get("text", "")
elif event in ("done", "pending"):
done = data
elif event == "error":
raise RuntimeError(f"{data.get('code')}: {data.get('message')} (job {data.get('job_id')})")
with urllib.request.urlopen(req) as stream: # a non-2xx status raises HTTPError here
if "text/event-stream" not in stream.headers.get("Content-Type", ""):
done = json.load(stream)["data"] # an idempotent replay: the result itself
else:
event, data = "message", ""
for raw in stream:
line = raw.decode("utf-8").rstrip("\r\n")
if line == "":
if data:
handle(event, json.loads(data))
event, data = "message", ""
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
if data:
handle(event, json.loads(data))
streamed = ((done or {}).get("output") or {}).get("output") or deltas
print((done or {}).get("status"), (done or {}).get("charged_credits"), len(streamed))
const sres = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
});
let deltas = "", doneEvent = null;
if (!(sres.headers.get("content-type") || "").includes("text/event-stream")) {
const payload = await sres.json().catch(() => ({}));
if (!sres.ok) throw new Error(`HTTP ${sres.status} ${payload.error?.code}: ${payload.error?.message}`);
doneEvent = payload.data; // an idempotent replay: the result itself
} else {
const reader = sres.body.getReader();
const dec = new TextDecoder();
let buf = "";
const handle = (event, data) => {
if (event === "delta") deltas += data.text || "";
else if (event === "done" || event === "pending") doneEvent = data;
else if (event === "error") throw new Error(`${data.code}: ${data.message} (job ${data.job_id})`);
};
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true }).replace(/\r/g, "");
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, i);
buf = buf.slice(i + 2);
let event = "message", data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (data) handle(event, JSON.parse(data));
}
}
}
const streamed = doneEvent?.output?.output || deltas;
console.log(doneEvent?.status, doneEvent?.charged_credits, streamed.length);
sreq, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
sreq.Header.Set("Authorization", "Bearer "+token)
sreq.Header.Set("Content-Type", "application/json")
sreq.Header.Set("Idempotency-Key", key)
sres, err := http.DefaultClient.Do(sreq)
if err != nil {
panic(err)
}
defer sres.Body.Close()
type final struct {
Status string `json:"status"`
ChargedCredits float64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
var done final
var deltas strings.Builder
if !strings.Contains(sres.Header.Get("Content-Type"), "text/event-stream") {
var env struct {
Data final `json:"data"`
Error json.RawMessage `json:"error"`
}
_ = json.NewDecoder(sres.Body).Decode(&env)
if sres.StatusCode/100 != 2 {
panic(fmt.Sprintf("HTTP %d %s", sres.StatusCode, env.Error))
}
done = env.Data // an idempotent replay: the result itself
} else {
handle := func(event, data string) {
switch event {
case "delta":
var d struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(data), &d)
deltas.WriteString(d.Text)
case "done", "pending":
_ = json.Unmarshal([]byte(data), &done)
case "error":
panic("job failed: " + data)
}
}
sc := bufio.NewScanner(sres.Body)
sc.Buffer(make([]byte, 1<<20), 16<<20)
event, data := "message", ""
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
switch {
case line == "":
if data != "" {
handle(event, data)
}
event, data = "message", ""
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
}
}
if data != "" {
handle(event, data)
}
}
streamed := done.Output.Output
if streamed == "" {
streamed = deltas.String()
}
fmt.Println(done.Status, done.ChargedCredits, len(streamed))
HttpRequest streamReq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HttpResponse<java.io.InputStream> sres = HTTP.send(streamReq, HttpResponse.BodyHandlers.ofInputStream());
StringBuilder deltas = new StringBuilder();
JsonNode done = null;
String ctype = sres.headers().firstValue("content-type").orElse("");
try (BufferedReader br = new BufferedReader(new InputStreamReader(sres.body(), StandardCharsets.UTF_8))) {
if (!ctype.contains("text/event-stream")) {
JsonNode env = JSON.readTree(br);
if (sres.statusCode() / 100 != 2) throw new RuntimeException("HTTP " + sres.statusCode() + " " + env.path("error"));
done = env.path("data"); // an idempotent replay: the result itself
} else {
String event = "message", line;
StringBuilder data = new StringBuilder();
while (true) {
line = br.readLine();
if (line == null || line.isEmpty()) {
if (data.length() > 0) {
JsonNode d = JSON.readTree(data.toString());
if (event.equals("delta")) deltas.append(d.path("text").asText());
else if (event.equals("done") || event.equals("pending")) done = d;
else if (event.equals("error")) throw new RuntimeException("job failed: " + d);
}
if (line == null) break;
event = "message";
data.setLength(0);
} else if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
data.append(line.substring(5).trim());
}
}
}
}
String fromDone = done == null ? "" : done.path("output").path("output").asText("");
String streamed = fromDone.isEmpty() ? deltas.toString() : fromDone;
System.out.println(streamed.length() + " characters");
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{$token}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = INPUT.to_json
deltas, done = +"", nil
handle = lambda do |event, data|
d = JSON.parse(data)
case event
when "delta" then deltas << d["text"].to_s
when "done", "pending" then done = d
when "error" then raise "job failed: #{d['code']}: #{d['message']} (job #{d['job_id']})"
end
end
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
if !res["Content-Type"].to_s.include?("text/event-stream")
payload = JSON.parse(res.read_body) rescue {}
raise "HTTP #{res.code} #{payload['error']}" unless res.is_a?(Net::HTTPSuccess)
done = payload["data"] # an idempotent replay: the result itself
else
buf = +""
res.read_body do |chunk|
buf << chunk.delete("\r")
while (i = buf.index("\n\n"))
block = buf.slice!(0, i + 2)
event, data = "message", +""
block.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..].strip
elsif line.start_with?("data:") then data << line[5..].strip
end
end
handle.call(event, data) unless data.empty?
end
end
end
end
end
streamed = (done && done.dig("output", "output")) || deltas
puts "#{done && done['status']} #{streamed.length} characters"
$buf = ""; $deltas = ""; $done = null; $plain = "";
$handle = function (string $event, string $data) use (&$deltas, &$done) {
$d = json_decode($data, true);
if ($event === "delta") { $deltas .= $d["text"] ?? ""; }
elseif ($event === "done" || $event === "pending") { $done = $d; }
elseif ($event === "error") { throw new RuntimeException("job failed: " . $data); }
};
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $GLOBALS["token"], "Content-Type: application/json",
"Idempotency-Key: " . $key],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$plain, $handle) {
$ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
if (!str_contains($ctype, "text/event-stream")) { $plain .= $chunk; return strlen($chunk); }
$buf .= str_replace("\r", "", $chunk);
while (($i = strpos($buf, "\n\n")) !== false) {
$block = substr($buf, 0, $i);
$buf = substr($buf, $i + 2);
$event = "message"; $data = "";
foreach (explode("\n", $block) as $line) {
if (str_starts_with($line, "event:")) { $event = trim(substr($line, 6)); }
elseif (str_starts_with($line, "data:")) { $data .= trim(substr($line, 5)); }
}
if ($data !== "") { $handle($event, $data); }
}
return strlen($chunk);
},
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($plain !== "") {
$payload = json_decode($plain, true) ?: [];
if ($status < 200 || $status > 299) { throw new RuntimeException("HTTP $status " . json_encode($payload["error"] ?? null)); }
$done = $payload["data"] ?? null; // an idempotent replay: the result itself
}
$streamed = $done["output"]["output"] ?? $deltas;
echo strlen($streamed), " characters\n";
var sreq = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {token}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Content = new StringContent(input, Encoding.UTF8, "application/json");
using var sres = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
var deltas = new StringBuilder();
string? fromDone = null;
var ctype = sres.Content.Headers.ContentType?.MediaType ?? "";
if (ctype != "text/event-stream")
{
using var env = JsonDocument.Parse(await sres.Content.ReadAsStringAsync());
if (!sres.IsSuccessStatusCode) throw new Exception($"HTTP {(int)sres.StatusCode} {env.RootElement}");
// an idempotent replay: the result itself
fromDone = env.RootElement.GetProperty("data").GetProperty("output").GetProperty("output").GetString();
}
else
{
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
string ev = "message", data = "";
while (true)
{
var line = await sr.ReadLineAsync();
if (line == null || line.Length == 0)
{
if (data.Length > 0)
{
using var d = JsonDocument.Parse(data);
var r = d.RootElement;
if (ev == "delta" && r.TryGetProperty("text", out var tx)) deltas.Append(tx.GetString());
else if ((ev == "done" || ev == "pending") && r.TryGetProperty("output", out var o)
&& o.ValueKind == JsonValueKind.Object && o.TryGetProperty("output", out var oo)) fromDone = oo.GetString();
else if (ev == "error") throw new Exception($"job failed: {r}");
}
if (line == null) break;
ev = "message"; data = "";
}
else if (line.StartsWith("event:")) ev = line[6..].Trim();
else if (line.StartsWith("data:")) data += line[5..].Trim();
}
}
var streamed = string.IsNullOrEmpty(fromDone) ? deltas.ToString() : fromDone;
Console.WriteLine($"{streamed.Length} characters");
After the run
- Parse the reply text as the page does (/recon.js): strip a code fence, take the first balanced JSON object, and check that
taskis the lane you sent. - Audit: every id in
facts.findingsanswered exactly once withconfirmed,not_applicableorneeds_you; noblockerorhighfinding fromschema,codeorpackagedismissed asnot_applicablewithout a reason; a plan that runsnpx prisma generatefor anupgrade. - Rewrite: splice
schema_blocksinto your schema withDeskKit.spliceSchema(original, blocks), applypackage_changeswithDeskKit.applyPackageandtsconfig_changeswithDeskKit.applyTsconfig, or callDeskKit.assemble(reply, A)for the whole set. The page then runsRecon.reconcile, which re-checks every returned file with the same Prisma 7 checks. truncated: truemeans the reply hit the run's output cap;Recon.closeJsonrecovers the sections that arrived.