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
| HTTP | code | What it means | Retryable |
|---|---|---|---|
| 400 | validation_error | The input object is malformed — usually routines missing or not a string. | No — fix the request |
| 401 | unauthorized | Missing, expired or revoked token. Mint a new one. | No — re-authenticate |
| 402 | payment_required | Balance is below min_credits for this run. | No — top up first |
| 404 | not_found | Unknown job id, or a collection this release does not declare. | No |
| 429 | rate_limited | Too many requests. Back off and retry with the same Idempotency-Key. | Yes — with backoff |
| 500 | server_error | Something 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
import urllib.request, json
BASE = "https://api.skillsafe.ai/v1/app-api"
def guest_token():
req = urllib.request.Request(
BASE + "/guest",
data=json.dumps({"slug": "plsql-shift"}).encode(),
headers={"Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]["token"]
# A personal token comes from https://plsql-shift.skillsafe.ai/tokens.html
TOKEN = "YOUR_TOKEN"
const BASE = "https://api.skillsafe.ai/v1/app-api";
async function guestToken() {
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "plsql-shift" })
});
const json = await res.json();
return json.data.token;
}
// A personal token comes from https://plsql-shift.skillsafe.ai/tokens.html
const TOKEN = "YOUR_TOKEN";
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// A personal token comes from https://plsql-shift.skillsafe.ai/tokens.html
var token = os.Getenv("SKILLSAFE_TOKEN")
func guestToken() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "plsql-shift"})
res, err := http.Post(base+"/guest", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
Data struct{ Token string } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&out)
return out.Data.Token, nil
}
import java.net.http.*;
import java.net.URI;
public class Tokens {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
// A personal token comes from https://plsql-shift.skillsafe.ai/tokens.html
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");
static String guestToken() throws Exception {
HttpClient c = HttpClient.newHttpClient();
HttpRequest r = HttpRequest.newBuilder(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"plsql-shift\"}"))
.build();
return c.send(r, HttpResponse.BodyHandlers.ofString()).body();
}
}
require "net/http"
require "json"
BASE = "https://api.skillsafe.ai/v1/app-api"
# A personal token comes from https://plsql-shift.skillsafe.ai/tokens.html
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
def guest_token
uri = URI(BASE + "/guest")
res = Net::HTTP.post(uri, { slug: "plsql-shift" }.to_json,
"Content-Type" => "application/json")
JSON.parse(res.body)["data"]["token"]
end
<?php
$base = "https://api.skillsafe.ai/v1/app-api";
// A personal token comes from https://plsql-shift.skillsafe.ai/tokens.html
$token = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
function guest_token($base) {
$ch = curl_init($base . "/guest");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "plsql-shift"]),
CURLOPT_RETURNTRANSFER => true,
]);
$out = json_decode(curl_exec($ch), true);
return $out["data"]["token"];
}
using System.Net.Http;
using System.Text;
using System.Text.Json;
const string Base = "https://api.skillsafe.ai/v1/app-api";
// A personal token comes from https://plsql-shift.skillsafe.ai/tokens.html
string token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
var http = new HttpClient();
var body = new StringContent("{\"slug\":\"plsql-shift\"}", Encoding.UTF8, "application/json");
var res = await http.PostAsync(Base + "/guest", body);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
string guest = doc.RootElement.GetProperty("data").GetProperty("token").GetString();
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"}
}
import json, urllib.request, urllib.error
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
class ApiError(Exception):
def __init__(self, status, code, message):
super().__init__("%s: %s" % (code, message))
self.status, self.code = status, code
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method,
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
return json.load(r)["data"] # unwrap the envelope
except urllib.error.HTTPError as e:
err = json.load(e).get("error", {})
raise ApiError(e.code, err.get("code"), err.get("message"))
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json"
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const json = await res.json();
if (!res.ok) {
const e = new Error(json.error ? json.error.message : res.statusText);
e.code = json.error && json.error.code;
e.status = res.status;
throw e;
}
return json.data; // unwrap the envelope
}
func call(method, path string, body any) (json.RawMessage, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct{ Code, Message string } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
static String call(String method, String path, String body) throws Exception {
HttpClient client = HttpClient.newHttpClient();
HttpRequest.BodyPublisher pub = (body == null)
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, pub)
.build();
HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // {"ok":true,"data":{...}}
}
def call(method, path, body = nil)
uri = URI(BASE + path)
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.generate(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
json = JSON.parse(res.body)
raise "#{json.dig("error","code")}: #{json.dig("error","message")}" unless json["ok"]
json["data"]
end
function call($method, $path, $body = null) {
global $base, $token;
$ch = curl_init($base . $path);
$headers = ["Authorization: Bearer $token", "Content-Type: application/json"];
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$out = json_decode(curl_exec($ch), true);
if (empty($out["ok"])) throw new Exception($out["error"]["message"] ?? "request failed");
return $out["data"];
}
static async Task<JsonElement> Call(HttpClient http, string method,
string path, object body = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
req.Headers.Add("Authorization", "Bearer " + token);
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!res.IsSuccessStatusCode)
throw new Exception(doc.RootElement.GetProperty("error")
.GetProperty("message").GetString());
return doc.RootElement.GetProperty("data");
}
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}}
me = call("GET", "/me")
print(me["subject_type"], me.get("username"), me["credits"])
const me = await call("GET", "/me");
console.log(me.subject_type, me.username, me.credits);
raw, err := call("GET", "/me", nil)
if err != nil {
log.Fatal(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Username string `json:"username"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Username, me.Credits)
String me = call("GET", "/me", null);
System.out.println(me);
me = call("GET", "/me")
puts "#{me["subject_type"]} #{me["username"]} #{me["credits"]}"
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Call(http, "GET", "/me");
Console.WriteLine(me.GetProperty("credits").GetInt32());
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}}
INPUT = {
"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."
}
est = call("POST", "/estimate", INPUT) # free: no charge, no job
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserves", est["hold_credits"], "minimum", est["min_credits"])
const INPUT = {
"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."
};
const est = await call("POST", "/estimate", INPUT); // free: no charge, no job
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserves", est.hold_credits, "minimum", est.min_credits);
input := map[string]any{
"routines": oracleSource,
"schema_ddl": tableDDL,
"target": "postgres-17",
"orafce": "available",
"emphasis": "general",
"context": "Order-management estate moving off Oracle 19c.",
}
raw, err := call("POST", "/estimate", input) // free: no charge, no job
if err != nil {
log.Fatal(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.Model, est.HoldCredits)
String input = """
{
"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."
}
""";
// free: no charge, no job
String est = call("POST", "/estimate", input);
System.out.println(est);
INPUT = JSON.parse(<<~JSON)
{
"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."
}
JSON
est = call("POST", "/estimate", INPUT) # free: no charge, no job
puts "#{est["model"]} reserves #{est["hold_credits"]}"
$input = json_decode(<<<'JSON'
{
"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."
}
JSON, true);
$est = call("POST", "/estimate", $input); // free: no charge, no job
echo $est["model"], " reserves ", $est["hold_credits"], "\n";
var input = new {
routines = oracleSource,
schema_ddl = tableDdl,
target = "postgres-17",
orafce = "available",
emphasis = "general",
context = "Order-management estate moving off Oracle 19c."
};
// free: no charge, no job
var est = await Call(http, "POST", "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
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"
import hashlib, time
# Derived from the input, so a retry of the SAME translation never double-bills.
digest = hashlib.sha256(INPUT["routines"].encode()).hexdigest()[:16]
key = f"plsql-shift:{digest}:a1"
job = call_with_key("POST", "/run", INPUT, key) # see note below
job_id = job["job_id"]
while True:
j = call("GET", f"/jobs/{job_id}")
if j["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if j["status"] == "failed":
raise SystemExit(j.get("error"))
raw = j["output"]["output"] # the model reply, as text
print(j.get("charged_credits"), "credits charged")
// Derived from the input, so a retry of the SAME translation never double-bills.
const key = "plsql-shift:" + hashOf(INPUT.routines) + ":a1";
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(INPUT)
});
const { data: job } = await res.json();
let j;
do {
await new Promise(r => setTimeout(r, 2000));
j = await call("GET", "/jobs/" + job.job_id);
} while (j.status !== "succeeded" && j.status !== "failed");
if (j.status === "failed") throw new Error(j.error);
const raw = j.output.output; // the model reply, as text
sum := sha256.Sum256([]byte(oracleSource))
key := fmt.Sprintf("plsql-shift:%x:a1", sum[:8])
b, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key) // a retry returns the same job
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var env struct {
Data struct{ JobID string `json:"job_id"` } `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
for {
raw, _ := call("GET", "/jobs/"+env.Data.JobID, nil)
var j struct{ Status string `json:"status"` }
json.Unmarshal(raw, &j)
if j.Status == "succeeded" || j.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
// Derived from the input, so a retry of the SAME translation never double-bills.
String key = "plsql-shift:" + sha256Hex(oracleSource).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
String jobJson = HttpClient.newHttpClient()
.send(run, HttpResponse.BodyHandlers.ofString()).body();
// then poll GET /jobs/{job_id} until status is succeeded or failed
String job = call("GET", "/jobs/" + jobId, null);
require "digest"
# Derived from the input, so a retry of the SAME translation never double-bills.
key = "plsql-shift:#{Digest::SHA256.hexdigest(INPUT["routines"])[0, 16]}:a1"
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
j = call("GET", "/jobs/#{job_id}")
break j if %w[succeeded failed].include?(j["status"])
sleep 2
end
// Derived from the input, so a retry of the SAME translation never double-bills.
$key = "plsql-shift:" . substr(hash("sha256", $input["routines"]), 0, 16) . ":a1";
$ch = curl_init($base . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: $key",
],
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
do {
sleep(2);
$j = call("GET", "/jobs/" . $job["job_id"]);
} while (!in_array($j["status"], ["succeeded", "failed"]));
using System.Security.Cryptography;
// Derived from the input, so a retry of the SAME translation never double-bills.
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(oracleSource)));
var key = $"plsql-shift:{hash[..16]}:a1";
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
req.Headers.Add("Authorization", "Bearer " + token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(input),
Encoding.UTF8, "application/json");
var res = await http.SendAsync(req);
// then poll GET /jobs/{job_id} until status is succeeded or failed
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... }"}}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(INPUT).encode(), method="POST",
headers={"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key})
raw, event = "", None
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: "):
payload = json.loads(line[6:])
if event == "delta":
raw += payload["text"]
elif event == "done":
raw = payload["output"]["output"]
print("charged", payload.get("charged_credits"))
// The vendored SDK wraps exactly this:
// ss.runStream(input, { onDelta, onJob, idempotencyKey })
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(INPUT)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", raw = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop();
for (const part of parts) {
const ev = /^event: (.*)$/m.exec(part);
const dat = /^data: (.*)$/m.exec(part);
if (!ev || !dat) continue;
const payload = JSON.parse(dat[1]);
if (ev[1] === "delta") raw += payload.text;
if (ev[1] === "done") raw = payload.output.output;
}
}
req, _ = http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
var raw strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<24)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
raw.WriteString(d.Text)
}
}
HttpRequest stream = 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();
StringBuilder raw = new StringBuilder();
HttpClient.newHttpClient()
.send(stream, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(line -> {
if (line.startsWith("data: ")) {
// parse {"text":"..."} for delta events,
// or {"output":{"output":"..."}} for the done event
raw.append(line.substring(6));
}
});
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 = JSON.generate(INPUT)
raw = +""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
event = line[7..].strip if line.start_with?("event: ")
next unless line.start_with?("data: ")
payload = JSON.parse(line[6..])
raw << payload["text"] if event == "delta"
raw = payload["output"]["output"] if event == "done"
end
end
end
end
$raw = "";
$event = null;
$ch = curl_init($base . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"Content-Type: application/json",
"Idempotency-Key: $key",
],
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = trim(substr($line, 7));
if (str_starts_with($line, "data: ")) {
$p = json_decode(substr($line, 6), true);
if ($event === "delta") $raw .= $p["text"];
if ($event === "done") $raw = $p["output"]["output"];
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Add("Authorization", "Bearer " + token);
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(input),
Encoding.UTF8, "application/json");
var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string ev = null, line;
while ((line = await reader.ReadLineAsync()) != null) {
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ")) {
using var d = JsonDocument.Parse(line[6..]);
if (ev == "delta") raw.Append(d.RootElement.GetProperty("text").GetString());
if (ev == "done") raw.Clear().Append(d.RootElement.GetProperty("output")
.GetProperty("output").GetString());
}
}
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
result = json.loads(raw) # raw is the model reply, as text
print(result["portability"]) # direct | needs-review | manual-rework
print(result["verdict"])
open("translated.sql", "w").write(result["translated_sql"])
for s in result["signatures"]:
if not s["preserved"]:
print("SIGNATURE CHANGED:", s["routine"], "-", s["note"])
for f in sorted(result["findings"], key=lambda f: f["priority"]):
print(f["id"], f["priority"], f["routine"], "-", f["problem"])
for c in result["constructs"]:
if c["status"] == "unsupported":
print("NO EQUIVALENT:", c["oracle"], "-", c["note"])
const result = JSON.parse(raw); // raw is the model reply, as text
console.log(result.portability); // direct | needs-review | manual-rework
console.log(result.verdict);
await fs.writeFile("translated.sql", result.translated_sql);
for (const s of result.signatures) {
if (!s.preserved) console.warn("SIGNATURE CHANGED:", s.routine, s.note);
}
for (const c of result.constructs) {
if (c.status === "unsupported") console.warn("NO EQUIVALENT:", c.oracle, c.note);
}
for (const f of result.findings) {
console.log(f.id, f.priority, f.routine, "-", f.problem);
}
type Result struct {
MigrationName string `json:"migration_name"`
Portability string `json:"portability"`
Verdict string `json:"verdict"`
OrafceUse string `json:"orafce_use"`
TranslatedSQL string `json:"translated_sql"`
Signatures []struct {
Routine string `json:"routine"`
Preserved bool `json:"preserved"`
Note string `json:"note"`
} `json:"signatures"`
Constructs []struct{ Oracle, Postgres, Status, Note string } `json:"constructs"`
Findings []struct{ ID, Priority, Routine, Problem string } `json:"findings"`
}
var result Result
json.Unmarshal([]byte(raw.String()), &result)
os.WriteFile("translated.sql", []byte(result.TranslatedSQL), 0o644)
// raw is the model reply, as text: parse it with your JSON library
// of choice (Jackson, Gson) into the shape documented below.
JsonNode result = new ObjectMapper().readTree(raw.toString());
System.out.println(result.get("portability").asText());
Files.writeString(Path.of("translated.sql"),
result.get("translated_sql").asText());
for (JsonNode s : result.get("signatures")) {
if (!s.get("preserved").asBoolean())
System.out.println("SIGNATURE CHANGED: " + s.get("routine").asText());
}
result = JSON.parse(raw) # raw is the model reply, as text
puts result["portability"] # direct | needs-review | manual-rework
File.write("translated.sql", result["translated_sql"])
result["signatures"].reject { |s| s["preserved"] }.each do |s|
warn "SIGNATURE CHANGED: #{s["routine"]} - #{s["note"]}"
end
result["constructs"].select { |c| c["status"] == "unsupported" }.each do |c|
warn "NO EQUIVALENT: #{c["oracle"]} - #{c["note"]}"
end
$result = json_decode($raw, true); // $raw is the model reply, as text
echo $result["portability"], "\n"; // direct | needs-review | manual-rework
file_put_contents("translated.sql", $result["translated_sql"]);
foreach ($result["signatures"] as $s) {
if (!$s["preserved"]) {
fwrite(STDERR, "SIGNATURE CHANGED: {$s["routine"]} - {$s["note"]}\n");
}
}
foreach ($result["constructs"] as $c) {
if ($c["status"] === "unsupported") {
fwrite(STDERR, "NO EQUIVALENT: {$c["oracle"]}\n");
}
}
using var result = JsonDocument.Parse(raw.ToString());
var root = result.RootElement;
Console.WriteLine(root.GetProperty("portability").GetString());
File.WriteAllText("translated.sql",
root.GetProperty("translated_sql").GetString());
foreach (var s in root.GetProperty("signatures").EnumerateArray()) {
if (!s.GetProperty("preserved").GetBoolean())
Console.Error.WriteLine($"SIGNATURE CHANGED: {s.GetProperty("routine")}");
}
The input object
The same object goes to /estimate, /run and /run-stream. Only routines is required.
| Field | Type | Meaning |
|---|---|---|
| routines | string (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_ddl | string | Oracle CREATE TABLE / view DDL, so %TYPE anchors and column types resolve. Optional, but the translation is materially better with it. |
| target | string | One of postgres-17, postgres-16, postgres-15, aurora, unknown. |
| orafce | string | One of available, unavailable, unknown. unavailable forces a core-PostgreSQL translation with no oracle.* calls. |
| emphasis | string | One of general, signatures, collation, exceptions, cursors, transactions, performance. |
| context | string | Free text about the estate. Changes what gets flagged and how it is ordered. |
| prescan_facts | object | {"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.
| Key | Type | Meaning |
|---|---|---|
| migration_name | string | A short name for what was translated. |
| portability | enum | direct | needs-review | manual-rework. |
| verdict | string | One sentence naming the thing that decides the verdict. |
| oracle_features | string | One line summarising the Oracle features the source actually uses. |
| target_version | string | The PostgreSQL target the translation assumed. |
| orafce_use | enum | required | optional | not-used. |
| exec_summary | string | A short paragraph to read before the SQL. |
| assumptions | string[] | What was assumed because the paste did not say. |
| open_questions | string[] | What to settle before running this against a real database. |
| signatures | object[] | {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. |
| constructs | object[] | {oracle, postgres, status, note}, one row per distinct Oracle construct met. status is translated, orafce, rewritten, unsupported or unchanged. |
| findings | object[] | {id, category, severity, likelihood, priority, routine, problem, impact, fix, snippet}. Ids are PS-001, PS-002… category 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_check | object[] | {id, addressed, note}, one entry per prescan_facts.flags[].id you sent. |
| translated_sql | string | The 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. |
| commands | string[] | Shell / psql commands that verify the result. |
| quick_wins | string[] | Small changes worth making immediately. |
| summary | string | A 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
/estimateis free. It creates no job and charges nothing, so there is no reason not to call it before every run and comparehold_creditsagainst the balance from/me.- Read the SQL before you run it. The translation marks what it could not do faithfully with
-- MANUAL:rather than guessing, and flags any signature it had to change. Neither this API nor the web app ever connects to your database.