Drive Rulecut from your own code
The page does its free work in the browser: it splits off the frontmatter, replaces every code block with a marker line
(@@KEEP_01@@ ...), lists what must survive and runs a port of the caveman-compress validator. Its two paid
lanes are available over HTTP. You send one JSON object with task set to "compress" or
"fix", and the model replies with one JSON object whose text lines you put back into the file. The easiest
way to build the input is the page itself: TerseKit.buildInput(TerseKit.analyze(text, {name, compressed}), lane)
in tersekit.js, which also runs under Node; TerseKit.assemble(A, lane, reply.compressed || reply.fixed)
restores the markers and the frontmatter and runs the validator on the result.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10%
publisher markup. A run needs a signed-in (user) token and is metered: the platform holds
the estimate's hold_credits while the run is in flight and then charges only
what it used (charged_credits). /estimate is free. A plain-text summary is at
/llms.txt.
The response envelope
Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.
Error codes
| HTTP | code | meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a field is the wrong type. Every field of the run input is a string. |
| 401 | UNAUTHORIZED | No token, or an expired one: get a new one (step 2). |
| 402 | INSUFFICIENT_CREDITS | The balance is below the run's minimum. Top up at skillsafe.ai/account/billing/. |
| 403 | FORBIDDEN | A guest token tried a metered run: sign in for a personal token. |
| 404 | NOT_FOUND | Unknown job id. |
| 429 | RATE_LIMITED | Back off and retry with the same Idempotency-Key. |
The task field
Two lanes, in the order a person meets them:
"compress"- rewrite the body in terse caveman style: articles, filler, pleasantries, hedging and connective fluff removed, sentences as fragments, duplicates merged only when nothing protected is lost. Every heading, marker line, inline code span, URL, path, environment name, version, date and number is kept exactly. A long file is sent one part at a time (cut on headings)."fix"- the skill's targeted repair of a compressed version that fails the validator: one repair per error, text copied back from the original, an altered code block swapped for its marker, every other line left as it was.
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | compress or fix |
body | required | The file without its frontmatter, every code block replaced by a marker line (@@KEEP_01@@, @@KEEP_02@@ ...). For compress, one part of a long file (up to 16,000 characters). |
compressed | fix only | The compressed version, masked with the original's markers wherever a block survived byte for byte; an altered block stays raw. |
facts | required | A JSON string: file{name, kind, frontmatter}, part{n, of, from_line, to_line} (compress), measure{chars, words, lines, tokens}, protect{markers[], headings[], inline_code, urls[], paths[], env[], versions[], bullets}, errors[{id, code, text, lost[], added[]}] (fix; for a code error lost names the missing markers). |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contract
compress: task, task_note, compressed[] (one string per line, every marker line present once),
changes[{kind: article|filler|pleasantry|hedging|phrase|fragment|synonym|merge|example, before, after}] (2-8, quoted from the body and from the result),
at_risk[{before, note}] (0-5), headline, summary.
fix: task, task_note, fixed[] (one string per line), repairs[{id, action}] (one per error, same ids and order),
headline, summary. Neither lane states a savings figure: the page measures them.
Worked examples
These are the page's own bundled examples (an invented shop, people and URLs), with the input as app.js builds it and the saved model reply the page replays for free - two for compress, one for fix.
compress: a shop's notes for its coding agent
Input (body shown separately, facts shortened here):
{
"task": "compress",
"body": "(a string - shown below)",
"facts": "(a JSON string; decoded: part 1 of 1, about 821 tokens; protects 7 headings, 1 markers, 16 inline code spans, 4 URLs, 1 paths)"
}
body:
# Larkspur Supply storefront - notes for the coding agent
## About the project
Larkspur Supply is a small online shop that sells garden tools and seeds. The storefront is a Next.js 14 application
written in TypeScript, and it talks to a separate order service that we basically treat as a black box. You should
always keep in mind that the shop is run by three people, so it is really important to keep things simple and to avoid
adding new dependencies unless there is a very good reason for it.
The production site lives at https://larkspur-supply.example and the staging site lives at
https://staging.larkspur-supply.example. Please make sure that you never point anything at production while you are
testing.
## Getting started
In order to run the project locally, you will need Node.js 20.11 and pnpm 9. Once you have those installed, you can
simply run the following commands from the root of the repository:
@@KEEP_01@@
The development server will then be available at http://localhost:3100. The reason this port is used is because the
order service mock already runs on port 3000.
## Where things live
- Page routes are in `src/app/`, and each route folder has its own `page.tsx` file.
- Shared UI components are in `src/components/`. It might be worth checking there before you write a new component,
because there is a good chance that something similar already exists.
- The cart logic lives in `src/lib/cart/` and it is generally the most fragile part of the codebase.
- Product data comes from the CMS, and the client for it is in `src/lib/cms/client.ts`.
- Tests live next to the code they test, in files that end in `.test.ts`.
## Environment
The app reads a handful of environment variables. You should never commit real values for any of them.
| Variable | What it is for |
| --- | --- |
| `NEXT_PUBLIC_SHOP_URL` | The public base URL of the shop, used in links and in the sitemap |
| `ORDER_API_BASE` | The base URL of the order service |
| `CMS_TOKEN` | The read-only token that is used to fetch product data from the CMS |
If you add a new variable, please remember to add it to `.env.example` as well, with a placeholder value, so that
other people know that it exists.
## Rules for changes
1. Always run `pnpm lint` and `pnpm test` before you consider a change to be finished.
2. Do not change anything in `src/lib/cart/` without also adding or updating a test that covers the change. The cart
has broken in production twice, on 2026-03-14 and on 2026-06-02, and both times it was because of an untested
change.
3. Prices are stored in cents as integers. You should never use floating point numbers for money, and you should
format prices for display with the `formatPrice` helper rather than doing it by hand.
4. Keep pull requests small. As a general rule, a pull request should do one thing, and it should be possible to
review it in about 15 minutes.
## Style
We use the default Prettier settings and the ESLint configuration in `eslint.config.mjs`. Additionally, we prefer
named exports over default exports, because named exports are easier to search for. However, Next.js requires default
exports for page files, so `page.tsx` files are the one exception to this rule.
For more background on how the order service works, see the design notes at
https://wiki.larkspur-supply.example/order-service.
Reply:
{
"task": "compress",
"task_note": "",
"compressed": [
"# Larkspur Supply storefront - notes for the coding agent",
"",
"## About the project",
"",
"Larkspur Supply: small online shop, garden tools and seeds. Storefront is Next.js 14 app in TypeScript; talks to separate order service treated as black box. Shop run by three people: keep things simple, no new dependencies without very good reason.",
"",
"Production: https://larkspur-supply.example. Staging: https://staging.larkspur-supply.example. Never point anything at production while testing.",
"",
"## Getting started",
"",
"Run locally: need Node.js 20.11 and pnpm 9. Then run from repository root:",
"",
"@@KEEP_01@@",
"",
"Dev server at http://localhost:3100, because order service mock already runs on port 3000.",
"",
"## Where things live",
"",
"- Page routes in `src/app/`; each route folder has own `page.tsx`.",
"- Shared UI components in `src/components/`. Check there before writing new component; something similar likely exists.",
"- Cart logic in `src/lib/cart/`; most fragile part of codebase.",
"- Product data from CMS; client in `src/lib/cms/client.ts`.",
"- Tests next to code they test, in files ending `.test.ts`.",
"",
"## Environment",
"",
"App reads a handful of environment variables. Never commit real values for any.",
"",
"| Variable | What it is for |",
"| --- | --- |",
"| `NEXT_PUBLIC_SHOP_URL` | Public base URL of shop, used in links and sitemap |",
"| `ORDER_API_BASE` | Base URL of order service |",
"| `CMS_TOKEN` | Read-only token to fetch product data from CMS |",
"",
"New variable: add it to `.env.example` too, with placeholder value, so others know it exists.",
"",
"## Rules for changes",
"",
"1. Always run `pnpm lint` and `pnpm test` before considering change finished.",
"2. No changes in `src/lib/cart/` without adding or updating test covering change. Cart broke in production twice, 2026-03-14 and 2026-06-02, both from untested changes.",
"3. Prices stored in cents as integers. Never use floating point for money; format prices for display with `formatPrice` helper, not by hand.",
"4. Keep pull requests small. Rule of thumb: one pull request does one thing, reviewable in about 15 minutes.",
"",
"## Style",
"",
"Default Prettier settings and ESLint config in `eslint.config.mjs`. Prefer named exports over default exports: easier to search for. Next.js requires default exports for page files, so `page.tsx` files are the one exception.",
"",
"Order service background: design notes at https://wiki.larkspur-supply.example/order-service."
],
"changes": [
{
"kind": "fragment",
"before": "Please make sure that you never point anything at production while you are",
"after": "Never point anything at production while testing."
},
{
"kind": "hedging",
"before": "It might be worth checking there before you write a new component,",
"after": "Check there before writing new component; something similar likely exists."
},
{
"kind": "phrase",
"before": "The reason this port is used is because the",
"after": "because order service mock already runs on port 3000."
},
{
"kind": "filler",
"before": "it is generally the most fragile part of the codebase.",
"after": "most fragile part of codebase."
},
{
"kind": "fragment",
"before": "3. Prices are stored in cents as integers. You should never use floating point numbers for money, and you should",
"after": "3. Prices stored in cents as integers. Never use floating point for
... (shortened here; the page shows it whole)
compress: writing preferences with frontmatter and a duplicated rule
Input (the frontmatter is kept by the page and never sent):
{
"task": "compress",
"body": "(a string - shown below)",
"facts": "(a JSON string; decoded: part 1 of 1, about 433 tokens; protects 6 headings, 1 markers, 0 inline code spans, 0 URLs, 1 paths)"
}
body:
# Writing preferences
## Tone
Sure! Here are my preferences. I generally prefer a plain, direct tone. It would be good to avoid marketing language,
and I would really rather not see words like "seamless", "robust" or "leverage" in anything you draft for me. When you
are not certain about something, you should say so clearly instead of hedging every single sentence.
## Drafts
- When I ask for a draft, give me one version, not three. If I want alternatives, I will ask for them.
- Keep paragraphs short. As a rule of thumb, a paragraph should be no longer than four sentences.
- Use British spelling (organise, colour, catalogue), because most of my readers are in the UK.
- Do not add a conclusion section that simply repeats what the document has already said.
- Do not add a conclusion that restates the earlier sections in different words.
## Reviews
When I ask you to review something that I have written, I would like you to:
1. Point out anything that is factually wrong first, before anything else.
2. Then point out sentences that are hard to follow, and suggest a shorter version for each of them.
3. Leave style preferences until the very end, and keep them to five items at most.
Please do not rewrite the whole document unless I specifically ask you to do that.
## Summaries
For meeting notes, I basically want three things: the decisions that were made, the actions with an owner and a
date, and the open questions. A summary should fit on one screen. For example, a summary of the planning meeting on
2026-09-11 would look like this:
@@KEEP_01@@
## Things I am working on
- [x] Finish the Q3 report for the board
- [ ] Rewrite the onboarding guide for new contractors
- [ ] Review the style guide draft that Tomasz shared in the team folder at docs/style/house-style-v3.md
Reply:
{
"task": "compress",
"task_note": "",
"compressed": [
"",
"# Writing preferences",
"",
"## Tone",
"",
"Prefer plain, direct tone. Avoid marketing language; no words like \"seamless\", \"robust\" or \"leverage\" in drafts for me.",
"When unsure, say so clearly; don't hedge every sentence.",
"",
"## Drafts",
"",
"- Draft request: give one version, not three. Want alternatives, I'll ask.",
"- Short paragraphs. Rule of thumb: max four sentences.",
"- British spelling (organise, colour, catalogue): most readers in UK.",
"- No conclusion section repeating or restating earlier sections.",
"",
"## Reviews",
"",
"Reviewing my writing:",
"",
"1. Factual errors first, before anything else.",
"2. Then hard-to-follow sentences, with shorter version for each.",
"3. Style preferences last, five items max.",
"",
"Don't rewrite whole document unless I specifically ask.",
"",
"## Summaries",
"",
"Meeting notes: three things - decisions made, actions with owner and date, open questions. Summary fits on one screen.",
"Example, planning meeting summary 2026-09-11:",
"",
"@@KEEP_01@@",
"",
"## Things I am working on",
"",
"- [x] Finish Q3 report for board",
"- [ ] Rewrite onboarding guide for new contractors",
"- [ ] Review style guide draft Tomasz shared in team folder at docs/style/house-style-v3.md"
],
"changes": [
{
"kind": "merge",
"before": "- Do not add a conclusion that restates the earlier sections in different words.",
"after": "- No conclusion section repeating or restating earlier sections."
},
{
"kind": "pleasantry",
"before": "Sure! Here are my preferences. ",
"after": ""
},
{
"kind": "hedging",
"before": "It would be good to avoid marketing language,",
"after": "Avoid marketing language;"
},
{
"kind": "fragment",
"before": "1. Point out anything that is factually wrong first, before anything else.",
"after": "1. Factual errors first, before anything else."
},
{
"kind": "filler",
"before": "For meeting notes, I basically want three things:",
"after": "Meeting notes: three things -"
},
{
"kind": "phrase",
"before": "3. Leave style preferences until the very end, and keep them to five items at most.",
"after": "3. Style preferences last, five items max."
},
{
"kind": "article",
"before": "- [x] Finish the Q3 report for the board",
"after": "- [x] Finish Q3 report for board"
}
],
"at_risk": [
{
"before": "It would be good to avoid marketing language,",
"note": "A soft preference now reads as a firm rule (\"Avoid\"); same for \"would really rather not\" becoming \"no\"."
},
{
"before": "- Do not add a conclusion section that simply repeats what the document has already said.",
"note": "Two conclusion bullets merged into one; the Drafts list now has one bullet fewer."
}
],
"headline": "Tone, Drafts, Reviews and Summaries cut to fragments; every heading, the code block marker, the date and docs/style/house-style-v3.md kept.",
"summary": "Pleasantries, hedging, filler and articles were cut, and the two duplicate conclusion bullets were merged into one. Every heading, the @@KEEP_01@@ marker, the date 2026-09-11, the path docs/style/house-style-v3.md, the banned words and the todo checkboxes were kept exactly. Re-read the Tone section: soft wording such as \"would rather not\" now reads as a firm rule."
}
fix: a runbook compressed elsewhere that broke four things
Input (body and the compressed version shown separately):
{
"task": "fix",
"body": "(a string - shown below)",
"compressed": "(a string - shown below)",
"facts": "(a JSON string; decoded: 4 errors - E1 headings, E2 code, E3 urls, E4 inline; 2 markers)"
}
body:
# On-call notes for the payments API
## Before you change anything
You should always check the status page at https://status.payments.example first, because there is a good chance
that the incident has already been reported and that someone else is working on it. If there is already an open
incident, please join the channel `#inc-payments` instead of starting a new investigation.
## Restarting the API
In most cases, a restart of the API pods fixes the problem. The deployment is called `payments-api` and it runs in
the `prod` namespace. To restart it, run the following command:
@@KEEP_01@@
After the restart, it is really important to watch the error rate on the dashboard at
https://grafana.payments.example/d/payments-overview for at least ten minutes before you close the incident.
## Rolling back a release
If the problem started right after a release, it is generally better to roll back than to try to fix forward. The
release history is kept in `deploy/releases.yaml`, and each entry has a version number such as 3.18.2. To roll back,
run the rollback script with the previous version:
@@KEEP_02@@
The script will ask you to confirm. Make sure that you type the version number exactly, because the script does not
check it against the release history.
## Escalation
If the error rate is still above 2% after a restart and a rollback, page the payments lead through the escalation
policy called `payments-primary`. Write a short summary in the incident document that includes the time the problem
started, what you have tried, and what the error rate is now.
compressed:
# On-call notes for payments API
## Before you change anything
Check status page first - incident often already reported, someone already on it. Open incident: join `#inc-payments`,
don't start new investigation.
## Restart API
Restart of API pods fixes most problems. Deployment `payments-api`, runs in `prod` namespace. Restart:
kubectl rollout restart deployment/payments-api -n dev
After restart, watch error rate on dashboard at https://grafana.payments.example/d/payments-overview for at least ten
minutes before closing incident.
## Rolling back a release
Problem started right after release: roll back, don't fix forward. Release history in `deploy/releases.yaml`, each
entry has version number like 3.18.2. Roll back with previous version:
@@KEEP_02@@
Script asks to confirm. Type version number exactly - script does not check it against release history.
## Escalation
Error rate still above 2% after restart and rollback: page payments lead via escalation policy. Write short summary in
incident document: time problem started, what tried, current error rate.
Reply:
{
"task": "fix",
"task_note": "",
"fixed": [
"# On-call notes for the payments API",
"",
"## Before you change anything",
"",
"Check status page at https://status.payments.example first - incident often already reported, someone already on it. Open incident: join `#inc-payments`,",
"don't start new investigation.",
"",
"## Restarting the API",
"",
"Restart of API pods fixes most problems. Deployment `payments-api`, runs in `prod` namespace. Restart:",
"",
"@@KEEP_01@@",
"",
"After restart, watch error rate on dashboard at https://grafana.payments.example/d/payments-overview for at least ten",
"minutes before closing incident.",
"",
"## Rolling back a release",
"",
"Problem started right after release: roll back, don't fix forward. Release history in `deploy/releases.yaml`, each",
"entry has version number like 3.18.2. Roll back with previous version:",
"",
"@@KEEP_02@@",
"",
"Script asks to confirm. Type version number exactly - script does not check it against release history.",
"",
"## Escalation",
"",
"Error rate still above 2% after restart and rollback: page payments lead via escalation policy `payments-primary`. Write short summary in",
"incident document: time problem started, what tried, current error rate."
],
"repairs": [
{
"id": "E1",
"action": "Restored heading lines \"# On-call notes for the payments API\" (line 1) and \"## Restarting the API\" (line 8) exactly as in body."
},
{
"id": "E2",
"action": "Replaced the raw indented kubectl block at line 12 with marker @@KEEP_01@@, which stands at that place in body; the page puts the original block back."
},
{
"id": "E3",
"action": "Put https://status.payments.example back into the first sentence under \"## Before you change anything\" (\"Check status page at ... first\")."
},
{
"id": "E4",
"action": "Restored inline code `payments-primary` as the escalation policy name in the first sentence under \"## Escalation\"."
}
],
"headline": "Restored 2 headings, the @@KEEP_01@@ marker in place of an altered kubectl block, the status-page URL and `payments-primary`.",
"summary": "All four validator failures are repaired; every other line is unchanged. The raw kubectl block in the compressed version had been altered (it targets -n dev, while body says the deployment runs in `prod`), so it was swapped for @@KEEP_01@@ and the page will restore the original command."
}
The page re-checks the reply; the API does not
The page puts every reply back into the whole file and holds it to account (recon.js): every code block marker exactly once, or it refuses to guess; the skill's validator over the result - headings, code blocks, URLs, file paths, inline code; shorter than the original; environment names, versions and dates kept; every quoted rewrite present in the original and the result; at most five at-risk sentences quoted from the original; no savings figure claimed by the reply. The fix lane adds one repair per error in order and a cap on lines rewritten that no error touched. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same file into the page.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://rulecut.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://rulecut.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"]
// rulecut.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://rulecut.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://rulecut.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
}
// Rulecut.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 Rulecut {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://rulecut.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://rulecut.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://rulecut.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://rulecut.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":"rulecut"}' | 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": "rulecut"})["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: "rulecut" })).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": "rulecut"}, 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", "rulecut"), 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: "rulecut" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "rulecut"])["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 = "rulecut" }))!["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 (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "compress", "body": "...", "facts": "..."}
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": "compress", "body": "...", "facts": "..."}
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, body, facts}
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 rulecut:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="rulecut:fix:$(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 lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "rulecut:" + 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 lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("headline"))
import { createHash } from "node:crypto";
const key = `rulecut:${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.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "rulecut:" + 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 = "rulecut:" + 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 = "rulecut:#{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["headline"]].inspect
$key = "rulecut:" . $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["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"rulecut:{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. fix.json is a run input like input.json.
# 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: rulecut:fix:$(shasum -a 256 fix.json | cut -c1-16):a1" --data @fix.json
rev = json.load(open("fix.json")) # the same run input as input.json: {"task": "fix", "body": "...", "compressed": "...", "facts": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "rulecut:fix:" + 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("fix.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": `rulecut:fix:${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("fix.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", "rulecut:fix:"+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("fix.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", "rulecut:fix:" + 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("fix.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "rulecut:fix:#{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("fix.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: rulecut:fix:" . 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("fix.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", $"rulecut:fix:{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);