# StitchAPI > API stitching: turn any API into a typed, resilient function. Declare an endpoint once — its types, auth, and resilience — and call it like a local function, from your code, the CLI, or an AI agent over MCP, without ever touching a credential. fetch/axios are pluggable adapters underneath; a stitch sits above them, it does not replace them. Search these docs instead of loading the whole file: this site is also a hosted MCP server (search_docs + get_doc) at https://stitchapi.dev/api/mcp — see /docs/agents/search-over-mcp. ## What an agent needs to know - API stitching, not an HTTP client: a **stitch** takes one endpoint (HTTP, GraphQL, SSE, an LLM, even a shell command) and hands back a callable. Keep the fetch/axios you already have — it is the adapter underneath. - Capability, not credential: an agent invokes a stitch and gets structured, validated, traceable data; the secret stays behind the boundary. - One context-frugal **code-mode** tool (run_stitch + list_stitches + describe_stitch), not one tool per endpoint — adding APIs never floods the context window. - No server, no codegen, no config files — a URL and one example response is enough; only explicit composition (no ambient/global config a stitch silently inherits). - Zero-dependency core, ~25 kB min+gzip for the whole entry (~22 kB for a tree-shaken import { stitch }), validator-agnostic (bring your own Standard Schema / Zod), and it runs in the browser. - Composes with your data layer: a stitch is the queryFn for TanStack Query / SWR — it owns the call's resilience; your query layer owns view state. ## Quickstart ```ts import { stitch } from 'stitchapi'; const getUser = stitch('https://api.example.com/users/{id}'); const user = await getUser({ params: { id: 1 } }); // typed, validated, traced ``` # Introduction (/docs/getting-started) A **stitch** is a typed, declarative, composable unit that turns one endpoint into a resilient, validated, observable function. You declare it once; your code calls it, the CLI runs it, and an agent invokes it — without any of them ever touching a credential. That is the whole idea, and the rest of these docs are an expansion of it. A stitch is atomic (one endpoint at a time, no spec required) and it hands the caller a capability, not a secret. Everything that normally rots in a `src/api/` folder — auth lifecycle, retries, validation, drift detection, tracing — folds into the call itself. ## The pain it removes [#the-pain-it-removes] Most integrations start as a thin wrapper over `fetch` that returns opaque bytes, and then every reliability concern gets re-implemented by hand at each call site, separately, where it quietly drifts out of date. A stitch replaces that wrapper with a declaration: * **No spec, no codegen, no config files, no server.** A stitch needs a URL and an example response — so it works on the long tail of internal and undocumented APIs that never get a complete OpenAPI document. * **Auth is a boundary.** Bearer, API key, cookie sessions, OAuth2 client credentials — the stitch owns the secret and its lifecycle, and the caller receives data, never the credential. * **Resilience is declared.** Retry with backoff, proactive throttling, and real timeouts are configuration, uniform across every stitch. * **Validation and drift are live.** Responses are validated on every call, and a silently renamed field surfaces as a loud, leveled drift signal instead of an `undefined` three layers downstream. * **Observability ships with it.** Every call emits a typed event stream, traced to a local JSONL log by default — no collector, nothing to deploy. ## The smallest stitch [#the-smallest-stitch] The smallest stitch is a URL. Declare it once, call it as a typed function: ```ts twoslash import { stitch } from 'stitchapi'; const getUsers = stitch('https://api.example.com/users'); const users = await getUsers(); // GET, parsed JSON ``` That is the entire surface for a basic call. Adding path params, an output schema, auth, and resilience is the job of the [Quickstart](/docs/getting-started/quickstart), which walks the full first run end to end. Awaiting a stitch gives you just the final validated value. The same call is also an event stream you can iterate — that is how progress, retries, and drift become observable for free. ## How this documentation is organized [#how-this-documentation-is-organized] The docs are seven top-level sections, ordered for a first read. Start at the top and move down, or jump straight to the section that matches what you need. ## Next steps [#next-steps] # Installation (/docs/getting-started/installation) Install the package, import `stitch`, and turn your first endpoint into a typed, callable function. `stitchapi` has zero dependencies and runs anywhere `fetch` does — Node, the browser, and edge runtimes. The whole entry is **\~25 kB minified + gzipped** — and with no dependencies, there is no transitive tree behind it (a typical `import { stitch }` tree-shakes to \~22 kB). **Validators are bring-your-own.** Because `stitchapi` ships with zero dependencies, it does **not** bundle a schema library and declares no `zod` (or Valibot / ArkType / …) peer dependency. `input`/`output` accept any [Standard Schema](/docs/guides/validation/standard-schema) validator — Zod, Valibot, ArkType, Effect, TypeBox — or a plain predicate, but the one you pick is **your** dependency: add it to your own project's `dependencies` so it is never a phantom (undeclared) import. ## Install [#install] npm pnpm yarn bun ```bash npm install stitchapi@rc ``` ```bash pnpm add stitchapi@rc ``` ```bash yarn add stitchapi@rc ``` ```bash bun add stitchapi@rc ``` ## Import it [#import-it] `stitch` is a named export. Pick the form that matches your module system — everything after the import is identical. ESM CommonJS ```ts twoslash tabGroup="module" import { stitch } from 'stitchapi'; ``` ```js tabGroup="module" const { stitch } = require('stitchapi'); ``` ## Verify [#verify] Declare a stitch from one URL and call it like a function — no config object, no client to construct: ```ts twoslash import { stitch } from 'stitchapi'; const getUsers = stitch('https://api.example.com/users'); const users = await getUsers(); // GET, parsed JSON ``` If that returns without throwing, the runtime is wired up. Add an `output` schema next and you get TypeScript types, runtime validation, and drift detection in one move — see Quickstart. ## See also [#see-also] # Quickstart (/docs/getting-started/quickstart) In about five minutes you will declare your first stitch from a single endpoint and call it as a typed, async function — no spec, no codegen, no server. This page assumes the package is already installed; if not, start with [Installation](/docs/getting-started/installation). ## Declare a stitch [#declare-a-stitch] The smallest stitch is a URL. Pass it to `stitch()` as a string and you get back a callable — the string sets the `path`: ```ts twoslash import { stitch } from 'stitchapi'; const getUsers = stitch('https://api.example.com/users'); ``` ## Call it as a typed function [#call-it-as-a-typed-function] The returned value is a function. Call it and `await` the result — `await` is sugar that consumes the underlying stream and returns the final, validated value (a `GET` with parsed JSON here): ```ts twoslash import { stitch } from 'stitchapi'; const getUsers = stitch('https://api.example.com/users'); const users = await getUsers(); ``` ## Add path params and query [#add-path-params-and-query] Use `{param}` slots in the path. Params, query, headers, and body all travel in a single input object, so a path param goes under `params` and query keys under `query`: ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch('https://api.example.com/users/{id}'); await getUser({ params: { id: 1 }, query: { expand: 'roles' } }); // → GET https://api.example.com/users/1?expand=roles ``` A templated path makes its `params` **required** at the call site — that's the type narrowing at work. It also means the inferred type is more specific than a bare `Stitch`, which matters if you store mixed stitches in one collection; see [Migration notes](/docs/getting-started/migration-notes). ## Type the result [#type-the-result] Pass a type argument to `stitch()` and the awaited value is typed as `T`, with no extra dependency: ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch<{ id: number; name: string }>( 'https://api.example.com/users/{id}', ); const user = await getUser({ params: { id: 1 } }); ``` The generic gives you compile-time types only. To also validate the response at runtime — and catch a vendor silently renaming a field — bring a [Standard Schema](/docs/guides/validation/standard-schema) validator as the `output`; see [Validation](/docs/guides/validation/validation). ## Observe the event stream [#observe-the-event-stream] A stitch does not return `Promise` — it yields a typed event stream, and `await` simply consumes it for you. Call `.stream()` instead to watch progress, retries, and drift as they happen: ```ts twoslash import { stitch } from 'stitchapi'; const getUsers = stitch('https://api.example.com/users'); for await (const ev of getUsers.stream()) { if (ev.type === 'result') console.log(ev.data); } ``` The [event stream](/docs/concepts/event-stream) concept covers every event type in depth. ## Next steps [#next-steps] # Use from an agent (/docs/agents) StitchAPI is agent-native because of two choices that fall out of the stitch primitive: an agent receives a **capability, not a credential** — it invokes a stitch and gets back structured, validated, traceable data without ever touching the secret — and it works through **one context-frugal tool**, not one tool per endpoint, so adding APIs never floods the model's context window. An agent does three things with a stitch: invokes it, authors it, and reasons over it — one subsection each. ## Invoke [#invoke] An agent drives stitches from a sandbox through a single **code-mode** tool, `run_stitch`, rather than a generated tool per endpoint. The agent writes a small TypeScript snippet that imports `stitchapi`, calls the stitch, and returns the result — `list_stitches` tells it what is available to call, and `describe_stitch` tells it the shape of one before running it. ```ts twoslash import { stitch } from 'stitchapi'; import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: z.object({ id: z.number(), name: z.string() }), }); // The agent writes this in the sandbox; run_stitch executes it. const user = await getUser({ params: { id: 7 } }); ``` The `output` schema (raw Zod here, the visible default) means the agent gets a validated, typed value back, not a guess. One tool keeps the context budget flat as the catalog grows, and the credential stays behind the boundary — the agent sees the user, never the token. Three tools make up the code-mode surface: `run_stitch` to execute, `list_stitches` to discover names, and `describe_stitch` to understand a stitch's shape, schema, and diagram before running it. ## Author [#author] An agent does not need a spec to add an API. Hand it a single example — a `curl` command, a HAR entry, or a snippet from a vendor's docs — and it emits a stitch declaration: the URL, the input shape, and an `output` schema inferred from the one response it saw. For the common `curl`/HAR case there is also a deterministic shortcut — `stitch from-curl ''` prints the same declaration with no model in the loop (id-like path segments lifted, credentials replaced with `env()`). Either way the declaration is committable code the agent (or you) can review. ## Adopt in your project [#adopt-in-your-project] Drop a short rule into your repo and any agent working in it reaches for a typed **stitch** instead of a hand-rolled `fetch`/`axios` whenever it adds an external API. Hand-copy the rule, or run `npx stitch init` to write it as `AGENTS.md`, a Cursor rule, or a `CLAUDE.md` section. That rule teaches an agent to *write* stitches. A companion rule teaches it to *look up* how — point it at these docs over MCP so it searches the current API before answering instead of guessing from training data. ## Reason over [#reason-over] Two outputs let an agent reason about stitches cheaply. The docs build emits an auto-generated `llms.txt` and a per-page `llms.mdx`, so an agent pulls just the one page it needs into context instead of the whole manual. And every call is a typed **event stream** — `start → progress → drift → result → done` — so the agent reads not just the final value but the retry that succeeded, the throttle that paused, and the field that drifted. These three pages assume the runtime is already installed and a stitch is declared. Start at the [Quickstart](/docs/getting-started/quickstart) if it is not. ## See also [#see-also] # run_stitch & code-mode (/docs/agents/run-stitch-tool) Drive stitches from an agent sandbox with a single `run_stitch` tool — pass `{ name, input }` and it covers every stitch you register, so the model's tool list (and its context) stays tiny no matter how many stitches there are. Two companion tools round out the surface: `list_stitches` discovers the available names, and `describe_stitch` returns one stitch's shape before it runs. ## Example [#example] The agent first discovers what is available, then runs one by name. Both are plain MCP tool calls — there is no library import on the agent side. ```json { "name": "list_stitches", "input": {} } ``` `list_stitches` returns the registered stitches with their method and path, so the agent knows what to pass to `run_stitch`: ```json { "name": "run_stitch", "input": { "name": "getUser", "input": { "params": { "id": 1 } } } } ``` Here the inner `input` is the stitch input — `{ params?, query?, body?, headers? }` — and the stitch runs against `api.example.com` behind the capability boundary. The agent never sees the credential. The credential boundary is not the only one, though: the agent is choosing those argument values. An input slot you leave unconstrained is a passthrough the model fills in — [the agent picks the arguments](/docs/scenarios/agent-holds-the-tool) tests both boundaries and shows which one is yours to close. ## describe\_stitch [#describe_stitch] Before running a name, an agent can ask for its shape. `describe_stitch` takes `{ name }` and returns one text result — a JSON description built from the stitch's public `__config`: its endpoint and surface, which input slots it takes, whether the output is validated and what it picks, the auth **scheme** (never the token), which policies are on, the pipeline of steps a call runs through, and a Mermaid `diagram`. ```json { "name": "describe_stitch", "input": { "name": "getWidget" } } ``` returns one text result whose body is JSON like: ```json { "name": "getWidget", "endpoint": "GET https://api.example.com/widgets/{id}", "surface": "http", "input": { "params": true, "query": false, "body": false, "headers": false }, "output": { "validated": true, "pick": "data" }, "auth": "bearer", "policies": { "retry": true, "throttle": false, "cache": false, "timeout": true }, "pipeline": [ "call", "GET /widgets/{id}", "retry", "validate", "pick: data", "result" ], "diagram": "flowchart TD\n subgraph ..." } ``` The `auth` field is the **scheme only** (read from `__config.authScheme`) — the credential never crosses the boundary. The `diagram` reuses `toMermaid(registry, { name })`, the same call graph `stitch diagram` renders. An unknown name is an error result, not a throw: ```json { "content": [ { "type": "text", "text": "unknown stitch \"x\". Available: getWidget, ping" } ], "isError": true } ``` ## Options [#options] Three tools make up the whole surface. `run_stitch` takes `{ name, input }`, where `input` is the stitch input `{ params?, query?, body?, headers? }`; `list_stitches` takes no arguments and returns the names, methods, and paths; `describe_stitch` takes `{ name }` and returns the JSON shape above. This is **code-mode**: one tool for every stitch, rather than one MCP tool per endpoint. One-tool-per-endpoint floods the model's context — every registered stitch adds another schema the model must carry — while `run_stitch` keeps the tool list at a constant three no matter how many stitches you register. The agent calls `run_stitch` and never touches the credential: it names a capability, the server holds the secret. That is [capability, not credential](/docs/concepts/capability-not-credential) applied to the agent boundary. This page is about the tool shape and why code-mode. For starting the server (`stitch mcp`) and the transport details, see the [MCP surface](/docs/surfaces/mcp). If you author the stitch that backs a name like `getUser`, it is an ordinary stitch from `stitchapi`. Adding an `output` schema (raw Zod here, the visible default) is what makes `describe_stitch` report `validated: true`: ```ts twoslash import { stitch } from 'stitchapi'; import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: z.object({ id: z.number(), name: z.string() }), }); ``` ## See also [#see-also] # Author from one example (/docs/agents/author-from-one-example) A complete stitch can come from one example — a `curl`, a HAR entry, a doc snippet — because a stitch is declarative and atomic: it needs only a URL and one example, never an OpenAPI spec and never codegen. There are two ways to get there: run the deterministic `stitch from-curl` command, or hand-author the same `stitch({...})` declaration. Both land on the identical authoring surface a human writes by hand. ## Deterministic: `stitch from-curl` [#deterministic-stitch-from-curl] Hand the CLI a single `curl` and it prints the matching declaration — no model, no guessing: ```bash stitch from-curl 'curl https://api.example.com/users/1' ``` It writes the stitch (and a matching example call) to stdout: ```ts import { stitch } from 'stitchapi'; export const getUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users/{userId}', }); // add an output schema to validate + type the response (rerun with --zod) await getUsers({ params: { userId: 1 } }); ``` The origin becomes `baseUrl`, the rest becomes `path`, and id-like segments (`/users/1`) are lifted into `{param}` slots — each lift is announced on stderr so you can revert a false positive. A recognised credential is **never** emitted: an `Authorization: Bearer …` header becomes `auth: bearer(env('API_TOKEN'))`, an `x-api-key` header becomes `auth: apiKey({ secret: env('API_KEY') })`, and the captured token stays in your shell, out of committed code. Read a single HAR entry instead with `--from-har request.har`. By default `from-curl` emits **no output schema** — just a comment to add one, since one example cannot prove the full response shape. Pass `--zod` with a `--response sample.json` to infer a starter `output:` schema (generated text; core never imports a validator): ```bash stitch from-curl 'curl https://api.example.com/users/1' \ --zod --response sample.json ``` ## Hand-author the same surface [#hand-author-the-same-surface] The CLI is a starting point, not a requirement — the output is the ordinary authoring surface, so you (or an agent) can write or extend it directly. Given the same one `curl`: ```bash curl https://api.example.com/users/1 ``` the equivalent hand-authored stitch, with an `output` schema added: ```ts twoslash // [!code fold:start] import { stitch } from 'stitchapi'; import { z } from 'zod'; // [!code fold:end] const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', method: 'GET', output: z.object({ id: z.number(), name: z.string() }), }); await getUser({ params: { id: 1 } }); ``` That declaration is the whole integration — no spec to ingest, no generated code to commit. The `output` here is raw Zod, shown as the default — but core is validator-agnostic, so any [Standard Schema](/docs/guides/validation/standard-schema) validator (Valibot, ArkType, …) or a plain `(value) => boolean` predicate works in the same slot. ## How each part maps [#how-each-part-maps] The same mapping rules drive both `stitch from-curl` and a hand-authored stitch: * **URL → `baseUrl` + `path`.** The origin becomes `baseUrl`; the rest becomes `path`, with id-like segments lifted into `{param}` slots (so `/users/1` becomes `/users/{userId}`, called with `{ params: { userId: 1 } }`). `from-curl` is conservative — it lifts only pure digits, UUIDs, long hex, or url-encoded segments, and warns on each one so you can revert it. * **Headers → `auth` / `headers`.** An `Authorization: Bearer …` or `x-api-key` header becomes an `auth` strategy resolved at call time — the captured credential is replaced with an `env('NAME')` placeholder and never written into the source. Other headers become static `headers` (transport noise like `host`, `cookie`, and `content-length` is dropped). * **A request body → `wire.body` + an example.** A JSON `-d` payload becomes `wire: { body: 'json' }`, a form one (`k=v&…` or `--data-urlencode`) becomes `wire: { body: 'form' }`, and a body implies `POST` unless `-G` folds the data into the query. * **An example response → an `output` schema.** `from-curl` emits none by default (one example cannot prove the shape); add one with `--zod --response`, or hand-write it — turning one move into TypeScript types, runtime validation, and drift detection. This works because a stitch is atomic, not spec-first — no OpenAPI document, no codegen step. What `from-curl` writes is the normal authoring surface: a plain `stitch({...})` declaration, identical to a hand-written one, that you can read, review, and extend. The emitted `stitch({...})` is the same call documented in [stitch()](/docs/guides/authoring/stitch) — add an `output` schema with [validation](/docs/guides/validation/validation) exactly as you would for a hand-authored stitch. ## See also [#see-also] # Point an agent at llms.txt (/docs/agents/llms-txt) Point an agent at this site's auto-generated `llms.txt`, `llms-full.txt`, and per-page `llms.mdx` instead of scraping rendered HTML — it reads clean Markdown with no markup and no token waste, whether it grabs the whole corpus at once or pulls just the page it needs. ## Example [#example] Fetch the index to discover structure, then pull a single page as Markdown: ```bash # The curated index of every docs page, one line per page. curl https://stitchapi.dev/llms.txt # The clean Markdown form of one page — just this feature, into context. curl https://stitchapi.dev/llms.mdx/docs/agents/run-stitch-tool/content.md # When the agent needs everything at once, the whole docs concatenated. curl https://stitchapi.dev/llms-full.txt ``` Each page already stands on its own — restating its premise rather than leaning on neighbors — so the Markdown the agent pulls reads correctly in isolation. The stitch it documents is the same one-source-of-truth definition the runtime uses. ## Default: load the whole corpus [#default-load-the-whole-corpus] The simplest workflow is also the cheapest correct one: fetch `/llms-full.txt` once and drop the entire docs into context. The whole corpus fits inside a modern large-context model — Claude's 200k window, for instance — so there is nothing to gain from retrieval: no search index to build, no vector store to host, no embeddings to keep in sync. The agent reads exactly the Markdown a human does, generated from the same source, so it never drifts. Reach for the frugal path — the per-page `llms.mdx` above, or a dedicated docs-search tool — only when one of two things becomes true: the corpus outgrows the model's context window, or loading it on every turn costs more than a targeted lookup is worth. Until then, `llms-full.txt` wholesale is the default. ## Options [#options] The site emits three machine-readable outputs automatically; you never hand-author them. `/llms.txt` opens with a curated StitchAPI header — the one-line premise, the signals an agent needs, and a runnable quickstart — and then the curated index, one line per page, title plus description, so an agent grasps what StitchAPI is before it maps the docs structure or pulls anything. A page's `llms.mdx` variant is the same page as clean Markdown, with rendered chrome stripped. Because guides are fine-grained — one page per feature — an agent pulls exactly one feature's page into context (say, just [`run_stitch`](/docs/agents/run-stitch-tool)) rather than the whole manual. That fine-grained, self-contained-pages design is what makes each `llms.mdx` a focused, extractable unit. `/llms-full.txt` is every page concatenated, for when an agent genuinely needs the entire corpus in one fetch. These are generated from the page content itself, so they never drift from what a human reads — the same anti-drift, one-source-of-truth principle the stitch enforces at runtime. ## See also [#see-also] # StitchAPI (/docs) A **stitch** is a typed, declarative, composable unit that turns one endpoint into a resilient, validated, observable function — you declare it once, and your code, the CLI, and an agent all call it without ever touching a credential. Keep the `fetch` or axios you already have: it is the adapter underneath, and a stitch sits above it, turning an endpoint into a function rather than replacing the transport. ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch<{ id: number; name: string }>( 'https://api.example.com/users/{id}', ); const user = await getUser({ params: { id: 1 } }); ``` New here? Install the runtime and declare your first stitch in five minutes with the [Quickstart](/docs/getting-started/quickstart). For the why behind it, read the [Introduction](/docs/getting-started) and [The stitch primitive](/docs/concepts/the-stitch). ## Explore the docs [#explore-the-docs] Driving stitches from an agent? Head to [Use from an agent](/docs/agents) — one context-frugal `run_stitch` tool, the capability boundary, and the auto-generated `llms.txt`. # Adopt in your project (/docs/agents/adopt-in-your-project) Teach the agents that work in your repo one habit: when the project calls an external API, declare a typed **stitch** instead of hand-rolling `fetch` or `axios`. The rule below is the canonical consumer pattern. Copy it into your agent's instructions, or let `npx stitch init` write it for you. ## The rule [#the-rule] This is the body to hand an agent — paste it into `AGENTS.md`, a Cursor rule, or a `CLAUDE.md` section: ````md # Using StitchAPI in this project When this project calls an external API (HTTP, GraphQL, an LLM, or a shell tool), do NOT hand-roll `fetch`/`axios`. Declare a typed **stitch** instead. 1. Put declarations in `stitches.ts`. 2. One canonical pattern — a bare stitch for a single endpoint: ```ts import { stitch } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; import { z } from 'zod'; export const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', // {param} is an RFC 6570 slot auth: bearer(env('API_TOKEN')), // secret stays here — callers get a capability, not the token output: z.object({ id: z.number(), name: z.string() }), // runtime-validated, drift-caught }); // await getUser({ params: { id: 1 } }) → typed, validated value ``` 3. Reuse a credential / principal / throttle budget across calls? Group the endpoints under a `seam(...)` so they share one runtime; never put the principal in the call input. 4. Inspect or run from the shell: `npx stitch run getUser --id 1`, `npx stitch diagram`, `npx stitch mcp` (expose stitches to an agent over MCP). Rule of thumb: a new external endpoint = a new stitch export, not a new fetch. ```` ## npx stitch init [#npx-stitch-init] Rather than copy the rule by hand, run the generator from your project root: ```bash npx stitch init ``` `stitch init` (aliased as `stitch rules`) writes the rule into the convention each agent reads. Pick one or more with `--format` (a comma list, or `all` — the default): | `--format` | Writes | | ---------- | ---------------------------------------------------------------------- | | `agents` | `AGENTS.md` (the open standard) | | `cursor` | `.cursor/rules/stitchapi.mdc` (Cursor) | | `claude` | a marked `## Using StitchAPI` section in `CLAUDE.md` | | `copilot` | a marked section in `.github/copilot-instructions.md` (GitHub Copilot) | | `windsurf` | `.windsurf/rules/stitchapi.md` (Windsurf) | | `cline` | `.clinerules/stitchapi.md` (Cline) | | `aider` | a marked section in `CONVENTIONS.md` (Aider) | For example, `npx stitch init --format agents,cursor` writes just those two. It refuses to clobber an existing file unless you pass `--force`. For the shared files (`CLAUDE.md`, Copilot, Aider) only the marked block is replaced — your surrounding content is left intact. ## Make the rule about *your* APIs [#make-the-rule-about-your-apis] Add `--project` and `stitch init` reads your stitches module (the `./stitches.*` it would `run`, or `--module `) and appends the endpoints you have already declared — so an agent reuses them instead of duplicating a `fetch`: ```bash npx stitch init --project ``` ```md ## Stitches already declared in this project Reuse these before declaring a new one — call them, compose them under a `seam(...)`, or extend an existing one. - `getUser` — GET https://api.example.com/users/{id} - `listOrders` — GET https://api.example.com/orders ``` ## Keep it in sync — `--check` [#keep-it-in-sync----check] In CI, `--check` verifies the committed rule files still match the rule the installed StitchAPI would write, without touching disk. It exits non-zero when a file has **drifted** (an absent rule is reported, but is not a failure): ```bash npx stitch init --check ``` Pair it with `--project` to also re-check the declared-stitches list: `npx stitch init --check --project`. ## See also [#see-also] # Search the docs over MCP (/docs/agents/search-over-mcp) This documentation site is itself a hosted MCP server. Point an agent at it and it gets two tools: `search_docs`, which returns the handful of relevant doc *sections* for a query — excerpts plus deep links, not whole pages — and `get_doc`, which reads one full page as clean Markdown. The agent connects once and pulls in exactly the passage it needs, instead of loading the whole manual on every turn. This is **not** the library's [`run_stitch` surface](/docs/surfaces/mcp). That MCP server runs on *your* machine over stdio and calls *your* stitches. This one is hosted by us at `stitchapi.dev`, speaks MCP Streamable HTTP, and searches *our* docs — so an agent can learn StitchAPI before it writes a line. They compose: `search_docs` (learn the API) → `get_doc` (read the page) → `run_stitch` (call it). ## Connect [#connect] The server lives at `https://stitchapi.dev/api/mcp`. It needs no key. **One-click:** [**Add to Cursor**](https://cursor.com/install-mcp?name=stitchapi-docs\&config=eyJ1cmwiOiAiaHR0cHM6Ly9zdGl0Y2hhcGkuZGV2L2FwaS9tY3AifQ==) · [**Install in VS Code**](https://insiders.vscode.dev/redirect/mcp/install?name=stitchapi-docs\&config=%7B%22name%22%3A%22stitchapi-docs%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fstitchapi.dev%2Fapi%2Fmcp%22%7D). Any other client can import [`/.well-known/mcp.json`](/.well-known/mcp.json), or drop one of these into your agent's config: **Claude Code** — add it from the CLI: ```bash claude mcp add --transport http stitchapi-docs https://stitchapi.dev/api/mcp ``` or commit it to the project's `.mcp.json`: ```json { "mcpServers": { "stitchapi-docs": { "type": "http", "url": "https://stitchapi.dev/api/mcp" } } } ``` **Cursor** — `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): ```json { "mcpServers": { "stitchapi-docs": { "url": "https://stitchapi.dev/api/mcp" } } } ``` **VS Code (Copilot)** — `.vscode/mcp.json`; note the top-level key is `servers`: ```json { "servers": { "stitchapi-docs": { "type": "http", "url": "https://stitchapi.dev/api/mcp" } } } ``` ## Tell your agent to use it [#tell-your-agent-to-use-it] Connecting the server is only half of it — an agent won't reach for a tool it forgets it has. Give it a standing rule so it searches the docs on its own. Drop this into whichever convention your agent reads — `AGENTS.md`, a `CLAUDE.md` section, a file under `.cursor/rules/`, or `.windsurfrules`: ```md # Using the StitchAPI docs MCP For any question about StitchAPI — its API, configuration, errors, or runtime behavior — use the `stitchapi-docs` MCP server before answering: - Call `search_docs` with a natural-language query first, and read the excerpts and links it returns. - Call `get_doc` to read a full page when an excerpt is not enough. - Prefer these tools over prior knowledge. StitchAPI is newer and moves faster than model training data, so the docs are the source of truth — when they disagree with memory, follow the docs. ``` Keep it short: it names the server, the two tools, and the one judgment call that matters — prefer the live docs to a stale guess. It pairs with the [consumer rule](/docs/agents/adopt-in-your-project), which teaches the same agent to *write* stitches; this one teaches it to *look up* how. ## The two tools [#the-two-tools] `search_docs` takes a natural-language `query` and an optional `limit` (default 5\). It runs hybrid retrieval — keyword and semantic together — over the same Markdown that feeds `llms.txt`, and returns each hit as `{ title, url, excerpt, score }`. The `url` carries the section anchor, so it deep-links straight to the passage. It never returns full pages: that frugality is the whole point. `get_doc` is the escape hatch for when an excerpt isn't enough. Give it the `url` from a `search_docs` hit (or a `slug` like `guides/resilience/throttle`) and it returns that page as clean Markdown — the same output as its [`llms.mdx`](/docs/agents/llms-txt) route. ## When to reach for it [#when-to-reach-for-it] For a corpus this size, [loading the whole `llms-full.txt`](/docs/agents/llms-txt) once is still the simplest correct default — it fits a modern context window, so there's nothing to gain from retrieval. Reach for `search_docs` when one of two things becomes true: the docs outgrow the model's window, or reloading them every turn costs more than a targeted lookup is worth. The index is rebuilt from the docs on every deploy — the same source a human reads — so a search hit never drifts from the page it cites. ## See also [#see-also] # How the rule saves tokens (/docs/agents/token-savings) The [rule that `stitch init` writes](/docs/agents/adopt-in-your-project) is a few hundred tokens the agent reads once, at the start of a session. It earns its place by being the cheapest way to answer a question the agent would otherwise pay for every time it touches an endpoint: *how does this project call an external API?* ## The always-on cost is tiny [#the-always-on-cost-is-tiny] Measured against the site's own machine-readable docs, the rule weighs almost nothing: | What the agent could load | Size | \~Tokens | | -------------------------------------------- | ------ | --------- | | The `stitch init` rule (`AGENTS.md`) | 1.2 KB | **\~300** | | …with the `--project` inventory (2 stitches) | 1.5 KB | \~390 | | `/llms.txt` — the curated docs index | 21 KB | \~5,500 | | `/llms-full.txt` — the entire docs corpus | 451 KB | \~115,000 | Token figures are the byte count over four — a rough English/code estimate, not a tokenizer run. The ratios are what matter: the rule is under 0.3% of the full corpus. The cost model matches each ecosystem. The `AGENTS.md` and `CLAUDE.md` rules are always-on but small; the Cursor (`.cursor/rules/stitchapi.mdc`) and Windsurf rules are **glob-scoped to `stitches.ts`**, so the model only spends the tokens when it is already working near your API layer. ## What it replaces [#what-it-replaces] The rule is cheap in absolute terms, but the saving is what it lets the agent *not* do: 1. **Load the manual.** [`llms-full.txt`](/docs/agents/llms-txt) is the docs' own "simplest correct default" — the whole corpus (\~115k tokens) fits in a modern context window. The rule front-loads the \~300 tokens that resolve the common case (*declare a stitch, reuse these*) and defers the rest to an on-demand [`search_docs`](/docs/agents/search-over-mcp) lookup the agent makes only when it hits an edge. 2. **Explore the repo.** Without `--project`, an agent that wants to reuse `getUser` has to find it first — open `stitches.ts`, skim the exports, match one to the endpoint. `--project` appends that inventory to the rule for one line per endpoint, so the list is already in context. 3. **Hand-roll, then fix.** A raw `fetch` with no validation and an inline token is the path the model takes by default. The round-trip of writing it, having it reviewed or failing at runtime, and rewriting it as a stitch costs more than the rule that prevented it. It is a fixed cost, too. The rule is read once per session and amortises across every call the agent makes after it; rediscovery is paid again every session the rule is absent. ## Layer it: rule plus retrieval [#layer-it-rule-plus-retrieval] The rule is the always-on layer, not the only one. Pair it with the docs MCP and you get a two-tier budget: the small rule resolves the frequent decisions for free, and [`search_docs`](/docs/agents/search-over-mcp) pulls exactly the page the agent needs for the rare ones — never the whole corpus, never stale training data. ```bash # always-on: the habit, in every agent's rules file npx stitch init --project # on-demand: the long tail, one page at a time # → connect the docs MCP at https://stitchapi.dev/api/mcp ``` That frugality is the whole point: adding APIs never floods the model's context window, because the rule stays small and the retrieval stays scoped. ## See also [#see-also] # Capability, not credential (/docs/concepts/capability-not-credential) Hand an agent — or any caller — a stitch and it can call your authenticated API without ever seeing the secret. A stitch holds its credential; what it hands the caller is a capability — a callable that, when invoked, returns data. The caller gets the ability to make the authenticated call, never the token or API key that authorizes it. ## Why it's shaped this way [#why-its-shaped-this-way] A credential is not a value you want flowing through application code. The moment a token is read into a variable, passed as an argument, or logged on the way to a request, it has more places to leak from than it should. A stitch removes those places by owning the secret end to end and exposing only the act of calling. Secrets are resolved **lazily, at call time**. `env('NAME')` and `credential.file('NAME')` don't read anything when you declare the stitch — they return a `() => string` resolver that the runtime invokes on each request, reads the value, attaches it, and discards it. So the declaration carries a *reference* to where the secret lives, not the secret itself: it is safe to commit, diff, and share, because there is nothing sensitive in it to commit. ```ts twoslash import { stitch } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; const getMovie = stitch({ path: 'https://api.example.com/movies/{id}', // env('API_TOKEN') is a resolver; the runtime reads the token per call, // attaches it to the request, and the caller never receives it. auth: bearer(env('API_TOKEN')), }); // The caller just calls the stitch and gets data — no token in sight. const movie = await getMovie({ params: { id: 1 } }); ``` The stitch also owns the whole **auth lifecycle**, not just the one header. Each `AuthStrategy` — `bearer`, `apiKey`, `basic`, `cookieSession`, `oauth2` — attaches the secret to the request *inside* the runtime; the strategies that have a session detect when they hit a wall and refresh it (re-login, fetch a new token) without the caller doing anything. Attach, detect, refresh: all of it happens behind the capability, so the caller never sees the token even when the token is rotating underneath them. ### Safe to hand to an agent [#safe-to-hand-to-an-agent] That is the security boundary that lets an agent call your API without ever seeing the secret. Hand an agent a stitch and it invokes the authenticated call without being granted the credential — the agent operates with a capability scoped to exactly that endpoint, and a leak in the agent's context can't leak a token that was never in it. ## How it relates [#how-it-relates] This boundary is the *why* behind the auth machinery, which the guides cover in detail. The [secret resolvers](/docs/guides/auth/secret-resolvers) `env()` and `credential.file()` are the lazy lookup that keeps secrets out of the declaration; the [auth strategies](/docs/guides/auth/bearer) — `bearer` here, [`cookieSession`](/docs/guides/auth/cookie-session) for login-and-cookie APIs — are what attaches and refreshes the secret inside the runtime. The boundary is what lets the [agent surfaces](/docs/agents) exist: a stitch invoked through [MCP](/docs/surfaces/mcp) or the [`run_stitch`](/docs/agents/run-stitch-tool) tool runs its auth on the runtime's side of the line, so the agent gets a structured result and never a token. And when a session can't be refreshed — an expired login, a soft 200 wall — the lifecycle surfaces a typed [`STITCH_AUTH_WALL`](/docs/errors/stitch-auth-wall) error rather than silently returning a login page, so a failed capability is a loud, structured signal too. ## See also [#see-also] # Correlation vs idempotency (/docs/concepts/correlation-vs-idempotency) A write can carry two different ids, and they are easy to confuse: an **idempotency key** so a server collapses a duplicate, and a **correlation / trace id** so logs and spans can find the request. Reach for this distinction whenever you're deciding which header carries which — because routing one through the other quietly fails. ## Why it's shaped this way [#why-its-shaped-this-way] The two keys answer different questions: | | **Idempotency key** | **Correlation / trace id** | | ------------------------------------ | ---------------------------------- | ----------------------------------- | | Question | "are these the same *write*?" | "which *operation* is this?" | | Server does | dedupe / collapse the replay | log it, link spans | | Stable across retries | yes (must be) | yes (typically) | | Stable across *separate submissions* | only if **derived** | **no** — each call is its own trace | | Right value | random *or* derived from the input | the engine's `traceId` / `spanId` | | Standard header | `Idempotency-Key` | `traceparent`, `X-Request-Id` | They share exactly one property — "stable across a call's retries" — which is why a random idempotency key *feels* like a request id. But their semantics diverge the moment you look past that: * An idempotency key is sometimes **derived** from the input, so two *separate* submissions (a double-clicked button) collapse into one write. A correlation id is the opposite: you want each submission to stay **distinct** in the logs. * A correlation id wants to **be** the trace id, so the line a server logs joins the spans you already export. An idempotency key has no such tie — a random uuid is fine, because all it needs to be is *the same* across one write's attempts. So one field can't serve both. The idempotency `header` option renames the key a server **dedupes** on — nothing more. A correlation id belongs to tracing, where its value is your [run's trace id](/docs/concepts/run-identity). ## How it relates [#how-it-relates] **The tempting shortcut that breaks.** Setting `idempotency.header` to `X-Request-Id` to "also get a request id" pushes a correlation header through the dedupe machinery. It looks like tracing without being it: the random idempotency key is unrelated to your real `traceId`, so the `X-Request-Id` a server logs can never be joined to the spans you export. Keep the idempotency header for dedupe; let tracing own correlation. **Where each lives.** The idempotency key is a write-safety tool — see [Idempotency keys](/docs/guides/resilience/idempotency), which pairs it with retries and shows when to derive a stable key. The correlation/trace id is a property of [run identity](/docs/concepts/run-identity); carrying it to a downstream service on a `traceparent` header is planned trace-context propagation, distinct from anything idempotency does. ## See also [#see-also] # The event stream (/docs/concepts/event-stream) A stitch returns an async iterable of typed events — `start → progress → drift → result → done` — not `Promise`. Awaiting it gives you just the final value; iterating its stream gives you everything that happened on the way there. ## Why it's shaped this way [#why-its-shaped-this-way] A single `Promise` collapses a call to two outcomes — it resolved, or it threw — and throws away the story in between: the retry that succeeded on the third attempt, the throttle that paused for 400ms, the field that silently changed shape. That story is exactly what an agent needs to reason about a call, and what an operator needs to debug one. So a stitch is a single source that you can read at the altitude you need: ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search' }); // Await it for just the value… const result = await search({ query: { q: 'mango' } }); // …or iterate the typed event stream for everything that happened. for await (const event of search({ query: { q: 'mango' } }).stream()) { if (event.type === 'progress') console.log(event.phase, event.attempt); if (event.type === 'drift') console.log(event.finding.level, event.finding.path); if (event.type === 'result') console.log(event.data); } ``` The events form a fixed spine — `start`, then any number of `progress` (auth, throttle, retry, pagination) and `drift` findings, then exactly one terminal `result` or `error`, then `done`. The union is discriminated on `type`, so narrowing one field tells the compiler the shape of the rest. ## Awaiting without the throw [#awaiting-without-the-throw] Awaiting collapses the stream to its terminal — the `result` value, or a thrown [`StitchError`](/docs/errors) when the terminal is an `error`. When you'd rather branch on failure than wrap the call in `try`/`catch`, `.safe()` consumes the same stream and never throws: it resolves to a discriminated `{ ok, data, error }` where `error` is the `StitchError`, or `null` on success. ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search' }); const { data, error } = await search({ query: { q: 'mango' } }).safe(); if (error) { console.error(error.status, error.attempts); // a StitchError } else { console.log(data); // the validated result; error is null here } ``` `.unwrap()` is the explicit throwing twin — the value, or a thrown `StitchError`, exactly what awaiting the bare call does. Each also has an eager form on the stitch itself (`search.safe(input)` / `search.unwrap(input)`) that mirrors `search.stream(input)`. ## How it relates [#how-it-relates] The stream is what every cross-cutting feature reports through: [retry](/docs/guides/resilience/retry), [throttle](/docs/guides/resilience/throttle), and auth emit `progress`; [leveled drift](/docs/guides/validation/drift) emits `drift`. It is also the seam observability plugs into — a [trace sink](/docs/guides/observability/trace-sinks) is just a consumer of these same events, which is how console logs, JSONL, and [OTLP spans](/docs/guides/observability/otlp) all come from one source without the call site knowing. Streaming responses are where the terminal matters most. A call that has already emitted 800 tokens spent its `200` on the first one, so failure has to arrive in-band or not at all — [a stream that fails after 800 tokens](/docs/scenarios/mid-stream-failure) works through what a retry means once the user has read half the answer, and [the export that eats the heap](/docs/scenarios/large-response-memory) measures what the stream actually retains while you consume it. ## See also [#see-also] # Principles (/docs/concepts/principles) A handful of design principles explain why the API looks the way it does — why the smallest stitch is a bare string, why there is no central config object, and why the same definition answers to an agent. This page is the synthesis; each principle links to the surface that embodies it. ## Why it's shaped this way [#why-its-shaped-this-way] ### Progressive disclosure [#progressive-disclosure] Complexity is opt-in. A bare URL string is a working stitch; reach for more and the same call grows into a config object with `extends` for composition. Every capability (validation, auth, retries, observability) has a sane default and reveals its knobs only when you ask, so simple things stay one line and the depth is there when you need it. ```ts twoslash import { stitch } from 'stitchapi'; // A string is a whole stitch. const getUsers = stitch('https://api.example.com/users'); // Grow into a config object when you want more than the default. const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', retry: { attempts: 3 }, }); ``` ### Atomic stitches [#atomic-stitches] A stitch is one endpoint at a time and nothing else — self-contained, with no spec to author, no client to generate, no config file to load, and no server to run. It needs only a URL and (optionally) an example response, which is why it reaches the spec-less long tail of internal and undocumented APIs that codegen never covers. The declaration *is* the runtime; there is nothing to commit, diff, and regenerate. ### Composition over configuration [#composition-over-configuration] Cross-cutting concerns — `baseUrl`, auth, `pick`, retry, throttle, timeout — are named, shareable values you compose, not a central config object far from the call site. Nothing is inherited implicitly: no config file, no ambient defaults, no global a stitch silently reads. Everything that shapes a call was composed into it explicitly, so you can read one stitch and know exactly what it does. `extends` folds fragments into a stitch. You share a plain fragment instead of re-typing config, and a stitch is itself a composable value others can extend. (For a whole shared surface — shared runtime plus a trusted principal boundary — a `seam` owns the fragment instead.) ```ts twoslash import { stitch } from 'stitchapi'; // A reusable fragment is just a plain object — no helper required. const api = { baseUrl: 'https://api.example.com', retry: { attempts: 3, on: [429, 503] }, }; // Fold the shared fragment into each stitch; it inherits baseUrl + retry. const getUser = stitch({ extends: [api], path: '/users/{id}', pick: 'data' }); ``` ### One source of truth [#one-source-of-truth] The product sells anti-drift discipline; the API holds itself to the same rule. Responses are validated on every live call, and differences from a committed contract surface as a *leveled* drift signal — error, warn, or info — instead of a silent `undefined` three layers downstream. The docs apply the principle to themselves: a fact lives in exactly one place — full type tables only in Reference, error remediation only in Errors & pitfalls — and everywhere else links to it rather than restating (and risking drift from) it. ### Agent-native [#agent-native] An agent is a first-class caller, not an afterthought bolted on with a server. Three things follow. A stitch hands the caller a capability, not a credential: the secret resolves at call time and never reaches the caller, human or agent. Every call yields a typed event stream — `start → progress → drift → result → done` — so an agent gets structured, validated, traceable results instead of opaque bytes. And the agent surface is one code-mode tool the agent drives, not one tool per endpoint, which keeps its context frugal. ### Browser-first [#browser-first] A stitch runs wherever `fetch` does — browser, edge, or Node. The call path leans on web-platform primitives only; capabilities that genuinely need a server — the file trace sink, the CLI, `serve`, MCP — are explicitly server-side surfaces rather than silent assumptions inside the core. Every feature is judged in the browser first: if it cannot work there, it ships beside the core, not inside it. ### Pay only for what you import [#pay-only-for-what-you-import] Bundle size is part of the API contract, because the frontend pays for every byte. `import { stitch }` brings the engine and nothing else; adapters, stores, trace sinks, and the server-side surfaces are separate imports you reach for explicitly. A new capability either earns its place in the core entry or lives behind its own import — so an app never ships bytes for a CLI it will not run. The same frugality the event stream practices with an agent's context, the package practices with your bundle. Concretely, the whole `stitch` entry is **\~25 kB minified + gzipped** (≈61 kB raw), and because every surface beyond `http` is a separate subpath import, a typical `import { stitch }` tree-shakes to **\~22 kB**. With zero runtime dependencies, that figure is the entire cost — not the tip of a transitive tree. ### Contract, not dependency [#contract-not-dependency] Redis is not the only KV store, `fetch` is not the only transport, and Zod is not the only validator — so the package commits to none of them. Every integration point is a small contract — the HTTP adapter, the state store, trace sinks, auth strategies, Standard Schema validation — and core ships only the platform defaults: `fetch`, an in-memory store, console and file sinks. Anything vendor-shaped lives in its own package, built against the contract and proven by a conformance kit from `stitchapi/testing`, so swapping a vendor is a one-line import change, never a migration. ### No side effects by default [#no-side-effects-by-default] Making the call is the only thing a stitch does to the world. The state a stitch keeps to do its job — throttle buckets, the cookie jar, cached tokens, the circuit-breaker counter — lives in memory, scoped to the process, and is gone when the process exits; nothing is persisted or shared across workers, and no trace is written to disk, unless you ask for it. State turns durable and shared the moment you hand a stitch a `store`: the same throttle becomes a distributed rate limit and the same session survives restarts and is shared across processes. Observability is the same opt-in — a stitch traces nothing until you pass `trace: 'console'`, a `fileSink`, or your own sink. Persistence and sharing are config you reach for, never a default you inherit, which is what keeps a stitch safe to import and call anywhere. It is progressive disclosure applied to durability: ephemeral and isolated until you opt into more. ### Declarative spelling [#declarative-spelling] Every capability has a spelling that round-trips as plain JSON. Functions in config — hooks, `transform`, custom predicates — are sugar for the common cases, never the only way in. This is the agent-native principle applied to authoring: an agent cannot emit a closure over MCP, but it can emit data, and a stitch that is data can be stored, diffed, inferred from one example, and exported as a spec. ## How it relates [#how-it-relates] * **Progressive disclosure** runs from the [stitch primitive](/docs/concepts/the-stitch) through the full config object — the string and the config object are two altitudes of one call. * **Atomic stitches** are the [stitch](/docs/concepts/the-stitch) itself: one endpoint, no spec, no codegen, no server. * **Composition over configuration** is what [`extends`](/docs/guides/authoring/extends) expresses — a plain fragment is what it composes (a [`seam`](/docs/concepts/the-seam) owns one for a whole shared surface). * **One source of truth** shows up at runtime as [leveled drift](/docs/guides/validation/drift), the same anti-drift rule this documentation follows. * **Agent-native** is spelled out across [capability, not credential](/docs/concepts/capability-not-credential), the [event stream](/docs/concepts/event-stream), and the [agent surfaces](/docs/agents). * **Browser-first** is what [installation](/docs/getting-started/installation) promises — the package runs anywhere `fetch` does, with server-side capabilities kept to the [CLI](/docs/surfaces/cli), [HTTP serve](/docs/surfaces/http-serve), and [MCP](/docs/surfaces/mcp) surfaces. * **Pay only for what you import** governs how those surfaces and the pluggable seams ([adapters](/docs/reference/helpers), stores, trace sinks) are packaged: explicit imports beside the core, never bytes inside it. * **Contract, not dependency** is visible at every seam — the pluggable [adapters](/docs/reference/helpers), stores, and [Standard Schema validation](/docs/guides/validation/standard-schema) — and made enforceable by the [conformance kits](/docs/reference/conformance-kits). * **No side effects by default** is the [in-memory store](/docs/guides/state/pluggable-store) behind every stitch and the silent [trace sink](/docs/guides/observability/trace-sinks): opt into a shared store and the same throttle turns [distributed](/docs/guides/state/distributed-throttle) and the same [session is shared](/docs/guides/state/shared-sessions) across workers; opt into a sink and the event stream lands on the console or disk. * **Declarative spelling** keeps every stitch agent-authorable: the [config object](/docs/reference/config-types) is the canonical form. ## See also [#see-also] # Run identity & the trace tree (/docs/concepts/run-identity) Every stitch call carries a **run identity**: a shared `traceId`, a span id (`spanId`) for the call itself, and a `parentSpanId` when one call spawned another. Reach for this model when you want a stitch, the login it runs, and the steps it composes to read back as **one tree**, not a flat list of unrelated calls. ## Why it's shaped this way [#why-its-shaped-this-way] Three ids, in the OpenTelemetry shape — because a run has to answer two questions at once, and one id can't: * **`traceId`** — a 32-hex id **shared** by every run in one logical operation. It is not parented; it is the same value everywhere in the tree. Filter by it and you get the whole operation. * **`spanId`** — a 16-hex id for **this** call (the OpenTelemetry span id). * **`parentSpanId`** — the **caller's** `spanId`, set only when one run spawned another. Absent on a root run. The tree comes from **composition**, not configuration. When a stitch spawns another run, the child **inherits** the `traceId` and points its `parentSpanId` at the parent's `spanId`. Two paths do this today: a `cookieSession` login runs as a child of the call that triggered it, and each call in a `linked()` scope is a child of the call before it. ``` traceId = abc… (shared by every node below) [spanId A] ← root: a getUser call. parentSpanId = none └─ [spanId B] parentSpanId = A ← the cookieSession login it triggered [spanId C] ← a linked() scope: call 1. parentSpanId = none └─ [spanId D] parentSpanId = C ← call 2, a child of call 1 ``` A single flat per-call id could say "these are different calls" but never "this login happened **inside** that request." The `parentSpanId` is what carries that causality, so it has to be its own field alongside `spanId`, not folded into it. The ids are **engine-minted, never caller-supplied** — a caller-named id would let one caller spoof another's identity, the same reason a stitch never takes its principal from call input. ## How it relates [#how-it-relates] **To OpenTelemetry.** The field names *are* the OTel span names — `traceId`, `spanId`, `parentSpanId` — so the OTLP exporter writes them straight through, no translation. It reads them as fields and builds the tree; it never guesses structure from timing. **To the wire.** The W3C `traceparent` header is `version-trace-id-parent-id-flags` — one `traceId` + **one** span id + flags. A run needs **two** span ids (its own `spanId` *and* its `parentSpanId`), so `traceparent` is a **projection** of the run identity onto a single header, not a replacement for it. The spec's middle field (`parent-id`) is directional: it carries the sender's `spanId` going out and becomes the receiver's `parentSpanId` coming in, so a downstream server's spans become children under the same `traceId`. **Today the tree is in-process** — it covers a stitch and the runs it composes; carrying it across the wire so a downstream service joins your trace is planned trace-context propagation, not a shipped feature. **To the two keys.** A run identity is for **observability** — "which call is this, and what spawned it?" It is not the idempotency key (which answers "is this the same write?"). See [Correlation vs idempotency](/docs/concepts/correlation-vs-idempotency) for why those stay separate. ## See also [#see-also] # The seam (/docs/concepts/the-seam) A seam is the second primitive StitchAPI is built on: a long-lived surface that stitches *belong to*. Where a stitch is one call, a seam is the shared runtime behind a whole set of them — one store, one auth vault, one throttle bucket, one trace sink — fronted by a shared base config and a trusted principal boundary. ## Why it's shaped this way [#why-its-shaped-this-way] A single API is rarely one call. You wrap a handful of endpoints against the same host, with the same auth, the same rate limit, and the same place you want traces to land. The question a seam answers is what those stitches should *share* — and the answer is more than configuration. **A fragment shares config; a seam shares runtime.** A plain object folded in through [`extends`](/docs/guides/authoring/extends) is data: every stitch that extends it gets its *own* copy — its own throttle counter, its own cookie jar, its own cached tokens. That is the right default, but it can't express "these five stitches draw from one rate budget" or "these share a login". A seam can, because it isn't a fragment the stitches copy — it is the live runtime they all *point at*. One throttle bucket pools the budget across every member; one vault lets a session captured by one stitch serve the next call; one trace sink sees the whole surface as a single stream. ```ts twoslash import { seam } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; import { z } from 'zod'; const User = z.object({ id: z.number(), name: z.string(), email: z.string(), role: z.enum(['admin', 'member', 'viewer']), }); // One seam: the shared base every member inherits, and the runtime they share. const api = seam({ baseUrl: 'https://api.example.com', auth: bearer(env('API_TOKEN')), retry: { attempts: 3, on: [429, 503] }, }); // Members are stitches that belong to the seam — they extend its config and // share its store, throttle bucket, vault, and trace sink. const getUser = api.stitch({ path: '/users/{id}', pick: 'data', output: User, }); const listUsers = api.stitch({ path: '/users', pick: 'data', output: User.array(), }); ``` **The decisive job is the trusted principal boundary.** `seam.as(id)` derives a handle whose members carry that principal — and the principal lives in the *closure*, never in a call's input. A caller invokes a stitch the same way it always does; it cannot name a different principal, because there is no field on the call to name one. Auth is scoped per principal (separate sessions, no bleed between tenants), while the throttle stays shared (one bucket across every principal, so the whole surface still honours one rate limit). That split — isolated identity, pooled budget — is exactly what a multi-tenant server wants, and the seam enforces it by where it holds the principal, not by a check the caller could forget. **Lifecycle belongs to the root, not the per-request handle.** `flush`, `close`, and `invalidate` act on the runtime *every* principal shares, so they live on the root seam alone. A handle from `seam.as(...)` is deliberately lifecycle-free: the least-trusted, per-request caller can derive its own identity but can never flush the trace sink, close the shared store, or bust the cache out from under the rest of the surface. ## How it relates [#how-it-relates] A seam is the surface-scale companion to the per-call [stitch](/docs/concepts/the-stitch): you still author and call stitches the same way, but they now belong to something. It owns the fragment that [`extends`](/docs/guides/authoring/extends) composes — reach for a plain fragment when stitches share only config, and a seam when they share *runtime*. Building and using one is the job of the [seam guide](/docs/guides/authoring/seam). Its principal boundary is [capability, not credential](/docs/concepts/capability-not-credential) at surface scale: the secret never leaves the runtime, and now the *identity* the secret authenticates is bound in the closure too. The shared runtime is also what makes the state features work across a whole surface rather than one call — a [distributed throttle](/docs/guides/state/distributed-throttle) and [shared sessions](/docs/guides/state/shared-sessions) are a seam pointed at a [shared store](/docs/guides/state/pluggable-store). Because the seam is surface-agnostic, it can [mint members of any surface](/docs/reference/surfaces) — `http`, `graphql`, `sse`, `stream`, `download` — from the one shared runtime. And it is the object the [framework integrations](/docs/integrations) build once per app and bind a principal to per request. ## See also [#see-also] # The stitch primitive (/docs/concepts/the-stitch) A stitch is the one primitive StitchAPI is built on: a typed, declarative, composable unit that turns a single API call into a resilient, validated, observable function — declared once, callable by your code, the CLI, or an agent. ## Why it's shaped this way [#why-its-shaped-this-way] The thing every project reaches for is `fetch`, and `fetch` hands back opaque bytes. Everything that actually makes an integration reliable — the auth lifecycle, retries, rate limits, timeouts, response validation, drift detection, tracing — is left to be re-implemented at every call site, where each wrapper rots on its own. A stitch exists to make those concerns properties of the call itself rather than scaffolding you rebuild by hand. **Declarative, not an imperative wrapper.** A stitch is a description of an API call, not a function body that performs one. You state *what* the call is — its URL, its input and output shapes, how it authenticates, how it should behave under failure — and the engine decides *how* to carry it out. That inversion is what lets one declaration be read at runtime to validate every live response, surface drift, emit a trace, and back a CLI command. An imperative wrapper around `fetch` is just code; it can't be introspected, composed, or invoked by an agent without bespoke glue. A declaration can. **Atomic, not spec-first.** A stitch describes exactly one call, and it needs nothing more than a URL to exist. There is no OpenAPI document to obtain first, no codegen step, no generated artifact to commit and regenerate when the API changes. Spec-based generators can't emit anything until a complete, accurate spec exists — which most internal services and long-tail vendors never have. Working one call at a time means you can wrap the API in front of you today, and because every stitch carries its own schema, the contract accretes from the calls you actually make rather than from a document someone has to maintain. **One primitive, two facades.** There is exactly one thing — the stitch — reached through two authoring surfaces that all resolve to the same engine. The string form is the smallest possible declaration; the config form is the same declaration with more fields. Neither is a different kind of object: each compiles to one canonical config and produces the same callable. ```ts twoslash import { stitch } from 'stitchapi'; // The string form: a bare URL is the smallest complete stitch. const a = stitch('https://api.example.com/users'); // The config form: the same declaration, with room to grow. const b = stitch({ baseUrl: 'https://api.example.com', path: '/users' }); ``` The point of two facades is reach, not variety. A single shape can't be both the one-liner a quick call wants and the structured object a fully-specified integration needs, so the surface flexes while the primitive underneath stays singular. That singularity is what keeps the mental model small: learn the stitch once, and every facade is just a different door into it. ## How it relates [#how-it-relates] A stitch is the unit everything else in StitchAPI attaches to or is built from. What a call *returns* is the [event stream](/docs/concepts/event-stream) — an async iterable of typed events rather than `Promise`. Awaiting a stitch is sugar that consumes that stream and hands back the final validated value; the cross-cutting features all report through the same spine. How a stitch *authenticates* is the [capability, not credential](/docs/concepts/capability-not-credential) boundary. Auth is a field on the stitch, so a caller invokes it and receives data without ever touching the secret — which matters most when the caller is an agent. Because a stitch is itself a value, stitches *compose*: a reusable fragment can be extended, a base can be bound into a factory, and partial input can be pre-applied, all without any global configuration. The composition facades are covered in the [authoring guides](/docs/guides/authoring/stitch). And because the declaration is the runtime, one stitch is reachable through more than one [surface](/docs/surfaces/function) — an in-process function and a CLI command today — without re-describing the call for each. A stitch is a per-call primitive, not a workflow engine. Multi-step orchestration, queues, and inbound webhooks stay your application's job — the stitch is the durable unit those things are composed *from*. ## See also [#see-also] # Errors & pitfalls (/docs/errors) When a stitch can't produce a result, it fails with a `StitchError` — an `Error` subclass exported from `stitchapi`, carrying a human-readable `message`, an optional `.status` (the upstream HTTP status), and `.attempts` (how many tries the runtime made before giving up). When the failure came from an HTTP response it also carries `.body` (the parsed error payload — an API's `{ error: "…" }`) and `.url` (the final request URL, after redirects); both are `undefined` for transport/internal errors. Await a stitch and that error is what `catch` receives — narrow it with `instanceof`: ```ts twoslash import { StitchError, stitch } from 'stitchapi'; const me = stitch({ baseUrl: 'https://api.example.com', path: '/me', }); try { await me(); } catch (e) { if (e instanceof StitchError) { // .body is the parsed error payload; .url is the final request URL. console.error(e.message, e.status, e.attempts, e.body, e.url); } } ``` `.body` carries the upstream error payload only to the awaited / `.safe()` caller — it rides a non-enumerable channel and is **never** written to a trace sink, so an `{ error: "…" }` payload can't leak into a JSONL/console log. If a non-2xx is actually expected control flow (a `404` you fall back from), see [`verdict.accept`](/docs/guides/resilience/verdict) — it returns the body as a normal result instead of throwing. If you read the [event stream](/docs/concepts/event-stream) instead of awaiting, the same failure arrives as an `error` event carrying `{ name, message, status?, attempts, at }` — `attempts` is how many tries the runtime made before giving up, `at` is when it gave up. ## Handle failure without throwing [#handle-failure-without-throwing] `await` and `.unwrap()` throw on failure. When you'd rather branch than wrap the call in `try`/`catch`, `.safe()` consumes the call and never throws — it resolves to a discriminated `{ ok, data, error }`, where `error` is the same `StitchError`, or `null` on success: ```ts twoslash import { stitch } from 'stitchapi'; const me = stitch({ baseUrl: 'https://api.example.com', path: '/me' }); const { data, error } = await me.safe(); if (error) console.error(error.status, error.attempts); else console.log(data); // the validated result; error is null here ``` `.unwrap()` is the explicit throwing twin of `.safe()` — the value, or a thrown `StitchError`, exactly like awaiting the bare call. See [the event stream](/docs/concepts/event-stream) for the iterate-instead-of-await view of the same outcomes. ## Catalog IDs are documentation, not runtime values [#catalog-ids-are-documentation-not-runtime-values] Each catalog page below is filed under a stable **catalog ID** (`STITCH_VALIDATION`, `STITCH_DRIFT`, and so on), and the ID's page **slug is its URL**. Slugs are an API: once a page is published its slug is never renamed, only added-and-redirected, so a link written a year ago still resolves. These IDs name **documentation pages** — they are not values the runtime puts on the error. A thrown `StitchError` has no `.code` property, so `err.code` is `undefined` and `if (err.code === 'STITCH_DRIFT')` never matches. Use the IDs to refer to a failure mode (in this catalog, in the docs MCP, in an issue report); to branch in code, use [the fields below](#telling-failures-apart-at-runtime). ## Telling failures apart at runtime [#telling-failures-apart-at-runtime] Every failure arrives as a `StitchError` except the delegate-backoff rate limit, which is its own exported `RateLimitError`. So narrow with `instanceof` first, then branch on `.status` and `.attempts` — together they separate every failure mode in the catalog: | What you see | What happened | | -------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `attempts === 0` | No request was made — input validation (`status` `undefined`) or an open circuit (`status` `503`). | | `status` is a **2xx** | The response arrived and broke its contract — an `output` schema/drift failure, or GraphQL `errors`. | | `status` is **non-2xx** | The upstream rejected it, after any retries — a `401` auth wall, a `5xx`, and so on. | | `status` `undefined`, `attempts` ≥ `1` | The request went out but never produced a response — a timeout or a transport error. | ```ts twoslash import { RateLimitError, StitchError, stitch } from 'stitchapi'; const report = stitch({ baseUrl: 'https://api.example.com', path: '/report' }); try { await report(); } catch (e) { if (e instanceof RateLimitError) { // The one catalog entry that IS a runtime identity of its own. It // subclasses StitchError, so this arm MUST come before the one below — // otherwise the generic arm swallows it. console.error('back off for', e.retryAfter); } else if (e instanceof StitchError) { if (e.attempts === 0) { // Nothing left the process: bad input, or the breaker is open. console.error(e.status === 503 ? 'circuit open' : 'bad input'); } else if (e.status !== undefined && e.status < 300) { // A 2xx that still failed: the body broke the declared contract. console.error('contract violation', e.status); } else { console.error('upstream failed', e.status ?? 'no response'); } } } ``` `.message` is human-readable, not a contract — today it is `invalid : ` for input validation, `contract violation (drift)` for a response that failed `output`, `timed out after ms`, `circuit open`, `GraphQL: `, or `HTTP `. Match on it only as a last resort, and expect the wording to change. Two catalog IDs share one runtime failure: a plain `output` schema failure (`STITCH_VALIDATION`) and a hard [`drift()`](/docs/guides/validation/drift) failure (`STITCH_DRIFT`) both throw the same `contract violation (drift)` `StitchError`. The thrown error does not say **which** path broke — that detail rides the `drift` events on [the event stream](/docs/concepts/event-stream), where a hard failure appears as a finding with `level: 'error'` and `change: 'invalid'`: ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; const user = stitch({ baseUrl: 'https://api.example.com', path: '/user', output: drift(z.object({ id: z.number() })), }); for await (const ev of user().stream()) { if (ev.type === 'drift' && ev.finding.level === 'error') { // The structured signal: which path broke, and how. console.error(ev.finding.path, ev.finding.change, ev.finding.detail); } } ``` ## Every documented failure [#every-documented-failure] Debugging one of these with an agent? Connect the [docs MCP](/docs/agents/search-over-mcp) and it can pull any error page on demand — `search_docs('STITCH_DRIFT')`, `search_docs('rate limit')` — instead of guessing from stale training data. A response failed its output schema — the shape you asked for isn't the shape you got. A response drifted from the schema past the level you allowed, so the stitch refused it. Authentication failed, or a soft 200 login wall was hit and couldn't be refreshed. A call exceeded its timeout budget before a response arrived. The circuit breaker is open after repeated failures and is short-circuiting calls. A GraphQL-over-HTTP response came back `200` but carried an `errors` array. A delegate-backoff stitch surfaced a rate-limit response for an outer gate to back off on, instead of retrying it. ## See also [#see-also] # RateLimitError (/docs/errors/rate-limit) ## What you'll see [#what-youll-see] A stitch with [`throttle: { delegate: true }`](/docs/guides/resilience/delegate-backoff) that gets a rate-limit response (status in `throttle.on`, default `[429]`) rejects with a **`RateLimitError`** instead of retrying. It is a `StitchError` — a **subclass** — so it carries the usual `status` / `attempts` / `body` / `url` and is caught by a generic `instanceof StitchError` arm, while remaining its own exported class you can catch specifically to hand the signal to whatever owns your backoff: ```ts twoslash import { RateLimitError, stitch } from 'stitchapi'; const fetchPage = stitch({ baseUrl: 'https://api.example.com', path: '/pages/{id}', throttle: { delegate: true }, }); try { await fetchPage({ params: { id: '42' } }); } catch (e) { if (e instanceof RateLimitError) { console.log(e.status); // 429 (the rate-limit status that was hit) console.log(e.retryAfter); // ms parsed from Retry-After, or undefined console.log(e.response.headers); // the raw response, for other rate headers } } ``` `RateLimitError` carries, on top of the inherited `StitchError` fields (`status`, `attempts`, `body`, `url`): * **`retryAfter`** — the wait the server asked for, parsed from `Retry-After` (delta-seconds **or** an HTTP-date) into milliseconds; `undefined` when the header is absent or unparseable. * **`response`** — the raw `AdapterResult`, so you can read other rate headers the API sent (`X-RateLimit-Remaining`, a reset timestamp, and so on). Its `status` is the rate-limit status (e.g. `429`, or whatever you listed in `throttle.on`) — always present, where the base class leaves it optional. **Test `RateLimitError` before `StitchError`.** Because it subclasses `StitchError`, a `catch` whose first arm is `instanceof StitchError` will swallow the rate-limit signal and never reach a later `RateLimitError` arm — which defeats the point of delegating. Put the specific class first, as the example above does. This is the one entry in the [error catalog](/docs/errors) that is a real runtime identity rather than a documentation ID: `RateLimitError` is an exported class you can `instanceof`. The `STITCH_*` entries are page IDs — those failures all arrive as a plain `StitchError` with no `.code`, told apart by [their fields](/docs/errors#telling-failures-apart-at-runtime). ## Why it happens [#why-it-happens] You opted into delegate-backoff because an **outer** gate or circuit — not StitchAPI — owns the rate budget. So when the API returns a rate-limit status, the stitch deliberately does *not* retry it and does *not* pace it on the internal [throttle](/docs/guides/resilience/throttle): it surfaces the outcome so your gate can apply the backoff, persist it, and decide when to release. This is the mode working as designed, not a new failure — the rate limit reached you on purpose. ## How to handle it [#how-to-handle-it] * **Feed it to your outer gate.** Use `retryAfter` (falling back to your own default when it's `undefined`) to set the gate's penalty window, then let the gate gate the next attempt. That's the whole point of delegating. * **Read it off the stream instead.** If you consume [the event stream](/docs/concepts/event-stream) rather than awaiting, the same outcome arrives as an `error` event carrying `status` and `retryAfter` — no `try`/`catch` needed. * **Branch without throwing.** `.safe()` returns the very same `RateLimitError` in `error` — `SafeResult.error` is typed `StitchError`, and this is one, so nothing is downgraded. `instanceof RateLimitError`, `retryAfter`, `body` and `response` all work identically whether you `await` or `.safe()`. * **Reconsider whether to delegate.** If nothing outside StitchAPI actually owns the backoff, you probably want internal handling instead — drop `throttle.delegate` and use [retry](/docs/guides/resilience/retry) + [throttle](/docs/guides/resilience/throttle). Stuck, or debugging with an agent? Ask the [docs MCP](/docs/agents/search-over-mcp) — `search_docs('RateLimitError')` pulls this page and the related guides straight into context. ## Related [#related] # STITCH_AUTH_WALL (/docs/errors/stitch-auth-wall) ## What you'll see [#what-youll-see] A call that depends on a session fails after the runtime has tried to re-authenticate. With [`cookieSession`](/docs/guides/auth/cookie-session) the login is re-run on the configured `refresh` status (default `401`) and the request retried once; when that retry still can't get through, the stitch surfaces the failure instead of looping. The downstream response keeps coming back unauthenticated — most often a `401`, sometimes a `200` whose body is actually the login page. `STITCH_AUTH_WALL` is this page's [catalog ID](/docs/errors), not a runtime value — the thrown error has no `.code`. This failure has no distinct runtime identity of its own: a hard wall surfaces as an ordinary upstream rejection (a `StitchError` with `status: 401` and the message `HTTP 401`), and an **uncaught soft 200 wall does not throw at all** — the login page arrives as a normal result, and usually fails your `output` schema instead. That silent case is the reason for `refresh.when` below. ## Why it happens [#why-it-happens] Two distinct triggers land here: * **The login no longer establishes a session.** Bad or expired credentials, rotated form fields, or a login endpoint that now sets a different cookie than the one you capture — so `cookieSession` replays a cookie the API rejects. * **A soft 200 wall.** The API answers an unauthenticated request with `200` and an HTML login page rather than a `401`. `refresh.on` only matches status codes, so the wall slips past it and the body is treated as a successful response. ## How to fix [#how-to-fix] Confirm the credentials resolve at call time (see [secret resolvers](/docs/guides/auth/secret-resolvers)) and that `cookie` captures the cookie the API actually checks — `'*'` captures the whole jar when you're unsure which one matters. For a soft 200 wall, add `refresh.when` so a login page served with `200` triggers a re-login instead of passing as data: ```ts twoslash import { stitch } from 'stitchapi'; import { cookieSession, env } from 'stitchapi/auth'; const login = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/login', wire: { body: 'form' }, }); const me = stitch({ baseUrl: 'https://api.example.com', path: '/me', auth: cookieSession({ login, cookie: '*', credentialsOf: () => ({ body: { user: env('USER')(), pass: env('PASS')() }, }), // A 200 that is really the login page is a soft wall — re-login on it. refresh: { when: (res) => typeof res.body === 'string' && res.body.includes('id="login"'), }, }), }); ``` Stuck, or debugging with an agent? Ask the [docs MCP](/docs/agents/search-over-mcp) — `search_docs('STITCH_AUTH_WALL')` pulls this page and the related guides straight into context. ## Related [#related] # STITCH_CIRCUIT_OPEN (/docs/errors/stitch-circuit-open) ## What you'll see [#what-youll-see] A stitch configured with a [`circuit`](/docs/guides/resilience/circuit-breaker) fails *immediately* — no request leaves the process — with a `StitchError`, surfaced on the event stream as an `error` event. The dependency itself isn't being hit: the breaker tripped open after repeated failures and is now fast-failing every call to protect it. This is the breaker doing its job, not a fresh failure. `STITCH_CIRCUIT_OPEN` is this page's [catalog ID](/docs/errors), not a runtime value — the thrown error has no `.code`. Recognise a short-circuit by its fields instead: `status === 503` with `attempts === 0`, the combination that means the breaker answered before any request was made (the message is `circuit open`). See [telling failures apart](/docs/errors#telling-failures-apart-at-runtime). ## Why it happens [#why-it-happens] The breaker counts *consecutive* failures. Once it reaches `failures`, it trips **open** and records the moment. While open, every call fast-fails for `cooldown` instead of touching the dependency. Seeing this error means you are inside that cooldown window — the breaker hasn't yet allowed the **half-open** trial that decides whether to close again. The real failures that tripped it happened on earlier calls; this one is just being short-circuited. ## How to fix [#how-to-fix] Most of the time this is working as intended — a failing dependency is being shielded from a stampede. Work through these in order: * **Wait it out.** After `cooldown` the breaker goes half-open and lets a single trial through; a success closes it and traffic resumes. If the dependency has recovered, the next call after the window will succeed. * **Fix the underlying dependency.** The breaker is a symptom, not the cause. Find and fix the real failures — the earlier errors that incremented the count to `failures` — so the half-open trial can close it. * **Tune the thresholds.** Raise `failures` if the breaker trips on transient blips; adjust `cooldown` to control how long it fast-fails before retesting. * **Check the `key`.** Breakers are keyed (default: the stitch/host key). If two unrelated stitches share a `key`, one failing dependency trips the breaker for both — give them distinct keys so they don't share a breaker. Pair the breaker with [retry](/docs/guides/resilience/retry) and [timeout](/docs/guides/resilience/timeout): timeouts and retries absorb the transient failures, and the breaker steps in only once those keep failing. Stuck, or debugging with an agent? Ask the [docs MCP](/docs/agents/search-over-mcp) — `search_docs('STITCH_CIRCUIT_OPEN')` pulls this page and the related guides straight into context. ## Related [#related] # STITCH_DRIFT (/docs/errors/stitch-drift) ## What you will see [#what-you-will-see] A call fails during validation with a `drift` finding that carries `level: 'error'` and `change: 'invalid'`. The finding names the offending `path`, and the call throws a `StitchError` instead of returning a result. `STITCH_DRIFT` is this page's [catalog ID](/docs/errors), not a runtime value — the thrown error has no `.code`. At runtime this failure is a `StitchError` with the message `contract violation (drift)` and the upstream `status` (a 2xx: the response arrived, the contract broke). The offending `path` is on the `drift` event, not on the error — see [telling failures apart](/docs/errors#telling-failures-apart-at-runtime). Soft drift findings — `undeclared` (`info`), `coerced` (`warn`), `defaulted` (`verbose`) — stream as `drift` events but do **not** fail the call. Only a hard validation failure (`change: 'invalid'`, `level: 'error'`) breaks the contract. ## Why it happens [#why-it-happens] Drift validates the live response against the declared `output` schema (the consumer contract). A **required field was missing** or the **value was incompatible** with its declared type — the schema could not produce a valid result. Because the field is required, this is a hard contract break: the engine throws rather than returning a value that does not match the declared TypeScript type. Soft drift (`undeclared`, `coerced`, `defaulted`) is always non-fatal. Fatality lives entirely in the schema: a required field whose absence or type mismatch throws; an optional field whose absence is silently tolerated. ## How to fix [#how-to-fix] Read the finding's `path` to see exactly what moved, then decide which case you are in: * **The change is intended.** The upstream shape genuinely changed and you want the new shape. Update the `output` schema to match the new type or mark the field optional, and the error stops. * **It is a vendor regression.** You did not expect this change — that is the signal drift exists to give you. Treat the thrown error as the alert and act on the upstream break. If a field should be tolerated when absent, declare it `.optional()` in the schema. Validation will then succeed, and drift may emit a soft `defaulted` finding if the schema has a `.default()` — but the call will not throw. ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; const user = stitch({ baseUrl: 'https://api.example.com', path: '/user', output: drift( z.object({ // `id` is required — its absence or type mismatch throws. id: z.number(), // `displayName` is optional — its absence is tolerated, no error thrown. displayName: z.string().optional(), }), ), }); ``` See the [drift guide](/docs/guides/validation/drift) for how the schema's required/optional structure maps to finding levels, and how `level` and `ignore` filter soft findings. Stuck, or debugging with an agent? Ask the [docs MCP](/docs/agents/search-over-mcp) — `search_docs('STITCH_DRIFT')` pulls this page and the related guides straight into context. ## Related [#related] # STITCH_GRAPHQL (/docs/errors/stitch-graphql) ## What you'll see [#what-youll-see] A stitch built with [`graphql()`](/docs/guides/data/graphql) throws a `StitchError` even though the HTTP status was `200`. The response body carried a non-empty top-level `errors` array, so the stitch refused to pick `data` and surfaced the failure instead. The thrown error carries the GraphQL message(s), prefixed `GraphQL:` and joined with `; ` when there is more than one: ```ts twoslash import { StitchError, graphql } from 'stitchapi'; const user = graphql({ baseUrl: 'https://api.example.com', path: '/graphql', document: `query ($id: ID!) { user(id: $id) { name } }`, }); try { await user({ variables: { id: '42' } }); } catch (err) { if (err instanceof StitchError) { // err.message === 'GraphQL: Not authorized to view user 42.' console.error(err.message); } } ``` `STITCH_GRAPHQL` is this page's [catalog ID](/docs/errors), not a runtime value — the thrown error has no `.code`. Recognise this failure by its shape: a `StitchError` whose `status` is `200` (a success at the transport layer) with a message prefixed `GraphQL:`. See [telling failures apart](/docs/errors#telling-failures-apart-at-runtime). ## Why it happens [#why-it-happens] GraphQL-over-HTTP commonly answers with HTTP `200` even when the operation failed, reporting the failure in a top-level `errors` array rather than via the status line. A `graphql()` stitch treats a `200` that carries a non-empty `errors` array as a failure instead of silently picking `data` — so a request that "looked successful" at the transport layer still throws. The entries in `errors` come from the GraphQL server: a malformed query or missing variable, a resolver that threw, or an authorization check the server enforced in-band. Whatever the cause, the server signalled it in the body, and the stitch honours that signal. ## How to fix [#how-to-fix] Read the GraphQL message(s) on the thrown `StitchError` — they are the server's own description of what went wrong — and fix the cause: * **Bad query or variables.** Correct the operation string or the `variables` you pass so they satisfy the schema (right field names, required variables provided, correct types). * **Resolver error.** The fault is server-side; reproduce the operation against the GraphQL server and fix it there. * **Authorization.** The server rejected the operation in the `errors` array, not with a status code — supply the credential or capability the operation requires. Remember that GraphQL signals failure in the response body, not through the HTTP status: a `200` is not proof of success. See the [GraphQL guide](/docs/guides/data/graphql) for how `graphql()` posts the operation and picks `data` on the success path. Stuck, or debugging with an agent? Ask the [docs MCP](/docs/agents/search-over-mcp) — `search_docs('STITCH_GRAPHQL')` pulls this page and the related guides straight into context. ## Related [#related] # STITCH_TIMEOUT (/docs/errors/stitch-timeout) ## What you'll see [#what-youll-see] A call aborts and throws a `StitchError` once a configured timeout elapses. `timeout.total` bounds the whole call across every retry and its backoff; `timeout.each` bounds a single attempt. The runtime enforces these with a real `AbortSignal`, so the in-flight request is actually aborted rather than left running while the call gives up — the underlying request receives the abort. `STITCH_TIMEOUT` is this page's [catalog ID](/docs/errors), not a runtime value — the thrown error has no `.code`. A timed-out call is a `StitchError` with **no** `status` and `attempts` of at least `1`: the request went out, but no response ever came back (the message is `timed out after ms`). See [telling failures apart](/docs/errors#telling-failures-apart-at-runtime). ## Why it happens [#why-it-happens] Two distinct triggers land here: * **The dependency is slow or unreachable.** The request genuinely takes longer than the budget you allowed — a degraded upstream, a cold start, or a host that never answers and ties up the attempt until the signal fires. * **The budget is too tight.** `each` is shorter than the dependency's normal latency, so healthy responses get cut off; or `total` doesn't leave room for every attempt plus the backoff between them, so the whole call aborts mid- way through a retry sequence that would otherwise have succeeded. ## How to fix [#how-to-fix] Raise `total` and `each` to a realistic budget for the dependency's actual latency. When you also configure [retry](/docs/guides/resilience/retry), make `total` large enough to cover the full sequence — as a rule of thumb, `total ≥ attempts × (each + backoff)` — so the call isn't aborted partway through a retry that would have landed. ```ts twoslash import { stitch } from 'stitchapi'; const report = stitch({ baseUrl: 'https://api.example.com', path: '/reports/slow', retry: { attempts: 3, backoff: { base: 200 } }, timeout: { // Each attempt gets 5s; the whole call (3 attempts + backoff) gets 20s. each: '5s', total: '20s', }, }); ``` Check the dependency's measured latency before widening the budget — if it is consistently slow or flapping, a longer timeout only delays the failure. For a dependency that is genuinely failing, pair the timeout with [retry](/docs/guides/resilience/retry) for transient blips and a [circuit breaker](/docs/guides/resilience/circuit-breaker) to fast-fail once it stops responding, instead of waiting out the timeout on every call. Stuck, or debugging with an agent? Ask the [docs MCP](/docs/agents/search-over-mcp) — `search_docs('STITCH_TIMEOUT')` pulls this page and the related guides straight into context. ## Related [#related] # STITCH_VALIDATION (/docs/errors/stitch-validation) ## What you'll see [#what-youll-see] A stitch with an `input` or `output` schema throws a `StitchError`. Where it throws tells you which side failed — and the two sides report very differently: * **Input, before the request.** When call input doesn't match the `input` schema, the stitch fails fast — the error is thrown *before* any network call, so nothing leaves the process. The message names the failing slot and issue (`invalid body: name is required`), and the error carries `attempts: 0` with no `status`. This is the common case for a malformed `params`, `query`, `body`, or `headers`. * **Response, after the request.** When the response doesn't match `output`, the error is thrown after the response returns and has been transformed and picked — the request did go out. The message is the generic `contract violation (drift)` and the `status` is the upstream one (a 2xx: the response arrived, the contract broke). **The failing path is not on the error** — it rides the `drift` event on [the event stream](/docs/concepts/event-stream), as a finding with `level: 'error'` and `change: 'invalid'`. `STITCH_VALIDATION` is this page's [catalog ID](/docs/errors), not a runtime value — the thrown error has no `.code`. Note too that the response side shares its runtime failure with a hard [`drift()`](/docs/guides/validation/drift) breach: both throw the same `contract violation (drift)` error, so `STITCH_VALIDATION` and `STITCH_DRIFT` name one failure at runtime, not two. See [telling failures apart](/docs/errors#telling-failures-apart-at-runtime). ## Why it happens [#why-it-happens] The value didn't satisfy the schema you adapted onto the config: * **Input didn't match `input`.** A required field is missing, a field has the wrong type, or an extra/renamed field tripped a strict schema — checked against `{ params, query, body, headers }` before the request. * **Response didn't match `output`.** A real contract mismatch: the API dropped a field you require, returned a field with the wrong type, or changed a shape your `output` schema declares. The validator reports each issue with its path. ## How to fix [#how-to-fix] Find the failing path, then check it against the schema: * **If input failed,** the path is in the message — fix the call input, or relax the `input` schema if the field really is optional. The request never went out, so there's nothing to retry once the input is correct. * **If the response failed,** read the path off the `drift` event rather than the message, then decide whether the schema or the API is wrong. Update the `output` schema to match the real payload, or fix the expectation if the API contract genuinely changed. For a response whose shape shifts in noisy-but-nonbreaking ways, don't reach for hard validation — a single new optional field shouldn't throw. Pass a [`drift()`](/docs/guides/validation/drift) spec to `output` instead, which reports leveled findings without failing the call. Assign the schema straight to `input`/`output` — a Zod schema, any Standard Schema (Valibot, ArkType), or a plain predicate goes on the config as written, and the engine adapts it internally: ```ts twoslash import { stitch } from 'stitchapi'; import { z } from 'zod'; const createUser = stitch<{ id: number; name: string }>({ method: 'POST', baseUrl: 'https://api.example.com', path: '/users', // Validated before the POST is sent — bad input fails fast. input: { body: z.object({ name: z.string() }) }, // Validated after the response returns. output: z.object({ id: z.number(), name: z.string() }), }); ``` See [Bring your own validator](/docs/guides/validation/standard-schema) for adapting non-Zod schemas, and [Validation](/docs/guides/validation/validation) for how `input` and `output` are checked. Stuck, or debugging with an agent? Ask the [docs MCP](/docs/agents/search-over-mcp) — `search_docs('STITCH_VALIDATION')` pulls this page and the related guides straight into context. ## Related [#related] # Migration notes (/docs/getting-started/migration-notes) When you port an existing integration onto a stitch, most things just work. A few do not *look* like they work, because StitchAPI emits bytes (and infers types) that are correct by the relevant spec but differ from the baseline a hand-rolled wrapper or a `URLSearchParams` snapshot produced. The mismatch is in your fixture, not the call — so you chase a phantom failure. This page collects the three that bite during a migration: lowercase header names, `%20` for spaces in the query string, and the precise type a template-narrowed stitch infers. Each section is the same shape — what you'd expect, what a stitch emits, why it's conformant, and the one-line adjustment to your fixture or field type. It opens with two items of a different kind — not conformance surprises but deliberate pre-GA breaks: the auth strategies moved to their own import, and the four secret resolvers folded into two namespaces. The running example is a generic catalog provider at `api.example.com` — a media server exposing items behind an API key. ## Auth strategies import from `stitchapi/auth` [#auth-strategies-import-from-stitchapiauth] You upgrade, and every file that authenticates stops compiling: ``` Module '"stitchapi"' has no exported member 'bearer'. ``` The five strategy factories (`bearer`, `apiKey`, `basic`, `oauth2`, `cookieSession`) and the secret resolvers (`env`, with `env.optional`, and the `credential` namespace) now live on the `stitchapi/auth` subpath. Split the import in two: ```ts twoslash // @noErrors // before import { bearer, env, stitch } from 'stitchapi'; ``` ```ts twoslash // after import { stitch } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; ``` Nothing else changes — same factories, same options, same wire behaviour. Only the module they come from. **Why.** `auth` is the one config slot whose values cost real bytes to build: `oauth2` carries a token cache and a refresh state machine, `cookieSession` a login flow and a cookie jar. On the root barrel those rode along in the advertised entry size for everyone, including projects with no `auth` at all. Behind their own import, "you only pay for the strategy you use" stops depending on your bundler's tree-shaking and becomes a fact about the module graph — it holds for CommonJS and for a naive bundler too. The whole entry drops from \~25 kB to \~23 kB min+gzip; `import { stitch }` is unchanged. There is no compatibility re-export. Re-exporting from the root would put the factories back in the barrel and undo the entire change, and StitchAPI is still pre-GA (`1.0.0-rc`), where the alias obligation does not yet apply. The break is a compile error at the import line, not a runtime surprise. A custom strategy is unaffected in kind — `AuthStrategy`, `AuthContext` and `SecurityScheme` are still exported from `stitchapi` (they type `StitchConfig.auth`), and `stitchapi/auth` re-exports them so authoring one takes a single import. ## The four secret resolvers are two namespaces [#the-four-secret-resolvers-are-two-namespaces] Three of the four resolver names are gone from `stitchapi/auth`: ``` Module '"stitchapi/auth"' has no exported member 'secretsFile'. ``` They fold into two namespaces on the same subpath: | Was | Now | | -------------------------- | ------------------------------- | | `env(name)` | `env(name)` — unchanged | | `optionalEnv(name)` | `env.optional(name)` | | `secretsFile(name)` | `credential.file(name)` | | `secretFrom(source, name)` | `credential.from(source, name)` | ```ts twoslash // @noErrors // before import { optionalEnv, secretFrom, secretsFile } from 'stitchapi/auth'; ``` ```ts twoslash // after import { credential, env } from 'stitchapi/auth'; ``` Nothing else changes — same resolution order, same empty-value rejection, same `OptionalSecret` brand, same thunk handed to every strategy. Only the spelling. **Why two and not one.** These four were never a single cluster. `env` and `optionalEnv` name the **same source** and differ only in requiredness, so requiredness becomes a modifier on one callable name: `env('NAME')` throws when the variable is unset, `env.optional('NAME')` resolves to *absent* instead. `secretsFile` and `secretFrom` differ by **source**, so the source is named at the call site on a namespace that groups the ones that are not the process environment. Two dimensions, so two names — the same shape as the `duration` / `size` / `rate` token grammars and the root's `secrets` namespace. The noun is `credential`, not `secrets`, deliberately: `secrets` is already the root barrel's trace-redaction namespace (`secrets.register` / `has` / `redact`). Reusing it here would put two different objects behind one word on two entry points, which is the one-word-one-concept rule this project holds itself to. There are no aliases and no deprecation shims. StitchAPI is still pre-GA (`1.0.0-rc`), and keeping the old spellings would leave two ways to write one call on the subpath — the thing being removed. The break is a compile error at the import line, not a runtime surprise. ## Header names go on the wire lowercase [#header-names-go-on-the-wire-lowercase] You configure `apiKey({ name: 'X-Catalog-Token', ... })` and your fixture asserts the request carried a header literally named `X-Catalog-Token`. A stitch lowercases the header name: it sends `x-catalog-token`. The default `x-api-key` is already lowercase; a custom name is lowercased too. The same holds for the headers on an `oauth2` token request. ```ts twoslash import { stitch } from 'stitchapi'; import { apiKey, env } from 'stitchapi/auth'; const getItem = stitch({ baseUrl: 'https://api.example.com', path: '/items/{id}', auth: apiKey({ name: 'X-Catalog-Token', secret: env('CATALOG_TOKEN') }), }); // On the wire, the request header is `x-catalog-token`, not `X-Catalog-Token`. ``` **Why it's conformant.** HTTP header field names are case-insensitive ([RFC 9110 §5.1](https://datatracker.ietf.org/doc/html/rfc9110#section-5.1)), so a server compares them case-insensitively and accepts `x-catalog-token` everywhere it would accept `X-Catalog-Token`. Lowercase is also the canonical on-the-wire form in HTTP/2 and HTTP/3, where field names are required to be lowercase. Emitting lowercase is the deliberate, portable choice. **The adjustment.** A fixture that does a case-sensitive string compare on the header name is the thing to fix — assert against the lowercase name, or compare case-insensitively. If you key a header map, lowercase the key before you look it up. Don't try to force the original casing back; there's no option to, by design. ## Spaces in the query string are `%20`, not `+` [#spaces-in-the-query-string-are-20-not-] You search for `the matrix` and your snapshot — captured from `new URLSearchParams({ q: 'the matrix' }).toString()` or an `application/x-www-form-urlencoded` baseline — records `q=the+matrix`. A stitch encodes query values with `encodeURIComponent`, so a space becomes `%20`: it sends `q=the%20matrix`. ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch('https://api.example.com/items'); await search({ query: { q: 'the matrix' } }); // → GET https://api.example.com/items?q=the%20matrix // (a URLSearchParams baseline would have written `q=the+matrix`) ``` **Why it's conformant.** `+`-for-space is specific to the `application/x-www-form-urlencoded` serialization; it is **not** a generic URI rule. In a generic URI query a literal `+` is a valid character, and `%20` is the percent-encoding of a space under [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-2.1). Every conformant server decodes **both** `%20` and `+` back to a single space in the query component, so the two forms are interchangeable on the receiving end — `encodeURIComponent` simply picks the unambiguous one. **The adjustment.** Re-record the byte-for-byte snapshot against the `%20` form, or normalize before comparing — decode the query (or replace `+` with `%20`) so the assertion compares decoded values rather than encoded bytes. The request the server receives is unchanged. ## A template-narrowed stitch won't drop into a uniform `Stitch` field [#a-template-narrowed-stitch-wont-drop-into-a-uniform-stitch-field] `stitch()` reads the [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) path-template variables off a string-literal `path`/`url` and **requires the matching `params` at the call site**. That is the feature — `getItem()` won't typecheck without `params.id`: ```ts twoslash import { stitch } from 'stitchapi'; const getItem = stitch({ path: '/items/{id}' }); // ^? await getItem({ params: { id: 1 } }); // `params.id` is required and type-checked ``` The cost shows up when you collect heterogeneous stitches in one container — a `Map`, a class field typed `Stitch`, a registry. The narrowed type carries its specific `params` shape, and that shape is **not assignable** to the loose, uniform `Stitch`: ```ts twoslash // @errors: 2345 import { type Stitch, stitch } from 'stitchapi'; const getItem = stitch({ path: '/items/{id}' }); const registry = new Map(); registry.set('item', getItem); // the narrowed params shape rejects the uniform field ``` **Why it's conformant.** This is ordinary TypeScript: the second type parameter of `Stitch` defaults to the loose call input, and a stitch with a required `params` slot narrows `TIn` to a more specific shape. A more specific type is not assignable to the default one — the narrowing that makes the call site safe is exactly what the uniform field rejects. **The adjustment.** Pick the trade-off you want: ```ts twoslash import { type Stitch, stitch } from 'stitchapi'; // Heterogeneous collection — opt back into the loose, uniform type with `as Stitch`. // You give up call-site param checking for the entries stored this way. const registry = new Map(); registry.set('item', stitch({ path: '/items/{id}' }) as Stitch); // Single field you want to keep precise — annotate it with the narrowed type and you keep // the required-params check (path vars stringify to `string | number | bigint`). class Catalog { getItem: Stitch = stitch({ path: '/items/{id}' }); } ``` Use `as Stitch` when you deliberately want a uniform collection of mixed stitches; annotate the field with the precise type when the stitch is singular and you want to keep the call-site guarantee. ## See also [#see-also] # Authenticate without the token touching your code (/docs/recipes/auth-as-a-capability) ## Task [#task] You want every call to an API authenticated, without the token ever living in your application code — where most clients would have you read it into a variable and thread it through every call, into your logs, your traces, and anything you hand to an agent. ## Example [#example] One way: declare the auth on the stitch itself. Callers get a callable; the secret stays behind it. ```ts twoslash import { stitch } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; const getInvoice = stitch({ baseUrl: 'https://api.example.com', path: '/invoices/{id}', auth: bearer(env('API_TOKEN')), }); // The caller invokes and gets data — no token in sight. const invoice = await getInvoice({ params: { id: 42 } }); ``` ## How it works [#how-it-works] `env('API_TOKEN')` is a resolver, not a value — nothing is read when you declare the stitch. The runtime invokes it on each request, attaches the token, and discards it; the caller receives data and never the secret. The declaration holds a *reference* to where the secret lives, so it is safe to commit, diff, and share. Swap `bearer` for [`apiKey`](/docs/guides/auth/api-key), [`basic`](/docs/guides/auth/basic), or [`oauth2`](/docs/guides/auth/oauth2) — the boundary is the same, and the strategies with a session refresh themselves behind it. Because the token never crosses back to the caller, you can hand the stitch to an [agent](/docs/agents) and it can make the authenticated call without ever holding the credential. ## See also [#see-also] # Cancel an in-flight call (/docs/recipes/cancel-an-in-flight-call) ## Task [#task] The user typed a new search, closed the dialog, or navigated away — and the call you started for them is still running. You want the stale call actually cancelled: connection dropped, retries stopped, rate-limit slot released. Not ignored while it quietly burns all three. ## Example [#example] One way: hand the call an `AbortController`'s signal, and abort the old call before starting the next one. ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', retry: { attempts: 3, on: [429, 503], backoff: 'expo-jitter' }, }); let inFlight: AbortController | undefined; async function onKeystroke(q: string) { inFlight?.abort(); // drop the stale call — request and retries both stop const controller = new AbortController(); inFlight = controller; try { return await search({ query: { q }, signal: controller.signal }); } catch (err) { if (controller.signal.aborted) return undefined; // cancelled on purpose — not a failure throw err; } } ``` ## How it works [#how-it-works] There is no cancel callback to register — cancellation is the platform's own primitive, threaded through every layer you declared. `signal` rides the call input next to `params` and `body`, and the stitch mirrors it onto each attempt's transport, linked with the [per-attempt timeout](/docs/guides/resilience/timeout). `abort()` severs the in-flight request itself — the connection closes; the response is not silently discarded. The same abort cuts every wait around the request: a [retry](/docs/guides/resilience/retry)'s backoff sleep and a throttle's rate-spacing pause reject promptly, and on a [paginated call](/docs/recipes/paginate-into-one-array) the signal carries onto every follow-up page, so the loop stops wherever it is. The cancelled call settles with a rejection. To tell a deliberate cancel from a real failure, check your own `controller.signal.aborted` in the catch — it is unambiguous and does not depend on the error's shape. Keep the two tools distinct: a deadline you can declare (`timeout`) belongs on the stitch, so reach for `signal` only for caller-driven cancellation — a user gesture, a component unmounting, a shutdown. The framework hooks are built on this same signal: [React's](/docs/integrations/react) `useStitch` returns an imperative `cancel()`, and [Angular's](/docs/integrations/angular) `injectStitch` aborts the in-flight run on its own when the input changes. ## See also [#see-also] # Catch a breaking API change before your users do (/docs/recipes/catch-a-breaking-api-change) ## Task [#task] A vendor can rename or drop a field without telling you, and a plain type check will not notice until something breaks in production. You want a hard failure the moment a field you depend on disappears, and soft notifications for other structural changes — without committing a snapshot or maintaining a baseline. ## Example [#example] Wrap the `output` schema with `drift`. The schema *is* the contract. Declare the fields you depend on as **required** — their disappearance will throw immediately. ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: drift( // `id` and `email` are required: if either disappears the call throws. z.object({ id: z.number(), email: z.string() }), ), }); const user = await getUser({ params: { id: 1 } }); ``` ## How it works [#how-it-works] Every call validates the response against the schema first. If a required field is missing or has an incompatible value, validation throws [`STITCH_DRIFT`](/docs/errors/stitch-drift) immediately — a `drift` finding with `change: 'invalid'` and `level: 'error'`. No soft-level configuration is needed to make this fatal; making a field required in the schema is sufficient. On a clean validation, drift diffs the raw body against the validated value and emits any difference as a non-fatal `drift` event on the [event stream](/docs/concepts/event-stream): * `undeclared` (`info` by default) — a key the schema stripped; the API added something you do not model. * `coerced` (`warn` by default) — a value the schema coerced, e.g. `"42"` → `42`; a wire-type shift that validation hides. * `defaulted` (`verbose` by default) — a `.default()` fired because the field was absent. These non-fatal findings do not throw — they ride the event stream so you can [watch a field erode](/docs/recipes/watch-a-call-as-it-happens) before it becomes a hard failure. Declared variance (an optional field absent, a nullable null, an empty array) validates clean and produces no finding. Drift sits at the opposite end from plain [validation](/docs/guides/validation/validation), which rejects one malformed response hard, here and now. The call returns the **validated value** — defaults applied, unknown keys stripped, types coerced — so the result matches the declared TypeScript type exactly. ## See also [#see-also] # Catch a silent selector rename in scraped HTML (/docs/recipes/catch-a-selector-rename) ## Task [#task] You scrape a page that has no API — a catalog provider returns HTML, and you parse it into a structured object with a selector. The danger is silent: the provider renames a CSS class or moves a cell, your selector quietly stops matching, and the field comes back `undefined`. Nothing throws; downstream code keeps running on corrupted data until someone notices a ranking is wrong. You want that markup rename to surface the moment it happens, as a loud error on the **structured** shape — not the raw HTML. ## Example [#example] The response pipeline is `transform → pick → validation`, so `transform` reshapes the HTML into a structured object **before** drift ever runs — drift then validates the *parsed* value, not the raw markup. Declare `score` as **required** in the schema: its loss is the breakage you want caught. ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; // A trivial hand-rolled scraper: the `score` selector keys off `td.score` — // exactly the class a markup rename silently breaks. function scrape(html: unknown): { items: Record[] } { const text = String(html); const rows = text.split(/]*class="row1"[^>]*>/i).slice(1); const items = rows.map((row) => { const item: Record = {}; const title = /]*class="title"[^>]*>([^<]+)<\/td>/i.exec(row); const score = /]*class="score"[^>]*>\s*(\d+)\s*<\/td>/i.exec( row, )?.[1]; if (title) item['title'] = title[1]!.trim(); if (score !== undefined) item['score'] = Number(score); // omitted when the selector misses return item; }); return { items }; } const catalog = stitch({ baseUrl: 'https://api.example.com', path: '/catalog', transform: scrape, // HTML string -> { items: [...] } pick: 'items', output: drift( // `score` is REQUIRED — its loss is the breakage we want caught. // A required field that goes missing throws (`STITCH_DRIFT`, `change: 'invalid'`). z.array(z.object({ title: z.string(), score: z.number() })), ), }); const items = await catalog(); ``` ## How it works [#how-it-works] Each call parses the page and validates the **transformed** shape — `[{ title, score }]`, not the HTML body — against the schema. No baseline to generate or commit: the schema *is* the contract. When the provider renames the score cell's class (`score` → `rank`), the selector stops matching and `scrape` drops `score` from each item. Because the schema requires `score`, validation sees a missing required field and throws [`STITCH_DRIFT`](/docs/errors/stitch-drift) — a finding with `{ level: 'error', change: 'invalid', path: '[].score' }`. The path is the proof: `[].score` exists only on the parsed object, so a finding on it can only come from drift inspecting the `transform` output, not the raw markup. The silent `undefined` is now a loud, immediate failure. Other non-fatal soft-drift findings (`undeclared`, `coerced`, `defaulted`) ride the [event stream](/docs/concepts/event-stream) without throwing — you can [watch them](/docs/recipes/watch-a-call-as-it-happens) before any becomes a hard failure. Declaring `score` **required** is the whole trick. Drift honors the schema's optionality: if `score` were `.optional()`, a response without it would validate clean and the rename would slip past — which is correct for a field that genuinely may be absent. Make a field required precisely when its disappearance is a bug. **Anti-pattern:** do not drift the raw HTML body and skip `transform`. Snapshotting the markup makes every cosmetic edit — a reordered attribute, a whitespace change — look like drift, burying the one rename that matters. Parse to the structured shape first, then rely on required fields in the schema to catch the breakage you actually depend on. ## See also [#see-also] # Define an entity once, derive every request shape (/docs/recipes/define-an-entity-once) ## Task [#task] You have a resource — a `User` — with the usual CRUD endpoints. You want to describe its shape **once** and derive the create body (no server-assigned fields), the update body (every field optional), and a query filter from that one definition — without retyping fields and without learning a StitchAPI-specific schema type. ## Example [#example] Your validator already does this. Define the entity with your schema library, then derive the request shapes with its own composition methods and hand each one straight to a stitch's [`input`/`output`](/docs/guides/validation/validation). ```ts twoslash import { stitch } from 'stitchapi'; import { z } from 'zod'; // Define the entity once — one source for the runtime schema and the TS type. const User = z.object({ id: z.number(), name: z.string(), email: z.string(), createdAt: z.string(), }); type User = z.infer; // Derive request shapes with the validator's own methods — not a StitchAPI layer. const NewUser = User.omit({ id: true, createdAt: true }); // drop server-assigned fields const UserPatch = NewUser.partial(); // every field optional const UserFilter = User.pick({ email: true }).partial(); // optional ?email= filter const baseUrl = 'https://api.example.com'; const listUsers = stitch({ method: 'GET', baseUrl, path: '/users', input: { query: UserFilter }, output: z.array(User), }); const createUser = stitch({ method: 'POST', baseUrl, path: '/users', input: { body: NewUser }, output: User, }); const updateUser = stitch({ method: 'PATCH', baseUrl, path: '/users/{id}', input: { body: UserPatch }, output: User, }); ``` `NewUser`, `UserPatch`, and `UserFilter` all trace back to the one `User` schema — rename a field there and every derived shape, every stitch, and the `User` type move with it. ## How it works [#how-it-works] `.omit()`, `.partial()`, and `.pick()` are **your validator's** methods, not StitchAPI's. The runtime never sees them — it only ever calls `validate()` through the [`Validator`](/docs/reference/config-types) the engine adapts each schema into, so each derived schema is just another schema as far as a stitch is concerned. One `z.object({...})` definition then feeds three things at once: the runtime validation on every slot (`query`, `body`, `output`), and — via `z.infer` — the `User` TS type the `stitch` generic uses. This is **not** StitchAPI-specific. Valibot, ArkType, and Effect Schema each ship their own composition (`v.partial`, `.pick`, `.omit`, `.and`); the recipe is identical, only the method names change. Any [Standard Schema](/docs/guides/validation/standard-schema) validator passes to a stitch the same way. **Why there's no `pattern` primitive.** StitchAPI deliberately does *not* ship its own schema type with `.partial()`/`.pick()`/`.omit()` — that would re-implement a validator inside a validator-agnostic core. The schema library you already use owns composition; StitchAPI consumes the result. The full reasoning is recorded in [ADR 0011](https://github.com/rejifald/StitchAPI/blob/main/docs/adr/0011-no-pattern-primitive-schema-reuse-is-the-validators-job.md) — schema reuse is the validator's job. ## See also [#see-also] # Let an agent call your API without handing it the key (/docs/recipes/expose-a-stitch-to-an-agent) ## Task [#task] You want an LLM agent to call your APIs, but you cannot give it the API key — and you do not want to flood its context with one tool per endpoint. ## Example [#example] One way: author named stitches that hold their own auth, then serve them over MCP. ```ts twoslash // stitches.ts — author each stitch with its auth and a name. import { stitch } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; export const getUser = stitch({ name: 'getUser', baseUrl: 'https://api.example.com', path: '/users/{id}', auth: bearer(env('API_TOKEN')), }); ``` Serve the module over MCP from the shell: ```bash stitch mcp --module ./stitches.ts ``` The agent discovers names with `list_stitches`, then calls one through the single `run_stitch` tool: ```json { "name": "run_stitch", "input": { "name": "getUser", "input": { "params": { "id": 1 } } } } ``` ## How it works [#how-it-works] `run_stitch` covers **every** registered stitch with one tool — `name` selects it, `input` is its `{ params, query, body, headers }` — so the agent's tool list stays at two entries (`run_stitch` + `list_stitches`) no matter how many stitches you register, keeping its context flat. The auth runs inside the runtime: the agent names a [capability](/docs/concepts/capability-not-credential), the server holds the `bearer(env('API_TOKEN'))` secret, and the result returns without the token. To run the server in your own process, import `serveStdio` from the `stitchapi/mcp` subpath. ## See also [#see-also] # Retry a write without double-charging (/docs/recipes/idempotent-writes) ## Task [#task] You want to retry a `POST` that creates a charge or an order — but a retry that actually succeeded server-side the first time would double-charge. You need the server to recognize the replay as the same logical write. ## Example [#example] One way: pair `retry` with an `idempotency` key derived from the request. ```ts twoslash import { stitch } from 'stitchapi'; const createOrder = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/orders', retry: { attempts: 3, on: [429, 503] }, idempotency: { // body is unknown, so narrow it before reading a property. keyOf: (input) => `order-${(input.body as { ref: string }).ref}`, }, }); const order = await createOrder({ body: { ref: 'inv-1001' } }); ``` ## How it works [#how-it-works] Each logical call carries a stable `Idempotency-Key` header. When [`retry`](/docs/guides/resilience/retry) replays the write, the server sees the same key and dedupes the side effect instead of running it twice. The default key is a random uuid generated once per call — already retry-safe; `keyOf` derives a stable value from the [input](/docs/reference/config-types) (here the order ref) so the same logical write always maps to the same key — which collapses two separate submissions (a double-click) even with no retry in play. A derived `keyOf` must be stable for a given logical call and unique across distinct ones — colliding keys make the server dedupe two different writes into one. ## See also [#see-also] # Recipes (/docs/recipes) A recipe is a worked example of a common task — the whole thing in one block, runnable as a starting point and one way to get the job done, not the only way. Where a [guide](/docs/guides/authoring/stitch) explains one feature, a recipe shows several working together — then links down to the guides when you want the detail. Reach for a recipe when you know the task and want somewhere to start. Reach for a guide when you want to understand a feature on its own. ## Start here [#start-here] ## Stay resilient [#stay-resilient] ## Validate & observe [#validate--observe] ## Run at scale [#run-at-scale] ## Authenticate [#authenticate] ## Agents & surfaces [#agents--surfaces] These assume the runtime is installed and you can declare a stitch. New to it? Start at the [Quickstart](/docs/getting-started/quickstart). # Inspect what the server actually sent (/docs/recipes/inspect-what-the-server-sent) ## Task [#task] The schema coerced a field, filled a default, or stripped a key — and now you want to see what the server *actually* sent. An `await getUser(...)` hands back only the validated value; the raw body and the soft drift findings rode the [event stream](/docs/concepts/event-stream) and are gone. You want both, on the resolved result, without consuming a stream and without throwing when the response breaks the contract. ## Example [#example] `.inspect()` runs one fresh call and resolves to an `Inspection` — `{ value, raw, findings, status, error }`. It never throws: a hard contract violation comes back as `value: null` with `error` set, and `raw` / `findings` / `status` still populated. ```ts twoslash // [!code fold:start] import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', pick: 'data', output: drift( z.object({ id: z.number(), name: z.string(), role: z.string() }), ), }); // [!code fold:end] const result = await getUser.inspect({ params: { id: 1 } }); result.data; // the validated User (coerced/defaulted/stripped) — or null on a hard fail result.findings; // every drift finding: undeclared, coerced, defaulted, invalid result.status; // the HTTP status, so `raw` is interpretable (a 422 body reads unlike a 200) result.error; // a StitchError on a contract violation, else null — value/error are inverse // `raw` is the pre-validation body the findings are diffed against. Reach for it // deliberately (see the caveat below); here, only when something actually drifted. if (result.findings.length > 0) { console.log('server sent:', result.raw); } ``` `value` is the same validated value `await getUser(...)` returns; `raw` is the body *before* validation, the coordinate space each `finding.path` is anchored to. The pair is self-explaining: a `coerced` finding at `role` plus `result.raw.role` tells you exactly which wire value the schema rewrote. ## How it works [#how-it-works] `.inspect()` consumes one run with the raw body retained and surfaces it on a **non-enumerable** field. The other four fields are ordinary properties; `raw` alone is hidden, so it cannot leak into a log by accident. **Anti-pattern:** don't log the whole wrapper — `raw` is **unredacted**, so a stray token or PII in an undeclared field would land in your logs. Because `raw` is non-enumerable, `JSON.stringify(result)`, `{...result}`, and trace sinks all skip it; that protection only holds if you don't defeat it. Read `result.raw` deliberately, at the one place you mean to. **`.inspect()` always hits the network.** It is a fresh probe — it bypasses the cache by default, reading and writing neither — so it is *not* an observer of what your cached `await` call did. Pass `{ cache: true }` to honour the cache policy instead; on a cache hit the entry stores only `{ value, status }`, so `raw` is then `null`. ```ts twoslash // [!code fold:start] import { stitch } from 'stitchapi'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', cache: { ttl: '60s' }, }); // [!code fold:end] // Honour the cache; on a hit, `raw` is null (the cache holds no raw body). const cached = await getUser.inspect({ params: { id: 1 } }, { cache: true }); ``` `true` is shorthand for `{ cache: true }` — `getUser.inspect(input, true)` reads the same way at a call site. `raw` is `null` on a [streaming surface](/docs/concepts/event-stream) too — the engine refuses to buffer an unbounded delta spine — while `findings` and `status` still populate; use `.stream()` for incremental inspection there. ## See also [#see-also] # Make a flaky call succeed on its own (/docs/recipes/make-a-flaky-call-succeed) ## Task [#task] An API you depend on returns the occasional `429` or `503` that clears on its own. You do not want every call site wrapped in a try/catch and a sleep. ## Example [#example] One way: declare a retry policy on the stitch and let the caller just `await` the value. ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', retry: { attempts: 3, on: [429, 503], backoff: 'expo-jitter', }, }); // A transient 429 or 503 is retried up to twice; the caller just gets the value. const user = await getUser({ params: { id: 1 } }); ``` ## How it works [#how-it-works] `attempts` is the **total** number of tries including the first, so `3` means up to two retries. `on` is the set of status codes that trigger a retry. `backoff: 'expo-jitter'` doubles the wait each attempt and spreads it randomly to avoid thundering herds — though when the server sends a `Retry-After` header, that wait wins over the computed one, which is the default (`respect: false` opts out). Each retry rides the [event stream](/docs/concepts/event-stream) as a `progress` event (phase `retry`), so you can [watch it happen](/docs/recipes/watch-a-call-as-it-happens). When the dependency is not just flaky but failing, compose retry with a [timeout and circuit breaker](/docs/recipes/survive-a-flaky-dependency). ## See also [#see-also] # Mirror a paginated API to NDJSON (/docs/recipes/mirror-a-paginated-api) ## Task [#task] You want a local copy of every record behind a paginated API — one that happens to be OAuth2-protected, rate-limited, and prone to the odd `429` or `503`. By hand that means a token dance, a manual page loop, backoff code, and a rate limiter before you write a single row. ## Example [#example] One way: declare all four concerns on a single stitch and `await` it once. ```ts twoslash import { stitch } from 'stitchapi'; import { env, oauth2 } from 'stitchapi/auth'; const mirror = stitch({ baseUrl: 'https://api.example.com', path: '/users', auth: oauth2({ tokenUrl: 'https://api.example.com/oauth/token', client: { id: env('CLIENT_ID'), secret: env('CLIENT_SECRET') }, scope: 'users.read', }), retry: { attempts: 4, on: [429, 503], backoff: 'expo-jitter', }, throttle: { rate: '5/s', concurrency: 2, pool: 'host' }, paginate: { next: (prevBody) => { const cursor = (prevBody as { nextCursor?: string }).nextCursor; return cursor ? { query: { cursor } } : undefined; }, items: (value) => (value as { results: unknown[] }).results, pages: 100, }, }); // One await runs the whole paged loop — auth, retry, and throttle apply to // every page — and aggregates every row into a single array. const rows = (await mirror()) as unknown[]; // Newline-delimited JSON: one row per line. const ndjson = rows.map((row) => JSON.stringify(row)).join('\n'); ``` Write the rows to disk: ```ts import { writeFile } from 'node:fs/promises'; await writeFile('users.ndjson', ndjson); ``` ## How it works [#how-it-works] The four concerns are independent keys on one declaration, so they compose without any glue. [`oauth2`](/docs/guides/auth/oauth2) fetches, caches, and refreshes the token behind the [capability boundary](/docs/concepts/capability-not-credential) — your code never sees it. [`paginate`](/docs/guides/data/pagination) reads `nextCursor` off each raw body and asks for the next page until `next` returns `undefined`, aggregating every page's `results` into the array you `await`. [`retry`](/docs/guides/resilience/retry) and [`throttle`](/docs/guides/resilience/throttle) apply **per page**, not once for the whole run: a `429` on page seven recovers on its own, and the loop stays under five requests a second the entire time. Without an [`output` schema](/docs/guides/validation/validation) the rows arrive as `unknown[]`, so the cast just tells TypeScript what you already know — add a schema and each row is typed and validated, and the cast goes away. Either way, serializing to NDJSON is one `.map()`. `next` reads the raw body; `items` reads the value after pick. Reach for the cursor in `next` from where it actually lives in the response — see [Pagination](/docs/guides/data/pagination). ## See also [#see-also] # Share one rate limit across every worker (/docs/recipes/one-rate-limit-across-workers) ## Task [#task] You run several workers that all hit the same rate-limited API. Each process enforcing `"10/s"` on its own means the fleet does `10/s × workers` — and trips the limit. You want one budget the whole fleet shares. ## Example [#example] One way: keep the `throttle` policy, but move its counters to a shared `store`. ```ts twoslash import { stitch } from 'stitchapi'; import type { StitchStore } from 'stitchapi'; // A Redis- or Postgres-backed store shared by every worker. declare const sharedStore: StitchStore; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', throttle: { rate: '10/s', pool: 'host' }, store: sharedStore, // the budget now spans all workers using this store }); const hits = await search({ query: { q: 'mango' } }); ``` ## How it works [#how-it-works] By default throttle counters live in the in-memory store, so each worker enforces the limit alone and the fleet runs at `10/s × workers`. Point `store` at one shared backend and the rate counter becomes a single fixed-window count every worker increments — one `10/s` budget across the fleet. `pool: 'host'` keys the budget by origin, so every stitch hitting the host pools into it; workers share only when they point at the same store and resolve to the same key. (Concurrency stays in-process; only the rate limit is distributed.) A [`StitchStore`](/docs/guides/state/pluggable-store) is three async methods — `get` / `set` / `increment` with TTL — backed by anything; the same store also gives you [shared auth sessions](/docs/guides/state/shared-sessions). ## See also [#see-also] # Call one stitch as a function, a CLI command, and an agent tool (/docs/recipes/one-stitch-every-surface) ## Task [#task] You have defined an API call once. You want to use it in app code, run it ad-hoc from the shell, and let an agent call it — without redefining it three times. ## Example [#example] One way: define the stitch once with a `name`, then reach it through each surface. ```ts twoslash // stitches.ts — define it once, export it by name. import { stitch } from 'stitchapi'; export const getUser = stitch<{ id: number; name: string }>({ name: 'getUser', baseUrl: 'https://api.example.com', path: '/users/{id}', }); // In app code: call it as a typed async function. const user = await getUser({ params: { id: 1 } }); ``` From the shell, the CLI loads the module and runs it by name: ```bash stitch run getUser --id 1 ``` To an agent over MCP, the single `run_stitch` tool covers it: ```json { "name": "run_stitch", "input": { "name": "getUser", "input": { "params": { "id": 1 } } } } ``` ## How it works [#how-it-works] One definition, four surfaces. The [in-process function](/docs/surfaces/function) awaits or `.stream()`s it; the [CLI](/docs/surfaces/cli) (`stitch run`) loads the module and runs it by name; [MCP](/docs/surfaces/mcp) exposes it to agents through the single `run_stitch` tool; and it can be [served over HTTP](/docs/surfaces/http-serve). The `name` is what the CLI and MCP resolve — nothing else about the definition changes between front doors. ## See also [#see-also] # Loop a cursor API into one array (/docs/recipes/paginate-into-one-array) ## Task [#task] An endpoint returns one page at a time behind a cursor. You want every item in a single array — without writing the `while` loop, the cursor bookkeeping, and the concat by hand. ## Example [#example] One way: declare how to find the next page and which array to collect, and `await` once. ```ts twoslash import { stitch } from 'stitchapi'; const allUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', paginate: { next: (prevBody) => { const cursor = (prevBody as { nextCursor?: string }).nextCursor; return cursor ? { query: { cursor } } : undefined; }, items: (value) => (value as { results: unknown[] }).results, pages: 20, }, }); // One await follows every page and returns the aggregated items. const users = (await allUsers()) as unknown[]; ``` ## How it works [#how-it-works] `next(prevBody)` reads the cursor off the previous page's raw body and returns the [input](/docs/reference/config-types) for the next page, merged over the original call — set only what changes. Return `undefined` to stop. `items` selects the array to aggregate from each page (after [pick](/docs/guides/data/pick)). `pages` caps the page count as a safety net (default `50`). Auth, retry, and throttle apply per page, not once for the whole run. Without an [`output` schema](/docs/guides/validation/validation) the aggregated result is typed `unknown[]`, hence the cast — add a schema to type the rows. ## See also [#see-also] # Keep a failing dependency from taking you down (/docs/recipes/survive-a-flaky-dependency) ## Task [#task] A downstream API starts timing out and erroring. Retrying alone just piles more load on something already struggling, and a hung request ties up a caller indefinitely. You want bounded calls, a few smart retries, and a breaker that stops hammering a dependency that is clearly down. ## Example [#example] One way: layer `timeout`, `retry`, and `circuit` on a single stitch. ```ts twoslash import { stitch } from 'stitchapi'; const orders = stitch({ baseUrl: 'https://api.example.com', path: '/orders', timeout: { total: '10s', each: '3s' }, retry: { attempts: 3, on: [429, 503], backoff: 'expo-jitter' }, circuit: { failures: 5, cooldown: 30_000 }, }); const result = await orders(); ``` ## How it works [#how-it-works] Three layers compose. [`timeout`](/docs/guides/resilience/timeout) bounds each attempt (`3s`) and the whole call (`10s`) with a real `AbortSignal`, so a hung request is actually cancelled, not leaked. [`retry`](/docs/guides/resilience/retry) recovers transient `429`/`503`s with jittered backoff inside that budget. [`circuit`](/docs/guides/resilience/circuit-breaker) watches the failures: after five consecutive it trips **open** and calls fast-fail for 30 seconds ([`STITCH_CIRCUIT_OPEN`](/docs/errors/stitch-circuit-open)) instead of hammering a dead dependency, then lets a single trial call probe recovery. Together: a blip is retried, a hang is cut, and an outage stops being yours to absorb. All three report on the [event stream](/docs/concepts/event-stream) (phases `retry`, `circuit`). ## See also [#see-also] # Send JSON and get a typed result back (/docs/recipes/typed-json-round-trip) ## Task [#task] You are POSTing JSON to an API and want the result back as a typed value — and you would like bad input rejected before it leaves the process, not returned to you as a `400` from the server. ## Example [#example] One way: validate the `body` going out and the response coming back, and type the result with the `stitch` generic. ```ts twoslash import { stitch } from 'stitchapi'; import { z } from 'zod'; const createUser = stitch<{ id: number; name: string }>({ method: 'POST', baseUrl: 'https://api.example.com', path: '/users', input: { body: z.object({ name: z.string() }) }, output: z.object({ id: z.number(), name: z.string() }), }); // A bad body throws before the POST; a bad response throws after. const user = await createUser({ body: { name: 'Ada' } }); ``` `user` is typed `{ id: number; name: string }`. ## How it works [#how-it-works] `input.body` is checked **before** the request — wrong input fails fast with no network call. `output` is checked **after** the response returns. Either failure throws [`STITCH_VALIDATION`](/docs/errors/stitch-validation). The `stitch` generic types the resolved value, and the config takes the `z.object(...)` schema directly. Any [Standard Schema](/docs/guides/validation/standard-schema) validator (Valibot, ArkType) works the same way, passed straight to `input`/`output`. ## See also [#see-also] # Watch retries and throttling as they happen (/docs/recipes/watch-a-call-as-it-happens) ## Task [#task] A call is slow or flaky and you want to see why — the retry that fired, the throttle that paused, the field that drifted — not just the final value or a thrown error. ## Example [#example] One way: iterate the stitch's event stream instead of awaiting it. ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search' }); // Await for just the value… const value = await search({ query: { q: 'mango' } }); // …or iterate the stream for everything that happened on the way there. for await (const event of search({ query: { q: 'mango' } }).stream()) { if (event.type === 'progress') console.log(event.phase, event.attempt); if (event.type === 'drift') console.log(event.finding.level, event.finding.path); if (event.type === 'result') console.log(event.data); } ``` ## How it works [#how-it-works] A stitch yields a typed event stream — `start → progress → drift → result → done` — and `await` simply consumes it for the final value. `.stream()` hands you the events instead, discriminated on `type`, so narrowing one field tells the compiler the shape of the rest. `progress` carries a `phase` (`auth`, `throttled`, `retry`, `pagination`, `circuit`) and an `attempt`; `drift` carries leveled `finding`s — including the `warn`/`info` ones that never throw; then exactly one terminal `result` or `error`, then `done`. This is the one source every cross-cutting feature reports through, and exactly what a [trace sink](/docs/guides/observability/trace-sinks) consumes. ## See also [#see-also] # Auth strategies (/docs/reference/auth-strategies) The signatures for every auth strategy and secret resolver. Each strategy holds the credential and resolves it at call time, so the caller never sees it. The guides under [Auth](/docs/guides/auth/secret-resolvers) show how to use them. ## bearer [#bearer] Attaches `Authorization: Bearer `. ```ts twoslash import { bearer } from 'stitchapi/auth'; ``` ## apiKey [#apikey] Sends a secret in a header (`X-API-Key` by default), a query parameter (`in: 'query'`, `api_key` by default, URL-encoded), or a cookie (`in: 'cookie'`). `name` labels the key in every arm, resolved at call time. ```ts twoslash import { apiKey } from 'stitchapi/auth'; ``` ## basic [#basic] Base64-encodes `user:pass` into `Authorization: Basic …`. ```ts twoslash import { basic } from 'stitchapi/auth'; ``` ## cookieSession [#cookiesession] Logs in once, captures the cookie from `Set-Cookie`, replays it on each request, and re-logs in on a status code or a soft 200 wall. ## oauth2 [#oauth2] Performs the `client_credentials` grant, caches the access token, refreshes it before expiry, and attaches it as `Authorization: Bearer …`. ### OAuth2ClientOptions [#oauth2clientoptions] The shape behind `oauth2({ client })` — who the client is at the token endpoint, and how it proves it. `id` and `secret` are both required, so `client: {}` is a compile error; there is no scalar shorthand, because neither field dominates the other. `auth` picks `client_secret_post` (the default) or `client_secret_basic`. ## env [#env] The environment-variable resolver, and the only one whose source is the process environment. It is **callable** for the required case, with the optional variant hanging off it as a modifier — one name for one source, requiredness named at the call site. Resolves a **required** secret from an environment variable at call time. It needs a non-empty value: an unset variable *and* a set-but-empty one (`MY_TOKEN=`) both throw, so a blank credential is never silently sent. For the may-or-may-not-be-set case use [`env.optional`](#envoptional). ```ts twoslash import { env } from 'stitchapi/auth'; ``` ### env.optional [#envoptional] Like `env`, but optional: resolves the variable's value, or *absent* when it is unset or empty — it never throws. Pass it to `bearer` to attach the credential only when present (`bearer(env.optional('GITHUB_TOKEN'))`); a missing variable just sends the request unauthenticated. See [the Bearer guide](/docs/guides/auth/bearer). ```ts twoslash import { env } from 'stitchapi/auth'; const token = env.optional('GITHUB_TOKEN'); ``` ## credential [#credential] The resolvers whose source is **not** the process environment, grouped under one noun with the source named at the call site. Both members are required resolvers — they throw rather than hand back a blank credential, mirroring [`env`](#env) — and both return the same `() => string` thunk every strategy accepts. The noun is `credential` rather than `secrets` because `secrets` is already the root barrel's trace-redaction namespace (`secrets.register` / `has` / `redact`); two different objects behind one word on two entry points is the one-word-one-concept rule this project holds itself to. ### credential.file [#credentialfile] Resolves a secret from `~/.stitch/secrets.json` (plaintext JSON — keep it private), falling back to the environment variable of the same name when the file is absent or lacks the key, and throwing when neither has it. ```ts twoslash import { credential } from 'stitchapi/auth'; const token = credential.file('BILLING_TOKEN'); ``` `~/.stitch/secrets.json` is **unencrypted plaintext JSON**. Restrict its permissions (`chmod 600 ~/.stitch/secrets.json`) and never commit it. ### credential.from [#credentialfrom] Resolves a **required** secret from an arbitrary injected `source` at call time — for DI'd apps that supply config without touching `process.env` (a `ConfigService`, a secrets-manager client, a validated config object). The source is either an object with a `get(name)` method or a plain `(name) => value` function; a missing or empty value throws, mirroring [`env`](#env). Compose it with any strategy exactly like `env()`. ```ts twoslash import { bearer, credential } from 'stitchapi/auth'; // Any object with a `get(name)` method — e.g. a NestJS ConfigService. declare const configService: { get(name: string): string | undefined }; const auth = bearer(credential.from(configService, 'GITHUB_TOKEN')); ``` ## See also [#see-also] # Config types (/docs/reference/config-types) The configuration object accepted by `stitch()`. Every field is optional; a stitch is the result of deep-merging these fragments through `extends`. ## StitchConfig [#stitchconfig] ```ts twoslash import type { StitchConfig } from 'stitchapi'; ``` ### wire [#wire] Every wire-format choice lives in one envelope, grouped by category rather than by request/response phase: No single field dominates, so `wire` takes no scalar shorthand — like `input`, it is always the object form, and the opaque `wire: {}` is rejected. #### wire.array [#wirearray] Controls how arrays are serialised on **both** `application/x-www-form-urlencoded` surfaces — the query string and a `wire: { body: 'form' }` request body. They run the same walker, so one setting governs both. The default (`'indices'`) matches `qs` behaviour. | Value | Wire format | | --------------------- | --------------------------- | | `'indices'` (default) | `ids%5B0%5D=1&ids%5B1%5D=2` | | `'brackets'` | `ids%5B%5D=1&ids%5B%5D=2` | | `'repeat'` | `ids=1&ids=2` | ```ts // repeat — Laravel, Rails, and many PHP frameworks expect this const getUsers = stitch({ baseUrl, path: '/users', wire: { array: 'repeat' } }); await getUsers({ query: { ids: [1, 2] } }); // → GET /users?ids=1&ids=2 // the same setting shapes a form body const search = stitch({ method: 'POST', baseUrl, path: '/search', wire: { body: 'form', array: 'repeat' }, }); await search({ body: { ids: [1, 2] } }); // → POST /search ids=1&ids=2 ``` Nested objects always expand `qs`-style to `a[b]=c` on both surfaces — `wire.array` selects the array axis only: ```ts await search({ body: { page: { size: 10 } } }); // → POST /search page%5Bsize%5D=10 ``` The two surfaces differ in one detail: a space is `%20` in a query string and `+` in a form body. Both are valid urlencoded and both round-trip. #### wire.multipart [#wiremultipart] A `wire: { body: 'multipart' }` body has its own grammar and is **not** governed by `wire.array` — use `wire.multipart`'s `nesting` instead. Setting `multipart` without `wire: { body: 'multipart' }` is a type error, since the slot would otherwise be silently ignored. A bare `MultipartNesting` string is shorthand for the object form — `multipart: 'dot'` ≡ `multipart: { nesting: 'dot' }`; the opaque `multipart: {}` is rejected. ## Option shapes [#option-shapes] The resilience, verdict, idempotency, cache, pagination, and drift fields above are themselves typed shapes. Each is documented from the source so it can never drift from the runtime. ### RetryOptions [#retryoptions] #### BackoffOptions [#backoffoptions] The shape behind `retry.backoff`. A bare curve is the shorthand for `{ curve }` — `backoff: 'fixed'` ≡ `backoff: { curve: 'fixed' }`; the envelope adds `base` and `max`. ### ThrottleOptions [#throttleoptions] ### TimeoutOptions [#timeoutoptions] ### CircuitOptions [#circuitoptions] ### VerdictOptions [#verdictoptions] The shape behind `verdict` — what counts as success. Both members move the verdict in exactly one direction: `accept` can only turn a failure into a success, `flag` only a success into a failure. Neither invents a verdict from absence. ### IdempotencyOptions [#idempotencyoptions] The shape behind `idempotency`. `true` is shorthand for the defaults — a random uuid minted once per logical call and reused across that call's retries. ### CacheOptions [#cacheoptions] The shape behind `cache`. Off unless set; the key is **derived** from the resolved request, so it cannot drift from what it names. `methods` defaults to `['GET','HEAD']`. Two body-carrying reads opt in by naming their method: a GraphQL **query** (`methods: 'POST'`) and [`QUERY`](/docs/guides/authoring/stitch#the-query-method) (`methods: 'QUERY'`), whose responses are cacheable by spec. Neither is a default — a `POST`'s read-vs-mutate intent cannot be inferred, and caching a body-carrying request is a decision worth writing down. The key already folds the request body in, so two different `QUERY` bodies to the same URL get two entries and cannot collide. #### CacheFingerprintOptions [#cachefingerprintoptions] The shape behind `cache.fingerprint` — how a stored value is detected as stale against its contract (the [ADR 0004](https://github.com/rejifald/StitchAPI/blob/main/docs/adr/0004-standard-schema-fingerprint-for-cache-invalidation.md) ladder). Every member is a rung of that ladder; `ttl`, `tenancy`, `vary` and the rest of `CacheOptions` answer a different question and stay outside. A bare version tag is the shorthand for `{ version }` — `fingerprint: 3` ≡ `fingerprint: { version: 3 }`, the always-available manual override. Unset is the automatic path: a registered `@stitchapi/fingerprint-*` strategy makes `output` changes self-invalidate, and an un-fingerprintable schema refuses to cache. #### CacheTransformOptions [#cachetransformoptions] The shape behind `cache.fingerprint.transform` — what the cache knows about an opaque `transform`. A bare version tag is the shorthand for `{ version }` here too, one level in: `fingerprint: { transform: 3 }` ≡ `fingerprint: { transform: { version: 3 } }`. A stitch that has a `transform` and none of this **refuses to cache**: the closure cannot be hashed, and re-validation cannot detect a transform change, so no policy would be sound. `version` wins when both are set. ### PaginateOptions [#paginateoptions] The shape behind `paginate`. `next` drives the loop; auth, retry, and throttle apply to every page. ### DriftOptions [#driftoptions] ## See also [#see-also] # Conformance kits (/docs/reference/conformance-kits) Every pluggable seam in StitchAPI is a **contract, not a dependency**: the transport (`Adapter`), the state store (`StitchStore`), and the trace sink (`TraceSink`) each have a small documented surface, core ships only platform defaults for them (`fetchAdapter`, `memoryStore`, `createTrace`), and vendor implementations — a Redis store, an axios transport, a Datadog sink — live in their own packages. The `stitchapi/testing` subpath makes that contract enforceable. It ships one `conformance` namespace with one member per seam; any implementation that passes its seam's member is a valid seam implementation, and third-party authors can prove it in their own CI without depending on StitchAPI internals. ```ts import { conformance } from 'stitchapi/testing'; conformance.store; // the StitchStore contract conformance.adapter; // the Adapter transport contract conformance.sink; // the TraceSink contract conformance.fingerprint; // the SchemaFingerprinter contract (ADR 0004) conformance.assert; // turn a report into a failing test conformance.fixture; // the echo server `conformance.adapter` verifies against ``` The seam is the dimension, so it is the name — the same shape the `duration` / `size` / `rate` grammars and `secrets` take on the main entry. The kit is framework-agnostic and browser-safe: every verifier is a plain function that **returns** a report (no vitest/jest imports, no `node:*` modules), so it runs inside any test runner — or a browser. ## The report [#the-report] Each verifier resolves to a `ContractReport`: ```ts interface ContractReport { seam: string; // 'store' | 'adapter' | 'sink' | 'fingerprint' ok: boolean; // true when every rule passed passed: string[]; // names of the rules that passed violations: { rule: string; detail: string }[]; } ``` `seam` names which member produced the report — the same dimension the namespace is keyed by. Rules are checked independently — one violation never masks another — so a failing report lists every broken rule at once. ### conformance.assert [#conformanceassert] `conformance.assert(report)` throws one readable `Error` listing every violation (and is a no-op on a clean report). It is the one-liner for a test body: ```ts import { conformance } from 'stitchapi/testing'; test('myStore implements the StitchStore contract', async () => { conformance.assert(await conformance.store(() => myStore())); }); ``` ## conformance.store [#conformancestore] `conformance.store(makeStore, opts?)` verifies a [pluggable store](/docs/guides/state/pluggable-store) — the `get`/`set`/`increment` + TTL surface the engine uses for throttle counters and auth/session state: * `set`/`get` round-trips a value; a missing key resolves to `undefined`; a second `set` overwrites; writes are isolated by key. * A `set` with `ttl` expires; a `set` without `ttl` does not. * `increment` initializes a missing key to 1, increments an existing counter, restarts at 1 once its TTL window lapses, and is atomic within a process: 20 concurrent calls must return 1..20 exactly. TTL rules use real timers with a small window (default 60ms). Pass `{ ttl }` to give a backend with coarser expiry more room. Keys are namespaced per run, so rerunning against a persistent backend never collides. ## conformance.adapter [#conformanceadapter] `conformance.adapter(adapter, { baseUrl })` verifies a custom transport against a documented echo contract served at `baseUrl`: * Status passthrough: 200, 404, and 500 all **resolve** — an adapter never throws on a non-2xx status; only network and abort errors reject. * Request delivery: method, headers, and the JSON-encoded body reach the server intact. * Response headers are readable using **lowercased** names (the `AdapterResult` case convention). * A `text/plain` body round-trips as a string; an `application/json` body round-trips as parsed data. * A pre-aborted `AbortSignal` rejects, and an in-flight abort rejects promptly instead of waiting out the response. ### conformance.fixture [#conformancefixture] The echo contract itself ships as `conformance.fixture` — a **pure** function (request in, response out, no server), so you can mount it on anything: `node:http`, hono, a service worker. The host lowercases request header names, hands over the raw body text, and honors the fixture's `delay` (the in-flight abort rule depends on it): ```ts import { createServer } from 'node:http'; import { conformance } from 'stitchapi/testing'; const server = createServer((req, res) => { let raw = ''; req.setEncoding('utf8'); req.on('data', (chunk) => (raw += chunk)); req.on('end', () => { const out = conformance.fixture({ method: req.method ?? 'GET', path: req.url ?? '/', headers: Object.fromEntries( Object.entries(req.headers).map(([k, v]) => [ k.toLowerCase(), Array.isArray(v) ? v.join(', ') : (v ?? ''), ]), ), ...(raw === '' ? {} : { body: raw }), }); const respond = () => { res.writeHead(out.status, out.headers); res.end(out.body); }; if (out.delay) setTimeout(respond, out.delay); else respond(); }); }); ``` The routes are documented on the function's JSDoc (`/status/{code}`, `/echo`, `/text`, `/json`, `/slow`). ## conformance.sink [#conformancesink] `conformance.sink(makeSink)` feeds one sink instance a canonical fixture sequence covering **all** `StitchEvent` variants — `start`, `progress`, `drift`, `delta`, `result`, `error`, `done` — and asserts `handle` accepts each without throwing, then exercises the optional `flush()` when present. The sequence includes `delta`, which the type declares ahead of engine support — a conforming sink must already tolerate events it does not recognize. ## Worked example: a community Redis store [#worked-example-a-community-redis-store] A third-party author publishing `@acme/stitch-redis-store` proves compliance in their own CI — no StitchAPI internals, just the kit: ```ts import { redisStore } from '../src/redis-store'; import { createClient } from 'redis'; import { conformance } from 'stitchapi/testing'; import { test } from 'vitest'; test('redisStore implements the StitchStore contract', async () => { const client = createClient({ url: process.env.REDIS_URL }); await client.connect(); try { conformance.assert( await conformance.store(() => redisStore(client), { // Give Redis expiry more room than the 60ms default. ttl: 250, }), ); } finally { await client.quit(); } }); ``` If the store cuts a corner — a non-atomic `INCR`, a dropped TTL — the test fails with every broken rule named: ``` Error: StitchAPI store contract: 2 violation(s), 8 rule(s) passed - set: a ttl entry expires: value survived its 250ms TTL: got "soon-gone" - increment: 20 concurrent calls net exactly +20: sorted results of 20 concurrent increments ... ``` Passing the kit is the compatibility claim: a store that passes can back [distributed throttle](/docs/guides/state/distributed-throttle) and shared sessions with no change at the call site. ## See also [#see-also] # Event types (/docs/reference/events) The events a stitch emits as it runs. A stitch streams an async iterable of `StitchEvent` values; this page documents their shapes. ## StitchEvent [#stitchevent] `StitchEvent` is a discriminated union on `type`. The `T` parameter is the type of the value carried by the `result` event. ```ts twoslash import type { StitchEvent } from 'stitchapi'; ``` ## The events [#the-events] A union table can render sparsely, so here is each of the eight variants and its fields. Every event carries an `at` field — a millisecond timestamp. * **`start`** — the request is about to go out. Fields: `name` (the stitch label), `method`, `url`, `input` (the `StitchInput` for this call), `at`. * **`progress`** — a lifecycle beat within an attempt. Fields: `phase` (a `ProgressPhase`), `attempt` (1-based), `detail?`, `waited?` (set when the phase involved a wait, e.g. `throttled` or `retry`), `at`. * **`info`** — an auth strategy announcing a decision it made (e.g. which env var a `bearer` token resolved from, or that `oauth2` fetched a token); it never carries the secret itself. Fields: `topic`, `detail?`, `at`. * **`drift`** — a single contract-drift observation against the response. Fields: `finding` (a `DriftFinding` with `level`, `path`, `change`, `detail?`), `at`. * **`delta`** — a streamed chunk of the response body. Fields: `chunk` (`unknown`), `at`. * **`result`** — the call succeeded and produced a value. Fields: `data` (the `T`), `status` (HTTP status), `attempts` (how many it took), `at`. * **`error`** — the call failed. Fields: `name`, `message`, `status?`, `attempts`, `at`. * **`done`** — the stream is finished, success or failure. Fields: `ok`, `elapsed` (total duration), `attempts`, `at`. `ProgressPhase` is one of `'auth'`, `'request'`, `'throttled'`, `'retry'`, `'paginate'`, or `'circuit'`. `result` and `error` are mutually exclusive within one stream, but `done` always fires last — read `done.ok` to know which way it went. ## See also [#see-also] # Helpers (/docs/reference/helpers) The exported helper functions you wire into a stitch: the default transport, the trace sinks (including OTLP export), the secret-redaction hooks, the default store, and the parsers behind the duration, size, and rate tokens. Each signature is below; the guides cover usage in depth and are linked under [See also](#see-also). ## Transport [#transport] Signatures below; [Transport & adapters](/docs/guides/transport/adapters) is the guide — which transport to pick, what each can and can't do, and how to write your own. ### fetchAdapter [#fetchadapter] The default fetch-based transport. Returns an `Adapter` backed by the global `fetch`; it never throws on a non-2xx response — only network and abort errors propagate, leaving the engine to decide what a status means. ```ts twoslash import { fetchAdapter } from 'stitchapi'; const adapter = fetchAdapter(); ``` It accepts an optional `FetchAdapterOptions` to thread a **per-stitch undici dispatcher** — an `Agent` for a proxy, a custom CA, or interface binding — straight through as Node's non-standard `dispatcher` fetch init option. The runtime stays zero-dependency, so StitchAPI never imports undici; you bring your own `Agent`. An optional `fetch` override swaps the global `fetch` for testing or custom runtimes. With no options, behavior is identical to `fetchAdapter()` (no `dispatcher` is set). ```ts twoslash import { fetchAdapter, stitch } from 'stitchapi'; // Your real `new Agent(...)` from undici is structurally an unknown dispatcher here — // no undici import inside StitchAPI or this snippet. declare const agent: unknown; const getUser = stitch({ url: 'https://api.example.com/users/{id}', adapter: fetchAdapter({ dispatcher: agent }), }); ``` The `axiosAdapter` equivalent is axios's own `httpAgent` / `httpsAgent`, passed through its `defaults` (`axiosAdapter(axios, { httpsAgent })`) — see below. ### axiosAdapter [#axiosadapter] Route a stitch through a caller-supplied axios instance instead of `fetch`. The runtime stays zero-dependency, so you pass *your* axios (or an `axios.create()`) rather than an import inside StitchAPI; body encoding, headers, and parsing match `fetchAdapter`, so swapping transports never changes behavior. It is **buffered-only** — it throws on a `stream`/`sse` surface; use `fetchAdapter` to stream. It *does* report byte progress: the adapter wires axios's native `onUploadProgress`/`onDownloadProgress` into `onProgress` (both phases), so an upload bar works on a modern axios without leaving your instance. ```ts twoslash import { type AxiosLike, axiosAdapter, stitch } from 'stitchapi'; // Your real axios instance is structurally an AxiosLike — no axios import needed here. declare const axios: AxiosLike; const getUser = stitch({ url: 'https://api.example.com/users/{id}', adapter: axiosAdapter(axios), }); ``` ### xhrAdapter [#xhradapter] An `XMLHttpRequest`-backed transport whose reason to exist is **upload progress** (`fetch` cannot report bytes sent). Browser-only by default; pass a constructor to inject a polyfill or a fake. Buffered-only, like `axiosAdapter`. ```ts twoslash import { stitch, xhrAdapter } from 'stitchapi'; const upload = stitch({ method: 'POST', url: 'https://api.example.com/files', adapter: xhrAdapter(), // defaults to globalThis.XMLHttpRequest }); ``` ### Adapter capabilities [#adapter-capabilities] Each built-in adapter declares what it **supports** as an optional `capabilities` descriptor on the returned function — `{ name?, supports }`, where `supports` lists the optional features the transport has (`'stream'`, `'uploadProgress'`, `'downloadProgress'`); anything not listed, it can't do. | adapter | `stream` | `uploadProgress` | `downloadProgress` | | -------------- | :------: | :--------------: | :----------------: | | `fetchAdapter` | ✓ | — | ✓ | | `xhrAdapter` | — | ✓ | ✓ | | `axiosAdapter` | — | ✓ | ✓ | Only `fetchAdapter` streams (`xhr`/axios buffer and reject `stream`); only `fetch` **cannot** report upload progress. `axiosAdapter` wires axios's native `onUploadProgress`/`onDownloadProgress`, so it needs an axios that supports them (v1+). ```ts twoslash import { fetchAdapter } from 'stitchapi'; // { name: 'fetchAdapter', supports: ['stream', 'downloadProgress'] } const caps = fetchAdapter().capabilities; ``` Declaring capabilities is **opt-in**: a custom adapter is still just `(req) => Promise`, and one that declares nothing is treated as unknown — no checks run against it. The engine checks `supports` for `'uploadProgress'` to turn a silent footgun into a teaching note. If a call passes `onProgress` **with a request body** and the active adapter's `supports` omits `'uploadProgress'` (only `fetch`, among the built-ins), the stitch emits one `info` event — topic `adapter.upload-progress-unsupported` — through your trace sink, pointing you at `xhrAdapter`: > onProgress is set with a request body, but fetchAdapter cannot report upload > progress — only 'direction: download' events fire. Use xhrAdapter() to draw an > upload progress bar. It is a **note, not an error**: `fetch` still reports `direction: 'download'` progress, so a download bar on a `POST` keeps working — only the upload phase is dark. To see the note, attach any `trace` sink (e.g. `consoleSink`); to act on it, switch the call to `xhrAdapter()`. ## Tracing & OTLP [#tracing--otlp] Tracing is **off by default** — a stitch traces nothing until you pass a `trace` sink (or set `STITCH_TRACE_*`). These are the sinks you reach for; see [Trace sinks](/docs/guides/observability/trace-sinks) for the full opt-in model. ### consoleSink [#consolesink] A console-only sink: the colored one-line-per-event stream to stderr, nothing on disk. The `trace: 'console'` shorthand resolves to this. ```ts twoslash import { consoleSink } from 'stitchapi'; const sink = consoleSink(); ``` ### fileSink [#filesink] A file-only sink: append every event as JSONL to `path` (defaults to `~/.stitch/runs/proto.jsonl`). Writing to disk is a side effect, so you pass it explicitly — a stitch never opens a trace file on its own. An optional second argument tunes privacy: `body` (a character cap, default `2048`; `{ chars: false }` for full capture) caps body/result [truncation](/docs/guides/observability/trace-sinks#body-truncation-and-full-capture), and `redactHeaders` widens the redaction denylist. ```ts twoslash import { fileSink } from 'stitchapi'; const sink = fileSink('./runs/today.jsonl', { body: { chars: false } }); ``` ### createTrace [#createtrace] Build a zero-infra trace sink: a compact one-line-per-event summary on the console and/or an appended JSONL record per event. `console` defaults to `true`; `file` defaults to a path under `$HOME`, or `false` to disable the JSONL stream; `body` and `redactHeaders` tune the JSONL sink's [privacy defaults](/docs/guides/observability/trace-sinks#default-secret-redaction) (header/URL redaction and body truncation are always applied to the file stream). (This is the low-level builder behind `consoleSink`/`fileSink`; the off-by-default behavior lives at the stitch's `trace` field, not here.) ```ts twoslash import { createTrace } from 'stitchapi'; const sink = createTrace({ console: true, file: false }); ``` ### multiplex [#multiplex] Fan one event stream out to several sinks — for example console/JSONL alongside OTLP — flushing each in turn. ```ts twoslash import { createTrace, multiplex, otlp } from 'stitchapi'; const sink = multiplex(createTrace(), otlp.sink()); ``` ### loggerSink [#loggersink] Bridge the event stream to any host logger — pino, winston, the `console`, anything with `error` / `warn` / `info` / `debug` methods (`LoggerLike`). It is **payload-free** (logs only metadata — name, method, scrubbed URL, status, attempt counts, drift path/level, timing — never the body, the result's `data`, or a `delta` chunk) and maps each event to a level: `result` → `info`, `error` → `error`, `drift` → the finding's level, `start`/`progress`/`info`/`done` → `debug`, and `delta` is never logged. Override per type with `levels`. The logger-agnostic core twin of `@stitchapi/nest`'s `nestLoggerSink`. ```ts twoslash import { loggerSink } from 'stitchapi'; // `console` satisfies LoggerLike; pass `pino()` or a winston logger just the same. const sink = loggerSink(console, { levels: { result: 'debug' } }); ``` ### otlp.sink [#otlpsink] An opt-in OpenTelemetry sink: it maps each stitch call's events to a single OTel `CLIENT` span and hands finished spans to a `SpanExporter`. With no exporter supplied it builds `otlp.exporter()`. Configured by `OtlpOptions` (below). ```ts twoslash import { otlp } from 'stitchapi'; const sink = otlp.sink({ endpoint: 'https://api.example.com:4318' }); ``` `otlp` groups the three layers of one export path the same way [`duration`](#duration) groups a parse/format pair: one name on the barrel, the role at the call site. They are layers, not siblings — `sink` builds spans and hands them to `exporter`, which POSTs what `json` serialized — so reach for the one you actually need. `otlp.sink()` alone covers the common case. ### otlp.exporter [#otlpexporter] The default exporter: POST spans as OTLP/JSON to `${endpoint}/v1/traces`. The endpoint defaults to `OTEL_EXPORTER_OTLP_ENDPOINT` or `http://localhost:4318`. Fire-and-forget — a missing collector never breaks a stitch call. ```ts twoslash import { otlp } from 'stitchapi'; const exporter = otlp.exporter({ endpoint: 'https://api.example.com:4318', headers: { authorization: 'Bearer token' }, }); ``` ### otlp.json [#otlpjson] Serialize finished spans to the OTLP/JSON `ResourceSpans` shape a collector accepts on `/v1/traces`. This is the seam for a transport core doesn't ship — gRPC, a queue, a file — so a custom `SpanExporter` reuses the same wire mapping rather than re-deriving it. ```ts twoslash import { otlp } from 'stitchapi'; const body = otlp.json([]); ``` #### OtlpOptions [#otlpoptions] ## Secret redaction [#secret-redaction] One namespace over one denylist. Reach for it when a credential of yours rides under a name the built-in list doesn't know. Before an event reaches a sink, the value of any secret-bearing key is replaced with `REDACTED` — matched by exact name (`key`, `sig`, `access_key`) or by a contained stem (`token`, `secret`, `signature`, `credential`, `api_key`), which is how `access_token`, `client_secret`, and `x-amz-signature` are all caught without listing every vendor spelling. A host that calls its credential `session_ref` matches neither, so you name it yourself. `secrets` groups the three calls that share that denylist the same way [`duration`](#duration) groups a parse/format pair: one name on the barrel, the verb at the call site. ### secrets.register [#secretsregister] Add one key name to the denylist. Every scrubber reads from it: the console and JSONL `start.url`, the OTLP `url.full` attribute, the structured `input.query`, and the traced request body. The **query bindings** read from it too. `@stitchapi/query-core` derives every cache key through the same predicate, so a registered name is redacted from the query keys built by [React](/docs/integrations/react), [Vue](/docs/integrations/vue), [Svelte](/docs/integrations/svelte), [Solid](/docs/integrations/solid) and [Angular](/docs/integrations/angular), and from [`swrKey`](/docs/integrations/swr). That reach matters more than a trace sink's: query keys are persisted by cache providers and shown in devtools, so a credential in one outlives the process. ```ts twoslash import { secrets, stitch } from 'stitchapi'; secrets.register('session_ref'); const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', trace: 'console', }); // The traced URL reads …/users?role=admin&session_ref=REDACTED await listUsers({ query: { role: 'admin', session_ref: 'sr_live_x' } }); ``` Additive and process-wide, mirroring the built-in list — a name goes in and never comes out. Registering the same name twice is a no-op, and the match is case-insensitive, so one lowercase registration covers `SESSION_REF` too. `apiKey({ in: 'query', name })` registers its configured `name` at construction, so a key the [apiKey strategy](/docs/guides/auth/api-key) puts in the URL is covered already. This is the hook for a credential no strategy declares — one your own code appends to a query, or one that arrives in a body field. ### secrets.has [#secretshas] The predicate the scrubbers run, exported so you can ask it yourself: does this key name — a query param, a body field — already redact? Audit your own config with it before deciding whether `secrets.register` has anything left to add. ```ts twoslash import { secrets } from 'stitchapi'; secrets.has('access_token'); // true — carries the `token` stem secrets.has('Client_Secret'); // true — the match is case-insensitive secrets.has('page'); // false — benign params survive for observability secrets.has('session_ref'); // false — until you register it ``` **Anti-pattern:** don't reach for this as your *only* test of whether a **header** is secret. It answers about query params and body keys, so `secrets.has('authorization')` and `secrets.has('x-api-key')` both come back `false` even though both headers are redacted. Headers get their own denylist: at the trace-sink boundary you widen it with [`redactHeaders`](/docs/guides/observability/trace-sinks#default-secret-redaction), not with `secrets.register`. Query keys are where the two meet. The key builders check their own header denylist **and then** fall through to `secrets.has`, so a name you register is redacted from a cache key even though `secrets.has` is the wrong question to ask about a header on its own. ### secrets.redact [#secretsredact] Deep-clone a value and replace every secret-named key inside it with `REDACTED`, so a payload you are about to log or forward carries no credential. It walks plain objects and arrays, leaves primitives alone, and hands back a new value — the input is never mutated. ```ts twoslash import { secrets } from 'stitchapi'; const safe = secrets.redact({ id: 7, profile: { email: 'ada@example.com', api_key: 'sk_live_x' }, }); // { id: 7, profile: { email: 'ada@example.com', api_key: 'REDACTED' } } ``` A second argument adds patterns on top of the shared denylist — an exact key name, a dotted path, a `*` single-segment wildcard, or a prefix that claims everything under it — for a field only this payload treats as sensitive: ```ts twoslash import { secrets } from 'stitchapi'; secrets.redact({ note: 'kept', meta: { note: 'gone' } }, ['meta.note']); // { note: 'kept', meta: { note: 'REDACTED' } } ``` This is what [`.inspect({ redact: true })`](/docs/recipes/inspect-what-the-server-sent) runs over its otherwise-unredacted `raw` body, and an array there (`{ redact: ['meta.note'] }`) is forwarded as this second argument. Call it directly for a value that didn't come from `.inspect()`. ## Store [#store] ### memoryStore [#memorystore] The default in-memory `StitchStore`: single-process, with TTL and atomic increment. Swap it for a Redis/Postgres-backed store to make throttling distributed and sessions shared across workers, with no change to the call site. ```ts twoslash import { memoryStore } from 'stitchapi'; const store = memoryStore(); ``` ## Duration, size, and rate tokens [#duration-size-and-rate-tokens] Three grammars, one namespace each, every one a `parse`/`format` pair. A stitch reads its own config through `parse`; the pair is exported so a peer package that takes an authored token — a distributed limiter's rate, a command runner's buffer cap — resolves it to the same number a stitch would, instead of mirroring the grammar in a second regexp that then drifts from it. `format` is the **exact** inverse, not a pretty-printer: `parse(format(v))` returns `v` unchanged for every value `parse` can produce. That is what makes it safe to write a token back — into a config file, a CLI flag, an error message quoting the limit it enforced — and it is where these differ from `ms`, whose `ms(90_000)` is `'2m'` and reads back as 120 000. ### duration [#duration] `5_000`, `'5s'`, `'1m'` → milliseconds. This is the grammar behind every authored duration on a stitch: `timeout.total`, `timeout.each`, `cache.ttl`, `backoff.base`, `circuit.cooldown`, a surface `interpret` hook's `after`. A number is already ms; `` takes `ms` | `s` | `m` | `h` | `d`, with fractions allowed. `duration.format` picks the largest unit that stays exact **and** readable, and falls back to plain milliseconds when no unit divides the value cleanly. ```ts twoslash import { duration } from 'stitchapi'; duration.parse('1.5s'); // 1500 duration.parse('2m'); // 120_000 duration.parse(5_000); // 5000 — a number is already ms duration.parse('soon'); // undefined duration.format(90_000); // '1.5m' duration.format(3_600_000); // '1h' duration.format(90_001); // '90001ms' — no unit divides it exactly ``` **Anti-pattern:** don't format a duration StitchAPI emitted into a field something else will read — keep it a number. Every emitted duration (`event.waited`, a result's `elapsed`, `retryAfter`) is raw milliseconds, and that is the contract a consumer parses against. `format` is for values a *person* reads, or for writing a token back into config; putting one in an emitted payload turns a number every reader can use into a string each of them has to parse. ### size [#size] `4096`, `'64kb'`, `'1mb'` → bytes, in **powers of 1024** — the npm-`bytes` convention every Node config parser already speaks, so `'1mb'` is `1_048_576`. Units are `b` | `kb` | `mb` | `gb` | `tb`, case-insensitive, and the IEC spellings (`kib`, `mib`, …) name the same values for callers who want the base explicit. It backs the byte caps — `serve.body.max`, and `buffer.max` on `@stitchapi/shell`. `size.format` emits the short spelling, and prefers the base unit over an exact but unreadable quotient — 1024 is a power of two, so almost every byte count divides into `kb` exactly and almost none of them divide into it *legibly*. ```ts twoslash import { size } from 'stitchapi'; size.parse('64kb'); // 65_536 size.parse('1mb'); // 1_048_576 size.parse(4096); // 4096 — a number is already bytes size.parse('big'); // undefined size.format(1_048_576); // '1mb' size.format(1536); // '1.5kb' size.format(1537); // '1537b' — '1.5009765625kb' is exact and useless ``` **Anti-pattern:** don't reach for this on a `chars` field — [`stream.buffer.chars`](/docs/reference/surfaces#streambuffer) and `trace.body.chars` count UTF-16 code units of decoded text, not bytes off the socket, so a `'64kb'` token there measures the wrong thing. Those fields take a bare number and reject a string at compile time. Worth holding onto now that the name is `size` rather than `parseBytes` — the call site no longer says "bytes" out loud, so the type is the only thing left warning you. ### rate [#rate] `'2/s'`, `'1000/h'` → `{ count, per }`, where `per` is the window length in ms. The denominator is a `duration` token and a bare unit means one of that unit (`'2/s'` ≡ `'2/1s'`), so this is the grammar behind [`throttle.rate`](/docs/guides/resilience/throttle#the-rate-grammar), in-process and store-backed alike. `rate.format` writes the pair back, using the bare-unit spelling for a one-unit window. ```ts twoslash import { rate } from 'stitchapi'; rate.parse('2/s'); // { count: 2, per: 1000 } rate.parse('1000/h'); // { count: 1000, per: 3_600_000 } rate.parse('100/15m'); // { count: 100, per: 900_000 } rate.format({ count: 2, per: 1000 }); // '2/s' rate.format({ count: 100, per: 900_000 }); // '100/15m' ``` A rate declares a minimum spacing rather than a bucket, so `'2/500ms'`, `'4/s'` and `'240/m'` all describe the same limiter. `format` returns the token for the pair it was handed rather than reducing it to one canonical member of that set — `{ count: 2, per: 500 }` comes back as `'2/500ms'`, the numbers the author wrote. **An unreadable token throws here, where the other two resolve to `undefined`.** The divergence runs with their fallback rather than against it. For a cap, falling back to the field's default leaves the ceiling where it was, so a typo can never widen it to "unbounded". A rate has no such safe default — `undefined` means *no rate limit at all* — so a fallback would let a typo silently remove the limit instead of narrowing it. `'fast'`, `'0/s'`, and `'1/30d'` (a spacing past the \~24.8-day timer ceiling) all throw, and `rate.format` rejects the same three rather than writing a token that would throw on the way back in. ## See also [#see-also] # seam() (/docs/reference/seam) Every prop `seam()` accepts, and the two handles it hands back. The option shapes those props take — `retry`, `throttle`, `cache`, and the rest — are documented once in [Reference → Config types](/docs/reference/config-types). ## seam() [#seam] `seam(options?)` takes a [`SeamConfig`](#seamconfig) fragment and returns a [`Seam`](#seam): the long-lived entity its members belong to, owning one store, one auth vault, one throttle bucket, and one trace sink. Members come from `.stitch()` and `.graphql()`, and inherit the seam's props as the base they extend. ```ts twoslash import { seam } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; const api = seam({ baseUrl: 'https://api.example.com', auth: bearer(env('API_TOKEN')), retry: { attempts: 3, on: [429, 503] }, }); const getUser = api.stitch({ path: '/users/{id}', pick: 'data' }); ``` ## SeamConfig [#seamconfig] ### The eight keys a seam refuses [#the-eight-keys-a-seam-refuses] `SeamConfig` is `StitchConfig` minus the keys that are intrinsically per-endpoint: the address (`path`, `url`, `method`, `document`) and the request and response shape (`name`, `input`, `output`, `kind`). A member sets those. Naming one on the seam is a type error rather than a silently-dropped field, so the type answers what belongs at the seam on its own. ```ts twoslash // @errors: 2353 import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); ``` ### Four props name shared runtime, not a copied value [#four-props-name-shared-runtime-not-a-copied-value] **`throttle` is one bucket, not one per member.** Every member acquires under a single seam-stable key, so the rate and concurrency budget pools across the whole surface; `pool: 'host'` keeps the engine's per-host key instead. A member's own `throttle` stacks on top — it gates that one stitch tighter, and never escapes the shared budget. **`store` is one instance the members share**, not a copy each: the same cache entries, throttle counters, and sessions. It defaults to an in-memory store. **`trace` is one sink for the whole surface.** Every member's events land in it, and `flush()` drains it. **`vault` splits the secrets off by visibility, not by backend.** Auth tokens and sessions live in the vault, which defaults to a reserved, redacted namespace over `store`; point `vault` at a KMS-, Vault- or keychain-backed store to harden those alone. Either may be distributed. It is an ordinary `StitchConfig` prop, so a standalone `stitch()` takes it too — the seam shares one vault across its members rather than being the only way to have one. ## Seam [#seam-1] The root handle: the member builders, the principal boundary, and the lifecycle over the runtime it owns. ## PrincipalSeam [#principalseam] `seam.as(id)` binds a principal in the closure and returns a `PrincipalSeam` — the same member builders, minus `flush`, `close`, and `invalidate`. Its members carry the principal in their auth context, so each principal gets its own sessions while the throttle bucket stays shared. ```ts twoslash import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com' }); // One handle per request — bind the caller's identity, then create members. const forTenant = api.as('tenant-42'); const getUser = forTenant.stitch({ path: '/users/{id}', pick: 'data' }); ``` **Anti-pattern:** don't hand the root `seam` to the least-trusted caller — it carries `close()` and `invalidate()` over the runtime every principal shares. Pass a `seam.as(id)` handle instead: it creates members and re-binds the principal, and has no lifecycle lever to pull. ## See also [#see-also] # stitch() (/docs/reference/stitch) The authoring surface: the exports that produce a stitch. For what goes inside the config object, see [Reference → Config types](/docs/reference/config-types). ## stitch() [#stitch] `stitch(config)` takes a path string or a partial config and returns a `Stitch`. Compose shared configuration via the `extends` key. ```ts twoslash import { stitch } from 'stitchapi'; const user = stitch<{ id: string }>({ path: 'https://api.example.com/me', }); ``` ## graphql() [#graphql] `graphql(config)` is a thin wrapper over `stitch()` for GraphQL-over-HTTP: it POSTs `{ query, variables }` and picks `data`. The config must carry a `document`. ```ts twoslash import { graphql } from 'stitchapi'; const viewer = graphql<{ viewer: { id: string } }>({ path: 'https://api.example.com/graphql', document: 'query { viewer { id } }', }); ``` See [Guide → graphql](/docs/guides/data/graphql) for usage. ## See also [#see-also] # Request surfaces (/docs/reference/surfaces) A **surface** is the request *style* a stitch speaks. `http` is the default — a plain JSON-over-HTTP call, what most of these docs use. GraphQL, Server-Sent Events, a raw byte stream, and a file download are **peer surfaces**: each shapes and interprets its own request, but they all ride the same engine, so `auth`, `retry`, `throttle`, `timeout`, validation, and the [event stream](/docs/concepts/event-stream) compose with every one. > A request surface is how a stitch shapes its *request*. Don't confuse it with > the four [invocation surfaces](/docs/surfaces/function) — function, CLI, HTTP, > MCP — which are how you *call* a stitch. The two are orthogonal: any request > surface is reachable through any invocation surface. Every non-`http` surface ships as its own **subpath import**, so `import { stitch }` from the root pulls in only the `http` engine — a surface's code (and its parser/decoder) loads only when you import it. | Surface | Import | Shapes | `await` resolves to | | ---------- | -------------------- | ----------------------------------------- | ------------------------------ | | `http` | `stitch` (default) | a JSON-over-HTTP call | the validated body | | `graphql` | `stitchapi/graphql` | POST `{ query, variables }`, pick `data` | the `data` payload | | `sse` | `stitchapi/sse` | a `text/event-stream` reader (over fetch) | every parsed event, collected | | `stream` | `stitchapi/stream` | a raw `ReadableStream` reader | every decoded chunk, collected | | `download` | `stitchapi/download` | a buffered binary GET | `{ blob, filename }` | ## http — the default [#http--the-default] `stitch(...)` with no `kind` is the `http` surface: send the request, parse JSON when the response is JSON-ish, validate, return. Nothing to import. ```ts import { stitch } from 'stitchapi'; const getUser = stitch('https://api.example.com/users/{id}'); ``` ### httpSurface [#httpsurface] The default is a **named value, not an engine branch**. Composing a stitch resolves an omitted `kind` to `httpSurface`, so `__config.kind` reads back `'http'` on a plain stitch the same way it reads `'download'` on a download one — a surface is always selected, and a consumer reading a declaration never has to special-case an empty slot. ```ts twoslash import { httpSurface, stitch } from 'stitchapi'; const getUser = stitch({ url: 'https://api.example.com/users/{id}' }); getUser.__config.kind; // 'http' — resolved at compose time, never absent // The same stitch, with the default said out loud. stitch({ kind: httpSurface, url: 'https://api.example.com/users/{id}' }); ``` Importing it by name pays off in one place: a base that supplies `kind` passes that surface down through [`extends`](/docs/guides/authoring/extends), and `kind: httpSurface` is how a single member declares itself a plain HTTP call against that base instead of inheriting the surface. ```ts twoslash import { graphqlSurface, httpSurface, stitch } from 'stitchapi'; const gqlBase = { baseUrl: 'https://api.example.com', kind: graphqlSurface }; // Every other member POSTs a document; this one is an ordinary GET. const getUser = stitch({ extends: [gqlBase], kind: httpSurface, path: '/users/{id}', }); ``` `graphqlSurface` is the generic spelling of the `graphql()` helper below, and it shares the root barrel with `httpSurface`. Every other surface exports its `*Surface` value from its own subpath — `sseSurface` from `stitchapi/sse`, `downloadSurface` from `stitchapi/download`. **Anti-pattern:** don't compose `httpSurface.interpret` into a surface of your own. It is reachable, and it is the rule `sse` and `stream` fall back to when they declare no `interpret` themselves — but it ends in "the body is the value", which is the http surface's own choice of result rather than a shared one. Compose [`verdictOf`](/docs/guides/resilience/verdict#beyond-configuration) instead: that is the caller's `verdict` config applied to a response, and it leaves the success value to you. ## graphql [#graphql] `graphql()` POSTs `{ query, variables }` and picks `data`; a `200` carrying `errors[]` is treated as a failure, never silently passed. ```ts import { graphql } from 'stitchapi/graphql'; const getThing = graphql({ baseUrl: 'https://api.example.com', document: 'query ($id: ID) { thing(id: $id) { name } }', }); const thing = await getThing({ variables: { id: 1 } }); ``` The surface owns the body encoding, so `wire.body` is not accepted here — setting one is a compile error. File uploads need the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec) envelope, which this surface does not implement; see [Guide → GraphQL](/docs/guides/data/graphql#file-uploads-are-not-supported). ## sse [#sse] `sse` parses the `text/event-stream` wire format over `fetch` + Web Streams — never `EventSource`, so it keeps your `auth`, headers, and retries. Each event becomes a `delta` chunk; `await` collects them, `.stream()` yields them live. ```ts import { sse } from 'stitchapi/sse'; const ticks = sse({ url: 'https://api.example.com/ticks' }); for await (const ev of ticks.stream()) { if (ev.type === 'delta') handle(ev.chunk); // a parsed SseEvent } ``` `data` is JSON-parsed when it parses, else kept as the raw string: ### Resumable SSE — sse.reconnect [#resumable-sse--ssereconnect] **Off by default**: with no `sse.reconnect` the engine opens the body exactly once. Turn it on and a dropped stream is reopened, replaying the last seen `id:` as the `Last-Event-ID` request header so the stream continues from where it broke. ```ts const ticks = sse({ url: 'https://api.example.com/ticks', sse: { reconnect: { attempts: 5, delay: '2s' } }, }); ``` `reconnect: true` is shorthand for the defaults; the object form tunes the cap and the fallback delay. A server-sent `retry:` on the dropped connection always wins over both. #### It needs a feed that emits `id:` [#it-needs-a-feed-that-emits-id] Two things must hold before a body is reopened, and both are properties of the stream rather than of the flag: * **It has to have dropped.** A body that ran out is a stream that *finished* — it is never reopened. Only a transport failure mid-flight counts. * **It has to be resumable.** The reconnect replays the last `id:` as `Last-Event-ID`, so a feed that never sends `id:` has no resume point. Reopening it could only re-request the whole body, so it is not reopened either. That second point rules out OpenAI-shaped completions — `data: {…}` frames with no `id:`, terminated by a `[DONE]` sentinel. Turning `reconnect` on for one of those does nothing; it will not replay the completion. Use it for feeds that emit `id:` and honour `Last-Event-ID` — ticks, change feeds, event logs. ## stream [#stream] `stream` is the raw streaming sibling — it hands back the response body decoded per `stream.decode`: `'bytes'` (the default, lossless `Uint8Array` chunks), `'lines'` (UTF-8 lines), or `'ndjson'` (a parsed value per line). The bare decoder is shorthand for the object form — `stream: 'ndjson'` ≡ `stream: { decode: 'ndjson' }` (the opaque `stream: {}` is rejected). ```ts import { stream } from 'stitchapi/stream'; const logs = stream({ url: 'https://api.example.com/logs', stream: 'ndjson', }); for await (const ev of logs.stream()) { if (ev.type === 'delta') console.log(ev.chunk); // one parsed JSON value per line } ``` ### stream.buffer [#streambuffer] Bounds what the decoder may hold for a single un-terminated unit — an unclosed JSON value, a line with no `\n`, or an SSE frame with no blank line — so a never-closing stream can't grow client memory without limit. The scalar is shorthand for the dominant field: `buffer: 4_000_000` ≡ `buffer: { chars: 4_000_000 }`. The cap counts characters of the **decoded text**, not bytes off the socket, so it is not meaningful for `decode: 'bytes'` (raw and unbuffered). Streaming members open on the same auth/retry/throttle spine, with one twist: opening a stream **charges the rate limiter once** but **never holds a concurrency slot**, so a long-lived connection can't pin a [seam](/docs/concepts/the-seam)'s concurrency budget. ## Validating streamed values [#validating-streamed-values] A streaming surface has no single response body, so an `output` contract validates **each `delta` before it is emitted** — never the collected array. A value that fails the schema fails the stream and is never delivered; soft drift (a coerced or undeclared field) surfaces as a leveled `drift` finding and the value still flows, so a `.stream()` consumer only ever sees values that passed the contract. ```ts import { stream } from 'stitchapi/stream'; import { z } from 'zod'; const logs = stream({ url: 'https://api.example.com/logs', stream: { decode: 'ndjson' }, output: z.object({ level: z.string(), msg: z.string() }), // checked per record }); ``` For `sse` the contract describes the event's `data` payload, not the `{ event, data, id, retry }` envelope. `transform` and `pick` reshape a whole buffered body and aren't applied to the delta path. ## download [#download] `download` fetches a file: a `GET` buffered into a `Blob`, with byte progress, a `Content-Disposition` filename (with a URL-last-segment fallback), and a per-call `AbortSignal`. It **never writes to disk** — saving the `Blob` is your call, which keeps it browser-first. ```ts import { download } from 'stitchapi/download'; const getReport = download({ url: 'https://api.example.com/report.pdf' }); const { blob, filename } = await getReport({ signal: controller.signal, onProgress: (p) => console.log(p.loaded, '/', p.total), }); ``` ### onProgress [#onprogress] The per-call `onProgress` callback above receives one of these per tick. It is per-call, not config — a different bar per download: Which phases report depends on the adapter — see [Guide → Adapters](/docs/guides/transport/adapters). The surface owns the request shape, so `method` and `wire.response` are not accepted here — setting either is a compile error rather than a silently ignored value. To download the result of a `POST`, use a plain [`stitch()`](/docs/guides/authoring/stitch) with `wire: { response: 'blob' }`; the only thing you give up is the `Content-Disposition` filename parsing. ```ts // ✗ dead config — the surface always issues a GET and always reads a Blob download({ url, method: 'POST' }); // ✓ the same request, on the surface that actually honours it stitch({ url, method: 'POST', wire: { response: 'blob' } }); ``` Only that one member of the envelope is closed: `wire.array` and the rest stay authorable on a download stitch. ## Both spellings, one engine [#both-spellings-one-engine] Every surface has two co-existing spellings. The monomorphic helper above (`sse(...)`, `download(...)`, …) is pre-bound to its surface and gives surface-specific types. The generic form passes the surface as `kind` on the core `stitch`, so a `seam` can mint members of any surface without forking: ```ts import { seam, stitch } from 'stitchapi'; import { downloadSurface } from 'stitchapi/download'; const file = stitch({ kind: downloadSurface, url: 'https://x/report.pdf' }); const api = seam({ baseUrl: 'https://api.example.com' }); const member = api.stitch({ kind: downloadSurface, path: '/report.pdf' }); ``` Either way the surface is named in `__config` by a stable string `id` (`'sse'`, `'download'`, …), so a stitch's declaration still round-trips to JSON for MCP, the playground, and `llms.txt` — the behaviour hooks stay a runtime detail. An `id` a consumer doesn't recognise degrades to "an http-shaped call of unknown style", never a crash. ## See also [#see-also] # The agent picks the arguments (/docs/scenarios/agent-holds-the-tool) ## The problem [#the-problem] You already call a vendor API from your code. Now an agent needs to call it too, over MCP. The model picks **which** call and **what arguments** — from a prompt that may contain text you did not write. That inverts the usual trust story. An agent tool is an API exposed to an untrusted caller, but it is almost always written as if it were an internal function. Three hazards, all measured in the wild: * **The credential must never reach the model.** A survey of over 10,000 public MCP servers found credentials, keys and PII leaking at rates [exceeding 10%](https://checkmarx.com/learn/mcp-security-risks-real-world-incidents-and-security-controls/). A token in a schema, an argument, a result or an error message is a token in the model's context — and therefore in its output, its logs, and any downstream tool it calls. * **The arguments are attacker-influenced.** A scan of popular MCP servers found 43% with command-injection flaws, 22% allowing path traversal and 30% exploitable via SSRF. The input schema stops being ergonomics and becomes the security boundary. * **The loop is the cost.** One agent scanning a network reached a [$6,531 bill](https://www.nexgismo.com/blog/ai-agent-budget-guards-stop-runaway-api-costs) in days with no hard limits. ## The common solutions [#the-common-solutions] | Approach | What it is | Where it breaks | | -------------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | | **One MCP server per vendor** | Hand-write a server wrapping the API. | Full control — and you write the auth, validation and limits yourself. That is what the >10% leak rate is measuring. | | **One tool per endpoint** | Narrow, typed tools the model picks between. | The safest shape: the schema *is* the allow-list. Costs a tool definition per endpoint, and a lot of context. | | **One generic "run it" tool** | The model names the call and passes arguments. | Compact, and far more dangerous: the argument object becomes the attack surface. | | **Gateway in front** | Policy, quotas and egress rules outside the app. | The enterprise answer. Another hop, and it cannot see intent. | | **Human confirmation on writes** | Ask before anything irreversible. | The one control that survives prompt injection. Needs a place to hook it. | ## What StitchAPI does [#what-stitchapi-does] `stitch mcp` is **code-mode**: three generic tools — `run_stitch`, `list_stitches`, `describe_stitch` — rather than one tool per endpoint. That is the compact-and-dangerous row of the table above, so the two boundaries are worth separating, because they landed differently. ### The credential boundary held [#the-credential-boundary-held] This is the product's central promise and it survived the sharpest test available. Across **34 JSON-RPC exchanges and 30 payload scans (14,529 bytes)** — `initialize`, `tools/list`, `describe_stitch` on all ten stitches, successful calls on `bearer`, `apiKey` in header, query and cookie form, `cookieSession`, a vendor 401 whose **body contained a credential-shaped string**, a validation failure, an unknown stitch, an unknown tool, a malformed JSON-RPC method, and the same run over stdio — **not one of the five held credentials appeared, by value, anywhere.** The controls confirm the calls were real: the same exchanges put `Bearer sk_live_…`, `X-API-Key: ak_live_…`, `api_key=ak_live_…` and `Cookie: SESSION=sess_live_…` on the wire, and the vendor authenticated every one. **And the model cannot forge a header at all.** `sanitizeAgentInput` deletes `input.headers` unless the stitch explicitly declares an `input.headers` schema: six model-supplied headers including `authorization`, `cookie` and `host` reached the vendor as **zero** headers. Even where an operator opts in, the credential header specifically is unforgeable, because `auth` is applied to a clone *after* the merge — a model-set `authorization` was overwritten with the real token on every attempt. This refuted our own starting hypothesis. `engine.ts` does merge input headers over config headers — but on the MCP path the agent's headers are removed before that merge ever runs. ### The argument boundary is yours [#the-argument-boundary-is-yours] Everything else in `input` reaches the request. Five levers, measured on an **ordinary stitch** — one that declares no input schemas: | Lever | Measured | | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | | **A query parameter pinned in the path is a *default*** | `path: '/v1/orders?tenant=acme'` → the model sent `tenant=globex`, **and the vendor returned the other tenant's data** | | **The whole request body of a write**, when no `body` schema | a 999,999 refund | | **Reserved expansion traverses endpoints** | `{+id}` reached `/v1/api-keys` **with the bearer token attached**; ordinary `{id}` correctly encoded it to `..%2F..%2F` | | **A templated endpoint reaches anywhere** | `url: '{+endpoint}'` → `https://metadata.internal/latest` | | **`cookieSession` joins where `apiKey` replaces** | with headers opted in: `SESSION=attacker; SESSION=sess_live_…` — a vendor reading the first pair runs as the model's session | The first row is the sharpest, because `?tenant=acme` in a configured path reads like an operator invariant and is spelled like one. It is `{ ...predefined, ...input.query }`. **A declared input schema now closes it.** When this audit first ran, `validateInput` threw on failure but **discarded the parsed value** — a schema was a check, not a filter, and a `query` validator that returned `{ limit: 10 }` still put `?tenant=globex&limit=10` on the wire. We filed that as [#648](https://github.com/rejifald/StitchAPI/issues/648); [#663](https://github.com/rejifald/StitchAPI/pull/663) fixed it. A declared slot now sends **what its schema returns** — coerced, defaulted, stripped — so the same validator puts `?tenant=acme&limit=10` on the wire: the model's `tenant=globex` gone, the operator's pin restored. Our probe keeps that pinned as a regression check. The surviving caveat is the real security point: **a slot with no schema is untouched.** A schema filters the one slot it is declared on, and an undeclared slot stays the full passthrough the table above measures — declaring `params` says nothing about `query`, and nothing requires a slot to be declared. ### The safe exposure is 47 lines across 3 seams [#the-safe-exposure-is-47-lines-across-3-seams] No fork, no config key. Replayed against the same vendor, the naive exposure sent `?tenant=globex`, `?include=internal_notes` and a 999,999 refund; the safe one sent `?tenant=acme&limit=5` and nothing else, refused the POST with a reason the model can read, and still authenticated every read. ```ts const server = createMcpServer( expose({ getOrder: only(getOrder, { params: ['id'], query: ['limit'] }) }), { adapter: readsOnly(fetchAdapter) }, ); ``` * **`expose`** — the registry object you hand `createMcpServer` *is* the allow-list. It also has to reject a stitch whose configured `name` differs from its key, because `selectStitch` falls back to that name: a renamed stitch stays callable while vanishing from `list_stitches`. * **`only`** — a `Proxy` apply-trap that **rebuilds** the input from an explicit key list before the engine sees it. Since [#663](https://github.com/rejifald/StitchAPI/pull/663) a declared schema filters its own slot, so `only` is no longer forced — it earns its place covering the slots a stitch leaves undeclared, in one line, and as defence in depth on the rest. * **`readsOnly`** — an `Adapter` wrapper, the last seam before the transport, refusing non-GET. `throttle` and `circuit` both apply on the MCP path, because `run_stitch` calls the stitch and the stitch *is* the engine: `throttle: '50/s'` paced ten tool calls; `circuit: { failures: 3 }` turned twenty tool calls into three vendor requests and seventeen fast-fails. ## What StitchAPI does not solve [#what-stitchapi-does-not-solve] 1. **An error message can carry a credential out.** `run_stitch` renders `(e as Error).message` unfiltered, so any text the *transport* writes reaches the model verbatim. With `apiKey({ in: 'query' })` on the default adapter, a DNS failure put `…/v1/metrics?api_key=ak_live_…` into the model's context — **from zero lines of user code**. The fix is `apiKey({ in: 'header' })`; the [auth guide](/docs/reference/auth-strategies) already warns that a key in a URL leaks wherever URLs go, and the model's context is one more place URLs go. 2. **There is no confirmation seam, in either direction.** The server advertises only `capabilities: { tools }` and cannot originate a message, so it cannot ask. The tool descriptors carry no `annotations`, so `readOnlyHint`/`destructiveHint` are absent and the host cannot decide to prompt — and because code-mode puts every endpoint behind one tool name, reading an order and issuing a 25,000 refund arrive at the host as the same `run_stitch` call. User code can **refuse** (`hooks.onRequest` throws; the vendor got zero requests, and `retry: { attempts: 3 }` asked the gate exactly once) but never **ask**. 3. **One tool call is not one request.** `retry: { attempts: 5 }` made five; `paginate` made twelve, with a default ceiling of 50 — and neither is signalled in the tool result. A host that budgets 20 tool calls has budgeted up to 1,000 vendor requests. 4. **No bound expresses cost.** Every limiter is a count (`throttle.rate`, `retry.attempts`, `paginate.pages`, `circuit.failures`) or a duration (`timeout`, `circuit.cooldown`). Nothing expresses tokens, bytes or money — the axis the runaway-bill incidents actually ran along. 5. **`describe_stitch` is a map.** Per stitch it discloses the full internal endpoint URL, the surface, the auth *scheme*, which resilience features are on, and a Mermaid diagram — about 1 KB. Not the credential, not your configured request headers, not the env var name. Whether that is disclosure or documentation depends on who is connected. 6. **`stitch mcp --module ./stitches.ts` exposes everything.** `collectStitches` sweeps up every exported stitch — in our fixture, a write and a login stitch alongside the intended read. The registry filter is a real seam, but the documented starter does not use it. Nothing in `StitchConfig`'s 32 top-level slots excludes a stitch from MCP. The nearest-looking key, `sensitive: true`, is a **cache** opt-out — a stitch carrying it was still listed and still ran. ## See also [#see-also] * [The MCP surface](/docs/surfaces/mcp) — the three tools and the registry * [Auth strategies](/docs/reference/auth-strategies) — why `{ in: 'header' }` beats `{ in: 'query' }` * [Scenario: the webhook you cannot verify](/docs/scenarios/webhook-receipt) — the other surface where the caller is untrusted * [Scenario: one tenant takes the others down](/docs/scenarios/multi-tenant-blast-radius) — keying limiters, which is the containment half of this problem # Submit, poll, download — the async job triangle (/docs/scenarios/async-job-polling) ## The problem [#the-problem] You ask for something slow — a Salesforce Bulk export, a report render, a transcode. The API answers **`202 Accepted`** with a `Location`, and you come back later. That is the [asynchronous request–reply pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/asynchronous-request-reply), and it is not one call but three endpoints with a loop between them: 1. `POST /jobs` → `202`, `Location: /jobs/{id}`, often `Retry-After` 2. `GET /jobs/{id}` → repeatedly, until the state is terminal 3. `GET ` → the payload, often a pre-signed link that expires or is single-use Each step brings its own difficulty. **The next URL is in a response header**, not the body. **Terminal state is in-band** — Salesforce runs `InProgress → JobComplete | Failed`, all at HTTP 200, so failure arrives as a successful response. **The wait is the server's to set**, via `Retry-After`, over minutes to hours. **The budget spans the whole triangle**, not one call. And **a restart loses the job**, which is still running server-side — resubmitting duplicates hours of work. The tell that this has no easy answer: in [jsforce#298](https://github.com/jsforce/jsforce/issues/298) the poll timeout is **hardcoded**, and the documented workaround is to turn the helper off and write the loop yourself. Same request in [go-salesforce#139](https://github.com/k-capehart/go-salesforce/issues/139) and [salesforcer#13](https://github.com/StevenMMortimer/salesforcer/issues/13). ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | | The SDK's built-in waiter | Hardcoded timeout. Fine until the job is big, then unfixable without abandoning the helper. | | Hand-rolled `while` + `sleep` | Correct and universal. Timeout, circuit breaking and tracing see three unrelated calls, not one operation. | | Fixed-interval polling | Hammers the API for hour-long jobs and ignores `Retry-After`. | | Exponential backoff | The right default with no `Retry-After` — but uncapped, the last gap overshoots the finish by minutes. | | Webhook callback | Strictly better where offered, and *additional* work: you still need a fallback poll for missed deliveries. | | Queue + separate worker | The production answer for hour-long jobs, and the only one that survives a restart. Costs infrastructure. | ## What StitchAPI does [#what-stitchapi-does] There is no poll primitive. Polling is spelled as **retry, where the failure is "not done yet"** — a custom [surface](/docs/reference/surfaces) whose `interpret` reads the in-band state: ```ts export function jobPollSurface(clock: Clock): Surface { return { id: 'job-poll', interpret: (res, cfg) => { // verdictOf FIRST, or a 404 comes back as a successful poll. const failed = verdictOf(res, cfg); if (failed) return failed; const state = stateOf(res.body); if (state === 'InProgress') { const after = retryAfterMs(res.headers['retry-after'], clock); return after === undefined ? { ok: false, retry: true, message: 'InProgress' } // capped expo fallback : { ok: false, retry: true, message: 'InProgress', after }; } if (state === 'Failed') return { ok: false, message: `job failed: ${errorMessageOf(res.body)}`, }; return { ok: true, data: res.body }; }, }; } ``` Three stitches then run under [`linked`](/docs/reference/helpers), which chains them into one trace, with a caller-owned `AbortSignal` as the operation deadline. **Measured:** 1 submit → 5 polls at the server's own 300 s pacing → 1 download, 20 virtual minutes, `Failed` terminating on the first terminal body with 17 of 20 poll attempts unspent. `linked` produced **one traceId across three spans**, each parented to the last. A crash mid-poll resumed after 3 polls with **1 submit total**. ## The trade you have to make [#the-trade-you-have-to-make] This is the part worth knowing before you start. There are two constructions, and **you can have one deadline over the whole triangle, or per-hop retry policies — not both.** | | One stitch (hook rewrites the URL) | Three stitches under `linked` | | -------------------------- | ----------------------------------------------------- | -------------------------------------------------- | | Deadline over the triangle | **`timeout.total`** — measured 253 ms | caller-owned `AbortSignal` on every `input.signal` | | Per-hop retry policy | **no** — one stitch is one `retry` block | **yes** — 20 poll attempts, 1 download attempt | | Single-use result URL | burned all 8 shared attempts on the dead link | download retried exactly once | | Trace | 1 span, `attempts: 5` — the three endpoints invisible | 3 spans, `submit → poll → download` | | Concurrency | **unsafe** — see below | safe | The one-stitch form buys the config-level deadline and loses the per-hop split; `linked` keeps both of those and replaces the deadline with a signal you own. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] The hand-rolled `while` loop produces a **byte-identical** request sequence and pacing, in **49 lines against 110**. What the extra lines buy was measured, not asserted: one `start` and one `done` per hop with the polls folded in as `attempts: 3` rather than three unrelated calls, one traceId chaining `job-submit → job-poll → job-download`, and a per-hop retry policy. All of the *semantics* — the state machine, the pacing, the resume — are still yours either way. If you don't need the trace or the per-hop policies, the `while` loop is the honest answer. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **No poll or until primitive.** Nothing in the config vocabulary waits for a state. 2. **`retry.respect` does not reach the body-driven path.** With the server asking for 30 s, the measured gaps were **7 ms** — the computed backoff. The engine reads `Retry-After` only on the status-driven path; a surface must pick the header up itself. 3. **`parseRetryAfter` is not exported.** The HTTP-date form has to be re-implemented by every surface author, and if you don't, the server's pacing is discarded silently. 4. **No operation-scoped deadline field.** `linked` takes a body and nothing else. The budget is an `AbortSignal` you build and thread through every `input.signal`; when it fires, the rejection carries your own abort `reason`. 5. **No operation-level span.** `linked` emits nothing of its own, so a mid-operation failure is attributable to the *step*, never to "the export failed". 6. **Nothing persists the job id.** The store is engine state — throttle, auth, cache. Resume is entirely yours. 7. **Every failure arrives as `StitchError`, and only the deadline is typed underneath.** The engine's live `TimeoutError` rides `error.cause` (the class is unexported — check `cause?.constructor.name`), so "deadline fired" is structurally separable. "Poll budget exhausted" and "the job failed" carry no cause, `status` is `undefined` on all three, and telling those two apart is still string matching. 8. **`paginate` cannot do this** — and not for the reason you'd guess. It *does* loop (the default `items` wraps a non-array body as one item, so the empty-page break never fires), but it cannot **wait**: measured gaps of `0, 0, 0`, with no delay field. A paginated poll also cannot fail — `Failed` is aggregated as just another value. **Two concurrency traps, both measured.** A stitch that rewrites its own URL in a hook is **not safe to call twice at once**: two concurrent calls submitted two jobs, polled the second one twice, and handed *both* callers the second job's result — the first job ran to completion, unread. And a poll surface without a `hooks.onRequest` guard **re-submits**: 4 POSTs, 4 duplicate jobs, under `attempts: 4`. Build one stitch per in-flight job. ## See also [#see-also] # Batch writes that fail one item at a time (/docs/scenarios/batch-partial-failure) ## The problem [#the-problem] You write in bulk — DynamoDB `BatchWriteItem`, Elasticsearch `_bulk`, SQS `SendMessageBatch`, Salesforce sObject Collections. One request carries 25 or 1,000 items, the API answers **HTTP 200**, and inside the body it says some of them didn't land. **The retry unit is smaller than the request.** Every HTTP client retries by replaying the identical request, which here re-applies the writes that already succeeded. The correct behaviour is to rewrite the body to the failed subset, resend, wait longer each round, and report whatever never landed. Three things make it worse than it sounds: backoff is **mandatory** (AWS is explicit — the cause is capacity, so an immediate resend throttles again), failures are **not uniform** (a `429` item should be resent, a `400` mapping error must not be), and the loop needs to hand back **the residue** — the caller needs the items, not just an error. This is the failure mode behind [`elastic/logstash#1631`](https://github.com/elastic/logstash/issues/1631) — "rejected docs in bulk indexing partial failure are **silently lost**" — and [`elasticsearch-py#1004`](https://github.com/elastic/elasticsearch-py/issues/1004), where errors are aggregated *without their data*, so you cannot tell which items to resend. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | The client's built-in retry | Replays **all** items. Fixes 7 by re-writing 93 — duplicate side effects on a non-idempotent endpoint. | | Hand-rolled `while` loop | Correct, and what most teams write. Lives outside the client, so timeout, circuit breaking and tracing stop seeing the real call. | | Vendor SDK helper | Only where an SDK exists, and the policy is theirs — `streaming_bulk` retries `429` only, and drops the failed items' data. | | Check the status, move on | The Logstash bug. Silent data loss, found later by absence. | | One request per item | Trivially retryable, at 100× the requests the batch endpoint existed to avoid. | ## What StitchAPI does [#what-stitchapi-does] **Not `paginate`, however much it looks like the answer.** `paginate.next` genuinely expresses the residue resend — measured: 3 requests for 6 items, **zero** duplicate writes. Then it loses your data. A round in which *nothing* lands aggregates zero items, and the loop treats that as the end: measured **4 of 6 rows never written, `ok: true`, no error**. That is the ordinary response from a table that is out of capacity. Hitting the `pages` cap also returns `ok: true`, so "finished" and "gave up" are the same value — and a residue ledger built inside `next` is stale by one round, naming an item that already landed. The seam that works is `Surface.interpret` — which reads the 200 body and asks for another attempt with a wait that grows — paired with **`hooks.onRequest`, the only place in the library that can change a request between attempts**: ```ts const kind: Surface = { id: 'batch-residue', interpret: (res, cfg): SurfaceOutcome => { // A 500 is a transport failure before it is a batch envelope — and must still // open the circuit. Let the declarative verdict compose first. const failed = verdictOf(res, cfg); if (failed) return failed; ledger.rounds += 1; ledger.landed.push(...landedOf(res.body)); ledger.terminal.push(...terminalOf(res.body)); // 400s — never resent ledger.residue = residueOf(res.body); // 429s / UnprocessedItems if (ledger.residue.length === 0) return { ok: true, data: ledger }; if (ledger.rounds >= rounds) { // Out of rounds. Resolve SUCCESSFULLY with the residue in the payload — // an error would throw the landed items away, and dropping it is the Logstash bug. ledger.gaveUp = true; return { ok: true, data: ledger }; } // `message` is required on the retry arm — it becomes the `retry` // event's detail, so the trace says WHY the round is being re-run. return { ok: false, retry: true, message: `${ledger.residue.length} unprocessed after round ${ledger.rounds}`, after: backoff(ledger.rounds), }; }, }; const hooks = { onRequest: (ctx) => { if (!ctx.req || ctx.attempt === 1) return; // Assign, never mutate: each attempt's request is a shallow clone of one baseReq, // so an in-place edit of `body` rewrites the caller's own array too. ctx.req.body = bodyOf(ledger.residue); }, }; ``` **Measured** against a capacity-limited table: 4 rounds at t = 0, 1000, 3000, 7000 — `abcdef → cdef → def → f` — **zero duplicate writes**, every item landed, and the wait is the engine's own sleep rather than a hidden one. It survives three consecutive zero-progress rounds, which is precisely the case `paginate` drops. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] **It is more code, not less** — 50 lines against 28 for the hand-rolled `while` loop. The trade is what the hand-rolled loop gives up, and this was measured rather than assumed: | | hand-rolled loop | on the surface seam | | --------------------------------- | --------------------- | ------------------------------------------------- | | `start` events for one logical op | 3 | **1** | | reported `attempts` | 1, three times over | **3** | | `retry` events | 0 | **2**, with detail | | circuit breaker | never sees the rounds | **opens after 2 × 500** | | `timeout: { total: 100 }` | bounds each round | **bounds the operation** — cut at round 2, 101 ms | If you don't need any of that, the `while` loop is honestly the smaller answer. Reach for this when the batch call has to behave like one call to everything else in your system. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **There is no batch-residue concept.** Nothing in the config vocabulary expresses "the retry unit is smaller than the request". Everything above is assembled. 2. **`retry` is blind to per-item failure, and harmful when forced.** The status is 200, and `retry.on` receives only the status. `retry.on: 200` typechecks, fires — and made **10 duplicate writes on a batch that never failed at all**. 3. **`paginate` has no wait and no field to declare one.** Six rounds fired at t=0. `throttle` paces them, but as one fixed ratio with no curve — and it paces every other call through that stitch (`pool: 'host'` pushes unrelated reads too). 4. **A growing backoff is user code the engine can't see.** A sleep in `onRequest` works, but 2.5 s of real waiting produced **0 `throttled` events** and no `waited` in the run report. 5. **`SurfaceOutcome` cannot see the request or the attempt number**, so a surface can neither rewrite what it retries nor know it is on its last round. The count has to be kept by hand. 6. **The engine owns no residue channel.** Not the result, the error, `.inspect()`, `.report()`, the event stream, or a trace sink. If you don't capture it yourself, it is gone. 7. **The ledger is per-stitch, not per-call.** Two concurrent calls through one such stitch corrupt each other — measured: both callers told everything landed, two rows written by nobody. Build one stitch per in-flight batch, or add your own scoping. ## See also [#see-also] # The free poll — ETag revalidation and the bodyless 304 (/docs/scenarios/conditional-requests-304) ## The problem [#the-problem] You poll for changes and nothing has changed. HTTP has a way to make that free: keep the `ETag`, send it back as `If-None-Match`, and the server answers **`304 Not Modified`** with no body. On GitHub a 304 **doesn't count against your primary rate limit** — 600 polls, 90% unchanged, cost 60 requests. The awkwardness is that a 304 is a *status* meaning "use what you have", and both obvious readings are wrong. Treat it as a failure and every unchanged poll is an error; treat it as a success and the caller gets `undefined` where the resource should be. The only correct behaviour is to **substitute the previously cached body**, which means the cache and the request path have to know about each other. Four constraints most clients get wrong: * **ETags are per-credential.** GitHub caches them per token. A store keyed only by URL will replay one principal's validator for another. * **ETags are per-page, not per-collection.** A 304 on page 1 of 5 says nothing about 2–5. * **Weak validators compare weakly.** `W/"abc"` and `"abc"` are not interchangeable, and the comparison is the *server's* to make — so the client must not normalize the tag. * **Some servers never match.** Apache's default ETag embeds the file inode, so behind a load balancer revalidation never succeeds and the feature silently does nothing. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | TTL cache only | Never revalidates. Every refresh is billed **and** the data is stale up to the TTL — you pay full price for staleness. | | Hand-rolled ETag store | Correct, and what most teams write. Easy to key wrong; the swap must happen below parsing and validation. | | An HTTP caching proxy | The most standards-correct answer. Adds a dependency or a hop. | | Treat 304 as an error and retry | Actively wrong — turns the success case into an error and re-sends the same validator. | | Ignore conditional requests | What most integrations do. On GitHub it costs 10× the rate-limit budget for identical data. | ## What StitchAPI does [#what-stitchapi-does] There is no conditional-request feature — `If-None-Match` and `304` appear nowhere in the core. What there is, is the one seam that owns a request *and its own response* in a single function: **`Surface.execute`**. It is a one-argument `Adapter` the engine calls *in place of* the transport — a surface with `execute` ignores `StitchConfig.adapter` — so the real transport is handed to the surface's factory and closed over (`transport` below), next to the store. ```ts execute: async (req) => { const key = `${req.method} ${req.url} ${credentialOf(req.headers)}`; // per-credential, see below const cached = store.get(key); if (cached) req.headers['If-None-Match'] = cached.etag; // byte-exact, never normalized else clearValidator(req.headers); // a re-attempt must be able to DROP it const res = await transport(req); if (res.status === 304 && cached) return { ...res, body: cached.body }; const etag = res.headers['etag']; if (res.status === 200 && etag) store.set(key, { etag, body: res.body }); return res; }, ``` The `status === 200` gate on the write is load-bearing. Servers mint ETags on responses you must not learn from — Express fingerprints error bodies too, and CDNs validate error objects — and without the gate one ETagged error writes `{ etag, body: error }` into the store, which every later 304 then serves as `ok: true` data. The full 87-line version this abridges adds the rule the gate can't cover: a 304 with **no** stored body (an orphaned validator — a restarted process, an evicted entry) is refetched unconditionally instead of returned bare, which would rebuild the empty-304 trap by hand. It also bounds the store and keeps the counters (`revalidated` / `stored` / `orphans` / `unvalidatable`) the callout below asserts on. It needs **no custom `interpret`**. The substituted body rides back on a response whose status is still 304, and `classifyStatus` only fails at `>= 400` — so `.inspect().status` honestly reports 304 while `.data` is the resource. **Measured**, ten polls at one-minute intervals with the resource changing once before poll 6: | | billed | versions seen | | ------------------ | ------ | ----------------------------------------------------------- | | no caching | 10/10 | correct, never stale | | TTL cache (30 min) | 1/10 | `[1,1,1,1,1,1,1,1,1,1]` — **never sees the change** | | revalidation | 2/10 | `[1,1,1,1,1,2,2,2,2,2]` — picked up on the poll it happened | Eight of ten polls became free with **zero** staleness. That middle row is the argument: a TTL cache is cheaper *and* wrong, serving a superseded version on 5 of 10 polls. `interpret` **does** run on a 304 — measured with a counter across `[200, 304, 404]`. The engine interprets every response, not just 2xx. (The "`interpret` never runs" finding on [the streaming page](/docs/scenarios/mid-stream-failure) is specific to streams.) So `Surface.interpret` + `hooks.onRequest` is a valid second route; `execute` is just fewer moving parts. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] **The StitchAPI version is longer — 87 lines against 79** for a feature-matched hand-rolled twin, and both produce identical versions, statuses and billed counts on all four shapes (quiet, changed, weak validators, never-matching). So the extra lines are not buying behaviour. They attribute exactly: `credentialOf` and `clearValidator`, two helpers that exist only because the engine hands a surface a shared header record it never case-folds. What the hand-rolled twin lacks is everything that stayed *configuration* on the stitch — `auth`, `output`, `retry`, `timeout`, `seam.as()` and the trace spine. Every one of those would have to be written into the hand-rolled file to match. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **`cache` cannot revalidate — at all.** It is a *value* store, not a response store: the entry is `{ v, s, vary }` and what gets written is the post-`interpret`, post-validation value. No response header, and therefore no ETag, can reach it. 2. **`revalidateOnHit` is a false friend.** It re-checks the stored value against the `output` **schema**, never the network. 3. **A cache hit short-circuits everything below it.** Measured over 3 calls: hooks fired once, `interpret` ran once, one request. The hit spine is `[start, cache:hit, result, done]` — no `request` phase at all — so revalidation can't run under a hit. 4. **The cache cannot store a 304 either.** Forced into its own key, three conditional calls measured `[undefined, undefined, undefined]` — a stored `undefined` reads as a permanent miss. 5. **The one workaround turns caching off.** Folding the ETag into the value via `transform` makes the stitch un-fingerprintable, so it fails closed and refuses to cache. 6. **A surface cannot see the bound principal.** `ResolvedStitchConfig` has no `principal`, and `buildRequest` runs *before* `auth.apply` — so it can't even read the credential header. Only `hooks.onRequest` and `Surface.execute` are downstream of auth. 7. **`buildRequest` runs once per run, not per attempt.** A validator set there is baked into every retry — measured 3 identical validators across 3 attempts and a failed run. 8. **Adding an `output` schema to a bare conditional poll breaks it.** The empty 304 body fails the contract: `ok: false`, `contract violation (drift)`. With substitution in place the schema is handed the real body and never sees `undefined`. 9. **`cache.ttl` ignores an injected `clock`** — it reads `Date.now()`. Measured: one request after advancing a `manualClock` by a virtual hour against a 1-second TTL. **The cross-principal leak, and why it hides.** Against a server with content-derived validators, an ETag store keyed on `METHOD URL` alone leaked: one store entry, and **bob received alice's data** (`viewer: tok-alice`). The reason it survives review is that the rate-limit metrics *improve* while it happens — a 50% 304 rate is exactly what a working revalidator looks like. `cache.tenancy` does not protect a store you wrote yourself. Put the credential in the key, as above. **And the silent no-op.** Against a server minting a fresh validator per response (the load-balancer inode case), 10 polls billed 10, got **zero** 304s, and raised nothing. Assert on it: `revalidated === 0` while `stored === 10` is the only signal you get. ## See also [#see-also] # Rate limits priced in query cost (/docs/scenarios/cost-based-rate-limits) ## The problem [#the-problem] Shopify's GraphQL Admin API meters a **1,000-point bucket refilling at 50 points/second**, and each query has its own price — 11 points for one, 900 for another. Four properties each defeat a different standard tool: 1. **The unit is cost, not requests.** No single requests-per-second is correct for both an 11-point query and a 900-point one. 2. **Overspending answers `200 OK`.** The failure arrives in the body as a `THROTTLED` entry in `errors[]` — never a `429`. Retry policies keyed on status see success. 3. **The wait is arithmetic, not a guess.** Every response carries `extensions.cost.throttleStatus`; the correct wait is `(requestedQueryCost − currentlyAvailable) / restoreRate`. A backoff curve over-waits when the bucket is full and under-waits when it is empty. 4. **The bucket belongs to the shop, not to you.** Another app draining it moves your headroom between two of your own requests ([shopify-api-js#602](https://github.com/Shopify/shopify-api-js/issues/602)), so a local ledger can never be authoritative — it must be overwritten from every response. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | | Retry on 429 + exponential backoff | Never fires. The response is a `200`, and the THROTTLED envelope is returned as data. | | Body-sniffing retry, then exponential backoff | Fires correctly, then ignores the arithmetic the server already supplied. | | Compute the wait from `throttleStatus` | Correct — but needs `extensions`, which GraphQL clients discard when they unwrap `data`. | | Local cost ledger, pause below a threshold | Paces your own traffic; blind to other apps on the same shop. | | Fixed rate limiter (`N/sec`) | Wrong unit. Sized for the worst query it wastes the quota; sized for the average it throttles. | | Single-worker global queue | Correct and common. Costs concurrency and a piece of infrastructure. | ## What StitchAPI does [#what-stitchapi-does] Not `retry`, and not `throttle` — measured, both miss, and the section below says exactly how. The seam that fits is a **custom [surface](/docs/reference/surfaces)**: `interpret` sees every response body before the engine decides anything, and the `SurfaceOutcome` it returns can ask for a retry **after a wait you computed**. ```ts import { graphqlSurface, verdictOf } from 'stitchapi'; import type { Surface, SurfaceOutcome } from 'stitchapi'; export function shopifyCostSurface(ledger: CostLedger): Surface { return { id: 'graphql', buildRequest: graphqlSurface.buildRequest, interpret: (res, cfg): SurfaceOutcome => { const failure = verdictOf(res, cfg); if (failure) return failure; // EVERY response updates the budget — successes carry throttleStatus too, // and the server's number is authoritative because the bucket is shared. const cost = costOfBody(res.body); if (cost) ledger.record(cost); // The 200-with-THROTTLED, and the wait the server's own arithmetic dictates. if (isThrottled(res.body) && cost) return { ok: false, retry: true, message: `THROTTLED — need ${cost.requestedQueryCost}`, after: deficitWaitMs(cost), // ← (requested − available) / restoreRate }; return ( graphqlSurface.interpret?.(res, cfg) ?? { ok: true, data: res.body, } ); }, }; } ``` Pair it with an `onRequest` hook that pauses while the ledger says the next query is unaffordable, and the reactive half only handles what the proactive half cannot predict — another app draining the shop. **Measured:** the computed wait was honored exactly (**6000 ms**, succeeding on attempt 2, where the built-in curve waited 100 ms and failed all three attempts). Against a neighbour emptying the bucket before every single call, **8/8 queries succeeded**, absorbing 8 throttles. The whole thing is **73 lines**. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] The retry loop stays the engine's. Because the cost logic lives in `interpret` rather than in a wrapper around the call, `timeout.total` still bounds the whole thing, the circuit breaker still counts failures, and every wait shows up as a `retry` progress event on the [event stream](/docs/concepts/event-stream). The obvious alternative — wrapping the adapter — was measured going blind: a call that made 2 requests and slept 6 seconds reported `attempts: 1` and emitted **zero** retry events. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **`retry.on` cannot see the body.** The predicate receives exactly one argument, the status number. A 200-with-THROTTLED is invisible to every built-in retry policy. 2. **`retry.on: 200` retries your successes.** The status matcher runs before `interpret` and cannot tell the two apart — measured 3× the requests and 3× the points for one result. 3. **`backoff` has no function form.** A wait computed from the payload cannot enter through it, and casting past the type error is not a way in either: construction **throws `bad backoff`** rather than silently degrading to the default curve, as it once did ([#651](https://github.com/rejifald/StitchAPI/issues/651), fixed by #666). 4. **`retry.respect` reads a `Retry-After` header** that Shopify never sends. 5. **`throttle.rate` cannot express cost.** Requests-per-interval only, minimum spacing, no burst. Approximating the 1,000-point bucket took **18 s** for work the bucket absorbs instantly — and with mixed costs no single spacing is correct for both. 6. **`throttle.delegate` is status-keyed.** The escape hatch the throttle docs point at for vendor-accounted quotas does not reach a body-reported one. 7. **A rejected body never reaches the caller.** On a surface failure the `StitchError` has no `body`, the error event has no field for one, and `.inspect().raw` is `null`. Whatever you need from the body must be captured inside `interpret` or `hooks.onResponse`. 8. **`interpret` is synchronous.** A *distributed* cost ledger cannot live in the seam that otherwise solves this — single process only. **Do not reach for `verdict.flag` here.** It is the one built-in that reads the body for a verdict, so it is the natural guess — and on a throttled Shopify response it is **inert**: the payload has no `data` key, an absent path is "no signal", the 200 stands, and the call returns **`ok: true` with the THROTTLED envelope as your data**. Silent, and it looks like a successful sync. ## See also [#see-also] # The vendor told you for six months, in a header (/docs/scenarios/deprecation-headers) ## The problem [#the-problem] A vendor is retiring the endpoint you depend on. They announced it in a blog post, sent one email, and — if they follow the standards — have been telling you **on every single response** for six months, via `Deprecation` (RFC 9745) and `Sunset` (RFC 8594). **The signal arrives on responses that succeeded.** Nothing failed, nothing retried, no status changed — so every mechanism a client has for noticing trouble points the wrong way. As one write-up puts it: *"The clients that broke never read the blog post — but their code reads your HTTP responses on every single request."* Three specifics make it awkward: **two headers, two formats** (`Deprecation` is a structured-field date `@1735689600`, `Sunset` is an HTTP-date); **it is a hint, not a guarantee**, so failing the call is wrong and ignoring it is also wrong; and **the useful unit is the fleet** — "which of my forty endpoints are deprecated, and which sunsets first" — which no per-call log line can answer. ## Where a response header is reachable [#where-a-response-header-is-reachable] This is the table worth keeping. Measured across every accessor on a **successful** call: | | carries response headers | | ------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `adapter` | **yes** — it built the response, but knows no stitch name and can't change the result | | `hooks.onResponse` | **yes** — the whole `AdapterResult`, plus `ctx.name` | | **`Surface.interpret(res, cfg)`** | **yes** — and it returns the value the call resolves to | | `await` / `.unwrap()` / `.safe()` | no | | `.inspect()` (5 keys) / `.report()` (9 keys) | no — `status`, but no headers | | `StitchError` (5 keys) | no | | `transform` | no — its one parameter is the body | | the **entire event spine** — 4 events, 15 distinct keys | **no** | That last row is the one with consequences: because no event carries a header, a `TraceSink` inherits the same hole. It can only aggregate what a `Surface` folded into the value. `Surface.interpret` is the only seat where a header **and** the returned value are in scope together, which makes it the answer to this scenario and to any other "the signal is in a header" problem. ## What StitchAPI does [#what-stitchapi-does] **Not one config key knows what a `Deprecation` header is.** The answer is three seams and about 112 lines: `Surface.interpret` reads the headers and renders a verdict, a seam-level `trace` sink aggregates by `ctx.name`, and the injected `clock` makes the sunset crossing deterministic. **The fleet view works, and the [scenario-12](/docs/scenarios/intermittent-drift) shape transfers.** One sink configured once on a seam, 500 calls across 5 endpoints: ``` 3 endpoints deprecated (users, search, orders), earliest sunset in 12 days: users ``` One row per endpoint however many calls arrive — and with traffic skewed **200:1** toward the healthy endpoints the report was unchanged, which is exactly what a per-call log line cannot do. **And the tripwire is elegant.** `interpret`'s `{ ok: false }` arm plus `cfg.clock` makes the crossing deterministic to the millisecond — `[sunset−1ms, sunset, sunset+1ms]` measured `["ok", "FAILED", "FAILED"]`. Two things make it safe to deploy, both measured: it does **not** burn retry attempts (5 configured, 1 request made) and it does **not** open the circuit breaker (5 consecutive trips past `failures: 2`, every message still the real one), because `classifyStatus` rules on the status and the transport was healthy. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **No config key, and no reachable parser.** `parseRetryAfter` handles delta-seconds **or** an HTTP-date against an injectable clock and returns ms-until — precisely the `Sunset` requirement — and it is not on any of the 17 published subpaths. The public barrel exports `parseDuration`, `parseBytes`, `parseRate`; none parses a date. 2. **No event carries headers**, so a `TraceSink` sees `null` for a response that carried both — unless a `Surface` folded them into the value. And then an ordinary `output` contract that doesn't declare the folded field **deletes it again** before the `result` event fires, with no warning. 3. **No API mints a levelled finding.** A `Validator` returns a value or *issues*, and an issue is fatal. The one door onto the drift channel is folding a field into the value and letting an `output` contract report it `info | undeclared` — which is non-fatal and re-levellable, but **carries neither the header value nor the endpoint**. (Smuggling the date into the *path* works — `_sunset_2026-01-01` — at the cost of a new finding path per date.) 4. **No de-duplication primitive.** 600 calls produced **360 log lines carrying 3 distinct facts**; the built-in `loggerSink` is louder at 2400. `levelOf` can drop events but cannot answer "have I said this already" — it's a pure function of one event. A 3-line `Set` latch fixed it; nothing on the barrel latches, samples or de-duplicates an observation. 5. **The line count goes against the library** — **132 lines against 81** hand-rolled for identical output. Wiring alone favours it (20 vs 52); the total doesn't, because reaching `res.headers` costs a `Surface` object and holding the per-endpoint map costs a `TraceSink` object, where a hand-rolled client does both inline in the method that already had the response. **`hooks.onResponse` can rewrite the call, and the hooks guide says it cannot.** The guide's "hooks never change what a stitch returns" is true only of the *return value*. `ctx.res` is the engine's live object, read again by `interpret` afterwards — so mutating `res.body` added a key to the caller's value, mutating `res.status` turned the vendor's **200 into a thrown `HTTP 503`**, and mutating `res.headers` made a surface read `"REWRITTEN BY HOOK"` instead of the real `Sunset`. **A seam-level `kind` is a compile error that works perfectly at runtime.** `TS2353: 'kind' does not exist in type 'SeamConfig'` — yet members inherited the surface and folded correctly. A typed codebase writes the surface on all 40 members for a capability that already works from one. **`ctx.name` defaults to the literal `"stitch"`**, so two unnamed endpoints silently merge into one row at the sink. **A cache hit re-serves a notice captured on the one wire response** — 9 of 10 rows were a replayed header, and a long TTL will report a passed sunset as "in 12 days". And **`Date.parse("@1735689600")` is `NaN`**, so the naive parser reads `Sunset` correctly and reports *no deprecation* for the format RFC 9745 actually mandates. ## See also [#see-also] # The migration you have to run twice (/docs/scenarios/dual-run-migration) ## The problem [#the-problem] Your vendor is retiring v1. [Scenario 17](/docs/scenarios/deprecation-headers) is how you found out; this is what you do next. You cannot flip on faith, so you run both against real traffic, compare, and cut over when the diff goes quiet. **Every shadow-traffic guide in the field is written for the service owner.** The canonical architecture mirrors at the gateway — client → proxy → primary, with a copy to the shadow and a shadow database beside it. You own the gateway, both services and both datastores. As the **consumer** of a third-party API you own none of that, and every term changes: * **There is no proxy to mirror at.** The duplication happens in your own client code, on the call path, which is exactly where you cannot afford it to go wrong. * **The shadow spends the vendor's meter.** "Sample heavily" is cost control, not statistics. * **You cannot shadow a write.** A mirrored `POST /charges` charges twice — so the technique is read-only, and writes are the calls you are most afraid of migrating. * **Telling a real diff from a benign one is the actual work.** A v2 that renames `created` to `created_at`, returns ISO instants instead of epochs and orders an array differently is **correct**, and diffs on every call. ## The common solutions [#the-common-solutions] | Approach | What it is | Where it breaks | | ----------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------- | | **Mirror at the gateway** | Proxy duplicates the request. | The standard answer, unavailable when the endpoint is someone else's. | | **Dual-call in the client** | Issue both, return v1, log the diff. | Available to you — and now the shadow is inside your latency and failure budget. | | **Offline replay** | Capture v1 traffic, replay against v2 later. | No user impact, and no live comparison. | | **Diff in a batch job** | Log both, compare nightly. | Cheap and slow. A regression lives a day. | | **Trust the changelog** | Read the migration guide, flip. | Free, and the reason this scenario exists. | | **Sample a small percentage** | Shadow 1–5% of reads. | The cost control that makes it viable. Needs a spelling. | ## What StitchAPI does [#what-stitchapi-does] ### The safe dual-run is 69 lines across 5 seams [#the-safe-dual-run-is-69-lines-across-5-seams] Replayed against the same vendor and the same flaky v2, the naive construction and the safe one differ **0-of-4 versus 4-of-4** user-facing calls succeeding. The naive version — `all([v1, v2])` under one seam, both versions on `/customers` — propagated v2's 500 to the caller twice, then fast-failed v1 on **v2's** breaker twice, and along the way put a 90 ms shadow on a 10 ms call's critical path and sent v2's parameter name to v1. The safe version needed no fork and no new config key: * **`readsOnly` on the shadow's adapter only** — an 8-line wrapper. Three shadow write attempts (a plain POST, an `llm`-surface call, a `.with()`-bound handle) reached the wire **0 times**, while the primary's own POST still succeeded. * **A seam-level `throttle`** — one bucket by default, because `seamBucket` re-keys every acquire onto the seam id, so the meter adds up across both versions. * **Distinct `circuit.key` strings** — the shadow tripped its own breaker and the primary never noticed. * **A hand-written normalizer** — 7 raw diff ops per call became exactly 1, the planted regression, reported with both values. * **A floated `.safe()` at the call site** — the one spelling that is simultaneously off the critical path, unable to reject, unable to cancel the primary, and eager enough to actually run. ### A `url` thunk moves more than the base URL [#a-url-thunk-moves-more-than-the-base-url] `url` is `string | (() => string)`, it carries the **complete endpoint**, and `{param}` interpolation still applies to a thunk-supplied URL. One flag moved `/v1/customers/{id}` to `/v2/customers` between calls with no redeploy. ## What StitchAPI does not solve [#what-stitchapi-does-not-solve] ### One of five isolation channels is safe by default [#one-of-five-isolation-channels-is-safe-by-default] | channel | safe by default? | what it takes | | ---------------- | -------------------------------------------------- | ---------------------------------------------------- | | **retry budget** | **yes** — a per-call loop counter | — | | **latency** | no — `all()` cost **+109 ms** on a 13 ms call | don't await the shadow; no combinator does this | | **thrown error** | no — `all()` threw `StitchError` | `void v2.safe(input)` | | **cancellation** | no — the primary measured `aborted: true` | never put the shadow in `all()` | | **circuit** | no — see below | distinct `circuit.key`; never unkeyed `pool: 'host'` | The cancellation channel was not on our list of things to check, and it is the most dangerous: `all()` aborts the in-flight **primary** when the shadow settles first. ### The circuit is a key collision, not a shared default [#the-circuit-is-a-key-collision-not-a-shared-default] More precise than "resilience state is shared." Identity is `(store) × ('circuit:' + (circuit.key ?? name ?? path ?? 'stitch'))`. Across five configurations the primary got `1, 1, 0, 0, 1` requests — isolated standalone and under a seam with distinct paths, **broken** with a seam and the same path, and **broken** under `pool: 'host'`. Both failing cases are ordinary dual-run shapes: v1 and v2 usually share a path and differ by base URL, and they are usually on the same host. **The `pool: 'host'` trap.** It is the setting that makes cost accounting correct across two stitches — and it silently re-keys the **breaker** onto the host too, which is exactly the configuration measured fast-failing the primary on the shadow's breaker. Use a seam-level throttle instead: it pools correctly and leaves the circuit keyed per path. ### The rest [#the-rest] 1. **The combinators broadcast one input.** `all`/`any` build every member's input from the one group input, so the shadow received `/v2/customers` — **no id**. Adding the id for v2 made v1 send `/v1/customers/cus_7Q2?customer_id=cus_7Q2`. This is [#643](https://github.com/rejifald/StitchAPI/issues/643), and a dual-run is the case that needs the opposite. 2. **`.with()` survives the broadcast but binds a constant.** A group built once and called twice left the shadow pinned to `cus_7Q2` while the primary followed `cus_ZZZ`. 3. **A bare `void v2(input)` sends zero requests.** `StitchResult` extends `PromiseLike` (`types.ts:2007`), so nothing runs until `.then`. No request, no rejection, no `unhandledRejection` — the dual-run silently compares nothing. Same root cause as [#660](https://github.com/rejifald/StitchAPI/issues/660); `void v2.safe(input)` is the spelling that works. 4. **No response-vs-response comparator is reachable.** Of 33 root exports the only comparison-shaped one is `drift()`, which takes a *schema*. The real primitives exist — `diff(before, after)` and `classifyDiff(a, b, opts)` — and neither is exported from any of the 17 subpaths. A hand-written replacement is 23 lines, and is **better** for this: `classifyDiff` renders the planted regression as `"number -> number"`, a type delta with no numbers in it. 5. **`DriftOptions.ignore` is suppression, not relevancy.** Four clauses take 7 diff ops to 1 — and measured, the clause silencing a benign tag reorder also silences a real tag change, and the one silencing the rename also silences a v2 reporting the wrong instant. There is no aliasing, no unordered-array comparison, no coercion hook and no tolerance anywhere in the tree, so a filter that still catches what it should is 24 lines of user code. 6. **Sampling is not expressible.** No sample/ratio/percent slot exists on any subpath. The shadow doubles consumption exactly — 20 logical calls became 40 vendor requests, **2.000×** — and 5 lines of user code took that to 1.05×. 7. **No write guard exists.** An unguarded dual-run of `POST /charges` sent **2 charges** with no config key, type error or runtime nudge objecting. The only method-shaped option is `cache.methods`, which gates cacheability and let the POST through. A construction-time gate on `__config.method` is also not enough: the `llm` surface reports `method === undefined`, passes the gate, and POSTs. The guard has to sit at the `Adapter`, below every authoring surface. 8. **Cutover cannot be one flag.** A thunk moves the URL, but the input mapping is caller-side (after the flag, a v1-shaped input against the v2 URL silently produced `/v2/customers` with no id and nothing threw) and `output` is resolved once at construction (the same stitch went from `ok: true` to `ok: false` against a v1-shaped schema). The real cutover is a 4-line selector over two whole stitches. ## See also [#see-also] * [Scenario: the vendor told you for six months, in a header](/docs/scenarios/deprecation-headers) — how you learn the migration is coming * [Scenario: failing over to the backup provider](/docs/scenarios/provider-failover) — where the one-input broadcast was first measured * [Scenario: one tenant's revoked token, everyone's outage](/docs/scenarios/multi-tenant-blast-radius) — the keying problem this scenario runs into again # The signature that expired in your own queue (/docs/scenarios/expiring-signatures) ## The problem [#the-problem] You call a service that requires **signed requests** — S3 or any AWS API over SigV4. The signature covers a timestamp, and the server rejects anything more than **five minutes** off its own clock. That window exists to stop replay attacks and is not negotiable. There are three ways to fall outside it, and only one is your clock: 1. **The clock drifts.** Containers inherit the host's time at start and never re-sync. Retry makes it *worse* — the same stale clock produces the same invalid timestamp every attempt. 2. **The signature ages in a queue — yours.** Sign, then hold: behind a rate limiter, a concurrency cap, a backoff. AWS's own answer: *"The SDK signs the request, then puts it in a queue. If the request is pending for more than 5 minutes, the signature expires."* The fix filed against botocore ([#149](https://github.com/boto/botocore/issues/149)) is to generate the timestamp per signing operation. 3. **The retry replays a stale signature**, if signing happens once per call rather than per attempt. And the compounding detail: `RequestTimeTooSkewed` is a **403**, so it reads as "auth problem, retry it" — the failure most likely to be retried is the one retry cannot fix. ## What StitchAPI does [#what-stitchapi-does] **Cases 2 and 3 need no user code, because the ordering is right by construction.** Inside the attempt loop, the throttle acquire sits *above* the auth apply: ``` engine.ts:657 const { waited } = await acquireWithin(…) ← the queue wait engine.ts:677 await cfg.auth.apply(req, …) ← signing engine.ts:680 await cfg.hooks?.onRequest?.(…) ``` and each attempt gets fresh headers off the *unsigned* base request. **Measured:** | | signature age on arrival | statuses | | ------------------------------------------------------------- | -------------------------------------- | ------------------------ | | 4 calls behind `rate: '1/2m'`, granted at 0/2/4/6 virtual min | **0, 0, 0, 0 ms** | `200, 200, 200, 200` | | the same calls **pre-signed** (the botocore shape) | 0, 2, 4, **6 min** | `200, 200, 200, **403**` | | held 6 min behind `concurrency: 1` | **0 ms** | `200` | | 3 attempts 6 min apart, long backoff | **0, 0, 0 ms** — 3 distinct signatures | | | a 10-minute `Retry-After` park | **0 ms** | `200` | **A StitchAPI throttle cannot expire a signature.** The circuit breaker doesn't either — it fast-fails *before* signing, so three blocked calls produced **zero** signings. ## Clock drift is still yours — 26 lines [#clock-drift-is-still-yours--26-lines] Per-attempt signing re-mints the *same wrong time*: measured, 4 attempts, 4 identical 600,000 ms skews. The correction AWS SDKs implement — learn the offset from the server's `Date` header, re-sign — is reachable through `AuthStrategy.shouldRefresh`/`refresh`: ```ts shouldRefresh: (res) => res.status === 403 && isSkew(res), refresh: async () => { /* offset learned from the Date header, via a closure */ }, ``` **Measured:** the offset was learned (600,000 ms), the *same attempt* re-signed, and the call returned `200` — costing **no retry budget**, because a refresh re-runs the attempt rather than consuming one. The offset persists, so the next call needed **one** request. Assembled: **4 of 4 calls succeeded through a 10-minute host drift *and* a 6-minute rate-limited queue**, worst signature age 0 ms, breaker never opened — 26 lines in two declarations, all of it for the drift half. With no user code at all the same workload gave `["403", "403", "503", "503"]`. **`hooks.onRequest` runs *after* signing** (`:680` vs `:677`) — and it is the only user-code seam that does. A hand-rolled pacing gate there **re-creates botocore#149 inside a library that doesn't have it**: measured, a 6-minute wait in `onRequest` aged the signature 6 minutes and got a `403`. Pace with `throttle`, never with a sleep in a hook. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **Clock drift itself.** The library re-signs faithfully with whatever clock you have. 2. **Skew correction has no config vocabulary** — it is `shouldRefresh`/`refresh` plus a closure, because `refresh` cannot see the response that triggered it. The offset has to be smuggled out of `shouldRefresh`. 3. **A skew 403 counts as a circuit failure.** A fault *inside your own process* opens the dependency's breaker, and the half-open probe then reports `RequestTimeTooSkewed` rather than the fault that opened it. 4. **A breaker does not shed a burst already queued behind a throttle** — the circuit phase is read before the throttle wait. Measured: 4 concurrent calls all reached the wire over 6 minutes *after* the breaker opened; the same 4 issued sequentially stopped after 2. A fifth gap this audit found is gone. `awsSigV4` used to stamp `x-amz-date` from `new Date()` rather than the injected clock, so a skew test on a `manualClock` was impossible: 600 virtual seconds moved the shipped stamp **0 seconds**, and under a default `manualClock()` **0 of 3** calls were accepted. Filed as [#658](https://github.com/rejifald/StitchAPI/issues/658), fixed by [#667](https://github.com/rejifald/StitchAPI/pull/667): the signer now stamps from the stitch's [injected clock](/docs/guides/testing/mocking) — the same seam that drives retry, throttle and token freshness — so the same measurement moves **600 seconds** and a SigV4 stitch is testable on a `manualClock`, **3 of 3** accepted. One residual: a default `manualClock()` starts at epoch 0 and signs `19700101T000000Z`, so seed it (`manualClock(Date.now())`) when the stamp must be plausible to a real endpoint. **`verdict: { accept: [403], flag: 'ok' }` swallows the skew error.** The recipe that works for a 401 in [the multi-tenant scenario](/docs/scenarios/multi-tenant-blast-radius) does not transfer: `verdict.flag` is three-state, an **absent** flag means "no signal", and AWS error bodies carry no flag. Measured: `ok: true`, with `RequestTimeTooSkewed` handed to the caller as data. Six lines of `Surface.interpret` fix both this and finding 3 above. Also: **`backoff.max` defaults to 10 s**, so `base: '6m'` silently waits 10 seconds. Protective here — it cost this scenario's proofs a false negative. ## See also [#see-also] # Scenarios (/docs/scenarios) A scenario is a problem people actually hit against a real API, picked because the honest answer is *not* a one-liner in any library. Each page states the problem, lays out the solutions the ecosystem has converged on and what each one costs, shows the StitchAPI shape next to them — and then says plainly what StitchAPI does **not** solve, so you can see the remaining work before you commit to it. Where a [recipe](/docs/recipes) shows a task with a known good answer, a scenario shows a problem where the answer is a trade-off. Every claim on these pages is backed by a script that was run offline against the real runtime, not by reading the source. # The vendor changed the shape for 5% of responses (/docs/scenarios/intermittent-drift) ## The problem [#the-problem] A vendor ships a response-shape change — but not all at once. A canary at 5% of traffic, then 25, then 50. Or not a rollout at all: the shape varies *by data*, like a geocoder that returns `null` for `formatted_address` only on ambiguous queries. **Intermittent breakage is harder than total breakage.** A change that breaks every call is found in minutes and rolled back. A change that breaks 5% looks like flakiness, sits in the backlog for a week, and gets fixed once someone spots the pattern. Four change classes, and treating them alike is the mistake — addition is non-breaking by every published policy, removal and type change are breaking, and a field becoming nullable is warning-level and the most intermittent of all. The dangerous one is the type change, because a naive cast produces a **plausible** value: a payment provider moves `transaction_id` from `12345` to `"12345"`, your code casts to int, gets `0`, and processes a **$0 transaction**. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ------------------------------- | --------------------------------------------------------------------------------- | | Strict schema validation | Catches everything — including the added field that broke nothing. Alarm fatigue. | | Parse loosely, cast defensively | Never alarms, and manufactures the `$0` transaction. | | Contract tests in CI | Can't see a canary that started after your deploy. | | Level the findings | The right model — needs a vocabulary most validators lack. | | Log and aggregate | The only way to see 5% → 25%. Needs the finding to name the field. | | Pin a vendor API version | The real fix where offered; useless against data-dependent nulls. | ## What StitchAPI does [#what-stitchapi-does] **The default is safe, and that is the headline.** Against `z.number()`, a `transaction_id` that arrives as `"12345"` produces `error | invalid | transaction_id | Invalid input: expected number, received string` and the **call fails** with `data: null`. Even `z.coerce.number()` on `"abc"` fails — Zod rejects `NaN`. StitchAPI does not manufacture a $0 charge on its own. **The precision is excellent.** Over 100 calls where a geocoder returned `null` on 5 of them, drift fired on **exactly** calls `[20, 40, 60, 80, 100]` — matching the vendor's own ledger, zero false positives on the other 95, each finding naming `formatted_address` with `null -> string`. **And aggregation is a real seam**, which is what makes an intermittent change actionable. A `TraceSink` sees every event of every call, and `ctx.spanId` lets you hold per-call state honestly: ```ts const charges = stitch({ url: 'https://api.vendor.com/charges/{id}', output: drift(StrictCharge, { level: { undeclared: 'verbose' } }), trace: new DriftRate({ window: '15m' }), }); ``` **Measured:** `5.0% of calls: warn|coerced|transaction_id|null -> number (5/100, 5 landed 0)`, and on a rolling window the same field widening **5.0% → 25.0%** as the canary expanded. Across six workloads at 100 calls each: silent on a 100% *addition* rollout, one alert line per breaking class at 5%, and **zero $0 charges**. The same six workloads against the soft schema teams write for availability kept 100% of calls and produced **ten** $0 charges. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **A `coerced` finding cannot say whether the coercion was destructive.** `"12345" → 12345` and `"abc" → 0` emit **byte-identical** findings — `warn | coerced | transaction_id | string -> number` — because `detail` is `kindOf(old) -> kindOf(new)` and values never appear in a finding. Only joining `drift` to `result` on `ctx.spanId` in a sink separates them. 2. **The nullable class is inexpressible.** "Nullable is a warning, value intact" — the fourth industry class — has no spelling. `.nullable()` produces **nothing at all**, so the 5% rollout is invisible; the strict schema turns it into a 5% error rate that also discards the four fields that were fine. A hand-rolled classifier beats `DriftOptions` on exactly this row. 3. **`level` is keyed by mechanism, not by change class.** It takes `undeclared`/`coerced`/`defaulted` — of which only *addition* maps 1:1 to an industry class. Removal, type change and nullability each land on a kind decided by **your schema**, so their loudness is a schema decision rather than a severity one. 4. **No per-path severity.** `ignore` is the only path-aware lever and it is on/off. "Coercion on `transaction_id` pages, coercion on `description` doesn't" has no spelling. 5. **A soft finding cannot be promoted to fatal through the type.** `error` isn't in the soft half of `DriftLevel` — `Exclude` (a cast past it does work at runtime). 6. **Soft findings are invisible on the awaited path.** `await` and `.safe()` carry nothing — `StitchError` has no `findings`, and a hard failure gives only a generic `contract violation (drift)`. The trace sink for the *same* run named the field and both types. `drift()` with only `.safe()` does nothing for you. 7. **A schema strips what it doesn't declare.** The added field in the addition case is `undefined` on `data`; reading it needs `.inspect().raw`, which is a fresh request. **Two ordinary spellings manufacture the $0 charge.** `z.coerce.number()` maps **`null` → `0`** with no `.catch()` involved, because `Number(null) === 0`. Measured: `null`, `""`, `" "`, `false` and `[]` all coerce to exactly `0`; only `"abc"` rejects. And `.catch(0)` hands the caller `0` for *anything*. `.default('usd')` on a **removed** field is the same shape — it fabricates a value the vendor never sent, at `verbose`. On money, prefer a failed call to a plausible number. **A cache hit emits no drift, so caching divides your drift rate by the miss ratio.** Measured: 5 calls against a vendor drifting on **100%** of responses reported **20%**. Two smaller counting traps: findings are not calls (2 findings on one response reads as 200% unless you collapse on `ctx.spanId`), and `.report()`/`.inspect()` are **fresh probes** — they cost a request, tick your denominator, and answer about a different response. `.report()` called right after a drifting call reported **zero** findings. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] **A wash on size — 93 lines against 92.** But the halves are not alike: detection is **9 declarative lines against \~35**, while aggregation is \~84 lines of user code either way, because the library counts nothing. What the hand-rolled 92 lack is the resilience stack, measured here as one `retry` line absorbing 8 × `503` across the canary while the rate still counted **100 logical calls out of 108 wire requests**. ## See also [#see-also] # The export that eats the heap (/docs/scenarios/large-response-memory) ## The problem [#the-problem] You call an export endpoint. The vendor hands back one JSON array with tens of thousands of rows. You `await` it, parse it, iterate. Then the catalog grows and the process dies. **Parsing costs several times the wire size**, because `JSON.parse` holds the whole string *and* builds the object tree. A documented sync of 22,000 products in an **84 MB response exhausted \~2.1 GB** on a 4 GB VPS; the streaming rewrite ran at **180 MB peak**. This failure mode is unlike every other scenario here. Nothing returns wrong data — the process just dies, usually after a dataset crossed a threshold nobody was watching. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ----------------------------- | ------------------------------------------------------------------------------------------------------------- | | `await` the JSON | Dies above a size you can't predict, and a chunked response has no `Content-Length`. | | Ask the vendor for NDJSON | The correct fix where offered. Most REST endpoints don't. | | Structural streaming parser | The real answer for one giant array. A dependency, and fiddly to assemble. | | Paginate instead of exporting | Bounded — and inherits [every pagination problem](/docs/scenarios/unstable-pagination), plus N× the requests. | | Raise `--max-old-space-size` | Moves the cliff toward you as data grows. | | Batch and release references | Necessary alongside streaming; useless if the parse already buffered. | ## What StitchAPI does [#what-stitchapi-does] **It ships a genuinely O(1) NDJSON decoder.** Driven directly, `decode: 'ndjson'` held **0.8 MB of retained heap for 1,000,000 rows and 214 MB of wire**, moving less than 15% across a 1000× change in workload. That is the real thing. **And the engine retains every chunk one line later.** Through `runStreaming` the same decoder is linear — 3.5 MB → 30.2 MB from 10k to 100k rows — because every delta is pushed onto an accumulator so the terminal `result` can mirror the whole spine. **`.stream()` is not a memory fix.** Measured: **30.2 MB iterating vs 33.5 MB awaiting** — the same number twice. The accumulator is *inside* the generator both accessors drain, and no option disables it. The engine's own code comment recommends reading incrementally via `.stream()` for exactly this reason; the measurement says that mitigation doesn't work. So the seam that does work is `Surface.stream` — not to replace the decoding, which is already O(1), but because the engine retains whatever that hook *yields*. Yield one small receipt per batch instead of one row per row: ```ts const rows = stitch({ url: 'https://api.vendor.com/export.ndjson', kind: batchedExport({ size: 1_000, onBatch: writeToDb }), // yields a receipt, not rows stream: 'ndjson', }); ``` **Measured:** **1.3 MB retained for 100,000 rows against the buffered baseline's 53.8 MB — a 40× cut — and flat**: 1.3 MB at 1,000 rows, 1.4 MB at 100,000. Under a 96 MB heap ceiling, 400,000 rows that killed the buffered path outright processed completely at 1.3 MB. Fairness on the baseline: the buffered multiplier measured **2.5×**, and it is `JSON.parse`'s, not the library's — StitchAPI's overhead over a bare `JSON.parse` of the same bytes was **0.2 MB**. It adds no copy. It also removes none. ## `decode: 'json'` on one array: right answer — and, since #665, right memory [#decode-json-on-one-array-right-answer--and-since-665-right-memory] The structural decoder's **emission was always correct**, and impressively so — one delta per element, holding up under `,` `]` `}` inside string values, escaped quotes, embedded newlines, pretty-printed multi-line records, deep nesting, and 1-character chunk boundaries. Its memory was not. The decoder retained the whole array text at \~0.9× the wire in quadratic time, and **tripped its own default cap mid-export**: a 60,000-row array delivered 37,312 rows and then `error`/`done(ok: false)` under *"a malformed or never-closing value was streamed"* — which the vendor did not do. That defect was measured here, filed as [#659 §2](https://github.com/rejifald/StitchAPI/issues/659), and **fixed by [#665](https://github.com/rejifald/StitchAPI/issues/665)**: a top-level array records no compaction floor of its own, so emitted elements are released as they close. **Re-measured after the fix:** a 100,000-row single array decodes **flat — 0.6 MB retained, under 3% of the 21.4 MB wire, in linear time — and finishes on the default cap**; the same records as concatenated top-level values measure alike. The cap no longer trips on a well-formed array of any length: it bounds **one element**, so an array is capped by its largest element, not its row count. (Node v24.18.1, arm64 macOS — absolute numbers differ elsewhere; the shapes don't.) What the fix does not change: through the engine, `decode: 'json'` now costs exactly what `ndjson` costs — 30.2 MB at 100,000 rows either way — because the accumulator above it still retains every element. The decoder stopped buffering; the engine hasn't. What it does change: the batched seam above no longer depends on being offered NDJSON — over one 100,000-row array the same surface runs at **1.4 MB**, where it used to pay 19 MB for the decoder's buffer sitting upstream of the seam. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **No memory-bounded path is reachable from config alone.** Every decoder × every accessor is O(N) through the engine. 2. **No size guard, threshold, warning or event on the buffered path.** A 21 MB response emits the same four events a 40-byte one does. When the wall arrives it is V8's: 400,000 rows under a 96 MB ceiling gave `FATAL ERROR: … JavaScript heap out of memory`, **exit 134** — no catchable error, no `error` event, no `finally`. 3. **`stream.buffer.chars` is a malformed-input guard, not a budget.** It bounds one line or one un-closed value — 20,000 well-formed rows streamed cleanly through a **1,000-character** cap while the engine accumulated all 20,000. On a buffered stitch it typechecks and is **inert**. 4. **`pick` and `transform` are silently inert on a stream** — `transform` called **zero** times over 200 deltas. No `info`, no drift finding, no throw. A stitch that carries them and is later switched to `kind: stream` keeps compiling and quietly stops reshaping. 5. **`output` on a stream validates without transforming.** The buffered path serves the validated value; the streaming path keeps only the errors. The same coercing schema reshapes your data on `await` and silently does not on `.stream()`. 6. **A failing row is a circuit breaker, not a filter** — `contract violation (drift)`, the stream ends, and the rest is never read. 7. **No batching primitive.** There's no `paginate`-style chunking for streams; the 75 lines above are yours. **`stream({ kind: mySurface })` silently drops your surface** — `stream()` overwrites `kind` after spreading your config. Measured: 1,000 raw rows, **zero** through the surface, no error. Spell it `stitch({ kind })`. And a backlogged socket is **invisible to `heapUsed`** — a producer that ignores `desiredSize` puts the body in the stream's internal queue, which is external memory. Watch `arrayBuffers`. ## See also [#see-also] # A stream that fails after 800 tokens (/docs/scenarios/mid-stream-failure) ## The problem [#the-problem] You stream a completion to a user. Eight hundred tokens in, it stops. The request did not fail — `200 OK` went out with the *first* token, and the status line was spent before anything went wrong. Every later failure has to arrive **in-band**, as an SSE frame, or as nothing at all when the socket simply drops. Three consequences, all awkward: * **Retrying is not neutral.** Replaying re-runs the model — you pay for the first 800 tokens *and* their replacement — and it duplicates content the consumer has already accumulated. The user watches the answer restart. * **"Ended" and "ended early" are the same shape.** A complete OpenAI stream is terminated by `[DONE]`. A truncated one just stops. Nothing else distinguishes them. * **Resumption mostly isn't offered.** SSE's `Last-Event-ID` needs the server to put `id:` on every frame. OpenAI-style completion chunks carry none, so the standard mechanism does not apply to the most common streaming API there is. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ----------------------------------- | ------------------------------------------------------------------------------------------ | | Retry the whole request | Pays twice, re-runs the model, duplicates content in any accumulator. The answer restarts. | | Never retry a stream | Safe and common. Turns every transient blip into a visible failure. | | Resume via `Last-Event-ID` | The correct mechanism — and unavailable on LLM APIs, which emit no `id:`. | | Continuation prompt | The pragmatic LLM answer. Costs another call, and the seam shows in the output. | | Buffer everything, emit at `[DONE]` | Makes truncation detectable and retry safe — and discards the entire point of streaming. | | Check the sentinel | Necessary in all of the above. Cheap, and routinely forgotten. | ## What StitchAPI does [#what-stitchapi-does] **If your stream is resumable, this is one flag and it is correct.** A feed that emits `id:` on every frame, dropped after 2 of 5 tokens, with `sse: { reconnect: true }`: ```ts const feed = sse({ url: 'https://api.example.com/events', sse: { reconnect: true }, }); ``` Measured: the consumer saw `ABCDE` — five deltas, **zero duplication** — and the reopened request carried exactly the right header, `Last-Event-ID` moving `(none) → t2`, where `t2` was the last id delivered before the drop. Server pacing is honoured too: a `retry: 9000` frame produced 9000 ms gaps, overriding the authored `reconnect.delay`. **`reconnect` cannot help an LLM stream — resuming needs `id:`.** With no `id:` on any frame there is nothing to replay as `Last-Event-ID`, so the engine does not reopen. Measured on an OpenAI-shaped stream with `sse: { reconnect: true }`: a completed stream is 1 open, `ABCDE` once, one `[DONE]`, and a mid-body drop surfaces its error with the partial kept (`ABC`, `done(ok: false)`) instead of being reopened. An earlier build replayed the whole completion here — measured by this audit, filed as #640, fixed in #647 — so the flag on an id-less stream is now a no-op, not a replay. `sse: true` is the same flag. What the LLM case actually needs is below. For the LLM case the answer is **two seams**: `Surface.execute` for connect-only retry, and the surface's `stream` hook to require the sentinel and reject in-band error frames. ```ts const TRANSIENT = [429, 502, 503, 504]; // the engine's own default retry.on // Retry the CONNECT phase only — the one replay that is unambiguously safe. // `transport` is the injected adapter (a surface with `execute` replaces // `config.adapter`), and adapters resolve on every status — a 503 is a value, // not a throw — so the loop tests `res.status`; a catch would never see it. execute: async (req) => { let res = await transport(req); for (let i = 1; i < connectAttempts && TRANSIENT.includes(res.status); i++) res = await transport(req); // no bytes have flowed yet return res; }, // Require the sentinel: truncation is the ABSENCE of a frame, so nothing // per-frame can catch it. stream: async function* (res, cfg) { let sawDone = false; for await (const frame of sseSurface.stream(res, cfg)) { if (isErrorFrame(frame)) throw new Error(`provider error frame: ${messageOf(frame)}`); if (isDone(frame)) { sawDone = true; continue; } yield frame; } if (!sawDone) throw new Error('stream truncated: no [DONE] sentinel'); }, ``` **Measured across six shapes:** a complete answer delivered once; a healed connect retried at the connect phase only (3 opens, `ABCDE` once); a mid-body drop keeping `ABC`; a truncation caught as `stream truncated: no [DONE] sentinel` with the partial kept; an in-band error frame surfaced with the bad frame **withheld** from the consumer; and a dead server failing after 4 connect attempts rather than resolving empty. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] Unusually for this section, **the StitchAPI version is smaller** — 62 lines against 83 for the hand-rolled twin, which has to bring its own SSE parser. Both produce byte-identical results and identical open counts on all six shapes, so the machinery isn't buying behaviour; it's buying the spine: one `start` / `delta`×N / `error` / `done` trace under one traceId, and `auth`, `headers`, `throttle` and `timeout` staying configuration instead of growing the hand-rolled file. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **`retry` does not run on a streaming stitch at all.** Measured: `retry: { attempts: 4 }` against an always-503 server made **4 requests on a buffered stitch and 1 on an `sse` one**, with `error.attempts: 1`. The safe replay — the connect phase, before any byte — is the one case `retry` cannot cover. 2. **`retry.attempts` is inert but `retry.backoff` is live**, as the *reconnect* curve. One name, two fates: on a resumable feed that kept dropping, `retry: { attempts: 1 }` still produced 4 opens — the cap that binds is `reconnect.attempts`. 3. **Connect-phase and body-phase policy are not separately addressable.** `reconnect.onlyOnDrop`, `reconnect.requireToken` and `retry.phase` are all absent (machine-checked) — the engine now behaves as the first two would have configured, but the phase split itself still has no config spelling. 4. **`interpret` and `verdict.flag` are dead code on streaming surfaces.** A custom surface's `interpret` ran **zero** times — `runStreaming` calls only `classifyStatus`. They typecheck and do nothing. 5. **`hooks.onError` never fires for a post-200 stream failure.** Measured hook sequence on a mid-body drop: `[onRequest, onResponse]`, while the run failed. Any hook-based error pipeline is blind to this entire scenario. `onResponse` also fires *before* a single frame is parsed — `ctx.res.body` is a live `ReadableStream`. 6. **Truncation is undetectable by any built-in.** A truncated stream and a complete one produce the same terminal spine — `result, done(ok: true)`. `output` can't express it either: a schema demanding the sentinel rejects delta 1 instead. 7. **The partial survives only on `.stream()`.** `.safe().data` is `null`; the thrown `StitchError` has `body`, `data`, `partial` and `chunks` all `undefined`; and `.inspect()` — whose whole job is "what did the server actually send?" — returns `data: null`, `raw: null`, `status: 0`. The engine is holding the answer one line above the `return` that discards it. **Default reconnect backoff is roughly 50 ms** (`expo-jitter` off base 100), so a dropped stream reopens within a tenth of a second unless you author a `retry.backoff`. And `verdict: { accept: [503] }` combined with `reconnect` resolves a permanently-failing server **successfully** with `data: []`. ## See also [#see-also] # One customer's revoked token, everyone's outage (/docs/scenarios/multi-tenant-blast-radius) ## The problem [#the-problem] You integrate a vendor API on behalf of each of your customers. Every call carries that customer's credential. At 500 customers with 8 connections each, that's [4,000 token lifecycles](https://truto.one/blog/how-to-architect-a-scalable-oauth-token-management-system-for-saas-integrations/). **The unit of failure is the tenant. The unit of protection usually isn't.** Rate budgets and circuit breakers are scoped to a *dependency*, but what goes wrong is scoped to a *customer* — their token was revoked, their admin changed a permission, their batch job went rogue. When the protection is broader than the failure, one customer's problem becomes everyone's. **Measured, on a shared seam with `circuit: { failures: 3, cooldown: '30s' }`:** one customer with a revoked token failed **9 of 9 healthy customers**, and **zero** of their requests ever reached the vendor — they fast-failed in-process with `503 circuit open`. **And the outage does not end on its own.** Half-open admits exactly one trial call, and the broken tenant is the one retrying hardest — so across four full cooldown windows the healthy tenant measured `503, 503, 503, 503`. Recovery happens when a healthy tenant *happens* to win the probe. That's a race, not a policy. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | -------------------------------------- | ----------------------------------------------------------------------------------------- | | One client instance per tenant | Correct by construction — and usually assumed not to scale. | | Partitioned limiter | The right shape, if your client offers one. Most don't. | | Per-tenant circuit breaker | The named mitigation. Needs the breaker to accept a per-tenant key. | | Global breaker, tuned high | Trades one failure mode for another — a real outage now takes far longer to trip. | | Exclude auth failures from the breaker | Genuinely correct and usually forgotten. A `401` says the credential is bad, not the API. | | Sharded workers by tenant | Real isolation, at the cost of a routing tier. | ## What StitchAPI does [#what-stitchapi-does] The library already carries a tenancy axis — and it reaches exactly half of what you need. `AuthContext.principal` is visible to the auth strategies and the cache-key builder, **and nothing else**. So the split falls on the auth/resilience line: | resource | isolated by | fails | | ------------------- | -------------------------------------------------- | -------------------------------------------- | | **Token** | `oauth2({ tenancy: 'principal' })` + `seam.as(id)` | **closed** — errors if no principal is bound | | **Cache** | `tenancy`, defaults to `'principal'` | **closed** | | **Rate budget** | a per-tenant limiter **key** you write | **open, silently** | | **Circuit breaker** | a per-tenant `circuit.key` you write | **open, silently** | The two whose isolation is a *security* property fail closed. The two whose isolation is an *availability* property fail open. That is the whole scenario in one table. The working construction is one shared seam plus a per-tenant member: ```ts const vendor = seam({ baseUrl: 'https://api.vendor.com', auth: oauth2({ tenancy: 'principal' /* … */ }), store: redisStore(/* … */), }); const itemsFor = (tenant: string) => vendor.as(tenant).stitch({ path: '/v1/items', name: `items:${tenant}`, // partitions the rate budget circuit: { failures: 3, cooldown: '30s', key: `items:${tenant}` }, // and the breaker // A 401 means the CREDENTIAL is bad, not the vendor — don't let it trip the breaker, // but don't swallow it either. verdict: { accept: [401], flag: 'ok' }, }); ``` **Measured:** the same revoked credential that took down 9 of 9 in the callout above took down **0 of 9** here. The broken tenant still received a real `401`. A genuine `500` still opened **that tenant's own** breaker (`500, 500, 500, 503`) with zero effect on the others. And the 20-call burst that pushed a quiet customer from t=0 to **t=2000 ms** arrived at **t=0**. **`flag: 'ok'` has a precondition, and missing it re-creates the swallowing.** The flag is three-state, and only a **present, falsy** value fails the call — absence is silence, not failure. The vendor measured above answers `401` with `{ "ok": false, "error": "invalid_token" }`. Against a vendor whose error body has no `ok` key at all — a plain `{ "error": "invalid_token" }` is typical — `accept: [401]` stands alone and the call resolves `ok: true` **with the error envelope as its data**. For that vendor, keep `accept` and restore the failure in five lines of `Surface.interpret` composing `verdictOf` (measured identically: a real `401`, 0 of 9 healthy failed, 0 circuit failures): ```ts const credentialAware: Surface = { id: 'http', interpret: (res, cfg) => { if (res.status === 401 || res.status === 403) return { ok: false, message: `credential rejected (HTTP ${res.status})`, status: res.status, }; return verdictOf(res, cfg) ?? { ok: true, data: res.body }; }, }; ``` Pass it as the stitch's `kind`, and keep `verdict: { accept: [401, 403] }` — `accept` is what stops the engine throwing on the status before the surface is ever consulted. ## Isolation is a property of the key string, never of the object graph [#isolation-is-a-property-of-the-key-string-never-of-the-object-graph] This is the part worth internalising, because the intuitive constructions don't work: * **10 separate `.as()`-bound stitch objects** resolving to the same `path` → **1** breaker key, 9 of 9 healthy tenants down. * **10 separate seams** sharing one store → same result. * A **`url`-only stitch** keys its breaker on the literal string `'stitch'` — so every such stitch sharing a store shares one process-wide breaker, across tenants *and* endpoints. Conversely the correct partition is cheap: 100 keyed stitches built in under 100 ms with zero timers armed, and 100 per-tenant seams cost well under 40 kb each with **zero** connection pools — a seam owns no transport, so the "4,000 pools" the literature warns about isn't a cost this construction has. **A per-tenant seam isolates the rate budget and *not* the breaker.** Measured in one run: the quiet tenant left at t=0 (rate isolated) while 3 of 3 healthy tenants got `503` (breaker shared). The most isolated-looking construction is half a fix, and the missing half is the one that causes outages. **And `throttle: { pool: 'host' }` silently re-keys the *circuit* onto the host.** A per-tenant `name` partition evaporates, and an unrelated endpoint for an unrelated tenant measured `503`. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **No per-tenant declaration exists for either resource.** `ThrottleOptions.pool` is `'stitch' | 'host'`; `CircuitOptions` is `{ failures, cooldown, key }`. `pool: 'principal'`, `throttle.key`, `throttle.tenancy` and `circuit.tenancy` are all compile errors. You smuggle tenancy through key strings. 2. **`oauth2` defaults to `tenancy: 'app'`** — three different customers measured **one** token fetch and one shared `Authorization` header. Nothing at the call site hints at it. 3. **`tenancy` partitions the token *cache*, not the *credential*.** All tenants' tokens are minted from one `client_id`, because `Secret` is a niladic thunk with no context. Per-customer credentials need a custom `AuthStrategy.apply(req, ctx)` reading `ctx.principal` — the only user-reachable hook that sees the bound principal at call time. 4. **Global quota *and* per-tenant fairness is not expressible in one construction.** A member throttle stacks tighten-only on the seam bucket, so declaring "1000/m to the vendor" puts the noisy neighbour straight back (quiet tenant returns to t=2000). 5. **Breaker records have no TTL.** A churned tenant's key was still resident after a virtual *year*, and nothing sweeps them — while the rate counter beside it does expire. 6. **`seam.stitch()` pins every stitch it creates.** Measured with `WeakRef` after a forced GC: 200/200 root-created still reachable, versus **0/200** created through `seam.as(p).stitch()`. The per-request shape is the one that doesn't leak; the only release for the other is `seam.close()`, which also closes the store. 7. **Seam ids are a creation-order counter**, so per-tenant seams over a shared durable store collide across worker processes non-deterministically. 8. **`CircuitOpenError` names nothing about who tripped it.** When the breaker is shared, you cannot tell from the error which tenant caused the outage. ## See also [#see-also] # The upload you must clean up after (/docs/scenarios/multipart-upload) ## The problem [#the-problem] A 5 GB upload can't go in one request, so you use S3-style multipart: 1. `POST ?uploads` → an `UploadId` 2. `PUT ?partNumber=N&uploadId=…` × N → each returns an **`ETag` response header** 3. `POST ?uploadId=…` with the **ordered** `{ PartNumber, ETag }` list → the object 4. …and on **any** failure, `DELETE ?uploadId=…` **Step 4 is the one nobody models.** Abandon an upload and every part already sent stays in the bucket and bills as storage indefinitely — while being invisible to `aws s3 ls` and to the console's objects tab. AWS's own FinOps guidance puts incomplete multipart uploads at [up to 20% of an S3 bill](https://aws.amazon.com/blogs/aws-cloud-financial-management/discovering-and-deleting-incomplete-multipart-uploads-to-lower-amazon-s3-costs/), and there is a [lifecycle rule](https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpu-abort-incomplete-mpu-lifecycle-config.html) that exists purely to clean up after clients that didn't. That's a **compensating action**: a failure in step 2 or 3 obliges you to make a *different* API call. No HTTP client models it. The rest is ordinary orchestration made fiddly — the part result is a header, the list must be in part order not completion order, concurrency needs a bound, and progress needs XHR because `fetch` cannot report bytes sent. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Single `PUT` of the whole file | One blink and 5 GB is gone; above 5 GB S3 refuses outright. | | Vendor SDK (`@aws-sdk/lib-storage`) | Correct, and right if you're on AWS. Large dependency, and the shape recurs on every non-AWS API. | | Hand-rolled loop + `try/finally` | What most teams write. The abort is one early `return` from being skipped, and nothing tells you when it was. | | tus / resumable protocol | Better where you control the server. Not an option against S3's own API. | | Lifecycle rule as the safety net | Necessary, not a fix — you still pay for N days of orphans on every failed upload. | ## What StitchAPI does [#what-stitchapi-does] Three things become configuration, and they're worth having: ```ts const putPart = stitch({ method: 'PUT', // `{?…}` is the RFC 6570 query operator, filled from `params`. A literal // `?partNumber={part}` suffix is split off BEFORE templating and ships // its braces percent-encoded. url: 'https://s3.example.com/{key}{?partNumber,uploadId}', // S3's own transient error is 500 InternalError — NOT in the default set. retry: { attempts: 3, on: [429, 500, 502, 503, 504] }, // One stitch, called N times. `pool: 'host'` because the default is per-stitch. throttle: { concurrency: 4, pool: 'host' }, adapter: xhrAdapter(), // fetch cannot report bytes sent kind: { id: 'http', // The part's result is a HEADER. Without a surface it is unrecoverable — // `.inspect()` carries no headers at all. interpret: (res, cfg) => verdictOf(res, cfg) ?? { ok: true, data: res.headers['etag'] }, }, }); ``` **Measured:** per-part retry re-sent only the failing part (arrival order `[1,2,3,4,3]`, one initiate, zero orphans). `throttle: { concurrency: 3, pool: 'host' }` held peak in-flight at **3** across 8 parts. `xhrAdapter` reported 4 upload ticks per part *before the response existed*; the same call through `fetchAdapter` reported **zero**. ## What it does not do — and this is the point of the page [#what-it-does-not-do--and-this-is-the-point-of-the-page] **There is no compensation seam.** `Hooks` is exactly `{ onRequest, onResponse, onError, onRetry }`, and `onError` is not a failure hook — it's the `catch` around the transport. On an HTTP 500 the measured hook sequence was `[onRequest, onResponse]` with **zero** `onError` calls. `HookContext` has no run-scoped slot to hold an `UploadId`, and `linked()` has no `finally`. So the cleanup is a plain `try/finally` in your own orchestration function — and it has to be, because the `UploadId` only exists there. Measured, with a part failing and no user cleanup: | | orphaned parts | DELETEs | | --------------------------- | ----------------------------------- | ------- | | no cleanup | **3 (15 MiB), 1 dangling UploadId** | 0 | | cancelled via `AbortSignal` | **2** | 0 | | `timeout.total` expiry | **3** | 0 | | user-written `try/finally` | 0 | 1 | Cancellation is not cleanup: the engine cancels in-flight work and forgets the work that landed. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] **141 lines against 163** hand-rolled — 22 shorter, and the difference attributes exactly to the retry loop with backoff, the concurrency pool, the retryable-status set and URL assembly, all of which became config. What did *not* shrink is the half the scenario exists for: the `try/finally`, the loud-cleanup rule, the per-part high-water progress map and the input-order assembly are byte-for-byte identical on both sides. The library is a bystander for the compensation. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **The compensating call.** No hook, no config key, no surface position runs on failure. 2. **`hooks.onError` is not a failure hook** — zero calls on an HTTP 500. 3. **Cancellation ≠ cleanup.** `AbortSignal` and `timeout` both leave orphans. 4. **`all()` bounds nothing** — measured peak **8** over 8 members — and it hands every member the *same* input, so one stitch × 8 members produced 8 PUTs all carrying `partNumber=1`. 5. **`throttle.concurrency` defaults to `pool: 'stitch'`.** Eight stitches at `concurrency: 3` each measured a peak of **8**. Use `pool: 'host'` or a seam bucket. 6. **`all()` discards partial results on fail-fast** — 2 parts stored, **0 nameable** for the abort. There is no `allSettled`; only an `onResponse` side channel recovers them. 7. **The default `retry.on` excludes 500**, which is S3's own transient error. Measured: 4 PUTs, 1 failed part, 3 orphans with the default set. 8. **Whole-upload retry is neither flagged nor prevented** — `retry` on the outer orchestration measured **3 initiates, 9 orphaned parts, 45 MiB**. 9. **A progress tick has no identity** — `{ direction, loaded, total }` only, so a shared `onProgress` across a fan is unattributable. And ticks are cumulative *within* a part, so summing them overshoots: naive `Σ loaded` measured **400** against a real 160. 10. **Response headers are absent from `.inspect()`**, so without a surface the `ETag` is unrecoverable on the awaited path. **Two ways cleanup lies to you, both measured.** **`.safe()` on the abort cannot throw.** Inside a correct-looking `try/finally`, pointed at a wrong `UploadId`: **0 accepted DELETEs, 3 orphaned parts, and nothing thrown anywhere**. The `finally` ran. That is a permanent invisible bill with a clean code review — make the cleanup failure loud. **Cleanup inside `Surface.execute` runs after the caller returns.** Measured at the instant the caller's promise settled: **3 orphans, 0 DELETEs**; the DELETE landed several turns later. In a lambda, or any process that exits on the error, the later half never happens — and the code reads as though the engine owns the lifecycle. ## See also [#see-also] # One list, a hundred follow-up calls (/docs/scenarios/n-plus-one-fanout) ## The problem [#the-problem] `GET /orders` returns 100. Each carries a `customerId`, so you make 100 more calls. It is the most common composition shape in API integration, and every part of it is a decision. **N is unknown until runtime**, and every call needs a *different* input. Then: * **Concurrency** — all 100 at once trips a rate limit; one at a time wastes the afternoon. And vendor governance is often concurrency-based, so a requests-per-second cap doesn't protect you. * **Partial failure** — `Promise.all` rejects on the first error **and discards the successes**. * **The thundering herd** — 100 calls that 429 together and back off by the same amount retry together. Deterministic backoff *re-clusters* the burst. * **Duplicates** — 100 orders commonly reference far fewer customers. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | -------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `Promise.all(ids.map(fetch))` | Unbounded concurrency, and one failure discards every success. | | `Promise.allSettled` + `p-limit` | Correct — two dependencies and a hand-rolled join. | | Sequential loop | Safe, N× the latency. | | A batch endpoint | Best where offered, and then you inherit [partial-failure semantics](/docs/scenarios/batch-partial-failure). | | Cache / dedupe by id | Free quota — only if **in-flight** duplicates collapse too. | | `?expand=` on the list | The real fix. Rarely offered. | ## What StitchAPI does [#what-stitchapi-does] **`cache.coalesce` collapses in-flight duplicates, and that is the headline.** 100 concurrent calls over 30 distinct ids made **30 requests** — one per id, exactly the floor — with every call in flight and **not one response landed**. Seventy callers were served without a request of their own, from a single `cache: { ttl }` block and no user code. The same cache with `coalesce: false` made **100**. Three more things are configuration: ```ts const customer = stitch({ url: 'https://api.vendor.com/customers/{id}', throttle: { concurrency: 8 }, // measured: peak 8 exactly, against 100 unbounded cache: { ttl: '60s' }, // the dedupe retry: { attempts: 3 }, // jittered by default }); ``` **Measured:** bounded concurrency held **peak 8** exactly. And the retry default genuinely de-clusters a herd — 100 calls 429'd in the same instant retried across **\~98 distinct milliseconds** under `expo-jitter`, where `'fixed'` **and** `'expo'` both put all 100 into **one millisecond** (doubling a constant is still a constant on attempt 2). `linked` gives the fan-out **one trace** — 1 traceId, 1 root, 101 spans covering the list and every lookup, with per-call inputs. Assembled: **45 executable lines against 87** hand-rolled, the difference being the FIFO pool, the retry loop, the retryable-status set and URL assembly, all of which became config. ## Where it loses: a coalesced failure is not shared [#where-it-loses-a-coalesced-failure-is-not-shared] The leader's *failure* releases its joiners to re-run. Measured: 100 concurrent calls for one id that 404s made **100 requests in two waves** — 1 leader, then 99 followers each re-running the whole chain. Every joiner gets its own honest `HTTP 404`, never a leader artefact — the right *error* at the wrong *price*. End to end this is the one place the hand-rolled version wins: **44 customer requests against 32**, and a deleted customer cost **4 requests against 1**. A `Map` shares the rejection; the library spends a wasted request per duplicate reference to a broken id — which is exactly the shape a dead foreign key takes. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **The combinators cannot express this** — and not for the reason you'd guess. The runtime length is fine (`all(ids.map(…))` compiles). **The input is the wall**: `all()` spreads one `StitchInput` into every member, so 100 members made **100 requests for one distinct id**. It also bounded nothing (peak 100) and fail-fast **discarded 99 successful fetches** while still paying for them — the auto-cancel prevented **zero** requests. 2. **`allSettled` semantics are absent**, and the documented workaround — compose `.safe()` members by hand — does not typecheck, because `Member` is brand-gated. 3. **In-flight dedupe of failures**, above. 4. **A fan-shaped trace over per-call inputs.** You get a fan (`all()`, wrong inputs) or per-call inputs (`linked`, which chains each call under the previous — measured **depth 101, fan-out 1** for calls that ran at peak 100 concurrently). Not both. 5. **Deep-copying joined results** — see the aliasing callout. 6. **A batch endpoint.** Nothing can invent one. **A declared concurrency budget silently multiplies across stitch objects.** One stitch called 100 times at `concurrency: 8` → peak **8**. One hundred *separate* stitches each declaring 8 → peak **100**, because the limiter is per stitch. `pool: 'host'` repairs it, and a lease-capable `store` keeps the repair — measured peak **8** — and makes it fleet-wide: the store holds **one** budget under the same host key, so eight in flight means eight across every worker on that store. The remaining trap is a store *without* the lease verbs (an eventually-consistent KV, a minimal custom store): concurrency falls back to per-process there, and the multiplied budget returns with the config unchanged. A seam-level `concurrency` survives the stitch-per-id shape on its own. Related: **a backing-off call holds its slot.** With a bound of 4 and a 1 s backoff, \~95% of the budget sat idle on sleeping calls, and the retry re-queued at the **back** of the FIFO. **`cache: { ttl: 0 }` caches forever.** It is the obvious spelling for "dedupe but don't cache", and `expires === 0` reads as live — a later fan-out added **0 requests**. There is no coalesce-only spelling. **`Retry-After` defeats the jitter by default.** `retry.respect` is on, so a 429 carrying `Retry-After: 2` put all 100 retries back into **one millisecond** with `expo-jitter` still declared. `respect: false` restores the spread and is all-or-nothing. **Coalesced and cached callers share one object by reference.** 20 rows over 5 customers gave **5 distinct objects** — mutating row 0 changed row 5. The aliasing arrives with the optimisation. Two smaller ones: **`sensitive: true` silently disables coalescing**, and coalescing is GET/HEAD only until you name `methods`. ## See also [#see-also] # OAuth2 refresh tokens that rotate (/docs/scenarios/oauth2-refresh-token-rotation) ## The problem [#the-problem] Your backend holds a per-account **refresh token** for a third-party API — Atlassian, Asana, Xero, Slack — and exchanges it for short-lived access tokens. The provider implements [RFC 6819 §5.2.2.3](https://datatracker.ietf.org/doc/html/rfc6819#section-5.2.2.3) replay detection: redeeming a refresh token returns **a new one** and invalidates the old, and presenting an already-redeemed token is read as evidence of theft. So the provider does not reject one call — it **revokes the entire token family**. The user is silently disconnected and has to re-authorize in a browser. Now add ordinary concurrency. A sync job, a webhook handler, and a user request all hit `401` at the same instant. Each independently decides to refresh. The first redemption rotates the token; every other one presents a consumed token and trips replay detection. The failure mode is not "a request failed." It is "the integration lost the account," and it only appears under load. This is not a beginner's mistake. It has been filed against [OpenAI Codex](https://github.com/openai/codex/issues/10332), the [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk/issues/1760), and [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy/issues/1992) — and it was a CVE in an auth library whose whole job is this ([GHSA-392p-2q2v-4372](https://github.com/better-auth/better-auth/security/advisories/GHSA-392p-2q2v-4372)). ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Refresh-on-401 interceptor | The baseline bug. N concurrent 401s ⇒ N redemptions ⇒ family revoked. | | In-process promise memo | Correct for **one** process. Silently insufficient at two workers — and it looks fixed in dev, where there is one. | | Distributed lock (Redis `SETNX`) | Works, and makes refresh a distributed-systems problem: lock TTL vs latency, crash-while-holding, fencing. | | Proactive refresh before expiry | Shrinks the window; does not close it. The skew boundary is a moment every worker crosses together. | | Provider grace period | Not yours to choose. Auth0 and Okta offer one; Atlassian and Asana do not. | | Dedicated refresh worker | Genuinely correct. Costs a deployable, and a cold access token now waits on a queue round-trip. | There is no one-liner. The honest minimum is **coordination scoped to the account and spanning processes**, plus **durable persistence of the rotated token before the old one is treated as spent**. ## What StitchAPI does [#what-stitchapi-does] Not `oauth2()`. That strategy implements the `client_credentials` grant, which has no refresh token by design — it reads only `access_token` and `expires_in` from the token response, so a rotated `refresh_token` is discarded. **`params` will let you look like you configured rotation.** It merges into the token-request body and can override `grant_type`, so `params: { grant_type: 'refresh_token', refresh_token: rt }` compiles, and the **first redemption returns 200**. The second sends the same consumed token and the provider revokes the family. There is no error and no type friction at the moment you write it. Don't. What StitchAPI gives you is the **seam**: `AuthStrategy` is a public exported type, and a strategy receives an `AuthContext` with an async `vault` it owns, plus a `shouldRefresh`/`refresh` pair wired into the call. You write the rotation policy; the runtime owns where it runs. ```ts import type { AuthContext, AuthStrategy } from 'stitchapi'; /** What the vault holds for the access token. `expiresAt: 0` = no known expiry. */ type CachedAccess = { token: string; expiresAt: number }; export function rotatingRefresh(opts: RotatingRefreshOptions): AuthStrategy { const skew = opts.skewMs ?? 30_000; // treat the token as stale this early const accessKey = `rr:${opts.key}:access`; const refreshKey = `rr:${opts.key}:refresh`; let inFlight: Promise | undefined; const redeem = async (ctx: AuthContext): Promise => { const stored = (await ctx.vault.get(refreshKey)) as string | undefined; const res = await opts.adapter({ url: opts.tokenUrl, method: 'POST', headers: { accept: 'application/json' }, body: { grant_type: 'refresh_token', refresh_token: stored ?? opts.seedRefreshToken, client_id: opts.client.id, client_secret: opts.client.secret, }, bodyType: 'form', }); const body = (res.body ?? {}) as TokenResponse; if (res.status >= 400 || !body.access_token) throw new Error(`refresh_token grant failed: HTTP ${res.status}`); // Persist the ROTATED token first — the old one is spent server-side the // moment the provider answered, so this write must land before use. if (body.refresh_token) await ctx.vault.set(refreshKey, body.refresh_token); const ttl = body.expires_in ? body.expires_in * 1000 : undefined; await ctx.vault.set( accessKey, { token: body.access_token, expiresAt: ttl ? Date.now() + ttl : 0 }, ttl, ); return body.access_token; }; // Coalesce concurrent redemptions; clear on settle so a failure never sticks. const redeemOnce = (ctx: AuthContext): Promise => (inFlight ??= redeem(ctx).finally(() => { inFlight = undefined; })); return { name: 'rotatingRefresh', async apply(req, ctx) { // `vault.get` returns `Promise` — type the read. Freshness // is the STORED expiry minus a skew, so a token about to lapse // mid-request is already treated as stale. const cached = (await ctx.vault.get(accessKey)) as CachedAccess | undefined; const fresh = cached && (cached.expiresAt === 0 || Date.now() < cached.expiresAt - skew); req.headers['authorization'] = `Bearer ${fresh ? cached.token : await redeemOnce(ctx)}`; }, shouldRefresh: (res) => res.status === 401, async refresh(ctx) { await redeemOnce(ctx); }, }; } ``` Attach it like any other strategy, to any stitch: ```ts const issues = stitch({ baseUrl: 'https://api.atlassian.com', path: '/ex/jira/{cloudId}/rest/api/3/search', auth: rotatingRefresh({ key: `jira:${accountId}` /* … */ }), store: redisStore(/* … */), }); ``` **Measured:** 20 concurrent cold callers produce **1** redemption and **0** replays. Cross-worker exclusion — built on `vault.increment(key, ttl) === 1` to acquire and `vault.set(key, undefined)` to release — collapses **3 workers × 10 callers to 1** redemption, and costs **42 more lines**. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] The line count is not the win. A correct axios interceptor with a Redis lock is about as long. Three things change: * **The policy is one object, not a call-site convention.** Every stitch that names this strategy gets it — the CLI, an HTTP route, and an agent tool included. Nothing can call the API and miss the interceptor. * **The token never reaches the caller.** It lives in the vault and the outgoing header. A caller, an agent included, receives data — never the credential. See [Capability, not credential](/docs/concepts/capability-not-credential). * **The store is already there.** `vault` is scoped, async, and shared by `key` + `store`, so "where does the rotated token live" is answered before you start. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] Blunt, because getting this wrong costs an account: 1. **`oauth2()` does not implement the refresh-token grant.** Rotation is entirely yours to write. 2. **`params` is a footgun for this.** It works once, then revokes the family. 3. **A shared `store` is a cache, not a lock.** Two cold workers fire one token request *each* — measured: 2 workers ⇒ 2 requests, 2 workers × 10 callers ⇒ still 2. The store only helps once a write has landed. 4. **The built-in single-flight is per strategy instance, in-process, and time-windowed.** 20 *simultaneous* 401s coalesce to 1 refresh; 20 staggered 8 ms apart produced **10**. Coalescing is not identity-scoped. 5. **There is no distributed-lock primitive.** `StitchStore` is `get`/`set`/`increment`/`close` — no compare-and-set, no fencing token, no blocking wait. A lock built on `increment` leans on cross-process atomicity that the store contract only guarantees *within* a process; confirm your backend before relying on it. 6. **Nothing enforces write-before-use ordering.** The vault is a plain KV. The "persist before the old token is spent" discipline is yours. 7. **`cookieSession` is not an alternative path.** Its refresh hook receives `{ ok, status }` — the login response body never reaches you, so it cannot carry a rotated token. 8. **One refresh per call.** If the retry after a refresh also 401s, the call fails. A lock timeout surfaces to the caller as a 401. ## See also [#see-also] # The customer data you didn't mean to log (/docs/scenarios/pii-in-the-logs) ## The problem [#the-problem] You integrate a vendor API that returns customer records — names, emails, addresses, health or legal detail. You add tracing, because you are responsible about observability. Six months later someone greps the log aggregator and finds all of it in plain text. Nobody logs PII on purpose. **Middleware does** — the two most-cited causes in the field are a debug endpoint returning full user objects and [a logging middleware that captures raw request bodies](https://hoop.dev/blog/why-pii-leakage-happens-in-apis/), and the second is exactly what a well-instrumented client library is. Three things make it structural rather than careless. **You cannot enumerate what is sensitive in advance** — a denylist of field names misses `primaryContactMail`, `profile.contact.mail`, and an address inside a free-text note. **The vendor adds a field and it starts flowing**, which makes this a drift problem, not a configuration one. And **the regulation is about defaults**: GDPR Article 25 mandates protection by design *and by default*, so "we can turn redaction on" is not the same thing. ## The common solutions [#the-common-solutions] | Approach | What it is | Where it breaks | | ----------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------- | | **Denylist of field names** | Scrub `email`, `ssn`, `card`. | Misses renamed, nested and free-text fields. Silently incomplete the day the vendor adds one. | | **Allowlist of safe fields** | Log only what you named. | Correct by construction; needs the whole response shape — the thing that drifts. | | **Don't log bodies at all** | Metadata only. | Safe and often unusable: the body is what you need when debugging an integration. | | **Scrub at the aggregator** | Filter on ingest. | The data already left your process and crossed a network. | | **Gateway/sidecar redaction** | Strip in a proxy. | Another hop, and it cannot know which field is sensitive in *your* domain. | ## What StitchAPI does [#what-stitchapi-does] ### The exposure is binary, which makes it auditable [#the-exposure-is-binary-which-makes-it-auditable] Measured with seven distinct sentinels — a name, an email, an SSN, a nested `profile.contact.mail`, an email inside a prose `note`, one in `contacts[1].email`, and a renamed `primaryContactMail`. **Thirteen destinations carry all seven. Eleven carry zero. Nothing is partially redacted.** | carries the whole body | carries none of it | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | the `result` event · `fileSink` at any non-zero cap · `.inspect().raw` · `.inspect().data` · `JSON.stringify(inspect())` · `.report()` · `StitchError.body` · `JSON.stringify(err)` · the cache entry | `start` / `progress` / `done` / `error` events · `consoleSink` · `loggerSink` · `otlp.sink` · `StitchError.message` · `String(err)` · `err.stack` | Exactly **one event** carries the response body — `result`, on `data`. "The event spine leaks" is really "one event leaks", which is a much smaller thing to reason about. ### An `output` allowlist genuinely filters — 9 lines [#an-output-allowlist-genuinely-filters--9-lines] This is the answer, and it works because the engine serves the **validated** value — [measured in scenario 20](/docs/scenarios/precision-loss), and since [#663](https://github.com/rejifald/StitchAPI/pull/663) no longer an asymmetry: an `input` schema now shapes the request the same way (scenario 20 caught it merely checking; [#648](https://github.com/rejifald/StitchAPI/issues/648) filed the gap). Either direction filters the slots you **declare** — a slot with no schema on it still flows through untouched, which is why the nine lines below have to be written. ```ts const SAFE = z.object({ id: z.string(), status: z.string(), total: z.number(), }); const getCustomer = stitch({ url: '…', output: drift(SAFE) }); ``` A four-field schema took the JSONL sink, the `result` event, the console line, the cache entry and the whole `.inspect()` wrapper from **7 sentinels to 0** — at depth (`profile.contact` gone) and inside array elements (`contacts[].email` gone), **without naming a single PII field anywhere**. That is the allowlist property: it survives the vendor adding a field. ### `drift()` gives you the inventory without the data [#drift-gives-you-the-inventory-without-the-data] Wrapped in `drift()`, the same allowlist emits a value-free record of everything it stripped — seven `undeclared` findings whose paths name the fields and whose details are **kinds only** (`undeclared field (string)`), with zero sentinels across every finding, every drift event and every sink. That is exactly the *log the detection, not the data* shape the guidance recommends. When the vendor adds `taxId` at three levels, you get exactly three new `undeclared` findings. ### Declarative credentials never enter the spine [#declarative-credentials-never-enter-the-spine] `auth.apply` runs on a request clone inside the attempt loop, while the `start` event was built from the pre-auth request — so a `bearer` token or `apiKey` in query or cookie is **0 of 3** even for a naive custom sink. Hand-rolled request credentials are scrubbed by every built-in sink: `"authorization":"[REDACTED]"`, a scrubbed `url`, and no headers at all on console, logger or OTLP. ## What StitchAPI does not solve [#what-stitchapi-does-not-solve] 1. **`sensitive: true` does not mean "do not log".** It changed the leak at **1 of 11** destinations — the cache. Across all of `packages/core/src` there is exactly **one** read of the value, `engine.ts:1051`, inside `ensureCache`. No sink, no trace module and no event builder mentions it. It means *do not persist this to the cache*, and while it is set the JSONL sink still writes the full body to disk. It also survives onto the public `__config`, so `.report()` prints `"sensitive":true` beside the record it did not protect. 2. **`.inspect({ redact: true })` removes 0 of 7.** The shared denylist is the *credential* list (`token`, `secret`, `password`, `apikey`, `signature`, …) reused — and no PII field name is on it. `redact: ['mail', 'email']` does work, at depth and across array elements, but only for names you enumerate; a renamed key and an address in free text are unreachable by construction. There is no stitch-level or process-level default — ADR 0018's `defaultInspect` was never implemented. 3. **`JSON.stringify(err)` leaks the whole record; `console.error(err)` does not.** `StitchError.body` is an own enumerable property while `message` is not. So `err.stack` and `String(err)` are clean, and **`logger.error({ err })` is the leak.** The same shape repeats on success: ADR 0016's non-enumerability protects `.inspect().raw` and nothing else, because `.inspect().data` holds the same record enumerably. 4. **A validation error can carry the value into a log — when the validator's message echoes it.** `validationErrors` (`drift.ts:50-56`) copies the validator's own message into `detail` verbatim, so what escapes is decided by the message's wording, not by anything in this library. Stock Zod 4 (the workspace's validator since [#589](https://github.com/rejifald/StitchAPI/pull/589)) no longer echoes the received value — its enum error reads `Invalid option: expected one of "enterprise"|"free"`, and every sink measures clean. A message that does embed the input — a custom `refine`/`check` message, or Zod 3's enum wording — is measured reaching the JSONL file, `consoleSink` **and** `loggerSink` — the two sinks that are otherwise 0 of 7. Only OTLP stays clean, because it drops `detail`. 5. **The `fileSink` body cap is a size control, not a privacy control.** On a 2.9 KB body all seven sentinels still persisted into the `preview`; only an eighth planted past character 2048 was absent. It keeps a *prefix*, so reordering the vendor's JSON changes which fields leak. 6. **Only `hooks.onResponse` covers the failure path.** On a 200, `interpret`, `transform` and the hook are equivalent. On a 500 the engine throws carrying the untouched response, so `StitchError.body` stays at 7/7 under `transform` and under a stripping `interpret` — the hook holds at 0/7 only because it *mutated* `ctx.res.body` in place. Its type is `(ctx) => void`, and mutating the body there is nowhere described as a privacy mechanism. 7. **The boundary and the drift signal are mutually exclusive.** Stripping in the hook takes `drift()` to **0 findings**, because drift diffs exactly the bytes the boundary removed. Having both means re-implementing `drift.ts`'s walker — 23 lines, and not exported. 8. **Nothing here is upstream of the `Adapter`**, which read the bytes first. A spy inside the transport still sees all seven. **A credential that rides the payload is treated exactly like PII.** An `access_token` in a *response* body, or a `client_secret` in a *request* body, is written to the JSONL log in full, because that sink's redactor is a five-name **header** denylist rather than the deep secret-key scrubber. The same file already ships that scrubber and applies it to a request body for the `serve` SSE transport — the disk sink simply never calls it. `redactHeaders` is the one config-reachable way to point the redactor at a body key, and its type and JSDoc both say "header names", so nothing tells you that works. ## The assembled setup [#the-assembled-setup] **42 executable lines across 2 seams** — `hooks.onResponse` plus `output: drift(SAFE)` — reaches **0 of 9 destinations on both the success and the failure path**, and still recovers a names-only inventory of every undeclared field. Of those 42 lines, 23 re-implement the walker `drift.ts` already contains. The cheaper variants are honest about their edges: the allowlist alone is 9 lines and 1 of 10 on a 200 (only `.inspect().raw`, by design) but leaves the 500 path untouched; the boundary alone is 7 lines and 0 of 10 on both paths, at the cost of the entire drift signal. ## See also [#see-also] * [Trace sinks](/docs/guides/observability/trace-sinks) — the sinks measured above * [Drift](/docs/guides/validation/drift) — the inventory channel * [Scenario: the ID that changed on the way in](/docs/scenarios/precision-loss) — where `output`'s serve-the-parsed-value behaviour was measured * [Scenario: the agent picks the arguments](/docs/scenarios/agent-holds-the-tool) — the other scenario about data reaching somewhere it should not # The ID that changed on the way in (/docs/scenarios/precision-loss) ## The problem [#the-problem] You integrate an API whose IDs are 64-bit integers — Discord and Twitter/X snowflakes, database primary keys. The vendor sends them as JSON numbers. Measured through StitchAPI's real default transport: | sent | received | | | --------------------- | --------------------- | --------------- | | `1234567890123456789` | `1234567890123456768` | off by 21 | | `9007199254740993` | `9007199254740992` | 2⁵³+1 | | `9223372036854775807` | `9223372036854775808` | int64 max | | `9007199254740991` | `9007199254740991` | control, intact | `JSON.parse` produces IEEE 754 doubles, so integers above 2⁵³ are not all representable. No throw, no warning. Three things make it hard rather than merely annoying. **The damage happens before your code runs** — by the time any hook or schema sees the value it is a `Number` and the digits are gone. **It is data-dependent, so it arrives on a date**: IDs below 2⁵³ round-trip perfectly, and snowflakes are time-ordered, so the failure lands fleet-wide at once. And **the fix is not local** — not using `JSON.parse` means values become `BigInt` or `string`, which breaks `JSON.stringify`, cache serialisers, arithmetic and every validator expecting `number`. It is also a cross-language interop bug specifically: Python and Java parse the same payload exactly, so the vendor's tests pass and it is entirely on your side of the wire. **Money is a different problem wearing the same hat.** `19.99` round-trips through the wire fine, because the nearest double's shortest form *is* `"19.99"`. The value is still inexact (`19.98999999999999843681`), so decimals fail in **arithmetic**, not in **transport**. Only integers above 2⁵³ are a wire-fidelity bug. ## The common solutions [#the-common-solutions] | Approach | What it is | Where it breaks | | --------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------- | | **Use the vendor's string field** | Read `id_str` instead of `id`. | Correct and free — when the vendor provides one. Most do not. | | **`json-bigint` / custom parser** | Replace `JSON.parse` wholesale. | Correct at the boundary; now every consumer must handle `BigInt`. | | **Reviver on `JSON.parse`** | `JSON.parse(text, reviver)`. | **Does not work** — the reviver receives the already-parsed `Number`. | | **Regex the raw text** | Quote big integers before parsing. | Works, and is a JSON parser written in regex. Breaks on numbers inside strings. | | **Keep everything as strings** | Treat IDs as opaque text. | The most robust answer, enforced by convention across every layer. | ## What StitchAPI does [#what-stitchapi-does] ### The repair is config, not a custom adapter — 16 lines [#the-repair-is-config-not-a-custom-adapter--16-lines] `wire: { response: 'text' }` is an `else if` branch at `http-adapter.ts:123` that returns **before** the JSON branch at `:135`. So the stock `fetchAdapter` hands you the verbatim bytes and `transform` runs pre-parse: ```ts const getThing = stitch({ url: 'https://api.vendor.test/v1/things/{id}', wire: { response: 'text' }, // stop the transport parsing transform: parseBigIntsAsStrings, // parse it yourself output: z.object({ id: z.coerce.string() }), }); ``` Measured end to end, this delivered the **exact sent digits** `1234567890123456789`, with zero findings and a result that `JSON.stringify`s with no replacer. The `transform` is a single-pass scanner — correct where a regex is not, leaving `"order 1234567890123456789 shipped"` and escaped-quote strings untouched. ### A detector that stays loud without failing the call — 15 lines [#a-detector-that-stays-loud-without-failing-the-call--15-lines] ```ts output: drift( z.object({ id: z .number() .transform((n) => (Number.isSafeInteger(n) ? n : String(n))), }), ); ``` produces `warn|coerced|id|number -> string`, inserts a `drift` event into the spine, and the call still resolves `ok: true`. So it reaches `.inspect().findings`, `.report()`, any `TraceSink`, and `loggerSink` at warn level — because a finding's level *is* its log level. Over 20,000 random Discord-range snowflakes: **zero false negatives**, 0.535% false positives (roughly the spacing of representable doubles at 1.1×10¹⁸). Walking the boundary shows `lossless=false` never co-occurs with `flagged=false`, so **false negatives are impossible**, not merely unobserved. ### Three lossless paths, and two that are not [#three-lossless-paths-and-two-that-are-not] Splitting by **decoder**, not by surface. `stream` with the default `decode: 'bytes'`, `decode: 'lines'`, and `download` all bypass the JSON branch entirely and deliver verbatim bytes. `decode: 'ndjson'`, `decode: 'json'` and `sse` all corrupt, because each calls `JSON.parse` in its own file. Note a bare 19-digit SSE payload *is* valid JSON, so even an unstructured `data:` line corrupts. ### Two places the library already got this right [#two-places-the-library-already-got-this-right] `cache.ts:43` tags bigint cache keys deliberately, so `42n` cannot collide with the string `"42n"` — two identical bigint queries coalesced to one call. And `trace.ts` ships a `bigintSafe` replacer so tracing cannot break the call it observes, which makes a `fileSink` the one diagnostic surface in this whole scenario that ends up holding the vendor's real digits. ## What StitchAPI does not solve [#what-stitchapi-does-not-solve] 1. **The default path corrupts, and says nothing.** The whole spine is `start`, `progress`, `result`, `done` — **four events, zero drift, zero error, zero info** — and the sent digits appear nowhere in it. `.report()` adds nine keys and no findings. Nothing downstream is withholding a warning it could have given: `hooks.onResponse`, `Surface.interpret`, `.inspect().raw`, `.report()` and a `TraceSink` all hold the already-parsed number. `raw` means pre-**validation**, not pre-parse. 2. **A `bigint` in `params` silently vanished — this audit found it, and it is fixed.** `expandTemplateVar` branched on `string | number | boolean`, so a bigint produced `…/v1/things/` — no error, no event — while the sibling `query` slot handled bigint **exactly**: the two URL positions disagreed with each other. [#661](https://github.com/rejifald/StitchAPI/pull/661) fixed it before the issue draft was even filed — `bigint` joined the scalar arm (`util.ts:421`) and the folded `params` slot is typed `string | number | bigint` — so path and query now agree, measured as the exact digits in the URL. Of four outbound positions bigint works in three and throws in one (JSON body — loud, and correct). 3. **Reading an id and handing it straight back is eight lines and wrong.** A number in `params` goes out as `1234567890123456800` — a **third** distinct digit string, and not the one a debugger shows you. That is the `Unknown Channel` shape, with nothing reported. 4. **`wire.response` and `transform` are independent keys.** Set the first and forget the second and the call silently returns a **string** instead of an object. They are independent at the type level too: `transform` is `(body: unknown) => unknown`, so a parser written `(text: string)` does not typecheck in the slot even though `wire.response: 'text'` guarantees a string at runtime. 5. **`transform` is redacted from `__config`**, so "is this stitch repaired?" is only half auditable — `wire` shows, `transform` does not. 6. **`wire.response` is an HTTP key** and does nothing for `sse` or `stream({ decode: 'ndjson' })`, which parse in their own files. 7. **The seam that solves this has no guide page.** `wire.response` appears in the docs only in passing, in the GraphQL guide's list of what `wire.body` does *not* do. The one config key that recovers a corrupted ID is effectively undiscoverable. 8. **The detector buys visibility, not correctness.** The value it reports, `1234567890123456800`, is still not the value the vendor sent. Under a BigInt repair, `JSON.stringify(report)` throws `Do not know how to serialize a BigInt` — and `.report()` is documented as safe to log. A JSON-backed `store` throws on the write and the throw is **fatal** (`ok: false`), not a silent cache miss. `memoryStore` survives, because it holds values by reference and never encodes. Adding a bigint replacer to a JSON store fixes the throw and introduces a subtler bug: `typeof data.id` is `bigint` on a cache miss and `string` on a hit — a type that depends on cache state, which a cold-cache test suite never sees. ## See also [#see-also] * [Config types](/docs/reference/config-types) — where `wire.response` lives * [Drift](/docs/guides/validation/drift) — the channel the detector reports through * [Scenario: the export that eats the heap](/docs/scenarios/large-response-memory) — the other scenario decided by which decoder runs * [Scenario: a canary rollout of a response-shape change](/docs/scenarios/intermittent-drift) — `drift()` used for its intended purpose # Failing over to the backup provider (/docs/scenarios/provider-failover) ## The problem [#the-problem] You depend on a provider that will eventually be down — an LLM API, an SMS gateway, a payment processor — so you line up a second one of the same shape. Two techniques wear similar clothes: * **Failover** — try the primary; on failure, try the backup. **One** call on the happy path. * **Hedging** — fire both immediately, take the first. **Two** calls, always, for better tail latency. Picking the wrong one is a bill, not a bug report. And the trigger has to **classify**, not just notice. The industry consensus is consistent: timeouts (`408`), `429` and the whole `5xx` family are availability errors — and so is no status at all, the connection that never got an answer. Try the next provider. A `400` is a bad request — **stop**, because your payload is malformed and the backup will reject it identically. **`any()` is named for failover and priced as a hedge.** Its docstring says *"failover across interchangeable sources — a primary and a mirror, two regions, two providers"*, over an implementation that starts **every** member eagerly. Measured: ten calls where the primary succeeded every time cost **10 primary + 10 backup requests — 20 requests for 10 answers**, 100% amplification against a provider that never failed. The same providers under `try`/`catch`: `[10, 0]`. **"The losers are auto-cancelled" is not "the losers are free."** The abort runs in a `finally` *after* the winner settles, so the loser's request always arrives: 10/10 backup requests completed, **zero** aborted. And `any` has **no preferred member** — a healthy primary that was merely 10 ms slower *lost*, and was aborted mid-flight. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | -------------------------- | ---------------------------------------------------------------------------------------------------- | | Sequential fallback | The correct default — one call in the happy path. Adds the primary's timeout to the failure path. | | Concurrent "first success" | Best latency, **double spend on every call** — including the 99% that didn't need it. | | Hedge after a delay | The nuanced answer. Needs a threshold, and amplifies an outage exactly when you can least afford it. | | Gateway / router | Complete, and a third party in the path plus a bill. | | Retry, not failover | Right for a `429`; useless when the provider is genuinely down. | | Classify then route | What everyone converges on, and what hand-rolled failover usually skips. | ## What StitchAPI does [#what-stitchapi-does] **Everything per-provider is free and declarative — and that's most of the work.** Two providers with different origins, paths, auth strategies, retry policies, breakers, timeouts and response shapes compose with zero glue: ```ts const primary = stitch({ url: 'https://primary.example.com/v1/complete', auth: bearer(env('PRIMARY_KEY')), retry: { attempts: 2, on: [429, 503] }, circuit: { failures: 3, cooldown: '30s', key: 'llm:primary' }, pick: 'choices.0.text', transform: (v) => ({ provider: 'primary', value: v }), // attribution }); // …and a `backup` with a different path, `x-api-key` auth, and `pick: 'output'`. ``` Measured: neither credential appeared on the other provider, and per-stitch `pick` normalised two different response envelopes into one string. **The routing between them is yours** — about 30 lines, on `linked`: ```ts import type { StitchError } from 'stitchapi'; import { linked } from 'stitchapi/pipe'; // `0` stands in for "no status at all": `StitchError.status` is `undefined` // when the transport never answered — DNS, refused connection, timeout — which // is exactly the "provider is down" this page is about. `?? 0` routes it here. const FAILOVER_ON = new Set([0, 408, 425, 429, 500, 502, 503, 504]); const answer = await linked(async (run) => { try { return await run(primary, input); } catch (err) { // A 400 means the REQUEST is broken — the backup will reject it too. // (A breaker-open fast-fail carries status 503, so it does fail over.) if (!FAILOVER_ON.has((err as StitchError).status ?? 0)) throw err; return run(backup, input); } }); ``` **Measured:** 10 successful calls sent `[10, 0]`, every one credited to the primary. A `400` stopped the chain at `[1, 0]` with a real `StitchError` 400 and the provider's `invalid_request` body intact. A `503` retried the primary twice, then failed over — `[2, 1]` — in **one trace tree** with the spine `primary ← root, backup ← primary`. With both providers down the caller got the **last real error**, not an aggregate. **30 lines against 104** hand-rolled for the same feature set: the library carries \~71%, all of it per-member, and **zero** of the routing. Use `linked` rather than a bare `try`/`catch`. Both produce the same request counts, but `linked` measured **one** traceId with a `primary → backup` spine, while the bare version produced **two unrelated root traces** — the failover becomes invisible to an on-call engineer at exactly the moment it matters. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **No sequential-fallback combinator.** `all`, `any` and `race` are one eager implementation with three different joins — each measured `[1, 1]` on a single call. `linked` is sequential but returns a Promise, not a node, so the flow can't be nested, handed to a seam, or introspected. 2. **No failure classification for routing.** `retry.on` is exactly the right vocabulary, scoped to the wrong target — it re-hits the *same* endpoint. 3. **`AggregateError` drops everything you'd route on.** `status` and `body` are both `undefined`; the actionable `400` survives only inside `.errors[0]`, which no `StitchError` API points at. 4. **No winner identity.** The result is the winner's raw body, and the `pick` that normalises two envelopes destroys the only attribution. The group emits **zero** events of its own — a composition is not a span — so the trace can't break the tie either. Fix it with `transform`, one line per member. 5. **A cancelled member emits nothing terminal** — `start`, `progress`, then silence. Zero `error`, zero `done`. A span-based backend reads that as a leak. 6. **No hedge delay or threshold anywhere.** `race` measured **2.00×** amplification healthy *and* degraded — the doubling is the steady state, not an outage behaviour. The delayed hedge everyone actually recommends took \~11 lines of raw `AbortController` and got the right profile: `[10, 0]` healthy, `[10, 10]` degraded. 7. **A breaker is a health gate, not a budget gate** — 10 healthy calls with `circuit` on both members still measured `[10, 10]`. 8. **`Composable` is not user-authorable.** `makeComposable` is unexported and the member gate checks only the brand, so a hand-branded node **compiles** and then throws `TypeError`. **A per-call header reaches every provider.** Config headers merge *under* input headers, and each auth strategy only overwrites its own header name — so a per-call `headers: { authorization: 'Bearer …' }` intended for the primary was measured **arriving at the backup verbatim**. One vendor is handed another vendor's credential, silently, with no type error. Keep per-provider credentials in each stitch's `auth`, never in the call input. **And two `url`-only stitches share one breaker.** With neither `name` nor `path` they both key on the literal string `'stitch'` — measured, the primary's outage opened the *backup's* breaker and fast-failed 3 of 5 healthy calls. Setting `name` fixes it completely, which makes a diagnostic label load-bearing. ## See also [#see-also] # The mock that passed for six months (/docs/scenarios/stale-fixture) ## The problem [#the-problem] You integrate a vendor API. You write tests. You cannot call the real API on every CI run — it is slow, rate-limited, costs money and mutates state — so you test against something fake. Then the vendor changes the API, and **your tests keep passing.** This is the one scenario in this pass where the failure mode is the test suite actively lying to you. Every approach trades one wrongness for another: a recording goes stale silently (the classic shape is a cassette holding a session token that later expires, so the failure surfaces somewhere unrelated); a hand-written mock encodes your misunderstanding faithfully, because the same person wrote the mock and the code from the same reading of the docs; a vendor sandbox exists for initial development rather than regression and lags production. The consensus is that no single approach suffices — you need virtualisation for volume *and* live verification for accuracy. Which narrows the question for a client library to: **can it tell you your fake has drifted from the real thing?** ## The common solutions [#the-common-solutions] | Approach | What it is | Where it breaks | | ---------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------- | | **Record/replay cassettes** | Record real traffic once, replay forever. | Goes stale silently. Re-recording is a process, not a check. | | **Hand-written mocks** | Fixtures you write from the docs. | Encode your misunderstanding faithfully. Cannot catch what you got wrong. | | **Vendor sandbox** | The vendor's test environment. | Lags production, misses edge cases, slow, rate-limited. Not built for regression runs. | | **Contract testing (Pact)** | Both sides verify a shared contract. | Needs vendor participation. Not available for a third party. | | **Hit production in CI** | The only truly accurate option. | Slow, costly, mutating, and flaky for reasons unrelated to your code. | | **Schema/contract snapshot** | Validate responses against a schema. | The honest middle ground — but only if the *same* schema guards prod and the fixtures. | ## What StitchAPI does [#what-stitchapi-does] ### Resilience tests need no vendor and no waiting [#resilience-tests-need-no-vendor-and-no-waiting] This is the strongest result. With `mockAdapter` + `manualClock` + `collectStitchEvents`, retry, throttle and circuit behaviour is assertable to the millisecond with zero real time elapsed: * **Attempt counts**, three independent ways: `mockAdapter.callCount()` → `3`, `result.attempts` → `3`, and five `progress` events phased `request, retry, request, retry, request`. * **Circuit transitions** — the whole closed → open → half-open → closed trace reads off `callCount()` as `1, 2, 2 (blocked), 3, 4`, because an open circuit doesn't move the transport count. * **Throttle spacing**, exact and self-reporting: requests at virtual `0 / 500 / 1000` for `'2/s'`, with `progress{phase:"throttled"}.waited` reading `500` then `1000`. * **Backoff curves** are exact under the clock — requests at virtual `0 / 1000 / 3000` for an `expo` base-1000 curve. ### Streams are fully deterministic [#streams-are-fully-deterministic] `streamThenError` over an `sse` stitch produced `start, progress, delta, delta, delta, error, done` with all three deltas intact, `error.message: "socket reset"`, `done.ok: false` — and **five repeat runs produced one distinct outcome, byte-identical.** `gatedStream` holds a connection open on a promise you resolve; `sseStream` writes well-formed frames. ### A shared schema catches a fixture that drifts from the contract [#a-shared-schema-catches-a-fixture-that-drifts-from-the-contract] Point the same `output` schema at production and at the fixture, and all four mutations fail the call — a field removed, renamed, retyped, and nulled — with `drift()` naming each: `error|invalid|paid|Expected boolean, received string`. ### Targeting three environments is one config slot [#targeting-three-environments-is-one-config-slot] `extends: { baseUrl, adapter }` aims one endpoint object at fixture, sandbox and production, and the difference is visible through the same schema: `ok`, `ok`, `contract violation (drift)`. A `baseUrl` thunk (`string | (() => string)`) retargets between calls without rebuilding. ## What StitchAPI does not solve [#what-stitchapi-does-not-solve] 1. **The scenario's actual direction is invisible offline.** Everything above catches the *fixture* drifting from the schema. The scenario is the *vendor* drifting while the fixture sits still — measured, that run is **test `ok: true` with zero findings, production `ok: false`, five keys different**. Nothing offline closes it, because offline the only bytes are the fixture's. The comparison that catches it is 8 lines and every seam it needs already exists; what is missing is a place to put a call you are only allowed to make sometimes. 2. **`manualClock` drives eight time-driven features and not the other four.** The table below is the one to keep. A test written against a wall-clock row **passes without asserting anything**. 3. **`mockAdapter` validates almost nothing.** It defaults `status` to 200 and lowercases header names; beyond that it served statuses `999, -1, 0, 1.5, 200.7` and bodies of type `Date`, `Map`, class instance, `undefined`, `function`, `bigint` and Symbol-keyed — all verbatim, through the full engine. The consequence is a green test for code that cannot work: a fixture built from `new Invoice(...)` gives the caller `data.total === 42` from a **prototype getter**, where the same object over a JSON wire is `{"id":"inv_1"}` and `data.total` is `undefined`. 4. **`stubStitch` runs none of the `input` schemas.** One line of calling code passing `42` where the schema says `z.string()`: the real stitch errors and no request leaves the process; the stub **resolves** and records `{"params":{"id":42}}`. There is no slot to hand it the contract — `StubStitchOptions` is `{name,status,config,events}`, and `config` is the redacted read-out shape, which carries no schema. 5. **A fixture cannot say when it was recorded.** Of all exported names across the main entry and `stitchapi/testing`, exactly one matches `/fixture|cassette|record|snapshot|stale|fresh|expire/` — `conformance.fixture`, the transport echo contract for plugin authors. `__config` has no metadata slot, so "recorded on 2026-02-04" is not expressible. 6. **Retry backoff delays are absent from the event stream.** `progress{phase:"retry"}` carries `detail: "status 503"` and `waited: undefined`, where the throttle and reconnect paths both set `waited`. You can assert the backoff, but only by reading `clock.now()` yourself. ### Which features `manualClock` actually drives [#which-features-manualclock-actually-drives] | Feature | Driven by | Measured | | ------------------------------- | ----------------- | -------------------------------------------------------------------------------- | | `retry` backoff | **`manualClock`** | `advance(5000)` → 3 calls; `advance(0)` → 1 call, pending | | `throttle` rate | **`manualClock`** | `advance(3000)` → 3 calls | | `throttle` concurrency | **`manualClock`** | holder releases on virtual time → queued callers proceed | | `circuit.cooldown` | **`manualClock`** | `advance(60_000)` past a 30s cooldown → half-open probe reaches the vendor | | `timeout` (per-attempt) | **`manualClock`** | `advance(2000)` past a 1s timeout → error | | `Retry-After` | **`manualClock`** | reads the injected clock — **and that is the trap**, see below | | OAuth2 token expiry | **`manualClock`** | `advance(600_000)` past a 60s `expires_in` → a fresh token fetch | | AWS SigV4 signing date | **`manualClock`** | `x-amz-date` reads the clock; a default `manualClock()` signs `19700101T000000Z` | | **`timeout.total`** | wall clock | a 1000ms budget survived **2700 virtual ms** across 3 attempts, `ok: true` | | **`cache.ttl`** | wall clock | `advance(600_000)` past a 60s TTL still served the cached entry | | **`memoryStore` TTL** | wall clock | a 1s entry survived 60,000 virtual ms | | **event `at` / `done.elapsed`** | wall clock | `at = 1785941469178` while `clock.now() = 0`; `done.elapsed` reads `0` | | `paginate` | no time | no inter-page delay knob; 3 pages fetched at `clock.now() = 0` | The OAuth2 and SigV4 rows are new arrivals in the driven half: both read the wall clock when this audit measured them — 600,000 virtual ms past a 60s `expires_in` refetched nothing, and no clock reached the signer. Filed as [#650](https://github.com/rejifald/StitchAPI/issues/650) and [#658](https://github.com/rejifald/StitchAPI/issues/658), fixed by [#664](https://github.com/rejifald/StitchAPI/pull/664) and [#667](https://github.com/rejifald/StitchAPI/pull/667): token freshness and `x-amz-date` now read the same injected clock that drives retry and throttle. The four rows still on the wall are deliberate scope — the clock owns control-flow time, not bookkeeping. **`Retry-After` is a trap precisely because it honours the clock.** `parseRetryAfter` computes `httpDateEpoch - clock.now()`, and `manualClock()` starts at `0` — so an HTTP-date header meaning "5 seconds" becomes a wait of roughly **20,000 days**. Start the clock at a realistic epoch if a fixture carries a dated `Retry-After`. **`timeout.total` does not "ignore" the clock — it resets.** The per-attempt clamp *does* fire on virtual time; the wall-anchored part is the deadline (`wallT0 + total`). Virtual sleeps never drain it, so each attempt gets the full budget back. On a real clock it behaves correctly. This is specifically a testing unsoundness, and it was the pass's own prediction that got the mechanism wrong. ## The best available setup [#the-best-available-setup] **98 executable lines across 5 seams**, closing four of the five gaps: a wire-shape guard (rejects the class-instance fixture with `fixture is not a wire shape ($: LiveInvoice)`), a `contractStub` that runs the input schema, a config lint that refuses to pair a `manualClock` with `cache` or `timeout.total`, and a fixture datestamp (`getInvoice recorded 2026-02-04 (182d old)`). The fifth does not close offline. Dating a fixture fails your suite on the **calendar**, not on the drift. The only thing that actually detects the drift is a live comparison — measured returning `DISAGREE live=contract violation (drift) fake=ok` — and it needs a real call. ## See also [#see-also] * [Mocking guide](/docs/guides/testing/mocking) — `mockAdapter`, `stubStitch`, `manualClock` * [Drift](/docs/guides/validation/drift) — what `output` findings mean * [Scenario: a canary rollout of a response-shape change](/docs/scenarios/intermittent-drift) — detecting vendor drift in production, which is the half this scenario cannot do offline * [Scenario: a stream that fails after 800 tokens](/docs/scenarios/mid-stream-failure) — the behaviour `streamThenError` reproduces deterministically # The charge you can't confirm (/docs/scenarios/unconfirmed-write) ## The problem [#the-problem] You POST a charge. The connection times out. **You have no idea whether the money moved.** A timeout does not distinguish "the request never arrived" from "it was processed and the response was lost". No client-side care removes that — you can only make the *retry* safe. And idempotency keys, which do, bring their own edges: the replay returns the **original** outcome including a failure, the key has a **TTL shorter than your job queue**, and the same key with a different body is an **error**. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ---------------------------------- | ------------------------------------------------------------------------------------- | | Retry blindly | Double-charges. The failure the whole scenario exists to prevent. | | Never retry a write | Safe and expensive — every blip becomes a support ticket. | | Key minted per call | Correct for retries *inside* the call; useless across a restart. | | Key derived from the business fact | The right answer — stable across processes, restarts and queues. | | Query-then-decide | The standard recovery, and it must run **first** — after the write it can't un-write. | | Persist intent first | Durable, and now you own a two-phase workflow. | ## What StitchAPI does [#what-stitchapi-does] **`idempotency.keyOf` fixes the restart *and* the concurrency race in configuration alone.** ```ts const charge = stitch({ method: 'POST', url: 'https://api.vendor.com/charges', // Derived from the business fact — stable across processes, restarts and queues. idempotency: { keyOf: (input) => `charge:${(input.body as Charge).invoiceRef}`, }, retry: { attempts: 3 }, }); ``` **Measured**, across six workloads including a crash-and-re-drive, a lost response, a TTL expiry and two concurrent runs: | | charges created | intended | | ---------------------------------------------- | -------------------------- | -------- | | `idempotency: true` (the default), no recovery | **8** — two duplicates | 6 | | derived `keyOf` + a query-first recovery | **5** (the sixth declined) | 6 | Two things work well and are worth naming. The **same key is carried on every attempt** of a call — 3 attempts, 1 key, 1 charge — so a response lost *after* the charge was processed is **recovered** by the retry replaying the stored `200`. And a **cached failure is not retried by default**: a stored `500` produced exactly 1 request under `attempts: 4`, because 500 isn't in the default `retry.on`. **`idempotency: true` is per call, not per intent.** The default key is a `randomUUID()` minted when the request is built, so a queue re-driving a job after a crash mints a *new* key. Measured: **2 distinct keys, 2 charges, for 1 intended payment.** And the library warns about exactly this — unless you follow the advice. The nudge fires only when there is a random key *and* no `retry`, on the reasoning that a retry is the thing a random key does protect. True, and narrower than most readers will assume: **adding `retry` silences the warning while leaving the restart case wide open.** ## The recovery has to run first [#the-recovery-has-to-run-first] A query-then-decide recovery only works **before** the write. Running it after a failure still double-charged on the TTL workload — because a recovery that runs after the write cannot un-write it. That is 19 of the 43 lines, and no hook can do it: a hook cannot change the outcome. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **Restart safety by default** — see the callout. `keyOf` is the fix and it is opt-in. 2. **TTL expiry.** A prune turns a correct, stable key into a second charge — measured **2 charges at a 25 h delay against a 24 h TTL**, 1 charge at 23 h. The duplicate arrives as a clean `200` with **no replay marker**, so nothing client-side can notice. `timeout.total` is per call and cannot bound the gap. 3. **Timeout disambiguation.** A dropped request (**0 charges**) and a lost response (**1 charge**) produced **field-for-field identical** errors: `StitchError`, `status: undefined`, `attempts: 1`, `timed out after 5000ms`, `body: undefined` — and the same live `TimeoutError` on `error.cause`. The class stays unexported, but the instance rides `cause`, so "was this a timeout?" is the structural check `err.cause?.constructor.name === 'TimeoutError'`, not a message match. What it cannot tell you is whether the money moved. 4. **"Don't retry into the unknown."** `retry.on` gates statuses only — a transport failure is retried even with `on: []`. The only lever is `attempts: 1`. 5. **"Don't retry a replayed failure."** Inexpressible declaratively: `interpret` runs *after* the retry check, and `retry.on`'s predicate sees only the status. The vendor's replay marker is visible to `hooks.onResponse` and absent from `StitchError`, which carries no headers. 6. **No event carries the idempotency key** — so with the random default, a query-by-key recovery is impossible: you cannot ask about a key you never learned. 7. **Nothing expresses "sticky for a decline, fresh for a blip."** A stable key makes a recorded failure sticky for the whole TTL — measured, a declined card stayed declined — while the random key never reaches the record and simply charges again on the second run. Both behaviours are defensible; neither is selectable. **`keyOf: (i) => JSON.stringify(i.body)` fails by charging twice, not by erroring.** Key order alone moved the hash — measured **2 keys, 2 charges, statuses `[200, 200, 200]`**, no `409` anywhere, because the vendor never saw the same key twice. Derive from a business reference, canonicalise before hashing — or declare a coercing input **schema**: `keyOf` now reads the **validated** body (#648, closed by #663), so a schema that normalises the body stabilises the key. Measured: the same three renderings under one → 1 key, 1 charge. Two smaller ones: **pagination mints a key per page** (3 pages, 3 keys), and **`verdict: { accept: [409], flag: 'ok' }` swallows an idempotency conflict**, returning the error payload as data — the same trap as [the signature scenario](/docs/scenarios/expiring-signatures). ## See also [#see-also] # The page that moved while you were reading it (/docs/scenarios/unstable-pagination) ## The problem [#the-problem] You page through a live collection — `?offset=0&limit=100`, then `100`, then `200` — while other people are inserting and deleting rows. You end up with a list, and it is quietly wrong. **Offset is a position in a result set, not a position in the data.** The two cases go in opposite directions, and it's worth getting them the right way round: * **An insert behind your cursor** pushes rows to *higher* indices, so your next fixed offset lands on a row you already read. Measured: `skipped []`, **`duplicated ["r04"]`** — 11 items for 10 distinct rows. An offset insert can never cause a skip. * **A delete behind your cursor** pulls rows to *lower* indices, so your next offset jumps past one. Measured: **`skipped ["r05"]`**, `duplicated []`. And the case people don't believe until they see it: **a non-unique sort key breaks it with no writes at all.** Ten rows in, ten rows out, nothing created or destroyed — measured `skipped ["r05"]` *and* `duplicated ["r03"]`, because the server returned tied `created_at` values in a different order on each query. The fix is **keyset (seek) pagination** on a composite `(sort, id)` cursor. That is a *server* capability — no client can make an offset API consistent. What a client can do is **notice**. **Every damaged run above reported success.** `ok: true`, `error: null`, `findings: []`, `status: 200`, `attempts: 1` — byte-identical to a clean run. Measured on the insert, the delete, the tie case, and two more below. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ------------------------- | ----------------------------------------------------------------------------- | | Offset/limit, as offered | Duplicates on insert, skips on delete, both under ties with no writes at all. | | Keyset / seek pagination | The correct fix — if the vendor implemented it. | | Snapshot / point-in-time | Ideal where offered. Rare in REST. | | Sort by an immutable key | Removes the mutable-sort case, not the insert/delete cases. | | Client-side dedupe by id | Fixes duplicates, leaves skips invisible — **and see the callout below**. | | Reconcile against `total` | The obvious check, and it misses the case that matters. | ## What StitchAPI does [#what-stitchapi-does] **Keyset is four lines, and it works.** `next` receives the previous page's raw body, so a composite cursor is straightforward: ```ts paginate: { next: (body) => { const rows = (body as Page).rows; const last = rows.at(-1); return last ? { query: { after_ts: last.created_at, after_id: last.id } } : undefined; }, items: (v) => (v as Page).rows, } ``` **Measured:** against a real seek endpoint, every workload that broke offset came back complete and clean — the insert (which had cost a duplicate), the delete (a skip), and the ties (both) — `skipped []`, `duplicated []`, 10 rows in cursor order. **Sending a composite cursor does not make an endpoint a seek endpoint.** The same four lines against a vendor that *accepts* `(after_ts, after_id)` but orders by `created_at` alone lost `["r03"]` on one collection and duplicated `["r13"]` on another — with zero writes. The client half of keyset is four lines and it is not the half that decides. **Detection, when only offset is on offer, is yours to write.** The library ships no comparison of any page to any other. The seam that works is `output` — a validator over the *aggregated* array, which runs after the loop: ```ts output: reconcile, // dedupes, or fails the run with the ids named ``` Measured over 8 workloads: **zero false negatives** — every run that lost rows was flagged — and 3 false alarms on undamaged runs, which is the right trade for a sync job. **The signal that carries the delete case is not the one anyone writes.** It is that the **declared `total` moved** (10 → 9). A plain length-vs-total check fired **0 of 4** times; the deduped variant missed the delete entirely — because the delete removed a row from `total` at the same instant it removed one from your result, so the arithmetic balances perfectly while `r05` is gone. ## StitchAPI vs the common solution [#stitchapi-vs-the-common-solution] **84 lines against 74 hand-rolled — the library version is longer.** The detection is identical in both, and a raw paging loop is cheaper to write than the declarative equivalent. The two agreed on rows and verdict across all 8 workloads. What the 74 lines don't have is the resilience stack: one `retry` line recovered a page that answered `500` mid-run — 4 wire requests, 10 rows, nothing skipped. Every page runs inside its own attempt loop, so retry, auth, throttle and the circuit breaker apply per page for free. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **No dedupe, no reconciliation, no page-to-page comparison.** Nothing in `paginate`'s three fields (`next`, `items`, `pages`) looks at what the last page contained. 2. **Four different endings share one `break` and one successful result** — the collection ended, a page came back empty, the page cap was hit, or drift emptied a page mid-run. Measured `pages: 50` silently returning **200 of 220 rows**, `ok`, with no event distinguishing cap from end. 3. **`total` is not surfaced anywhere structured** — it's a field in a body you have to catch yourself, in `transform` or `hooks.onResponse` (`next` never sees the terminal page's). 4. **No per-page state.** Every cross-page fact is a closure you own — with the reuse hazard below. 5. **You cannot both succeed and warn.** `output` either replaces the value or fails the run. 6. **`drift()` cannot express this.** Deduping an array re-indexes it, so the findings come back as `coerced`/`undeclared` on element paths — none of them says "duplicate". 7. **`.report()` is a fresh probe.** It re-paginated the collection and did not reproduce the duplicate at all — it describes a run it just made, never the run you made. **Deduping inside the loop can cause the data loss it was meant to prevent.** `items` and `transform` run *per page*, above the break. On a workload where page 2 repeated page 1 verbatim, the deduper emptied that page, the loop treated zero items as the end, and the run finished `ok` having **skipped `["r05"…"r10"]` — 6 rows lost by the fix**, against a declared total of 14. Dedupe in `output`, after the loop, not in `items`. **And a deduping `items` on a reused stitch returns `[]` — successfully — on every call after the first**, because the `seen` set outlives the call. The natural way to write it is defined once and called many times. ## See also [#see-also] # Receiving a signed webhook (/docs/scenarios/webhook-receipt) ## The problem [#the-problem] Stripe, GitHub, Slack push events at an endpoint you host. You verify the signature, decide it's genuine, and act. This is the only scenario in this section where **someone is calling you** — and the [stitch is a per-call primitive, not a server](/docs/concepts/the-stitch): inbound webhooks are your application's job. So this page is about **where the line falls**, and what's on each side of it. What makes the receipt half hard: * **Signature verification needs the exact bytes.** The provider signs the raw payload; any JSON round-trip yields logically identical bytes that don't match. This is the famous `express.raw()`-before-`express.json()` rule, and the most-reported webhook bug there is. * **At-least-once means duplicates are normal.** Dedup on the event id, with a TTL longer than the provider's retry window (Stripe: 3 days). * **Order cannot be trusted.** `subscription.updated` can arrive before `.created`. * **Ack fast.** Providers time out in seconds and retry on slowness. ## The common solutions [#the-common-solutions] | Approach | Where it breaks | | ---------------------------------------- | ----------------------------------------------------------------------------------- | | Provider SDK verifier (`constructEvent`) | Correct, and the right answer per vendor. One per provider. | | Framework raw-body middleware | The standard fix, and entirely about ordering — get it wrong and it fails silently. | | Hand-rolled HMAC | Fine, and easy to get subtly wrong: constant-time compare, timestamp tolerance. | | Webhook gateway (Svix, Hookdeck) | Most complete at scale. A third party in the path, and a bill. | | Dedup on the event id | Necessary. The TTL is the trap — it must outlive the retry window. | | Fetch-on-receipt | Makes payload order irrelevant. Costs an API call per event. | ## Where the line falls [#where-the-line-falls] **`serve` is not a webhook endpoint**, and the measurements are unambiguous. A real `serve()` process answered **404** to a correctly signed Stripe POST at `/webhooks/stripe`, `/webhook`, `/`, `/stitch` and `/hooks/v1/billing` — the route table is exactly `GET /` and `POST /stitch/:name`. On the one route that reaches user code: * the body has already been through `JSON.parse`, and the deepest user-reachable seam got a parsed object with **no raw string anywhere**; * **the inbound headers are dropped entirely** — `stripe-signature` reaches nothing, so there is no signature to verify even if you had the bytes; * a form-encoded provider (Slack) is rejected `400 invalid JSON body` before any of it. The byte gap is real and measured: the provider signed **162 bytes**; a parse→stringify round-trip produces **153** logically identical bytes, and verification returns `bad-signature`. There is also **no inbound-signature primitive anywhere** in the packages. Enumerating all 89 runtime exports — one level into namespace exports, so nothing hides behind one — **none** match `/verif|hmac|…/`; the only near-misses are the BYO-plugin conformance kit. The one HMAC in the repo is `@stitchapi/aws-sigv4`, whose key is imported with `usages: ['sign']` — it structurally cannot verify. ## What StitchAPI does own [#what-stitchapi-does-own] Everything after the ack, and it is worth having: ```ts const billing = seam({ baseUrl: 'https://api.example.com', auth: bearer(env('API_KEY')), throttle: { rate: '10/s' }, }); // Fetch-on-receipt: the payload is a hint; the API is the truth. const getSubscription = billing.stitch({ path: '/subscriptions/{id}', retry: { attempts: 3 }, timeout: { total: '10s' }, }); ``` **Measured:** acting on payload order with a reversed pair left the app believing `trialing/free` while the server said `active/pro` — a silent, self-inflicted downgrade. Fetch-on-receipt converged both events on `active/pro` at a cost of 2 calls for 2 events, and the call survived two 503s in 3 attempts with the retry, backoff, auth and deadline all as config rather than handler code. The **dedup ledger** is also first-class, and the store is yours to supply — `redisStore`, `cloudflareKvStore` and `denoKvStore` all implement the same interface, so durability is a one-line change and `conformance.store` proves a BYO one conforms (measured: all 11 rules, 0 violations). **Use `increment`, not `get`-then-`set`, to claim an event id.** Measured with 3 concurrent deliveries of one id: `get`+`set` returned `[true, true, true]` — three charges — while `increment(key, ttl)` returned `[true, false, false]`, exactly one. The default `memoryStore` is also not durable: `close()` clears it, so a deploy inside the retry window re-processes everything still in flight. ## The boundary, as a number [#the-boundary-as-a-number] A complete, honest implementation — a `node:http` server you own, plus StitchAPI downstream — rejects forged signatures (`400 bad-signature`), rejects a genuine MAC on a 10-minute-old timestamp (`400 stale`, zero side effects), converges a reversed pair, and acks a duplicate at **zero API calls**. | half | lines | what it is | | ------------ | -------------------------------------- | ----------------------------------------------------------------- | | **Receipt** | **154** (96 server + 58 `node:crypto`) | imports *nothing* from `stitchapi` at runtime — one `import type` | | **Reaction** | **63** | almost all config: one seam, two stitches, a 4-line version guard | **71% of the code by line, 100% by concern, is the half StitchAPI does not participate in.** That is the honest shape of this scenario, and it is by design. ## What StitchAPI does not solve here [#what-stitchapi-does-not-solve-here] 1. **Route ownership, raw bytes, and the inbound headers.** All three are absent from `serve`. 2. **HMAC verification and constant-time comparison.** No primitive exists. `xxh128` is **unkeyed** and non-cryptographic — it will produce a plausible digest that authenticates nobody. 3. **Replay/timestamp tolerance.** Yours. 4. **Non-JSON bodies.** `serve` rejects them before any user code. 5. **Any queue, outbox, dead-letter or backpressure.** `pipelineStages` on a maximally configured stitch shows no stage matching queue/detach/defer — `result` is last, and the pipeline never releases a caller early. 6. **The ack/work split.** Worse: `serve` consumes the run to completion before writing a byte, so with `retry: { attempts: 3 }` and a 5 s backoff the ack went out only at attempt 3, after **exactly 10 virtual seconds** — Stripe's timeout to the second. The retry policy manufactures the duplicate it was meant to survive. 7. **Write-order conflict.** Fetch-on-receipt fixes *payload* order, not *write* order: two concurrent handlers with snapshots v2 and v3 landed on v2 under last-write-wins. A version guard recovered it — four lines the library does not express. 8. **`.deleted` events.** The fetch returns 404, indistinguishable from "never existed", so the event type stays load-bearing. **`void call(input)` is a no-op, not fire-and-forget.** It is the spelling anyone reaches for to ack-then-continue, and it made **0 HTTP calls and raised 0 errors** — a stitch call is a lazy thenable that starts on `.then`, so the work is silently dropped *after* the provider was told 200. `void call(input).then(…)` does run it, and is then unsupervised: unhandled it surfaced as an `unhandledRejection`, and `.safe()` reported the failure nowhere. **And `serve` is unauthenticated by design.** An unsigned, forged body under the size cap ran the stitch and returned 200. Anyone who can reach the port can run your stitches — it is a local front door, not an internet-facing endpoint. ## See also [#see-also] # Angular (/docs/integrations/angular) Use `@stitchapi/angular` when you call stitches from Angular components and want the call's lifecycle — pending / success / error, cancellation, refetch, and **streaming re-renders** — managed for you. `injectStitch` is the request/response function; `injectStitchStream` re-renders as each `delta` chunk arrives, which is the differentiator over plain request/response query libraries. Every result is exposed **two ways from one shared execution**: fine-grained **signals** (`data()`, `isPending()`, …) for templates and `computed`, and an RxJS **observable** (`state$`) for the `async` pipe and operators. Both are a thin layer over [`@stitchapi/query-core`](#the-shared-store-stitchapiquery-core), the framework-agnostic store that owns the reactive lifecycle. ## Example [#example] Install the bindings, the store, core, and Angular: npm pnpm yarn bun ```bash npm install @stitchapi/angular@rc @stitchapi/query-core@rc stitchapi@rc @angular/core ``` ```bash pnpm add @stitchapi/angular@rc @stitchapi/query-core@rc stitchapi@rc @angular/core ``` ```bash yarn add @stitchapi/angular@rc @stitchapi/query-core@rc stitchapi@rc @angular/core ``` ```bash bun add @stitchapi/angular@rc @stitchapi/query-core@rc stitchapi@rc @angular/core ``` `stitchapi`, `@angular/core`, and `rxjs` are peer dependencies; `@tanstack/angular-query-experimental` is an **optional** peer, needed only for the `stitchQueryOptions` adapter below. ## `injectStitch` — request / response [#injectstitch--request--response] Call `injectStitch` in an injection context (a component field initializer). Pass the input as a **signal or getter** (`() => this.id()`) so the query re-fetches when it changes — Angular signal inputs are the natural source. Read the result's signals in the template; the in-flight run is aborted when the component is destroyed. ```ts import { Component, input } from '@angular/core'; import { injectStitch } from '@stitchapi/angular'; import { stitch } from 'stitchapi'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); @Component({ selector: 'app-profile', template: ` @if (user.isPending()) { } @else if (user.isError()) { } @else {

{{ user.data()?.name }}

} `, }) export class ProfileComponent { readonly id = input.required(); // `id` is a signal — passing a getter re-fetches when it changes. readonly user = injectStitch(getUser, () => ({ params: { id: this.id() }, })); } ``` ## `injectStitchStream` — live deltas [#injectstitchstream--live-deltas] For a streaming stitch (an [`sse`](/docs/reference/surfaces) or `stream` surface), `injectStitchStream` updates as each chunk arrives. `chunks()` is the running list, `data()` is the accumulated array (`mode: 'append'`, default) or the latest chunk (`mode: 'replace'`), and `status()` is `'streaming'` until the terminal `result`, then `'success'`: ```ts import { Component, input } from '@angular/core'; import { injectStitchStream } from '@stitchapi/angular'; import { sse } from 'stitchapi/sse'; const chat = sse({ url: 'https://api.example.com/chat' }); @Component({ selector: 'app-chat', template: ` @for (c of tokens.chunks(); track $index) { {{ c }} } @if (tokens.isStreaming()) { } `, }) export class ChatComponent { readonly prompt = input.required(); readonly tokens = injectStitchStream(chat, () => ({ body: { prompt: this.prompt() }, })); } ``` Both functions return the same shape — `data`, `error`, `status`, `chunks`, and the `isPending` / `isError` / `isSuccess` / `isStreaming` flags (each a `Signal`), the `state$` observable, plus `refetch` and `cancel`. ## The observable: `state$` [#the-observable-state] Prefer RxJS? The same state is a multicast `Observable>` on `state$`, sharing the one query execution with the signals. Read it with the `async` pipe: ```ts import { AsyncPipe } from '@angular/common'; import { Component, input } from '@angular/core'; import { injectStitch } from '@stitchapi/angular'; @Component({ selector: 'app-profile', imports: [AsyncPipe], template: ` @if (state$ | async; as s) { @if (s.isSuccess) {

{{ s.data?.name }}

} } `, }) export class ProfileComponent { readonly id = input.required(); readonly state$ = injectStitch(getUser, () => ({ params: { id: this.id() }, })).state$; } ``` ## The shared store: `@stitchapi/query-core` [#the-shared-store-stitchapiquery-core] The bindings hold almost no logic. All of it — running the call under an `AbortController`, publishing status transitions, folding `delta` chunks into state, `cancel()` by aborting, `refetch()` by re-running — lives in `@stitchapi/query-core`'s `createStitchQuery`, a `subscribe` / `getSnapshot` handle with **no framework imports** and **no `node:*`**, so it is browser- and edge-safe. Angular bridges it to an observable and derives the signals from that: ```ts twoslash import { createStitchQuery } from '@stitchapi/query-core'; import { stitch } from 'stitchapi'; import { z } from 'zod'; const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', output: z.array(z.object({ id: z.number(), name: z.string() })), }); const query = createStitchQuery(listUsers, { query: { q: 'ada' } }); const unsubscribe = query.subscribe(() => { const snapshot = query.getSnapshot(); if (snapshot.isSuccess) console.log(snapshot.data); }); ``` Because the whole reactive lifecycle lives in the store and not the binding, the [React](/docs/integrations/react), [Vue](/docs/integrations/vue), and [Svelte](/docs/integrations/svelte) bindings are the same few lines against their own reactive primitive — Angular's are `toSignal` + `toObservable`. ## Optional: TanStack Query [#optional-tanstack-query] Already on [TanStack Query](/docs/integrations/tanstack-query)? `stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object you pass straight to `injectQuery` — it imports nothing from `@tanstack/angular-query-experimental`, so it works even if you never install it: ```ts import { Component, input } from '@angular/core'; import { stitchQueryOptions } from '@stitchapi/angular'; import { injectQuery } from '@tanstack/angular-query-experimental'; @Component({ selector: 'app-profile', template: `...` }) export class ProfileComponent { readonly id = input.required(); readonly user = injectQuery(() => stitchQueryOptions(getUser, { params: { id: this.id() } }), ); } ``` Reach for `stitchQueryOptions` when TanStack Query already owns your view state and you want a stitch as the `queryFn`; reach for `injectStitch` / `injectStitchStream` when you want StitchAPI to own the lifecycle directly — especially for streaming, which `injectQuery` does not model. See the [TanStack Query guide](/docs/integrations/tanstack-query) for how the two layers split. ## See also [#see-also] # AWS SigV4 (/docs/integrations/aws-sigv4) Core's built-in [auth strategies](/docs/reference/auth-strategies) cover bearer, apiKey, basic, cookieSession, and oauth2 — but not **request signing**, which AWS APIs, S3-compatible object stores, and many SigV4-protected endpoints require. `@stitchapi/aws-sigv4` adds it as an `AuthStrategy`. `awsSigV4(...)` signs the **fully-built** request — method, URI, query, headers, and payload hash — at call time, attaching the `Authorization` and `x-amz-*` headers. As with every StitchAPI auth strategy, the caller (an agent) gets a [capability, not the credential](/docs/concepts/capability-not-credential): the keys resolve per call and never reach the call site or a trace. Crypto is the platform's **Web Crypto** (`crypto.subtle`), so it runs unchanged on Node 20+, edge runtimes (Workers / Deno), and the browser; Node 18 falls back to `node:crypto`. No runtime dependencies. ## Example [#example] npm pnpm yarn bun ```bash npm install @stitchapi/aws-sigv4@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/aws-sigv4@rc stitchapi@rc ``` ```bash yarn add @stitchapi/aws-sigv4@rc stitchapi@rc ``` ```bash bun add @stitchapi/aws-sigv4@rc stitchapi@rc ``` `stitchapi` is the only peer dependency. ## Sign a stitch [#sign-a-stitch] Attach `awsSigV4(...)` as the stitch's `auth`. Pass the region, service, and credentials (as `Secret`s — a string or a call-time getter like `env(...)`): ```ts import { awsSigV4 } from '@stitchapi/aws-sigv4'; import { stitch } from 'stitchapi'; import { env } from 'stitchapi/auth'; const putObject = stitch({ baseUrl: 'https://my-bucket.s3.us-east-1.amazonaws.com', path: '/{key}', method: 'PUT', auth: awsSigV4({ region: 'us-east-1', service: 's3', accessKeyId: env('AWS_ACCESS_KEY_ID'), secretAccessKey: env('AWS_SECRET_ACCESS_KEY'), // sessionToken: env('AWS_SESSION_TOKEN'), // temporary STS credentials }), }); ``` The signature is computed on the final request — after path templating and query building — so it always matches the bytes the transport sends. A `sessionToken` adds and signs `x-amz-security-token` for temporary credentials. ## Payload signing [#payload-signing] The `x-amz-content-sha256` header is set for you: * **No body** → the empty-payload hash (always correct). * **String body** → its SHA-256 (exact bytes). * **Non-string body** → `UNSIGNED-PAYLOAD` — safe over HTTPS, and what S3 and many services accept. Set `signBody: true` to hash the body instead. The hash is taken over the exact bytes the transport sends, chosen by the stitch's [`wire.body`](/docs/guides/data/body-encoding): * **JSON** (`wire: { body: 'json' }` or unset) → `JSON.stringify(body)`. * **Form** (`wire: { body: 'form' }`) → its `application/x-www-form-urlencoded` encoding — e.g. the query-protocol bodies SQS, SNS, and STS use. * **Multipart** (`wire: { body: 'multipart' }`) → **cannot be payload-signed**: the transport generates a non-deterministic boundary, so the hash could never match the bytes sent. `signBody: true` with a multipart body **throws** — leave it unset to send `UNSIGNED-PAYLOAD`, or pass a pre-serialised string body to sign it. ## Signing time [#signing-time] `x-amz-date` is stamped from the stitch's [`clock`](/docs/guides/testing/mocking), the same injectable seam that drives retry backoff, throttle pacing, and OAuth2 token freshness — not from `new Date()`. On the default `systemClock` that is wall-clock time, so nothing changes in production; injecting a `manualClock()` makes the signature's timestamp something a test can drive: ```ts import { awsSigV4 } from '@stitchapi/aws-sigv4'; import { stitch } from 'stitchapi'; import { manualClock } from 'stitchapi/testing'; const clock = manualClock(Date.UTC(2015, 7, 30, 12, 36, 0)); const call = stitch({ baseUrl: 'https://my-bucket.s3.us-east-1.amazonaws.com', path: '/objects', clock, auth: awsSigV4({ region: 'us-east-1', service: 's3', accessKeyId: 'AKID…', secretAccessKey: '…', }), }); await call(); // signs x-amz-date: 20150830T123600Z await clock.advance(600_000); // ten virtual minutes await call(); // signs x-amz-date: 20150830T124600Z ``` `manualClock()` starts at epoch `0`, which stamps `19700101T000000Z` — about twenty thousand days of apparent skew, and a real AWS endpoint answers it with `RequestTimeTooSkewed`. Seed the clock when the signature has to be plausible: `manualClock(Date.now())`. Signing at the last moment is also what keeps a signature from going stale in your own backlog: because a throttle waits *before* the request is signed, a queued call cannot age out of its own signature window here. [The signature that expired in your own queue](/docs/scenarios/expiring-signatures) measures that ordering, and what real clock drift still costs you. ## Low-level signer [#low-level-signer] `signRequestV4(params)` is the pure signing function the strategy wraps — exported for out-of-band signing (presigned URLs, custom flows) and verified against the official AWS `aws-sig-v4-test-suite` vectors. ```ts import { EMPTY_PAYLOAD_SHA256, signRequestV4 } from '@stitchapi/aws-sigv4'; const { authorization } = await signRequestV4({ method: 'GET', url: 'https://example.amazonaws.com/', headers: { host: 'example.amazonaws.com', 'x-amz-date': '20150830T123600Z', }, payloadHash: EMPTY_PAYLOAD_SHA256, accessKeyId: 'AKID…', secretAccessKey: '…', region: 'us-east-1', service: 'service', amzDate: '20150830T123600Z', }); ``` Generic per-API HMAC signing isn't shipped as a strategy — each vendor canonicalises differently, so there's no single contract to standardise. SigV4 is the one signing scheme common enough to ship; for a one-off scheme, write a small custom `AuthStrategy` (an `apply(req, ctx)` that sets your header). ## See also [#see-also] # Batch downloads (/docs/integrations/download) Core's [`download` surface](/docs/reference/surfaces#download) fetches **one** file: a buffered `GET` with byte progress, a `Content-Disposition` filename, and a per-call `AbortSignal`. `@stitchapi/download` sequences a **list** of them and adds what a single call can't express — FIFO admission under a `concurrency` ceiling, per-item settling, cancellation, aggregate progress with an ETA, a forward-progress `idle` timeout, and opt-in dedupe. **Every item is an ordinary stitch.** Each download is a real `download()` call, so [`retry`](/docs/guides/resilience/retry), [`throttle`](/docs/guides/resilience/throttle), [`timeout`](/docs/guides/resilience/timeout), `auth`, the [circuit breaker](/docs/guides/resilience/circuit-breaker), and the [event stream](/docs/concepts/event-stream) apply per item exactly as they do on a lone call. This package only orchestrates the batch. **Browser-first, zero runtime dependencies.** The surface returns `Blob`s and never writes to disk, so the same code runs on the server and in the browser. ## Example [#example] npm pnpm yarn bun ```bash npm install @stitchapi/download@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/download@rc stitchapi@rc ``` ```bash yarn add @stitchapi/download@rc stitchapi@rc ``` ```bash bun add @stitchapi/download@rc stitchapi@rc ``` `stitchapi` is the only peer dependency. `downloadAll(items, options)` returns a batch that is **awaitable** — and never rejects. Each item settles on its own, so one failure can't sink the rest: ```ts import { downloadAll } from '@stitchapi/download'; const batch = downloadAll( [ 'https://api.example.com/reports/q1.pdf', 'https://api.example.com/reports/q2.pdf', ], { concurrency: 4, onProgress: (p) => console.log(`${p.completed}/${p.count}`, p.eta), }, ); const results = await batch; // ItemResult[] — in enqueue order, never throws ``` ## Per-item results [#per-item-results] Results come back in **enqueue order**, shaped like `Promise.allSettled` plus a `cancelled` arm: ```ts for (const r of results) { if (r.status === 'fulfilled') save(r.data.blob, r.data.filename); else if (r.status === 'rejected') console.warn(r.id, r.code, r.retryable ? '(retryable)' : '(terminal)'); // r.status === 'cancelled' → the item never finished; nothing to save } ``` | `status` | Also carries | | ------------- | -------------------------------------------------------------------------------------- | | `'fulfilled'` | `data` — the `download()` result: a `blob`, and a `filename` when the server named one | | `'rejected'` | `error` (a `StitchError`), plus a `retryable` verdict and a best-effort machine `code` | | `'cancelled'` | nothing more — cancelling isn't a failure, so it carries no classification | Every arm carries the item's `id`, which is how progress, cancellation, and results correlate. An id defaults to the item's URL and falls back to its enqueue index, so give colliding URLs an explicit `id` when you need to tell them apart. ## Items and shared config [#items-and-shared-config] An item is either a URL string or a partial stitch config with an optional `id`. Shared config goes in `defaults`, merged **under** every item — the item's own fields win: ```ts downloadAll( [ { path: '/reports/q1.pdf' }, { path: '/reports/q2.pdf', retry: { attempts: 5 } }, ], { defaults: { baseUrl: 'https://api.example.com', throttle: { concurrency: 4, pool: 'host' }, }, }, ); ``` ## Bounded concurrency [#bounded-concurrency] Items are admitted in **FIFO order** as slots free, up to `concurrency` (default `4`). `onItemStart` fires at the moment an item leaves the queue for a slot. This is a different ceiling from [`throttle.concurrency`](/docs/guides/resilience/throttle), and they compose. The batch's `concurrency` governs the queue this package owns — the one you can `snapshot()` and cancel out of. A stitch's `throttle` governs the engine's own in-flight budget, pooled per stitch or per host, and so is shared with every other caller of that host rather than scoped to this batch. ## Cancellation [#cancellation] ```ts const batch = downloadAll(urls, { concurrency: 2 }); batch.cancel(id); // cancel ONE item batch.cancel(); // omit the id to cancel EVERYTHING ``` An **in-flight** item aborts, and its freed slot goes to the next queued item. A **still-queued** item simply drops: it held no slot, so nothing else is skipped. Either way the item settles as `cancelled` and the batch still never rejects. The abort reason is a `DownloadCancelledError` (exported, so you can identify it). Pass an `AbortSignal` as `signal` to wire cancel-all to a controller you already own. ## Progress and ETA [#progress-and-eta] `onProgress` reports the batch in aggregate; `onItemProgress` reports one item's bytes: ```ts downloadAll(urls, { onProgress: (p) => overall.set(p.loaded, p.total), onItemProgress: (id, p) => rows[id].set(p.loaded, p.total), }); ``` | Field | Meaning | | ------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `loaded` | Bytes so far, summed across in-flight and fulfilled items | | `total` | Sum of known per-item totals; `undefined` while any **started** item is indeterminate | | `completed` `count` | Items settled (fulfilled + rejected + cancelled), and items in the batch | | `throughput` | **Recent** throughput — a decayed average of the last few seconds, not the lifetime average; `undefined` before any bytes arrive | | `eta` | Estimated ms to completion at the current `throughput`; `undefined` when `total` is unknown or the rate has decayed to zero | `total` firms up as items are admitted — a queued item's size isn't known until it starts, and a chunked response never declares one. A failed or cancelled item's partial bytes are **discarded** from the aggregate, so one dead stream can't inflate the total downloaded. `batch.snapshot()` reads the same aggregate plus each item's phase (`queued`, `active`, or `settled`) at any moment. ## Forward progress: `idle` [#forward-progress-idle] A stitch's `timeout` is **wall-clock** — it fires on elapsed time whether or not bytes are arriving, so a healthy-but-slow download dies by the same clock as a dead one. `idle` is a different clock: it resets on **every** progress chunk, so a slow stream survives while a genuinely stalled one is cut. ```ts downloadAll(urls, { idle: '10s' }); // abort an item after 10s of NO new bytes ``` A stall surfaces as a retryable rejection with code `IDLE_TIMEOUT` (the abort reason is an exported `DownloadIdleTimeoutError`). Both clocks take `number | string`, and both are available: `idle` is the batch's, while the wall-clock `timeout` is set per item or under `defaults`. **Anti-pattern:** don't shrink `timeout` to cut stalled transfers — it's the total-elapsed clock, so any value tight enough to catch a stall also kills the large file that is downloading perfectly well. Set `idle` instead and leave `timeout` as the outer bound. ## Why an item failed [#why-an-item-failed] A bare `fetch failed` says nothing about whether retrying is worth it. Each rejection is classified, so the caller can act on it without parsing a message: ```ts // { id, status: 'rejected', error, retryable: true, code: 'UND_ERR_SOCKET' } ``` `retryable` is `true` for transport faults (`UND_ERR_SOCKET`, `ECONNRESET`, `ENOTFOUND`, and friends), `5xx`, `429`, `408`, and idle timeouts — and `false` for a terminal `4xx`, where a retry can't fix a `404` or a `401`. `code` is a best-effort machine code: an undici transport code, `HTTP_`, `IDLE_TIMEOUT`, or absent when nothing identifiable was recovered. ## Same-request dedupe [#same-request-dedupe] By default every item is an **independent** fetch — predictable, with no cross-item coupling. Opt in to collapse duplicates onto a single in-flight request: ```ts downloadAll([url, url], { dedupe: true }); // one request on the wire ``` Items dedupe by their explicit `id`, else by URL. Followers share the leader's result and its progress — so a follower **cannot be cancelled independently** of the shared fetch. Leave `dedupe` off when each item must be separately cancellable. ## `DownloadManager` [#downloadmanager] `downloadAll` is a one-shot wrapper over the manager. Use the manager directly to enqueue over time — the same options, plus a handle per item: ```ts import { DownloadManager } from '@stitchapi/download'; const mgr = new DownloadManager({ concurrency: 3, onItemSettled: (r) => log(r), }); const handle = mgr.add('https://api.example.com/reports/q3.pdf'); handle.cancel(); // cancel just this item const result = await handle.done; // this item's ItemResult — never rejects await mgr.drained(); // resolves when the queue is fully drained const all = await mgr.results(); // every result so far, in enqueue order ``` `drained()` is named for the **queue**, not for idleness: `idle` is the per-item forward-progress window above, a different clock over a different subject. ## Options [#options] Shared by `downloadAll` and `DownloadManager`: | Option | Type | Default | What it does | | ---------------- | ------------------------------- | ------------- | -------------------------------------------------------------------- | | `concurrency` | `number` | `4` | Max items downloading at once (floored at 1) | | `defaults` | `Partial` | — | Config merged under every item; the item's own fields win | | `idle` | `number \| string` | off | Abort an item after this long with no new bytes | | `dedupe` | `boolean` | `false` | Collapse items sharing a key onto one in-flight request | | `onProgress` | `(p: BatchProgress) => void` | — | Aggregate progress; fires on every per-item chunk | | `onItemProgress` | `(id, p: ItemProgress) => void` | — | One item's byte progress | | `onItemStart` | `(id) => void` | — | Fires as an item is admitted, in FIFO order | | `onItemSettled` | `(r: ItemResult) => void` | — | Fires as each item settles | | `signal` | `AbortSignal` | — | External cancel-all | | `clock` | `Clock` | `systemClock` | Time seam for the idle timer and ETA math — `manualClock()` in tests | ## See also [#see-also] # Elysia (/docs/integrations/elysia) Use `@stitchapi/elysia` when you serve an API with [Elysia](https://elysiajs.com). `.use()` the plugin and every request gets a principal-bound `seam` on its context, plus two bridges into Elysia's Web-standard world: an SSE writer and a stitch-error → HTTP mapping. **Web-standard by construction.** Elysia is Bun-first, but the plugin imports only `elysia` and `stitchapi` — there are **no `node:*` imports**, so it runs unchanged on Bun, Node, Deno, and the edge. ## Example [#example] Install the package alongside core and Elysia: npm pnpm yarn bun ```bash npm install @stitchapi/elysia@rc stitchapi@rc elysia ``` ```bash pnpm add @stitchapi/elysia@rc stitchapi@rc elysia ``` ```bash yarn add @stitchapi/elysia@rc stitchapi@rc elysia ``` ```bash bun add @stitchapi/elysia@rc stitchapi@rc elysia ``` Build (and own) the seam once at startup, then `.use()` the plugin. With a `principal` resolver, each request's context `stitch` is a `seam.as(id)` handle — a principal-bound seam with separate auth sessions per principal over one shared throttle. A `.derive` runs per request and puts it on the context, so a handler reads it straight off the destructured context: ```ts import { stitch } from '@stitchapi/elysia'; import { Elysia } from 'elysia'; import { seam } from 'stitchapi'; import { z } from 'zod'; const api = seam({ baseUrl: 'https://api.example.com' }); const app = new Elysia() .use( stitch({ seam: api, principal: ({ request }) => request.headers.get('x-tenant') ?? undefined, }), ) .get('/me', ({ stitch }) => stitch.stitch({ path: '/me', output: z.object({ id: z.string(), name: z.string() }), })(), ); ``` The principal lives in the closure, never in a call argument, so a handler can never name another identity. Returning `undefined` falls back to the unbound root seam for that request (e.g. anonymous). **Borrow, don't own:** the plugin never calls `seam.close()` — you build the seam at startup and `await api.close()` on shutdown. ## Streaming — `streamStitchSse` [#streaming--streamstitchsse] Stream a streaming/SSE stitch's `.stream()` to the client by returning the `text/event-stream` `Response` `streamStitchSse` builds. Each `delta` becomes a `data:` message (shape it with `data`, name it with `event`, make the stream resumable with `id`); a terminal `error` (or a throw) becomes a final `event: error` message whose `data` is a **generic `error` token by default** — the raw upstream message (an internal hostname, an `HTTP 401`) never reaches the client. Opt in to a client-facing message with `error`, and observe the real failure server-side with `error.observe`. Control events are consumed but not forwarded, and a client disconnect cancels the body and aborts the upstream stitch stream. ```ts import { stitch, streamStitchSse } from '@stitchapi/elysia'; import { Elysia } from 'elysia'; import { seam } from 'stitchapi'; import { sseSurface } from 'stitchapi/sse'; const api = seam({ baseUrl: 'https://api.example.com' }); const app = new Elysia() .use(stitch({ seam: api })) .get('/chat', ({ stitch, query }) => { const chat = stitch.stitch({ kind: sseSurface, path: '/v1/messages' }); return streamStitchSse(chat.stream({ body: { prompt: query.q } }), { delta: (chunk) => (chunk as { data: string }).data, }); }); ``` By default the terminal `error` frame carries a generic `data: error` token — the raw `event.message` is **not** echoed, since it can disclose internal network topology (a transport failure reads like `getaddrinfo ENOTFOUND payments.internal.corp`) or the upstream's status (`HTTP 401`) to the client. Pass `error` to opt in when the upstream messages are known safe (`error: (e) => e.message`); the object form `error: { observe }` still receives the real failure server-side for logging. Both `delta` and `error` also take a full object (`delta: { data, event, id }`, `error: { data, event, observe }`). **Anti-pattern:** return the `streamStitchSse` `Response` as the handler's result — don't also set `set.status` / `set.headers` or return a second value from the same handler. The helper builds the complete `text/event-stream` response (status, headers, and the frame body); a competing write corrupts the stream. ## Errors [#errors] A failed stitch throws a `StitchError` carrying the upstream `status`. The plugin registers an `.onError` that maps it to an HTTP response (`502` by default, so an upstream's status is never leaked) and lets every other error fall through to Elysia's default handling — so handlers need no try/catch: ```ts import { stitch } from '@stitchapi/elysia'; import { Elysia } from 'elysia'; import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com' }); const app = new Elysia().use( stitch({ seam: api, // the default is a safe 502; propagate the upstream status instead: onError: { status: (e) => e.status ?? 502 }, }), ); ``` Set `onError: false` to register none and wire your own with `stitchError.handler()` (an `.onError`-compatible mapper) or `stitchError.map(err, options)` (a one-off `StitchError` → `Response`); `stitchError.is(err)` narrows an unknown error first. `stitchError` is the same namespace every `@stitchapi` host adapter exports for this one concept. The **option** keeps Elysia's own word (`onError`) while the **export** is shared vocabulary. On Bun clusters and other multi-runtime deployments, pair the seam with a shared [store](/docs/guides/state/pluggable-store) so throttle and sessions are fleet-wide rather than per-process. ## See also [#see-also] # Expo (/docs/integrations/expo) Use `@stitchapi/expo` when you call stitches from an Expo app. Expo ships [`expo/fetch`](https://docs.expo.dev/versions/latest/sdk/expo/), a WinterCG-compliant fetch whose `Response#body` is a real `ReadableStream` — so the streaming gap bare React Native has is already solved at the Expo layer, and this package is thin. Everything except the two Expo-specific pieces below is re-exported from [`@stitchapi/react-native`](/docs/integrations/react-native): the `useStitch` / `useStitchStream` hooks, the `asyncStorageStore`, and the `useAppActiveRefetch` / `useReconnectRefetch` lifecycle helpers. You import it all from `@stitchapi/expo`. Bare React Native needs an XHR shim and `TextEncoder` / `ReadableStream` polyfills to stream. Expo needs **neither** — `expoFetchAdapter` is just core's `fetchAdapter` pointed at `expo/fetch`. ## Example [#example] Install the bindings, the shared store, and core: npm pnpm yarn bun ```bash npm install @stitchapi/expo@rc @stitchapi/react-native@rc @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/expo@rc @stitchapi/react-native@rc @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc ``` ```bash yarn add @stitchapi/expo@rc @stitchapi/react-native@rc @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc ``` ```bash bun add @stitchapi/expo@rc @stitchapi/react-native@rc @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc ``` `stitchapi`, `react`, `react-native`, and `expo` are peer dependencies; `expo-secure-store`, `@react-native-async-storage/async-storage`, and `@react-native-community/netinfo` are **optional** peers. ## Wire it into a seam [#wire-it-into-a-seam] `expoFetchAdapter` streams via `expo/fetch`; `expoSecureStore` keeps the auth vault encrypted at rest (keychain / keystore) rather than in plaintext AsyncStorage: ```ts import { expoFetchAdapter, expoSecureStore } from '@stitchapi/expo'; import * as SecureStore from 'expo-secure-store'; import { seam } from 'stitchapi'; export const api = seam({ baseUrl: 'https://api.example.com', adapter: expoFetchAdapter(), vault: expoSecureStore(SecureStore), }); ``` ## Stream deltas into a component [#stream-deltas-into-a-component] ```tsx import { expoFetchAdapter, useStitchStream } from '@stitchapi/expo'; import { sse } from 'stitchapi/sse'; const chat = sse({ url: 'https://api.example.com/chat', adapter: expoFetchAdapter(), }); function Chat({ prompt }: { prompt: string }) { const { chunks, isStreaming } = useStitchStream(chat, { body: { prompt } }); return ( {(chunks as string[]).join('')} {isStreaming ? '▌' : null} ); } ``` ## Refetch on foreground / reconnect [#refetch-on-foreground--reconnect] These come straight through from `@stitchapi/react-native` — they use RN's built-in `AppState`, which Expo apps have: ```tsx import NetInfo from '@react-native-community/netinfo'; import { useAppActiveRefetch, useReconnectRefetch, useStitch, } from '@stitchapi/expo'; const q = useStitch(getInbox, {}); useAppActiveRefetch(q); useReconnectRefetch(q, { netInfo: NetInfo }); ``` ## The secure store [#the-secure-store] `expoSecureStore(SecureStore, options?)` reuses the AsyncStorage store's logic (JSON envelope, TTL, serialized atomic `increment`) over SecureStore's `*Async` methods, hex-encoding engine keys into SecureStore's restricted key charset. Keep individual values under SecureStore's \~2 KB limit — auth tokens fit comfortably. For non-secret throttle counters, pair it with the re-exported `asyncStorageStore`. ## See also [#see-also] # Express (/docs/integrations/express) Use `@stitchapi/express` when you serve an API with [Express](https://expressjs.com) (4 or 5). It is three thin bridges between StitchAPI's backend primitive (the `seam`) and Express's `req`/`res`: middleware that puts a principal-bound seam on the request, an SSE writer, and a stitch-error → HTTP mapper. **The shallow binding.** Express has no plugin / lifecycle / logger structure to bridge — unlike [Fastify](/docs/integrations/fastify) — so this package is just a request handler, an SSE writer, and an error middleware. Importing it augments Express's `Request`, so `req.stitch` is typed everywhere. ## Example [#example] Install the package alongside core and Express: npm pnpm yarn bun ```bash npm install @stitchapi/express@rc stitchapi@rc express ``` ```bash pnpm add @stitchapi/express@rc stitchapi@rc express ``` ```bash yarn add @stitchapi/express@rc stitchapi@rc express ``` ```bash bun add @stitchapi/express@rc stitchapi@rc express ``` Build (and own) the seam once at startup, then register the middleware. With a `principal` resolver, each request's `req.stitch` is a `seam.as(id)` handle — a principal-bound seam with separate auth sessions per principal over one shared throttle: ```ts import { stitch } from '@stitchapi/express'; import express from 'express'; import { seam } from 'stitchapi'; import { z } from 'zod'; const api = seam({ baseUrl: 'https://api.example.com' }); const app = express(); // Put a principal-bound seam on every request. app.use(stitch({ seam: api, principal: (req) => req.header('x-tenant') })); app.get('/me', async (req, res) => { const me = await req.stitch.stitch({ path: '/me', output: z.object({ id: z.string(), name: z.string() }), })(); res.json(me); }); ``` The principal lives in the closure, never in a call argument, so a handler can never name another identity. **Borrow, don't own:** the middleware never calls `seam.close()` — you build the seam at startup and close it on shutdown. A generic helper that only holds a loosely-typed `Request` can read the same handle back with `currentStitch(req)`, which returns `undefined` if the middleware never ran — so a caller can fall back to an explicit seam instead of crashing. ## Streaming — `streamStitchSse` [#streaming--streamstitchsse] Stream a streaming/SSE stitch's `.stream()` to the client as Server-Sent Events. Each `delta` becomes a `data:` frame; a terminal `error` (or a throw) becomes a final `event: error` frame carrying a generic `error` token by default (opt in to a client-facing message with `error`); control events are consumed but not forwarded; and a client disconnect aborts the upstream stitch stream. ```ts import { stitch, streamStitchSse } from '@stitchapi/express'; import express from 'express'; import { seam } from 'stitchapi'; import { sseSurface } from 'stitchapi/sse'; const api = seam({ baseUrl: 'https://api.example.com' }); const app = express(); app.use(stitch({ seam: api })); app.get('/chat', async (req, res) => { const completion = req.stitch.stitch({ kind: sseSurface, path: '/v1/messages', }); await streamStitchSse( res, completion.stream({ body: { prompt: String(req.query.q ?? '') } }), { delta: (chunk) => (chunk as { data: string }).data, req, // also tear down if the request socket signals disconnect }, ); }); ``` **Anti-pattern:** don't also `res.json()` / `res.send()` from a handler that calls `streamStitchSse` — the helper owns the response (it writes the `text/event-stream` head and the frames). A second write corrupts the stream or throws *headers already sent*. Let the helper finish the response; do your own thing in a separate route. ## Errors — `stitchError` [#errors--stitcherror] A failed stitch throws a `StitchError` carrying the upstream `status`. Register `stitchError.handler()` **after your routes** (an Express error middleware is matched by its 4-arg arity) so handlers need no per-route try/catch — it maps a `StitchError` to a JSON response (`502` by default, so an upstream's status is never leaked) and `next(err)`s everything else: ```ts import { stitchError } from '@stitchapi/express'; import express from 'express'; const app = express(); // default 502 — an upstream's 401/404/etc. is never leaked to your client. app.use(stitchError.handler()); // or propagate the upstream status, or shape your own envelope: app.use( stitchError.handler({ status: (e) => e.status ?? 502, body: (e, status) => ({ code: status, msg: e.message }), }), ); ``` Because it `next(err)`s any non-Stitch error, Express's default handler — and any error middleware you register after it — stays in charge of everything else. `stitchError.is(err)` narrows an unknown error to a `StitchError` if you want to branch on one yourself. `stitchError` is the same namespace every `@stitchapi` host adapter exports for this one concept — `.is` everywhere, `.handler` wherever the framework has an error hook, `.map` wherever it has a mapped value to return. Express has no `.map`: its error middleware writes onto `res` and returns nothing. ## See also [#see-also] # Fastify (/docs/integrations/fastify) Use `@stitchapi/fastify` when you serve an API with [Fastify](https://fastify.dev) and want your stitches wired into its request lifecycle: one shared `seam` decorated on the instance, a **request-scoped principal** bound per request, and three bridges into Fastify's world — its built-in Pino logger, SSE streaming, and a stitch-error → HTTP error handler. Like `@stitchapi/nest`, it is a thin peer-dependency plugin that adds no capability of its own: it wires StitchAPI's shared-runtime primitive (one store, one trace sink, a trusted principal boundary) into Fastify, so core stays untouched. ## Example [#example] Install the package alongside core and Fastify: npm pnpm yarn bun ```bash npm install @stitchapi/fastify@rc stitchapi@rc fastify ``` ```bash pnpm add @stitchapi/fastify@rc stitchapi@rc fastify ``` ```bash yarn add @stitchapi/fastify@rc stitchapi@rc fastify ``` ```bash bun add @stitchapi/fastify@rc stitchapi@rc fastify ``` Register the plugin once. Give it a `principal` resolver and every request gets its own `seam.as(principal)` handle on `request.stitch` — a separate session/token over the **shared** store and throttle. The plugin is wrapped with `fastify-plugin`, so the decorators escape its encapsulation and are visible app-wide: ```ts twoslash import { stitchPlugin } from '@stitchapi/fastify'; import Fastify from 'fastify'; const app = Fastify({ logger: true }); app.register(stitchPlugin, { seam: { baseUrl: 'https://api.example.com', retry: { attempts: 3 } }, principal: (req) => req.headers['x-tenant'] as string | undefined, }); app.get('/me', async (request) => request.stitch.stitch({ path: '/me' })()); ``` `request.stitch` is the per-request principal handle; `fastify.stitch` is the root seam, decorated app-wide. This is the trusted boundary StitchAPI's seam exists for: the principal lives in the closure, so a handler can never name another identity. ## Seam: build or borrow [#seam-build-or-borrow] Pass `seam` a config fragment and the plugin **builds and owns** the seam — closing it on the Fastify `onClose` hook. Pass `seam` a prebuilt seam instead and the plugin **borrows** it: the app owns its lifecycle and the plugin never closes it. The one field carries both; the plugin tells them apart at runtime with `isSeam()`. ```ts twoslash import { stitchPlugin } from '@stitchapi/fastify'; import Fastify from 'fastify'; import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com' }); const app = Fastify(); // borrowed — the app owns `api`; override with `closeSeam: true` to force-close. app.register(stitchPlugin, { seam: api }); ``` The ownership rule mirrors `@stitchapi/nest`: a seam the plugin built is closed for you; a borrowed seam is yours to close. ## Ambient principal — `currentStitch()` [#ambient-principal--currentstitch] `currentStitch()` reads the request's bound seam from Node's `AsyncLocalStorage`, so services called from a handler need not thread `request` through every call — a value-add a Node integration can offer that the browser-first core cannot. It returns `undefined` outside a request, so a caller can fall back to an explicit seam: ```ts twoslash import { currentStitch } from '@stitchapi/fastify'; // In a service called from a handler — no `request` threaded through: async function loadProfile() { const api = currentStitch(); // the request's principal-bound seam return api?.stitch({ path: '/profile' })(); } ``` ## SSE streaming [#sse-streaming] `streamStitchSse(reply, stream, options?)` streams a stitch's `.stream()` output to a `text/event-stream` reply. Each `delta` chunk becomes one SSE frame; an `error` event ends the stream as a named `error` frame whose `data` is a generic `error` token by default (the raw upstream message never reaches the client — opt in with `error`); stream end closes the response; and a client disconnect aborts the upstream stitch generator rather than leaving it running. ```ts twoslash import { streamStitchSse } from '@stitchapi/fastify'; import Fastify from 'fastify'; import { sse } from 'stitchapi/sse'; const chat = sse({ url: 'https://api.example.com/chat' }); const app = Fastify(); app.get<{ Querystring: { q: string } }>('/chat', (req, reply) => streamStitchSse(reply, chat.stream({ query: { q: req.query.q } }), { delta: (chunk) => (chunk as { data: string }).data, // pull text out }), ); ``` ## Error handling [#error-handling] The plugin registers a `setErrorHandler` that maps a thrown `StitchError` to an HTTP response (`502` by default, so an upstream's status is never leaked) and rethrows everything else, leaving Fastify's default handler in charge. Propagate the upstream status instead by configuring it: ```ts twoslash import { stitchPlugin } from '@stitchapi/fastify'; import Fastify from 'fastify'; const app = Fastify(); app.register(stitchPlugin, { seam: { baseUrl: 'https://api.example.com' }, errorHandler: { status: (e) => e.status ?? 502 }, }); ``` Set `errorHandler: false` to register none and wire your own with `stitchError.handler(options)`; `stitchError.is(err)` narrows an unknown error to a `StitchError` first. The **option** keeps Fastify's own word (`errorHandler`, after `setErrorHandler`) while the **export** is the `stitchError` namespace every `@stitchapi` host adapter shares. By default `fastify.log` (Fastify's built-in Pino logger) is bridged as the seam's `TraceSink`, logging **only metadata** — name, method, scrubbed URL, status, attempts, timing — never request/response bodies or headers, so it is safe on a secret-bearing seam. Disable it with `logger: false`, or drop the happy path with `logger: { lifecycle: false }`. See [`@stitchapi/pino`](/docs/integrations/pino) for the same sink as a standalone export. ## See also [#see-also] # Hono (/docs/integrations/hono) Use `@stitchapi/hono` when you serve an API with [Hono](https://hono.dev) on the edge or across runtimes. It is three thin bridges between StitchAPI's backend primitive (the `seam`) and Hono's Fetch-based request model: middleware that puts a principal-bound seam on the request context, an SSE helper, and a stitch-error → HTTP mapper. **Edge/multi-runtime by construction.** Every import is from `hono` or `stitchapi`; there are **no `node:*` imports**, so the package runs unchanged on Node, Cloudflare Workers, Deno, Bun, and Vercel Edge. ## Example [#example] Install the package alongside core and Hono: npm pnpm yarn bun ```bash npm install @stitchapi/hono@rc stitchapi@rc hono ``` ```bash pnpm add @stitchapi/hono@rc stitchapi@rc hono ``` ```bash yarn add @stitchapi/hono@rc stitchapi@rc hono ``` ```bash bun add @stitchapi/hono@rc stitchapi@rc hono ``` Build (and own) the seam once at startup, then register the middleware. With a `principal` resolver, each request's `c.var.stitch` is a `seam.as(id)` handle — a principal-bound seam with separate auth sessions per principal over one shared throttle. Parametrise your app with `StitchEnv` so `c.get('stitch')` is typed: ```ts twoslash import { type StitchEnv, stitch } from '@stitchapi/hono'; import { Hono } from 'hono'; import { seam } from 'stitchapi'; import { z } from 'zod'; const api = seam({ baseUrl: 'https://api.example.com' }); const app = new Hono(); // Put a principal-bound seam on every request's context. app.use(stitch({ seam: api, principal: (c) => c.req.header('x-tenant') })); app.get('/me', async (c) => { const me = await c.get('stitch').stitch({ path: '/me', output: z.object({ id: z.string(), name: z.string() }), })(); return c.json(me); }); ``` The principal lives in the closure, never in a call argument, so a handler can never name another identity. **Borrow, don't own:** the middleware never calls `seam.close()` — you build the seam at startup and close it on shutdown. ## Streaming — `streamStitchSse` [#streaming--streamstitchsse] Stream a streaming/SSE stitch's `.stream()` to the client as Server-Sent Events, via Hono's `streamSSE`. Each `delta` becomes a `data:` message; a terminal `error` (or a throw) becomes a final `event: error` message carrying a generic `error` token by default (opt in to a client-facing message with `error`); control events are consumed but not forwarded; and a client disconnect aborts the upstream stitch stream. ```ts twoslash import { type StitchEnv, stitch, streamStitchSse } from '@stitchapi/hono'; import { Hono } from 'hono'; import { seam } from 'stitchapi'; import { sseSurface } from 'stitchapi/sse'; const api = seam({ baseUrl: 'https://api.example.com' }); const app = new Hono(); app.use(stitch({ seam: api })); app.get('/chat', (c) => { const completion = c .get('stitch') .stitch({ kind: sseSurface, path: '/v1/messages' }); return streamStitchSse( c, completion.stream({ body: { prompt: c.req.query('q') } }), { delta: (chunk) => (chunk as { data: string }).data }, ); }); ``` ## Errors — `stitchError` [#errors--stitcherror] A failed stitch rejects with a `StitchError` carrying the upstream `status`. Register `stitchError.handler()` as your app's `onError` so handlers need no per-route try/catch — it maps a `StitchError` to a Hono `HTTPException` (`502` by default, so an upstream's status is never leaked) and rethrows everything else: ```ts twoslash import { stitchError } from '@stitchapi/hono'; import { Hono } from 'hono'; const app = new Hono(); // default 502 — an upstream's 401/404/etc. is never leaked to your client. app.onError(stitchError.handler()); // or propagate the upstream status instead: app.onError(stitchError.handler({ status: (e) => e.status ?? 502 })); ``` Or map a single error by hand with `stitchError.map(err)`, which returns an `HTTPException` — or `undefined` for a non-Stitch error, so you can rethrow it untouched. `stitchError.is(err)` is the guard on its own. `stitchError` is the same namespace every `@stitchapi` host adapter exports for this one concept: `.is` everywhere, `.map` wherever the framework has a mapped value to return, `.handler` wherever it has an error hook. On Cloudflare Workers and other multi-runtime deployments, pair the seam with a shared [store](/docs/guides/state/pluggable-store) (e.g. `@stitchapi/redis`) so throttle and sessions are fleet-wide rather than per-isolate. ## See also [#see-also] # Integrations (/docs/integrations) A stitch is plain TypeScript with no framework and no `node:*`, so it already runs anywhere. The packages here are the **companions**: thin bridges that wire a stitch into the tool you already run — a backend framework, a frontend's reactivity, your logger, your store — so its lifecycle, principal, streaming, and telemetry line up with the host instead of being bolted on. Each one is small on purpose. They plug into a handful of **stable seams**, so a new integration is \~100 lines over an existing contract, not a rewrite — and every companion is proven against the same conformance kits the core ships: * **Backend seam** — lifecycle, request-scoped principal (`seam.as(user)`), an SSE→response bridge, and an error→HTTP mapping, identical across frameworks. * **Reactive store** — [`@stitchapi/query-core`](/docs/integrations/react) owns a framework-agnostic `subscribe` / `getSnapshot` store; each frontend binding is a few lines against its own primitive. * **[`TraceSink`](/docs/guides/observability/trace-sinks)** — forward the typed [event stream](/docs/concepts/event-stream) to the logger or telemetry pipeline you already run. * **[`StitchStore`](/docs/guides/state/pluggable-store)** — move throttle counters, sessions, and cache into a shared backend so they go fleet-wide with no call-site change. ## Server frameworks [#server-frameworks] Attach a `seam` to your server: one shared client with a request-scoped principal, an SSE helper, and an error bridge. ## Client & UI bindings [#client--ui-bindings] Each binding is a thin layer over the shared `@stitchapi/query-core` store, so updates are tearing-free and **streaming-first** — the view re-renders as each `delta` chunk arrives, which plain request/response query libraries don't model. One core, one binding per framework's own reactive primitive — including the mobile bindings, made to stream on device where the platform's `fetch`, storage, and lifecycle differ from the web. ## Data-fetching libraries [#data-fetching-libraries] Run a stitch through the cache-and-revalidation library you already use — the stitch is the fetcher; the library owns caching, dedup, and invalidation. ## File transfers [#file-transfers] Sequence a list of downloads over core's `download` surface — every item still an ordinary stitch, with the batch adding what a single call can't express. ## State stores [#state-stores] Attach a `StitchStore` to move a stitch's throttle counters, sessions, and cache into a shared backend — fleet-wide, with no call-site change. ## Validation [#validation] Adapt a schema you only obtain at runtime — delivered as JSON Schema — into the same Standard Schema contract a stitch validates through. ## Auth [#auth] Core ships bearer / apiKey / basic / cookieSession / oauth2 built in; companions add the schemes that need more. ## AI [#ai] A stitch is agent-native through the [MCP surface](/docs/surfaces/mcp); a companion adapts it to a specific framework's tool-calling loop. ## Observability [#observability] For OpenTelemetry, core exports an OTLP trace bridge directly — see the [OTLP guide](/docs/guides/observability/otlp). ## See also [#see-also] # JSON Schema (/docs/integrations/json-schema) A stitch's `input`/`output` accepts any [Standard Schema](/docs/guides/validation/standard-schema) — Zod, Valibot, ArkType — but those describe schemas you **wrote**. Sometimes the schema shows up at **runtime**: you fetch it, a tenant registers it, a config push delivers it, as JSON Schema you didn't have when you compiled. You can't generate a type for a schema that doesn't exist yet — so you don't type across that boundary, you validate at it. `@stitchapi/json-schema` adapts that JSON Schema into a `StitchSchema` (a Standard Schema) a stitch accepts directly. It ships **no validation engine** — you bring your own, so the schema is checked with exactly the keywords, formats, `$ref`s, and draft your app already uses. ## Example [#example] npm pnpm yarn bun ```bash npm install @stitchapi/json-schema@rc stitchapi@rc ajv ``` ```bash pnpm add @stitchapi/json-schema@rc stitchapi@rc ajv ``` ```bash yarn add @stitchapi/json-schema@rc stitchapi@rc ajv ``` ```bash bun add @stitchapi/json-schema@rc stitchapi@rc ajv ``` `stitchapi` is a peer; `ajv` is an optional peer, needed only for the `{ ajv }` engine. ## Adapt a schema [#adapt-a-schema] Pass your configured engine as the second argument. With `{ ajv }`, the adapter calls `.compile()` on the instance you hand it — reusing all of its configuration: ```ts import { JsonSchema } from '@stitchapi/json-schema'; import Ajv from 'ajv'; import { stitch } from 'stitchapi'; const ajv = new Ajv(); // your app's engine — formats, keywords, $refs, draft // `discovered` is a JSON Schema you obtained at runtime. const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', output: JsonSchema.adapt(discovered, { ajv }), }); ``` Used standalone, hand the adapted schema to `compile` for a reusable checker (or `validate` for a one-off) — the same functions a stitch validates through internally. They take any schema a stitch accepts and return a result, never throwing: ```ts import { compile } from 'stitchapi'; const check = compile(JsonSchema.adapt(discovered, { ajv })); const result = await check(payload); if (!result.ok) { // Structured, per-path — hand it back to the sender to correct. // [{ message: 'must be <= 50', path: ['limit'] }] return respondWithErrors(result.issues); } handle(result.value); // `unknown` — a runtime schema carries no static shape ``` `JsonSchema.adapt()` takes an optional type argument. Leave it `unknown` for a runtime-obtained schema; pass `T` only when you already know the shape at authoring time. ## Why you pass the engine [#why-you-pass-the-engine] Ajv configuration is stateful and app-specific — custom keywords, formats, `$ref` resolvers, the draft you target. An engine this package instantiated itself would **throw or silently mis-validate** a schema that relies on your setup: "works everywhere except through the adapter." So the engine isn't hidden — you pass the instance you already use, and the schema is checked with those exact semantics. It also keeps `ajv` out of this package's bundle (the adapter is \~800 bytes) and off your dependency tree unless you use it. ## Bring your own engine [#bring-your-own-engine] Not on Ajv? Pass a compiled `check` instead — a Workers-safe validator, a draft-2020-12 engine, a shared instance. Return `valid` plus a JSON Pointer + message per failure; the issue-path mapping into `{ message, path }` stays identical. ```ts JsonSchema.adapt(discovered, { check: (value) => { const { valid, errors } = myEngine.validate(value); return { valid, issues: errors.map((e) => ({ pointer: e.instanceLocation, // JSON Pointer, e.g. '/limit' message: e.message, })), }; }, }); ``` The output type is `unknown` by design: a schema you only learn at runtime carries no compile-time shape, so the honest result is `unknown` — narrow it where you read it. Anchoring a stitch's `output` to the same schema also lets [`inspect()`](/docs/guides/validation/drift) report where the live service drifts from the contract it handed you. ## See also [#see-also] # NestJS (/docs/integrations/nestjs) Use `@stitchapi/nest` when you want your stitches available through NestJS dependency injection — configured once, injected into services as typed dependencies, with the framework's `Logger` and `ConfigService` wired in and shutdown handled for you. It is a thin peer-dependency package that adds no capability of its own: it wires StitchAPI's shared-runtime primitive (one store, one trace sink, a trusted principal boundary) into Nest's DI graph, so core stays untouched. ## Example [#example] Install the package alongside core: npm pnpm yarn bun ```bash npm install @stitchapi/nest@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/nest@rc stitchapi@rc ``` ```bash yarn add @stitchapi/nest@rc stitchapi@rc ``` ```bash bun add @stitchapi/nest@rc stitchapi@rc ``` Configure the shared client once in your root module. `forRootAsync` resolves its options from an injected factory, which is how `ConfigService` reaches the base URL, secrets, and store. The event stream is bridged to the Nest `Logger` by default — nothing to switch on: ```ts twoslash // app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { StitchModule, fromNestConfig } from '@stitchapi/nest'; import { bearer } from 'stitchapi/auth'; @Module({ imports: [ StitchModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ baseUrl: config.getOrThrow('API_BASE_URL'), auth: bearer(fromNestConfig(config)('API_TOKEN')), }), }), ], }) export class AppModule {} ``` Declare a stitch with `defineStitch`, register it per feature with `forFeature`, and inject it as a typed dependency: ```ts twoslash // users.module.ts import { Injectable, Module } from '@nestjs/common'; import { InjectStitch, type Injected, StitchModule, defineStitch, } from '@stitchapi/nest'; import { z } from 'zod'; export const GetUser = defineStitch('GET_USER', (api) => api.stitch({ path: '/users/{id}', output: z.object({ id: z.number(), name: z.string() }), }), ); @Module({ imports: [StitchModule.forFeature({ stitches: [GetUser] })] }) export class UsersModule {} @Injectable() export class UsersService { constructor( @InjectStitch(GetUser) private readonly getUser: Injected, ) {} find(id: string) { return this.getUser({ params: { id } }); } } ``` `Injected` keeps the injection-site type tied to the definition, so the injected `getUser` is the same typed callable a stitch always is — awaited for the validated result, or `.stream()`-ed for the [event stream](/docs/concepts/event-stream). Call `app.enableShutdownHooks()` in `main.ts`. Without it Nest never fires its shutdown hook, so the shared trace sink is not flushed and the store is not closed on exit. ## Options [#options] `forRoot({ store, trace, ...defaults })` configures the app-wide infrastructure — one store, one trace sink — plus the shared config (`baseUrl`, `auth`, `retry`, `throttle`, …) every stitch inherits; `forRootAsync` resolves the same options from an injected factory. `forFeature({ stitches, seam })` registers injectable stitches and, optionally, a per-upstream seam built over the shared store and trace — `seam: { config, token? }` (its `config` is the feature seam's `baseUrl`/`auth`/…); omit `seam` to attach the stitches to the default one. Two bridges connect the framework, and neither changes core: * **`logger`** bridges a stitch's [event stream](/docs/concepts/event-stream) into the Nest `Logger` as the seam's `TraceSink` — **on by default**, matching the Fastify plugin. It logs only metadata (name, method, scrubbed URL, status, attempts, timing) — never bodies or headers. Disable it with `logger: false`, or drop the happy path with `logger: { lifecycle: false }`; an explicit `trace` wins over the bridge. * **`fromNestConfig(config)('KEY')`** is a `ConfigService`-backed [secret resolver](/docs/guides/auth/secret-resolvers) — the `ConfigService` twin of `env()`. It resolves synchronously at call time, so the credential never lands on the config or in a trace. For multi-tenancy, bind a principal per request with `seam.as(...)`. The principal scopes sessions and cache over the one shared store, so each tenant gets its own login while the connection pool stays shared: ```ts twoslash import { Scope } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { STITCH_SEAM } from '@stitchapi/nest'; import type { Seam } from 'stitchapi'; const TenantSeam = { provide: 'TENANT_SEAM', scope: Scope.REQUEST, useFactory: (root: Seam, req: { user?: { tenantId?: string } }) => root.as(req.user?.tenantId ?? 'anonymous'), inject: [STITCH_SEAM, REQUEST], }; ``` **Anti-pattern:** don't swap the store per request to isolate tenants — bind the principal with `seam.as(tenantId)` instead. Multi-tenancy is principal-scoped over one shared store (per-tenant sessions and cache keys, a single connection pool); a per-request store throws all of that away. Outside an HTTP request — a queue worker or a scheduled job — there is no request to read, so bind the principal explicitly from the job's own data. ## See also [#see-also] # Next.js (/docs/integrations/next) Next App Router route handlers are **Web-standard**: they take a `Request` and return a `Response`. So a stitch already runs in one directly — define a [`seam`](/docs/concepts/the-seam) once and call it in the handler. `@stitchapi/next` adds the two bits you'd otherwise hand-roll on the Web platform: streaming a stitch as Server-Sent Events, and mapping a `StitchError` to a `Response`. It's built on Web standards only (`Response`, `ReadableStream`, `TextEncoder`) — no `next` import — so the same helpers also work in Remix, SvelteKit endpoints, Bun, Deno, and Workers. ## Example [#example] npm pnpm yarn bun ```bash npm install @stitchapi/next@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/next@rc stitchapi@rc ``` ```bash yarn add @stitchapi/next@rc stitchapi@rc ``` ```bash bun add @stitchapi/next@rc stitchapi@rc ``` `stitchapi` is the only peer dependency. ## A route handler [#a-route-handler] A non-streaming endpoint is the stitch plus the error helper — no per-route `try/catch` shape to invent: ```ts // app/api/users/[id]/route.ts import { getUser } from '@/lib/api'; import { stitchError } from '@stitchapi/next'; export async function GET( _req: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; try { return Response.json(await getUser({ params: { id } })); } catch (err) { const mapped = stitchError.map(err); if (mapped) return mapped; throw err; } } ``` `stitchError.map` returns a `Response` for a `StitchError` and `undefined` for anything else — map the stitch failure, rethrow the rest, no `instanceof` dance. It maps to `502` by default — the safe choice that never leaks the upstream's `401`/`404` semantics. Pass `{ status: (e) => e.status ?? 502 }` to propagate the upstream status instead. `stitchError.is(err)` is the guard on its own. `stitchError` is the same namespace every `@stitchapi` host adapter exports for this one concept. There is no `.handler` here: a route handler is its own `Request` → `Response` function, so Next has no central error hook to register one on — you map in the handler's own `catch`, as above. ## Streaming with SSE [#streaming-with-sse] `streamStitchSse` streams a stitch's events as `text/event-stream`. Each `delta` becomes one frame; an `error` event ends the stream with a named `event: error` frame whose `data` is a generic `error` token by default (opt in to a client-facing message with `error`); the terminal `result` closes it: ```ts // app/api/chat/route.ts import { chat } from '@/lib/api'; import { streamStitchSse } from '@stitchapi/next'; export async function POST(request: Request) { const { prompt } = await request.json(); return streamStitchSse(chat({ body: { prompt } }).stream(), { delta: (c) => String(c), // pull text out of each chunk signal: request.signal, // abort the upstream if the client leaves }); } ``` Pass `request.signal` so a client disconnect aborts the upstream iterator rather than leaving it running. On the client, pair a streaming route with [`useStitchStream`](/docs/integrations/react) (or the Vue / Svelte / Angular binding) to render tokens as they arrive. ## See also [#see-also] # Pino (/docs/integrations/pino) Use `@stitchapi/pino` when your app already logs with [Pino](https://getpino.io) and you want a stitch's [event stream](/docs/concepts/event-stream) to land there as structured records. It is a [`TraceSink`](/docs/guides/observability/trace-sinks): attach it to a seam (or a single stitch) and every call emits one Pino record per event, at the right level, with **no change to the call site**. It is a thin peer-dependency package that adds no capability of its own — it maps StitchAPI's existing trace events onto the logger you already run. ## Example [#example] Install the package alongside core and your Pino: npm pnpm yarn bun ```bash npm install @stitchapi/pino@rc pino ``` ```bash pnpm add @stitchapi/pino@rc pino ``` ```bash yarn add @stitchapi/pino@rc pino ``` ```bash bun add @stitchapi/pino@rc pino ``` `pinoSink(logger)` returns a `TraceSink`. Pass it as the seam's `trace` and the shared client now logs every call through Pino: ```ts twoslash import { pinoSink } from '@stitchapi/pino'; import { pino } from 'pino'; import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com', trace: pinoSink(pino()), }); ``` The same sink works on a single stitch — `stitch({ trace: pinoSink(pino()) })` — so you can opt one endpoint into Pino logging without a seam. Every call now emits structured records in Pino's native shape (a metadata object plus a short message): ```jsonc { "level": 20, "stitch": "getUser", "method": "GET", "url": "https://api.example.com/users", "msg": "→ getUser GET https://api.example.com/users" } { "level": 30, "stitch": "getUser", "status": 200, "attempts": 1, "msg": "← getUser 200 (1 attempt(s))" } ``` ## Event → level mapping [#event--level-mapping] The sink maps each event to a Pino level — the same logic `@stitchapi/nest`'s `Logger` bridge uses — and logs in Pino's **structured** form (`logger.info({ stitch, … }, msg)`) so log queries can pivot on the fields: | Event | Pino level | | ---------- | --------------------------------------------------------------------------------------- | | `error` | `error` | | `drift` | `error` / `warn` / `debug` — follows the finding's own `level` | | `progress` | `warn` for a `retry` / `circuit` phase (upstream flaky / breaker tripped), else `debug` | | `start` | `debug` — gated by `lifecycle` | | `result` | `info` — gated by `lifecycle` | | `done` | `debug` — gated by `lifecycle` | | `delta` | **dropped** — a streamed chunk is raw response data, never logged | `start` / `done` sit at `debug`, so a production Pino level (`info`) hides them by default. Set `lifecycle: false` to drop the happy path entirely and log only retries, drift findings, and errors: ```ts twoslash import { pinoSink } from '@stitchapi/pino'; import { pino } from 'pino'; // ---cut--- const sink = pinoSink(pino(), { lifecycle: false }); ``` ## Bring your own logger [#bring-your-own-logger] The package **imports no logger**. It runs on a small structural `PinoLoggerLike` surface (`{ error, warn, info, debug, trace }`, plus an optional `child`), so a real `pino()` instance, a `logger.child({ requestId })`, and a plain test double are all interchangeable — the same "contract, not dependency" stance core takes everywhere. `pino` is the single peer dependency (v8 or v9). **Metadata only, by design.** A custom `TraceSink` receives the **raw** event — core only redacts inside its own built-in sinks. So `pinoSink` logs only the stitch name, method, **redacted URL** (the query string is stripped — it can carry `?api_key=…`), status, attempt counts, drift path/level/change, progress phase, and timing. It never logs `event.input` (headers like `authorization` stay raw on the event), the response body, or a `delta` chunk. That keeps it safe on a secret-bearing seam regardless of core's trace redaction. ## See also [#see-also] # React Native (/docs/integrations/react-native) Use `@stitchapi/react-native` when you call stitches from a bare React Native app. The `useStitch` / `useStitchStream` hooks are re-exported verbatim from [`@stitchapi/react`](/docs/integrations/react) — they are pure `useSyncExternalStore` over the shared [`@stitchapi/query-core`](/docs/integrations/react#the-shared-store-stitchapiquery-core) store and run unchanged on React Native. What this package **adds** is the platform glue bare RN needs. On **Expo**, use [`@stitchapi/expo`](/docs/integrations/expo) instead — `expo/fetch` streams natively, so it needs none of the XHR shim or polyfills below. ## Why a dedicated adapter [#why-a-dedicated-adapter] StitchAPI is streaming-first, but bare RN's global `fetch` **cannot stream**: `response.body` is `undefined`, not a `ReadableStream` ([facebook/react-native#27741](https://github.com/facebook/react-native/issues/27741)). RN's `XMLHttpRequest`, however, exposes `responseText` incrementally as bytes arrive. `rnStreamAdapter` wraps that growing text in a real `ReadableStream`, which is exactly what core's [`sse`](/docs/reference/surfaces) / `stream` decoders consume — while non-streaming requests delegate to core's buffered [`xhrAdapter`](/docs/guides/transport/xhr-adapter). The `adapter` field it plugs into is the same [transport seam](/docs/guides/transport/adapters) every stitch has, so nothing above it changes. ## Example [#example] Install the bindings, the shared store, core, and React Native: npm pnpm yarn bun ```bash npm install @stitchapi/react-native@rc @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/react-native@rc @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc ``` ```bash yarn add @stitchapi/react-native@rc @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc ``` ```bash bun add @stitchapi/react-native@rc @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc ``` `stitchapi`, `react`, and `react-native` are peer dependencies; `@react-native-async-storage/async-storage` and `@react-native-community/netinfo` are **optional** peers, for the store and reconnect helpers. ### Polyfills (streaming only) [#polyfills-streaming-only] Hermes ships no `TextEncoder` / `TextDecoder` / `ReadableStream`, which the stream decoder needs. Install them once at your app's entry — `rnStreamAdapter` throws a precise error if they're missing. Non-streaming stitches need none of this. ```ts import 'react-native-polyfill-globals/auto'; ``` ## Wire it into a seam [#wire-it-into-a-seam] `rnStreamAdapter` streams when asked and buffers otherwise; `asyncStorageStore` makes the auth vault and throttle counters survive app restarts: ```ts import AsyncStorage from '@react-native-async-storage/async-storage'; import { asyncStorageStore, rnStreamAdapter } from '@stitchapi/react-native'; import { seam } from 'stitchapi'; export const api = seam({ baseUrl: 'https://api.example.com', adapter: rnStreamAdapter(), store: asyncStorageStore(AsyncStorage), }); ``` ## Stream deltas into a component [#stream-deltas-into-a-component] ```tsx import { rnStreamAdapter, useStitchStream } from '@stitchapi/react-native'; import { sse } from 'stitchapi/sse'; const chat = sse({ url: 'https://api.example.com/chat', adapter: rnStreamAdapter(), }); function Chat({ prompt }: { prompt: string }) { const { chunks, isStreaming } = useStitchStream(chat, { body: { prompt } }); return ( {(chunks as string[]).join('')} {isStreaming ? '▌' : null} ); } ``` ## Refetch on foreground / reconnect [#refetch-on-foreground--reconnect] The data lifecycle a mobile app expects — refetch when the app returns to the foreground, and when connectivity comes back: ```tsx import NetInfo from '@react-native-community/netinfo'; import { useAppActiveRefetch, useReconnectRefetch, useStitch, } from '@stitchapi/react-native'; function Inbox() { const q = useStitch(getInbox, {}); useAppActiveRefetch(q); useReconnectRefetch(q, { netInfo: NetInfo }); // ... } ``` `useAppActiveRefetch` uses RN's built-in `AppState` (no extra dependency); `useReconnectRefetch` takes your NetInfo module. Both are also available as plain functions — `onAppActive(appState, cb)` and `onReconnect(netInfo, cb)` — for use outside React. ## The persistent store [#the-persistent-store] `asyncStorageStore(storage, options?)` accepts any client matching `{ getItem, setItem, removeItem }`. Values ride in a JSON envelope with an absolute expiry (AsyncStorage has no native TTL), and `increment` is serialized so concurrent increments stay atomic — the throttle counter behaves exactly as it does on Redis. It is proven against the same `conformance.store` kit every [`StitchStore`](/docs/guides/state/pluggable-store) passes. ## See also [#see-also] # React (/docs/integrations/react) Use `@stitchapi/react` when you call stitches from React components and want the call's lifecycle — pending / success / error, cancellation, refetch, and **streaming re-renders** — managed for you. `useStitch` is the request/response hook; `useStitchStream` re-renders as each `delta` chunk arrives, which is the differentiator over plain request/response query libraries. The hooks are a thin layer over [`@stitchapi/query-core`](#the-shared-store-stitchapiquery-core), the framework-agnostic store that owns the reactive lifecycle. React reads it through `useSyncExternalStore`, so updates are tearing-free. ## Example [#example] Install the hooks, the store, core, and your React: npm pnpm yarn bun ```bash npm install @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc react ``` ```bash pnpm add @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc react ``` ```bash yarn add @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc react ``` ```bash bun add @stitchapi/react@rc @stitchapi/query-core@rc stitchapi@rc react ``` `stitchapi` and `react` are peer dependencies; `@tanstack/react-query` is an **optional** peer, needed only for the `stitchQueryOptions` adapter below. ## `useStitch` — request / response [#usestitch--request--response] Declare a stitch once, then drive it from a component (or a custom hook). The result carries the validated `data`, the `error`, the boolean status flags, and imperative `refetch` / `cancel` handles: ```ts twoslash import { useStitch } from '@stitchapi/react'; import { stitch } from 'stitchapi'; import { z } from 'zod'; const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', output: z.array(z.object({ id: z.number(), name: z.string() })), }); // A custom hook wrapping useStitch — re-renders on the query's transitions. function useUsers(q: string) { const { data, isPending, isError, refetch } = useStitch(listUsers, { query: { q }, }); return { data, isPending, isError, refetch }; } ``` The query is re-created (and re-fetched) when a structural key of `input` changes; pass `options.deps` to control that explicitly. An inline stitch is safe — its function identity does not trigger a re-create — and the in-flight run is aborted on unmount. ## `useStitchStream` — live deltas [#usestitchstream--live-deltas] For a streaming stitch (an [`sse`](/docs/reference/surfaces) or `stream` surface), `useStitchStream` re-renders as each chunk arrives. `chunks` is the running list, `data` is the accumulated array (`mode: 'append'`, default) or the latest chunk (`mode: 'replace'`), and `status` is `'streaming'` until the terminal `result`, then `'success'`: ```ts twoslash import { useStitchStream } from '@stitchapi/react'; import { sse } from 'stitchapi/sse'; const chat = sse({ url: 'https://api.example.com/chat' }); // Re-renders as each `delta` chunk streams in — the streaming differentiator. function useChat(prompt: string) { const { chunks, isStreaming } = useStitchStream(chat, { body: { prompt }, }); return { chunks, isStreaming }; } ``` Both hooks return the same shape — `data`, `error`, `status`, `chunks`, the `isPending` / `isError` / `isSuccess` / `isStreaming` flags, plus `refetch` and `cancel`. ## The shared store: `@stitchapi/query-core` [#the-shared-store-stitchapiquery-core] The hooks hold almost no logic. All of it — running the call under an `AbortController`, publishing status transitions, folding `delta` chunks into state, `cancel()` by aborting, `refetch()` by re-running — lives in `@stitchapi/query-core`'s `createStitchQuery`, a `subscribe` / `getSnapshot` handle with **no framework imports** and **no `node:*`**, so it is browser- and edge-safe. ```ts twoslash import { createStitchQuery } from '@stitchapi/query-core'; import { stitch } from 'stitchapi'; import { z } from 'zod'; const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', output: z.array(z.object({ id: z.number(), name: z.string() })), }); const query = createStitchQuery(listUsers, { query: { q: 'ada' } }); const unsubscribe = query.subscribe(() => { const snapshot = query.getSnapshot(); if (snapshot.isSuccess) console.log(snapshot.data); }); ``` That `subscribe` / `getSnapshot` shape is exactly what `useSyncExternalStore` (and the equivalent external-store primitives in **Vue**, **Svelte**, and **Solid**) consume directly. Because the whole reactive lifecycle lives in the store and not the binding, those bindings are the same few lines against their own primitive — which is why `@stitchapi/react` is small and Vue/Svelte/Solid bindings are cheap follow-ons rather than rewrites. ## Optional: TanStack Query [#optional-tanstack-query] Already on [TanStack Query](/docs/integrations/tanstack-query)? `stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object you pass straight to `useQuery` — it imports nothing from `@tanstack/react-query`, so it works even if you never install it: ```ts twoslash import { stitchQueryOptions } from '@stitchapi/react'; import { useQuery } from '@tanstack/react-query'; import { stitch } from 'stitchapi'; import { z } from 'zod'; const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', output: z.array(z.object({ id: z.number(), name: z.string() })), }); function useUsers(q: string) { return useQuery(stitchQueryOptions(listUsers, { query: { q } })); } ``` Reach for `stitchQueryOptions` when TanStack Query already owns your view state and you want a stitch as the `queryFn`; reach for `useStitch` / `useStitchStream` when you want StitchAPI to own the lifecycle directly — especially for streaming, which `useQuery` does not model. See the [TanStack Query guide](/docs/integrations/tanstack-query) for how the two layers split. ## See also [#see-also] # RTK Query (/docs/integrations/rtk-query) Use `@stitchapi/rtk-query` when your app is already on [RTK Query](https://redux-toolkit.js.org/rtk-query/overview). RTK Query keeps owning the cache, tags, and generated hooks; the stitch stays the **typed, validated, traced** call. The adapters are plain functions (no React), so they work with both `@reduxjs/toolkit/query` and `@reduxjs/toolkit/query/react`. No `@stitchapi/query-core` — RTK Query is the store. ## Example [#example] Install the adapter alongside core and Redux Toolkit: npm pnpm yarn bun ```bash npm install @stitchapi/rtk-query@rc stitchapi@rc @reduxjs/toolkit ``` ```bash pnpm add @stitchapi/rtk-query@rc stitchapi@rc @reduxjs/toolkit ``` ```bash yarn add @stitchapi/rtk-query@rc stitchapi@rc @reduxjs/toolkit ``` ```bash bun add @stitchapi/rtk-query@rc stitchapi@rc @reduxjs/toolkit ``` `stitchapi` and `@reduxjs/toolkit` are peer dependencies. ## `stitchQueryFn` — a stitch as an endpoint [#stitchqueryfn--a-stitch-as-an-endpoint] `stitchQueryFn(stitch)` returns an endpoint `queryFn`: `{ data }` with the validated output on success, or `{ error }` with a **serialisable** error on a throw (so Redux holds no non-serialisable value). `queryFn` bypasses the `baseQuery`, so `fakeBaseQuery()` is the natural base when every endpoint is a stitch: ```ts import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react'; import { stitchQueryFn } from '@stitchapi/rtk-query'; import { stitch } from 'stitchapi'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); export const api = createApi({ baseQuery: fakeBaseQuery(), endpoints: (build) => ({ getUser: build.query({ queryFn: stitchQueryFn(getUser) }), }), }); export const { useGetUserQuery } = api; // useGetUserQuery({ params: { id } }) ``` ## `stitchStreamUpdater` — streaming into the cache [#stitchstreamupdater--streaming-into-the-cache] RTK Query is the one cache library here that models streaming, via `onCacheEntryAdded`. `stitchStreamUpdater(stitch)` folds a streaming stitch's `delta` chunks into the cached array as they arrive. Seed the endpoint with an empty array through `queryFn`: ```ts import { sse } from 'stitchapi/sse'; import { stitchStreamUpdater } from '@stitchapi/rtk-query'; const chat = sse({ url: 'https://api.example.com/chat' }); // inside endpoints: (build) => ({ ... }) chat: build.query({ queryFn: () => ({ data: [] }), onCacheEntryAdded: stitchStreamUpdater(chat), }), ``` The cached data is the accumulated chunks (`mode: 'append'`, default) or the latest chunk — pass the mode as a scalar shorthand: `stitchStreamUpdater(chat, 'replace')` ≡ `stitchStreamUpdater(chat, { mode: 'replace' })`. Streaming stops when the cache entry is removed (the last subscriber leaves). Reach for these adapters when RTK Query already owns your view state and you want a stitch as the endpoint. For StitchAPI to own the lifecycle directly, use [`useStitch` / `useStitchStream`](/docs/integrations/react). The same split applies to [TanStack Query](/docs/integrations/tanstack-query) and [SWR](/docs/integrations/swr). ## See also [#see-also] # Sentry (/docs/integrations/sentry) The logger sinks ([`@stitchapi/pino`](/docs/integrations/pino), core's `loggerSink`) and the [OTLP bridge](/docs/guides/observability/otlp) cover logs and traces; [Sentry](https://sentry.io)'s model is different — a trail of **breadcrumbs** leading up to a **captured error**. `@stitchapi/sentry` is a [`TraceSink`](/docs/guides/observability/trace-sinks) that maps the stitch [event stream](/docs/concepts/event-stream) onto it: routine events become breadcrumbs, and an `error` event is captured as a Sentry issue with the call's context — so the breadcrumb trail attaches automatically. **Bring your own Sentry.** The sink imports no SDK — it talks to a small structural surface that `@sentry/node`, `@sentry/browser`, `@sentry/react`, and friends all satisfy. There's no `@sentry/*` dependency. ## Example [#example] npm pnpm yarn bun ```bash npm install @stitchapi/sentry@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/sentry@rc stitchapi@rc ``` ```bash yarn add @stitchapi/sentry@rc stitchapi@rc ``` ```bash bun add @stitchapi/sentry@rc stitchapi@rc ``` `stitchapi` is the only peer dependency; pass whichever `@sentry/*` SDK you already run. ## Usage [#usage] `sentrySink(Sentry)` returns a `TraceSink`. Pass it as a seam's `trace` and every call now reports through Sentry: ```ts import * as Sentry from '@sentry/node'; import { sentrySink } from '@stitchapi/sentry'; import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com', trace: sentrySink(Sentry), }); ``` The same sink works on a single stitch — `stitch({ trace: sentrySink(Sentry) })`. ## What it sends [#what-it-sends] | Event | Sentry | | ------------------------------ | --------------------------------------------------------------------- | | `error` | `captureMessage` (level `error`) + an error breadcrumb | | `progress` (`retry`/`circuit`) | breadcrumb, level `warning` | | `progress` (throttle/paginate) | breadcrumb, level `debug` | | `drift` | breadcrumb (level follows the finding); captured with `capture.drift` | | `start` / `result` / `done` | breadcrumb only when `lifecycle: true` (off by default) | | `delta` / `info` | **never sent** (raw response data / strategy announcements) | Options: `{ lifecycle?, capture? }` — `capture` is `boolean | { errors?, drift? }`: `true`/omitted captures errors (not drift), `false` disables both, or set `errors`/`drift` independently. **Metadata only, by design.** A custom `TraceSink` receives the **raw** event — core only redacts inside its own built-in sinks. So this sink sends only the stitch name, method, **redacted URL** (the query string is stripped — it can carry `?api_key=…`), status, attempt counts, drift path/level/change, phase, and timing. It never sends `event.input` (its headers still hold the live `authorization` / `cookie`), the response `data`, or a `delta` chunk — safe on a secret-bearing seam regardless of core's trace redaction. ## See also [#see-also] # Solid (/docs/integrations/solid) Use `@stitchapi/solid` when you call stitches from Solid components and want the call's lifecycle — pending / success / error, cancellation, refetch, and **streaming re-renders** — managed for you. `createStitch` is the request/response primitive; `createStitchStream` reconciles each `delta` chunk into the store as it arrives, which is the differentiator over plain request/response query libraries. The primitives are a thin layer over [`@stitchapi/query-core`](#the-shared-store-stitchapiquery-core), the framework-agnostic store that owns the reactive lifecycle. Solid mirrors each snapshot into a `createStore`, so reads inside JSX track fine-grained. ## Example [#example] Install the primitives, the store, core, and Solid: npm pnpm yarn bun ```bash npm install @stitchapi/solid@rc @stitchapi/query-core@rc stitchapi@rc solid-js ``` ```bash pnpm add @stitchapi/solid@rc @stitchapi/query-core@rc stitchapi@rc solid-js ``` ```bash yarn add @stitchapi/solid@rc @stitchapi/query-core@rc stitchapi@rc solid-js ``` ```bash bun add @stitchapi/solid@rc @stitchapi/query-core@rc stitchapi@rc solid-js ``` `stitchapi`, `@stitchapi/query-core`, and `solid-js` (`^1.8`) are peer dependencies; `@tanstack/solid-query` is an **optional** peer, needed only for the `stitchQueryOptions` adapter below. ## `createStitch` — request / response [#createstitch--request--response] Declare a stitch once, then drive it from a component. `input` and `options` may be plain values (read once) or accessors — pass an accessor (`() => props.id`) so the query re-fetches when its value changes. Read state off the returned store's `state` proxy: ```tsx import { createStitch } from '@stitchapi/solid'; import { Show } from 'solid-js'; import { stitch } from 'stitchapi'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); function Profile(props: { id: string }) { // Pass an accessor so the query re-fetches when `props.id` changes. const user = createStitch(getUser, () => ({ params: { id: props.id } })); return ( }> } >

{user.state.data?.name}

); } ``` The query is re-created (and re-fetched) when the stitch's name or a structural key of `input` changes. The in-flight run is aborted when the component's scope is disposed (`onCleanup`). **Anti-pattern:** don't destructure `state` — `const {data} = user.state` reads the field once and loses reactivity, because `state` is a Solid store proxy that tracks on property access. Read `user.state.data` directly inside JSX (or an effect) so Solid re-renders fine-grained when it changes. ## `createStitchStream` — live deltas [#createstitchstream--live-deltas] For a streaming stitch (an [`sse`](/docs/reference/surfaces) or `stream` surface), `createStitchStream` reconciles each chunk into the store as it arrives. `chunks` is the running list, `data` is the accumulated array (`mode: 'append'`, default) or the latest chunk (`mode: 'replace'`), and `status` is `'streaming'` until the terminal `result`, then `'success'`: ```tsx import { createStitchStream } from '@stitchapi/solid'; import { For, Show } from 'solid-js'; import { sse } from 'stitchapi/sse'; const chat = sse({ url: 'https://api.example.com/chat' }); function Chat(props: { prompt: string }) { const c = createStitchStream(chat, () => ({ body: { prompt: props.prompt }, })); return (
{(chunk) => {chunk}}
); } ``` Both primitives return the same shape — a `state` proxy carrying `data`, `error`, `status`, `chunks`, and the `isPending` / `isError` / `isSuccess` / `isStreaming` flags, plus `refetch` and `cancel` methods. ## The shared store: `@stitchapi/query-core` [#the-shared-store-stitchapiquery-core] The primitives hold almost no logic. All of it — running the call under an `AbortController`, publishing status transitions, folding `delta` chunks into state, `cancel()` by aborting, `refetch()` by re-running — lives in `@stitchapi/query-core`'s `createStitchQuery`, a `subscribe` / `getSnapshot` handle with **no framework imports** and **no `node:*`**, so it is browser- and edge-safe. Solid subscribes to it and reconciles each snapshot into a `createStore`: ```ts twoslash import { createStitchQuery } from '@stitchapi/query-core'; import { stitch } from 'stitchapi'; import { z } from 'zod'; const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', output: z.array(z.object({ id: z.number(), name: z.string() })), }); const query = createStitchQuery(listUsers, { query: { q: 'ada' } }); const unsubscribe = query.subscribe(() => { const snapshot = query.getSnapshot(); if (snapshot.isSuccess) console.log(snapshot.data); }); ``` Because the whole reactive lifecycle lives in the store and not the binding, the [React](/docs/integrations/react), [Vue](/docs/integrations/vue), and [Svelte](/docs/integrations/svelte) bindings are the same few lines against their own reactive primitive — Solid's is `createStore` + `onCleanup`. ## Optional: TanStack Query [#optional-tanstack-query] Already on [TanStack Query](/docs/integrations/tanstack-query)? `stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object you pass straight to `createQuery` — it imports nothing from `@tanstack/solid-query`, so it works even if you never install it: ```tsx import { stitchQueryOptions } from '@stitchapi/solid'; import { createQuery } from '@tanstack/solid-query'; import { stitch } from 'stitchapi'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); function User(props: { id: string }) { const query = createQuery(() => stitchQueryOptions(getUser, { params: { id: props.id } }), ); return {query.data?.name}; } ``` Reach for `stitchQueryOptions` when TanStack Query already owns your view state and you want a stitch as the `queryFn`; reach for `createStitch` / `createStitchStream` when you want StitchAPI to own the lifecycle directly — especially for streaming, which `createQuery` does not model. See the [TanStack Query guide](/docs/integrations/tanstack-query) for how the two layers split. ## See also [#see-also] # Distributed stores (/docs/integrations/stores) A [`StitchStore`](/docs/guides/state/pluggable-store) is the one seam that turns the process-local pieces of a stitch — its [rate-limit counters](/docs/guides/state/distributed-throttle), its [auth sessions and tokens](/docs/guides/state/shared-sessions), and its response cache — into **fleet-wide** state, with **no change to the call site**. Core ships no backend; you attach one of these drivers to a [`seam`](/docs/concepts/the-seam) and every worker shares the same counters and sessions. Each driver is a thin peer-dependency (or zero-dependency) package that imports no client of its own — it runs on a small structural surface, so the client you already use is a drop-in, and a test double is too. All three are proven against the same `conformance.store` kit from `stitchapi/testing`. ## Which store? [#which-store] The deciding axis is **atomic `increment`**: a distributed *throttle* needs it (rate counters must increment exactly once under concurrency), while shared *cache* and *sessions* need only `get` / `set`. | Package | Runtime | Atomic `increment` → distributed throttle | Shared cache & sessions | TTL unit | | -------------------------- | ---------------------------------------------- | ----------------------------------------- | ----------------------- | ----------- | | `@stitchapi/redis` | Node (TCP) **and** edge (Upstash HTTP) | ✅ Lua `INCR` + `PEXPIRE` | ✅ | ms | | `@stitchapi/deno-kv` | Deno / Deno Deploy · Node & Bun via `@deno/kv` | ✅ atomic compare-and-set | ✅ | ms | | `@stitchapi/cloudflare-kv` | Cloudflare Workers / Pages (Web-API only) | ❌ throws — back it with a Durable Object | ✅ | s (60s min) | Reach for **Redis** on Node — or when you want one store that *also* runs on the edge via Upstash; **Deno KV** on Deno Deploy, where the same handle is replicated globally; **Cloudflare Workers KV** when you're on Workers and need shared cache and sessions (and pair it with a Durable Object if you also need a throttle). ## Redis — `@stitchapi/redis` [#redis--stitchapiredis] npm pnpm yarn bun ```bash npm install @stitchapi/redis@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/redis@rc stitchapi@rc ``` ```bash yarn add @stitchapi/redis@rc stitchapi@rc ``` ```bash bun add @stitchapi/redis@rc stitchapi@rc ``` Attach a `redisStore` to a seam and its throttle budget and auth sessions become fleet-wide. The package imports no Redis client — wrap the one you already run with the matching adapter (`ioredis` and `redis` are optional peers; `@upstash/redis` is matched structurally, so it never weighs on Node-only users): ```ts import { fromIoredis, redisStore } from '@stitchapi/redis'; import Redis from 'ioredis'; import { seam } from 'stitchapi'; const api = seam({ store: redisStore(fromIoredis(new Redis(process.env.REDIS_URL))), }); ``` The same store runs on the **edge** with `fromUpstash` — Upstash speaks Redis over HTTP, so the identical atomic `INCR` + `EXPIRE` logic works from a Vercel or Cloudflare function with no TCP socket: ```ts import { fromUpstash, redisStore } from '@stitchapi/redis'; import { Redis } from '@upstash/redis'; const store = redisStore(fromUpstash(Redis.fromEnv())); ``` `increment` must be **atomic** and set the key's TTL only when it *creates* the counter — the bundled adapters do this in a single Lua `EVAL` (`INCR`, then `PEXPIRE` only when the value is `1`), so a rate window can't slide forever and a crash can't strand an immortal counter. With a shared store the rate limiter is **even-spaced across the fleet** — see [distributed throttle](/docs/guides/state/distributed-throttle) for the exact pacing semantics. Namespace keys with `keyPrefix` to share one Redis with other data: ```ts redisStore(fromIoredis(client), { keyPrefix: 'myapp:' }); ``` ## Cloudflare Workers KV — `@stitchapi/cloudflare-kv` [#cloudflare-workers-kv--stitchapicloudflare-kv] npm pnpm yarn bun ```bash npm install @stitchapi/cloudflare-kv@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/cloudflare-kv@rc stitchapi@rc ``` ```bash yarn add @stitchapi/cloudflare-kv@rc stitchapi@rc ``` ```bash bun add @stitchapi/cloudflare-kv@rc stitchapi@rc ``` Web-API only (no `node:*`), so it runs in a Worker, in Pages Functions, and in any other edge runtime. Bind a KV namespace and the read-heavy halves of a stitch — **cache** and **shared sessions/tokens** — become fleet-wide across every isolate: ```ts import { cloudflareKvStore } from '@stitchapi/cloudflare-kv'; import { seam } from 'stitchapi'; export default { async fetch(req, env) { const api = seam({ store: cloudflareKvStore(env.MY_KV) }); return handle(api, req); }, }; ``` **Workers KV has no atomic increment.** It is last-write-wins `get` / `put` / `delete` only, so a throttle counter built on it would undercount under concurrency and silently break rate limiting. Rather than do that, `cloudflareKvStore(...).increment(...)` **throws** a documented error. For a distributed throttle, back it with a [Durable Object](https://developers.cloudflare.com/durable-objects/) — the single-writer, strongly-consistent counter an atomic `increment` requires. KV remains the right backend for cache and sessions, the common edge need. KV's `expirationTtl` is in **seconds** with a **60-second minimum**; the store reconciles that with the contract's millisecond TTLs (`ttl` → `max(60, ceil(ttl / 1000))` s), so a value asked to live 5s lives 60s (harmless for caches and sessions). `set(key, undefined)` deletes; `keyPrefix` namespaces. ## Deno KV — `@stitchapi/deno-kv` [#deno-kv--stitchapideno-kv] npm pnpm yarn bun ```bash npm install @stitchapi/deno-kv@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/deno-kv@rc stitchapi@rc ``` ```bash yarn add @stitchapi/deno-kv@rc stitchapi@rc ``` ```bash bun add @stitchapi/deno-kv@rc stitchapi@rc ``` Zero runtime dependencies — it never touches the `Deno` global, running on a structural `DenoKvLike` surface that a real `Deno.Kv` satisfies as-is. On Deno Deploy the same handle is replicated globally, so a stitch's state is edge-native for free: ```ts import { denoKvStore } from '@stitchapi/deno-kv'; import { seam } from 'stitchapi'; const api = seam({ store: denoKvStore(await Deno.openKv()) }); ``` The same package runs on **Node or Bun** through the [`@deno/kv`](https://www.npmjs.com/package/@deno/kv) npm package: ```ts import { openKv } from '@deno/kv'; import { denoKvStore } from '@stitchapi/deno-kv'; import { seam } from 'stitchapi'; const api = seam({ store: denoKvStore(await openKv(process.env.DENO_KV_URL)) }); ``` Deno KV has no native `INCR`, so `increment` is an **atomic compare-and-set loop** (read value + `versionstamp`, then `atomic().check(...).set(...).commit()`, retrying if another isolate raced) — N concurrent increments net exactly +N. The TTL (`expireIn`, already in milliseconds) is set only on the increment that creates the counter, so a busy window never resets. `keyPrefix` namespaces every key for sharing one KV database. ## See also [#see-also] # Svelte (/docs/integrations/svelte) Use `@stitchapi/svelte` when you call stitches from Svelte components and want the call's lifecycle — pending / success / error, cancellation, refetch, and **streaming re-renders** — managed for you. `stitchStore` is the request/response store; `stitchStreamStore` emits a new state as each `delta` chunk arrives, which is the differentiator over plain request/response query libraries. Both are real Svelte stores (`{ subscribe }`) built on `readable` from `svelte/store` — the surface that is unchanged across **Svelte 4 and 5** — so you read them with the `$store` auto-subscription you already use. They are a thin layer over [`@stitchapi/query-core`](#the-shared-store-stitchapiquery-core), the framework-agnostic store that owns the reactive lifecycle. ## Example [#example] Install the stores, the shared store, core, and Svelte: npm pnpm yarn bun ```bash npm install @stitchapi/svelte@rc @stitchapi/query-core@rc stitchapi@rc svelte ``` ```bash pnpm add @stitchapi/svelte@rc @stitchapi/query-core@rc stitchapi@rc svelte ``` ```bash yarn add @stitchapi/svelte@rc @stitchapi/query-core@rc stitchapi@rc svelte ``` ```bash bun add @stitchapi/svelte@rc @stitchapi/query-core@rc stitchapi@rc svelte ``` `stitchapi` and `svelte` (`^4 || ^5`) are peer dependencies; `@tanstack/svelte-query` is an **optional** peer, needed only for the `stitchQueryOptions` adapter below. ## `stitchStore` — request / response [#stitchstore--request--response] Create the store once, then read it with `$user`. The run starts on the store's **first subscriber** (so `$user` fetches when the component mounts) and is aborted when the **last subscriber** leaves (component teardown). `refetch()` re-runs; `cancel()` aborts: ```svelte {#if $user.isPending} {:else if $user.isError} {:else}

{$user.data?.name}

{/if} ``` `refetch` and `cancel` are attached as methods on the store itself (`user.refetch()`), while `$user` is the reactive state value. ## `stitchStreamStore` — live deltas [#stitchstreamstore--live-deltas] For a streaming stitch (an [`sse`](/docs/reference/surfaces) or `stream` surface), `stitchStreamStore` emits a new state as each chunk arrives. `chunks` is the running list, `data` is the accumulated array (`mode: 'append'`, default) or the latest chunk (`mode: 'replace'`), and `status` is `'streaming'` until the terminal `result`, then `'success'`: ```svelte {#each $tokens.chunks as c}{c}{/each} {#if $tokens.isStreaming}{/if} ``` Same state shape as `stitchStore`. ## The shared store: `@stitchapi/query-core` [#the-shared-store-stitchapiquery-core] The stores hold almost no logic. All of it — running the call under an `AbortController`, publishing status transitions, folding `delta` chunks into state, `cancel()` by aborting, `refetch()` by re-running — lives in `@stitchapi/query-core`'s `createStitchQuery`, a `subscribe` / `getSnapshot` handle with **no framework imports** and **no `node:*`**, so it is browser- and edge-safe. The Svelte binding hands its `subscribe` straight to `readable`: ```ts twoslash import { createStitchQuery } from '@stitchapi/query-core'; import { stitch } from 'stitchapi'; import { z } from 'zod'; const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', output: z.array(z.object({ id: z.number(), name: z.string() })), }); const query = createStitchQuery(listUsers, { query: { q: 'ada' } }); const unsubscribe = query.subscribe(() => { const snapshot = query.getSnapshot(); if (snapshot.isSuccess) console.log(snapshot.data); }); ``` Because the whole reactive lifecycle lives in the store and not the binding, the [React](/docs/integrations/react), [Vue](/docs/integrations/vue), and Solid bindings are the same few lines against their own reactive primitive — Svelte's is `readable`, the one store contract shared by Svelte 4 and 5. ## Optional: TanStack Query [#optional-tanstack-query] Already on [TanStack Query](/docs/integrations/tanstack-query)? `stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object you pass straight to `createQuery` — it imports nothing from `@tanstack/svelte-query`, so it works even if you never install it: ```svelte ``` Reach for `stitchQueryOptions` when TanStack Query already owns your view state and you want a stitch as the `queryFn`; reach for `stitchStore` / `stitchStreamStore` when you want StitchAPI to own the lifecycle directly — especially for streaming, which `createQuery` does not model. See the [TanStack Query guide](/docs/integrations/tanstack-query) for how the two layers split. ## See also [#see-also] # SWR (/docs/integrations/swr) Use `@stitchapi/swr` when your app is already on [SWR](https://swr.vercel.app) and you want a stitch as the fetcher. SWR keeps owning caching, deduping, and revalidation; the stitch stays the **typed, validated, traced** call. There is no `@stitchapi/query-core` here — SWR is the store. If you'd rather StitchAPI own the lifecycle directly — especially **streaming**, which SWR doesn't model — reach for `useStitchStream` in [`@stitchapi/react`](/docs/integrations/react) instead. ## Example [#example] Install the adapter alongside core, SWR, and React: npm pnpm yarn bun ```bash npm install @stitchapi/swr@rc stitchapi@rc swr react ``` ```bash pnpm add @stitchapi/swr@rc stitchapi@rc swr react ``` ```bash yarn add @stitchapi/swr@rc stitchapi@rc swr react ``` ```bash bun add @stitchapi/swr@rc stitchapi@rc swr react ``` `stitchapi`, `swr`, and `react` are peer dependencies. ## `useStitchSWR` [#usestitchswr] Pass a stitch and its input; get SWR's own response back (`data`, `error`, `isLoading`, `isValidating`, `mutate`). The cache key is the stitch's name plus the input, so two components calling the same stitch with the same input dedupe to one request. ```tsx import { useStitchSWR } from '@stitchapi/swr'; import { stitch } from 'stitchapi'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); function Profile({ id }: { id: string }) { const { data, error, isLoading } = useStitchSWR(getUser, { params: { id }, }); if (isLoading) return ; if (error) return ; return

{data?.name}

; } ``` Pass SWR options as the third argument — they go straight through: ```tsx useStitchSWR(getUser, { params: { id } }, { refreshInterval: 5000 }); ``` ## Conditional fetching & `mutate` [#conditional-fetching--mutate] `swrKey(stitch, input)` returns the cache key, so you can use SWR's null-key conditional pattern or target a `mutate`: ```tsx import { swrKey } from '@stitchapi/swr'; import useSWR from 'swr'; // Skip the request until `id` exists. const { data } = useSWR(id ? swrKey(getUser, { params: { id } }) : null, () => getUser({ params: { id } }), ); ``` The key is `[name, input]`, but never your raw input. `name` is the stitch's `name ?? path ?? url ?? 'stitch'` (so two nameless stitches on different paths can't collide on one entry), and the input is sanitised: the runtime-only `signal` and `onProgress` are dropped — `onProgress` changes identity every render, which would refetch forever — and the **values** of secret-bearing headers are replaced with `[redacted]`. The header itself stays, so a call that legitimately varies by `accept-language` still gets its own entry. Secret headers are the fixed denylist (`authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `x-auth-token`), any `*-token` / `*-api-key` spelling, and anything [`secrets.register`](/docs/reference/helpers) has widened — the same rule `@stitchapi/query-core` applies to every other binding's query key, so a credential redacted in a React app is redacted here too. This matters because SWR keys are persisted by cache providers and shown in devtools: a token in one outlives the request. `useStitchSWR` is for when SWR already owns your view state and you want a stitch as the fetcher. For StitchAPI to own the lifecycle — and for streaming — use [`useStitch` / `useStitchStream`](/docs/integrations/react). The same split applies to [TanStack Query](/docs/integrations/tanstack-query) and [RTK Query](/docs/integrations/rtk-query). ## See also [#see-also] # TanStack Query (/docs/integrations/tanstack-query) Use a stitch as your TanStack Query `queryFn` in React — declare the endpoint once, pass it straight to `useQuery`, and there's no glue code. TanStack Query is not an alternative to StitchAPI — it sits one layer up, and the two fit together cleanly. A stitch is a typed, resilient **function**; TanStack Query is the thing that calls functions from your components and manages the result as view state. So the join is the obvious one: **A stitch *is* the `queryFn`.** Pass the stitch straight to `useQuery`. The stitch owns the *call* — auth, retries, throttling, validation, drift. The query owns the *view* — caching, subscriptions, background refetch, invalidation. Neither reaches into the other's job. ## A stitch is the queryFn [#a-stitch-is-the-queryfn] Declare the endpoint once as a stitch, then hand it to `useQuery`. Awaiting a stitch returns the validated value and **throws a `StitchError` on failure** — which is exactly the contract `queryFn` wants, so there is no glue code. ```ts // stitches.ts — your declarations live here import { seam } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; import { z } from 'zod'; const api = seam({ baseUrl: 'https://api.example.com', auth: bearer(env('API_TOKEN')), retry: { attempts: 3, on: [429, 503] }, }); export const getUser = api.stitch({ path: '/users/{id}', output: z.object({ id: z.number(), name: z.string() }), }); ``` ```tsx // useUser.ts — the React layer import { getUser } from './stitches'; import { useQuery } from '@tanstack/react-query'; export function useUser(id: number) { return useQuery({ queryKey: ['user', id], // The stitch is the queryFn. It throws StitchError → the query sees `error`. queryFn: () => getUser({ params: { id } }), }); } ``` That is the whole integration. `data` is the validated `{ id, name }`; `error` is the `StitchError` (with `.status`, `.attempts`, `.body`, `.url`) when the call fails after the stitch has exhausted its own retries. ## Two layers, cleanly split [#two-layers-cleanly-split] The reason they compose rather than overlap is that they cache and retry at different altitudes: | Concern | The stitch (StitchAPI) | The query (TanStack Query) | | -------------------- | ---------------------------------------------------- | ---------------------------------------------------- | | **Unit** | one call to one endpoint | one piece of UI state, keyed | | **Retries** | transport-level: status-aware backoff, `Retry-After` | re-invokes the whole `queryFn` | | **Caching** | derived-key response cache + in-flight coalescing | view cache: stale-while-revalidate, GC, persistence | | **Validation/types** | runtime-validated, drift-caught on every call | passes the value through untouched | | **Invalidation** | not its job | `queryClient.invalidateQueries`, mutations | | **Lifecycle** | none — it is a function | subscriptions, refetch on focus/reconnect, `enabled` | ## Let the stitch throw — don't use `.safe()` here [#let-the-stitch-throw--dont-use-safe-here] TanStack Query discriminates success from failure by whether `queryFn` **throws**. So inside a query, use the bare awaitable (or `.unwrap()`), *not* `.safe()` — `.safe()` resolves to `{ ok, data, error }` and never throws, which would make every query look successful with `data` shaped like an error envelope. ```tsx // ✅ throws on failure → React Query populates `error` queryFn: () => getUser({ params: { id } }); // ❌ never throws → the query always looks "successful" queryFn: () => getUser.safe({ params: { id } }); ``` Reach for `.safe()` outside React Query — in a loader, a server action, or any place you'd rather branch on `error` than catch. ## Pick one retry owner [#pick-one-retry-owner] Both layers can retry, and you usually want exactly one of them to. The stitch's retry is the smarter one — it is status-aware (`on: [429, 503]`), honors `Retry-After`, and backs off with jitter. So let the stitch own retries and turn the query's generic retry off: ```tsx useQuery({ queryKey: ['user', id], queryFn: () => getUser({ params: { id } }), retry: false, // the stitch already retried; don't multiply the attempts }); ``` Set it once on the `QueryClient` default if you want it project-wide. (If you prefer the query to own retries instead, drop `retry` from the stitch — just don't run both unaware of each other.) ## Mutations [#mutations] A writing stitch is a `mutationFn` the same way: ```tsx import { createUser } from './stitches'; import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useCreateUser() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: { name: string }) => createUser({ body }), onSuccess: () => qc.invalidateQueries({ queryKey: ['user'] }), }); } ``` ## Streaming surfaces stay out of `useQuery` [#streaming-surfaces-stay-out-of-usequery] `useQuery` is request/response. For a streaming stitch — `sse`, `stream`, or an `llm` completion — consume `.stream()` directly (e.g. in an effect or a store) rather than through a query; the event stream is its own subscription model and does not map onto a single cached value. ```tsx import { sse } from 'stitchapi/sse'; const ticks = sse({ url: 'https://api.example.com/ticks' }); // in an effect, not a query: for await (const ev of ticks.stream()) { if (ev.type === 'delta') push(ev.chunk); } ``` ## Do I still need TanStack Query? [#do-i-still-need-tanstack-query] Yes — they solve different problems. The stitch makes a single call dependable; TanStack Query manages many calls' state across your UI (subscriptions, caching, background refetch, invalidation). StitchAPI's own cache is function-level (derived-key responses + coalescing), not a view cache, so it does not replace the query layer. Use a stitch as the `queryFn` and let each layer do its job. The same shape works with **SWR** (`useSWR(key, () => getUser({ params: { id } }))`) and **RTK Query** (a stitch is the `queryFn` in a `fetchBaseQuery` replacement). ## See also [#see-also] # Vercel AI SDK (/docs/integrations/vercel-ai) StitchAPI is agent-native through the [MCP surface](/docs/surfaces/mcp) — the canonical way to hand a stitch to any agent. `@stitchapi/vercel-ai` is the framework-specific convenience for apps already on the [Vercel AI SDK](https://sdk.vercel.ai): wrap a stitch as a `tool()` the model can call inside `generateText` / `streamText`. The stitch runs as the tool's `execute`, so the model gets **typed, validated** data back — and the credential stays behind the boundary (a [capability, not the credential](/docs/concepts/capability-not-credential)). **Structural, version-bridged.** It imports nothing from `ai`; the returned tool carries both `parameters` (AI SDK v4) and `inputSchema` (v5), so it drops into either. ## Example [#example] npm pnpm yarn bun ```bash npm install @stitchapi/vercel-ai@rc stitchapi@rc ``` ```bash pnpm add @stitchapi/vercel-ai@rc stitchapi@rc ``` ```bash yarn add @stitchapi/vercel-ai@rc stitchapi@rc ``` ```bash bun add @stitchapi/vercel-ai@rc stitchapi@rc ``` `stitchapi` is the only required peer; bring the `ai` SDK you already run. ## `stitchTool` [#stitchtool] Drop a stitch straight into a `tools` map: ```ts import { getUser } from './api'; import { stitchTool } from '@stitchapi/vercel-ai'; import { generateText } from 'ai'; import { z } from 'zod'; const { text } = await generateText({ model, prompt: 'Who is user 7?', tools: { getUser: stitchTool(getUser, { description: 'Fetch a user by id', inputSchema: z.object({ id: z.string() }), toInput: ({ id }) => ({ params: { id } }), }), }, }); ``` * `inputSchema` — the schema the model fills (a Zod schema, or any AI SDK `Schema`). * `toInput` — maps the model's args onto the stitch's `{ params, query, body }`. Omit it when the model's args *are* the stitch input. When the schema is all you need, pass it positionally — `stitchTool(getUser, z.object({ params: z.object({ id: z.string() }) }))` ≡ `stitchTool(getUser, { inputSchema: … })` with the args used as the stitch input directly. The tool's result is the stitch's validated output, so the model reasons over real data, not a guess; a failure rejects, and the AI SDK's tool-error handling reports it. ## `stitchExecute` [#stitchexecute] If you'd rather compose the tool yourself — e.g. with `ai`'s own `tool()` for tighter typing — `stitchExecute` is just the `execute`: ```ts import { stitchExecute } from '@stitchapi/vercel-ai'; import { tool } from 'ai'; import { z } from 'zod'; const getUserTool = tool({ description: 'Fetch a user by id', inputSchema: z.object({ id: z.string() }), execute: stitchExecute(getUser, ({ id }) => ({ params: { id } })), }); ``` For a model running outside the AI SDK — Claude, an MCP client, your own agent loop — expose the stitch over [MCP](/docs/surfaces/mcp) instead: one `run_stitch` tool reaches every stitch, no per-endpoint wiring. This adapter is for when the AI SDK already owns your tool-calling loop. ## See also [#see-also] # Vue (/docs/integrations/vue) Use `@stitchapi/vue` when you call stitches from Vue 3 components and want the call's lifecycle — pending / success / error, cancellation, refetch, and **streaming re-renders** — managed for you. `useStitch` is the request/response composable; `useStitchStream` re-renders as each `delta` chunk arrives, which is the differentiator over plain request/response query libraries. The composables are a thin layer over [`@stitchapi/query-core`](#the-shared-store-stitchapiquery-core), the framework-agnostic store that owns the reactive lifecycle. Vue mirrors each snapshot into refs, so destructuring keeps reactivity and templates unwrap `.value` for you. ## Example [#example] Install the composables, the store, core, and Vue: npm pnpm yarn bun ```bash npm install @stitchapi/vue@rc @stitchapi/query-core@rc stitchapi@rc vue ``` ```bash pnpm add @stitchapi/vue@rc @stitchapi/query-core@rc stitchapi@rc vue ``` ```bash yarn add @stitchapi/vue@rc @stitchapi/query-core@rc stitchapi@rc vue ``` ```bash bun add @stitchapi/vue@rc @stitchapi/query-core@rc stitchapi@rc vue ``` `stitchapi` and `vue` (`^3.4`) are peer dependencies; `@tanstack/vue-query` is an **optional** peer, needed only for the `stitchQueryOptions` adapter below. ## `useStitch` — request / response [#usestitch--request--response] Declare a stitch once, then drive it from a component. `input` may be a plain value, a `ref`, or a getter — pass a getter (`() => props.id`) so the query re-fetches when its structural key changes. Each returned field is a `ComputedRef`, so destructuring keeps it reactive and the template unwraps `.value` automatically: ```vue ``` The query is re-created (and re-fetched) when the stitch's name or a structural key of `input` changes. The in-flight run is aborted when the component's scope is disposed (`onScopeDispose`). ## `useStitchStream` — live deltas [#usestitchstream--live-deltas] For a streaming stitch (an [`sse`](/docs/reference/surfaces) or `stream` surface), `useStitchStream` re-renders as each chunk arrives. `chunks` is the running list, `data` is the accumulated array (`mode: 'append'`, default) or the latest chunk (`mode: 'replace'`), and `status` is `'streaming'` until the terminal `result`, then `'success'`: ```vue ``` Both composables return the same shape — `data`, `error`, `status`, `chunks`, the `isPending` / `isError` / `isSuccess` / `isStreaming` flags (each a `ComputedRef`), plus `refetch` and `cancel`. ## The shared store: `@stitchapi/query-core` [#the-shared-store-stitchapiquery-core] The composables hold almost no logic. All of it — running the call under an `AbortController`, publishing status transitions, folding `delta` chunks into state, `cancel()` by aborting, `refetch()` by re-running — lives in `@stitchapi/query-core`'s `createStitchQuery`, a `subscribe` / `getSnapshot` handle with **no framework imports** and **no `node:*`**, so it is browser- and edge-safe. Vue subscribes to it and reconciles each snapshot into a `shallowRef`: ```ts twoslash import { createStitchQuery } from '@stitchapi/query-core'; import { stitch } from 'stitchapi'; import { z } from 'zod'; const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', output: z.array(z.object({ id: z.number(), name: z.string() })), }); const query = createStitchQuery(listUsers, { query: { q: 'ada' } }); const unsubscribe = query.subscribe(() => { const snapshot = query.getSnapshot(); if (snapshot.isSuccess) console.log(snapshot.data); }); ``` Because the whole reactive lifecycle lives in the store and not the binding, the [React](/docs/integrations/react), [Svelte](/docs/integrations/svelte), and Solid bindings are the same few lines against their own reactive primitive — Vue's is `shallowRef` + `watch` + `onScopeDispose`. ## Optional: TanStack Query [#optional-tanstack-query] Already on [TanStack Query](/docs/integrations/tanstack-query)? `stitchQueryOptions(stitch, input)` returns a plain `{ queryKey, queryFn }` object you pass straight to `useQuery` — it imports nothing from `@tanstack/vue-query`, so it works even if you never install it: ```vue ``` Reach for `stitchQueryOptions` when TanStack Query already owns your view state and you want a stitch as the `queryFn`; reach for `useStitch` / `useStitchStream` when you want StitchAPI to own the lifecycle directly — especially for streaming, which `useQuery` does not model. See the [TanStack Query guide](/docs/integrations/tanstack-query) for how the two layers split. ## See also [#see-also] # CLI (/docs/surfaces/cli) Reach for the `stitch` CLI when you want to run a stitch and read its trace from the shell — no app boot. `stitch run ` streams a named stitch's events to stdout as JSONL, and `stitch trace` summarizes the run log those events leave behind. ## Example [#example] Author your stitches in a module that exports each one by name; the CLI discovers them there (default `./stitches.ts`, or pass `--module`). ```ts twoslash // stitches.ts import { stitch } from 'stitchapi'; export const getUser = stitch({ name: 'getUser', baseUrl: 'https://api.example.com', path: '/users/{id}', }); ``` Run it by name. A bare `--id` routes to the `{id}` path param, and every event is printed as one line of JSON, so the stream pipes straight into `jq`: ```bash stitch run getUser --id 1 ``` Then summarize what those runs recorded: ```bash stitch trace --since 1h ``` ## Options [#options] **`stitch run [--module ] [--trace[=console|]] [--flags…]`** resolves the stitch named `` from the module and streams its event stream to stdout as JSONL — one JSON object per line, exit code `1` if an `error` event is seen. Flags map onto the stitch's single input object: a bare `--` becomes a path param when `` is a `{param}` in the path and a query param otherwise, while `--params.`, `--query.`, `--headers.`, and `--body ''` (or `--body. `) target a bucket explicitly. `--module, -m` points at the stitches module (default `./stitches.ts` and friends). `--trace` records the run (off by default): bare writes the default JSONL file for `stitch trace`, `--trace=console` streams events to stderr, and `--trace=` writes JSONL to that path. **`stitch trace [--file ] [--since 1h] [--name ] [--json]`** folds the JSONL run log into per-stitch stats (runs, ok/failed, retries, drift, latency percentiles). `--file` picks the log (default `~/.stitch/runs/proto.jsonl`), `--since` keeps only records inside a window like `1h`/`30m`/`45s`/`2d`, `--name` filters to one stitch, and `--json` emits the raw summary instead of the aligned table. **Anti-pattern:** don't lean on `stitch trace` and the default `~/.stitch/runs/proto.jsonl` log as your production observability — that file is process-local (the `proto` name is a clue: it's a prototyping convenience), so its percentiles and ok/failed counts only ever cover the runs from this one shell and never roll up across machines, CI jobs, or workers. For durable, shared telemetry, fan the same event stream into the observability stack you already run with OTLP export instead. See [OTLP export](/docs/guides/observability/otlp). The CLI runs the very same stitch definition as the in-process [function](/docs/surfaces/function) — one source of truth, just a different front door. Like the library, the CLI traces nothing by default; pass `--trace` to `stitch run` to record the JSONL [trace sink](/docs/guides/observability/trace-sinks) at `~/.stitch/runs/proto.jsonl` (or `STITCH_TRACE_FILE`) so `stitch trace` has a log to read — `--trace=console` streams to stderr, `--trace=` writes JSONL there. A `.ts` module loads only when the host has a TypeScript loader registered (run under `tsx`); otherwise point `--module` at compiled JS. ## Diagram [#diagram] **`stitch diagram [--module ] [--name ]`** prints a [Mermaid](https://mermaid.js.org) flowchart of each stitch's configured pipeline — throttle, request, retry, the request [surface](/docs/reference/surfaces), pagination, validation, transform, pick, and cache — read straight from the definition, with no run. Pass `--name` to diagram a single stitch by export name or configured `name`. Auth is redacted from a stitch's public config, so the credential never appears in the diagram. ```bash stitch diagram --name getUser # paste the Mermaid into any renderer ``` ## Export [#export] **`stitch export --openapi [--module ] [--title ] [--api-version ] [--schema-module ]`** emits an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) document (JSON) for the whole module to stdout — the emit half of "reversible": a stitch declaration becomes a spec. ```bash stitch export --openapi --title 'My API' --api-version 1.0.0 > openapi.json ``` It is **structural** by default — paths, methods, `operationId`s, and the path/query parameters parsed from each [URL template](/docs/guides/data/url-shaping), plus the presence of a request body and response. Three things make it richer: * **Body and per-parameter schemas.** Pass `--schema-module ` at a module that exports a `toJsonSchema(source, info)` converter (its default export or a named `toJsonSchema`). Core stays zero-dependency — you bring the converter (e.g. one wrapping `zod-to-json-schema`), the way an adapter brings your `axios`. Without it, bodies and parameters stay `{}`. * **Security schemes.** Each stitch's `auth` contributes a `components.securitySchemes` entry and a per-operation `security` requirement — `bearer`/`basic` become HTTP schemes, `apiKey` its declared header, `oauth2` its token endpoint and scopes. Only the **scheme** is emitted; the credential is never read or written. * **Warnings, not silent drops.** A stitch whose endpoint is a thunk (resolved at call time) can't be exported statically, so it is reported on stderr as a `warning:` and skipped — never dropped silently. `toOpenApi` is also exported from `stitchapi/registry` for in-process use — pass your own `toJsonSchema` converter to fill body and parameter schemas: ```ts twoslash import { stitch } from 'stitchapi'; import { toOpenApi } from 'stitchapi/registry'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); const { document, warnings } = toOpenApi({ getUser }); ``` ## See also [#see-also] # In-process function (/docs/surfaces/function) A stitch is a typed async function — no server, no transport. `await` it for the final picked, validated value, or iterate its event stream for everything that happened on the way there. This is the closest of the four surfaces: the same definition you call here also runs from the CLI, over HTTP, and through MCP. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch<{ id: number; name: string }>({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); // Await it for just the value… const user = await getUser({ params: { id: 1 } }); // …or iterate the typed event stream for everything that happened. for await (const event of getUser.stream({ params: { id: 1 } })) { if (event.type === 'result') console.log(event.data); } ``` ## Options [#options] **Awaited vs streamed.** `await getUser(input)` resolves to the final value, already picked and validated. `getUser.stream(input)` yields the typed [event stream](/docs/concepts/event-stream) — `start → progress → drift → result → done` — so you can observe retries, throttling, and drift as they happen. Same call, two altitudes. **Pre-binding input.** `getUser.with(partial)` returns a new stitch with part of the input baked in, so a shared header or a fixed path param is set once and reused across calls. See [`.with()` partial application](/docs/guides/authoring/with). **One definition, four surfaces.** The in-process function is one surface over a single definition. The same stitch is reachable from the [CLI](/docs/surfaces/cli), served over [HTTP](/docs/surfaces/http-serve), and exposed to agents through [MCP](/docs/surfaces/mcp) — nothing about the definition changes. Calling the stitch returns a result you can both `await` and `.stream()`; `getUser.stream(input)` is shorthand for streaming that same result. ## See also [#see-also] # HTTP serve (/docs/surfaces/http-serve) Reach for `stitch serve` when a caller can't import your stitches in-process — a remote service, another language, a shell script — and you want to expose the registry of named stitches over a thin local HTTP front door instead. ## Example [#example] Start the server, then call a registered stitch by name with a JSON body: ```bash # Serves the registry on http://127.0.0.1:8787 by default. stitch serve # List the available stitch names. curl http://127.0.0.1:8787/ # Run the `me` stitch; the request body is its input. curl -X POST http://127.0.0.1:8787/stitch/me \ -H 'content-type: application/json' \ -d '{}' ``` The server runs the named stitch through the same engine as an in-process call and returns the final validated result as JSON. ## Options [#options] Two routes serve the registry of named stitches: * `GET /` lists the available stitch names. * `POST /stitch/:name` runs that stitch; the JSON request body is its input. Any other method returns `405`, and an unknown name returns `404`. The server binds to `127.0.0.1:8787` by default. Responses are the final validated result as JSON; send `Accept: text/event-stream` (or add `?stream=1`) to receive the live event stream as SSE instead. This front door runs stitches you name; it is not a webhook endpoint, and StitchAPI does not receive webhooks at all — that is your server's job. [Receiving a signed webhook](/docs/scenarios/webhook-receipt) draws exactly where the line falls and what the library still owns on the far side of it. **Anti-pattern:** don't move the bind off the `127.0.0.1` loopback default to a public interface so a remote caller can reach it directly — the front door has no auth of its own, so a public bind turns the registry into an open proxy that runs every stitch for anyone who can reach the port. Keep it on loopback and put a fronting layer (a reverse proxy or gateway that authenticates the caller) in front of it; the stitch still holds its own credential and resolves it server-side, never handing it to the caller. See [Capability, not credential](/docs/concepts/capability-not-credential). ## Programmatic API [#programmatic-api] The CLI is a thin wrapper. To embed the same server in your own process, import `serve` from the `stitchapi/serve` subpath and hand it a registry of named stitches: ```ts import { me } from './stitches'; import { serve } from 'stitchapi/serve'; const handle = await serve({ me }); console.log(`listening on ${handle.url}`); // later await handle.close(); ``` `serve(registry, options?)` returns a handle with the bound `url`, `port`, the underlying `server`, and a `close()` method. It binds `127.0.0.1:8787` by default; pass `{ port: 0 }` to let the OS pick an ephemeral port. A served stitch is the same definition as the in-process [function surface](/docs/surfaces/function) and the [`stitch` CLI](/docs/surfaces/cli) — one source of truth, three ways to call it. ## See also [#see-also] # MCP (/docs/surfaces/mcp) Reach for the MCP surface when an agent should call your stitches: it exposes a single code-mode `run_stitch` tool over MCP, plus a `list_stitches` discovery tool — so the agent's tool list stays tiny no matter how many stitches you register, and never touches a credential. ## Example [#example] Start the server with the `stitch` CLI. It loads your stitches module and speaks JSON-RPC over stdio (newline-delimited), with a startup notice on stderr: ```bash stitch mcp --module ./stitches.ts ``` The agent then calls one tool — `run_stitch` — passing the stitch name and its input. The tool call looks like this: ```json { "name": "run_stitch", "input": { "name": "getUser", "input": { "params": { "id": "42" }, "query": { "expand": "owner" } } } } ``` `input` carries `{ params, query, body, headers }`; `run_stitch` runs the named stitch behind the [capability boundary](/docs/concepts/capability-not-credential) and returns its validated result. The agent's object is the *call* input, not the request: a slot with a declared [`input` schema](/docs/guides/validation/validation) is rebuilt from what that schema returned, so a key the model invented is stripped before the request. A slot with no schema stays a passthrough — declare the slots an agent may fill. ## Options [#options] `run_stitch` takes `{ name, input }` and covers **every** registered stitch with one tool — `name` selects the stitch, `input` is its `{ params, query, body, headers }` object. This is code-mode: one tool, not one tool per path. `list_stitches` is the discovery tool. It takes no arguments and returns each stitch's `name`, `method`, and `path`, so the agent knows what to pass to `run_stitch`. Why one tool beats one-per-path: a tool per registered stitch floods the agent's context — every name, description, and input schema is re-sent on each turn. Code mode keeps the tool list at two entries however many stitches you register, so the agent spends its context on the task, not on a tool catalog. The full design of the tool lives in the agents section; see [run\_stitch & code mode](/docs/agents/run-stitch-tool). **Anti-pattern:** don't register one MCP tool per stitch or path — say, a separate tool for each route under `api.example.com` — to make the agent's calls "more explicit"; that re-sends every tool's name, description, and input schema on each turn and floods the model's context. Expose the single code-mode `run_stitch` tool (with `list_stitches` for discovery) and let the agent select the stitch by name instead. See [run\_stitch & code mode](/docs/agents/run-stitch-tool). ## Programmatic API [#programmatic-api] The CLI is a thin wrapper. To run the MCP server in your own process, import `serveStdio` from the `stitchapi/mcp` subpath and hand it a registry of named stitches: ```ts import * as stitches from './stitches'; import { serveStdio } from 'stitchapi/mcp'; // Reads newline-delimited JSON-RPC from stdin, writes responses to stdout. const { close } = serveStdio(stitches); // later close(); ``` `serveStdio(registry, options?)` wires the `run_stitch` / `list_stitches` server to stdio and returns a handle whose `close()` detaches the transport. Pass `{ input, output }` to bind a different stream pair instead of stdio, and `server` to set the identity reported in the MCP `initialize` result — a bare string names it (`server: 'orders-api'` ≡ `server: { name: 'orders-api' }`). The `version` defaults to the package version baked in at build time, so it can never drift from the release you installed; set `server: { name, version }` only when you want to report your own. The capability boundary holds here: the agent calls `run_stitch` by name and never sees the token, cookie, or signing key the stitch holds — it invokes a capability, not a credential. The transport writes JSON-RPC to stdout and its startup notice to stderr, so keep stdout clean — don't log to it or pipe anything else through it, or the framing breaks. ## See also [#see-also] # apiKey (/docs/guides/auth/api-key) Use `apiKey` when an API authenticates with a static key and you want the stitch to attach it as a request header, resolved at call time so the caller never sees the secret. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import { apiKey, env } from 'stitchapi/auth'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', auth: apiKey({ secret: env('API_KEY') }), }); ``` Every call to `search()` sets the `x-api-key` header to the resolved key, behind the capability boundary. ## Options [#options] `value` is the key itself — a literal string, or (preferred) a resolver like [`env('API_KEY')`](/docs/guides/auth/secret-resolvers) so the secret is read at call time and never committed. `in` chooses where the key goes: `'header'` (the default), `'query'`, or `'cookie'`. `name` labels the key in every arm — the header name, the query-parameter name, or the cookie name. It defaults to `X-API-Key` for a header and `api_key` for a query parameter or cookie. For a custom header, pass `apiKey({ name: 'X-My-Key', secret: env('API_KEY') })`. A header name goes on the wire **lowercased** (`x-my-key`) — case-insensitive per HTTP, but worth knowing if a fixture asserts the original casing. See [Migration notes](/docs/getting-started/migration-notes). ## In a query parameter [#in-a-query-parameter] Some APIs (often older REST endpoints) expect the key in the query string rather than a header. Set `in: 'query'`; the key is appended to the request URL at call time, URL-encoded, onto whatever query the endpoint already carries: ```ts twoslash import { stitch } from 'stitchapi'; import { apiKey, env } from 'stitchapi/auth'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search?type=full', // an existing query is preserved auth: apiKey({ in: 'query', name: 'api_key', secret: env('API_KEY') }), }); ``` Every call appends `&api_key=` to the URL. `name` is the parameter name (default `api_key`). A key in the URL is exposed wherever URLs travel — server access logs, proxies, browser history, a `Referer` header. Prefer `in: 'header'` when the API accepts it. StitchAPI keeps the key out of its own traces two ways: the strategy attaches it only to the request the transport sends (the `start` event's URL is the pre-auth URL, so the key never lands there), and the configured query `name` is registered with the URL-credential scrubber — so if it does appear in a sink (an OTLP `url.full`, the structured `input.query`) it is redacted to `REDACTED`, exactly like `api_key`/`access_token`. None of that protects the upstream server's logs. ## In a cookie [#in-a-cookie] Some APIs read the key from a cookie. Set `in: 'cookie'`; the key is appended to the `Cookie` header as `name=` at call time, merged with any cookie the request already carries: ```ts twoslash import { stitch } from 'stitchapi'; import { apiKey, env } from 'stitchapi/auth'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', auth: apiKey({ in: 'cookie', name: 'api_key', secret: env('API_KEY') }), }); ``` The `Cookie` header is on StitchAPI's trace denylist, so the key is redacted from sinks exactly like a header key — no separate scrubber registration is needed. See [Reference → Auth strategies](/docs/reference/auth-strategies) for every field. ## See also [#see-also] # basic (/docs/guides/auth/basic) Use `basic` when an API authenticates with HTTP Basic — it base64-encodes `user:pass` and sets the `Authorization: Basic …` header, with both halves resolved at call time so the caller never holds the credential. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import { basic, env } from 'stitchapi/auth'; const report = stitch({ baseUrl: 'https://api.example.com', path: '/report', auth: basic({ user: env('API_USER'), pass: env('API_PASS') }), }); ``` Each call to `report()` resolves `API_USER` and `API_PASS`, encodes them, and attaches the header — behind the capability boundary, so the credential is never passed in by the caller. ## Options [#options] `user` and `pass` each take a string or a resolver. Prefer a resolver like [`env()`](/docs/guides/auth/secret-resolvers): it reads the secret per call, so nothing is captured in the config or committed to source — the [capability, not credential](/docs/concepts/capability-not-credential) boundary. A literal string works but bakes the secret into the stitch. See [Reference → Auth strategies](/docs/reference/auth-strategies) for every field. ## See also [#see-also] # bearer (/docs/guides/auth/bearer) Use `bearer` when an API authenticates with a token sent as `Authorization: Bearer `, and you want the token resolved at call time so the secret stays out of your code and out of the caller's hands. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; const me = stitch({ baseUrl: 'https://api.example.com', path: '/me', auth: bearer(env('API_TOKEN')), }); ``` The token is read each time `me()` runs, so a rotated secret takes effect on the next call with no rewiring. ## Options [#options] `bearer` accepts either a literal string or a resolver thunk (`() => string`). Strongly prefer a resolver: it reads the secret per call from the environment or a secrets file, so nothing is committed and the stitch hands callers a capability, not the credential — see [Capability, not credential](/docs/concepts/capability-not-credential). Use [`env('API_TOKEN')`](/docs/guides/auth/secret-resolvers) for an environment variable or `credential.file('API_TOKEN')` for `~/.stitch/secrets.json`; both return a thunk resolved at call time. See [Reference → Auth strategies](/docs/reference/auth-strategies) for the full auth table. **Anti-pattern:** don't pass a literal token string — it bakes the secret into your source and hands the credential straight to anyone who reads it. Pass a resolver thunk instead, so the secret resolves at call time and the stitch hands callers a capability, not the credential (a literal is fine only for a non-secret placeholder). See [Capability, not credential](/docs/concepts/capability-not-credential). ## Optional credentials with `env.optional` [#optional-credentials-with-envoptional] When you're scripting against an API whose token already sits in your environment — a local dev run, a notebook, an agent loop — wrap the variable in `env.optional('NAME')` and hand it to `bearer`. It resolves the variable's value, or *absent* when the variable is unset. Unlike `bearer(env('NAME'))`, which **throws** on a missing variable, `bearer(env.optional('NAME'))` attaches the header only when the token is present — otherwise the request goes out with no credential. ```ts twoslash import { stitch } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; // Attaches `Authorization: Bearer $GITHUB_TOKEN` when that var is set; // otherwise sends the request with no credential. const repos = stitch({ url: 'https://api.github.com/user/repos', auth: bearer(env.optional('GITHUB_TOKEN')), }); ``` The outcome **announces itself**: `bearer` emits an `info` event naming the variable it read — or noting that it was unset — but never the token itself, so the decision is visible in the [trace](/docs/guides/observability/trace-sinks). In a browser bundle there is no process environment, so the variable resolves absent and nothing is attached. **Optional, not required.** If the variable is unset, `bearer(env.optional(…))` sends **no** credential rather than failing — the request goes out unauthenticated. When the call must be authenticated, use `bearer(env('GITHUB_TOKEN'))`, which throws on a missing variable. ## See also [#see-also] # cookieSession (/docs/guides/auth/cookie-session) Use `cookieSession` when an API authenticates with a login form and a cookie instead of a token, and you want the session captured, replayed, and refreshed for you — the stitch holds the login, the caller never touches a cookie. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import { cookieSession, env } from 'stitchapi/auth'; // The login is itself a stitch — its Set-Cookie response seeds the session. const login = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/login', wire: { body: 'form' }, }); const me = stitch({ baseUrl: 'https://api.example.com', path: '/me', auth: cookieSession({ login, cookie: '*', credentialsOf: () => ({ body: { user: env('USER')(), pass: env('PASS')() }, }), refresh: [401], }), }); ``` The first call to `me()` runs `login` once, captures its cookies, and replays them; a `401` afterward re-runs the login and retries, all behind the capability boundary. ## Options [#options] Point `login` at the stitch that authenticates, and pass `credentialsOf` to supply its credentials, resolved at call time with [`env()`](/docs/guides/auth/secret-resolvers). `cookie` selects what to keep: a single cookie name, or `'*'` (equivalently `jar: true`) to capture and replay the whole Set-Cookie jar. `refresh` is one envelope (CONTRACT.md P24). A bare `StatusMatch` — a number, a list, or a predicate — is shorthand for `refresh: { on }`: the status(es) that re-run the login (default `[401]`, and `refresh: 401` ≡ `refresh: { on: [401] }`). Reach `refresh.when` — a content predicate for soft 200 walls where a login page is served with a `200` — through the envelope form: `refresh: { when: (res) => … }`. Give two stitches the same `key` plus a shared `store` to share one session. See [Reference → Auth strategies](/docs/reference/auth-strategies) for every field. ## Durable state and an external recovery loop [#durable-state-and-an-external-recovery-loop] `cookieSession` does the mechanical capture-and-replay, refreshing on the wall — but it can't model a host that owns **durable, categorised** auth state and its own recovery loop (mark a session `backoff` after a rate-limit, `failed` after bad creds, and retry on its own schedule). Two optional host-owned hooks hand that decision back to you. They fire **once per actual login attempt** — inside the single-flight refresh, never once per coalesced caller — and a throwing hook never crashes the call. * `onRefresh({ ok, status })` runs after **every** (re)login attempt with its outcome (`ok` = a cookie was captured), so you can persist session state and clear or extend a cooldown. * `onFailure(info)` runs when an attempt captured no cookie, with a `category` you map to your own status: `'unauthenticated'` (a `refresh` status, e.g. `401` — bad/expired creds), `'rate-limited'` (`429`, with `retryAfter` parsed from `Retry-After`), `'network'` (the login threw before any response — with the thrown `error`), or `'unknown'`. `info.step` is `'apply'` for a cold session or `'refresh'` for a wall that was hit mid-stream. ```ts twoslash import { stitch } from 'stitchapi'; import { cookieSession, env } from 'stitchapi/auth'; import type { AuthFailureResult, RefreshResult } from 'stitchapi/auth'; // Your own durable store — a row in a database, a Redis hash, anything. declare const sessionState: { markActive(): Promise; markFailed(reason: string): Promise; backoffUntil(at: number): Promise; }; const login = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/login', wire: { body: 'form' }, }); const me = stitch({ baseUrl: 'https://api.example.com', path: '/me', auth: cookieSession({ login, cookie: '*', credentialsOf: () => ({ body: { user: env('USER')(), pass: env('PASS')() }, }), // Persist the outcome of every attempt. onRefresh: async ({ ok }: RefreshResult) => { if (ok) await sessionState.markActive(); }, // Categorise a failure and drive your own recovery loop. onFailure: async (info: AuthFailureResult) => { switch (info.category) { case 'rate-limited': // Back off until the server says we may retry. await sessionState.backoffUntil( Date.now() + (info.retryAfter ?? 60_000), ); break; case 'unauthenticated': // Bad/expired creds — stop hammering; surface for re-auth. await sessionState.markFailed('credentials rejected'); break; case 'network': // Transport failure — your loop decides whether to retry. await sessionState.backoffUntil(Date.now() + 30_000); break; default: await sessionState.markFailed('unknown auth failure'); } }, }), }); ``` The hooks are purely additive: omit them and `cookieSession` behaves exactly as before. They observe and record — they never change *whether* the stitch logs in or retries (that stays governed by `refresh`). Your external loop reads the durable state and decides when to call the stitch again. **Anti-pattern:** don't lean on `refresh.on` status codes alone to catch an expired session — a soft 200 wall, where the login page comes back with status `200`, slips straight past it and your stitch reads the login HTML as data; add a `refresh.when` content predicate so the session re-logs in on that body too. See [STITCH\_AUTH\_WALL](/docs/errors/stitch-auth-wall). ## See also [#see-also] # oauth2 (/docs/guides/auth/oauth2) Use `oauth2` when an API authenticates with the OAuth2 `client_credentials` grant and you want the access token fetched, cached, and refreshed for you — the stitch holds the client id and secret, the caller never sees the token. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import { env, oauth2 } from 'stitchapi/auth'; const orders = stitch({ baseUrl: 'https://api.example.com', path: '/orders', auth: oauth2({ tokenUrl: 'https://api.example.com/oauth/token', client: { id: env('CLIENT_ID'), secret: env('CLIENT_SECRET') }, scope: 'orders.read', }), }); ``` The first call to `orders()` POSTs the form-encoded token request, caches the token in the stitch store (TTL from `expires_in`), and attaches it as `Authorization: Bearer …`; later calls reuse the cached token until it nears expiry, all behind the capability boundary. ## Options [#options] `tokenUrl` is the `client_credentials` token endpoint. The client's identity lives in one `client` envelope (CONTRACT.md P24): pass `client.id` and `client.secret` as [secret resolvers](/docs/guides/auth/secret-resolvers) — e.g. `env('CLIENT_ID')` — so the credentials are read at call time and never committed. Both are required, so `client: {}` is a compile error. `scope` is an optional space-delimited scope string. The token is cached and auto-refreshed through one `refresh` envelope (CONTRACT.md P24). A bare `StatusMatch` — a status, a list, or a predicate — is shorthand for `refresh: { on }`: the status(es) that mean the token was rejected and force a fresh fetch plus retry (default `[401]`, and `refresh: 419` ≡ `refresh: { on: [419] }`). Reach `refresh.skew` — how many milliseconds before expiry to refresh so the token is never used mid-flight (default `30_000`) — through the envelope form: `refresh: { skew: '1m' }`. Give two stitches the same `key` plus a shared `store` and one token serves them all — across stitches and across workers, surviving restarts. See [Reference → Auth strategies](/docs/reference/auth-strategies) for every field. This strategy covers `client_credentials`, where the token is yours to re-fetch at will. A provider that issues **single-use refresh tokens** is a different problem — two workers refreshing at once can revoke the whole grant, and the obvious guards turn out to be narrower races rather than fixes. [OAuth2 refresh tokens that rotate](/docs/scenarios/oauth2-refresh-token-rotation) builds the strategy that holds. ## Client authentication [#client-authentication] By default the client authenticates with `client_secret_post`: `client_id` and `client_secret` ride in the form body. Some providers require `client_secret_basic` instead — the credentials as an HTTP Basic header, with only `grant_type` in the body. Set `client.via: 'basic'` for those: ```ts twoslash import { stitch } from 'stitchapi'; import { env, oauth2 } from 'stitchapi/auth'; const messages = stitch({ baseUrl: 'https://sms.example.com', path: '/messages', auth: oauth2({ tokenUrl: 'https://sms.example.com/oauth/token', client: { id: env('CLIENT_ID'), secret: env('CLIENT_SECRET'), via: 'basic', }, }), }); ``` The Basic header is Base64 of `id:secret`, encoded without Node's `Buffer` so it works in a browser bundle too. For provider-specific extras on the token request, `audience` sets the OAuth2 `audience` field, `params` merges arbitrary fields into the token-request body (e.g. `resource`, or a custom `grant_type`), and `headers` adds headers to it. The client credentials are always applied last, so `params` can never shadow them. **Anti-pattern:** don't rely on the default token cache across multiple workers — it lives in memory, process-local, so every worker fetches and refreshes its own token and hammers the token endpoint; give the stitches a shared `key` and a durable `store` so one token serves them all. See [Pluggable store](/docs/guides/state/pluggable-store). The token lives only in the store and the outgoing `Authorization` header — the caller, an agent included, never receives it. That is the capability boundary; see [Capability, not credential](/docs/concepts/capability-not-credential). ## See also [#see-also] # Secret resolvers (/docs/guides/auth/secret-resolvers) Every auth strategy takes a secret as a reference, not a value, and resolves it at call time — reach for `env()` and `credential.file()` so the declaration carries a capability, not a credential you have to commit. The resolvers are two namespaces, not four loose functions. `env` is the environment-variable source and carries its own requiredness modifier (`env('NAME')` throws when unset, `env.optional('NAME')` resolves to absent); `credential` groups the sources that are **not** the process environment (`credential.file`, `credential.from`). ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import { bearer, credential, env } from 'stitchapi/auth'; // env() reads process.env.API_TOKEN when the stitch is called, not now. const me = stitch({ baseUrl: 'https://api.example.com', path: '/me', auth: bearer(env('API_TOKEN')), }); // credential.file() resolves from ~/.stitch/secrets.json, falling back to the env var. const billing = stitch({ baseUrl: 'https://api.example.com', path: '/billing', auth: bearer(credential.file('BILLING_TOKEN')), }); ``` The token is never read at declaration time, so the secret never enters the stitch's source — only the name of where to find it does. ## Options [#options] A secret is a `string | (() => string)`. Every strategy — `bearer`, `apiKey`, `basic`, `oauth2` — accepts one and resolves it the moment a request goes out, so the same declaration works across processes and machines without baking a value in. `env(name)` returns a thunk that reads `process.env[name]` at call time and throws `missing env var ` if it is unset — failing loudly at the call rather than silently sending an empty token. `env.optional(name)` is the same source without the throw: it resolves to `undefined` when the variable is unset **or** empty, and `bearer` then sends the request unauthenticated rather than failing. See [Optional credentials](/docs/guides/auth/bearer#optional-credentials-with-envoptional). `credential.file(name)` reads `~/.stitch/secrets.json` if present and the key is there, otherwise falls back to the environment variable of the same name, and throws if neither has it. `credential.from(source, name)` resolves from an injected source instead — an object with a `get(name)` method (a NestJS `ConfigService`, a secrets-manager client) or a plain `(name) => value` function — for apps that supply config without ever touching `process.env`. Like `env()`, it throws on a missing or empty value. `~/.stitch/secrets.json` is **unencrypted plaintext JSON**. Restrict its permissions (`chmod 600 ~/.stitch/secrets.json`), add it to your `.gitignore`, and never commit it. For production workloads prefer a proper secrets manager or `env()` backed by your deployment platform's secret injection. Why call-time resolution matters: the value is fetched only when a call is made, so you never commit a secret and the stitch holds a capability rather than the credential itself — the [capability boundary](/docs/concepts/capability-not-credential) the product is built on. You can pass a plain `string` (discouraged — that hard-codes the value), or any `() => string` thunk of your own, so plugging in a vault or secrets manager is just supplying a different resolver. **Anti-pattern:** don't pass a secret as a plain string — it is read at declaration time and lives in your source and history, collapsing the capability back into a committed credential; pass `env()` or `credential.file()` instead so the value resolves at call time and the stitch carries a capability, not a credential. See [Capability, not credential](/docs/concepts/capability-not-credential). ## See also [#see-also] # extends (/docs/guides/authoring/extends) Use `extends` to build a stitch from layered fragments — a shared base, an options object, and the stitch's own inline fields — deep-merged in order so the common configuration lives in one place and each stitch states only what differs. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; // A base stitch carrying the shared host and headers. const base = stitch({ baseUrl: 'https://api.example.com', headers: { accept: 'application/json' }, }); const getUser = stitch({ extends: [base, { retry: { attempts: 3 } }], path: '/users/{id}', }); ``` `getUser` deep-merges three layers: `base`'s config, the inline retry object, and its own `path`. The result carries `baseUrl`, `headers`, `retry`, and `path`. ## Options [#options] `extends` takes an array of fragments, each normalized to a partial config: a **string** becomes `{ path: string }`; another **stitch** contributes its resolved config; a plain **object** is used as-is. Nested `extends` inside any fragment are flattened first, so a base can itself extend another base. Layers are deep-merged left to right, and **later layers win** — the stitch's own inline fields are the last layer, so they override everything they extend. Two exceptions: hooks **chain** instead of overwriting (every layer's `onRequest`/`onResponse`/`onError`/`onRetry` runs), and `store` is taken from the last layer that sets one. A fragment is just a plain object you drop into `extends`. For a whole shared surface — shared *runtime* and a trusted principal boundary, not just config — reach for a [`seam`](/docs/guides/authoring/seam). ## See also [#see-also] # Hooks (/docs/guides/authoring/hooks) Hooks are observation points on a stitch's lifecycle: small callbacks that run as a call progresses, for logging, metrics, or tracing. They never change what a stitch returns — a call's only result is its response — so reach for a hook when you want to *watch* a call, for [`transform`](/docs/guides/data/transform) when you want to *reshape* its output, and for the [`input` schema](/docs/guides/data/transform#reshaping-the-request) when you want to reshape what it sends. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch({ url: 'https://api.example.com/users/{id}', hooks: { onRequest: ({ name, attempt, req }) => { console.log(`→ ${name} #${attempt}: ${req?.method} ${req?.url}`); }, onResponse: ({ res }) => { console.log(`← ${res?.status}`); }, onRetry: ({ attempt, error, res }) => { console.log(`retrying after #${attempt}`, error ?? res?.status); }, }, }); ``` ## The four hooks [#the-four-hooks] Each hook receives a `HookContext` — `{ name, attempt, req?, res?, error? }` — with the fields relevant to its moment populated: | Hook | Fires when | Populated | | ------------ | ------------------------------------------- | ---------------- | | `onRequest` | just before the request goes out | `req` | | `onResponse` | a response came back (any status) | `res` | | `onError` | the transport threw (network/abort/timeout) | `error` | | `onRetry` | the engine decided to retry, before backoff | `error` or `res` | `onResponse` fires on **any** status, which makes it the seam for everything a server tells you on a call that succeeded. A vendor announcing a shutdown does exactly that — `Deprecation` and `Sunset` ride a `200`, so nothing fails and nothing retries; [the vendor told you for six months, in a header](/docs/scenarios/deprecation-headers) finds the three places a response header is actually reachable. ## Firing order [#firing-order] Hooks interleave with the engine's per-attempt pipeline. Within a single attempt, in order: 1. **throttle** — wait for a concurrency / rate slot 2. **`auth.apply`** — credentials are stamped onto the request 3. **`onRequest`** — the request is fully shaped (auth headers included) 4. **the network call** (bounded by `timeout.each`) 5. on a thrown transport error → **`onError`**, then if another attempt remains → **`onRetry`** → backoff → next attempt 6. on a response → **`onResponse`** 7. **`auth.shouldRefresh` / `auth.refresh`** — a token wall (e.g. a 401) refreshes auth and replays the attempt 8. on a retryable status → **`onRetry`** → backoff → next attempt So `onRequest` always sees the post-auth request, and `onRetry` fires with `error` populated on a network failure or `res` populated on a retryable status. Hooks run on **every attempt**, not once per call: a stitch that retries twice fires `onRequest` three times. Use `attempt` to tell them apart. **Observe the request; don't reshape it.** `ctx.req` is the live object the transport is about to send, so mutating it in `onRequest` does change the call — but step 3 puts you *after* `auth.apply`, and a strategy that signs the payload (AWS SigV4 with `signBody`, or any HMAC scheme) has already hashed the body you are replacing. The signature then covers bytes that never went out, and the API rejects the call with a 403 that points at nothing. Reshape the request in its [`input` schema](/docs/guides/data/transform#reshaping-the-request), which resolves before auth signs anything — and before the cache key is derived from the request, which hook mutation also misses. ## Order across composed layers [#order-across-composed-layers] When stitches compose via [`extends`](/docs/guides/authoring/extends), hooks **chain** rather than overwrite — every layer's hook runs. They unwind like middleware: * `onRequest` runs **base → child** (outermost layer first) * `onResponse`, `onError`, `onRetry` run **child → base** (innermost layer first) ```ts twoslash import { stitch } from 'stitchapi'; const base = stitch({ baseUrl: 'https://api.example.com', hooks: { onRequest: () => console.log('base onRequest') }, }); const getUser = stitch({ extends: [base], path: '/users/{id}', hooks: { onRequest: () => console.log('child onRequest') }, }); // Calling getUser logs "base onRequest" then "child onRequest". ``` ## See also [#see-also] # seam (/docs/guides/authoring/seam) Use `seam` when several stitches hit the same API and should share one runtime — one throttle budget, one store, one trace sink, one auth vault — instead of each carrying its own copy of the config. Build it once, then create members with `.stitch()` and `.graphql()`. ## Example [#example] ```ts twoslash import { seam } from 'stitchapi'; import { bearer, env } from 'stitchapi/auth'; import { z } from 'zod'; const User = z.object({ id: z.number(), name: z.string(), email: z.string(), role: z.enum(['admin', 'member', 'viewer']), }); // Build the seam once: its config is the base every member inherits, and its // runtime — store, vault, throttle bucket, trace sink — is shared across them. const api = seam({ baseUrl: 'https://api.example.com', auth: bearer(env('API_TOKEN')), retry: { attempts: 3, on: [429, 503] }, }); // Members are stitches that belong to the seam. const getUser = api.stitch({ path: '/users/{id}', pick: 'data', output: User, }); const listUsers = api.stitch({ path: '/users', pick: 'data', output: User.array(), }); const user = await getUser({ params: { id: 7 } }); ``` ## Options [#options] A seam takes the same fields a stitch does — `baseUrl`, `auth`, `retry`, `throttle`, `timeout`, `store`, `vault`, `trace`, and the rest — as the base its members extend. The eight per-endpoint keys (`path`, `url`, `method`, `document`, `name`, `input`, `output`, `kind`) belong to a member, not the seam. See [Reference → seam()](/docs/reference/seam) for every prop and both handles. **Members extend the base; a member's own throttle stacks on the seam's.** Anything a member declares layers on top of the shared config — and a member's own `throttle` is *tighten-only*: it gates only that stitch on top of the shared bucket, so it can be stricter than the seam's budget but never escape it. Sharing a runtime is what you usually want, and occasionally exactly what you don't. Running a vendor's v1 and v2 side by side to compare them means deciding which channels stay shared and which must not — [the migration you have to run twice](/docs/scenarios/dual-run-migration) counts the five and finds that only one is safe by default. **Per-principal sessions — `seam.as(id)`.** Derive a handle whose members carry a principal. The handle binds that principal in the closure, never in the call's input, so a caller can't name another one. Each principal gets its own sessions; the throttle bucket stays shared. ```ts twoslash import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com' }); // One handle per request — bind the caller's identity, then create members. const forTenant = api.as('tenant-42'); const getUser = forTenant.stitch({ path: '/users/{id}', pick: 'data' }); ``` **Anti-pattern:** don't build a fresh `seam` per request — you'd get a new store, throttle bucket, and vault each time, so nothing pools or persists. Build the seam **once at startup** and derive a per-request identity with `seam.as(req.user.id)` instead. **Lifecycle — `flush` / `close` / `invalidate`.** These act on the shared runtime, so they live on the root seam only (a `seam.as(...)` handle is lifecycle-free). `close()` flushes the trace sink and closes the shared store on shutdown; `invalidate()` bulk-evicts the cache across members. ```ts twoslash import { seam } from 'stitchapi'; const api = seam({ baseUrl: 'https://api.example.com' }); // ... build members, serve requests ... await api.close(); // flush the trace sink, close the shared store ``` ## See also [#see-also] # stitch() (/docs/guides/authoring/stitch) Reach for `stitch()` to declare a single HTTP call once and get back a typed, callable function — it is the core authoring move that every other feature layers onto. ## Example [#example] Pass a string when the URL is all you need; pass a config object when you want a `baseUrl`, a parameterized `path`, or a non-GET `method`. The generic types the awaited value. ```ts twoslash import { stitch } from 'stitchapi'; // String form — the string becomes the path; a full URL is fine. const listUsers = stitch<{ id: number; name: string }[]>( 'https://api.example.com/users', ); // Config form — baseUrl + a {param} slot; method defaults to GET. const createUser = stitch<{ id: number; name: string }>({ baseUrl: 'https://api.example.com', path: '/users/{id}', method: 'POST', }); const users = await listUsers(); const user = await createUser({ params: { id: 1 } }); ``` ## Options [#options] The handful of fields that shape every stitch: * **`baseUrl`** + **`path`** — the request target. `path` may contain `{param}` slots and a `?query` string; in string form the whole string becomes `path`. * **`method`** — the HTTP verb. Defaults to `GET`. Any verb your transport accepts, including [`QUERY`](#the-query-method). * **The input object** — `params`, `query`, `headers`, and `body` all travel in one `StitchInput` you pass at call time: `await createUser({ params: { id: 1 } })`. * **The generic** — `stitch(...)` types the awaited value `T`. Auth, retry, and validation are configured on the same config object but documented in their own guides — see below — so there is one source of truth per feature. ## The QUERY method [#the-query-method] Some reads don't fit in a URL. A faceted search, a big list of ids, a nested filter — encode it as a query string and you hit length limits, proxies that truncate, and logs that now hold your filter. The usual workaround is to `POST` the filter and give up everything a read gets: no caching, and a client that can't tell the call apart from a write. `QUERY` ([`draft-ietf-httpbis-safe-method-w-body`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/)) is the method for exactly that — **safe**, **idempotent**, and **cacheable**, with a request body. Set it like any other verb: ```ts const search = stitch({ method: 'QUERY', baseUrl: 'https://api.example.com', path: '/orders', // Responses are spec-cacheable, but opt in — the key folds in the body, so two // different filters get two entries and cannot collide. cache: { ttl: '1m', methods: 'QUERY' }, }); await search({ body: { status: ['open', 'held'], region: 'eu', limit: 200 } }); ``` StitchAPI treats it as the read it is: * **The body is sent.** `GET`/`HEAD` have theirs dropped — the transport forbids one — but `QUERY` keeps it, JSON-encoded like any other body. * **No idempotency key.** With `idempotency` configured, a `QUERY` is not stamped with an `Idempotency-Key`; there is no side effect to dedupe. Setting `idempotency` on one logs the same construction nudge a `GET` gets. * **A 301/302 stays a `QUERY`.** The downgrade-to-`GET` on a permanent or temporary redirect is a historical exception granted to `POST`, and the draft says it does not apply here — downgrading would drop the body, turning a filtered read into an unfiltered one. A `303` still means "GET the result at the `Location`", for a `QUERY` as for anything else. * **`cache.methods` accepts it.** Not in the default `['GET','HEAD']`: like a GraphQL `POST`, caching a body-carrying request is explicit. `QUERY` is a young method. Check that your server — **and every proxy, CDN, and WAF between you and it** — actually routes it before reaching for it; intermediaries have been known to reject or mangle an unfamiliar verb. For every field and its default, see [Reference → stitch()](/docs/reference/stitch) and [Reference → Config types](/docs/reference/config-types). ## See also [#see-also] # .with() partial application (/docs/guides/authoring/with) Use `.with()` when you want to pre-bind part of a stitch's input — a query param, a header, a body field — and reuse it across calls, while the bound stitch keeps the same runtime so cookies, throttle counters, and session state carry over. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch('https://api.example.com/search'); // Pre-bind a query param; per-call input merges over it. const inEurope = search.with({ query: { region: 'eu' } }); await inEurope({ query: { q: 'mango' } }); // → region=eu&q=mango ``` `inEurope` shares one runtime with `search`, so a throttle budget or a captured cookie set is the same whichever you call. ## Options [#options] `.with(partial)` accepts any slice of `StitchInput` — `params`, `query`, `headers`, `body`, or `variables` — and returns a new stitch with that slice bound in. Each call merges its own input **over** the bound partial, so per-call values win on conflict (`region` here, overridden if a call passes its own `region`). Calls are chainable: `search.with(a).with(b)` layers both partials. The key point is the shared runtime: the bound stitch does not start fresh. The same [cookie session](/docs/guides/auth/cookie-session) jar, throttle counters, and [shared session](/docs/guides/state/shared-sessions) state persist across every call made through it. **Anti-pattern:** don't reach for `.with()` to get an isolated copy with its own throttle budget or circuit-breaker state — because the runtime is shared, a bound stitch draws from the same buckets and the same breaker counter as its parent. When you need separate state, declare a separate stitch instead of binding one. See [throttle](/docs/guides/resilience/throttle). ## See also [#see-also] # Body encoding (/docs/guides/data/body-encoding) Set `wire.body` when an API expects a request body in a particular wire format — JSON, URL-encoded form, or multipart form data — and you want the stitch to serialize the `body` you pass at call time into that shape. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const submit = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/submit', wire: { body: 'form' }, }); await submit({ body: { name: 'mango', qty: 3 } }); ``` The body travels in the call input, not the config: `submit` encodes `{ name: 'mango', qty: 3 }` as an `application/x-www-form-urlencoded` body because `wire.body` is `'form'`. ## Options [#options] `wire.body` chooses how the call-time `body` is encoded (default `'json'`): * `json` — a JSON body with `Content-Type: application/json`. The default; reach for it for ordinary REST payloads. * `form` — a URL-encoded `application/x-www-form-urlencoded` body, for APIs that expect classic HTML-form fields (login forms, OAuth token exchanges). Nested objects and arrays serialize exactly as they do in a query string — same walker, same [`wire.array`](/docs/reference/config-types#wirearray): ```ts await submit({ body: { ids: [1, 2], page: { size: 10 } } }); // → ids%5B0%5D=1&ids%5B1%5D=2&page%5Bsize%5D=10 ``` * `multipart` — multipart form data, for file uploads and mixed field-plus-binary payloads; the boundary is set for you. Nested objects/arrays serialize with bracket nesting by default; `multipart: 'dot'` (≡ `multipart: { nesting: 'dot' }`) switches to dot-path field names. Multipart has its own grammar, so `wire.array` does not apply to it — and `multipart` may only be set alongside `wire: { body: 'multipart' }`, since the slot is ignored on any other body. Note this is multipart *encoding* of one request, a different thing from S3-style **multipart upload** — a four-step protocol whose last step, aborting on failure, no HTTP client models for you; [the upload you must clean up after](/docs/scenarios/multipart-upload) covers what the orphaned parts cost when you skip it. Body encoding is **not** a surface — it composes with one. `wire.body` says how the request payload is framed; a [surface](/docs/reference/surfaces) says what kind of interaction the call is (REST, GraphQL, SSE, a stream, a download). The two are separate axes, so they are separate fields. The body itself always comes from the call input (`await submit({ body })`), never from the config — so one stitch encodes whatever you pass it. See [Guide → stitch()](/docs/guides/authoring/stitch) for the full configuration. **`wire.body` is an `http`-surface option.** The `graphql` surface builds its own body — a JSON `{ query, variables }` envelope — so setting `wire.body` alongside it is a compile error rather than a silently ignored field. GraphQL file uploads are a separate wire format (the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec)'s `operations`/`map`/file-part envelope), not this JSON body re-encoded as multipart, and are **not supported today**. To upload alongside a GraphQL API, POST the file to a plain `http` stitch and pass the resulting handle as a variable. See [Request surfaces](/docs/reference/surfaces). The same applies to `llm`, which POSTs a provider-shaped JSON body: `method` and `wire.body` are both compile errors on an `llm()` config. The rest of the envelope is untouched — `wire.response` and `wire.array` are knobs the surface leaves alone, so they still work. **Anti-pattern:** don't hand-serialize the body before you pass it — stringifying the value yourself and handing the stitch a pre-encoded string double-encodes it, because `wire.body` already serializes the value into the wire format you chose; pass the plain object and let the stitch encode it once. See [stitch()](/docs/guides/authoring/stitch). ## See also [#see-also] # GraphQL (/docs/guides/data/graphql) Use `graphql()` when an API speaks GraphQL-over-HTTP and you want a stitch that POSTs a query, passes variables per call, and hands back the `data` payload already picked — with a `200` that carries an `errors` array treated as a failure, not a success. ## Example [#example] ```ts twoslash import { graphql } from 'stitchapi'; const getUser = graphql<{ user: { id: string; name: string } }>({ baseUrl: 'https://api.example.com', path: '/graphql', document: `query GetUser($id: ID!) { user(id: $id) { id name } }`, }); // Variables travel in the input; the result is already picked from `data`. const data = await getUser({ variables: { id: '1' } }); ``` ## Options [#options] `graphql()` is a thin wrapper over `stitch()`: it fixes `kind: 'graphql'`, `method: 'POST'`, and `pick: 'data'` for you. You supply the GraphQL document as `document`, plus a `baseUrl`/`path` pointing at the endpoint. `document` and `operationName` are read **only** by the graphql surface, so they are a type error on any other one — a plain `stitch({ document })` would otherwise drop the document silently and send an ordinary request. Reach for `graphql()` (or `seam.graphql()`), which selects the surface for you; the generic spelling works too if you name it yourself: ```ts import { graphqlSurface, stitch } from 'stitchapi'; stitch({ baseUrl, kind: graphqlSurface, document: `query { me { id } }` }); ``` The check reads the config you write, not the composed result — so a surface inherited through `extends` is not seen. Put `kind` on the same layer as the document, or use `graphql()`. Pass GraphQL variables through the input's `variables` field at call time — `getUser({ variables: { id: '1' } })` — so one stitch serves every set of arguments. Because `pick` defaults to `'data'`, the resolved value is the contents of the response's `data` object; override `pick` if you need a different field or the raw envelope. **Anti-pattern:** don't interpolate argument values straight into the `query` string and mint a fresh stitch for each set of arguments; pass them through the input's `variables` field at call time so one declared stitch serves every call. See [Reference → stitch](/docs/reference/stitch). A GraphQL endpoint can return HTTP `200` while reporting an `errors` array in the body. The stitch treats that as a failure and surfaces it — see [STITCH\_GRAPHQL](/docs/errors/stitch-graphql). ### File uploads are not supported [#file-uploads-are-not-supported] The surface builds the request body itself, so `wire.body` is **not** a field a GraphQL stitch accepts — authoring one is a compile error rather than a silently ignored setting. Its siblings are unaffected: `wire.response` and `wire.array` are not body encodings, so both stay legal here. That includes `wire: { body: 'multipart' }`. A GraphQL file upload is not "the JSON body, multipart-encoded" — it is a distinct wire format, the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec), which frames the call as an `operations` part, a `map` part, and one part per file. This surface does not implement that envelope. Until it does, upload the file with a plain [`stitch()`](/docs/guides/authoring/stitch) using `wire: { body: 'multipart' }` and pass the returned identifier as a GraphQL variable — which is also what [Apollo's own upload guidance](https://www.apollographql.com/blog/file-upload-best-practices) recommends over in-band multipart uploads. For the full set of fields a stitch accepts, see [Reference → Config types](/docs/reference/config-types). ## See also [#see-also] # Pagination (/docs/guides/data/pagination) Add `paginate` when one logical call spans many pages and you want the stitch to follow them for you — it loops until you say stop, aggregates every page's items into one array, and applies auth, retry, and throttle to each page along the way. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const allUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', paginate: { next: (prevBody) => { const cursor = (prevBody as { nextCursor?: string }).nextCursor; return cursor ? { query: { cursor } } : undefined; }, items: (value) => (value as { results: unknown[] }).results, pages: 20, }, }); ``` Each page reads `nextCursor` off the raw body and asks for the next page; when the body has no cursor, `next` returns `undefined` and the loop stops. `prevBody` and `value` arrive as `unknown` — narrow them before you reach in. A cursor is the shape to prefer here. Paging by offset over a collection that is still being written returns a silently wrong list — items skipped, items duplicated, every damaged run reporting success — which [the page that moved while you were reading it](/docs/scenarios/unstable-pagination) demonstrates and works around. ## Options [#options] `next(prevBody, pagesFetched)` receives the previous page's raw body plus the page count and returns the [`StitchInput`](/docs/reference/config-types) for the next page, merged over the original call — set only what changes, like a cursor or page number. Return `undefined` to stop. `items(value)` selects the array to aggregate from each page's picked value; it defaults to the value itself when that value is already an array. The pick runs first, so pair `items` with [`pick`](/docs/guides/data/pick) when the array sits under an envelope, and use `items` to dig into a nested shape. `pages` caps the page count as a safety net (default `50`) so a misbehaving `next` can't loop forever. Auth, retry, and throttle apply per page, not once for the whole run: a [`throttle`](/docs/guides/resilience/throttle) of `"2/s"` paces the page loop, and a [`retry`](/docs/guides/resilience/retry) recovers each page on its own. **Anti-pattern:** don't read the next-page cursor in `next` from the picked array, expecting it where `items` looks — `next` receives the raw body and `items` receives the value after `pick` runs, so read the cursor in `next` from where it lives in the raw response instead. See [pick](/docs/guides/data/pick). ## See also [#see-also] # pick (/docs/guides/data/pick) Use `pick` when an API wraps the value you want in an envelope and you want the stitch to hand back the inner field directly — give it a dot-path and the caller never sees the wrapper. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch<{ id: number; name: string }>({ baseUrl: 'https://api.example.com', path: '/users/{id}', pick: 'data.user', }); // `user` is the inner { id, name } object, not the { data: { user } } envelope. const user = await getUser({ params: { id: 1 } }); ``` ## Options [#options] `pick` takes a **dot-path** — each segment selects a nested key, so `'data.user'` reaches into `{ data: { user: ... } }` and returns the inner object. It sits late in the pipeline: a response is reshaped by [`transform`](/docs/guides/data/transform) first, then `pick` selects the slice, and only then does [validation](/docs/guides/validation/validation) run — so a schema sees the picked value, not the envelope. For paged calls, each page is picked before its items are collected; see [Pagination](/docs/guides/data/pagination). Pair `pick` with the generic on `stitch()` to type the result: the stitch returns `T` (the picked value), not the envelope. See [Reference → Config types](/docs/reference/config-types) for the full config shape. **Anti-pattern:** don't reach for the dot-path to reshape, rename, or merge fields — `pick` only *selects* one already-nested slice, so anything beyond picking an existing branch belongs in a reshape step that runs first. Do the restructuring in `transform` instead, then point `pick` at the slice it produces. See [transform](/docs/guides/data/transform). ## See also [#see-also] # transform (/docs/guides/data/transform) Use `transform` when the raw response isn't yet the shape the rest of the pipeline expects — scrape HTML text into a structured object, or flatten an odd envelope — so a single function reshapes the body before pick and validation ever see it. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const listing = stitch({ baseUrl: 'https://api.example.com', path: '/listings', // The body is `unknown` — narrow it, then reshape the raw envelope into a // plain array before pick and validation run. transform: (body) => (body as { results: unknown[] }).results, }); ``` The raw body arrives as a wrapped `{ results: [...] }`; `transform` returns the inner array, and everything downstream works against that array instead. ## Options [#options] `transform?: (body: unknown) => unknown` runs **first** in the response pipeline — before [`pick`](/docs/guides/data/pick) and before [validation](/docs/guides/validation/validation) (and [drift](/docs/guides/validation/drift)). That ordering is the point: reshape a raw body — scrape HTML text into a structured object, or flatten an odd envelope — into the shape pick and validation are written for, rather than bending them around the source's quirks. `body` is typed `unknown`, so narrow it before reading (a cast or a type guard) and return any reshaped value. See [Reference → Config types](/docs/reference/config-types) for every field. **Anti-pattern:** don't reach for `transform` to pull the inner field out of an envelope — a cast like reading `.results` off the body is an opaque function whose output is unchecked, so a wrong narrowing slips past and only surfaces later as drift. Name the path with `pick` instead, which is declarative, round-trips as JSON, and feeds validation and drift directly. See [pick](/docs/guides/data/pick). ## Reshaping the request [#reshaping-the-request] `transform` is a response slot. To reshape a body on the way *out* — your field names in, the API's on the wire — put the mapping in the `input` schema. A declared slot doesn't only validate: the request is built from the value the schema **returned**, so a schema that transforms is a request transform. ```ts twoslash import { stitch } from 'stitchapi'; import { z } from 'zod'; const createUser = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/users', input: { // Callers pass the house shape; the API's shape goes on the wire. body: z .object({ userName: z.string(), joinedAt: z.date() }) .transform((b) => ({ user_name: b.userName, joined_at: b.joinedAt.toISOString(), })), }, }); await createUser({ body: { userName: 'mango', joinedAt: new Date() } }); ``` The call argument is typed from the schema's **input** side, so callers keep `{ userName, joinedAt }` and never see `{ user_name, joined_at }`. The mapping lives on the stitch once, instead of at every call site. It runs in the right place, too: input schemas resolve before the request is built, so the reshaped body is what `auth` signs and what the cache key is derived from. Every slot works this way — `params`, `query`, `body`, `headers`, and GraphQL `variables` — and each is transformed independently. ## Why `transform` is response-only [#why-transform-is-response-only] An `output` schema can reshape too, by the same rule: the stitch serves the value the schema returned. So why does `transform` exist as its own slot — and why only on the response? Because on the response side the reshaping has to happen somewhere a schema can't reach, and has to stay visible to machinery a schema would hide it from: * **Ordering.** `transform` runs before [`pick`](/docs/guides/data/pick), so `pick` addresses the reshaped body. A schema's own transform runs *at* validation — after `pick` has already read the raw shape. * **Drift.** [Drift](/docs/guides/validation/drift) is anchored on the difference between the raw body and the validated value. A transform inside a `drift()` schema sits on the validated side, so every field it renames is reported as drift. * **Caching.** A `transform` is a declared opacity: a stitch that has one refuses to cache until you set `cache.fingerprint.transform`, because re-validation cannot detect that the transform itself changed. A transform buried inside `output` is invisible to that check, so it silently defeats it. None of the three has a request-side counterpart. There is no request `pick` and no request drift, and the cache key is derived from the request *after* the input schema shaped it, so the reshaping is already folded in. A request `transform` slot would add a second spelling and no guarantee — which is why there isn't one. The corollary for the response side: prefer `transform` over a transform inside `output`. Keeping the two apart is what keeps `pick`, drift, and the cache fingerprint honest about what reshaped the body. ## See also [#see-also] # URL shaping (/docs/guides/data/url-shaping) A stitch builds its URL from an [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI template plus the `params` and `query` you pass at call time. Simple `{id}` interpolation is the common case; the full operator set is there when you need it. ## Absolute `url` vs relative `path` [#absolute-url-vs-relative-path] How you spell the endpoint decides whether a shared [`baseUrl`](/docs/reference/config-types) applies: * **`path`** is *relative* and is **joined onto `baseUrl`** — the origin a stitch inherits from a seam or a shared fragment. Reach for it whenever many stitches share one host. * **`url`** is the *complete* endpoint (host included). It is templated and query-aware just like `path`, but it **ignores `baseUrl`** — setting `url` means "this stitch addresses itself." Reach for it for a one-off endpoint. ```ts twoslash import { stitch } from 'stitchapi'; // `path` is relative → it joins `baseUrl` (typically shared via a seam or fragment). const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); await getUser({ params: { id: 1 } }); // → GET https://api.example.com/users/1 ``` > \[!WARNING] > > A relative `url` is **not** joined to `baseUrl`. `url: '/users/{id}'` overrides > the base and resolves to the bare `/users/{id}`, which isn't fetchable — it > fails at call time with a `StitchConfigError` ("request URL … is not absolute"). > When you mean "relative to `baseUrl`", use `path`, not `url`. ## Path templates [#path-templates] ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch('https://api.example.com/users/{id}'); await getUser({ params: { id: 1 }, query: { expand: 'roles' } }); // → GET https://api.example.com/users/1?expand=roles ``` Template variables are filled from `params`; `params`, `query`, `headers`, and `body` all travel in one input object. The RFC 6570 operators: | Operator | Template | Role | | -------- | ------------ | ------------------------------------------- | | simple | `{id}` | percent-encoded interpolation (the default) | | reserved | `{+path}` | keep reserved chars (`/`, `?`) unencoded | | path | `{/id}` | prefix the value with `/` | | query | `{?id,sort}` | append `?id=…&sort=…` | | explode | `{list*}` | one `key=value` pair per array/object entry | | prefix | `{id:1}` | take only the first *n* characters | ## Query serialization [#query-serialization] The query builder serializes nested objects and arrays `qs`-style: ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch('https://api.example.com/users'); await search({ query: { filter: { type: 'admin' }, ids: [1, 2] } }); // → /users?filter[type]=admin&ids[0]=1&ids[1]=2 ``` Array encoding is configurable with [`wire.array`](/docs/reference/config-types) — `indices` (the default shown above), `brackets`, or `repeat`. The same walker and the same `wire.array` shape a `wire: { body: 'form' }` request body, so an urlencoded body nests and serializes exactly like the query string: ```ts twoslash import { stitch } from 'stitchapi'; const create = stitch({ method: 'POST', url: 'https://api.example.com/users', wire: { body: 'form' }, }); await create({ body: { filter: { type: 'admin' }, ids: [1, 2] } }); // → filter[type]=admin&ids[0]=1&ids[1]=2 ``` > \[!NOTE] > > Query values are percent-encoded with `encodeURIComponent`, so a space in a > query value goes on the wire as `%20`, not `+` like `URLSearchParams`. A form > **body** keeps `+`, which is what urlencoded bodies have always used. Both > decode to a space server-side; see > [Migration notes](/docs/getting-started/migration-notes) if a byte-for-byte > fixture trips on it. ## Query defaults in the path [#query-defaults-in-the-path] Query keys can be baked into the path string; call-time `query` merges over them, so a stitch can carry sensible defaults that each call selectively overrides: ```ts twoslash import { stitch } from 'stitchapi'; const findUsers = stitch('https://api.example.com/users?sort=name&type=admin'); await findUsers({ query: { type: 'user' } }); // → /users?sort=name&type=user ``` ## See also [#see-also] # OTLP export (/docs/guides/observability/otlp) Reach for OTLP export when you want a stitch's events as OpenTelemetry spans in the observability stack you already run — each call's event stream maps to one CLIENT span, so a stitch drops into your existing OTel collector with no new plumbing. It is just another [trace sink](/docs/guides/observability/trace-sinks), teed alongside the console/JSONL sink rather than replacing it. ## Example [#example] The zero-code path is an env toggle: set `STITCH_EXPORT=otlp` and every stitch also fans its events to an OTLP collector (default `http://localhost:4318/v1/traces`, or `OTEL_EXPORTER_OTLP_ENDPOINT`). ```bash STITCH_EXPORT=otlp OTEL_EXPORTER_OTLP_ENDPOINT=https://api.example.com node app.js ``` To wire it yourself — to set headers, or to tee OTLP next to your own sink — build the sink with `otlp.sink()` and combine sinks with `multiplex`: ```ts twoslash import { createTrace, multiplex, otlp } from 'stitchapi'; const trace = multiplex( createTrace({ console: true }), otlp.sink({ endpoint: 'https://api.example.com', headers: { authorization: 'Bearer token' }, }), ); ``` ## Options [#options] `STITCH_EXPORT=otlp` is the toggle (a comma list, e.g. `console,otlp`); `OTEL_EXPORTER_OTLP_ENDPOINT` sets the collector base URL. The pipeline is one namespace, `otlp`, with a member per layer. `otlp.sink(opts?)` returns a sink. `OtlpOptions` carries `endpoint` (OTLP/HTTP base URL), `headers` (extra headers on the POST, e.g. auth), and `exporter` (a custom `SpanExporter` destination, defaulting to the built-in HTTP exporter). `otlp.exporter({ endpoint?, headers? })` is that default exporter — it POSTs OTLP/JSON to `${endpoint}/v1/traces`. `otlp.json(spans)` serializes collected spans to the OTLP/JSON `ResourceSpans` shape a collector accepts, if you ship them some other way. A span carries method, `url.full`, `server.address`, status, and timings — never request headers or bodies. `url.full` is the only attribute that can hold a secret, so it is scrubbed before export: userinfo is stripped and the values of secret-bearing query params (`api_key`, `access_token`, `signature`, …) become `REDACTED`. See [Reference → Helpers](/docs/reference/helpers) for the helper signatures and [Reference → Events](/docs/reference/events) for the event types each span is built from. Export is fire-and-forget: a missing collector or network error drops the batch and never breaks a stitch call. **Defense in depth, not a guarantee:** `url.full` is scrubbed before export — userinfo stripped, secret-bearing query values masked — but that is a best-effort denylist, so an unusually named param can still slip through. Resolve a secret as a header-borne capability so it never enters the URL the span exports in the first place (headers are never exported at all). See [Capability, not credential](/docs/concepts/capability-not-credential). ## See also [#see-also] # Trace sinks (/docs/guides/observability/trace-sinks) A trace sink is any consumer of a stitch's [event stream](/docs/concepts/event-stream) — reach for one when you want every `start → progress → drift → result → done` teed somewhere durable. Tracing is **off by default**: a stitch makes its call and writes nothing, prints nothing ([no side effects by default](/docs/concepts/principles)). You turn it on per stitch with the `trace` field, globally with an env var, or handle events yourself by iterating `.stream()`. ## Example [#example] Opt in per stitch with the `trace` field — `'console'` for the colored stderr stream, or `fileSink(path)` for JSONL on disk: ```ts twoslash import { fileSink, stitch } from 'stitchapi'; // Colored one-line-per-event stream to stderr. const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', trace: 'console', }); // JSONL appended to a file you name. const report = stitch({ baseUrl: 'https://api.example.com', path: '/report', trace: fileSink('./runs/today.jsonl'), }); ``` Or wire it globally by environment variable — handy for turning tracing on without touching code: ```bash # Colored stream to stderr. STITCH_TRACE_CONSOLE=1 node run.js # Append JSONL to a path (off unless set). STITCH_TRACE_FILE=./runs/today.jsonl node run.js # ALSO fan the same events to an OTLP collector. STITCH_EXPORT=otlp node run.js # Turn file tracing OFF: 0, false, or an empty value disable it (no file is written). STITCH_TRACE_FILE=0 node run.js # Capture full request/response bodies (the JSONL truncates them to 2048 chars by default). STITCH_TRACE_MAX_BODY=full node run.js ``` A stitch-local `trace` wins over the environment: `trace: false` forces tracing off even when the env vars are set. To consume events as your own sink, iterate the stream — that's the public seam for custom handling: ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search' }); for await (const event of search({ query: { q: 'mango' } }).stream()) { if (event.type === 'drift') console.log(event.finding.level, event.finding.path); if (event.type === 'result') console.log(event.data); } ``` ## Options [#options] The `trace` field accepts: | Value | Effect | | ------------- | ---------------------------------------------------------------------- | | *(unset)* | Falls back to the env vars below — off unless one is set. | | `'console'` | The colored one-line-per-event stream to stderr. | | `fileSink(p)` | JSONL appended to `p` (defaults to `~/.stitch/runs/proto.jsonl`). | | a `TraceSink` | Any custom sink — e.g. from `createTrace` / `multiplex` / `otlp.sink`. | | `false` | Forces tracing off, even when `STITCH_TRACE_*` is set. | When `trace` is unset, four env vars control the built-in sink: `STITCH_TRACE_CONSOLE=1` turns on the colored stderr stream; `STITCH_TRACE_FILE=` appends JSONL to that path (off when unset, `0`, or `false`); `STITCH_EXPORT=otlp` adds OTLP as one more sink fed from the same events — see [OTLP export](/docs/guides/observability/otlp); and `STITCH_TRACE_MAX_BODY` tunes [body truncation](#body-truncation-and-full-capture) (`full` to capture whole bodies, or a character cap). To build sinks in code, `consoleSink()` and `fileSink(path)` return the single-purpose sinks, `createTrace({ console, file })` returns one that does both, and `multiplex(a, b, ...)` fans one event stream out to several sinks at once. All are exported from [`stitchapi`](/docs/reference/helpers). ## Logging to a host logger [#logging-to-a-host-logger] Already running pino, winston, or just `console`? `loggerSink(logger)` bridges the event stream straight onto it — no JSONL file, no OTLP collector. It is **logger-agnostic** (anything with `error` / `warn` / `info` / `debug` methods satisfies the `LoggerLike` shape) and **payload-free**: it logs only metadata — name, method, scrubbed URL, status, attempt counts, drift path/level, timing — never `event.input`, `event.data`, or a streamed `delta` chunk. ```ts twoslash import { loggerSink, stitch } from 'stitchapi'; // `console` already satisfies LoggerLike; swap in `pino()` / a winston logger / a // Nest `Logger` and nothing else changes. const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', trace: loggerSink(console), }); ``` Each `StitchEvent` maps to a level: | Event | Level | | -------------------------------------- | ------------------------------------------------------- | | `result` | `info` | | `error` | `error` | | `drift` | the finding's own level (`error` / `warn` / `info`) | | `start` / `progress` / `info` / `done` | `debug` | | `delta` | *never logged* — a streamed chunk is raw response data. | Override any per-type level with `levels` — for example, demote the happy-path `result` to `debug` so production logs stay quiet until something drifts or errors: ```ts twoslash import { loggerSink, stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', trace: loggerSink(console, { levels: { result: 'debug' } }), }); ``` This is the generic core counterpart to `@stitchapi/nest`'s NestJS-specific `nestLoggerSink`, which forwards the same payload-free metadata to a Nest `Logger` — see [NestJS integration](/docs/integrations/nestjs). ## Turning tracing off [#turning-tracing-off] There are two off switches, both of which disable the built-in sinks: ```ts twoslash import { stitch } from 'stitchapi'; // ---cut--- // Per-stitch: `trace: false` disables ALL built-in sinks for this stitch and wins // over every STITCH_TRACE_* env var. (A `TraceSink` value replaces them instead.) const quiet = stitch({ baseUrl: 'https://api.example.com', path: '/health', trace: false, }); ``` Globally, set `STITCH_TRACE_FILE` to `0`, `false`, or an empty string to turn file tracing off — these are treated as the off switch, not as a path, so no file named `0` or `false` is ever written to the working directory. ## Default secret redaction [#default-secret-redaction] Before any event is written to the built-in JSONL (or console) sink, header values for a fixed denylist are replaced with the literal `[REDACTED]`, matched case-insensitively wherever headers appear in the event payload (the `start` event's input headers, response headers, and so on): * `authorization` * `proxy-authorization` * `cookie` * `set-cookie` * `x-api-key` So a call carrying `authorization: 'Bearer …'` traces as `"authorization":"[REDACTED]"` — the raw secret never reaches disk. Redaction happens at the sink boundary, so the live request still sends the real header; only the trace copy is scrubbed. The same boundary scrubs **URLs**: a `start` event's resolved URL has any userinfo (`https://user:pass@host`) removed and the values of secret-bearing query params (`api_key`, `access_token`, `signature`, …) replaced with `REDACTED` — in both the JSONL `url` field and the OTLP [`url.full`](/docs/guides/observability/otlp) attribute. The matching keys in the `start` event's structured `input.query` are redacted the same way, so the two views can't disagree; benign params (`page`, `sort`) survive for observability. Need to redact a non-standard auth header (say `x-auth-token`)? Widen the denylist — you can extend it but never shrink it — with `redactHeaders`: ```ts twoslash import { fileSink, stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', trace: fileSink('./runs/today.jsonl', { redactHeaders: ['x-auth-token', 'x-session-id'], }), }); ``` A `TraceSink` is just `{ handle(event, ctx), flush?() }` — implement those two and you have a custom consumer you can pass to `trace` or `multiplex`. The same shape backs the console, JSONL, and OTLP sinks, which is why all three come from one source without the call site knowing. See [Reference → Helpers](/docs/reference/helpers) for the exported building blocks and [Reference → Event types](/docs/reference/events) for the `StitchEvent` union a sink receives. **Anti-pattern:** don't assume a custom `TraceSink` inherits the built-in `[REDACTED]` scrubbing and write the raw event payload straight to your destination — its headers still carry the live secret (`authorization`, `cookie`, `x-api-key`), so a `handle` that logs them verbatim leaks a credential the call resolves but the caller never sees; scrub the same header denylist inside your `handle` before you persist, or wrap the built-in sink via `multiplex` so redaction runs first. See [Capability, not credential](/docs/concepts/capability-not-credential). The CLI honors the same default: `stitch run` traces nothing until you pass `--trace`, which records the JSONL file (at `~/.stitch/runs/proto.jsonl`, or wherever `STITCH_TRACE_FILE` points) so [`stitch trace`](/docs/surfaces/cli) has a log to summarize. Use `--trace=console` to stream to stderr instead. ## Body truncation and full capture [#body-truncation-and-full-capture] To keep one large response from bloating the log — and to limit how much payload is persisted by default — the built-in JSONL sink **truncates** the request body (the `start` event) and the response value (the `result` event) once their JSON encoding passes a character cap. The oversized value is replaced with a compact marker: ```json { "truncated": true, "chars": 51234, "preview": "{\"items\":[{\"id\":1,…" } ``` `preview` is the first `body.chars` characters of the (already header-redacted) JSON, so you keep a readable head of the payload without storing all of it. The default cap is **2048 characters**; `0` keeps only the marker, no preview. Truncation bounds *size* — it is not field-level redaction, and a preview can still contain a body-embedded secret. Header and URL secrets are redacted (above) regardless of the cap. Customer data in a response body is the case this leaves open: [the customer data you didn't mean to log](/docs/scenarios/pii-in-the-logs) counts which destinations carry a whole body and which carry none, and why there is nothing in between. Opt into **full capture** — the whole body, untruncated — when you need it, with `body: { chars: false }` on the file sink: ```ts twoslash import { fileSink, stitch } from 'stitchapi'; const report = stitch({ baseUrl: 'https://api.example.com', path: '/report', // false = capture the whole body; a number sets a custom character cap. trace: fileSink('./runs/today.jsonl', { body: { chars: false } }), }); ``` Or globally, without touching code, via the env knob the built-in sink reads: ```bash # Whole bodies, no truncation. STITCH_TRACE_MAX_BODY=full node run.js # A custom character cap (here 8 KB). STITCH_TRACE_MAX_BODY=8192 node run.js ``` **Migration:** earlier releases stored every request and response body in the JSONL trace in full. The built-in file sink now truncates them to 2048 characters by default, and resolved URLs have their userinfo and secret query values scrubbed. No API changed shape and nothing was removed — if a tool or `stitch trace` workflow relied on whole bodies on disk, restore the old behaviour with `STITCH_TRACE_MAX_BODY=full` (or `fileSink(path, { body: { chars: false } })`). A custom `TraceSink` is unaffected: it receives the live events and decides for itself. ## See also [#see-also] # Circuit breaker (/docs/guides/resilience/circuit-breaker) Add `circuit` when an upstream API is flaky or failing and you want the stitch to **stop hammering it**. After a run of failures the breaker *opens* and calls fast-fail for a cooldown: the stitch backs off, giving the unhealthy dependency room to recover before a single trial call probes it again — and shielding your app from the slow, cascading failures a dead upstream would otherwise pull it into. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const orders = stitch({ baseUrl: 'https://api.example.com', path: '/orders', circuit: { failures: 5, cooldown: 30_000 }, }); ``` After 5 consecutive failures the breaker opens; for the next 30 seconds calls to `orders()` fast-fail instead of hitting `api.example.com`. ## Options [#options] The breaker moves through four states. It starts **closed** (calls pass through). After `failures` consecutive failures it trips **open** and calls fast-fail for `cooldown`. It then goes **half-open** and lets a single trial call through: a success **closes** it and clears the count, another failure re-**opens** it for a fresh cooldown. The breaker emits `progress` events with phase `circuit`. The positional form names both knobs at once: `circuit: [5, '30s']` ≡ `circuit: { failures: 5, cooldown: '30s' }`. `failures` is the count of consecutive failures that trips the breaker open (default `5`), and `cooldown` is the fast-fail window after opening, before a half-open trial is allowed (default `'30s'`). Both are optional, so `circuit: { key: 'orders' }` builds a working breaker on the house defaults — declaring `circuit` at all is the decision a reviewer needs to see; the thresholds are tuning. `cooldown` is the **only** knob on that boundary: the instant the fast-fail window ends is the instant the trial is admitted. There is no separate probe timer, because a call is either rejected or let through — a phase between the two would have to behave exactly like one of them. `key` is a store namespace: give two stitches the same `key` plus a shared [`store`](/docs/guides/state/pluggable-store) to share one breaker, so a failing host opens the circuit for every stitch that talks to it. The default key is the stitch/host key — which also means a multi-tenant fleet shares one breaker until you say otherwise, and [one customer's revoked token, everyone's outage](/docs/scenarios/multi-tenant-blast-radius) measures which isolation channels are automatic and which are a string you have to remember to write. When the point of the breaker is to hand traffic to a second vendor, [failing over to the backup provider](/docs/scenarios/provider-failover) covers the routing it does not do for you. **Anti-pattern:** don't lean on the default in-memory breaker across multiple workers or instances — the counter is process-local, so each replica tallies its own failures and `failures` trips per process, letting a failing host take many times the hits you intended before every circuit opens. Give the stitches a shared `key` plus a durable `store` so one breaker spans the fleet. See [Pluggable store](/docs/guides/state/pluggable-store). While the breaker is open, a call fast-fails instead of reaching the dependency — see [STITCH\_CIRCUIT\_OPEN](/docs/errors/stitch-circuit-open) for what callers see and how to handle it. See [Reference → Config types](/docs/reference/config-types) for every field. ## See also [#see-also] # Delegate backoff (/docs/guides/resilience/delegate-backoff) By default a stitch handles a rate limit *itself*: a `429` listed in [`retry.on`](/docs/guides/resilience/retry) is retried with backoff, the `Retry-After` header is consumed to pace that wait, and the built-in [`throttle`](/docs/guides/resilience/throttle) spaces calls before they leave. That's the right behavior when the stitch is the only thing talking to the API. It's the **wrong** behavior when something *outside* StitchAPI already owns the rate budget — an outer gate or circuit with its own `Retry-After` hook and a persisted backoff window. There, internal retry hides the rate-limit signal from the gate, and the internal throttle double-counts against it. Turn on **delegate-backoff** mode and the stitch surfaces the rate-limit outcome instead of absorbing it, so the outer gate decides what to do. ## Example [#example] ```ts twoslash import { RateLimitError, stitch } from 'stitchapi'; // Your own rate-gate / circuit, living outside StitchAPI. declare const outerGate: { penalize(ms: number): void }; const fetchPage = stitch({ baseUrl: 'https://api.example.com', path: '/pages/{id}', throttle: { delegate: true }, // surface 429s; don't retry or throttle them }); try { await fetchPage({ params: { id: '42' } }); } catch (e) { if (e instanceof RateLimitError) { // Hand the signal to your outer gate — it owns the backoff. outerGate.penalize(e.retryAfter ?? 1000); } } ``` A `429` no longer retries: the call rejects with a [`RateLimitError`](/docs/errors/rate-limit) carrying `status`, the `retryAfter` parsed from `Retry-After` (delta-seconds **or** an HTTP-date), and the raw `response` so you can read any other rate headers the API sent. The internal throttle is bypassed for the call, so it adds no pacing of its own. `RateLimitError` **subclasses `StitchError`**, so it also carries the usual `attempts` / `body` / `url`, and `.safe()` hands back the same instance rather than a downgraded copy — branch with `.safe()` or `try`/`catch`, whichever you prefer, and `instanceof RateLimitError` works in both. The one rule that follows from the subclassing: if you branch on both classes, **test `RateLimitError` first**, or a generic `instanceof StitchError` arm will swallow the signal you opted in to receive. ## Options [#options] `delegate` switches the mode on; it defaults to `false`, so a stitch behaves exactly as before until you opt in. `on` is the `StatusMatch` treated as a rate-limit signal — a status, a list, or a predicate — defaulting to `[429]` (`on: 429` ≡ `on: [429]`). Widen it when an API signals back-pressure with another status (for example `{ delegate: true, on: [429, 503] }`); a status *not* matched by `on` is left to the ordinary [retry](/docs/guides/resilience/retry) path. ## Reading it off the stream [#reading-it-off-the-stream] If you iterate the [event stream](/docs/concepts/event-stream) instead of awaiting, the same outcome arrives as an `error` event carrying `status` and `retryAfter`, so a streaming consumer gets the identical structured hint: ```ts twoslash import { stitch } from 'stitchapi'; // Your own rate-gate / circuit, living outside StitchAPI. declare const outerGate: { penalize(ms: number): void }; const fetchPage = stitch({ baseUrl: 'https://api.example.com', path: '/pages/{id}', throttle: { delegate: true }, }); for await (const ev of fetchPage.stream({ params: { id: '42' } })) { if (ev.type === 'error' && ev.retryAfter !== undefined) { outerGate.penalize(ev.retryAfter); } } ``` ## What still runs [#what-still-runs] Delegate mode changes *only* how a rate-limit status is handled. Everything else on a stitch is untouched: input validation, templating, `transform`, `pick`, and [drift](/docs/guides/validation/drift) all still run on a successful response, and non-rate-limit failures (a `500`, a transport error) behave exactly as they do without the flag. A [`circuit`](/docs/guides/resilience/circuit-breaker) block, if you also set one, still applies — you can let the host layer both. **The `throttle` block becomes inert** for a stitch in delegate mode — the host owns the gate, so the stitch never acquires its internal limiter and never emits a `throttled` event. If you still want StitchAPI to pace calls, don't delegate: use [retry](/docs/guides/resilience/retry) + [throttle](/docs/guides/resilience/throttle) instead. Delegate mode and internal retry-on-rate-limit are mutually exclusive *for the delegated statuses*: a status in `throttle.on` is surfaced, never retried, even if it also appears in `retry.on`. See [Reference → Config types](/docs/reference/config-types) for every field. ## See also [#see-also] # Idempotency keys (/docs/guides/resilience/idempotency) Make retries on writes safe, so a replayed write settles once and never becomes a duplicate. Add `idempotency` to a write stitch and each logical call carries a stable `Idempotency-Key` header — when a retry replays the same write, the server sees the same key and dedupes the side effect instead of running it twice. Reach for it on `POST`/`PUT` where a duplicate would double-charge, double-ship, or double-create. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; // Default: a fresh random key per call, sent as 'Idempotency-Key'. const charge = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/charges', idempotency: true, }); // Custom: derive a stable key from the input. `body` is `unknown`, so narrow it. const createOrder = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/orders', idempotency: { keyOf: (input) => `order-${(input.body as { ref: string }).ref}`, }, }); ``` A single `charge()` call generates one key and reuses it across every retry of that call, so a network blip that triggers a retry settles a single charge. **Anti-pattern:** don't lean on the default random key to dedupe two separate submissions — a double-clicked "Pay" fires two distinct `charge()` calls, each with a fresh key, so the server sees two writes and charges twice; derive a stable `keyOf` from something the duplicate submissions share (an order reference, or an id your client mints once per intent) so both map to one key. See [Reference → Config types](/docs/reference/config-types). ## Options [#options] `header` is the header name the key rides on, default `'Idempotency-Key'`. Set it when the API expects a different name (`'Idempotency-Token'`, a vendor-prefixed `'X-Idempotency-Key'`). It renames the idempotency key — the value the server *dedupes* on — not a correlation or trace header like `traceparent`/`X-Request-Id`, which identify a request for logs and spans and belong to tracing, not dedupe. `keyOf` builds the key from the call's [`StitchInput`](/docs/reference/config-types) (`params`, `query`, `body`, `headers`, `variables`). The default is a random uuid generated once per logical call; a custom `keyOf` lets you derive a stable key from the request — for example an order reference — so the same logical write maps to the same key. `body` is typed `unknown`, so narrow or cast it before reading a property, as the `createOrder` example does. Why it matters: idempotency pairs most naturally with [retries](/docs/guides/resilience/retry) — a retried write replays the same key, so the server recognizes the duplicate and skips the side effect, and safe retries don't double-charge. A timeout is the hard version of this — it tells you nothing about whether the server applied the write, and [the charge you can't confirm](/docs/scenarios/unconfirmed-write) measures what the default key does to a re-driven job. It isn't only for retries, though: the same key also covers a duplicate the stitch never issued (a proxy or the transport resending the request), and a derived `keyOf` dedupes two separate submissions with no retry at all. Because the random default key mostly earns its keep alongside a retry, declaring it on a write with **no** `retry` logs a one-time construction nudge pointing you at `retry` (or a derived `keyOf`). It's a hint, not an error — set `warn: false` to silence it when the keyless-retry case (a proxy dedupe, say) is deliberate. A custom `keyOf` derived from `body` must be stable for a given logical call and unique across distinct ones — colliding keys make the server dedupe two different writes into one. See [Reference → Config types](/docs/reference/config-types) for the full `IdempotencyOptions` shape. ## Reads never get a key [#reads-never-get-a-key] The key rides on **writes only**. Declare `idempotency` on a read and the engine drops it and logs the same kind of nudge — almost always a missing `method: 'POST'`. "A read" means every method [RFC 9110 §9.2.1](https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1) calls *safe* — `GET`, `HEAD`, `OPTIONS`, `TRACE` — plus [`QUERY`](/docs/guides/authoring/stitch#the-query-method), which is safe **and carries a request body**. Carrying a body is not what makes a call a write, so a `QUERY` is not stamped: there is no side effect for the server to collapse. ## See also [#see-also] # Retry & backoff (/docs/guides/resilience/retry) Add `retry` when an API returns transient failures — rate limits and gateway hiccups — and you want the stitch to wait and try again instead of surfacing the first error. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', retry: { attempts: 3, on: [429, 503], backoff: { curve: 'expo-jitter', base: 200 }, }, }); ``` ## Options [#options] `attempts` is the **total** number of attempts including the first call, so `attempts: 3` means up to two retries; the default is `1`, which disables retrying. `on` selects the response status codes that trigger a retry — a `StatusMatch`: a single status, a list, or a predicate `(status) => boolean` (`on: 429` ≡ `on: [429]`). It defaults to `[429, 502, 503, 504]` — the usual transient set — and you narrow or widen it per stitch. The predicate receives the status and nothing else, so a vendor that reports throttling in the body of a `200` is invisible to every policy you can write here — [rate limits priced in query cost](/docs/scenarios/cost-based-rate-limits) works that case through a surface instead. `backoff` chooses how long to wait between attempts. A bare curve is the whole policy — `backoff: 'expo-jitter'` — and the object form adds the two bounds that shape it, `base` and `max`: ```ts retry: { attempts: 3, backoff: { curve: 'expo', base: 200, max: '10s' } } ``` `'expo'` doubles the delay each attempt starting from `base`, `'expo-jitter'` spreads it randomly up to that exponential value to avoid thundering herds, and `'fixed'` waits a constant `base` every time. Every computed delay is clamped to `max`. `respect` honors a `Retry-After` header on the failing response — delta-seconds or an HTTP-date — using it in place of the computed backoff. **It defaults to `true`**: the statuses `on` retries by default are the ones RFC 9110 defines the header for, so when the server tells you when to come back, guessing at a curve instead is strictly worse information. Set `respect: false` to force the computed curve regardless. There is deliberately no ceiling on an honored `Retry-After`. A server asking for five minutes gets five minutes, because the place to say how long you're willing to wait is [`timeout.total`](/docs/guides/resilience/timeout) — one patience budget for the whole call, not a second one hidden in `retry`. It already bounds every backoff sleep, so a long `Retry-After` under a total budget fails with the timeout rather than parking the call; the wait also ends early if the caller's `AbortSignal` fires. A stitch with `retry` and **no** `timeout.total` will wait as long as the server asks. Each retry surfaces as a `progress` event (phase `retry`) on the [event stream](/docs/concepts/event-stream), so a trace sees every wait and re-attempt. For the exhaustive field list and defaults, see [Reference → Config types](/docs/reference/config-types). **Anti-pattern:** don't add `retry` to a stitch that performs a non-idempotent write — a POST that creates an order, for instance — because a re-attempt after a timeout or `503` can silently apply the side effect twice; pair retries with an idempotency key so the server collapses the duplicate instead. See [Idempotency](/docs/guides/resilience/idempotency). When the write is a batch and only some of its items failed, the whole request is the wrong retry unit — [batch writes that fail one item at a time](/docs/scenarios/batch-partial-failure) walks that case through. ## See also [#see-also] # Throttle (/docs/guides/resilience/throttle) Add `throttle` to a stitch when you need to stay under an API's rate limit and keep in-flight requests bounded — it spaces calls to a target rate and caps how many run at once, before any request leaves. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', throttle: { rate: '5/s', concurrency: 2, pool: 'host' }, }); ``` Calls to `search()` are spaced to five per second with at most two running concurrently. When a call has to wait, the stitch emits a `progress` event with phase `throttled`, so a waiting call is visible on the event stream rather than silently stalled. ## Options [#options] `rate` is a string like `"5/s"` — a minimum spacing between successive calls, not a token bucket. `concurrency` caps simultaneous in-flight calls; either may be set on its own. ### The rate grammar [#the-rate-grammar] A rate is `/`. The count is a whole number; the denominator is any [duration token](/docs/reference/helpers#duration), and a bare unit means one of that unit — so `'2/s'` is `'2/1s'`. | Rate | Means | Spacing | | ----------- | ------------------ | ------- | | `'2/s'` | 2 per second | 500ms | | `'1000/h'` | 1000 per hour | 3600ms | | `'100/15m'` | 100 per 15 minutes | 9000ms | | `'2/500ms'` | 2 per half-second | 250ms | Write the limit the way your vendor publishes it. A quota of "1000 requests per hour" is `'1000/h'` — you don't have to convert it to a per-minute count, and you couldn't do so exactly anyway (it's 16.67/m). **A rate is a ratio, so equal ratios are the same limiter.** `'2/500ms'`, `'4/s'` and `'240/m'` all declare a 250ms gap and behave identically — the window length is a way of spelling the ratio, not a burst allowance. Because this is a pacer rather than a token bucket, there is no capacity for a longer window to grant: calls are spaced, never released in a burst. If you need to spend a real quota the way the vendor accounts for it, hand the backoff to an outer gate with `delegate` instead — and when the vendor prices each call differently, as Shopify's point bucket does, [rate limits priced in query cost](/docs/scenarios/cost-based-rate-limits) shows why no single rate is the right one. A rate that can't be honored is rejected when the stitch is built, rather than silently ignored — a dropped rate is an unlimited one. That covers the obvious typos (`'fast'`, `'2/nope'`) and both degenerate ends: a zero count (`'0/s'`), and a spacing longer than the \~24.8-day timer ceiling (`'1/30d'`). When the rate is all you need, pass it directly — a bare string is shorthand for `rate`, exactly like `timeout: '5s'` is shorthand for `timeout: { total: '5s' }`: ```ts twoslash import { stitch } from 'stitchapi'; const ping = stitch({ url: 'https://api.example.com/ping', throttle: '1/s', // ≡ { rate: '1/s' } }); ``` The shorthand expands at compose time, so `__config.throttle` always reads back as the object form, and a `throttle: '1/s'` layered over an inherited `{ rate: '5/s', concurrency: 2 }` replaces only the rate. `pool` decides what shares the budget: `'stitch'` (the default) gives each stitch its own budget, while `'host'` pools the budget across every stitch hitting the same host — use it when one API publishes a single account-wide limit. Host pooling works in-process out of the box: separate stitches in the same process draw from one budget per host with no extra configuration. Giving those stitches a shared [store](/docs/guides/state/pluggable-store) extends that single-process budget into a [distributed one](/docs/guides/state/distributed-throttle) that spans workers. **Anti-pattern:** don't rely on a host-pooled throttle to honor an account-wide limit once you run more than one process — the budget lives in memory and is process-local, so each of N workers keeps its own count and together they hit `api.example.com` at up to N times the rate you declared; pass a shared store so every worker draws from one budget instead. See [Distributed throttle](/docs/guides/state/distributed-throttle). A throttled wait surfaces as a `throttled` progress phase on the event stream — see [The event stream](/docs/concepts/event-stream). See [Reference → Config types](/docs/reference/config-types) for every field. ## See also [#see-also] # Timeouts (/docs/guides/resilience/timeout) Add `timeout` when a slow or hung response shouldn't be allowed to block the caller indefinitely — the stitch bounds the call with a real `AbortSignal`, so a breached deadline actually aborts the in-flight request rather than leaking it. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; const report = stitch({ baseUrl: 'https://api.example.com', path: '/report', timeout: { total: '10s', each: '3s' }, }); ``` Each call to `report()` gives every attempt at most 3 seconds and the whole call at most 10 seconds; whichever fires first aborts the request. ## Options [#options] The two fields name the two scopes: `total` bounds the entire call across every retry, `each` bounds a single attempt on its own. Both accept either a number of milliseconds (`3000`) or a duration string (`'3s'`), so `total: '10s'` and `total: 10000` are equivalent. The deadline is backed by a real `AbortSignal`: when it elapses the stitch aborts the underlying request — the connection is actually cancelled, not just ignored — and the call fails with a timeout. With [retry](/docs/guides/resilience/retry), the two timeouts compose: `each` gates one try, while `total` caps the sum of all tries plus their backoff waits, so a long enough `total` is what lets a slow attempt be retried at all. Neither scope spans separate calls, so a submit-then-poll job needs its deadline built a level up — [submit, poll, download](/docs/scenarios/async-job-polling) works through the three-call version and what each choice costs. **Anti-pattern:** with retry enabled, don't rely on `each` alone to bound how long the caller waits — it bounds one try, not the call, so several retries plus their backoff waits can block the caller far past your intended deadline. Set `total` to cap the whole call instead. See [Retry](/docs/guides/resilience/retry). A breached deadline aborts the call and surfaces as a timeout error. See [STITCH\_TIMEOUT](/docs/errors/stitch-timeout) for how it reads and how to fix it. See [Reference → Config types](/docs/reference/config-types) for the full `TimeoutOptions` shape. ## See also [#see-also] # Verdict (/docs/guides/resilience/verdict) Every call ends with one decision: **is this response a result, or an error?** The stitch calls that decision the **verdict**, and `verdict` is where you configure it. By default every `status >= 400` is a failure: the stitch throws a [`StitchError`](/docs/errors), so a `404` you *expected* — a resource that's gone, where you fall back to a broader call — has to be handled in a `catch`. That turns ordinary control flow into exception handling. ## Where it runs [#where-it-runs] The verdict is rendered at **stage 4 of the pipeline**, by the surface's `interpret` hook. `verdict` is the declarative input that hook reads: ``` call → throttle → request → retry → interpret → paginate → pick → validate → cache → result ▲ the verdict, from `verdict` ``` That is why it is one envelope rather than a loose status option: it names a stage, and its members are the inputs to that stage's decision. `stitch diagram` and the MCP teaching list both render it, so the decision is visible rather than implied. ## `accept` — a non-2xx that is a normal result [#accept--a-non-2xx-that-is-a-normal-result] An accepted status no longer throws: its response body becomes the result and flows through the same pipeline a `2xx` does — `interpret` → `transform` → `pick` → `output` validation — so the happy path stays on the happy path. ```ts twoslash import { stitch } from 'stitchapi'; const lookup = stitch<{ id: string } | { missing: true }>({ baseUrl: 'https://api.example.com', path: '/items/{id}', verdict: { accept: [404] }, // a 404 is "not found", not a thrown error transform: (body) => // Reshape the 404 payload into a sentinel your code can branch on. body && typeof body === 'object' && 'id' in body ? body : { missing: true }, }); const item = await lookup({ params: { id: 'abc' } }); if ('missing' in item) { // fall back to a broader call — no try/catch needed } ``` A `404` here **resolves** with the response body instead of rejecting. Everything else (`401`, `500`, …) still throws a `StitchError` exactly as before — `accept` is additive, never a blanket "ignore failures". ### A number, a list, or a predicate [#a-number-a-list-or-a-predicate] `accept` is a `StatusMatch`: a single status, a number list, or a predicate `(status) => boolean`. A bare number is shorthand for its one-element list (`accept: 404` ≡ `accept: [404]`). Use a predicate to accept a range: ```ts twoslash import { stitch } from 'stitchapi'; const probe = stitch({ baseUrl: 'https://api.example.com', path: '/probe', // accept every 4xx as a result; a 5xx still throws verdict: { accept: (status) => status >= 400 && status < 500 }, }); ``` ## `flag` — a 200 whose body says it failed [#flag--a-200-whose-body-says-it-failed] Some APIs — older ones especially — answer `200 OK` and put the real outcome in the payload: `{ ok: false, code: 'RATE_LIMITED' }`. `flag` is a dot-path (the same shape as [`pick`](/docs/guides/data/pick)) to a field that is **explicitly falsy on failure**: ```ts twoslash import { stitch } from 'stitchapi'; const call = stitch({ baseUrl: 'https://api.example.com', path: '/legacy', verdict: { flag: 'meta.success' }, // a 200 with meta.success: false is a failure }); ``` `accept` and `flag` move the verdict in exactly one direction each — `accept` can only turn a failure into a success, `flag` only a success into a failure. Neither invents a verdict from absence. ### It has to *say* failure [#it-has-to-say-failure] `flag` is three-state, and only one state is a verdict: | at the path | verdict | | --------------------------------- | --------------------------------------------------- | | present, truthy | success — the flag confirms it | | present, falsy (`false` `0` `''`) | **failure** — the flag says so. The feature. | | `null` | **no signal** | | absent (`undefined`) | **no signal** — falls through to the status verdict | A missing path cannot manufacture a failure, and a `200` carrying no flag is still a `200`. That matters because a server sends what it sends: the same endpoint returns `{ meta: { success: true }, data }` on Tuesday and a bare `{ data }` on Wednesday — a different version, a cache tier, a partial rollout. This library exists to survive that, so it must not be the thing that breaks on it. `null` sits with absence rather than with `false` on the same reasoning: APIs spell "not applicable" and "unknown" as `null` constantly, and JS truthiness would read that as a declaration of failure it never made. A path that resolves to nothing still emits an `info` [drift finding](/docs/guides/validation/drift), so a typo — or an API that quietly dropped its envelope — shows up in `.inspect()` and the drift report without anyone's call failing. If the envelope is genuinely guaranteed, declare the field in `output` and let validation enforce it. That is what the schema is for, it produces a real error with a real path, and it means `flag` needs no strict mode. ## Interaction with retry [#interaction-with-retry] `retry.on` **wins while attempts remain**. A status listed in *both* [`retry.on`](/docs/guides/resilience/retry) and `verdict.accept` is retried until attempts are exhausted, then **accepted** (returned) on the final attempt — so you can retry a flaky `503` a few times and still treat a persistent one as a result rather than an error: ```ts twoslash import { stitch } from 'stitchapi'; const call = stitch({ baseUrl: 'https://api.example.com', path: '/maybe', retry: { attempts: 3, on: [503] }, // retry a 503 up to 3 times… verdict: { accept: [503] }, // …then accept it on the final attempt }); ``` `verdict` is orthogonal to [`throttle.delegate`](/docs/guides/resilience/delegate-backoff), which surfaces a `RateLimitError` on rate-limit statuses earlier and is unaffected by this option. ## When you still want the error body [#when-you-still-want-the-error-body] If a non-2xx is a *genuine* failure but you need the API's error payload — its `{ error: "…" }` body — don't reach for `verdict`. Let it throw and read [`StitchError.body`](/docs/errors) (and `.url`), which carry the parsed payload and final request URL on the awaited / `.safe()` path. ## Beyond configuration [#beyond-configuration] `verdict` is the declarative half. When the rule is genuinely code — "a `200` whose `status` field is `PENDING` should be re-attempted" — the stage has a functional seam too: a [surface](/docs/reference/surfaces)'s `interpret` hook, which can also ask for another attempt. Compose `verdictOf` in front of your own rules so a `500` stays a `500`: ```ts twoslash import { type Surface, verdictOf } from 'stitchapi'; const poller: Surface = { id: 'poller', interpret: (res, cfg) => verdictOf(res, cfg) ?? ((res.body as { status?: string }).status === 'PENDING' ? { ok: false, retry: true, message: 'PENDING', after: '1s' } : { ok: true, data: res.body }), }; ``` `after` is how long to wait before the next attempt, in the same duration form as every other authored duration — `1_000` or `'1s'`. Omit it and the configured `backoff` curve paces the wait instead. A body-driven retry shares the `retry.attempts` budget and reuses the run's `Idempotency-Key`, so replaying a write is as safe as a status-driven retry. # Distributed throttle (/docs/guides/state/distributed-throttle) Share one rate limit across every worker or server by pointing the throttle at a shared `store`. By default a stitch keeps its throttle counters in the in-memory store, so the rate budget is per process: with `pool: 'host'` every stitch in that process already pools into one budget per host (see [Throttle](/docs/guides/resilience/throttle)), but the in-memory store keeps that pooling within a single process — each worker gets its own. Point the throttle at a shared store and one budget spans every worker that uses it. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import type { StitchStore } from 'stitchapi'; // A shared store — e.g. @stitchapi/redis's redisStore() — see The pluggable store. declare const sharedStore: StitchStore; const search = stitch({ baseUrl: 'https://api.example.com', path: '/search', throttle: { rate: '10/s', pool: 'host' }, store: sharedStore, // the budget now spans all workers using this store }); ``` ## Options [#options] `throttle` sets the limits — `rate` (e.g. `'10/s'`), `concurrency`, and `pool` — while `store` decides where the counters live. The default in-memory store is single-process, so each worker enforces `10/s` on its own and the fleet runs at `10/s × workers`. A shared store holds the rate counter every worker increments, so the whole fleet shares one `10/s` budget — and grants are **even-spaced** (about one every 100ms, i.e. `window / limit`), the same pacing the in-process limiter uses, so attaching a store doesn't turn a smooth limit into boundary bursts. `concurrency` goes fleet-wide with it: `concurrency: 5` over a shared store means five calls in flight across every worker, not five each. Nothing extra to configure — a store that supports it just does it. **A fleet-wide slot is a lease, so it has an expiry.** A worker that crashes mid-call never gives its slot back, and `throttle.lease` (default 30s) is how long the fleet waits before reclaiming it. Size it above your slowest call: too long strands a dead worker's slots, too short and a call still running loses its slot to the next caller, briefly putting the fleet over the cap. Streaming never holds a slot at all, so this only bounds buffered calls — a `lease` comfortably above `timeout.total` can't be outlived. Two behaviours differ from the in-process limiter, inherently: a blocked call **polls** rather than being handed the slot (no worker can wake another), so strict FIFO fairness is gone; and a release is fire-and-forget, because the expiry already covers a lost one. Backed by `memoryStore`, `@stitchapi/redis` and `@stitchapi/deno-kv`; a store without leases keeps per-process concurrency. **How exact the fleet-wide pacing is depends on your store.** A store with a pacing cell — `memoryStore`, `@stitchapi/redis`, `@stitchapi/deno-kv` — paces the whole fleet on one continuous cursor, so every worker draws from one schedule no matter when it starts. A store without one (`@stitchapi/cloudflare-kv`, which is eventually consistent and has no atomic read-compute-write to build a cell from) falls back to a shared counter plus a per-worker cursor. That holds the budget at a window boundary and keeps each worker's own pacing exact, but a fleet that starts *mid-window* runs at up to `rate × workers` until the schedule catches up with the clock. Nothing to configure either way — the throttle uses the cell when the store has it. `pool` decides what shares a budget: `'stitch'` (the default) gives each stitch its own counter keyed by name, while `'host'` keys by origin so every stitch hitting `api.example.com` draws from one budget. Workers share a budget only when they point at the same store and resolve to the same key — same pool, same stitch name or host. The same shared store also backs auth sessions, so one store gives you both a fleet-wide rate budget and a shared session — see [Shared sessions](/docs/guides/state/shared-sessions). ## See also [#see-also] # The pluggable store (/docs/guides/state/pluggable-store) Swap the in-memory store for a shared one when you want throttle counters and auth sessions to live outside a single process — back them with Redis, Postgres, or anything else by implementing three async methods and attaching it via `store`. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import type { StitchStore } from 'stitchapi'; // A StitchStore is just three async methods + TTL — back it with anything. const sharedStore: StitchStore = { async get(key) { /* read from your shared backend */ return undefined; }, async set(key, value, ttl) { /* write, honoring ttl */ }, async increment(key, ttl) { /* atomic counter with a TTL window */ return 1; }, }; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', store: sharedStore, }); ``` Don't hand-roll one unless you need to — **`@stitchapi/redis`** is the first-party store: `redisStore(fromIoredis(client))` (or `fromNodeRedis`), proven against the `conformance.store` kit. ## Options [#options] A store is the `StitchStore` contract — three async methods: `get(key)` returns the value or `undefined`, `set(key, value, ttl?)` writes with an optional expiry, and `increment(key, ttl)` is an atomic counter scoped to a TTL window. The runtime holds two kinds of state behind this interface: throttle counters (the even-spaced rate counter) and auth session/token state (captured cookies and refreshed tokens). The default is `memoryStore()` — single process, in-memory. It works until you run more than one worker, where each process keeps its own counters and its own sessions. Point `store` at one shared backend and that splits apart: the rate counter becomes a single window every process increments, so the limit holds across the fleet — see [Distributed throttle](/docs/guides/state/distributed-throttle) — and a captured session is written once and replayed everywhere, so workers don't each re-run the login — see [Shared sessions](/docs/guides/state/shared-sessions). Concurrency limits stay in-process even with a shared store; only the rate counter is distributed. See [throttle](/docs/guides/resilience/throttle). ## See also [#see-also] # Shared sessions (/docs/guides/state/shared-sessions) Give two stitches the same auth `key` plus a shared `store` and they share one login: the session is captured once, replayed by every stitch that names the key, and — because the store backs it — it survives restarts and is shared across workers, not just within a single process. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import type { StitchStore } from 'stitchapi'; import { cookieSession, env } from 'stitchapi/auth'; // A Redis-backed StitchStore — see The pluggable store. declare const sharedStore: StitchStore; // The login is itself a stitch — its Set-Cookie response seeds the session. const login = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/login', wire: { body: 'form' }, }); const me = stitch({ baseUrl: 'https://api.example.com', path: '/me', store: sharedStore, auth: cookieSession({ login, cookie: '*', key: 'acme-session', // same key + same store = one shared session credentialsOf: () => ({ body: { user: env('USER')(), pass: env('PASS')() }, }), }), }); ``` Any other stitch that points its `store` at `sharedStore` and passes `key: 'acme-session'` reuses the same captured session — it never re-runs the login. ## Options [#options] `key` is the store namespace the session is filed under. Two stitches that share a `key` read and write the same session entry; without it each stitch namespaces by its own cookie name (or token URL) and stays isolated. `store` is the backing store the session lives in. The default is in-memory and single-process; pointing `store` at a shared, durable store (Redis/Postgres) is what makes one login persist across restarts and serve every worker — see [The pluggable store](/docs/guides/state/pluggable-store). Within one process, [`.with()`](/docs/guides/authoring/with) already reuses the same in-memory session across calls. **Anti-pattern:** don't rely on the default in-memory store to share a session across workers — it's process-local, so every worker re-runs the login and keeps its own isolated session; point `store` at a shared, durable store so one captured login serves all of them. See [The pluggable store](/docs/guides/state/pluggable-store). This works the same for tokens: [`oauth2`](/docs/guides/auth/oauth2) caches its access token under `key`, so a shared key plus a shared store means one token serves many stitches and workers and survives restarts, refreshed once for all of them. Sharing a session shares the capability, not the credential: callers still only invoke the stitch. The boundary holds — the credential stays inside the stitch and is never handed to the caller. ## See also [#see-also] # Testing (/docs/guides/testing/mocking) There are two things to test, and a tool for each. To test a **stitch definition** — does it route, retry, validate, time out the way you declared? — inject a mock `Adapter` and drive the real engine. To test **code that calls a stitch** — a service, a handler, a loader — replace the stitch with a fake `Stitch` so no network is involved at all. Both tools ship from the `stitchapi/testing` subpath. It is framework-agnostic and browser-safe — no `vitest`/`jest` imports, no `node:*` modules — so it runs in any test runner. This guide covers the **mocking kit** for application authors. The `verify*Contract` functions in the same entry — for proving a custom adapter, store, or sink implements its seam — are a separate reference: [Conformance kits](/docs/reference/conformance-kits). ## Testing a stitch definition — `mockAdapter` [#testing-a-stitch-definition--mockadapter] The transport is a [seam](/docs/guides/transport/adapters): a stitch sends through its `adapter`, defaulting to `fetch`. Pass a mock adapter instead and the rest of the engine runs for real — URL building, retry, timeout, validation, the event stream. Injecting an adapter beats monkeypatching global `fetch`: it is scoped to the stitch under test, typed, and comes with a request spy. `mockAdapter(routes, opts?)` takes one route or a list. The first route whose `method` + `match` accept a request answers it. ```ts twoslash import { stitch } from 'stitchapi'; import { mockAdapter } from 'stitchapi/testing'; import { z } from 'zod'; const api = mockAdapter([ { method: 'GET', match: '/users/42', respond: { body: { id: 42, name: 'Ada' } }, }, ]); const getUser = stitch({ baseUrl: 'https://api.test', path: '/users/{id}', output: z.object({ id: z.number(), name: z.string() }), adapter: api, }); const user = await getUser({ params: { id: 42 } }); // user: { id: 42, name: 'Ada' } — validated against `output` api.callCount('/users/42'); // 1 api.lastRequest()?.method; // 'GET' ``` ### Matching [#matching] A route's `match` selects which requests it answers: * a **string** matches the URL pathname (or, failing that, any substring of the full URL), * a **`RegExp`** tests the full URL, * a **function** `(req) => boolean` is a predicate. Omit `match` (and `method`) and the route catches every request. ### Responding [#responding] `respond` is one of three shapes: * a single **`MockResponse`** — one fixed reply. Its fields: `status`, `headers`, `body`, `stream`, `delay`, `retryAfterSeconds`. * an **array** of them — one per call, the last entry repeating, which is how you drive a retry sequence, * a **function** `(call) => MockResponse` that sees `call.index` and `call.req` — for pagination. `body` is the already-decoded body: an object is delivered as-is. `delay` is **abortable**, so a stitch `timeout` cancels it just like a real slow endpoint. Cancellation is honoured on **every** route, delay or not: a request whose `signal` is already aborted rejects without being answered, matching what a real transport does (and what [`conformance.adapter`](/docs/reference/conformance-kits) requires of one). Nothing is sent, so such a request is absent from the spy and leaves a response sequence at its current position. A status sequence drives [retry](/docs/guides/resilience/retry), and the call spy proves how many attempts happened: ```ts twoslash import { stitch } from 'stitchapi'; import { mockAdapter } from 'stitchapi/testing'; const api = mockAdapter({ match: '/flaky', respond: [{ status: 503 }, { status: 503 }, { body: { ok: true } }], }); const call = stitch({ baseUrl: 'https://api.test', path: '/flaky', adapter: api, retry: { attempts: 3, on: [503], backoff: { base: 1 } }, }); await call(); // { ok: true } — the third reply api.callCount(); // 3 ``` A function responder sees a per-call `index`, which is all you need to test [pagination](/docs/guides/data/pagination): ```ts twoslash import { stitch } from 'stitchapi'; import { mockAdapter } from 'stitchapi/testing'; const api = mockAdapter({ match: '/items', respond: ({ index }) => ({ body: { page: index } }), }); const list = stitch({ baseUrl: 'https://api.test', path: '/items', adapter: api, }); await list(); // { page: 0 } await list(); // { page: 1 } ``` ### The request spy [#the-request-spy] Every mock adapter records what it received: * **`.calls(filter?)`** — the requests, optionally filtered by the same string/regex/predicate as `match`. * **`.callCount(filter?)`** — how many; assert retry and cache behaviour with it. * **`.lastRequest()`** — the most recent request, or `undefined`. * **`.reset()`** — forget everything and reset each route's call counter. ### Unmatched requests [#unmatched-requests] By default an unmatched request fails the test loudly — `opts.onUnmatched` is `'throw'`. Pass a status number to reply with that bare status instead: ```ts twoslash import { stitch } from 'stitchapi'; import { mockAdapter } from 'stitchapi/testing'; const api = mockAdapter( { match: '/known', respond: { body: {} } }, { onUnmatched: 404 }, ); const call = stitch({ url: 'https://api.test/missing', adapter: api }); const res = await call.safe(); res.ok; // false res.error?.status; // 404 ``` ## Testing code that calls a stitch — `stubStitch` / `failStitch` [#testing-code-that-calls-a-stitch--stubstitch--failstitch] When the unit under test is a function that *takes* a stitch and you do not care about the transport, hand it a fake. `stubStitch` and `failStitch` return a conformant `Stitch`: callable, with `.safe`/`.unwrap`/`.stream`/`.with`, passing `isStitch`, plus a call spy — but with no network behind it. `stubStitch(value | (input) => value, opts?)` resolves to the value (or a function of the call input). The spy exposes `.calls(filter?)`, `.callCount(filter?)`, and `.reset()`: ```ts twoslash import { type Stitch, isStitch } from 'stitchapi'; import { stubStitch } from 'stitchapi/testing'; // The code under test depends on a stitch, not on the network. async function loadProfile(getUser: Stitch) { return getUser({ params: { id: 42 } }); } const getUser = stubStitch({ id: 42, name: 'Ada' }); isStitch(getUser); // true — it's a real Stitch shape await loadProfile(getUser); getUser.callCount(); // 1 getUser.calls()[0]; // { params: { id: 42 } } ``` `failStitch(error, opts?)` is the failure twin. Pass a message, an `{ status, message }` shape, or a ready `StitchError`. `await`/`.unwrap()` reject with a `StitchError`; `.safe()` resolves to `{ ok: false }`: ```ts twoslash import { StitchError } from 'stitchapi'; import { failStitch } from 'stitchapi/testing'; const broken = failStitch({ status: 500, message: 'boom' }); // await rejects with a StitchError… try { await broken(); } catch (e) { if (e instanceof StitchError) { e.message; // 'boom' e.status; // 500 } } // …and .safe() never throws. const res = await broken.safe(); res.ok; // false res.error?.status; // 500 ``` With NestJS these pair with `overrideProvider(token).useValue(...)`: stub the stitch provider in a test module and the service under test never knows. See [Integrations → NestJS](/docs/integrations/nestjs). ## Streaming bodies and `collectStitchEvents` [#streaming-bodies-and-collectstitchevents] To test the [`stream`](/docs/reference/surfaces) and [`sse`](/docs/reference/surfaces) surfaces you need a live response body. The stream builders produce one, and `collectStitchEvents` drains the result so you can assert on it. * **`streamOf(chunks)`** — a `ReadableStream` that emits each chunk as its own read, then closes. * **`sseStream(events)`** — a `text/event-stream` body, one well-formed frame per event (a bare string is shorthand for `{ data }`). * **`streamThenError(chunks, err?)`** — emits the chunks, then errors instead of closing — for a mid-stream transport failure. * **`gatedStream(first, gate)`** — emits `first`, then stays open until `gate` resolves — the long-lived shape a concurrency / long-poll test needs. * **`streamAdapter(body, init?)`** — a minimal adapter that returns `body` as the live response body; for routing and a spy, use `mockAdapter` (it accepts a `stream` response too). `collectStitchEvents(source)` drains a `StitchResult` (or a `.stream()` generator) into `{ types, deltas, drifts, result, error, done, events }` — the canonical way to assert on a stream: ```ts twoslash import { stream } from 'stitchapi/stream'; import { collectStitchEvents, mockAdapter, streamOf } from 'stitchapi/testing'; const api = mockAdapter({ match: '/s', respond: { stream: streamOf(['ab', 'cd']) }, }); const logs = stream({ url: 'https://api.test/s', adapter: api }); const ev = await collectStitchEvents(logs.stream()); ev.types.includes('delta'); // true ev.deltas.length; // 2 ev.done?.ok; // true ``` A `streamThenError` body surfaces as an `error` event with the deltas seen so far preserved: ```ts twoslash import { stream } from 'stitchapi/stream'; import { collectStitchEvents, mockAdapter, streamThenError, } from 'stitchapi/testing'; const api = mockAdapter({ match: '/s', respond: { stream: streamThenError(['ab']) }, }); const logs = stream({ url: 'https://api.test/s', adapter: api }); const ev = await collectStitchEvents(logs.stream()); ev.deltas.length; // 1 — the chunk before the failure ev.error; // { message, status } ev.done?.ok; // false ``` `collectStitchEvents` also drains a plain call (the function surface synthesizes a `start → result → done` spine), so it works against a stub too — handy for asserting that your code consumed the stream it was given. ## Controlling time — `manualClock` [#controlling-time--manualclock] Retry backoff, throttle pacing, the per-attempt timeout and OAuth2 token expiry are time-driven, so testing them used to mean real waiting. Inject a `manualClock()` as `clock` and drive that time by hand with `advance(ms)` — no real delay, no fake-timer library: ```ts twoslash import { stitch } from 'stitchapi'; import { manualClock, mockAdapter } from 'stitchapi/testing'; const clock = manualClock(); const api = mockAdapter({ match: '/flaky', respond: [{ status: 503 }, { body: { ok: true } }], }); const call = stitch({ baseUrl: 'https://api.test', path: '/flaky', adapter: api, retry: { attempts: 2, on: [503], backoff: { base: 10_000 } }, clock, }); const p = call.safe(); await clock.advance(0); // run the first attempt (503) await clock.advance(10_000); // fire the backoff → the retry succeeds await p; // { ok: true } — with zero real waiting ``` `advance(ms)` fires every backoff/pacing/timeout due at or before the new time, in order, and `pending()` reports the timers still waiting. The default is the real `systemClock` (exported from the main entry), so this changes nothing until you inject a clock; you can also supply your own object implementing `Clock`. ### Token expiry [#token-expiry] `oauth2` decides token freshness on the same clock, so "does my client refresh before the token expires?" is a test you can write — `advance` past `expires_in` minus `refresh.skew` and assert the second fetch: ```ts twoslash import { stitch } from 'stitchapi'; import { oauth2 } from 'stitchapi/auth'; import { manualClock, mockAdapter } from 'stitchapi/testing'; const clock = manualClock(); const api = mockAdapter([ { method: 'POST', match: '/token', respond: ({ index }) => ({ body: { access_token: `T${index + 1}`, expires_in: 60 }, }), }, { method: 'GET', match: '/data', respond: { body: { ok: true } } }, ]); const call = stitch({ baseUrl: 'https://api.test', path: '/data', adapter: api, clock, auth: oauth2({ tokenUrl: 'https://api.test/token', client: { id: 'cid', secret: 'csecret' }, adapter: api, }), }); await call(); api.callCount('/token'); // 1 await clock.advance(600_000); // ten virtual minutes, past a 60s token await call(); api.callCount('/token'); // 2 — refreshed, with zero real waiting ``` ### What the clock drives, and what it does not [#what-the-clock-drives-and-what-it-does-not] The clock owns **control-flow** time — the waiting and the deciding — not bookkeeping. That boundary is deliberate, and knowing where it falls is the difference between a test that asserts something and one that only looks like it does — a distinction [the mock that passed for six months](/docs/scenarios/stale-fixture) works through, along with the drift a fixture can never show you because it never changes: | Feature | Under `manualClock` | | ---------------------- | --------------------------------------------------- | | `retry` backoff | **virtual** — `advance` fires each backoff | | `retry.respect` delays | **virtual** — a parsed `Retry-After` is a wait | | `throttle` rate | **virtual** — spacing is exact, `waited` reports it | | `throttle` concurrency | **virtual** — a holder releases on virtual time | | `circuit.cooldown` | **virtual** — `advance` past it opens the probe | | `timeout.each` | **virtual** — the per-attempt clamp fires on it | | OAuth2 token expiry | **virtual** — `expires_in` minus `refresh.skew` | | AWS SigV4 signing date | **virtual** — `x-amz-date` (`@stitchapi/aws-sigv4`) | | `timeout.total` | wall-clock — the budget deadline is wall-anchored | | `cache.ttl` | wall-clock — cache expiry is a store concern | | `memoryStore` TTL | wall-clock — same | | Event `at` / `done.ms` | wall-clock — timestamps are cosmetic | | `paginate` | no time to drive | The four core wall-clock rows are **decisions, not gaps** — ADR 0010 scoped the clock to control flow on purpose. `timeout.total`'s budget is anchored to a wall-clock deadline, so virtual sleeps never drain it and it does **not** stop a virtual-time retry loop: assert a total budget against the real clock, or assert attempt counts instead. Store and cache TTLs are a separate concern (expiry, not scheduling), and threading the clock through \~30 event builders to move a cosmetic timestamp would buy no determinism. `manualClock()` starts at epoch `0`. A `Retry-After` given as an **HTTP-date** is honoured relative to the clock, so a fixture carrying a real date resolves to a wait of roughly twenty thousand days — which presents as a hang, not a failure. Use `retryAfterSeconds` with a **number** in fixtures, or seed the clock with a realistic epoch: `manualClock(Date.now())`. The same seeding applies to SigV4 — an epoch-`0` clock stamps `x-amz-date` as `19700101T000000Z`, which a real AWS endpoint answers with `RequestTimeTooSkewed`. ## See also [#see-also] # The adapter seam (/docs/guides/transport/adapters) Set `adapter` when the default `fetch` can't carry a call the way you need it carried — to stream a response, to draw an upload bar, or to ride an HTTP client your platform already configured — and the stitch's types, validation, retry, and auth keep running on top unchanged. The adapter is the layer a stitch hands the round trip to: take a request, return a response. **A swapped transport, not a rewritten call** — it changes how the bytes move, never what the stitch promises its caller. ## Example [#example] ```ts twoslash import { stitch, xhrAdapter } from 'stitchapi'; // No `adapter` field — the stitch already runs on fetchAdapter(). const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', }); // One field moves the same declaration onto XMLHttpRequest, for the bytes-sent // progress `fetch` cannot report. const createUser = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/users', adapter: xhrAdapter(), }); ``` ## Options [#options] `adapter` takes an `Adapter`: one function, `(req) => Promise`. Leave the field off and the engine wires `fetchAdapter()`, so a stitch runs on `fetch` without ever naming it. Three transports ship in core, and a fourth in `stitchapi/testing`: * [`fetchAdapter`](/docs/guides/transport/fetch-adapter) — the default. The only built-in that streams, and the one that takes an undici dispatcher for a proxy or a custom CA. * [`axiosAdapter`](/docs/guides/transport/axios-adapter) — routes through an axios instance you supply, keeping its agent, proxy, and interceptors. * [`xhrAdapter`](/docs/guides/transport/xhr-adapter) — `XMLHttpRequest` in the browser, for upload progress. * [`mockAdapter`](/docs/guides/testing/mocking) — canned responses, so a test drives the real engine with no network. One rule separates a transport from the engine above it: **an adapter never throws on a non-2xx status.** A 500 comes back as `{ status: 500, ... }`; only a network failure or an abort rejects. The stitch decides what a status *means* — whether 503 retries, whether 404 is an error or an empty result — so policy stays in the declaration and the transport stays a pipe. [Writing an adapter](/docs/guides/transport/custom-adapter) covers the rest of the contract. Every built-in shares one body-encoding and one response-decoding helper, so json, form, and multipart bodies go out identically and the same content-type detection reads the response back. Swapping the transport changes what the transport *can do*, not how a request is sent or a response is parsed. ### What a transport can't do [#what-a-transport-cant-do] An adapter declares the optional features it `supports` — `'stream'`, `'uploadProgress'`, `'downloadProgress'` — so the engine answers for it instead of going quiet. Point a buffered-only transport at a `stream` or `sse` surface and it throws at the call. Ask one that can't report bytes sent for upload progress and an `info` event comes through your trace sink: ``` adapter.upload-progress-unsupported — onProgress is set with a request body, but fetchAdapter cannot report upload progress — only 'direction: download' events fire. Use xhrAdapter() to draw an upload progress bar. ``` **Anti-pattern:** don't wire `onProgress` to an upload bar and leave the call on the default `fetch` — it reports the download phase only, so the bar sits at zero until the response lands. Put the uploading stitch on `adapter: xhrAdapter()` or `axiosAdapter(client)`, which read `xhr.upload` and axios's `onUploadProgress` respectively. Declaring capabilities is opt-in: an adapter that declares nothing is treated as unknown, and no checks run against it. See [Reference → Helpers](/docs/reference/helpers#adapter-capabilities) for the descriptor shape and the per-adapter support matrix. ## Which transport [#which-transport] | You need | Reach for | Because | | ---------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------- | | To start | `fetchAdapter()` — implicit | It's the default; there's no field to write. | | A `stream` or `sse` surface | `fetchAdapter()` | The only built-in that hands back a live `ReadableStream`. | | An upload progress bar | `xhrAdapter()` | `xhr.upload` reports bytes sent; `fetch` leaves them silent. | | A proxy, a custom CA, a bound interface | `fetchAdapter({ dispatcher })` | Threads an undici `Agent` through one stitch. | | An axios instance your platform already trusts | `axiosAdapter(client)` | Keeps its agent, proxy, and interceptor stack intact. | | Streaming in bare React Native | `rnStreamAdapter()` | RN's `fetch` leaves `response.body` undefined. From [`@stitchapi/react-native`](/docs/integrations/react-native). | | Canned responses in a test | `mockAdapter([...])` | The real engine runs; only the network goes away. From `stitchapi/testing`. | | A transport core doesn't ship | a function of your own | The contract *is* the extension point. | ## See also [#see-also] # axiosAdapter (/docs/guides/transport/axios-adapter) Reach for `axiosAdapter(client, defaults?)` when an axios instance is already configured the way your platform demands — an `httpsAgent`, a proxy, an interceptor stack a team depends on — and you want a stitch's types, validation, retry, and auth on top of it without re-creating that setup. ## Example [#example] ```ts twoslash // @noErrors import axios from 'axios'; import { axiosAdapter, stitch } from 'stitchapi'; const client = axios.create({ proxy: false, timeout: 10_000 }); const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', adapter: axiosAdapter(client), }); ``` ## Options [#options] You pass the client; StitchAPI never imports axios. The runtime stays zero-dependency and the instance stays yours — the adapter borrows it for the round trip. Anything satisfying `AxiosLike` works, which the real `axios` export and any `axios.create()` instance do structurally, so no cast is needed: ```ts twoslash import { type AxiosLike, axiosAdapter, stitch } from 'stitchapi'; // Your real axios instance is structurally an AxiosLike. declare const axios: AxiosLike; const listUsers = stitch({ baseUrl: 'https://api.example.com', path: '/users', pick: 'data', adapter: axiosAdapter(axios), }); ``` The second argument is a defaults object — `{ httpsAgent, proxy, timeout }` and anything else axios accepts — merged under every request and overridden by per-call values. It is the axios equivalent of [`fetchAdapter`'s `dispatcher`](/docs/guides/transport/fetch-adapter): where the fetch transport threads an undici `Agent`, this one hands your agent to axios. Body encoding and response parsing come from the same helpers `fetchAdapter` uses, so json, form, and multipart go out byte-identical and the response decodes the same way. The adapter asks the client for an `arraybuffer` and sets `validateStatus: () => true`, so a non-2xx never throws inside axios — the stitch decides what a status means. ### Buffered-only, but progress still works [#buffered-only-but-progress-still-works] `axiosAdapter` throws on a `stream` or `sse` surface, because axios buffers the whole body before handing it over. That failure is loud at the call rather than a silent hang; an endpoint that needs to stream rides [`fetchAdapter`](/docs/guides/transport/fetch-adapter) instead, and the two can sit side by side in the same codebase. Byte progress does survive: the adapter wires axios's native `onUploadProgress` and `onDownloadProgress` into the stitch's `onProgress`, so both phases report and an upload bar works without leaving the instance you trust. That needs an axios new enough to honor those callbacks (v1+); an older client simply never fires them. **Anti-pattern:** don't reach for `axiosAdapter` on an endpoint you plan to stream — it rejects the call outright. Declare that one stitch with `fetchAdapter()` and leave the rest on axios; the transport is per-stitch, so the two coexist. ### Redirects don't leak credentials [#redirects-dont-leak-credentials] axios follows 3xx hops through follow-redirects, which strips `authorization` and `cookie` cross-origin but forwards custom headers — so an `x-api-key` or an `x-amz-*` set by an auth strategy would reach an unintended host. The adapter installs a `beforeRedirect` hook that applies the same policy `fetchAdapter` enforces: a hop leaving the original origin keeps only the CORS-safelisted headers and drops every credential, while same-origin hops pass through untouched. ## See also [#see-also] # Writing an adapter (/docs/guides/transport/custom-adapter) Write your own `Adapter` when the bytes need to move a way the built-ins don't cover — over a platform HTTP client, through an instrumentation layer, or across a transport that isn't HTTP at all. The contract is one function, so most custom adapters are a thin wrapper rather than a rewrite. ## Example [#example] A timing wrapper around another adapter — it delegates the round trip and adds nothing to the request: ```ts twoslash import { type Adapter, fetchAdapter, stitch } from 'stitchapi'; const timed = (inner: Adapter): Adapter => async (req) => { const started = performance.now(); const res = await inner(req); console.log( `${req.method} ${req.url} → ${res.status}`, performance.now() - started, ); return res; }; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', adapter: timed(fetchAdapter()), }); ``` ## Options [#options] An adapter is `(req: AdapterRequest) => Promise` — a plain function, so nothing needs to be registered or subclassed. Four rules make it a transport the engine can drive: * **Never throw on a non-2xx status.** A 500 resolves as `{ status: 500, headers, body }`. Only a genuine network failure or an abort rejects. The stitch owns what a status means — whether 503 retries, whether 404 is an error or an empty result — so a throwing transport takes that decision away from the declaration. * **Lowercase the response header names.** Readers above you look them up in that case. * **Honor `req.signal`.** Abort the in-flight request when it fires, and reject if it is already aborted. * **Reject `req.stream` if you can't stream.** Failing loudly at the call beats buffering a stream the caller expected to consume incrementally. `req` carries what the transport needs and nothing about policy: `{ url, method, headers, body?, bodyType?, multipart?, array?, response?, stream?, onProgress?, signal? }`. The response is `{ status, headers, body, url? }`, where `body` is the parsed value — or a `ReadableStream` when `stream` was set. See [Reference → Config types](/docs/reference/config-types) for the full shapes. ### Declare what it supports [#declare-what-it-supports] Hang a `capabilities` descriptor off the function and the engine can answer for your transport instead of going quiet — a call asking for upload progress on an adapter that can't report it gets an `info` event rather than a bar that never moves. ```ts twoslash import type { Adapter } from 'stitchapi'; declare const send: Adapter; // ---cut--- const myAdapter: Adapter = send; myAdapter.capabilities = { name: 'myAdapter', supports: ['downloadProgress'], // anything not listed, it can't do }; ``` Declaring is opt-in. An adapter that declares nothing is treated as unknown and no checks run against it, so the plain one-function form stays valid. **Anti-pattern:** don't reach for a custom adapter to add retries, timeouts, auth headers, or logging of the event stream — a stitch already declares those as fields, and burying them in a transport hides them from the declaration, the trace, and every other surface. Write one when the *transport* differs; use [retry](/docs/guides/resilience/retry), [timeout](/docs/guides/resilience/timeout), [auth](/docs/guides/auth/bearer), and [trace sinks](/docs/guides/observability/trace-sinks) for the rest. ### Prove it against the contract [#prove-it-against-the-contract] `conformance.adapter(adapter, baseUrl)` from `stitchapi/testing` runs your transport against a fixture host and checks the rules above: status passthrough for 200/404/500, request delivery, lowercased response headers, text and JSON decoding, and both abort paths. Mount `conformance.fixture` on any server — `node:http`, hono, a service worker — and point the verifier at it. See [Conformance kits](/docs/reference/conformance-kits) for the fixture and the report shape. ## See also [#see-also] # fetchAdapter (/docs/guides/transport/fetch-adapter) `fetchAdapter()` is the transport every stitch gets when you leave `adapter` off — name it explicitly when you need to thread infrastructure through the call, such as a proxy, a corporate CA, or a `fetch` of your own. ## Example [#example] ```ts twoslash import { fetchAdapter, stitch } from 'stitchapi'; // Your real `new Agent(...)` from undici — structurally an unknown dispatcher // here, so nothing imports undici inside StitchAPI or this snippet. declare const agent: unknown; const listOrders = stitch({ baseUrl: 'https://internal.example.com', path: '/users/{id}/orders', adapter: fetchAdapter({ dispatcher: agent }), }); ``` ## Options [#options] `dispatcher` takes an undici `Agent` and passes it straight to Node's `fetch` as the non-standard `dispatcher` init option — a proxy, a custom CA bundle, a bound network interface, a tuned connection pool. StitchAPI never imports undici, so the runtime stays zero-dependency and you bring the `Agent`. It is per-stitch, not global: one stitch can sit behind the proxy while its neighbors go direct. `fetch` overrides the global implementation, for a custom runtime or a test that wants a stand-in without touching `globalThis`. ```ts twoslash import { fetchAdapter, stitch } from 'stitchapi'; declare const fetchImpl: typeof fetch; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', adapter: fetchAdapter({ fetch: fetchImpl }), }); ``` With no options, `fetchAdapter()` behaves exactly as the implicit default does — no `dispatcher` is set and the global `fetch` runs. Both fields are optional and independent. ### The one that streams [#the-one-that-streams] `fetch` is the only built-in transport that hands back a live `ReadableStream`, so the `stream` and `sse` surfaces ride it and nothing else. `axiosAdapter` and `xhrAdapter` buffer the whole body and throw when a stitch asks them to stream. It also reports `direction: 'download'` byte progress while reading a buffered body, which is what drives a download bar. **Anti-pattern:** don't pass `onProgress` for an **upload** bar on this adapter — `fetch` cannot report bytes sent, so only download events fire and the bar sits at zero. The engine emits an `adapter.upload-progress-unsupported` `info` event rather than leaving you to guess; move that stitch to [`xhrAdapter()`](/docs/guides/transport/xhr-adapter). ### Redirects don't leak credentials [#redirects-dont-leak-credentials] Auth strategies put credentials in request headers — `apiKey` sets `x-api-key`, `awsSigV4` sets `authorization` plus `x-amz-*`. The platform's default redirect policy re-sends every header to the target, and undici strips only `authorization` and `cookie` on a cross-origin hop, which would forward a custom auth header to an unintended host. So `fetchAdapter` sends with `redirect: 'manual'` and follows redirects itself, capped at 20 hops. A same-origin hop keeps its headers; a hop that leaves the original origin keeps only the CORS-safelisted subset — `accept`, `accept-language`, `content-language`, `content-type` — and drops the rest. That is the browser's own redirect model, reconstructed on Node where the platform doesn't apply it. In a browser, `redirect: 'manual'` yields an opaque response JS can't follow, so the adapter hands it back and CORS enforces the same guarantee. ## See also [#see-also] # xhrAdapter (/docs/guides/transport/xhr-adapter) Reach for `xhrAdapter()` when a browser upload needs a progress bar: `fetch` cannot report how many bytes have left the machine, and `xhr.upload` can, so this is the transport that makes a 50MB `POST` look like something other than a frozen page. ## Example [#example] ```ts twoslash // [!code fold:start] import { stitch, xhrAdapter } from 'stitchapi'; // [!code fold:end] const uploadVideo = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/videos', wire: { body: 'multipart' }, adapter: xhrAdapter(), }); declare const file: File; await uploadVideo({ body: { file }, // `onProgress` is per-call, not config — a different bar per upload. onProgress: (p) => { if (p.direction === 'upload') { console.log(`${p.loaded} / ${p.total ?? '?'} bytes sent`); } }, }); ``` ## Options [#options] `xhrAdapter(XHR?)` defaults to `globalThis.XMLHttpRequest`, so in a browser you call it with no arguments. Pass a constructor to run it anywhere else — a polyfill in a non-browser runtime, or a fake that records what a test sent. Off a browser with no constructor supplied, the call rejects with a message naming the fix rather than failing on an undefined global. ```ts twoslash import { type XhrLikeCtor, stitch, xhrAdapter } from 'stitchapi'; // A polyfill or a test double — anything matching the XhrLike shape. declare const FakeXHR: XhrLikeCtor; const createUser = stitch({ method: 'POST', baseUrl: 'https://api.example.com', path: '/users', adapter: xhrAdapter(FakeXHR), }); ``` Both progress phases report. `xhr.upload.onprogress` drives `direction: 'upload'`, `xhr.onprogress` drives `direction: 'download'`, and each carries `loaded` plus a `total` when the length is known. Body encoding and response decoding reuse the same helpers `fetchAdapter` does, so a multipart body goes out byte-identical on either transport. **Anti-pattern:** don't reach for `xhrAdapter` on a `stream` or `sse` surface — it is buffered-only and rejects the call. [`fetchAdapter`](/docs/guides/transport/fetch-adapter) is the built-in that streams; the transport is per-stitch, so the uploading stitch can stay on `xhr` while the streaming one runs on `fetch`. An `AbortSignal` passed on the call aborts the request in flight. A signal that is *already* aborted rejects before `send()`, because calling `abort()` ahead of `send()` fires no abort event and the cancelled request would otherwise go out anyway. ### No redirect guard needed here [#no-redirect-guard-needed-here] The fetch and axios transports rebuild a cross-origin credential strip by hand. This one doesn't need it: the browser follows redirects internally and re-applies CORS to the resulting request, so a custom header like `x-api-key` or `x-amz-*` is only sent after the target host opts in through a preflight. JS can't read the `Location` or intercept the hop, so there is nothing left to strip. ## See also [#see-also] # Drift detection (/docs/guides/validation/drift) Wrap a stitch's `output` with `drift` when you want to catch an API quietly changing shape and be notified through the event stream. Drift is **schema-anchored and diff-based**: the `output` schema you already declare *is* the contract, and drift computes the structural difference between the raw body and the validated value to surface what changed. ## Example [#example] ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: drift(z.object({ id: z.number(), email: z.string() })), }); ``` On every call the engine validates the response against the schema, then diffs the raw body against the validated value and emits each difference as a `drift` event on the [event stream](/docs/concepts/event-stream). ## Two tiers: validation and drift [#two-tiers-validation-and-drift] These are distinct layers, not options on a dial. ### Validation — the hard contract [#validation--the-hard-contract] Validation runs first. A **missing required field** or **incompatible value** throws immediately — a `StitchError` reading `contract violation (drift)`, alongside a finding with `change: 'invalid'`, `level: 'error'` (documented as [`STITCH_DRIFT`](/docs/errors/stitch-drift), a docs ID rather than a runtime `.code`). On success the call **returns the validated value** — defaults applied, types coerced, unknown keys stripped — so the result matches the declared TypeScript type exactly. Make a field required in the schema when its absence is a bug. That is all that is needed for a hard failure; no extra configuration is required. ### Drift — the soft, non-fatal layer [#drift--the-soft-non-fatal-layer] When validation succeeds, drift diffs the raw body against the validated value. The delta is the drift: | diff op | meaning | `change` | default level | | -------- | --------------------------------------------- | ------------ | ------------- | | `remove` | a key the schema stripped | `undeclared` | `info` | | `change` | a value the schema coerced (`"42"` → `42`) | `coerced` | `warn` | | `create` | a `.default()` fired because the field absent | `defaulted` | `verbose` | Soft drift findings are **always non-fatal**. To make a change fatal, declare the field required in the schema — it will surface as `invalid` / `error` from validation, not drift. ## Options [#options] `drift(schema, opts?)` accepts an optional `DriftOptions` object with two keys: ### `ignore` [#ignore] ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: drift(z.object({ id: z.number(), email: z.string() }), { // Acknowledge known API surface we intentionally do not consume. ignore: ['meta', '_links'], }), }); ``` `ignore: string | string[]` suppresses soft drift findings whose path matches an entry — a bare string is shorthand for a one-element list (`ignore: 'meta'` ≡ `ignore: ['meta']`). Use it for API fields you know about but have chosen not to model — acknowledged but unconsumed surface. The grammar: nested keys join with `.`, array elements collapse to `[]` (so `items[].meta` suppresses every element's `meta`), and a bare prefix (`meta`) suppresses that key and everything beneath it. A stale entry in `ignore` is harmless — you keep ignoring a field that is still present. ### `level` [#level] `level` controls how soft drift is leveled or filtered. Three shapes: ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; // 1. Single level string — surface only drift at this level (allowlist). const a = stitch({ baseUrl: 'https://api.example.com', path: '/a', output: drift(z.object({ id: z.number() }), { level: 'warn', // only coerced findings surface; undeclared (info) and defaulted (verbose) are dropped }), }); // 2. Array of levels — allowlist of multiple levels. const b = stitch({ baseUrl: 'https://api.example.com', path: '/b', output: drift(z.object({ id: z.number() }), { level: ['info', 'warn'], // drop verbose (defaulted), surface the rest }), }); // 3. Map — re-level a specific kind (all kinds still surface). const c = stitch({ baseUrl: 'https://api.example.com', path: '/c', output: drift(z.object({ id: z.number() }), { level: { coerced: 'info', defaulted: 'info' }, }), }); ``` Omit `level` to surface all soft drift at its default level. ## Variance is not drift [#variance-is-not-drift] Because the schema is the contract, normal response variance never reads as drift: an optional field that is absent, a `string | null` that is null, an empty or heterogeneous array all **satisfy** a well-written schema, so nothing is raised. Declare what may vary, and only genuine change is reported. ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: drift( z.object({ id: z.number(), avatar: z.string().nullable(), // sometimes null — declared, so never drift badges: z.array(z.string()), // may be empty — declared, so never drift nickname: z.string().optional(), // sometimes absent — declared, so never drift }), ), }); ``` The flip side: drift watches the surface you *declare*. What the provider changes in a field you do not model is, by definition, change you do not consume. Real change rarely arrives all at once, either. A vendor rolling a new shape to a slice of traffic produces findings on a fraction of calls and none on the rest — [the vendor changed the shape for 5% of responses](/docs/scenarios/intermittent-drift) measures what a leveled policy catches there, and the one distinction it cannot draw. A field that survives validation can still be wrong before drift ever sees it: [the ID that changed on the way in](/docs/scenarios/precision-loss) covers the 64-bit id that `JSON.parse` rounds off. ## Detecting new fields [#detecting-new-fields] A brand-new field the schema does not declare shows up as an `undeclared` finding (`change: 'undeclared'`, default level `info`) — the schema strips the key, the diff sees the removal, and drift reports it. No `.strict()` is needed: the diff catches stripped keys automatically, and the output stays clean. ```ts twoslash import { drift, stitch } from 'stitchapi'; import { z } from 'zod'; // A new field the API adds will show up as an `undeclared` finding at `info` level // — no `.strict()` required, and the validated output is still clean. const getUser = stitch({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: drift(z.object({ id: z.number(), email: z.string() })), }); ``` To suppress a known-but-not-consumed field, add it to `ignore` rather than expanding the schema — that keeps the typed contract tight. Findings arrive as `drift` events on [the event stream](/docs/concepts/event-stream). Subscribe to inspect `warn`, `info`, and `verbose` levels that never throw. See [Reference → Config types](/docs/reference/config-types) for the full `DriftOptions` and `DriftFinding` tables. ## See also [#see-also] # Standard Schema (/docs/guides/validation/standard-schema) Validate an API response with Zod — or Valibot, ArkType, or any [Standard Schema](https://standardschema.dev) validator. StitchAPI is validator-agnostic: bring the schema you already have, hand it straight to a stitch, and each response is checked against it — with the result type inferred from the same schema. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import { z } from 'zod'; const User = z.object({ id: z.number(), name: z.string() }); const getUser = stitch<{ id: number; name: string }>({ baseUrl: 'https://api.example.com', path: '/users/{id}', output: User, }); ``` The `output` schema runs against each response; pairing it with the `stitch<{ id: number; name: string }>` generic types the validated result. ## Options [#options] A config's `input` and `output` fields accept a raw schema directly — Zod (via `safeParse`), any Standard Schema (`~standard`), or a plain predicate (`(value) => boolean`). Valibot and ArkType implement Standard Schema, so they pass the same way with no special handling. For anything the built-in coercions don't cover, expose the [Standard Schema](https://standardschema.dev) `~standard` interface on your own object — a stitch checks it exactly like a Zod schema, no wrapper in between. Pair the schema with the `stitch()` generic so the typed result and the runtime check come from one source. ## See also [#see-also] # Validation (/docs/guides/validation/validation) Use validation when you want a stitch to reject bad input before it leaves the process and to verify the response once it arrives — input is checked *before* the request, so wrong params, query, body, or headers fail fast with no network call. ## Example [#example] ```ts twoslash import { stitch } from 'stitchapi'; import { z } from 'zod'; const createUser = stitch<{ id: number; name: string }>({ method: 'POST', baseUrl: 'https://api.example.com', path: '/users', input: { body: z.object({ name: z.string() }) }, output: z.object({ id: z.number(), name: z.string() }), }); ``` A bad `body` rejects before the POST is sent; a response that doesn't match `output` rejects after. Either way the stitch throws [STITCH\_VALIDATION](/docs/errors/stitch-validation), and the `stitch` generic keeps the resolved value typed as `{ id: number; name: string }`. ## Options [#options] `input` is a map of `{ params, query, body, headers }`, each an optional `Validator`. Every entry present is validated against the call input *before* the request — the fail-fast guarantee — so the request never goes out with input the API would reject. A declared slot also **shapes** the request: the request is built from the value the schema returned, so whatever it coerces, defaults or strips is what goes on the wire. A `z.object(...)` strips unknown keys by default, so an extra key a caller tacked onto a declared slot never reaches the API — which matters most when the caller is untrusted, such as a model calling through [the MCP surface](/docs/surfaces/mcp). A schema that *transforms* therefore reshapes the request. This is how a stitch maps your field names onto the API's — the call argument is typed from the schema's input side, so callers keep the house shape while the wire gets the API's. See [Reshaping the request](/docs/guides/data/transform#reshaping-the-request). A schema constrains **one slot**. An undeclared slot stays a full passthrough, so declaring `params` does nothing about `query`. And a query pair written into the endpoint (`?tenant=acme`) is a *default* that `input.query` overrides — declare the slot if callers must not move it. `output` is a single `Validator` that checks the response body after it returns. (Pass a `drift()` spec here instead to report changes without throwing — see [Schema drift detection](/docs/guides/validation/drift).) **Anti-pattern:** don't reach for a strict `output` validator to catch upstream API changes — an additive, non-breaking change to the response then throws `STITCH_VALIDATION` and breaks the call. Pass a `drift()` spec to `output` instead, so changes are reported without failing the request. See [Schema drift detection](/docs/guides/validation/drift). `input` and `output` accept the schema directly — a raw `z.object(...)`, any [Standard Schema](/docs/guides/validation/standard-schema) validator (Valibot, ArkType), or a plain predicate. Each slot's type accepts the schema as written, and the engine adapts it internally, so there's no cast at the call site. See [Reference → Config types](/docs/reference/config-types) for every field on `input` and `output`. ## See also [#see-also]