Card Forge — API

Turn an API response into an Adaptive Card response template, from your own tools.

API tokens Open the app

Turn a sample API response and an intent into an Adaptive Card bundle from your own pipeline

Send what the card should show, the response's measured facts and the sample JSON itself, and get back one JSON object: a bundle with the response_semantics block that goes in your plugin manifest — data_path, the citation properties, a static_template and/or embedded_templates keyed by the selector values in your data — 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 cardkit.js re-evaluates every ${binding}, citation property and selector the bundle uses against the same sample, and your pipeline can do the same. Wire it into a plugin toolchain to draft a card per function, regenerate a whole card library after an API change, or gate a manifest 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 card-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. Card bundles 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/cards/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":"card-forge"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import os, json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "card-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", "card-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 = "card-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 = "card-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 CardForge {
    static final String BASE = "https://api.skillsafe.ai/v1/app-api";
    static final String SLUG = "card-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 = "card-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 = "card-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 CardForge {
    const string Base = "https://api.skillsafe.ai/v1/app-api";
    const string Slug = "card-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 @card-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=card_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: cardInput, 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, cardInput, &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, cardInputJson);
System.out.println(est);
est = call("/estimate", body: card_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", $card_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", cardInput, 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="card-forge:$(shasum -a 256 card-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 @card-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"])'
# => {"bundle":{...},"rationale_notes":[...],"unverified":[]}
# The "bundle" object carries response_semantics for ai-plugin.json plus any
# embedded templates your API should serve alongside the data.
import hashlib, time

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

def run_bundle(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_bundle(card_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 `card-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(cardInput, 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("card-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 = "card-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(cardInputJson))
    .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. Merge the "bundle" response_semantics into ai-plugin.json.
require "digest"

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

def run_bundle(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_bundle(card_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 "card-forge:" . substr(hash("sha256", $seed), 0, 16) . ":a" . $attempt;
}

function run_bundle(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_bundle($card_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 $"card-forge:{hash[..16]}:a{attempt}";
}

var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run") {
    Content = JsonContent.Create(cardInput)
};
req.Headers.Add("Authorization", "Bearer " + token);
req.Headers.Add("Idempotency-Key", IdemKey(cardInput));
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.
// Merge the "bundle" response_semantics into your ai-plugin.json manifest.

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 card body is not a finished bundle, so say so rather than shipping 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 @card-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(card_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(cardInput, 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(cardInputJson))
    .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(card_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($card_input),
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer " . $token,
        "Idempotency-Key: " . idem_key($card_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(cardInput)
};
sreq.Headers.Add("Authorization", "Bearer " + token);
sreq.Headers.Add("Idempotency-Key", IdemKey(cardInput));

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 bundle history — and search it by meaning

Past bundles are stored in a declared collection named cards, with name, function_name, intent, fields_summary, lint_fails and ran_at as indexed fields, and name, intent and fields_summary as the embedded (vector-searchable) ones — so “the money one with the red debits” finds it without remembering the function name. 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 bundle JSON, the full model result and a capped copy of the pasted sample response ride along as undeclared keys — stored and returned intact, just not filterable. Documents are capped at 64 KB, so the app drops the stored sample first and marks the record doc_trimmed.

Writing records. The query endpoint is POST /collections/cards/query, but the record CRUD paths sit under /records and wrap the document in a doc envelope:
POST /collections/cards/records with {"doc": {…}}{"data":{"record":{"record_id":"rec_…"}}}
GET /collections/cards/records/{record_id} · PUT /collections/cards/records/{record_id} · DELETE /collections/cards/records/{record_id}
Semantic search is POST /collections/cards/similar with {"text": "the money one with the red debits", "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 bundles for one plugin function, newest first.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/cards/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"where":{"function_name":{"eq":"GetBudgets"},"lint_fails":{"eq":0}},
       "sort":{"field":"ran_at","dir":"desc"},"limit":20}'

# Semantic search over name + intent + fields_summary (30/min per IP; ~10x a query):
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/cards/similar \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"text":"the money one with the red debits","limit":8}'
res = call("/collections/cards/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["function_name"], "-", d["fields_summary"])

hits = call("/collections/cards/similar",
            body={"text": "the money one with the red debits", "limit": 8},
            token=token)
for rec in hits["records"]:
    print("%.2f" % rec.get("score", 0), rec["doc"]["name"])
const res = await call("/collections/cards/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.function_name, "-", d.fields_summary);
}

const hits = await call("/collections/cards/similar", {
  token,
  body: { text: "the money one with the red debits", limit: 8 },
});
for (const rec of hits.records) console.log(rec.score, rec.doc.name);
// POST /collections/cards/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/cards/query", token, query, &res); err != nil {
    panic(err)
}
for _, r := range res.Records {
    fmt.Println(r.Doc["ran_at"], r.Doc["name"], r.Doc["function_name"])
}
// POST /collections/cards/query
String q = "{\"where\":{\"lint_fails\":{\"eq\":0}}," +
           "\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":24}";
System.out.println(call("/collections/cards/query", token, q));
// Semantic search: POST /collections/cards/similar {"text":"...","limit":8}
res = call("/collections/cards/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['function_name']} - #{d['fields_summary']}"
end
$res = call("/collections/cards/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['function_name']}\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/cards/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 sample response is analyzed locally before the run: every candidate data_path and every field with its type, a sample value and a presence count becomes a measured fact in response_facts, and that is what the reply is held to — a binding, citation property or selector used in the bundle that response_facts does not vouch for is printed by name next to the rendered card. Very large samples are clipped on a line boundary for the wire (the cut marker says how many characters went), but response_facts is computed from the full JSON, so nothing the analyzer saw is lost. A client that computes no facts may send an empty object; the bundle still gets written, it simply has nothing to be grounded against.

FieldTypeMeaning
intentstringWhat the card should show and emphasize. Required in practice — this is what the template is shaped around.
function_namestringThe plugin function the card belongs to, possibly empty — e.g. GetBudgets.
modestringOne of auto, static, dynamic, combined. auto lets the pass decide; a measured selector_field is strong evidence for dynamic.
data_path_hintstringA JSONPath the user picked for the item array, possibly empty. Honoured verbatim when it resolves.
current_bundlestringOptional: an existing bundle, as a JSON string, to refine instead of starting over.
refine_notestringWhat to change about current_bundle. Only meaningful alongside it.
response_facts.root_typestringobject or array — the shape of the sample's root.
response_facts.candidate_data_pathsarray{path, count} per array of objects found in the sample (first 8 sent). The only paths data_path may name.
response_facts.chosen_data_pathstringThe analyzer's pick (or the user's hint) — the reply's data_path must be this.
response_facts.item_count, sampled_countnumberHow many items the path selects and how many were sampled for the field inventory (up to 20).
response_facts.fieldsarray{path, type, sample, present_in} per field, dot paths relative to one item (first 60 sent). The only fields a ${binding} or citation property may reference; present_in below sampled_count means the binding needs a guard.
response_facts.selector_fieldstringA per-item field whose values name a template (e.g. displayTemplate), or empty. The only field template_selector may point at.
response_facts.bytes_total, bytes_sentnumberHow much sample JSON exists and how much of it travelled — honest clipping, declared.
response_excerptstringThe sample JSON. When it exceeds the cap it is clipped from the middle — the head and the tail are both kept, on line boundaries, with the cut announced in-band — because a response body carries meaning at both ends. Context, not evidence: the facts cover the full sample, so a field absent from the excerpt but listed in response_facts.fields is still real.
current_datetimestringThe caller's local time, weekday included.
retry_notestringOptional, and absent on a first attempt. The app sets it only when a previous reply could not be parsed as the single JSON object the contract requires; it names the parse error and asks for the same bundle again, correctly formatted. If you drive the API yourself you will not normally send it — and the retry reuses an Idempotency-Key derived from the same input, so a reformat never double-bills.

A complete body

A deliberately tiny budgets response, so the shape is readable. Real response_facts from a production API carry dozens of fields, and response_excerpt runs to thousands of characters.

{
  "intent": "One card per budget — the name up top, available funds as money, owner in a fact list, and an Open button.",
  "function_name": "GetBudgets",
  "mode": "static",
  "data_path_hint": "",
  "current_bundle": "",
  "refine_note": "",
  "response_facts": {
    "root_type": "object",
    "candidate_data_paths": [{ "path": "$.budgets", "count": 3 }],
    "chosen_data_path": "$.budgets",
    "item_count": 3,
    "sampled_count": 3,
    "fields": [
      { "path": "name", "type": "string", "sample": "Lobby renovation", "present_in": 3 },
      { "path": "availableFunds", "type": "number", "sample": 18250.5, "present_in": 3 },
      { "path": "owner", "type": "string", "sample": "Dana Whitcomb", "present_in": 3 },
      { "path": "category", "type": "string", "sample": "facilities", "present_in": 2 },
      { "path": "budgetUrl", "type": "string", "sample": "https://finance.example/budgets/1", "present_in": 3 }
    ],
    "selector_field": "",
    "bytes_total": 812,
    "bytes_sent": 812
  },
  "response_excerpt": "{ \"budgets\": [ { \"name\": \"Lobby renovation\", \"availableFunds\": 18250.5, … } ] }",
  "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 CardKit.parseJsonText 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
bundle.function_nameThe plugin function this bundle describes. Empty draws a warning — the exported ai-plugin.json snippet needs it.
bundle.modeExactly one of static, dynamic, combined. static/combined require static_template; dynamic/combined require template_selector and non-empty embedded_templates.
bundle.response_semantics.data_pathA JSONPath that selects the items in the sample — it must equal chosen_data_path (or the honoured hint). A path that selects nothing is a validation failure.
bundle.response_semantics.propertiestitle (expected), subtitle and url (optional) as $.field paths relative to one item, each resolving on at least one sampled item; template_selector only in dynamic/combined and only pointing at the measured selector_field.
bundle.response_semantics.static_templateAn Adaptive Card: type "AdaptiveCard", $schema, version "1.5", non-empty body. Element types and enum values only from the documented vocabulary; content TextBlocks carry wrap: true; no fixed pixel widths beyond icon size.
bundle.embedded_templatesObject mapping each template name the selector values reference (the tail of the value, e.g. $.templates.debitdebit) to an Adaptive Card under the same rules. {} for pure static.
every ${binding}Template language limited to item field paths from response_facts.fields, $root, $index, string literals, ==/!=, if(cond, a, b) and formatNumber(v, d). Each binding must resolve on at least one sampled item; fields with present_in below sampled_count need an if() guard or $when.
rationale_notesArray, may be empty. One line per non-obvious choice: why this mode, this data_path, this guard, this layout.
unverifiedArray. Anything used that response_facts could not vouch for. An empty array is a claim that everything in the bundle is grounded — and the app checks it.

A complete reply

{
  "bundle": {
    "function_name": "GetBudgets",
    "mode": "static",
    "response_semantics": {
      "data_path": "$.budgets",
      "properties": { "title": "$.name", "subtitle": "$.owner", "url": "$.budgetUrl" },
      "static_template": {
        "type": "AdaptiveCard",
        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
        "version": "1.5",
        "body": [
          {
            "type": "Container",
            "$data": "${$root}",
            "items": [
              { "type": "TextBlock", "size": "medium", "weight": "bolder", "wrap": true, "text": "${name}" },
              { "type": "FactSet", "facts": [
                { "title": "Available funds", "value": "${formatNumber(availableFunds, 2)}" },
                { "title": "Owner", "value": "${owner}" },
                { "title": "Category", "value": "${if(category, category, 'N/A')}" }
              ] }
            ]
          }
        ],
        "actions": [{ "type": "Action.OpenUrl", "title": "Open budget", "url": "${budgetUrl}" }]
      }
    },
    "embedded_templates": {}
  },
  "rationale_notes": [
    "category is present on 2 of 3 items, so it is guarded with if() instead of omitted.",
    "availableFunds renders through formatNumber for money formatting; the unit label lives in the fact title."
  ],
  "unverified": []
}
The response_semantics object is what ships. Merge it into the function's capabilities in ai-plugin.json; templates in embedded_templates are not manifest content — your API serves them inside the response where the template_selector values point.
Two prohibitions matter most. No invented bindings: every ${field}, citation property and selector must appear in response_facts.fields — the app re-evaluates each one mechanically against the same sample and names every binding that resolves on no item. And no conjured facts: when the sample carries no url or no category, the element is omitted or guarded with if()/$when and said so in rationale_notes — never filled with a plausible-looking field name.

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

The engine ships with the app as cardkit.js and calls no network: the response analyzer (candidate data_paths and a field inventory with types, sample values and presence counts), the grounded skeleton generator, the tolerant JSON parser, the bundle validator (modes, citation properties, template selectors, element vocabulary, enum values, responsive lints) and the grounding check that re-evaluates every binding a bundle uses against the sample it claims to render — plus a previewer that expands $data, $when and ${…} against each sampled item. The template-language evaluator is a hand-written recursive-descent parser: if(), formatNumber(), comparisons and property paths, with no dynamic code execution anywhere. It exposes window.CardKit.analyze(data, hint), skeleton(facts, opts), parseJsonText(text), parseBundleText(text), coerceBundle(v), validateBundle(bundle, sample), evalExpression(src, scope), collectBindings(node), expandNode(node, scope), preview(bundle, sample), resolvePath(data, path), itemsAt(data, path) and summarize(findings). A pipeline can validate every bundle the model returns — or bundles it wrote itself — without spending anything.

The logic is string-and-JSON work with no I/O: stub a window object and the module loads under Node directly (the DOM is touched only by preview, which you can skip in a pipeline). The same sample always analyzes to the same facts and the same bundle always validates to the same findings, so a CI job can gate a plugin manifest repo on zero grounding failures — including after an API change, by re-running the analyzer against a freshly captured response.