Drive URDF Desk from your own code
Base URL https://api.skillsafe.ai/v1/app-api. One robot description goes in, one structured
review comes back. Everything on this page uses the same token the web app uses, which you
can read off the tokens page.
The task field comes first
This app has three lanes over one work object, and task selects which one you
get. It is the field to decide before any other: it changes the body key you get back, the
posture vocabulary, the artifacts, and the price. Omit it and the model picks the lane your
input best fits and names its choice in exec_summary — convenient
interactively, not something to rely on in a script.
task | Lane | What it reads | Body key | posture | Artifacts |
|---|---|---|---|---|---|
structure | Structure & physics | The kinematic tree, the inertials, the joint limits and axes, the geometry and mesh references. | links[] | spawnable / spawnable-with-caveats / not-spawnable | structure-review.md, links.csv |
planning | MoveIt 2 semantics | The planning groups, chains, group states, end effectors, virtual joint and self-collision matrix, each resolved against the URDF. Paste no SRDF and this lane authors one. | groups[] | plannable / plannable-with-caveats / not-plannable | planning-review.md, robot.srdf |
sim | Gazebo handoff | The SDFormat version, per-link inertials and collisions, mesh URI schemes, sensors, plugins and physics — plus which URDF links did not survive the conversion. | entities[] | sim-ready / sim-ready-with-caveats / not-sim-ready | sim-review.md, entities.csv |
The envelope
Every response has the same shape. Check ok before touching data.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "...", "details": { ... }}}
| HTTP | error.code | What to do |
|---|---|---|
| 401 | UNAUTHORIZED | The token is missing, malformed or expired. Get a fresh one from the tokens page. |
| 402 | INSUFFICIENT_CREDITS | The balance is below min_credits. /estimate is free, so check it first. |
| 403 | FORBIDDEN | A guest token tried a metered call. /run and /run-stream need a personal token. |
| 404 | NOT_FOUND | Usually a job id that does not exist, or a mistyped path. |
| 409 | IDEMPOTENCY_CONFLICT | The same Idempotency-Key was reused with a different body. Change the key or send the original body. |
| 422 | VALIDATION_ERROR | The input object is the wrong shape. Note that the body is the input object — do not wrap it in an input key. |
| 429 | RATE_LIMITED | Back off and retry; do not tight-loop. |
| 503 | UPSTREAM_UNAVAILABLE | The model provider is briefly unavailable. Retry with the same idempotency key. |
Input fields
The request body is the input object itself. Do not wrap it in an
input key: a wrapped body returns 200 while hiding task from the
model, so you silently get whichever lane it guessed.
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | recommended | One of structure, planning, sim. |
description | string | yes | The robot description text. Several files in one string are fine — separate them with a line reading # file: name.urdf. URDF, SRDF and SDF are told apart by their content, not by the name. |
notes | string | no | What you are about to do with the robot. Short and concrete sharpens the review a lot. |
prescan | object | no | The deterministic facts and flags the browser computes. The web app always sends it, and the prompt requires one coverage_check entry per flag. Omit it and you get a review with an empty coverage_check — still useful, but nothing holds the model to arithmetic. |
clip_note | string | no | Set this when you have truncated a long description yourself, so the model knows what it is missing. |
Output contract
job.output is a JSON string; parse it. Inside is one object: a common
envelope shared by all three lanes, plus that lane’s own body key from the table above.
Taken from the parser the web app itself uses.
{
"task": "structure" | "planning" | "sim",
"title": string,
"posture": one of the lane's three values,
"confidence": "high" | "medium" | "low",
"verdict": string // one actionable sentence
"exec_summary": string,
"findings": [{
"id": "S-001" | "P-001" | "M-001",
"severity": "critical" | "high" | "medium" | "low",
"area": lane-specific,
"target": string, // the link, joint, group or element
"title": string,
"evidence": string, // the exact values from your input
"impact": string,
"remedy": string,
"blocks": boolean,
"elements_cited": [string]
}],
"coverage_check": [{"id": prescan flag id,
"status": "confirmed" | "set-aside" | "contradicted",
"note": string}],
"assumptions": [string],
"open_questions": [string],
"artifacts": [{"name": string,
"language": "markdown" | "csv" | "xml" | "text",
"content": string}],
"next_steps": [string],
"summary": string,
// exactly one of these, matching "task":
"links": [{"name", "role", "parent_joint", "mass_kg", "grade", "issue", "note"}],
"groups": [{"name", "origin", "kind", "dof", "members", "grade", "issue", "note"}],
"entities": [{"name", "kind", "grade", "issue", "note"}]
}
grade is always one of ok, watch,
broken. An empty array is a legitimate answer and does not mean the run
failed — a description with nothing wrong in a lane returns no findings for it.
1. A tiny client helper
Every endpoint below returns the same {ok, data, error} envelope and takes the same two headers, so one small helper covers the whole API. Get your token from the tokens page — no developer console required.
# Every call needs the same two headers. Keep the token out of your shell history:
# read it from a file you control, or paste it into a variable in a subshell.
TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"
call() { # call <method> <path> [json]
curl -sS -X "$1" "$BASE/$2" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
${3:+--data "$3"}
}
import json, urllib.request
TOKEN = "YOUR_TOKEN" # from https://urdf-desk.skillsafe.ai/tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(method, path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
body = json.load(r)
if not body.get("ok"):
raise RuntimeError(body.get("error"))
return body["data"]
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
async function call(method, path, payload) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: payload === undefined ? undefined : JSON.stringify(payload)
});
const body = await res.json();
if (!body.ok) throw new Error(JSON.stringify(body.error));
return body.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const token = "YOUR_TOKEN" // from /tokens.html
const base = "https://api.skillsafe.ai/v1/app-api"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error json.RawMessage `json:"error"`
}
func call(method, path string, payload any) (json.RawMessage, error) {
var body io.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s", env.Error)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class UrdfDesk {
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String json) throws Exception {
HttpRequest.BodyPublisher body = (json == null)
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(json);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, body)
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // {"ok":true,"data":{...}} — check ok before using data
}
}
require "json"
require "net/http"
TOKEN = "YOUR_TOKEN" # from /tokens.html
BASE = URI("https://api.skillsafe.ai/v1/app-api")
def call(method, path, payload = nil)
uri = URI.join(BASE.to_s + "/", path.sub(%r{^/}, ""))
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(payload) if payload
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
body = JSON.parse(res.body)
raise body["error"].to_s unless body["ok"]
body["data"]
end
<?php
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
function call(string $method, string $path, ?array $payload = null) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
],
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($body["ok"])) {
throw new RuntimeException(json_encode($body["error"] ?? null));
}
return $body["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
class UrdfDesk {
const string Token = "YOUR_TOKEN"; // from /tokens.html
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(HttpMethod method, string path, object? payload = null) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (payload is not null) {
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean()) {
throw new Exception(doc.RootElement.GetProperty("error").ToString());
}
return doc.RootElement.GetProperty("data");
}
}
2. Check the session and the balance
GET /me tells you whether the token is a guest or a personal session, and what the credit balance is. Do this before a metered call so a shortfall is something you handle rather than a 402 you are surprised by.
call GET me
me = call("GET", "/me")
print(me["type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.type, me.credits);
data, err := call("GET", "/me", nil)
if err != nil {
panic(err)
}
fmt.Println(string(data))
System.out.println(call("GET", "/me", null));
me = call("GET", "/me")
puts "#{me["type"]} #{me["credits"]}"
$me = call("GET", "/me");
echo $me["type"], " ", $me["credits"] ?? "n/a", PHP_EOL;
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("type"));
3. Price a lane for free
POST /estimate costs nothing and starts no job. It returns hold_credits (what will be reserved, priced against the full output cap), min_credits, and the model binding. The hold differs per lane, because the lanes have different prompts and output caps — estimate the lane you are actually about to run.
# /estimate is FREE and runs no job. It is the honest way to price a lane.
DESC=$(python3 -c 'import json,sys;print(json.dumps(open("my_robot.urdf").read()))')
call POST estimate "{\"task\":\"structure\",\"description\":$DESC}"
payload = {
"task": "structure",
"description": open("my_robot.urdf").read(),
"notes": "Freshly generated from CAD; going into a MoveIt config next week."
}
est = call("POST", "/estimate", payload)
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserved:", est["hold_credits"], "minimum:", est["min_credits"])
const payload = {
task: "structure",
description: urdfText,
notes: "Freshly generated from CAD."
};
const est = await call("POST", "/estimate", payload);
console.log(est.model, est.hold_credits);
payload := map[string]any{
"task": "structure",
"description": urdfText,
}
data, err := call("POST", "/estimate", payload)
if err != nil {
panic(err)
}
fmt.Println(string(data))
String payload = """
{"task":"structure","description":%s}
""".formatted(jsonQuote(urdfText));
System.out.println(call("POST", "/estimate", payload));
est = call("POST", "/estimate", {
"task" => "structure",
"description" => File.read("my_robot.urdf")
})
puts "#{est["model"]} reserves #{est["hold_credits"]}"
$est = call("POST", "/estimate", [
"task" => "structure",
"description" => file_get_contents("my_robot.urdf"),
]);
echo $est["model"], " reserves ", $est["hold_credits"], PHP_EOL;
var est = await Call(HttpMethod.Post, "/estimate", new {
task = "structure",
description = File.ReadAllText("my_robot.urdf")
});
Console.WriteLine(est.GetProperty("hold_credits"));
4. Run a review and poll for it
POST /run is metered and returns a job_id; poll GET /jobs/{id} until status is succeeded or failed. Always send an Idempotency-Key: it makes a retry after a network blip free instead of double-billing. Include the lane in the key — two lanes over one description are two distinct runs and must not collide.
# Metered. The Idempotency-Key makes a retry safe: the same key returns the
# same job instead of billing twice.
KEY="urdf-desk:structure:$(shasum -a 256 my_robot.urdf | cut -c1-16):a0"
DESC=$(python3 -c 'import json,sys;print(json.dumps(open("my_robot.urdf").read()))')
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
--data "{\"task\":\"structure\",\"description\":$DESC}" \
| python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# Poll until terminal.
while :; do
OUT=$(call GET "jobs/$JOB")
echo "$OUT" | grep -q '"status":"succeeded"' && break
echo "$OUT" | grep -q '"status":"failed"' && { echo "$OUT"; exit 1; }
sleep 2
done
echo "$OUT"
import hashlib, time
urdf = open("my_robot.urdf").read()
key = "urdf-desk:structure:%s:a0" % hashlib.sha256(urdf.encode()).hexdigest()[:16]
req = urllib.request.Request(BASE + "/run",
data=json.dumps({"task": "structure", "description": urdf}).encode(),
method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
job = json.load(urllib.request.urlopen(req))["data"]["job_id"]
while True:
j = call("GET", "/jobs/" + job)
if j["status"] in ("succeeded", "failed"):
break
time.sleep(2)
review = json.loads(j["output"])
print(review["posture"], "-", review["verdict"])
const key = `urdf-desk:structure:${hash16(urdfText)}:a0`;
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify({ task: "structure", description: urdfText })
});
const { data } = await res.json();
let job;
do {
await new Promise(r => setTimeout(r, 2000));
job = await call("GET", `/jobs/${data.job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
const review = JSON.parse(job.output);
console.log(review.posture, review.verdict);
b, _ := json.Marshal(map[string]any{
"task": "structure", "description": urdfText,
})
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "urdf-desk:structure:"+hash16(urdfText)+":a0")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
// decode {"ok":true,"data":{"job_id":"..."}} then poll GET /jobs/{id}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "urdf-desk:structure:" + hash16(urdfText) + ":a0")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
String created = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// read data.job_id, then poll GET /jobs/{id} until status is terminal
uri = URI.join(BASE.to_s + "/", "run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "urdf-desk:structure:#{Digest::SHA256.hexdigest(urdf)[0, 16]}:a0"
req.body = JSON.dump({ "task" => "structure", "description" => urdf })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("GET", "/jobs/#{job_id}")
break puts JSON.parse(job["output"])["verdict"] if job["status"] == "succeeded"
raise job.to_s if job["status"] == "failed"
sleep 2
end
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: urdf-desk:structure:" . substr(hash("sha256", $urdf), 0, 16) . ":a0",
],
CURLOPT_POSTFIELDS => json_encode(["task" => "structure", "description" => $urdf]),
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
do {
sleep(2);
$job = call("GET", "/jobs/" . $jobId);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
$review = json_decode($job["output"], true);
echo $review["posture"], ": ", $review["verdict"], PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", $"urdf-desk:structure:{Hash16(urdf)}:a0");
req.Content = new StringContent(
JsonSerializer.Serialize(new { task = "structure", description = urdf }),
Encoding.UTF8, "application/json");
var created = JsonDocument.Parse(
await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
var jobId = created.RootElement.GetProperty("data").GetProperty("job_id").GetString();
// then poll GET /jobs/{jobId} until status is terminal
5. Stream it instead
POST /run-stream returns server-sent events. A full review takes tens of seconds, so streaming lets you show progress. Concatenate every delta, then parse the accumulated text as one JSON object. The same Idempotency-Key rules apply.
# Server-sent events. Each data: line carries a delta; the terminal event
# carries the whole output. Useful because a full review takes tens of seconds.
curl -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
--data "{\"task\":\"planning\",\"description\":$DESC}"
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps({"task": "planning", "description": urdf}).encode(),
method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf = ""
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().strip()
if line.startswith("data:"):
chunk = line[5:].strip()
if chunk and chunk != "[DONE]":
buf += json.loads(chunk).get("delta", "")
review = json.loads(buf[buf.index("{"):buf.rindex("}") + 1])
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify({ task: "planning", description: urdfText })
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
for (const line of dec.decode(value).split("\n")) {
if (!line.startsWith("data:")) continue;
const chunk = line.slice(5).trim();
if (chunk && chunk !== "[DONE]") buf += (JSON.parse(chunk).delta || "");
}
}
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var buf bytes.Buffer
for sc.Scan() {
line := sc.Text()
if after, ok := strings.CutPrefix(line, "data:"); ok {
// unmarshal {"delta":"..."} and append
_ = after
}
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> appendDelta(l.substring(5).trim()));
uri = URI.join(BASE.to_s + "/", "run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "task" => "planning", "description" => urdf })
buf = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |seg|
seg.each_line do |line|
next unless line.start_with?("data:")
chunk = line[5..].strip
buf << (JSON.parse(chunk)["delta"] || "") unless chunk.empty? || chunk == "[DONE]"
end
end
end
end
$buf = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["task" => "planning", "description" => $urdf]),
CURLOPT_WRITEFUNCTION => function ($ch, $seg) use (&$buf) {
foreach (explode("\n", $seg) as $line) {
if (str_starts_with($line, "data:")) {
$chunk = trim(substr($line, 5));
if ($chunk !== "" && $chunk !== "[DONE]") {
$buf .= json_decode($chunk, true)["delta"] ?? "";
}
}
}
return strlen($seg);
},
]);
curl_exec($ch);
curl_close($ch);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
while (await reader.ReadLineAsync() is string line) {
if (!line.StartsWith("data:")) continue;
var chunk = line[5..].Trim();
if (chunk.Length > 0 && chunk != "[DONE]") {
buf.Append(JsonDocument.Parse(chunk).RootElement
.GetProperty("delta").GetString());
}
}
One worked example per lane
The same description, three requests. Only task changes.
task: "structure" — Structure & physics
# request
{"task": "structure", "description": "<robot name=\"acme_arm\"> ... </robot>",
"notes": "Going into a MoveIt config next week."}
# response: job.output parsed
{"task": "structure", "posture": ..., "verdict": ...,
"findings": [...], "links": [...], "artifacts": ["structure-review.md", "links.csv"]}
task: "planning" — MoveIt 2 semantics
# request
{"task": "planning", "description": "<robot name=\"acme_arm\"> ... </robot>",
"notes": "Going into a MoveIt config next week."}
# response: job.output parsed
{"task": "planning", "posture": ..., "verdict": ...,
"findings": [...], "groups": [...], "artifacts": ["planning-review.md", "robot.srdf"]}
task: "sim" — Gazebo handoff
# request
{"task": "sim", "description": "<robot name=\"acme_arm\"> ... </robot>",
"notes": "Going into a MoveIt config next week."}
# response: job.output parsed
{"task": "sim", "posture": ..., "verdict": ...,
"findings": [...], "entities": [...], "artifacts": ["sim-review.md", "entities.csv"]}
Costs, and what not to do on a schedule
/estimate,/meand minting a guest token are free. Price every lane as often as you like./runand/run-streamare metered. The hold reserves against the full output cap; the actual charge is usually much lower.- If the balance sits between
min_creditsandhold_creditsthe run still executes with a reduced cap and returnstruncated: true. Treat that as an incomplete review, not a short one. - Send an
Idempotency-Keyon every metered call, and include the lane in it.
What this app does not do
It reads the description text you send and returns judgement over it. It never loads your robot, never resolves a mesh file, never spawns anything, and never runs a command. Findings about a mesh are about its path; findings about physics are computed from the numbers you sent. That boundary is deliberate and the prompt enforces it.
Credits
URDF Desk is a derived work built on three agent skills by @earthtojake/urdf, @earthtojake/srdf and @earthtojake/sdf. It is not a republication of those skills.