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).

StatusMeaning
401Missing or expired token — mint a new one.
402Not enough credits — top up at skillsafe.ai/account/billing.
403The token isn't allowed to do this (e.g. a guest starting a metered run, or an upload over the size cap).
404Unknown job, file or record id.
413The body or upload is too large — clip the Markdown, or send fewer page images.
5xxTransient 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

POST /guest

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

GET /me

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 fieldTypeNotes
taskstring, required"repair" or "read_scan". repair works from markdown; read_scan works from uploaded page images (step 8).
filenamestringThe source document's name, e.g. report.pdf. Used for context only — the file itself is never sent.
formatstringThe 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.
markdownstring, required for repairThe 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.
factsobject, requiredWhat the parser measured, and the gaps it could not solve. Fields below.
facts.headingsnumberCount 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.tablesnumberCount of GFM tables. 0 together with a pdf-tables gap is the classic "the table came out as aligned text" case.
facts.table_rowsnumberTotal body rows across those tables.
facts.list_itemsnumberCount of list items, at any nesting depth.
facts.linksnumberCount of Markdown links. Must not drop.
facts.imagesnumberCount of image references. Must not drop — an image reference removed by a "repair" is a silent data loss.
facts.code_blocksnumberCount of fenced or indented code blocks.
facts.footnotesnumberCount of footnote definitions.
facts.wordsnumberWord count of the body. A repair that moves this by more than about a fifth has rewritten prose, not structure — check it.
facts.numbers_samplestring[]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.gapsarray{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.warningsstring[]Parser warnings worth passing along ("3 pages had no text layer", "table clipped at 400 rows"). Send an empty array if you have none.
pagesnumber[], read_scan onlyThe 1-based page numbers, in order, matching the uploaded images. See step 8.
$filesstring[], read_scan onlyFile ids from POST /files, in the same order as pages. See step 8.
instructionsstring, optionalA 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.

idWhat it asks for
no-headingsThe source never declared headings at all. Promote the lines that visibly are headings to #######, consistent with the document's own numbering.
bold-as-headingBold, ALL-CAPS or numbered caption lines ("3. Discussion") stand in for headings. Convert the ones that really are section titles.
pdf-tablesTab- or space-aligned regions are tables the PDF never declared. Rebuild each as a valid GFM table.
multi-row-headerA table's header spans two or more rows. Collapse it into one sensible header row.
headerless-tableA table has no header row. Use the first row if it reads like one, otherwise leave it headerless rather than inventing labels.
merged-cellsMerged cells were expanded into repeated values. Keep the repeats only where they carry meaning.
multi-columnReading order is visibly wrong (sentences interleave mid-thought). Reorder paragraphs into the correct flow — order only, never wording.
ocr-noiseCharacter-level OCR damage (l/1, O/0, rn/m). Fix only where the correction is certain from context.
image-onlyPages are images with no text layer. Paired with task: "read_scan" and uploaded page images (step 8).
clippedThe Markdown you sent is not the whole document. The <!-- clipped: … --> marker must survive the repair.
dropped-imagesEmbedded 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

POST /estimate

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

POST /run
GET /jobs/{job_id}

/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.

BlockContents
<<<DOCDOC>>>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.
<<<CHANGESCHANGES>>>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

POST /run-stream

/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.

EventPayloadMeaning
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

POST /files
POST /run-stream

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

GET /drive/files?scope=shared
GET /drive/files/content?path=
POST /drive/files
GET /drive/grants

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

POST /extract

/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 MBor 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

POST /sessions
POST /sessions/{id}/messages
DELETE /sessions/{id}

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.

Modelled on the open-source anydoc project by Firecrawl (MIT). Not affiliated with Firecrawl.

Back to the app · API tokens · Repairs are AI-generated — reconcile before you trust them.