Drive Interface Desk from your own code
The page does its free work in the browser: it reads the brief against the design-an-interface skill's five requirement
questions, lists the key operations, counts the entry points and parameters of every signature and assigns each design
its constraint. Its two paid lanes are available over HTTP. You send one JSON object with task set to
"designs" or "compare", and the model replies with one JSON object. The easiest way to build the
input is the page itself: IfaceKit.buildInput(IfaceKit.analyze({module, language, brief, sketch, designs, count, paradigm}), lane)
in ifacekit.js, which also runs under Node; Recon.reconcile(reply, {A, K: IfaceKit, lane, sent})
in recon.js runs the page's checks on a normalised reply.
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, a required field is missing, 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:
"designs"- restate the requirements from the brief, then write one interface design per assigned constraint: fewest methods (1-3), most flexible, the common case first, and optionally one that takes inspiration from a paradigm or library. Each design has a signature (declarations only, in the requested language), a usage example, what it hides, its trade-offs and which entry point serves each key operation."compare"- weigh two to six designs in prose on interface simplicity, general-purpose vs specialised, implementation efficiency, depth and ease of misuse; say where they diverge most; synthesise an interface from one base design with named borrowings; ask the user which design fits the primary use case.
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | designs or compare |
module | required | The module's name, e.g. rate limiter. |
language | required | One of TypeScript, JavaScript, Python, Go, Rust, Java, C#, Kotlin, Swift. |
brief | required | The module brief, up to 8,000 characters (the page cuts lines from the middle beyond that and marks the cut). |
sketch | designs, optional | Your own first interface idea, up to 4,000 characters - context, never one of the designs. |
designs | compare only | A JSON string: [{id, name, constraint, signature[], usage[], hides[], tradeoffs[]}], at most 14,000 characters. |
facts | required | A JSON string: brief{answered[], missing[], operations[], callers[], constraints[], hidden[]}, assign[{id, constraint}] (designs), metrics (per design for compare: {entry_points[], params, max_params, flag_params, types[], implemented[]} - implemented lists methods of an injected interface, which the caller implements and which are not entry points; for designs, the sketch's), clip. |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contract
designs: task, task_note, requirements{problem, callers[], operations[], constraints[], hidden[], assumed[]},
designs[{id, name, constraint, signature[], usage[], hides[], tradeoffs[], covers[{operation, via}]}] (one per facts.assign, in order),
headline, summary.
compare: task, task_note, criteria[{criterion: simplicity|general_purpose|efficiency|depth|misuse, discussion, favours}] (exactly five, in that order; favours a design id or tie),
divergence[], synthesis{base, borrowed[{from, element, why}], signature[], usage[], rationale}, questions[], headline, summary.
Code is always an array of lines. Neither lane states a measurement: the page counts.
Worked examples
These are the page's own bundled examples (invented modules and callers), with the input as app.js builds it and the saved model reply the page replays for free - two for designs, one for compare.
designs: a rate limiter for an API gateway, TypeScript, three designs
Input (the brief shown separately, facts shortened here):
{
"task": "designs",
"module": "rate limiter",
"language": "TypeScript",
"brief": "(a string - shown below)",
"sketch": "",
"facts": "(a JSON string; decoded: 5 of 5 questions answered, 4 key operations; assigns A: Minimize method count - aim for 1-3 methods max; B: Maximize flexibility - support many use cases; C: Optimize for the most common case)"
}
brief:
# Rate limiter for the Quillmere API gateway
Problem: one noisy tenant can starve every other tenant on the shared gateway. We need a per-tenant rate limiter that the
gateway consults before it forwards a request, and that the admin console can query.
Callers:
- the HTTP middleware in the gateway, once per incoming request (hot path)
- the admin console, to show a tenant's remaining quota and to lift a limit after a plan upgrade
- integration tests, which need to advance time without sleeping
Operations:
- check whether a request may proceed, and if not, how long the caller should wait
- report the remaining quota for a tenant
- reset a tenant's usage after a plan upgrade
- change a tenant's limits without restarting the gateway
Constraints:
- the check must add well under a millisecond on the hot path
- state lives in memory on one node today; a shared Redis store is planned for next quarter, and callers must not change when it arrives
- the gateway code base is TypeScript on Node 22 and uses async/await throughout
- some routes cost more than others: a bulk export counts as 10 ordinary requests
Hidden:
- the token-bucket arithmetic and refill timing
- where the counters are stored (memory now, Redis later)
- clock handling, so tests can control time
Reply:
{
"task": "designs",
"task_note": "",
"requirements": {
"problem": "One noisy tenant can starve every other tenant on the shared Quillmere API gateway. The module is a per-tenant rate limiter that the gateway consults before forwarding a request and that the admin console can query.",
"callers": [
"the HTTP middleware in the gateway, once per incoming request (hot path)",
"the admin console, to show a tenant's remaining quota and to lift a limit after a plan upgrade",
"integration tests, which need to advance time without sleeping"
],
"operations": [
"check whether a request may proceed, and if not, how long the caller should wait",
"report the remaining quota for a tenant",
"reset a tenant's usage after a plan upgrade",
"change a tenant's limits without restarting the gateway"
],
"constraints": [
"the check must add well under a millisecond on the hot path",
"state lives in memory on one node today; a shared Redis store is planned for next quarter, and callers must not change when it arrives",
"the gateway code base is TypeScript on Node 22 and uses async/await throughout",
"some routes cost more than others: a bulk export counts as 10 ordinary requests"
],
"hidden": [
"the token-bucket arithmetic and refill timing",
"where the counters are stored (memory now, Redis later)",
"clock handling, so tests can control time"
],
"assumed": [
"Every call is async (returns a Promise) even with the in-memory store, so callers need not change when the Redis store arrives.",
"A tenant's limit can be expressed as a capacity plus a refill rate per second; the brief names token buckets but not the exact limit fields.",
"Route cost is known by the gateway at check time, either as a number or as a route name looked up in a cost table."
]
},
"designs": [
{
"id": "A",
"name": "Factory plus two verbs: admit and adjust",
"constraint": "Minimize method count - aim for 1-3 methods max",
"signature": [
"type TenantId = string;",
"",
"interface Limits { capacity: number; refillPerSecond: number }",
"",
"interface LimiterConfig {",
" defaults: Limits;",
" store?: { kind: \"memory\" } | { kind: \"redis\"; url: string };",
" clock?: \"system\" | \"manual\";",
"}",
"",
"interface Decision {",
" allowed: boolean;",
" remaining: number;",
" retryAfterMs: number;",
"}",
"",
"type Change =",
" | { kind: \"reset\" }",
" | { kind: \"limits\"; limits: Limits }",
" | { kind: \"advanceClock\"; ms: number };",
"",
"interface Limiter {",
" admit(tenant: TenantId, cost: number): Promise<Decision>;",
" adjust(tenant: TenantId, change: Change): Promise<Decision>;",
"}",
"",
"declare function createLimiter(config: LimiterConfig): Limiter;"
],
"usage": [
"const limiter = createLimiter({ defaults: { capacity: 100, refillPerSecond: 10 } });",
"",
"const d = await limiter.admit(tenantId, route === \"/export\" ? 10 : 1);",
"if (!d.allowed) res.setHeader(\"Retry-After\", Math.ceil(d.retryAfterMs / 1000));",
"",
"// admin console: cost 0 peeks without consuming",
"const { remaining } = await limiter.admit(tenantId, 0);",
"await limiter.adjust(tenantId, { kind: \"limits\", limits: { capacity: 500, refillPerSecond: 50 } });",
"await limiter.adjust(tenantId, { kind: \"reset\" });",
"",
"// tests, created with clock: \"manual\"",
"await limiter.adjust(tenantId, { kind: \"advanceClock\", ms: 60_000 });"
],
"hides": [
"the token-bucket arithmetic and refill timing behind a single Decision value",
"the choice of counter store, selected once in the config and invisible to admit and adjust",
"clock handling: the manual clock exists only as a config switch and one Change variant",
"every admin mutation behind one adjust entry point keyed by a discriminated union"
],
"tradeoffs": [
"Only three things to learn (createLimiter, admit, adjust); new admin actions become new Change variants, not new methods.",
"Reporting remaining quota by admitting with cost 0 is a convention a reader must know;
... (shortened here; the page shows it whole)
designs: a feature-flag client in Python with a first sketch, four designs
Input (the brief and the sketch shown separately):
{
"task": "designs",
"module": "feature-flag client",
"language": "Python",
"brief": "(a string - shown below)",
"sketch": "(a string - shown below)",
"facts": "(a JSON string; decoded: 5 of 5 questions answered, 4 key operations; assigns A: Minimize method count - aim for 1-3 methods max; B: Maximize flexibility - support many use cases; C: Optimize for the most common case; D: Take inspiration from Python's logging module (named loggers in a dotted hierarchy))"
}
brief:
# Feature-flag client for the Fernhollow billing service
Problem: the billing service decides at runtime whether new behaviour is on for a customer - a new invoice layout, a
changed proration rule - and today it reads environment variables scattered through the code. We want one small client
module that answers "is this on for this customer?" consistently.
Callers:
- request handlers in the billing service (Django), many times per request
- nightly batch jobs that loop over every customer account
- unit tests, which must force a flag on or off without a network call
Operations:
- ask whether a boolean flag is on for a given customer
- get the chosen variant of a multi-variant experiment for a customer
- force a flag's value inside a test, and undo it afterwards
- list every flag the service evaluated during a request, for the audit log
Constraints:
- flag definitions come from a remote flag service; that service may be down, and a sensible default must apply
- evaluation must not make a network call per check - definitions are refreshed in the background every 30 seconds
- the code base is Python 3.12 with type hints checked by mypy in strict mode
- percentage rollouts must give a customer the same answer on every call
Hidden:
- the refresh loop and the cache of flag definitions
- the hashing that buckets customers into rollout percentages
- the HTTP client used to talk to the flag service
sketch:
class FlagClient:
def __init__(self, service_url: str, api_key: str, refresh_seconds: int = 30) -> None: ...
def is_enabled(self, flag: str, customer_id: str, default: bool = False) -> bool: ...
def get_variant(self, flag: str, customer_id: str, default: str = "control") -> str: ...
def override(self, flag: str, value: bool) -> None: ...
def clear_override(self, flag: str) -> None: ...
def evaluated_flags(self) -> list[str]: ...
Reply:
{
"task": "designs",
"task_note": "",
"requirements": {
"problem": "The Fernhollow billing service needs one small client that answers consistently whether new behaviour (a flag or an experiment variant) is on for a given customer, replacing environment variables scattered through the code.",
"callers": [
"request handlers in the billing service (Django), many times per request",
"nightly batch jobs that loop over every customer account",
"unit tests, which must force a flag on or off without a network call"
],
"operations": [
"ask whether a boolean flag is on for a given customer",
"get the chosen variant of a multi-variant experiment for a customer",
"force a flag's value inside a test, and undo it afterwards",
"list every flag the service evaluated during a request, for the audit log"
],
"constraints": [
"flag definitions come from a remote flag service; that service may be down, and a sensible default must apply",
"evaluation must not make a network call per check - definitions are refreshed in the background every 30 seconds",
"the code base is Python 3.12 with type hints checked by mypy in strict mode",
"percentage rollouts must give a customer the same answer on every call"
],
"hidden": [
"the refresh loop and the cache of flag definitions",
"the hashing that buckets customers into rollout percentages",
"the HTTP client used to talk to the flag service"
],
"assumed": [
"The service URL and API key are supplied once at process start-up (A, B, D); the brief does not say where they come from.",
"A Django request carries an authenticated user from which the customer id can be read, and per-flag fallback defaults live in Django settings (C).",
"Flag keys can be named as dotted paths such as 'billing.invoices.new_layout' (D).",
"Each request is evaluated in its own context (thread or contextvar), so a per-request audit trail does not mix requests (A, C)."
]
},
"designs": [
{
"id": "A",
"name": "Typed flag values + one evaluate() + one scope()",
"constraint": "Minimize method count - aim for 1-3 methods max",
"signature": [
"from collections.abc import Iterator, Mapping",
"from contextlib import contextmanager",
"from dataclasses import dataclass",
"from typing import Generic, TypeVar",
"",
"T = TypeVar('T', bool, str)",
"",
"@dataclass(frozen=True)",
"class Flag(Generic[T]):",
" key: str",
" default: T",
"",
"def configure(service_url: str, api_key: str) -> None: ...",
"",
"def evaluate(flag: Flag[T], customer_id: str) -> T: ...",
"",
"@contextmanager",
"def scope(pins: Mapping[str, bool | str] | None = None) -> Iterator[list[str]]: ..."
],
"usage": [
"NEW_INVOICE_LAYOUT = Flag('new-invoice-layout', default=False)",
"PRORATION = Flag('proration-rule', default='control')",
"",
"with scope() as evaluated:",
" if evaluate(NEW_INVOICE_LAYOUT, customer.id):",
" render_new_layout(invoice)",
" rule = evaluate(PRORATION, customer.id)",
"audit_log.record(flags=evaluated)",
"",
"def test_new_layout() -> None:",
" with scope(pins={'new-invoice-layout': True}):",
" assert evaluate(NEW_INVOICE_LAYOUT, 'cust-1') is True"
],
"hides": [
"the refresh loop and the cache of flag definitions, started by configure()",
"the hashing that buckets customers into rollouts, inside evaluate()",
"the HTTP client used to talk to the flag service",
"the fallback to Flag.default when the flag service is down or the key is unknown",
"how scope() tracks evaluations and pins per request context"
],
"tradeoffs": [
"Three callables cover all four operations: the boolean and variant checks share evaluate(), whose return type mypy infers from the Flag's default.",
"Defaults are declared once with the flag, not at every call site as in the sketch's default= parameters, so two call sites cannot disagree on a default.",
"scope() does double duty (audit trail and test pins), which is compact but means a reader has to learn that one context manager serves two different callers.",
... (shortened here; the page shows it whole)
compare: the rate limiter's three designs
Input (the designs are the first example's reply, handed over by the page):
{
"task": "compare",
"module": "rate limiter",
"language": "TypeScript",
"brief": "(a string - shown below)",
"designs": "(a JSON string: 3 designs - A Factory plus two verbs: admit and adjust, B Pluggable engine: store, clock and policy resolver injected, C Hot-path function returning a wait time, admin functions beside it)",
"facts": "(a JSON string; decoded: 5 of 5 questions answered, 4 key operations; metrics for A 3 entry points, B 9 entry points, C 8 entry points)"
}
brief:
# Rate limiter for the Quillmere API gateway
Problem: one noisy tenant can starve every other tenant on the shared gateway. We need a per-tenant rate limiter that the
gateway consults before it forwards a request, and that the admin console can query.
Callers:
- the HTTP middleware in the gateway, once per incoming request (hot path)
- the admin console, to show a tenant's remaining quota and to lift a limit after a plan upgrade
- integration tests, which need to advance time without sleeping
Operations:
- check whether a request may proceed, and if not, how long the caller should wait
- report the remaining quota for a tenant
- reset a tenant's usage after a plan upgrade
- change a tenant's limits without restarting the gateway
Constraints:
- the check must add well under a millisecond on the hot path
- state lives in memory on one node today; a shared Redis store is planned for next quarter, and callers must not change when it arrives
- the gateway code base is TypeScript on Node 22 and uses async/await throughout
- some routes cost more than others: a bulk export counts as 10 ordinary requests
Hidden:
- the token-bucket arithmetic and refill timing
- where the counters are stored (memory now, Redis later)
- clock handling, so tests can control time
Reply:
{
"task": "compare",
"task_note": "",
"criteria": [
{
"criterion": "simplicity",
"discussion": "A is the smallest surface by a wide margin: 3 entry points (createLimiter, admit, adjust), 5 parameters in total and at most 2 on any call, so a new caller learns one factory and two verbs. C has 8 entry points and 8 parameters with at most 2 per call, and each function name says exactly what it does, which makes it easy to read even though there is more of it. B has 9 entry points and 13 parameters with up to 3 on one call, and on top of that the caller must supply or understand four implemented methods (load, save, now, resolve) before the first evaluate. A is the easiest to learn, with the caveat that its compactness comes from packing admin actions into one Change union.",
"favours": "A"
},
{
"criterion": "general_purpose",
"discussion": "B is the most general: the store, clock, policy source and cost rule are all injected, and LimitKey already carries an optional scope, so a Redis CounterStore, per-route limits or a new cost rule arrive without touching callers. The risk is over-generalisation, since the brief names only one concrete future change (the Redis store next quarter) and B pays for many futures it does not mention. A covers that same Redis change through a store variant in its config and absorbs new admin actions as new Change variants, which is general enough for this brief. C's module-level state and fixed set of functions mean one limiter per process, so it is the least able to grow.",
"favours": "B"
},
{
"criterion": "efficiency",
"discussion": "The hot path must add well under a millisecond, so the shape of the per-request call matters most. A's admit returns allowed, remaining and retry time in one awaited call, with the store and limits fixed at creation, which leaves nothing extra to look up per request. C's waitBeforeForward is equally lean, but because it returns only a wait time, middleware that wants to send remaining-quota headers must make a second call to remainingQuota. B's evaluate awaits a PolicyResolver and runs a CostFunction on every request and also dispatches events, and the shape does not force the resolver to cache, so it is the one most likely to push latency onto the hot path.",
"favours": "A"
},
{
"criterion": "depth",
"discussion": "A is the deepest: behind 3 entry points it hides the token-bucket arithmetic, the store choice and clock handling, which is exactly the hidden list in the brief. C also hides route-cost lookup, which A leaves to the caller, but it spreads that across 8 entry points, including test-only advance, release and freezeTime in the same public surface. B is the shallowest relative to what it hides, because it publishes CounterStore and BucketState, which puts the storage boundary the brief wants hidden into the public types.",
"favours": "A"
},
{
"criterion": "misuse",
"discussion": "C is the hardest to misuse on the hot path: the middleware passes a tenant id and a route and gets one number back, and the bulk-export weight never leaves the module. Its weak points are that configureLimits must run before the first check, an ordering rule the types cannot enforce, and that module-wide frozen time does not suit parallel tests. A relies on conventions that are easy to get wrong: reading quota by admitting with cost 0, computing route cost in gateway code, and an advanceClock variant that a system-clock limiter can only reject at run time. B gives a caller many ways to assemble a limiter wrongly, such as a resolver that does not cache or a store that leaks state.",
"favours": "C"
}
],
"divergence": [
"The designs differ most in where the route cost and the clock live: C keeps the bulk-export weight inside the module and swaps time module-wide, A makes the caller pass a numeric cost and puts clock control in a production Change variant, and B injects both as replaceable parts.",
"This matters because the brief asks for both a sub-millisecond hot path and clock handling hidden from production callers, and only a design that fixes cost rules and the clock at construction time gets both without leak
... (shortened here; the page shows it whole)
The page re-checks the reply; the API does not
The page holds every reply to account (recon.js). Designs: one design per assigned constraint, in order, the constraint copied; declarations only, no function bodies, loops or return statements; every key operation covered by every design through a name that is in that design's signature; the fewest-methods design within three entry points by the page's count; no two designs near-clones and none a renamed copy of the sketch. Compare: the five criteria in order, each favouring a real design or a tie, discussed in prose with no tables, with any entry-point count it quotes equal to the page's; a synthesis based on a real design, borrowing only from the others; at least one question back. Both: no percentage the brief does not contain. 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 brief 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://interface-desk.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://interface-desk.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"]
// interface-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://interface-desk.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://interface-desk.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
}
// InterfaceDesk.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 InterfaceDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://interface-desk.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://interface-desk.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://interface-desk.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://interface-desk.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":"interface-desk"}' | 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": "interface-desk"})["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: "interface-desk" })).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": "interface-desk"}, 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", "interface-desk"), 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: "interface-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "interface-desk"])["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 = "interface-desk" }))!["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": "designs", "module": "...", "language": "...", "brief": "...", "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": "designs", "module": "...", "language": "...", "brief": "...", "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, module, language, brief, 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 interface-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="interface-desk:designs:$(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 = "interface-desk:" + 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 = `interface-desk:${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 := "interface-desk:" + 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 = "interface-desk:" + 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 = "interface-desk:#{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 = "interface-desk:" . $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 = $"interface-desk:{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. compare.json is a compare-lane run input, built 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: interface-desk:compare:$(shasum -a 256 compare.json | cut -c1-16):a1" --data @compare.json
rev = json.load(open("compare.json")) # the same run input as input.json: {"task": "compare", "module": "...", "language": "...", "brief": "...", "designs": "...", "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": "interface-desk:compare:" + 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("compare.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": `interface-desk:compare:${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("compare.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", "interface-desk:compare:"+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("compare.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", "interface-desk:compare:" + 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("compare.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "interface-desk:compare:#{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("compare.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: interface-desk:compare:" . 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("compare.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", $"interface-desk:compare:{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);