> ## 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.

# JavaScript SDK

> Typed HTTP client and tool DSL for the Runflow API. Server-side or through a proxy from the browser.

[`@runflow-io/sdk`](https://www.npmjs.com/package/@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.

<Warning>
  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.
</Warning>

## Install

```bash theme={"dark"}
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

```ts theme={"dark"}
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`.

```ts theme={"dark"}
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.

```ts theme={"dark"}
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:

| Source    | Collected from caller?                           | Passed to `buildRequest`? |
| --------- | ------------------------------------------------ | ------------------------- |
| `preset`  | No, baked into the tool's `value` field.         | Yes.                      |
| `runtime` | Yes, every call (for example, the source image). | Yes.                      |
| `user`    | Yes, 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`.

<Warning>
  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.
</Warning>

```ts theme={"dark"}
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.

```ts theme={"dark"}
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`](https://www.npmjs.com/package/@runflow-io/proxy) on your server, point the SDK at it, and the key stays server-side.

```ts theme={"dark"}
// 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](#proxy-browser-calls-server-side) below.

## Configuration

```ts theme={"dark"}
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`:

| Error             | When it throws                                                                                                                                            |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RunflowError`    | HTTP failure, network error, request timeout, bad config, invalid model id, invalid run id, malformed apiKey.                                             |
| `RunFailedError`  | A run finished with `status_code: failed` or `canceled`. Thrown by `runs.wait`, `runs.poll`, and `tools.run`. Has `.run.id`, `.run.status`, `.run.error`. |
| `RunTimeoutError` | `runs.wait` / `runs.poll` did not see a terminal status before `timeoutMs`. Has `.runId`, `.elapsedMs`.                                                   |

Common `RunflowError.status` and `code` values:

| Trigger                                                                  | `err.status` | `err.code`           |
| ------------------------------------------------------------------------ | ------------ | -------------------- |
| Missing/invalid API key                                                  | 401          | (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 proxy                                           | 415          | (server-set)         |
| Rate limit (check `Retry-After` header)                                  | 429          | (server-set)         |
| Upstream timed out via proxy                                             | 504          | (server-set)         |
| Per-request timeout (default 30 s)                                       | undefined    | `request_timeout`    |
| Network error                                                            | undefined    | `network_error`      |
| Empty model id or `.`/`..` segment                                       | undefined    | `invalid_model_id`   |
| Run id contains `/`, `.`, or `..`                                        | undefined    | `invalid_run_id`     |
| Malformed apiKey at construction                                         | undefined    | `invalid_api_key`    |
| Default extractor found no image URL                                     | undefined    | `output_parse_error` |

```ts theme={"dark"}
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`](https://www.npmjs.com/package/@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.

<Warning>
  **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](#auth-rate-limit-telemetry-hooks) below.
</Warning>

### Next.js App Router

```ts theme={"dark"}
// 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

```ts theme={"dark"}
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)`

```ts theme={"dark"}
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:

```ts theme={"dark"}
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:

| Method | Path                                | Purpose         |
| ------ | ----------------------------------- | --------------- |
| POST   | `/v1/models/{owner}/{slug...}/runs` | Dispatch a run. |
| GET    | `/v1/runs/{uuid}`                   | Poll a run.     |
| GET    | `/v1/health`                        | Public 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.

<Warning>
  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.
</Warning>

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.

## Related

<CardGroup cols={2}>
  <Card title="Embed the Studio" icon="window" href="/guides/embed-studio">Drop the Studio UI on your site.</Card>
  <Card title="Authentication" icon="key" href="/concepts/authentication">Bearer header, key rotation.</Card>
  <Card title="Runs" icon="play" href="/concepts/runs">Lifecycle and statuses.</Card>
  <Card title="Errors" icon="triangle-exclamation" href="/concepts/errors">Status codes and the error envelope.</Card>
</CardGroup>
