Repo Tour Guide API
The page lists a repository, ranks the files a newcomer would open first, packs them into a token budget and sends only that pack. The API takes the same input, so a script or CI job can produce an onboarding doc for any codebase it can read, or ask a follow-up about one.
Base URL: https://api.skillsafe.ai/v1/app-api. Every response is {"data": ...} on success or {"error": {"code", "message"}} on failure. The request body is the input object - do not wrap it in {"input": ...}. Every field is a string; objects such as facts are JSON-encoded strings.
The task field
| task | What it returns | Extra fields |
|---|---|---|
tour | An onboarding doc with the headings Summary, At a glance, Architecture, How it fits together, How to run, Where to start reading, Key concepts, Gotchas, Not shown in the files sent, Follow-up questions. | audience: contributor (default), user or evaluator. |
ask | An answer to one follow-up, with the headings Answer, Files used, Not shown in the files sent, Follow-up questions. | question (required), tour (the doc already written), transcript (JSON list of earlier {"q","a"}). |
Input
| Field | Type | Notes |
|---|---|---|
task | string, required | tour or ask. |
repo | string, required | owner/name, or a folder name. |
files | string, required | Each file as a line === FILE: path === followed by a fenced block with its text. Add (clipped) after the path when you cut it. Keep the whole field under about 130,000 characters. |
outline | string | A folder outline of the whole repository, so the model can name files it was not sent. |
facts | string (JSON) | File count, languages, manifests, entry points, tests, CI - whatever you computed. Treated as authoritative. |
meta | string (JSON) | Description, licence, default branch, topics. |
files_note | string | Which files were clipped or left out. |
source, ref | string | github or upload; the branch or commit. |
retry_note | string | Optional. The app sets it only on its one automatic reformat retry. |
Output
The finished job's output.output is Markdown, one ## heading per section in the order above. Repository paths are in backticks; check each one against your file list - the app does exactly that and marks every cited path as read, present-but-not-sent, or not in the repository.
## Summary
tiny-cli is a one-command Python CLI that prints a greeting...
## Architecture
- `tiny_cli/main.py` - the entry point; parses argv and calls `greet`
...
Errors
| HTTP | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a required field is missing. |
| 401 | UNAUTHORIZED | Missing or expired token. |
| 402 | INSUFFICIENT_CREDITS | Balance below the run's minimum. Top up at skillsafe.ai. |
| 403 | FORBIDDEN | A guest token tried to run. Sign in for a personal token. |
| 429 | RATE_LIMITED | Too many requests. Back off and retry. |
1. A tiny client
# Every call: Authorization: Bearer $TOKEN, JSON in, {"data": ...} or {"error": ...} out.
TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json", **(headers or {})})
with urllib.request.urlopen(req) as r:
return json.load(r)["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body, headers = {}) {
const res = await fetch(BASE + path, {
method,
headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json", ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error ? json.error.code + ": " + json.error.message : res.status);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
func call(method, path string, body any, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct{ Data json.RawMessage `json:"data"` }
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return err
}
return json.Unmarshal(env.Data, out)
}
import java.net.URI;
import java.net.http.*;
public class Api {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient http = HttpClient.newHttpClient();
static String call(String method, String path, String json) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json");
b = json == null ? b.method(method, HttpRequest.BodyPublishers.noBody())
: b.method(method, HttpRequest.BodyPublishers.ofString(json));
return http.send(b.build(), HttpResponse.BodyHandlers.ofString()).body(); // {"data": ...}
}
}
require "net/http"
require "json"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
headers.each { |k, v| req[k] = v }
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call($method, $path, $body = null, $headers = []) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge(["Authorization: Bearer " . TOKEN, "Content-Type: application/json"], $headers),
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$out = json_decode(curl_exec($ch), true);
return $out["data"] ?? $out;
}
using System.Net.Http;
using System.Text;
using System.Text.Json;
var http = new HttpClient { BaseAddress = new Uri("https://api.skillsafe.ai/v1/app-api/") };
http.DefaultRequestHeaders.Add("Authorization", "Bearer YOUR_TOKEN");
async Task<JsonElement> Call(HttpMethod m, string path, object? body = null) {
var req = new HttpRequestMessage(m, path.TrimStart('/'));
if (body != 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());
return doc.RootElement.GetProperty("data").Clone();
}
2. Get a token
Guests can price a run; running needs a personal token. The easiest way to get one is the token page.
# A guest token (enough for /me and /estimate). A run needs a personal token:
# sign in on https://repo-tour-guide.skillsafe.ai/tokens.html and copy it from there.
curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"repo-tour-guide"}'
guest = json.load(urllib.request.urlopen(urllib.request.Request(
BASE + "/guest", data=json.dumps({"slug": "repo-tour-guide"}).encode(),
headers={"Content-Type": "application/json"})))["data"]
print(guest["token"]) # guest: /me and /estimate only. Personal token: tokens.html
const g = await fetch(BASE + "/guest", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "repo-tour-guide" }),
}).then((r) => r.json());
console.log(g.data.token); // personal token for runs: tokens.html
// POST {"slug":"repo-tour-guide"} to base+"/guest" without a token; read data.token.
// For runs, copy a personal token from https://repo-tour-guide.skillsafe.ai/tokens.html
// POST {"slug":"repo-tour-guide"} to BASE + "/guest" (no Authorization header) and read data.token.
// Runs need a personal token from https://repo-tour-guide.skillsafe.ai/tokens.html
uri = URI(BASE + "/guest")
res = Net::HTTP.post(uri, { slug: "repo-tour-guide" }.to_json, "Content-Type" => "application/json")
puts JSON.parse(res.body)["data"]["token"]
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"], CURLOPT_POSTFIELDS => json_encode(["slug" => "repo-tour-guide"])]);
echo json_decode(curl_exec($ch), true)["data"]["token"];
var g = await new HttpClient().PostAsync("https://api.skillsafe.ai/v1/app-api/guest",
new StringContent("{\"slug\":\"repo-tour-guide\"}", Encoding.UTF8, "application/json"));
Console.WriteLine(await g.Content.ReadAsStringAsync()); // data.token
3. Who am I, and my balance
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
var me map[string]any
call("GET", "/me", nil, &me)
System.out.println(Api.call("GET", "/me", null));
me = call("GET", "/me")
puts me["credits"]
$me = call("GET", "/me");
echo $me["credits"];
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me);
4. Read a GitHub repository (free, optional)
The app's own repository reader is a function on the app's origin, not on the API host. It needs a personal (signed-in) token. /api/repo lists a public repository's files; /api/files returns the text of up to 40 paths per call.
curl -s -X POST "https://repo-tour-guide.skillsafe.ai/api/repo" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"repo": "psf/requests"}'
# -> {"ok": true, "ref", "meta": {...}, "tree": [{"p": "path", "s": bytes}], "tree_total", "notes": []}
curl -s -X POST "https://repo-tour-guide.skillsafe.ai/api/files" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"owner": "psf", "name": "requests", "ref": "main", "paths": ["README.md"]}'
# -> {"ok": true, "files": [{"path", "text", "size", "clipped"}]}
5. Price a tour (free)
hold_credits is the most a run can cost, reserved up front; you are charged only what it uses. 10,000 credits = $1.
curl -s -X POST "$BASE/estimate" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"task": "tour", "repo": "acme/tiny-cli", "audience": "contributor", "facts": "{\"files\":6,\"languages\":[{\"lang\":\"Python\",\"pct\":100}],\"manifests\":[\"pyproject.toml\"],\"entry_points\":[\"tiny_cli/main.py\"]}", "outline": "./ README.md, pyproject.toml\ntiny_cli/ (3 files)\n __init__.py, main.py, greet.py\ntests/ (1 file)", "files": "=== FILE: README.md ===\n```\n# tiny-cli\nSay hello from the terminal.\n\n pip install -e .\n tiny-cli greet Ada\n```\n\n=== FILE: tiny_cli/main.py ===\n```\nimport sys\nfrom .greet import greet\n\ndef main():\n print(greet(sys.argv[2]))\n```"}'
body = {
"task": "tour",
"repo": "acme/tiny-cli",
"audience": "contributor",
"facts": "{\"files\":6,\"languages\":[{\"lang\":\"Python\",\"pct\":100}],\"manifests\":[\"pyproject.toml\"],\"entry_points\":[\"tiny_cli/main.py\"]}",
"outline": "./ README.md, pyproject.toml\ntiny_cli/ (3 files)\n __init__.py, main.py, greet.py\ntests/ (1 file)",
"files": "=== FILE: README.md ===\n```\n# tiny-cli\nSay hello from the terminal.\n\n pip install -e .\n tiny-cli greet Ada\n```\n\n=== FILE: tiny_cli/main.py ===\n```\nimport sys\nfrom .greet import greet\n\ndef main():\n print(greet(sys.argv[2]))\n```"
}
est = call("POST", "/estimate", body)
print(est["hold_credits"], est["model_alias"], est.get("warnings"))
const body = {
"task": "tour",
"repo": "acme/tiny-cli",
"audience": "contributor",
"facts": "{\"files\":6,\"languages\":[{\"lang\":\"Python\",\"pct\":100}],\"manifests\":[\"pyproject.toml\"],\"entry_points\":[\"tiny_cli/main.py\"]}",
"outline": "./ README.md, pyproject.toml\ntiny_cli/ (3 files)\n __init__.py, main.py, greet.py\ntests/ (1 file)",
"files": "=== FILE: README.md ===\n```\n# tiny-cli\nSay hello from the terminal.\n\n pip install -e .\n tiny-cli greet Ada\n```\n\n=== FILE: tiny_cli/main.py ===\n```\nimport sys\nfrom .greet import greet\n\ndef main():\n print(greet(sys.argv[2]))\n```"
};
const est = await call("POST", "/estimate", body);
console.log(est.hold_credits, est.model_alias, est.warnings);
body := map[string]string{
"task": "tour",
"repo": "acme/tiny-cli",
"files": "=== FILE: README.md ===\n```\n# tiny-cli\nSay hello from the terminal.\n\n pip install -e .\n tiny-cli greet Ada\n```\n\n=== FILE: tiny_cli/main.py ===\n```\nimport sys\nfrom .greet import greet\n\ndef main():\n print(greet(sys.argv[2]))\n```",
}
var est map[string]any
call("POST", "/estimate", body, &est)
String body = "{\"task\": \"tour\", \"repo\": \"acme/tiny-cli\", \"audience\": \"contributor\", \"facts\": \"{\\\"files\\\":6,\\\"languages\\\":[{\\\"lang\\\":\\\"Python\\\",\\\"pct\\\":100}],\\\"manifests\\\":[\\\"pyproject.toml\\\"],\\\"entry_points\\\":[\\\"tiny_cli/main.py\\\"]}\", \"outline\": \"./ README.md, pyproject.toml\\ntiny_cli/ (3 files)\\n __init__.py, main.py, greet.py\\ntests/ (1 file)\", \"files\": \"=== FILE: README.md ===\\n```\\n# tiny-cli\\nSay hello from the terminal.\\n\\n pip install -e .\\n tiny-cli greet Ada\\n```\\n\\n=== FILE: tiny_cli/main.py ===\\n```\\nimport sys\\nfrom .greet import greet\\n\\ndef main():\\n print(greet(sys.argv[2]))\\n```\"}";
System.out.println(Api.call("POST", "/estimate", body));
body = JSON.parse("{\"task\": \"tour\", \"repo\": \"acme/tiny-cli\", \"audience\": \"contributor\", \"facts\": \"{\\\"files\\\":6,\\\"languages\\\":[{\\\"lang\\\":\\\"Python\\\",\\\"pct\\\":100}],\\\"manifests\\\":[\\\"pyproject.toml\\\"],\\\"entry_points\\\":[\\\"tiny_cli/main.py\\\"]}\", \"outline\": \"./ README.md, pyproject.toml\\ntiny_cli/ (3 files)\\n __init__.py, main.py, greet.py\\ntests/ (1 file)\", \"files\": \"=== FILE: README.md ===\\n```\\n# tiny-cli\\nSay hello from the terminal.\\n\\n pip install -e .\\n tiny-cli greet Ada\\n```\\n\\n=== FILE: tiny_cli/main.py ===\\n```\\nimport sys\\nfrom .greet import greet\\n\\ndef main():\\n print(greet(sys.argv[2]))\\n```\"}")
est = call("POST", "/estimate", body)
puts est["hold_credits"]
$body = json_decode("{\"task\": \"tour\", \"repo\": \"acme/tiny-cli\", \"audience\": \"contributor\", \"facts\": \"{\\\"files\\\":6,\\\"languages\\\":[{\\\"lang\\\":\\\"Python\\\",\\\"pct\\\":100}],\\\"manifests\\\":[\\\"pyproject.toml\\\"],\\\"entry_points\\\":[\\\"tiny_cli/main.py\\\"]}\", \"outline\": \"./ README.md, pyproject.toml\\ntiny_cli/ (3 files)\\n __init__.py, main.py, greet.py\\ntests/ (1 file)\", \"files\": \"=== FILE: README.md ===\\n```\\n# tiny-cli\\nSay hello from the terminal.\\n\\n pip install -e .\\n tiny-cli greet Ada\\n```\\n\\n=== FILE: tiny_cli/main.py ===\\n```\\nimport sys\\nfrom .greet import greet\\n\\ndef main():\\n print(greet(sys.argv[2]))\\n```\"}", true);
$est = call("POST", "/estimate", $body);
echo $est["hold_credits"];
var body = JsonSerializer.Deserialize<JsonElement>("{\"task\": \"tour\", \"repo\": \"acme/tiny-cli\", \"audience\": \"contributor\", \"facts\": \"{\\\"files\\\":6,\\\"languages\\\":[{\\\"lang\\\":\\\"Python\\\",\\\"pct\\\":100}],\\\"manifests\\\":[\\\"pyproject.toml\\\"],\\\"entry_points\\\":[\\\"tiny_cli/main.py\\\"]}\", \"outline\": \"./ README.md, pyproject.toml\\ntiny_cli/ (3 files)\\n __init__.py, main.py, greet.py\\ntests/ (1 file)\", \"files\": \"=== FILE: README.md ===\\n```\\n# tiny-cli\\nSay hello from the terminal.\\n\\n pip install -e .\\n tiny-cli greet Ada\\n```\\n\\n=== FILE: tiny_cli/main.py ===\\n```\\nimport sys\\nfrom .greet import greet\\n\\ndef main():\\n print(greet(sys.argv[2]))\\n```\"}");
var est = await Call(HttpMethod.Post, "/estimate", body);
6. Run it and poll
Always send an Idempotency-Key: a retry with the same key never bills twice.
curl -s -X POST "$BASE/run" -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: my-tour-001" \
-H "Content-Type: application/json" -d '{"task": "tour", "repo": "acme/tiny-cli", "audience": "contributor", "facts": "{\"files\":6,\"languages\":[{\"lang\":\"Python\",\"pct\":100}],\"manifests\":[\"pyproject.toml\"],\"entry_points\":[\"tiny_cli/main.py\"]}", "outline": "./ README.md, pyproject.toml\ntiny_cli/ (3 files)\n __init__.py, main.py, greet.py\ntests/ (1 file)", "files": "=== FILE: README.md ===\n```\n# tiny-cli\nSay hello from the terminal.\n\n pip install -e .\n tiny-cli greet Ada\n```\n\n=== FILE: tiny_cli/main.py ===\n```\nimport sys\nfrom .greet import greet\n\ndef main():\n print(greet(sys.argv[2]))\n```"}'
# -> data.job_id; then poll until status is succeeded or failed:
curl -s "$BASE/jobs/JOB_ID" -H "Authorization: Bearer $TOKEN"
import time
job = call("POST", "/run", body, {"Idempotency-Key": "my-tour-001"})
while job.get("status") not in ("succeeded", "failed"):
time.sleep(1)
job = call("GET", "/jobs/" + job["job_id"])
print(job["output"]["output"]) # Markdown with ## Summary, ## Architecture, ...
let job = await call("POST", "/run", body, { "Idempotency-Key": "my-tour-001" });
while (!["succeeded", "failed"].includes(job.status)) {
await new Promise((r) => setTimeout(r, 1000));
job = await call("GET", "/jobs/" + job.job_id);
}
console.log(job.output.output);
var job map[string]any
call("POST", "/run", body, &job) // set Idempotency-Key on the request too
for job["status"] != "succeeded" && job["status"] != "failed" {
time.Sleep(time.Second)
call("GET", "/jobs/"+job["job_id"].(string), nil, &job)
}
String job = Api.call("POST", "/run", body); // add header Idempotency-Key
// read data.job_id, then GET /jobs/{job_id} every second until status is succeeded or failed
job = call("POST", "/run", body, "Idempotency-Key" => "my-tour-001")
until %w[succeeded failed].include?(job["status"])
sleep 1
job = call("GET", "/jobs/#{job['job_id']}")
end
puts job["output"]["output"]
$job = call("POST", "/run", $body, ["Idempotency-Key: my-tour-001"]);
while (!in_array($job["status"], ["succeeded", "failed"])) {
sleep(1);
$job = call("GET", "/jobs/" . $job["job_id"]);
}
echo $job["output"]["output"];
var job = await Call(HttpMethod.Post, "/run", body); // add Idempotency-Key header
while (job.GetProperty("status").GetString() is not ("succeeded" or "failed")) {
await Task.Delay(1000);
job = await Call(HttpMethod.Get, "/jobs/" + job.GetProperty("job_id").GetString());
}
7. Or stream it
curl -N -X POST "$BASE/run-stream" -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: my-tour-002" \
-H "Content-Type: application/json" -d '{"task": "tour", "repo": "acme/tiny-cli", "audience": "contributor", "facts": "{\"files\":6,\"languages\":[{\"lang\":\"Python\",\"pct\":100}],\"manifests\":[\"pyproject.toml\"],\"entry_points\":[\"tiny_cli/main.py\"]}", "outline": "./ README.md, pyproject.toml\ntiny_cli/ (3 files)\n __init__.py, main.py, greet.py\ntests/ (1 file)", "files": "=== FILE: README.md ===\n```\n# tiny-cli\nSay hello from the terminal.\n\n pip install -e .\n tiny-cli greet Ada\n```\n\n=== FILE: tiny_cli/main.py ===\n```\nimport sys\nfrom .greet import greet\n\ndef main():\n print(greet(sys.argv[2]))\n```"}'
# event: job / event: delta {"text":...} / event: done {charged_credits, truncated, output}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(body).encode(),
headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json", "Idempotency-Key": "my-tour-002"})
with urllib.request.urlopen(req) as r:
for line in r:
print(line.decode().rstrip()) # event: / data: lines
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: { Authorization: "Bearer " + TOKEN, "Content-Type": "application/json", "Idempotency-Key": "my-tour-002" },
body: JSON.stringify(body),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
console.log(dec.decode(value)); // frames split on a blank line
}
// POST to base+"/run-stream" with the same headers plus Idempotency-Key,
// then read res.Body with bufio.Scanner: lines are "event: ..." and "data: {...}".
// POST to BASE + "/run-stream" and read HttpResponse.BodyHandlers.ofLines():
// "event: delta" lines carry data: {"text": ...}; "event: done" carries the final job.
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json", "Idempotency-Key" => "my-tour-002")
req.body = body.to_json
Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) { |res| res.read_body { |c| print c } } }
// Use CURLOPT_WRITEFUNCTION on a POST to BASE . "/run-stream" to receive SSE frames as they arrive.
var req = new HttpRequestMessage(HttpMethod.Post, "run-stream") {
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json") };
req.Headers.Add("Idempotency-Key", "my-tour-002");
var res = await http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await res.Content.ReadAsStreamAsync());
while (await sr.ReadLineAsync() is string line) Console.WriteLine(line);