Repo Tour Guide

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

taskWhat it returnsExtra fields
tourAn 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.
askAn 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

FieldTypeNotes
taskstring, requiredtour or ask.
repostring, requiredowner/name, or a folder name.
filesstring, requiredEach 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.
outlinestringA folder outline of the whole repository, so the model can name files it was not sent.
factsstring (JSON)File count, languages, manifests, entry points, tests, CI - whatever you computed. Treated as authoritative.
metastring (JSON)Description, licence, default branch, topics.
files_notestringWhich files were clipped or left out.
source, refstringgithub or upload; the branch or commit.
retry_notestringOptional. 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

HTTPCodeMeaning
400VALIDATION_ERRORThe body is not a JSON object, or a required field is missing.
401UNAUTHORIZEDMissing or expired token.
402INSUFFICIENT_CREDITSBalance below the run's minimum. Top up at skillsafe.ai.
403FORBIDDENA guest token tried to run. Sign in for a personal token.
429RATE_LIMITEDToo 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);