Drive Cut to Match from your own code
The one metered task, match, is available over HTTP. It takes a storyboard of two to eight shots - what is in frame, its shape, its place on a 3 by 3 frame grid, its main colour, how it starts and ends, its speed and length - together with the page's read of every cut, and returns a verdict, one filming recipe per cut (how shot A ends, how shot B starts, where to cut, a fix where one is needed), two to four shooting tips and what already works.
The division of labour first: the page owns the checks; the model owns the words; the page re-checks the reply. Working out what carries across each cut, choosing its seam, the cut verdicts, the sequence findings and the overall verdict are plain arithmetic in cutkit.js; send them in, and check the reply with CutKit.check.
Get a token on the token page (step 2 below also shows how to mint a guest token in code).
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"cut-to-match"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object - no wrapper.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, aspect, shots, cuts, facts, figures) is missing or not a string. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the recipes are metered and need a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field and the verdict rule
Every run sends task: match (the only task; a missing or unknown one is treated as match). Each cut is locked (two or more of motion, shape, place in frame and colour carry across, nothing clashing), loose (one carries across) or broken (nothing does, the motion doubles back, or the zoom flips). The verdict is a fixed rule on the page's figures, checked in this order:
- rethink - 1 or more broken cuts.
- tighten - 1 or more gaps (a loose cut, a slow side on a motion cut, a motion cut travelling against an earlier one, a pull-back before the last cut, too many rack-focus cuts).
- ready - everything else.
The declared input schema carries scalars only, so the array- and object-valued fields travel JSON-encoded as strings (CutKit.toWire does this). Every value is a string.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | match. Required. |
idea | string | What the video is, up to 140 characters. Optional. |
aspect | string | vertical 9:16, wide 16:9 or square 1:1. Required. |
shots | string (JSON) | [{id, name, shape, place, colour, starts, ends, speed, seconds}] - ids S1.., 2 to 8, plain words. Required. |
cuts | string (JSON) | [{id, from, to, shared, seam, seam_name, verdict, spec}] - ids C1..; seam is one of whip, zoom, pullback, focus, graphic, plain. Required. |
facts | string (JSON) | [{id, level, ref, text}] - ids F1..; level is break or gap; ref is a cut id or ALL. Required (may be []). |
figures | string (JSON) | {shots, cuts, locked, loose, broken, gaps, seconds}, every value a string. Required. |
notes | string | Free text about the shoot, up to 300 characters. |
clip_note, retry_note | string | What the page cut, when it cut something; and only on a reformat retry, what was wrong. |
The reply
The job's output.output is a string holding one JSON object: task, verdict, title, summary, cuts (one {cut, seam, verdict, end_a, start_b, on_the_cut, fix} per input cut, in order, with the page's seam and verdict; fix is empty only for a locked cut no finding names), tips (2-4 strings) and keep. Parse it with CutKit.parseReply (which also closes a cut-off stream) and CutKit.normalize.
Worked examples
Replies produced against the app's prompt for the three sample storyboards (made up; long fields shortened; the full replies ship in example-result.js). Together they cover every verdict.
a three-shot morning where every cut locks - ready
Input:
{
"task": "match",
"idea": "A slow morning in three round things",
"aspect": "vertical 9:16",
"shots": "[{\"id\":\"S1\",\"name\":\"Alarm clock on the nightstand\",\"shape\":\"round\",\"place\":\"centre\",\"colour\":\"white\",\"starts\":\"starts holding still\",\"ends\":\"ends pushing in, toward the lens\",\"speed\":\"fast\",\"seconds\":2},{\"id\":\"S2\",\"name\":\"Coffee mug from above\",\"shape\":\"round\",\"place\":\"centre\",\"colour\":\"white\",\"starts\":\"starts pushing in, toward the lens\",\"ends\":\"ends moving right\",\"speed\":\"fast\",\"seconds\":2},{\"id\":\"S3\",\"name\":\"Bike ... (573 characters)",
"cuts": "[{\"id\":\"C1\",\"from\":\"S1\",\"to\":\"S2\",\"shared\":[\"motion\",\"shape\",\"place in frame\",\"colour\"],\"seam\":\"zoom\",\"seam_name\":\"Zoom-through\",\"verdict\":\"locked\",\"spec\":\"Both sides push in. Shot A grows toward the lens for about the last 0.2 s before the cut; cut at its fastest, blurriest moment; shot B keeps growing into focus for about 0.5 s after the cut. Use it to go deeper into the same idea.\"},{\"id\":\"C2\",\"from\":\"S2\",\"to\":\"S3 ... (794 characters)",
"facts": "[]",
"figures": "{\"shots\":\"3\",\"cuts\":\"2\",\"locked\":\"2\",\"loose\":\"0\",\"broken\":\"0\",\"gaps\":\"0\",\"seconds\":\"7\"}",
"notes": "Shot on one phone, handheld. The mug is filmed on the kitchen table.",
"clip_note": ""
}
Reply:
{
"task": "match",
"verdict": "ready",
"title": "Both cuts lock - you're ready to shoot",
"summary": "Ready: 2 cuts are locked, 0 are loose, and 0 are broken. Shoot it handheld, keeping the push-in and the rightward motion smooth throughout.",
"cuts": [
{
"cut": "C1",
"seam": "zoom",
"verdict": "locked",
"end_a": "Hold the alarm clock centred in the frame, white and round, pushing in toward the lens as it builds to its fastest, blurriest moment just before the cut.",
"start_b": "Start the mug already pushing in toward the lens, the same white, round shape centred in the frame, growing into focus for about 0.5 s after the cut.",
"on_the_cut": "Cut at the alarm clock's fastest, blurriest moment, right before the mug picks up the same push in.",
"fix": ""
},
{
"cut": "C2",
"seam": "whip",
"verdict": "locked",
"end_a": "Have the mug accelerate moving right, travelling about 12% of the frame as it builds to peak speed before the cut.",
"start_b": "Pick up the wheel already moving right at the same speed, carrying the motion on for roughly 0.3 s before it settles.",
"on_the_cut": "Cut mid-motion at peak speed, keeping the movement inside the frame on both sides of the cut.",
"fix": ""
}
],
"tips": [
"Keep the phone steady through each push-in so the zoom-through reads as one continuous move.",
"Match the rightward speed on both sides of the cut so the motion carries straight through.",
"Since you're filming handheld, brace your arms to keep both moves smooth and steady."
],
"keep": "The first cut carries four things across at once - motion, shape, place in frame and colour - a strong match."
}
four outfits in one mirror with a loose cut, a slow side and a change of direction - tighten
Input:
{
"task": "match",
"idea": "Four outfits, one hallway mirror",
"aspect": "vertical 9:16",
"shots": "[{\"id\":\"S1\",\"name\":\"Hoodie and jeans in the mirror\",\"shape\":\"tall and upright\",\"place\":\"centre\",\"colour\":\"black\",\"starts\":\"starts holding still\",\"ends\":\"ends moving left\",\"speed\":\"fast\",\"seconds\":2},{\"id\":\"S2\",\"name\":\"Green dress, same mirror\",\"shape\":\"tall and upright\",\"place\":\"centre\",\"colour\":\"green\",\"starts\":\"starts moving left\",\"ends\":\"ends holding still\",\"speed\":\"fast\",\"seconds\":2},{\"id\":\"S3\",\"name\":\"Hand grabb ... (782 characters)",
"cuts": "[{\"id\":\"C1\",\"from\":\"S1\",\"to\":\"S2\",\"shared\":[\"motion\",\"shape\",\"place in frame\"],\"seam\":\"whip\",\"seam_name\":\"Cut the curve\",\"verdict\":\"locked\",\"spec\":\"Both shots move the same way at speed. Shot A accelerates out, travelling about 12% of the frame; cut mid-motion at peak speed; shot B carries on the same way and settles. Roughly 0.3 s on each side. Keep partial travel - nothing needs to leave the frame.\"},{\"id\":\"C2\",\"fr ... (983 characters)",
"facts": "[{\"id\":\"F1\",\"level\":\"gap\",\"ref\":\"C2\",\"text\":\"Only the place in frame carries from Green dress, same mirror to Hand grabbing a raincoat.\"},{\"id\":\"F2\",\"level\":\"gap\",\"ref\":\"C3\",\"text\":\"The motion cut from Hand grabbing a raincoat to Raincoat on, walking to the door (cut the curve) has a slow side - a motion cut hides best at peak speed, so both sides should move fast.\"},{\"id\":\"F3\",\"level\":\"gap\",\"ref\":\"C3\",\"text\":\"The cu ... (647 characters)",
"figures": "{\"shots\":\"4\",\"cuts\":\"3\",\"locked\":\"2\",\"loose\":\"1\",\"broken\":\"0\",\"gaps\":\"3\",\"seconds\":\"8\"}",
"notes": "The mirror is in a narrow hallway, so there is not much room to step back.",
"clip_note": ""
}
Reply:
{
"task": "match",
"verdict": "tighten",
"title": "Two cuts need tightening before you shoot",
"summary": "This storyboard needs tightening: 2 cuts lock, 1 is loose, and 0 are broken. Start by giving the cut from the green dress shot to the raincoat hand shot a second shared thing.",
"cuts": [
{
"cut": "C1",
"seam": "whip",
"verdict": "locked",
"end_a": "Keep the upright hoodie and jeans shot centred, then move it left fast as it exits, travelling about 12% of the frame.",
"start_b": "Pick up the green dress shot already centred and moving left at the same fast speed, then let it settle.",
"on_the_cut": "Cut mid-motion at peak speed, about 0.3 s into each shot's movement.",
"fix": ""
},
{
"cut": "C2",
"seam": "plain",
"verdict": "loose",
"end_a": "End the green dress shot drifting left rather than holding still, so a move carries into the next shot.",
"start_b": "Start the raincoat hand shot already drifting left too, instead of holding still, so the move continues.",
"on_the_cut": "Cut where both shots' leftward drift and framing line up.",
"fix": "Give both still ends the same move: have the green dress shot and the raincoat hand shot drift left instead of holding still."
},
{
"cut": "C3",
"seam": "whip",
"verdict": "locked",
"end_a": "Have the raincoat hand shot accelerate left fast at the end, travelling about 12% of the frame, instead of right.",
"start_b": "Pick up the walking shot already moving left at the same fast speed, then let it settle.",
"on_the_cut": "Cut mid-motion at peak speed, about 0.3 s into each shot's movement.",
"fix": "Make the walking shot's slow side fast, and turn its direction around so both shots at this cut travel left instead of right."
}
],
"tips": [
"Film both sides of a whip cut back to back so the speed and direction match exactly.",
"Keep one direction of travel through the whole video, not just within a single cut.",
"With no room to step back in the hallway, use a small sideways move instead."
],
"keep": "The cut from the hoodie and jeans shot to the green dress shot already locks, with motion, shape and place in frame all carrying across."
}
a weekend in four shots where one zoom flips at the cut - rethink
Input:
{
"task": "match",
"idea": "A Saturday in four shots",
"aspect": "vertical 9:16",
"shots": "[{\"id\":\"S1\",\"name\":\"Pancake flip\",\"shape\":\"round\",\"place\":\"centre\",\"colour\":\"yellow\",\"starts\":\"starts holding still\",\"ends\":\"ends moving up\",\"speed\":\"fast\",\"seconds\":2},{\"id\":\"S2\",\"name\":\"Tennis ball toss\",\"shape\":\"round\",\"place\":\"top centre\",\"colour\":\"yellow\",\"starts\":\"starts moving up\",\"ends\":\"ends pulling back, away from the lens\",\"speed\":\"fast\",\"seconds\":2},{\"id\":\"S3\",\"name\":\"Friends waving from the bench\",\"shape ... (777 characters)",
"cuts": "[{\"id\":\"C1\",\"from\":\"S1\",\"to\":\"S2\",\"shared\":[\"motion\",\"shape\",\"colour\"],\"seam\":\"whip\",\"seam_name\":\"Cut the curve\",\"verdict\":\"locked\",\"spec\":\"Both shots move the same way at speed. Shot A accelerates out, travelling about 12% of the frame; cut mid-motion at peak speed; shot B carries on the same way and settles. Roughly 0.3 s on each side. Keep partial travel - nothing needs to leave the frame.\"},{\"id\":\"C2\",\"from\":\"S2\" ... (750 characters)",
"facts": "[{\"id\":\"F1\",\"level\":\"break\",\"ref\":\"C2\",\"text\":\"Tennis ball toss ends pulling back, away from the lens, but Friends waving from the bench starts pushing in, toward the lens - the zoom flips direction at the cut.\"},{\"id\":\"F2\",\"level\":\"gap\",\"ref\":\"C3\",\"text\":\"Only the shape carries from Friends waving from the bench to Sunset over the car park. The two subjects sit in neighbouring parts of the frame - one nudge would li ... (434 characters)",
"figures": "{\"shots\":\"4\",\"cuts\":\"3\",\"locked\":\"1\",\"loose\":\"1\",\"broken\":\"1\",\"gaps\":\"1\",\"seconds\":\"11\"}",
"notes": "Everything is already filmed except the sunset.",
"clip_note": ""
}
Reply:
{
"task": "match",
"verdict": "rethink",
"title": "One broken cut and one loose cut - rethink first",
"summary": "This storyboard needs a rethink: 1 cut is locked, 1 is loose, and 1 is broken. Fix the broken cut between the tennis ball toss and the friends waving from the bench first, since the zoom flips direction there.",
"cuts": [
{
"cut": "C1",
"seam": "whip",
"verdict": "locked",
"end_a": "End the pancake flip moving up at speed, travelling about 12% of the frame, so the motion is still accelerating as you cut.",
"start_b": "Start the tennis ball toss moving up at the same fast speed as the pancake flip, carrying the motion straight on.",
"on_the_cut": "Cut mid-motion at peak speed, keeping roughly 0.3 s of movement on each side, with partial travel so nothing leaves frame.",
"fix": ""
},
{
"cut": "C2",
"seam": "plain",
"verdict": "broken",
"end_a": "End the tennis ball toss pulling back, away from the lens, keeping the ball centred near the top of frame as it recedes.",
"start_b": "Start the friends waving shot pulling back too, away from the lens, so the bench group recedes the same way the ball did.",
"on_the_cut": "Cut when both shots are pulling back at the same speed, so the movement continues smoothly across the edit.",
"fix": "Turn the friends waving shot's start around to pull back, away from the lens, matching the tennis ball toss's ending zoom."
},
{
"cut": "C3",
"seam": "plain",
"verdict": "loose",
"end_a": "End the friends waving from the bench shot wide and flat in the bottom centre of frame, letting the group go soft as it drifts out of focus.",
"start_b": "Start the sunset over the car park shot wide and flat, holding still, with the car park framed in the bottom centre to match where the friends sat.",
"on_the_cut": "Cut once both shapes read as wide and flat and the frame placement lines up, so the edit feels like one continuous view.",
"fix": "Frame the sunset shot in the bottom centre too, matching where the friends waving from the bench sit, to line up the cut."
}
],
"tips": [
"Keep one direction of travel on a cut the curve seam so the motion never doubles back.",
"Match the direction of a zoom across a cut so it never flips midway.",
"Since the sunset is still unfilmed, frame it to match the friends waving shot's place first."
],
"keep": "The pancake flip into tennis ball toss cut already carries motion, shape, and colour together."
}
Build the input yourself
Load the script in node (global.window = global; require("./cutkit.js")), then:
const K = CutKit;
const form = K.form({ idea: "Door to door", aspect: "9:16", notes: "",
shots: [{ name: "Front door opening", shape: "tall", cell: "c", colour: "red", in: "still", out: "right", speed: "fast", secs: 2 },
{ name: "Garden gate opening", shape: "tall", cell: "c", colour: "green", in: "right", out: "still", speed: "fast", secs: 2 }] });
const r = K.read(form); // r.cuts (shared handles, seam, verdict), r.facts (breaks and gaps), r.fig (counts, verdict)
const built = K.buildInput(form);
const wire = K.toWire(built.input); // POST this to /estimate and /run
// then: const reply = K.normalize(K.parseReply(job.output.output));
// K.check(reply, built).notes // the page's re-check of the reply
// K.md(reply, built, form); K.csv(built) // the Markdown storyboard and the CSV shot list
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://cut-to-match.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://cut-to-match.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// cut-to-match.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://cut-to-match.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://cut-to-match.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// CutToMatch.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class CutToMatch {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://cut-to-match.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://cut-to-match.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://cut-to-match.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://cut-to-match.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"cut-to-match"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "cut-to-match"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "cut-to-match" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "cut-to-match"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "cut-to-match"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "cut-to-match" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "cut-to-match"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "cut-to-match" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "match", "idea": "A slow morning in three round things", "aspect": "vertical 9:16", "shots": "[{\"id\":\"S1\",\"name\":\"Alarm clock on the nightstand\",\"shape\":\"round\",\"place\":\"centre\",\"colour\":\"white\",\"starts\":\"starts holding still\",\"ends\":\"ends pushing in, toward the lens\",\"speed\":\"fast\",\"seconds\":2}, ...]", "cuts": "[...]", "facts": "[]", "figures": "{\"shots\":\"3\",\"cuts\":\"2\",\"locked\":\"2\",\"loose\":\"0\",\"broken\":\"0\",\"gaps\":\"0\",\"seconds\":\"7\"}", "notes": "Shot on one phone, handheld. The mug is filmed on the kitchen table."}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "match", "idea": "A slow morning in three round things", "aspect": "vertical 9:16", "shots": "[{\"id\":\"S1\",\"name\":\"Alarm clock on the nightstand\",\"shape\":\"round\",\"place\":\"centre\",\"colour\":\"white\",\"starts\":\"starts holding still\",\"ends\":\"ends pushing in, toward the lens\",\"speed\":\"fast\",\"seconds\":2}, ...]", "cuts": "[...]", "facts": "[]", "figures": "{\"shots\":\"3\",\"cuts\":\"2\",\"locked\":\"2\",\"loose\":\"0\",\"broken\":\"0\",\"gaps\":\"0\",\"seconds\":\"7\"}", "notes": "Shot on one phone, handheld. The mug is filmed on the kitchen table."}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "match", idea, aspect, shots, cuts, facts, figures, notes, ... (see the field table)}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses cut-to-match:<task>:<inputHash>:a<attempt> (the one task is match), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="cut-to-match:match:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "cut-to-match:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `cut-to-match:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "cut-to-match:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "cut-to-match:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "cut-to-match:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["title"]].inspect
$key = "cut-to-match:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["title"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"cut-to-match:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: cut-to-match:match:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "match", "idea": "A slow morning in three round things", "aspect": "vertical 9:16", "shots": "[{\"id\":\"S1\",\"name\":\"Alarm clock on the nightstand\",\"shape\":\"round\",\"place\":\"centre\",\"colour\":\"white\",\"starts\":\"starts holding still\",\"ends\":\"ends pushing in, toward the lens\",\"speed\":\"fast\",\"seconds\":2}, ...]", "cuts": "[...]", "facts": "[]", "figures": "{\"shots\":\"3\",\"cuts\":\"2\",\"locked\":\"2\",\"loose\":\"0\",\"broken\":\"0\",\"gaps\":\"0\",\"seconds\":\"7\"}", "notes": "Shot on one phone, handheld. The mug is filmed on the kitchen table."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "cut-to-match:match:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `cut-to-match:match:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "cut-to-match:match:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "cut-to-match:match:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "cut-to-match:match:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: cut-to-match:match:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"cut-to-match:match:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);