Drive EDA Desk from your own code
EDA Desk checks a tabular dataset before modeling. In the page, JavaScript ports of the three CSV/TSV
tools of the agent skill @k-dense-ai/exploratory-data-analysis
(k-dense-ai/scientific-agent-skills) run free: tabular_profile.py (kinds, missingness,
distinct counts, numeric aggregates), missingness_leakage_audit.py (missingness by split
and group; entities, groups, identical rows and time ranges in more than one split) and
distribution_sensitivity.py (mean vs median, SD vs MAD, IQR fences, trimmed and winsorized
means, log1p skewness). From code, run the same scripts with Python
(python scripts/missingness_leakage_audit.py data.csv --root . --split-column split --entity-column subject_id)
and build facts from their JSON.
The metered lanes read only aggregates: split judges whether the split supports an honest
held-out score and returns a resplit plan with scikit-learn code; report drafts the EDA
report the skill's template asks for. Neither ever sees a row, a cell value or an entity id.
Two lanes: the task field
task picks the lane. /estimate does not validate the body, so always send a
JSON object with task set to one of the two lanes.
| task | needs | returns |
|---|---|---|
split | facts with a split role; context and question optional | status (resplit_required, usable_with_fixes, usable_as_is), a response to every flag, leak_findings, a split_plan (unit, method, hold-out, steps, rationale), preprocessing_rules, code, questions and assumptions. |
report | facts; context, question and split_review optional | status (proceed_to_modeling, proceed_with_caveats, do_not_model_yet), flag responses, sections (scope, structure, split, schema, missingness, distributions, transformations, limitations), key_findings, data_dictionary_requests, questions and assumptions. |
Worked example: split
The clinic example on the page: visit rows split 75/25 at random, so the same subject appears in both splits. Column ids are tokens.
{
"task": "split",
"facts": "{\"page\":\"eda-desk\",\"tools\":\"tabular_profile.py, missingness_leakage_audit.py, distribution_sensitivity.py (exploratory-data-analysis skill, run in the browser on the user's file)\",\"identifiers\":\"tokenized (column_*, split_*, group_* pseudonyms); the user sees names, you see tokens\",\"file\":{\"format\":\"CSV\",\"rows_scanned\":176,\"row_limit_reached\":false,\"columns\":11,\"duplicate_rows\":0,\"missing_codes_declared\":1},\"roles\":{\"split\":\"column_4a334e7217932c40\",\"entity\":\"column_ce22b27fac9ca72c\",\"group\":\"column_86523fcfc53231f7\",\"time\":\"column_bfa48dcb85289118\",\"target\":\"column_73c6fa731c23780b\"},\"columns\":[{\"id\":\"column_ce22b27fac9ca72c\",\"role\":\"entity\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":56},{\"id\":\"column_bfa48dcb85289118\",\"role\":\"time\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":153},{\"id\":\"column_86523fcfc53231f7\",\"role\":\"group\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":3},{\"id\":\"column_4a334e7217932c40\",\"role\":\"split\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":2},{\"id\":\"column_73c6fa731c23780b\",\"role\":\"target\",\"kind\":\"integer\",\"missing_pct\":0,\"distinct\":2,\"mean\":0.2273,\"sd\":0.4203,\"min\":0,\"median\":0,\"max\":1,\"mad\":0,\"iqr\":0,\"trimmed_mean\":0.162,\"outside_iqr_fences\":\"40/176\",\"skew\":1.302,\"log1p_skew\":1.302},{\"id\":\"column_b4c12f0ab59f1f7f\",\"kind\":\"numeric\",\"missing_pct\":0,\"distinct\":90,\"mean\":6.355,\"sd\":8.974,\"min\":0.4,\"median\":3.8,\"max\":71.6,\"mad\":2.3,\"iqr\":5.75,\"trimmed_mean\":4.506,\"outside_iqr_fences\":\"13/176\",\"skew\":4.221,\"log1p_skew\":0.7836},{\"id\":\"column_2a39cbc6e4c57f3d\",\"kind\":\"mixed\",\"missing_pct\":0,\"distinct\":131,\"mean\":3.136,\"sd\":0.7404,\"min\":1.3,\"median\":3.14,\"max\":5.18,\"numeric_values\":175,\"text_values\":1,\"mad\":0.51,\"iqr\":1,\"trimmed_mean\":3.136,\"outside_iqr_fences\":\"1/175\",\"skew\":0.0182,\"log1p_skew\":-0.4604},{\"id\":\"column_974ca6e12d61c806\",\"kind\":\"numeric\",\"missing_pct\":8.5,\"distinct\":45,\"mean\":6.329,\"sd\":0.9731,\"min\":4.2,\"median\":6.2,\"max\":8.8,\"mad\":0.6,\"iqr\":1.3,\"trimmed_mean\":6.292,\"outside_iqr_fences\":\"0/161\",\"skew\":0.3328,\"log1p_skew\":0.01885},{\"id\":\"column_b315a92d8b82a14c\",\"kind\":\"integer\",\"missing_pct\":0,\"distinct\":34,\"mean\":56.16,\"sd\":13.26,\"min\":34,\"median\":59,\"max\":82,\"mad\":10,\"iqr\":23.25,\"trimmed_mean\":56.04,\"outside_iqr_fences\":\"0/176\",\"skew\":-0.00217,\"log1p_skew\":-0.3196},{\"id\":\"column_dbc0491d72d586d9\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":2},{\"id\":\"column_c50d01f329dff09d\",\"kind\":\"numeric\",\"missing_pct\":5.1,\"distinct\":98,\"mean\":28.28,\"sd\":3.981,\"min\":16.7,\"median\":28.2,\"max\":37,\"mad\":2.4,\"iqr\":5.25,\"trimmed_mean\":28.36,\"outside_iqr_fences\":\"2/167\",\"skew\":-0.1781,\"log1p_skew\":-0.5898}],\"columns_omitted\":0,\"tool_exits\":{\"profile\":0,\"leakage\":0,\"distribution\":0},\"flags\":[{\"id\":\"F1\",\"kind\":\"leak_entity\",\"severity\":\"high\",\"text\":\"31 entity value(s) of column_ce22b27fac9ca72c appear in more than one split.\"},{\"id\":\"F2\",\"kind\":\"leak_group\",\"severity\":\"high\",\"text\":\"3 group value(s) of column_86523fcfc53231f7 appear in more than one split.\"},{\"id\":\"F3\",\"kind\":\"leak_duplicate_rows\",\"severity\":\"high\",\"text\":\"2 row(s) identical apart from the split column appear in more than one split.\"},{\"id\":\"F4\",\"kind\":\"leak_time\",\"severity\":\"high\",\"text\":\"1 pair(s) of splits overlap in column_bfa48dcb85289118 (min-max intervals).\"},{\"id\":\"F5\",\"kind\":\"missing_split_gap\",\"severity\":\"medium\",\"text\":\"column_974ca6e12d61c806 is missing in 3.1%-24.4% of rows depending on the split (a gap of 21.4 points).\"},{\"id\":\"F6\",\"kind\":\"mixed_kind\",\"severity\":\"medium\",\"text\":\"column_2a39cbc6e4c57f3d mixes 175 numeric and 1 text value(s) - a detection-limit marker, an undeclared missing code or a unit suffix is the usual cause.\"},{\"id\":\"F7\",\"kind\":\"outliers\",\"severity\":\"low\",\"text\":\"column_b4c12f0ab59f1f7f: 13 of 176 sampled values sit outside the IQR fences; the raw mean is 6.355 against a 10% trimmed mean of 4.506.\"},{\"id\":\"F8\",\"kind\":\"skewed\",\"severity\":\"low\",\"text\":\"column_b4c12f0ab59f1f7f is skewed (moment skewness 4.221); log1p would bring it to 0.7836. A diagnostic only - any transform is fitted on training data.\"}],\"browser_status\":\"leakage_flagged\",\"leakage\":{\"status\":\"potential_leakage_detected\",\"entity_tokens_in_multiple_splits\":31,\"group_tokens_in_multiple_splits\":3,\"identical_row_hashes_in_multiple_splits\":2,\"overlapping_split_time_interval_pairs\":1,\"rows_with_missing_split\":0,\"unparseable_nonmissing_time_values\":0,\"tracking_truncated\":false,\"splits\":[{\"split\":\"split_2aefc8423ce0c051\",\"rows\":45,\"missing\":[\"column_974ca6e12d61c806:24.4%\",\"column_c50d01f329dff09d:8.9%\"]},{\"split\":\"split_373650b82b95498a\",\"rows\":131,\"missing\":[\"column_974ca6e12d61c806:3.1%\",\"column_c50d01f329dff09d:3.8%\"]}],\"groups\":3,\"group_gaps\":[]}}",
"context": "Visit-level records from three outpatient sites. We want to predict 30-day readmission (readmit_30d) at a new visit. The train/test column was made with a random 75/25 split of rows."
}
The reply's status is resplit_required, with split_plan.unit set to the entity column's token and a GroupShuffleSplit in code. Load the example on the page to see a saved reply in full, free.
Worked example: report
The bioreactor example: runs split forward in time by weekly batch, with --reveal-identifiers so column names are sent (split and group values stay tokens).
{
"task": "report",
"facts": "{\"page\":\"eda-desk\",\"tools\":\"tabular_profile.py, missingness_leakage_audit.py, distribution_sensitivity.py (exploratory-data-analysis skill, run in the browser on the user's file)\",\"identifiers\":\"sanitized column names (--reveal-identifiers); split, group and entity values stay tokenized\",\"file\":{\"format\":\"TSV\",\"rows_scanned\":146,\"row_limit_reached\":false,\"columns\":10,\"duplicate_rows\":0,\"missing_codes_declared\":1},\"roles\":{\"split\":\"split\",\"entity\":\"run_id\",\"group\":\"batch\",\"time\":\"run_start\",\"target\":\"titer_g_l\"},\"columns\":[{\"id\":\"run_id\",\"role\":\"entity\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":146},{\"id\":\"batch\",\"role\":\"group\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":24},{\"id\":\"run_start\",\"role\":\"time\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":146},{\"id\":\"split\",\"role\":\"split\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":3},{\"id\":\"titer_g_l\",\"role\":\"target\",\"kind\":\"numeric\",\"missing_pct\":3.4,\"distinct\":103,\"mean\":1.964,\"sd\":0.7443,\"min\":0.69,\"median\":1.81,\"max\":5.38,\"mad\":0.35,\"iqr\":0.72,\"trimmed_mean\":1.852,\"outside_iqr_fences\":\"8/141\",\"skew\":1.835,\"log1p_skew\":0.9429},{\"id\":\"feed_rate_ml_h\",\"kind\":\"numeric\",\"missing_pct\":0,\"distinct\":104,\"mean\":12.39,\"sd\":8.507,\"min\":3.1,\"median\":10.4,\"max\":60.7,\"mad\":3.4,\"iqr\":6.925,\"trimmed_mean\":10.84,\"outside_iqr_fences\":\"12/146\",\"skew\":2.436,\"log1p_skew\":0.6074},{\"id\":\"operator\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":1},{\"id\":\"reactor\",\"kind\":\"text\",\"missing_pct\":0,\"distinct\":3},{\"id\":\"temp_c\",\"kind\":\"numeric\",\"missing_pct\":0,\"distinct\":84,\"mean\":36.85,\"sd\":0.352,\"min\":35.84,\"median\":36.8,\"max\":37.68,\"mad\":0.22,\"iqr\":0.4675,\"trimmed_mean\":36.85,\"outside_iqr_fences\":\"1/146\",\"skew\":-0.06966,\"log1p_skew\":-0.09774},{\"id\":\"ph\",\"kind\":\"numeric\",\"missing_pct\":0,\"distinct\":31,\"mean\":7.044,\"sd\":0.06326,\"min\":6.88,\"median\":7.045,\"max\":7.24,\"mad\":0.04,\"iqr\":0.08,\"trimmed_mean\":7.044,\"outside_iqr_fences\":\"1/146\",\"skew\":0.03359,\"log1p_skew\":0.006589}],\"columns_omitted\":0,\"tool_exits\":{\"profile\":0,\"leakage\":0,\"distribution\":0},\"flags\":[{\"id\":\"F1\",\"kind\":\"constant\",\"severity\":\"low\",\"text\":\"'operator' has a single distinct value.\"},{\"id\":\"F2\",\"kind\":\"outliers\",\"severity\":\"low\",\"text\":\"'feed_rate_ml_h': 12 of 146 sampled values sit outside the IQR fences; the raw mean is 12.39 against a 10% trimmed mean of 10.84.\"},{\"id\":\"F3\",\"kind\":\"skewed\",\"severity\":\"low\",\"text\":\"'feed_rate_ml_h' is skewed (moment skewness 2.436); log1p would bring it to 0.6074. A diagnostic only - any transform is fitted on training data.\"},{\"id\":\"F4\",\"kind\":\"outliers\",\"severity\":\"low\",\"text\":\"'titer_g_l': 8 of 141 sampled values sit outside the IQR fences; the raw mean is 1.964 against a 10% trimmed mean of 1.852.\"}],\"browser_status\":\"review_before_modeling\",\"leakage\":{\"status\":\"not_detected_in_scanned_rows\",\"entity_tokens_in_multiple_splits\":0,\"group_tokens_in_multiple_splits\":0,\"identical_row_hashes_in_multiple_splits\":0,\"overlapping_split_time_interval_pairs\":0,\"rows_with_missing_split\":0,\"unparseable_nonmissing_time_values\":0,\"tracking_truncated\":false,\"splits\":[{\"split\":\"split_2aefc8423ce0c051\",\"rows\":23,\"missing\":[]},{\"split\":\"split_373650b82b95498a\",\"rows\":100,\"missing\":[\"titer_g_l:3%\"]},{\"split\":\"split_c3544993fefff43f\",\"rows\":23,\"missing\":[\"titer_g_l:8.7%\"]}],\"groups\":24,\"group_gaps\":[\"titer_g_l:16.7 points\"]}}",
"context": "One row per bioreactor run. Batches of media are used for one week each. Target is titer_g_l. We split forward in time by batch: the first 16 weekly batches train, the next 4 validate, the last 4 test."
}
Input fields
Every field is a string; facts is JSON text.
| field | type | required | meaning |
|---|---|---|---|
task | string | yes | "split" or "report". |
facts | string | yes | The aggregates, as JSON text (below). |
context | string | no | What the data is, the observational unit, how the split was made and what will be predicted. Sent as written, up to about 4,000 characters. |
question | string | no | A question to answer in the reply. |
split_review | string | no | Report lane: an earlier split review as text (the page fills it from the review's status, findings and plan). |
retry_note | string | no | Only on a reformat retry. |
The facts string
file (format, rows_scanned, row_limit_reached, columns, duplicate_rows, missing_codes_declared);
roles (split, entity, group, time, target: a column id or null); columns (per column:
id, role, kind, missing_pct, distinct, mean, sd, min, median,
max, mad, iqr, trimmed_mean, outside_iqr_fences as "count/sampled", skew and the log1p or
signed_log1p skew, 4 significant digits); columns_omitted; leakage (the audit's counts,
splits with each split's token, rows and missing columns, groups, group_gaps);
flags (id, kind, severity, text); browser_status
(leakage_flagged, not_assessed, review_before_modeling, no_flags); and
identifiers. Column ids are the skill's tokens, "column_" + blake2s("eda-v1.1\0column\0" + name, digest_size=8),
unless you use --reveal-identifiers; split and group values are always tokens.
The output
One JSON object, serialised as a string at data.output.output. Keys in both lanes: task, status, headline, flag_responses (ref, stance = confirmed | explained | dismissed | needs_data_owner, note), questions_for_data_owner, assumptions. Lane keys are listed in the table above.
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": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}
The token is minted for this app (the guest endpoint takes {"slug":"eda-desk"} in
its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ....
The input object IS the request body. There is no {"input": ...}
wrapper. A wrapped body is answered with an unknown field 'input' warning, and the
model never sees your text.
Error codes
| status | code | what to do |
|---|---|---|
| 400 | validation_error | A field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object. |
| 401 | unauthorized | The token is missing, malformed or expired. Get a new one from the token page. |
| 402 | payment_required | The balance is below min_credits. Call /estimate first and top up. |
| 403 | forbidden | The token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token. |
| 404 | not_found | Unknown job id, or the app slug does not exist. |
| 409 | conflict | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
| 429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
| 5xx | internal | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. A tiny client
One helper that sends the token, unwraps data and raises on ok: false.
The token comes from the token page (Copy token or
Copy shell export); step 2 covers the kinds of token and minting one from code.
# 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="eda-desk"
TOKEN="$SKILLSAFE_TOKEN" # from https://eda-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 "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "eda-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://eda-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}")
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"]
import { readFileSync } from "node:fs";
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "eda-desk";
// Paste the token from https://eda-desk.skillsafe.ai/tokens.html into a file named "token",
// or replace the fallback with it.
let TOKEN = "YOUR_TOKEN";
try { TOKEN = readFileSync("token", "utf8").trim(); } catch {}
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(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 (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "eda-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://eda-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)
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 EdaDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "eda-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
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);
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":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "eda-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://eda-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}"
if body
req["Content-Type"] = "application/json"
req.body = body.to_json
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
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 = "eda-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
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 EdaDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "eda-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
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}");
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. A guest token, minted with
POST /guest and {"slug":"eda-desk"}, can call /me and
/estimate; the run is metered, so /run and /run-stream need
a personal token.
# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
# https://eda-desk.skillsafe.ai/tokens.html
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" -d '{"slug":"eda-desk"}'
# {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://eda-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "eda-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://eda-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "eda-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://eda-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"eda-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://eda-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"eda-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"...","subject_type":"guest"}}
# Open https://eda-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = { slug: "eda-desk" }.to_json
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://eda-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "eda-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://eda-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"eda-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
3. Check the session and the balance
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));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await EdaDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the run (free)
/estimate returns the model binding and the credits a run would reserve. It creates no
job and charges nothing. Expect model_alias gpt-terra.
hold_credits is a reservation, not the price. min_credits is
the least balance that can start a run. What you pay is charged_credits, reported on the
finished job, usually far lower. The body is the input object itself, with no
{"input": ...} wrapper. /estimate does no input validation,
so check the shape yourself: an object whose every value is a string, task equal to
split or report, and a facts that is a JSON string parsing to an object.
# body.json is the input object itself - no {"input": ...} wrapper. estimate does
# not validate it, so check the shape first:
python3 -c '
import json
b = json.load(open("body.json"))
assert isinstance(b, dict) and b.get("task") in ("split", "report")
assert all(isinstance(v, str) for v in b.values())
assert isinstance(json.loads(b["facts"]), dict)
'
INPUT=$(cat body.json)
call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
# "markup_bps":...,"hold_credits":...,"min_credits":...,"sponsor_enabled":false}}
# hold_credits is RESERVED, not the price; charged_credits after the run is the cost.
INPUT = json.load(open("body.json")) # the input object itself, no wrapper
assert isinstance(INPUT, dict) and INPUT.get("task") in ("split", "report")
assert all(isinstance(v, str) for v in INPUT.values()), "every field is a string"
assert isinstance(json.loads(INPUT["facts"]), dict), "facts is a JSON string of an object"
est = call("estimate", INPUT)
print(est["model_alias"], "reserves", est["hold_credits"], "credits (not the price)")
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // no {input: ...} wrapper
if (!INPUT || typeof INPUT !== "object" || !["split", "report"].includes(INPUT.task)) throw new Error("task must be split or report");
if (!Object.values(INPUT).every((v) => typeof v === "string")) throw new Error("every field is a string");
if (typeof JSON.parse(INPUT.facts) !== "object") throw new Error("facts is a JSON string of an object");
const est = await call("estimate", INPUT);
console.log(est.model_alias, "reserves", est.hold_credits, "credits (not the price)");
raw, _ := os.ReadFile("body.json")
var input map[string]string // every field is a string; Unmarshal fails otherwise
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
if input["task"] != "split" && input["task"] != "report" {
panic("task must be split or report")
}
var facts map[string]any
if err := json.Unmarshal([]byte(input["facts"]), &facts); err != nil {
panic("facts must be a JSON string of an object")
}
est := call("estimate", input)
fmt.Println(est["model_alias"], "reserves", est["hold_credits"], "credits (not the price)")
String input = Files.readString(Path.of("body.json")); // the input object itself
if (!input.matches("(?s)\\s*\\{.*\"task\"\\s*:\\s*\"(split|report)\".*\\}\\s*"))
throw new IllegalStateException("body.json must be an object with task split or report");
if (!input.contains("\"facts\""))
throw new IllegalStateException("both lanes need facts");
System.out.println(call("estimate", input)); // hold_credits is a reservation, not the price
INPUT = JSON.parse(File.read("body.json")) # no {"input": ...} wrapper
raise "task must be split or report" unless %w[split report].include?(INPUT["task"])
raise "every field is a string" unless INPUT.values.all? { |v| v.is_a?(String) }
raise "facts is a JSON string of an object" unless JSON.parse(INPUT["facts"]).is_a?(Hash)
est = call("estimate", INPUT)
puts "#{est['model_alias']} reserves #{est['hold_credits']} credits (not the price)"
$input = json_decode(file_get_contents("body.json"), true); // no {"input": ...} wrapper
if (!is_array($input) || !in_array($input["task"] ?? "", ["split", "report"], true)) { throw new Exception("task must be split or report"); }
foreach ($input as $v) { if (!is_string($v)) { throw new Exception("every field is a string"); } }
if (!is_array(json_decode($input["facts"] ?? "", true))) { throw new Exception("facts is a JSON string of an object"); }
$est = call("estimate", $input);
echo $est["model_alias"], " reserves ", $est["hold_credits"], " credits (not the price)\n";
var input = File.ReadAllText("body.json"); // the input object itself
using var doc = JsonDocument.Parse(input);
var root = doc.RootElement;
var lane = root.GetProperty("task").GetString();
if (lane != "split" && lane != "report") throw new Exception("task must be split or report");
foreach (var p in root.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception("every field is a string");
JsonDocument.Parse(root.GetProperty("facts").GetString()!); // throws unless facts is JSON
Console.WriteLine(await Call("estimate", input)); // hold_credits is a reservation, not the price
5. Run it, then poll
POST /run returns a job_id; poll GET /jobs/{id} until it is
terminal. The reply is a string at data.output.output: JSON.parse
it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the
attempt number, eda-desk:<lane>:<hash>:a<attempt>, so a retried
request returns the same job instead of billing a second run. Use one key per distinct input: edited
facts, context, split_review or question are a new hash, and replaying an old key with a different
body is a 409. Any stable digest of the body works. Leave retry_note out of
the hash and bump the attempt instead.
# 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.
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])') # split or report
KEY="eda-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')
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
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
# "output":{"output":"{\"task\":\"split\",\"status\":\"resplit_required\",\"headline\":\"...\", ...}"},
# "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json
import hashlib, time
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"eda-desk:{INPUT['task']}:{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("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"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
text = job["output"]["output"] # the reply, as a string
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `eda-desk:${INPUT.task}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
if (!started.ok) throw new Error(`${started.error.code}: ${started.error.message}`);
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 text = job.output.output; // the reply, as a string
console.log(job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("eda-desk:%s:%x:a1", input["task"], sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
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"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
String key = "eda-desk:" + lane + ":" + sha256Hex(input).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 started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
key = "eda-desk:#{INPUT['task']}:#{Digest::SHA256.hexdigest(INPUT.to_json)[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 = INPUT.to_json
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
text = job["output"]["output"] # the reply, as a string
puts job["charged_credits"], job["truncated"]
<?php
$key = "eda-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
if ($job["status"] === "failed") { throw new RuntimeException(json_encode($job)); }
$text = $job["output"]["output"]; // the reply, as a string
echo $job["charged_credits"], PHP_EOL;
using System.Security.Cryptography;
var json = input; // the body.json text from step 4
var key = $"eda-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await EdaDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!; // the reply, as a string
6. Or stream it
POST /run-stream takes the same body and headers and answers with server-sent events:
job (the job id), delta (chunks of the reply) and done (the
status, charged_credits, truncated and, when present, the full
output). A browser page may receive only tick heartbeats and then
done, never a delta, so take the reply from done.output.output
when it is there, fall back to the concatenated deltas, and fall back again to
GET /jobs/{id}.
# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: job {"job_id":"job_..."}
# event: delta {"text":"{\"task\":\"split\",\"status\":\"resplit_required\",\"headline\":\"The"}
# event: done {"status":"succeeded","charged_credits":...,"truncated":false}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, 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", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
text = (done.get("output") or {}).get("output") or raw
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
const streamed = done?.output?.output || raw; // browsers may get only ticks + done
console.log(done, streamed.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = INPUT.to_json
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.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" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
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"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
7. Parse the reply
The reply is one JSON object serialised as a string. Parse it and check that task is the
lane you asked for. Column ids in it are the tokens from your facts (or the sanitized
names, if you built facts with --reveal-identifiers); map tokens back with the same BLAKE2s
rule the skill's tools use. Read the code before running it.
# The reply is a JSON string inside data.output.output (saved as reply.json in step 5):
python3 -c 'import json;r=json.load(open("reply.json"));print(r["task"],r["status"],r["headline"])'
# A split review: the plan and the code
python3 -c '
import json
r = json.load(open("reply.json"))
if r["task"] == "split":
print(r["split_plan"]["unit"], r["split_plan"]["method"])
open("split_plan.py", "w").write(r["code"]) # read it before running it
else:
for f in r["key_findings"]: print("-", f["finding"])
'
# Column ids in the reply are the tokens from facts; map them back with your own header:
# token = "column_" + blake2s(b"eda-v1.1\0column\0" + name.encode(), digest_size=8).hexdigest()
reply = json.loads(job["output"]["output"])
assert reply["task"] == INPUT["task"], "the model answered as another lane"
print(reply["status"], reply["headline"])
if reply["task"] == "split":
plan = reply["split_plan"]
print(plan["unit"], plan["method"])
open("split_plan.py", "w").write(reply["code"]) # read it before running it
else:
for f in reply["key_findings"]:
print("-", f["finding"], "|", f["evidence"])
# Tokens -> your column names (the same BLAKE2s rule the skill's tools use):
import hashlib
def token(name, kind="column"):
return kind + "_" + hashlib.blake2s(f"eda-v1.1\0{kind}\0{name}".encode(), digest_size=8).hexdigest()
const reply = JSON.parse(job.output.output);
if (reply.task !== INPUT.task) throw new Error("the model answered as another lane");
console.log(reply.status, reply.headline);
if (reply.task === "split") {
console.log(reply.split_plan.unit, reply.split_plan.method);
writeFileSync("split_plan.py", reply.code); // read it before running it
} else {
for (const f of reply.key_findings) console.log("-", f.finding, "|", f.evidence);
}
var reply map[string]any
json.Unmarshal([]byte(job["output"].(map[string]any)["output"].(string)), &reply)
if reply["task"] != input["task"] {
panic("the model answered as another lane")
}
fmt.Println(reply["status"], reply["headline"])
if reply["task"] == "split" {
plan := reply["split_plan"].(map[string]any)
fmt.Println(plan["unit"], plan["method"])
os.WriteFile("split_plan.py", []byte(reply["code"].(string)), 0o644) // read it before running it
} else {
for _, f := range reply["key_findings"].([]any) {
fmt.Println("-", f.(map[string]any)["finding"])
}
}
// With any JSON library, parse data.output.output (a string) into an object, then:
// reply.task must equal the task you sent;
// split -> reply.status, reply.split_plan.unit, reply.split_plan.method, reply.code
// report -> reply.status, reply.key_findings[].finding, reply.sections.missingness[]
String out = job.replaceAll("(?s).*\"output\"\\s*:\\s*\\{\\s*\"output\"\\s*:\\s*(\".*?(?<!\\\\)\").*", "$1");
System.out.println(out.substring(0, Math.min(200, out.length())));
reply = JSON.parse(job["output"]["output"])
raise "the model answered as another lane" unless reply["task"] == INPUT["task"]
puts reply["status"], reply["headline"]
if reply["task"] == "split"
puts reply["split_plan"].values_at("unit", "method").join(" ")
File.write("split_plan.py", reply["code"]) # read it before running it
else
reply["key_findings"].each { |f| puts "- #{f['finding']}" }
end
$reply = json_decode($job["output"]["output"], true);
if ($reply["task"] !== $input["task"]) { throw new Exception("the model answered as another lane"); }
echo $reply["status"], " ", $reply["headline"], "\n";
if ($reply["task"] === "split") {
echo $reply["split_plan"]["unit"], " ", $reply["split_plan"]["method"], "\n";
file_put_contents("split_plan.py", $reply["code"]); // read it before running it
} else {
foreach ($reply["key_findings"] as $f) { echo "- ", $f["finding"], "\n"; }
}
using var rdoc = JsonDocument.Parse(outputString); // data.output.output
var reply = rdoc.RootElement;
if (reply.GetProperty("task").GetString() != lane) throw new Exception("the model answered as another lane");
Console.WriteLine($"{reply.GetProperty("status")} {reply.GetProperty("headline")}");
if (lane == "split")
{
var plan = reply.GetProperty("split_plan");
Console.WriteLine($"{plan.GetProperty("unit")} {plan.GetProperty("method")}");
File.WriteAllText("split_plan.py", reply.GetProperty("code").GetString()); // read it before running it
}
else
{
foreach (var f in reply.GetProperty("key_findings").EnumerateArray())
Console.WriteLine("- " + f.GetProperty("finding").GetString());
}
Costs
- The three skill tools are free and run in the page; nothing is metered until you start a lane.
/estimateis free. It creates no job and returnshold_credits: a reservation held against your balance while the run executes, not the price.- A run is billed only for what it uses:
charged_creditson the finished job and in thedoneevent, usually far below the hold. - Sponsorship is off: every run is paid from the caller's own balance.
- Runs need a signed-in user token. A guest token can call
/meand/estimateonly; sign in for a personal token on the token page. - A reformat retry (with
retry_note) is a new attempt with its own key and its own charge.
Invariants worth asserting
- The reply is one JSON object whose
taskequals thetaskyou sent, with every key of that lane's contract present. - Every flag id in
facts.flagsis answered once inflag_responses, and no other id is. - Split: a confirmed
leak_entityflag meansstatusisresplit_requiredandsplit_plan.unitis the entity column;usable_as_isonly when everyleak_*flag is dismissed or explained; a group method passes the unit column asgroups=. - Report: the status is never
proceed_to_modelingwhenfacts.browser_statusisleakage_flagged(with an open leak flag) ornot_assessed, and isdo_not_model_yetwhensplit_reviewasked for a resplit. - Every number in the evidence appears in
facts,contextorquestion; everycolumn_/split_/group_token appears infacts; no causal wording.