Drive Expo Upgrade Desk from your own code
Everything the web page does is available over HTTP: paste a project in, get the same structured
upgrade plan back. The natural use is a CI job that re-plans the upgrade whenever
package.json changes, or a script that runs the same check across every app in a
monorepo.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
Send your app slug as X-App-Slug: expo-upgrade-desk and your token as
Authorization: Bearer … on every call.
Error codes
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
forbidden | 403 | The token is valid but not for this app. Check the X-App-Slug header. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
validation_error | 400 | The input object is missing a required field - `files` is the usual one. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
not_found | 404 | Unknown job id, or the app slug does not exist. |
internal | 500 | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that adds the headers, unwraps data and raises on error.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="expo-upgrade-desk"
TOKEN="YOUR_TOKEN" # from https://expo-upgrade-desk.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "X-App-Slug: $SLUG" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG"
fi
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "expo-upgrade-desk"
TOKEN = "YOUR_TOKEN" # from https://expo-upgrade-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
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}")
req.add_header("X-App-Slug", SLUG)
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "expo-upgrade-desk";
const TOKEN = "YOUR_TOKEN"; // from https://expo-upgrade-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "expo-upgrade-desk"
token = "YOUR_TOKEN" // from https://expo-upgrade-desk.skillsafe.ai/tokens.html
)
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-App-Slug", slug)
if body != nil {
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 envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class Desk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "expo-upgrade-desk";
static final String TOKEN = "YOUR_TOKEN"; // from https://expo-upgrade-desk.skillsafe.ai/tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN)
.header("X-App-Slug", SLUG);
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());
// The envelope is always {"ok":...,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "expo-upgrade-desk"
TOKEN = "YOUR_TOKEN" # from https://expo-upgrade-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["X-App-Slug"] = SLUG
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "expo-upgrade-desk";
const TOKEN = "YOUR_TOKEN"; // from https://expo-upgrade-desk.skillsafe.ai/tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN, "X-App-Slug: " . SLUG];
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);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class Desk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "expo-upgrade-desk";
const string Token = "YOUR_TOKEN"; // from https://expo-upgrade-desk.skillsafe.ai/tokens.html
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
req.Headers.Add("X-App-Slug", Slug);
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
2. Get a token
The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. You never need to open the developer console.
A guest token can call /me and /estimate. Running a plan
is metered, so it needs a personal token from signing in.
# A guest token is enough for /me and /estimate. Running a plan is metered and
# needs a personal token: open the token page and press "Sign in".
#
# https://expo-upgrade-desk.skillsafe.ai/tokens.html
#
# That page also gives you a ready-made shell export:
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "X-App-Slug: expo-upgrade-desk"
# Open https://expo-upgrade-desk.skillsafe.ai/tokens.html and press "Copy token".
# Never read it out of the browser devtools console - the token page exists so
# you do not have to.
#
# A guest token, which can call /me and /estimate but cannot run:
guest = call("guest")
TOKEN = guest["token"]
// Open https://expo-upgrade-desk.skillsafe.ai/tokens.html and press "Copy token".
// A guest token can call /me and /estimate but cannot run a metered plan.
const guest = await call("guest");
// Use guest.token as the bearer for subsequent calls.
// Open https://expo-upgrade-desk.skillsafe.ai/tokens.html and press "Copy token".
// Or mint a guest token, which can call /me and /estimate but cannot run:
raw, err := call("guest", map[string]any{})
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
}
_ = json.Unmarshal(raw, &guest)
// Open https://expo-upgrade-desk.skillsafe.ai/tokens.html and press "Copy token".
// A guest token can call /me and /estimate but cannot run a metered plan.
String guest = call("guest", "{}");
System.out.println(guest);
# Open https://expo-upgrade-desk.skillsafe.ai/tokens.html and press "Copy token".
# A guest token can call /me and /estimate but cannot run a metered plan.
guest = call("guest", {})
puts guest["token"]
<?php
// Open https://expo-upgrade-desk.skillsafe.ai/tokens.html and press "Copy token".
// A guest token can call /me and /estimate but cannot run a metered plan.
$guest = call("guest", []);
echo $guest["token"];
// Open https://expo-upgrade-desk.skillsafe.ai/tokens.html and press "Copy token".
// A guest token can call /me and /estimate but cannot run a metered plan.
var guest = await Desk.Call("guest", new { });
Console.WriteLine(guest.GetProperty("token").GetString());
3. Check the session and the balance
/me tells you whether the token is a guest or a person, and what the balance is.
Compare it against min_credits from the next step before you run, so a shortfall
surfaces as your own clear message rather than a 402.
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await Desk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run — free
The input object is exactly what the app's own form submits:
| field | type | meaning |
|---|---|---|
files | string, required | The pasted project. Put a # file: package.json marker line above each file so they can be told apart. |
target_sdk | string | "latest" or a bare major such as "56". |
concern | string | general, dependencies, native, config or breaking-changes. Emphasis, not exclusivity. |
context | string, optional | Free-form notes: app size, stores, deadlines, whether anyone has tried this upgrade. |
prescan_facts | object | {resources: [{id,label}], flags: [{id,label}]} — what a deterministic scan established. Every flags id must come back in coverage_check, which is how you hold the model to the facts. |
/estimate creates no job and charges nothing. It returns the model
binding and the reservation: hold_credits is what gets held, and the actual charge is
normally far lower because the hold prices the full output cap.
INPUT='{"files": "# file: package.json\n{\n \"dependencies\": {\n \"expo\": \"~53.0.9\",\n \"expo-av\": \"~15.0.2\",\n \"react\": \"18.2.0\"\n }\n}\n\n# file: app.json\n{ \"expo\": { \"name\": \"acme\", \"newArchEnabled\": true } }\n\n# file: project-listing.txt\napp/\napp.json\npackage.json", "target_sdk": "56", "concern": "general", "context": "Managed app, 4 engineers, shipped to both stores.", "prescan_facts": {"resources": [{"id": "res:dependency/expo", "label": "Dependency expo (~53.0.9)"}], "flags": [{"id": "dep-deprecated:expo-av", "label": "expo-av ~15.0.2 is deprecated"}]}}'
call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":1730,"min_credits":260}}
#
# estimate is FREE. It creates no job and charges nothing. hold_credits is what
# gets RESERVED; the charge afterwards is normally much lower.
INPUT = {
"files": "# file: package.json\n{\n \"dependencies\": {\n \"expo\": \"~53.0.9\",\n \"expo-av\": \"~15.0.2\",\n \"react\": \"18.2.0\"\n }\n}\n\n# file: app.json\n{ \"expo\": { \"name\": \"acme\", \"newArchEnabled\": true } }\n\n# file: project-listing.txt\napp/\napp.json\npackage.json",
"target_sdk": "56",
"concern": "general",
"context": "Managed app, 4 engineers, shipped to both stores.",
"prescan_facts": {
"resources": [
{
"id": "res:dependency/expo",
"label": "Dependency expo (~53.0.9)"
}
],
"flags": [
{
"id": "dep-deprecated:expo-av",
"label": "expo-av ~15.0.2 is deprecated"
}
]
}
}
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["hold_credits"], est["min_credits"])
# estimate is free: no job is created and nothing is charged.
const INPUT = {
"files": "# file: package.json\n{\n \"dependencies\": {\n \"expo\": \"~53.0.9\",\n \"expo-av\": \"~15.0.2\",\n \"react\": \"18.2.0\"\n }\n}\n\n# file: app.json\n{ \"expo\": { \"name\": \"acme\", \"newArchEnabled\": true } }\n\n# file: project-listing.txt\napp/\napp.json\npackage.json",
"target_sdk": "56",
"concern": "general",
"context": "Managed app, 4 engineers, shipped to both stores.",
"prescan_facts": {
"resources": [
{
"id": "res:dependency/expo",
"label": "Dependency expo (~53.0.9)"
}
],
"flags": [
{
"id": "dep-deprecated:expo-av",
"label": "expo-av ~15.0.2 is deprecated"
}
]
}
};
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.hold_credits, est.min_credits);
// estimate is free: no job is created and nothing is charged.
input := map[string]any{
"files": "# file: package.json\n{ \"dependencies\": { \"expo\": \"~53.0.9\" } }",
"target_sdk": "56",
"concern": "general",
"context": "Managed app, 4 engineers, shipped to both stores.",
"prescan_facts": map[string]any{
"resources": []any{},
"flags": []any{map[string]string{"id": "dep-deprecated:expo-av", "label": "expo-av is deprecated"}},
},
}
raw, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(raw)) // estimate is free - no job, no charge
String input = """
{
"files": "# file: package.json\n{\n \"dependencies\": {\n \"expo\": \"~53.0.9\",\n \"expo-av\": \"~15.0.2\",\n \"react\": \"18.2.0\"\n }\n}\n\n# file: app.json\n{ \"expo\": { \"name\": \"acme\", \"newArchEnabled\": true } }\n\n# file: project-listing.txt\napp/\napp.json\npackage.json",
"target_sdk": "56",
"concern": "general",
"context": "Managed app, 4 engineers, shipped to both stores.",
"prescan_facts": {
"resources": [
{
"id": "res:dependency/expo",
"label": "Dependency expo (~53.0.9)"
}
],
"flags": [
{
"id": "dep-deprecated:expo-av",
"label": "expo-av ~15.0.2 is deprecated"
}
]
}
}
""";
System.out.println(call("estimate", input));
// estimate is free: no job is created and nothing is charged.
input = {
"files" => "# file: package.json\n{ \"dependencies\": { \"expo\": \"~53.0.9\" } }",
"target_sdk" => "56",
"concern" => "general",
"context" => "Managed app, 4 engineers, shipped to both stores.",
"prescan_facts" => { "resources" => [], "flags" => [
{ "id" => "dep-deprecated:expo-av", "label" => "expo-av is deprecated" }
] }
}
est = call("estimate", input)
puts "#{est['model']} #{est['hold_credits']}"
# estimate is free: no job is created and nothing is charged.
<?php
$input = [
"files" => "# file: package.json\n{ \"dependencies\": { \"expo\": \"~53.0.9\" } }",
"target_sdk" => "56",
"concern" => "general",
"context" => "Managed app, 4 engineers, shipped to both stores.",
"prescan_facts" => ["resources" => [], "flags" => [
["id" => "dep-deprecated:expo-av", "label" => "expo-av is deprecated"],
]],
];
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], PHP_EOL;
// estimate is free: no job is created and nothing is charged.
var input = new
{
files = "# file: package.json\n{ \"dependencies\": { \"expo\": \"~53.0.9\" } }",
target_sdk = "56",
concern = "general",
context = "Managed app, 4 engineers, shipped to both stores.",
prescan_facts = new
{
resources = Array.Empty<object>(),
flags = new[] { new { id = "dep-deprecated:expo-av", label = "expo-av is deprecated" } }
}
};
var est = await Desk.Call("estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
// estimate is free: no job is created and nothing is charged.
5. Run it, then poll
/run returns a job_id; poll jobs/{job_id} until
status is succeeded or failed. The plan JSON is the string at
data.output.output.
Always send an Idempotency-Key. Derive it from the input, as the web
app does (expo-upgrade-desk:<hash>:a<attempt>). A retried request carrying
the same key returns the same job instead of billing a second run — which is what makes a CI retry
safe.
# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="expo-upgrade-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "X-App-Slug: $SLUG" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until the job reaches a terminal status.
while :; do
OUT=$(call "jobs/$JOB")
STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
[ "$STATUS" = "succeeded" ] && break
[ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
sleep 2
done
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'
import hashlib, time
# Always send an Idempotency-Key derived from the input: a retried request with
# the same key returns the SAME job instead of billing a second run.
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"expo-upgrade-desk:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("X-App-Slug", SLUG)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] == "succeeded":
break
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
time.sleep(2)
plan = json.loads(job["output"]["output"])
print(plan["readiness"], plan["current_sdk"], "->", plan["target_sdk"], len(plan["findings"]), "findings")
import { createHash } from "node:crypto";
// Always send an Idempotency-Key derived from the input: a retried request with
// the same key returns the SAME job instead of billing a second run.
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `expo-upgrade-desk:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const plan = JSON.parse(job.output.output);
console.log(plan.readiness, plan.hops.length, "hops", plan.findings.length, "findings");
// Always send an Idempotency-Key derived from the input: a retried request with
// the same key returns the SAME job instead of billing a second run.
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("expo-upgrade-desk:%x:a1", sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-App-Slug", slug)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output)
break
}
if job.Status == "failed" {
panic("run failed")
}
time.Sleep(2 * time.Second)
}
// Always send an Idempotency-Key derived from the input: a retried request with
// the same key returns the SAME job instead of billing a second run.
var digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
var key = "expo-upgrade-desk:" + java.util.HexFormat.of().formatHex(digest).substring(0, 16) + ":a1";
var start = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("X-App-Slug", SLUG)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
String started = HTTP.send(start, HttpResponse.BodyHandlers.ofString()).body();
// Parse job_id out of `started`, then poll GET jobs/{job_id} every two seconds
// until status is "succeeded" or "failed"; the plan JSON is data.output.output.
System.out.println(started);
require "digest"
# Always send an Idempotency-Key derived from the input: a retried request with
# the same key returns the SAME job instead of billing a second run.
digest = Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]
key = "expo-upgrade-desk:#{digest}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["X-App-Slug"] = SLUG
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
job = call("jobs/#{job_id}")
break puts(job["output"]["output"]) if job["status"] == "succeeded"
raise "run failed" if job["status"] == "failed"
sleep 2
end
<?php
// Always send an Idempotency-Key derived from the input: a retried request with
// the same key returns the SAME job instead of billing a second run.
$digest = substr(hash("sha256", json_encode($input)), 0, 16);
$key = "expo-upgrade-desk:{$digest}:a1";
$ch = curl_init(BASE . "/run");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"X-App-Slug: " . SLUG,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
while (true) {
$job = call("jobs/" . $jobId);
if ($job["status"] === "succeeded") { echo $job["output"]["output"]; break; }
if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
sleep(2);
}
using System.Security.Cryptography;
using System.Text;
// Always send an Idempotency-Key derived from the input: a retried request with
// the same key returns the SAME job instead of billing a second run.
var json = JsonSerializer.Serialize(input);
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16].ToLowerInvariant();
var key = $"expo-upgrade-desk:{digest}:a1";
var run = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
run.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
run.Headers.Add("X-App-Slug", "expo-upgrade-desk");
run.Headers.Add("Idempotency-Key", key);
run.Content = JsonContent.Create(input);
// POST it, read data.job_id, then poll GET jobs/{job_id} every two seconds until
// status is "succeeded" or "failed"; the plan JSON is data.output.output.
6. Or stream it
/run-stream is the same call over server-sent events. The web app uses it to advance a
staged progress display as section headings arrive, and to keep whatever parsed if the stream dies
mid-flight.
# Server-sent events. Each `delta` carries a chunk of the JSON plan; the final
# `done` event carries the whole thing plus charged_credits.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "X-App-Slug: $SLUG" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"plan_name\":\"acme"}
# event: delta {"text":" - SDK 53 to 56 upgrade plan\","}
# event: done {"status":"succeeded","charged_credits":412,"truncated":false}
# Server-sent events: the plan arrives in chunks, so a UI can show progress.
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("X-App-Slug", SLUG)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw = ""
event = None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
plan = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print(plan["readiness"], len(plan["findings"]), "findings")
// Server-sent events: the plan arrives in chunks, so a UI can show progress.
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let raw = "";
let event = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") {
raw += JSON.parse(line.slice(6)).text ?? "";
}
}
}
const plan = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(plan.readiness, plan.findings.length, "findings");
// Server-sent events: the plan arrives in chunks, so a UI can show progress.
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-App-Slug", slug)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
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, 64*1024), 4*1024*1024)
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)
}
}
fmt.Println(raw.String())
// Server-sent events: the plan arrives in chunks, so a UI can show progress.
var stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("X-App-Slug", SLUG)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7);
else if (line.startsWith("data: ") && "delta".equals(event[0])) {
raw.append(line.substring(6)); // each data line is {"text":"..."} - decode and append .text
}
});
System.out.println(raw);
# Server-sent events: the plan arrives in chunks, so a UI can show progress.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["X-App-Slug"] = SLUG
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
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|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
raw << (JSON.parse(line[6..])["text"] || "")
end
end
end
end
end
plan = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts "#{plan['readiness']} #{plan['findings'].length} findings"
<?php
// Server-sent events: the plan arrives in chunks, so a UI can show progress.
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"X-App-Slug: " . SLUG,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$raw .= json_decode(substr($line, 6), true)["text"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$plan = json_decode(substr($raw, strpos($raw, "{")), true);
echo $plan["readiness"], PHP_EOL;
// Server-sent events: the plan arrives in chunks, so a UI can show progress.
var stream = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
stream.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
stream.Headers.Add("X-App-Slug", "expo-upgrade-desk");
stream.Headers.Add("Idempotency-Key", key);
stream.Headers.Add("Accept", "text/event-stream");
stream.Content = JsonContent.Create(input);
using var res = await Http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: ") && evt == "delta")
{
var d = JsonSerializer.Deserialize<JsonElement>(line[6..]);
if (d.TryGetProperty("text", out var t)) raw.Append(t.GetString());
}
}
Console.WriteLine(raw.ToString());
The output contract
data.output.output is a JSON string holding one object. This is exactly what the web
app parses, so anything that renders here will render there:
{
"plan_name": "acme-mobile - SDK 53 to 56 upgrade plan",
"readiness": "safe-to-upgrade | staged-upgrade-needed | blocked-until-resolved",
"verdict": "one sentence naming the thing that decides the readiness",
"current_sdk": "53",
"target_sdk": "56",
"workflow": "cng | bare | unknown",
"exec_summary": "2-3 paragraphs separated by blank lines",
"assumptions": ["..."],
"open_questions": ["..."],
"inventory": [
{ "kind": "Dependency", "name": "expo-av", "version": "~15.0.2", "role": "..." }
],
"hops": [
{ "from": "53", "to": "54", "theme": "...", "must_do": ["..."], "watch_for": ["..."] }
],
"findings": [
{
"id": "EX-001",
"category": "dependencies | config | native | breaking-change | deprecation | hygiene",
"severity": "low | medium | high",
"likelihood": "low | medium | high",
"priority": "critical | high | medium | low",
"resource": "Dependency/expo-av",
"problem": "...",
"impact": "...",
"fix": "...",
"snippet": "corrected JSON / JS / shell fragment, or \"\""
}
],
"coverage_check": [
{ "id": "dep-deprecated:expo-av", "addressed": true, "note": "EX-001." }
],
"corrected_manifest": "the corrected dependencies and devDependencies blocks, as a JSON string",
"commands": ["npx expo install expo@latest # move to the target SDK"],
"quick_wins": ["..."],
"focus_areas": [{ "area": "...", "why": "...", "finding_ids": ["EX-001"] }],
"summary": "closing paragraph"
}
Two rules worth enforcing on your side, because the app enforces them too: every
focus_areas[].finding_ids entry must name a real finding id, and every
prescan_facts.flags id must appear exactly once in coverage_check. If a
flag is missing from the reconciliation, the model quietly skipped a fact you established — treat
that as a failed run, not a passing one.
A CI gate
The readiness value is the natural exit code. Fail the job when a project drifts into
blocked-until-resolved, warn on staged-upgrade-needed, and pass on
safe-to-upgrade — with the Idempotency-Key derived from the input so a re-run of the
same commit replays instead of re-billing.
READINESS=$(printf '%s' "$PLAN" | python3 -c 'import sys,json;print(json.load(sys.stdin)["readiness"])')
case "$READINESS" in
blocked-until-resolved) echo "::error::Expo upgrade is blocked"; exit 1 ;;
staged-upgrade-needed) echo "::warning::Expo upgrade needs staging"; exit 0 ;;
safe-to-upgrade) echo "Expo upgrade is clear"; exit 0 ;;
esac