Clipper Forge — API

Turn a page into an importable Web Clipper template, from your own tools.

API tokens Open the app

Turn a page and an intent into an Obsidian Web Clipper template from your own pipeline

Send what the note should capture, the page's measured facts and a head-and-tail excerpt of its HTML, and get back one JSON object: a template that imports straight into Obsidian's Web Clipper — schemaVersion, name, behavior, noteNameFormat, path, the frontmatter properties, the auto-select triggers and the noteContentFormat body — plus rationale_notes explaining each non-obvious choice and unverified listing anything the facts could not vouch for. The output is deterministic to check: the app's own clipkit.js re-verifies every selector, meta key and schema path the template uses against the same page, and your pipeline can do the same. Wire it into a vault toolchain to draft a template per site, regenerate a whole template library after a site redesign, or gate a template repo on the grounding check. Every code 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 clipper-forge. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. Templates are written by the gpt-terra model alias (currently gpt-5.6-terra) at a publisher markup of 1000 bps — 10%. Credits are in units of 1/10 000 of a US dollar, so 10 000 credits is $1.00.

POST /guest GET /me POST /estimate POST /run GET /jobs/{id} POST /run-stream POST /collections/templates/query

Error codes

HTTPcodeWhat it means and what to do
400validation_errorThe body is missing a required field or a field has the wrong type. error.details names it. POST /guest in particular needs slug in the body — an X-App-Slug header is not accepted.
401unauthorizedNo token, a malformed token, or a token that has expired. Mint a new guest token or sign in again.
402payment_requiredThe balance cannot cover this run's minimum. Call /estimate first and compare min_credits against /me's credits.
404not_foundUnknown job id, unknown collection, or a record that belongs to another subject. Guest identities are per-token: a new guest token cannot see the previous guest's records.
409conflictAn Idempotency-Key was reused with a different body. Change the attempt counter in the key when the input changes.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
500internal_errorTransient. Retry with the same Idempotency-Key so the retry cannot bill twice.
The one call that costs money is /run and /run-stream. /guest, /me and /estimate are free, so a client can price a run, check the balance and prove the model binding without spending anything.

Step 1 · Get a token

Two ways in. If you already use the app in a browser, open the token page at /tokens.html and press Copy shell export — it hands you the exact export SKILLSAFE_TOKEN="…" line, with no DevTools console involved. For a fully scripted client, POST /guest mints a guest token with no browser at all. Guest tokens can call /me and the free /estimate; a personal token is what bills template runs to your own account.

# Option A — take the token this browser already has: open /tokens.html,
# press "Copy shell export", and paste the line it gives you.
export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"

# Option B — mint a guest token with no browser at all. Guest tokens can call
# /me and the free /estimate; sign in for a personal token to bill template
# runs to your own account.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H 'Content-Type: application/json' \
  -d '{"slug":"clipper-forge"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import os, json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "clipper-forge"

def call(path, body=None, token=None, method=None):
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(BASE + path, data=data,
                                method=method or ("POST" if data else "GET"))
    req.add_header("Content-Type", "application/json")
    req.add_header("User-Agent", "clipper-forge-client/1.0")
    if token:
        req.add_header("Authorization", "Bearer " + token)
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())["data"]

# Option A: the token from /tokens.html, kept in your environment.
token = os.environ.get("SKILLSAFE_TOKEN")

# Option B: a fresh guest token, no browser involved.
if not token:
    token = call("/guest", {"slug": SLUG})["token"]

print(token[:12] + "...")
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "clipper-forge";

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

// Option A: paste the token from /tokens.html (or read it from your own config).
let token = "YOUR_TOKEN";

// Option B: mint a guest token — good for /me and the free /estimate.
if (token === "YOUR_TOKEN") token = (await call("/guest", { body: { slug: SLUG } })).token;

console.log(token.slice(0, 12) + "...");
package main

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

const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "clipper-forge"

type envelope struct {
    Data  json.RawMessage `json:"data"`
    Error *struct {
        Code    string `json:"code"`
        Message string `json:"message"`
    } `json:"error"`
}

func call(path, token string, body any, out any) error {
    var rdr io.Reader
    method := "GET"
    if body != nil {
        b, _ := json.Marshal(body)
        rdr = bytes.NewReader(b)
        method = "POST"
    }
    req, _ := http.NewRequest(method, base+path, rdr)
    req.Header.Set("Content-Type", "application/json")
    if token != "" {
        req.Header.Set("Authorization", "Bearer "+token)
    }
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return err
    }
    defer res.Body.Close()
    var env envelope
    if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
        return err
    }
    if env.Error != nil {
        return errors.New(env.Error.Code + ": " + env.Error.Message)
    }
    if out != nil {
        return json.Unmarshal(env.Data, out)
    }
    return nil
}

func main() {
    token := os.Getenv("SKILLSAFE_TOKEN")
    if token == "" {
        var guest struct{ Token string `json:"token"` }
        if err := call("/guest", "", map[string]string{"slug": slug}, &guest); err != nil {
            panic(err)
        }
        token = guest.Token
    }
    fmt.Println(token[:12] + "...")
}
import java.net.URI;
import java.net.http.*;
import java.util.Map;

public class ClipperForge {
    static final String BASE = "https://api.skillsafe.ai/v1/app-api";
    static final String SLUG = "clipper-forge";
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String call(String path, String token, String jsonBody) throws Exception {
        HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + path))
            .header("Content-Type", "application/json");
        if (token != null) b.header("Authorization", "Bearer " + token);
        b = jsonBody == null ? b.GET()
                             : b.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
        HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
        return res.body();   // {"data":...} or {"error":{...}} — parse with your JSON library
    }

    public static void main(String[] args) throws Exception {
        String token = System.getenv("SKILLSAFE_TOKEN");
        if (token == null) {
            // POST /guest returns {"data":{"token":"aut_..."}}
            System.out.println(call("/guest", null, "{\"slug\":\"" + SLUG + "\"}"));
        } else {
            System.out.println(token.substring(0, 12) + "...");
        }
    }
}
require "json"
require "net/http"

BASE = URI("https://api.skillsafe.ai/v1/app-api")
SLUG = "clipper-forge"

def call(path, body: nil, token: nil, method: nil)
  uri = URI(BASE.to_s + path)
  req = (method || (body ? "POST" : "GET")) == "POST" ?
    Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}" if token
  req.body = JSON.generate(body) if body
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  json = JSON.parse(res.body)
  raise "#{json['error']['code']}: #{json['error']['message']}" if json["error"]
  json["data"]
end

token = ENV["SKILLSAFE_TOKEN"] || call("/guest", body: { slug: SLUG })["token"]
puts token[0, 12] + "..."
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "clipper-forge";

function call(string $path, ?array $body = null, ?string $token = null): array {
    $headers = ["Content-Type: application/json"];
    if ($token) { $headers[] = "Authorization: Bearer " . $token; }
    $ch = curl_init(BASE . $path);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => $headers,
    ]);
    if ($body !== null) {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    $json = json_decode(curl_exec($ch), true);
    curl_close($ch);
    if (isset($json["error"])) {
        throw new RuntimeException($json["error"]["code"] . ": " . $json["error"]["message"]);
    }
    return $json["data"];
}

$token = getenv("SKILLSAFE_TOKEN") ?: call("/guest", ["slug" => SLUG])["token"];
echo substr($token, 0, 12) . "...\n";
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading.Tasks;

class ClipperForge {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    const string Slug = "clipper-forge";
    static readonly HttpClient Http = new HttpClient();

    static async Task<JsonElement> Call(string path, object body = null, string token = null) {
        var req = new HttpRequestMessage(body == null ? HttpMethod.Get : HttpMethod.Post, Base + path);
        if (token != null) req.Headers.Add("Authorization", "Bearer " + token);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
        if (doc.RootElement.TryGetProperty("error", out var err))
            throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
        return doc.RootElement.GetProperty("data");
    }

    static async Task Main() {
        var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
        if (token == null) {
            var guest = await Call("/guest", new { slug = Slug });
            token = guest.GetProperty("token").GetString();
        }
        Console.WriteLine(token.Substring(0, 12) + "...");
    }
}

Step 2 · Check who you are and what you can spend

GET /me returns subject_type (user or guest), subject_id and credits. Compare credits against /estimate's min_credits before submitting a run — a 402 after submit is a client bug, not a user problem.

curl -s https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# => {"data":{"subject_type":"user","subject_id":"usr_...","credits":184213}}
#
# subject_type is "user" for a personal token and "guest" for a guest one.
# credits is in credit units: 10 000 credits = $1.00.
me = call("/me", token=token)
print(me["subject_type"], me["credits"], "credits",
      "= $%.2f" % (me["credits"] / 10000))
const me = await call("/me", { token });
console.log(me.subject_type, me.credits, "credits =",
  "$" + (me.credits / 10000).toFixed(2));
var me struct {
    SubjectType string `json:"subject_type"`
    SubjectID   string `json:"subject_id"`
    Credits     int64  `json:"credits"`
}
if err := call("/me", token, nil, &me); err != nil {
    panic(err)
}
fmt.Printf("%s %d credits = $%.2f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
// GET /me — {"data":{"subject_type":"user","credits":184213}}
String me = call("/me", token, null);
System.out.println(me);
me = call("/me", token: token)
puts "#{me['subject_type']} #{me['credits']} credits = $#{'%.2f' % (me['credits'] / 10000.0)}"
$me = call("/me", null, $token);
printf("%s %d credits = $%.2f\n", $me["subject_type"], $me["credits"], $me["credits"] / 10000);
var me = await Call("/me", null, token);
var credits = me.GetProperty("credits").GetInt64();
Console.WriteLine($"{me.GetProperty("subject_type").GetString()} {credits} credits = ${credits / 10000.0:F2}");

Step 3 · Price the run — free, and it proves the model binding

POST /estimate takes the same body as /run, creates no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. Present hold_credits as reserved, never as the price: the hold covers the full output cap, and the settled charged_credits is usually far lower.

# /estimate is free: no job is created, no credits are held, nothing is charged.
# Use it to show a price and to prove the model binding before you spend anything.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d @clip-input.json
# => {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#             "hold_credits":3120,"min_credits":260,"sponsor_enabled":false}}
est = call("/estimate", body=clip_input, token=token)
print("model", est["model"], "alias", est["model_alias"], "markup", est["markup_bps"])
print("reserved up to $%.4f" % (est["hold_credits"] / 10000))
if me["credits"] < est["min_credits"]:
    raise SystemExit("balance below the model minimum — top up before running")
const est = await call("/estimate", { body: clipInput, token });
console.log(est.model, est.model_alias, est.markup_bps);
console.log("reserved up to $" + (est.hold_credits / 10000).toFixed(4));
if (me.credits < est.min_credits) throw new Error("balance below the model minimum");
var est struct {
    Model       string `json:"model"`
    ModelAlias  string `json:"model_alias"`
    MarkupBps   int    `json:"markup_bps"`
    HoldCredits int64  `json:"hold_credits"`
    MinCredits  int64  `json:"min_credits"`
}
if err := call("/estimate", token, clipInput, &est); err != nil {
    panic(err)
}
fmt.Printf("%s (%s) markup %d bps, reserve $%.4f\n",
    est.Model, est.ModelAlias, est.MarkupBps, float64(est.HoldCredits)/10000)
// POST /estimate with the same body you would send to /run. Free, no job.
String est = call("/estimate", token, clipInputJson);
System.out.println(est);
est = call("/estimate", body: clip_input, token: token)
puts "#{est['model']} (#{est['model_alias']}) markup #{est['markup_bps']} bps"
puts "reserved up to $#{'%.4f' % (est['hold_credits'] / 10000.0)}"
$est = call("/estimate", $clip_input, $token);
printf("%s (%s) markup %d bps, reserve $%.4f\n",
    $est["model"], $est["model_alias"], $est["markup_bps"], $est["hold_credits"] / 10000);
var est = await Call("/estimate", clipInput, token);
Console.WriteLine(est.GetProperty("model").GetString() + " / " +
                  est.GetProperty("model_alias").GetString() + " markup " +
                  est.GetProperty("markup_bps").GetInt32() + " bps");

Step 4 · Run the template pass and poll for it

POST /run returns {"job_id"}; poll GET /jobs/{id} until status is succeeded or failed, then read data.output.output — the result as a JSON string. Always send Idempotency-Key, derived from the input plus an attempt counter: a network blip or a retry after a malformed reply must never bill the same template twice. Reuse the key for a retry of the same input; bump the attempt counter only when the input itself changes.

# Metered. Always send Idempotency-Key: a retry with the same key returns the
# same job instead of billing twice.
KEY="clipper-forge:$(shasum -a 256 clip-input.json | cut -c1-16):a1"

JOB=$(curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $KEY" \
  -d @clip-input.json | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal.
while true; do
  OUT=$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
    -H "Authorization: Bearer $SKILLSAFE_TOKEN")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'
# => {"template":{...},"rationale_notes":[...],"unverified":[]}
# The "template" object is the file you import in Obsidian:
# Settings → Web Clipper → Templates → Import.
import hashlib, time

def idem_key(inp, attempt=1):
    seed = " ".join(str(inp.get(k, "")) for k in
                    ("intent", "page_url", "behavior"))
    return "clipper-forge:%s:a%d" % (hashlib.sha256(seed.encode()).hexdigest()[:16], attempt)

def run_template(inp, token, attempt=1):
    data = json.dumps(inp).encode()
    req = urllib.request.Request(BASE + "/run", data=data, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + token)
    req.add_header("Idempotency-Key", idem_key(inp, attempt))
    with urllib.request.urlopen(req) as r:
        job_id = json.loads(r.read())["data"]["job_id"]
    while True:
        job = call("/jobs/" + job_id, token=token)
        if job["status"] in ("succeeded", "failed"):
            break
        time.sleep(2)
    if job["status"] == "failed":
        raise RuntimeError(job.get("error") or "run failed")
    return json.loads(job["output"]["output"])

result = run_template(clip_input, token)
tpl = result["template"]
print(tpl["name"], "-", len(tpl["properties"]), "properties,",
      len(tpl["triggers"]), "triggers")
open("template.json", "w").write(json.dumps(tpl, indent=2))
import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";

function idemKey(inp, attempt = 1) {
  const seed = ["intent", "page_url", "behavior"]
    .map((k) => String(inp[k] ?? "")).join(" ");
  return `clipper-forge:${createHash("sha256").update(seed).digest("hex").slice(0, 16)}:a${attempt}`;
}

async function runTemplate(inp, token, attempt = 1) {
  const res = await fetch(BASE + "/run", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + token,
      "Idempotency-Key": idemKey(inp, attempt),
    },
    body: JSON.stringify(inp),
  });
  const { data, error } = await res.json();
  if (error) throw new Error(error.message);
  let job;
  do {
    await new Promise((r) => setTimeout(r, 2000));
    job = await call("/jobs/" + data.job_id, { token });
  } while (job.status !== "succeeded" && job.status !== "failed");
  if (job.status === "failed") throw new Error(job.error || "run failed");
  return JSON.parse(job.output.output);
}

const result = await runTemplate(clipInput, token);
console.log(result.template.name, "-", result.template.triggers.length + " triggers");
writeFileSync("template.json", JSON.stringify(result.template, null, 2));
import (
    "crypto/sha256"
    "encoding/hex"
    "strings"
    "time"
)

func idemKey(inp map[string]any, attempt int) string {
    parts := []string{}
    for _, k := range []string{"intent", "page_url", "behavior"} {
        parts = append(parts, fmt.Sprint(inp[k]))
    }
    sum := sha256.Sum256([]byte(strings.Join(parts, " ")))
    return fmt.Sprintf("clipper-forge:%s:a%d", hex.EncodeToString(sum[:])[:16], attempt)
}

// POST /run with the Idempotency-Key header, then poll GET /jobs/{id} every two
// seconds until status is "succeeded" or "failed". job.Output.Output holds the
// result as a JSON string; unmarshal it and write .template to a file to import.
func runTemplate(inp map[string]any, token string) (string, error) {
    b, _ := json.Marshal(inp)
    req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Idempotency-Key", idemKey(inp, 1))
    res, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", err
    }
    defer res.Body.Close()
    var env envelope
    json.NewDecoder(res.Body).Decode(&env)
    var started struct{ JobID string `json:"job_id"` }
    json.Unmarshal(env.Data, &started)
    for {
        var job struct {
            Status string `json:"status"`
            Output struct{ Output string `json:"output"` } `json:"output"`
        }
        if err := call("/jobs/"+started.JobID, token, nil, &job); err != nil {
            return "", err
        }
        if job.Status == "succeeded" {
            return job.Output.Output, nil
        }
        if job.Status == "failed" {
            return "", errors.New("run failed")
        }
        time.Sleep(2 * time.Second)
    }
}
// POST /run must carry Idempotency-Key, derived from the input plus an attempt
// counter, so a network retry cannot bill the template twice.
String key = "clipper-forge:" + sha256Hex(intent + pageUrl + behavior).substring(0, 16) + ":a1";

HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(clipInputJson))
    .build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// started => {"data":{"job_id":"job_..."}}
// then poll GET /jobs/{job_id} until status is succeeded or failed, and read
// data.output.output — {"template":{...},"rationale_notes":[...],"unverified":[]}
// as a JSON string. Write the "template" object out and import it in Obsidian.
require "digest"

def idem_key(inp, attempt = 1)
  seed = %w[intent page_url behavior].map { |k| inp[k].to_s }.join(" ")
  "clipper-forge:#{Digest::SHA256.hexdigest(seed)[0, 16]}:a#{attempt}"
end

def run_template(inp, token)
  uri = URI(BASE.to_s + "/run")
  req = Net::HTTP::Post.new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}"
  req["Idempotency-Key"] = idem_key(inp)
  req.body = JSON.generate(inp)
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
  job_id = JSON.parse(res.body)["data"]["job_id"]
  loop do
    job = call("/jobs/#{job_id}", token: token)
    return JSON.parse(job["output"]["output"]) if job["status"] == "succeeded"
    raise "run failed" if job["status"] == "failed"
    sleep 2
  end
end

result = run_template(clip_input, token)
tpl = result["template"]
puts "#{tpl['name']} - #{tpl['properties'].length} properties"
File.write("template.json", JSON.pretty_generate(tpl))
function idem_key(array $inp, int $attempt = 1): string {
    $seed = implode(" ", array_map(fn($k) => (string)($inp[$k] ?? ""),
        ["intent", "page_url", "behavior"]));
    return "clipper-forge:" . substr(hash("sha256", $seed), 0, 16) . ":a" . $attempt;
}

function run_template(array $inp, string $token): array {
    $ch = curl_init(BASE . "/run");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode($inp),
        CURLOPT_HTTPHEADER => [
            "Content-Type: application/json",
            "Authorization: Bearer " . $token,
            "Idempotency-Key: " . idem_key($inp),
        ],
    ]);
    $job_id = json_decode(curl_exec($ch), true)["data"]["job_id"];
    curl_close($ch);
    while (true) {
        $job = call("/jobs/" . $job_id, null, $token);
        if ($job["status"] === "succeeded") { return json_decode($job["output"]["output"], true); }
        if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
        sleep(2);
    }
}

$result = run_template($clip_input, $token);
$tpl = $result["template"];
echo $tpl["name"] . " - " . count($tpl["properties"]) . " properties\n";
file_put_contents("template.json", json_encode($tpl, JSON_PRETTY_PRINT));
using System.Security.Cryptography;
using System.Text;

static string IdemKey(Dictionary<string, object> inp, int attempt = 1) {
    var seed = string.Join(" ", new[] { "intent", "page_url", "behavior" }
        .Select(k => inp.TryGetValue(k, out var v) ? v?.ToString() ?? "" : ""));
    var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(seed))).ToLowerInvariant();
    return $"clipper-forge:{hash[..16]}:a{attempt}";
}

var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
    Content = JsonContent.Create(clipInput)
};
req.Headers.Add("Authorization", "Bearer " + token);
req.Headers.Add("Idempotency-Key", IdemKey(clipInput));
var started = JsonDocument.Parse(await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
var jobId = started.RootElement.GetProperty("data").GetProperty("job_id").GetString();

// Poll GET /jobs/{jobId} every two seconds; on "succeeded", data.output.output is
// {"template":{...},"rationale_notes":[...],"unverified":[]} as a JSON string.
// Save the "template" object and import it in Obsidian's Web Clipper settings.

Step 5 · Or stream it

POST /run-stream is the same call over server-sent events, which is what the web app uses so it can show progress. The frame name arrives on the event: line — job, delta, done — and is not a type field inside the payload. Concatenate every delta payload's text to rebuild the JSON, and read charged_credits and truncated from the done frame. If truncated is true the output cap was reduced to fit the balance: a clipped noteContentFormat is not a finished template, so say so rather than importing it.

# Server-sent events. Frame names arrive on the `event:` line, not as a field in
# the payload — `delta` carries text chunks, `job` the job id, `done` the
# settlement (charged_credits, truncated).
curl -N -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $KEY" \
  -d @clip-input.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"template\":{\"schemaVersion\":\"0.1.0\",\"name\":\"Rec"}
# ...
# event: done
# data: {"status":"succeeded","charged_credits":812,"truncated":false}
def run_stream(inp, token, attempt=1, on_delta=None):
    data = json.dumps(inp).encode()
    req = urllib.request.Request(BASE + "/run-stream", data=data, method="POST")
    req.add_header("Content-Type", "application/json")
    req.add_header("Authorization", "Bearer " + token)
    req.add_header("Idempotency-Key", idem_key(inp, attempt))
    raw, event = "", None
    with urllib.request.urlopen(req) as r:
        for line in r:
            line = line.decode().rstrip("\n")
            if line.startswith("event:"):
                event = line[6:].strip()
            elif line.startswith("data:"):
                payload = json.loads(line[5:].strip() or "{}")
                if event == "delta":
                    raw += payload.get("text", "")
                    if on_delta:
                        on_delta(payload.get("text", ""))
                elif event == "done":
                    return json.loads(raw), payload
    raise RuntimeError("stream ended without a done frame")

result, settle = run_stream(clip_input, token)
print(result["template"]["name"], "charged", settle["charged_credits"])
if result["unverified"]:
    print("model could not ground:", "; ".join(result["unverified"]))
async function runStream(inp, token, onDelta, attempt = 1) {
  const res = await fetch(BASE + "/run-stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + token,
      "Idempotency-Key": idemKey(inp, attempt),
    },
    body: JSON.stringify(inp),
  });
  const reader = res.body.getReader();
  const dec = new TextDecoder();
  let buf = "", raw = "", event = null;
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += dec.decode(value, { stream: true });
    const lines = buf.split("\n");
    buf = lines.pop();
    for (const line of lines) {
      if (line.startsWith("event:")) event = line.slice(6).trim();
      else if (line.startsWith("data:")) {
        const payload = JSON.parse(line.slice(5).trim() || "{}");
        if (event === "delta") { raw += payload.text || ""; onDelta?.(payload.text || ""); }
        else if (event === "done") return { result: JSON.parse(raw), settle: payload };
      }
    }
  }
  throw new Error("stream ended without a done frame");
}

let chars = 0;
const { result, settle } = await runStream(clipInput, token, (t) => { chars += t.length; });
console.log(result.template.name, "charged", settle.charged_credits, "-", chars, "chars");
// POST /run-stream and read the SSE frames. The frame name is on the `event:`
// line; `delta` payloads carry {"text":"..."} and concatenate into the result JSON.
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", idemKey(inp, 1))
res, err := http.DefaultClient.Do(req)
if err != nil {
    panic(err)
}
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
var raw strings.Builder
event := ""
for sc.Scan() {
    line := sc.Text()
    switch {
    case strings.HasPrefix(line, "event:"):
        event = strings.TrimSpace(line[6:])
    case strings.HasPrefix(line, "data:"):
        payload := strings.TrimSpace(line[5:])
        if event == "delta" {
            var d struct{ Text string `json:"text"` }
            json.Unmarshal([]byte(payload), &d)
            raw.WriteString(d.Text)
        } else if event == "done" {
            fmt.Println("settled:", payload)
            fmt.Println("result:", raw.String())
            return
        }
    }
}
// POST /run-stream with BodyHandlers.ofLines() and fold the SSE frames yourself.
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
    .header("Content-Type", "application/json")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(clipInputJson))
    .build();

StringBuilder raw = new StringBuilder();
String[] event = { "" };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
    if (line.startsWith("event:")) {
        event[0] = line.substring(6).trim();
    } else if (line.startsWith("data:") && event[0].equals("delta")) {
        // parse {"text":"..."} with your JSON library and append it
        raw.append(extractText(line.substring(5).trim()));
    }
});
System.out.println(raw);   // {"template":{...},"rationale_notes":[...],"unverified":[]}
def run_stream(inp, token, attempt = 1)
  uri = URI(BASE.to_s + "/run-stream")
  req = Net::HTTP::Post.new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}"
  req["Idempotency-Key"] = idem_key(inp, attempt)
  req.body = JSON.generate(inp)
  raw = ""
  event = nil
  settle = nil
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
    http.request(req) do |res|
      res.read_body do |chunk|
        chunk.each_line do |line|
          line = line.chomp
          if line.start_with?("event:")
            event = line[6..].strip
          elsif line.start_with?("data:")
            payload = JSON.parse(line[5..].strip.empty? ? "{}" : line[5..].strip)
            raw << payload.fetch("text", "") if event == "delta"
            settle = payload if event == "done"
          end
        end
      end
    end
  end
  [JSON.parse(raw), settle]
end

result, settle = run_stream(clip_input, token)
puts "#{result['template']['name']} charged #{settle['charged_credits']}"
// POST /run-stream with a write callback; the frame name arrives on `event:`.
$raw = "";
$event = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode($clip_input),
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer " . $token,
        "Idempotency-Key: " . idem_key($clip_input),
    ],
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
        foreach (explode("\n", $chunk) as $line) {
            $line = rtrim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:") && $event === "delta") {
                $payload = json_decode(trim(substr($line, 5)), true) ?: [];
                $raw .= $payload["text"] ?? "";
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$result = json_decode($raw, true);
echo $result["template"]["name"] . "\n";
var sreq = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream") {
    Content = JsonContent.Create(clipInput)
};
sreq.Headers.Add("Authorization", "Bearer " + token);
sreq.Headers.Add("Idempotency-Key", IdemKey(clipInput));

using var sres = await Http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) != null) {
    if (line.StartsWith("event:")) {
        evt = line[6..].Trim();
    } else if (line.StartsWith("data:")) {
        var payload = JsonDocument.Parse(line[5..].Trim() is { Length: > 0 } s ? s : "{}");
        if (evt == "delta" && payload.RootElement.TryGetProperty("text", out var t))
            raw.Append(t.GetString());
        else if (evt == "done")
            Console.WriteLine("settled: " + payload.RootElement);
    }
}
Console.WriteLine(raw.ToString());

Step 6 · Read the template history — and search it by meaning

Past templates are stored in a declared collection named templates, with name, site, intent, schema_types, lint_fails and ran_at as indexed fields, and name, site and intent as the embedded (vector-searchable) ones — so “the recipe one with the ingredient checkboxes” finds it without remembering the domain. Every where entry must be an operator object ({"eq": …}); a bare value is rejected. Operators: eq ne lt lte gt gte in contains. Records are scoped to the calling subject, and each POST /guest mints a new guest identity, so reuse one token across writes and reads. The template JSON, the full model result and a capped copy of the pasted page HTML ride along as undeclared keys — stored and returned intact, just not filterable. Documents are capped at 64 KB, so the app drops the stored HTML first and marks the record doc_trimmed.

Writing records. The query endpoint is POST /collections/templates/query, but the record CRUD paths sit under /records and wrap the document in a doc envelope:
POST /collections/templates/records with {"doc": {…}}{"data":{"record":{"record_id":"rec_…"}}}
GET /collections/templates/records/{record_id} · PUT /collections/templates/records/{record_id} · DELETE /collections/templates/records/{record_id}
Semantic search is POST /collections/templates/similar with {"text": "the recipe one with ingredient checkboxes", "limit": 8} — each hit carries a cosine score. It is rate-limited to 30 requests/minute per IP and costs roughly ten times a filtered query, so debounce it and prefer where whenever an exact match would do. Indexing is asynchronous and only records written after the collection was declared are searchable.
# Filtered query: clean templates for one site, newest first.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/templates/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"where":{"site":{"eq":"example.com"},"lint_fails":{"eq":0}},
       "sort":{"field":"ran_at","dir":"desc"},"limit":20}'

# Semantic search over name + site + intent (30/min per IP; ~10x a query):
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/templates/similar \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"text":"the recipe one with ingredient checkboxes","limit":8}'
res = call("/collections/templates/query", body={
    "where": {"lint_fails": {"eq": 0}},
    "sort": {"field": "ran_at", "dir": "desc"},
    "limit": 24,
}, token=token)
for rec in res["records"]:
    d = rec["doc"]
    print(d["ran_at"], d["name"], "-", d["site"], "-", d["schema_types"])

hits = call("/collections/templates/similar",
            body={"text": "the recipe one with ingredient checkboxes", "limit": 8},
            token=token)
for rec in hits["records"]:
    print("%.2f" % rec.get("score", 0), rec["doc"]["name"])
const res = await call("/collections/templates/query", {
  token,
  body: {
    where: { lint_fails: { eq: 0 } },
    sort: { field: "ran_at", dir: "desc" },
    limit: 24,
  },
});
for (const rec of res.records) {
  const d = rec.doc;
  console.log(d.ran_at, d.name, "-", d.site, "-", d.schema_types);
}

const hits = await call("/collections/templates/similar", {
  token,
  body: { text: "the recipe one with ingredient checkboxes", limit: 8 },
});
for (const rec of hits.records) console.log(rec.score, rec.doc.name);
// POST /collections/templates/query with an operator object per where field.
query := map[string]any{
    "where": map[string]any{"lint_fails": map[string]any{"eq": 0}},
    "sort":  map[string]string{"field": "ran_at", "dir": "desc"},
    "limit": 24,
}
var res struct {
    Records []struct {
        RecordID string         `json:"record_id"`
        Doc      map[string]any `json:"doc"`
    } `json:"records"`
}
if err := call("/collections/templates/query", token, query, &res); err != nil {
    panic(err)
}
for _, r := range res.Records {
    fmt.Println(r.Doc["ran_at"], r.Doc["name"], r.Doc["site"])
}
// POST /collections/templates/query
String q = "{\"where\":{\"lint_fails\":{\"eq\":0}}," +
           "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":24}";
System.out.println(call("/collections/templates/query", token, q));
// Semantic search: POST /collections/templates/similar {"text":"...","limit":8}
res = call("/collections/templates/query", body: {
  "where" => { "lint_fails" => { "eq" => 0 } },
  "sort" => { "field" => "ran_at", "dir" => "desc" },
  "limit" => 24,
}, token: token)
res["records"].each do |rec|
  d = rec["doc"]
  puts "#{d['ran_at']} #{d['name']} - #{d['site']} - #{d['schema_types']}"
end
$res = call("/collections/templates/query", [
    "where" => ["lint_fails" => ["eq" => 0]],
    "sort" => ["field" => "ran_at", "dir" => "desc"],
    "limit" => 24,
], $token);
foreach ($res["records"] as $rec) {
    $d = $rec["doc"];
    echo "{$d['ran_at']} {$d['name']} - {$d['site']}\n";
}
var q = new {
    where = new { lint_fails = new { eq = 0 } },
    sort = new { field = "ran_at", dir = "desc" },
    limit = 24
};
var res = await Call("/collections/templates/query", q, token);
foreach (var rec in res.GetProperty("records").EnumerateArray()) {
    var d = rec.GetProperty("doc");
    Console.WriteLine($"{d.GetProperty("ran_at")} {d.GetProperty("name")}");
}

The input schema

These are the exact fields the app submits. The page is analyzed locally before the run: every meta tag, every schema.org JSON-LD path with a sample value and every CSS selector that actually matches becomes a measured fact in page_facts, and that is what the reply is held to — a selector, meta key or schema path used in the template that appears nowhere in page_facts is printed by name next to the rendered template. Long pages are clipped head-and-tail for the wire (the app keeps the start and the end with a cut marker in the middle, and the marker says how many characters went), but page_facts is computed from the full HTML, so nothing the analyzer saw is lost. A client that computes no facts may send an empty object; the template still gets written, it simply has nothing to be grounded against.

FieldTypeMeaning
intentstringWhat the note should capture and how it should look. Required in practice — this is what the template is shaped around.
page_urlstringA sample page URL, possibly empty. Sets the domain and the URL-prefix triggers.
behaviorstringOne of create, append-specific, append-daily — and it constrains path: a folder for create, a full .md file path for append-specific.
folder_hintstringVault folder or note path the user wants, possibly empty. Honoured verbatim when given.
note_name_hintstringFilename pattern wish, possibly empty — e.g. {{date}} - {{title}}.
current_templatestringOptional: an existing template, as a JSON string, to refine instead of starting over.
refine_notestringWhat to change about current_template. Only meaningful alongside it.
page_facts.title, domain, descriptionstringThe page's own title, host and meta description, measured from the parsed document.
page_facts.metaarray{attr, key, content} per meta tag — attr is name or property. The only keys {{meta:…}} may reference (first 60 sent).
page_facts.schema_typesarraySchema.org @type values found in the page's JSON-LD. The only types a schema: trigger may name (first 20 sent).
page_facts.schema_pathsarray{path, sample} per flattened JSON-LD path, e.g. Recipe:recipeIngredient. The only paths {{schema:…}} may reference (first 150 sent).
page_facts.selectorsarray{selector, count, sample} per CSS selector that actually matched. The only selectors {{selector:…}} may reference (first 40 sent).
page_facts.word_count, json_ld_countnumberHow much text the page carries and how many JSON-LD blocks it has.
page_facts.html_chars_total, html_chars_sentnumberHow much HTML exists and how much of it travelled — honest clipping, declared.
html_excerptstringHead and tail of the page HTML with a cut marker between them. Context, not evidence: the facts cover the full page.
current_datetimestringThe caller's local time, weekday included.
retry_notestringOptional, and normally absent. The web app adds it only when a reply failed to parse, describing how the output contract was broken; the prompt treats it as the highest-priority instruction and re-answers the same request in the correct shape. Send it yourself only if you are re-issuing a request after a malformed reply — and reuse the same Idempotency-Key base so the retry is not billed as a fresh run.

A complete body

A deliberately tiny recipe page, so the shape is readable. Real page_facts from an article carry dozens of meta tags, schema paths and selectors, and html_excerpt runs to thousands of characters.

{
  "intent": "Clip recipes into my Recipes folder — author and yield in the frontmatter, the ingredients as checkboxes, then the method, then a link back.",
  "page_url": "https://example.com/recipes/roast-chicken",
  "behavior": "create",
  "folder_hint": "Recipes/",
  "note_name_hint": "{{title}}",
  "current_template": "",
  "refine_note": "",
  "page_facts": {
    "title": "Simple Roast Chicken",
    "domain": "example.com",
    "description": "A one-pan roast chicken that takes ninety minutes.",
    "meta": [
      { "attr": "property", "key": "og:title", "content": "Simple Roast Chicken" },
      { "attr": "name", "key": "author", "content": "Dana Okoye" }
    ],
    "schema_types": ["Recipe"],
    "schema_paths": [
      { "path": "Recipe:name", "sample": "Simple Roast Chicken" },
      { "path": "Recipe:recipeYield", "sample": "4 servings" },
      { "path": "Recipe:recipeIngredient", "sample": "1 whole chicken, about 1.6 kg" }
    ],
    "selectors": [
      { "selector": "h1", "count": 1, "sample": "Simple Roast Chicken" },
      { "selector": ".ingredients li", "count": 6, "sample": "1 whole chicken, about 1.6 kg" },
      { "selector": ".method p", "count": 4, "sample": "Heat the oven to 220C." }
    ],
    "word_count": 412,
    "json_ld_count": 1,
    "html_chars_total": 88000,
    "html_chars_sent": 8000
  },
  "html_excerpt": "<!doctype html><html><head><title>Simple Roast Chicken</title> … <!-- … 72,000 characters cut from the middle — page_facts covers the full page … --> … </body></html>",
  "current_datetime": "2026-08-07T12:00:00+08:00 (Friday)"
}

The output contract

The reply is one JSON object and nothing else. Parse defensively anyway: strip a stray code fence, take the span from the first { to the matching last } — which is exactly what ClipKit.parseTemplateText does — and re-ask once with the same idempotency seed and a bumped attempt counter if it does not parse. These are the fields the app's own validator requires, and the constraints it enforces.

FieldConstraint
template.schemaVersionAlways the string "0.1.0" — the Obsidian Web Clipper import schema this file targets.
template.nameNon-empty short display name; what the template is called in the Clipper's template list.
template.behaviorExactly one of create, append-specific, append-daily, and it must be the behavior that was requested.
template.noteNameFormatThe filename pattern, e.g. {{title}} or {{date}} - {{title}}. Variables here follow the same grounding rules as the body.
template.pathA folder ending in / for create; a full file path ending in .md for append-specific. Ignored for append-daily. A mismatch with behavior is a validation failure.
template.propertiesArray of {name, value, type} — the YAML frontmatter. type is one of text multitext number checkbox date datetime and nothing else. May be empty.
template.triggersArray of strings that auto-select the template: URL prefixes such as https://example.com/recipes/, and schema:Type entries — only for types listed in page_facts.schema_types.
template.noteContentFormatThe note body in Markdown, newlines as \n. Every {% if %} / {% for %} block must be balanced.
rationale_notesArray, may be empty. One line per non-obvious choice: why this variable, this trigger, this filter. Prompt variables that need the AI Interpreter belong here.
unverifiedArray. Anything used that page_facts could not vouch for. An empty array is a claim that everything in the template is grounded — and the app checks it.

A complete reply

{
  "template": {
    "schemaVersion": "0.1.0",
    "name": "Recipe (example.com)",
    "behavior": "create",
    "noteNameFormat": "{{title}}",
    "path": "Recipes/",
    "properties": [
      { "name": "author", "value": "{{meta:author}}", "type": "text" },
      { "name": "yield", "value": "{{schema:Recipe:recipeYield}}", "type": "text" },
      { "name": "source", "value": "{{url}}", "type": "text" },
      { "name": "clipped", "value": "{{date}}", "type": "date" }
    ],
    "triggers": ["https://example.com/recipes/", "schema:Recipe"],
    "noteContentFormat": "# {{title}}\n\n## Ingredients\n{{schema:Recipe:recipeIngredient|list}}\n\n## Method\n{{selector:.method p|list}}\n\n[Source]({{url}})\n"
  },
  "rationale_notes": [
    "Ingredients come from schema:Recipe:recipeIngredient rather than .ingredients li — JSON-LD survives a redesign.",
    "The method has no schema path on this page, so .method p is used; it matched 4 nodes."
  ],
  "unverified": []
}
The template object is the import file. Write it out on its own — not the whole reply — and import it in Obsidian under Settings → Web Clipper → Templates → Import.
Two prohibitions matter most. No invented variables: every selector, meta key and schema path must appear in page_facts, and preset variables are limited to the documented sixteen (content contentHtml title url author date published site description highlights selection fullHtml favicon image words domain) — the app re-checks this mechanically against the same page and names every unvouched variable. And no conjured facts: when the page carries no author or no published date, the property is omitted or guarded with a conditional and said so in rationale_notes — never filled with a plausible-looking selector.

The free lane is client-side, and you can have it too

The engine ships with the app as clipkit.js and calls no network: the page analyzer (meta tags, schema.org JSON-LD types and flattened paths with sample values, and a probe list of CSS selectors resolved against the real document), the grounded skeleton generator, the tolerant template parser, the import-schema validator (behaviors, property types, path shape, trigger form, template-logic balance, filter names) and the grounding check that re-verifies every variable a template uses against the page it claims to clip — plus a previewer that substitutes only the values the page actually carries. It exposes window.ClipKit.analyze(doc), makeQueryFn(doc), skeleton(facts, opts), parseTemplateText(text), validateTemplate(tpl, facts), preview(tpl, queryFn), extractTokens(str), extractLogic(str), schemaGrounded(path, facts), metaGrounded(key, facts) and summarize(findings). A pipeline can validate every template the model returns — or templates it wrote itself — without spending anything.

The pure parts (parseTemplateText, extractTokens, extractLogic, validateTemplate, summarize) are string work with no I/O and run under Node directly — the module exports itself via module.exports when there is no window. The DOM-dependent parts take an injectable query function, so pointing them at any HTML parser is enough. The same page always analyzes to the same facts and the same template always validates to the same findings, so a CI job can gate a template repo on zero grounding failures — including after a site redesign, by re-running the analyzer against a freshly fetched page.