Memo Merge — API

Promote matured lessons into the instruction file from your own tools.

API tokens Open the app

Consolidate a memory file into its instruction file from your own pipeline

Send the measured plan — every lesson with its bucket, maturity score and closest existing rule — plus both documents, and get back one JSON object: merged_instructions (a complete markdown file), residual_memory (what should stay behind), decisions (exactly one per lesson: merged, consolidated or deferred), conflicts_resolved, notes and unverified. The output is deterministic to check, and the app's own mergekit.js is the checker: it counts the decisions for exclusivity, looks for every promoted lesson and every pre-existing rule again in the merged text, re-compares related rules for redundancy and polarity flips, and verifies each target_section against the merged file's real headings. Your pipeline can run exactly the same checks — wire this into a nightly job that drains each domain's memory file and opens a pull request only when the checks come back clean. 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 memo-merge. 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. Merges 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. /estimate, /me and /guest are free; /run and /run-stream are metered. Run input caps at 1 MB of JSON.

POST /v1/app-api/guest
GET /v1/app-api/me
POST /v1/app-api/estimate
POST /v1/app-api/run
GET /v1/app-api/jobs/{id}
POST /v1/app-api/run-stream
POST /v1/app-api/collections/merges/query
POST /v1/app-api/collections/merges/similar

Error codes

codestatuswhat it means
unauthorized401Missing or stale token. Mint a guest token or sign in again.
forbidden403The token belongs to a different app.
payment_required402Balance below min_credits. Call /estimate first and compare against /me.
validation_error400Malformed body. error.details names the field.
rate_limited429Back off. /similar is 30 req/min per IP, tighter than the other data endpoints.
not_found404Unknown job or record id.
internal5xxRetry with the SAME Idempotency-Key - it returns the original job instead of billing again.
Nothing here needs the browser. The whole free lane — parsing, bucketing, maturity scoring, the deterministic merge and every check — is in /mergekit.js, which is plain ES5 with no dependencies and no network calls. Run it in Node and you can pre-compute the plan yourself, exactly as the page does.

Step 1 · Get a token

Two ways in. /tokens.html shows the token this browser already holds and copies a shell export for it — you never need the DevTools console. Or mint a guest token from anywhere: guests can call /me and the free /estimate, which is enough to verify the model binding, but a merge run needs a personal token so it bills your own wallet.

# 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 merge runs
# to your own account.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H 'Content-Type: application/json' \
  -d '{"slug":"memo-merge"}'
# => {"data":{"token":"aut_...","subject_type":"guest","credits":0}}
import os, json, urllib.request

BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "memo-merge"

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", "memo-merge-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 shell 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 = "memo-merge";

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"
	"fmt"
	"io"
	"net/http"
)

const base = "https://api.skillsafe.ai/v1/app-api"
const slug = "memo-merge"

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

func call(path, method, token string, body any) (json.RawMessage, error) {
	var rdr io.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		rdr = bytes.NewReader(b)
	}
	if method == "" {
		if body != nil {
			method = "POST"
		} else {
			method = "GET"
		}
	}
	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 nil, err
	}
	defer res.Body.Close()
	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return nil, err
	}
	if env.Error != nil {
		return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
	}
	return env.Data, nil
}

func main() {
	// Paste the token from /tokens.html, or mint a guest one.
	token := "YOUR_TOKEN"
	if token == "YOUR_TOKEN" {
		raw, err := call("/guest", "POST", "", map[string]string{"slug": slug})
		if err != nil {
			panic(err)
		}
		var g struct{ Token string `json:"token"` }
		json.Unmarshal(raw, &g)
		token = g.Token
	}
	fmt.Println(token[:12] + "...")
}
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public class MemoMerge {
  static final String BASE = "https://api.skillsafe.ai/v1/app-api";
  static final String SLUG = "memo-merge";
  static final HttpClient HTTP = HttpClient.newBuilder()
      .connectTimeout(Duration.ofSeconds(20)).build();

  // Minimal envelope handling: this sample keeps the raw JSON string and lets
  // your own JSON library (Jackson, Gson, ...) map it.
  static String call(String path, String method, 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.method(method, jsonBody == null
        ? HttpRequest.BodyPublishers.noBody()
        : HttpRequest.BodyPublishers.ofString(jsonBody));
    HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
    return res.body(); // {"data":...} or {"error":{...}}
  }

  public static void main(String[] args) throws Exception {
    String token = System.getenv("SKILLSAFE_TOKEN");
    if (token == null) {
      String body = call("/guest", "POST", null, "{\"slug\":\"" + SLUG + "\"}");
      System.out.println("guest response: " + body);
      // pull data.token out of that with your JSON library
      token = "aut_from_the_response_above";
    }
    System.out.println(token.substring(0, Math.min(12, token.length())) + "...");
  }
}
require "json"
require "net/http"
require "uri"

BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "memo-merge"

def call(path, body: nil, token: nil, method: nil)
  uri = URI(BASE + path)
  method ||= body ? "POST" : "GET"
  klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
  req = klass.new(uri)
  req["Content-Type"] = "application/json"
  req["Authorization"] = "Bearer #{token}" if token
  req.body = JSON.dump(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

# Option A: the token from /tokens.html. Option B: a guest token.
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 = "memo-merge";

function call(string $path, ?array $body = null, ?string $token = null, ?string $method = null) {
    $ch = curl_init(BASE . $path);
    $headers = ["Content-Type: application/json"];
    if ($token) $headers[] = "Authorization: Bearer " . $token;
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CUSTOMREQUEST => $method ?? ($body ? "POST" : "GET"),
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
    ]);
    $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"];
}

// Option A: the token from /tokens.html. Option B: a guest token.
$token = getenv("SKILLSAFE_TOKEN") ?: call("/guest", ["slug" => SLUG])["token"];
echo substr($token, 0, 12) . "...\n";
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "memo-merge";

var http = new HttpClient();

async Task<JsonElement> Call(string path, object? body = null, string? token = null, HttpMethod? method = null)
{
    var req = new HttpRequestMessage(method ?? (body is null ? HttpMethod.Get : HttpMethod.Post), Base + path);
    if (body is not null)
        req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
    if (token is not null)
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
    var res = await http.SendAsync(req);
    using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
    if (doc.RootElement.TryGetProperty("error", out var err))
        throw new Exception($"{err.GetProperty("code")}: {err.GetProperty("message")}");
    return doc.RootElement.GetProperty("data").Clone();
}

// Option A: the token from /tokens.html. Option B: a guest token.
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
if (string.IsNullOrEmpty(token))
    token = (await Call("/guest", new { slug = Slug })).GetProperty("token").GetString();

Console.WriteLine(token![..12] + "...");
Guest identities are per-token. acl_read: "owner" scopes the merges collection to the calling subject, so a fresh guest token sees an empty history even when records exist — reuse one token across create and query.

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

/me is free and returns subject_type (user or guest) and the credit balance. Compare it against min_credits from the next step before you submit anything: a 402 after submit is a failure of your client, not of the user.

curl -s https://api.skillsafe.ai/v1/app-api/me -H "Authorization: Bearer $SKILLSAFE_TOKEN"
# => {"data":{"subject_type":"user","credits":184213,"app":{"slug":"memo-merge","model":"gpt-terra"}}}
me = call("/me", token=token)
print(me["subject_type"], me["credits"], "credits")   # 1 credit = $0.0001
const me = await call("/me", { token });
console.log(me.subject_type, me.credits, "credits");   // 1 credit = $0.0001
raw, err := call("/me", "GET", token, nil)
if err != nil {
	panic(err)
}
var me struct {
	SubjectType string `json:"subject_type"`
	Credits     int    `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits, "credits")
String me = call("/me", "GET", token, null);
System.out.println(me);   // {"data":{"subject_type":"user","credits":...}}
me = call("/me", token: token)
puts "#{me['subject_type']} #{me['credits']} credits"
$me = call("/me", null, $token);
echo $me["subject_type"], " ", $me["credits"], " credits\n";
var me = await Call("/me", token: token);
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")} credits");

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

/estimate takes the exact body /run takes, charges nothing and creates no job. It returns model, model_alias, markup_bps, hold_credits (the worst case that gets reserved) and min_credits (below which the run cannot start). Assert model_alias == "gpt-terra" in CI and you have proof the app is still wired to the tier you expect.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H 'Content-Type: application/json' \
  -d '{
  "domain": "git-workflow",
  "bar": "balanced",
  "note": "",
  "facts": { "instruction_empty": false, "maturity_bar": 2,
             "instruction_sections": ["Branches", "Rebasing", "Review", "Releases"],
             "instruction_rules": [{"id": "i3", "section": "Rebasing", "text": "Rebase your branch onto the target branch before you open a review"}],
             "lessons": [{"id": "m1", "section": "Rebasing", "text": "- Confirmed again: rebase before review, never merge the target in.",
                          "maturity": 4, "maturity_why": ["carries a settled-knowledge marker"],
                          "bucket": "overlap", "best_match": {"id": "i3", "score": 0.533},
                          "target_section": "Rebasing"}],
             "counts": {"conflict": 0, "duplicate": 0, "immature": 0, "overlap": 1, "new": 0},
             "applyto_union": ["**/*.md"] },
  "memory_excerpt": "## Rebasing\n\n- Confirmed again: rebase before review, never merge the target in.\n",
  "instruction_excerpt": "# Git workflow\n\n## Rebasing\n\n- Rebase your branch onto the target branch before you open a review\n",
  "current_datetime": "2026-08-07T10:30:00+00:00 (Friday)"
}'
# => {"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra","markup_bps":1000,
#             "hold_credits":3140,"min_credits":260,"sponsor_enabled":false}}
# Nothing is charged and no job is created. hold_credits is the worst case that
# gets RESERVED; charged_credits after the run is usually far lower.
body = json.loads(open("merge-input.json").read())   # the shape shown below
est = call("/estimate", body, token=token)
print(est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"])
assert est["model_alias"] == "gpt-terra"        # the binding, proved for free
if me["credits"] < est["min_credits"]:
    raise SystemExit("top up first - a run would 402")
const est = await call("/estimate", { body: input, token });
console.log(est.model, est.model_alias, est.markup_bps, est.hold_credits);
if (me.credits < est.min_credits) throw new Error("top up first - a run would 402");
raw, err = call("/estimate", "POST", token, input)   // input is your request map
if err != nil {
	panic(err)
}
var est struct {
	Model      string `json:"model"`
	ModelAlias string `json:"model_alias"`
	MarkupBps  int    `json:"markup_bps"`
	Hold       int    `json:"hold_credits"`
	Min        int    `json:"min_credits"`
}
json.Unmarshal(raw, &est)
fmt.Println(est.Model, est.ModelAlias, est.MarkupBps, est.Hold)
String est = call("/estimate", "POST", token, inputJson);
System.out.println(est);   // model, model_alias, markup_bps, hold_credits, min_credits
est = call("/estimate", body: input, token: token)
puts [est["model"], est["model_alias"], est["markup_bps"], est["hold_credits"]].join(" ")
abort "top up first" if me["credits"] < est["min_credits"]
$est = call("/estimate", $input, $token);
printf("%s %s %d %d\n", $est["model"], $est["model_alias"], $est["markup_bps"], $est["hold_credits"]);
if ($me["credits"] < $est["min_credits"]) exit("top up first\n");
var est = await Call("/estimate", input, token);
Console.WriteLine($"{est.GetProperty("model")} {est.GetProperty("model_alias")} " +
                  $"{est.GetProperty("hold_credits")}");
If the balance sits between min_credits and hold_credits the run still executes with a reduced output cap and comes back with truncated: true. Treat that as a partial merge — the decisions list will be short, and the app's exclusivity check is what tells you which lessons never got decided.

Step 4 · Run the merge and poll for it

/run returns a job_id; poll /jobs/{id} until status leaves running. The model's reply is a JSON string in data.output.output — parse it, do not eval it. Always send an Idempotency-Key: a retried POST with the same key returns the original job rather than billing a second merge, which matters most on the reformat retry, where the input has not changed.

# Always send an Idempotency-Key. A retried POST with the same key returns the
# same job instead of billing a second merge.
KEY="memo-merge:$(printf '%s' "$(cat merge-input.json)" | shasum -a 256 | cut -c1-24):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" --data-binary @merge-input.json | \
  python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal.
until [ "$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | \
  python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')" != "running" ]; do
  sleep 2
done

curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $SKILLSAFE_TOKEN" | \
  python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > merge.json
import hashlib, time

payload = json.dumps(body, sort_keys=True).encode()
key = "memo-merge:" + hashlib.sha256(payload).hexdigest()[:24] + ":a1"

def run(body, token, key):
    data = json.dumps(body).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", key)          # never bill a retry twice
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())["data"]["job_id"]

job = run(body, token, key)
while True:
    j = call("/jobs/" + job, token=token)
    if j["status"] != "running":
        break
    time.sleep(2)

result = json.loads(j["output"]["output"])
print(len(result["decisions"]), "decisions,",
      len(result["merged_instructions"]), "chars of merged file")
if j.get("truncated"):
    print("cut short by balance - top up and re-run for the whole merge")
import { createHash } from "node:crypto";

const key = `memo-merge:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 24)}:a1`;

const res = await fetch(BASE + "/run", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: "Bearer " + token,
    "Idempotency-Key": key,          // never bill a retry twice
  },
  body: JSON.stringify(input),
});
const { data: job } = await res.json();

let j;
do {
  await new Promise((r) => setTimeout(r, 2000));
  j = await call("/jobs/" + job.job_id, { token });
} while (j.status === "running");

const result = JSON.parse(j.output.output);
console.log(result.decisions.length, "decisions");
if (j.truncated) console.warn("cut short by balance");
sum := sha256.Sum256(mustJSON(input))
key := fmt.Sprintf("memo-merge:%x:a1", sum[:12])

req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(mustJSON(input)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
// ... decode {"data":{"job_id":...}}, then GET /jobs/{id} every 2s until
// status != "running", and json.Unmarshal(job.Output.Output) into your struct.
String key = SLUG + ":" + sha256Hex(inputJson).substring(0, 24) + ":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(inputJson))
    .build();
String jobJson = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// poll GET /jobs/{job_id} until status is not "running", then parse
// data.output.output as the merge JSON object.
require "digest"

key = "memo-merge:#{Digest::SHA256.hexdigest(JSON.dump(input))[0, 24]}:a1"

uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = key
req.body = JSON.dump(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job = JSON.parse(res.body)["data"]

loop do
  j = call("/jobs/#{job['job_id']}", token: token)
  break (@job = j) if j["status"] != "running"
  sleep 2
end

result = JSON.parse(@job["output"]["output"])
puts "#{result['decisions'].length} decisions"
$key = "memo-merge:" . substr(hash("sha256", json_encode($input)), 0, 24) . ":a1";

$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Authorization: Bearer " . $token,
        "Idempotency-Key: " . $key,
    ],
    CURLOPT_POSTFIELDS => json_encode($input),
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);

do {
    sleep(2);
    $j = call("/jobs/" . $job["job_id"], null, $token);
} while ($j["status"] === "running");

$result = json_decode($j["output"]["output"], true);
echo count($result["decisions"]), " decisions\n";
using System.Security.Cryptography;

var payload = JsonSerializer.Serialize(input);
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))[..24];
var key = $"memo-merge:{hash}:a1";

var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run")
{
    Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Idempotency-Key", key);
var runRes = await http.SendAsync(req);
// decode data.job_id, poll GET /jobs/{id} until status != "running",
// then JsonDocument.Parse(data.output.output) for the merge object.

Step 5 · Or stream it

/run-stream is the same call over SSE. Frame names arrive on the event: line — job, delta, done — and there is no type field inside the payload. merged_instructions is the first field the model writes, so even a stream that dies late usually carries the whole merged file: close the JSON and keep it rather than throwing the run away.

curl -N -s -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" -H 'Accept: text/event-stream' \
  --data-binary @merge-input.json
# event: job      data: {"job_id":"job_..."}
# event: delta    data: {"text":"{\"merged_instructions\":\"---\\nname: git-workflow..."}
# event: done     data: {"status":"succeeded","charged_credits":812,"truncated":false}
#
# The frame name is on the `event:` line - there is no "type" field inside the
# payload. merged_instructions streams first, so a stream that dies late still
# carries the whole merged file.
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(body).encode(), method="POST")
for k, v in {"Content-Type": "application/json", "Accept": "text/event-stream",
             "Authorization": "Bearer " + token, "Idempotency-Key": key}.items():
    req.add_header(k, v)

buf, event = "", ""
with urllib.request.urlopen(req) as r:
    for raw in r:
        line = raw.decode().rstrip("\n")
        if line.startswith("event: "):
            event = line[7:]                  # the frame NAME lives here
        elif line.startswith("data: "):
            payload = json.loads(line[6:])
            if event == "delta":
                buf += payload.get("text", "")
            elif event == "done":
                print("charged", payload.get("charged_credits"), "truncated", payload.get("truncated"))

result = json.loads(buf)
const res = await fetch(BASE + "/run-stream", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Accept: "text/event-stream",
    Authorization: "Bearer " + token,
    "Idempotency-Key": key,
  },
  body: JSON.stringify(input),
});

const reader = res.body.getReader();
const dec = new TextDecoder();
let carry = "", event = "", buf = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  carry += dec.decode(value, { stream: true });
  const lines = carry.split("\n");
  carry = lines.pop();
  for (const line of lines) {
    if (line.startsWith("event: ")) event = line.slice(7).trim();
    else if (line.startsWith("data: ")) {
      const p = JSON.parse(line.slice(6));
      if (event === "delta") buf += p.text || "";
      if (event === "done") console.log("charged", p.charged_credits, "truncated", p.truncated);
    }
  }
}
const result = JSON.parse(buf);
req, _ = http.NewRequest("POST", base+"/run-stream", bytes.NewReader(mustJSON(input)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", key)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()

sc := bufio.NewScanner(res.Body)
var event, buf string
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event: "):
		event = strings.TrimSpace(line[7:])
	case strings.HasPrefix(line, "data: "):
		var p struct {
			Text            string `json:"text"`
			ChargedCredits  int    `json:"charged_credits"`
		}
		json.Unmarshal([]byte(line[6:]), &p)
		if event == "delta" {
			buf += p.Text
		}
	}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
    .header("Content-Type", "application/json")
    .header("Accept", "text/event-stream")
    .header("Authorization", "Bearer " + token)
    .header("Idempotency-Key", key)
    .POST(HttpRequest.BodyPublishers.ofString(inputJson))
    .build();

StringBuilder buf = new StringBuilder();
String[] event = { "" };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
  if (line.startsWith("event: ")) event[0] = line.substring(7).trim();
  else if (line.startsWith("data: ") && event[0].equals("delta")) {
    // append the "text" field of the payload with your JSON library
    buf.append(line.substring(6));
  }
});
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Authorization"] = "Bearer #{token}"
req["Idempotency-Key"] = key
req.body = JSON.dump(input)

buf = ""
event = ""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
  h.request(req) do |res|
    res.read_body do |chunk|
      chunk.each_line do |line|
        line = line.chomp
        if line.start_with?("event: ")
          event = line[7..].strip
        elsif line.start_with?("data: ")
          p = JSON.parse(line[6..])
          buf << p.fetch("text", "") if event == "delta"
        end
      end
    end
  end
end
result = JSON.parse(buf)
$buf = "";
$event = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => [
        "Content-Type: application/json",
        "Accept: text/event-stream",
        "Authorization: Bearer " . $token,
        "Idempotency-Key: " . $key,
    ],
    CURLOPT_POSTFIELDS => json_encode($input),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buf, &$event) {
        foreach (explode("\n", $chunk) as $line) {
            if (str_starts_with($line, "event: ")) {
                $event = trim(substr($line, 7));
            } elseif (str_starts_with($line, "data: ") && $event === "delta") {
                $p = json_decode(substr($line, 6), true);
                $buf .= $p["text"] ?? "";
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);
$result = json_decode($buf, true);
var sreq = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream")
{
    Content = new StringContent(payload, Encoding.UTF8, "application/json"),
};
sreq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Headers.Add("Idempotency-Key", key);

using var sres = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await sres.Content.ReadAsStreamAsync());
var sb = new StringBuilder();
var ev = "";
while (await reader.ReadLineAsync() is string line)
{
    if (line.StartsWith("event: ")) ev = line[7..].Trim();
    else if (line.StartsWith("data: ") && ev == "delta")
    {
        using var d = JsonDocument.Parse(line[6..]);
        if (d.RootElement.TryGetProperty("text", out var t)) sb.Append(t.GetString());
    }
}
using var result = JsonDocument.Parse(sb.ToString());

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

Every merge the app runs is stored in the declared merges collection (acl_read: owner, acl_write: user) with both output files, the decision list and the counts. query filters and sorts; similar searches the four embedded fields — name, domain, summary and deferred_summary. That last one is why “the one where we could not decide about force-pushing” finds the right merge: the phrase lives in the deferral reasons, not in the title.

# Newest merges first. `sort` is an OBJECT; `order_by` is silently ignored.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/merges/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"sort":{"field":"ran_at","dir":"desc"},"limit":10}'

# Only the merges that still failed a check - every `where` entry is an
# operator object, never a bare value.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/merges/query \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"where":{"check_fails":{"gt":0}},"sort":{"field":"ran_at","dir":"desc"}}'

# By meaning, over name + domain + summary + deferred_summary. 30 req/min per IP.
curl -s -X POST https://api.skillsafe.ai/v1/app-api/collections/merges/similar \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H 'Content-Type: application/json' \
  -d '{"text":"the one where we could not decide about force-pushing","limit":5}'
# => {"data":{"records":[{"record_id":"rec_...","score":0.71,"doc":{...}}]}}
# Newest first. The key is `sort` (an object) - `order_by` is silently ignored.
page = call("/collections/merges/query",
            {"sort": {"field": "ran_at", "dir": "desc"}, "limit": 10}, token=token)
for rec in page["records"]:
    d = rec["doc"]
    print(d["ran_at"], d["domain"], d["merged_count"], "merged",
          d["deferred_count"], "deferred", d["check_fails"], "fails")

# Filters are operator objects: {"gt": 0}, never a bare 0.
dirty = call("/collections/merges/query",
             {"where": {"check_fails": {"gt": 0}}}, token=token)

# Semantic search over name + domain + summary + deferred_summary.
hits = call("/collections/merges/similar",
            {"text": "the one where we could not decide about force-pushing", "limit": 5},
            token=token)
const page = await call("/collections/merges/query", {
  body: { sort: { field: "ran_at", dir: "desc" }, limit: 10 },
  token,
});
for (const rec of page.records) console.log(rec.doc.domain, rec.doc.check_fails);

// Operator objects only - { gt: 0 }, not 0.
const dirty = await call("/collections/merges/query", {
  body: { where: { check_fails: { gt: 0 } } },
  token,
});

const hits = await call("/collections/merges/similar", {
  body: { text: "the one where we could not decide about force-pushing", limit: 5 },
  token,
});
raw, err = call("/collections/merges/query", "POST", token, map[string]any{
	"sort":  map[string]string{"field": "ran_at", "dir": "desc"},
	"limit": 10,
})

raw, err = call("/collections/merges/query", "POST", token, map[string]any{
	"where": map[string]any{"check_fails": map[string]int{"gt": 0}},
})

raw, err = call("/collections/merges/similar", "POST", token, map[string]any{
	"text":  "the one where we could not decide about force-pushing",
	"limit": 5,
})
String page = call("/collections/merges/query", "POST", token,
    "{\"sort\":{\"field\":\"ran_at\",\"dir\":\"desc\"},\"limit\":10}");

String dirty = call("/collections/merges/query", "POST", token,
    "{\"where\":{\"check_fails\":{\"gt\":0}}}");

String hits = call("/collections/merges/similar", "POST", token,
    "{\"text\":\"the force-push one\",\"limit\":5}");
page = call("/collections/merges/query",
            body: { sort: { field: "ran_at", dir: "desc" }, limit: 10 }, token: token)
page["records"].each { |r| puts "#{r['doc']['domain']} #{r['doc']['check_fails']}" }

dirty = call("/collections/merges/query",
             body: { where: { check_fails: { gt: 0 } } }, token: token)

hits = call("/collections/merges/similar",
            body: { text: "the force-push one", limit: 5 }, token: token)
$page = call("/collections/merges/query",
    ["sort" => ["field" => "ran_at", "dir" => "desc"], "limit" => 10], $token);
foreach ($page["records"] as $rec) {
    echo $rec["doc"]["domain"], " ", $rec["doc"]["check_fails"], "\n";
}

$dirty = call("/collections/merges/query",
    ["where" => ["check_fails" => ["gt" => 0]]], $token);

$hits = call("/collections/merges/similar",
    ["text" => "the force-push one", "limit" => 5], $token);
var page = await Call("/collections/merges/query",
    new { sort = new { field = "ran_at", dir = "desc" }, limit = 10 }, token);

var dirty = await Call("/collections/merges/query",
    new { where = new { check_fails = new { gt = 0 } } }, token);

var hits = await Call("/collections/merges/similar",
    new { text = "the force-push one", limit = 5 }, token);
Two DSL facts worth knowing before you debug for an hour: every where entry must be an operator object ({"gt": 0}, never a bare 0), and the sort key is sort — an object. order_by is accepted, silently ignored, and falls back to created_at desc.

The input schema

One JSON object. facts is the measured plan and is the part that matters: it is what the model is held to afterwards. You can produce it with MergeKit.analyze(memoryText, instructionText, { bar }) from /mergekit.js in Node, which is exactly what the page does in the browser.

fieldtypemeaning
domainstringWhat the two files are about — git-workflow, release-notes. Names the created file when there is no instruction file yet.
barstringconservative | balanced | everything — the maturity threshold for promotion.
notestringOptional free-text steer. May be empty.
facts.instruction_emptybooleanTrue when there is no instruction file, so the run creates it.
facts.maturity_barnumberThe numeric bar (0–5) the chosen bar resolves to.
facts.thresholdsobjectduplicate, overlap and placement similarity thresholds.
facts.instruction_sectionsstring[]Headings in the instruction file, in document order.
facts.instruction_rulesobject[]{ id, section, text } per existing rule. id is what consolidated decisions must name.
facts.lessonsobject[]{ id, section, text, maturity, maturity_why, bucket, best_match, target_section }, plus conflict when one was found.
facts.countsobjectHow many lessons landed in each bucket.
facts.applyto_unionstring[]The union of both files' applyTo globs with subsumed globs removed. Join with ", " for the merged frontmatter.
memory_excerptstringThe memory file. Clipped from the MIDDLE when long, with both ends kept and the cut announced in-band.
instruction_excerptstringThe instruction file, clipped the same way.
current_datetimestringThe caller's local time with weekday.
retry_notestringPresent ONLY on a reformat retry. Names the parse error from the previous reply.

A complete body

{
  "domain": "git-workflow",
  "bar": "balanced",
  "note": "Keep the numbered steps numbered",
  "facts": {
    "instruction_empty": false,
    "maturity_bar": 2,
    "thresholds": { "duplicate": 0.8, "overlap": 0.34, "placement": 0.12 },
    "instruction_sections": ["Branches", "Rebasing", "Review", "Releases"],
    "instruction_rules": [
      { "id": "i1", "section": "Branches", "text": "One branch per unit of work, cut fresh from the target branch." },
      { "id": "i4", "section": "Review", "text": "Force-push freely while a branch is in review; the reviewer can always re-read the diff." }
    ],
    "lessons": [
      { "id": "m1", "section": "Rebasing",
        "text": "- Confirmed again this week: rebase onto the target branch before opening a review, never merge the target into the branch.",
        "maturity": 4, "maturity_why": ["specific (14 content words)", "carries a settled-knowledge marker"],
        "bucket": "overlap", "best_match": { "id": "i3", "score": 0.533 }, "target_section": "Rebasing" },
      { "id": "m3", "section": "Review",
        "text": "- Do not force-push a branch that someone is already reviewing - the review comments detach.",
        "maturity": 3, "maturity_why": ["already written as a rule"],
        "bucket": "conflict", "best_match": { "id": "i4", "score": 0.384 },
        "conflict": { "kind": "polarity", "rule_id": "i4",
                      "rule_text": "Force-push freely while a branch is in review; the reviewer can always re-read the diff." },
        "target_section": "" },
      { "id": "m4", "section": "Review",
        "text": "- Maybe we should require two approvals on migrations? Not sure, only came up once.",
        "maturity": 0, "maturity_why": ["hedged - reads as an open question"],
        "bucket": "immature", "best_match": { "id": "", "score": 0 }, "target_section": "" }
    ],
    "counts": { "conflict": 1, "duplicate": 0, "immature": 1, "overlap": 1, "new": 0 },
    "applyto_union": ["**/*.md", ".github/**/*.yml"]
  },
  "memory_excerpt": "---\nname: git-workflow-memory\n---\n\n## Rebasing\n\n- Confirmed again this week: ...\n",
  "instruction_excerpt": "---\nname: git-workflow\n---\n\n# Git workflow\n\n## Branches\n\n- One branch per unit of work ...\n",
  "current_datetime": "2026-08-07T10:30:00+00:00 (Friday)"
}
Never conclude a rule is absent because an excerpt was clipped — facts was measured over the whole of both documents and is authoritative. That is also why the plan travels separately from the text: the model gets the shape of the whole file even when it only sees part of the bytes.

The output contract

Exactly one JSON object, no prose and no code fences. This is the shape the app's render path parses, so it is the shape your client should assert on.

fieldtyperule the checker enforces
merged_instructionsstringA complete markdown document. Every promoted lesson and every pre-existing rule must be findable in it by its own vocabulary; no two rules in it may exceed the duplicate threshold; no related pair may flip polarity.
residual_memorystringThe memory file after the merge. Must keep every deferred lesson and unresolved conflict, and must not still contain a promoted one.
decisionsobject[]Exactly one entry per lesson id — counted, not spot-checked. action is merged, consolidated or deferred; consolidated needs a real instruction_id; deferred needs a why; a target_section must exist as a heading in merged_instructions.
conflicts_resolvedobject[]{ lesson_id, instruction_id, resolution }. Every measured conflict must appear here or be deferred with a reason.
notesstring[]One line per non-obvious call. Never mentions retry_note.
unverifiedstring[]Anything in the merged file that traces to neither a lesson nor an existing rule. Empty is a claim that everything traces — and the app prints the disagreement when the checker finds one anyway.

A complete reply

{
  "merged_instructions": "---\nname: git-workflow\napplyTo: **/*.md, .github/**/*.yml\ndescription: How we use git on this repository\n---\n\n# Git workflow\n\n## Branches\n\n- One branch per unit of work, cut fresh from the target branch.\n\n## Rebasing\n\n- Rebase onto the target branch before opening a review - never merge the target into your branch. A merge commit mid-branch makes the review diff unreadable; this has cost us three reviews.\n\n## Review\n\n- Do not force-push a branch that someone is already reviewing: the review comments detach and the reviewer loses their place. Push a fixup commit and squash at the end.\n",
  "residual_memory": "---\nname: git-workflow-memory\n---\n\n## Review\n\n- Maybe we should require two approvals on migrations? Not sure, only came up once.\n",
  "decisions": [
    { "lesson_id": "m1", "action": "consolidated", "instruction_id": "i3", "target_section": "Rebasing",
      "why": "same rule; the lesson adds the unreadable-diff reason and the three-times evidence, both kept" },
    { "lesson_id": "m3", "action": "merged", "target_section": "Review",
      "why": "the contradiction is settled in favour of the lesson - see conflicts_resolved" },
    { "lesson_id": "m4", "action": "deferred",
      "why": "asked as an open question and seen once - not a rule yet, so it stays in memory" }
  ],
  "conflicts_resolved": [
    { "lesson_id": "m3", "instruction_id": "i4",
      "resolution": "the lesson wins: detached review comments are a concrete, repeated cost, so the permissive force-push rule is replaced rather than kept alongside it" }
  ],
  "notes": ["m1 was consolidated rather than merged so the Rebasing section keeps one rule instead of two that say the same thing"],
  "unverified": []
}

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

/mergekit.js ships in this app's bundle and has no dependencies, no network calls and no DOM use. curl -s https://memo-merge.skillsafe.ai/mergekit.js, load it in Node, and you have the whole measurement locally: analyze for the plan, renderMerged and renderResidual for a complete merge with no model call at all, validateMerged and validateResidual for the checks, reconcile for holding a model reply to the plan, and summarize for the counts. Gate a pull request on summarize(...).fails === 0 and no merge lands that lost a rule.

// node check-merge.js  - no API calls, nothing charged
const fs = require("fs");
global.window = {};
require("./mergekit.js");        // an IIFE that assigns window.MergeKit
const MK = window.MergeKit;

const memory = fs.readFileSync("git-workflow-memory.instructions.md", "utf8");
const rules  = fs.readFileSync("git-workflow.instructions.md", "utf8");

const plan = MK.analyze(memory, rules, { bar: "balanced" });
console.log(plan.counts, "partition ok:", plan.partition_ok);

// A complete merge with no model involved:
const merged   = MK.renderMerged(plan, { domain: "git-workflow" });
const residual = MK.renderResidual(plan);

// ...and the same checks the app runs on the model's version:
const checks = MK.validateMerged(merged, plan).concat(MK.validateResidual(residual, plan));
const sum = MK.summarize(checks);
for (const c of checks) if (c.level !== "pass") console.log(c.level, c.id, c.detail);
process.exit(sum.fails ? 1 : 0);
Same engine, same thresholds, same verdicts as the page — there is exactly one implementation of the similarity function and every lane calls it, so a “duplicate” means the same thing in your CI as it does in the browser.

Rate limits and etiquette

Data endpoints share 120 requests/min; /collections/{name}/similar is 30/min per IP and costs roughly an order of magnitude more than a where filter — use the filter whenever an exact match would do, and never fire a similarity query per keystroke. Vector indexing is asynchronous, so a similar call immediately after a write can lag by seconds. There is no backfill: records written before an embed field existed are never vectorized. Storage quotas that matter here: 64 KB per document, 10 000 records per collection, 1 000 records per owner.