Driving Session Lens from code
Everything the page does you can do from a script: hand it a transcript of an agent session and get back a review, a handoff digest, or a root-cause analysis. Reading a session is a client-side concern — the API only sees the transcript you send it.
Base URL: https://api.skillsafe.ai/v1/app-api
The envelope
Every response is {"ok": true, "data": {...}} on success and
{"ok": false, "error": {"code": "...", "message": "..."}} on
failure. Check for error before reading data; the HTTP
status matches the code but the body is where the detail is.
Two things that bite
The request body IS the input object. There is no
input wrapper and no slug header — the slug is in the host.
Wrapping the input returns 200 while hiding task from
the model, so the run silently answers as a different lane than the one you
asked for. That is the single most expensive mistake available here.
Send an Idempotency-Key on every run. Retrying with
the same key returns the same job instead of billing twice. Derive it from a hash
of the input plus an attempt counter, so a network blip replays and a genuine
second attempt does not.
1 · Get a token
Open the tokens page in a browser: it shows the token
this origin holds, mints a guest one, and signs you in for a personal one. A
guest token can call /me and /estimate
— enough to price a review. Running a lane is metered and needs a
personal token.
2 · A tiny client
One helper that adds the bearer header and unwraps the envelope. Everything below assumes it.
# There is no client to build in a shell -- every call is one curl. # Keep the token and the base URL in shell variables and reuse them. TOKEN="YOUR_TOKEN" BASE="https://api.skillsafe.ai/v1/app-api" curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
import json
import urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://session-lens.skillsafe.ai/tokens.html
def call(path, body=None):
"""POST when there is a body, GET otherwise. Raises on the error envelope."""
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(BASE + path, data=data, method="POST" if data else "GET")
req.add_header("Authorization", "Bearer " + TOKEN)
if data:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as res:
payload = json.load(res)
if not payload.get("ok", True) or "error" in payload:
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code', 'ERROR')}: {err.get('message', '')}")
return payload["data"]
print(call("/me"))
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://session-lens.skillsafe.ai/tokens.html
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.error) {
throw new Error(`${payload.error.code}: ${payload.error.message}`);
}
return payload.data;
}
console.log(await call("/me"));
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
token = "YOUR_TOKEN" // from https://session-lens.skillsafe.ai/tokens.html
)
type envelope struct {
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 reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
method, reader = http.MethodPost, bytes.NewReader(raw)
}
req, err := http.NewRequest(method, base+path, reader)
if err != nil {
return nil, err
}
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.Error != nil {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
func main() {
data, err := call("/me", nil)
if err != nil {
panic(err)
}
fmt.Println(string(data))
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SessionLens {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from the tokens page
static final HttpClient CLIENT = 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 = b.GET();
} else {
b = b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> res = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is {"data": ...} on success and {"error": {...}} on failure.
// Parse it with whichever JSON library the project already uses.
return res.body();
}
public static void main(String[] args) throws Exception {
System.out.println(call("/me", null));
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from the tokens page
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 = JSON.dump(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
if payload["error"]
raise "#{payload['error']['code']}: #{payload['error']['message']}"
end
payload["data"]
end
puts call("/me")
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from the tokens page
function call(string $path, ?array $body = null): array {
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
}
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POST => $body !== null,
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$raw = curl_exec($ch);
curl_close($ch);
$payload = json_decode($raw, true);
if (isset($payload["error"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
print_r(call("/me"));
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class SessionLens
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // from the tokens page
static readonly HttpClient Http = new();
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.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body is not null)
{
req.Content = new StringContent(
JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (doc.RootElement.TryGetProperty("error", out var err))
{
throw new Exception(err.GetProperty("code").GetString() + ": "
+ err.GetProperty("message").GetString());
}
return doc.RootElement.GetProperty("data").Clone();
}
static async Task Main()
{
Console.WriteLine(await Call("/me"));
}
}
3 · Who am I, and can I afford this?
GET /me returns the account, the kind of token, and the credit balance. Compare the balance against the hold from /estimate before running — a 402 after submit is a failure of your client, not of the user.
curl -s "https://api.skillsafe.ai/v1/app-api/me" \
-H "Authorization: Bearer YOUR_TOKEN"
# {"ok":true,"data":{"account_id":"acc_...","kind":"personal","credits":184200}}
# kind is "guest" for an anonymous token. A guest can call /me and /estimate
# but not /run.
me = call("/me")
print(me["kind"], me.get("credits"))
if me["kind"] == "guest":
raise SystemExit("Runs are metered. Sign in for a personal token.")
const me = await call("/me");
console.log(me.kind, me.credits);
if (me.kind === "guest") {
throw new Error("Runs are metered. Sign in for a personal token.");
}
raw, err := call("/me", nil)
if err != nil {
panic(err)
}
var me struct {
AccountID string `json:"account_id"`
Kind string `json:"kind"`
Credits int64 `json:"credits"`
}
if err := json.Unmarshal(raw, &me); err != nil {
panic(err)
}
fmt.Println(me.Kind, me.Credits)
if me.Kind == "guest" {
panic("runs are metered; sign in for a personal token")
}
String me = call("/me", null);
System.out.println(me);
// data.kind is "guest" or "personal"; data.credits is the balance in credits.
// A guest token can price a run but not execute one.
me = call("/me")
puts "#{me['kind']} #{me['credits']}"
raise "Runs are metered. Sign in for a personal token." if me["kind"] == "guest"
$me = call("/me");
echo $me["kind"], " ", $me["credits"], PHP_EOL;
if ($me["kind"] === "guest") {
throw new RuntimeException("Runs are metered. Sign in for a personal token.");
}
var me = await Call("/me");
Console.WriteLine($"{me.GetProperty("kind").GetString()} {me.GetProperty("credits").GetInt64()}");
if (me.GetProperty("kind").GetString() == "guest")
{
throw new Exception("Runs are metered. Sign in for a personal token.");
}
4 · Price the run (free)
POST /estimate costs nothing and creates no job. Assert that model_alias reads gpt-terra and markup_bps is 1000; a successful estimate proves the token, the input shape and the model binding are all valid. Re-estimate on every lane change, because the hold differs per lane.
# The body IS the input object. There is no "input" wrapper and no slug header.
# Wrapping it returns 200 while hiding "task" from the model, which silently
# answers as the wrong lane -- so don't.
curl -s "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"task": "review",
"session_name": "invoice-rounding-fix",
"format": "claude-code",
"facts": {"turns": 2, "tool_calls": 10, "failed_tool_calls": 3},
"transcript": "### turn 1\nUSER: The test suite fails on main. Fix it.\n..."
}'
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":4180,"min_credits":420,"sponsor_enabled":false}}
payload = {
"task": "review",
"session_name": "invoice-rounding-fix",
"format": "claude-code",
"facts": {"turns": 2, "tool_calls": 10, "failed_tool_calls": 3},
"transcript": transcript, # see "Building the transcript" below
}
est = call("/estimate", payload)
print(est["hold_credits"], "credits reserved;", est["model"])
# /estimate is free and creates no job. Estimate per lane: the hold differs
# because the prompts and output caps differ.
const payload = {
task: "review",
session_name: "invoice-rounding-fix",
format: "claude-code",
facts: { turns: 2, tool_calls: 10, failed_tool_calls: 3 },
transcript, // see "Building the transcript" below
};
const est = await call("/estimate", payload);
console.log(est.hold_credits, "credits reserved;", est.model);
payload := map[string]any{
"task": "review",
"session_name": "invoice-rounding-fix",
"format": "claude-code",
"facts": map[string]any{
"turns": 2, "tool_calls": 10, "failed_tool_calls": 3,
},
"transcript": transcript,
}
raw, err := call("/estimate", payload)
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
}
if err := json.Unmarshal(raw, &est); err != nil {
panic(err)
}
fmt.Println(est.HoldCredits, "credits reserved;", est.Model)
String body = """
{
"task": "review",
"session_name": "invoice-rounding-fix",
"format": "claude-code",
"facts": {"turns": 2, "tool_calls": 10, "failed_tool_calls": 3},
"transcript": "### turn 1\nUSER: The test suite fails on main. Fix it.\n..."
}
""";
String est = call("/estimate", body);
System.out.println(est);
// data.hold_credits is what gets reserved; data.model_alias should read "gpt-terra".
payload = {
"task" => "review",
"session_name" => "invoice-rounding-fix",
"format" => "claude-code",
"facts" => { "turns" => 2, "tool_calls" => 10, "failed_tool_calls" => 3 },
"transcript" => transcript
}
est = call("/estimate", payload)
puts "#{est['hold_credits']} credits reserved; #{est['model']}"
$payload = [
"task" => "review",
"session_name" => "invoice-rounding-fix",
"format" => "claude-code",
"facts" => ["turns" => 2, "tool_calls" => 10, "failed_tool_calls" => 3],
"transcript" => $transcript,
];
$est = call("/estimate", $payload);
echo $est["hold_credits"], " credits reserved; ", $est["model"], PHP_EOL;
var payload = new
{
task = "review",
session_name = "invoice-rounding-fix",
format = "claude-code",
facts = new { turns = 2, tool_calls = 10, failed_tool_calls = 3 },
transcript,
};
var est = await Call("/estimate", payload);
Console.WriteLine($"{est.GetProperty("hold_credits").GetInt64()} credits reserved");
5 · Run it and poll
POST /run returns a job_id; poll GET /jobs/{job_id} to a terminal state. charged_credits is the real cost and is usually far below the hold, which prices the full output cap. If truncated is true the answer was cut short for balance reasons — surface that rather than presenting a clipped answer as complete.
# Pass an Idempotency-Key on every run. Retrying with the SAME key returns the
# SAME job instead of billing twice; a genuine second attempt varies the suffix.
curl -s "https://api.skillsafe.ai/v1/app-api/run" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: session-lens:9f2c1ab4:7431:a1" \
-d @payload.json
# {"ok":true,"data":{"job_id":"job_7yq2..."}}
# Then poll until the job reaches a terminal state:
curl -s "https://api.skillsafe.ai/v1/app-api/jobs/job_7yq2..." \
-H "Authorization: Bearer YOUR_TOKEN"
# {"ok":true,"data":{"status":"succeeded","output":"VERDICT: acceptable\n...",
# "charged_credits":1290,"truncated":false}}
import time
key = "session-lens:9f2c1ab4:7431:a1" # hash of the input + attempt
job = call_with_key("/run", payload, key) # adds the Idempotency-Key header
while True:
state = call("/jobs/" + job["job_id"])
if state["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(1.5)
if state["status"] != "succeeded":
raise RuntimeError(state.get("error", "run failed"))
print(state["output"])
print("charged", state["charged_credits"], "credits")
if state.get("truncated"):
print("WARNING: the answer was cut short -- top up for a full run.")
const key = "session-lens:9f2c1ab4:7431:a1";
const job = await callWithKey("/run", payload, key);
let state;
for (;;) {
state = await call(`/jobs/${job.job_id}`);
if (["succeeded", "failed", "cancelled"].includes(state.status)) break;
await new Promise((r) => setTimeout(r, 1500));
}
if (state.status !== "succeeded") throw new Error(state.error ?? "run failed");
console.log(state.output);
console.log("charged", state.charged_credits, "credits");
if (state.truncated) console.warn("answer was cut short -- top up for a full run");
// Add the header in a variant of call() that takes an Idempotency-Key.
raw, err := callWithKey("/run", payload, "session-lens:9f2c1ab4:7431:a1")
if err != nil {
panic(err)
}
var job struct {
JobID string `json:"job_id"`
}
_ = json.Unmarshal(raw, &job)
var state struct {
Status string `json:"status"`
Output string `json:"output"`
ChargedCredits int64 `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
for {
raw, err = call("/jobs/"+job.JobID, nil)
if err != nil {
panic(err)
}
_ = json.Unmarshal(raw, &state)
if state.Status == "succeeded" || state.Status == "failed" || state.Status == "cancelled" {
break
}
time.Sleep(1500 * time.Millisecond)
}
fmt.Println(state.Output, state.ChargedCredits)
// callWithKey() is call() plus a header:
// .header("Idempotency-Key", "session-lens:9f2c1ab4:7431:a1")
String job = callWithKey("/run", body, "session-lens:9f2c1ab4:7431:a1");
// Poll GET /jobs/{job_id} until data.status is succeeded, failed or cancelled.
// On success data.output holds the answer and data.charged_credits the real cost,
// which is usually far below the hold. data.truncated marks a clipped answer.
String state = call("/jobs/job_7yq2...", null);
System.out.println(state);
key = "session-lens:9f2c1ab4:7431:a1"
job = call_with_key("/run", payload, key)
state = nil
loop do
state = call("/jobs/#{job['job_id']}")
break if %w[succeeded failed cancelled].include?(state["status"])
sleep 1.5
end
raise(state["error"] || "run failed") unless state["status"] == "succeeded"
puts state["output"]
puts "charged #{state['charged_credits']} credits"
$key = "session-lens:9f2c1ab4:7431:a1";
$job = call_with_key("/run", $payload, $key);
do {
$state = call("/jobs/" . $job["job_id"]);
if (in_array($state["status"], ["succeeded", "failed", "cancelled"], true)) {
break;
}
usleep(1_500_000);
} while (true);
if ($state["status"] !== "succeeded") {
throw new RuntimeException($state["error"] ?? "run failed");
}
echo $state["output"], PHP_EOL;
echo "charged ", $state["charged_credits"], " credits", PHP_EOL;
var job = await CallWithKey("/run", payload, "session-lens:9f2c1ab4:7431:a1");
var jobId = job.GetProperty("job_id").GetString();
JsonElement state;
while (true)
{
state = await Call($"/jobs/{jobId}");
var status = state.GetProperty("status").GetString();
if (status is "succeeded" or "failed" or "cancelled") break;
await Task.Delay(1500);
}
Console.WriteLine(state.GetProperty("output").GetString());
Console.WriteLine($"charged {state.GetProperty("charged_credits").GetInt64()} credits");
6 · Or stream it
POST /run-stream is the same run over server-sent events. Frames are {"text": "..."} deltas followed by a terminal {"status": ...}. Prefer this: the answer is long enough to want progress, and the section headings arriving in the stream are exactly what the page uses to advance its progress stages.
# Server-sent events. Prefer this over /run + poll: the answer is long enough
# that you want progress, and the page maps section headings to progress stages.
curl -N "https://api.skillsafe.ai/v1/app-api/run-stream" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: session-lens:9f2c1ab4:7431:a1" \
-d @payload.json
# event: job
# data: {"job_id":"job_7yq2..."}
#
# event: delta
# data: {"text":"VERDICT: acceptable\nHEADLINE: The fix is correct"}
#
# event: done
# data: {"status":"succeeded","charged_credits":1290,"truncated":false}
import json
import urllib.request
req = urllib.request.Request(
BASE + "/run-stream",
data=json.dumps(payload).encode(),
method="POST",
)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", "session-lens:9f2c1ab4:7431:a1")
buf = ""
with urllib.request.urlopen(req) as res:
for line in res:
line = line.decode().strip()
if not line.startswith("data:"):
continue
frame = json.loads(line[5:].strip())
if "text" in frame:
buf += frame["text"]
elif frame.get("status"):
print("terminal:", frame["status"])
print(buf)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "session-lens:9f2c1ab4:7431:a1",
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let answer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop() ?? "";
for (const frame of frames) {
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
const payloadFrame = JSON.parse(line.slice(5).trim());
if (payloadFrame.text) answer += payloadFrame.text;
}
}
console.log(answer);
raw, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(raw))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "session-lens:9f2c1ab4:7431:a1")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var answer strings.Builder
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 0, 1<<20), 1<<20)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
var frame struct {
Text string `json:"text"`
Status string `json:"status"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &frame); err != nil {
continue
}
answer.WriteString(frame.Text)
}
fmt.Println(answer.String())
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "session-lens:9f2c1ab4:7431:a1")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
StringBuilder answer = new StringBuilder();
HttpResponse<java.util.stream.Stream<String>> res =
CLIENT.send(req, HttpResponse.BodyHandlers.ofLines());
res.body().filter(l -> l.startsWith("data:")).forEach(l -> {
// Each frame is one JSON object: {"text": "..."} or {"status": "succeeded"}.
// Append the text fields in arrival order to rebuild the answer.
answer.append(extractText(l.substring(5).strip()));
});
System.out.println(answer);
require "net/http"
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "session-lens:9f2c1ab4:7431:a1"
req.body = JSON.dump(payload)
answer = +""
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|
next unless line.start_with?("data:")
frame = JSON.parse(line[5..].strip) rescue next
answer << frame["text"] if frame["text"]
end
end
end
end
puts answer
$answer = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: session-lens:9f2c1ab4:7431:a1",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$answer) {
foreach (explode("\n", $chunk) as $line) {
if (strpos($line, "data:") !== 0) {
continue;
}
$frame = json_decode(trim(substr($line, 5)), true);
if (isset($frame["text"])) {
$answer .= $frame["text"];
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
echo $answer, PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", "session-lens:9f2c1ab4:7431:a1");
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new System.IO.StreamReader(await res.Content.ReadAsStreamAsync());
var answer = new StringBuilder();
while (await reader.ReadLineAsync() is string line)
{
if (!line.StartsWith("data:")) continue;
using var frame = JsonDocument.Parse(line[5..].Trim());
if (frame.RootElement.TryGetProperty("text", out var t))
{
answer.Append(t.GetString());
}
}
Console.WriteLine(answer);
7 · The task field: three lanes
task is required and selects the lane. All three take the same
input and return the same envelope; what differs is the question and the verdict
vocabulary.
| task | Verdict is one of | What it answers |
|---|---|---|
review | efficient | acceptable | wasteful | Was the run a good use of the agent? Thrash, failure loops, instruction drift, token shape. |
digest | complete | partial | unclear | What did the session change? The PR note: files written, decisions taken, what is unfinished. |
debug | root-cause-found | probable-cause | insufficient-evidence | Why did it fail? The earliest point the run went off the rails, working back from the errors. |
task: review
Was the run a good use of the agent? Thrash, failure loops, instruction drift, token shape.
# task="review" -- verdict comes back as one of: efficient | acceptable | wasteful
curl -s "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "review", "session_name": "invoice-rounding-fix",
"format": "claude-code",
"facts": {"turns": 2, "tool_calls": 10, "failed_tool_calls": 3},
"transcript": "### turn 1\nUSER: ...\n"}'
payload["task"] = "review"
est = call("/estimate", payload) # re-price: the hold differs per lane
result = run_and_wait(payload) # verdict: efficient | acceptable | wasteful
payload.task = "review";
const est = await call("/estimate", payload); // re-price per lane
const result = await runAndWait(payload); // verdict: efficient | acceptable | wasteful
payload["task"] = "review"
// Re-price per lane, then run. Verdict: efficient | acceptable | wasteful
raw, err = call("/estimate", payload)
// task = "review"; verdict is one of: efficient | acceptable | wasteful
// Re-estimate whenever the lane changes -- the hold differs per lane.
String est = call("/estimate", bodyFor("review"));
payload["task"] = "review"
est = call("/estimate", payload) # re-price per lane
# verdict: efficient | acceptable | wasteful
$payload["task"] = "review";
$est = call("/estimate", $payload); // re-price per lane
// verdict: efficient | acceptable | wasteful
// task = "review"; verdict is one of: efficient | acceptable | wasteful
var est = await Call("/estimate", PayloadFor("review"));
task: digest
What did the session change? The PR note: files written, decisions taken, what is unfinished.
# task="digest" -- verdict comes back as one of: complete | partial | unclear
curl -s "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "digest", "session_name": "invoice-rounding-fix",
"format": "claude-code",
"facts": {"turns": 2, "tool_calls": 10, "failed_tool_calls": 3},
"transcript": "### turn 1\nUSER: ...\n"}'
payload["task"] = "digest"
est = call("/estimate", payload) # re-price: the hold differs per lane
result = run_and_wait(payload) # verdict: complete | partial | unclear
payload.task = "digest";
const est = await call("/estimate", payload); // re-price per lane
const result = await runAndWait(payload); // verdict: complete | partial | unclear
payload["task"] = "digest"
// Re-price per lane, then run. Verdict: complete | partial | unclear
raw, err = call("/estimate", payload)
// task = "digest"; verdict is one of: complete | partial | unclear
// Re-estimate whenever the lane changes -- the hold differs per lane.
String est = call("/estimate", bodyFor("digest"));
payload["task"] = "digest"
est = call("/estimate", payload) # re-price per lane
# verdict: complete | partial | unclear
$payload["task"] = "digest";
$est = call("/estimate", $payload); // re-price per lane
// verdict: complete | partial | unclear
// task = "digest"; verdict is one of: complete | partial | unclear
var est = await Call("/estimate", PayloadFor("digest"));
task: debug
Why did it fail? The earliest point the run went off the rails, working back from the errors.
# task="debug" -- verdict comes back as one of: root-cause-found | probable-cause | insufficient-evidence
curl -s "https://api.skillsafe.ai/v1/app-api/estimate" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"task": "debug", "session_name": "invoice-rounding-fix",
"format": "claude-code",
"facts": {"turns": 2, "tool_calls": 10, "failed_tool_calls": 3},
"transcript": "### turn 1\nUSER: ...\n"}'
payload["task"] = "debug"
est = call("/estimate", payload) # re-price: the hold differs per lane
result = run_and_wait(payload) # verdict: root-cause-found | probable-cause | insufficient-evidence
payload.task = "debug";
const est = await call("/estimate", payload); // re-price per lane
const result = await runAndWait(payload); // verdict: root-cause-found | probable-cause | insufficient-evidence
payload["task"] = "debug"
// Re-price per lane, then run. Verdict: root-cause-found | probable-cause | insufficient-evidence
raw, err = call("/estimate", payload)
// task = "debug"; verdict is one of: root-cause-found | probable-cause | insufficient-evidence
// Re-estimate whenever the lane changes -- the hold differs per lane.
String est = call("/estimate", bodyFor("debug"));
payload["task"] = "debug"
est = call("/estimate", payload) # re-price per lane
# verdict: root-cause-found | probable-cause | insufficient-evidence
$payload["task"] = "debug";
$est = call("/estimate", $payload); // re-price per lane
// verdict: root-cause-found | probable-cause | insufficient-evidence
// task = "debug"; verdict is one of: root-cause-found | probable-cause | insufficient-evidence
var est = await Call("/estimate", PayloadFor("debug"));
8 · Building the transcript
This is the part that decides whether the answer is any good. A real session is megabytes; you have to clip, and how you clip matters more than the budget you pick.
- Keep the final turns in full. A run goes wrong at the end. A prefix-only cut throws away the thing you are asking about.
- Keep the opening ask. It is what every lane judges the run against. If it will not fit in full, send its one-line summary.
- Mark what you dropped with a
[NOT SENT]stub naming its tools and failures. The model is instructed never to describe a turn it cannot see — but only if you tell it which ones those are. - Cut long blocks head-and-tail, stating the size of the cut inline. A traceback carries its diagnosis in the last lines.
- Give failures a bigger allowance than successes.
The renderer the page itself uses, in outline. Per-block caps: user text 4000, agent text 2500, thinking 700, tool arguments 700, successful tool result 900, failed tool result 2200.
# The transcript is plain text, one block per turn, in this shape:
#
# ### turn 1
# USER: The test suite fails on main. Fix it.
# AGENT_THINKING: Start by reproducing...
# AGENT: I'll reproduce the failure first.
# TOOL_CALL Bash: {"command":"pytest -q"}
# TOOL_RESULT (ERROR): ...traceback...
# STOP_REASON: tool_use
#
# Turns you cut must be marked so the model knows not to describe them:
#
# ### turn 4 [NOT SENT]
# ask: add a regression test | tools: Write, Bash | failures: 0
#
# jq turns a session file into the raw material:
jq -r 'select(.type=="assistant") | .message.content[]?
| select(.type=="tool_use") | "TOOL_CALL \(.name): \(.input|tostring)"' session.jsonl
def render_turn(index, turn):
lines = [f"### turn {index + 1}"]
lines.append("USER: " + clip(turn["user"], 4000))
for step in turn["steps"]:
for block in step["content"]:
kind = block["type"]
if kind == "text":
lines.append("AGENT: " + clip(block["text"], 2500))
elif kind == "thinking":
lines.append("AGENT_THINKING: " + clip(block["text"], 700))
elif kind == "toolUse":
lines.append(f"TOOL_CALL {block['name']}: " + clip(json.dumps(block["input"]), 700))
elif kind == "toolResult":
tag = "TOOL_RESULT (ERROR)" if block["isError"] else "TOOL_RESULT"
lines.append(f"{tag}: " + clip(block["content"], 2200 if block["isError"] else 900))
return "\n".join(lines)
def clip(text, cap):
"""Head-and-tail, never a blind prefix: a traceback keeps its diagnosis."""
text = text or ""
if len(text) <= cap:
return text
head = (cap * 55) // 100
return text[:head] + f"\n... [{len(text) - cap} characters cut] ...\n" + text[cap - head:]
function renderTurn(index, turn) {
const lines = [`### turn ${index + 1}`];
lines.push("USER: " + clip(turn.user, 4000));
for (const step of turn.steps) {
for (const block of step.content) {
if (block.type === "text") lines.push("AGENT: " + clip(block.text, 2500));
else if (block.type === "thinking") lines.push("AGENT_THINKING: " + clip(block.text, 700));
else if (block.type === "toolUse") {
lines.push(`TOOL_CALL ${block.name}: ` + clip(JSON.stringify(block.input), 700));
} else if (block.type === "toolResult") {
const tag = block.isError ? "TOOL_RESULT (ERROR)" : "TOOL_RESULT";
lines.push(`${tag}: ` + clip(block.content, block.isError ? 2200 : 900));
}
}
}
return lines.join("\n");
}
// Head-and-tail, never a blind prefix: the command AND the outcome survive.
function clip(text, cap) {
text = text ?? "";
if (text.length <= cap) return text;
const head = Math.ceil(cap * 0.55);
return `${text.slice(0, head)}\n... [${text.length - cap} characters cut] ...\n${text.slice(text.length - (cap - head))}`;
}
func renderTurn(index int, turn Turn) string {
var b strings.Builder
fmt.Fprintf(&b, "### turn %d\n", index+1)
fmt.Fprintf(&b, "USER: %s\n", clip(turn.User, 4000))
for _, step := range turn.Steps {
for _, block := range step.Content {
switch block.Type {
case "text":
fmt.Fprintf(&b, "AGENT: %s\n", clip(block.Text, 2500))
case "thinking":
fmt.Fprintf(&b, "AGENT_THINKING: %s\n", clip(block.Text, 700))
case "toolUse":
args, _ := json.Marshal(block.Input)
fmt.Fprintf(&b, "TOOL_CALL %s: %s\n", block.Name, clip(string(args), 700))
case "toolResult":
cap := 900
tag := "TOOL_RESULT"
if block.IsError {
cap, tag = 2200, "TOOL_RESULT (ERROR)"
}
fmt.Fprintf(&b, "%s: %s\n", tag, clip(block.Content, cap))
}
}
}
return b.String()
}
// One block per turn. Keep the final turns in full -- that is where a run goes
// wrong -- and mark anything you drop so the model does not invent it:
//
// ### turn 1
// USER: The test suite fails on main. Fix it.
// AGENT: I'll reproduce the failure first.
// TOOL_CALL Bash: {"command":"pytest -q"}
// TOOL_RESULT (ERROR): ...traceback...
//
// ### turn 4 [NOT SENT]
// ask: add a regression test | tools: Write, Bash | failures: 0
//
// Clip long blocks head-and-tail rather than truncating: a traceback carries
// its diagnosis in the LAST lines, so a prefix-only cut throws the answer away.
String transcript = turns.stream()
.map(t -> renderTurn(t.index(), t))
.collect(java.util.stream.Collectors.joining("\n\n"));
def render_turn(index, turn)
lines = ["### turn #{index + 1}", "USER: #{clip(turn[:user], 4000)}"]
turn[:steps].each do |step|
step[:content].each do |block|
case block[:type]
when "text" then lines << "AGENT: #{clip(block[:text], 2500)}"
when "thinking" then lines << "AGENT_THINKING: #{clip(block[:text], 700)}"
when "toolUse"
lines << "TOOL_CALL #{block[:name]}: #{clip(JSON.dump(block[:input]), 700)}"
when "toolResult"
tag = block[:isError] ? "TOOL_RESULT (ERROR)" : "TOOL_RESULT"
lines << "#{tag}: #{clip(block[:content], block[:isError] ? 2200 : 900)}"
end
end
end
lines.join("\n")
end
# Head-and-tail: keep both the command and its outcome.
def clip(text, cap)
text = text.to_s
return text if text.length <= cap
head = (cap * 0.55).ceil
"#{text[0, head]}\n... [#{text.length - cap} characters cut] ...\n#{text[-(cap - head)..]}"
end
function render_turn(int $index, array $turn): string {
$lines = ["### turn " . ($index + 1), "USER: " . clip($turn["user"], 4000)];
foreach ($turn["steps"] as $step) {
foreach ($step["content"] as $block) {
switch ($block["type"]) {
case "text":
$lines[] = "AGENT: " . clip($block["text"], 2500);
break;
case "thinking":
$lines[] = "AGENT_THINKING: " . clip($block["text"], 700);
break;
case "toolUse":
$lines[] = "TOOL_CALL {$block['name']}: "
. clip(json_encode($block["input"]), 700);
break;
case "toolResult":
$tag = $block["isError"] ? "TOOL_RESULT (ERROR)" : "TOOL_RESULT";
$lines[] = "$tag: " . clip($block["content"], $block["isError"] ? 2200 : 900);
break;
}
}
}
return implode("\n", $lines);
}
// Head-and-tail, never a blind prefix.
function clip(?string $text, int $cap): string {
$text = $text ?? "";
if (strlen($text) <= $cap) {
return $text;
}
$head = (int) ceil($cap * 0.55);
return substr($text, 0, $head)
. "\n... [" . (strlen($text) - $cap) . " characters cut] ...\n"
. substr($text, -($cap - $head));
}
static string RenderTurn(int index, Turn turn)
{
var lines = new List<string> { $"### turn {index + 1}", "USER: " + Clip(turn.User, 4000) };
foreach (var step in turn.Steps)
{
foreach (var block in step.Content)
{
switch (block.Type)
{
case "text":
lines.Add("AGENT: " + Clip(block.Text, 2500));
break;
case "thinking":
lines.Add("AGENT_THINKING: " + Clip(block.Text, 700));
break;
case "toolUse":
lines.Add($"TOOL_CALL {block.Name}: "
+ Clip(JsonSerializer.Serialize(block.Input), 700));
break;
case "toolResult":
var tag = block.IsError ? "TOOL_RESULT (ERROR)" : "TOOL_RESULT";
lines.Add($"{tag}: " + Clip(block.Content, block.IsError ? 2200 : 900));
break;
}
}
}
return string.Join("\n", lines);
}
// Head-and-tail: a traceback keeps its diagnosis, which lives in the last lines.
static string Clip(string text, int cap)
{
text ??= "";
if (text.Length <= cap) return text;
var head = (int)Math.Ceiling(cap * 0.55);
return text[..head]
+ $"\n... [{text.Length - cap} characters cut] ...\n"
+ text[^(cap - head)..];
}
9 · The facts field, and why it matters
facts is the exact census of the session — turn count, tool
calls by name, failures by tool, repeated identical calls, files touched, token
totals, cache hit rate. It is arithmetic over the file, so it is true by
construction, and the prompt tells the model that the census wins over its own
reading.
Send it, then check the answer against it. Every count the model
restates in its ## NUMBERS section, every turn number it cites, and
every path it quotes as evidence can be compared to the census. The page prints
the disagreements above the findings. That check is the only reason to trust any
of this, and it is a dozen lines of code — do not skip it.
10 · The output contract
Every lane returns the same plain-text envelope. Parse it forgivingly: run the parser on every delta so a stream that dies two thirds of the way through still renders what arrived.
VERDICT: <one token from the lane's list> HEADLINE: <one sentence, at most 140 characters> ## SUMMARY <2-5 sentences> ## FINDINGS ### <short title> | <critical|major|minor> | turn <n or -> <1-4 sentences> EVIDENCE: <verbatim tool name, path, or quoted excerpt> ACTION: <one concrete change, imperative> ## NUMBERS - <label>: <value> ## NEXT 1. <concrete next step>
Notes that matter when you write the parser: the ### line has
exactly three |-separated parts; every finding carries exactly one
EVIDENCE: and one ACTION:; an empty findings section is
the literal line None — the run was clean on this axis. rather
than a missing heading; and a NUMBERS label containing
rate, ratio or percent is a derived figure, not a
restatement of a count — do not reconcile it as one.
Errors
| HTTP | code | What to do |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or task is not one of review, digest, debug. Also what you get if you wrapped the input in an input key. |
| 401 | UNAUTHORIZED | Missing or malformed Authorization header. |
| 403 | FORBIDDEN | The token is valid but not for this app, or a guest token tried to /run. |
| 402 | INSUFFICIENT_CREDITS | Balance is below min_credits. Call /estimate first and compare against /me so this never reaches a user. |
| 404 | NOT_FOUND | Unknown job id on /jobs/{id}. |
| 409 | IDEMPOTENCY_CONFLICT | The same Idempotency-Key was reused with a different body. Vary the key when the input changes. |
| 429 | RATE_LIMITED | Back off and retry; never tight-loop. |
| 500 | INTERNAL | Retry once with the same Idempotency-Key — that returns the original job rather than billing a second one. |
Rate limits and good manners
Debounce /estimate — the page waits 400 ms after the last
keystroke. Space out retries and back off on 429. If you are
batch-reviewing a directory of sessions, run them in sequence with the
per-session hash in the Idempotency-Key, so re-running the batch after a crash
costs nothing for the sessions that already finished.