Build n8n workflows from your own scripts
Send an automation described in plain English — the trigger, the steps, where results go — and get back one JSON object: a complete n8n workflow document that imports as-is (real node types, resolved connections, credential placeholders instead of inline secrets), a node-by-node configuration guide, numbered credential setup steps, honest assumptions and limits, and an executable test plan. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so you can wire it into a scaffolding CLI, an internal portal that turns tickets into starting workflows, or a batch job that drafts one workflow per row of an automation backlog. Every code step below is shown in cURL, Python, JavaScript, Go, Java, Ruby, PHP and C#; pick a language once and the whole page follows.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api, app slug
n8n-studio. Every request sends
Authorization: Bearer <token> and JSON bodies with
Content-Type: application/json. Responses are wrapped in an envelope:
{"data": …} on success, {"error": {"code", "message"}} on failure.
The workflow itself is produced by the gpt-terra model. Estimates are
free; runs are metered against your credit balance. There is a single run task — one
description in, one workflow package out, no follow-up calls and no session state to
carry. Revisions work by sending the current workflow JSON along with the change request.
| Status | Meaning |
|---|---|
401 | Missing or expired token — create a new session. |
402 | Not enough credits — top up at skillsafe.ai/account/credits. |
403 | The token isn't allowed to do this (e.g. a guest building against a very large pasted workflow). |
404 | Unknown job or record id. |
5xx | Transient platform error — retry with backoff. |
Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.
Step 0 — A tiny client
Every task below is a single HTTP call, so start with a short helper that adds the auth
header, sends JSON and unwraps the data envelope. The later steps reuse it.
export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # see step 1 — read it from your shell environment in real code
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not res.ok:
raise RuntimeError(payload.get("error", {}).get("message", res.reason))
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
func call(method, path string, body, out any) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *struct{ Message string `json:"message"` } `json:"error"`
}
json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode >= 400 {
return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body(); // envelope: {"data": …}
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
def api(method, path, body = nil)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new Exception($payload["error"]["message"] ?? "HTTP $status");
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!res.IsSuccessStatusCode)
throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
return json.GetProperty("data");
}
}
Step 1 — Get a token
A guest token lets you check balances and estimate costs for free. For metered build
runs billed to your own account, use your personal token: open the
token page, sign in with SkillSafe, and press
Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your
clipboard, which every example below reads. Treat the token like a password: it can spend
your credits. For fully headless scripts, POST /guest mints a guest token with
no browser involved.
curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"n8n-studio"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "n8n-studio"})["token"]
const { token } = await api("POST", "/guest", { slug: "n8n-studio" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "n8n-studio"}, &guest)
String envelope = api("POST", "/guest", """
{"slug":"n8n-studio"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "n8n-studio" })["token"]
$token = api("POST", "/guest", ["slug" => "n8n-studio"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
new { slug = "n8n-studio" });
var token = guest.GetProperty("token").GetString();
The app stores this browser's token under the localStorage key
skillsafe_app_token:n8n-studio, on the app's own origin. The
token page reads and manages it for you — you never need
to open developer tools.
Step 2 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Check this before building
against a large pasted workflow.
curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
me = api("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
var me struct {
SubjectType string `json:"subject_type"`
Credits int64 `json:"credits"`
}
err := call("GET", "/me", nil, &me)
String envelope = api("GET", "/me", null);
// data.subject_type, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");
Step 3 — Estimate the cost
Send exactly the input you would send to /run; the response's
hold_credits is the worst-case cost. Nothing is charged and no job is created,
so estimating is free — useful when you are sending a large existing workflow for
revision and want a ceiling before spending credits.
| Input field | Type | Notes |
|---|---|---|
description | string, required | The automation in plain English — what should trigger it, what it should do, where results go — up to 16000 characters. Longer text is clipped middle-out, with a [... clipped ...] marker showing where. |
services | string, optional | Comma-separated apps/services to use, e.g. "Slack, Google Sheets, Stripe", up to 500 characters. When present these are authoritative: the builder prefers the matching n8n nodes. Leave empty to infer from the description. |
trigger | string | auto | webhook | schedule | manual | app-event — what starts the workflow. auto infers from the description; app-event means the service's own trigger node (a new row, a new payment) where a standard one exists, with a schedule-plus-fetch fallback declared in assumptions otherwise. |
posture | string | lean (happy path only — the fewest nodes that do the job) or robust (validation of required incoming fields, IF-guards on empty results, retryOnFail on network-bound nodes, and a settings.errorWorkflow reference with a setup step explaining how to create the error-notification workflow). |
existing_workflow | string, optional | The JSON of a workflow you already have, as a string. When present the run is a revision: working structure, node names and ids are preserved where possible, only what the description asks for changes, and the overview says what changed. The web app compacts large workflows structurally (pinned data dropped, oversized parameters summarized) rather than truncating the JSON mid-token; do the same if yours is over ~40000 characters. |
prescan_facts | object | What the app's free client-side scan mechanically detected: {"services": [], "trigger_hints": [], "steps": [], "existing_lint": []}. services holds {id, label, token} entries (svc:slack, svc:stripe, …) for service names matched in the description; trigger_hints holds {id, label, excerpt} (trig:schedule, trig:webhook, trig:event, trig:manual); steps holds {id, label} for step-shaped lines (step:1…); existing_lint holds {id, level, label} findings the client linter raised on existing_workflow (lint:orphan-node, lint:no-trigger, lint:inline-secret, …). Every id you send comes back in coverage_check — confirmed with the node that covers it, or explicitly set aside. API callers may send the empty object {"services": [], "trigger_hints": [], "steps": [], "existing_lint": []}. |
retry_note | string, optional | Only set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out. |
DESC='Every weekday at 9am, fetch the open GitHub issues labeled bug from acme/webapp
and post a count plus the five oldest titles to #eng in Slack.
If there are none, post that instead.'
jq -n --arg description "$DESC" \
'{description: $description, services: "GitHub, Slack", trigger: "schedule",
posture: "lean", existing_workflow: "",
prescan_facts: {services: [], trigger_hints: [], steps: [], existing_lint: []}}' > input.json
curl -s -X POST "$API/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json | jq '.data.hold_credits'
DESC = ("Every weekday at 9am, fetch the open GitHub issues labeled bug from acme/webapp "
"and post a count plus the five oldest titles to #eng in Slack. "
"If there are none, post that instead.")
payload = {
"description": DESC,
"services": "GitHub, Slack",
"trigger": "schedule",
"posture": "lean",
"existing_workflow": "",
"prescan_facts": {"services": [], "trigger_hints": [], "steps": [], "existing_lint": []},
}
est = api("POST", "/estimate", payload)
print("worst case:", est.get("hold_credits", est.get("credits")), "credits")
const description =
"Every weekday at 9am, fetch the open GitHub issues labeled bug from acme/webapp " +
"and post a count plus the five oldest titles to #eng in Slack. " +
"If there are none, post that instead.";
const payload = {
description,
services: "GitHub, Slack",
trigger: "schedule",
posture: "lean",
existing_workflow: "",
prescan_facts: { services: [], trigger_hints: [], steps: [], existing_lint: [] },
};
const est = await api("POST", "/estimate", payload);
console.log("worst case:", est.hold_credits ?? est.credits, "credits");
const description = "Every weekday at 9am, fetch the open GitHub issues labeled bug from acme/webapp " +
"and post a count plus the five oldest titles to #eng in Slack. " +
"If there are none, post that instead."
payload := map[string]any{
"description": description,
"services": "GitHub, Slack",
"trigger": "schedule",
"posture": "lean",
"existing_workflow": "",
"prescan_facts": map[string]any{
"services": []any{}, "trigger_hints": []any{}, "steps": []any{}, "existing_lint": []any{},
},
}
var est struct{ HoldCredits int64 `json:"hold_credits"` }
err := call("POST", "/estimate", payload, &est)
String description = "Every weekday at 9am, fetch the open GitHub issues labeled bug from acme/webapp "
+ "and post a count plus the five oldest titles to #eng in Slack. "
+ "If there are none, post that instead.";
String jsonPayload = """
{"description": %s, "services": "GitHub, Slack",
"trigger": "schedule", "posture": "lean", "existing_workflow": "",
"prescan_facts": {"services": [], "trigger_hints": [], "steps": [], "existing_lint": []}}
""".formatted(toJsonString(description));
String envelope = api("POST", "/estimate", jsonPayload);
// worst-case cost is at data.hold_credits
DESC = "Every weekday at 9am, fetch the open GitHub issues labeled bug from acme/webapp " \
"and post a count plus the five oldest titles to #eng in Slack. " \
"If there are none, post that instead."
payload = { description: DESC, services: "GitHub, Slack",
trigger: "schedule", posture: "lean", existing_workflow: "",
prescan_facts: { services: [], trigger_hints: [], steps: [], existing_lint: [] } }
est = api("POST", "/estimate", payload)
puts "worst case: #{est["hold_credits"] || est["credits"]} credits"
$description = "Every weekday at 9am, fetch the open GitHub issues labeled bug from acme/webapp "
. "and post a count plus the five oldest titles to #eng in Slack. "
. "If there are none, post that instead.";
$payload = [
"description" => $description,
"services" => "GitHub, Slack",
"trigger" => "schedule",
"posture" => "lean",
"existing_workflow" => "",
"prescan_facts" => ["services" => [], "trigger_hints" => [], "steps" => [], "existing_lint" => []],
];
$est = api("POST", "/estimate", $payload);
echo "worst case: " . ($est["hold_credits"] ?? $est["credits"]) . " credits\n";
var description = "Every weekday at 9am, fetch the open GitHub issues labeled bug from acme/webapp "
+ "and post a count plus the five oldest titles to #eng in Slack. "
+ "If there are none, post that instead.";
var payload = new {
description,
services = "GitHub, Slack",
trigger = "schedule",
posture = "lean",
existing_workflow = "",
prescan_facts = new {
services = Array.Empty<object>(), trigger_hints = Array.Empty<object>(),
steps = Array.Empty<object>(), existing_lint = Array.Empty<object>(),
},
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"worst case: {est.GetProperty("hold_credits")} credits");
prescan_facts is how you make the builder answer for things you already know
about. Send {"services": [{"id": "svc:slack", "label": "Slack", "token": "slack"}],
"trigger_hints": [{"id": "trig:schedule", "label": "Schedule / cron phrasing", "excerpt":
"Every weekday at 9am"}], "steps": [{"id": "step:1", "label": "post a count plus the five
oldest titles"}], "existing_lint": []} and every one of those ids comes back in
coverage_check — confirmed with the node that covers it, or explained
away (a service mentioned only as a negative, or a word like "email" that is a data field
rather than a channel, is correctly set aside). Nothing you flag is silently dropped.
Step 4 — Run the build and wait for the result
/run takes the same input as /estimate, places a credit hold and
returns a job_id. Poll /jobs/{job_id} every 1–2 seconds
until status is succeeded or failed (a build
typically takes 30–90 s, since the whole workflow document is written out).
Always send an Idempotency-Key header so a network retry can't start a second,
double-charged run. The result is in output — usually nested as
output.output, and as a JSON string, so parse defensively. The
samples below print the workflow name, node count and setup steps, then write the
importable workflow to workflow.json.
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: build-$(date +%s)" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
# unwrap the result once, then read it
echo "$JOB" | jq -r '.data.output.output' > result.json
jq -r '
"\(.workflow_name): \(.workflow.nodes | length) nodes",
"",
"SETUP",
(.setup_steps[] | " - \(.)"),
"",
"TEST PLAN",
(.test_plan[] | " - \(.)"),
"",
"ASSUMPTIONS",
(.assumptions[] | " - \(.)")' result.json
# and drop the importable workflow straight onto disk
jq '.workflow' result.json > workflow.json # import via n8n's Workflow menu
import time
job_id = api("POST", "/run", payload,
**{"Idempotency-Key": "build-001"})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"]
result = json.loads(raw) if isinstance(raw, str) else raw
print(f'{result["workflow_name"]}: {len(result["workflow"]["nodes"])} nodes')
for g in result["nodes_guide"]:
print(f' [{g["type"]}] {g["node"]}: {g["configure"]}')
for s in result["setup_steps"]:
print(f' setup: {s}')
for c in result["coverage_check"]:
print(f' {c["id"]}: {"ok" if c["addressed"] else "SET ASIDE"} - {c["note"]}')
with open("workflow.json", "w", encoding="utf-8") as fh:
json.dump(result["workflow"], fh, indent=2) # import via n8n's Workflow menu
import { writeFileSync } from "node:fs";
const { job_id } = await api("POST", "/run", payload,
{ "Idempotency-Key": crypto.randomUUID() });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const raw = job.output?.output ?? job.output;
const result = typeof raw === "string" ? JSON.parse(raw) : raw;
console.log(`${result.workflow_name}: ${result.workflow.nodes.length} nodes`);
for (const g of result.nodes_guide) {
console.log(` [${g.type}] ${g.node}: ${g.configure}`);
}
for (const s of result.setup_steps) console.log(` setup: ${s}`);
for (const c of result.coverage_check) {
console.log(` ${c.id}: ${c.addressed ? "ok" : "SET ASIDE"} - ${c.note}`);
}
writeFileSync("workflow.json", JSON.stringify(result.workflow, null, 2)); // import into n8n
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
Error string `json:"error"`
Output json.RawMessage `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
// job.Output is {"output": "<json string>"} — unwrap, unquote, then unmarshal:
type Result struct {
WorkflowName string `json:"workflow_name"`
Overview string `json:"overview"`
Workflow struct {
Name string `json:"name"`
Nodes []map[string]any `json:"nodes"`
} `json:"workflow"`
WorkflowRaw json.RawMessage `json:"workflow"`
NodesGuide []struct {
Node, Type, Purpose, Configure string
} `json:"nodes_guide"`
SetupSteps []string `json:"setup_steps"`
TestPlan []string `json:"test_plan"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var result Result
json.Unmarshal([]byte(wrapper.Output), &result)
fmt.Printf("%s: %d nodes\n", result.WorkflowName, len(result.Workflow.Nodes))
for _, g := range result.NodesGuide {
fmt.Printf(" [%s] %s: %s\n", g.Type, g.Node, g.Configure)
}
for _, s := range result.SetupSteps {
fmt.Printf(" setup: %s\n", s)
}
os.WriteFile("workflow.json", result.WorkflowRaw, 0o644) // import into n8n
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;
while (true) {
String job = api("GET", "/jobs/" + jobId, null);
String status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
// The result is at data.output.output as a JSON string — parse it again, then read
// workflow_name, overview, workflow{name, nodes[], connections{}, settings{}},
// nodes_guide[] (node/type/purpose/configure), setup_steps[], coverage_check[]
// (id/addressed/note), assumptions[], limits[], test_plan[] and summary.
// Finally write the importable workflow to disk:
// Files.writeString(Path.of("workflow.json"), workflowJson); // import into n8n
started = api("POST", "/run", payload)
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
result = raw.is_a?(String) ? JSON.parse(raw) : raw
puts "#{result["workflow_name"]}: #{result["workflow"]["nodes"].length} nodes"
result["nodes_guide"].each { |g| puts " [#{g["type"]}] #{g["node"]}: #{g["configure"]}" }
result["setup_steps"].each { |s| puts " setup: #{s}" }
result["coverage_check"].each { |c| puts " #{c["id"]}: #{c["addressed"] ? "ok" : "SET ASIDE"}" }
File.write("workflow.json", JSON.pretty_generate(result["workflow"])) # import into n8n
$started = api("POST", "/run", $payload);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$result = is_string($raw) ? json_decode($raw, true) : $raw;
echo "{$result['workflow_name']}: " . count($result["workflow"]["nodes"]) . " nodes\n";
foreach ($result["nodes_guide"] as $g) {
echo " [{$g['type']}] {$g['node']}: {$g['configure']}\n";
}
foreach ($result["setup_steps"] as $s) { echo " setup: $s\n"; }
foreach ($result["coverage_check"] as $c) {
echo " {$c['id']}: " . ($c["addressed"] ? "ok" : "SET ASIDE") . "\n";
}
file_put_contents("workflow.json",
json_encode($result["workflow"], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); // import into n8n
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(1500);
}
var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var result = doc.RootElement;
Console.WriteLine($"{result.GetProperty("workflow_name")}: " +
$"{result.GetProperty("workflow").GetProperty("nodes").GetArrayLength()} nodes");
foreach (var g in result.GetProperty("nodes_guide").EnumerateArray())
{
Console.WriteLine($" [{g.GetProperty("type")}] {g.GetProperty("node")}: " +
$"{g.GetProperty("configure")}");
}
foreach (var s in result.GetProperty("setup_steps").EnumerateArray())
Console.WriteLine($" setup: {s}");
await File.WriteAllTextAsync("workflow.json",
result.GetProperty("workflow").GetRawText()); // import into n8n
The model is asked for one JSON object and nothing else, but a stray code fence or preamble
is always possible. Strip a leading ```json fence, take the text between the
first { and the last }, and only then parse — that is what
the app does before it falls back to a retry_note reformat run.
The result object — output schema
One JSON object, always the same shape. Every array is present (assumptions
and limits may be empty when nothing genuinely applies); workflow.nodes
is never empty, nodes_guide covers every node except sticky notes, and
setup_steps and test_plan are never empty. If the description was
too thin to build from responsibly, you still get the object: the smallest honest workflow
matching what was said, an overview that says what is missing, and the open
questions in assumptions.
| Field | Type | Meaning |
|---|---|---|
workflow_name | string | A short name in the automation's own domain language. |
overview | string | One or two paragraphs: what the workflow does end to end — and, for a revision, exactly what changed and what was preserved. |
workflow | object | The importable n8n document: {name, nodes[], connections{}, settings{}}. Every node carries id, a unique human-readable name, a real n8n type (e.g. n8n-nodes-base.scheduleTrigger, n8n-nodes-base.slack, n8n-nodes-base.httpRequest, n8n-nodes-base.if), typeVersion, position and parameters. Exactly one trigger node unless the automation genuinely needs more; IF nodes wire both branches; one stickyNote summarizes what to configure. Credentials appear only as placeholder references (__SLACK_CREDENTIAL_ID__-style) — never an inline key or token. When the builder is not confident a dedicated node or parameter exists, it uses httpRequest against the service's public REST API and says so in assumptions, so the workflow imports rather than failing on an invented node type. |
nodes_guide | array | {node, type, purpose, configure} — one entry per node (sticky notes excluded): what it does in this flow and exactly what to set or verify in it before first run. |
setup_steps | string[] | Ordered and concrete: which credential types to create in n8n's credential manager, which scopes or permissions they need, which ids (a sheet, a channel, a repo) to paste into which node. |
coverage_check | array | {id, addressed, note} — one entry per prescan_facts fact you sent (svc:slack, trig:schedule, step:2, lint:orphan-node, …), naming the node that covers it or saying why it was set aside. A keyword hit can be a false positive — "email" as a data field is not an email integration — and the note says so. Nothing you flagged is silently dropped. |
assumptions | string[] | Each thing assumed because the description did not say: a field name, a channel, a sheet id, an API shape, a rate limit. Real entries only; an empty array is honest when nothing was guessed. |
limits | string[] | Each thing the workflow deliberately does not handle: pagination beyond page one, dedup across runs, timezone edge cases. |
test_plan | string[] | Executable, ordered steps for the n8n editor: pin sample data on the trigger, execute single nodes, what a successful output looks like at each stage, then activate. References nodes by their names in this workflow. |
summary | string | 3–5 sentences you could paste into a ticket or a runbook. |
The generated workflow is a starting point, not a sign-off: it is written to import
cleanly and be structurally sound — and the app re-lints it client-side before
rendering — but node parameters encode assumptions about your account's data. Import
it into a non-production n8n first, follow the test_plan node by node, and
create the credentials yourself; the workflow never contains one.
Step 5 — Stream the build as it is written
/run-stream takes exactly the same body as /run but answers with
server-sent events, so you can show progress instead of a spinner — useful here
because the whole workflow document makes for a long reply. This app's own progress panel is this
endpoint. Events are separated by a blank line; each has an event: line and a
data: line carrying JSON.
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted — show "starting". |
delta | {text} | A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). |
done | {job_id, status, charged_credits, output} | The final, authoritative result — read the result from output.output rather than trusting concatenated deltas, and the settled price from charged_credits. |
error | {code, message} | Replaces done when the run fails. |
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: build-$(date +%s)" \
-d @input.json
# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"workflow_name\":\"Bug digest"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":548,"output":{"output":"{...}"}}
import json, requests
result = None
with requests.post(
API + "/run-stream",
headers={"Authorization": f"Bearer {TOKEN}",
"Idempotency-Key": "build-001"},
json=payload,
stream=True,
) as r:
r.raise_for_status()
event = None
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
data = json.loads(line[len("data:"):].strip())
if event == "delta":
print(".", end="", flush=True) # live progress
elif event == "done":
result = data
elif event == "error":
raise RuntimeError(data.get("message", "run failed"))
built = json.loads(result["output"]["output"]) # authoritative
print("charged:", result["charged_credits"], "-", built["workflow_name"],
len(built["workflow"]["nodes"]), "nodes")
open("workflow.json", "w", encoding="utf-8").write(
json.dumps(built["workflow"], indent=2))
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop();
for (const frame of frames) {
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
if (!name || !body) continue;
const data = JSON.parse(body);
if (name === "delta") process.stdout.write("."); // live progress
if (name === "done") done = data;
if (name === "error") throw new Error(data.message ?? "run failed");
}
}
const built = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${built.workflow_name} (${built.workflow.nodes.length} nodes)`);
writeFileSync("workflow.json", JSON.stringify(built.workflow, null, 2)); // import into n8n
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "build-001")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
var data map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
switch event {
case "delta":
fmt.Print(".") // live progress
case "done":
final = data
case "error":
log.Fatal(data["message"])
}
}
}
// final["output"].(map[string]any)["output"].(string) is the result JSON —
// unmarshal it into the Result struct from step 4, then write
// result.WorkflowRaw to workflow.json.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "build-001")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if ("delta".equals(event)) System.out.print("."); // live progress
else if ("done".equals(event)) done = data;
else if ("error".equals(event)) throw new RuntimeException(data);
}
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// workflow_name, overview, workflow{nodes, connections, settings}, nodes_guide[],
// setup_steps[], coverage_check[], assumptions[], limits[], test_plan[] and summary.
require "net/http"
require "json"
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "build-001"
req.body = payload.to_json
event = nil
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
data = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then print "." # live progress
when "done" then done = data
when "error" then raise (data["message"] || "run failed")
end
end
end
end
end
end
built = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{built["workflow_name"]} (#{built["workflow"]["nodes"].length} nodes)"
File.write("workflow.json", JSON.pretty_generate(built["workflow"])) # import into n8n
$event = null;
$done = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: build-001",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { echo "."; } // live progress
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$built = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$built['workflow_name']} ("
. count($built["workflow"]["nodes"]) . " nodes)\n";
file_put_contents("workflow.json",
json_encode($built["workflow"], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); // import into n8n
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "build-001");
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = line[5..].Trim();
if (evt == "delta") Console.Write("."); // live progress
else if (evt == "done") done = data;
else if (evt == "error") throw new Exception(data);
}
}
using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var resultDoc = JsonDocument.Parse(text!);
var built = resultDoc.RootElement;
Console.WriteLine($"{built.GetProperty("workflow_name")}: " +
$"{built.GetProperty("workflow").GetProperty("nodes").GetArrayLength()} nodes");
await File.WriteAllTextAsync("workflow.json",
built.GetProperty("workflow").GetRawText()); // import into n8n
In a browser, the native EventSource only speaks GET, and this endpoint is a
POST — read the fetch response body incrementally, as the JavaScript
sample above does. On an idempotent replay the server may answer with a plain JSON
envelope instead of an event stream; check the Content-Type before you start
parsing frames.