Customers · Guides
Run a workflow from code
Turn the quickstart into code you can leave running: a reusable client with retries, time limits and step errors.
- Before you start
- An API key with the runs:create and runs:read scopes.
- The id of a workflow or agent to run.
- You'll end with
- A small client, in TypeScript or Python, that starts runs and waits for them safely.
The quickstart runs a workflow once. This guide turns it into code you can leave running: a small client that waits when the API asks it to, never waits forever, and tells you which step failed and why.
Choose what to run#
You can start a run two ways. Both take the same body and answer the same way.
- Run a workflow runs one exact workflow, by its id. Use it when your code depends on that version.
- Run an agent runs the agent's active workflow. Use it when your team should be able to publish a new version in the app without a change to your code. The answer names the workflow that ran in
workflowId.
AGENT_ID="YOUR_AGENT_ID"
curl -X POST "https://app.sushibelt.ai/api/v1/agents/$AGENT_ID/runs" \
-H "Authorization: Bearer $SUSHIBELT_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'Use a key with the runs:create and runs:read scopes. Add workflows:read if your code also lists workflows. See Scopes.
A client you can reuse#
Three functions cover most integrations:
callsends one request. On429(too many requests) and503(a monthly cap that could not be checked just now) it waits for the number of seconds inRetry-Afterand tries again. Every other error is raised with the status and the body.startRunstarts a run. On409, the workflow already has a run in progress, and the answer names it inactiveJobId; the client returns that id so you can wait for the run that is already going.waitForRunreads the run until it completes or fails, a little less often each time, and gives up after 15 minutes.
// sushibelt.mts: a small client for the Sushibelt API. Node 18 or later.
const API = 'https://app.sushibelt.ai/api/v1'
const KEY = process.env.SUSHIBELT_API_KEY
export interface Run {
jobId: string
status: string
progress: number
steps: { node_id: string; step_type: string; status: string; error?: string }[]
output: { node_id: string; step_type: string; result: unknown; licence?: unknown }[]
}
export class SushibeltError extends Error {
constructor(readonly status: number, readonly body: Record<string, unknown>) {
super(`Sushibelt answered ${status}: ${JSON.stringify(body)}`)
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
/** One request. On 429 and 503 it waits for Retry-After and tries again, up to 5 times. */
async function call(method: 'GET' | 'POST', path: string, body?: unknown): Promise<Record<string, unknown>> {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`${API}${path}`, {
method,
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>
if (res.ok) return json
if ((res.status === 429 || res.status === 503) && attempt < 5) {
await sleep(Number(res.headers.get('Retry-After') ?? 30) * 1000)
continue
}
throw new SushibeltError(res.status, json)
}
}
/**
* Start a run and return its id. If the workflow already has a run in
* progress, returns that run's id instead: it may have been started with
* other inputs, so only do this when any run of the workflow will do.
*/
export async function startRun(workflowId: string, body: Record<string, unknown> = {}): Promise<string> {
try {
return (await call('POST', `/workflows/${workflowId}/runs`, body)).jobId as string
} catch (err) {
if (err instanceof SushibeltError && err.status === 409) return err.body.activeJobId as string
throw err
}
}
/** Read a run until it completes or fails. Gives up after timeoutMs. */
export async function waitForRun(jobId: string, timeoutMs = 15 * 60_000): Promise<Run> {
const deadline = Date.now() + timeoutMs
let delay = 2_000
while (Date.now() < deadline) {
const run = (await call('GET', `/runs/${jobId}`)) as unknown as Run
if (run.status === 'completed' || run.status === 'failed') return run
await sleep(delay)
delay = Math.min(delay * 1.5, 10_000)
}
throw new Error(`Run ${jobId} did not finish within ${timeoutMs / 1000} seconds`)
}
// Use it:
const run = await waitForRun(await startRun('YOUR_WORKFLOW_ID'))
if (run.status === 'failed') {
for (const s of run.steps.filter((s) => s.status === 'failed')) console.error(`${s.step_type} failed: ${s.error}`)
} else {
console.log(JSON.stringify(run.output, null, 2))
}Note: why every wait has a time limit
Most runs end in seconds or minutes, but a slow provider can hold one for longer, and your code should not wait on it without end. A run someone stops in the app ends as stopped. Choose a limit that fits your slowest workflow.
When a step fails#
Any failed step fails the whole run. The failed step carries an error that says why, in plain words, and the steps after it are skipped. You are charged for the steps that finished; the rest are refunded.
Starting the run again runs every step again, and charges for each. To keep the steps that finished, continue the run in the app instead. See Resume a failed run.
Options when you start a run#
Inputs#
Replace a workflow's text for this run only, by the names your team gave its inputs. See Pass inputs to a run.
Draft runs#
Pass "mode": "draft" to try a workflow out cheaply: each model makes one variation with fewer sampling steps, and text models give short answers. It is not a discount: a model priced per run costs the same in draft.
WORKFLOW_ID="YOUR_WORKFLOW_ID"
curl -X POST "https://app.sushibelt.ai/api/v1/workflows/$WORKFLOW_ID/runs" \
-H "Authorization: Bearer $SUSHIBELT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mode": "draft"
}'Queue a run for later#
Pass "defer": true to queue the run instead of starting it. It starts when Sushibelt's current price multiplier is at or below maxMultiplier (0.7 unless you say otherwise), and expires if that has not happened within 24 hours. The answer is 202 with a deferredJobId.
The multiplier discounts only steps that run on Sushibelt's own hardware. A step served by an outside provider (Claude, OpenAI, Fal, Replicate and the rest) is charged its full price whenever it runs, so queueing a run made only of those saves nothing.
WORKFLOW_ID="YOUR_WORKFLOW_ID"
curl -X POST "https://app.sushibelt.ai/api/v1/workflows/$WORKFLOW_ID/runs" \
-H "Authorization: Bearer $SUSHIBELT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"defer": true,
"maxMultiplier": 0.7
}'{
"deferred": true,
"deferredJobId": "c5a0e7d2-1b94-4f63-8e2a-6d7b90f14c38",
"workflowId": "3f1c9a52-8d4e-4b7a-9c61-2e5f0d7a1b90"
}Read it back with Get a deferred run. Its status is waiting until it starts, then dispatched with the jobId of the run it became — follow that like any other run. A callbackUrl sent with the deferred run is kept, so its webhook arrives when the run finishes.
DEFERRED-RUN_ID="YOUR_DEFERRED-RUN_ID"
curl "https://app.sushibelt.ai/api/v1/deferred-runs/$DEFERRED-RUN_ID" \
-H "Authorization: Bearer $SUSHIBELT_API_KEY"Note: a deferred run may never start
It expires if it has not started within 24 hours, or if your team cannot pay for it when its turn comes. If the key that queued it is revoked or expires first, it is cancelled; if the key is over its monthly cap, it keeps waiting. No webhook is sent for a run that never started — read it back to find out.