DocSmith API

Module in, docs article out — from your own app, pipeline, or editor.

Back to DocSmith

Overview

Everything the DocSmith app does is available over plain HTTPS. There are four moving parts:

  1. Mint an app-user token from your SkillSafe API key.
  2. Optionally fetch the module's code from a URL or an uploaded file — free.
  3. Submit a run and poll the job until it finishes. Runs are billed to your SkillSafe credits.
  4. Parse the reply, which arrives in a fixed plain-text shape.

The base URL for every call is https://api.skillsafe.ai. All requests and responses are JSON unless noted; successful responses arrive wrapped in {"ok": true, "data": ...} and errors in {"ok": false, "error": {code, message, details}}.

If you would rather not write the HTTP calls yourself, download docsmith-client.js — a zero-dependency client for Node 18+ and the browser that wraps all four steps. The DocSmith page itself uses the same file for its URL import, so it is exercised on every visit.

Examples on this page are shown in cURL, JavaScript, Python, Go, and Ruby — pick a language on any code block and the whole page follows. The API is plain HTTPS and JSON, so any language with an HTTP client works the same way.

Quickstart — module in, article out, from a terminal

One prerequisite: a SkillSafe account with a few credits and an API key from your account settings. The whole flow — token, submit, poll, read the article — in your language:

# 1. Exchange your API key for a DocSmith token (expires; re-run when it does)
TOKEN=$(curl -s https://api.skillsafe.ai/v1/app-api/authorize \
  -H "Authorization: Bearer $SKILLSAFE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"slug":"docsmith"}' | jq -r .data.token)

# 2. Preview the worst-case cost - free, optional
curl -s https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile code src/cache.ts '{code: $code}')" | jq .data.hold_credits

# 3. Submit the module
JOB=$(curl -s https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile code src/cache.ts '{code: $code, docType: "Reference"}')" \
  | jq -r .data.job_id)

# 4. Poll until status is "succeeded", then read the article text
curl -s https://api.skillsafe.ai/v1/app-api/jobs/$JOB \
  -H "Authorization: Bearer $TOKEN" | jq -r .data.output.output
// Node 18+ - global fetch, no dependencies. (Or use the client library below.)
import { readFileSync } from "node:fs";

const API = "https://api.skillsafe.ai";
const KEY = readKeyFromYourSecretStore(); // your SkillSafe API key, e.g. an env var

async function api(path, token, body) {
  const res = await fetch(API + path, {
    method: body ? "POST" : "GET",
    headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (!json.ok) throw new Error(json.error.message);
  return json.data;
}

// 1. token   2. submit   3. poll   4. read
const { token } = await api("/v1/app-api/authorize", KEY, { slug: "docsmith" });
const code = readFileSync("src/cache.ts", "utf8");
const { job_id } = await api("/v1/app-api/run", token, { code, docType: "Reference" });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("/v1/app-api/jobs/" + job_id, token);
} while (job.status !== "succeeded" && job.status !== "failed");

console.log(job.output.output);
import os
import time

import requests

API = "https://api.skillsafe.ai"


def api(path, token, body=None):
    fn = requests.post if body is not None else requests.get
    r = fn(API + path, json=body, headers={"Authorization": f"Bearer {token}"})
    data = r.json()
    if not data.get("ok"):
        raise RuntimeError(data["error"]["message"])
    return data["data"]


# 1. token   2. submit   3. poll   4. read
key = os.environ["SKILLSAFE_API_KEY"]
token = api("/v1/app-api/authorize", key, {"slug": "docsmith"})["token"]

code = open("src/cache.ts").read()
job_id = api("/v1/app-api/run", token, {"code": code, "docType": "Reference"})["job_id"]

while True:
    time.sleep(1.5)
    job = api(f"/v1/app-api/jobs/{job_id}", token)
    if job["status"] in ("succeeded", "failed"):
        break

print(job["output"]["output"])
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

const api = "https://api.skillsafe.ai"

func call(method, path, token string, body map[string]any) map[string]any {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, api+path, &buf)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	var out map[string]any
	json.NewDecoder(res.Body).Decode(&out)
	if ok, _ := out["ok"].(bool); !ok {
		panic(fmt.Sprint(out["error"]))
	}
	return out["data"].(map[string]any)
}

func main() {
	key := os.Getenv("SKILLSAFE_API_KEY")
	token := call("POST", "/v1/app-api/authorize", key,
		map[string]any{"slug": "docsmith"})["token"].(string)

	src, _ := os.ReadFile("src/cache.ts")
	jobID := call("POST", "/v1/app-api/run", token,
		map[string]any{"code": string(src), "docType": "Reference"})["job_id"].(string)

	for {
		time.Sleep(1500 * time.Millisecond)
		job := call("GET", "/v1/app-api/jobs/"+jobID, token, nil)
		if s := job["status"]; s == "succeeded" || s == "failed" {
			fmt.Println(job["output"].(map[string]any)["output"])
			return
		}
	}
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai"

def api(path, token, body = nil)
  uri = URI(API + path)
  req = (body ? Net::HTTP::Post : Net::HTTP::Get).new(
    uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{token}")
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  data = JSON.parse(res.body)
  raise data.dig("error", "message").to_s unless data["ok"]
  data["data"]
end

# 1. token   2. submit   3. poll   4. read
token = api("/v1/app-api/authorize", ENV["SKILLSAFE_API_KEY"], { slug: "docsmith" })["token"]

code = File.read("src/cache.ts")
job_id = api("/v1/app-api/run", token, { code: code, docType: "Reference" })["job_id"]

job = nil
loop do
  sleep 1.5
  job = api("/v1/app-api/jobs/#{job_id}", token)
  break if %w[succeeded failed].include?(job["status"])
end

puts job.dig("output", "output")

The text that comes back follows a fixed shape (the output contract); split out the ARTICLE: section or let the client library do the whole flow, parsing included, in one call. Every step is explained in detail below.

Authentication

Create a SkillSafe API key in your SkillSafe account settings, then exchange it for a DocSmith-scoped app-user token:

curl -s https://api.skillsafe.ai/v1/app-api/authorize \
  -H "Authorization: Bearer $SKILLSAFE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"slug": "docsmith"}'
const res = await fetch("https://api.skillsafe.ai/v1/app-api/authorize", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${SKILLSAFE_API_KEY}`,
  },
  body: JSON.stringify({ slug: "docsmith" }),
});
const token = (await res.json()).data.token;
import requests

r = requests.post(
    "https://api.skillsafe.ai/v1/app-api/authorize",
    headers={"Authorization": f"Bearer {SKILLSAFE_API_KEY}"},
    json={"slug": "docsmith"},
)
token = r.json()["data"]["token"]

The response is {"ok":true,"data":{"token":"aut_...","expires_at":"..."}}. Send that token as Authorization: Bearer aut_... on every call below. Tokens expire; when a call returns 401, mint a new one the same way.

Keep the API key server-side. In a browser app, mint tokens on your backend and hand the short-lived aut_ token to the client, or send your users through SkillSafe sign-in so runs bill to their own account.

Get the module code from a URL or a file

POST /v1/app-api/extract fetches a URL server-side and reduces it to plain text. It is free, rate-limited, and requires a signed-in token (a guest token gets 403).

curl -s https://api.skillsafe.ai/v1/app-api/extract \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://raw.githubusercontent.com/owner/repo/main/src/cache.ts"}'
const res = await fetch("https://api.skillsafe.ai/v1/app-api/extract", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({
    url: "https://raw.githubusercontent.com/owner/repo/main/src/cache.ts",
  }),
});
const code = (await res.json()).data.text;
r = requests.post(
    "https://api.skillsafe.ai/v1/app-api/extract",
    headers={"Authorization": f"Bearer {token}"},
    json={"url": "https://raw.githubusercontent.com/owner/repo/main/src/cache.ts"},
)
code = r.json()["data"]["text"]

The response data is {text, name, content_type, bytes, truncated}. Use the raw-file URL, not the repository web page: a github.com/…/blob/… link returns GitHub's page chrome, so rewrite it to raw.githubusercontent.com first. The client library's normalizeCodeUrl() does this for GitHub, gists, GitLab, and Bitbucket.

To extract from a local file instead, send multipart form data with a file field to the same endpoint. Plain source files can skip extraction entirely — read them yourself and put the contents straight into code.

Estimate the cost — free

POST /v1/app-api/estimate takes the same body as a run and returns the worst-case reservation without charging or starting anything: {hold_credits, min_credits, model, model_alias, ...}. hold_credits is the amount reserved; the settled charge is usually far lower. Credits are $1 = 10,000.

curl -s https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile code src/cache.ts '{code: $code}')" | jq .data
const res = await fetch("https://api.skillsafe.ai/v1/app-api/estimate", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ code }),
});
const { hold_credits, model } = (await res.json()).data;
est = requests.post(
    "https://api.skillsafe.ai/v1/app-api/estimate",
    headers={"Authorization": f"Bearer {token}"},
    json={"code": code},
).json()["data"]
print(est["hold_credits"], est["model"])

Generate the article

POST /v1/app-api/run submits the job. The input object:

FieldRequiredMeaning
codeyesThe source module, as written. JS or TS, ESM or CJS. Send at most 60,000 characters. If the module is longer, cut the middle rather than the tail — the default export, the re-exports and any CommonJS module.exports block live at the bottom of a file, and a head-only slice throws exactly those away. Mark the cut in-band with [ ... N characters cut from the middle of the file; the beginning and the end are shown in full ... ]; the prompt is written to recognise that wording, keep to what it can see, and record the cut under gaps in the notes.
contextnoTeammate notes: what the module is for, who reads the docs, a gotcha to call out.
draftnoAn existing draft to revise (at most 20,000 characters).
docTypenoReference (default) or Nugget.
titlenoWorking title for the article.

The response is 202 with {job_id, model, ...}. Poll GET /v1/app-api/jobs/{job_id} every second or two until status is succeeded or failed; the article text is at output.output and the settled price at charged_credits. A truncated: true flag means the reply hit the output cap — treat it as incomplete. Pass an Idempotency-Key header to make retries safe.

curl -s https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile code src/cache.ts \
        '{code: $code, docType: "Reference"}')"

curl -s https://api.skillsafe.ai/v1/app-api/jobs/$JOB_ID \
  -H "Authorization: Bearer $TOKEN"
const submit = await fetch("https://api.skillsafe.ai/v1/app-api/run", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ code, docType: "Reference" }),
});
const { job_id } = (await submit.json()).data;

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  const res = await fetch(`https://api.skillsafe.ai/v1/app-api/jobs/${job_id}`, {
    headers: { "Authorization": `Bearer ${token}` },
  });
  job = (await res.json()).data;
} while (job.status !== "succeeded" && job.status !== "failed");

console.log(job.output.output);   // the article text
console.log(job.charged_credits); // the settled price
import time

job_id = requests.post(
    "https://api.skillsafe.ai/v1/app-api/run",
    headers={"Authorization": f"Bearer {token}"},
    json={"code": code, "docType": "Reference"},
).json()["data"]["job_id"]

while True:
    time.sleep(1.5)
    job = requests.get(
        f"https://api.skillsafe.ai/v1/app-api/jobs/{job_id}",
        headers={"Authorization": f"Bearer {token}"},
    ).json()["data"]
    if job["status"] in ("succeeded", "failed"):
        break

print(job["output"]["output"])    # the article text
print(job["charged_credits"])     # the settled price

Polling tells you whether the run is done. To show your users live progress while the model writes, use the streaming endpoint instead.

Streaming progress

POST /v1/app-api/run-stream takes the same body as run but answers with server-sent events, so you can render the article as the model writes it — the DocSmith page's own progress panel is exactly this. Three event types matter:

EventPayloadMeaning
job{job_id, ...}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it to your progress view; the accumulated length is your progress indicator (there is no percentage — total length is not known in advance).
done{job_id, status, charged_credits, truncated, output}The final, authoritative result — read the full text from output.output rather than trusting the concatenated deltas, and the settled price from charged_credits.

An error event replaces done when the run fails. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

# -N disables buffering so events print as they arrive
curl -N -s https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "$(jq -n --rawfile code src/cache.ts '{code: $code, docType: "Reference"}')"

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"TITLE: Caching with"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":596,"output":{"output":"TITLE: ..."}}
// The client library wraps the SSE plumbing - onDelta is your progress hook.
const result = await ds.generateStream(
  { code, docType: "Reference" },
  {
    onJob: () => console.error("starting..."),
    onDelta: (text) => process.stdout.write(text),   // or append to your UI
  }
);

console.error(`\n${result.charged_credits} credits, truncated: ${result.truncated}`);
console.log(result.doc ? result.doc.article : result.raw);
import json

import requests

result = None
with requests.post(
    "https://api.skillsafe.ai/v1/app-api/run-stream",
    headers={"Authorization": f"Bearer {token}"},
    json={"code": code, "docType": "Reference"},
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(data.get("text", ""), end="", flush=True)  # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

print()
print("charged:", result["charged_credits"])
article_text = result["output"]["output"]  # authoritative full reply
In a browser you can use the native EventSource only for GET endpoints; this one is a POST, so read the fetch response body incrementally as the client library does — its generateStream works unchanged in browsers and Node 18+.

The output contract

The reply is plain text in a fixed shape — not JSON:

TITLE: Caching with TtlCache
TYPE: Reference
SUMMARY: What the cache stores, TTL and eviction rules, and the get gotcha

FRONTMATTER:
title: Caching with TtlCache
description: ...
status: draft
keywords: [...]

ARTICLE:
<the complete article, markdown, headings start at ##>

NOTES:
**Assumptions:** ...
**Gaps to fill:** ...
Doc confidence: NN%, <why>.

The client library's parse(raw) decodes this into {title, docType, summary, frontmatter, article, notes, confidence} and returns null if the shape is missing — in that case, resubmit once with a retry_note field describing what was malformed, which is what the DocSmith page does.

The client library

Vendor docsmith-client.js into your project (or load it with a script tag, which defines window.DocSmithClient). Node 18+:

const DocSmithClient = require("./docsmith-client.js");

// Read the key from your environment or secret store - never hard-code it.
const token = await DocSmithClient.authorize(mySkillSafeApiKey);
const ds = DocSmithClient.client(token);

// From a GitHub URL (blob links are normalized to raw automatically)...
const code = await ds.codeFromUrl(
  "https://github.com/owner/repo/blob/main/src/cache.ts");

// ...or from a local file.
// const code = require("fs").readFileSync("src/cache.ts", "utf8");

const est = await ds.estimate({ code });          // free
console.log("reserves at most", est.hold_credits, "credits");

const result = await ds.generate({
  code,
  context: "Read by SDK users; call out the eviction-order gotcha.",
  docType: "Reference"
});

if (result.doc) {
  console.log(result.doc.title);
  console.log(result.doc.article);       // markdown
  console.log(result.doc.notes);         // assumptions and gaps
  console.log(result.doc.confidence);    // { pct, note }
} else {
  console.log("reply broke the shape:", result.raw);
}

The client exposes authorize(apiKey), guest(), normalizeCodeUrl(url), parse(raw), and per-token clients with estimate, codeFromUrl, codeFromFile, generate (submit, poll, parse in one call), and generateStream (the same, but with an onDelta callback for live progress).

Errors and limits

StatusWhenWhat to do
401Missing or expired token.Mint a new token via authorize.
402Balance below the run minimum (reason: below_minimum).Top up the SkillSafe account the token belongs to.
403Guest token on run or extract.Use a signed-in token from authorize.
413Input or fetched document too large.Trim the module to the surface you want documented.
429Rate limited.Back off per the Retry-After header.

A balance between the minimum and the full hold still runs — with a reduced output cap and truncated: true on the result. Surface that to your users rather than presenting a clipped article as finished.