# 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="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
{{ 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 (`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
{{ c }}
```
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]