← ORM Query Check/API
Tokens

ORM Query Check API

ORM Query Check has two parts. The checker is free and runs only in your browser: pqscan.js parses schema.prisma and checks every Prisma Client call in pasted code against it (unique selectors, fields, relations, provider support, raw SQL, transactions, queries in loops). It makes no API call and costs nothing, so it is not part of this API — the file is plain JavaScript you can run yourself.

The two metered lanes are what the API runs. Each one is a single model run that returns one JSON object:

Everything below is taken from the page's own source: sdk.js (endpoints, headers, envelope), kit.js (the run input), SKILL.md (the model's instructions) and recon.js (how the reply is read). The web page runs the reply back through the checker; over the API you get the reply as it is, so run pqscan.js on returned code if you want the same check.

The task field comes first

Every request body is one JSON object, and its task field picks the lane. There are exactly two lanes: "write" and "review". The model is told to honour task; if it is missing or unrecognised it picks the closer lane from the fields present and says which in the reply's own task field. The two reply shapes are never mixed.

taskWhat it doesSendReply keys
writeWrites a query from a request.task, schema, request, facts; optional noteheadline, method, model, code, explanation, returns, fields_used, assumptions, caveats, cannot
reviewReviews code you already wrote.task, schema, code, facts (with the checker's findings and calls); optional noteheadline, verdict, finding_responses, extra_findings, fixed_code, changes, to_confirm

Input fields

The page builds this object with QueryKit.buildInput(lane, analysis, {note}). The limits are the page's own clipping rules from kit.js; the SDK documents no server-side limits.

FieldTypeLaneMeaningPage clipping limit
taskstringboth"write" or "review".—
schemastringbothYour whole schema.prisma as text.30,000 characters. A longer schema is cut down to the generator, datasource and enum blocks plus the models the lane needs (named in the request for write, called in the code for review) and their direct relations, capped at 30,000, then a comment // [... ORM Query Check cut: N models not sent ...] naming the models left out.
requeststringwriteWhat you want, in plain words (the page asks for at least 8 characters).3,000 characters: the opening and the closing are kept and the middle is replaced by [... cut by ORM Query Check: N characters from the middle].
codestringreviewTypeScript or JavaScript that calls Prisma Client (prisma.user.findMany(...)).24,000 characters. Longer code keeps the lines around every Prisma call (8 before, 16 after) and 8 lines either side of $transaction, $queryRaw, $executeRaw and PrismaClient; each gap becomes // [... ORM Query Check cut: lines A-B not sent ...], and the result is capped at 36,000.
factsstring (JSON)bothA JSON object serialised as a string, written by the browser checker. See the next table.findings: first 60; calls: first 80; schema_problems: first 20.
notestringboth, optionalContext from you (framework, what goes wrong). Sent only when not empty.2,000 characters, cut the same way as request.
retry_notestringboth, optionalNot typed by the user. The page adds it on its one automatic retry, when a reply was not the single valid JSON object the instructions require; that retry is a new run with attempt a2.Not clipped.

The facts string

facts is JSON.stringify of the object kit.js facts() returns. It is what the model trusts about your schema, and in review it carries the findings the reply must answer by id. If you call the API without the page, build the same object (running pqscan.js gives you every value) and send it as a string.

KeyLaneValue
providerbothThe datasource provider, e.g. postgresql, mysql; unknown when absent.
generatorbothprisma-client or prisma-client-js; unknown when absent.
client_apibothprisma-orm-7 (prisma-client generator), prisma-orm-6 or 7 (prisma-client-js generator) or unknown.
capabilitiesbothObject: provider, mode_insensitive, and_return, skip_duplicates, json_filters (array path like ['a','b'], string path like '$.a.b' or not supported), scalar_lists, is_set. Booleans are null when there is no provider.
modelsbothArray of {model, accessor, fields, unique}. fields are name:Type with [] for lists, ? for optional and (relation), (relation, list) or (enum) markers; unique lists the selectors (id, email, compound orderId_productId). When the schema was clipped, only the models sent.
enumsbothObject: enum name to its values.
schema_problemsbothUp to 20 strings, line N: text.
findingsreviewUp to 60 of {id, severity, code, line, quote, text}: ids F1, F2, ...; severity error, warning or info; code is the rule.
findings_not_sentreview, when over 60How many findings were left out.
callsreviewUp to 80 of {call, line}, e.g. {"call": "prisma.customer.findUnique", "line": 6}.
statusreviewThe checker's verdict on the code: unsafe, errors, warnings or clean.
schema_clippedwhen clipped{sent, models_not_sent}.
code_clippedreview, when clipped{lines_not_sent, total_chars}.

The output contract

A finished job carries the model's reply as text at job.output.output. By the rules in SKILL.md that text is one JSON object with every key present (empty arrays when there is nothing to say) and no prose or code fence around it. The page parses it with recon.js: parseResult takes the first balanced JSON object in the text (tolerating a stray fence), and normalize(obj, lane) gives each lane one shape, adding lane, lane_mismatch and present (the section keys the reply actually carried). The rightmost column is what normalize does to each key.

task "write"

KeyTypeMeaning (SKILL.md)recon.js normalize
taskstring"write".Picks the shape; an unknown value falls back to the lane you asked for, and a known other lane is flagged as lane_mismatch.
headlinestringOne sentence: what the call does.Stringified.
methodstringThe main call, e.g. findMany.Trimmed.
modelstringThe main model, e.g. Post.Trimmed.
codestringTypeScript; "" when the request cannot be met.A wrapping code fence is removed; trailing space trimmed.
explanationstring[]One short line per decision.Non-empty strings, first 30.
returnsstringThe shape the call resolves to.Stringified.
fields_usedstring[]Every Model.field the code touches.First 80. The page checks each one exists.
assumptionsstring[]What had to be assumed.First 20.
caveats{kind, text}[]kind: performance, correctness, database or security.First 20; an unknown kind becomes correctness; empty text dropped.
cannotstringEmpty, or one or two sentences naming exactly what the schema lacks.Trimmed; non-empty sets the page's verdict to cannot, else written.

task "review"

KeyTypeMeaning (SKILL.md)recon.js normalize
taskstring"review".As for write.
headlinestringOne-sentence verdict.Stringified.
verdictstringok, fix or unsafe (SQL injection, or a query that can hit every row).Lower-cased; anything else becomes fix, the raw value kept as verdict_raw.
finding_responses{ref, stance, note}[]Every facts.findings id answered once; stance confirmed or not_applicable.First 200; ref (or id) upper-cased; an unknown stance becomes unclear, raw kept as stance_raw.
extra_findings{title, severity, line, evidence, problem, fix}[]Problems the checker did not report; evidence is copied verbatim from code.First 40; severity error/warning/info, default warning; line a number, 0 when missing.
fixed_codestringThe whole code with every fix; "" when the verdict is ok.Code fence removed; trailing space trimmed.
changesstring[]One line per change, e.g. line 4: findUnique -> findFirst ....First 60.
to_confirmstring[]Questions you must answer.First 20.

Worked examples

Both are real runs the page ships as saved examples. The long schema and facts strings are abbreviated here (marked ... [abbreviated: N characters in full]); every other value is exact.

write: a paged, case-insensitive search (PostgreSQL)

The schema has User, Post, Comment and Tag and a Role enum on postgresql with the prisma-client generator; facts says mode_insensitive: true.

Input:

{
  "task": "write",
  "schema": "generator client {\n  provider = \"prisma-client\"\n  output   = \"../src/generated/prisma\"\n}\n\n ... [abbreviated: 1298 characters in full]",
  "facts": "{\"provider\":\"postgresql\",\"generator\":\"prisma-client\",\"client_api\":\"prisma-orm-7 (prisma-cl ... [abbreviated: 1215 characters in full]",
  "request": "Search published posts whose title contains a search term, ignoring case. Show 20 per page, newest first by publish date, with the author's name, the tag names, and how many comments each post has. I page with a cursor (the last post id I got)."
}

Reply (the text at job.output.output, parsed and pretty-printed):

{
  "task": "write",
  "headline": "Finds up to 20 published posts whose title matches a search term (case-insensitive), newest-published first, with the author's name, tag names, and a comment count, paged by a post-id cursor.",
  "method": "findMany",
  "model": "Post",
  "code": "declare const searchTerm: string;\ndeclare const cursorId: number | undefined;\n\nconst posts = await prisma.post.findMany({\n  where: {\n    published: true,\n    title: { contains: searchTerm, mode: 'insensitive' },\n  },\n  orderBy: [\n    { publishedAt: { sort: 'desc', nulls: 'last' } },\n    { id: 'desc' },\n  ],\n  take: 20,\n  ...(cursorId !== undefined ? { cursor: { id: cursorId }, skip: 1 } : {}),\n  select: {\n    id: true,\n    title: true,\n    publishedAt: true,\n    author: { select: { name: true } },\n    tags: { select: { name: true } },\n    _count: { select: { comments: true } },\n  },\n});",
  "explanation": [
    "where combines published: true with title: { contains: searchTerm, mode: 'insensitive' } for a case-insensitive substring match; mode is valid because facts.provider is postgresql.",
    "orderBy sorts by publishedAt descending for \"newest first by publish date\", with id descending as a tiebreaker so ties don't reorder between pages; nulls: 'last' keeps a published post with no publishedAt from sorting as newest.",
    "cursor: { id: cursorId }, skip: 1 resumes after the last post id the caller already saw; it is applied only when cursorId is defined, so the first page (no prior id) just returns the first 20 rows.",
    "select returns only what a results list needs - title, publish date, author name, tag names - instead of the full post body.",
    "_count: { select: { comments: true } } gives the comment total for each post without loading the comment rows."
  ],
  "returns": "Array<{ id: number; title: string; publishedAt: Date | null; author: { name: string | null }; tags: { name: string }[]; _count: { comments: number } }>",
  "fields_used": [
    "Post.published",
    "Post.title",
    "Post.publishedAt",
    "Post.id",
    "Post.author",
    "Post.tags",
    "Post.comments",
    "User.name",
    "Tag.name"
  ],
  "assumptions": [
    "\"Newest first by publish date\" sorts by Post.publishedAt, not createdAt, since publishedAt is the field that represents when the post went live.",
    "cursorId is the last post's id from the previous page, supplied by the caller as undefined on the first page.",
    "Only a results-list view is needed (title, publish date, author name, tag names, comment count), not the full post body."
  ],
  "caveats": [
    {
      "kind": "correctness",
      "text": "Post.publishedAt is optional, so a row with published: true but publishedAt: null (allowed by the schema even if the app never creates one) sorts to the end under nulls: 'last' instead of appearing as 'newest'."
    },
    {
      "kind": "database",
      "text": "mode: 'insensitive' depends on facts.provider being postgresql; it must be dropped if this query is ever run against MySQL or SQL Server."
    },
    {
      "kind": "performance",
      "text": "title: { contains } can't use a plain B-tree index, so on a large posts table add a pg_trgm trigram index (or a full-text index) on title to keep the search fast."
    }
  ],
  "cannot": ""
}

review: a MySQL shop with eight checker findings

The schema is Customer, Product, Order and OrderItem (compound @@id([orderId, productId])) on mysql. facts.findings carries F1-F8 from the browser checker: unique-where (line 6), db-support (11), include-scalar (12), bad-value (13), raw-injection (18), tx-outer-client (25) and two query-in-loop warnings (25, 26); facts.status is unsafe.

Input:

{
  "task": "review",
  "schema": "generator client {\n  provider = \"prisma-client-js\"\n}\n\ndatasource db {\n  provider = \"mysql\" ... [abbreviated: 1138 characters in full]",
  "facts": "{\"provider\":\"mysql\",\"generator\":\"prisma-client-js\",\"client_api\":\"prisma-orm-6 or 7 (prisma ... [abbreviated: 3470 characters in full]",
  "code": "import { PrismaClient } from '@prisma/client'\n\nconst prisma = new PrismaClient()\n\nexport async function customerByName(name: string) {\n  return prisma.customer.findUnique({ where: { name } })\n}\n\nexport async function searchProducts(term: string) {\n  return prisma.product.findMany({\n    where: { name: { contains: term, mode: 'insensitive' } },\n    include: { sku: true },\n    orderBy: { price: 'ASC' },\n  })\n}\n\nexport async function ordersForEmail(email: string) {\n  return prisma.$queryRawUnsafe(`SELECT * FROM \\`Order\\` o JOIN Customer c ON c.id = o.customerId WHERE c.email = '${email}'`)\n}\n\nexport async function placeOrder(customerId: number, lines: { productId: number; quantity: number }[]) {\n  return prisma.$transaction(async (tx) => {\n    const order = await tx.order.create({ data: { customerId, total: 0 } })\n    for (const line of lines) {\n      await prisma.product.update({ where: { id: line.productId }, data: { stock: { decrement: line.quantity } } })\n      await tx.orderItem.create({ data: { orderId: order.id, productId: line.productId, quantity: line.quantity } })\n    }\n    return order\n  })\n}\n",
  "note": "Express API on MySQL. placeOrder is slow and sometimes leaves stock wrong."
}

Reply:

{
  "task": "review",
  "headline": "Unsafe: ordersForEmail builds raw SQL from a template string ($queryRawUnsafe), and placeOrder decrements stock on the outer client instead of tx, escaping its own transaction; four smaller query bugs also need fixing.",
  "verdict": "unsafe",
  "finding_responses": [
    {
      "ref": "F1",
      "stance": "confirmed",
      "note": "name is not @id/@unique (only id, email are on Customer); changed findUnique to findFirst (line 6)."
    },
    {
      "ref": "F2",
      "stance": "confirmed",
      "note": "MySQL has no mode option and its default collation is already case-insensitive; removed mode: 'insensitive' (line 11)."
    },
    {
      "ref": "F3",
      "stance": "confirmed",
      "note": "include only takes relations; Product.sku is a scalar and findMany already returns it, so the include was removed (line 12)."
    },
    {
      "ref": "F4",
      "stance": "confirmed",
      "note": "orderBy direction must be lower case; changed 'ASC' to 'asc' (line 13)."
    },
    {
      "ref": "F5",
      "stance": "confirmed",
      "note": "SQL injection via string-interpolated $queryRawUnsafe; replaced with a $queryRaw tagged template so ${email} is bound as a parameter (line 18)."
    },
    {
      "ref": "F6",
      "stance": "confirmed",
      "note": "the decrement ran on the outer prisma client, not tx, so it committed outside the transaction; changed to tx.product.update (line 25)."
    },
    {
      "ref": "F7",
      "stance": "confirmed",
      "note": "one product.update per line; each line decrements a different product by a different amount so there is no bulk updateMany equivalent without raw SQL - kept per-line but now inside tx (see F6) so it is atomic with the rest of the order."
    },
    {
      "ref": "F8",
      "stance": "confirmed",
      "note": "one orderItem.create per line; replaced the loop with a single tx.orderItem.createMany call after the loop (line 26)."
    }
  ],
  "extra_findings": [
    {
      "title": "Order total is hard-coded to 0",
      "severity": "warning",
      "line": 23,
      "evidence": "data: { customerId, total: 0 }",
      "problem": "Every Order is persisted with total: 0 regardless of its line items and product prices, so the stored total is always wrong - this matches the note that placeOrder 'sometimes leaves stock wrong', but here it's the total that's silently wrong on every call, not just sometimes.",
      "fix": "Compute the total inside the transaction from the ordered products (e.g. tx.product.findMany the line productIds, sum price * quantity) and either pass that into tx.order.create's data or update it with tx.order.update once the amount is known, instead of the literal 0."
    },
    {
      "title": "Stock is decremented with no check that enough stock exists",
      "severity": "warning",
      "line": 25,
      "evidence": "data: { stock: { decrement: line.quantity } }",
      "problem": "The update has no condition on the current stock, so decrementing by more than what is in stock still succeeds and Product.stock can go negative (oversell) instead of the order being rejected.",
      "fix": "Constrain the update to rows with enough stock, e.g. tx.product.update({ where: { id: line.productId, stock: { gte: line.quantity } }, data: { stock: { decrement: line.quantity } } }), and treat a missing/failed update (record not found) as insufficient stock so the transaction is rolled back."
    }
  ],
  "fixed_code": "import { PrismaClient } from '@prisma/client'\n\nconst prisma = new PrismaClient()\n\nexport async function customerByName(name: string) {\n  return prisma.customer.findFirst({ where: { name } })\n}\n\nexport async function searchProducts(term: string) {\n  return prisma.product.findMany({\n    where: { name: { contains: term } },\n    orderBy: { price: 'asc' },\n  })\n}\n\nexport async function ordersForEmail(email: string) {\n  return prisma.$queryRaw`SELECT * FROM \\`Order\\` o JOIN Customer c ON c.id = o.customerId WHERE c.email = ${email}`\n}\n\nexport async function placeOrder(customerId: number, lines: { productId: number; quantity: number }[]) {\n  return prisma.$transaction(async (tx) => {\n    const order = await tx.order.create({ data: { customerId, total: 0 } })\n    for (const line of lines) {\n      await tx.product.update({ where: { id: line.productId }, data: { stock: { decrement: line.quantity } } })\n    }\n    await tx.orderItem.createMany({\n      data: lines.map((line) => ({ orderId: order.id, productId: line.productId, quantity: line.quantity })),\n    })\n    return order\n  })\n}\n",
  "changes": [
    "line 6: findUnique -> findFirst because name is not a unique field (F1).",
    "line 11: removed mode: 'insensitive' - unsupported on MySQL (F2).",
    "line 12: removed the invalid include: { sku: true } - sku is a scalar and is already returned (F3).",
    "line 13: 'ASC' -> 'asc' (F4).",
    "line 18: $queryRawUnsafe template string -> $queryRaw tagged template with email bound as a parameter, closing the SQL-injection hole (F5).",
    "line 25: prisma.product.update -> tx.product.update so the decrement is part of the transaction (F6).",
    "line 26: moved orderItem.create out of the loop into one tx.orderItem.createMany call after the loop (F8); the per-line tx.product.update stays because each line decrements a different product by a different amount, so it remains N+1 (F7)."
  ],
  "to_confirm": [
    "order.total is still hard-coded to 0 - should placeOrder compute it from the ordered products' prices, and if so at what price (current Product.price, or a price supplied by the caller)?",
    "Should placeOrder check that a product has enough stock before decrementing and roll back the order when it doesn't, or is negative stock acceptable (e.g. a backorder)?",
    "If lines contains the same productId twice, should the quantities be merged before creating OrderItem rows? As written, two rows with the same (orderId, productId) would violate the @@id([orderId, productId]) compound key and throw."
  ]
}

Base URL and envelope

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer YOUR_TOKEN. The request body for /estimate, /run and /run-stream is the input object itself — no wrapper.

Method and pathBodydata
POST /guest{"slug": "orm-query-check"}, no Authorization{token, guest_id}
GET /me—{subject_type, subject_id, credits, profile?}
POST /estimatethe input object{hold_credits, min_credits, sponsor_enabled, ...} — no charge, no job
POST /runthe input object; header Idempotency-Key{job_id, ...}
GET /jobs/{id}—{status, output: {output}, charged_credits, truncated, error}
POST /run-streamthe input object; header Idempotency-Keyan SSE stream, or a plain envelope on an idempotent replay

Success is {"data": ...}; failure is {"error": {"code": "...", "message": "...", "details": ...}}.

Errors

These are the only failure paths sdk.js and app.js handle; the SDK names no other codes.

WhatShapeWhat to do
Any non-2xx JSON response{"error": {"code", "message", "details"}}The SDK throws an error carrying status, code and details; the message falls back to the HTTP status text.
401error envelopeThe page treats it as an expired session: it forgets the user and asks you to sign in again.
402error envelopeNot enough credits for the run: top up. The page prices every run first and never submits into a 402 knowingly.
SSE event: errordata: {code, message, job_id}Raised after the stream ends, with code and job_id. The page keeps whatever part of the reply had already arrived.
Job status: "failed"GET /jobs/{id} is terminaljob.error says why; there is no reply to parse.

Idempotency-Key

Send an Idempotency-Key header on /run and /run-stream. The page's key is orm-query-check:<lane>:<hash>:a<attempt>, e.g. orm-query-check:write:1p0oleyqsq3:a1 for the write example below, where <hash> is kit.js hashInput(input) (a 53-bit hash of the key-sorted input, in base 36) and <attempt> starts at 1. A network retry with the same key never bills twice; the same schema in the two lanes is two runs; the one automatic reformat retry (with retry_note) is attempt 2. Any stable hash of your exact input works; the samples below use the first 16 hex digits of its SHA-256. Re-sending a used key replays that job: /run-stream then answers with a plain JSON envelope instead of a stream, and the samples handle both.

Estimate, hold and charge

Step by step

1. A tiny client helper

It sends JSON, adds the bearer token, returns data and turns the error envelope into an exception.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"

# api METHOD PATH [JSON_BODY] [extra curl args...]
# Prints the envelope: {"data": ...} on 2xx, {"error": {"code", "message", "details"}} otherwise.
api() {
  local method="$1" path="$2" body="${3-}"
  shift 2
  [ $# -gt 0 ] && shift
  if [ -n "$body" ]; then
    curl -sS -X "$method" "$BASE$path" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      --data-binary "$body" "$@"
  else
    curl -sS -X "$method" "$BASE$path" \
      -H "Authorization: Bearer $TOKEN" "$@"
  fi
}

2. Get a token

A guest token comes from POST /guest with {"slug": "orm-query-check"} and returns token and guest_id. It can read /me and price runs. Metered runs need a personal, signed-in token: copy yours from the token page.

# A guest token: no Authorization header, body {"slug": "orm-query-check"}.
GUEST=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  --data-binary '{"slug":"orm-query-check"}')
echo "$GUEST" | jq '.data.guest_id'
TOKEN=$(echo "$GUEST" | jq -r '.data.token')

# Metered runs (task "write" and "review") need a personal, signed-in token instead:
# copy it from https://orm-query-check.skillsafe.ai/tokens.html
# TOKEN="YOUR_TOKEN"

3. Who am I: GET /me

api GET /me | jq '.data'
# {"subject_type": "...", "subject_id": "...", "credits": 12345, ...}

4. Price a write run: POST /estimate

The body is the input object. facts here is a hand-written subset of what the page sends (the keys from the facts table); run pqscan.js to produce the full object.

cat > schema.prisma <<'PRISMA'
generator client {
  provider = "prisma-client"
  output   = "../src/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
  posts Post[]
}

model Post {
  id          Int       @id @default(autoincrement())
  title       String
  published   Boolean   @default(false)
  publishedAt DateTime?
  author      User      @relation(fields: [authorId], references: [id])
  authorId    Int
}
PRISMA

# facts is sent as a STRING holding JSON; the page builds it with kit.js facts().
cat > facts.json <<'JSON'
{
  "provider": "postgresql",
  "generator": "prisma-client",
  "client_api": "prisma-orm-7 (prisma-client generator)",
  "capabilities": {"provider": "postgresql", "mode_insensitive": true, "and_return": true, "skip_duplicates": true, "json_filters": "array path like ['a','b']", "scalar_lists": true, "is_set": false},
  "models": [
    {"model": "User", "accessor": "user", "fields": ["id:Int", "email:String", "name:String?", "posts:Post[](relation, list)"], "unique": ["id", "email"]},
    {"model": "Post", "accessor": "post", "fields": ["id:Int", "title:String", "published:Boolean", "publishedAt:DateTime?", "author:User(relation)", "authorId:Int"], "unique": ["id"]}
  ],
  "enums": {},
  "schema_problems": []
}
JSON

INPUT=$(jq -n \
  --rawfile schema schema.prisma \
  --arg facts "$(jq -c . facts.json)" \
  --arg request "Published posts whose title contains a search term, ignoring case, newest first by publish date, 20 per page, with the author's name." \
  '{task: "write", schema: $schema, request: $request, facts: $facts}')

api POST /estimate "$INPUT" | jq '.data'
# hold_credits = the reservation, not the price; min_credits = the balance floor

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

Send the Idempotency-Key, poll about once a second until status is succeeded or failed, then read the reply text at job.output.output and parse it as JSON.

# orm-query-check:<lane>:<hash of the exact input>:a<attempt>
KEY="orm-query-check:write:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

RUN=$(api POST /run "$INPUT" -H "Idempotency-Key: $KEY")
JOB_ID=$(echo "$RUN" | jq -r '.data.job_id // empty')
[ -n "$JOB_ID" ] || { echo "$RUN" | jq '.error'; exit 1; }   # 401: sign in again, 402: top up

while :; do
  JOB=$(api GET "/jobs/$JOB_ID")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  case "$STATUS" in succeeded|failed) break ;; esac
  sleep 1
done

echo "$JOB" | jq '.data | {status, charged_credits, truncated, error}'
# The model's reply is a JSON object serialised as text in job.output.output:
echo "$JOB" | jq -r '.data.output.output' | jq '{headline, method, model, code}'

6. Stream it: POST /run-stream

The response is Server-Sent Events: delta ({text}, a piece of the reply), job, done or pending (the final payload {job_id, status, charged_credits, output, truncated}) and error ({code, message, job_id}). Browsers may receive only heartbeats and a final done, with no deltas, so always read the reply from the final payload's output.output and use the joined deltas only as a fallback. The samples treat a final payload that is not yet succeeded or failed as unfinished and poll /jobs/{job_id}.

KEY="orm-query-check:write:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

# -N turns off buffering. A replay of a key already used answers with a plain {"data": ...} JSON body.
curl -sS -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data-binary "$INPUT" |
while IFS= read -r line; do
  case "$line" in
    "{"*) echo "$line" | jq -r '.data.output.output // .error' ;;   # plain JSON: replay or error
    "event:"*) EVENT="${line#event:}"; EVENT="${EVENT# }" ;;
    "data:"*) DATA="${DATA-}${line#data:}" ;;
    "")
      if [ -n "${DATA-}" ]; then
        case "${EVENT-message}" in
          delta) printf '%s' "$DATA" | jq -j '.text // ""' ;;
          job) printf '%s' "$DATA" | jq -c '{job: .}' >&2 ;;
          done|pending) printf '%s' "$DATA" | jq -c '{status, job_id, charged_credits, truncated}' >&2
                        printf '%s' "$DATA" | jq -r '.output.output // empty' ;;
          error) printf '%s' "$DATA" | jq -c '{error: .}' >&2 ;;
        esac
      fi
      EVENT=message; DATA="" ;;
  esac
done
# A "pending" payload has no output yet: poll /jobs/{job_id} as in step 5.