Repair converted Markdown from your own code
doc-to-md has two lanes, and only one of them is an API. The free conversion is
client-side JavaScript — docx, xlsx, pptx, odt, ods, odp, epub, rtf, html,
csv, tsv, json, txt and text-layer PDFs are parsed inside the browser tab that loaded this
site, your file is never uploaded, and there is no endpoint that converts a document
for you. Nothing on this page will turn a .docx into Markdown; bring
your own parser (or the browser app) for that. What the API exposes is the
metered AI lane: the structure repair run, which takes Markdown a
deterministic parser already produced plus the fact sheet it measured and fixes only what a
parser cannot decide, and the read scanned pages run, which transcribes page images
of a PDF that has no text layer. Both are plain JSON over HTTPS, both return the repaired
document as text between sentinel lines, and both are billed against your credit balance.
Alongside those, the app uses the platform's user drive to read documents
the signed-in user already has stored and to write the converted Markdown back beside them
— free, signed-in only, and documented in step 9. Step 10 covers
POST /extract: paste-a-link input, and the free first fallback that keeps a
scanned PDF off the metered vision path. Step 11 covers multi-turn refinement over
sessions.
Every step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a
language once and the whole page follows.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api, app slug
doc-to-md. Every request sends
Authorization: Bearer <token>, and JSON bodies go with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}} on
failure. Estimates are free; /run and /run-stream are metered.
There are three run tasks — repair and read_scan, each a
single stateless call, and refine, which runs inside a
session so a follow-up costs an instruction rather than a whole document
(step 11).
| Status | Meaning |
|---|---|
401 | Missing or expired token — mint a new one. |
402 | Not enough credits — top up at skillsafe.ai/account/billing. |
403 | The token isn't allowed to do this (e.g. a guest starting a metered run, or an upload over the size cap). |
404 | Unknown job, file or record id. |
413 | The body or upload is too large — clip the Markdown, or send fewer page images. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal
— not from another website's frontend. The API never sees your original document: you
send Markdown (or, for a scan, page images you chose to upload), never the
.docx or .pdf itself.
Step 0 — A tiny client
Every call below is one HTTP request, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export SKILLSAFE_TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $SKILLSAFE_TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, os, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # see step 1
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not res.ok:
raise RuntimeError(payload.get("error", {}).get("message", res.reason))
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 - read it from your shell environment in real code
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
func call(method, path string, body, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+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"`
Error *struct{ Message string `json:"message"` } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson...)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": ...}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($payload["error"]["message"] ?? "HTTP $status");
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
}
Step 1 — Get a token
A guest token lets you check balances and estimate costs for free. For metered repair runs
billed to your own account, use your personal token: open the
token page, sign in with SkillSafe, and press
Copy shell export — it puts export SKILLSAFE_TOKEN="…" on
your clipboard, which every example below reads. Treat the token like a password: it can
spend your credits. For fully headless scripts, POST /guest mints a guest token
with no browser involved. The slug goes in the request body, as
{"slug":"doc-to-md"} — there is no slug header.
curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"doc-to-md"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "doc-to-md"})["token"]
const { token } = await api("POST", "/guest", { slug: "doc-to-md" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "doc-to-md"}, &guest)
String envelope = api("POST", "/guest", """
{"slug":"doc-to-md"}""");
// the token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "doc-to-md" })["token"]
$token = api("POST", "/guest", ["slug" => "doc-to-md"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "doc-to-md" });
var token = guest.GetProperty("token").GetString();
The browser app stores this device's token under the localStorage key
skillsafe_app_token:doc-to-md, on the app's own origin. The
token page reads and manages it for you — you never need to
open developer tools.
Step 2 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Worth calling before you push
a long document through a repair run.
curl -s "$API/me" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
Step 3 — The run input, task repair
One JSON object, sent as the whole body of /estimate, /run and
/run-stream — there is no wrapper key. It carries the Markdown a parser
already produced and the facts that parser measured about it. The facts are not decoration:
they are what the repair is checked against afterwards, and they are how you tell the model
which structural problems to solve.
| Input field | Type | Notes |
|---|---|---|
task | string, required | "repair" or "read_scan". repair works from markdown; read_scan works from uploaded page images (step 8). |
filename | string | The source document's name, e.g. report.pdf. Used for context only — the file itself is never sent. |
format | string | The detected source format: pdf, docx, xlsx, pptx, odt, ods, odp, epub, rtf, html, csv, tsv, json, txt. It changes what repairs are plausible — a pdf may have lost its tables entirely, a docx practically never has. |
markdown | string, required for repair | The converted GitHub-Flavored Markdown to repair. Keep the parser's <!-- … --> marker comments (page breaks, inference notes) in place. If you clipped the text for size, mark the cut with a <!-- clipped: … --> comment saying what was removed and where; the model is required to leave that marker standing rather than inventing the missing part. |
facts | object, required | What the parser measured, and the gaps it could not solve. Fields below. |
facts.headings | number | Count of ATX headings in markdown. Reconciled after the run: the repair may add headings (that is often the point) but must never lose one. |
facts.tables | number | Count of GFM tables. 0 together with a pdf-tables gap is the classic "the table came out as aligned text" case. |
facts.table_rows | number | Total body rows across those tables. |
facts.list_items | number | Count of list items, at any nesting depth. |
facts.links | number | Count of Markdown links. Must not drop. |
facts.images | number | Count of image references. Must not drop — an image reference removed by a "repair" is a silent data loss. |
facts.code_blocks | number | Count of fenced or indented code blocks. |
facts.footnotes | number | Count of footnote definitions. |
facts.words | number | Word count of the body. A repair that moves this by more than about a fifth has rewritten prose, not structure — check it. |
facts.numbers_sample | string[] | Distinct numeric tokens taken from the document body, as they literally appear ("1,240", "62%", "2026-08-31"). The web UI sends up to 24. Every one of these must still appear verbatim in the repaired document; that check is the single most useful assertion you can run. |
facts.gaps | array | {id, label} entries naming the structural problems the parser could not decide. This is the work order — the model repairs these and changes nothing else. Ids are listed below. |
facts.warnings | string[] | Parser warnings worth passing along ("3 pages had no text layer", "table clipped at 400 rows"). Send an empty array if you have none. |
pages | number[], read_scan only | The 1-based page numbers, in order, matching the uploaded images. See step 8. |
$files | string[], read_scan only | File ids from POST /files, in the same order as pages. See step 8. |
instructions | string, optional | A short note from you, up to about 300 characters — "the two right-hand columns are one table", "the header row repeats on every page". Use it for facts about the document; it does not loosen the rules the repair obeys. |
The facts.gaps ids
Send only the gaps that actually apply. Each id maps to one specific, bounded repair; an id you do not send is a repair the model is told not to attempt.
| id | What it asks for |
|---|---|
no-headings | The source never declared headings at all. Promote the lines that visibly are headings to #–######, consistent with the document's own numbering. |
bold-as-heading | Bold, ALL-CAPS or numbered caption lines ("3. Discussion") stand in for headings. Convert the ones that really are section titles. |
pdf-tables | Tab- or space-aligned regions are tables the PDF never declared. Rebuild each as a valid GFM table. |
multi-row-header | A table's header spans two or more rows. Collapse it into one sensible header row. |
headerless-table | A table has no header row. Use the first row if it reads like one, otherwise leave it headerless rather than inventing labels. |
merged-cells | Merged cells were expanded into repeated values. Keep the repeats only where they carry meaning. |
multi-column | Reading order is visibly wrong (sentences interleave mid-thought). Reorder paragraphs into the correct flow — order only, never wording. |
ocr-noise | Character-level OCR damage (l/1, O/0, rn/m). Fix only where the correction is certain from context. |
image-only | Pages are images with no text layer. Paired with task: "read_scan" and uploaded page images (step 8). |
clipped | The Markdown you sent is not the whole document. The <!-- clipped: … --> marker must survive the repair. |
dropped-images | Embedded images could not be extracted, so references point at nothing. Keep the references and their captions; do not quietly delete them. |
A complete repair body
A PDF whose retention table came out as aligned text, clipped in the middle for size. This is the payload the next steps send.
{
"task": "repair",
"filename": "report.pdf",
"format": "pdf",
"markdown": "# Retention Review\n\nPrepared for the Q3 board packet; the review window
closes 2026-08-31.\n\nCohort Users Day 90 Day 365\nJan 2026
1,240 62% 41%\nFeb 2026 1,190 58% 39%\n\nRETENTION
WINDOWS\n\n- 180 day window\n- 90 day window\n- 365 day window\n\n
<!-- clipped: 4,812 characters were removed from the MIDDLE of the
document for size. Repair the visible parts only; leave this marker in
place. -->\n\n retention = active_users / cohort_size\n",
"facts": {
"headings": 5, "tables": 0, "table_rows": 0, "list_items": 3,
"links": 0, "images": 0, "code_blocks": 1, "footnotes": 0, "words": 270,
"numbers_sample": ["180", "90", "365", "2026-08-31"],
"gaps": [{"id": "pdf-tables", "label": "1 tabular region left as text"}],
"warnings": []
},
"instructions": "The Cohort/Users block is one table; RETENTION WINDOWS is a heading."
}
facts.gaps is how you keep the run honest. It names the work, and
facts.numbers_sample plus the counts are what you re-check against the output
(step 6). Sending accurate facts for Markdown you did not measure is worse than sending
modest ones — if you do not know a count, count it, or leave the gap out.
Step 4 — Estimate the cost
Send exactly the body you would send to /run; the response's
hold_credits is the worst case that will be reserved. Nothing is charged and no
job is created, so estimating is free — which makes it the right way to find out
whether a 200-page conversion is worth repairing before you spend anything. The settled
cost after a run is usually far lower than the hold.
Choosing a model per run: $model
A top-level "$model" string on the body overrides the app's configured model
for that one call. It is valid on /estimate, /run,
/run-stream and session turns (step 11), and it is stripped before the model
ever sees the input, exactly like $files. The hold is priced on the override,
settlement bills the override, and the choice is snapshotted at submit, so
changing it later never affects an in-flight job. An idempotent replay returns the ORIGINAL
job even if the retry passes a different $model.
Pass a tier alias, not a concrete id. An alias repoints as new models ship
and can only ever move to a model at or below the outgoing one's token rates, so your code
tracks the tier without a change; a pinned id freezes you where you were. Read the live map
from GET /v1/models — it needs no token at all and
returns aliases plus every model's billed_usd_per_mtok rates and
caps. An alias that no longer exists is a 400 validation_error
("Unsupported model 'gpt-neptune'"), not a silent fallback, so re-validate any
stored preference against that map rather than trusting it.
This app defaults to gpt-terra and offers the rest of the live map. Measured
against /estimate on the same short repair body, the holds differ by more than
two orders of magnitude — gemma-fast 29 credits,
claude-haiku 275, gpt-luna 324, gpt-terra 1,530,
claude-opus 2,555, gpt-sol 3,047 — so re-estimate whenever
the choice changes, or the number on screen is a number for a different model. One
capability caveat: the Workers AI tier is text-only, so it cannot serve the
read_scan page-image run in step 8; picking it there would take a hold and then
fail.
# build the body once; every later step reuses input.json
cat > markdown.md <<'MD'
# Retention Review
Prepared for the Q3 board packet; the review window closes 2026-08-31.
Cohort Users Day 90 Day 365
Jan 2026 1,240 62% 41%
Feb 2026 1,190 58% 39%
RETENTION WINDOWS
- 180 day window
- 90 day window
- 365 day window
retention = active_users / cohort_size
MD
jq -n --rawfile md markdown.md \
'{task: "repair",
filename: "report.pdf",
format: "pdf",
markdown: $md,
facts: {headings: 5, tables: 0, table_rows: 0, list_items: 3,
links: 0, images: 0, code_blocks: 1, footnotes: 0, words: 270,
numbers_sample: ["180", "90", "365", "2026-08-31"],
gaps: [{id: "pdf-tables", label: "1 tabular region left as text"}],
warnings: []},
instructions: "The Cohort/Users block is one table."}' > input.json
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data.hold_credits'
MARKDOWN = """# Retention Review
Prepared for the Q3 board packet; the review window closes 2026-08-31.
Cohort Users Day 90 Day 365
Jan 2026 1,240 62% 41%
Feb 2026 1,190 58% 39%
RETENTION WINDOWS
- 180 day window
- 90 day window
- 365 day window
retention = active_users / cohort_size
"""
payload = {
"task": "repair",
"filename": "report.pdf",
"format": "pdf",
"markdown": MARKDOWN,
"facts": {
"headings": 5, "tables": 0, "table_rows": 0, "list_items": 3,
"links": 0, "images": 0, "code_blocks": 1, "footnotes": 0, "words": 270,
"numbers_sample": ["180", "90", "365", "2026-08-31"],
"gaps": [{"id": "pdf-tables", "label": "1 tabular region left as text"}],
"warnings": [],
},
"instructions": "The Cohort/Users block is one table.",
}
est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const markdown = [
"# Retention Review",
"",
"Prepared for the Q3 board packet; the review window closes 2026-08-31.",
"",
"Cohort Users Day 90 Day 365",
"Jan 2026 1,240 62% 41%",
"Feb 2026 1,190 58% 39%",
"",
"RETENTION WINDOWS",
"",
"- 180 day window",
"- 90 day window",
"- 365 day window",
"",
" retention = active_users / cohort_size",
"",
].join("\n");
const payload = {
task: "repair",
filename: "report.pdf",
format: "pdf",
markdown,
facts: {
headings: 5, tables: 0, table_rows: 0, list_items: 3,
links: 0, images: 0, code_blocks: 1, footnotes: 0, words: 270,
numbers_sample: ["180", "90", "365", "2026-08-31"],
gaps: [{ id: "pdf-tables", label: "1 tabular region left as text" }],
warnings: [],
},
instructions: "The Cohort/Users block is one table.",
};
const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const markdown = "# Retention Review\n\n" +
"Prepared for the Q3 board packet; the review window closes 2026-08-31.\n\n" +
"Cohort Users Day 90 Day 365\n" +
"Jan 2026 1,240 62% 41%\n" +
"Feb 2026 1,190 58% 39%\n\n" +
"RETENTION WINDOWS\n\n" +
"- 180 day window\n- 90 day window\n- 365 day window\n\n" +
" retention = active_users / cohort_size\n"
payload := map[string]any{
"task": "repair",
"filename": "report.pdf",
"format": "pdf",
"markdown": markdown,
"facts": map[string]any{
"headings": 5, "tables": 0, "table_rows": 0, "list_items": 3,
"links": 0, "images": 0, "code_blocks": 1, "footnotes": 0, "words": 270,
"numbers_sample": []string{"180", "90", "365", "2026-08-31"},
"gaps": []map[string]string{
{"id": "pdf-tables", "label": "1 tabular region left as text"},
},
"warnings": []string{},
},
"instructions": "The Cohort/Users block is one table.",
}
var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String markdown = """
# Retention Review
Prepared for the Q3 board packet; the review window closes 2026-08-31.
Cohort Users Day 90 Day 365
Jan 2026 1,240 62% 41%
Feb 2026 1,190 58% 39%
RETENTION WINDOWS
- 180 day window
- 90 day window
- 365 day window
retention = active_users / cohort_size
""";
// toJsonString() is your JSON library's string escaper (Jackson, Gson...)
String jsonPayload = """
{"task": "repair",
"filename": "report.pdf",
"format": "pdf",
"markdown": %s,
"facts": {"headings": 5, "tables": 0, "table_rows": 0, "list_items": 3,
"links": 0, "images": 0, "code_blocks": 1, "footnotes": 0, "words": 270,
"numbers_sample": ["180", "90", "365", "2026-08-31"],
"gaps": [{"id": "pdf-tables", "label": "1 tabular region left as text"}],
"warnings": []},
"instructions": "The Cohort/Users block is one table."}
""".formatted(toJsonString(markdown));
String envelope = api("POST", "/estimate", jsonPayload);
// the worst-case cost is at data.hold_credits
MARKDOWN = <<~MD
# Retention Review
Prepared for the Q3 board packet; the review window closes 2026-08-31.
Cohort Users Day 90 Day 365
Jan 2026 1,240 62% 41%
Feb 2026 1,190 58% 39%
RETENTION WINDOWS
- 180 day window
- 90 day window
- 365 day window
retention = active_users / cohort_size
MD
payload = { task: "repair",
filename: "report.pdf",
format: "pdf",
markdown: MARKDOWN,
facts: { headings: 5, tables: 0, table_rows: 0, list_items: 3,
links: 0, images: 0, code_blocks: 1, footnotes: 0, words: 270,
numbers_sample: %w[180 90 365 2026-08-31],
gaps: [{ id: "pdf-tables", label: "1 tabular region left as text" }],
warnings: [] },
instructions: "The Cohort/Users block is one table." }
est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$markdown = <<<'MD'
# Retention Review
Prepared for the Q3 board packet; the review window closes 2026-08-31.
Cohort Users Day 90 Day 365
Jan 2026 1,240 62% 41%
Feb 2026 1,190 58% 39%
RETENTION WINDOWS
- 180 day window
- 90 day window
- 365 day window
retention = active_users / cohort_size
MD;
$payload = [
"task" => "repair",
"filename" => "report.pdf",
"format" => "pdf",
"markdown" => $markdown,
"facts" => [
"headings" => 5, "tables" => 0, "table_rows" => 0, "list_items" => 3,
"links" => 0, "images" => 0, "code_blocks" => 1, "footnotes" => 0,
"words" => 270,
"numbers_sample" => ["180", "90", "365", "2026-08-31"],
"gaps" => [["id" => "pdf-tables", "label" => "1 tabular region left as text"]],
"warnings" => [],
],
"instructions" => "The Cohort/Users block is one table.",
];
$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var markdown = """
# Retention Review
Prepared for the Q3 board packet; the review window closes 2026-08-31.
Cohort Users Day 90 Day 365
Jan 2026 1,240 62% 41%
Feb 2026 1,190 58% 39%
RETENTION WINDOWS
- 180 day window
- 90 day window
- 365 day window
retention = active_users / cohort_size
""";
var payload = new {
task = "repair",
filename = "report.pdf",
format = "pdf",
markdown,
facts = new {
headings = 5, tables = 0, table_rows = 0, list_items = 3,
links = 0, images = 0, code_blocks = 1, footnotes = 0, words = 270,
numbers_sample = new[] { "180", "90", "365", "2026-08-31" },
gaps = new[] { new { id = "pdf-tables", label = "1 tabular region left as text" } },
warnings = Array.Empty<string>(),
},
instructions = "The Cohort/Users block is one table.",
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");
The hold scales with the size of markdown, because the model has to write the
whole document back out. If a document is very large, clip it — with a
<!-- clipped: … --> marker and the clipped gap id
— or repair it in sections and stitch the results yourself. The browser app clips the
middle of anything past its own per-run character budget and says so on screen.
Step 5 — Run the repair and wait for it
/run takes the same body as /estimate, places a credit hold and
returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until
status is succeeded or failed; a repair takes roughly
as long as it takes to retype the document, so 30–120 s is normal and a big
document is slower. Always send an Idempotency-Key header so a network retry
cannot start a second, double-charged run. The reply is in output —
usually nested as output.output, and it is plain text, not
JSON: the sentinel-delimited document described in step 6.
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: repair-report-pdf-1" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $SKILLSAFE_TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
[ "$STATUS" = "succeeded" ] || { echo "$JOB" | jq -r '.data.error'; exit 1; }
# the model's reply is plain text, one level down
echo "$JOB" | jq -r '.data.output.output' > reply.txt
echo "charged: $(echo "$JOB" | jq -r '.data.charged_credits') credits"
import time
job_id = api("POST", "/run", payload,
**{"Idempotency-Key": "repair-report-pdf-1"})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
raw = job["output"]
if isinstance(raw, dict):
raw = raw.get("output", raw)
print("charged:", job.get("charged_credits"), "credits")
# `raw` is the sentinel-delimited text parsed in step 6
const { job_id } = await api("POST", "/run", payload,
{ "Idempotency-Key": "repair-report-pdf-1" });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const raw = job.output?.output ?? job.output; // plain text, not JSON
console.log("charged:", job.charged_credits, "credits");
// `raw` is the sentinel-delimited text parsed in step 6
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
Error string `json:"error"`
ChargedCredits int64 `json:"charged_credits"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
log.Fatal(job.Error)
}
raw := job.Output.Output // plain text; parse the sentinels as in step 6
fmt.Println("charged:", job.ChargedCredits, "credits")
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;
String job;
String status;
while (true) {
job = api("GET", "/jobs/" + jobId, null);
status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
if (status.equals("failed")) throw new RuntimeException(job);
// data.output.output is PLAIN TEXT, not JSON: the <<<DOC / <<<CHANGES blocks
// from step 6. Pull it out with your JSON library, then split it on the sentinels.
// data.charged_credits is the settled price.
started = api("POST", "/run", payload)
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
puts "charged: #{job["charged_credits"]} credits"
# `raw` is the sentinel-delimited text parsed in step 6
$started = api("POST", "/run", $payload);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
echo "charged: {$job['charged_credits']} credits\n";
// $raw is the sentinel-delimited text parsed in step 6
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(1500);
}
var raw = job.GetProperty("output").GetProperty("output").GetString(); // plain text
Console.WriteLine($"charged: {job.GetProperty("charged_credits")} credits");
// `raw` is the sentinel-delimited text parsed in step 6
Idempotency keys are cheap insurance here, because a repair run is one of the more expensive calls on the platform: derive the key from a hash of the body, and bump a counter only when you deliberately want a second attempt. That is exactly what the browser app's Retry button does.
Step 6 — The output contract, and reconciling it
The model does not answer with JSON. It answers with the repaired document and a change log, each between sentinel lines, and nothing before or after. Verbatim, this is the whole contract:
<<<DOC
(the full repaired Markdown document)
DOC>>>
<<<CHANGES
- one line per repair made
CHANGES>>>
Each sentinel stands alone on its own line, and the output is not wrapped in a code fence.
Parse it exactly like that: take the text between <<<DOC and
DOC>>> as the document, and the lines between
<<<CHANGES and CHANGES>>> as the change log (one
- bullet per repair, or a single "No repairs needed" line). Two practical
notes: if a stream or a credit ceiling cuts the reply short you may have
<<<DOC with no closing sentinel — keep what arrived and mark it
partial, which is what the app does — and if <<<DOC is missing
entirely, treat the run as a contract failure rather than trying to salvage prose.
| Block | Contents |
|---|---|
<<<DOC … DOC>>> | The complete repaired Markdown document — not a diff and not an excerpt. Prose is unchanged; numbers are unchanged; the parser's marker comments, including <!-- clipped: … -->, are still there. This is the artifact you keep. |
<<<CHANGES … CHANGES>>> | One concrete, checkable line per repair — "Rebuilt the 3-row cohort/retention region on page 1 as a GFM table". "No repairs needed" when the document was already right. Show this to whoever has to trust the output. |
Reconcile before you trust it
The contract forbids losing content, but nothing enforces that for you — so do what the
web UI does after every run: recount the structures on the repaired document and compare
against the facts you sent, then check that every value in
facts.numbers_sample still appears verbatim. Headings, tables and list items may
legitimately go up (that is the repair working); they must never go down. Code
blocks, links and images must not move at all. A word count that shifted by more than about
a fifth means prose was rewritten. Numbers that vanished are the loudest possible signal to
discard the output.
# split the reply on the sentinels
sed -n '/^<<<DOC$/,/^DOC>>>$/p' reply.txt | sed '1d;$d' > repaired.md
sed -n '/^<<<CHANGES$/,/^CHANGES>>>$/p' reply.txt | sed '1d;$d' > changes.txt
test -s repaired.md || { echo "no DOC block - contract failure"; exit 1; }
cat changes.txt
# recount and compare against the facts that were sent
before_h=5; before_l=3; before_c=1
after_h=$(grep -c '^#\{1,6\} ' repaired.md)
after_t=$(grep -c '^|' repaired.md)
after_l=$(grep -cE '^[[:space:]]*([-*+]|[0-9]+\.) ' repaired.md)
echo "headings: $before_h -> $after_h table lines: $after_t list items: $before_l -> $after_l"
[ "$after_h" -ge "$before_h" ] || echo "HEADINGS DROPPED"
[ "$after_l" -ge "$before_l" ] || echo "LIST ITEMS DROPPED"
# every sampled number must still be there, verbatim
for n in 180 90 365 2026-08-31; do
grep -qF "$n" repaired.md || echo "MISSING NUMBER: $n"
done
import re
def parse_reply(text):
doc = re.search(r"<<<DOC\s*\n([\s\S]*?)\nDOC>>>", text)
partial = False
if doc:
body = doc.group(1)
else: # cut short? keep what arrived
start = text.find("<<<DOC")
if start == -1:
raise RuntimeError("no DOC block - the run broke the output contract")
body = re.sub(r"\n?<<<CHANGES[\s\S]*$", "", text[start + 6:]).strip() + "\n"
partial = True
ch = re.search(r"<<<CHANGES\s*\n([\s\S]*?)\nCHANGES>>>", text)
changes = [l.lstrip("- ").strip()
for l in (ch.group(1).splitlines() if ch else []) if l.strip()]
return body, changes, partial
repaired, changes, partial = parse_reply(raw)
for c in changes:
print("change:", c)
facts = payload["facts"]
after = {
"headings": len(re.findall(r"(?m)^#{1,6} ", repaired)),
"tables": len(re.findall(r"(?m)^\|[-: |]+\|$", repaired)),
"list_items": len(re.findall(r"(?m)^\s*([-*+]|\d+\.) ", repaired)),
"code_blocks": repaired.count("```") // 2,
"links": len(re.findall(r"\[[^\]]*\]\([^)]*\)", repaired)),
"words": len(repaired.split()),
}
for key in ("headings", "list_items"):
assert after[key] >= facts[key], f"{key} dropped: {facts[key]} -> {after[key]}"
missing = [n for n in facts["numbers_sample"] if n not in repaired]
if missing:
raise SystemExit(f"numbers missing after repair: {missing} - do not trust this output")
if facts["words"] and abs(after["words"] - facts["words"]) / facts["words"] > 0.2:
print("warning: word count moved more than a structure-only repair should")
with open("repaired.md", "w", encoding="utf-8") as fh:
fh.write(repaired)
import { writeFileSync } from "node:fs";
function parseReply(text) {
const doc = /<<<DOC\s*\n([\s\S]*?)\nDOC>>>/.exec(text);
let body, partial = false;
if (doc) {
body = doc[1];
} else {
const start = text.indexOf("<<<DOC");
if (start === -1) throw new Error("no DOC block - the run broke the output contract");
body = text.slice(start + 6).replace(/\n?<<<CHANGES[\s\S]*$/, "").trim() + "\n";
partial = true; // stream ended early
}
const ch = /<<<CHANGES\s*\n([\s\S]*?)\nCHANGES>>>/.exec(text);
const changes = (ch ? ch[1].split("\n") : [])
.map((l) => l.replace(/^-\s*/, "").trim())
.filter(Boolean);
return { body, changes, partial };
}
const { body: repaired, changes } = parseReply(raw);
for (const c of changes) console.log("change:", c);
const count = (re) => (repaired.match(re) ?? []).length;
const after = {
headings: count(/^#{1,6} /gm),
tables: count(/^\|[-: |]+\|$/gm),
list_items: count(/^\s*([-*+]|\d+\.) /gm),
words: repaired.split(/\s+/).filter(Boolean).length,
};
const facts = payload.facts;
if (after.headings < facts.headings) throw new Error("headings dropped");
if (after.list_items < facts.list_items) throw new Error("list items dropped");
const missing = facts.numbers_sample.filter((n) => !repaired.includes(n));
if (missing.length) throw new Error(`numbers missing after repair: ${missing.join(", ")}`);
writeFileSync("repaired.md", repaired);
docRe := regexp.MustCompile(`(?s)<<<DOC\s*\n(.*?)\nDOC>>>`)
chRe := regexp.MustCompile(`(?s)<<<CHANGES\s*\n(.*?)\nCHANGES>>>`)
m := docRe.FindStringSubmatch(raw)
if m == nil {
if i := strings.Index(raw, "<<<DOC"); i == -1 {
log.Fatal("no DOC block - the run broke the output contract")
} else { // cut short: keep what arrived
m = []string{"", strings.TrimSpace(strings.Split(raw[i+6:], "<<<CHANGES")[0])}
}
}
repaired := m[1]
if c := chRe.FindStringSubmatch(raw); c != nil {
for _, line := range strings.Split(c[1], "\n") {
if line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "-")); line != "" {
fmt.Println("change:", line)
}
}
}
headings := len(regexp.MustCompile(`(?m)^#{1,6} `).FindAllString(repaired, -1))
items := len(regexp.MustCompile(`(?m)^\s*([-*+]|\d+\.) `).FindAllString(repaired, -1))
if headings < 5 || items < 3 { // the counts sent in facts
log.Fatal("structure dropped during repair")
}
for _, n := range []string{"180", "90", "365", "2026-08-31"} {
if !strings.Contains(repaired, n) {
log.Fatalf("missing number after repair: %s", n)
}
}
os.WriteFile("repaired.md", []byte(repaired), 0o644)
// `raw` is data.output.output, plain text.
var doc = java.util.regex.Pattern
.compile("<<<DOC\\s*\\n([\\s\\S]*?)\\nDOC>>>").matcher(raw);
String repaired;
if (doc.find()) {
repaired = doc.group(1);
} else {
int i = raw.indexOf("<<<DOC");
if (i < 0) throw new RuntimeException("no DOC block - output contract broken");
repaired = raw.substring(i + 6).split("<<<CHANGES")[0].strip() + "\n"; // partial
}
var ch = java.util.regex.Pattern
.compile("<<<CHANGES\\s*\\n([\\s\\S]*?)\\nCHANGES>>>").matcher(raw);
if (ch.find()) {
for (String line : ch.group(1).split("\n")) {
if (!line.isBlank()) System.out.println("change: " + line.replaceFirst("^-\\s*", ""));
}
}
long headings = repaired.lines().filter(l -> l.matches("#{1,6} .*")).count();
long items = repaired.lines().filter(l -> l.matches("\\s*([-*+]|\\d+\\.) .*")).count();
if (headings < 5 || items < 3) throw new RuntimeException("structure dropped during repair");
for (String n : new String[] {"180", "90", "365", "2026-08-31"}) {
if (!repaired.contains(n)) throw new RuntimeException("missing number: " + n);
}
java.nio.file.Files.writeString(java.nio.file.Path.of("repaired.md"), repaired);
def parse_reply(text)
if (m = text.match(/<<<DOC\s*\n([\s\S]*?)\nDOC>>>/))
body = m[1]
partial = false
else
i = text.index("<<<DOC") or raise "no DOC block - output contract broken"
body = text[(i + 6)..].sub(/\n?<<<CHANGES[\s\S]*\z/, "").strip + "\n"
partial = true
end
changes = (text[/<<<CHANGES\s*\n([\s\S]*?)\nCHANGES>>>/, 1] || "")
.lines.map { |l| l.sub(/\A-\s*/, "").strip }.reject(&:empty?)
[body, changes, partial]
end
repaired, changes, = parse_reply(raw)
changes.each { |c| puts "change: #{c}" }
facts = payload[:facts]
after_headings = repaired.scan(/^\#{1,6} /).size
after_items = repaired.scan(/^\s*([-*+]|\d+\.) /).size
raise "headings dropped" if after_headings < facts[:headings]
raise "list items dropped" if after_items < facts[:list_items]
missing = facts[:numbers_sample].reject { |n| repaired.include?(n) }
raise "numbers missing after repair: #{missing.join(", ")}" unless missing.empty?
File.write("repaired.md", repaired)
function parse_reply(string $text): array {
if (preg_match('/<<<DOC\s*\n([\s\S]*?)\nDOC>>>/', $text, $m)) {
$body = $m[1];
$partial = false;
} else {
$i = strpos($text, "<<<DOC");
if ($i === false) {
throw new Exception("no DOC block - output contract broken");
}
$body = trim(preg_replace('/\n?<<<CHANGES[\s\S]*$/', "",
substr($text, $i + 6))) . "\n";
$partial = true;
}
$changes = [];
if (preg_match('/<<<CHANGES\s*\n([\s\S]*?)\nCHANGES>>>/', $text, $c)) {
foreach (explode("\n", $c[1]) as $line) {
$line = trim(preg_replace('/^-\s*/', "", trim($line)));
if ($line !== "") { $changes[] = $line; }
}
}
return [$body, $changes, $partial];
}
[$repaired, $changes] = parse_reply($raw);
foreach ($changes as $c) { echo "change: $c\n"; }
$facts = $payload["facts"];
$afterHeadings = preg_match_all('/^#{1,6} /m', $repaired);
$afterItems = preg_match_all('/^\s*([-*+]|\d+\.) /m', $repaired);
if ($afterHeadings < $facts["headings"]) { throw new Exception("headings dropped"); }
if ($afterItems < $facts["list_items"]) { throw new Exception("list items dropped"); }
foreach ($facts["numbers_sample"] as $n) {
if (!str_contains($repaired, $n)) { throw new Exception("missing number: $n"); }
}
file_put_contents("repaired.md", $repaired);
using System.Text.RegularExpressions;
string repaired;
var doc = Regex.Match(raw!, @"<<<DOC\s*\n([\s\S]*?)\nDOC>>>");
if (doc.Success)
{
repaired = doc.Groups[1].Value;
}
else
{
var i = raw!.IndexOf("<<<DOC", StringComparison.Ordinal);
if (i < 0) throw new Exception("no DOC block - output contract broken");
repaired = raw[(i + 6)..].Split("<<<CHANGES")[0].Trim() + "\n"; // partial
}
var ch = Regex.Match(raw, @"<<<CHANGES\s*\n([\s\S]*?)\nCHANGES>>>");
if (ch.Success)
{
foreach (var line in ch.Groups[1].Value.Split('\n'))
if (line.Trim().Length > 0)
Console.WriteLine("change: " + Regex.Replace(line.Trim(), @"^-\s*", ""));
}
var headings = Regex.Matches(repaired, "^#{1,6} ", RegexOptions.Multiline).Count;
var items = Regex.Matches(repaired, @"^\s*([-*+]|\d+\.) ", RegexOptions.Multiline).Count;
if (headings < 5 || items < 3) throw new Exception("structure dropped during repair");
foreach (var n in new[] { "180", "90", "365", "2026-08-31" })
if (!repaired.Contains(n)) throw new Exception($"missing number: {n}");
await File.WriteAllTextAsync("repaired.md", repaired);
A reply for the payload in step 3 looks like this:
<<<DOC
# Retention Review
Prepared for the Q3 board packet; the review window closes 2026-08-31.
| Cohort | Users | Day 90 | Day 365 |
| -------- | ----- | ------ | ------- |
| Jan 2026 | 1,240 | 62% | 41% |
| Feb 2026 | 1,190 | 58% | 39% |
## Retention windows
- 180 day window
- 90 day window
- 365 day window
<!-- clipped: 4,812 characters were removed from the MIDDLE of the document for size.
Repair the visible parts only; leave this marker in place. -->
retention = active_users / cohort_size
DOC>>>
<<<CHANGES
- Rebuilt the 2-row cohort region on page 1 as a GFM table with a Cohort/Users/Day 90/Day 365 header
- Promoted the ALL-CAPS line "RETENTION WINDOWS" to a level-2 heading
- Left the clipped marker, the code block and every numeric value untouched
CHANGES>>>
This is an AI repair of text a parser already produced, not a second opinion on your original
document — the model never sees the .pdf or .docx. It cannot
recover a column the parser dropped, and it cannot know what was behind a
clipped marker. Read the CHANGES block, run the reconciliation
above, and keep the deterministic conversion around: it is the thing that is guaranteed not
to have invented anything.
Step 7 — Stream the document as it is written
/run-stream takes exactly the same body as /run but answers with
server-sent events — which matters here, because the model retypes the entire document
and a long repair otherwise looks like a stalled spinner. The browser app's progress panel is
this endpoint: it watches for DOC>>> in the accumulated text to know
the document is finished and the change log has started. Events are separated by a blank
line; each event's name arrives on the event: line and its JSON payload on the
data: line.
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted — show "starting". |
delta | {text} | A chunk of the reply, in order. Append it. The accumulated text is your only progress signal (the total length is not known in advance); seeing DOC>>> arrive is the useful milestone. |
done | {job_id, status, charged_credits, output} | The final, authoritative reply — parse the document from output.output rather than trusting concatenated deltas, and read the settled price from charged_credits. |
error | {code, message} | Replaces done when the run fails. |
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: repair-report-pdf-1" \
-d @input.json
# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"<<<DOC\n# Retention Review\n"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":312,
# "output":{"output":"<<<DOC\n...\nCHANGES>>>"}}
import json, requests
result, seen_doc_end = None, False
with requests.post(
API + "/run-stream",
headers={"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": "repair-report-pdf-1"},
json=payload,
stream=True,
) as r:
r.raise_for_status()
event, acc = None, ""
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
data = json.loads(line[len("data:"):].strip())
if event == "delta":
acc += data["text"]
if not seen_doc_end and "DOC>>>" in acc:
seen_doc_end = True
print("\ndocument finished, writing the change log...")
else:
print(".", end="", flush=True)
elif event == "done":
result = data
elif event == "error":
raise RuntimeError(data.get("message", "run failed"))
raw = result["output"]["output"] # authoritative
print("\ncharged:", result["charged_credits"], "credits")
repaired, changes, partial = parse_reply(raw) # step 6
print(len(repaired.split()), "words,", len(changes), "repairs")
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": "repair-report-pdf-1",
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", acc = "", done = null, sawDocEnd = false;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop();
for (const frame of frames) {
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const bodyLine = /^data:\s*(.+)$/m.exec(frame)?.[1];
if (!name || !bodyLine) continue;
const data = JSON.parse(bodyLine);
if (name === "delta") {
acc += data.text;
if (!sawDocEnd && acc.includes("DOC>>>")) {
sawDocEnd = true;
console.log("\ndocument finished, writing the change log...");
}
}
if (name === "done") done = data;
if (name === "error") throw new Error(data.message ?? "run failed");
}
}
const raw = done.output.output; // authoritative
console.log(`${done.charged_credits} credits`);
const { body: repaired, changes } = parseReply(raw); // step 6
console.log(`${changes.length} repairs, ${repaired.length} characters`);
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "repair-report-pdf-1")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var event, acc string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
var data map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
switch event {
case "delta":
acc += data["text"].(string)
fmt.Print(".")
case "done":
final = data
case "error":
log.Fatal(data["message"])
}
}
}
// final["output"].(map[string]any)["output"].(string) is the sentinel-delimited
// text - run it through the step 6 parser and the same reconciliation checks.
raw := final["output"].(map[string]any)["output"].(string)
fmt.Println("\ncharged:", final["charged_credits"], "credits", len(raw), "bytes")
// Java 17+ - read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "repair-report-pdf-1")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
var acc = new StringBuilder();
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if ("delta".equals(event)) {
acc.append(data); // {"text":"..."} - unescape with your JSON library
System.out.print(".");
} else if ("done".equals(event)) {
done = data;
} else if ("error".equals(event)) {
throw new RuntimeException(data);
}
}
}
// parse `done`, take data.output.output as PLAIN TEXT, then split it on the
// <<<DOC / DOC>>> and <<<CHANGES / CHANGES>>> sentinels exactly as in step 6,
// and re-run the count and numbers_sample checks before saving repaired.md.
require "net/http"
require "json"
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "repair-report-pdf-1"
req.body = payload.to_json
event = nil
done = nil
acc = ""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
data = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then (acc += data["text"]; print ".")
when "done" then done = data
when "error" then raise (data["message"] || "run failed")
end
end
end
end
end
end
raw = done["output"]["output"]
puts "\ncharged: #{done["charged_credits"]} credits"
repaired, changes, = parse_reply(raw) # step 6
puts "#{changes.size} repairs, #{repaired.length} characters"
$event = null;
$done = null;
$acc = "";
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: repair-report-pdf-1",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done, &$acc) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { $acc .= $data["text"]; echo "."; }
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$raw = $done["output"]["output"];
echo "\ncharged: {$done['charged_credits']} credits\n";
[$repaired, $changes] = parse_reply($raw); // step 6
echo count($changes) . " repairs, " . strlen($repaired) . " characters\n";
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "repair-report-pdf-1");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
var acc = new System.Text.StringBuilder();
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = line[5..].Trim();
if (evt == "delta")
{
acc.Append(JsonDocument.Parse(data).RootElement
.GetProperty("text").GetString());
Console.Write(".");
}
else if (evt == "done") done = data;
else if (evt == "error") throw new Exception(data);
}
}
using var final = JsonDocument.Parse(done!);
var raw = final.RootElement.GetProperty("output").GetProperty("output").GetString();
Console.WriteLine($"\ncharged: {final.RootElement.GetProperty("charged_credits")} credits");
// then parse the sentinels and reconcile exactly as in step 6
In a browser the native EventSource only speaks GET and this endpoint is a POST
— read the fetch body incrementally, as the JavaScript sample does. On an
idempotent replay the server may answer with a plain JSON envelope instead of an event
stream, so check the Content-Type before you start parsing frames. If you stop
reading early, parse what you have: a document with <<<DOC and no
closing sentinel is a partial result, not a failure, and you are charged only for actual
usage.
Step 8 — Scanned pages, task read_scan
A scanned PDF has no text layer, so there is nothing for the free converter to read and
nothing worth repairing — the deterministic lane can only name which pages are images.
The read_scan task transcribes those page images into Markdown instead. It is a
two-part call: upload the images, then start a run that references them.
8a — Upload the page images
POST /files is multipart/form-data with the image in a form field
named file (an optional name field overrides the stored filename).
Do not send Content-Type: application/json here — let your HTTP
client set the multipart boundary. The response is
{"data": {"file": {"file_id", …}}}; keep each
file_id. Limits: up to 4 images per run,
5 MB each, and the type must be image/jpeg,
image/png, image/webp or image/gif. Most scanned PDFs
embed JPEGs, which is why the app can hand them straight over; a page it cannot extract as
one of those four types is not offered for reading.
8b — Run with $files
The body is the same shape as repair, with three differences:
task is "read_scan", there is a pages array of 1-based
page numbers, and a top-level $files array of the uploaded file ids in the same
order as pages. markdown is not required (there is no trustworthy
text to repair), but keep sending facts — an image-only gap
and the parser's warnings tell the model what it is looking at. Everything else is identical:
same estimate call, same job polling or SSE, and the same
<<<DOC/<<<CHANGES output contract, with anything
illegible marked [illegible] in the document. Reconcile the same way, minus the
count comparisons that a scan cannot have.
# 8a - upload up to 4 page images (jpeg/png/webp/gif, 5 MB each)
FILE_IDS=()
for p in page-003.jpg page-004.jpg; do
ID=$(curl -s -X POST "https://api.skillsafe.ai/v1/app-api/files" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-F "file=@$p;type=image/jpeg" | jq -r '.data.file.file_id')
FILE_IDS+=("$ID")
done
# 8b - run the transcription
jq -n --argjson files "$(printf '%s\n' "${FILE_IDS[@]}" | jq -R . | jq -s .)" \
'{task: "read_scan",
filename: "scanned-report.pdf",
format: "pdf",
pages: [3, 4],
facts: {headings: 0, tables: 0, table_rows: 0, list_items: 0,
links: 0, images: 0, code_blocks: 0, footnotes: 0, words: 0,
numbers_sample: [],
gaps: [{id: "image-only", label: "2 pages are images with no text layer"}],
warnings: ["pages 3-4 have no text layer"]},
instructions: "Page 4 is a single wide table.",
"$files": $files}' > scan.json
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: scan-report-3-4" -d @scan.json
# 8a - upload up to 4 page images (jpeg/png/webp/gif, 5 MB each)
file_ids = []
for path in ("page-003.jpg", "page-004.jpg"):
with open(path, "rb") as fh:
res = requests.post(
API + "/files",
headers={"Authorization": f"Bearer {TOKEN}"}, # no Content-Type here
files={"file": (path, fh, "image/jpeg")},
)
res.raise_for_status()
file_ids.append(res.json()["data"]["file"]["file_id"])
# 8b - run the transcription
scan_payload = {
"task": "read_scan",
"filename": "scanned-report.pdf",
"format": "pdf",
"pages": [3, 4],
"facts": {
"headings": 0, "tables": 0, "table_rows": 0, "list_items": 0,
"links": 0, "images": 0, "code_blocks": 0, "footnotes": 0, "words": 0,
"numbers_sample": [],
"gaps": [{"id": "image-only", "label": "2 pages are images with no text layer"}],
"warnings": ["pages 3-4 have no text layer"],
},
"instructions": "Page 4 is a single wide table.",
"$files": file_ids,
}
job_id = api("POST", "/run", scan_payload,
**{"Idempotency-Key": "scan-report-3-4"})["job_id"]
# poll as in step 5, then parse_reply() as in step 6
import { openAsBlob } from "node:fs";
// 8a - upload up to 4 page images (jpeg/png/webp/gif, 5 MB each)
const fileIds = [];
for (const path of ["page-003.jpg", "page-004.jpg"]) {
const fd = new FormData();
fd.append("file", await openAsBlob(path, { type: "image/jpeg" }), path);
const res = await fetch(API + "/files", {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}` }, // no Content-Type: fetch sets it
body: fd,
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
fileIds.push(json.data.file.file_id);
}
// 8b - run the transcription
const scanPayload = {
task: "read_scan",
filename: "scanned-report.pdf",
format: "pdf",
pages: [3, 4],
facts: {
headings: 0, tables: 0, table_rows: 0, list_items: 0,
links: 0, images: 0, code_blocks: 0, footnotes: 0, words: 0,
numbers_sample: [],
gaps: [{ id: "image-only", label: "2 pages are images with no text layer" }],
warnings: ["pages 3-4 have no text layer"],
},
instructions: "Page 4 is a single wide table.",
$files: fileIds,
};
const { job_id } = await api("POST", "/run", scanPayload,
{ "Idempotency-Key": "scan-report-3-4" });
// poll as in step 5, then parseReply() as in step 6
// 8a - upload up to 4 page images (jpeg/png/webp/gif, 5 MB each)
uploadPage := func(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return "", err
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, path))
h.Set("Content-Type", "image/jpeg")
part, _ := w.CreatePart(h)
part.Write(raw)
w.Close()
req, _ := http.NewRequest("POST", API+"/files", &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", w.FormDataContentType())
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var env struct {
Data struct {
File struct{ FileID string `json:"file_id"` } `json:"file"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
return env.Data.File.FileID, nil
}
var fileIDs []string
for _, p := range []string{"page-003.jpg", "page-004.jpg"} {
id, err := uploadPage(p)
if err != nil {
log.Fatal(err)
}
fileIDs = append(fileIDs, id)
}
// 8b - run the transcription
scanPayload := map[string]any{
"task": "read_scan",
"filename": "scanned-report.pdf",
"format": "pdf",
"pages": []int{3, 4},
"facts": map[string]any{
"headings": 0, "tables": 0, "table_rows": 0, "list_items": 0,
"links": 0, "images": 0, "code_blocks": 0, "footnotes": 0, "words": 0,
"numbers_sample": []string{},
"gaps": []map[string]string{
{"id": "image-only", "label": "2 pages are images with no text layer"},
},
"warnings": []string{"pages 3-4 have no text layer"},
},
"instructions": "Page 4 is a single wide table.",
"$files": fileIDs,
}
// same /run + poll (step 5) and sentinel parsing (step 6) as the repair task
// 8a - upload up to 4 page images (jpeg/png/webp/gif, 5 MB each).
// Java has no multipart body publisher, so build the body by hand.
static String uploadPage(java.nio.file.Path path, String mime) throws Exception {
String boundary = "----doc-to-md-" + System.nanoTime();
var head = ("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\""
+ path.getFileName() + "\"\r\n"
+ "Content-Type: " + mime + "\r\n\r\n").getBytes();
var tail = ("\r\n--" + boundary + "--\r\n").getBytes();
var body = new java.io.ByteArrayOutputStream();
body.write(head);
body.write(java.nio.file.Files.readAllBytes(path));
body.write(tail);
var req = HttpRequest.newBuilder(URI.create(API + "/files"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray()))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // data.file.file_id via your JSON library
}
// 8b - run the transcription: same body as `repair` plus
// "task": "read_scan", "pages": [3, 4] and "$files": ["file_...", "file_..."].
// markdown may be omitted; keep facts with an image-only gap.
// Then poll (step 5) and split the sentinels (step 6) exactly as before.
require "net/http"
# 8a - upload up to 4 page images (jpeg/png/webp/gif, 5 MB each)
def upload_page(path, mime)
uri = URI(API + "/files")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req.set_form([["file", File.open(path, "rb"),
{ filename: File.basename(path), content_type: mime }]],
"multipart/form-data")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
raise res.body unless res.is_a?(Net::HTTPSuccess)
JSON.parse(res.body).dig("data", "file", "file_id")
end
file_ids = ["page-003.jpg", "page-004.jpg"].map { |p| upload_page(p, "image/jpeg") }
# 8b - run the transcription
scan_payload = {
task: "read_scan",
filename: "scanned-report.pdf",
format: "pdf",
pages: [3, 4],
facts: { headings: 0, tables: 0, table_rows: 0, list_items: 0,
links: 0, images: 0, code_blocks: 0, footnotes: 0, words: 0,
numbers_sample: [],
gaps: [{ id: "image-only", label: "2 pages are images with no text layer" }],
warnings: ["pages 3-4 have no text layer"] },
instructions: "Page 4 is a single wide table.",
"$files": file_ids,
}
started = api("POST", "/run", scan_payload)
# poll as in step 5, then parse_reply as in step 6
// 8a - upload up to 4 page images (jpeg/png/webp/gif, 5 MB each)
function upload_page(string $path, string $mime): string {
global $TOKEN;
$ch = curl_init(API . "/files");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $TOKEN"], // no JSON header
CURLOPT_POSTFIELDS => [
"file" => new CURLFile($path, $mime, basename($path)),
],
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($payload["error"]["message"] ?? "HTTP $status");
}
return $payload["data"]["file"]["file_id"];
}
$fileIds = array_map(
fn ($p) => upload_page($p, "image/jpeg"),
["page-003.jpg", "page-004.jpg"]
);
// 8b - run the transcription
$scanPayload = [
"task" => "read_scan",
"filename" => "scanned-report.pdf",
"format" => "pdf",
"pages" => [3, 4],
"facts" => [
"headings" => 0, "tables" => 0, "table_rows" => 0, "list_items" => 0,
"links" => 0, "images" => 0, "code_blocks" => 0, "footnotes" => 0,
"words" => 0, "numbers_sample" => [],
"gaps" => [["id" => "image-only",
"label" => "2 pages are images with no text layer"]],
"warnings" => ["pages 3-4 have no text layer"],
],
"instructions" => "Page 4 is a single wide table.",
"\$files" => $fileIds,
];
$started = api("POST", "/run", $scanPayload);
// poll as in step 5, then parse_reply as in step 6
// 8a - upload up to 4 page images (jpeg/png/webp/gif, 5 MB each)
static async Task<string> UploadPageAsync(string path, string mime)
{
using var content = new MultipartFormDataContent();
var bytes = new ByteArrayContent(await File.ReadAllBytesAsync(path));
bytes.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mime);
content.Add(bytes, "file", Path.GetFileName(path));
var req = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/files") { Content = content };
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data").GetProperty("file")
.GetProperty("file_id").GetString()!;
}
var fileIds = new List<string>();
foreach (var p in new[] { "page-003.jpg", "page-004.jpg" })
fileIds.Add(await UploadPageAsync(p, "image/jpeg"));
// 8b - run the transcription
var scanPayload = new Dictionary<string, object?> {
["task"] = "read_scan",
["filename"] = "scanned-report.pdf",
["format"] = "pdf",
["pages"] = new[] { 3, 4 },
["facts"] = new {
headings = 0, tables = 0, table_rows = 0, list_items = 0,
links = 0, images = 0, code_blocks = 0, footnotes = 0, words = 0,
numbers_sample = Array.Empty<string>(),
gaps = new[] { new { id = "image-only",
label = "2 pages are images with no text layer" } },
warnings = new[] { "pages 3-4 have no text layer" },
},
["instructions"] = "Page 4 is a single wide table.",
["$files"] = fileIds, // a dictionary keeps the literal "$files" key
};
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", scanPayload);
// poll as in step 5, then parse the sentinels as in step 6
A transcription is a reading of an image, so it has no fact sheet to be checked against
— the reconciliation that makes a repair trustworthy does not exist here.
Expect [illegible] markers where the scan is poor, read the
CHANGES block, and treat the result as a draft to proofread against the page
rather than a faithful copy of it.
Step 9 — Read documents from, and write Markdown back to, the user's drive
The SkillSafe drive is a file namespace owned by the user, not by this app, and it
is what turns doc-to-md from one-file-in/one-file-out into a batch converter. The app reads
a document out of the drive, converts it, and writes the .md straight back.
Drive calls are free (no nanos, no credits) and
signed-in only — a guest token gets 403 on every route
below. The free conversion lane in the browser does not touch any of this.
9a — What this app may touch
Access is checked on every request. doc-to-md owns apps/doc-to-md/ outright
(read and write, no consent, not revocable) and writes its converted Markdown to
apps/doc-to-md/converted/ by default. Beyond that folder it holds only what the
user granted: the release manifest declares read on
documents/, docs/ and inbox/ and write
on converted/, approved once in the SkillSafe consent popup; the platform file
picker grants individual paths; and the user can share any file or folder with the app from
their cloud storage. GET /drive/grants reports what is actually live for the
calling user — ask it rather than assuming, and treat a 403 as
“not granted”, never as “missing file”.
9b — Folder shares are a poll, not a push
A folder share covers files added to that folder later, which is the whole
pipeline primitive: something drops a document in, doc-to-md picks it up. But there is no
webhook and no server in this bundle, so “picks it up” means
somebody lists the folder. The web app lists ?scope=shared when the
page opens and again when the user presses Check shared folders for new files; from
your own code, call it on whatever schedule you like. Nothing happens between polls.
9c — Not converting the same file twice
The app keeps one record per source path in its drive_conversions collection
(POST /collections/drive_conversions/query, and
PUT /collections/drive_conversions/records/{id} to upsert). Each record carries
source_path, output_path, source_sig — the
source's bytes|modified_at|etag joined — and converted_at. A
file whose signature matches its record is skipped; a file edited in the drive since the last
pass has a different signature and is converted again. The Markdown the app itself wrote is
recognised by its output_path and never fed back in as a source.
9d — Limits
10 MB per file, 1,000 files per account, paths up to 512 bytes and 16 segments, no leading slash. Uploading to a path that already exists replaces it (and preserves its shares); deleting a drive file unshares it everywhere. Every call this app makes is written to the user's dashboard audit trail.
DRIVE="https://api.skillsafe.ai/v1/app-api/drive"
# 9a - what has this app actually been granted?
curl -s "$DRIVE/grants" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data.grants'
# 9b - the folder-share inbox (poll this; there is no push)
curl -s "$DRIVE/files?scope=shared&limit=100" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" | jq '.data.files'
# 9c - read one document's bytes (raw body, not the JSON envelope)
curl -s "$DRIVE/files/content?path=inbox%2Freport.docx" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -o report.docx
# ... convert it locally, or POST it to /extract ...
# 9d - write the Markdown back (multipart; same path replaces)
curl -s -X POST "$DRIVE/files" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-F "file=@report.md;type=text/markdown" \
-F "path=apps/doc-to-md/converted/inbox/report.md" | jq '.data.file'
import requests
DRIVE = "https://api.skillsafe.ai/v1/app-api/drive"
H = {"Authorization": f"Bearer {TOKEN}"} # TOKEN from step 1
def grants():
return requests.get(f"{DRIVE}/grants", headers=H).json()["data"]["grants"]
def shared(prefix=None):
"""The folder-share inbox. Poll it - nothing pushes."""
files, cursor = [], None
while True:
p = {"scope": "shared", "limit": 100}
if prefix: p["prefix"] = prefix
if cursor: p["cursor"] = cursor
j = requests.get(f"{DRIVE}/files", headers=H, params=p).json()
files += j["data"]["files"]
page = j.get("meta", {}).get("pagination", {})
if not page.get("has_more"): return files
cursor = page["next_cursor"]
def read(path):
r = requests.get(f"{DRIVE}/files/content", headers=H, params={"path": path})
r.raise_for_status()
return r.content
def write_md(path, markdown):
r = requests.post(f"{DRIVE}/files", headers=H,
files={"file": (path.rsplit("/", 1)[-1],
markdown.encode(), "text/markdown")},
data={"path": path})
r.raise_for_status()
return r.json()["data"]["file"]
def sig(f):
return "|".join(str(f.get(k, "")) for k in ("bytes", "modified_at", "etag"))
# skip anything already converted whose bytes have not moved
seen = {r["doc"]["source_path"]: r["doc"]["source_sig"]
for r in api("POST", "/collections/drive_conversions/query",
{"limit": 100})["records"]}
for f in shared():
if seen.get(f["path"]) == sig(f):
continue
md = convert(read(f["path"])) # your converter
out = "apps/doc-to-md/converted/" + f["path"].rsplit(".", 1)[0] + ".md"
write_md(out, md)
const DRIVE = "https://api.skillsafe.ai/v1/app-api/drive";
const H = { Authorization: `Bearer ${TOKEN}` }; // TOKEN from step 1
const grants = async () =>
(await (await fetch(`${DRIVE}/grants`, { headers: H })).json()).data.grants;
// The folder-share inbox. Poll it - nothing pushes.
async function shared(prefix) {
const out = [];
let cursor = null;
do {
const q = new URLSearchParams({ scope: "shared", limit: "100" });
if (prefix) q.set("prefix", prefix);
if (cursor) q.set("cursor", cursor);
const json = await (await fetch(`${DRIVE}/files?${q}`, { headers: H })).json();
out.push(...json.data.files);
cursor = json.meta?.pagination?.has_more ? json.meta.pagination.next_cursor : null;
} while (cursor);
return out;
}
async function read(path) {
const res = await fetch(`${DRIVE}/files/content?path=${encodeURIComponent(path)}`,
{ headers: H });
if (!res.ok) throw new Error(`drive read ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
async function writeMd(path, markdown) {
const fd = new FormData();
fd.append("file", new Blob([markdown], { type: "text/markdown" }),
path.split("/").pop());
fd.append("path", path);
const res = await fetch(`${DRIVE}/files`, { method: "POST", headers: H, body: fd });
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message || `drive write ${res.status}`);
return json.data.file;
}
const sig = (f) => [f.bytes ?? "", f.modified_at ?? "", f.etag ?? ""].join("|");
// In the browser the SDK wraps all of this:
// const picked = await ss.drive.pick({ mode: "readwrite" });
// const bytes = await (await ss.drive.content(picked[0].path)).arrayBuffer();
// await ss.drive.put(new File([md], "report.md", { type: "text/markdown" }), out);
const drive = "https://api.skillsafe.ai/v1/app-api/drive"
func driveShared(token string) ([]map[string]any, error) {
req, _ := http.NewRequest("GET", drive+"/files?scope=shared&limit=100", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var out struct {
Data struct {
Files []map[string]any `json:"files"`
} `json:"data"`
}
return out.Data.Files, json.NewDecoder(res.Body).Decode(&out)
}
func driveRead(token, path string) ([]byte, error) {
req, _ := http.NewRequest("GET",
drive+"/files/content?path="+url.QueryEscape(path), nil)
req.Header.Set("Authorization", "Bearer "+token)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("drive read %d", res.StatusCode)
}
return io.ReadAll(res.Body)
}
func driveWriteMD(token, path, markdown string) error {
var body bytes.Buffer
w := multipart.NewWriter(&body)
name := path[strings.LastIndex(path, "/")+1:]
part, _ := w.CreateFormFile("file", name)
part.Write([]byte(markdown))
w.WriteField("path", path)
w.Close()
req, _ := http.NewRequest("POST", drive+"/files", &body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", w.FormDataContentType())
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
return fmt.Errorf("drive write %d", res.StatusCode)
}
return nil
}
// A file is "already converted" when bytes|modified_at|etag matches the
// source_sig stored in the drive_conversions collection.
static final String DRIVE = "https://api.skillsafe.ai/v1/app-api/drive";
static String driveShared(String token) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(DRIVE + "/files?scope=shared&limit=100"))
.header("Authorization", "Bearer " + token)
.GET().build();
return HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
static byte[] driveRead(String token, String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(DRIVE + "/files/content?path="
+ URLEncoder.encode(path, StandardCharsets.UTF_8)))
.header("Authorization", "Bearer " + token)
.GET().build();
HttpResponse<byte[]> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofByteArray());
if (res.statusCode() != 200) throw new RuntimeException("drive read " + res.statusCode());
return res.body();
}
static void driveWriteMd(String token, String path, String markdown) throws Exception {
String boundary = "----doctomd" + System.nanoTime();
String name = path.substring(path.lastIndexOf('/') + 1);
var out = new ByteArrayOutputStream();
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\";"
+ " filename=\"" + name + "\"\r\nContent-Type: text/markdown\r\n\r\n")
.getBytes(StandardCharsets.UTF_8));
out.write(markdown.getBytes(StandardCharsets.UTF_8));
out.write(("\r\n--" + boundary + "\r\nContent-Disposition: form-data; name=\"path\""
+ "\r\n\r\n" + path + "\r\n--" + boundary + "--\r\n")
.getBytes(StandardCharsets.UTF_8));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(DRIVE + "/files"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())).build();
HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
}
require "net/http"
require "json"
require "uri"
DRIVE = "https://api.skillsafe.ai/v1/app-api/drive"
def drive_get(path, params = {})
uri = URI("#{DRIVE}#{path}")
uri.query = URI.encode_www_form(params) unless params.empty?
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
end
# The folder-share inbox. Poll it - nothing pushes.
def drive_shared
JSON.parse(drive_get("/files", scope: "shared", limit: 100).body)["data"]["files"]
end
def drive_read(path)
res = drive_get("/files/content", path: path)
raise "drive read #{res.code}" unless res.code == "200"
res.body
end
def drive_write_md(path, markdown)
uri = URI("#{DRIVE}/files")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req.set_form([["file", markdown,
{ filename: File.basename(path), content_type: "text/markdown" }],
["path", path]], "multipart/form-data")
Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
end
def sig(f) = [f["bytes"], f["modified_at"], f["etag"]].join("|")
<?php
const DRIVE = "https://api.skillsafe.ai/v1/app-api/drive";
function driveGet(string $path, array $params = [], bool $raw = false) {
$url = DRIVE . $path . ($params ? "?" . http_build_query($params) : "");
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN],
]);
$body = curl_exec($ch);
curl_close($ch);
return $raw ? $body : json_decode($body, true)["data"];
}
// The folder-share inbox. Poll it - nothing pushes.
function driveShared(): array {
return driveGet("/files", ["scope" => "shared", "limit" => 100])["files"];
}
function driveRead(string $path): string {
return driveGet("/files/content", ["path" => $path], true);
}
function driveWriteMd(string $path, string $markdown): array {
$tmp = tempnam(sys_get_temp_dir(), "md");
file_put_contents($tmp, $markdown);
$ch = curl_init(DRIVE . "/files");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN],
CURLOPT_POSTFIELDS => [
"file" => new CURLFile($tmp, "text/markdown", basename($path)),
"path" => $path,
],
]);
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
unlink($tmp);
return $out["data"]["file"];
}
function sig(array $f): string {
return implode("|", [$f["bytes"] ?? "", $f["modified_at"] ?? "", $f["etag"] ?? ""]);
}
const string Drive = "https://api.skillsafe.ai/v1/app-api/drive";
static HttpRequestMessage Signed(HttpMethod m, string url)
{
var r = new HttpRequestMessage(m, url);
r.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Token);
return r;
}
// The folder-share inbox. Poll it - nothing pushes.
static async Task<JsonElement> DriveSharedAsync()
{
var res = await Http.SendAsync(Signed(HttpMethod.Get,
$"{Drive}/files?scope=shared&limit=100"));
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("data").GetProperty("files");
}
static async Task<byte[]> DriveReadAsync(string path)
{
var res = await Http.SendAsync(Signed(HttpMethod.Get,
$"{Drive}/files/content?path={Uri.EscapeDataString(path)}"));
res.EnsureSuccessStatusCode();
return await res.Content.ReadAsByteArrayAsync();
}
static async Task DriveWriteMdAsync(string path, string markdown)
{
using var content = new MultipartFormDataContent();
var md = new StringContent(markdown);
md.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/markdown");
content.Add(md, "file", path[(path.LastIndexOf('/') + 1)..]);
content.Add(new StringContent(path), "path");
var req = Signed(HttpMethod.Post, $"{Drive}/files");
req.Content = content;
var res = await Http.SendAsync(req);
res.EnsureSuccessStatusCode();
}
static string Sig(JsonElement f) => string.Join("|",
f.TryGetProperty("bytes", out var b) ? b.ToString() : "",
f.TryGetProperty("modified_at", out var m) ? m.ToString() : "",
f.TryGetProperty("etag", out var e) ? e.ToString() : "");
Drive access is granted by the user and revocable by the user, so write defensively: a
403 on a path that worked yesterday means the grant was withdrawn, not that
something broke. The web app degrades by falling back to
apps/doc-to-md/converted/, which it always owns, and saying so in the row it
prints for that file.
Step 10 — Ingest a link or a file server-side, and the cheap scan fallback
/extract reduces a document to plain text on the platform's side. It takes
either a JSON body {"url": "…"} — a server-side
fetch, SSRF-guarded, redirects and size capped at 4 MB —
or a multipart/form-data body with a file field,
capped at 8 MB. It reads PDF (through Workers AI toMarkdown), .docx,
HTML and text/markdown; legacy .doc is 415. The reply is
{"text", "name", "content_type", "bytes", "truncated", "url"?} with
text capped at 200,000 characters
(truncated: true past it). It is free — no credits, no
job — signed-in only (guests get 403) and rate-limited.
A site that blocks the fetch comes back 422 with a message worth showing the
user verbatim.
10a — Two things this buys you
First, “paste a link” as an input. A browser app cannot fetch
an arbitrary third-party document itself — the gateway CSP pins
connect-src to 'self' and api.skillsafe.ai —
so this endpoint is the only way a link becomes a document.
Second, and more valuable: the cheap first fallback for a scanned PDF.
Because the multipart form accepts a file, a PDF the user picked off their own disk (or
read out of their drive) can be routed through it — the lane is not limited
to documents that already live at a public URL. So when the deterministic parser reports no
text layer, the order is: /extract first, at zero cost; only if that comes back
with nothing usable do you pay for the read_scan vision run in step 8.
10b — What “nothing usable” has to mean
/extract can return 200 and still hand back a page of nothing: an
empty string, a few form-field labels, or ligature noise. Accepting that as a converted
document is worse than admitting the scan needs reading, so the fall-through hangs on an
explicit content test rather than on the HTTP status. The app requires
≥ 200 characters, ≥ 35% letters and digits
(a decode failure fails here) and ≥ 40 word-like tokens before it
calls the text a document. Anything short of that unlocks the metered lane instead. Note the
asymmetry that follows: the metered vision run is unreachable until the free lane
has been tried and failed — the fall-through is a priced click, never an automatic
charge.
10c — What it saves
At the app's gpt-terra rates (gpt-5.6-terra, $2.75/Mtok in and
$16.50/Mtok out, +10% publisher markup, so 0.03025 credits per input token and 0.1815 per
output token), one scanned page read through read_scan costs roughly
185 credits (~$0.019) — about 765 image tokens in plus about 900
transcription tokens out — and a run carries at most 4 pages. A 20-page scan is
therefore 5 runs and roughly 3,700 credits (~$0.37).
/extract does the same 20 pages in one free call. When the
text layer can be recovered at all, the fallback removes 100% of that cost and the 4-page
ceiling with it.
# 10a - a link
curl -s -X POST "$API/extract" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/quarterly-report.pdf"}' | jq '.data | {name, content_type, bytes, truncated}'
# 10b - a local file (multipart; let curl set the boundary)
curl -s -X POST "$API/extract" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" \
-F "file=@scanned-invoice.pdf;type=application/pdf" > extracted.json
# 10c - only pay for read_scan if the free lane came back with nothing usable
CHARS=$(jq -r '.data.text | length' extracted.json)
WORDS=$(jq -r '.data.text' extracted.json | grep -oE '[A-Za-z]{2,}' | wc -l)
if [ "$CHARS" -ge 200 ] && [ "$WORDS" -ge 40 ]; then
jq -r '.data.text' extracted.json > document.md # free - done
else
echo "extractor found nothing usable; falling through to step 8 (metered)"
fi
import re, requests
def extract_url(url):
r = requests.post(f"{API}/extract", headers=AUTH, json={"url": url})
r.raise_for_status()
return r.json()["data"]
def extract_file(path, mime="application/pdf"):
with open(path, "rb") as fh:
r = requests.post(f"{API}/extract", headers=AUTH,
files={"file": (path.split("/")[-1], fh, mime)})
r.raise_for_status()
return r.json()["data"]
def usable(text, min_chars=200, min_words=40):
"""The gate the fall-through hangs on - a 200 is not enough on its own."""
s = (text or "").strip()
if len(s) < min_chars:
return False
alnum = sum(c.isalnum() for c in s)
if alnum / len(s) < 0.35: # did not decode as text
return False
return len(re.findall(r"[A-Za-z]{2,}", s)) >= min_words
data = extract_file("scanned-invoice.pdf")
if usable(data["text"]):
markdown = data["text"] # free - no credits, no job
else:
markdown = read_scan(...) # step 8, metered, max 4 pages/run
async function extractUrl(url) {
const res = await fetch(`${API}/extract`, {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error?.message), { status: res.status });
return json.data;
}
async function extractFile(file) { // File | Blob, max 8 MB
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`${API}/extract`, { method: "POST", headers: AUTH, body: fd });
const json = await res.json();
if (!res.ok) throw Object.assign(new Error(json.error?.message), { status: res.status });
return json.data;
}
// The gate the fall-through hangs on - a 200 is not enough on its own.
function usable(text, { minChars = 200, minWords = 40 } = {}) {
const s = String(text ?? "").trim();
if (s.length < minChars) return false;
const alnum = (s.match(/[A-Za-z0-9À-ɏ]/g) || []).length;
if (alnum / s.length < 0.35) return false;
return (s.match(/[A-Za-zÀ-ɏ]{2,}/g) || []).length >= minWords;
}
const data = await extractFile(pdfFile);
const markdown = usable(data.text) ? data.text : await readScan(/* step 8, metered */);
type extractOut struct {
Text string `json:"text"`
Name string `json:"name"`
ContentType string `json:"content_type"`
Bytes int `json:"bytes"`
Truncated bool `json:"truncated"`
}
func extractURL(token, link string) (extractOut, error) {
body, _ := json.Marshal(map[string]string{"url": link})
req, _ := http.NewRequest("POST", api+"/extract", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return extractOut{}, err
}
defer res.Body.Close()
var out struct {
Data extractOut `json:"data"`
}
return out.Data, json.NewDecoder(res.Body).Decode(&out)
}
func extractFile(token, path, mime string) (extractOut, error) {
var body bytes.Buffer
w := multipart.NewWriter(&body)
part, _ := w.CreateFormFile("file", filepath.Base(path))
raw, _ := os.ReadFile(path)
part.Write(raw)
w.Close()
req, _ := http.NewRequest("POST", api+"/extract", &body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", w.FormDataContentType())
res, err := http.DefaultClient.Do(req)
if err != nil {
return extractOut{}, err
}
defer res.Body.Close()
var out struct {
Data extractOut `json:"data"`
}
return out.Data, json.NewDecoder(res.Body).Decode(&out)
}
// The gate the fall-through hangs on - a 200 is not enough on its own.
func usable(text string) bool {
s := strings.TrimSpace(text)
if len(s) < 200 {
return false
}
alnum := 0
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
alnum++
}
}
if float64(alnum)/float64(len(s)) < 0.35 {
return false
}
return len(regexp.MustCompile(`[A-Za-z]{2,}`).FindAllString(s, -1)) >= 40
}
static String extractUrl(String token, String link) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API + "/extract"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(
"{\"url\":\"" + link + "\"}")).build();
return HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
static String extractFile(String token, Path file, String mime) throws Exception {
String boundary = "----doctomd" + System.nanoTime();
var out = new ByteArrayOutputStream();
out.write(("--" + boundary + "\r\nContent-Disposition: form-data; name=\"file\";"
+ " filename=\"" + file.getFileName() + "\"\r\nContent-Type: " + mime
+ "\r\n\r\n").getBytes(StandardCharsets.UTF_8));
out.write(Files.readAllBytes(file));
out.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API + "/extract"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(out.toByteArray())).build();
return HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
/** The gate the fall-through hangs on - a 200 is not enough on its own. */
static boolean usable(String text) {
String s = text == null ? "" : text.strip();
if (s.length() < 200) return false;
long alnum = s.chars().filter(Character::isLetterOrDigit).count();
if ((double) alnum / s.length() < 0.35) return false;
return Pattern.compile("[A-Za-z]{2,}").matcher(s).results().count() >= 40;
}
def extract_url(link)
uri = URI("#{API}/extract")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.generate(url: link)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
def extract_file(path, mime = "application/pdf")
uri = URI("#{API}/extract")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req.set_form([["file", File.open(path),
{ filename: File.basename(path), content_type: mime }]],
"multipart/form-data")
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
JSON.parse(res.body)["data"]
end
# The gate the fall-through hangs on - a 200 is not enough on its own.
def usable?(text, min_chars: 200, min_words: 40)
s = text.to_s.strip
return false if s.length < min_chars
return false if s.count("A-Za-z0-9").to_f / s.length < 0.35
s.scan(/[A-Za-z]{2,}/).length >= min_words
end
data = extract_file("scanned-invoice.pdf")
markdown = usable?(data["text"]) ? data["text"] : read_scan(...) # step 8, metered
<?php
function extractUrl(string $link): array {
$ch = curl_init(API . "/extract");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN,
"Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["url" => $link]),
]);
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
return $out["data"];
}
function extractFile(string $path, string $mime = "application/pdf"): array {
$ch = curl_init(API . "/extract");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN],
CURLOPT_POSTFIELDS => ["file" => new CURLFile($path, $mime, basename($path))],
]);
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
return $out["data"];
}
// The gate the fall-through hangs on - a 200 is not enough on its own.
function usable(?string $text, int $minChars = 200, int $minWords = 40): bool {
$s = trim((string) $text);
if (strlen($s) < $minChars) return false;
$alnum = strlen(preg_replace("/[^A-Za-z0-9]/", "", $s));
if ($alnum / strlen($s) < 0.35) return false;
return preg_match_all("/[A-Za-z]{2,}/", $s) >= $minWords;
}
$data = extractFile("scanned-invoice.pdf");
$markdown = usable($data["text"]) ? $data["text"] : readScan(/* step 8, metered */);
static async Task<JsonElement> ExtractUrlAsync(string link)
{
var req = new HttpRequestMessage(HttpMethod.Post, $"{Api}/extract")
{
Content = JsonContent.Create(new { url = link }),
};
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Token);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
static async Task<JsonElement> ExtractFileAsync(string path, string mime)
{
using var content = new MultipartFormDataContent();
var bytes = new ByteArrayContent(await File.ReadAllBytesAsync(path));
bytes.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(mime);
content.Add(bytes, "file", Path.GetFileName(path));
var req = new HttpRequestMessage(HttpMethod.Post, $"{Api}/extract") { Content = content };
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Token);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("data");
}
// The gate the fall-through hangs on - a 200 is not enough on its own.
static bool Usable(string? text, int minChars = 200, int minWords = 40)
{
var s = (text ?? "").Trim();
if (s.Length < minChars) return false;
var alnum = s.Count(char.IsLetterOrDigit);
if ((double)alnum / s.Length < 0.35) return false;
return Regex.Matches(s, "[A-Za-z]{2,}").Count >= minWords;
}
This lane sends bytes to the platform, which the in-browser conversion never does. Say so in
your own UI the way the app does: the drag-and-drop lane is client-side and free with no
account at all; /extract is free but server-side and signed-in. They are not
interchangeable from a privacy standpoint, only from a cost one.
Step 11 — Refine the document over several turns
Steps 5 and 7 are one-shot: a document in, a repaired document out. That is the wrong shape for “make the tables narrower”, “keep footnotes as endnotes”, “redo the heading levels” — each of those would otherwise mean re-sending the whole document as a fresh run and paying to re-read it. A session keeps the conversation server-side against the same system prompt, so turn 2 costs an instruction rather than a document.
11a — The turn contract
A session message is {"content": "…", "stream": true?, "$model": "…"?}
— a string, not the JSON input object steps 3 and 8 use. This app's
prompt defines a refinement message as the literal line REFINE followed by the
instruction. The first message then adds a blank line and the familiar JSON
object with task: "refine", the current markdown and the original
facts. Every later message is the instruction alone —
the document is already in the conversation, and that is the entire saving.
The reply uses the same <<<DOC/<<<CHANGES
sentinels as step 6, and the prompt requires the complete document on every
turn — never a diff and never an excerpt, because the client replaces the whole
document with what is between the sentinels.
11b — Reconcile every turn against the ORIGINAL parse
This is the part that is easy to get wrong. Re-run the step 6 reconciliation on
every turn's output, always against the first message's
facts — the counts and numbers the deterministic parser measured —
never against the previous turn. Reconciling against the previous turn makes drift
invisible: turn 1 loses a table, turn 2 matches turn 1, and by turn 5 nothing disagrees with
anything while the document has quietly lost content. Against the original parse, the loss
surfaces on the turn it happens.
11c — Cost, keys and limits
Every turn is a billed job — same holds, same pricing, same
charged_credits as /run. Send an
Idempotency-Key header on each one; a replay returns the ORIGINAL job rather
than billing again (and returns it even if the retry passes a different $model).
Note that the platform SDK's ss.sessions.send() is the one run-shaped call that
does not take an idempotency key, so the web app posts the message itself with the
header attached and hands the response to the SDK's SSE reader. Limits: 20 live
sessions per user, 200 messages per session, history truncated
oldest-pair-first to fit the model budget. Sessions are strictly own-subject.
DELETE /sessions/{id} removes the session and its messages.
# 11a - open a session
SID=$(curl -s -X POST "$API/sessions" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-d '{"title": "doc-to-md - quarterly-report.docx"}' | jq -r '.data.session.session_id')
# 11b - turn 1 carries the document and the fact sheet
jq -Rs --slurpfile f facts.json '"REFINE\nmake the tables narrower\n\n" +
({task: "refine", filename: "quarterly-report.docx", format: "docx",
markdown: ., facts: $f[0]} | tojson)' repaired.md \
| jq '{content: ., stream: false}' > turn1.json
curl -s -X POST "$API/sessions/$SID/messages" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: turn-$SID-1" -d @turn1.json | jq '.data.job_id'
# 11c - every later turn is the instruction ALONE
curl -s -X POST "$API/sessions/$SID/messages" \
-H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: turn-$SID-2" \
-d '{"content": "REFINE\nkeep footnotes as endnotes"}' | jq '.data.job_id'
# poll each job as in step 5, split the sentinels as in step 6, then reconcile
# the result against facts.json - the ORIGINAL parse, not the previous turn.
curl -s -X DELETE "$API/sessions/$SID" -H "Authorization: Bearer $SKILLSAFE_TOKEN"
import json, uuid, requests
class Refinement:
"""One session. facts stays pinned to the ORIGINAL parse for its lifetime."""
def __init__(self, markdown, facts, filename, fmt, title=None):
self.markdown, self.facts = markdown, facts
self.filename, self.fmt = filename, fmt
self.opened = False
r = requests.post(f"{API}/sessions", headers=AUTH,
json={"title": title or f"doc-to-md - {filename}"})
r.raise_for_status()
self.sid = r.json()["data"]["session"]["session_id"]
self.n = 0
def _content(self, instruction):
if self.opened: # later turns: instruction only
return f"REFINE\n{instruction}"
payload = {"task": "refine", "filename": self.filename, "format": self.fmt,
"markdown": self.markdown, "facts": self.facts}
return f"REFINE\n{instruction}\n\n{json.dumps(payload)}"
def turn(self, instruction):
self.n += 1
r = requests.post(f"{API}/sessions/{self.sid}/messages",
headers={**AUTH, "Idempotency-Key": f"turn-{self.sid}-{self.n}"},
json={"content": self._content(instruction)})
r.raise_for_status()
job = wait_for_job(r.json()["data"]["job_id"]) # step 5
doc, changes = parse_reply(job["output"]["output"]) # step 6
if not doc:
raise RuntimeError("no <<
// facts is pinned to the ORIGINAL parse for the session's whole lifetime.
async function openRefinement({ markdown, facts, filename, format }) {
const { session } = await api("POST", "/sessions",
{ title: `doc-to-md - ${filename}` });
const sid = session.session_id;
let opened = false, n = 0, doc = markdown;
async function turn(instruction) {
const content = opened
? `REFINE\n${instruction}` // later turns: instruction only
: `REFINE\n${instruction}\n\n` + JSON.stringify(
{ task: "refine", filename, format, markdown: doc, facts });
const res = await fetch(`${API}/sessions/${sid}/messages`, {
method: "POST",
headers: { ...AUTH, "Content-Type": "application/json",
"Idempotency-Key": `turn-${sid}-${++n}` },
body: JSON.stringify({ content, stream: true }),
});
const done = await readSse(res); // step 7
const { doc: next, changes } = parseReply(done.text ?? done.output?.output);
if (!next) throw new Error("no << api("DELETE", `/sessions/${sid}`) };
}
type Refinement struct {
SID, Filename, Format string
Facts map[string]any // pinned to the ORIGINAL parse
Markdown string
opened bool
n int
token string
}
func (r *Refinement) content(instruction string) string {
if r.opened { // later turns: instruction only
return "REFINE\n" + instruction
}
payload, _ := json.Marshal(map[string]any{
"task": "refine", "filename": r.Filename, "format": r.Format,
"markdown": r.Markdown, "facts": r.Facts,
})
return "REFINE\n" + instruction + "\n\n" + string(payload)
}
func (r *Refinement) Turn(instruction string) (string, error) {
r.n++
body, _ := json.Marshal(map[string]any{"content": r.content(instruction)})
req, _ := http.NewRequest("POST",
api+"/sessions/"+r.SID+"/messages", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+r.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", fmt.Sprintf("turn-%s-%d", r.SID, r.n))
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return "", err
}
job, err := waitForJob(r.token, out.Data.JobID) // step 5
if err != nil {
return "", err
}
doc, _ := parseReply(job.Output.Output) // step 6
if doc == "" {
return "", errors.New("no <<
final class Refinement {
private final String sid, filename, format, token;
private final String factsJson; // pinned to the ORIGINAL parse
private String markdown;
private boolean opened = false;
private int n = 0;
Refinement(String token, String sid, String markdown, String factsJson,
String filename, String format) {
this.token = token; this.sid = sid; this.markdown = markdown;
this.factsJson = factsJson; this.filename = filename; this.format = format;
}
private String content(String instruction) {
if (opened) return "REFINE\n" + instruction; // later turns: instruction only
return "REFINE\n" + instruction + "\n\n{\"task\":\"refine\",\"filename\":\""
+ filename + "\",\"format\":\"" + format + "\",\"markdown\":"
+ jsonString(markdown) + ",\"facts\":" + factsJson + "}";
}
String turn(String instruction) throws Exception {
n++;
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API + "/sessions/" + sid + "/messages"))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "turn-" + sid + "-" + n)
.POST(HttpRequest.BodyPublishers.ofString(
"{\"content\":" + jsonString(content(instruction)) + "}")).build();
String started = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString()).body();
String reply = waitForJob(token, jobIdOf(started)); // step 5
String doc = docOf(reply); // step 6
if (doc.isEmpty()) throw new IllegalStateException("no <<
class Refinement
# facts stays pinned to the ORIGINAL parse for the session's lifetime.
def initialize(markdown, facts, filename, format)
@markdown, @facts = markdown, facts
@filename, @format = filename, format
@opened = false
@n = 0
@sid = api(:post, "/sessions",
{ title: "doc-to-md - #{filename}" })["session"]["session_id"]
end
def content(instruction)
return "REFINE\n#{instruction}" if @opened # later turns: instruction only
payload = { task: "refine", filename: @filename, format: @format,
markdown: @markdown, facts: @facts }
"REFINE\n#{instruction}\n\n#{JSON.generate(payload)}"
end
def turn(instruction)
@n += 1
uri = URI("#{API}/sessions/#{@sid}/messages")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "turn-#{@sid}-#{@n}"
req.body = JSON.generate(content: content(instruction))
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
job = wait_for_job(JSON.parse(res.body)["data"]["job_id"]) # step 5
doc, changes = parse_reply(job["output"]["output"]) # step 6
raise "no <<
<?php
final class Refinement {
private bool $opened = false;
private int $n = 0;
public function __construct(
private string $sid, private string $markdown,
private array $facts, // pinned to the ORIGINAL parse
private string $filename, private string $format,
) {}
private function content(string $instruction): string {
if ($this->opened) return "REFINE\n" . $instruction; // instruction only
$payload = json_encode([
"task" => "refine", "filename" => $this->filename,
"format" => $this->format, "markdown" => $this->markdown,
"facts" => $this->facts,
]);
return "REFINE\n" . $instruction . "\n\n" . $payload;
}
public function turn(string $instruction): array {
$this->n++;
$ch = curl_init(API . "/sessions/" . $this->sid . "/messages");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: turn-{$this->sid}-{$this->n}",
],
CURLOPT_POSTFIELDS => json_encode(["content" => $this->content($instruction)]),
]);
$started = json_decode(curl_exec($ch), true);
curl_close($ch);
$job = waitForJob($started["data"]["job_id"]); // step 5
[$doc, $changes] = parseReply($job["output"]["output"]); // step 6
if ($doc === "") throw new RuntimeException("no <<<DOC block");
$problems = reconcile($this->facts, $doc); // ALWAYS the original parse
$this->opened = true;
$this->markdown = $doc;
return [$doc, $changes, $problems, $job["charged_credits"] ?? null];
}
}
sealed class Refinement
{
readonly string _sid, _filename, _format;
readonly JsonElement _facts; // pinned to the ORIGINAL parse
string _markdown;
bool _opened;
int _n;
public Refinement(string sid, string markdown, JsonElement facts,
string filename, string format)
=> (_sid, _markdown, _facts, _filename, _format)
= (sid, markdown, facts, filename, format);
string Content(string instruction)
{
if (_opened) return $"REFINE\n{instruction}"; // later turns: instruction only
var payload = JsonSerializer.Serialize(new {
task = "refine", filename = _filename, format = _format,
markdown = _markdown, facts = _facts,
});
return $"REFINE\n{instruction}\n\n{payload}";
}
public async Task<string> TurnAsync(string instruction)
{
_n++;
var req = new HttpRequestMessage(HttpMethod.Post,
$"{Api}/sessions/{_sid}/messages")
{
Content = JsonContent.Create(new { content = Content(instruction) }),
};
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", $"turn-{_sid}-{_n}");
var started = await Http.SendAsync(req);
var json = await started.Content.ReadFromJsonAsync<JsonElement>();
var job = await WaitForJobAsync(json.GetProperty("data")
.GetProperty("job_id").GetString()!);
var doc = DocOf(job); // step 6
if (string.IsNullOrEmpty(doc))
throw new InvalidOperationException("no <<<DOC block - nothing applied");
Reconcile(_facts, doc); // ALWAYS the original parse, never the last turn
_opened = true;
_markdown = doc;
return doc;
}
}
A session is not free iteration. Each turn is a job with its own hold and its own
charged_credits, so show the per-turn cost the way the web app does — a
turn list with what each one cost and whether it reconciled — rather than a single
total the user has to work backwards from.
Output targets — and why none of them are endpoints
The browser app can leave a conversion in five formats: .md, a
.json document AST, standalone .html, plain .txt, and
the YAML front matter alone as .yaml. None of these is an API call.
Markdown was only ever one serialization of an internal document model, and the other four
are the same model serialized differently — entirely client-side, free, no account, no
credits. There is nothing here to call, which is the point: if you are driving this app
programmatically you already hold the Markdown, and the AST is what you would want in order
to re-render it yourself.
The .json AST envelope
Stable enough to build on: format is always
"doc-to-md-ast" and version is an integer that increments if the
block shapes change. blocks is the document; stats is the same
fact sheet the repair run is reconciled against, so a consumer can apply the convention-6
check itself.
{
"format": "doc-to-md-ast",
"version": 1,
"meta": { "title": "Ingest Pipeline Review", "author": "...", "pages": 4 },
"blocks": [
{ "t": "heading", "level": 1, "anchor": "ingest-pipeline-review",
"inlines": [ { "t": "text", "v": "Ingest Pipeline Review" } ] },
{ "t": "para", "inlines": [ { "t": "text", "v": "Prepared for..." },
{ "t": "note", "id": "1" } ] },
{ "t": "table",
"head": [ { "inlines": [ { "t": "text", "v": "Topic" } ], "colspan": 1,
"rowspan": 1, "header": true } ],
"rows": [ [ { "inlines": [ { "t": "text", "v": "Retention" } ],
"colspan": 1, "rowspan": 1, "header": false } ] ],
"align": [ "left" ], "caption": "" },
{ "t": "code", "lang": "sql", "text": "select 1" },
{ "t": "list", "ordered": false, "start": 1, "tight": true,
"items": [ { "blocks": [ { "t": "para", "inlines": [] } ] } ] },
{ "t": "marker", "kind": "page", "text": "page 2" }
],
"notes": [ { "id": "1", "blocks": [ ... ] } ],
"warnings": [ "3 repeated running header/footer lines were removed." ],
"stats": { "words": 812, "headings": 6, "tables": 2, "table_rows": 11,
"list_items": 9, "links": 3, "images": 0, "code_blocks": 1 }
}
Block types: heading, para, list, table,
code, quote, hr, image,
marker, defs. Inline types: text,
strong, em, del, code,
link, image, br, note.
The AST is lossless against the Markdown writer: feeding
blocks, meta and notes back through the same
serializer reproduces the .md byte for byte, which is asserted on real
.docx and .xlsx bytes in the app's test harness.
One shape to expect: a source that was already Markdown or plain text is passed
through rather than parsed into blocks. Its JSON carries
"passthrough": true and the raw markdown instead of a
blocks array — there is no tree, and the app says so rather than
fabricating one.