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
| HTTP | code | What it means and what to do |
|---|---|---|
400 | validation_error | The 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. |
401 | unauthorized | No token, a malformed token, or a token that has expired. Mint a new guest token or sign in again. |
402 | payment_required | The balance cannot cover this run's minimum. Call /estimate first and compare min_credits against /me's credits. |
404 | not_found | Unknown 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. |
409 | conflict | An Idempotency-Key was reused with a different body. Change the attempt counter in the key when the input changes. |
429 | rate_limited | Too many requests. Back off and retry; do not tight-loop. |
500 | internal_error | Transient. Retry with the same Idempotency-Key so the retry cannot bill twice. |
/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.
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.
| Field | Type | Meaning |
|---|---|---|
intent | string | What the card should show and emphasize. Required in practice — this is what the template is shaped around. |
function_name | string | The plugin function the card belongs to, possibly empty — e.g. GetBudgets. |
mode | string | One of auto, static, dynamic, combined. auto lets the pass decide; a measured selector_field is strong evidence for dynamic. |
data_path_hint | string | A JSONPath the user picked for the item array, possibly empty. Honoured verbatim when it resolves. |
current_bundle | string | Optional: an existing bundle, as a JSON string, to refine instead of starting over. |
refine_note | string | What to change about current_bundle. Only meaningful alongside it. |
response_facts.root_type | string | object or array — the shape of the sample's root. |
response_facts.candidate_data_paths | array | {path, count} per array of objects found in the sample (first 8 sent). The only paths data_path may name. |
response_facts.chosen_data_path | string | The analyzer's pick (or the user's hint) — the reply's data_path must be this. |
response_facts.item_count, sampled_count | number | How many items the path selects and how many were sampled for the field inventory (up to 20). |
response_facts.fields | array | {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_field | string | A 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_sent | number | How much sample JSON exists and how much of it travelled — honest clipping, declared. |
response_excerpt | string | The 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_datetime | string | The caller's local time, weekday included. |
retry_note | string | Optional, 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.
| Field | Constraint |
|---|---|
bundle.function_name | The plugin function this bundle describes. Empty draws a warning — the exported ai-plugin.json snippet needs it. |
bundle.mode | Exactly one of static, dynamic, combined. static/combined require static_template; dynamic/combined require template_selector and non-empty embedded_templates. |
bundle.response_semantics.data_path | A 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.properties | title (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_template | An 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_templates | Object mapping each template name the selector values reference (the tail of the value, e.g. $.templates.debit → debit) 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_notes | Array, may be empty. One line per non-obvious choice: why this mode, this data_path, this guard, this layout. |
unverified | Array. 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": []
}
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.
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.