← PL/SQL Shift / API
Tokens

Driving PL/SQL Shift from your own code

Everything the web app does is a public HTTP call. Paste Oracle PL/SQL, get the PostgreSQL PL/pgSQL translation back as one structured JSON object — from a migration script, a CI job, or a batch that walks a whole schema.

Base URL and the response envelope

Base URL: https://api.skillsafe.ai/v1/app-api. The app slug is plsql-shift; you only pass it when minting a guest token — every other call identifies the app from the token itself.

Every response is the same envelope:

{"ok":true,"data":{ ... }}

{"ok":false,"error":{"code":"...","message":"...","details":{ ... }}}

Error codes

HTTPcodeWhat it meansRetryable
400validation_errorThe input object is malformed — usually routines missing or not a string.No — fix the request
401unauthorizedMissing, expired or revoked token. Mint a new one.No — re-authenticate
402payment_requiredBalance is below min_credits for this run.No — top up first
404not_foundUnknown job id, or a collection this release does not declare.No
429rate_limitedToo many requests. Back off and retry with the same Idempotency-Key.Yes — with backoff
500server_errorSomething failed on our side.Yes — with backoff and the same key

On any retryable error, reuse the Idempotency-Key from the original request. That is the difference between retrying a translation and paying for two.

Step 1 — Get a token

Everything below needs an Authorization: Bearer header. The easiest way to get a token is the app's own token page — it reads the token this browser already holds, shows the session it belongs to, and gives you a one-click Copy shell export. You never need to open a developer console.

A guest token is minted by POST /guest and is enough for /me and /estimate. Translating is metered, so /run and /run-stream need a personal token, which comes from signing in.

# A guest token is enough for /me and /estimate.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H 'Content-Type: application/json' \
  -d '{"slug":"plsql-shift"}'

# -> {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
# Running a translation is metered and needs a PERSONAL token:
# get one from https://plsql-shift.skillsafe.ai/tokens.html

Step 2 — A tiny client

One helper that adds the auth header, sends JSON, and unwraps the {ok, data} envelope. Everything after this uses it.

# Every call carries the same two headers. Keep them in a shell function.
TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"

ss() {  # ss <METHOD> <PATH> [JSON_BODY]
  curl -s -X "$1" "$BASE$2" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    ${3:+-d "$3"}
}

Step 3 — Check the session and the balance

GET /me returns subject_type (user or guest), username and credits. Compare the balance against min_credits from the next step before you submit, so a run never fails with a 402 you could have predicted.

ss GET /me

# {"ok":true,"data":{"subject_type":"user","username":"you","credits":124500}}

Step 4 — Price the run

POST /estimate takes the same input object as /run. It is free: no charge, no job created. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled.

This app is bound to the model alias gpt-terra (today resolving to gpt-5.6-terra) at markup_bps: 1000 — a 10% publisher markup. hold_credits is a reservation priced against the full output cap, not the price; what you are actually charged is usually far lower and comes back as charged_credits when the job settles.

# free: no charge, no job created
ss POST /estimate '{"routines": "CREATE OR REPLACE FUNCTION GET_ORDER_TOTAL(\n  P_ORDER_ID IN ORDERS.ORDER_ID%TYPE\n) RETURN NUMBER\nIS\n  V_TOTAL NUMBER;\nBEGIN\n  SELECT NVL(SUM(LINE_TOTAL), 0) INTO V_TOTAL\n    FROM ORDER_LINES WHERE ORDER_ID = P_ORDER_ID;\n  RETURN V_TOTAL;\nEXCEPTION\n  WHEN NO_DATA_FOUND THEN RETURN 0;\nEND;", "schema_ddl": "CREATE TABLE ORDERS (\n  ORDER_ID   NUMBER(12)   NOT NULL,\n  PLACED_AT  DATE         NOT NULL\n);", "target": "postgres-17", "orafce": "available", "emphasis": "general", "context": "Order-management estate moving off Oracle 19c."}'

# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":24800,"min_credits":900,"sponsor_enabled":false}}

Step 5 — Run it, then poll

POST /run returns {"job_id": "job_..."}. Poll GET /jobs/{job_id} until status is succeeded or failed.

Always send an Idempotency-Key. It is what makes a retried POST — after a dropped connection, a timeout, or a reformat retry — return the same job instead of starting and billing a second translation. Derive it from the input, and add an attempt counter only when you deliberately want a fresh run. The web app uses plsql-shift:{hash of the input}:a{attempt}.

# The Idempotency-Key makes a retried POST return the SAME job
# instead of starting - and billing - a second translation.
KEY="plsql-shift:$(printf %%s "$ROUTINES" | shasum -a 256 | cut -c1-16):a1"

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

# poll until terminal
while :; do
  OUT=$(ss GET "/jobs/$JOB")
  echo "$OUT" | grep -q '"status":"succeeded"' && break
  echo "$OUT" | grep -q '"status":"failed"'    && { echo "$OUT"; exit 1; }
  sleep 2
done
echo "$OUT"

Step 6 — Or stream it

POST /run-stream is the same call over Server-Sent Events. It emits event: delta frames carrying {"text": "..."} as the reply generates, then one event: done frame with the terminal payload {job_id, status, charged_credits, output}. Concatenating every delta gives you the same string as output.output.

This is what the app itself uses, so the progress card can advance as section keys arrive. If the response comes back as plain JSON rather than text/event-stream, it is an idempotent replay of a job you already ran — read it as a normal /run reply.

curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT_JSON"

# event: delta
# data: {"text":"{\"migration_name\":\"ORDER"}
# event: delta
# data: {"text":"_AUDIT package"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":18240,
#        "output":{"output":"{ ...the whole JSON object... }"}}

Step 7 — Read the result

The job's output.output is a string containing one JSON object. Parse it, then read translated_sql — the field the whole app exists to produce — and check signatures[].preserved and any construct whose status is unsupported before you run anything.

# `raw` is a JSON *string* holding the object. Unwrap it, then read the SQL.
echo "$OUT" \
  | python3 -c 'import sys,json; j=json.load(sys.stdin)["data"]; print(j["output"]["output"])' \
  > reply.json

python3 -c 'import json; r=json.load(open("reply.json")); print(r["translated_sql"])' \
  > translated.sql

psql -f translated.sql -v ON_ERROR_STOP=1

The input object

The same object goes to /estimate, /run and /run-stream. Only routines is required.

FieldTypeMeaning
routinesstring (required)The Oracle source: packages, package bodies, standalone procedures and functions, triggers. Several files may be concatenated, each preceded by a -- file: NAME.sql marker line.
schema_ddlstringOracle CREATE TABLE / view DDL, so %TYPE anchors and column types resolve. Optional, but the translation is materially better with it.
targetstringOne of postgres-17, postgres-16, postgres-15, aurora, unknown.
orafcestringOne of available, unavailable, unknown. unavailable forces a core-PostgreSQL translation with no oracle.* calls.
emphasisstringOne of general, signatures, collation, exceptions, cursors, transactions, performance.
contextstringFree text about the estate. Changes what gets flagged and how it is ordered.
prescan_factsobject{"routines": [{"id","label"}], "flags": [{"id","label"}]} — what the browser's free parser found. The model is required to reconcile every flags[].id exactly once in coverage_check. API callers may omit it; if you do, expect coverage_check to come back empty, and nothing else to change.

The output contract

The job output is a string holding one JSON object with these keys. This is exactly what the app's own render path parses, so anything it accepts, yours can too.

KeyTypeMeaning
migration_namestringA short name for what was translated.
portabilityenumdirect | needs-review | manual-rework.
verdictstringOne sentence naming the thing that decides the verdict.
oracle_featuresstringOne line summarising the Oracle features the source actually uses.
target_versionstringThe PostgreSQL target the translation assumed.
orafce_useenumrequired | optional | not-used.
exec_summarystringA short paragraph to read before the SQL.
assumptionsstring[]What was assumed because the paste did not say.
open_questionsstring[]What to settle before running this against a real database.
signaturesobject[]{routine, kind, oracle_signature, postgres_signature, preserved, note}. kind is function, procedure, trigger, package-spec, package-body or type-body. preserved: false means callers must change.
constructsobject[]{oracle, postgres, status, note}, one row per distinct Oracle construct met. status is translated, orafce, rewritten, unsupported or unchanged.
findingsobject[]{id, category, severity, likelihood, priority, routine, problem, impact, fix, snippet}. Ids are PS-001, PS-002category is one of syntax, datatype, collation, exception, cursor, transaction, package-state, sql-dialect, performance, security, testing. May legitimately be empty for a clean translation.
coverage_checkobject[]{id, addressed, note}, one entry per prescan_facts.flags[].id you sent.
translated_sqlstringThe complete PL/pgSQL. $$-quoted bodies, one CREATE OR REPLACE per routine, with -- MANUAL: comments wherever a human has to decide. An empty value here is the one hard parse failure: the app rejects such a reply and retries once.
commandsstring[]Shell / psql commands that verify the result.
quick_winsstring[]Small changes worth making immediately.
summarystringA closing paragraph.

History

Every translation the web app runs is written to the app's declared translations collection under the caller's own account (acl_read: owner, acl_write: user), which is what makes history follow a user to another device. API callers reach the same rows through /v1/app-api/collections/translations/query with their own token — you only ever see your own records. Runs you drive through the API are not written there automatically; store what you need on your side.

Two things worth repeating