Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.runflow.io/llms.txt

Use this file to discover all available pages before exploring further.

@runflow-io/sdk wraps the Runflow REST API in a typed client and a small tool DSL. It uses Web Standards fetch, so the same package runs in Node, Bun, Deno, browsers, and edge workers.
All @runflow-io/* packages are pre-1.0 (currently 0.0.3). Patch releases may change types or runtime behavior. Pin exact versions in production until 1.0.

Install

bun add @runflow-io/sdk
# or: npm install @runflow-io/sdk
# or: pnpm add @runflow-io/sdk
The SDK has zero runtime dependencies. Node 18+ or any environment with a global fetch.

Server-side: dispatch and wait

import { Runflow } from "@runflow-io/sdk";

const rf = new Runflow({ apiKey: process.env.RUNFLOW_API_KEY });

const dispatched = await rf.models.run("runflow/background-removal", {
  input: { image_url: "https://example.com/photo.jpg" },
});

const final = await rf.runs.wait(dispatched.id, {
  timeoutMs: 120_000,
  pollIntervalMs: 1500,
  onPoll: (run) => console.log(run.status_code),
});

console.log(final.output);
rf.models.run(model, body) dispatches and returns immediately with { id, status_code, ... }. rf.runs.wait(id) polls until the run finishes and resolves with the final record. onPoll fires on each poll if you want progress updates. Model ids are owner/slug (or owner/slug/sub). A bare slug like "background-removal" returns HTTP 405 from the API directly and HTTP 403 Not allowed through the proxy. The SDK rejects empty model ids and any segment equal to . or .. at the client with code: invalid_model_id, and URL-encodes every other segment, so it is safe to pass user or LLM-controlled model ids as long as you pair them with an allowedModels policy on the proxy. Defaults for runs.wait: timeoutMs: 180_000, pollIntervalMs: 2_000. If the wait times out, the run is still going upstream; you can extend the deadline or use rf.runs.get(err.runId) to check status separately. To stream every poll yourself, use rf.runs.poll(id), which returns an async iterable. It yields each successful response, retries 5xx silently between yields, and throws RunTimeoutError if the run has not finished within timeoutMs.
for await (const run of rf.runs.poll(dispatched.id)) {
  console.log(run.status_code);
}

The Tools DSL

defineTool binds a model id to a typed input schema, an output schema, and a buildRequest function that maps inputs to the model’s body shape. Once defined, the same object can be dispatched from the SDK or rendered by the Studio.
import {
  defineTool,
  imageInput,
  textInput,
  imageOutput,
  Runflow,
} from "@runflow-io/sdk";

const sceneSwap = defineTool({
  id: "ai-scene",
  name: "Drop into a new scene",
  model: "google/nano-banana-pro/edit",
  inputs: {
    image: imageInput({ source: "runtime" }),
    prompt: textInput({
      source: "user",
      label: "Describe the scene",
      maxLength: 400,
    }),
    style: textInput({
      source: "preset",
      value: "Photoreal product photography, true colors preserved",
    }),
  },
  output: { image: imageOutput() },
  buildRequest: ({ image, prompt, style }) => ({
    input: {
      prompt: `Place the subject ${prompt}. ${style}.`,
      image_urls: [image],
    },
  }),
});

const rf = new Runflow({ apiKey: process.env.RUNFLOW_API_KEY });
const { output } = await rf.tools.run(sceneSwap, {
  image: "https://example.com/sneaker.png",
  prompt: "on a windswept rooftop at golden hour",
});
console.log(output.image);

Input sources

Each input has a source that controls where its value comes from:
SourceCollected from caller?Passed to buildRequest?
presetNo, baked into the tool’s value field.Yes.
runtimeYes, every call (for example, the source image).Yes.
userYes, from the Studio UI or programmatically.Yes.
buildRequest receives every input including presets (the AllInputValues type). Callers only supply non-preset inputs (the RuntimeInputValues type), so the destructured style in the example above works even though style is a preset. Optional user inputs use optional: true and become optional in RuntimeInputValues too.

Input and output builders

Inputs: imageInput, textInput, numberInput, colorInput, selectInput, referenceInput, maskInput, pinInput. Outputs: imageOutput, textOutput, numberOutput, jsonOutput, imageListOutput.
The default extractor only fills the image field. If you declare any other output field without supplying extractOutput, that field will be undefined at runtime even though the TypeScript types claim it exists. Always supply extractOutput for non-image-only schemas.
const upscale = defineTool({
  id: "upscale",
  name: "Upscale",
  model: "topaz/upscale/image",
  inputs: { image: imageInput({ source: "runtime" }) },
  output: { image: imageOutput(), width: numberOutput(), height: numberOutput() },
  buildRequest: ({ image }) => ({ input: { image_url: image } }),
  extractOutput: (raw) => {
    const url = (raw as { image_urls: string[] }).image_urls[0];
    return { image: url, width: 4096, height: 4096 };
  },
});

Dispatch without waiting

rf.tools.dispatch(tool, args) returns { runId, model } immediately if you want to manage polling yourself or relay the id to a callback handler.
const { runId, model } = await rf.tools.dispatch(sceneSwap, { image, prompt });
// later, from a webhook handler or a polling worker
const final = await rf.runs.wait(runId);
Note the asymmetry: rf.models.run(...) returns { id, status_code, ... } (the field is id), while rf.tools.dispatch(...) returns { runId, model }. Both ids are interchangeable when passed to rf.runs.get / rf.runs.wait / rf.runs.poll.

Browser, through a proxy

Never put a Runflow API key in a browser bundle. Mount @runflow-io/proxy on your server, point the SDK at it, and the key stays server-side.
// browser
const rf = new Runflow({ baseUrl: "/api/runflow" });
When baseUrl is set the SDK omits the Authorization header. The proxy injects it before forwarding upstream. See Proxy browser calls server-side below.

Configuration

interface RunflowConfig {
  apiKey?: string;            // server-side; sent as Authorization: Bearer
  baseUrl?: string;           // browser; usually "/api/runflow"
  apiBase?: string;           // override upstream base; default https://api.runflow.io
  requestTimeoutMs?: number;  // per-request timeout; default 30_000 ms
  headers?: Record<string, string>;
  fetch?: typeof fetch;       // custom fetch (useful in tests)
}
Exactly one of apiKey or baseUrl is required. If both are set, baseUrl wins and the bearer header is omitted. API keys are alphanumeric plus underscore (current format: rf_live_* for inference, rf_svc_* for admin). The constructor rejects keys with hyphens, dots, or other punctuation with code: invalid_api_key. Strip whitespace before passing.

Errors

The SDK throws three error classes, all extending RunflowError:
ErrorWhen it throws
RunflowErrorHTTP failure, network error, request timeout, bad config, invalid model id, invalid run id, malformed apiKey.
RunFailedErrorA run finished with status_code: failed or canceled. Thrown by runs.wait, runs.poll, and tools.run. Has .run.id, .run.status, .run.error.
RunTimeoutErrorruns.wait / runs.poll did not see a terminal status before timeoutMs. Has .runId, .elapsedMs.
Common RunflowError.status and code values:
Triggererr.statuserr.code
Missing/invalid API key401(server-set)
Proxy denied (model not in allowlist, origin mismatch, path not allowed)403(server-set)
Payload too large (proxy 32 KB cap by default)413(server-set)
Content-Type not JSON on proxy415(server-set)
Rate limit (check Retry-After header)429(server-set)
Upstream timed out via proxy504(server-set)
Per-request timeout (default 30 s)undefinedrequest_timeout
Network errorundefinednetwork_error
Empty model id or ./.. segmentundefinedinvalid_model_id
Run id contains /, ., or ..undefinedinvalid_run_id
Malformed apiKey at constructionundefinedinvalid_api_key
Default extractor found no image URLundefinedoutput_parse_error
import { RunFailedError, RunTimeoutError, RunflowError } from "@runflow-io/sdk";

try {
  const run = await rf.runs.wait(id);
} catch (err) {
  if (err instanceof RunFailedError) {
    // Log err.run.error server-side; do not surface upstream messages to end users.
    console.error("run failed:", err.run.error);
  } else if (err instanceof RunTimeoutError) {
    console.error("run timed out:", err.runId);
  } else if (err instanceof RunflowError) {
    console.error("HTTP", err.status, err.code, err.message);
  } else {
    throw err;
  }
}

Proxy browser calls server-side

@runflow-io/proxy is a Web Standards request handler. It accepts only the run-dispatch and run-poll paths, validates the model against an allowlist, and forwards to api.runflow.io with your key injected.
Always add an authenticate hook in production. The CSRF defaults block browser drive-by attacks but not direct server-to-server callers: anyone with your proxy URL can hit it from curl, a script, or another server and spend your Runflow budget against any allowed model. The hook is shown under Auth, rate limit, telemetry hooks below.

Next.js App Router

// app/api/runflow/[...path]/route.ts
import { runflowProxy } from "@runflow-io/proxy";

export const { GET, POST } = runflowProxy({
  apiKey: process.env.RUNFLOW_API_KEY!,
  authenticate: async (req) => {
    const session = await getSession(req);
    if (!session) return null; // returns 401
    return { userId: session.userId };
  },
});

Hono, Cloudflare Workers, SvelteKit, Bun, Deno

import { runflowProxy } from "@runflow-io/proxy";

const handler = runflowProxy({ apiKey: process.env.RUNFLOW_API_KEY! });

// Hono
app.all("/api/runflow/*", (c) => handler(c.req.raw));

// Cloudflare Workers
export default { fetch: (req: Request) => handler(req) };

Express, Fastify, classic Node (req, res)

import { runflowProxyNode } from "@runflow-io/proxy/node";

app.use("/api/runflow", runflowProxyNode({
  apiKey: process.env.RUNFLOW_API_KEY!,
}));

Auth, rate limit, telemetry hooks

The proxy ships safe defaults (model allowlist, 32 KB body cap, 30 s upstream timeout, masked upstream errors). Layer your own auth, rate limit, and observability with hooks:
runflowProxy({
  apiKey: process.env.RUNFLOW_API_KEY!,

  authenticate: async (req) => {
    const session = await getSession(req);
    if (!session) return null; // returns 401
    return { userId: session.userId, context: { plan: session.plan } };
  },

  rateLimit: async ({ auth }) => {
    const hits = await redis.incr(`rf:${auth?.userId}`);
    if (hits > 100) return { status: 429, message: "Slow down", retryAfter: 60 };
  },

  allowedModels: (auth) => {
    const plan = (auth?.context as { plan?: string })?.plan;
    return plan === "pro"
      ? ["runflow/background-removal", "google/nano-banana-pro/edit"]
      : ["runflow/background-removal"];
  },

  onRun: async ({ runId, model, auth }) => {
    await db.runs.insert({ runId, model, userId: auth?.userId });
  },
});

Path contract

The proxy accepts only these paths:
MethodPathPurpose
POST/v1/models/{owner}/{slug...}/runsDispatch a run.
GET/v1/runs/{uuid}Poll a run.
GET/v1/healthPublic health.
Everything else returns 403 Not allowed. Run IDs are validated as UUIDv4-shape to block path traversal.

CSRF defaults

Non-GET requests are checked against the proxy’s allowedOrigins policy. The default "same-origin" accepts only requests whose Origin host matches the request Host header. Pass an array of full origins (["https://example.com"]) to allow specific third-party callers.
Two carveouts to know about:
  1. No Origin header = pass. Server-to-server callers (curl, scripts, other backends) do not send Origin, and the proxy lets them through this gate by design. The authenticate hook is the only thing that can block them.
  2. allowedOrigins: false is dangerous. If your authenticate hook reads cookies or session, disabling the origin check lets any third-party site trigger paid runs in a logged-in user’s name. Prefer adding the third-party origin to the array.
By default the proxy also requires Content-Type: application/json on non-GET requests so a malicious page cannot drain credentials via a text/plain CORS simple request. Set requireJsonContentType: false only if you proxy non-JSON workloads.

Embed the Studio

Drop the Studio UI on your site.

Authentication

Bearer header, key rotation.

Runs

Lifecycle and statuses.

Errors

Status codes and the error envelope.