Skip to content
SBDocs

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.
Run an agent
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:

  • call sends one request. On 429 (too many requests) and 503 (a monthly cap that could not be checked just now) it waits for the number of seconds in Retry-After and tries again. Every other error is raised with the status and the body.
  • startRun starts a run. On 409, the workflow already has a run in progress, and the answer names it in activeJobId; the client returns that id so you can wait for the run that is already going.
  • waitForRun reads the run until it completes or fails, a little less often each time, and gives up after 15 minutes.
A reusable client
// 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.

Start a draft run
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.

Queue a run
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
  }'
Example responseJSON
{
  "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.

Follow a deferred run
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.

Next steps