# skein-js — full documentation > The open-source alternative to LangGraph Platform for TypeScript: a self-hosted Agent Protocol > server for LangGraph.js, and a drop-in replacement for the LangGraph CLI. > This file concatenates the user-facing docs for wholesale ingestion. It is GENERATED by > scripts/generate-llms-full.mjs — run `pnpm docs:llms` to regenerate. The curated index is llms.txt. # skein-js [![npm](https://img.shields.io/npm/v/skein-js?logo=npm&color=cb3837&label=skein-js)](https://www.npmjs.com/package/skein-js) [![downloads](https://img.shields.io/npm/dm/skein-js?color=blue)](https://www.npmjs.com/package/skein-js) [![license](https://img.shields.io/npm/l/skein-js?color=green)](./LICENSE) [![CI](https://github.com/skein-js/skein-js/actions/workflows/ci.yml/badge.svg)](https://github.com/skein-js/skein-js/actions/workflows/ci.yml) [![docs](https://img.shields.io/badge/docs-skein--js.github.io-3b82f6)](https://skein-js.github.io/skein-js/) **The open-source LangGraph Platform alternative, for TypeScript.** _(LangGraph Platform is now LangSmith Deployment.)_ Self-host your [LangGraph.js](https://docs.langchain.com/oss/javascript/langgraph/overview) agents with threads, streaming, long-term memory, human-in-the-loop, background work, and scheduling already built in. Run them on **your infrastructure** and your Postgres — Cloud Run, Railway, Fly.io, Render, AWS, Kubernetes, or a plain VPS. Your agents, your data, **no license key, no per-run bill**. **Works with:** [LangGraph.js](https://docs.langchain.com/oss/javascript/langgraph/overview) · LangGraph SDK · [Agent Chat UI](https://github.com/langchain-ai/agent-chat-ui) · LangGraph Studio **Fits your stack:** Express · Fastify · NestJS · Next.js · Bun · Deno · React · Vue · Svelte · Angular **Turn LangGraph graphs into real-world workflows.** LangGraph owns the orchestration; it doesn't own provider integrations. Skein [channels](./docs/channels.md) connect authenticated sources such as WhatsApp, email, Slack, GitHub, or your own webhooks to the graph, then deliver its outcome reliably to the same provider or a graph-selected destination. See how the whole workflow behaves with [PostHog](./docs/observability.md#posthog), LangSmith, or OpenTelemetry. That means practical processes such as a WhatsApp support request that looks up an order and replies, an emailed refund that gathers approvals over WhatsApp before notifying the customer, or a failed GitHub deployment that evaluates severity before alerting the on-call team in Slack. **Already using the LangGraph CLI?** Change one word: `langgraph dev` → `skein dev`. Your `langgraph.json`, graphs, and clients stay unchanged. You also get a self-hosted **[console](./docs/console.md)** for threads, live runs, approvals, time travel, memory, and schedules. Think of it as [**aegra**](https://github.com/aegra/aegra) for the TypeScript ecosystem. > **skein** _(noun, /skeɪn/ — "skayn", rhymes with "rain")_ — a coiled length of thread. The Agent > Protocol's first-class **threads**, and the strands of a graph. ## Quick start > 🚧 **Status: pre-alpha, but end-to-end.** Development and self-hosted production work today, with > Fetch, Express, Fastify, NestJS, and Next.js adapters. See the [roadmap](./docs/roadmap.md). ```bash npm create skein-js@latest my-agent cd my-agent npm run dev ``` That's it — an agent server on , with the console at . No API key, no database, no Docker: the graph it scaffolds runs on nothing but Node. Add a model when you want one (`--provider anthropic|openai|google`). [**Your first agent**](./docs/your-first-agent.md) walks from here to deployed and teaches the LangGraph you need along the way. [Scaffolding reference](./docs/scaffolding.md) covers every flag. ### Or wire it up by hand There is no magic to undo later — a skein-js project is just a **graph**, a **`langgraph.json`**, and the **`skein` CLI**. Nothing in your graph code is skein-specific. **1. Install the CLI** into your project: ```bash pnpm add -D skein-js # or: npm i -D skein-js · yarn add -D skein-js ``` **2. Write a plain LangGraph.js graph** and export it — e.g. `src/graph.ts`: ```ts import { AIMessage } from "@langchain/core/messages"; import { MessagesAnnotation, StateGraph } from "@langchain/langgraph"; export const graph = new StateGraph(MessagesAnnotation) .addNode("echo", (state) => ({ messages: [new AIMessage(`echo: ${state.messages.at(-1)?.content}`)], })) .addEdge("__start__", "echo") .addEdge("echo", "__end__") .compile(); ``` **3. Point a `langgraph.json` at it** — the same format the LangGraph CLI uses: ```json { "node_version": "24", "graphs": { "agent": "./src/graph.ts:graph" }, "env": ".env" } ``` **4. Start the server** — no Docker, TypeScript loaded directly, hot reload, state persisted across restarts: ```bash pnpm skein dev # → http://127.0.0.1:2024 (drop-in for `langgraph dev`) ``` **5. Talk to it** with the official SDK (or point Agent Chat UI / Studio at the same URL): ```ts import { Client } from "@langchain/langgraph-sdk"; const client = new Client({ apiUrl: "http://127.0.0.1:2024" }); const thread = await client.threads.create(); const answer = await client.runs.wait(thread.thread_id, "agent", { input: { messages: [{ role: "user", content: "hello" }] }, }); console.log(answer); ``` …or with plain `curl`: ```bash TID=$(curl -s -X POST http://127.0.0.1:2024/threads -H 'content-type: application/json' -d '{}' \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["thread_id"])') curl -s -X POST "http://127.0.0.1:2024/threads/$TID/runs/wait" \ -H 'content-type: application/json' \ -d "{\"assistant_id\":\"agent\",\"input\":{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}}" ``` Edit `src/graph.ts` and save — the server hot-reloads while keeping your threads. `Ctrl-C` and restart — state is restored from `.skein/`. Already have a LangGraph project? Just change your `"dev": "langgraph dev"` script to `"dev": "skein dev"` and run it — see [`examples/migrated-langgraph`](./examples/migrated-langgraph). ## Contents - [Quick start](#quick-start) - [What you get](#what-you-get) - [The console](#the-console) - [Building rich agent UIs](#building-rich-agent-uis) - [Using the CLI](#using-the-cli) - [Deploy anywhere](#deploy-anywhere) - [Embedding skein-js in your own server](#embedding-skein-js-in-your-own-server) - [Why skein-js?](#why-skein-js) - [Under the hood](#under-the-hood) - [Packages](#packages) - [Examples](#examples) - [Tested end-to-end](#tested-end-to-end) - [Documentation](#documentation) - [Sponsorship](#sponsorship) - [Contributing & feedback](#contributing--feedback) - [License](#license) ## What you get Your graph is the product. skein-js handles the production plumbing around it: | The concept | What skein-js gives you | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Threads** | Conversations that persist, addressable by your own key — a ticket id, a phone number. [→](./docs/threads.md) | | **Runs** | Wait for it, stream it, or queue it in the background — plus what happens when a second message lands mid-run. [→](./docs/runs.md) | | **Streaming** | SSE with reconnect and replay, a real `streamEvents` mode, fan-out across instances. [→](./docs/streaming.md) | | **Human-in-the-loop** | `interrupt()` parks a run on a checkpoint holding no connection. Resume hours later, from any client. [→](./docs/human-in-the-loop.md) | | **Time travel** | Fork from any past checkpoint and run forward — the machinery behind "edit and resubmit". [→](./docs/threads.md) | | **Long-term memory** | A store reachable via `getStore()` inside a node, with semantic search and TTL. [→](./docs/memory.md) | | **Assistants** | The same graph configured per tenant or per experiment, versioned, with one-call rollback. [→](./docs/assistants.md) | | **Scheduled work** | Crons that fire exactly once across instances, no leader election. [→](./docs/crons.md) | | **Background jobs** | Queue a run, get an id back, hear the result on a signed webhook. [→](./docs/background-jobs.md) | | **Durable execution** | Postgres state, a Redis queue, crash recovery, and callbacks committed with the run. [→](./docs/webhooks.md) | | **Workflows & channels** | Bring authenticated provider events into LangGraph, then deliver workflow outcomes to the source or an allowlisted destination. [→](./docs/channels.md) | | **Observability** | LangSmith, PostHog and OpenTelemetry sinks — or your own. [→](./docs/observability.md) | Can skein do X? [**The features page**](./docs/features.md) answers it in one line per capability, including what _isn't_ built. ## The console `skein dev` serves a full web UI at `/console` — the thing you'd otherwise reach for LangGraph Studio to get, except it's **served by your own server**. No account, no internet connection, no CORS to configure, no Cloudflare tunnel. It inherits your `auth` rather than needing a bypass. ![The skein console: threads waiting for a human, counts, and recent activity](./docs/public/images/console/overview-light.png) **"Waiting for you"** is what makes human-in-the-loop real: every thread parked on an `interrupt()`, one click from approving, rejecting, or resuming it with any JSON. There's also a playground that renders your graph's shape and streams a run into it, an assistants view with schemas and version history, live run tails you can cancel or roll back, a store browser with semantic search, and cron management. It is **off by default in production** — opt in with `{"http": {"console": true}}`, because it can read and delete everything. [More about the console →](./docs/console.md) ## Building rich agent UIs A modern agent UI shows more than a final answer — it streams reasoning, renders tool results as cards, and pauses for your approval. skein-js speaks the Agent Protocol over SSE, so the standard LangChain client tooling gives you all of this with only a URL change. The flagship [`chat-app`](./examples/chat-app) example is the full reference; here are the building blocks. **Stream a conversation with `useStream`** — tokens arrive incrementally: ```tsx import { useStream } from "@langchain/langgraph-sdk/react"; const thread = useStream({ apiUrl: "http://localhost:2024", assistantId: "agent" }); // send a message; `thread.messages` updates live as tokens stream in thread.submit({ messages: [{ type: "human", content: input }] }); ``` **Stream model _thinking_** — when your model emits reasoning (e.g. Gemini's `includeThoughts`), it arrives as `thinking` content blocks you can render in a collapsible panel, separate from the answer. See [`docs/streaming.md`](./docs/streaming.md). **Render tool results as cards** — have a tool return structured JSON, then render it as a weather / flight / booking card instead of raw text. The [`chat-app`](./examples/chat-app) example dispatches tool output to rich React components. **Human-in-the-loop** — a graph node calls LangGraph's `interrupt()` to pause a run; the interrupt surfaces on the client, and you resume with a `command`: ```tsx // a pending interrupt (e.g. "approve this flight booking?") is exposed here: if (thread.interrupt) { // ...render an approval card, then resume the paused run: thread.submit(undefined, { command: { resume: { approved: true } } }); } ``` skein-js injects a checkpointer automatically, so interrupt/resume works in `skein dev` with no setup. See [`docs/react-sdk.md`](./docs/react-sdk.md). **Long-term memory across threads** — inside a graph node, `getStore()` gives you a namespaced, persistent store that outlives a single conversation. skein-js injects it as a LangGraph `BaseStore` — in-memory in dev, Postgres with **pgvector semantic search** in production: ```ts import { getStore } from "@langchain/langgraph"; // inside a node or tool: const store = getStore(); await store.put(["memories", userId], "fact-1", { text: "prefers window seats" }); const recalled = await store.search(["memories", userId], { query: "seat preference" }); ``` See [`docs/storage.md`](./docs/storage.md). ## Using the CLI Point `skein` at an existing `langgraph.json`; your graph code and config are unchanged. ```bash skein dev # in-process dev server, hot reload, no Docker (port 2024) skein dev --store postgres --queue redis # dev against production-shaped storage (POSTGRES_URI / REDIS_URI) skein up # self-hosted stack via Docker Compose: app + Postgres + Redis skein build -t my-agent # build a deployable Docker image skein build -t my-agent --runtime bun # native Bun image (Deno is also available) skein dockerfile -o Dockerfile # emit a standalone Dockerfile ``` | Command | What it does | LangGraph CLI equivalent | | ------------------ | ------------------------------------------------------------------------- | ------------------------ | | `skein dev` | In-process dev server: vite-loaded TS graphs, hot reload, `.skein/` state | `langgraph dev` | | `skein up` | Production Docker Compose stack (app + Postgres + Redis) | `langgraph up` | | `skein build` | Build a deployable Docker image | `langgraph build` | | `skein dockerfile` | Emit a standalone Dockerfile | `langgraph dockerfile` | Useful `skein dev` flags: `-p, --port` (default 2024), `--host`, `--store memory|postgres`, `--queue memory|redis`, `--concurrency` (background runs at once, default 10 — also `-n, --n-jobs-per-worker`, as in the LangGraph CLI), `--no-persist`, `--no-reload`, `-v, --verbose`. Full mapping and the annotated `langgraph.json`: [`docs/langgraph-cli-compat.md`](./docs/langgraph-cli-compat.md). Production images default to Node 24 LTS. Select Node, Bun, or Deno with `--runtime` and optionally `--runtime-version`, or set `skein.runtime` in `langgraph.json`. Bun and Deno use the native Fetch transport rather than Express compatibility and remain preview targets pending their full clean-image conformance matrices; see [deployment](./docs/deploy.md) and the [profiling guide](./docs/profiling.md). Private production deps? `skein build`/`up` take `-n, --npmrc `, mounting an `.npmrc` as a BuildKit secret so the image can install from a **private/authenticated npm registry** without baking a token into any layer. ## Deploy anywhere **Deploy your LangGraph.js graphs anywhere you can run a container.** `skein build` produces an ordinary Docker image — no control plane, no license key, no per-deployment fee — so the same artifact runs on **Google Cloud Run, Railway, Fly.io, Render, AWS App Runner or ECS Fargate, Kubernetes, or your own VPS**. ```bash skein build -t my-agent # → a deployable Docker image docker run -p 8123:8123 \ -e POSTGRES_URI="postgresql://…" \ -e REDIS_URI="redis://…" my-agent ``` The image binds `$PORT` when a platform injects one (and 8123 when nothing does), runs as a non-root user, serves a `/ok` health probe, and drains in-flight runs on `SIGTERM`. All it needs from you is a Postgres, a Redis, and those two environment variables. | Platform | Guide | | ------------------------------ | ----------------------------------------------------------------------------------- | | Google Cloud Run | [docs/deploy-cloud-run.md](./docs/deploy-cloud-run.md) | | Railway | [docs/deploy-railway.md](./docs/deploy-railway.md) | | Fly.io | [docs/deploy-fly.md](./docs/deploy-fly.md) | | Render | [docs/deploy-render.md](./docs/deploy-render.md) | | AWS (App Runner · ECS Fargate) | [docs/deploy-aws.md](./docs/deploy-aws.md) | | Kubernetes | [docs/deploy-kubernetes.md](./docs/deploy-kubernetes.md) | | VPS / plain Docker | [docs/deploy-vps.md](./docs/deploy-vps.md) | | Vercel & serverless | [docs/deploy-serverless.md](./docs/deploy-serverless.md) — what works, what doesn't | Ports, pool sizing, health probes, `SIGTERM` windows and the multi-instance caveats are the same everywhere and live in one place: **[docs/deploy.md](./docs/deploy.md)**. ## Embedding skein-js in your own server Prefer to run inside your own Node process? There are **two ways in**, both mounting the same Agent Protocol server. **Already have a compiled graph in code (no `langgraph.json`, never used the LangGraph Platform)?** Bring it directly — pass a graph map to `embedInMemoryGraphs` and hand the result to any adapter: ```ts import { createExpressServer } from "@skein-js/express"; import { embedInMemoryGraphs } from "@skein-js/server-kit"; import { graph } from "./my-graph.js"; // your existing `new StateGraph(...).compile()` const server = await createExpressServer({ deps: embedInMemoryGraphs({ agent: graph }) }); await server.listen(2024); ``` `embedInMemoryGraphs` turns a graph map into a `ProtocolDeps` (store, queue, bus, checkpointer) — the `{ deps }` seam **every** adapter accepts, so the same `deps` mounts on Express, Fastify, NestJS, or Next.js unchanged. See [docs/embedding.md](./docs/embedding.md) and [`examples/embed-graph`](./examples/embed-graph). **Have a `langgraph.json`?** Serve it from an Express app — the zero-setup path wires in-memory drivers: ```ts import { createExpressServer } from "@skein-js/express"; const server = await createExpressServer({ config: "./langgraph.json" }); await server.listen(2024); ``` Or mount the Agent Protocol on an existing app and bring your own production drivers through the `deps` seam ([`@skein-js/runtime`](./packages/runtime) assembles them): ```ts import { skeinRouter } from "@skein-js/express"; import { buildRuntime } from "@skein-js/runtime"; const runtime = await buildRuntime({ configPath: "./langgraph.json", store: "postgres", queue: "redis", }); const { router } = await skeinRouter({ deps: runtime.deps, cors: runtime.cors }); app.use(router); ``` ## Why skein-js? Two common alternatives explain why skein-js exists. ### Instead of building the server yourself On its own, a graph is a function you call in-process. Putting it in front of a chat UI or another service means an actual **server** — and everything in [What you get](#what-you-get) is plumbing that has nothing to do with your agent. None of it is your product; all of it is load-bearing. It's a few thousand lines you'd own forever, and every piece has a subtle failure mode you'd find in production rather than in review. ### Instead of the paid platform The **[Agent Protocol](https://github.com/langchain-ai/agent-protocol)** is the open HTTP + SSE standard that describes all of this. Because it's a standard, any client that speaks it — [`@langchain/langgraph-sdk`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/sdk), the [`useStream`](https://reference.langchain.com/javascript/langchain-langgraph-sdk/react/useStream) React hook, [Agent Chat UI](https://github.com/langchain-ai/agent-chat-ui), LangGraph Studio — works with any server that implements it. The **LangGraph CLI** (`langgraph dev`) gives you such a server on your laptop. Taking it to production is where the options narrow: - **LangGraph Platform** (the managed deployment target, now **LangSmith Deployment**) is a **paid product** — self-hosting it in production needs a **commercial Enterprise license** ([pricing & licensing below](#a-note-on-langgraph-platform-pricing)). - The leading _open_, self-hostable alternative, [**aegra**](https://github.com/aegra/aegra), is **Python / FastAPI only**. So a **TypeScript team that wants to truly self-host** — your infra, your data, no license key, no per-run bill — was stuck choosing between the paid platform, a Python sidecar, or hand-rolling an HTTP layer around the graph. **skein-js is that missing piece:** an open-source, TypeScript-native alternative to LangGraph Platform that you host yourself. It serves the same Agent Protocol your existing clients already speak, and the `skein` CLI is a drop-in for the LangGraph CLI — so your `langgraph.json`, graphs, and clients keep working unchanged. ### Core principles - **🔓 Self-hosted, no lock-in.** Your agents, your infrastructure, your data — Apache-2.0, no license key, no control plane to call home to, no per-run bill. - **🔁 Drop-in LangGraph CLI compatibility.** `skein dev` / `up` / `build` mirror the LangGraph CLI, and your `langgraph.json` stays **unchanged**. Migrating off (or comparing against) the LangGraph CLI is a one-word change. If something works under `langgraph dev` but not `skein dev`, that's a bug we want to hear about — [please file it](https://github.com/skein-js/skein-js/issues). - **♻️ Reuse first.** On JavaScript the Agent Protocol server internals are already open source ([`@langchain/langgraph-api`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/langgraph-api), MIT), so skein-js doesn't rebuild them. It reuses the LangGraph runtime, checkpointers, `langgraph.json` parser, schemas, and SDK/types, and adds only the durable-production, multi-framework, and drop-in-CLI layer that OSS lacks. See [docs/reuse.md](./docs/reuse.md). - **✨ Rich agent UX out of the box.** Streaming tokens and model **thinking**, structured **tool-result cards**, **human-in-the-loop** interrupt/resume, and cross-thread **long-term memory** — everything you need to communicate effectively with an agent, not just get a final string. See [Building rich agent UIs](#building-rich-agent-uis). | | LangGraph Platform | aegra | **skein-js** | | ------------------------------- | --------------------------------------- | ---------------- | ---------------------------------------- | | Self-hosted in production | 💲 Enterprise license only | ✅ free | ✅ free | | Server runtime license | Elastic License 2.0 (source-available) | Apache-2.0 | **Apache-2.0** | | Cost | $39/seat/mo + usage; self-host = custom | free | **free** | | Language | — | Python / FastAPI | **TypeScript / Node** | | HTTP framework | — | FastAPI | **Express · Fastify · NestJS · Next.js** | | Agent Protocol | ✅ | ✅ | ✅ | | Drop-in for the LangGraph _CLI_ | — | partial | **✅ (`skein dev` / `up` / `build`)** | | Cron / scheduled runs | ✅ | ✅ | **✅ ([crons](./docs/crons.md))** | ### A note on LangGraph Platform pricing You _can_ self-host **LangGraph Platform** — but production self-hosting is an **Enterprise add-on that requires a commercial license key** (contact sales), because the platform's server runtime is source-available under the [Elastic License 2.0](https://www.elastic.co/licensing/elastic-license), not open source. The managed **Plus** plan is **$39 / seat / month**, includes one small serverless deployment, and meters additional deployment compute and storage through usage-based LCU/LSU rates. Fully self-hosted and hybrid deployment are **Enterprise-only** with custom pricing. If you're a **hobbyist or just getting started**, that model isn't ideal — you shouldn't need a commercial license or a per-run bill to ship a side project. And if the LangGraph Platform license _does_ make sense for you later (bigger team, SLAs, managed ops), that's fine too: skein-js is built to make moving **either direction** painless. Because it's a drop-in for the LangGraph CLI on an **unchanged `langgraph.json`**, switching _from_ LangGraph — or back _to_ it — is a one-word change, not a migration. Our goal is low lock-in in both directions, so you can start free on skein-js and adopt the platform if and when it's worth it. _LangSmith Deployment pricing/licensing as of August 2026 — see [langchain.com/pricing](https://www.langchain.com/pricing) and the [self-hosting docs](https://docs.langchain.com/langsmith/self-hosted). Always verify current terms._ ## Under the hood skein-js keeps the **contract** identical to the LangGraph CLI — same `langgraph.json`, same graph code, same Agent Protocol on the wire — while re-implementing the runtime with an open, self-hostable toolset: - **[commander](https://github.com/tj/commander.js)** powers the `skein` CLI. - **[vite](https://vitejs.dev)** loads your TypeScript graphs in-process for `skein dev` — no build step, with **state-preserving hot reload** and `.skein/` persistence across restarts. - **[BullMQ](https://docs.bullmq.io)** (on Redis) runs the production job queue with retries and crash recovery; **[ioredis](https://github.com/redis/ioredis) + Redis Streams** fan run streams across instances so a client on one instance can follow a run on another. - **[pgvector](https://github.com/pgvector/pgvector)** (via `pg`) backs long-term memory with semantic search, while checkpoints stay LangGraph-native via **[`PostgresSaver`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/checkpoint-postgres)** — reused, not reinvented. Because storage and the queue are **pluggable drivers**, `skein dev` can even run against production-shaped Postgres/Redis without Docker (`--store postgres --queue redis`). None of this changes what your clients see. Details: [docs/langgraph-cli-compat.md](./docs/langgraph-cli-compat.md#under-the-hood-what-skein-js-changes-transparently) and [docs/runs-and-redis.md](./docs/runs-and-redis.md). ## Packages Most projects install only the **CLI** (`skein-js`). The rest are building blocks for embedding, custom drivers, or a custom server; they share one version, so one number pins the whole set. Each section links to that package's README for install, usage, and API reference. ### `skein-js` — the CLI The drop-in for the LangGraph CLI; this is the only package most projects install. ```bash pnpm add -D skein-js pnpm skein dev ``` → [`packages/cli`](./packages/cli) ### `create-skein-js` — the scaffolder Writes a working project — `langgraph.json`, a keyless graph, a test, and the whole `dev` → `build` → `start` lifecycle. You never install it; you run it once. ```bash npm create skein-js@latest my-agent ``` → [`packages/create-skein-js`](./packages/create-skein-js) · [scaffolding docs](./docs/scaffolding.md) ### `@skein-js/agent-protocol` — the engine ⭐ The transport-agnostic heart: a complete implementation of the **Agent Protocol** — run engine, HTTP handler table, and SSE streaming, driven entirely by injected dependencies. Build your own server on it, on any HTTP framework, with any storage/queue — and with any agent runtime: `npm i @skein-js/agent-protocol` pulls **no graph runtime**, and the engine drives an `AgentGraph` (`stream` + `getState`, everything else optional). Bring LangGraph.js with [`@skein-js/langgraph`](./packages/langgraph), or [implement `AgentGraph` yourself](./docs/building-a-runner.md). ```bash pnpm add @skein-js/agent-protocol @skein-js/core ``` ```ts import { createProtocolRuntime } from "@skein-js/agent-protocol"; const runtime = createProtocolRuntime(deps); // service + HTTP handlers + background worker ``` → [`packages/agent-protocol`](./packages/agent-protocol) ### `@skein-js/langgraph` — the LangGraph.js binding Makes a compiled LangGraph.js graph into an `AgentGraph` the engine can drive, and owns every LangGraph-specific decision the engine no longer makes: `Command` construction, the `BaseStore` bridge that gives nodes `getStore()`, and checkpoint cloning for thread copy, prune, and rollback. It depends on `@skein-js/agent-protocol`, never the reverse — which is what lets the engine install with no graph runtime at all. ```bash pnpm add @skein-js/langgraph @langchain/langgraph ``` ```ts import { langGraphAgent } from "@skein-js/langgraph"; const agent = langGraphAgent(graph); // a compiled graph, as an AgentGraph ``` Using the CLI or an adapter's `{ config }` seam? This is wired for you. → [`packages/langgraph`](./packages/langgraph) ### Server adapters — Fetch, Express, Fastify, NestJS, Next.js Each adapter is a thin transport shim that mounts the Agent Protocol engine on its framework — no protocol logic of its own, just request/response translation over the shared handler table. Pick the one matching your stack (or [write your own](./docs/building-an-adapter.md)); the wire format is identical because they all drive the same engine. ```bash pnpm add @skein-js/express @langchain/langgraph # or @skein-js/fastify · @skein-js/nestjs · @skein-js/nextjs ``` ```ts // Express (createExpressServer) / Fastify (createFastifyServer) / NestJS (createNestServer) — // standalone servers with the same shape: import { createFastifyServer } from "@skein-js/fastify"; const server = await createFastifyServer({ config: "./langgraph.json" }); await server.listen(2024); // …or embed alongside your app's own routes: // Fastify: await app.register(skeinPlugin, { prefix: "/agent", config }); // NestJS: imports: [SkeinModule.forRoot({ config })] // Next.js: export const { GET, POST, PUT, PATCH, DELETE } = createSkeinRouteHandlers({ config }); ``` | Adapter | Serve it as | | ------------------------------------------------ | ---------------------------------------------------- | | [`@skein-js/fetch`](./packages/server-fetch) | Native Web Fetch handler; `Bun.serve` / `Deno.serve` | | [`@skein-js/express`](./packages/server-express) | Express `Router` / standalone server | | [`@skein-js/fastify`](./packages/server-fastify) | Fastify plugin / standalone server | | [`@skein-js/nestjs`](./packages/server-nestjs) | `SkeinModule` / standalone server (Express platform) | | [`@skein-js/nextjs`](./packages/server-nextjs) | App Router + Pages Router API routes (same-origin) | Shared, framework-agnostic building blocks (the route table lives in the engine; the in-memory runtime, dev-state import, and CORS mapping in [`@skein-js/server-kit`](./packages/server-kit)) mean no adapter depends on another. ### `@skein-js/runtime` — production wiring Assembles a production `ProtocolDeps` (memory / Postgres / Redis) from a `langgraph.json` — the same wiring the CLI uses. Use it to embed a production-shaped server in your own app. ```bash pnpm add @skein-js/runtime ``` ```ts import { buildRuntime } from "@skein-js/runtime"; const runtime = await buildRuntime({ configPath: "./langgraph.json", store: "postgres", queue: "redis", }); ``` → [`packages/runtime`](./packages/runtime) ### `@skein-js/config` — `langgraph.json` loader Parses and validates an unchanged `langgraph.json` and resolves each `path:export` graph plus its schemas. Handy on its own for tooling. ```bash pnpm add @skein-js/config ``` ```ts import { loadConfig } from "@skein-js/config"; const config = await loadConfig({ configPath: "./langgraph.json" }); ``` → [`packages/config`](./packages/config) ### `@skein-js/channels` — connect workflows to external systems Connect a LangGraph workflow to authenticated sources and destinations from WhatsApp, Slack, email, GitHub, or another provider. skein-js keeps conversations connected, prevents duplicate work, resumes interrupts, and delivers outcomes reliably. The shape is source → LangGraph workflow → destination, with Skein owning the durable provider lifecycle around the graph. ```bash pnpm add @skein-js/channels ``` → [`packages/channels`](./packages/channels) · [channels guide](./docs/channels.md) ### `@skein-js/core` — the shared contract Agent Protocol wire types plus the `SkeinStore` / queue / bus / auth interfaces every other package implements. Depend on it to build a custom driver or adapter. ```bash pnpm add @skein-js/core ``` → [`packages/core`](./packages/core) ### Storage & queue drivers Pick a store (persistence for threads/runs/memory) and a queue/streaming bus (run scheduling + cross-instance fan-out). These map directly to the CLI's `--store` and `--queue` flags. | Package | Use it for | Install | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------- | | [`@skein-js/storage-memory`](./packages/storage-memory) | Zero-dependency in-memory store + queue + bus (dev / tests) | `pnpm add @skein-js/storage-memory` | | [`@skein-js/storage-postgres`](./packages/storage-postgres) | Production Postgres store with **pgvector** semantic search; `PostgresSaver` checkpoints | `pnpm add @skein-js/storage-postgres` | | [`@skein-js/redis`](./packages/runtime-redis) | Redis job queue (BullMQ) + cross-instance streaming bus (multi-instance prod) | `pnpm add @skein-js/redis` | ### Observability Optional telemetry sinks — traces and metrics for your runs. Off by default; see [docs/observability.md](./docs/observability.md). | Package | Use it for | Install | | ------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------ | | [`@skein-js/langsmith`](./packages/telemetry-langsmith) | LangSmith tracing — run identity, and thread grouping in the **Threads** view | `pnpm add @skein-js/langsmith` | | [`@skein-js/posthog`](./packages/telemetry-posthog) | PostHog — run lifecycle events plus `$ai_generation` LLM analytics (tokens, latency) | `pnpm add @skein-js/posthog` | | [`@skein-js/otel`](./packages/telemetry-otel) | OpenTelemetry spans + metrics; API-only, so Datadog/Grafana/Honeycomb/Jaeger all work | `pnpm add @skein-js/otel` | ### Roadmap An MCP endpoint and the remaining LangGraph SDK gaps are planned. See the [roadmap](./docs/roadmap.md) for what works today and what comes next. > Package names are the npm names; a few on-disk directories differ (`@skein-js/express` → > `packages/server-express`, likewise `@skein-js/fastify` · `@skein-js/nestjs` · `@skein-js/nextjs` → > `packages/server-{fastify,nestjs,nextjs}`, `@skein-js/redis` → `packages/runtime-redis`, > `@skein-js/{langsmith,posthog,otel}` → `packages/telemetry-{langsmith,posthog,otel}`, `skein-js` > → `packages/cli`). The links above point at the directories. ## Examples Each is a runnable project — `cd` into it and follow its README. | Example | What you'll learn | How to run | | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- | | [`chat-app`](./examples/chat-app) | **Flagship** — build a full rich-UX chat app: streamed thinking, web search, structured tool-result cards, human-in-the-loop booking, long-term memory, custom auth (Gemini + Next.js + shadcn/ui) | `pnpm dev` + `pnpm dev:ui` | | [`migrated-langgraph`](./examples/migrated-langgraph) | The **drop-in proof** — a stock LangGraph project under `skein dev`, with hot reload + `.skein/` persistence | `pnpm dev` | | [`gemini-chat`](./examples/gemini-chat) | **Model-backed end-to-end** — a Gemini ReAct agent streamed into a browser; also an embedded `@skein-js/express` server | `pnpm dev` | | [`express-basic`](./examples/express-basic) | **Hello world** — zero-setup `echo` (no API key) + a Claude `agent` graph in one config | `pnpm dev` | | [`embed-graph`](./examples/embed-graph) | **In-code embedding** — serve a graph you already have with **no `langgraph.json`** (`embedInMemoryGraphs` + `{ deps }`); the config-free counterpart to `express-basic` | `pnpm dev` | | [`invoke-endpoint`](./examples/invoke-endpoint) | **Non-chat serving** — graphs as plain `POST /invoke/:graph_id` endpoints (body in, final state out), with no threads or runs | `pnpm start` | | [`fastify-basic`](./examples/fastify-basic) · [`fastify-app`](./examples/fastify-app) | **Fastify** — a standalone graph server, and the protocol embedded under `/agent` alongside a REST API | `pnpm dev` | | [`nestjs-basic`](./examples/nestjs-basic) · [`nestjs-app`](./examples/nestjs-app) | **NestJS** — a standalone graph server, and `SkeinModule` alongside the app's own controller | `pnpm dev` | | [`nextjs-basic`](./examples/nextjs-basic) · [`nextjs-app`](./examples/nextjs-app) | **Next.js** — headless Pages Router API, and a full-stack App Router app serving the protocol same-origin behind a `useStream` chat UI | `pnpm dev` | | [`whatsapp-agent`](./examples/whatsapp-agent) | **A coupled WhatsApp workflow** — a compact Twilio integration with signature checks, retry dedup, conversation memory, a typing indicator, async human-in-the-loop, and a durable reply; runs offline | `pnpm dev` | | [`react-usestream`](./examples/react-usestream) | A minimal **`useStream` SSE frontend** you can point at any skein-js server | `pnpm dev` | ## Tested end-to-end skein-js is verified in layers — not just unit tests, but real clients driving a real server. The examples above _are_ the integration/e2e suite: - **Storage conformance** — every storage driver (memory + Postgres) runs against one shared `SkeinStore` conformance suite, so drivers behave identically. - **SDK conformance (e2e)** — `examples/express-basic` (and the Fastify/NestJS `*-basic` + `*-app` examples) are exercised by the **real `@langchain/langgraph-sdk`** client (`threads.create`, `runs.stream`, `runs.wait`). Every adapter also has its own HTTP conformance suite (`fetch` against a live server, one assertion per response shape). If the official SDK is happy, the wire format is correct — across all five adapters. - **Drop-in migration** — `examples/migrated-langgraph` runs a real `langgraph.json` under `skein dev` in place of `langgraph dev`, with **no other change** — the headline compatibility test. - **React `useStream` (frontend)** — `examples/react-usestream` streams a reply token-by-token from skein-js, pointed at the `examples/gemini-chat` Gemini backend for a live model-backed FE+BE run. - **Agent Chat UI interop** — the stock Agent Chat UI points at a local skein-js server and renders a streamed conversation. - **Browser e2e (flagship)** — `examples/chat-app` is driven by **Playwright** end to end, asserting streamed tokens, a rendered **thinking block**, and a **tool-call card** (model-key-gated). - **Long-term memory** — a run-engine test writes and reads via the injected `getStore()`, and `chat-app` recalls a saved fact across threads. - **Postgres + Redis (Testcontainers)** — the conformance suite re-runs against real Postgres, and a **cross-instance** test starts a run on instance A and joins its SSE stream from instance B via Redis. Run them with `pnpm test` (fast unit + conformance, no Docker) and `pnpm test:integration` (Testcontainers — needs Docker). See [testing.md](./docs/testing.md). ## Documentation 📖 **[skein-js.github.io/skein-js](https://skein-js.github.io/skein-js/)** — the full docs, searchable. Start here: - [Getting started](https://skein-js.github.io/skein-js/getting-started) — the guided path from zero to a running server, then prod - [LangGraph CLI compatibility](https://skein-js.github.io/skein-js/langgraph-cli-compat) — commands + every `langgraph.json` field - [Agent Protocol surface](https://skein-js.github.io/skein-js/agent-protocol) — the endpoints skein-js serves - [Recipes](https://skein-js.github.io/skein-js/recipes) — auth, human-in-the-loop, long-term memory, CORS, background runs - [Workflows and channels](https://skein-js.github.io/skein-js/channels) — connect provider sources and durable destinations through LangGraph workflows - [Observability](https://skein-js.github.io/skein-js/observability) — PostHog, LangSmith, OpenTelemetry, and custom telemetry - [Deploy anywhere](https://skein-js.github.io/skein-js/deploy) — Cloud Run, Railway, Fly.io, Render, AWS, Kubernetes, VPS The source markdown lives in [`docs/`](./docs); the site's sidebar is the complete index. This list is deliberately short — it used to mirror the whole set and had already drifted out of date. **What changed and when:** [CHANGELOG.md](./CHANGELOG.md), or the [releases](https://github.com/skein-js/skein-js/releases). **Working on skein-js rather than with it?** [CONTRIBUTING.md](./CONTRIBUTING.md) and [AGENTS.md](./AGENTS.md) — the internals docs ([reuse](./docs/reuse.md), [code practices](./docs/code-practices.md), [testing](./docs/testing.md)) live in the repo rather than on the docs site. **For AI agents & tools:** [`llms.txt`](./llms.txt) is a curated, machine-readable index of these docs (per the [llmstxt.org](https://llmstxt.org) convention); [`llms-full.txt`](./llms-full.txt) is the same set concatenated for wholesale ingestion. Regenerate the latter with `pnpm docs:llms`. ## Sponsorship skein-js is sponsored by **[Garatropic Studios](https://garatropic.com)**, supporting the project's engineering and ongoing maintenance. Individuals and organizations can also sponsor skein-js. If you would like to support ongoing development, [get in touch](https://github.com/skein-js/skein-js/issues). ## Contributing & feedback skein-js is young and we'd love your help — especially **LangGraph CLI compatibility reports** (does your `langgraph dev` project work under `skein dev`?). - 🐛 **Found a bug or a compatibility gap?** [Open an issue](https://github.com/skein-js/skein-js/issues). - 💡 **Want a feature or a new framework adapter?** [Start a discussion or file an issue](https://github.com/skein-js/skein-js/issues). - 🙌 **Want to contribute code?** PRs are very welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md) to get set up (and [AGENTS.md](./AGENTS.md) for the deep contributor guide). ## License [Apache-2.0](./LICENSE) --- **Works with:** [LangGraph.js](https://docs.langchain.com/oss/javascript/langgraph/overview) · LangGraph SDK · [Agent Chat UI](https://github.com/langchain-ai/agent-chat-ui) · LangGraph Studio **Fits your stack:** Express · Fastify · NestJS · Next.js · Bun · Deno · React · Vue · Svelte · Angular **Turn LangGraph graphs into real-world workflows.** LangGraph owns the orchestration; it doesn't own provider integrations. Skein [channels](./channels.md) connect authenticated sources such as WhatsApp, email, Slack, GitHub, or your own webhooks to the graph, then deliver its outcome reliably to the same provider or a graph-selected destination. See how the whole workflow behaves with [PostHog](./observability.md#posthog), LangSmith, or OpenTelemetry. That means practical processes such as a WhatsApp support request that looks up an order and replies, an emailed refund that gathers approvals over WhatsApp before notifying the customer, or a failed GitHub deployment that evaluates severity before alerting the on-call team in Slack. ## Your agent works on your laptop. Now what? {#start} You could wrap it in Express yourself — it's an afternoon, and then it's five categories of plumbing you maintain forever. None of it is what your users came for. **skein-js is that backend, and it starts in one command:** ```bash npm create skein-js@latest my-agent cd my-agent && npm run dev ``` You now have an agent server on `http://localhost:2024` and a control room at `/console`. **No API key, no database, no Docker** — the agent it writes for you runs offline, so the first thing you see is your own agent working rather than a credentials error. Talk to it with the client you'd already be using. **If it speaks the Agent Protocol, it works** — you're only changing a URL: :::tabs == Any framework ```ts import { Client } from "@langchain/langgraph-sdk"; const client = new Client({ apiUrl: "http://localhost:2024" }); const thread = await client.threads.create(); for await (const chunk of client.runs.stream(thread.thread_id, "agent", { input: { messages: [{ role: "human", content: "hello" }] }, streamMode: "messages", })) { console.log(chunk); } ``` == React ```tsx import { useStream } from "@langchain/langgraph-sdk/react"; const thread = useStream({ apiUrl: "http://localhost:2024", assistantId: "agent" }); thread.submit({ messages: [{ type: "human", content: input }] }); // thread.messages updates live as tokens arrive; thread.interrupt holds a pending approval ``` == Vue ```ts import { useStream } from "@langchain/vue"; const thread = useStream({ apiUrl: "http://localhost:2024", assistantId: "agent" }); thread.submit({ messages: [{ type: "human", content: input }] }); ``` == Svelte ```ts import { getStream, provideStream } from "@langchain/svelte"; provideStream({ apiUrl: "http://localhost:2024", assistantId: "agent" }); const thread = getStream(); thread.submit({ messages: [{ type: "human", content: input }] }); ``` == Angular ```ts import { injectStream } from "@langchain/angular"; const thread = injectStream({ apiUrl: "http://localhost:2024", assistantId: "agent" }); thread.submit({ messages: [{ type: "human", content: input }] }); ``` ::: All four bindings are thin wrappers over the **same** `@langchain/langgraph-sdk`, so they issue identical requests and read identical frames. Agent Chat UI and LangGraph Studio work the same way, for the same reason. Details and the honest caveats: [react-sdk.md](./react-sdk.md). [**Your first agent**](./your-first-agent.md) takes it from here to deployed — and teaches you the LangGraph you need on the way, if you're new to it. ## How it fits together Three moving parts. Your clients and your agent are the ones you already have. Your clients LangGraph SDK · useStream · Agent Chat UI Agent Protocol · HTTP + SSE skein-js runs · threads · streaming · approvals memory · schedules · channels · console Your Postgres Your Redis Your agent a LangGraph.js graph, unchanged Your clients don't know skein-js exists — they speak a standard. Your agent doesn't either. skein-js is the middle box, and it's the only part you didn't have to write. ## You're not learning a new framework The agent itself is **plain LangGraph.js** — LangChain's own framework, MIT-licensed and pulling [millions of downloads a week](https://www.npmjs.com/package/@langchain/langgraph) on npm. skein-js introduces no framework of its own, and there is nothing skein-specific in your graph code. No imports of ours, no decorators, no lifecycle hooks: ```ts import { MessagesAnnotation, StateGraph } from "@langchain/langgraph"; export const graph = new StateGraph(MessagesAnnotation) .addNode("agent", callModel) // your step .addEdge("__start__", "agent") // what runs next .compile(); ``` That's the whole contract. The same file runs under `langgraph dev`, on LangGraph Platform, or on skein — which is why migrating is one word in either direction, and why [`migrated-langgraph`](https://github.com/skein-js/skein-js/tree/main/examples/migrated-langgraph) is a stock LangGraph project with nothing changed but a script. New to it? LangChain's [Thinking in LangGraph](https://docs.langchain.com/oss/javascript/langgraph/thinking-in-langgraph) is the shortest path to the mental model — nodes, shared state, and explicit routing. So the thing you're betting on for your agent logic is LangChain's, not ours. What skein-js adds is everything around it. [Why we bet on LangGraph →](./why.md#why-we-bet-on-langgraph) > **Not using LangGraph?** The protocol engine carries no graph runtime, so another agent runtime can > serve the same API by implementing two methods — bringing its own persistence and giving up the > LangChain-specific pieces. The honest limits are in > [building a runner](./building-a-runner.md#known-limits). ## Your stack is already TypeScript

Your frontend is TypeScript. Your API is TypeScript. Why is your agent in Python?

The cost of a Python sidecar isn't the extra runtime. It's that **nothing crosses the boundary** — your types stop dead, the domain logic your API already has is unreachable, and the agent's structured output, the thing your UI actually renders, becomes a shape declared twice and trusted once. Keeping it in one language collapses the stack. The people who already ship your product can build the agent, review each other's work on it, and fix it when it matters — one toolchain, one CI, one set of types, and no small group who are the only ones able to touch it. [The longer argument, if you want to nerd out →](./why.md#your-stack-is-already-typescript) ## See it running {#see-it} This ships in the box. It's the thing you'd otherwise reach for LangGraph Studio to get, except **your own server hosts it** — no account, no sign-in, no internet connection, no tunnel back to your laptop.
![The skein console: two conversations waiting on a human, live counts, and recent activity](/images/console/overview-light.png)
![The skein console: two conversations waiting on a human, live counts, and recent activity](/images/console/overview-dark.png)
**"Waiting for you"** is the one that changes how you build. Any conversation your agent paused for a human shows up there — approve it, reject it, or send back whatever answer you like, and the agent carries on from exactly where it stopped. Hours later. After a redeploy. From a different machine.
![The console filtered to conversations paused for a human decision](/images/console/interrupts-light.png)
![The console filtered to conversations paused for a human decision](/images/console/interrupts-dark.png)
There's more behind it: a playground that draws your agent's shape and streams a run into it, live run tails you can cancel or roll back, a memory browser with semantic search, and schedule management. It's **off by default in production** — you opt in, because it can read and delete everything. [Take the tour →](./console.md) ## Bring what you already have Four ways in. Pick yours — they all end at the same server. :::tabs == Starting fresh One command, and you have a working agent to edit: ```bash npm create skein-js@latest my-agent cd my-agent && npm run dev ``` Pick a model provider with `--provider anthropic|openai|google`, or take the keyless default and add one later. [Scaffolding reference →](./scaffolding.md) == On the LangGraph CLI Change one word. Your `langgraph.json`, your agent and your clients stay exactly as they are: ```diff - "dev": "langgraph dev", + "dev": "skein dev", ``` That's the whole migration. Every honoured config field is listed in [LangGraph CLI compatibility →](./langgraph-cli-compat.md) == Agent in an app I run Bring it in code — no config file, no CLI. `{ deps }` is the seam every adapter accepts, so the same two lines work on Fastify, NestJS, Next.js and Fetch: ```ts import { createExpressServer } from "@skein-js/express"; import { embedInMemoryGraphs } from "@skein-js/server-kit"; import { graph } from "./my-graph.js"; const server = await createExpressServer({ deps: embedInMemoryGraphs({ agent: graph }) }); await server.listen(2024); ``` [More on embedding →](./embedding.md) == Not a chat product For a classifier, an extractor, or a workflow another service calls, skip threads and runs entirely. Each agent gets one endpoint: your JSON in, its answer out. ```bash curl -sX POST localhost:2024/invoke/triage \ -H 'content-type: application/json' \ -d '{"text":"Refund charge failed — urgent!"}' ``` [Serving a single graph →](./serving-a-single-graph.md) ::: ## Why skein-js? {#why-skein-js} ## Steal our examples Every one of these runs, and CI proves it. | Example | What it shows | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) | **Start here** — schedules, background work, approvals, memory and rewind in one agent. **No API key, no network needed** | | [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) | **Flagship** — a research assistant with thinking, web search and memory, behind a Next.js + shadcn/ui UI | | [`migrated-langgraph`](https://github.com/skein-js/skein-js/tree/main/examples/migrated-langgraph) | The drop-in proof — a stock LangGraph project running under `skein dev` | Eleven more in [`examples/`](https://github.com/skein-js/skein-js/tree/main/examples), including one per adapter — standalone, and mounted inside an app that already has its own routes. ## Where to go next New here? [**Your first agent**](./your-first-agent.md) goes from an empty directory to deployed, then [**building blocks**](./building-blocks.md) names every piece an agent is made of. Never written a graph? [**LangGraph essentials**](./langgraph-essentials.md) is the short version. Wondering if skein does something specific? The [**features page**](./features.md) answers it in one line. Building _with_ an AI agent? [`llms.txt`](https://github.com/skein-js/skein-js/blob/main/llms.txt) hands it the whole set. --- **If this saved you a week, [give it a star](https://github.com/skein-js/skein-js)** — it's how other TypeScript teams find it. Hit something that doesn't work? [Tell us](https://github.com/skein-js/skein-js/issues); compatibility reports are the most useful feedback we get. --- # Why skein-js You can build an agent in an afternoon. Putting it in front of real users is the part nobody warns you about. This page is the honest version of how that goes, what your options are, and where skein-js fits — including where it doesn't. ## How everyone gets here
  1. **You write an agent, and it works.** Twenty lines of LangGraph. It answers questions, calls a tool, returns a result. You demo it and people are impressed.
  2. **Someone asks to use it.** So you wrap it in an HTTP handler. Fine — an hour's work.
  3. **"Can it remember what I said?"** Now you need conversations that persist, keyed to a user, restored on the next request. That's a schema, a migration, and a serialization format for graph state.
  4. **"Why does it take nine seconds to say anything?"** It doesn't — it's just not streaming. So you add server-sent events. Then a client drops mid-answer and you add reconnect-and-replay.
  5. **"Legal needs to approve before it sends."** Your agent has to _stop_, wait for a human who might answer tomorrow, and resume from exactly where it paused — across a deploy. That's checkpointing.
  6. **"Run it every morning."** A scheduler. Which must not fire twice when you scale to two servers.
  7. **"Tell our CRM when it's done."** A webhook. Which must not be lost when the receiver is redeploying, so now you own a retry queue and an outbox.
  8. **Something breaks at 2am** and you have no idea which conversation, which step, or what the agent actually saw.
None of that was your product. All of it is load-bearing. It's a few thousand lines you'd own forever, and every item has a failure mode you find in production rather than in review. **skein-js is that entire list, already built, on your own infrastructure.** ## Your four options
🔨 ### Build it yourself Total control, no dependency. Also the few thousand lines above, plus the ongoing cost of keeping up with a protocol other people's clients expect.
💳 ### LangGraph Platform The managed option, and genuinely good. Paid per seat plus metered compute; production self-hosting is an Enterprise add-on requiring a commercial licence key.
🐍 ### aegra The leading _open_ self-hosted alternative, and the project that inspired this one. Excellent — and Python only, so a TypeScript team runs a second language in production.
🧵 ### skein-js Open source, self-hosted, TypeScript. Your database, your servers, no licence key, no per-run bill — and a one-word path back out if you change your mind.
## How they compare | | LangGraph Platform | aegra | **skein-js** | | ----------------------------- | -------------------------- | ----------- | ------------------------------------------------ | | Language | Python + JS | Python only | **TypeScript** | | Licence | Elastic License 2.0 | Apache-2.0 | **Apache-2.0** | | Self-host in production | Enterprise add-on + key | ✅ | ✅ **no key** | | Cost | Per-seat + metered compute | Free | **Free** | | Your own database | Managed, or Enterprise | ✅ | ✅ | | Studio-style UI | Hosted, needs an account | — | ✅ **yours, at `/console`** | | Drop-in for the LangGraph CLI | — | Partial | ✅ **one word** | | HTTP framework | Theirs | FastAPI | **Express · Fastify · NestJS · Next.js · Fetch** | ## Your stack is already TypeScript Look at what you've already got. The frontend is TypeScript. The API is TypeScript. The types are shared across them, the CI is one pipeline, the team reviews each other's code. Then the agent shows up, and it's the one component anyone suggests you write in a different language. That's a real cost, and it's usually undercounted. The expensive part isn't the extra runtime — it's that **nothing crosses the boundary**: - **Types stop dead.** The agent's input and output are the shapes your app cares most about, and they become a Zod schema on one side and a Pydantic model on the other, kept in step by hand. You find out they've drifted in production, not at compile time. - **You can't reuse what you've already written.** The validation, the domain rules, the permission checks, the money and date formatting — your API has all of it, and the agent can reach none of it. So you duplicate it, and the two copies drift; or you put it behind HTTP, and the agent makes a network call to your own API to ask what a customer's plan is. - **The agent's output is a UI contract, and it's the one that hurts most.** A node returns a structured result — a booking, a diff, a set of options, a chart spec — and a component in your app renders it. In one language that's a single exported type shared by the node that produces it and the component that draws it, with the compiler catching a mismatch before you ship. Across two it's a shape declared in Pydantic, re-declared in TypeScript, and rendered on trust. Same story for a tool's schema: one Zod object can validate the model's arguments _and_ generate the form your UI shows. - **Two of everything around the code.** Two dependency trees to patch, two test runners, two CI pipelines, two Dockerfiles, two streams of security advisories, two ways to configure a logger. - **Review narrows.** A team fluent in TypeScript reviewing Python is working outside its day-to-day, so in practice one or two people end up owning the agent. That shows up as slow reviews long before it shows up as an incident. - **A smaller pool of people who can fix it.** At 2am the question isn't which language is nicer, it's who on the team can read the stack trace. **The honest counterpoint:** Python's ML ecosystem is deeper, and that's not close. If your agent does real numerical work, or reaches a model or library that only exists in Python, pay the cost — it's worth it, and [aegra](https://github.com/aegra/aegra) is very good. But that isn't the common case. The common case is an LLM behind an HTTP API, calling tools that are your own services. Every major model provider ships a first-class TypeScript SDK, LangGraph has a JavaScript implementation, and the whole thing can be one language, one deploy, one set of types. Agent tooling in JavaScript has lagged the Python side — not because the ecosystem is smaller, but because the infrastructure kept getting built there first. skein-js is one piece of closing that gap: the production server was the missing part, so we built it, in the open, for the language most product teams already ship. ## The licensing part, plainly You _can_ self-host LangGraph Platform (now LangSmith Deployment). But production self-hosting is an **Enterprise add-on that requires a commercial licence key** — the platform's server runtime is source-available under the [Elastic License 2.0](https://www.elastic.co/licensing/elastic-license), which is not an open-source licence. The managed **Plus** plan is **$39 / seat / month**, includes one small serverless deployment, and meters additional compute and storage on top. Fully self-hosted and hybrid deployment are Enterprise-only, with custom pricing. _As of August 2026 — see [langchain.com/pricing](https://www.langchain.com/pricing) and the [self-hosting docs](https://docs.langchain.com/langsmith/self-hosted). Always verify current terms._ If you're a solo developer, a small team, or just trying an idea, that model is a poor fit. You shouldn't need a commercial licence or a per-run bill to ship a side project. ## The same stack in dev and production With a managed platform, local development is an _approximation_ of production. You develop against a dev server, ship to someone else's service, and the gap between the two is where the surprises live — because the thing you deploy to is a thing you cannot run yourself. Self-hosted and open source removes that gap entirely: ```bash skein dev --store postgres --queue redis # the real drivers, on your machine skein up # app + Postgres + Redis, via Compose skein build # the image you deploy — `skein start` runs inside it ``` The same checkpointer, the same migrations, the same BullMQ queue semantics, the same code path. When something misbehaves in production you can reproduce it locally, because there is no privileged environment you're locked out of. And when reproducing isn't enough, you can read the server — all of it, Apache-2.0 — set a breakpoint in it, and patch it. That's not a small thing at 2am. ## We want leaving to be easy This is the part that's easy to leave unsaid. skein-js is a **drop-in for the LangGraph CLI on an unchanged `langgraph.json`**. That's usually pitched as "migration is one word" — and it is. But the same fact runs in both directions: if your team grows, you want SLAs and managed operations, and LangGraph Platform starts making sense, then **switching back is also one word**. That's deliberate. Low lock-in in both directions means you can start free, ship something real, and adopt a managed platform if and when it's actually worth paying for — instead of choosing your production architecture on day one, under uncertainty, and living with it. It's also the standard we hold ourselves to: we'd rather you stay because skein-js keeps being the right fit than because leaving got expensive. ## Won't it drift out of compatibility? The reasonable worry about any community project tracking a commercial one: it falls behind, breaks subtly, and strands you. That isn't the shape of this, because **compatibility here isn't maintained by hand**. The wire format is `@langchain/langgraph-sdk`'s own types — the SDK's types _are_ the contract, so `useStream` works by construction rather than because someone keeps a copy in sync. Your agent runs on LangGraph's runtime, saves through its checkpointers, and your `langgraph.json` is read by its parser. That reuse is possible because on JavaScript the pieces are **MIT-licensed**, including the Agent Protocol server internals ([`@langchain/langgraph-api`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/langgraph-api)). skein-js wraps them; it doesn't clone them, so there's no second implementation to fall behind. skein-js builds only what open source genuinely lacks: durable production storage and queueing, the framework adapters, the console, and the drop-in CLI. The package-by-package ledger of what's reused versus rebuilt is in [reuse.md](https://github.com/skein-js/skein-js/blob/main/docs/reuse.md). ## Why we bet on LangGraph skein-js is built for LangGraph.js, and that isn't incidental — it's the choice the whole project rests on. Here's the reasoning, because if you're adopting skein you're adopting that bet too. The short version: it isn't a new or niche framework. It's LangChain's, it's MIT, and it pulls [millions of npm downloads a week](https://www.npmjs.com/package/@langchain/langgraph) — so the part of your stack doing the actual agent work is the well-trodden part. **Control flow is explicit.** You break the agent into discrete steps — nodes — connected by a state they each read and write, and routing is a decision you can point at. As LangChain's own [Thinking in LangGraph](https://docs.langchain.com/oss/javascript/langgraph/thinking-in-langgraph) puts it, "you can always understand what your agent will do next by looking at the current node." That sounds academic until you need to pause halfway through, resume after a deploy, or ask what the agent saw three steps ago — at which point an explicit graph is the difference between a feature and a rewrite. **Its persistence primitives are exactly what a server needs.** Checkpointers aren't a nice-to-have we work around; they're the substrate. LangGraph writes a checkpoint at node boundaries, and human-in-the-loop, time travel, resumable runs and crash recovery all fall out of that one fact — a run that stops resumes from the node it stopped in, days later, on a different machine. A framework without it would make most of this page impossible to build. **The client ecosystem already exists.** `useStream`, the SDK, Agent Chat UI, Studio — all of it speaks one protocol, and the wire types are the SDK's own. Betting on LangGraph means your users' frontends work by construction rather than through a compatibility layer someone maintains. **And the graph is portable.** Nothing in your graph code is skein-specific — no imports, no decorators, no lifecycle hooks. It's the same file that runs under `langgraph dev` or on LangGraph Platform. That's what makes [leaving easy](#we-want-leaving-to-be-easy) in both directions. ### So, am I locked in? To LangGraph, **today, mostly yes** — and it's worth saying plainly rather than overselling a seam. Your agent runs on LangGraph's runtime, state persists through its checkpointers, `langgraph.json` uses its parser. It isn't merely the supported path; it's the complete one. What _is_ decoupled is narrower: - **The protocol engine carries no graph runtime.** `@skein-js/agent-protocol` installs without LangGraph or LangChain, and a test pins that. The LangGraph binding is a separate package using only the engine's public entry point. - **The runner seam is real, but partial.** The engine drives an `AgentGraph` — `stream` and `getState` required, the rest optional — so another runtime _can_ serve the protocol. It would bring its own persistence, and lose what's LangChain-specific by construction: `events` stream mode is a LangChain demux, and time travel needs `updateState` + `getStateHistory` you'd write yourself. The limits are listed honestly in [building a runner](./building-a-runner.md#known-limits). - **Your clients were never tied to skein.** They speak the Agent Protocol, so on that side the thing you'd migrate is a URL. Practically: if you're not writing LangGraph.js graphs, skein-js probably isn't for you yet. What we deliberately **don't** do is argue LangGraph beats every alternative — that's LangChain's case to make, and their [docs](https://docs.langchain.com/oss/javascript/langgraph/overview) make it. The above is why _we_ built on it. ## Who this is for
### A good fit TypeScript teams who want their agents and their data on their own infrastructure · anyone whose compliance story rules out a managed control plane · developers who want to ship a side project without a per-run bill · teams already on the LangGraph CLI who need a production story.
### Probably not for you Python-first teams — use [aegra](https://github.com/aegra/aegra), it's good · teams who want managed operations, SLAs and a vendor to call — that's what LangGraph Platform is for · anyone who wants `skein deploy` to a hosted platform, which is a deliberate non-goal.
## What we deliberately don't do Being honest about the edges is part of the pitch: - **No hosted platform.** Self-hosted by design; there's no managed target to deploy to. - **No WebSocket transport.** Server-sent events cover the client experience and the React SDK doesn't care. - **No sub-minute schedules**, and no backfilling schedules missed during an outage. - **No exactly-once webhook delivery.** Delivery is durable — the callback commits in the same transaction as the run's terminal status, and is retried until it lands. What it is not is exactly-once, which is why every attempt carries a stable dedup key. - **We don't restate LangGraph's docs.** We document our _conformance_ and the delta. The full list of what's shipped, in preview, and planned is on the [features page](./features.md) and the [roadmap](./roadmap.md). ## Convinced, or curious? ```bash npm create skein-js@latest my-agent cd my-agent && npm run dev ``` No API key, no database, no Docker. [Your first agent](./your-first-agent.md) takes it from there — or if you already have a LangGraph project, [change one word](./langgraph-cli-compat.md). --- # Your first agent From an empty directory to a deployed agent server. No LangGraph experience assumed — the parts of it you need are explained here, where you need them. If you already have a LangGraph.js graph or a `langgraph.json`, you want [getting-started.md](./getting-started.md) instead: there is nothing to scaffold, and adopting skein is a one-line change. **Prerequisites:** Node ≥ 20 and a package manager. That's all — nothing in this guide needs an API key until you decide you want one, and nothing needs Docker until the last section. ## 1. Create the project ```bash npm create skein-js@latest my-agent ``` `pnpm create skein-js my-agent` and `yarn create skein-js my-agent` do the same thing. Keep the `@latest`: without it, npm's cache can hand you an old copy of the scaffolder. It asks two questions — where to put the project, and which model provider you want. **Pick "None" for now.** You can add a model in a minute, and starting without one means nothing can go wrong before you have seen the thing work. Then: ```bash cd my-agent npm run dev ``` You now have an agent server on `http://localhost:2024`. ## 2. Look at it before you read any code Open `http://localhost:2024/console`. That's the **skein console**, served by your own process — no account, no hosted service, no tunnel. Create a thread, send "hello", and watch the run execute. You will get `echo: hello` back, because the graph you just created echoes its input. This is worth doing before anything else: everything below is about changing what happens between your message and that reply, and it helps to have seen the loop close. ## 3. What you actually got Eleven files. The three that matter: **`src/echo-graph.ts`** — your agent. We'll come back to it. **`langgraph.json`** — how skein finds your graphs: ```json { "node_version": "24", "graphs": { "echo": "./src/echo-graph.ts:graph" }, "env": ".env" } ``` `"./src/echo-graph.ts:graph"` means _the export named `graph` in that file_. The key — `echo` — is what clients ask for by name. This is the same file format the LangGraph CLI reads, which is why skein is a drop-in for it. **`package.json`** — the commands that are the whole lifecycle: | | | | ---------------------- | ------------------------------------------------------------- | | `npm run dev` | what you're running: hot reload, in-memory state, zero setup | | `npm run dev:services` | Postgres + Redis in Docker, for the durable `dev` and `start` | | `npm run dev:postgres` | the same hot reload, against those services instead | | `npm run build` | compile your graphs to plain JavaScript in `.skein/` | | `npm start` | serve that build — this is what production runs | (If you picked Postgres at the storage prompt, `dev` is already the durable one and the in-memory spelling is `dev:memory` instead — both are always there.) The rest: `.env` (ready to use — the model key is commented out, the service URIs are not) and `.env.example` (a committed reference copy of it), `compose.dev.yaml` (the services for `start`), `tsconfig.json`, a test, and a README. ## 4. Understanding the graph Here is the whole agent: ```ts import { AIMessage, type BaseMessage } from "@langchain/core/messages"; import { MessagesAnnotation, StateGraph } from "@langchain/langgraph"; function echo(state: typeof MessagesAnnotation.State): { messages: BaseMessage[] } { const last = state.messages.at(-1); const text = typeof last?.content === "string" ? last.content : ""; return { messages: [new AIMessage(`echo: ${text}`)] }; } export const graph = new StateGraph(MessagesAnnotation) .addNode("echo", echo) .addEdge("__start__", "echo") .addEdge("echo", "__end__") .compile(); ``` Four ideas, and they are the only LangGraph concepts you need to get started: **State** is the data flowing through your agent. `MessagesAnnotation` is a ready-made state that holds a list of chat messages and knows to _append_ new ones rather than replace the list. Most conversational agents want exactly this. **A node** is a function. It receives the current state and returns only the part that changed — here, one new message. It is a plain function: you can call it, test it, and step through it in a debugger. **Edges** say what runs next. `__start__` and `__end__` are the built-in entry and exit points, so this graph is "start → echo → done." Real agents branch here — that is where the "graph" part earns its name. **`.compile()`** turns the definition into something runnable. What it returns is what skein serves. Try it: change `echo:` to `you said:` in `src/echo-graph.ts` and save. The server hot-reloads and **keeps your existing threads** — send another message in the console and you will see the new reply in the same conversation. Those four ideas are enough to finish this guide. When you want the rest — custom state, branching, subgraphs, `interrupt()` — [LangGraph essentials](./langgraph-essentials.md) is the short version, with a link out to the LangChain docs on each one. ## 5. Give it a real model Now swap the echo for an LLM. Install a provider: ```bash npm install @langchain/anthropic ``` Create `src/agent-graph.ts`: ```ts import { ChatAnthropic } from "@langchain/anthropic"; import { createAgent } from "langchain"; const model = new ChatAnthropic({ model: "claude-sonnet-5", temperature: 0 }); export const graph = createAgent({ model, tools: [] }); ``` `createAgent` is LangChain's prebuilt agent loop: call the model, and if it asks for a tool, run the tool and call the model again. You did not have to build that loop. It lives in the `langchain` package — LangGraph v1 deprecated its own `createReactAgent` and moved it there. Register it in `langgraph.json`: ```json { "graphs": { "echo": "./src/echo-graph.ts:graph", "agent": "./src/agent-graph.ts:graph" } } ``` Then give it a key — `.env` is already there, with every line commented out: ```bash # in .env, set ANTHROPIC_API_KEY= — from https://console.anthropic.com/settings/keys ``` Restart, pick `agent` in the console, and you are talking to a real model. Tokens stream as they are generated. > Prefer to skip this assembly? `npm create skein-js@latest my-agent --provider anthropic` scaffolds > all of it, plus a working tool, in one step. ## 6. Give it a tool Talking is half of it. Tools are what let an agent actually do things: ```ts import { tool } from "@langchain/core/tools"; import { z } from "zod"; const getWeather = tool( async ({ city }: { city: string }) => { const geo = await fetch( `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`, ).then((response) => response.json()); const place = geo.results?.[0]; if (!place) return `I couldn't find ${city}.`; const forecast = await fetch( `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}` + `&longitude=${place.longitude}¤t=temperature_2m`, ).then((response) => response.json()); return `It's ${forecast.current.temperature_2m}°C in ${place.name}.`; }, { name: "get_weather", description: "Get the current weather for a city.", schema: z.object({ city: z.string().describe("City name, e.g. 'Nairobi'") }), }, ); ``` Pass it in — `createAgent({ model, tools: [getWeather] })` — and ask "what's the weather in Nairobi?". The console shows the model deciding to call the tool, the result coming back, and the final answer. (Open-Meteo needs no API key of its own.) The `description` and `schema` are how the model knows when to use it. Write them for a reader who has no other context, because that is exactly the model's situation. ## 7. Talk to it from your own code The server speaks the standard Agent Protocol, so the official SDK works — there is no skein client: ```ts import { Client } from "@langchain/langgraph-sdk"; const client = new Client({ apiUrl: "http://localhost:2024" }); const thread = await client.threads.create(); for await (const event of client.runs.stream(thread.thread_id, "agent", { input: { messages: [{ role: "user", content: "what's the weather in Nairobi?" }] }, })) { console.log(event.event, event.data); } ``` For a UI, the `useStream` React hook talks to the same server with only a URL change — see [react-sdk.md](./react-sdk.md). Vue, Svelte and Angular work too; so do [Agent Chat UI](https://github.com/langchain-ai/agent-chat-ui) and LangGraph Studio. ## 8. Remember things between conversations Threads persist a conversation. A **store** persists across conversations — what a user told you last week. Any node can reach it: ```ts import { getStore } from "@langchain/langgraph"; async function remember(state: typeof MessagesAnnotation.State) { const store = getStore(); await store.put(["users", "alice"], "prefers", { units: "celsius" }); const saved = await store.get(["users", "alice"], "prefers"); // …use saved.value in your prompt } ``` In `dev` this is in-memory. In production it is Postgres, with vector search for semantic recall — and your node code does not change. See [storage.md](./storage.md) and [memory.md](./memory.md). ## 9. Ship it `dev` is not the production path. Production is `build` + `start`: ```bash npm run dev:services # Postgres + Redis via Docker npm run build # graphs → plain JavaScript in .skein/build npm start ``` Nothing to edit first: the `POSTGRES_URI` and `REDIS_URI` in `.env` already match those services, and `npm start` loads them itself. `build` compiles your TypeScript graphs ahead of time; `start` serves that output with no TypeScript toolchain in the loop. It is exactly what the production container runs. `start` requires Postgres and Redis on purpose. That is what makes runs survive a restart, lets a human approve an interrupt an hour later, and lets you run more than one instance. For a container, `npx skein up` brings up the whole stack, and `npx skein build` produces an image. Then pick a host: [Cloud Run](./deploy-cloud-run.md), [Fly](./deploy-fly.md), [Railway](./deploy-railway.md), [Render](./deploy-render.md), [AWS](./deploy-aws.md), [Kubernetes](./deploy-kubernetes.md), or [a plain VPS](./deploy-vps.md). Full guide: [deploy.md](./deploy.md). ## Where to next **Read [building blocks](./building-blocks.md).** It is the map of everything this guide skipped — checkpoints, threads, multitask, interrupts, memory, what production actually needs — a few lines each, so you can tell which page you want before you need it. If you would rather understand the graph model first, [LangGraph essentials](./langgraph-essentials.md) covers state, reducers, branching, subgraphs and `interrupt()`, and links out to the LangChain docs on each. Then, as you need them: - [Recipes](./recipes/) — auth, human-in-the-loop, background runs, CORS - [The console](./console.md) — what else that UI does: time travel, interrupt approvals, crons - [Agent Protocol](./agent-protocol.md) — every endpoint your server exposes - [Scaffolding reference](./scaffolding.md) — every flag, and the Nx generators --- # Getting started A guided, end-to-end path to a running Agent Protocol server, then to production. Pick the path that matches what you have right now: - **Nothing yet** → [Path A](#path-a--starting-from-scratch): one command scaffolds a working project. - **A `langgraph.json`** → [Path B](#path-b--i-have-a-langgraphjson-drop-in): a one-line drop-in. - **A compiled graph in code** → [Path C](#path-c--i-have-a-graph-in-code-embed): wrap it in `deps`. All three produce the **identical** server. For a terse reference instead of a walkthrough, see [using-skein.md](./using-skein.md). ## Prerequisites - **Node ≥ 20** and a package manager (`pnpm`/`npm`). - For Paths B and C, a **[LangGraph.js graph](https://docs.langchain.com/oss/javascript/langgraph/graph-api)** — a `CompiledStateGraph` from `@langchain/langgraph`. Path A writes one for you. ## Path A — Starting from scratch No graph, no config, no LangGraph experience. One command: ```bash npm create skein-js@latest my-agent cd my-agent npm run dev # → http://localhost:2024, console at /console ``` You get a `langgraph.json`, a keyless echo graph you can edit, a test, and the whole `dev` → `build` → `start` lifecycle wired up. Nothing needs an API key or a database to run. [**Your first agent**](./your-first-agent.md) walks through it end to end — what each generated file does, the four LangGraph concepts you need, adding a model and a tool, then deploying. [scaffolding.md](./scaffolding.md) is the flag-by-flag reference, and covers doing it by hand if you would rather not run a scaffolder. ## Path B — I have a `langgraph.json` (drop-in) If you already run `langgraph dev`, this is a one-line change. Keep your `langgraph.json` exactly as it is: ```jsonc // langgraph.json { "graphs": { "agent": "./src/agent.ts:graph" } } ``` Swap the CLI: ```diff - "dev": "langgraph dev", + "dev": "skein dev", ``` ```bash pnpm add -D skein-js pnpm dev # skein dev — in-process, hot-reload, on http://localhost:2024 ``` `skein dev` loads your TypeScript graphs through vite (no separate loader), hot-reloads on save, and persists dev state across restarts. `.env` counts as a save: filling in a model key there reloads the graphs, so a graph that could not load for want of a credential starts working without a restart. This is the full LangGraph CLI surface — [langgraph-cli-compat.md](./langgraph-cli-compat.md) documents every field and command. Prefer to mount it inside your own Express/Fastify/Nest/Next app instead of the CLI? Point an adapter at the same config — `{ config: "./langgraph.json" }` — using the snippets in [Mount it on your framework](./using-skein.md#mount-it-on-your-framework). ## Path C — I have a graph in code (embed) No `langgraph.json`, no CLI — bring the compiled graph you already hold and wrap it into a `ProtocolDeps` with `embedInMemoryGraphs`, then hand `{ deps }` to any adapter: ```ts // server.ts import { createExpressServer } from "@skein-js/express"; import { embedInMemoryGraphs } from "@skein-js/server-kit"; import { graph } from "./agent.js"; // your CompiledStateGraph const server = await createExpressServer({ deps: embedInMemoryGraphs({ agent: graph }) }); await server.listen(2024); console.log("Agent Protocol on http://localhost:2024"); ``` ```bash pnpm add @skein-js/express @skein-js/server-kit @langchain/langgraph npx tsx server.ts # or your usual TS runner ``` All three paths produce the **identical** Agent Protocol server. See [embedding.md](./embedding.md) for the graph-map/factory semantics and how `overrides` swaps in production drivers. ## Talk to your server With the server running on `:2024`, drive it with the standard SDK — no skein-specific client: ```ts import { Client } from "@langchain/langgraph-sdk"; const client = new Client({ apiUrl: "http://localhost:2024" }); const thread = await client.threads.create(); const reply = await client.runs.wait(thread.thread_id, "agent", { input: { messages: [{ role: "user", content: "hello" }] }, }); console.log(reply); ``` Stream tokens as they arrive instead of waiting: ```ts for await (const event of client.runs.stream(thread.thread_id, "agent", { input: { messages: [{ role: "user", content: "hello" }] }, })) { console.log(event.event, event.data); } ``` `"agent"` is the `assistant_id`, which defaults to the `graph_id`. The full endpoint surface is in [agent-protocol.md](./agent-protocol.md); the stream wire format is in [streaming.md](./streaming.md). ## Add a web UI The `useStream` React hook streams over the same server — point it at your URL: ```tsx import { useStream } from "@langchain/langgraph-sdk/react"; function Chat() { const thread = useStream({ apiUrl: "http://localhost:2024", assistantId: "agent" }); return ( ); } ``` A browser on a different origin needs CORS enabled — see the [CORS recipe](./recipes/serving.md#cors-for-a-browser-client). For a same-origin full-stack app (no CORS), the [`nextjs-app`](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-app) example serves the protocol and the UI from one Next.js app. See [react-sdk.md](./react-sdk.md). ## Go to production Dev uses in-memory drivers. For durability and horizontal scale, swap in Postgres (state + checkpoints) and Redis (run queue + cross-instance streaming). Nothing else about your server changes — only how `deps` is built. ```ts // embed path → durable, reading POSTGRES_URI / REDIS_URI from the environment import { embedPostgresGraphs } from "@skein-js/runtime"; import { createExpressServer } from "@skein-js/express"; import { graph } from "./agent.js"; const { deps, dispose } = await embedPostgresGraphs({ agent: graph }); const server = await createExpressServer({ deps }); await server.listen(2024); process.on("SIGTERM", async () => { await server.close(); await dispose(); // release the pools it opened process.exit(0); }); ``` From a `langgraph.json`, either call `buildRuntime({ store: "postgres", queue: "redis" })` and pass its `deps` **and** `channels` to the adapter, or skip code entirely: `skein dev --store postgres --queue redis`, and `skein build` / `skein up` to containerize. Redis is optional for a single instance but required to run more than one. Details: [embedding.md](./embedding.md#going-to-production), [storage.md](./storage.md), [runs-and-redis.md](./runs-and-redis.md), [deploy.md](./deploy.md). ## Where to next - [Your first agent](./your-first-agent.md) — the from-zero walkthrough, if you took Path A. - [Recipes](./recipes/) — auth, human-in-the-loop, long-term memory, CORS, background runs, deploy. - [Using skein-js](./using-skein.md) — the terse consumer/agent cheat-sheet. - [Examples](https://github.com/skein-js/skein-js/tree/main/examples) — a runnable project per framework and pattern. - [Overview & architecture](./index.md) · [Agent Protocol surface](./agent-protocol.md) --- # Scaffolding a project `create-skein-js` generates a working skein-js project from an empty directory. It is the fastest path from nothing to a running agent server, and it is optional — everything it emits is a file you could write yourself, and the last section shows you exactly that. For a guided walkthrough rather than a reference, start with [your first agent](./your-first-agent.md). ## Quick start ```bash npm create skein-js@latest my-agent ``` ```bash pnpm create skein-js my-agent yarn create skein-js my-agent npx create-skein-js my-agent ``` **Keep the `@latest`.** Without it, npm's npx cache — and `pnpm dlx`'s 24-hour cache — can serve a stale copy of the scaffolder. ## What it generates ```text my-agent/ ├── langgraph.json Points skein at your graphs; the LangGraph CLI's format ├── package.json The skein CLI lifecycle as scripts ├── tsconfig.json Strict, ESM, bundler resolution ├── vitest.config.ts ├── compose.dev.yaml Postgres + Redis — what `start` needs ├── .env Ready to use, gitignored — never overwritten if one already exists ├── .env.example A committed reference copy of it ├── .gitignore ├── README.md Explains each of these files └── src/ ├── echo-graph.ts Runs with no API key, no network ├── echo-graph.test.ts So `npm test` is green on the first commit └── agent-graph.ts Only with --provider: a ReAct agent with a working tool ``` The scripts are the whole skein CLI lifecycle: | Script | Command | What it does | | -------------- | ------------------------------------------------- | ----------------------------------------------------------- | | `dev` | `skein dev --port 2024` | In-memory drivers, hot reload, state persisted to `.skein/` | | `dev:services` | `docker compose -f compose.dev.yaml up -d --wait` | Postgres + Redis, for the durable `dev` and for `start` | | `dev:postgres` | `skein dev … --store postgres --queue redis` | The same hot reload, against the drivers production uses | | `build` | `skein build --artifact-only` | Graphs → plain JavaScript in `.skein/build` | | `start` | `skein start -c .skein/build/langgraph.json` | Serve that build — the production entrypoint | | `typecheck` | `tsc --noEmit` | | | `test` | `vitest run` | | That is the in-memory axis, which is the default. The storage prompt decides only which of the two `dev` spellings is which: pick Postgres and `dev` becomes the durable one, with the in-memory one emitted as `dev:memory` instead of `dev:postgres`. Both are always on disk, so the choice sets a default rather than removing an option. `dev:services` uses `--wait` so it returns once both healthchecks pass, not merely once the containers exist — otherwise the very next command races first-boot `initdb`. `start` names the artifact's own `langgraph.json` because `skein start` serves a _build_, not a source project: it wants `schemas.json` beside the config it loads, and that file only exists in `.skein/build`. Run from the project root like this, it also picks up the `POSTGRES_URI` and `REDIS_URI` from the project's `.env` — `skein start` reads a conventional `.env` from its working directory as well as from the config's, because an artifact deliberately carries none of its own (it is the Docker build context). So `dev:services && build && start` works with nothing to edit first. Two deliberate choices worth knowing: - **`dev` never needs a credential or a service.** The `echo` graph is always present and always first in `graphs`, so the very first request works with an empty `.env`. - **`compose.dev.yaml` is always generated**, not hidden behind a flag, and the `POSTGRES_URI` / `REDIS_URI` in `.env` are live rather than commented out. `skein start` is durable-only — it defaults to `--store postgres --queue redis` and fails without those two — so a project shipping a `start` script has to ship both the services and the URIs that reach them, or the script is a trap. The values are the ones the generated compose file serves, so there was never anything to decide. ## Options ```text create-skein-js [directory] -m, --provider none | google | anthropic | openai (default: prompted, else none) --pm npm | pnpm | yarn | bun (default: detected) --no-install Skip installing dependencies (else: prompted, default yes) --no-git Skip initializing a git repository (else: prompted) -y, --yes Accept every default; never prompt -f, --force Scaffold into a directory that is not empty -v, --version -h, --help ``` **Passing flags through npm** needs a `--` separator. `pnpm create` and `npx` do not: ```bash npm create skein-js@latest my-agent -- --provider anthropic pnpm create skein-js my-agent --provider anthropic ``` ### `--provider` | | Package added | `.env.example` gains | Emits `agent-graph.ts`? | | ------------------ | ------------------------- | -------------------- | ----------------------- | | `none` _(default)_ | — | — | no | | `google` | `@langchain/google-genai` | `GOOGLE_API_KEY` | yes | | `anthropic` | `@langchain/anthropic` | `ANTHROPIC_API_KEY` | yes | | `openai` | `@langchain/openai` | `OPENAI_API_KEY` | yes | With a provider you also get a ReAct agent wired to a live weather tool that needs no key of its own, so the agent is genuinely runnable the moment you add your model key. Until the key is set, `agent` fails to load naming the variable it wants — a [load-failure block](./errors-and-logging.md#the-load-failure-block), while `echo` keeps serving. `skein dev` watches `.env`, so filling the key in takes effect on save. ### Behaviour you can rely on - **It never hangs unattended.** Prompts appear only when both streams are a TTY, `--yes` was not passed, and `CI` is unset. Otherwise it takes the flag, then the default — safe inside a Dockerfile or a CI job. - **A failed install is not fatal.** Your files are already written; the closing output just adds `install` back to the steps. - **Scaffolding into a fresh clone works.** A directory holding only `.git`, `LICENSE`, editor folders or `.DS_Store` counts as empty, so "create an empty repo, clone it, scaffold into it" needs no `--force`. - **git is skipped inside an existing work tree**, so it never nests a repository in yours — and the closing output says so, rather than leaving you to infer it from a missing `.git`. A failure (git absent, or no `user.email` configured) is reported too, and distinctly: the two used to be the same silent non-event. - **The version is pinned to a matching runtime.** Because every `packages/*` shares one version, `create-skein-js@x.y.z` pins `skein-js@^x.y.z` — the scaffolder and the runtime it scaffolds are always the same release. ## Nx and other monorepos Scaffold into whatever directory you want — the generated project is self-contained, so it works inside a workspace as-is: ```bash npm create skein-js@latest apps/my-agent ``` There is deliberately **no skein Nx plugin**. A generator collection would be permanent public API tracking Nx's release cadence, and it would buy you one file you can write once and own yourself. Here is that file — `apps/my-agent/project.json`: ```json { "name": "my-agent", "projectType": "application", "targets": { "dev": { "executor": "nx:run-commands", "cache": false, "options": { "command": "skein dev --port 2024", "cwd": "apps/my-agent" } }, "build": { "executor": "nx:run-commands", "options": { "command": "skein build --artifact-only", "cwd": "apps/my-agent" } }, "start": { "executor": "nx:run-commands", "cache": false, "options": { "command": "skein start", "cwd": "apps/my-agent" } }, "typecheck": { "executor": "nx:run-commands", "options": { "command": "tsc --noEmit", "cwd": "apps/my-agent" } } } } ``` `dev` and `start` are marked `"cache": false` because a long-running server has no meaningful cached result. Explicit targets work on every Nx version, with no plugin to install and nothing to migrate. To share the workspace's TypeScript settings, replace the generated `tsconfig.json` with one that extends your base: ```json { "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["node"] }, "include": ["src/**/*.ts"] } ``` The same approach works for Turborepo, pnpm workspaces, or plain npm workspaces — the project is just a package with a `langgraph.json` in it. ## If you don't want the scaffolder Nothing here is load-bearing. Two alternatives: **Copy a runnable example.** The [`examples/`](https://github.com/skein-js/skein-js/tree/main/examples) directory has one project per framework and pattern: ```bash npx degit skein-js/skein-js/examples/express-basic my-agent ``` Note that this copies from `main`, which tracks unreleased work: the examples depend on `workspace:*` versions that only resolve inside the monorepo, so you will need to replace those with real version ranges. The scaffolder exists partly to avoid exactly that. **Write the three files yourself.** A skein project is a graph, a `langgraph.json`, and the CLI: ```bash npm install -D skein-js npm install @langchain/core @langchain/langgraph ``` ```ts // src/graph.ts import { AIMessage } from "@langchain/core/messages"; import { MessagesAnnotation, StateGraph } from "@langchain/langgraph"; export const graph = new StateGraph(MessagesAnnotation) .addNode("echo", (state) => ({ messages: [new AIMessage(`echo: ${state.messages.at(-1)?.content}`)], })) .addEdge("__start__", "echo") .addEdge("echo", "__end__") .compile(); ``` ```json // langgraph.json { "node_version": "24", "graphs": { "agent": "./src/graph.ts:graph" }, "env": ".env" } ``` Add `"type": "module"` to your `package.json`, then `npx skein dev`. That is the entire contract — see [langgraph-cli-compat.md](./langgraph-cli-compat.md) for every field it accepts. ## See also - [Your first agent](./your-first-agent.md) — the guided version of all of this - [Getting started](./getting-started.md) — the paths for when you already have a graph - [LangGraph CLI compatibility](./langgraph-cli-compat.md) — every `langgraph.json` field --- # Using skein-js in your app A dense, task-oriented cheat-sheet for **consuming** skein-js — pick a framework, stand up an Agent Protocol server around your LangGraph.js graph, and call it. Written to be skim-friendly for humans _and_ for AI coding agents building on skein. (Working _on_ skein itself? See [AGENTS.md](https://github.com/skein-js/skein-js/blob/main/AGENTS.md).) ## The one thing to know: the `{ config } | { deps }` seam Every framework adapter takes the **same options bag** (`SkeinRuntimeOptions`). You choose one of two inputs, plus optional common fields: ```ts // EITHER: let skein build an in-memory runtime from a langgraph.json (dev / zero-setup) { config: "./langgraph.json", importModule? } // OR: bring your own assembled ProtocolDeps (production drivers, custom auth, in-code graphs) { deps } // plus common: { logger?, cors?, warm? } ``` `config` → in-memory drivers, hot-reload, great for dev. `deps` → whatever you assembled (Postgres + Redis for production, or an in-code graph map). **Same server either way** — only the wiring differs. ## Install Starting from nothing? `npm create skein-js@latest my-agent` writes the project for you and you can skip this section. Otherwise pick your framework adapter; `@langchain/langgraph` is always a peer dependency (bring your graph). ```bash pnpm add @skein-js/express @langchain/langgraph # Express pnpm add @skein-js/fastify @langchain/langgraph # Fastify pnpm add @skein-js/nestjs @langchain/langgraph # NestJS pnpm add @skein-js/nextjs @langchain/langgraph # Next.js ``` For production drivers add `@skein-js/runtime` (assembles Postgres/Redis). Prefer the CLI on-ramp? `pnpm add -D skein-js` and run `skein dev` — a drop-in for `langgraph dev`. ## Three on-ramps **A — You have nothing yet.** Scaffold a working project — a `langgraph.json`, a keyless graph, a test, and the `dev`/`build`/`start` lifecycle: ```bash npm create skein-js@latest my-agent ``` See [scaffolding.md](./scaffolding.md), or [your-first-agent.md](./your-first-agent.md) for the walkthrough. **B — You have a `langgraph.json`** (or use the LangGraph CLI today). Change one script and keep the config unchanged: ```diff - "dev": "langgraph dev", + "dev": "skein dev", ``` Or point an adapter at the config: `{ config: "./langgraph.json" }`. See [langgraph-cli-compat.md](./langgraph-cli-compat.md). **C — You have a compiled graph in code** (no config, no CLI). Wrap it into `deps` and pass `{ deps }`: ```ts import { createExpressServer } from "@skein-js/express"; import { embedInMemoryGraphs } from "@skein-js/server-kit"; import { graph } from "./my-graph.js"; const server = await createExpressServer({ deps: embedInMemoryGraphs({ agent: graph }) }); await server.listen(2024); ``` `embedInMemoryGraphs(graphs, { overrides? })` builds a `ProtocolDeps` (store, queue, bus, checkpointer). See [embedding.md](./embedding.md). ## Mount it on your framework Each adapter ships a **standalone** server (`create*Server`) and an **embed-alongside-your-app** path. All accept the `{ config } | { deps }` seam above. ```ts // Express — standalone, or skeinRouter({...}) to mount on an existing app import { createExpressServer } from "@skein-js/express"; const server = await createExpressServer({ config: "./langgraph.json" }); await server.listen(2024); // Fastify — standalone, or app.register(skeinPlugin, { prefix: "/agent", config }) import { createFastifyServer } from "@skein-js/fastify"; await (await createFastifyServer({ config: "./langgraph.json" })).listen(2024); // NestJS — imports: [SkeinModule.forRoot({ config: "./langgraph.json" })] import { createNestServer } from "@skein-js/nestjs"; await (await createNestServer({ config: "./langgraph.json" })).listen(2024); // Next.js — App Router catch-all: app/api/[...path]/route.ts import { createSkeinRouteHandlers } from "@skein-js/nextjs"; export const runtime = "nodejs"; export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = createSkeinRouteHandlers({ deps }); ``` Each adapter has a **standalone** entry (a dedicated graph server) and an **embed-alongside-your-app** entry, each with a runnable example: | Framework | Package | Standalone (dedicated server) | Embed in an existing app | Examples | | --------- | ------------------- | --------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Express | `@skein-js/express` | `createExpressServer` | `skeinRouter` (mount the `Router`) | [express-basic](https://github.com/skein-js/skein-js/tree/main/examples/express-basic), [embed-graph](https://github.com/skein-js/skein-js/tree/main/examples/embed-graph) | | Fastify | `@skein-js/fastify` | `createFastifyServer` | `skeinPlugin` (`register` under a `prefix`) | [fastify-basic](https://github.com/skein-js/skein-js/tree/main/examples/fastify-basic), [fastify-app](https://github.com/skein-js/skein-js/tree/main/examples/fastify-app) | | NestJS | `@skein-js/nestjs` | `createNestServer` | `SkeinModule.forRoot` (import it) | [nestjs-basic](https://github.com/skein-js/skein-js/tree/main/examples/nestjs-basic), [nestjs-app](https://github.com/skein-js/skein-js/tree/main/examples/nestjs-app) | | Next.js | `@skein-js/nextjs` | — (the route handlers _are_ the server) | `createSkeinRouteHandlers` (App Router) · `createSkeinPagesHandler` (Pages) | [nextjs-app](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-app), [nextjs-basic](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-basic) | [`embed-graph`](https://github.com/skein-js/skein-js/tree/main/examples/embed-graph) is the framework-agnostic **in-code** pattern (`embedInMemoryGraphs` + `{ deps }`, no `langgraph.json`) shown on the Express adapter; the same `deps` works with any adapter above. [`react-usestream`](https://github.com/skein-js/skein-js/tree/main/examples/react-usestream) is a browser frontend for any of them. See [Expand your setup](#expand-your-setup) to grow from here. ### Where to point your client when embedding The protocol is served wherever you mount it, so set the client's `apiUrl` to the **mount root**, not the server root. How you set the mount differs per adapter: ```ts // Express — mount the router under a path const { router } = await skeinRouter({ deps }); app.use("/agent", router); // → apiUrl: http://localhost:2024/agent // Fastify — the plugin is encapsulated, so `prefix` isolates skein's routes + CORS await app.register(skeinPlugin, { prefix: "/agent", deps }); // → .../agent // NestJS — no skein-side option: it follows your app's global prefix app.setGlobalPrefix("api"); // → apiUrl: http://localhost:2024/api // Next.js — the catch-all's location, via `basePath` (defaults to "/api") // app/api/[...path]/route.ts → apiUrl: http://localhost:3000/api ``` Mount at the root (no prefix) and `apiUrl` is just the server root. NestJS is the odd one out: it reads the mount from the framework rather than from an argument you pass, so there is no skein-side option to keep in sync with `setGlobalPrefix`. ## Go to production (Postgres + Redis) Swap the in-memory `deps` for durable ones — everything else stays the same. Two ways: ```ts // In code: durable deps around graphs you hold (reads POSTGRES_URI / REDIS_URI) import { embedPostgresGraphs } from "@skein-js/runtime"; import { createExpressServer } from "@skein-js/express"; const { deps, dispose } = await embedPostgresGraphs({ agent: graph }); const server = await createExpressServer({ deps }); await server.listen(2024); process.on("SIGTERM", () => dispose().then(() => process.exit(0))); ``` ```ts // From a langgraph.json: pick drivers explicitly import { buildRuntime } from "@skein-js/runtime"; const rt = await buildRuntime({ configPath: "./langgraph.json", store: "postgres", queue: "redis", }); const server = await createExpressServer({ deps: rt.deps, channels: rt.channels, cors: rt.cors, }); ``` Redis is optional but **required to run more than one instance** (the in-memory queue is process-local). Or skip the code entirely: `skein dev --store postgres --queue redis`, and `skein build` / `skein up` for a container. See [embedding.md](./embedding.md#going-to-production), [storage.md](./storage.md), [runs-and-redis.md](./runs-and-redis.md). ## Call the server Any Agent Protocol client works — no custom SDK. The two you'll reach for: ```ts // Node / server-to-server — @langchain/langgraph-sdk import { Client } from "@langchain/langgraph-sdk"; const client = new Client({ apiUrl: "http://localhost:2024" }); const thread = await client.threads.create(); const input = { messages: [{ role: "user", content: "hello" }] }; const reply = await client.runs.wait(thread.thread_id, "agent", { input }); for await (const ev of client.runs.stream(thread.thread_id, "agent", { input })) console.log(ev); ``` ```tsx // Browser — @langchain/langgraph-sdk/react useStream (SSE) import { useStream } from "@langchain/langgraph-sdk/react"; const thread = useStream({ apiUrl: "http://localhost:2024", assistantId: "agent" }); thread.submit({ messages: [{ type: "human", content: "hello" }] }); ``` `assistantId` defaults to the `graph_id`. See [react-sdk.md](./react-sdk.md), [streaming.md](./streaming.md). ## Endpoint surface skein implements the standard Agent Protocol REST + SSE contract, so the SDK maps onto it directly. The resources: **assistants** (a served graph + its schemas), **threads** (persistent conversations), **runs** (`/runs/wait`, `/runs/stream`, background `/threads/{id}/runs` with join + cancel), and a long-term **store** (`/store/items`, semantic `/store/items/search`). The full endpoint inventory and the auth route→permission map live in [agent-protocol.md](./agent-protocol.md). ## Package → import map | You want to… | Import | From | | ----------------------------------------------- | ------------------------------------------ | -------------------------- | | Serve on Express / Fastify / Nest / Next | `create*Server` / `skein*` / `SkeinModule` | `@skein-js/` | | Embed a graph in code (in-memory) | `embedInMemoryGraphs` | `@skein-js/server-kit` | | Embed a graph in code (durable Postgres) | `embedPostgresGraphs` | `@skein-js/runtime` | | Assemble prod deps from a `langgraph.json` | `buildRuntime` | `@skein-js/runtime` | | Implement a storage driver / handle edge errors | `SkeinStore`, `SkeinHttpError` | `@skein-js/core` | | Put skein on a framework we don't ship | `skeinRoutes`, `createProtocolRuntime` | `@skein-js/agent-protocol` | ## Expand your setup Grow from the minimal server without rewrites — each step changes one thing: - **Add another graph.** Add an entry to the graph map (`embedInMemoryGraphs({ echo, agent })`) or to `langgraph.json`'s `graphs`. Each becomes an assistant, addressed by its `graph_id`. - **Standalone → embedded.** Move from a dedicated `create*Server` to the embed entry for your framework (`skeinRouter` / `skeinPlugin` / `SkeinModule.forRoot` / `createSkein*Handlers`) — see the adapter table above — to serve the protocol next to your existing routes, under a prefix if you want. - **Go durable / scale out.** Swap the in-memory `deps` for `embedPostgresGraphs(...)` or `buildRuntime({ store: "postgres", queue: "redis" })`. Add Redis to run more than one instance. See [Go to production](#go-to-production-postgres--redis). - **Drain more background runs at once.** Each instance executes 10 queued runs concurrently by default — tune it with `skein dev --concurrency 4`, `SKEIN_RUN_CONCURRENCY=4`, or `worker: { maxConcurrency: 4 }` on any adapter. See [run concurrency](./runs-and-redis.md#run-concurrency). - **Add auth, memory, HITL, webhooks.** These are drop-in — see the [recipes](./recipes/) (custom auth, `getStore()` long-term memory, interrupt/resume, run-completion webhooks). - **A framework we don't ship.** The adapters are thin shims over one transport-neutral handler table (`createProtocolRuntime` + `skeinRoutes`); put skein on any Node HTTP framework by writing ~40 lines of request/response mapping. See [building-an-adapter.md](./building-an-adapter.md). ## Gotchas - **Auth is off by default.** No `auth` block / no `auth` dep → the server is fully open, exactly like `langgraph dev`. Turn it on with a `@langchain/langgraph-sdk/auth` `Auth` instance — see the [provider recipes](./recipes/authentication.md) and [production recipe](./recipes/production.md#custom-auth). - **CORS is off by default.** Browser clients on another origin need `http.cors` in `langgraph.json` (or the `cors` option). Same-origin (e.g. Next.js) needs nothing. - **A long-lived Node process** is required for the background run worker and in-memory drivers — fine on a normal server / `next start`; for serverless, use Postgres + Redis. - **`useStream` needs an absolute URL** — pass `` `${window.location.origin}/api` ``, not a bare `/api`. - **Bundling skein yourself** (rspack/webpack/esbuild, or an unusual Next.js config)? skein is ESM-only but `require()`-resolvable, and `@langchain/langgraph-api` + `@typescript/vfs` must stay external. See [bundling.md](./bundling.md). - **404s on every protocol path?** You're almost certainly pointing at the wrong root — the protocol lives at your **mount path**, not the server root (see [Where to point your client](#where-to-point-your-client-when-embedding)). On NestJS that means `app.setGlobalPrefix("api")` moves it to `/api/threads`. Two red herrings worth ruling out: an `Unsupported route path: "/api/*"` warning in a NestJS boot log is Nest auto-converting the adapter's catch-all and is harmless, and `/info` isn't part of the surface — a 404 there is correct. ## Go deeper - [Getting started](./getting-started.md) — the guided, end-to-end walkthrough. - [Recipes](./recipes/) — auth, human-in-the-loop, long-term memory, CORS, background runs, deploy. - [Overview & architecture](./index.md) · [Agent Protocol surface](./agent-protocol.md) · [Embedding](./embedding.md) · [Building a custom adapter](./building-an-adapter.md) --- # Framework adapters An adapter is a thin transport shim: it takes the framework-agnostic handler table from `@skein-js/agent-protocol` and puts it on Express, Fastify, NestJS, Next.js, or the Web Fetch API. The engine, the endpoints and the wire format are identical on all five — what differs is how you mount it, and a handful of host-framework details this page exists to spell out. Every adapter takes the **same options bag**: `{ config }` to build a runtime from a `langgraph.json`, or `{ deps }` to bring [your own assembled `ProtocolDeps`](./embedding.md). That seam is the whole API. Picking a different adapter changes neither. ## Pick one Each adapter has a **standalone** entry — a dedicated agent server — and an **embed** entry that mounts the protocol inside an app you already run: | Adapter | Standalone | Mount into your app | Example | | ------------------- | ------------------------ | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@skein-js/express` | `createExpressServer` | `skeinRouter` → `app.use()` | [express-basic](https://github.com/skein-js/skein-js/tree/main/examples/express-basic) | | `@skein-js/fastify` | `createFastifyServer` | `skeinPlugin` under a `prefix` | [fastify-basic](https://github.com/skein-js/skein-js/tree/main/examples/fastify-basic) · [fastify-app](https://github.com/skein-js/skein-js/tree/main/examples/fastify-app) | | `@skein-js/nestjs` | `createNestServer` | `SkeinModule.forRoot()` | [nestjs-basic](https://github.com/skein-js/skein-js/tree/main/examples/nestjs-basic) · [nestjs-app](https://github.com/skein-js/skein-js/tree/main/examples/nestjs-app) | | `@skein-js/nextjs` | — (the routes _are_ it) | `createSkeinRouteHandlers` / Pages handler | [nextjs-app](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-app) · [nextjs-basic](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-basic) | | `@skein-js/fetch` | `createSkeinFetchServer` | — (no mount export) | — (covered by CI's runtime matrix) | Express is the default: it is what `skein dev` and the production image run, and what the other docs use in examples. Reach for another when you already have an app on it. ## What is identical everywhere The full endpoint surface (`/threads`, `/assistants`, `/runs`, `/store`, `/crons`), SSE streaming with the same frames, human-in-the-loop, auth, and every client — `useStream`, Agent Chat UI, the LangGraph SDK — work against any of them with only a URL change. A `ProtocolDeps` you assembled for one adapter mounts on any other unchanged. ## What differs This is the part worth reading before you pick: | | Express | Fastify | NestJS | Next.js | Fetch | | ------------------ | --------------------------- | ------------------------------ | ------------------------------------- | --------------------------- | ---------------------------------- | | **Mount path** | `app.use("/agent", …)` | `register({ prefix })` | `app.setGlobalPrefix()` only | `basePath` (default `/api`) | `basePath` (default `""`) | | **CORS** | bundled `cors` package | optional peer `@fastify/cors` | skein's middleware, skein routes only | built in | built in | | **JSON body cap** | 100 kb, via `json.limit` | Fastify's `bodyLimit` (1 MB) | the host's parser, else unbounded | unbounded | 100 kb, via `maxBodyBytes` | | **Logger default** | none — stays silent | `fastify.log` (pino) | Nest's `Logger` | none — stays silent | none — stays silent | | **`/ok` route** | standalone only | standalone only | standalone only | **never** | always | | **Shutdown** | `close()` drains the worker | `close()`, or plugin `onClose` | needs `enableShutdownHooks()` | **none** — nothing drains | `close()` after the listener stops | Two of these bite most often. **The body cap is not uniform** — only Express and Fetch impose one of their own, so on NestJS with `bodyParser: false`, and on Next.js, a large body is read into memory before auth runs. And **only Express and Fetch drain cleanly by default**: a Next.js deploy has no shutdown path at all, because the runtime lives on `globalThis` for the life of the process. ## Express The default, and the reference implementation every other adapter is checked against. ```ts import { createExpressServer, skeinRouter } from "@skein-js/express"; // Standalone — a dedicated agent server with a /ok probe: const server = await createExpressServer({ config: "./langgraph.json" }); await server.listen(2024); // Or mounted on an app you already run. `skeinRouter` is async — it seeds assistants // and starts the run worker before returning, so the router is ready to serve: const { router } = await skeinRouter({ deps }); app.use("/agent", router); ``` Express strips the mount path for you, so nothing needs to be kept in sync. Two options no other adapter has: `json.limit` (default 100 kb — a limit the parser can't understand is rejected at mount time rather than silently becoming unlimited) and `requestLog`. **It logs nothing by default.** Express owns no logger, and a library shouldn't decide to start writing to its host's stdout — pass `createConsoleLogger()` if you want output. ## Fastify ```ts import { createFastifyServer, skeinPlugin } from "@skein-js/fastify"; // Standalone: const server = await createFastifyServer({ config: "./langgraph.json" }); await server.listen(2024); // Embedded — encapsulated, so skein's hooks don't leak into your app: await app.register(skeinPlugin, { prefix: "/agent", config: "./langgraph.json" }); ``` Three things to know. **CORS needs an optional peer**: `@fastify/cors` is imported lazily and only when CORS is on, so enabling it without installing the package throws at registration rather than failing a request later. The plugin **replaces the JSON content-type parser** so an empty body sent with `Content-Type: application/json` is read as `{}` — Fastify's default parser 400s on it where Express does not, and the adapters have to agree. And SSE **hijacks the reply** and writes to the raw socket, which is why a client disconnect is detected there rather than on the request. There is no `json.limit`; Fastify's own `bodyLimit` applies. > [!WARNING] > **`createFastifyServer` ignores `http.disable_*`.** The standalone server does not forward the > route table that a `langgraph.json`'s disable flags produce, so it mounts the full protocol > surface even when the config asks for less. `skeinPlugin` honours them correctly, as do the other > four adapters. This is a bug, not a design choice — until it is fixed, use the plugin if you rely > on those flags. ## NestJS ```ts import { SkeinModule } from "@skein-js/nestjs"; @Module({ imports: [SkeinModule.forRoot({ config: "./langgraph.json" })] }) export class AppModule {} ``` NestJS is the odd one out on mounting: it reads its prefix from the framework via `app.setGlobalPrefix()` rather than from an argument you pass, so there is no skein-side option that can drift out of sync — but also no way to mount it somewhere else. Three consequences that look like bugs and aren't: - **`GET /api` 404s**, and so does `/info`. Nest never routes the bare prefix root to middleware, and no skein route lives at `/`. - **`SkeinModule` registers no `/ok`.** Only `createNestServer` does. Point health checks at a route you own, or use the standalone server. - **A log line about an unsupported route path** during boot is harmless. **Call `app.enableShutdownHooks()`** when embedding. skein stops the run worker on `beforeApplicationShutdown` so in-flight SSE streams settle before the server closes, and without the hooks that never fires. The standalone server does it for you. CORS is applied by skein's own middleware, scoped to skein's routes, so it behaves identically standalone and embedded — `app.enableCors()` governs your routes, not these. ## Next.js There is no standalone entry: the route handlers **are** the server, same-origin with your UI. ```ts // app/api/[...path]/route.ts — App Router import { createSkeinRouteHandlers } from "@skein-js/nextjs"; export const runtime = "nodejs"; // the run worker needs a long-lived process, not the edge export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = createSkeinRouteHandlers({ deps }); ``` ```ts // pages/api/[...path].ts — Pages Router import { createSkeinPagesHandler } from "@skein-js/nextjs"; // externalResolver tells Next this route settles the response itself, which silences the // "API resolved without sending a response" warning that SSE streams otherwise trigger. export const config = { api: { bodyParser: true, externalResolver: true } }; export default createSkeinPagesHandler({ deps }); ``` `basePath` defaults to `/api` and must match where you put the catch-all. The runtime is memoized on `globalThis`, keyed by config path or `deps` identity, so two route files sharing a config share one runtime — which is why a handler's own `logger` option outranks the shared runtime's, rather than whichever handler served the first request deciding for both. **This needs a warm process.** The in-memory drivers and the background run worker don't survive a function that scales to zero — a serverless deploy needs the Postgres store and Redis queue. See [deploy-serverless.md](./deploy-serverless.md). ## Fetch (Bun / Deno) The web-standard transport, and the production path on Bun and Deno. `skein build --runtime bun|deno` selects it for you. ```ts import { createSkeinFetchServer, startBunServer } from "@skein-js/fetch"; const skein = await createSkeinFetchServer({ config: "./langgraph.json" }); const listener = startBunServer(skein, { port: 2024 }); // Stop accepting connections first, then drain: await listener.stop(); await skein.close(); ``` It is the only adapter that caps request bodies itself — `maxBodyBytes`, default 100 kb — and it has to: `Bun.serve` defaults to 128 MB and `Deno.serve` has no limit at all, and the body is read before auth runs, so an unbounded read is an unauthenticated way to exhaust memory. `/ok` is always served, `basePath` defaults to `""`, and there is **no mount export and no invoke surface** — the handler is a whole-server `fetch` function. Composing it into a host router is yours to do. ## What you must get right - **`skeinRouter` is async.** `skeinRouter({ deps }).router` is `undefined`; await it first. It seeds assistants and starts the run worker before returning. - **Auth handlers that match on a path are not portable between adapters.** Express and NestJS report the full mount-inclusive URL, while Next.js and Fetch report it with the mount prefix stripped — so a handler matching `/threads` sees that on one and `/api/threads` on another. Match on the protocol-relative suffix, or keep the handler adapter-specific. - **Auth is off by default on the `{ deps }` path**, whichever adapter you mount it on. See the warning in [embedding.md](./embedding.md). - **Only the standalone servers add `/ok`** — except Next.js, which never does, and Fetch, which always does. Check the table above before pointing a platform health check at it. - **No adapter serves the console.** `http.console` is honoured by `skein start`'s Node runtime, which mounts it for you; on Bun/Deno the flag warns and does nothing. Mount it into your own app yourself, whichever adapter you are on — see [console.md](./console.md#mounting-it-yourself). ## See also - [Using skein-js](./using-skein.md) — the terse cheat-sheet version of this page - [Embedding a graph](./embedding.md) — the `{ deps }` seam every adapter accepts - [Errors & logging](./errors-and-logging.md#what-each-adapter-does-by-default) — logger defaults in full - [A graph as a plain endpoint](./serving-a-single-graph.md) — the invoke surface, per adapter - [Building an adapter](./building-an-adapter.md) — putting the handler table on a framework we don't ship --- # LangGraph CLI compatibility The `skein` CLI is a **drop-in replacement for the LangGraph CLI** — the migration path onto skein-js for anyone already using it. That means two things: read the same `langgraph.json`, and mirror the same command surface. ## Command mapping | LangGraph CLI | skein-js | Behavior | | ---------------------- | ------------------ | -------------------------------------------------------------- | | `langgraph dev` | `skein dev` | In-process dev server, hot reload, **no Docker**. Local state. | | `langgraph up` | `skein up` | Docker Compose stack (app + **Postgres + Redis**). | | `langgraph build` | `skein build` | Build a deployable Docker image from the config. | | `langgraph dockerfile` | `skein dockerfile` | Emit a standalone Dockerfile from the config. | | `langgraph deploy` | — | Out of scope (hosted-platform push). | Shared flags where sensible: `--port`, `--host`, `--no-reload`, `--config`, `-n, --n-jobs-per-worker`. Production commands (`up`, `build`, `dockerfile`, and the generated `start`) also accept `--runtime node|bun|deno` and `--runtime-version`. Precedence is CLI flag, then `skein.runtime` in config, then Node 24 LTS. Legacy `node_version` remains the Node version fallback. **skein-only:** `skein start` serves a pre-built `.skein/build` artifact — plain compiled JS, no vite, no reload. It is the production image's entrypoint, and it is also how you run skein **without a container**: `skein build --artifact-only` writes the artifact on any machine with Node, and `skein start` serves it from a systemd unit or any process manager. See [Without Docker](./deploy.md#without-docker). The LangGraph CLI has no equivalent — it transforms TypeScript at runtime inside the image. **skein-only dev flags** (beyond the LangGraph CLI) — run `skein dev` against production-shaped storage without Docker: | Flag | Values | Default | Notes | | ------------------ | -------------------- | -------- | --------------------------------------------------------------- | | `--store ` | `memory`, `postgres` | `memory` | `postgres` reads `POSTGRES_URI`; also selects `PostgresSaver`. | | `--queue ` | `memory`, `redis` | `memory` | `redis` reads `REDIS_URI` (BullMQ queue + Streams/pub-sub bus). | Graph hot-reload works with any driver; the `.skein/` snapshot is skipped for durable drivers (they persist inherently). **Background-run concurrency.** `-n, --n-jobs-per-worker ` is honored on `skein dev` **and** `skein start`, with the same default of **10** the LangGraph CLI uses — so an existing `langgraph dev -n 4` command line ports over unchanged. `--concurrency ` is the same knob under a self-evident name, and wins if both are passed. Without a flag, skein reads `SKEIN_RUN_CONCURRENCY`, then the LangGraph-compatible `N_JOBS_PER_WORKER` — which is how the setting reaches a container. See [runs-and-redis.md](./runs-and-redis.md#run-concurrency). One deliberate cosmetic difference: skein's banner prints `Starting 1 worker, up to 10 concurrent runs` where `langgraph dev` prints `Starting 10 workers`. LangGraph really does spawn 10 loops; skein runs one worker whose consumer executes 10 runs at a time. Same throughput, honest wording. `skein up`/`build`/`dockerfile` accept `--tag` (build) and `--output` (dockerfile) in addition to `--config`. **`skein build --artifact-only`** (skein-only) writes `.skein/build` — the bundled graphs, the pinned `package.json`, the baked `schemas.json`, and the generated Dockerfile — and stops without invoking Docker. Use it when something else builds the image: Kaniko or Cloud Build in CI, a platform that builds from a committed Dockerfile, or any machine that has Node but no Docker daemon. **Private/authenticated npm registries.** When your production dependencies include private scoped packages (e.g. `@myorg/*` behind a token), pass an `.npmrc` so the image's dependency install can authenticate. `skein build --npmrc ` and `skein up --npmrc ` mount it as a BuildKit **secret** — it authenticates the install without ever landing in an image layer or build history. The generated Dockerfile always declares this secret mount (`id=npmrc`); it is optional, so public-registry builds that pass no `.npmrc` are unaffected. Building the standalone `skein dockerfile` output by hand? Supply the same secret directly: ```bash docker build --secret id=npmrc,src=$HOME/.npmrc -t my-app . ``` `skein build`/`up` also emit a `.dockerignore` that excludes `.env*`, `node_modules`, `.git`, and `.skein` — so secrets are **never baked into image layers**. Runtime configuration reaches the container through the environment instead: `skein up` sets `POSTGRES_URI`/`REDIS_URI` in the compose `environment:` block, and you add any provider keys (e.g. `OPENAI_API_KEY` for semantic search) there too. Generated Docker assets carry a `# Generated by skein` marker; skein regenerates its own files but leaves a hand-edited `Dockerfile`/`compose.yaml` untouched. References: - LangGraph CLI docs — - `@langchain/langgraph-cli` source — ## Under the hood: what skein-js changes (transparently) skein-js keeps the **contract** identical — same `langgraph.json`, same graph code, same Agent Protocol on the wire, same clients — while re-implementing the runtime underneath with a different, open, self-hostable set of building blocks. None of this requires a change on your side; it's the "drop-in" promise honored at the implementation level. **Same graph code, same config, same wire protocol.** skein-js reuses the LangGraph runtime, checkpointers, the `langgraph.json` parser/schemas, and the SDK/types from the open `@langchain/*` packages (see [reuse.md](https://github.com/skein-js/skein-js/blob/main/docs/reuse.md)). It only rebuilds the durable-production, multi-framework, drop-in-CLI layer that isn't open — so your project moves over untouched. **What we implement, and the tools we use:** | Concern | skein-js implementation | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | CLI | [commander](https://github.com/tj/commander.js) — the `skein dev`/`up`/`build`/`dockerfile` command surface, plus `skein start` (below). | | Dev graph loading | [vite](https://vitejs.dev) loads your TypeScript graphs **in-process** — no separate build step. tsconfig `paths` aliases resolve, so graphs in an Nx/Turborepo/pnpm-workspace monorepo import shared workspace packages unchanged. | | Prod image build | `skein build`/`up` bundle graphs (+ auth/embed) to plain JS with `vite.build()` at build time — same alias resolution as dev, anchored at the workspace root — into a self-contained `.skein/build` artifact. The slim image installs prod deps only and runs the compiled output via `skein start` (no vite, no runtime TypeScript transform). | | Dev hot reload | **State-preserving** reload on source change: your threads, runs, and memory survive the reload. `.env` is watched too, so filling in a missing model key reloads the graphs instead of needing a restart. | | Dev persistence | Dev state is snapshotted to `.skein/` so it survives restarts (opt out with `--no-persist`). | | Run queue (prod) | [BullMQ](https://docs.bullmq.io) on Redis — background runs, retries, backoff, and crash recovery. | | Cross-instance streaming | [ioredis](https://github.com/redis/ioredis) + **Redis Streams**/pub-sub — join a run's SSE stream from any instance. | | Postgres store (prod) | [pg](https://node-postgres.com) + compiled-in schema migrations + **pgvector** semantic search. | | Checkpoints (prod) | LangGraph-native [`PostgresSaver`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/checkpoint-postgres) — reused, not reinvented. | **Transparent improvements over `langgraph dev`.** Because the storage/queue are pluggable drivers, `skein dev` can run against **production-shaped** Postgres/Redis **without Docker** (`--store postgres --queue redis`), and dev state persists across restarts — both beyond the stock LangGraph CLI dev server, and both fully opt-in. The wire behavior your clients see is unchanged either way. If any of this ever _does_ change observable behavior versus the LangGraph CLI, that's a compatibility bug — please [report it](https://github.com/skein-js/skein-js/issues). ## `langgraph.json` — fields we honor skein-js parses an existing `langgraph.json` **unchanged**. A `skein.json` may extend/override it but is never required. ```jsonc { // REQUIRED: map of graph id -> "path:export" "graphs": { "agent": "./src/agent.ts:graph", // exported compiled graph instance "chat": "./src/chat.ts:makeGraph", // or a factory function }, // JS/Node runtime pin (20 | 22 | 24) "node_version": "24", // Native production runtime (skein extension). Omit for Node. "skein": { "runtime": { "name": "bun", "version": "1.3.14" }, // Retention for `Idempotency-Key` records (skein extension; see below). Tuning only — // omitting the block does NOT disable the header. "idempotency": { "retention_hours": 24, "in_flight_minutes": 15 }, // Delivery policy for run-completion callbacks (skein extension; see below). Tuning only — // omitting the block does NOT make callbacks fire-once. "webhooks": { "retries": { "max_attempts": 12 }, "allowed_hosts": ["hooks.example.com"] }, }, // .env path OR inline map "env": ".env", // long-term memory store; semantic search config drives our pgvector index "store": { "index": { "embed": "openai:text-embedding-3-small", "dims": 1536, "fields": ["$"] }, // your own LangGraph BaseStore instead of skein's driver (skein extension) "adapter": "./src/my-store.ts:store", }, // checkpointer backend; "default" == Postgres (via PostgresSaver) "checkpointer": { "type": "default" }, // server customization — `cors` plus LangGraph's route toggles "http": { "cors": { "allow_origins": ["*"] }, "disable_assistants": false, "disable_threads": false, "disable_runs": false, "disable_crons": false, // skein-only: also stops the scheduler, not just the routes "disable_store": false, "disable_meta": false, // turns off GET /info; never the /ok health probe "console": false, // skein-only: serve the console at /console (or a path). Off unless set; // `skein dev` serves it anyway. See docs/console.md. // `http.app` (custom user routes) is still accepted and ignored }, // custom authentication + authorization (see below) "auth": { "path": "./src/auth.ts:auth", // a "@langchain/langgraph-sdk/auth" `Auth` instance "disable_studio_auth": false, }, // where runs report themselves — traces + lifecycle events (a skein extension; see below) "telemetry": { "langsmith": true, "posthog": { "host": "https://eu.i.posthog.com" }, "otel": true, "paths": ["./src/my-telemetry.ts:sink"], // your own TelemetrySink }, // packages your code loads BY NAME at runtime, which no bundler can discover (skein extension) "dependencies": ["@langchain/openai"], // extra Dockerfile lines appended after the base image "dockerfile_lines": [], } ``` ### How each field maps into skein-js | `langgraph.json` field | skein-js wiring | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `graphs` | [`@skein-js/config`](./storage.md) resolves each `path:export`, loading a compiled graph or `makeGraph` factory. Drives `/agents` introspection + run execution. | | `node_version` | Used by `skein build` / `skein dockerfile` base image selection. Defaults to Node 24 LTS when omitted; an explicit value is honoured verbatim, including an older one. | | `skein.runtime` | Native production server and pinned official image: `node`, `bun`, or `deno`. Bun/Deno use `@skein-js/fetch` with `Bun.serve`/`Deno.serve`, never Express compatibility. | | `skein.idempotency` | Retention for `Idempotency-Key` records. Tuning only — the header is honoured either way. See [below](#idempotency-skeinidempotency). | | `skein.webhooks` | How run-completion callbacks are retried and bounded. Tuning only — a run carrying a `webhook` owes a callback either way. See [below](#webhooks-skeinwebhooks). | | `env` | Loaded into `process.env` at boot (dev) / baked into the image (build). | | `store` | `store.index.{embed,dims,fields,hnsw}` configures pgvector semantic search on the Postgres driver (`hnsw: true` opts into the approximate index); `store.ttl.{default_ttl,refresh_on_read,sweep_interval_minutes}` expires items. `store.adapter` is a **skein extension** — `path:export` to your own LangGraph `BaseStore` (e.g. `PostgresStore`, `MongoDBStore`), which then serves the whole `/store` surface. See [storage.md](./storage.md). | | `checkpointer` | `"default"` → `PostgresSaver`; dev falls back to an in-memory `MemorySaver`. | | `http` | `http.cors` maps to the adapter's CORS options; the `disable_*` flags remove that resource's routes before mounting, so it 404s from the host app as under `langgraph dev`. `http.console` is skein-only — it serves the [console](./console.md) at `/console` (off unless set; `skein dev` serves it regardless). `http.app` is still accepted and ignored. | | `auth` | `auth.path` loads an `Auth` from `@langchain/langgraph-sdk/auth`; every request is authenticated + authorized; `disable_studio_auth` honored. | | `telemetry` | **skein extension.** Builds the telemetry sinks runs report to — see [observability.md](./observability.md). Unknown to `langgraph dev`, which ignores it. | | `dependencies` | **skein extension on the JS side** (LangGraph's schema has it for Python only). Extra packages `skein build` pins into the artifact — see below. | | `dockerfile_lines` | Appended by `skein dockerfile` / `skein build`. | **`dependencies` means something different here.** In LangGraph it is a Python-only field, required and used to drive `pip install`; the JS schema has no such field, because `langgraph build` copies your project into the image and runs your package manager over it. skein bundles instead, so it derives the image's dependency list from what the bundle still imports — and `dependencies` is the escape hatch for the packages that list cannot contain: the ones you load **by name at runtime** (`import("@langchain/" + provider)`), which never appear in any module graph. Local-path entries (`"."`, `"./pkg"`) are accepted and ignored — that source is already bundled. See [bundling.md](./bundling.md#what-skein-build-inlines-vs-externalizes). ## Graph loading (`path:export` notation) `@skein-js/config` resolves entries exactly like the LangGraph CLI: - `"./src/agent.ts:graph"` — imports the module and reads the `graph` export, which must be a `CompiledStateGraph`. - `"./src/agent.ts:makeGraph"` — reads a factory export and calls it (optionally with config) to obtain a `CompiledStateGraph`. This is the same contract LangGraph.js users already write against, so **no code changes are required** to move a project onto skein-js. ## Idempotency (`skein.idempotency`) A skein extension with no LangGraph counterpart, so it lives under the reserved `skein` namespace rather than at the top level — a top-level key would collide if LangGraph ever claimed the same name. ```jsonc { "skein": { "idempotency": { "retention_hours": 24, // how long a recorded response stays replayable "in_flight_minutes": 15, // how long an unfinished create blocks a retry "sweep_interval_minutes": 60, // how often expired records are reclaimed }, }, } ``` **This block is tuning, not an on/off switch.** Omitting it does not disable `Idempotency-Key`: a caller who sends one has been promised their retry will not start a second run, and honouring that is not a deployment opinion. The defaults are the values shown above. What each one is for: - `retention_hours` — the dedup window. Long enough to cover any provider's retry schedule, short enough that the table doesn't grow without bound. Raise it if you integrate a provider that retries over days. - `in_flight_minutes` — how long a create that hasn't finished holds its key before a retry may take it over. Keep it above your slowest `POST /runs/wait`, or a retry could be admitted alongside the original; keep it well under `retention_hours`, or an instance killed mid-request leaves its keys 409-ing until the retention expires. - `sweep_interval_minutes` — cadence of the background reclaim. Purely housekeeping: an expired record is taken over by the next claim whether or not the sweeper has run, so this affects disk, never correctness. Honoured identically on every driver combination, including `skein dev` with no Docker. See [agent-protocol.md](./agent-protocol.md#idempotent-run-creation-idempotency-key) for the request semantics and the replay table. ## Webhooks (`skein.webhooks`) LangGraph's `webhook` field is a bare URL with no delivery policy at all, so this block is a skein extension under the reserved namespace, for the same reason `skein.idempotency` is. The **payload shape is unchanged** — this configures delivery, not the body. ```jsonc { "skein": { "webhooks": { "retries": { "max_attempts": 12, // attempts before a callback is dead, counting the inline first one "initial_delay_ms": 1000, // the first retry's delay; the rest double from it }, // The signing key. Prefer SKEIN_WEBHOOK_SECRET, which wins over this — see below. "secret": "whsec_…", "max_payload_bytes": 262144, // cap on the stored body; over it, `values` is truncated "retain_hours": 24, // how long a settled delivery is kept before it is reclaimed "allowed_hosts": ["hooks.example.com"], // absent = no restriction (today's behaviour) "require_https": true, // refuse plaintext callbacks; absent permits them }, }, } ``` **This block is tuning, not an on/off switch.** Omitting it does not make callbacks fire-once: a run that carries a `webhook` owes a callback, and how hard the server tries is a deployment decision rather than permission to try at all. `retries.max_attempts: 1` is how you ask for one shot. What each one is for: - `max_attempts` — read it as a **time horizon, not a count**. The delays double, so 12 attempts is 1+2+4+…+1024 seconds ≈ **34 minutes**, which rides out a rolling deploy with room to spare. Six is not "half as patient": it is ~31 seconds, which does not survive one redeploy. There is deliberately no ceiling knob — the doubling tops out around 17 minutes at any sane attempt count, and on Redis the schedule is BullMQ's own exponential backoff rather than ours. - `initial_delay_ms` — the first retry's delay and the base the rest double from. Raise it for a receiver you know is slow to come back; lower it only if you also lower `max_attempts`. - `max_payload_bytes` — the body is **stored** so a retry has something to send, so an unbounded payload is unbounded rows. Over the cap, `values` is replaced by a truncation marker inside the signed body; everything a receiver needs to fetch the state itself survives. - `retain_hours` — how long a delivered or dead row is kept. Disk only; nothing is incorrect if the sweep never runs. - `secret` — the signing key, or a list during a rotation (the first signs). **Prefer the `SKEIN_WEBHOOK_SECRET` environment variable**, which takes precedence: a key here is a key in version control, and skein warns about it once at startup. Note that skein does **not** expand `${VAR}` anywhere in `langgraph.json`, so `"secret": "${SKEIN_WEBHOOK_SECRET}"` would sign every callback with that literal 23-character string. Absent on both → callbacks are unsigned, which is today's behaviour. - `require_https` — refuse a callback whose URL is not `https:`. Off by default, because an internal receiver on a trusted network is legitimate and turning it on for everyone would break those deployments on upgrade. Turn it on whenever callbacks leave your network: the body carries the run's final state, and retries mean it crosses the wire up to `max_attempts` times rather than once. - `allowed_hosts` — an exact-hostname allowlist. Set it if you accept run creates from untrusted callers: `webhook` is a caller-supplied URL, so it is a server-side request to a target they chose, and retrying it turns a one-shot SSRF probe into a repeated one. **Off by default**, because turning it on for everyone would make an upgrade start dropping deployments' own callbacks. A refused host is recorded `dead` with the reason rather than silently skipped. **Where the retries actually run depends on your queue driver.** With Redis, the whole schedule is BullMQ's — delayed jobs, exponential backoff with jitter, and re-delivery of a job whose worker was killed — so a retry still waiting survives a restart. Without it, skein polls the outbox instead: correct, but the schedule dies with the process, and skein warns at startup when your store is durable and your delivery schedule is not. To exercise the production path locally, `skein dev --queue redis`. Whether a callback is **durable** does not depend on any of this: the delivery is recorded in the same transaction as the run's terminal status, so it cannot be lost even with no queue at all. See [webhooks.md](./webhooks.md). ## Authentication + authorization (`auth`) skein-js implements LangGraph's **custom-auth** model. Point `auth.path` at a module exporting a [`@langchain/langgraph-sdk/auth`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/sdk) `Auth` instance — the same class LangGraph Platform uses, so an existing auth file is drop-in: ```ts import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth"; export const auth = new Auth() .authenticate(async (request) => { const token = request.headers.get("authorization")?.replace(/^Bearer /, ""); const user = token ? await verify(token) : undefined; if (!user) throw new HTTPException(401, { message: "Unauthorized" }); return { identity: user.id, permissions: user.scopes }; // becomes ctx.user }) .on("threads", ({ user }) => ({ owner: user.identity })); // scope threads (and their runs) by owner ``` - `.authenticate(...)` runs on every request; throwing an `HTTPException` (or returning nothing) is a `401`. Its return becomes the `user` passed to the `.on(...)` handlers. - `.on(":", ...)` (or a broader `"threads"` / `"*"`) authorizes each operation: return `false` for a `403`, or a **filter** object to scope the resource. A returned filter both hides other owners' rows on reads and stamps ownership onto new rows. Runs authorize through their owning thread (there is no separate `runs` resource), matching LangGraph. - **No `auth` block** → the server is fully open (unauthenticated), exactly as before. - `disable_studio_auth: false` (the default) lets LangGraph Studio traffic (`x-auth-scheme: langsmith`) through without authenticating, so local dev stays frictionless; set it to `true` to require real credentials from everyone. See [agent-protocol.md](./agent-protocol.md#authentication--authorization) for the full request lifecycle and the route → resource/action map. ## `dev` vs `up` - **`skein dev`** — a single Node app process with hot reload. It defaults to in-memory (optionally file-backed) state, so Docker is not required; pass `--store postgres --queue redis` to develop against the production drivers, including Postgres and Redis running in Docker. Fast feedback, the exact `langgraph dev` niche. - **`skein up`** — Docker Compose bringing up the app plus Postgres (checkpoints + protocol resources + pgvector) and Redis (queue + cross-instance streaming). Mirrors production — see [runs-and-redis.md](./runs-and-redis.md). ## Migrating an existing `langgraph dev` state When you run `langgraph dev`, LangGraph persists all local state — assistants, threads, runs, store items, and graph **checkpoints** (state + full history) — under a `.langgraph_api/` directory. skein reads that format and carries everything over, so switching loses nothing. **Automatic (the common case).** The first time you run `skein dev` in a project that has a `.langgraph_api/` directory but no skein state yet (`.skein/dev-state.json`), skein imports it on boot and logs what it brought over. From then on it persists to `.skein/` as usual, so the import runs exactly once. Nothing to do — your old threads and their history are just there. **Explicit — `skein import-langgraph`.** For re-running, previewing counts, or importing into a durable database: | Flag | Values | Default | Notes | | ------------------ | -------------------- | -------------------------- | ------------------------------------------------------------------------------------- | | `-c, --config` | path | `langgraph.json` | Resolves the project directory. | | `--store ` | `memory`, `postgres` | `memory` | `memory` writes `.skein/dev-state.json`; `postgres` loads a live DB (`POSTGRES_URI`). | | `--from ` | path | `/.langgraph_api` | Source directory to read. | | `--force` | — | off | Overwrite an existing `.skein/dev-state.json` (memory target). | ```bash skein import-langgraph # → .skein/dev-state.json, then `skein dev` skein import-langgraph --store postgres # → your POSTGRES_URI (checkpoints + resource rows) ``` **What carries over, and what doesn't.** Assistants (with their **version history**), threads (with their latest state), runs, store items, and the **full checkpoint history** are all migrated. Because skein uses LangGraph's own `MemorySaver`/`PostgresSaver`, the checkpoint format is identical — history is preserved exactly. Minor, deliberate drops: the store's derived **embedding index** (`vectors`) is not copied (skein re-indexes on write / a configured `store.index` re-embeds on Postgres import), and LangGraph **retry counters** aren't part of skein's model. Run **webhooks** _are_ carried through, so an imported run still fires its completion webhook. > The `.langgraph_api/` layout is a `@langchain/langgraph-api` internal (stable across 1.2.x–1.4.x), > not a public API. The importer is best-effort and guarded — a format it can't read never blocks > `skein dev` from starting. > > **Already on Postgres?** If your LangGraph deployment already uses a Postgres checkpointer, you > don't need to migrate checkpoints at all: point skein at the **same `POSTGRES_URI`** and it shares > the exact same checkpoint tables. `skein import-langgraph --store postgres` is for moving an > in-memory `langgraph dev` (including one run in production) onto a durable skein deployment. --- # Embedding a graph you already have > **User guide.** skein-js has **two on-ramps**. If you already run the LangGraph CLI, the > [drop-in](./langgraph-cli-compat.md) (`langgraph dev` → `skein dev`, unchanged `langgraph.json`) is > for you. This doc is the **other** on-ramp: you have a LangGraph.js graph in your own app and never > adopted the LangGraph Platform's project shape — no `langgraph.json`, no CLI. Bring the compiled > graph in code and get the same Agent Protocol server in a few lines. ## Two on-ramps | | Drop-in CLI (`{ config }`) | In-code embedding (`{ deps }`) | | -------------------- | ----------------------------------------------------- | --------------------------------------------------------------- | | You start from | a `langgraph.json` + the `skein` CLI | a **compiled graph object** in your own code | | Wiring | `createExpressServer({ config: "./langgraph.json" })` | `createExpressServer({ deps: embedInMemoryGraphs({ graph }) })` | | Graph loading | `path:export` resolved from disk (vite/TS loader) | you already hold the graph — nothing is loaded from disk | | Best for | migrating off / comparing against the LangGraph CLI | greenfield apps, or anyone who never used the Platform | | Static graph schemas | ✅ extracted from source | 🟡 stubbed (see [trade-off](#the-one-trade-off-schemas)) | Both produce the **exact same** Agent Protocol server — same threads/runs/streaming/HITL/persistence, same `useStream` / Agent Chat UI / LangGraph SDK compatibility. The only difference is how graphs get in and how `ProtocolDeps` is assembled. Everything downstream is identical. ## The whole thing ```ts import { createExpressServer } from "@skein-js/express"; import { embedInMemoryGraphs } from "@skein-js/server-kit"; import { graph } from "./my-graph.js"; // ← your existing `new StateGraph(...).compile()` const server = await createExpressServer({ deps: embedInMemoryGraphs({ agent: graph }) }); await server.listen(2024); ``` That's a full server. Point any Agent Protocol client at `http://localhost:2024`: ```ts import { Client } from "@langchain/langgraph-sdk"; const client = new Client({ apiUrl: "http://localhost:2024" }); const thread = await client.threads.create(); await client.runs.wait(thread.thread_id, "agent", { input: { messages: [{ role: "user", content: "hello" }] }, }); ``` `embedInMemoryGraphs` ([`@skein-js/server-kit`](https://github.com/skein-js/skein-js/tree/main/packages/server-kit)) turns a **graph map** into a `ProtocolDeps` backed by in-process drivers — the store, run queue, event bus, and checkpointer. No config file, and nothing to import from a storage package. `{ deps }` is the seam **every** adapter accepts, so the same `deps` mounts on Express, Fastify, NestJS, or Next.js unchanged. Runnable version: [`examples/embed-graph`](https://github.com/skein-js/skein-js/tree/main/examples/embed-graph). > **⚠️ Auth is off by default.** `embedInMemoryGraphs` sets no `auth`, so the server it produces > **authenticates nothing** — every request is allowed (the same default as a `langgraph.json` with no > `auth` block). That's fine behind your own middleware or on a private network, but mounting `{ deps }` > on a **public** app exposes `/threads`, `/runs`, and `/store` to anyone — including running your graph > (spending model tokens) and reading/writing the long-term store. Add an `auth` engine before you go > public — see [Bring your own drivers, auth, logger](#bring-your-own-drivers-auth-logger). ## The graph map Keys become graph ids (one auto-registered assistant each). Values are either a **compiled graph** or a **factory** — a function that builds one, called with the run's `configurable`. Factories are how you defer expensive or key-requiring construction until a graph is actually run: ```ts embedInMemoryGraphs({ echo, // a compiled graph, imported eagerly // built lazily on first use — keeps a keyless boot when the model needs an API key: agent: async () => (await import("./agent-graph.js")).graph, // or per-run config: (config) => buildGraph(config.configurable?.model), }); ``` A concretely-typed `.compile()` result (e.g. from `MessagesAnnotation`) is accepted **without a cast** — the [`EmbeddableGraph`](#api-reference) type leaves the graph's generics open on purpose. ## Standalone or embedded `{ deps }` works with every adapter, in both its standalone and embedded form: ```ts const deps = embedInMemoryGraphs({ agent: graph }); // Express — standalone server, or mounted on your existing app. // `skeinRouter` is async: it seeds assistants and starts the run worker before returning. await (await createExpressServer({ deps })).listen(2024); app.use((await skeinRouter({ deps })).router); // Fastify — plugin under a prefix: await app.register(skeinPlugin, { prefix: "/agent", deps }); // NestJS — dynamic module: @Module({ imports: [SkeinModule.forRoot({ deps })] }) // Next.js — App Router catch-all (same-origin, no second server): export const { GET, POST, PUT, PATCH, DELETE, OPTIONS } = createSkeinRouteHandlers({ deps }); ``` The Next.js App Router case is the lightest full-stack story — an 11-line `route.ts` serving the protocol same-origin behind a `useStream` UI. See [`examples/nextjs-app`](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-app). Each adapter mounts a little differently — prefixes, CORS, body limits, `/ok` and shutdown all vary. [adapters.md](./adapters.md) covers each one in full. ## Bring your own drivers, auth, logger `embedInMemoryGraphs(graphs, overrides)` takes a second argument that replaces any field of `ProtocolDeps` except `graphs` (the first argument is the single source of graphs) — a driver, an `auth` engine, a `logger`. Supplying `auth` is how you close the open-by-default surface from the warning above: ```ts import { loadAuthEngine } from "@skein-js/config"; embedInMemoryGraphs({ agent: graph }, { auth: await loadAuthEngine(/* … */), logger: myLogger }); ``` A `logger` set here is used unless the adapter is given an explicit `logger` option, which wins. Under NestJS and Fastify, leaving both unset falls back to the host framework's own logger rather than to silence — see [errors-and-logging.md](./errors-and-logging.md#what-each-adapter-does-by-default). ## Going to production The in-memory drivers are ideal for a single long-lived process (dev, tests, a small app). For durable, horizontally-scalable state, use **`embedPostgresGraphs`** — the persistent sibling of `embedInMemoryGraphs`. Same graph-in-code call, but it assembles a Postgres store + `PostgresSaver` checkpointer and (when a Redis URL is present) a Redis run queue + event bus, reading `POSTGRES_URI` / `REDIS_URI` from the environment: ```ts import { createExpressServer } from "@skein-js/express"; import { embedPostgresGraphs } from "@skein-js/runtime"; import { graph } from "./my-graph.js"; const { deps, dispose } = await embedPostgresGraphs({ agent: graph }); // reads POSTGRES_URI / REDIS_URI const server = await createExpressServer({ deps }); await server.listen(2024); // It owns pools/connections, so release them on shutdown (embedInMemoryGraphs has nothing to release): process.on("SIGTERM", () => dispose().finally(() => process.exit(0))); ``` > **⚠️ Auth is still off by default — and this is the production path.** Like `embedInMemoryGraphs`, > `embedPostgresGraphs` sets no `auth`, so the server **authenticates nothing**. That's easy to miss > here precisely because you reach for this helper to _deploy_: shipping it public with no `auth` > exposes `/threads`, `/runs`, and `/store` — and running your graph (spending model tokens) — to > anyone. Pass an `auth` engine via `overrides` before you go public (see below). It lives in [`@skein-js/runtime`](https://github.com/skein-js/skein-js/tree/main/packages/runtime), not `@skein-js/server-kit` — a persistent helper pulls in the Postgres/Redis drivers that `server-kit` deliberately avoids. Pass explicit `postgresUri` / `redisUri` (and `index` for pgvector, `ttl`, `poolMax`, `sslNoVerify`) instead of env vars if you prefer, and `overrides` for `auth` / `logger` / etc. — see the [API reference](#api-reference): ```ts import { loadAuthEngine } from "@skein-js/config"; const { deps, dispose } = await embedPostgresGraphs( { agent: graph }, { overrides: { auth: await loadAuthEngine(/* … */) } }, ); ``` > **Redis is optional, but then you're single-instance.** With no `redisUri` / `REDIS_URI`, the run > queue + event bus fall back to in-memory: state still survives a restart (it's in Postgres), but the > run queue is process-local and streaming isn't fanned across instances, so you **can't run more than > one instance**. Set a Redis URL to scale horizontally. **Sizing the in-memory bus.** On the Redis-less path the event bus holds run frames in the process, bounded by `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN` (10000) and `SKEIN_MEMORY_BUS_MAX_RETAINED_RUNS` (50) rather than by a Redis TTL. The worst case is roughly `MAX_FRAMES_PER_RUN × (concurrent runs + MAX_RETAINED_RUNS)` — half a million frames at the defaults, which is hundreds of MB. Both are read from the environment, so they reach an embedded host without a code change. Size them against what your graph actually emits, and note that a far-behind subscriber loses the oldest frames here where Redis would still have them: [performance.md](./performance.md) has the sizing table and the triage symptoms. **Signing run-completion callbacks.** `SKEIN_WEBHOOK_SECRET` is read on the embed paths too, so setting it is all an embedded host has to do to get signed callbacks — there is no `langgraph.json` here to carry a `skein.webhooks` block. Pass `overrides.webhooks` to configure the rest of the delivery policy (retries, `allowed_hosts`, `max_payload_bytes`); it is spread last, so an explicit value wins over the environment. See [webhooks.md](./webhooks.md). > Before this was wired up, an embedded host that exported `SKEIN_WEBHOOK_SECRET` sent **unsigned** > callbacks with no warning — the env var was only read on the `langgraph.json` path. If you embed and > rely on signatures, check you are on a version that includes this. Prefer to assemble the drivers yourself (e.g. a Postgres store with an in-memory queue, or your own pool)? Pass them through `embedInMemoryGraphs`' `overrides`: ```ts import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres"; import { RedisRunEventBus, RedisRunQueue } from "@skein-js/redis"; import { createPostgresPool, PostgresSkeinStore } from "@skein-js/storage-postgres"; const checkpointer = new PostgresSaver(createPostgresPool(process.env.POSTGRES_URI!)); await checkpointer.setup(); const deps = embedInMemoryGraphs( { agent: graph }, { store: await PostgresSkeinStore.connect(process.env.POSTGRES_URI!), checkpointer, queue: new RedisRunQueue(process.env.REDIS_URI!), bus: new RedisRunEventBus(process.env.REDIS_URI!), }, ); ``` If you _do_ have a `langgraph.json`, [`@skein-js/runtime`](https://github.com/skein-js/skein-js/tree/main/packages/runtime)'s `buildRuntime({ configPath, store: "postgres", queue: "redis" })` assembles all of these for you — `embedPostgresGraphs` is the same assembly for graphs you hold in code. Serverless/edge deploys need durable drivers: the in-memory drivers (and the background run worker) assume one warm process, so they don't survive a function that scales to zero. See [storage.md](./storage.md) and [runs-and-redis.md](./runs-and-redis.md). Bundling the result yourself? The in-code path is the friendly one — it never reaches the `langgraph.json` graph loader, so everything on it bundles cleanly, `@skein-js/storage-postgres` included. See [bundling.md](./bundling.md). ## The one trade-off: schemas A **compiled** graph no longer carries its TypeScript source, so the in-code path can't extract real input/output/state JSON schemas — `embedInMemoryGraphs` returns a minimal `{ graph_id }` stub for the assistants introspection endpoints. This is enough for **everything `useStream` and Agent Chat UI render**; the only thing that degrades is **LangGraph Studio's** schema-driven forms and its graph/step views. If you need full static schemas, use the [`{ config }` path](./langgraph-cli-compat.md) — the `langgraph.json` loader runs `getStaticGraphSchema` over the graph source at build time. ## API reference From [`@skein-js/server-kit`](https://github.com/skein-js/skein-js/tree/main/packages/server-kit): ```ts // Build a ProtocolDeps around in-process drivers. Pass a graph map OR a ready GraphResolver. function embedInMemoryGraphs( graphs: GraphResolver | Record, overrides?: Omit, "graphs">, // every driver/auth/logger except `graphs` ): ProtocolDeps; // Turn just the graph map into a GraphResolver (the ids/load/schemas seam the engine consumes). function graphMapToResolver(graphs: Record): GraphResolver; // A graph you can embed: any compiled LangGraph.js graph, or a factory that builds one per run. type EmbeddableGraph = CompiledGraph | ((config: { configurable?: Record }) => …); ``` > `embedInMemoryGraphs` was previously named `createInMemoryDeps`. The old name is still exported as a > deprecated alias, so existing imports keep working — prefer `embedInMemoryGraphs` in new code. `graphMapToResolver` is useful on its own when you want the resolver but your **own** `ProtocolDeps`. `normalizeEmbeddableGraphs(graphs)` accepts either a graph map or a ready `GraphResolver` and returns a `GraphResolver` — the same normalization both embed helpers apply. ### Assembling `ProtocolDeps` by hand The engine drives an **`AgentGraph`** (`stream` + `getState` required, the rest optional), so `@skein-js/agent-protocol` installs with no graph runtime. Running **LangGraph.js** graphs therefore needs the binding wired in — four fields the embed helpers set for you, and which a hand-assembled `ProtocolDeps` must set itself: ```ts import { cloneLangGraphCheckpoint, langGraphResolver, SkeinBaseStore } from "@skein-js/langgraph"; import { MemorySaver } from "@langchain/langgraph"; const deps: ProtocolDeps = { store, queue, bus, checkpointer, // Translates the engine's command envelope into a LangGraph `Command`. // Without it, human-in-the-loop resume silently no-ops. graphs: langGraphResolver(graphMapToResolver({ agent })), // Bridges long-term memory in, so nodes reach it via `getStore()`. storeBridge: (repo) => new SkeinBaseStore(repo), // The throwaway saver `POST /invoke/:graph_id` runs against, so nothing persists. ephemeralCheckpointer: () => new MemorySaver(), // Clones a checkpoint when it is re-put under a new thread id (copy / prune / rollback). cloneCheckpoint: cloneLangGraphCheckpoint, }; ``` `langGraphResolver` is safe to apply to any resolver: it binds LangGraph compiled graphs and returns anything else untouched, so a resolver fronting your own `AgentGraph` still receives the envelope. From [`@skein-js/runtime`](https://github.com/skein-js/skein-js/tree/main/packages/runtime) (the durable path — see [Going to production](#going-to-production)): ```ts // Build a durable ProtocolDeps (Postgres store + PostgresSaver, Redis queue/bus when configured) and a // dispose() to release the pools/connections it owns. Async, because it connects + migrates on the way up. function embedPostgresGraphs( graphs: GraphResolver | Record, options?: { postgresUri?: string; // default process.env.POSTGRES_URI (required — one of the two) redisUri?: string; // default process.env.REDIS_URI (absent → in-memory queue/bus, single instance) index?: StoreIndexConfig; // pgvector semantic search (a resolved embedder) ttl?: StoreTtl; // store-item expiry + background sweep threadTtl?: ThreadTtl; // thread expiry — the in-code `checkpointer.ttl` poolMax?: number; // default env PG_POOL_MAX sslNoVerify?: boolean; // default env DATABASE_SSL_NO_VERIFY connectionTimeoutMs?: number; // default env PG_CONNECTION_TIMEOUT_MS, else 30s (0 = wait forever) idleTimeoutMs?: number; // default env PG_IDLE_TIMEOUT_MS statementTimeoutMs?: number; // default env PG_STATEMENT_TIMEOUT_MS, else 30s (0 = no limit) maxPageSize?: number; // default env SKEIN_MAX_PAGE_SIZE, else 1000 (list/search bound) overrides?: Omit, "graphs" | "store" | "queue" | "bus" | "checkpointer">; }, ): Promise<{ deps: ProtocolDeps; dispose(): Promise }>; ``` Unlike `embedInMemoryGraphs` (which returns a plain `ProtocolDeps`), `embedPostgresGraphs` is **async** and returns `{ deps, dispose }` — it owns Postgres pools and Redis connections, so you must `dispose()` them on shutdown. ## See also - [langgraph-cli-compat.md](./langgraph-cli-compat.md) — the other on-ramp (drop-in CLI + `langgraph.json`) - [agent-protocol.md](./agent-protocol.md) — the endpoints you get either way - [building-an-adapter.md](./building-an-adapter.md) — putting the engine on any HTTP framework - [storage.md](./storage.md) · [runs-and-redis.md](./runs-and-redis.md) — swapping in production drivers - [`examples/embed-graph`](https://github.com/skein-js/skein-js/tree/main/examples/embed-graph) · [`examples/nextjs-app`](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-app) --- # Serving a graph as a plain endpoint — no threads, no runs > **User guide.** skein's main surface is the full [Agent Protocol](./agent-protocol.md) — threads, > assistants, runs, streaming, history, human-in-the-loop. That's what a **chat** app needs. Plenty of > LangGraph work isn't chat: a classifier, an extractor, an enrichment step another service calls. For > those, this page describes a smaller surface — **one graph, one endpoint, called like a function**. ## Which surface do I want? | | Full Agent Protocol | Single-graph invoke | | ------------------ | -------------------------------------------------------------- | -------------------------------------------- | | Mount | `skeinRouter` / `skeinPlugin` / `SkeinModule` / route handlers | `skeinInvoke*` / `SkeinInvokeModule` | | Endpoint | `/threads`, `/assistants`, `/runs`, `/store`, … | `POST /invoke/:graph_id` | | Request | `{ input, config, stream_mode, … }` on a thread | the graph input, raw | | Response | a run object (or an SSE run stream) | the final graph state | | Conversation state | persisted per thread (resume, history, time travel) | **none** — each call is independent | | Best for | chat, HITL, anything resumable | classification, extraction, batch, workflows | | Clients | `useStream`, Agent Chat UI, LangGraph SDK | `fetch`, `curl`, any HTTP client | Both run the _same_ graph through the same LangGraph machinery. The difference is how much protocol sits in front of it. Mounting both is supported and safe: each call attaches its checkpointer and store to a per-call clone of the compiled graph, so an invoke never disturbs a concurrent protocol run's durable state. ## The whole thing ```ts import { skeinInvokeRouter } from "@skein-js/express"; import { embedInMemoryGraphs } from "@skein-js/server-kit"; import express from "express"; import { graph as triage } from "./triage-graph.js"; const app = express(); const { router } = await skeinInvokeRouter({ deps: embedInMemoryGraphs({ triage }) }); app.use(router); app.listen(2024); ``` ```bash curl -sX POST localhost:2024/invoke/triage \ -H 'content-type: application/json' \ -d '{"text":"Refund charge failed — urgent!"}' # {"text":"Refund charge failed — urgent!","category":"billing","priority":"P1"} ``` Runnable version: [`examples/invoke-endpoint`](https://github.com/skein-js/skein-js/tree/main/examples/invoke-endpoint). ## The contract **The request body is the graph input. The response is the graph's final state.** No envelope in either direction — the graph behaves like a function over HTTP. - **One endpoint per graph.** Every id in the graph map (or in `langgraph.json`) is mounted at `POST /`. The prefix defaults to `/invoke`. - **An unregistered id is a 404**, with `code: "graph_not_found"`. - **An empty body runs the graph with its state defaults** rather than surfacing LangGraph's opaque `EmptyInputError` — useful for graphs that take no input at all. - **Responses are serialized with the same wire encoder** the protocol uses, so LangChain messages in a final state flatten to the `{ type, content }` shape clients expect. - **A graph that throws is an error response**, mapped through the adapter's normal error path. ## Streaming The default is a single JSON response. Send `Accept: text/event-stream` and the _same_ endpoint streams the graph's steps as SSE instead, ending with a terminal `end` (or `error`) event: ```bash curl -NsX POST localhost:2024/invoke/triage \ -H 'content-type: application/json' -H 'accept: text/event-stream' \ -d '{"text":"Outage — everything is down"}' ``` ```text id: 1 event: values data: {"text":"Outage — everything is down","category":"","priority":""} id: 2 event: values data: {"text":"Outage — everything is down","category":"general","priority":""} event: end data: {"status":"success"} ``` The mode defaults to `values` (each frame is the full state after a step, ending at the value the JSON response would have returned). Override it per mount with `streamMode`, or per request with `?stream_mode=updates` (comma-separate for several). Modes are validated at the boundary, so an unknown one is a 400 rather than an opaque failure deeper in. If the graph throws mid-stream the failure arrives as an `error` frame — headers are already sent, so it can't become an HTTP status. `stream_mode=events` is **not** available here: it is not a Pregel stream mode (the run engine serves it from `graph.streamEvents`, while this surface drives `graph.stream`), so it is rejected with a 400. Use the Agent Protocol run endpoints for token-level events. ## On every adapter The surface is the same everywhere; only the mount idiom differs. ```ts const deps = embedInMemoryGraphs({ triage, extract }); // Express — a Router you mount const { router } = await skeinInvokeRouter({ deps }); app.use(router); // Fastify — a plugin, encapsulated under its prefix await app.register(skeinInvokePlugin, { prefix: "/agent", deps }); // → POST /agent/invoke/:graph_id // NestJS — a dynamic module, alongside your controllers @Module({ imports: [SkeinInvokeModule.forRoot({ deps })] }) export class AppModule {} // Next.js App Router — app/api/invoke/[graph_id]/route.ts export const runtime = "nodejs"; export const { POST } = createSkeinInvokeRouteHandlers({ deps, basePath: "/api/invoke" }); ``` Each accepts the same `{ config } | { deps }` seam as the full protocol, so `skeinInvokeRouter({ config: "./langgraph.json" })` works too — see [embedding.md](./embedding.md). The Express/Fastify/NestJS mounts claim only their own path and pass everything else through, so the host app's routes are untouched. ## Auth `deps.auth`, when configured, is enforced here exactly as on a run — invoking a graph _runs_ it (spending model tokens), so this is not a way around the gate. The caller is authenticated (401 on failure) and authorized against `threads:create_run` (403 on deny), and the authenticated principal is stamped into the graph's `configurable` as `langgraph_auth_user`, just like a protocol run. Auth runs _before_ the graph-exists check, so an unknown id also returns 401 rather than 404 — an anonymous caller can't enumerate which graphs you serve by telling the two apart. > **⚠️ With no `auth` configured, this endpoint is open** — the same default as the full protocol > (and as `langgraph dev`). That's fine behind your own middleware or on a private network; anywhere > public, wire an `auth` engine before you ship. See [embedding.md](./embedding.md#bring-your-own-drivers-auth-logger). ## What it deliberately doesn't do Each call is **independent**: no thread is created, nothing is persisted between calls, and the run never appears in `/runs`. Concretely, this surface has no: - **conversation state** — no thread id, no history, no time travel; - **interrupts / human-in-the-loop** — there is no thread to resume into; - **background runs, run rows, or webhooks** — the call is inline; there is no run to cancel by id; - **assistants** — you address the `graph_id` directly, with no assistant config layer. The **long-term store is still injected**, so nodes reach cross-thread memory via `getStore()` as usual. If you need any of the above, use the full protocol — that's what it's for. The call's lifetime really is the request: the graph runs under an `AbortSignal` that fires when the client disconnects (all four adapters wire this) and when `deps.runTimeoutMs` elapses — the same budget the run engine applies — so a disconnected or hung call doesn't keep burning model tokens. ## API reference From [`@skein-js/agent-protocol`](https://github.com/skein-js/skein-js/tree/main/packages/agent-protocol) (the shared handler every adapter wraps): ```ts // A ProtocolHandler for `POST /:graph_id`. Resolves the graph, injects the store, invokes. function createGraphInvokeHandler( deps: ProtocolDeps, options?: { streamMode?: StreamMode | StreamMode[] }, // SSE modes; default "values" ): ProtocolHandler; // The one-route table, shaped like `skeinRoutes` so catch-all adapters can match it identically. function graphInvokeRoutes(prefix?: string): RouteBinding[]; // default prefix "/invoke" // Build a matcher over any route table (used by the NestJS/Next.js catch-all mounts). function createRouteMatcher(bindings: readonly RouteBinding[]): RouteMatcher; ``` Per adapter — each takes `SkeinRuntimeOptions` (`{ config } | { deps }`, plus `logger`/`cors`) and the `streamMode` option above: | Adapter | Entry point | Path option | | ------- | ----------------------------------------- | -------------------- | | Express | `skeinInvokeRouter(options)` | `prefix` (`/invoke`) | | Fastify | `skeinInvokePlugin` | `invokePrefix` | | NestJS | `SkeinInvokeModule.forRoot(options)` | `prefix` | | Next.js | `createSkeinInvokeRouteHandlers(options)` | `basePath` | From [`@skein-js/server-kit`](https://github.com/skein-js/skein-js/tree/main/packages/server-kit): ```ts // Resolve `{ config } | { deps }` to just the deps — no assistants seeded, no run worker started. function resolveRuntimeDeps(options: SkeinRuntimeOptions): Promise<{ deps: ProtocolDeps; cors? }>; ``` ## See also - [embedding.md](./embedding.md) — bringing a graph in code (`{ deps }`), the on-ramp this builds on - [agent-protocol.md](./agent-protocol.md) — the full surface, and when you want it instead - [streaming.md](./streaming.md) — how skein maps run frames onto SSE - [`examples/invoke-endpoint`](https://github.com/skein-js/skein-js/tree/main/examples/invoke-endpoint) · [`examples/embed-graph`](https://github.com/skein-js/skein-js/tree/main/examples/embed-graph) --- # Agent Protocol surface skein-js implements LangChain's [**Agent Protocol**](https://github.com/langchain-ai/agent-protocol), an OpenAPI-specified, framework-agnostic HTTP + streaming contract for serving LLM agents. **What this gives you:** a standard REST + SSE API your client already speaks — assistants, threads, runs (wait / stream / background), streaming, interrupts, and a long-term store. Because it's the same contract LangGraph Platform serves, your existing [`@langchain/langgraph-sdk`](./react-sdk.md) and [`useStream`](./react-sdk.md) code works against a skein-js server by changing only the URL. You almost never call these endpoints by hand — the SDK does — but this page is the map of what's available and what ships in the MVP. For the streaming wire format, see [streaming.md](./streaming.md); for building a frontend on top, see [react-sdk.md](./react-sdk.md). ## Core resources | Resource | Description | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Assistants / agents** | A served graph plus its introspectable input/output/state/config schemas. | | **Threads** | Multi-turn conversation containers with persistent state and history; track status (`idle`, `busy`, `interrupted`, `error`). | | **Runs** | Atomic executions of a graph — stateless (ephemeral), streaming, or background. | | **Store** | Long-term memory organized by namespace + key, with CRUD and (semantic) search. Also injected into graph runs as a LangGraph `BaseStore` — see [storage.md](./storage.md#long-term-memory-in-the-graph-getstore). | | **Crons** | Schedules that fire a run on a cadence. A LangGraph Platform extension, not part of the open spec — see [crons.md](./crons.md). | | **Messages** | First-class primitives aligned with OpenAI/Anthropic formats. | ## Endpoint inventory Every endpoint below is implemented (✅). The route table in [`packages/agent-protocol/src/http/routes.ts`](https://github.com/skein-js/skein-js/blob/main/packages/agent-protocol/src/http/routes.ts) is the source of truth — paths mirror the `@langchain/langgraph-sdk` client, so runs are addressed thread-scoped (`/threads/{thread_id}/runs/{run_id}`). ### Assistants Full CRUD + version history (LangGraph parity). Assistants are auto-registered one-per-graph at startup (`assistant_id` defaults to `graph_id`), and can also be created/updated/deleted over the API. Every `PATCH` mints a new **immutable version**; the live row tracks the currently-active version and mirrors its fields, and `POST .../latest` rolls back to any past version. (Routes use the `/assistants/...` spelling the `@langchain/langgraph-sdk` client sends — not `/agents/...`.) | Method | Path | Notes | | -------- | --------------------------------------------- | --------------------------------------------- | | `POST` | `/assistants` | Create; `if_exists: "raise" \| "do_nothing"` | | `GET` | `/assistants/{assistant_id}` | | | `PATCH` | `/assistants/{assistant_id}` | Update — mints a new version | | `DELETE` | `/assistants/{assistant_id}` | `?delete_threads=true` cascades owned threads | | `POST` | `/assistants/search` | Filter by graph_id/name/metadata; sort + page | | `POST` | `/assistants/count` | Count matching the search filters | | `GET` | `/assistants/{assistant_id}/schemas` | Input/output/state/config schemas | | `GET` | `/assistants/{assistant_id}/graph` | Drawable graph JSON (`?xray`) | | `GET` | `/assistants/{assistant_id}/subgraphs[/{ns}]` | Subgraph schemas by namespace (`?recurse`) | | `POST` | `/assistants/{assistant_id}/versions` | Version history, newest-first (filter + page) | | `POST` | `/assistants/{assistant_id}/latest` | Roll back to an existing version | ### Threads | Method | Path | Notes | | -------- | -------------------------------------------- | --------------------------------------------- | | `POST` | `/threads` | Create; `if_exists`, `supersteps` seed state | | `GET` | `/threads/{thread_id}` | | | `POST` | `/threads/search` | | | `POST` | `/threads/count` | How many match the filters (no pagination) | | `POST` | `/threads/prune` | Bulk `delete`, or `keep_latest` history trim | | `GET` | `/threads/{thread_id}/state` | Current state snapshot (`useStream` hydrates) | | `POST` | `/threads/{thread_id}/state` | Time travel: fork state at a checkpoint | | `GET` | `/threads/{thread_id}/state/{checkpoint_id}` | Time travel: state at a checkpoint | | `POST` | `/threads/{thread_id}/state/checkpoint` | Same read, checkpoint given as an object | | `POST` | `/threads/{thread_id}/history` | Checkpoint history, newest-first (paged) | | `GET` | `/threads/{thread_id}/history` | Same, with `?limit` (the SDK sends `POST`) | | `PATCH` | `/threads/{thread_id}` | | | `POST` | `/threads/{thread_id}/copy` | Duplicates the thread + its history | | `DELETE` | `/threads/{thread_id}` | | **`if_exists` on thread creation.** `POST /threads` with a `thread_id` you choose defaults to `if_exists: "raise"` — a 409 when that id is taken. `"do_nothing"` returns the **existing** thread untouched, which makes `client.threads.create({ threadId: stableKey, ifExists: "do_nothing" })` a get-or-create: the idiom for addressing a conversation by an external identity (a phone number, an email thread, a ticket id) without tracking skein's own ids. Uniqueness is enforced in the storage driver, not by a read-then-write in the service, so two instances racing the same id cannot both win. **Seeding a thread with `supersteps`.** `POST /threads` accepts a `supersteps` array — updates written straight into the thread's checkpoint history, so you can **import** an existing conversation rather than replaying it through the graph. Each superstep is one tick and becomes one checkpoint; each update carries `values` (or a `command`) and a required `as_node` saying which node to attribute it to. Writing state needs a graph, and a thread this new has no run to infer one from — so pass `graphId`, which the SDK folds into `metadata.graph_id`. Without it this is a 400. That graph id is also what lets the seeded state be **read back**: `GET /threads/{id}/state` and the history routes fall back to `metadata.graph_id` when a thread has never run. Bounded at 100 supersteps of 100 updates: each update is a graph-state write to the checkpointer. Note supersteps are applied to whatever the create returned — including a thread that `if_exists: "do_nothing"` merely _found_, in which case they append to it. That matches `@langchain/langgraph-api`, which runs its bulk write on the result of the create either way. **Filtering threads by graph.** `POST /threads/search` matches on a metadata subset. When a run is created, skein stamps the run's `graph_id` and `assistant_id` into the thread's metadata (matching LangGraph), so listing the threads for a graph is just: ```jsonc // POST /threads/search { "metadata": { "graph_id": "my_graph" } } ``` The stamp reflects the thread's most recent run; a thread that has never run carries no `graph_id`. **Paging checkpoint history.** `POST /threads/{id}/history` takes its options in the **body** (as the LangGraph SDK sends them): `{ limit?, before?, metadata? }`. It returns at most **100** checkpoints when `limit` is omitted, and rejects a `limit` above 1000 — each element is a checkpoint's whole graph state, so a long thread's full history is one of the largest single responses skein can produce. Page back through it with `before` (a checkpoint config carrying `checkpoint_id`, or a bare `checkpoint_id`) — the bound is exclusive, so pass the last checkpoint you received — and narrow it with `metadata`, which becomes the checkpointer's filter. Only the `checkpoint_id` reaches the checkpointer: the thread scope is server-owned, so a `thread_id` in `before.configurable` is dropped rather than honoured. A `?limit=` query param is still accepted for hand-rolled callers, but it is _clamped_ to 1000 rather than rejected (a query string has no schema to 400 from). The body wins if both are present. `useStream` sends `limit: 10`, so 10 checkpoints come back rather than every one, matching LangGraph Platform. The rendered transcript is unaffected (the newest checkpoint's `values` carry the whole message list); what shrinks is how far back the branch/edit tree reaches. Raise it by passing your own `limit` if you need deeper history. The 100-checkpoint default is independent of `SKEIN_MAX_PAGE_SIZE` — history is read from the checkpointer, not from the store, so the store's page bound does not apply to it. **Time travel (fork from a checkpoint).** `POST /threads/{id}/history` is read-only, but you can also _branch_ from any past checkpoint: - `POST /threads/{id}/state` with `{ values, as_node?, checkpoint_id? }` calls `graph.updateState` to write a **new checkpoint** that forks history at `checkpoint_id` (or the tip). It returns the new checkpoint pointer, `{ "checkpoint": { "thread_id", "checkpoint_ns", "checkpoint_id" } }`, and mirrors the forked values onto the thread row. Rejected with `409` while a run is in flight on the thread. - `GET /threads/{id}/state/{checkpoint_id}` reads the state snapshot at a specific checkpoint, and `POST /threads/{id}/state/checkpoint` reads the same thing with the pointer in the body. Both exist because the SDK picks between them by argument _type_: `threads.getState(id, "ckpt-1")` takes the `GET`, while `threads.getState(id, { checkpoint_id, checkpoint_ns })` takes the `POST`. Only `checkpoint_id` is read from the pointer either way. A pointer with no id reads the tip rather than 404ing, and `subgraphs` is accepted and ignored on both. - Run creation accepts a top-level **`checkpoint_id`** to start a run from a chosen checkpoint instead of the thread tip. This is **server-validated and server-injected** — it is _not_ read from the client's `config.configurable` (which strips it), so a client can never redirect a run to an arbitrary checkpoint. It rides the LangGraph checkpointer, so no extra storage is involved; thread copy is the coarser, whole-history cousin. ### Runs — stateless / ephemeral Each of these creates its own thread. `on_completion` decides what happens to it (below). | Method | Path | Notes | | ------ | -------------- | ------------------------------------------------ | | `POST` | `/runs/wait` | Run to completion, answer with the final values | | `POST` | `/runs/stream` | Run and stream frames as SSE | | `POST` | `/runs` | Queue a background run, answer with the `Run` | | `POST` | `/runs/batch` | An **array** of run-creates; max 100 per request | | `POST` | `/runs/cancel` | `cancelMany` — see below | ### Runs — background (thread-scoped) | Method | Path | Notes | | -------- | -------------------------------------------------- | --------------------------------- | | `POST` | `/threads/{thread_id}/runs` | Start a background run | | `GET` | `/threads/{thread_id}/runs` | List a thread's runs (paginated) | | `GET` | `/threads/{thread_id}/runs/{run_id}` | Fetch one run | | `GET` | `/threads/{thread_id}/runs/{run_id}/stream` (join) | Join a run's stream | | `GET` | `/threads/{thread_id}/runs/{run_id}/join` | Block until it settles, then JSON | | `POST` | `/threads/{thread_id}/runs/{run_id}/cancel` | Cancel a run (`?action`, `?wait`) | | `DELETE` | `/threads/{thread_id}/runs/{run_id}` | Delete a run | | `GET` | `/runs/{run_id}/stream` (join) | Join by run id (thread-agnostic) | **Run-completion deliveries.** A run created with a `webhook` records its callback in an outbox, so the attempts are inspectable and a failed one can be re-sent: | Method | Path | Notes | | ------ | -------------------------------------------------------------------- | -------------------------------------- | | `GET` | `/threads/{thread_id}/runs/{run_id}/deliveries` | `?status=` · `?limit=` · `?offset=` | | `POST` | `/threads/{thread_id}/runs/{run_id}/deliveries/{delivery_id}/replay` | Makes a delivery due again immediately | The list strips the stored `payload` — up to 256 KiB of the run's final state per row — and reports a boolean `replayable` in its place; read the state from the run. Replay `409`s on a delivery that already succeeded (its payload was cleared on success, so there is nothing left to send) and `404`s on a delivery id that does not belong to the run in the path. Both sit in the **`runs` route group** deliberately: a run is already an ownership-scoped resource, so `http.disable_runs` and an existing `@auth.on.threads` handler cover them with no new switch and no new `RouteGroup` — which, once shipped, could never be withdrawn. Delivery semantics, signing and retention are in [webhooks.md](./webhooks.md). **Configured-channel inventory.** When a deployment configures `skein.channels`, it also mounts `GET /channels` for operator tooling. The response is intentionally small and sanitized: ```json { "channels": [ { "route_name": "support-whatsapp", "assistant": "support", "allowed_assistants": ["refunds"], "channel_name": "twilio", "delivery_supported": true } ] } ``` The inbound path is derived as `/channels/{route_name}`. The endpoint never returns module paths, `public_url`, raw configuration, credentials or reply destinations. It is absent when no channel is configured and, under custom auth, requires `assistants:read`. This is a skein operator extension, not part of the Agent Protocol or the upstream SDK resource clients; the console reuses the SDK's base transport for it. **Joining a run: two shapes.** `.../runs/{run_id}/stream` tails a run as SSE (`client.runs.joinStream()`, resumable with `Last-Event-ID`). `.../runs/{run_id}/join` is the blocking form (`client.runs.join()`): it waits for the run to settle and answers the thread's final `values` as plain JSON, or `{ "__error__": ... }` for a failed one — the same envelope `POST /runs/wait` uses. Joining a run that has _already_ settled returns immediately, including long after its frames have aged out of the event bus, because the wait is decided by the run row rather than by the bus. `?cancel_on_disconnect` is honoured on the **streaming** form and accepted-but-ignored on the blocking one, matching `@langchain/langgraph-api` — see `on_disconnect` under the run endpoints below. **Cancelling in bulk.** `POST /runs/cancel` takes `{ thread_id?, run_ids?, status? }`, narrowest selector first: explicit `run_ids`, else one thread's inflight runs, else **every** inflight run on the server. `status` is `pending` / `running` / `all` (the default). An unknown — or non-owned — run id is skipped rather than failing the sweep, so the response reports what actually happened: `{ cancelled_count, cancelled_run_ids, truncated }`. `truncated: true` means the whole-server sweep filled the store's page bound and should be repeated; the SDK types this call as returning `void` and ignores the body, so it is skein's to shape. `truncated` is deliberately a boolean about the caller's own page rather than a count of what is left. The per-thread concurrency guard the sweep reads through is **not** ownership-scoped — it has to see every inflight run on a thread whoever started it — so a total would tell an authenticated caller how much work every other principal has in flight. **`?action` and `?wait` on a cancel.** Both are sent by every `client.runs.cancel(...)`. `action=interrupt` (the default) settles the run `cancelled` and keeps whatever it wrote; `action=rollback` additionally discards its checkpoint writes and deletes the run row, so the turn reads as never having happened. `wait=1` returns only once the run has actually stopped executing rather than as soon as it is marked. **`on_completion` on a stateless run.** `"delete"` removes the server-created thread once the run settles; `"keep"` leaves it. **skein defaults to `keep`, LangGraph to `delete`** — a deliberate divergence, so a stateless run stays inspectable and so adding the field did not silently change what `/runs/wait` and `/runs/stream` already did. Pass `"delete"` for LangGraph's behaviour. An `interrupted` run keeps its thread either way: it has yielded to a human, and its checkpoint is the whole value of the turn. **`if_not_exists` on run creation.** Naming a thread that does not exist is a **404** by default (`if_not_exists: "reject"`, matching LangGraph). Pass `"create"` to have the run bring the thread into existence instead — the other half of addressing a conversation by an external key, so an inbound event can start a run without a round trip to create the thread first. All three thread-scoped run routes agree, and the default is the safe one — a mistyped thread id fails loudly rather than running against a fresh empty thread with none of the history the caller expected. Pass `if_not_exists: "create"` to restore the old behaviour. **`if_thread_status` on run creation.** A skein extension, and the counterpart to `multitask_strategy`: that one arbitrates `pending`/`running`, this one guards every other thread status. It matters most for `interrupted`, which is a **terminal** run status — a thread waiting on a human holds no inflight run, so no multitask strategy protects it and a plain start discards the pending interrupt. Send `if_thread_status: ["idle", "error"]` and an interrupted thread answers **409 `thread_status_mismatch`** with the observed status in `details`. Settled inside the driver's atomic create, so it holds across replicas. A storage driver that does not implement the precondition answers `501 if_thread_status_unsupported` rather than degrading to a non-atomic check. A thread created this way still belongs to the **caller**, not to the run: `on_completion: "delete"` never removes it, because the caller named it. Only a thread the server minted (no `thread_id` at all) is the run's to delete. The field is inert on `POST /runs` and `/runs/batch`, which strip `thread_id` outright — the server owns a stateless run's thread, so nothing can be missing. **`after_seconds` on run creation.** Holds the run for that many seconds before it starts — the SDK's "schedule a future run". Capped at **86400** (a day); anything longer is a [cron](./crons.md). A **background** run (`POST /runs`, `/runs/batch`, `POST /threads/{id}/runs`) is held by the queue itself, so it costs nothing while it waits and, on Redis, survives a process restart — BullMQ keeps it in its own delayed set. On the in-memory queue it is a timer, and is lost on restart exactly as every other run already sitting in that queue is. An **inline** run (`/runs/wait`, `/runs/stream`) has no queue to hold it, so the server waits with your connection open. A stream still responds immediately — the run row, its `Content-Location`, and the SSE heartbeats all come first, and only execution is deferred — but `/runs/wait` sends nothing until the run finishes, so a long `after_seconds` there will hit a proxy's idle timeout. Prefer a background run for anything more than a few seconds. Two consequences worth knowing. The run row exists for the whole delay with status `pending`, so it counts as **inflight**: a second run on that thread under the default `multitask_strategy: "reject"` is refused until the delayed one starts. That is the honest reading — work _is_ scheduled on the thread — but it surprises people. And a delayed run is cancellable like any other; `POST .../cancel` settles it before it ever runs. **`on_disconnect` on run creation.** `"cancel"` settles the run when the caller's connection drops; `"continue"` lets it finish. Only the inline routes (`/runs/wait`, `/runs/stream`) hold a connection to drop, which is why the SDK does not send it on a background create. **skein defaults to `"continue"`.** A proxy idle timeout or a load-balancer reset is indistinguishable from a real hang-up at this layer, so defaulting to cancel would let routine infrastructure kill a healthy run. Note `useStream` sends `"cancel"` on every submit unless the stream is resumable — so with a browser client, closing the tab stops the run. That is LangGraph's behaviour. The related query flag `?cancel_on_disconnect` is honoured on `GET .../runs/{run_id}/stream` (`client.runs.joinStream()`), matching `@langchain/langgraph-api`. It stays **accepted and ignored** on the blocking JSON `.../join`, because the reference server does not read it there either — a blocking join should not behave differently against the two servers the same client talks to. Adapters supply the disconnect signal from their own transport (`res` close, or the Web `Request`'s `signal`). An adapter that cannot observe disconnects simply omits it, and `"cancel"` degrades to `"continue"` rather than failing. **`Content-Location` on run creation.** Every run-create response (and `POST /threads/{id}/stream`) carries `Content-Location: /threads/{thread_id}/runs/{run_id}`. The `@langchain/langgraph-sdk` client parses it to fire `onRunCreated`, which is what `useStream` stores to rejoin a stream after a remount — so without it that callback never fires and `reconnectOnMount` cannot work. It is also the only way a caller learns the thread id of a stateless `/runs/wait`, whose body is the graph's state. ### Crons (LangGraph Platform extension) | Method | Path | Notes | | -------- | --------------------------------- | ------------------------------------------------ | | `POST` | `/runs/crons` | Stateless cron — a fresh thread per fire | | `POST` | `/threads/{thread_id}/runs/crons` | Thread cron — reuses the named thread | | `POST` | `/runs/crons/search` | Filter + sort + page; `x-pagination-total` | | `POST` | `/runs/crons/count` | Returns a **bare integer** | | `GET` | `/runs/crons/{cron_id}` | | | `PATCH` | `/runs/crons/{cron_id}` | Tri-state `end_time`/`timezone`; metadata merges | | `DELETE` | `/runs/crons/{cron_id}` | **200 with a JSON body**, not 204 | These are **not** in the open Agent Protocol spec — its `openapi.json` has no cron paths, and the OSS `@langchain/langgraph-api` throws `500 Not implemented` on all of them. skein serves them against the LangSmith Deployment OpenAPI spec plus the SDK's types. Two response shapes are deliberately unusual because the official client requires them: `count` answers a bare integer, and `DELETE` answers 200 with a body (the SDK skips `response.json()` only for 202 and 204). Full semantics — schedule format, catch-up, driver support, the scheduler — are in [crons.md](./crons.md). ### Meta | Method | Path | Notes | | ------ | ------- | -------------------------------------------------------------------- | | `GET` | `/info` | Version + `flags` capability handshake (Studio reads it) | | `GET` | `/ok` | Liveness probe — served by each adapter, **outside** the route table | `/ok` sits outside the protocol table on purpose. LangGraph groups it with `/info` under one `disable_meta` flag; in skein it is the container health check (see the generated Dockerfile), so no config flag may be able to make a healthy instance read as dead. **`/info` is served unauthenticated**, even with an `auth` block configured — matching `@langchain/langgraph-api`, whose auth middleware skips it explicitly. It is a handshake: Studio and monitoring clients probe it before they have credentials, so requiring auth would break connecting to a server `langgraph dev` would have answered. It exposes only versions and which resources are served. ### Store (long-term memory) | Method | Path | Notes | | -------- | --------------------- | ---------------------------------------------- | | `PUT` | `/store/items` | Upsert an item (optional `ttl`) | | `GET` | `/store/items` | Fetch by `?namespace=a.b&key=…` | | `DELETE` | `/store/items` | `{ namespace, key }` in the body, or the query | | `POST` | `/store/items/search` | pgvector semantic search, `filter` → `{items}` | | `POST` | `/store/namespaces` | Prefix/suffix/depth → `{namespaces}` | **`DELETE` takes a body.** The SDK's two single-item methods use different transports on the same path: `store.getItem` sends `?namespace=a.b&key=…`, while `store.deleteItem` sends a JSON body with `namespace` already an array. skein accepts **either** on both, body first. Reading only the query made every SDK `deleteItem` a silent no-op — empty namespace, empty key, nothing deleted, `204`. **Response shapes.** Search returns `{ "items": [...] }` and namespaces `{ "namespaces": [...] }` — the envelopes the SDK's `store.searchItems` / `store.listNamespaces` read. Store items carry `created_at`/`updated_at`, like every other resource here. **`filter` on search** narrows by the **top-level** keys of an item's `value` — `"a.b"` is the literal key `"a.b"`, not a path into a nested object. Keys are ANDed, and so are multiple operators on one key. The operator set is LangGraph's: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin`; a bare scalar means equality. Filtering happens before paging, so a page is a page of matches. An unknown operator or a non-scalar filter value is a **400**, not a silent narrowing — see [storage.md](./storage.md) for the full semantics, including the one place skein's ordering operators deliberately differ from LangGraph's. **Namespace matching** takes `prefix` and `suffix` (ANDed) and `max_depth`. `"*"` matches exactly one segment, positionally — so `["users","*"]` selects the `users` subtree, matching `["users","1"]` and `["users","1","memories"]` alike. Before 0.14 a wildcard was dropped and read as _no prefix_, so that same request returned **every namespace in the store**. `max_depth` truncates each match and de-duplicates, before paging. **Pagination on these two.** `GET /threads/{thread_id}/runs` takes `?limit`/`?offset` and `POST /store/namespaces` takes `limit`/`offset` in the body; both default to a **100**-row page, the same default the SDK sends for `store.listNamespaces`. A query `limit` above 1000 is clamped rather than rejected, matching every other query-string limit here. Truncation is not signalled on the response (only assistant search carries `x-pagination-total`), so page until you receive fewer rows than you asked for. `status` on `runs.list` **is** honoured, filtered in the driver so it pages the filtered set; `select` on `runs.list` and `refresh_ttl` on `store.searchItems` are still accepted and **ignored** (refresh-on-read is a deployment setting, `store.ttl.refresh_on_read`, not a per-request one). ### Thread streaming (SSE) | Method | Path | MVP | | ------ | ------------------------------------ | --- | | `POST` | `/threads/{thread_id}/stream` | ✅ | | `GET` | `/threads/{thread_id}/stream` | ✅ | | `GET` | `/threads/{thread_id}/stream/events` | ✅ | | `POST` | `/threads/{thread_id}/commands` | ✅ | `GET /stream/events` is a synonym for `GET /stream`: `client.threads.joinStream()` asks for the latter, the SDK's v2 agent-server transport reads from the former. The v2 transport as a whole is **not** supported — its `POST /stream/events` is a subscription (`{ channels, ... }`, carrying no `assistant_id`) rather than a run-create, and `/commands` expects a protocol command envelope skein does not implement. Use the run endpoints, which is what `useStream` does by default. > The protocol also describes a WebSocket upgrade for bidirectional streaming. That is > **post-MVP** — SSE covers the full client UX (see [streaming.md](./streaming.md)). ## Request/response conventions - JSON for all non-streaming payloads. - Request bodies carry `input`, optional `metadata`, optional `config`. - Responses carry status (`pending` / `success` / `error`), timestamps, and resource IDs. - A failed run also carries `error` — a skein extension over the SDK's `Run`, which records only _that_ a run failed. See [errors-and-logging.md](./errors-and-logging.md). - Schemas use JSON Schema for interoperability. ## Idempotent run creation (`Idempotency-Key`) A skein extension — LangGraph Platform has no equivalent. Send an `Idempotency-Key` header on a run create and a retry of that request returns the **original response** instead of starting a second run. This exists because every webhook provider retries: Twilio on timeout or 5xx, Stripe, GitHub, Slack, SendGrid. Without it, a retried delivery means a second reply to the end user or two agents acting on one event — and the failure is silent. The only other defence is a dedup table re-implemented inside every caller's application. **Supported on** `POST /threads/{thread_id}/runs`, `POST /threads/{thread_id}/runs/wait`, `POST /runs`, `POST /runs/wait`, and `POST /runs/batch`. Omitting the header is exactly today's behaviour, so this is purely additive. **Not supported on the streaming creates** (`POST /runs/stream`, `POST /threads/{thread_id}/runs/stream`, `POST /threads/{thread_id}/stream`, `POST /threads/{thread_id}/commands`), which answer with a live SSE stream: there is no body to record, and consuming the stream to make one would break it for the caller who asked. Sending the header there is a **422** rather than being ignored — a silently-dropped key would leave you believing your retries are deduplicated while every one starts another run. | Case | Response | | ------------------------------------------------ | -------------------------------------------------------------- | | Key unseen | Executes normally; the response is recorded | | Key seen, same request, original done | The recorded response verbatim, plus `Idempotent-Replay: true` | | Key seen, same request, original still in flight | `409` `idempotency_key_in_flight` — retry shortly | | Key seen, **different** request body | `422` `idempotency_key_reused` — a caller bug; do not retry | | Key over 255 chars, or not printable ASCII | `422` `invalid_idempotency_key` | The `code` carries the meaning on the 422s: these routes already answer 422 for a _transient_ reason (`thread_busy`, under the `reject` multitask strategy), and the two demand opposite client behaviour. Keys follow Stripe's 255-character limit. `Content-Location` is replayed with the rest of the response, so the SDK's `onRunCreated` still fires and `useStream`'s `reconnectOnMount` still works on a retry. A few properties worth knowing: - **The claim is atomic across instances.** The record is inserted first and the store's uniqueness constraint arbitrates, so two provider retries landing on two pods milliseconds apart still produce exactly one run. Held to that by the shared `SkeinStore` conformance suite, on every driver. - **Keys are scoped per principal** when auth is configured, so one caller cannot replay another's response by guessing their key. - **A replay is re-authorized.** It answers from the record without reaching the handler table, so the route's `@auth.on.*` check runs again first — and when it returns ownership filters, the recorded thread is re-read through the scoped store. Revoking a caller's access stops their replays at once rather than at the end of the retention window. - **Failures are never recorded.** A create that throws or answers non-2xx releases its key, so the next retry really runs — pinning a transient 503 for the retention would make a momentary outage permanent for that key. - **At-least-once, not exactly-once.** The guarantee is that a retry of the _same_ request does not create a second run. Choose keys that are stable across retries (the upstream provider's message id is usually right). - **Deleting a thread or a run erases its recorded responses.** A recorded response has to outlive its run for a replay to mean anything, and for `POST /runs/wait` that response _is_ the graph's final state — so `DELETE /threads/{thread_id}` takes the matching records with it, as do `DELETE /threads/{thread_id}/runs/{run_id}`, an assistant delete, and thread-TTL expiry. Erasure means erasure; a retry afterwards executes again rather than replaying, and on a deleted thread that is a 404. The one deletion that does **not** erase is a stateless run tidying up its own server-created thread (`on_completion: "delete"`). That thread is an implementation detail of `POST /runs`, disposed of on every such run — scrubbing there would destroy the record moments after writing it and break retries for exactly the case the header exists for. Those records expire on `retention_hours` like any other. Lower it if you want that window shorter. `POST /runs/batch` records carry no thread: their runs may span several, and the body is a list of run rows rather than conversation content. Retention is tunable under `skein.idempotency` in `langgraph.json` — see [langgraph-cli-compat.md](./langgraph-cli-compat.md#idempotency-skeinidempotency). ## Authentication + authorization Auth follows LangGraph's [custom-auth model](https://docs.langchain.com/langsmith/custom-auth) and is **transport-neutral** — it wraps the handler table, so every adapter inherits it identically. Active only when an `Auth` engine is configured (a `langgraph.json` `auth` block or an injected `auth` dep); otherwise the server is unauthenticated. Per request: 1. **Authenticate** — run the user's `authenticate` handler → an `AuthContext`, or `401`. Studio traffic (`x-auth-scheme: langsmith`) is admitted without authenticating unless `disable_studio_auth` is set. 2. **Authorize** — run the matching `@auth.on.*` handler (priority `resource:action` → `resource` → `*:action` → `*`) → `403` on `false`, else an ownership **filter**. 3. **Dispatch** — with the authenticated `user`. Ownership scoping applies to the `threads` family (threads + their runs): a non-owned row reads as absent (`404`, never `403`), and the filter's values are stamped onto rows it creates. `crons` scope the same way and **fall back to the `threads` handler when no `@auth.on.crons` callback is registered**, since callbacks match by exact event key and a deployment that scoped only threads would otherwise serve crons unscoped. `assistants` is **gate-only** — a handler can deny, but no filter applies, because auto-registered graph assistants have no owner and must stay runnable. Store scoping works through the namespace instead: ### Scoping the store A store item carries no metadata, so an ownership filter has nothing to match on. LangGraph's idiom is to **rewrite** `value.namespace`, and skein honours it on all five store routes: ```ts auth.on("store", ({ user, value }) => { value.namespace = [user.identity, ...(value.namespace ?? []).slice(1)]; }); ``` In-place mutation counts (`value.namespace[0] = …`), and the body namespace wins over query params on `GET`/`DELETE`. `value.key` is honoured the same way on `put`/`get`/`delete`. A rewrite that is not a `string[]` is a **500** (`store_namespace_rewrite_invalid`), not a silent fall-through — a handler that meant to scope and got the shape wrong is exactly the failure this prevents. Assigning `value.key` on `search` or `list_namespaces` is likewise a 500 (`store_key_rewrite_invalid`): there is nowhere for it to land. If you supply your own `AuthEngine`, its `authorize` must return the **same** `value` object for a rewrite to be observable. > **With no store handler registered, nothing narrows a store read.** The namespace is a request > parameter, so any authenticated caller can send `{"namespace_prefix": ["memories"]}` — or omit the > prefix entirely — and read **every** tenant's items. `POST /store/namespaces` likewise returns every > tenant's namespace names. Three traps if you validate rather than rewrite: ```ts // A namespace label is one segment: it must contain neither `.` (the query separator) nor `*` (the // positional wildcard). encodeURIComponent escapes neither, so do it explicitly. const tenantLabel = (identity: string) => encodeURIComponent(identity).replace(/\./g, "%2E").replace(/\*/g, "%2A"); auth.on("store", ({ user, value }) => { const namespace = value.namespace; // server-derived; absent means "every namespace" on search if (!namespace?.length || namespace[0] !== tenantLabel(user.identity)) { throw new HTTPException(403, { message: "Out of scope." }); } }); ``` - **Require a namespace; don't just reject suspicious ones.** A deny handler can refuse a request but never filter the result. An absent prefix reads every tenant, and a `"*"` wildcard matches a strict **subset** of what the shorter literal prefix already returns — so blocking wildcards buys nothing. - **Encode the identity.** `GET /store/items` takes a dot-joined query string, so `alice@corp.com` writes to `["alice@corp.com", …]` but reads back as `["alice@corp", "com", …]`. Emails and OIDC subjects hit this constantly. - **A handler cannot cover `getStore()` inside a graph** — that is not an HTTP request and has no principal. Build the namespace from `config.configurable.langgraph_auth_user_id`, never from model output. `value.namespace` is **server-derived** — set after merging the body, from the field the endpoint will really use. Store bodies are `passthrough`, so otherwise a caller could send a decoy `namespace` that satisfied the handler while the endpoint searched from an absent `namespace_prefix`. ### Where scoping runs **In the database.** An ownership-filtered thread search becomes a metadata containment clause (`metadata @> …` in Postgres, hitting `threads_metadata_idx`), so `limit`/`offset` page owned rows directly rather than reading everything and filtering in JS. The in-process `matchesFilters` check still runs and is what actually enforces ownership. The translation errs **broad**, omitting any clause it cannot express exactly, because a too-strict clause would silently hide rows a caller owns. A **custom** `AuthEngine` whose `matchesFilters` is stricter than its own filters is the exception — pages then come back short; keep the two consistent. **Principal in the run config.** Nodes read `config.configurable.langgraph_auth_user`, `langgraph_auth_user_id` and `langgraph_auth_permissions`. Server-owned and reserved, so a client cannot spoof them, and persisted on the run so a background run resumed elsewhere injects the same principal. With no `auth` configured, no keys are added. Route → resource/action (runs authorize through their owning thread — there is no `runs` resource): | Endpoint(s) | resource\:action | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | | `GET /assistants/{id}`, `/assistants/{id}/schemas` | `assistants:read` | | `POST /assistants/search` | `assistants:search` | | `POST /threads` | `threads:create` | | `GET /threads/{id}`, `/state`, `/state/{checkpoint_id}`; `POST /state/checkpoint`, `/history`; `GET /history`, `.../runs`, `.../runs/{run_id}`, run join (stream or blocking) | `threads:read` | | `POST /threads/search`, `POST /threads/count` | `threads:search` | | `PATCH /threads/{id}`; `POST /threads/{id}/state` (state fork); run cancel; `POST /runs/cancel` | `threads:update` | | `DELETE /threads/{id}`; run delete; `POST /threads/prune` | `threads:delete` | | run create (wait/stream/background/stateless/batch), thread stream / commands | `threads:create_run` | | `GET /info` | _(unauthenticated — see the Meta section)_ | | `PUT/GET/DELETE /store/items`, `/store/items/search`, `/store/namespaces` | `store:{put,get,delete,search,list_namespaces}` | `POST /runs/cancel` sweeps broadly but authorizes narrowly — every run goes back through the ownership-filtered `get`, so a non-owned run reads as absent and is skipped. The per-thread concurrency guard is deliberately **not** ownership-filtered: it must see every in-flight run on a thread whoever started it, or two could execute at once and interleave checkpoint writes. Nothing leaks, since the thread itself is gated. ## References - Agent Protocol repo + OpenAPI — - aegra's Agent Protocol implementation (Python prior art) — --- # Building blocks Every agent starts as a model, a prompt and a loop. That version works for about a day. What follows is what reality adds — each piece arriving because something broke without it, and each one either yours to write, LangGraph's to define, or skein's to run. ```mermaid flowchart LR Y["Yours
model · prompt · tools"] L["LangGraph
state · nodes · edges · interrupt"] S["skein
runs · threads · memory
schedules · callbacks · streaming"] Y --> L --> S class Y accent ``` Read it through once; after that it is a map — find the pressure you are under and follow the link. ## The model and the prompt — yours skein never sees your model choice. You construct it in your graph, from any LangChain provider, and your key lives in `.env`. One habit worth forming early: keep raw data in state and format the prompt inside the node. State is checkpointed every step, so a rendered prompt in a channel is a copy you pay for on every turn. → [Your first agent](./your-first-agent.md#5-give-it-a-real-model) ## Tools — yours A function plus the metadata the model reads to decide when to call it. That metadata is the model's _only_ context for the decision, so write it for someone who knows nothing else. **The trap:** a failing tool does not fail the run. The error goes back to the model, which will often try again. Bound that in the graph, not in hope. → [LangGraph essentials](./langgraph-essentials.md#tools) ## Control flow — LangGraph's Nodes and edges: what runs, and what runs next. Cycles are expected — an agent loop _is_ a node that routes back to the model. skein serves whatever you compile and wraps none of it, which is why the same graph runs on LangGraph Platform. → [LangGraph essentials](./langgraph-essentials.md) ## A run is one turn One input, one execution of your graph. What differs is how you wait: hold the connection, stream, or take an id and let it work. Two surprises. A run is not tied to the connection that started it — drop the stream and it keeps going. And a second message mid-run does not queue: the default strategy is `reject`, which fails the _second_ run with a 422 and leaves the first alone. → [Runs](./runs.md) ## Checkpoints, and everything they buy This is where homegrown agent servers start hurting, because "the conversation is still there" is not something a request handler can give you. LangGraph saves state at every node boundary; skein persists it. One mechanism, four consequences: ```mermaid flowchart LR CP[checkpoint at each
node boundary] CP --> A[multi-turn
conversations] CP --> B[pause for
a human] CP --> C[time travel
fork a past turn] CP --> D[crash
recovery] class CP accent ``` > [!WARNING] > **No checkpointer, none of the above** — and nothing warns you. Runs start empty and resume quietly > does nothing. `skein dev`, `embedInMemoryGraphs` and `embedPostgresGraphs` all configure one; the > mistake is constructing your own and handing it to `.compile()`. A related trap arrives with it. A run carries four bags — `input`, `context`, `config.configurable`, `metadata` — and only `input` becomes state the agent remembers. → [State, context & persistence](./state-and-context.md) ## Threads: one conversation A thread is the state plus every checkpoint behind it. Address one by a key you already have — a ticket number, a phone number — and your system needs no mapping table. → [Threads](./threads.md) ## Memory: what outlives the conversation A thread remembers a conversation, not a person. Two stores, two lifetimes: | | Scope | Written by | | ---------------- | ------------------------------------------ | ---------------------- | | **Checkpointer** | One thread | skein, every step | | **Store** | Namespaces you choose, across every thread | Your nodes, explicitly | **The trap:** a user's name in the thread. They start a new conversation and the agent has forgotten. Facts about a person go in the store; facts about this conversation stay in state. → [Memory](./memory.md) · [Storage](./storage.md) ## Pausing for a human The moment your agent can spend money or send email, someone will want to be asked first. `interrupt()` _ends the run_ on a checkpoint — no connection held, no timer, nothing billed while it waits. Approval comes an hour or a week later and the graph resumes where it stopped. A pause that cannot survive a redeploy is not worth much, which is why this is the feature that most often justifies durable storage on its own. → [Human-in-the-loop](./human-in-the-loop.md) ## Watching it think Runs stream over server-sent events, and LangGraph's stream modes map onto the wire unchanged — which is why `useStream` works against a skein server with a URL change and nothing else. A dropped stream reconnects and joins the same run. → [Streaming](./streaming.md) · [Frontend SDKs](./react-sdk.md) ## Work that outlives the request Not everything is a chat turn. - [Background jobs](./background-jobs.md) — hand it over, get an id - [Crons](./crons.md) — a schedule that fires exactly once across every instance - [Webhooks](./webhooks.md) — your service told when a run settles The webhook contract is worth reading twice: delivery is durable, committed in the same transaction as the run's terminal status, but **at-least-once**. Dedupe on `X-Skein-Delivery-Id` and answer `2xx` only once your own work is committed. ## Config without a redeploy An **assistant** is a named, versioned configuration of one graph. Ship a prompt change as a version, roll it back in one call, graph untouched. → [Assistants](./assistants.md) ## Knowing what it did An agent that works in a demo and misbehaves in production is the normal case. skein emits run and model-call telemetry through a sink interface, with LangSmith, PostHog and OpenTelemetry implementations, so traces land in a backend you already run. What skein does not do is judge the output — that is LangSmith or your own harness. Its job is making every run recorded and replayable. → [Observability](./observability.md) · [Errors & logging](./errors-and-logging.md) ## Where this lives in production Development is in memory and meant to be. Production is Postgres and Redis, and they buy different things: | | Buys you | | ------------ | -------------------------------------------------------------------------------------- | | **Postgres** | State, checkpoints, threads, runs, memories and pending webhooks across a restart | | **Redis** | A durable run queue, retry schedules that outlive a deploy, streaming across instances | Your graph code is identical either way — drivers are injected, not imported. → [Storage](./storage.md) · [Runs & Redis](./runs-and-redis.md) · [Deploy](./deploy.md) ## What was yours all along The model, the prompt and the tools — the reason your agent is _your_ agent. The graph is LangGraph's. Everything else is plumbing skein exists so you do not write. Want it as a lookup table instead of a story? [Features](./features.md). Want the graph model itself? [LangGraph essentials](./langgraph-essentials.md). --- # LangGraph essentials skein-js serves LangGraph.js graphs **unchanged**. It wraps none of the API below, adds no dialect of its own, and the graph you write here runs on LangGraph Platform without an edit. This page is the shallow dive: the parts of LangGraph you need to read and modify your own agent, each with a link to the LangChain docs that own it in full. It is not a LangGraph tutorial. When a section is the one you actually need, [Thinking in LangGraph](https://docs.langchain.com/oss/javascript/langgraph/thinking-in-langgraph) and the reference behind each **Go deeper** link are where to spend the time. ## The smallest graph State, a node, edges and `.compile()` — the first four concepts below, all in the graph [`npm create skein-js`](./scaffolding.md) scaffolds for you: ```ts import { AIMessage, type BaseMessage } from "@langchain/core/messages"; import { MessagesAnnotation, StateGraph } from "@langchain/langgraph"; function echo(state: typeof MessagesAnnotation.State): { messages: BaseMessage[] } { const last = state.messages.at(-1); const text = typeof last?.content === "string" ? last.content : ""; return { messages: [new AIMessage(`echo: ${text}`)] }; } export const graph = new StateGraph(MessagesAnnotation) .addNode("echo", echo) .addEdge("__start__", "echo") .addEdge("echo", "__end__") .compile(); ``` ## State and channels State is the data flowing through your agent, declared as **channels**. `MessagesAnnotation` is the ready-made one for chat — a list of messages that appends. When you need your own shape, declare it: ```ts import { Annotation, MessagesAnnotation } from "@langchain/langgraph"; const State = Annotation.Root({ ...MessagesAnnotation.spec, draft: Annotation(), }); ``` Keep **raw data in state and format prompts inside nodes**. Everything in state is checkpointed on every step, so a formatted prompt stored in a channel is a copy you pay for forever. **Go deeper →** [Graph API](https://docs.langchain.com/oss/javascript/langgraph/graph-api) ## Reducers A channel's reducer decides what happens when a node returns a value for it: replace, or combine. The `messages` reducer appends, which is why returning one message adds it rather than truncating the conversation to a single entry. ```ts // appends to messages — it does not overwrite the list return { messages: [new AIMessage("done")] }; ``` This bites in one specific place: **editing state by hand**, from the API or the [console](./console.md). You write through the same reducers, so patching `{ messages: [...] }` to fix a transcript appends to it. Use `asNode` to attribute the write to a node whose reducer does what you meant. See [state & context](./state-and-context.md#state-what-the-agent-knows-right-now). **Go deeper →** [Graph API](https://docs.langchain.com/oss/javascript/langgraph/graph-api) ## Nodes A node is a plain function. It receives the current state and returns **only the part that changed** — never the whole state. Because it is a plain function, you can import it in a test, call it with a literal, and step through it in a debugger without a server anywhere. Nodes may be async, and they may reach for injected resources — see [persistence](#persistence-the-checkpointer-and-the-store) below. **Go deeper →** [Graph API](https://docs.langchain.com/oss/javascript/langgraph/graph-api) ## Edges and conditional edges Edges say what runs next. `__start__` and `__end__` are the built-in entry and exit points. A straight line needs only `addEdge`; branching — the reason it is a graph at all — uses `addConditionalEdges` with a function that returns the name of the next node: ```ts const graph = new StateGraph(State) .addNode("classify", classify) .addNode("approve", approve) .addNode("send", send) .addEdge("__start__", "classify") .addConditionalEdges("classify", (state) => (state.needsApproval ? "approve" : "send")) .compile(); ``` Cycles are allowed and expected: an agent loop is a node that routes back to the model until there is nothing left to call. **Go deeper →** [Workflows and agents](https://docs.langchain.com/oss/javascript/langgraph/workflows-agents) ## `.compile()` `.compile()` turns the definition into something runnable, and **what it returns is what skein serves** — the value your `langgraph.json` points at: ```json { "graphs": { "agent": "./src/agent-graph.ts:graph" } } ``` Do not pass a `checkpointer` or `store` to `.compile()` yourself. skein injects both per run; see below. **Go deeper →** [Graph API](https://docs.langchain.com/oss/javascript/langgraph/graph-api) ## Prebuilt agents Most agents are the same loop: call the model, run the tool it asked for, call the model again. `createAgent` is that loop, already written: ```ts import { createAgent } from "langchain"; export const graph = createAgent({ model: "anthropic:claude-sonnet-5", tools: [getWeather], }); ``` It returns a compiled graph like any other, so everything skein does — threads, streaming, interrupts, memory — works against it unchanged. > [!NOTE] > You will meet **`createReactAgent`** from `@langchain/langgraph/prebuilt` in older code. It still > works, but it is `@deprecated` as of `@langchain/langgraph` 1.4: it moved to the `langchain` package > and was renamed. The parameter changed too — `llm` became `model`, which also accepts a > `"provider:model"` string — and a dynamic `prompt` function becomes `dynamicSystemPromptMiddleware`. > skein's examples and scaffolder are all on `createAgent`. **Go deeper →** [Agents](https://docs.langchain.com/oss/javascript/langchain/agents) ## Tools A tool is a function plus the metadata a model needs to decide when to call it: ```ts import { tool } from "@langchain/core/tools"; import { z } from "zod"; const getWeather = tool(async ({ city }: { city: string }) => `It's 21°C in ${city}.`, { name: "get_weather", description: "Get the current weather for a city.", schema: z.object({ city: z.string().describe("City name, e.g. 'Nairobi'") }), }); ``` The `description` and the schema's `.describe()` calls are the model's **only** context for the tool. Write them for a reader who knows nothing else, because that is exactly the situation. **Go deeper →** [Tools](https://docs.langchain.com/oss/javascript/langchain/tools) ## `interrupt()` and commands `interrupt()` pauses the graph from inside a node. The run **ends** on a checkpoint — no connection held, no timer running — and resuming later returns your supplied value from the `interrupt()` call: ```ts import { interrupt } from "@langchain/langgraph"; const answer = interrupt({ question: "Send this email?", draft: state.draft }); ``` This is the concept most dependent on the checkpointer: without one, `interrupt()` has nowhere to park and resume silently no-ops. skein exposes resuming as `command: { resume }` on a normal run create, plus `resume` / `update` / `goto`. **Go deeper →** [Interrupts](https://docs.langchain.com/oss/javascript/langgraph/interrupts) · skein side: [human-in-the-loop](./human-in-the-loop.md) ## Subgraphs A compiled graph can be a node in another graph. That is how you keep a large agent readable — a research step, a drafting step, an approval step, each its own graph with its own state, composed at the top. skein serves the outer graph; the nesting is invisible to the protocol. **Go deeper →** [Subgraphs](https://docs.langchain.com/oss/javascript/langgraph/use-subgraphs) ## Stream modes `graph.stream()` takes a `streamMode` — `values`, `updates`, `messages`, `custom`, `events`, `debug` — and you may request several at once. skein maps each of them onto Agent Protocol SSE frames without translation, which is why the LangChain SDKs and `useStream` work against a skein server with only a URL change. **Go deeper →** [Streaming](https://docs.langchain.com/oss/javascript/langgraph/streaming) · skein side: [streaming](./streaming.md) ## Persistence: the checkpointer and the store LangGraph defines two persistence interfaces, and **skein supplies both** — a checkpointer bound to the thread, and a `BaseStore` bridged from whichever [storage driver](./storage.md) you configured. Your nodes reach the store the usual LangGraph way: ```ts import { getStore, type LangGraphRunnableConfig } from "@langchain/langgraph"; // In a node, the store arrives on the config… async function remember(state: State, config: LangGraphRunnableConfig) { await config.store?.put(["users", userId], "profile", { name: state.name }); } // …and inside a tool, where there's no config argument, reach for getStore(). async function saveName(name: string) { await getStore().put(["users", userId], "profile", { name }); } ``` `getStore()` reads the run currently executing, so call it **inside** the function. At module scope there is no run yet and it throws on import. > [!WARNING] > **Do not construct your own checkpointer or store and pass them to `.compile()`.** It is the > single most common way this breaks: the graph then persists somewhere skein does not know about, > so threads, time travel and interrupt-resume all read the wrong state. Let the injection happen. **Go deeper →** [Persistence](https://docs.langchain.com/oss/javascript/langgraph/persistence) · skein side: [state & context](./state-and-context.md), [storage](./storage.md) ## Who owns what | LangGraph owns | skein-js owns | | ------------------------------------- | --------------------------------------------------- | | State, channels, reducers | Threads, runs, assistants and their versions | | Nodes, edges, subgraphs, `.compile()` | The HTTP surface — Agent Protocol, SSE, the console | | Tools and the agent loop | The run queue, multitask strategies, cancellation | | `interrupt()` and commands | Crons, run-completion webhooks, idempotency | | Stream modes | Storage drivers, and injecting them per run | The line matters when something goes wrong: if it is about what your graph _computed_, it is a LangGraph question. If it is about what got _served, stored or scheduled_, it is ours. ## See also - [Building blocks](./building-blocks.md) — the skein-side map of the same territory - [Your first agent](./your-first-agent.md) — build one from an empty directory - [LangGraph CLI compatibility](./langgraph-cli-compat.md) — what `langgraph.json` supports - [Building a runner](./building-a-runner.md) — serving the protocol from something that isn't LangGraph --- # Features What skein-js can do, in one page. Find the row that matches what you're trying to build, then follow the link. ✅ ships today · ⚠️ preview · 🗺️ planned. Comparing against LangGraph Platform specifically? [roadmap.md](./roadmap.md) has the same ground organised by parity instead. ## Run agents | Capability | Status | What it gets you | | --------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------- | | [Background jobs](./background-jobs.md) | ✅ | Fire-and-forget work: hand skein a job, get an id back, hear the result by webhook | | [Background runs](./runs.md) | ✅ | Return immediately, let the graph keep working, join the stream later | | [Wait / stream runs](./runs.md) | ✅ | Hold the connection for the answer, or stream tokens as they're produced | | [Stateless & batch runs](./runs.md) | ✅ | One-shot calls with no conversation to keep; up to 100 runs per request | | [Multitask / double-texting](./runs.md#multitask-what-happens-to-the-run-already-going) | ✅ | Decide what a second message does to the run already going — all four strategies | | [Cancel & rollback](./runs.md#cancelling) | ✅ | Stop a run, keeping its writes or discarding them | | [Run timeouts](./runs.md#bound-a-runaway-run) | ✅ | Bound a graph that hangs, instead of losing a worker slot | | [Idempotent run creation](./agent-protocol.md#idempotent-run-creation-idempotency-key) | ✅ | A retrying caller can't start the same run twice. No LangGraph Platform equivalent | | [Cron schedules](./crons.md) | ✅ | Fire a graph on a schedule, exactly once across instances, no leader election | | [A graph as a plain endpoint](./serving-a-single-graph.md) | ✅ | `POST /invoke/:graph_id` — body in, final state out, no threads or runs | ## Hold a conversation | Capability | Status | What it gets you | | --------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------- | | [State, context & persistence](./state-and-context.md) | ✅ | What the agent knows, what's only for this run, and what survives a restart | | [Threads](./threads.md) | ✅ | Conversations that persist, addressable by your own key (a ticket id, a phone number) | | [Streaming (SSE)](./streaming.md) | ✅ | Tokens, tool calls and reasoning as they happen — including a true `events` mode | | [Reconnect & join](./streaming.md) | ✅ | Resume a dropped stream, or tail the same run from a second client | | [Human-in-the-loop](./human-in-the-loop.md) | ✅ | Pause for approval and resume hours later, from anywhere | | [Time travel](./threads.md#time-travel-re-run-a-turn-a-different-way) | ✅ | Fork from any past checkpoint and run forward — "edit and resubmit" | | [Thread state & history](./threads.md#read-the-state) | ✅ | Read, patch, page, copy or prune a conversation's state | | [Thread TTL](./storage.md#thread-ttl) | ✅ | Expire conversations automatically. LangGraph OSS ignores `ttl`; skein doesn't | | [Assistants & versioning](./assistants.md) | ✅ | Ship a prompt change without redeploying, and roll it back in one call | | [`useStream` and friends](./react-sdk.md) | ✅ | The LangChain client ecosystem works unchanged — React, Vue, Svelte, Angular | ## Remember things | Capability | Status | What it gets you | | ------------------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------- | | [The store, via `getStore()`](./memory.md) | ✅ | Memory that outlives a conversation, reachable from inside a graph node | | [Semantic search](./storage.md) | ✅ | pgvector-backed recall, with optional HNSW indexing | | [Filters & namespace traversal](./storage.md#filtering-and-namespace-traversal) | ✅ | Query memory by value, and walk namespaces with wildcards | | [Store TTL](./storage.md#store-item-ttl) | ✅ | Expire memories, optionally refreshing on read | | [Bring your own store](./storage.md#bringing-your-own-store-storeadapter) | ✅ | Point skein at a LangGraph `BaseStore` or your own implementation | | [Memory patterns](./memory.md) | ✅ | Profile vs collection shapes, the dedup trap, recall, background writes | ## Build workflows across your systems LangGraph owns workflow orchestration; it doesn't own provider integrations. Skein channels supply the authenticated sources and durable destinations around the graph. | Capability | Status | What it gets you | | -------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------- | | [Workflows & channels](./channels.md) | ✅ | Turn provider events into real processes with durable outcomes | | [Run-completion webhooks](./webhooks.md) | ✅ | Be told a run finished — durably, so a receiver's redeploy doesn't lose the news | | [Signed callbacks](./webhooks.md#verify-a-callback-is-really-from-you) | ✅ | Receivers can prove a callback is yours and reject replays. The verifier ships too | | [Deliveries & replay](./webhooks.md#see-what-a-callback-did-and-replay-it) | ✅ | See every attempt, and re-send one by hand when it never landed | | [Custom auth](./agent-protocol.md#authentication--authorization) | ✅ | LangGraph's `Auth` model, drop-in, with ownership filters pushed into the query | | [Telemetry sinks](./observability.md) | ✅ | LangSmith, PostHog, OpenTelemetry — or your own `TelemetrySink` | | [MCP endpoint](./roadmap.md) | 🗺️ | Expose your graphs as MCP tools | ## Operate it | Capability | Status | What it gets you | | ------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------- | | [The console](./console.md) | ✅ | Test graphs and track channel wiring, interrupts and deliveries at `/console` | | [Postgres + pgvector](./storage.md) | ✅ | The production storage driver, with automatic migrations | | [Redis queue & pub/sub](./runs-and-redis.md) | ✅ | Durable run queue and cross-instance streaming | | [Multi-instance](./deploy.md#scaling-past-one-instance) | ✅ | Atomic create guard, cross-instance cancel, per-thread execution claim | | [Errors & logging](./errors-and-logging.md) | ✅ | What a failed run reports, and where — wire, log, and callback | | [Deploy anywhere](./deploy.md) | ✅ | One image; guides for Cloud Run, Fly, Railway, Render, AWS, Kubernetes, a VPS | | [Performance tuning](./performance.md) | ✅ | Every knob in one table, plus symptom → knob triage | | [Bun / Deno runtimes](./deploy.md) | ⚠️ preview | Fetch launchers and images ship; the runtime matrices must graduate each | ## Build on it | Capability | Status | What it gets you | | -------------------------------------------------- | ------ | ------------------------------------------------------------------------ | | [Framework adapters](./adapters.md) | ✅ | Express, Fastify, NestJS, Next.js, native Fetch — standalone or embedded | | [Embed a graph in code](./embedding.md) | ✅ | No `langgraph.json`, no CLI — bring a compiled graph and mount it | | [Drop-in LangGraph CLI](./langgraph-cli-compat.md) | ✅ | `skein dev` for `langgraph dev`, with your `langgraph.json` unchanged | | [Scaffolding](./scaffolding.md) | ✅ | `npm create skein-js@latest` — a working project, no API key needed | | [Your own adapter](./building-an-adapter.md) | ✅ | Put the handler table on any HTTP framework | | [Your own agent runtime](./building-a-runner.md) | ✅ | Serve the Agent Protocol from something that isn't LangGraph | ## Deliberately not - **WebSocket transport** — SSE covers the client UX and doesn't affect the React SDK. - **`skein deploy` to a hosted platform** — self-hosted by design; there's no managed target. - **Sub-minute cron schedules**, and **backfilling missed occurrences** — see [crons.md](./crons.md#semantics). - **Exactly-once webhook delivery** — delivery is durable and retried, but at-least-once, so every attempt carries a stable dedup key. See [webhooks.md](./webhooks.md#at-least-once-and-what-your-receiver-owes). Something missing? [File an issue](https://github.com/skein-js/skein-js/issues) — compatibility reports are the most useful feedback we get. --- # Assistants An assistant is a named, versioned configuration of one graph. It's how you ship a prompt change or a model swap without redeploying — and how you roll it back when the change was wrong. ## You already have one Every graph in your `langgraph.json` is registered as an assistant at startup, with `assistant_id` equal to `graph_id`. So this works on a fresh server with nothing configured: ```ts await client.runs.wait(threadId, "agent", { input }); // "agent" is the graph id *and* an assistant id ``` If you never need more than one configuration per graph, you can stop reading here. Assistants become useful when you want **several** configurations of the same graph. ## Several configurations of one graph ```ts const support = await client.assistants.create({ graphId: "agent", name: "support-tone", config: { configurable: { system_prompt: "Be concise and formal." } }, }); await client.runs.wait(threadId, support.assistant_id, { input }); ``` Whatever you put in `config.configurable` arrives in your graph as `config.configurable`. That's the seam: the graph stays one deployment, and the assistant decides how it behaves. This is what makes per-tenant or per-plan behaviour tractable — one assistant per tenant, rather than branching inside the graph on metadata you have to thread through every node. ## Versioning and rollback **Every `PATCH` mints a new immutable version.** The live assistant tracks whichever version is current, so changing a prompt is a `PATCH`, and undoing it is one call: ```ts await client.assistants.update(assistantId, { config: { configurable: { system_prompt: "Be warm and brief." } }, }); // now version 2 await client.assistants.getVersions(assistantId); // newest first await client.assistants.setLatest(assistantId, 1); // back to version 1 ``` Rollback points the assistant at an existing version — it doesn't delete the newer one, so you can go forward again. Nothing is lost by trying a change. **Runs record which assistant they used**, so a thread tells you what configuration produced it. What a version does _not_ capture is your graph's code: rolling back an assistant restores config, not the deployment. If the bad change was in a node, roll back the deploy. ## Inspect what a graph expects Useful when you're building a UI against a graph you didn't write, or checking what a config change is allowed to set: | You want | Call | | --------------------------------- | --------------------------------------------- | | Input/output/state/config schemas | `client.assistants.getSchemas(assistantId)` | | The drawable graph | `client.assistants.getGraph(assistantId)` | | Subgraph schemas by namespace | `client.assistants.getSubgraphs(assistantId)` | The [console](./console.md) renders all three — schemas, graph shape, and version history — so it's usually faster to look there than to call them. ## Housekeeping ```ts await client.assistants.search({ graphId: "agent" }); // filter by graph, name or metadata await client.assistants.delete(assistantId); ``` Two things to know before deleting: - **`delete(id, { deleteThreads: true })` cascades** to the threads that assistant owns. Without it, the threads stay and keep their history. - **Deleting a graph-registered assistant doesn't stop the graph.** It's re-registered on the next boot, since registration is keyed on the graph id. ## What you must get right - **`assistant_id` is not a graph id once you create your own.** Runs take an assistant id; a graph id only works because of the startup registration. Store the id you got back from `create`. - **`config.configurable` is the contract with your graph.** Nothing validates that an assistant's config matches what the graph reads — a typo'd key is silently ignored rather than rejected. Check `getSchemas` if you're unsure of the shape. - **`create` defaults to `if_exists: "raise"`.** Pass `"do_nothing"` if you're seeding assistants at startup and want the call to be idempotent. ## Working example There's no dedicated example yet. The [console](./console.md)'s Assistants view is the practical way to see versioning: edit an assistant's config, run it from the playground, then roll back. ## See also - [Runs](./runs.md) — passing an assistant id to a run - [The console](./console.md) — schemas, graph shape, version history in a UI - [LangGraph CLI compatibility](./langgraph-cli-compat.md) — how `graphs` entries become assistants - [Agent Protocol](./agent-protocol.md#assistants) — every assistant endpoint and field --- # State, context & persistence What your agent knows, where each piece of it lives, and what survives a restart. This is the building block everything else rests on — interrupts, time travel, memory and durability are all consequences of it. ## The four bags, and which one you want This is the single most common source of confusion, so start here. A run carries four separate things, and they behave completely differently: | You pass | Goes to | Persisted? | Use it for | | --------------------- | ------------------------------- | --------------------------------- | --------------------------------------------------------------------------- | | `input` | Your graph's **state channels** | **Yes** — checkpointed per thread | The turn itself: the new message, the document | | `context` | LangGraph's **runtime context** | Stored on the run, not the state | Per-run facts the graph reads but shouldn't remember: a locale, a tenant id | | `config.configurable` | The graph's **config** | Stored on the run, not the state | Knobs: which model, which prompt, a feature flag | | `metadata` | The **run/thread row** | Yes, as searchable labels | Finding things later — never read by the graph | ```ts await client.runs.create(threadId, "agent", { input: { messages: [msg] }, // becomes state context: { locale: "en-GB" }, // available to nodes this run config: { configurable: { model: "fast" } }, // knobs metadata: { ticket: "T-1234" }, // searchable label }); ``` The rule of thumb: **if the agent should still know it next turn, it belongs in `input`.** Everything else is per-run. ## State: what the agent knows right now Your graph declares the shape — the channels — and each node returns a partial update. skein-js doesn't define this; it's LangGraph's, unchanged. If the model is new to you, [Thinking in LangGraph](https://docs.langchain.com/oss/javascript/langgraph/thinking-in-langgraph) is the shortest path to it — and its advice to keep **raw data in state and format prompts inside nodes** is worth following, because everything you put in state is what gets checkpointed. The part that surprises people is that **updates go through reducers**. A channel that appends (the usual `messages` reducer) _adds_ what you return rather than replacing it: ```ts // appends to messages — it does not overwrite the list return { messages: [new AIMessage("done")] }; ``` That matters the moment you edit state by hand, from the API or the [console](./console.md): you're writing through the same reducers, so writing `{ messages: [...] }` to fix a transcript will append, not replace. Use `asNode` to attribute the write to a node whose reducer does what you intend. ## Checkpoints: what makes everything else work LangGraph saves the state to a **checkpoint** at node boundaries — each superstep of the graph. That single mechanism is what gives you: - **Multi-turn conversations** — the next run on the thread resumes from the last checkpoint rather than starting empty. - **[Human-in-the-loop](./human-in-the-loop.md)** — `interrupt()` parks on a checkpoint. The run ends; nothing holds a connection or a timer. Resuming reads the checkpoint back. - **[Time travel](./threads.md#time-travel-re-run-a-turn-a-different-way)** — every checkpoint is addressable, so you can read one, fork it, and run forward from the branch. - **Crash recovery** — a process that dies mid-run leaves a committed checkpoint behind. > [!WARNING] > **No checkpointer, none of the above.** Without one, each run starts empty, `interrupt()` has > nowhere to park, and resume silently no-ops — the single most common way this goes wrong. `skein dev` > configures one for you; in code, `embedInMemoryGraphs` / `embedPostgresGraphs` do. Checkpoint history is readable and pageable — see [threads.md](./threads.md#read-the-state). It's also the largest thing you store, which is why `POST /threads/prune` has a `keep_latest` strategy that drops history while keeping current state. ## Short-term vs long-term: two different stores The distinction people miss:
🧵 ### Short-term — the checkpointer Scoped to **one thread**. Holds the conversation's state and its history. Written automatically by the graph on every step; you rarely touch it directly.
🗄️ ### Long-term — the store Scoped to **whatever namespace you choose**, across every thread. Read and written explicitly by your nodes via `getStore()`, with semantic search and TTL.
A user's name belongs in the store, not the thread — otherwise the agent forgets it the moment they start a new conversation: ```ts import { getStore, type LangGraphRunnableConfig } from "@langchain/langgraph"; // In a node, the store arrives on the config… async function remember(state: State, config: LangGraphRunnableConfig) { await config.store?.put(["users", userId], "profile", { name: state.name }); } // …and inside a tool, where there's no config argument, reach for getStore(). async function saveName(name: string) { await getStore().put(["users", userId], "profile", { name }); } ``` `getStore()` reads the run currently executing, so it has to be called inside the function — at module scope there is no run yet and it throws. Both reach the same store — skein bridges its own into every run as a LangGraph `BaseStore`, so nothing here is skein-specific and the same graph runs unchanged on LangGraph Platform. Full patterns — profile vs collection shapes, the dedup trap, recall — are in [memory.md](./memory.md); the mechanism and drivers are in [storage.md](./storage.md). ## What actually persists, and where | Thing | Stored in | Survives a restart? | | ------------------------- | ---------------------------------- | ----------------------------------- | | Graph state & checkpoints | The checkpointer | With Postgres, yes. In memory, no | | Threads, runs, assistants | The store | With Postgres, yes. In memory, no | | Long-term memories | The store | With Postgres, yes. In memory, no | | Queued & delayed runs | The queue | With Redis, yes | | Pending webhook retries | The queue (schedule) + store (row) | Row always; schedule needs Redis | | Live stream frames | The event bus | No — replayable only while retained | **In development** everything is in-memory and disappears on exit — except that `skein dev` snapshots to `.skein/` so your threads survive a restart while you work. **In production**, use Postgres for state and Redis for the queue. See [storage.md](./storage.md) and [runs-and-redis.md](./runs-and-redis.md). ## Expiring what you don't need State accumulates. Two TTLs bound it: - **Thread TTL** (`checkpointer.ttl`) — expire whole conversations. A per-thread `ttl` overrides the default, and `null` pins a thread forever. [Details](./storage.md#thread-ttl) - **Store TTL** (`store.ttl`) — expire individual memories, optionally refreshing on read. [Details](./storage.md#store-item-ttl) Both are skein going past LangGraph OSS, which drops `ttl` on the floor. ## What you must get right - **`input` is the only bag the agent remembers.** `context` and `configurable` are per-run. Putting a fact in `context` and expecting it next turn is the classic mistake. - **Reserved `configurable` keys are stripped.** `thread_id`, `run_id`, `checkpoint_id`, `checkpoint_ns`, `langgraph_auth_user`, anything starting with `__` — the server owns these, so a client can't redirect a run to another thread or spoof the authenticated caller. Your own keys pass through untouched. - **The authenticated user arrives in `configurable`**, not in `context` — as `langgraph_auth_user`, `langgraph_auth_user_id` and `langgraph_auth_permissions`, stamped server-side and unspoofable. Present only when [auth](./agent-protocol.md#authentication--authorization) is configured. - **The store is not ownership-scoped by default.** An authenticated caller can read every tenant's items unless an `@auth.on.store` handler narrows it. See [scoping the store](./agent-protocol.md#scoping-the-store). - **Big state is expensive.** Checkpoints hold the whole state per superstep, so a graph that keeps large blobs in a channel multiplies them. Keep artifacts in object storage and a reference in state. ## See also - [Threads](./threads.md) — reading, editing, forking and expiring conversation state - [Memory](./memory.md) — long-term memory patterns - [Storage](./storage.md) — `SkeinStore`, drivers, TTL, bring-your-own - [Human-in-the-loop](./human-in-the-loop.md) — what checkpoints make possible - [Building a runner](./building-a-runner.md) — implementing state for a non-LangGraph runtime --- # Threads A thread is one conversation: its message history, its graph state, and every checkpoint along the way. If you're building a chat product, a thread is what a user would call "a chat" — and it's the thing you address instead of tracking state yourself. ## Start a conversation ```ts const thread = await client.threads.create(); await client.runs.wait(thread.thread_id, "agent", { input: { messages: [msg] } }); ``` You rarely need more than that. Threads persist, so the next run on the same id picks up where the last one left off — the graph sees the accumulated state without you passing any of it back. ## Address a conversation by your own key Most apps already have an identity for the conversation — a phone number, a support ticket, an email thread. You don't need to store skein's thread id alongside it. Pass your own id and make the create a **get-or-create**: ```ts const thread = await client.threads.create({ threadId: ticketId, ifExists: "do_nothing", // returns the existing thread instead of a 409 }); ``` Uniqueness is enforced in storage, so two instances racing the same id cannot both win — no lock of your own required. The default is `ifExists: "raise"`, a 409 on a taken id. The other half of the idiom is on run creation: `if_not_exists: "create"` lets a run bring the thread into existence, so an inbound webhook can start a run in one call. See [runs.md](./runs.md). ## Read the state ```ts const state = await client.threads.getState(threadId); // current values, next nodes, tasks const history = await client.threads.getHistory(threadId, { limit: 10 }); // newest first ``` `getState` is what a UI hydrates from on load. `getHistory` walks the checkpoints backwards — page with `before`, passing the last checkpoint you received. **History is capped, and the cap matters.** You get at most 100 checkpoints when `limit` is omitted, and a `limit` above 1000 is rejected — each element is a whole graph state, so a long thread's full history is one of the largest responses skein can produce. `useStream` asks for 10, which is enough to render the transcript (the newest checkpoint carries the whole message list) and only limits how far back the branch/edit tree reaches. ## Time travel: re-run a turn a different way ```mermaid flowchart LR C1[turn 1] --> C2[turn 2] --> C3[turn 3] C2 --> F[edited copy of turn 2] --> F2[turn 3, differently] class F accent ``` The original branch is left intact — a fork is a new checkpoint, not a rewrite. Fork from any past checkpoint and run forward from there, leaving the original branch intact. This is what "edit and resubmit" in a chat UI is built on, and it's also the fastest way to debug what a node actually saw. ```ts // Run again from an earlier checkpoint await client.runs.create(threadId, "agent", { input, checkpointId }); ``` To change the state before re-running, write a new checkpoint that forks history first: ```ts const forked = await client.threads.updateState(threadId, { values: { messages: editedMessages }, checkpointId, // omit to fork from the tip }); // The SDK returns a checkpoint config; the new id is on `configurable`. const forkedId = forked.configurable?.["checkpoint_id"] as string; // `input: null` + a checkpoint resumes the graph from that point rather than starting over. await client.runs.create(threadId, "agent", { input: null, checkpointId: forkedId }); ``` Things to know: - **The fork target is server-validated.** A client cannot redirect a run to an arbitrary checkpoint through `config` — skein reads `checkpoint_id` only from the top-level field. - **`updateState` 409s while a run is in flight** on that thread. Cancel or wait first. - **It rides the checkpointer**, so forking costs no extra storage. If you want a whole independent copy instead, `client.threads.copy(threadId)` duplicates the thread and its history. - The [console](./console.md) does all of this without code — open a checkpoint, edit, fork, run forward. > [!WARNING] > **Values you write go through the graph's reducers.** A channel that appends — a message list, say — > will add what you write rather than replace it. This surprises everyone once. Use `asNode` to > attribute the write to a node whose reducer does what you intend. ## Find threads ```ts await client.threads.search({ metadata: { graph_id: "my_graph" }, status: "interrupted" }); ``` skein stamps a run's `graph_id` and `assistant_id` onto its thread's metadata, so filtering by graph is just a metadata match. The stamp reflects the thread's most recent run — a thread that has never run carries no `graph_id`. Status filters are how you build a "waiting for you" queue: `interrupted` threads are the ones [paused for a human](./human-in-the-loop.md). ## Import an existing conversation Migrating from another system, you usually want the history there without re-running the graph over it. `POST /threads` accepts `supersteps` — updates written straight into the checkpoint history: ```ts await client.threads.create({ graphId: "agent", // required — a brand-new thread has no run to infer the graph from supersteps: [{ updates: [{ values: { messages: [past] }, asNode: "__start__" }] }], }); ``` Each superstep is one tick and becomes one checkpoint. Bounded at 100 supersteps of 100 updates. Without `graphId` this is a 400 — and it's also what lets the seeded state be **read back**, since `getState` falls back to `metadata.graph_id` for a thread that has never run. ## Clean up | You want | Call | | ---------------------------------------------- | ---------------------------------------------------- | | One thread gone, with its runs and checkpoints | `client.threads.delete(threadId)` | | Many gone, by filter | `POST /threads/prune` with `strategy: "delete"` | | To keep the conversation but drop its history | `POST /threads/prune` with `strategy: "keep_latest"` | | Threads to expire on their own | `checkpointer.ttl` — see below | `keep_latest` is the one people miss: it keeps each thread's current state and discards only the checkpoint history behind it. That's usually where the bytes are, and losing it costs you time travel, not the conversation. ### Expiring threads automatically ```json { "checkpointer": { "ttl": { "default_ttl": 43200, "strategy": "delete", "sweep_interval_minutes": 60 } } } ``` Durations are in **minutes**. `POST /threads` takes a per-thread `ttl` that overrides the default, and an explicit `null` **pins** a thread so no TTL ever collects it. **Expiry means "may be collected", not "gone".** An expired thread still reads normally until the sweeper takes it — hiding it early would make a thread with an in-flight run vanish out from under that run. > [!WARNING] > **Deleting a thread deletes any cron scheduled on it.** A thread-scoped [cron](./crons.md) on an > expiring thread stops firing, silently. Pin such threads with `ttl: null`, or use a stateless cron, > which owns no thread to lose. LangGraph OSS drops `ttl` on the floor (it's a Platform feature there), so this is skein going past what the open-source checkpointers do. Full reference: [storage.md](./storage.md#thread-ttl). ## Working example [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) puts each issue on its own thread, keyed by the issue id, and re-sweeps without duplicating any of them. ## See also - [Runs](./runs.md) — starting work on a thread - [Human-in-the-loop](./human-in-the-loop.md) — the `interrupted` status and resuming - [The console](./console.md) — browsing threads, checkpoints and forks in a UI - [Storage](./storage.md) — where threads and checkpoints actually live - [Agent Protocol](./agent-protocol.md#threads) — every thread endpoint and field --- # Runs A run is one execution of your graph. Starting one is the single most common thing you'll do against skein, and the shape you pick — wait for it, stream it, or queue it — decides everything about how your client has to behave. ## Pick a run mode | You want | Use | You get | | -------------------------------------------- | ------------------------------------------ | ----------------------------------------- | | The answer, and you can hold a connection | `client.runs.wait(threadId, …)` | The final state, as JSON | | Tokens as they're produced (a chat UI) | `client.runs.stream(threadId, …)` | An SSE stream | | To return immediately and check back later | `client.runs.create(threadId, …)` | The `Run` row; work continues server-side | | A one-shot call with no conversation to keep | The same three, with `null` for the thread | skein creates and owns the thread | | To start many at once | `client.runs.createBatch([…])` | Up to 100 runs per request | ```ts // Wait for it const state = await client.runs.wait(threadId, "agent", { input }); // Stream it for await (const chunk of client.runs.stream(threadId, "agent", { input, streamMode: "messages", })) { // … } // Queue it, come back later const run = await client.runs.create(threadId, "agent", { input }); for await (const ev of client.runs.joinStream(threadId, run.run_id)) console.log(ev); ``` **Background runs need somewhere to run.** They execute on the server after your request returns, so they need a process that stays alive — which is why they don't work on serverless platforms. See [what doesn't work on serverless](./deploy-serverless.md). Streaming is its own topic — the stream modes, reconnecting mid-stream, and joining from a second client are in [streaming.md](./streaming.md). ## Multitask: what happens to the run already going Send a second message before the first finishes ("double-texting") and something has to give. Pass `multitask_strategy` on the second run to say what: | Strategy | The run already going | The new run | Reach for it when | | ----------- | --------------------------- | ------------------ | ------------------------------------------------------------------------------------------ | | `reject` | Keeps going | **Fails with 422** | The default. A second message is a bug or a double-click | | `interrupt` | Stops, keeps what it wrote | Starts now | The user changed their mind, and the partial answer is still worth keeping | | `rollback` | Stops, its writes discarded | Starts now | The user is correcting themselves — the abandoned turn should read as if it never happened | | `enqueue` | Runs to completion | Waits, then runs | Both messages matter and order is what you want | ```ts await client.runs.create(threadId, "agent", { input, multitaskStrategy: "interrupt" }); ``` Things worth knowing before you pick: - **`reject` is the default, and a `pending` run counts as busy.** A run held by `afterSeconds`, or one sitting in the queue, will reject the next one just as a running one does. That surprises people — but work _is_ scheduled on the thread. - **The 422 is `thread_busy`.** Handle it distinctly from an ordinary validation failure: the client's move is to retry or to switch strategy, not to fix the request. - **`enqueue` is not strict FIFO** unless you run at concurrency 1. Several queued runs on one thread are dequeued together and race for the thread. See [head-of-line blocking](./runs-and-redis.md#head-of-line-blocking). - **It's decided atomically**, so two instances racing the same thread cannot both win. You don't need a lock of your own. A displaced run settles `interrupted` (under `interrupt`) or `cancelled` (under `rollback`), and one that never started executing sends no [webhook](./webhooks.md). ## Cancelling ```ts await client.runs.cancel(threadId, runId); // action: "interrupt", the default ``` | `action` | Effect | | ----------------------- | ----------------------------------------------------------------------------------------------------- | | `interrupt` _(default)_ | Settles the run `cancelled` and **keeps** whatever it wrote | | `rollback` | Also discards its checkpoint writes and deletes the run row — the turn reads as never having happened | > [!WARNING] > **`cancel` takes these positionally, not as an options object.** The signature is > `cancel(threadId, runId, wait?, action?)`, so `cancel(tid, rid, { action: "rollback" })` binds your > object to `wait` and silently performs an ordinary `interrupt` instead. ```ts await client.runs.cancel(threadId, runId, true, "rollback"); // wait for it to stop, discard its writes ``` The third argument returns only once the run has actually stopped, rather than as soon as it's been marked. To cancel in bulk, `POST /runs/cancel` takes `{ thread_id?, run_ids?, status? }`, narrowest selector first: explicit ids, else one thread's inflight runs, else **every** inflight run on the server. Unknown ids are skipped rather than failing the sweep, and the response says what actually happened. ## Bound a runaway run A graph that hangs — a model call with no timeout of its own, a node that loops — holds a worker slot until the process restarts. Set `--run-timeout ` or `SKEIN_RUN_TIMEOUT_MS`; the run aborts and settles as `timeout`. **Off by default, deliberately.** A legitimate research or multi-step tool run takes minutes, so a default would turn slow-but-working into killed — the exact failure the timeout exists to prevent. Pick a number from your own graphs' worst honest case. ## Start a run later `afterSeconds` holds a run before it starts — capped at 86400 (a day); for anything longer use a [cron](./crons.md). Use it on a **background** run. The queue holds it, so it costs nothing while it waits and, on Redis, survives a restart. On an inline `wait`/`stream` run the server holds _your connection_ open instead, so a long delay will hit a proxy's idle timeout. A delayed run is cancellable like any other, and counts as inflight the whole time it waits. ## Fields where skein differs from LangGraph Two defaults are deliberately not LangGraph's. If you're migrating, these are the ones to check: | Field | skein | LangGraph | Why | | --------------- | ---------- | --------- | ------------------------------------------------------------------------------------------ | | `on_completion` | `keep` | `delete` | A stateless run's thread stays inspectable afterwards | | `on_disconnect` | `continue` | `cancel` | A proxy timeout is indistinguishable from a real hang-up, and shouldn't kill a healthy run | Pass the LangGraph value explicitly if you want its behaviour. Note `useStream` sends `on_disconnect: "cancel"` on every submit unless the stream is resumable, so with a browser client closing the tab does stop the run — skein's default only applies to callers that don't send the field. One more worth knowing, though it matches LangGraph: **naming a thread that doesn't exist is a 404** (`if_not_exists: "reject"`). Pass `"create"` to have the run bring the thread into existence instead — which is how you start a run keyed on an external identity (a phone number, a ticket id) without a round trip to create the thread first. ## Don't start a run on a thread that's waiting for a human `interrupt()` leaves a thread parked until someone answers, and the run that parked it is **terminal** — `interrupted` is a finished status, so the thread holds no inflight run. That means `multitask_strategy` cannot protect it: every strategy, including the default `reject`, arbitrates `pending` and `running` only. A plain start on an interrupted thread therefore **succeeds**, and the pending question is discarded with no error and no log line. `if_thread_status` is how you say "only if nobody is waiting": ```ts await fetch(`${url}/threads/${threadId}/runs`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ assistant_id: "support", input: { messages: [{ role: "human", content: text }] }, if_thread_status: ["idle", "error"], }), }); // → 409 { code: "thread_status_mismatch", details: { status: "interrupted" } } ``` The check happens **inside the driver's atomic create**, alongside the one `multitask_strategy: "reject"` already uses — so two replicas racing the same thread cannot both win. An in-process mutex cannot give you this; the window is between processes. The 409 carries the status it actually observed, so you can branch without a second read that might see a third value. The usual branch for an async chat channel is: refused with `interrupted` → resume the pending interrupt instead of starting a new run. ```ts // The reply hours later is an answer, not a new conversation. body: JSON.stringify({ assistant_id: "support", command: { resume: text } }); ``` It composes with `multitask_strategy` rather than overlapping it — that one guards `pending`/`running`, this one guards everything else. Omit the field and run creation behaves exactly as it always has. A storage driver that does not implement the precondition answers `501 if_thread_status_unsupported` rather than quietly falling back to a read-then-create, because a non-atomic fallback looks like it works right up until two replicas race. Both first-party drivers implement it. ## Don't create the same run twice A retrying caller — Stripe, GitHub, Twilio, or your own sweep — should not start a second run. Send an `Idempotency-Key` header and the original response replays: ```ts // `runs.create` has no `headers` option — a `headers` key in the payload is silently dropped and you // get a brand-new run every retry. A per-key client is the only way to send it. const client = new Client({ apiUrl, defaultHeaders: { "Idempotency-Key": issue.id } }); await client.runs.create(threadId, "agent", { input }); ``` Streaming creates reject the header rather than ignoring it — an SSE response has no body to replay. Details: [agent-protocol.md](./agent-protocol.md#idempotent-run-creation-idempotency-key). ## Reading a run back | You want | Call | | ---------------------------------------- | ----------------------------------------- | | One run's row | `client.runs.get(threadId, runId)` | | A thread's runs | `client.runs.list(threadId)` | | To tail a run already in flight | `client.runs.joinStream(threadId, runId)` | | To block until it settles, then get JSON | `client.runs.join(threadId, runId)` | Joining a run that has **already** settled returns immediately, long after its frames have aged out — the wait is decided by the run row, not by the event stream. So a client that reconnects late still gets its answer. To be told a run finished without holding anything open, use a [webhook](./webhooks.md). ## Working example [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) dispatches one background run per issue, each on its own thread, with idempotent re-sweeps — and runs with no API key and no network. ## See also - [Streaming](./streaming.md) — stream modes, reconnecting, joining - [Runs & Redis](./runs-and-redis.md) — run concurrency, the queue, scaling past one instance - [Human-in-the-loop](./human-in-the-loop.md) — pausing a run for a person - [Crons](./crons.md) — running a graph on a schedule - [Agent Protocol](./agent-protocol.md#runs--stateless--ephemeral) — every run endpoint and field --- # Background jobs Fire-and-forget work: hand skein a job, get an id back immediately, and hear about the result later. No conversation to maintain, no connection to hold open. This is the shape you want when the caller is another service rather than a person — classification, enrichment, document processing, a queue drained by an agent. There is no separate "tasks" API. A background job **is** a run — you just don't keep the thread. ## Enqueue and return ```ts const run = await client.runs.create(null, "agent", { input: { documentUrl }, onCompletion: "delete", // don't keep the thread once it's done webhook: "https://example.com/hooks/done", // tell me when it lands }); // returns in milliseconds; the graph runs server-side ``` `null` for the thread id means **stateless**: skein creates a thread for the run, and `onCompletion: "delete"` removes it once the run settles. Nothing accumulates. Leave `onCompletion` off (skein's default is `keep`) if you'd rather the job stay inspectable afterwards — you can read its state and its checkpoints for as long as you keep it. ## Hearing the result Three ways, in order of how much they cost you: | Approach | Use when | | ----------------------------------- | -------------------------------------------------------------- | | A [webhook](./webhooks.md) | The default. Nothing to poll, and delivery survives a redeploy | | `client.runs.join(threadId, runId)` | You can hold a connection and just want the answer | | `client.runs.get(threadId, runId)` | You already have a polling loop | The webhook is the one to reach for. It's recorded in the same transaction as the run's terminal status, so a receiver that was restarting doesn't lose the news — and you can [see and replay](./webhooks.md#see-what-a-callback-did-and-replay-it) a callback that never landed. If you kept the thread (`onCompletion: "keep"`), joining works long after the run settled — the wait is decided by the run row, not by a live stream. If you deleted it, the webhook payload is the record; that's exactly why the payload is stored rather than re-rendered at send time. ## Don't run the same job twice A retrying caller — a queue, a cron, a partner's webhook — will re-send. Send an `Idempotency-Key` and the original response replays instead of starting a second run: ```ts // `runs.create` has no `headers` option, so a per-key client is the only way to send it. const client = new Client({ apiUrl, defaultHeaders: { "Idempotency-Key": documentId } }); await client.runs.create(null, "agent", { input, onCompletion: "delete" }); ``` The claim is arbitrated by a uniqueness constraint, so 50 concurrent retries across two instances still produce exactly one run. Details: [agent-protocol.md](./agent-protocol.md#idempotent-run-creation-idempotency-key). ## Many, later, or on a schedule ```ts // Up to 100 at once await client.runs.createBatch(items.map((item) => ({ assistantId: "agent", input: item }))); // Start in 30 seconds (capped at a day) await client.runs.create(null, "agent", { input, afterSeconds: 30 }); // Every five minutes, forever await client.crons.create("agent", { schedule: "*/5 * * * *", input: { source: "queue" } }); ``` A delayed background run is held by the queue, so it costs nothing while it waits and — on Redis — survives a restart. For anything longer than a day, use a [cron](./crons.md). ## How many run at once One instance executes **10** background runs concurrently by default, matching the LangGraph CLI's `--n-jobs-per-worker`. Raise it with `--concurrency` or `SKEIN_RUN_CONCURRENCY` when your jobs are I/O-bound (model calls); add instances when they're CPU-bound. Concurrency is the knob with the widest blast radius — it multiplies memory and Postgres connections at once. Size it against your pool: [runs-and-redis.md](./runs-and-redis.md#run-concurrency). ## What you must get right - **A failed job is not retried.** skein retries webhook _delivery_, not the run. A graph that throws settles `error` and stays there. If your work needs retries, either retry inside the graph, or have your caller re-submit with a fresh idempotency key. - **A crashed worker is different.** If the process dies mid-run, the job is recovered rather than lost — and re-delivery is safe, because a run already terminal in the store is skipped. - **Background runs need a process that stays alive.** They execute after your request returns, so they don't work on serverless. See [deploy-serverless.md](./deploy-serverless.md). - **Use durable storage.** With the in-memory driver a queued job is gone on restart. Postgres for state, Redis for the queue — [runs-and-redis.md](./runs-and-redis.md). - **Bound a job that hangs.** A graph with no timeout of its own holds a worker slot until the process restarts. Set `--run-timeout` / `SKEIN_RUN_TIMEOUT_MS`; it's off by default. - **Each job holds a Postgres connection** while it runs, and a second one on the Postgres driver for its thread claim. Ten concurrent jobs is not one connection. ## When you want the answer inline instead If the caller can wait and there's genuinely no conversation — a classifier, an extractor — skip runs entirely: ```bash curl -sX POST localhost:2024/invoke/triage \ -H 'content-type: application/json' \ -d '{"text":"Refund charge failed — urgent!"}' ``` `POST /invoke/:graph_id` takes the graph's input as the body and answers with its final state. No threads, no run rows, no webhooks. See [serving-a-single-graph.md](./serving-a-single-graph.md). ## Working example [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) is exactly this shape: a cron sweep dispatches one durable background run per item, idempotently, so re-sweeping creates nothing. It runs with no API key and no network. ## See also - [Runs](./runs.md) — every run mode and field - [Webhooks](./webhooks.md) — being told a run finished - [Crons](./crons.md) — running a graph on a schedule - [Runs & Redis](./runs-and-redis.md) — the queue, concurrency, scaling out - [Serving a single graph](./serving-a-single-graph.md) — the synchronous non-chat surface --- # Human-in-the-loop Pause a run for a person to approve, edit, or answer something — and resume it later, possibly hours later, from a different client. An interrupted run holds no connection and no timer, only a checkpoint, so your process can restart while it waits. ```mermaid flowchart LR A[create run] --> B["interrupt()"] --> C["run ends
nothing held open"] C --> D["hours, or weeks"] --> E[resume] --> F[done] class C accent ``` ## Pause the graph Call LangGraph's `interrupt()` from a node. Whatever you pass becomes the payload your UI renders: ```ts import { interrupt } from "@langchain/langgraph"; function askForApproval(state: State) { const answer = interrupt({ question: "Send this email?", draft: state.draft, }); return { sent: answer === true }; } ``` The run settles, the thread's status becomes `interrupted`, and the interrupt surfaces in the stream. Nothing is holding anything open. ## Resume it ```ts await client.runs.create(threadId, "agent", { input: null, // not a new turn — pick up where the interrupt parked command: { resume: true }, }); ``` `input: null` plus a `command` is the whole idiom. The run resumes from the checkpoint the interrupt parked on rather than starting over, and the value you pass in `resume` is what `interrupt()` returns inside the node. Three commands are available: | Command | Does | Use for | | -------- | ------------------------------------------- | ------------------------------------------ | | `resume` | Returns a value from the `interrupt()` call | Approve / reject / supply an edited answer | | `update` | Writes state before continuing | Correcting something the graph got wrong | | `goto` | Continues at a named node | Sending the turn down a different path | If you're building on `POST /threads/{id}/commands` directly, it infers the assistant from the thread's last run, so you don't have to track it — and it **409s (`thread_not_interrupted`)** on a thread that isn't paused, rather than quietly starting a new turn. ## In a React UI, this is free [`useStream`](./react-sdk.md) renders the interrupt and resumes it for you — no extra endpoints, no polling. The interrupt arrives on the stream, you render your approval card, and submitting a command continues the same conversation. ## Find what's waiting An approvals queue is a thread search: ```ts const waiting = await client.threads.search({ status: "interrupted" }); ``` The [console](./console.md) ships this view already — its Overview lists what's waiting for you, and the Interrupts view approves, rejects, or resumes with any JSON, without you building a UI first. ## What you must get right - **A checkpointer is required.** Without one there is no checkpoint to park on, and resume silently no-ops — the single most common way this fails. `skein dev` gives you one; in code, use `embedInMemoryGraphs`/`embedPostgresGraphs` or supply your own. See [embedding.md](./embedding.md). - **Use durable storage for anything real.** With the in-memory driver an interrupted thread is gone on restart, which defeats the point of pausing for a human. [Postgres](./storage.md) keeps it. - **The pause has no timeout.** A thread waits indefinitely. If your product needs "auto-reject after 48 hours", that's a [cron](./crons.md) sweeping `status: "interrupted"` threads — skein won't do it for you. - **Resuming starts a new run.** The interrupted run is terminal; the resume is a fresh run on the same thread. So a webhook fires per run, and the run id you resumed with is not the id you get back. - **A plain start on a waiting thread is not refused for you.** Because the interrupted run is _terminal_, the thread holds no inflight run — so `multitask_strategy`, which arbitrates `pending`/`running`, never sees it, and a fresh start **succeeds** and discards the pending question. Pass [`if_thread_status`](./runs.md#dont-start-a-run-on-a-thread-thats-waiting-for-a-human) when the caller might not know the thread is waiting, which is every asynchronous channel: ```ts // 409 `thread_status_mismatch` instead of silently trampling the interrupt. { assistant_id: "support", input, if_thread_status: ["idle", "error"] } ``` The check runs inside the driver's atomic create, so it holds across replicas — an in-process lock cannot give you this. - **Not available on `POST /invoke/:graph_id`**, which has no thread to park on. See [serving-a-single-graph.md](./serving-a-single-graph.md). ## Interrupting from outside the graph You can also pause without an `interrupt()` call, by naming nodes on run creation: ```ts await client.runs.create(threadId, "agent", { input, interruptBefore: ["send_email"] }); ``` `interruptBefore` / `interruptAfter` take a list of node names, or `"*"` for every node. This is a debugging and stepping tool more than a product feature — `interrupt()` is what you want when the pause is part of the design, because it carries a payload the UI can render. ## Working examples - [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) — reached by a conditional edge, so only items that need a human park. Runs with no API key and no network; approve from the console. - [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) — an approval card in a real Next.js UI. ## See also - [Threads](./threads.md) — the `interrupted` status, and editing state before you resume - [Runs](./runs.md) — what a resume run is, and multitask strategies - [Frontend SDKs & `useStream`](./react-sdk.md) — rendering and resuming in React - [The console](./console.md) — approving without building a UI - [Building a runner](./building-a-runner.md) — implementing interrupts for a non-LangGraph runtime --- # Run-completion webhooks Tell another service that a run finished, and have that news survive a receiver that was redeploying when it happened. Pass a `webhook` URL on run creation and skein POSTs the settled run to it — durably, with retries, optionally signed, and replayable by hand when it never lands. ## Quick start ```ts await client.runs.create(threadId, "agent", { input, webhook: "https://example.com/hooks/run", }); ``` That is the whole opt-in. The field is LangGraph's, unchanged; everything below is delivery semantics, not a different payload. It works on every run-creation route that has a run to report on — background, `wait` and `stream`, stateless, batch, and each firing of a [cron](./crons.md). It is not available on [`POST /invoke/:graph_id`](./serving-a-single-graph.md), which has no run row. ## What the callback carries The settled run, as a JSON body: the run's own fields (`run_id`, `thread_id`, `assistant_id`, `metadata`, …) plus | Field | What it is | | ------------------------------- | ----------------------------------------------------------------------------------------- | | `status` | The run's terminal status — `success` · `error` · `timeout` · `interrupted` · `cancelled` | | `values` | The run's final state | | `error` | The failure message, on a failed run only (a string, matching LangGraph) | | `interrupts` | The questions the run is waiting on, on an `interrupted` run only — see below | | `reply` | What the graph declared should be sent back, if it declared one — see below | | `run_started_at`/`run_ended_at` | When the run ran | | `webhook_sent_at` | When **this attempt** was sent — it changes between retries | `status` is read from the delivery row rather than from the stored body, so a callback can never disagree with the run you read back afterwards. A cancel that won the race is reported as the cancel. ### `interrupts` — the question a paused run is asking A run that hits `interrupt()` settles as `interrupted` and waits for an answer. The question itself is **not in `values`** — it is the interrupt's payload, which lives on the thread's pending tasks — so a receiver holding only the callback could not render it, and a question nobody asks is a conversation that waits forever. `interrupts` carries it, keyed by the task that raised it, in the same shape [`GET /threads/{id}`](./threads.md) returns: ```jsonc { "run_id": "…", "status": "interrupted", "values": { "…": "…" }, "interrupts": { "task-1": [{ "id": "…", "value": { "question": "Refund £40?" } }] }, } ``` The field is **absent entirely** on any other status, so a run that succeeded sends exactly the body it sent before this existed. Both `values` and `interrupts` count toward [`max_payload_bytes`](#what-this-stores-and-for-how-long); if the body is over the cap, `values` is replaced by a truncation marker first and `interrupts` only if that was not enough — you can act on a question without the state, but not on the state without the question. **A run that never started executing sends no callback.** A run cancelled while still `pending`, or one displaced by a [multitask strategy](./runs.md#multitask-what-happens-to-the-run-already-going) before it began, has nothing to report — so do not treat "a run was created" as "a callback is owed". Only a run that started does. Every request also carries three headers, whenever the store records deliveries — which both bundled drivers do: | Header | Use | | --------------------- | ----------------------------------------------------------- | | `X-Skein-Delivery-Id` | Stable across every retry — **this is your dedup key** | | `X-Skein-Attempt` | Which attempt this is, counting the first inline one as `1` | | `X-Skein-Signature` | Present when a signing key is configured — see below | The one exception is a custom store that doesn't record deliveries — see [the guarantee](#the-guarantee-why-a-callback-survives-a-crash) below. ## The guarantee: why a callback survives a crash ```mermaid flowchart LR R[run settles] --> TX subgraph TX["one transaction"] ST[terminal status] --- D[delivery row] end TX --> I[inline attempt] I -->|2xx| Done[done] I -->|fails| Q[retry schedule] Q --> Done class TX accent ``` **The callback is recorded in the same transaction as the run's terminal status.** A crash between "the run finished" and "someone was told" cannot lose it, because there is no instant where one is committed and the other is not. That guarantee comes from the store. It is not the retry policy, and it is not something you configure — a run that carries a `webhook` owes a callback whether or not you have tuned anything. skein attempts the first delivery inline, so a healthy receiver hears within milliseconds; only a failure falls through to the retry schedule. A [bring-your-own store](./storage.md) that does not implement the `deliveries` repo degrades to the older best-effort path — one POST, failures logged and swallowed. Both bundled drivers implement it, so this only bites a store you wrote yourself. > [!WARNING] > That fallback POST carries **no `X-Skein-Delivery-Id`, no `X-Skein-Attempt` and no > `X-Skein-Signature`** — there is no delivery row for those to come from. A receiver that dedupes on > the header, or rejects unsigned requests, will find nothing to work with. If you implement a custom > store and want callbacks at all, implement the `deliveries` repo. The body is **stored**, not re-rendered at send time. It has to be: a stateless `POST /runs` deletes the thread the server made for it as soon as the run settles, so an hour later there is no run row and no checkpoint left to render from — and that is exactly the "another service drives skein and wants the answer back" case webhooks exist for. Each POST is aborted after `SKEIN_WEBHOOK_TIMEOUT_MS` (default 5s), sized against the 8s shutdown budget so a slow receiver cannot get the server killed mid-POST. Delivery happens after the thread's execution lock is released, so a slow target never blocks other runs on that thread — which also means two callbacks for one thread are not guaranteed to arrive in run order. ## At-least-once, and what your receiver owes **Delivery is at-least-once, never exactly-once.** A POST that timed out _after_ your receiver committed is indistinguishable from one that never arrived, so it is retried. Dedupe on `X-Skein-Delivery-Id`, which is stable across every attempt. Answer `2xx` only once the work is durably done. Answering early and then failing loses the callback — the sender heard success and cleared the payload. A non-2xx is how you ask for a retry. The API remains the source of truth for run state. A callback is a notification, not a ledger. ## Where the retries actually run The durability of a callback is the same everywhere. What differs is whether a retry still _waiting_ survives a restart: | | Retry schedule | A pending retry survives a restart? | | ---------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | **Postgres + Redis** | BullMQ — delayed jobs, exponential backoff with jitter, stalled-job recovery | **Yes.** The schedule lives in Redis | | **Postgres, no Redis** | skein polls the outbox for rows that are due | No — but the _delivery_ does. The row is committed, and the next boot picks it up | | **Memory (dev)** | The same poll, in-process | No, and neither does the delivery — nothing here is durable | So: **run Redis in production.** skein warns at startup when your store is durable but your delivery schedule is not. To exercise the real path locally, `skein dev --queue redis`. A process killed at exactly the wrong moment can leave a committed delivery with no schedule attached. Nothing is lost: a background sweep picks it up on its next pass. The practical consequence is that such a callback arrives late rather than never — worth knowing if you alert on delivery latency. The default is 12 attempts over roughly 34 minutes, which rides out a rolling deploy with room to spare. Read that as a **time horizon, not a count** — the delays double, so dropping to 6 attempts is ~31 seconds, which does not survive one redeploy. Tune it under [`skein.webhooks`](./langgraph-cli-compat.md#webhooks-skeinwebhooks). ## Verify a callback is really from you Set `SKEIN_WEBHOOK_SECRET` and every callback carries `X-Skein-Signature` in Stripe's format — `t=,v1=` over `"{t}.{raw body}"` — so a receiver can reuse verification code it already has. skein ships the verifier too, from a package that installs with no graph runtime, so a service whose only relationship with skein is _receiving_ callbacks can depend on it for this and nothing else: ```ts import { verifySkeinSignature } from "@skein-js/agent-protocol"; const result = verifySkeinSignature({ header: req.headers["x-skein-signature"], body: rawBody, // the RAW bytes — see below secrets: [process.env.SKEIN_WEBHOOK_SECRET!], }); if (!result.ok) return res.status(401).end(); ``` It refuses with a `reason` of `missing`, `malformed`, `stale` or `mismatch`, compares digests in constant time, and tries every secret you hand it. Because the signature covers the timestamp, a **replay is detectable with no state on your side**: a captured callback stops verifying once it is outside the tolerance window (5 minutes by default, `toleranceSeconds`). The check is absolute, so a callback timestamped in the future is refused too. ### `reply` — letting the graph say what to send back Anything turning a run into an outbound message — a chat reply, a notification, an email — has to know where the answer lives. Only two parties could decide: your receiver, or the graph. A receiver that guesses (`state.answer`? `state.messages.at(-1)`? `state.draft`?) stops working the moment you change the graph's state shape. So let the graph declare it, on the custom stream: ```ts import { replyWith } from "@skein-js/agent-protocol"; // inside a node, with LangGraph's StreamWriter in scope writer(replyWith("Your order ships Tuesday.")); ``` It arrives as `reply` in the callback body, and the run must request the custom stream (`stream_mode: ["values", "custom"]`) for the writer's output to be seen. The field is absent unless the graph declared one, so nothing changes for a graph that doesn't — and it **adds** to the body rather than replacing `values`, so a receiver that already reads state keeps working. Declaring it on the stream rather than in state is deliberate: the reply is a message _about_ the run, not part of it, and putting it in state would make it a channel every reducer and checkpoint carries forever. skein captures it into the stored delivery as the run settles, so — unlike anything read off a live stream — it survives a crash and is replayed verbatim on a retry. Last write wins. ### Verifying somebody else's signature `verifySkeinSignature` only understands skein's own format. If you are on the _other_ side — checking a signature from Twilio, Slack, GitHub or Stripe as it arrives at a route of yours — the one piece worth borrowing rather than writing is the comparison: ```ts import { equalsConstantTime } from "@skein-js/agent-protocol"; const expected = createHmac("sha1", authToken).update(signable, "utf8").digest("base64"); if (!equalsConstantTime(expected, received)) return res.status(401).end(); ``` `===` on a signature is a timing oracle — it returns at the first differing byte, so an attacker who can send many requests and measure your response recovers a valid signature a byte at a time. The length check inside is not decoration either: Node's `timingSafeEqual` **throws** on unequal lengths, so the obvious hand-rolled version turns a forged short signature into a 500 instead of a 401. ### Three things receivers get wrong, in the order they get them wrong 1. **Verifying a re-serialized body.** `JSON.stringify(req.body)` does not reproduce what was signed — key order, whitespace and number formatting are not guaranteed to round-trip — so _every_ genuine callback fails. In Express: `express.raw({ type: "application/json" })` on that route, or `express.json({ verify })`. 2. **Not deduping.** See at-least-once above. `X-Skein-Delivery-Id` is the key; `X-Skein-Attempt` tells you it is a retry. 3. **Answering 2xx before the work is durable.** The sender clears the payload on success. There is nothing left to resend. A runnable receiver doing all three correctly — accepting a genuine callback, refusing a forged one and a replayed one — is `src/webhook-receiver.ts` in [`examples/express-basic`](https://github.com/skein-js/skein-js/tree/main/examples/express-basic) (`pnpm webhook-receiver`). ### Rotating the key Set `SKEIN_WEBHOOK_SECRET` to a comma-separated list, **new key first**: skein signs with the first and receivers accept any they hold. Remove the old key only once every receiver has the new one — the other order refuses every callback until they catch up. > [!WARNING] > If you inject your own `webhookDispatcher`, it must forward `attempt.headers` and send > `attempt.body` verbatim, or callbacks go out unsigned while your config says they are signed. skein > warns at startup when it sees both a signing key and a custom dispatcher. ## See what a callback did, and replay it When a receiver was down for longer than the retry horizon, the delivery is `dead` — and still on disk, which is what makes a by-hand replay possible. | Method | Path | Notes | | ------ | -------------------------------------------------------------------- | ----------------------------------- | | `GET` | `/threads/{thread_id}/runs/{run_id}/deliveries` | `?status=` · `?limit=` · `?offset=` | | `POST` | `/threads/{thread_id}/runs/{run_id}/deliveries/{delivery_id}/replay` | Makes it due immediately | ```bash curl "$API/threads/$TID/runs/$RID/deliveries?status=dead" curl -X POST "$API/threads/$TID/runs/$RID/deliveries/$DID/replay" ``` Each row reports its `status` (`pending` · `delivering` · `delivered` · `dead`), `attempt`, `last_error`, `next_attempt_at` and `payload_truncated`. The list reports a boolean **`replayable`** rather than the payload itself. The payload is up to 256 KiB of the run's final state _per row_, and this is a list — returning it would make an operator's "what failed?" the heaviest response the server can produce, for a field they did not ask for. The question a replay turns on is only whether there is anything left to send. Read the state from the run. Replay makes a delivery due immediately. It **409s** on one that already succeeded (its payload was cleared on delivery, so there is nothing to resend) and **404s** on a delivery id that does not belong to the run in the path — so an id cannot be used to reach a callback on a run you have no access to. Both endpoints sit in the `runs` route group, so `http.disable_runs` and your existing `@auth.on.threads` handler already cover them; no new switch, no new resource. There is **no console view for deliveries yet** — this is API-only today. ## What this stores, and for how long A retry has to have something to send, so **the callback body is stored** — including the run's final `values`. That is a new at-rest copy of whatever your graph produced, in the `deliveries` table, and it is worth classifying deliberately if that state carries personal or regulated data: - **On success it is cleared immediately**, so steady-state storage is in-flight deliveries, not every delivery ever made. - **A `dead` delivery keeps it** for `retain_hours` (default 24) — that is what makes replay possible, and it means a callback that never landed leaves the run's final state on disk for a day. - **It is not encrypted by skein.** Use your database's encryption at rest; the column is ordinary `jsonb`. - **Deleting a thread or run does not erase its deliveries** — unlike idempotency records, which are erased explicitly. Lower `retain_hours` if that matters to you. Payloads are capped at `max_payload_bytes` (default 256 KiB). Over the cap, `values` is replaced by a truncation marker **inside the signed body** — so a receiver is told it is looking at a truncated state rather than left to infer it — and the row's `payload_truncated` records it. Raise the cap, or read the state back from the API using the `run_id` in the callback. `values` and `interrupts` are the only fields ever replaced, because they are the only unbounded ones. When both are present `values` goes first: an interrupted run's question is what makes the callback actionable at all, so it is the last thing dropped. skein **redacts the webhook URL's path in logs**, keeping only the scheme and host: for Slack, Discord and Teams the path _is_ the credential, and a failing delivery would otherwise write it into your log sink on every attempt. ## Accepting webhook URLs from untrusted callers `webhook` is a caller-supplied URL, so it is a server-side request to a target they chose — and retrying it turns a one-shot SSRF probe into a repeated one. Set [`skein.webhooks.allowed_hosts`](./langgraph-cli-compat.md#webhooks-skeinwebhooks) if you accept run creates from clients you do not control. It is off by default so that upgrading cannot start dropping your own callbacks; a refused host is recorded `dead` with the reason rather than silently skipped. Set `require_https: true` if callbacks leave your network. Plaintext is permitted by default (an internal receiver is legitimate), but retries mean the body crosses the wire up to `max_attempts` times rather than once — a dozen exposures rather than one. ## Configuration Every knob — `retries.max_attempts`, `retries.initial_delay_ms`, `secret`, `max_payload_bytes`, `retain_hours`, `allowed_hosts`, `require_https` — is documented once, with its default and its reasoning, in [`skein.webhooks`](./langgraph-cli-compat.md#webhooks-skeinwebhooks). Two environment variables sit outside that block: `SKEIN_WEBHOOK_SECRET` (which takes precedence over a `secret` in the config file) and `SKEIN_WEBHOOK_TIMEOUT_MS`. > [!WARNING] > skein does **not** expand `${VAR}` anywhere in `langgraph.json`. `"secret": "${SKEIN_WEBHOOK_SECRET}"` > signs every callback with that literal 23-character string. Use the environment variable. ## See also - [Runs](./runs.md) — the run-creation fields a callback reports on - [Errors & logging](./errors-and-logging.md) — what a failed run puts in the callback body - [Agent Protocol](./agent-protocol.md) — the full endpoint inventory - [Background jobs](./background-jobs.md) — the fire-and-forget shape webhooks were built for --- # Workflows and channels — connect LangGraph to external systems Workflows and channels solve different halves of the same problem. A **workflow** defines what should happen after an event arrives. A **channel** is the provider integration: it defines how that event enters the workflow and how an outcome reaches an external system. Together they form one reliable path: ```text channel source → LangGraph workflow → channel destination ``` Skein owns the channel boundary: authentication, deduplication, thread and run coordination, and durable delivery. LangGraph owns the workflow in the middle: state, decisions, tools, branching, and `interrupt()`. LangGraph deliberately doesn't own integrations to WhatsApp, email, Slack, GitHub, or your internal systems. It gives you the orchestration engine. Skein channels give that engine authenticated sources and durable destinations. This is the part Skein adds: the integration lifecycle that turns graph logic into a workflow people and systems can actually participate in. ## What “workflow” means here A workflow is the business process expressed by your LangGraph graph and carried across its persisted thread state. It is more than forwarding one message to another provider. It can inspect an event, call tools, branch, fan out work, ask a person for a decision, pause for hours or days, resume from a later event, and finally choose what should happen next. For example, “send this email to WhatsApp” is message forwarding. “Validate a refund request, collect the required approvals over WhatsApp, wait for every response, decide the refund, and notify the customer by email” is a workflow. Workflow is not a new Skein resource or API. It is the LangGraph graph you already write, plus the state LangGraph persists in a thread. A channel is the adapter and Skein pipeline around that graph: its source verifies and translates provider events; its destination translates and delivers graph outcomes. ```text channel source LangGraph workflow channel destination email / WhatsApp / webhook → state + decisions → email / WhatsApp / provider API tools + branching interrupt / resume ``` ## What this looks like in practice The useful unit is the whole process, not a provider-to-provider pipe: | Real-world workflow | Channel source | LangGraph does | Channel destination | | ------------------------------ | ------------------------- | ------------------------------------------------------------- | --------------------------------------- | | **Answer an order question** | Customer WhatsApp message | Looks up the order, decides whether to escalate, drafts reply | Reply to the same WhatsApp chat | | **Approve a refund** | Customer email | Validates the claim, gathers approvals, pauses and resumes | WhatsApp approvers, then email customer | | **Handle a failed deploy** | GitHub deployment webhook | Inspects the failure, classifies severity, chooses escalation | Alert the on-call team in Slack | | **Resolve an order exception** | ERP order webhook | Checks inventory, branches by risk, waits for warehouse input | Notify sales or email the customer | In every case, the channel handles provider truth—signatures, event IDs, identities, payloads, and delivery—while the workflow handles business truth: what the event means and what should happen next. ## Why LangGraph and channels fit together LangGraph doesn't try to own provider integrations, and external systems fail differently from business logic. A provider webhook needs authentication, a fast acknowledgement, retry deduplication, and stable identity mapping. The business process needs state, branching, tool calls, and human-in-the-loop pauses. Outbound delivery needs recipient policy, provider-specific validation, retries, and idempotency. Keeping those responsibilities separate gives each layer one job: | Layer | Owns | | ----------------------- | -------------------------------------------------------------------- | | **Channel source** | Verify, parse, deduplicate, identify the thread, and start or resume | | **LangGraph workflow** | State, decisions, tools, branching, fan-out, and `interrupt()` | | **Channel destination** | Validate and deliver the declared outcome through the durable outbox | This keeps provider code thin and keeps transport concerns out of the graph. The graph declares an outcome; the destination adapter performs the external side effect after the run settles. ## When to use it Use a workflow with channels when an external provider event starts or resumes a process whose state belongs in LangGraph, and the result must return to a provider reliably. The channel shape depends on the workflow: - Use a **coupled channel** when the source already determines where the outcome belongs, such as a WhatsApp question followed by a WhatsApp answer. - Use **decoupled channel delivery** when the workflow must choose another provider, recipient, or route, such as an email request that triggers WhatsApp approvals and an email decision. - Use the Agent Protocol run API directly when your own application already owns ingress and only needs to invoke or stream a graph; a channel adds no value in that path. In both shapes, the workflow stays provider-independent. It receives ordinary graph input and declares an outcome; the surrounding channels translate between that data and provider payloads. You write thin provider adapters. Skein handles retry deduplication, external-identity-to-thread mapping, start-versus-resume decisions, and durable delivery consistently across channels. > A chat channel is a `Channel` whose reply target happens to be the sender. A GitHub webhook and a > Stripe dispute go through the identical pipeline without that property — which is why these types > talk about _events_, and why you will not find `from`, `to`, `body` or `typing` in any of them. Entirely optional. A deployment that configures no channel does not install the package, serves no channel routes, and cannot tell the feature exists. ## What Skein removes from each workflow connection Connecting a workflow to a phone number by hand means writing signature verification, deduplication for retried deliveries, a mapping from `whatsapp:+254…` to a thread, a branch on whether that thread is already waiting on a human, payload mapping in both directions, and a reply path that does not double-send. Only two of those are about the provider. The rest are identical for every integration anyone will ever write — and their failures are silent ones: a double reply, a lost reply, a question nobody was ever asked. ## Quick start ```bash pnpm add @skein-js/channels ``` Write the provider adapter and bind its source route to a graph: **`src/twilio-channel.ts`** ```ts import { equalsConstantTime } from "@skein-js/agent-protocol"; import type { Channel } from "@skein-js/channels"; export const channel: Channel = { name: "twilio", verify(request) { const expected = sign(process.env.TWILIO_AUTH_TOKEN!, request.url.href, request.form()); if (!equalsConstantTime(expected, request.headers["x-twilio-signature"] ?? "")) return false; return { identity: `channel:twilio:${request.form()["From"]}` }; }, parseEvent(request) { const message = request.form(); if (!message["Body"]) return { kind: "ignore" }; return { kind: "event", event: { threadKey: message["From"]!, idempotencyKey: message["MessageSid"]!, replyTo: { to: message["From"] }, input: { messages: [{ role: "human", content: message["Body"] }] }, resumeWith: message["Body"], }, }; }, async deliver(outcome, target) { const { to } = target as { to: string }; if (outcome.reply !== undefined) await sendWhatsApp(to, String(outcome.reply)); }, }; ``` **`langgraph.json`** ```jsonc { "graphs": { "support": "./src/support.ts:graph" }, "skein": { "channels": { "twilio": { "path": "./src/twilio-channel.ts:channel", "assistant": "support", "public_url": "https://api.example.com", }, }, }, } ``` That serves `POST /channels/twilio`. Point your provider's webhook at it. A complete, runnable version — offline, no Twilio account — is [`examples/whatsapp-agent`](https://github.com/skein-js/skein-js/tree/main/examples/whatsapp-agent). ### Let a workflow route between providers The source that starts a workflow does not have to receive the result. The [`decoupled-delivery`](https://github.com/skein-js/skein-js/tree/main/examples/decoupled-delivery) example sends an email into one LangGraph workflow, routes approval requests to WhatsApp, collects parallel authenticated approvals with LangGraph `interrupt()`, then sends the result by email. Its runnable refund demo uses Skein's source, destination, auth, deduplication, thread/run and delivery paths with offline provider fakes. Skein exposes this as a small composition layer rather than separate provider hierarchies: ```ts import { composeRoutedChannel, declareChannelDestinationDelivery, type ChannelDestinationDelivery, } from "@skein-js/channels"; const destinations = new Map([ [ "whatsapp", async (delivery: ChannelDestinationDelivery) => { const message = whatsappSchema.parse(delivery); await sendWhatsApp(message, { idempotencyKey: delivery.runId }); }, ], ]); export const channel = composeRoutedChannel(emailSource, destinations); ``` ### How LangGraph fills the middle and selects the destination LangGraph passes a custom-stream writer to every node in `LangGraphRunnableConfig`. The Skein helper uses that existing writer to declare the result of the workflow; the node does not call WhatsApp or email itself: ```ts import { Annotation, END, START, StateGraph, type LangGraphRunnableConfig, } from "@langchain/langgraph"; import { declareChannelDestinationDelivery } from "@skein-js/channels"; const RelayState = Annotation.Root({ source: Annotation<"email" | "whatsapp">, sender: Annotation, whatsappNumber: Annotation, subject: Annotation, }); function routeMessage( state: typeof RelayState.State, config: LangGraphRunnableConfig, ): Partial { if (state.source === "email" && state.whatsappNumber) { declareChannelDestinationDelivery(config.writer, { destination: "whatsapp", target: { to: state.whatsappNumber }, payload: { body: `Important email from ${state.sender}: ${state.subject}` }, }); } else { declareChannelDestinationDelivery(config.writer, { destination: "email", target: { to: state.sender }, payload: { subject: "Assistant update", body: state.subject }, }); } // The destination declaration is an output instruction, not graph state. return {}; } export const graph = new StateGraph(RelayState) .addNode("route-message", routeMessage) .addEdge(START, "route-message") .addEdge("route-message", END) .compile(); ``` The workflow connection is deliberately small: 1. Skein's source converts email or WhatsApp into ordinary LangGraph input. 2. LangGraph performs extraction, classification, branching, `Send`, and `interrupt()` as usual. 3. The graph writes one explicit destination declaration through `config.writer`. 4. When the run settles, Skein resolves that declaration and invokes the allowlisted destination through its durable outbox. For approvals, keep using LangGraph's `interrupt()`; no Skein approval abstraction is needed. A node can declare the WhatsApp approval request before it interrupts, and declare the final email result after `interrupt()` returns on resume. The complete parallel HR/Manager/Finance implementation is [`examples/decoupled-delivery/src/relay-graph.ts`](https://github.com/skein-js/skein-js/blob/main/examples/decoupled-delivery/src/relay-graph.ts). The map allowlists destination adapters and remains application-owned, along with provider credentials and routing policy. It does not authorize recipients or operations: derive those from trusted workflow data and enforce tenant/recipient policy before sending, rather than treating an LLM-selected or user-supplied target as permission. Only `declareChannelDestinationDelivery` triggers routing; ordinary `replyWith` values and inferred interrupt/AI-message replies are not dispatched by a routed channel. Construct declarations through the helper, never its internal persisted marker. `target` and `payload` must be plain JSON values so the outbox can persist them. They remain `unknown` to the destination, which must validate its provider boundary—Zod is used above for exactly that. Unknown or malformed declarations and thrown callbacks fail the outbox attempt visibly and are retried. Delivery is at-least-once, so pass a stable key such as `runId` to providers that support idempotency. One run still owns one outbox retry unit; aggregate fan-out does not create independent delivery rows. This helper is for channel-ingested runs. A poller using the Agent Protocol run API still uses its own HTTP callback receiver. Existing `Channel` implementations with `deliver` continue to work unchanged. Configured route keys and explicit channel-name aliases must be unique; collisions now fail at boot instead of sharing thread identity or misrouting a callback. For copy-first workflow implementations, compare the practical [WhatsApp → order lookup → WhatsApp recipe](./recipes/coupled-channel.md) with the [customer refund email → Finance WhatsApp approval → customer email recipe](./recipes/decoupled-channel-delivery.md). ## The pipeline ```text POST /channels/twilio → verify() your signature check → a principal, or 401 → authorize() your Auth block, exactly as on every run-creating route → parseEvent() ignore → 204 · respond → that response · event → on → dedup the provider's event id, claimed over the raw bytes → thread a deterministic id, get-or-create → start | resume decided atomically against the thread's real status → 2xx after enqueueing, never after the run finishes ├→ onSignal() progress, best-effort └→ deliver() the answer, durably ``` Two properties of that shape are worth internalising before you write anything. **The acknowledgement is early, always.** Slack retries after three seconds and shows the user an error; Twilio times out comparably. Your `parseEvent` returns, the run is enqueued, and skein answers 2xx — it never waits for the graph. That is why progress needs `onSignal` and why the answer needs `deliver`: by the time either has something to say, the HTTP request is long gone. **`verify` runs before anything is parsed.** A signature covers the bytes as sent, so any middleware that parses first destroys the thing you need to check. skein hands you the body as text for exactly this reason. ## Implementing `verify` ```ts verify(request: InboundRequest): Promise | ChannelPrincipal | false; ``` Return a **principal**, or `false` for a 401. **Why a principal and not a boolean.** A provider's signature _is_ an authentication scheme; it just is not a bearer token. Producing an identity lets the message flow through the `Auth` block you already configured — `@auth.on.threads` handlers see it, ownership filters apply, multi-tenancy works — instead of needing a route that bypasses authorization to create runs. ```ts return { identity: `channel:twilio:${from}`, // derived from provider-verified data only permissions: ["threads:write"], // optional; becomes AuthContext.scopes metadata: { tenant }, // optional; merged into the user object }; ``` Build `identity` **only from data the signature covered**. A value the caller could have set freely is not an identity, and everything downstream — ownership filters, per-tenant scoping — inherits whatever trust you put in it here. **`verify` is required.** A provider with no signature scheme must still yield a principal some other way: a secret path segment, a shared-secret query parameter, basic auth. Weaker is a decision your deployment gets to make; absent is not, because the route creates runs. ### `InboundRequest` | Member | Notes | | --------- | --------------------------------------------------------------------------- | | `method` | The HTTP method | | `url` | A `URL`. The **public** one — see below | | `headers` | Lower-cased keys, whatever the transport | | `text()` | The body exactly as sent | | `form()` | Decoded `application/x-www-form-urlencoded` | | `json()` | Parsed JSON. Throws on a malformed body, so guard it if the provider varies | The body views are lazy and cached, so reading `form()` in both `verify` and `parseEvent` parses once. **`url` is the public URL, and that matters more than it looks.** Twilio signs the full request URL, so verification depends on knowing what the provider saw — and behind a proxy the server cannot know it. The only inputs are `Host` and `X-Forwarded-Proto`, both attacker-controlled. So skein takes the origin from `public_url` in your config and keeps the router's own path. Set it in production, or signature verification will fail in a way that looks like every request being forged. ### Comparing signatures ```ts import { equalsConstantTime } from "@skein-js/agent-protocol"; ``` Not `===`. It returns at the first differing byte, so an attacker who can send many requests and measure your response recovers a valid signature a byte at a time. The length check inside is not decoration either: Node's `timingSafeEqual` **throws** on unequal lengths, so the obvious hand-rolled version turns a forged short signature into a 500 instead of a 401. ## Implementing `parseEvent` ```ts parseEvent(request: InboundRequest): Promise | ChannelOutcome; ``` Three outcomes, and you will need all three. ### `{ kind: "event", event }` — do something ```ts return { kind: "event", event: { threadKey: message.From, // which conversation input: { messages: [...] }, // what a fresh turn runs with resumeWith: message.Body, // what an interrupt gets, if different idempotencyKey: message.MessageSid, // the provider's own event id replyTo: { to: message.From }, // opaque; handed back to deliver/onSignal onExisting: "resume", // default assistantId: "triage", // only if allowed_assistants lists it metadata: { locale: message.Locale }, // stamped on the thread }, }; ``` | Field | Required | Notes | | ---------------- | -------- | --------------------------------------------------------------------------- | | `threadKey` | ✅ | Stable external identity. Hashed into a thread id | | `input` | ✅ | Graph input for a fresh turn | | `threadId` | | Choose the thread id yourself; skips the derivation entirely | | `resumeWith` | | What `interrupt()` receives. Defaults to `input` — usually wrong, see below | | `idempotencyKey` | | No key, no dedup. See [Retries](#retries-and-idempotency) | | `replyTo` | | Opaque to skein. Omit it and no reply is delivered | | `onExisting` | | `resume` (default) · `enqueue` · `interrupt` · `reject` | | `assistantId` | | Rejected unless the deployment listed it in `allowed_assistants` | | `metadata` | | Merged onto the thread's metadata | ### `{ kind: "ignore" }` — acknowledge, do nothing Answers 204 and starts no run. **Use it liberally.** Slack redelivers your own bot's messages, and a channel that cannot cheaply say "not interesting" ends up answering itself in a loop. Delivery receipts, read receipts, reactions, media-only messages and bot echoes all belong here. ```ts if (payload.bot_id) return { kind: "ignore" }; ``` ### `{ kind: "respond", status, body }` — answer this request directly No run, your response. Required by real providers, not a convenience: ```ts // Slack's very first request is a challenge that must be echoed back. if (payload.type === "url_verification") { return { kind: "respond", status: 200, body: { challenge: payload.challenge } }; } // A slash command wants an immediate ephemeral acknowledgement. return { kind: "respond", status: 200, body: { response_type: "ephemeral", text: "On it…" } }; ``` ## Retries and idempotency Providers retry. Twilio retries on any non-2xx and on a timeout; Slack retries three times. Without dedup the customer gets two answers. Set `idempotencyKey` to **the provider's own event id** — `MessageSid`, Slack's `event_id`, GitHub's `X-GitHub-Delivery`. A retry carries the same one, and skein replays the first response instead of starting a second run. Three things worth knowing about how it behaves: - **The fingerprint is the raw inbound bytes**, not anything derived. A retry that correctly re-reads the thread and resumes instead of starting sends the same key with a _different_ run body — and fingerprinting that derived body would refuse the retry precisely because it did the right thing. - **Same key, different bytes → 422.** Two genuinely different events sharing an id is a provider bug or a forgery; replaying would tell the sender its message was handled while silently dropping it. - **No key means no protection**, deliberately. Without a provider-assigned id nothing identifies two deliveries as the same event, and deriving one from the body would collide when a customer legitimately sends "yes" twice. ## Conversations that wait for a human This is the case channels exist for, and the one every hand-written integration gets wrong. `interrupt()` parks a thread until someone answers. The run that parked it settles as `interrupted`, which is a **terminal** status — so the thread holds no inflight run, no `multitask_strategy` guards it, and a plain start succeeds and discards the pending question. Silently. A channel does the right thing by default: a message arriving on a paused thread **resumes** it. The decision is a [precondition on the create](./runs.md#dont-start-a-run-on-a-thread-thats-waiting-for-a-human), settled inside the driver, so two replicas cannot both resume. ### Set `resumeWith` — you almost certainly need it `input` is what a _fresh turn_ takes. For a message-shaped graph that is an envelope: `{ messages: [{ role: "human", content: "yes" }] }`. But `interrupt()` returns whatever the node asked for — usually a scalar the graph author chose: ```ts const answer = interrupt("Approve this refund?"); if (/^y/i.test(String(answer))) { … } ``` Hand that node the envelope and `String(...)` gives `"[object Object]"`, which does not match — so the graph reads a "yes" as a "no", and nothing errors. skein will not guess between the two shapes, so say which is which: ```ts input: { messages: [{ role: "human", content: body }] }, resumeWith: body, ``` ### Choosing `onExisting` | Value | What happens | When | | ----------- | -------------------------------------------------------- | --------------------------------------------------- | | `resume` | Answers a pending interrupt; otherwise queues a new turn | **Default.** Chat, email, anything conversational | | `enqueue` | Always a new turn, behind whatever is running | A feed of events that each deserve their own run | | `interrupt` | Cancels the run in flight and starts this one | "Actually, stop — do this instead" | | `reject` | 409 unless the thread is genuinely free | An event that is only valid on an idle conversation | Under `resume`, a message arriving while the agent is still thinking **queues** rather than being refused. The server's default `multitask_strategy` is `reject`, which for Twilio would render as a failed message for having typed twice; a conversation wants ordering. When the answer is not an answer — _"actually, never mind, what's my balance?"_ — skein still resumes and the **graph** decides whether to re-ask, pivot or abandon, because the graph holds the question. If you would rather have a blunter rule, use `enqueue`. ## Delivering the reply ```ts deliver?(outcome: RunOutcomeForChannel, target: ReplyTarget): Promise; ``` Optional — a GitHub channel that comments from inside the graph owes no callback. Omit it and no reply is delivered. **It runs inside skein's delivery outbox**, which is where all its guarantees come from. The delivery row is written in the run's finalize transaction, so a crash between "the run succeeded" and "the customer was told" cannot lose the answer. A **throw is a failed attempt**: retried with exponential backoff, recorded, and replayable from the [delivery routes](./webhooks.md#see-what-a-callback-did-and-replay-it). So let it throw — swallowing an error here is how an answer gets silently dropped. ```ts async deliver(outcome, target) { if (outcome.reply === undefined) return; // nothing to say const { to } = target as { to: string }; await sendWhatsApp(to, String(outcome.reply)); // throws → retried } ``` ### What `outcome.reply` is Resolved by skein, in order: 1. **What the graph declared**, via `replyWith` on the custom stream. 2. **The last AI message** in `values.messages` — LangGraph's `MessagesAnnotation` convention, so an ordinary chat agent works with no graph changes at all. 3. **The interrupt's question**, when the run is `interrupted`. 4. Otherwise `undefined`. Resolved centrally rather than per channel on purpose: a channel that guessed at `state.answer` versus `state.draft` would stop being reusable across graphs, which is the premise the whole plugin surface rests on. For a graph whose state is not message-shaped, declare it: ```ts import { replyWith } from "@skein-js/agent-protocol"; // inside a node, with LangGraph's StreamWriter in scope writer(replyWith("Your order ships Tuesday.")); ``` **A failed run says nothing.** `error`, `timeout` and `cancelled` resolve to no reply, because whether an end user hears "something went wrong" is a product decision and leaking internals to a phone number is the wrong default. Send your own message from `deliver` if you want one. ## Progress signals ```ts signals: { kinds: ["progress"], keepaliveMs: 10_000 }, async onSignal(signal, target) { … } ``` Because the acknowledgement is early, progress cannot ride the response. `onSignal` is how a typing indicator gets sent while the graph works. **Its guarantees are deliberately the opposite of `deliver`'s**: best-effort, at most once, never retried, never blocking the run, dropped on any error. Retrying a "typing" indicator four minutes late is nonsense, and a slow provider must never be why the acknowledgement times out. That is why they are two methods rather than one with a flag. - `progress` — fires immediately, then once per frame the run produces. - `keepalive` — fires on `keepaliveMs`, for providers whose indicator expires (most do, in seconds). Declaring a subscription is what lets skein pick the cheapest stream that satisfies it. A channel that asks for nothing costs exactly what an API run costs; one that asks for `progress` gets node-level updates and **never** pays for token streaming it would only throw away. ## Addressing a conversation from outside Thread ids are derived, and the derivation is exported — which is what makes hashing acceptable rather than merely private: ```ts import { threadIdForChannelKey } from "@skein-js/channels"; const threadId = threadIdForChannelKey("twilio", "whatsapp:+254712345678"); await client.threads.get(threadId); ``` The phone number never reaches a primary key, an index or a backup. The raw key **is** stamped into thread metadata, so a search answers the same question from the other side — which is the shape a GDPR erasure request actually arrives in: ```ts await client.threads.search({ metadata: { skein_thread_key: "whatsapp:+254712345678" } }); ``` ### Ending a conversation and starting a new one Two different things people mean by this, and they need different answers. **"Forget everything and start over."** Delete the thread. The next message from that number derives the same id, finds nothing there, and begins fresh: ```ts await client.threads.delete(threadIdForChannelKey("twilio", "whatsapp:+254712345678")); ``` **"Start a new conversation, but keep the old one."** Then the thread key is not just the phone number — it is the phone number _and_ which conversation. `threadKey` is whatever your channel says, so put the session in it: ```ts // A ticket, a billing period, a counter you keep — whatever "a conversation" means to you. threadKey: `${message.From}:${await currentSessionFor(message.From)}`; ``` Old conversations stay readable, each under its own thread, and a search on `skein_thread_key` still finds them because the raw key is what gets stamped. A channel that already has its own conversation ids returns `threadId` on the event instead, and skein does no transformation at all. ## Configuration | Key | Meaning | | -------------------- | --------------------------------------------------------------------- | | `path` | `path:export`, or a package name | | `assistant` | **Required.** Which graph these events run | | `allowed_assistants` | Graphs the channel may route to itself. Omitted means it cannot route | | `public_url` | Your externally reachable origin, when signatures cover the URL | **`assistant` is required and not defaultable.** The binding is deployment knowledge: a community Twilio adapter has no business knowing you named your graph `support`. It takes a graph name or an assistant UUID, resolving exactly as it does for [crons](./crons.md). **`allowed_assistants` is opt-in and bounded** for a sharper reason. A channel is an npm package you installed; without a bound, an `assistantId` derived from untrusted input could reach any graph you serve. Omit the key and the channel cannot route at all. Everything is validated **at boot**: a missing `assistant`, one naming a graph that does not exist, an `allowed_assistants` entry outside your graphs, a `path` whose export is not a channel. Discovering any of those when the first customer texts is the failure this avoids. ## Testing a channel The quickest end-to-end check is the console's **Channels** tab. It shows the effective routes and assistant allowlists that passed boot validation. Copy an inbound path, send a fixture or provider event, then open the resulting thread and run: the run's **Deliveries** panel shows attempts, retry timing, errors, and a confirmed replay action. Sensitive webhook paths and opaque reply targets are redacted in the UI. The same inventory is available as authenticated `GET /channels`; it is mounted only when at least one channel is configured and exposes no module paths, public URLs, credentials or raw config. A channel is a plain object, so `verify` and `parseEvent` are unit-testable with no server: ```ts const request = buildInboundRequest({ method: "POST", url: "https://api.example.com/channels/twilio", headers: { "x-twilio-signature": signature }, text: "From=whatsapp%3A%2B254&Body=hi&MessageSid=SM-1", }); expect(channel.verify(request)).toEqual({ identity: "channel:twilio:whatsapp:+254" }); ``` For the whole path, boot a runtime from a `langgraph.json` and dispatch into the handler — no HTTP server, no ports: ```ts const resolved = await resolveProtocolRuntime({ config: "./langgraph.json" }); const response = await resolved.runtime.handlers.handleInboundEvent(request); expect(response.status).toBe(202); ``` Worth covering, because each has shipped broken in real integrations: a forged signature is refused; a retried delivery produces exactly one run; an interrupt is resumed rather than trampled; a delivery receipt starts no run. [`examples/whatsapp-agent`](https://github.com/skein-js/skein-js/tree/main/examples/whatsapp-agent) does all four offline. ## Things that will bite you - **Mount order, if you also hand-roll routes.** `skeinRouter` installs a JSON body parser for everything routed into it, so a raw-body route registered _after_ it sees an empty body and fails to verify — a 401 that looks like a forged request. Channel routes are handled for you; your own are not. - **`public_url` behind a proxy.** Without it, URL-signing providers fail every request. - **Forgetting `resumeWith`.** The graph reads a "yes" as a "no" and nothing errors. - **Swallowing errors in `deliver`.** A throw is what buys you the retry. - **Returning `false` from `verify` for a _parse_ failure.** That reports a forged request when the truth is a malformed one; prefer `{ kind: "respond", status: 400 }` from `parseEvent`. - **Sending the reply from `parseEvent`.** It will be sent again on every retry. The outbox exists so that it is not your problem. ## What is not here yet - **First-party channels.** There are none: you write the file. Shipping one would make skein responsible for the correctness of someone else's signature scheme, so a flaw becomes a CVE here rather than in your code — a real cost that has to be earned. - **A channel conformance suite**, so a community channel can prove it rejects forged signatures, stale timestamps and duplicate ids without hand review. - **Chunked streaming of the answer** — send it in pieces, crash halfway, and the outbox replays a message the user already partly received. Needs provider-side editing (Slack can, WhatsApp cannot). - **Attachments.** Carry the provider's media URL in `metadata` and fetch it from the graph. - **Polled sources** (IMAP, queue consumers). The pipeline is entered by an inbound HTTP request, and there is nothing to enter it with when you poll — that is closer to [crons](./crons.md). ## See also - [webhooks.md](./webhooks.md) — the durable delivery a channel's reply rides on - [human-in-the-loop.md](./human-in-the-loop.md) — `interrupt()`, and why a reply hours later must resume rather than restart - [runs.md](./runs.md#dont-start-a-run-on-a-thread-thats-waiting-for-a-human) — `if_thread_status`, the precondition underneath `onExisting` - [agent-protocol.md](./agent-protocol.md#authentication--authorization) — the `Auth` block a verified principal flows through --- # Streaming (SSE) Streaming is what makes an agent UI feel alive: tokens appear as the model writes them, model **thinking** streams into a collapsible panel, tool calls and their **structured results** show up as they happen, and a paused run's **interrupt** surfaces for approval. skein-js delivers all of it over one transport — **Server-Sent Events (SSE)** — so the standard clients ([`useStream`](./react-sdk.md), the vanilla SDK, Agent Chat UI) render a rich conversation against a skein-js server with only a URL change. The flagship [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) example wires the full experience end to end; [`react-usestream`](https://github.com/skein-js/skein-js/tree/main/examples/react-usestream) is the minimal harness. Under the hood, skein-js maps LangGraph.js **stream modes** onto Agent Protocol SSE. This one transport powers the `/runs/stream` endpoint, joining an in-flight run (`/runs/{id}/stream`), and thread-scoped streaming (`/threads/{id}/stream`). Reference: LangGraph streaming — ## LangGraph.js stream modes A `CompiledStateGraph.stream(input, { streamMode })` can emit any combination of: | Mode | Emits | | ---------------- | ------------------------------------------------- | | `values` | Full state after each step | | `updates` | State deltas per node (**default**) | | `messages` | Complete messages | | `messages-tuple` | Message chunk + metadata tuples (token streaming) | | `custom` | User-emitted custom events | | `events` | Fine-grained execution events | | `debug` | Detailed debug info | Multiple modes can be requested at once; skein-js preserves that. When `events` is among the requested modes the run engine drives the graph via LangGraph's `streamEvents` (v2) and emits each event as an `events` frame (co-requested modes like `values` still stream alongside); otherwise it uses `graph.stream`. ## Mapping to Agent Protocol SSE Each LangGraph stream item becomes an SSE frame: ``` event: # e.g. messages, updates, values, custom id: # per-run sequence for replay/reconnect data: ``` - **Event id sequencing** — each run assigns monotonically increasing ids so a reconnecting client can resume via `Last-Event-ID` (replay support; full replay buffering is iterative). - **Terminal frames** — a final `event: end` (or `error`) closes the stream with the run's status. An `error` frame's payload is a `RunError` — `{ error, message, name, cause?, errors? }`, plus `stack` when the server sets `exposeErrorStacks`. See [errors-and-logging.md](./errors-and-logging.md). - **Transport ownership** — `@skein-js/core` produces an async iterator of normalized frames; each framework adapter writes them as `text/event-stream` (Express `res.write`, Fastify reply stream, etc.). The core stays framework-agnostic. - **Idle heartbeats** — a stream that goes 15s without a frame gets a `: heartbeat` line. An SSE comment is spec-defined as ignorable, so no client dispatches it as an event; it exists for the machinery in between. Without it, a graph thinking for a minute before its first token looks identical to a dead socket — nginx, ALB, and Cloud Run all default to closing an idle connection around 60s, and the SDK's `stream_idle_reconnect: "auto"` has nothing to distinguish the two either. Tune it with `toSseEvents(..., { heartbeatMs })`; `0` writes only real frames. ## Slow clients and backpressure A stream is only as fast as the client reading it, and skein paces itself accordingly. Every adapter's write loop honors the response stream's backpressure signal: when the socket's buffer is full it waits for `drain` before pulling the next frame, rather than queueing whatever the graph produces. That matters because the alternative is unbounded. A client on a bad connection — a phone on mobile data, a buffering reverse proxy — that reads more slowly than the graph writes would otherwise be served entirely out of the server's memory, one full copy of the stream per connection. Measured in [`packages/bench`](https://github.com/skein-js/skein-js/tree/main/packages/bench) on the `slow-client` scenario (clients reading at ~25 fps against a 500 fps graph, ~2 MB per stream): | Concurrent slow streams | Unflushed server-side buffer | Per streaming connection | | ----------------------- | ---------------------------- | ------------------------ | | 50, before | 62.9 MB | ~1.26 MB | | 100, before | 125.5 MB | ~1.26 MB | | 50, after | 3.3 MB | ~67 KB | | 100, after | 6.5 MB | ~65 KB | Before, the per-connection cost was the whole stream, so total memory grew with both the number of clients **and** the length of each run. After, it is a constant close to the socket's own 64 KB high-water mark: still linear in connection count, as it must be, but no longer proportional to how much the graph produces. Two consequences worth knowing: - **The graph does not slow down.** The run engine publishes into the event bus and the write loop reads from it, so pacing the reader changes how fast frames leave the bus, not how fast they enter it. A slow client cannot stall the run, or any other client's stream. - **Backpressure itself never discards.** It delays delivery; a slow client receives every frame, just later. What it does is move the queue from the socket into the event bus — and the bus is bounded, so a client slow enough to exceed that bound has its stream **ended** rather than being left hanging. What happens next differs by bus. On **Redis** the frames are still in the durable stream, so reconnecting with `Last-Event-ID` replays them: nothing is lost. On the **in-memory bus** the stream ended precisely _because_ the frames were evicted to stay under `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN`, and that buffer is also the replay log — so a reconnect resumes from what survives, with a gap. See [performance.md](./performance.md#streaming-backpressure-drops-and-recovery) for the bounds, and why that asymmetry is a reason to run Redis for unreliable clients. The Next.js App Router adapter gets this for free: it maps frames onto a `ReadableStream` whose `pull` is demand-driven by the platform. ## Joining and cross-instance fan-out - `GET /runs/{run_id}/stream` lets a late client join a run already in progress. - When a run executes on a **different** worker than the one holding the client connection, [`@skein-js/redis`](./runs-and-redis.md) pub/sub fans the frames across instances so the join still works. In single-process `skein dev`, an in-memory event bus is used instead. ## Why SSE is enough (no WebSocket in v1) The entire LangChain client surface — the vanilla SDK, the [`useStream`](./react-sdk.md) React hook, and Agent Chat UI — consumes **SSE**. The protocol's optional WebSocket upgrade buys bidirectional framing we don't need for v1, so it is deferred (see [roadmap.md](./roadmap.md)). **Deferring WebSocket does not affect the React SDK.** --- # Errors & logging What happens when a graph throws — where the failure shows up, what a client sees, and what lands in your logs. Errors and logging live in one doc because a failure goes to both, on deliberately different terms: **the log gets everything; the wire gets what is safe to hand a caller.** ## When a graph throws A node that throws does not crash the server. The run engine catches it, and five things happen: | Surface | What it gets | | --------------------- | ---------------------------------------------------------------------------------------------- | | **The server log** | An `error`-level line with the full stack and `cause` chain. Always — see [Logging](#logging). | | **The SSE stream** | A terminal `event: error` frame carrying a [`RunError`](#the-runerror-payload). | | **The run row** | `status: "error"` plus `error` — the same `RunError`, durable. | | **The thread row** | `status: "error"` plus `error` (the message). A _mirror of the latest turn_. | | **`POST /runs/wait`** | `200` with `{ "__error__": { … } }` in place of the graph's values. | | **The webhook** | `error` as a plain message string, alongside the settled run. | The run row is the durable record. The thread's `status` and `error` are cleared as soon as a later run on that thread succeeds — they describe the thread _now_, not its history. If you need to know why a particular run failed, read the run. ```bash curl localhost:2024/threads/$THREAD/runs/$RUN ``` ```json { "run_id": "63086ee4-…", "status": "error", "error": { "error": "Error", "name": "Error", "message": "model call failed", "cause": { "error": "Error", "name": "Error", "message": "MISSING_KEY is undefined" } } } ``` ### The `RunError` payload One shape (`RunError`, from `@skein-js/core`) is used by the SSE frame, the persisted `Run.error`, and the `__error__` wait body — so the stream and a later `GET` can never disagree. | Field | | | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `error` | The error's constructor name. LangGraph Platform's name for this field. | | `message` | The error's message. The one field every client renders. | | `name` | Always identical to `error` — see [compatibility](#langgraph-platform-compatibility). | | `cause` | The `Error.cause` chain, one `RunError` per link, up to five deep. | | `errors` | An `AggregateError`'s members (up to ten) — LangGraph throws one when several nodes fail in the same superstep, and its envelope message alone tells you nothing. | | `stack` | **Only when the server opts in.** See below. | `toRunError(thrown, { includeStack })` builds one from anything thrown. It is cycle-safe, depth capped, and never throws — it runs on the failure path, where a second failure would hide the first. ### `exposeErrorStacks` A stack names server file paths, dependency versions, and sometimes argument values. So it goes to your logs unconditionally, and to the **client** only when you ask: ```ts const deps: ProtocolDeps = { /* … */ exposeErrorStacks: true }; ``` - **`skein dev` turns it on**, unconditionally — not behind `--verbose`. Needing a flag to find out why your graph crashed is the problem this exists to remove. - **`skein start`, and every embedded server, leave it off.** LangGraph Platform never puts a stack on the wire either, so with it off the frame is a strict superset of the platform's. Turning it off never costs you information as an operator: the server log has the full stack either way. It only affects what the _client_ can see. ## Logging skein logs through a four-method interface — no framework, no transport, no color: ```ts interface Logger { debug(message: string, meta?: unknown): void; info(message: string, meta?: unknown): void; warn(message: string, meta?: unknown): void; error(message: string, meta?: unknown): void; } ``` Every adapter takes a `logger` option, and it reaches **the run engine** — not just the transport. It is the one knob that decides whether a crashed graph is visible at all. ### What each adapter does by default | | Default | Why | | ------------------- | -------------------- | ------------------------------------------------------------------ | | `@skein-js/nestjs` | Nest's own `Logger` | A facade over `app.useLogger()` / `NestFactory.create({ logger })` | | `@skein-js/fastify` | `fastify.log` (pino) | The host's own instance, honoring its config | | `@skein-js/express` | **nothing** | Express owns no logger — pass `createConsoleLogger()` | | `@skein-js/nextjs` | **nothing** | Same | The split is deliberate. Where the framework owns a logger the host has already configured, skein _borrows_ that decision — including the decision to be silent (`NestFactory.create({ logger: false })`, `Fastify({ logger: false })`). Where it doesn't, defaulting on would mean a library writing into someone's stdout uninvited, so skein stays quiet until asked: > **The two standalone servers are the exception, and differ from each other.** `createNestServer` > owns its bootstrap and keeps Nest's banner off, which silences the global facade — so it defaults to > its own `ConsoleLogger` and `app.useLogger()` does _not_ redirect it; pass `logger` instead. > `createFastifyServer` leaves pino off exactly as Fastify does, so it stays silent until you enable > it with `{ fastify: { logger: true } }` or pass a `logger`. ```ts import { createConsoleLogger, createExpressServer } from "@skein-js/express"; createExpressServer({ config: "./langgraph.json", logger: createConsoleLogger() }); ``` `createConsoleLogger({ level, prefix })` is plain uncolored output — `level: "warn"` suppresses the per-run summaries while still reporting every failure. (The colored, code-framed failure block is `skein dev`'s; it knows your source root and can safely read from it.) ### Precedence | Situation | Result | | ---------------------------- | ----------------------------------------------------- | | `deps.logger` is set | that logger, always — the adapter never overwrites it | | otherwise, `logger` a Logger | that logger | | otherwise, `logger: false` | nothing — no default installed | | otherwise | the adapter's default from the table above | An injected `deps.logger` outranking the adapter option is deliberate, and it is what makes the rest safe: the adapter only ever _fills_ `deps.logger`, never replaces it. That in turn lets it hand the engine your actual deps object rather than a copy — which is what keeps post-mount configuration (`deps.exposeErrorStacks = true` after mounting) working, since the invoke surface re-reads its deps on every request. ### Bridging to your own logger Each bridge ships with its adapter, so meta lands in the shape that logger actually wants: ```ts import { createNestLogger } from "@skein-js/nestjs"; // Point skein at a specific LoggerService rather than the globally configured one. SkeinModule.forRoot({ config, logger: createNestLogger({ logger: myPinoNestLogger }) }); import { createFastifyLogger } from "@skein-js/fastify"; // pino is object-first, so a failed run's ids become queryable fields and `err` gets pino's // error serializer — not a pre-flattened string. app.register(skeinPlugin, { config, logger: createFastifyLogger(app.log.child({ svc: "graphs" })), }); ``` Anything else is four lines against the interface above. ### What is always logged A **failed run** is always reported at `error` level, whatever `logRunActivity` says. The `meta` is a `RunFailureReport` — plain data plus the original `Error`, so a console logger can render a stack and a code frame while a JSON logger can serialize the fields. Recognize it with `isRunFailureReport`. Also always logged: background-run lifecycle summaries (at `error` level, with a `run_error` field, when the run failed), webhook delivery failures, rollback failures, and queue-shutdown problems. That is the whole steady-state volume — failures, plus one line per background run. The noisy per-run chatter is behind `logRunActivity` and stays off. One cost worth knowing: naming the node that threw takes a checkpointer read, so the engine skips building the report when nothing is listening. With a logger configured — which, under NestJS and Fastify, is now the default — a **failed** run pays that read. Successful runs are unaffected. ### Sending failures somewhere else The log is one of **three** surfaces a failure reaches. The third is a telemetry sink — LangSmith, PostHog, OpenTelemetry, or your own — which receives a `run.finished` event carrying the `RunError`, the node that threw, and the original `Error`: ```ts const sink: TelemetrySink = { name: "sentry", onRunEvent: (event) => { if (event.type === "run.finished" && event.cause) Sentry.captureException(event.cause); }, }; ``` A sink is **server-side, like the log** — so it always gets the full `Error`, stack and `cause` chain included, **regardless of `exposeErrorStacks`**. That flag governs only what reaches a client. See [observability.md](./observability.md). ### What `--verbose` adds `skein dev --verbose` sets `ProtocolDeps.logRunActivity`, which adds per-run _chatter_: run start/finish with duration and frame count, each tool call and tool result, and interrupt prompts. It costs nothing when off — the engine skips the stream inspection entirely. It does **not** gate failures. A graph that throws is logged either way. ### The failure block The CLI — `skein dev` and `skein start` alike — renders a graph failure as a fenced block naming the run, the assistant, the thread, the node that threw, and a code frame pointing at the line: ``` error: Graph run failed: model call failed ──────────────────────── GRAPH RUN FAILED ──────────────────────── run 63086ee4-1729-4744-8c8e-2cbdd48aff80 assistant boom thread 76258f06-a401-44c0-a6d7-7e189a3f8b36 node call_model 3 | function callModel(): never { 4 | const apiKey = process.env["MISSING_KEY"]; > 5 | throw new Error("model call failed", { | ^ 6 | cause: new Error(`MISSING_KEY is ${String(apiKey)}`), Error: model call failed at RunnableCallable.callModel (src/boom-graph.ts:5:9) caused by: Error: MISSING_KEY is undefined ────────────────────────────────────────────────────────────────── ``` The rules and blank lines are plain text, not color — piped logs, CI output, and `NO_COLOR` all disable color, and a crash needs to stand out precisely there. Two parts are best-effort and are simply omitted when unavailable, never faked: - **The `node` row.** LangGraph rethrows a node's error verbatim, so the error object never names the node. skein reads it from the post-failure state snapshot instead, where the runner records an `__error__` write against the failing task. A failure before the graph started yields nothing, and a failure inside a subgraph names the _parent_ node. - **The code frame.** It needs the stack to point at readable source inside the project. Under `skein dev` it does — vite's module runner source-maps stacks back to your `.ts` files, and the frame is bounded to the workspace root vite serves from. Under `skein start` the bundled artifact usually ships without original sources, so the frame is normally absent and the stack still prints. The frame is deliberately conservative about where it reads from, because an error _message_ is frequently attacker-influenced — a raw model response, a fetched document, or simply ``throw new Error(`bad mode: ${input.mode}`)`` over a client-supplied input. Since `Error.stack` is `${name}: ${message}` followed by the frames, a newline in a message produces a line that parses exactly like a stack frame and sits ahead of every genuine one. skein parses frames only from the region after the header, and reads only files resolving inside the project root — so a crafted message cannot steer it into reading an unrelated file. ### The load-failure block A graph can fail the other way too — never loading at all, because importing its module threw. That is the failure a fresh project hits first, and almost always for one reason: the graph builds a model client at module scope and the API key is not set. `skein dev` and `skein start` import every declared graph once at startup, **after** the banner, and report each failure as its own block: ``` error: graph "agent" failed to load ────────────────────── GRAPH FAILED TO LOAD ────────────────────── graph agent source src/agent-graph.ts:68 66 | const apiKey = process.env.GOOGLE_API_KEY; 67 | if (!apiKey) { > 68 | throw new Error( | ^ GOOGLE_API_KEY is not set — the "agent" graph needs it. Uncomment it in .env (get a key at https://aistudio.google.com/apikey) and save; the dev server picks it up on reload. The "echo" graph needs no key. SkeinConfigError: Failed to import graph module ".../src/agent-graph.ts". caused by: Error: GOOGLE_API_KEY is not set — the "agent" graph needs it. … ────────────────────────────────────────────────────────────────── ``` The headline is the **root** of the `cause` chain, not the wrapper: `SkeinConfigError` says _where_ the failure happened, and only its cause says _what_ went wrong. The code frame follows the root's stack for the same reason — the wrapper's stack is nothing but skein frames. The full chain still prints underneath. One graph that cannot load never takes the server down; the rest keep serving. Under `skein dev` the project's `.env` is watched, so filling in the missing key and saving reloads the graphs — a key that is _newly added_ takes effect, while changing one that is already set still needs a restart (the ambient environment outranks the file, by design). ## Errors at the edges Two typed errors, both carrying a `cause` that the CLI prints: - **`SkeinHttpError`** (`@skein-js/core`) — carries the HTTP status a handler wants, plus optional `code` and `details`. Adapters map it to `{ status, message, code?, details? }`; anything else becomes an opaque `500` with the real error sent to the logger. See [building-an-adapter.md](./building-an-adapter.md). - **`SkeinConfigError`** (`@skein-js/config`) — a bad `langgraph.json`, an unknown graph, or a graph module that failed to import. Its `cause` is the actual import failure and its `details` are the Zod issues, so read past the top-level message. A graph that fails to _load_ during a **run** surfaces through the same failure path as one that throws, with the config error's `cause` chain intact — unchanged. On the surfaces that answer over HTTP directly — thread state and history, assistant introspection, the single-graph invoke handler — it is mapped to a `SkeinHttpError` carrying `code: "graph_load_failed"`, so it stops being an unhandled fault. How much of _why_ reaches the caller is governed by [`exposeErrorStacks`](#exposeerrorstacks), the same switch that governs stacks: | | Body | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | **`skein dev`** (on) | `{"status":500,"message":"Graph \"agent\" failed to load: GOOGLE_API_KEY is not set …","code":"graph_load_failed"}` | | **`skein start`**, embedded (off) | `{"status":500,"message":"Graph \"agent\" failed to load.","code":"graph_load_failed"}` | The reason is withheld in production because a load failure's message is not skein's: `Cannot find module '/srv/app/dist/tools.js'` and `connect ECONNREFUSED 10.0.3.14:5432` are both ordinary ones, and they name server paths and internal hosts. The stack never travels either way. **The operator loses nothing.** Adapters log every `5xx` — including this one, typed or not — with the full `cause` chain, so the reason is always in your logs even when it is not on the wire. `rootCause(thrown)` / `rootCauseMessage(thrown)` (`@skein-js/core`) are what collapse a chain to one sentence, if you want the same in your own handler. Cycle-safe and depth-capped, like `toRunError` — which you want instead when a client should see _every_ layer. ## LangGraph Platform compatibility Verified against `@langchain/langgraph-api` and `@langchain/langgraph-sdk`: - The platform's error frame is `{ error, message }`, and the SDK's `ErrorStreamEvent` declares exactly that. Its `StreamError` reads `data.name ?? data.error`. skein emits **both** `error` and `name` with the same value, so the SDK, the platform's own clients, and older skein clients (which saw only `name`) all agree. Extra keys are ignored by the SDK. - `Run.error` is a skein extension — the SDK's `Run` records only _that_ a run failed. It is optional, so a client that ignores it is unaffected. This and the `"cancelled"` run status are the only two places skein deliberately steps outside the SDK's wire contract. - `Thread.error` **is** an SDK field that the JS platform leaves empty; skein populates it (and keeps the older `metadata.error` alongside it for existing readers). - `ThreadTask.error` is a JSON string, because `useStream` reads thread history by `JSON.parse`ing this field and rebuilding a `StreamError` from it. - `POST /runs/wait` answers a failed run with `{ "__error__": … }`, the platform's key. skein does not reproduce the platform's double-encoding of that payload. - The webhook's `error` is a plain message string, matching the platform exactly. --- # Frontend SDKs / `useStream` compatibility A core promise of skein-js: **your existing frontend code keeps working by changing only the API URL.** That includes the React streaming hook, which is the most common way LangGraph apps render agent output — and the Vue, Svelte and Angular bindings, which wrap the same SDK and therefore work the same way ([see below](#not-just-react--vue-svelte-and-angular)). **What this gives you:** point [`useStream`](https://reference.langchain.com/javascript/langchain-langgraph-sdk/react/useStream) at a skein-js server and you get the whole rich chat UX for free — live token streaming, model **thinking**, structured **tool-result cards**, and **human-in-the-loop** interrupt/resume — with no custom SDK and no bespoke wire format. You send a turn with `thread.submit(...)`, read live state off `thread.messages`, and when a graph node pauses with `interrupt()` you render an approval card off `thread.interrupt` and resume with a `command`. The flagship [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) example builds all of this; [`react-usestream`](https://github.com/skein-js/skein-js/tree/main/examples/react-usestream) is the minimal copy-paste starting point. ```tsx // send a turn — thread.messages updates live as tokens stream in thread.submit({ messages: [{ type: "human", content: input }] }); // a pending interrupt (e.g. "approve this booking?") surfaces here; resume with a command if (thread.interrupt) { thread.submit(undefined, { command: { resume: { approved: true } } }); } ``` Long-term memory is the one piece that lives on the server, not in the hook: a graph node calls `getStore()` and skein-js persists it (see [storage.md](./storage.md)). The frontend just keeps streaming. ## The clients skein-js must satisfy | Client | Package | How it talks to skein-js | | ---------------------- | --------------------------------- | ---------------------------------------------------------------- | | Vanilla JS SDK | `@langchain/langgraph-sdk` | `client.threads.*`, `client.runs.stream()`, `client.runs.wait()` | | **React hook** | `@langchain/langgraph-sdk/react` | **`useStream({ apiUrl, assistantId })`** over SSE | | Vue / Svelte / Angular | `@langchain/{vue,svelte,angular}` | Same SSE path — see below | | Agent Chat UI | (built on `useStream`) | Same SSE path | | LangGraph Studio | — | Agent Protocol HTTP | ## Not just React — Vue, Svelte and Angular **Nothing in skein-js is React-specific.** LangChain publishes first-party bindings for four frameworks, and each one is a thin wrapper over the _same_ `@langchain/langgraph-sdk` — all four declare an identical pinned dependency on it. They therefore issue identical Agent Protocol requests and read identical SSE frames, so every one of them works against a skein-js server by pointing its `apiUrl` at it, exactly as `useStream` does. | Framework | Package | Entry points | Peer range | | --------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------- | | React | [`@langchain/react`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/sdk-react) | `useStream`, `StreamProvider` | React 18 · 19 | | Vue | [`@langchain/vue`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/sdk-vue) | `useStream`, `provideStream`, `LangChainPlugin` | Vue 3 | | Svelte | [`@langchain/svelte`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/sdk-svelte) | `provideStream`, `getStream` | Svelte 5 | | Angular | [`@langchain/angular`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/sdk-angular) | `injectStream`, the `inject*` family | Angular 18–21 | Each follows its own idiom — a React hook, a Vue composable, Svelte context, Angular DI — over one shared streaming core, so everything on this page transfers unchanged: submit a turn, read messages off the returned stream, render a pending `interrupt`, resume with a `command`. **Mind the package names.** These are `@langchain/` packages, _not_ subpaths of the SDK — `@langchain/langgraph-sdk/vue` does not resolve, and the SDK's own README links to them by monorepo path, which is easy to misread as a subpath. `@langchain/langgraph-sdk/react` still exists and is what the example below uses; the dedicated packages are the newer home and the only place the non-React bindings live. skein-js ships no Vue, Svelte or Angular example, and the runtime matrix in [`ci.yml`](https://github.com/skein-js/skein-js/blob/main/.github/workflows/ci.yml) does not exercise them. The compatibility is structural — same SDK, same wire — rather than separately tested, and `useStream` is what the [verification harness](#verification-harness) covers. ## `useStream` against skein-js ```tsx "use client"; import { useStream } from "@langchain/langgraph-sdk/react"; export function Chat() { const thread = useStream({ apiUrl: process.env.NEXT_PUBLIC_SKEIN_URL!, // e.g. http://localhost:2024 assistantId: "agent", // a graph id from langgraph.json }); return (
{thread.messages.map((m) => (
{typeof m.content === "string" ? m.content : ""}
))}
); } ``` The only difference from a LangGraph Platform setup is that `apiUrl` points at a skein-js server. `useStream` opens an SSE connection to `/runs/stream` (or the thread stream) and renders `messages` / `values` / `custom` events as they arrive — exactly the frames skein-js produces (see [streaming.md](./streaming.md)). **Closing the tab cancels the run.** `useStream` sends `on_disconnect: "cancel"` on every submit unless the stream is resumable, in which case it sends `"continue"`. skein honours that, so navigating away or closing the tab settles the in-flight run as `cancelled` rather than letting it finish in the background — LangGraph's behaviour, and usually the one you want, since nobody is left to read the result. Pass `onDisconnect: "continue"` to `submit()` to keep the run going, or set `reconnectOnMount` / a resumable stream so the client can rejoin it instead. ## Why it works over SSE `useStream` is an SSE client. Because skein-js serves the Agent Protocol streaming endpoints as `text/event-stream` with the same event names and payloads LangGraph emits, the hook cannot tell the difference. **No WebSocket is required**, so deferring WebSocket transport in v1 does not affect the React SDK. ## Verification harness [`examples/react-usestream`](https://github.com/skein-js/skein-js/tree/main/examples/react-usestream) is a minimal Next.js app wired to `useStream` and pointed at a placeholder skein-js URL. Once the server lands, it is the front-end signal that the SSE wiring satisfies the React SDK — token-by-token streaming in a real browser. See [testing.md](https://github.com/skein-js/skein-js/blob/main/docs/testing.md). ## References - LangGraph JS SDK — - `useStream` API reference — - LangGraph streaming — - Agent Chat UI — - React bindings — - Vue bindings — - Svelte bindings — - Angular bindings — --- # Storage **What this gives you:** durable agents and **long-term memory** that outlives a single conversation, with zero setup in dev. Threads, runs, and stored memories survive restarts, and inside a graph node you get a LangGraph-native store — `getStore()` — for cross-thread facts ("prefers window seats") backed by **pgvector semantic search** in production. It's the same store LangGraph Platform auto-provides, so a graph that calls `getStore()` runs unchanged on skein-js. The best part: you write your graph once and skein-js swaps the backend for you — **in-memory in `skein dev`, Postgres + pgvector in production** — no code change. The flagship [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) example uses this to remember a user across sessions. skein-js separates two kinds of persistence, and it is important not to conflate them: 1. **Graph checkpoints** — LangGraph's own state/history for a thread (this is what powers **interrupt/resume** and history). **Reused, never reimplemented:** delegated to an existing LangGraph checkpointer (`MemorySaver` in dev, `PostgresSaver` in prod; `@langchain/langgraph-checkpoint-redis` and `-sqlite` are also available). See [reuse.md](https://github.com/skein-js/skein-js/blob/main/docs/reuse.md). 2. **Protocol resources** — assistants, thread metadata/status, run rows, and long-term store items. These are the gap OSS keeps _in memory_, so skein-js owns them behind a single `SkeinStore` interface with durable drivers. ## `SkeinStore` interface A single interface, implemented by each driver, covering the protocol resources: ```ts interface SkeinStore { // assistants (derived from langgraph.json graphs, plus user-created) assistants: AssistantRepo; // threads: metadata + status (idle | busy | interrupted | error), plus the latest turn's error threads: ThreadRepo; // runs: status + queue rows (pending | running | success | error | cancelled | timeout), // and — for a failed run — why (see errors-and-logging.md) runs: RunRepo; // schedules that fire runs on a cadence, plus the compare-and-swap claim the scheduler uses crons: CronRepo; // long-term memory: namespace/key items with optional semantic search store: StoreRepo; // recorded responses for `Idempotency-Key`, so a provider's retry replays instead of re-running idempotency: IdempotencyRepo; } ``` Each repo exposes CRUD + list/search shaped to the [Agent Protocol](./agent-protocol.md) endpoints. All drivers are validated against **one shared conformance test suite**, so memory and Postgres behave identically. ### Page bound (`SKEIN_MAX_PAGE_SIZE`) Every list and search path is **bounded — including when the caller passes no `limit` at all**. The default is **1000 rows**. This is a memory bound: a thread row carries the thread's mirrored graph state, so an unbounded `POST /threads/search` on a large deployment materializes the table twice over (the rows, then the JSON response string) inside one request. | Surface | Bound | | --------------------------------------- | ----------------------------------------------------------- | | `limit` on a search request | rejected above 1000 by the wire schema | | `limit` omitted, or a `list()` call | the first `SKEIN_MAX_PAGE_SIZE` rows (default 1000) | | `assistants.count()`, `threads.count()` | **not** bounded — they answer "how many match" in total | | `runs.listByThread()` | **not** bounded — run rows carry no graph state | | `runs.latestForThread()` | one row by construction — the thread state path uses this | | `runs.listActiveRuns()` (all threads) | bounded — the whole-server sweep behind `POST /runs/cancel` | | `POST /threads/{id}/history` | 100 checkpoints by default, 1000 max — a separate bound | `runs.latestForThread()` exists because the unbounded `listByThread()` above used to be read on **every** thread state, history, and state-update request: resolving which graph a thread belongs to means reading its most recent run's `assistant_id`, and that was done by fetching the thread's entire run history and sorting it. A driver must return the newest run by `created_at` descending, tie-broken on `run_id` descending — the tie-break is a within-driver determinism contract (`created_at` ties at millisecond resolution), not a cross-driver one. Sizing guidance is in [performance.md](./performance.md#sizing). Set `SKEIN_MAX_PAGE_SIZE` to change the driver bound (`maxPageSize` on the store constructor and on `embedPostgresGraphs` do the same in code). Lowering it is the useful direction on a small container. Raising it widens what an omitted `limit` returns, but **not** the wire cap: a client-supplied `limit` is still rejected above 1000, deliberately, so a single request can't be made arbitrarily expensive from outside. Truncation is **not** signalled on the response today — a short page is indistinguishable from the end of the results, so page with `offset` until you get fewer rows than you asked for. Lowering the bound below 1000 clamps a client-supplied `limit` silently for the same reason, so page by what you _received_ rather than by what you requested. ### Server-enforced metadata (`enforcedMetadata`) `ThreadSearchQuery` carries a second metadata subset alongside `metadata`, AND-ed with it. It is set by the server, never read from a request body, and exists so the auth ownership filter is a `WHERE` clause instead of a full read plus an in-process pass — see [agent-protocol.md](./agent-protocol.md#authentication--authorization). Two subsets rather than one merged object, because a merge would silently drop one side on a key collision: a caller filtering `owner: "bob"` while the ownership filter requires `owner: "alice"` must match **nothing**, not one or the other. Both drivers apply it with the same containment semantics as `metadata`, and the shared conformance suite holds them to that. ### Assistant versioning `AssistantRepo` carries full CRUD plus a **version history** (LangGraph parity — see the [assistants endpoints](./agent-protocol.md#assistants)). The model is deliberately simple so nothing else has to change: - Each assistant keeps an append-only list of immutable **version snapshots** (`graph_id`/`name`/`description`/`config`/`context`/`metadata` at that version). - The **live assistant row mirrors the currently-active version** and carries its `version` number. So every existing reader — `get`, `list`, the run engine's graph resolution, thread history — keeps working unchanged; a run always resolves the assistant's _active_ version. - `create` seeds version 1. `update` mints a new version (`max + 1`) and makes it active. `setLatest(version)` rolls the live row back to an existing snapshot **without** minting a new one. `listVersions` returns history newest-first (filterable by metadata, paginated). Deleting an assistant cascades its versions. The memory driver holds versions in a second map; Postgres uses an `assistant_versions` table (`PRIMARY KEY (assistant_id, version)`, `ON DELETE CASCADE` from `assistants`) added in migration `0003`. Both are exercised by the shared conformance suite. ### Long-term memory in the graph (`getStore()`) The `store` repo isn't only reachable over the `/store/items` HTTP endpoints — it is also injected into **every graph run** as a LangGraph [`BaseStore`](https://docs.langchain.com/oss/javascript/langgraph/persistence), alongside the checkpointer. A node reads and writes cross-thread memory the LangGraph-native way: ```ts import { getStore } from "@langchain/langgraph"; async function remember(state) { const store = getStore(); // the run's SkeinStore.store, as a BaseStore await store.put(["memories", userId], "prefs", { units: "metric" }); const hits = await store.search(["memories", userId], { query: "units" }); // pgvector in Postgres return { ... }; } ``` This is what makes skein a faithful drop-in: LangGraph Platform auto-provides a store to graphs, so a graph that calls `getStore()` runs unchanged on skein. The bridge is `SkeinBaseStore` in [`@skein-js/agent-protocol`](https://github.com/skein-js/skein-js/tree/main/packages/agent-protocol), attached in the run engine the same way the checkpointer is. Semantic `search` uses pgvector on the Postgres driver and a naive scan on memory — both come from the same `StoreRepo`, so behavior matches. ### Filtering and namespace traversal Two ways to narrow a store read: by **content** (`search`'s `filter`) and by **namespace shape** (`listNamespaces`' `prefix`/`suffix`/`maxDepth`). Both are honoured over HTTP and through `getStore()`, and both are pinned for every driver by the shared conformance suite. ```ts await store.search(["users", userId], { filter: { topic: "coffee", score: { $gte: 3 } } }); await store.listNamespaces({ prefix: ["users", "*"], suffix: ["facts"], maxDepth: 3 }); ``` **`filter`** applies to the **top-level** keys of an item's `value`, never a nested JSON path — `"a.b"` is the literal key `"a.b"`. Keys are ANDed, as are multiple operators on one key. The operators are LangGraph's — `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$nin` — and a bare scalar means equality. It is applied **before** paging, so a page is a page of matches. | Case | Behaviour | | ------------------------------- | ---------------------------------------------------------------------------------------------------- | | Ordering (`$gt`/`$gte`/…) | **Numbers only** on both sides. A string, boolean, `null`, object, array or absent key never matches | | Key absent from `value` | `$ne` and `$nin` match; `$eq`, a bare scalar, `$in` and every ordering operator do not | | Present JSON `null` | Distinguishable from absent — `{ deleted: null }` means "present and null" | | Empty operator bag `{}` | States no conditions, so it matches everything | | Unknown `$op`, non-scalar value | **400** at the boundary, not a silent narrowing | The numbers-only ordering rule is a **deliberate departure from LangGraph**, which coerces both sides with `Number()` and so treats `"5" > 3` as true. Postgres cannot reproduce that (`'abc'::numeric` throws), and faithful reproduction would also make `true > 0` and `null >= 0` true. The divergence is limited to numeric strings. **Namespace matching** is positional. `"*"` stands for exactly one segment, `prefix` anchors at the first and `suffix` at the last, and a path longer than the namespace never matches. A path _shorter_ than the namespace still matches, which is what makes `prefix` select a subtree: `["users","*"]` matches `["users","1"]` and `["users","1","memories"]` alike. `maxDepth` truncates each match to that many segments and de-duplicates, before sorting and paging — so `{ maxDepth: 1, limit: 10 }` is ten _roots_, not the roots of the first ten namespaces. Namespaces come back in ascending, element-wise order, shorter first on a shared prefix. Per-segment ordering follows the **driver's** collation (Postgres's database collation; UTF-16 code units in memory), which agree for ordinary segments and can differ on exotic ones like `"a-b"` vs `"ab"`. The contract is the element-wise order, not a byte-exact one. > **Driver authors:** `StoreRepo.listNamespaces` takes a single `StoreNamespaceQuery` > (`{ prefix, suffix, maxDepth, limit, offset }`) rather than the old `(prefix, pagination)` pair, > which could not express a wildcard, a suffix or a depth. `listNamespaces(["users"])` becomes > `listNamespaces({ prefix: ["users"] })`. Like every other list path, it now applies the driver's > page bound when the caller names no `limit`. #### Multi-tenancy is yours to define > `prefix`, `suffix` and `filter` are _request parameters_, not access control. An omitted prefix > matches every namespace, so **any caller who can reach `POST /store/items/search` can read every item > in the store** — no wildcard required. skein offers no store-scoping mechanism of its own, deliberately: who owns what, and how a namespace encodes it, is the policy that varies most between deployments. Scope it with an `@auth.on.store` handler that rewrites `value.namespace` — the pattern, and its two limits, are in [agent-protocol.md](./agent-protocol.md#scoping-the-store). ### Store item TTL Store items can expire, matching LangGraph's store TTL. Configure it in `langgraph.json` under `store.ttl` (all durations in **minutes**): ```json { "store": { "ttl": { "default_ttl": 1440, "refresh_on_read": true, "sweep_interval_minutes": 60 } } } ``` - `default_ttl` — lifetime applied to a `put` that doesn't pass its own `ttl`. A `PUT /store/items` body may include a per-item `ttl` (minutes) that overrides the default for that item. - `refresh_on_read` (default `true`) — a `get` extends a live item's expiry by its own TTL. - `sweep_interval_minutes` (default `60`) — how often the background sweeper deletes expired rows. Expiry is enforced two ways: **lazily** (an expired item reads as absent from `get`/`search`/ `listNamespaces` even before it's swept) and by the **sweeper** (a periodic `DELETE`). With no `store.ttl` set, items never expire. The sweeper runs in the production runtime (`skein up`/`build`, and `skein dev --store postgres`); pure in-memory `skein dev` still enforces expiry lazily on read. ### Thread TTL Threads can expire too, configured under `checkpointer.ttl` — the shape LangGraph Platform documents, so the same `langgraph.json` works under both. Durations in **minutes**: ```json { "checkpointer": { "ttl": { "default_ttl": 43200, "strategy": "delete", "sweep_interval_minutes": 60 } } } ``` - `default_ttl` — lifetime applied to a thread created without its own `ttl`. `POST /threads` accepts a per-thread `ttl` (minutes) that overrides it, and an explicit `null` **pins** the thread so no TTL ever collects it. `PATCH /threads/{id}` can change or clear it later; a new value restarts the clock. - `strategy` — only `"delete"` is implemented, and it is the only thing a thread could expire into. - `sweep_interval_minutes` (default `60`) — how often the sweeper runs. **Expiry means "may be collected", not "gone"** — the one place this deliberately differs from store items. An expired thread still reads normally from `GET /threads/{id}` and search until the sweeper takes it. Hiding it early would make a thread with an in-flight run vanish out from under that run. The sweeper deletes through the **thread service**, not the driver: an expiring thread's in-flight run is aborted and its event bus closed first, then its runs and checkpoints go with it. That is why it lives beside the cron scheduler rather than with the store-item sweeper — a thread is a container, not a row. It collects a bounded batch per tick and re-ticks immediately while the batch stays full, so a backlog drains without waiting out the interval. > **Deleting a thread deletes any cron scheduled on it.** Thread crons cascade with their thread (the > same `ON DELETE CASCADE` a manual `DELETE /threads/{id}` triggers), so a **thread-scoped** > [cron](./crons.md) on an expiring thread stops firing — silently, since nothing errors. Either pin > such threads with `ttl: null` or use a stateless cron, which owns no thread to lose. The sweeper runs whether or not `checkpointer.ttl` is set, because a per-thread `ttl` can arrive on any `POST /threads`; the config block supplies the _default lifetime_ and the cadence, not permission to collect. With neither configured nor requested, nothing has an expiry and each sweep is one indexed read that finds nothing. With no `checkpointer.ttl` set, no sweeper runs and threads live until something deletes them. > Note this goes **past** LangGraph OSS rather than catching up to it: the open-source > `@langchain/langgraph-api` accepts `ttl` on thread create and silently drops it — thread expiry is a > LangGraph Platform feature there. ## Drivers ### `@skein-js/storage-memory` (dev/tests — and the Redis-less production path) - In-process maps; zero external dependencies. - Paired with an in-memory queue and a `MemorySaver` checkpointer for `skein dev`. - `store` semantic search falls back to a naive scan/embedding compare. - **Not only a dev driver.** `embedPostgresGraphs` uses this queue and event bus whenever no Redis URL is configured, so its retention bounds apply to real traffic — see [embedding.md](./embedding.md#going-to-production) for `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN` and `SKEIN_MEMORY_BUS_MAX_RETAINED_RUNS`. ### `@skein-js/storage-postgres` (prod) - Backed by `pg`; owns tables for assistants (+ assistant_versions)/threads/runs/store items + migrations. `store.migrate()` applies them on boot — idempotent, tracked in a `skein_migrations` table, and serialized by an advisory lock so concurrent boots queue rather than collide. The SQL is compiled into the package (no filesystem access at runtime), so the driver bundles with zero externals — see [bundling.md](./bundling.md). - Uses **`@langchain/langgraph-checkpoint-postgres`** (`PostgresSaver.fromConnString`) for graph checkpoints — we wrap it rather than reimplement checkpointing. - **pgvector** for semantic store search, configured from `langgraph.json`'s `store.index.{embed, dims, fields}` (see [langgraph-cli-compat.md](./langgraph-cli-compat.md)). pgvector is **opt-in**: the base schema needs no extension, so skein runs on a stock managed Postgres out of the box. Only when `store.index` is set does `migrate()` run `CREATE EXTENSION IF NOT EXISTS vector` and add the `embedding` column — which requires a Postgres that ships pgvector (see the provider table in [deploy.md](./deploy.md#1-a-postgres)). #### Indexes, and the one thing to watch on upgrade Migration `0005_performance_indexes` adds the indexes the list/search paths need: composite `(created_at, thread_id)` and `(updated_at, thread_id)` on `threads` matching the `ORDER BY … , ` the queries actually emit, `(status, created_at)` for the status filter, GIN `jsonb_path_ops` on `threads.metadata` and `assistants.metadata` for the `metadata @> …` containment the auth ownership check performs on **every** request, `runs (thread_id, created_at)`, and `store_items (created_at, key)`. `runs_thread_id_idx` is dropped, superseded by the composite with the same leading column. They are built with **`CREATE INDEX CONCURRENTLY`**, so the boot migration does not hold a write-blocking lock while indexing an existing table — a plain `CREATE INDEX` on a large `threads` would stall writes for minutes, at boot, during a rolling deploy. Concurrency has one cost: such a migration cannot be transactional, so a failure partway leaves some indexes created and the ledger row unwritten, and the next boot retries the whole migration. Every statement is `IF NOT EXISTS` for that reason. **If an index build is interrupted** (the pod is killed mid-migration, say), Postgres leaves an _invalid_ index behind — and `IF NOT EXISTS` will then skip it forever, so queries silently never use it. Nothing breaks; it just stays slow. To check and fix: ```sql -- Any invalid indexes? SELECT c.relname FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid WHERE NOT i.indisvalid; -- Drop each one; the next boot rebuilds it. DROP INDEX CONCURRENTLY ; ``` #### Semantic search: exact by default, HNSW opt-in With `store.index` configured, semantic search ranks by cosine distance over an **unindexed** `embedding` column — an exact scan of every row. Correct, and fine until the store is large. Add `"hnsw": true` to opt into an HNSW index: ```json { "store": { "index": { "embed": "openai:text-embedding-3-small", "dims": 1536, "hnsw": true } } } ``` Off by default deliberately: HNSW is an **approximate** nearest-neighbour index, so turning it on changes which rows a search returns. That is a semantic change, not something to inherit from an upgrade. Enabling it also **pins the column to `vector(dims)`** — pgvector cannot index a dimensionless `vector`, which is how the column is created so the base schema works without knowing `dims`. If rows already exist at a different dimensionality (an embedder or model change), boot fails with an error saying so rather than a raw Postgres one; re-embed or clear those rows before enabling it. Three things to know before turning it on: - **The first boot is not free.** Pinning the column rewrites the table under `ACCESS EXCLUSIVE`, which blocks reads and writes on `store_items` for the duration and queues behind any long-running query already touching it. The index build that follows is concurrent and does _not_ block, but it does hold boot until it finishes. If pgvector warns that the graph no longer fits in `maintenance_work_mem`, raise it — that is the biggest lever on build time. - **Namespace-filtered search needs pgvector ≥ 0.8.** HNSW selects a fixed candidate set (`hnsw.ef_search`) and the namespace predicate is applied _after_ it, so a prefixed search can return fewer rows than exist — or none. skein sets `hnsw.iterative_scan = strict_order` on every connection to prevent that, which requires pgvector 0.8. On an older server it warns once at startup and the post-filter behaviour stands; leave `hnsw` off there. - **Turning it back off does not unpin the column.** `hnsw: false` skips the `ALTER`, it does not reverse it, so a later `dims` change still fails on the pinned column. Undo it by hand: `ALTER TABLE store_items ALTER COLUMN embedding TYPE vector;` An interrupted build leaves an invalid index; the next boot detects it, drops it concurrently and rebuilds. That check exists because pinning a column whose index is invalid rebuilds that index _inline_ and non-concurrently, holding `ACCESS EXCLUSIVE` for the whole build. ```ts import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres"; const checkpointer = PostgresSaver.fromConnString(process.env.POSTGRES_URI!); await checkpointer.setup(); // idempotent migrations for checkpoint tables ``` ### Bringing your own store (`store.adapter`) Long-term memory is the one repo you can swap without implementing the other five. Point `store.adapter` at a `"path:export"` and skein uses it for `/store/*` **and** for the `getStore()` handed to every graph run; assistants, threads, runs, crons and idempotency keep using the configured driver. ```jsonc { "store": { "adapter": "./src/my-store.ts:store" } } ``` ```ts // src/my-store.ts — LangChain's own JS long-term-memory guide builds on this exact class. import { PostgresStore } from "@langchain/langgraph-checkpoint-postgres/store"; export const store = PostgresStore.fromConnString(process.env.POSTGRES_URI!); await store.setup(); // top-level await: `store.adapter` imports a *ready* store ``` The export may be **either** a LangGraph [`BaseStore`](https://docs.langchain.com/oss/javascript/langgraph/persistence) — `PostgresStore`, `InMemoryStore`, your own — or a skein `StoreRepo`. They are told apart structurally (`BaseStore` has `batch`), and a mis-shaped export fails at **startup** naming the missing method, rather than at the first request that reaches it. Prefer `BaseStore`: it is the wider ecosystem, and `PostgresStore` brings pgvector with HNSW _and_ IVFFlat plus a `"text" | "vector" | "hybrid" | "auto"` search mode — hybrid search being a capability skein's own driver does not have. **skein re-applies its own semantics rather than forwarding them**, because forwarding would be wrong: `InMemoryStore`'s `search` matches namespace prefixes as a raw _string_ (so `["users"]` also matches `["users2", …]` — a cross-tenant read the moment a prefix is derived from a principal), ignores `query` without a vector index, and coerces filter operands with `Number()`. So the adapted store supplies a candidate set and its vector ranking; skein applies filtering, ordering and paging on top. The price is over-fetching. The shared conformance suite runs against both an adapted `InMemoryStore` and a real `PostgresStore`. Four things worth knowing before you switch: | | | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`put` does a read-back** | `StoreRepo.put` returns the stored item; `BaseStore.put` returns `void`. So the adapter writes then reads, costing a round trip — and it is **racy**: a concurrent write to the same key returns the other writer's item. | | **TTL is partly expressible** | `store.ttl.default_ttl` works — the adapter stamps it onto every write. But `refresh_on_read` cannot be expressed through `BaseStore.get`, and it **defaults to enabled**, so `store.ttl` is refused unless you set `refresh_on_read: false` and accept write-based expiry. A store with no `sweepExpiredItems()` (`InMemoryStore`) refuses `store.ttl` outright; `PostgresStore` has it. A silently ignored retention policy is worse than a boot error. | | **A prefix-less search fans out** | Some stores reject an empty namespace prefix (`PostgresStore` does), so `search({})` enumerates namespaces and searches each. Scores are re-sorted globally afterwards, so semantic ranking still holds — but give a prefix when you can. | | **The store may have its own rules** | `PostgresStore` rejects namespace labels containing `.`, `%`, `_` or `\`, and a root label of `"langgraph"`. Items written under such a namespace by another driver are not reachable through it. | **Your store owns its items.** skein imports _into_ an adapter — `skein import-langgraph`, a restored `.skein/dev-state.json`, the one-time `langgraph dev` auto-import — but never snapshots back out of it, because writing whatever store you brought into `.skein/dev-state.json` every few seconds is not something to do to somebody's database. So an adapted `InMemoryStore` loses its items across a `skein dev` restart while a `PostgresStore` keeps them. `skein dev` prints this at startup rather than letting you find it by losing items. **`store.index` is refused alongside an adapter.** It configures pgvector on the store the adapter replaces, so it could only ever have no effect on search. Configure the index on your own store instead — `PostgresStore` and `InMemoryStore` both take one in their constructor. In code (no `langgraph.json`), the same seam is `ProtocolDeps.storeItems` — wrap a `BaseStore` with `fromBaseStore` first. Pass it as its own field rather than composing `{ ...store, store: mine }`: the bundled drivers expose `maxPageSize` and `durable` as class **getters** over **private** fields, so a spread silently loses them and a prototype clone throws on read. `withStoreItems` is the supported way to do it by hand. ## Checkpointer selection | `langgraph.json` `checkpointer` | skein-js uses | | ------------------------------- | ---------------------------------- | | absent (dev / `skein dev`) | `MemorySaver` | | `"default"` | `PostgresSaver` (Postgres) | | `"custom"` | user-supplied checkpointer (later) | You rarely wire these drivers by hand. [`@skein-js/runtime`](https://github.com/skein-js/skein-js/tree/main/packages/runtime) assembles the `PostgresSkeinStore` + `PostgresSaver` + Redis queue/bus (and their `dispose()`) for you, two ways: **`buildRuntime`** from a `langgraph.json` (the `skein dev`/`skein up` path), and **`embedPostgresGraphs`** from a graph you hold in code (the durable sibling of `embedInMemoryGraphs` — see [embedding.md](./embedding.md#going-to-production)). Protocol resources (`SkeinStore`) stay separate from LangGraph checkpoints, which is what allows an in-memory dev experience with no database while the checkpoint format stays 100% LangGraph-native — so history endpoints and interrupt/resume behave exactly as LangGraph expects. --- # Agent memory **What this gives you:** the patterns for giving an agent memory on skein, and the traps that are not obvious until they bite. There is no `@skein-js/memory` package to install — memory is something you build in your graph out of pieces that already exist, and this page is the map of which pieces, and why. That is a deliberate choice. Memory is _agent behaviour_, and skein's job is durable persistence, the queue, the adapters and the CLI. Everything below is ~100 lines in your own graph, portable to LangGraph Platform because it uses `getStore()` and nothing skein-specific. Where skein does contribute — durable storage, semantic search, schedules — it is called out. Per-owner isolation is **not** on that list: it is a policy your `@auth.on.store` handler decides, and [Multi-tenant memory](#multi-tenant-memory) is how. ## What you already have | Need | Use | From | | ------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------- | | Durable thread state | the checkpointer — `PostgresSaver` in prod, `MemorySaver` in dev | skein wires it ([storage.md](./storage.md)) | | Trimming a long conversation | `trimMessages`, `filterMessages`, `RemoveMessage` | `@langchain/core` | | Summarizing a long conversation | `summarizationMiddleware`, `contextEditingMiddleware` | the `langchain` v1 package | | Cross-thread memory store | `getStore()` — namespace/key items, vector search | skein injects it into every run | | Semantic search over memories | `store.index` (pgvector, HNSW optional) | skein ([storage.md](./storage.md#drivers)) | | Per-owner isolation | an `@auth.on.store` handler that roots the namespace | you ([agent-protocol.md](./agent-protocol.md#authentication--authorization)) | | Scheduled background extraction | a cron firing an extractor graph | skein ([crons.md](./crons.md)) | | Memory _shapes_, tools, dedup | this page | you, in ~100 lines | ## Short-term memory is already solved Worth stating plainly, because "durable short-term memory" sounds like a gap and is not one. **Durability** is the checkpointer. `PostgresSaver` persists a thread's state and history, so a conversation survives restarts and resumes from an interrupt; thread TTL collects the ones that go stale. skein selects it for you — nothing to build. **Context management** — keeping a long thread inside the model's window — is upstream too: ```ts import { createAgent, summarizationMiddleware } from "langchain"; const agent = createAgent({ llm, tools, middleware: [summarizationMiddleware({ model: llm, trigger: { tokens: 4000 }, keep: 20 })], }); ``` Do **not** write your own trimming loop. If you keep a running summary yourself, keep it in **graph state** rather than the store: state is checkpointed, thread-scoped, and rolls back with the checkpoint, none of which is true of a store item. ## Long-term memory: profile and collection Cross-thread memory is `getStore()` — namespace/key JSON documents, the same store LangGraph Platform auto-provides. Two shapes cover almost everything, and the choice is really "one document or many": **Profile** — one continuously-updated document. Good for stable facts with one current value: timezone, tone, home airport. Read it whole, merge a patch, write it back. **Collection** — many documents accumulated over time. Good for open-ended facts: "prefers morning meetings", "allergic to shellfish". Append, and retrieve by relevance rather than reading all of them. ```ts const NAMESPACE = (userId: string) => [userId, "memories"] as const; // Profile: read-modify-write one document. async function mergeProfile(store: BaseStore, userId: string, patch: Record) { const current = (await store.get([userId], "profile"))?.value ?? {}; await store.put([userId], "profile", { ...current, ...patch }); } ``` **Pick one merge rule and write it down.** The rule matters less than having one; without it the profile becomes unpredictable, which is the failure LangChain's own memory guide warns about. A rule that works: _shallow, patch wins, arrays unioned, `null` deletes, nested objects replaced._ Replace rather than deep-merge — deep merge makes deletion inexpressible. Two things to know: - **There is no compare-and-swap.** `BaseStore` has none, so read-modify-write is last-writer-wins. On skein, runs on one thread are serialized by the execution lock, so concurrent merges only arise across threads for the same user. Do not build an optimistic-retry counter — without CAS it does not close the race and implies a guarantee that is not there. - **Keep collection fields top-level and scalar.** `search`'s `filter` reads the _top-level_ keys of an item's `value` and takes scalars, so `{ content, kind, createdAt }` filters and `{ meta: { kind } }` does not. `tags: string[]` is storable but not filterable (`$in` asks the opposite question). Full operator semantics in [storage.md](./storage.md#filtering-and-namespace-traversal). ## The dedup trap **The most important paragraph on this page.** Without dedup, an agent that saves a memory each turn accumulates the same fact dozens of times and recall degrades into noise. The obvious fix — semantic search for a near-duplicate before writing — **inverts** on most substrates: | Substrate | `score` on a text search | | ------------------------------------------------------ | ------------------------ | | `@skein-js/storage-memory` (dev) | **`1` for every hit** | | `@skein-js/storage-postgres` **without** `store.index` | **`1` for every hit** | | `@skein-js/storage-postgres` **with** `store.index` | real cosine similarity | | LangGraph `InMemoryStore` without an index | no `score` field at all | So `score >= 0.9 means duplicate` classifies _everything_ as a duplicate on two of three substrates, and your agent **silently stops recording memories**. It will not error. You will notice weeks later. Two rules: **1. Dedup on content, not similarity.** A content-addressed key makes exact dedup free, with no read at all — saving the same sentence twice upserts one row instead of appending a second: ```ts async function memoryKey(content: string): Promise { const normalized = content.trim().toLowerCase().replace(/\s+/g, " "); const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(normalized)); return `mem-${[...new Uint8Array(digest).slice(0, 8)].map((b) => b.toString(16).padStart(2, "0")).join("")}`; } ``` Use `globalThis.crypto.subtle`, not `node:crypto` — it is present on Node ≥20, Bun, Deno and workers, so the same graph still runs on an edge runtime. **2. Treat semantic dedup as opt-in, requiring `store.index`.** And check the scores are meaningful before trusting them — at least one candidate scoring **strictly below 1** — falling back to exact dedup otherwise. State the safe direction out loud: **dedup errs toward writing.** A duplicate is recoverable; a dropped memory is not. One more, on cost: on Postgres _without_ `store.index`, a `query` search is an unbounded full-table read filtered in JS. Semantic dedup on every write there reads the whole `store_items` table each time. ## Recall Retrieval is `store.search` with the namespace and filter already right: ```ts const relevant = await store.search([userId, "memories"], { query: latestUserMessage, limit: 5 }); ``` **When to recall is your graph's decision, and skein has no opinion.** Two patterns, both fine: - **Auto-inject** — fetch relevant memories before each model call and fold them into the system message. Personalization does not depend on the model remembering to ask. This is what [`examples/chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) does. - **A recall tool** — the model calls `search_memory` when it decides it needs to. Fewer tokens per turn, but it only works when the model chooses well. If you expose memory as a **tool**, bind the namespace server-side and give the model no namespace parameter at all: ```ts // The model may choose *which collection*, from an enum — never which namespace. const userId = config.configurable?.langgraph_auth_user_id; // never model output ``` That is the pattern people get wrong: a namespace the model can name is a namespace the model can escape. ## Multi-tenant memory Memory is per-user by definition, so this matters more here than anywhere else in skein. `prefix`, `suffix` and `filter` are **request parameters, not access control**. An omitted prefix matches every namespace. The control is an **`@auth.on.store` handler** that rewrites `value.namespace` to root the caller — LangGraph's own idiom, and skein honours it. See [agent-protocol.md](./agent-protocol.md#authentication--authorization). It covers the HTTP surface. **`getStore()` inside a graph has no request and no principal**, so nothing guards it for you. Build its namespace from `config.configurable.langgraph_auth_user_id` (server-injected and unspoofable), never from model output, use the same encoding your handler uses — and encode it: an identity containing `.` splits into two namespace segments on `GET /store/items`, and `PostgresStore` rejects `.`, `%`, `_` and `\` in a label outright. ## Writing memories in the background Extracting memories inline costs the user's turn a model call. Two ways to move it off the hot path, both working today with no new skein surface: **A cron.** Point a [schedule](./crons.md) at an extractor graph — an ordinary graph you write, with your model and your prompt — and it runs on a cadence, durably, across restarts and instances: ```jsonc { "graphs": { "agent": "./src/agent.ts:graph", "extract-memories": "./src/extract.ts:graph" } } ``` **A debounced follow-up run.** Closer to real-time: when a turn finishes, create a _stateless background run_ of the extractor with `after_seconds`, and cancel this thread's pending one first so extraction fires once when the conversation pauses rather than once per turn. `after_seconds` is backed by the queue, so a pending extraction is a **row in the store** and survives the process that scheduled it — which is the part an in-process scheduler (Python `langmem`'s `ReflectionExecutor`, say) cannot give you without a server behind it. ```ts // Cancel the pending extractor for this thread, then schedule a fresh one. const inflight = await client.runs.list(threadId); for (const run of inflight.filter((r) => r.metadata?.extractor && r.status === "pending")) { await client.runs.cancel(threadId, run.run_id); } await client.runs.create(null, "extract-memories", { afterSeconds: 30, input: { thread_id: threadId }, metadata: { extractor: true }, }); ``` The one thing neither expresses: a graph node cannot know its own run _settled_, so "extract only from successful runs, reliably even on error and cancellation paths" is not reachable from inside the graph. A server-side settled-run trigger would cover it; it is [deferred](./roadmap.md), because everything above works without it and the gap is narrow. ## See also - [storage.md](./storage.md) — the store itself: drivers, `getStore()`, filters, TTL, scoping, BYO store - [crons.md](./crons.md) — schedules, for the cron extraction pattern - [`examples/chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) — a working research assistant with memory and recall - [LangChain's memory guide](https://docs.langchain.com/oss/javascript/concepts/memory) — the profile/collection framing this page builds on --- # Runs & Redis This doc covers how skein-js executes runs and how it scales horizontally — modeled on [aegra](https://github.com/aegra/aegra)'s worker + Redis architecture, adapted to Node. > **Reuse note:** `@skein-js/redis` is the run **queue + pub/sub** — the piece LangGraph OSS > does not provide (the open [`@langchain/langgraph-api`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/langgraph-api) > server runs runs in-process, in-memory). It is _not_ a checkpointer; for Redis-backed > checkpoints use `@langchain/langgraph-checkpoint-redis`. See [reuse.md](https://github.com/skein-js/skein-js/blob/main/docs/reuse.md). ## Run modes The [Agent Protocol](./agent-protocol.md) defines three ways to execute a graph: | Mode | Endpoint | Behavior | | -------------- | -------------------------------------------- | ---------------------------------------------------------- | | **wait** | `POST /runs/wait`, `GET /runs/{id}/wait` | Run to completion, return final output. | | **stream** | `POST /runs/stream`, `GET /runs/{id}/stream` | [SSE](./streaming.md) as output is produced. | | **background** | `POST /threads/{id}/runs` | Enqueue; poll (`GET /runs/{id}`) or join its stream later. | A **per-thread concurrency guard** prevents two active runs on the same thread (the protocol's concurrency-control requirement). That is a different thing from [run concurrency](#run-concurrency) below, which is how many runs on _different_ threads one instance executes at once. ## Run engine `@skein-js/agent-protocol` owns a run engine that: 1. Resolves the target graph via [`@skein-js/config`](./langgraph-cli-compat.md). 2. Persists a run row through [`SkeinStore`](./storage.md) (`pending → running → success/error`). A failed run also records _why_ on the row, so `GET /threads/{tid}/runs/{rid}` can still explain it afterwards — see [errors and logging](./errors-and-logging.md). 3. Invokes the graph (`invoke` for wait, `stream` for streaming), threading the LangGraph **checkpointer** so state/history persist and **interrupt/resume** (human-in-the-loop) works. 4. Publishes stream frames to subscribers (local bus or Redis pub/sub). ## Queue drivers The engine talks to a small `RunQueue` / `RunEventBus` interface (`@skein-js/core`) with two implementations. `RunQueue` is **processor-driven**: `enqueue(run)` adds a job and `consume(process)` registers a worker that drains the queue — so the same run worker code drives both drivers. Delivery is **at-least-once** (a crashed processor's run is redelivered); the worker makes this safe by skipping any run already terminal in the store. `enqueue` takes an optional `{ delayMs }` — the run-create [`after_seconds`](./agent-protocol.md). The driver holds the run until it comes due, which is why a delayed run costs nothing while it waits and cannot be picked up early. Both drivers dedupe a re-enqueue of a run that is still waiting out its delay, so the cron scheduler's outbox sweep — which re-enqueues anything it cannot prove reached the queue — can never cut a delay short or schedule the same run twice. A delayed run is durable exactly as far as its driver is: see below. ### In-memory (dev) - Single-process queue + event bus. No external services. - Used by `skein dev` so nothing beyond Node is required locally. - An `after_seconds` delay is a local timer, so it is lost on restart — like every other run already sitting in this queue, which is in-process too. ### `@skein-js/redis` (prod) - **Job queue ([BullMQ](https://docs.bullmq.io))** — background runs are enqueued in Redis; worker processes across instances consume and execute them. BullMQ provides retries, backoff, and concurrency out of the box. - **Crash recovery** — a stalled job (its worker died mid-run) is moved back to the queue by BullMQ's stalled-job check and retried, so runs survive restarts. - **Delayed runs** — an `after_seconds` delay is handed to BullMQ's own delayed set, which lives in Redis and promotes the job when it comes due, so a scheduled run outlives the process that created it. - **Cross-instance pub/sub** — run stream frames are published to a Redis Stream + channel so a client connected to instance B can join a run executing on instance A (see [streaming.md](./streaming.md)). - **Cross-instance cancellation** — a `RunAbortChannel` over Redis pub/sub carries the _stop now_ signal to whichever instance is executing a run, so a cancel routed to the wrong replica still stops the graph. The cancel itself is durable in the run row before the message is published, so a dropped message costs promptness rather than correctness. See [deploy.md](./deploy.md#scaling-past-one-instance). ### What a frame costs Publishing sits inside the graph's own loop — the engine awaits it per chunk — so at token granularity its cost is paid per token. It is **one pipelined round trip**: `XADD` and `PUBLISH` batched together, with the frame serialized once for both. The stream's TTL is refreshed every 256 frames and on close, rather than on every frame, since the window only has to outlive the run. All in-flight subscribers share **one** pub/sub connection, with a `SUBSCRIBE` per run rather than a connection per stream — an instance serving 500 SSE streams holds two Redis sockets, not 501. Replay is paged (`XRANGE … COUNT`), and a reconnecting subscriber resumes from the last stream id it read rather than re-reading the whole stream. Two knobs bound what this costs: | Variable | Default | Purpose | | ---------------------------- | ------- | --------------------------------------------------------------------------------------- | | `SKEIN_REDIS_STREAM_MAXLEN` | 10000 | Approximate cap on a run's stream, in frames. `0` disables trimming (TTL only). | | `SKEIN_STREAM_BUFFER_FRAMES` | 512 | Frames one subscriber may queue before it is judged too far behind and its stream ends. | `MAXLEN` exists because the 1-hour TTL bounds a stream in _time_ but not in size, and a chatty graph can produce a very large one well inside an hour. Trimming is approximate (`~`), which lets Redis trim whole nodes instead of walking the stream on every append. A subscriber whose buffer overflows has its stream ended rather than being allowed to grow without bound behind a reader that cannot drain it; the client reconnects with `Last-Event-ID` and replays from the stream, which is what the stream is for. This is the same shape aegra uses (Redis job queue + pub/sub, crash recovery, Postgres checkpoints) — . ## Run concurrency How many **queued** (background) runs one instance executes at once. It defaults to **10**, matching the LangGraph CLI's `--n-jobs-per-worker`, so a project moving off `langgraph dev` keeps its throughput. Inline `wait`/`stream` runs never touch the queue and are unaffected. Concurrency is the knob with the widest blast radius: it multiplies memory, Postgres connections, and in-flight graph state at once. Size it together with `PG_POOL_MAX` — see [performance.md](./performance.md#sizing), and note `skein start` warns at boot when the two disagree. **One worker, N concurrent runs.** skein runs a _single_ background worker whose consumer executes up to N runs at a time — which is why the startup banner says `Starting 1 worker, up to 10 concurrent runs` rather than `langgraph dev`'s `Starting 10 workers` (it really does spawn 10 loops; we don't). The observable behavior is the same. > **Upgrading from ≤ 0.9.0?** This default changed: background runs used to execute strictly one at a > time. Nothing about your code or config needs to change, but each instance now does up to 10 runs > concurrently — so check that your Postgres pool has headroom (see > [pool sizing](./deploy.md#connection-budget)), and note that background runs on one thread > using `multitask_strategy: "enqueue"` no longer execute in strict enqueue order. Set > `SKEIN_RUN_CONCURRENCY=1` (or `--concurrency 1`) to restore the previous behavior exactly. Three ways to set it, highest precedence first: | Surface | How | | -------------- | ---------------------------------------------------------------------------- | | CLI flag | `skein dev --concurrency 4` / `skein start -n 4` (`--n-jobs-per-worker` too) | | Environment | `SKEIN_RUN_CONCURRENCY=4`, or the LangGraph-compatible `N_JOBS_PER_WORKER=4` | | Adapter option | `createExpressServer({ deps, worker: { maxConcurrency: 4 } })` | An explicit value wins, but the environment is still validated — so a typo'd `SKEIN_RUN_CONCURRENCY` fails loudly at boot instead of being silently ignored. The environment is the path that reaches a container: add it to the `skein up` compose `environment:` block or your PaaS config. **Per-thread ordering is unaffected.** Two runs on the same thread are serialized by the engine's execution lock at _every_ concurrency, so the per-thread guard above holds regardless. ### Head-of-line blocking A run waiting on a busy thread's execution claim still occupies a slot. So N queued runs on the _same_ thread occupy N slots with N−1 merely waiting, and other threads wait behind them. This is a **utilization** limit, not a correctness one — the claim keeps the runs correctly serialized either way. Two things bound it: - It needs an explicit opt-in. The default `multitask_strategy` is `"reject"`, and a pending run counts as active — so a second background run on a busy thread is rejected before it can queue. Only `multitask_strategy: "enqueue"` piles runs up on one thread. - The worst case degrades to serial execution. No deadlock, no dropped run. Relatedly, **ordering across background `"enqueue"` runs is not guaranteed above concurrency 1**: several are dequeued at once and race for the thread's claim. This matches LangGraph at `N_JOBS_PER_WORKER=10`, whose N worker loops have no cross-loop ordering guarantee either. If you need strict FIFO across background runs on one thread, set concurrency to 1. **Concurrency vs. replicas.** Raise concurrency when you have many independent threads and runs are I/O-bound (model calls). Add instances when runs are CPU-bound, or when threads are long-lived and serialized. Note each concurrent run holds a Postgres connection — see the pool-sizing note in [deploy.md](./deploy.md#connection-budget) — and, on the Postgres driver, a second one for its per-thread execution claim, held for the whole run. ## Deployment topology (`skein up`) ```mermaid flowchart TB C(["clients · SSE"]) --> A["instance A"] C --> B["instance B"] A --> R[("Redis
queue + pub/sub")] B --> R A --> P[("Postgres
checkpoints · resources
pgvector")] B --> P class R accent ``` `skein up` brings this stack up via Docker Compose. Horizontal scaling is verified by starting a run on instance A and joining its SSE stream from instance B through Redis (see [testing.md](https://github.com/skein-js/skein-js/blob/main/docs/testing.md)). To run the same topology on a hosted platform, the generated image is PaaS-friendly (binds the injected `$PORT`, non-root, `/ok` health probe, graceful `SIGTERM`) — see [deploy.md](./deploy.md) for what every platform needs, plus per-platform guides for Cloud Run, Railway, Fly.io, Render, AWS, Kubernetes and a plain VPS. --- # Cron / scheduled runs Schedules that fire a run on a cadence — LangGraph Platform's **Crons** resource, served by skein-js and driven by the official `@langchain/langgraph-sdk` client. > **Compatibility note:** crons are **not** part of the open > [Agent Protocol](https://github.com/langchain-ai/agent-protocol) spec — its `openapi.json` has no > cron paths. They are a LangGraph Platform / LangSmith Deployment extension, and a paid-tier one. > The OSS [`@langchain/langgraph-api`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/langgraph-api) server > registers the routes but every handler throws `500 Not implemented`, and its `GET /info` reports > `crons: false`. skein implements them against the LangSmith Deployment OpenAPI spec plus the SDK's > TypeScript types — the same oracle the rest of the wire surface uses ([reuse.md](https://github.com/skein-js/skein-js/blob/main/docs/reuse.md)). **A cron is a _cadence_, not a delay.** To run something once, a little later, pass [`after_seconds`](./agent-protocol.md) on an ordinary run create — no cron row, nothing to clean up afterwards. Reach for a cron when the run should keep happening. ## Quick start ```ts import { Client } from "@langchain/langgraph-sdk"; const client = new Client({ apiUrl: "http://localhost:2024" }); // Every weekday at 09:00 New York time, on a fresh thread each fire. const cron = await client.crons.create("agent", { schedule: "0 9 * * 1-5", timezone: "America/New_York", input: { messages: [{ role: "user", content: "Summarize yesterday's issues." }] }, }); // What has it produced? A stateless cron makes a thread per fire, each tagged with the cron id. const threads = await client.threads.search({ metadata: { cron_id: cron.cron_id } }); const runs = await client.runs.list(threads[0].thread_id); await client.crons.update(cron.cron_id, { enabled: false }); // pause await client.crons.delete(cron.cron_id); ``` `assistant_id` accepts a UUID **or a graph name** from your `langgraph.json` — a graph name resolves to the assistant skein auto-registers for it. ## Stateless vs thread crons | | Stateless (`crons.create`) | Thread (`crons.createForThread`) | | --------------- | --------------------------------------------- | ------------------------------------------ | | Thread | A fresh one per fire | The one you named, reused every fire | | State | Starts clean each time | Accumulates across fires | | Default cleanup | `on_run_completed: "delete"` | n/a — the thread is yours | | Concurrency | Cannot collide (each fire has its own thread) | `multitask_strategy` defaults to `enqueue` | Use a **stateless** cron for independent jobs (a nightly digest). Use a **thread** cron when each run should see what the previous ones did (a long-running monitor). > **`on_run_completed` defaults to `"delete"`**, matching LangGraph — and deliberately _unlike_ > skein's own `on_completion` default of `"keep"` for one-off stateless runs. A schedule is the case > where the difference bites: a five-minutely cron keeping every thread accrues over a hundred > thousand of them a year, with nobody watching. Pass `on_run_completed: "keep"` if you want them, > and give the checkpointer a TTL. ## Schedule format **Standard 5-field cron only** — minute, hour, day-of-month, month, day-of-week: ``` ┌───────── minute (0-59) │ ┌─────── hour (0-23) │ │ ┌───── day of month (1-31) │ │ │ ┌─── month (1-12) │ │ │ │ ┌─ day of week (0-6, Sunday = 0) │ │ │ │ │ 0 9 * * 1-5 ``` Six-field (seconds-first) expressions, `@daily`-style nicknames, and `L`/`?` are **rejected with a 422**, matching LangGraph's _"cron must be a standard 5-field expression"_. skein's parser ([croner](https://github.com/hexagon/croner)) would accept them, so the five-field rule is enforced before the expression reaches it: silently running `0 0 9 * * *` at a different time than the author's crontab says is worse than refusing it. `timezone` is an optional IANA name (`America/New_York`); absent or `null` means UTC. DST is handled: an occurrence in a spring-forward gap shifts forward into the same day rather than being skipped, and a repeated fall-back hour fires once. The finest resolution is **one minute**. Sub-minute schedules are not expressible and are a [non-goal](./roadmap.md). ## Endpoints | Method | Path | Notes | | -------- | --------------------------------- | ----------------------------------- | | `POST` | `/runs/crons` | Stateless cron → `Cron` | | `POST` | `/threads/{thread_id}/runs/crons` | Thread cron → `Cron` | | `POST` | `/runs/crons/search` | → `Cron[]`, `x-pagination-total` | | `POST` | `/runs/crons/count` | → a **bare integer** | | `GET` | `/runs/crons/{cron_id}` | → `Cron` | | `PATCH` | `/runs/crons/{cron_id}` | → `Cron` | | `DELETE` | `/runs/crons/{cron_id}` | → **200** with a JSON body, not 204 | All seven answer the full `Cron` object. Two response shapes are deliberately unusual because the official SDK requires them: `count` returns a bare integer rather than `{ count }`, and `DELETE` returns 200 with a body (the SDK skips `response.json()` only for 202 and 204, so an empty 200 makes it throw). `search` accepts `assistant_id`, `thread_id`, `enabled`, `metadata` (subset match), `limit`, `offset`, `sort_by`, `sort_order`, and `select`. `select` is validated and then ignored — skein always returns the whole row. Set `"http": { "disable_crons": true }` in `langgraph.json` to remove the resource. That also stops the scheduler, so a disabled deployment does not keep firing schedules nobody can see or cancel. ## Semantics **Pausing.** `enabled: false` stops a cron without deleting it; the row stays readable and `next_run_date` becomes `null`. Re-enabling recomputes it. **`next_run_date === null` means dormant** — disabled, past `end_time`, or an unreachable expression (`0 0 30 2 *`). It is the same field the scheduler indexes on, so the wire answer and the firing behaviour can never disagree. **`end_time`** is inclusive: an occurrence falling exactly on it still fires. On `PATCH` it is tri-state — omit to leave it, send `null` to clear it, send a value to set it. Same for `timezone`. **Metadata merges** on `PATCH` rather than being replaced. The run **payload** is replaced wholesale when a patch touches any run field, because a half-replaced request (new `input`, stale `config`) is not a request anyone asked for. **Catch-up.** If the server is down when occurrences pass, the cron fires **once** on return and resyncs to the next future occurrence. It does not backfill: replaying an hour of a five-minutely schedule is twelve times the model spend on near-identical inputs, and on a thread cron they serialize so the catch-up becomes the outage. This matches APScheduler's `coalesce` default; there is no knob. **Traceability.** Every fired run — and every thread a stateless cron creates — carries `cron_id` in its metadata. For a **stateless** cron that makes `client.threads.search({ metadata: { cron_id } })` the way in, since each fire has its own thread; for a **thread** cron the thread is already known, so `client.runs.list(thread_id)` lists the runs and each carries the `cron_id` that produced it. (The SDK has no `runs.search` — runs are always addressed through their thread.) **Failures.** If a fire fails (a deleted assistant, a store error) the cron is logged at `error`, counted, and **left enabled** — the next occurrence is the retry. skein never auto-disables a schedule: turning a thirty-second rolling-deploy blip into a silently stopped cron needing a human is the worse failure. **Deleting a thread deletes its thread crons**, by foreign key. A cron cannot outlive the thread it runs on. ## Driver requirements Crons work on **every** store/queue combination, including `skein dev` on memory with no Docker. | Store | Queue | Crons | Posture | | -------- | ------ | ------------------------------------------------- | ------------------------- | | memory | memory | Work; snapshotted to `.skein/` by `skein dev` | Development | | postgres | memory | Durable; single-instance delivery | Fine for small production | | postgres | redis | Durable; at-least-once delivery; multi-instance | **Recommended** | | memory | redis | Work, but **lost on restart** outside `skein dev` | Warned about at startup | The scheduler logs one warning at startup on a non-durable store. A schedule that quietly stops is worse than one that was never created, and an embedded memory store in production loses every cron on every deploy. ## How it works Every instance runs a ticker. Every 30 seconds (`SKEIN_CRON_TICK_MS`) it asks the store for enabled crons whose `next_run_date` has arrived, and for each one: 1. Computes the **next** occurrence, from the later of the stored date and now — so a cron that fell behind lands in the future in one step rather than replaying the backlog. 2. **Claims** it with a single-row conditional `UPDATE` on the primary key, guarded by a claim token the store bumps on every write. If three instances see the same occurrence, exactly one wins; the others skip. No leader election, no distributed lock, no Redis. 3. Commits the claim **and the `pending` run row in one transaction**, then enqueues the run. That third step is a transactional outbox, and it is what makes delivery durable. Advancing without the run would silently skip the occurrence if the instance died in between; creating the run first would re-fire it. Committed together, the worst case is a `pending` run that never reached the queue — which the next tick re-enqueues. Because enqueue is idempotent (keyed on the run id) and the worker skips runs already terminal, delivery is **at-least-once** and execution **exactly-once**. The recovery sweep looks only at runs a cron produced. A bare `pending` is not enough to conclude a run is waiting for a worker: an inline `wait`/`stream` run is written `pending` too and only becomes `running` once it acquires its thread's lock, so one queued behind a long peer would look identical while its caller waits to execute it in-process. Handing that to a worker would run the graph twice. The run itself then goes through the ordinary run queue — on Redis that is BullMQ, so cron-fired runs get its retries, backoff, and stalled-job crash recovery like any other background run. Schedule state lives in the store rather than in the queue, because `enabled`, `sort_by=next_run_date`, filtered `count`, and metadata search are all things a queue cannot serve — and because a schedule that a `FLUSHALL` can erase while the API still reports it as enabled is worse than no schedule. ## Operating a scheduler **The one metric to alert on is cron lag** — how overdue the most overdue enabled cron is. Healthy, it stays below one tick; if the scheduler dies it climbs without bound. It is the only signal that distinguishes "nothing is due" from "nothing is running the crons". Each tick reports it, along with counts of fired / claims lost / failed / re-enqueued runs. Claims lost are **not** errors: on N instances, N−1 lose every occurrence by design. | Setting | Default | Notes | | -------------------- | ------- | ------------------------------------------------------------- | | `SKEIN_CRON_TICK_MS` | `30000` | Lower does not help below one minute; higher trades lateness. | **Serverless caveat.** Nothing fires if no process is running. On Cloud Run that means `--no-cpu-throttling` and `--min-instances=1` — see [deploy-cloud-run.md](./deploy-cloud-run.md). Scale-to-zero and cron are incompatible, and no queue changes that. ## Authentication Crons are a first-class auth resource, so `@auth.on.crons.create`, `.read`, `.update`, `.delete`, and `.search` all work — matching the SDK's own `Auth` class. **If you have not registered a cron handler, crons inherit your `threads` scoping.** This matters, because `@auth.on` callbacks are matched by exact event key: a deployment that wrote `.on("threads", …)` has no callback named `crons`, and without the fallback the whole resource would be served unscoped. Since a schedule is really "runs on a thread, later", `threads` is both the safe default and the honest one — cron routes fall back to it, and `create` specifically falls back to `threads:create_run`. **Attaching a schedule to a thread is always authorized as `threads:create_run`**, separately from the cron handler. A cron writes into a thread indefinitely, so it is gated by thread ownership even when your cron handler is more permissive than your thread handler. A thread you cannot read answers 404, exactly as it does everywhere else. A cron fires with no HTTP request behind it, so skein remembers the **creating principal** (beside the row, never on the wire) and replays it into every run. The ownership filters are re-derived from your `@auth.on` handler at fire time rather than frozen at create time, which means: - Editing your auth handler applies to existing crons immediately. - Revoking a principal stops their crons producing runs; re-granting resumes them. - A stateless cron's per-fire thread is owner-stamped, so the user who created the cron can actually find its results in their own `POST /threads/search`. See [agent-protocol.md](./agent-protocol.md#authentication--authorization) for the request lifecycle. --- # The skein console > A web UI for a running skein server: assistants, threads, live runs, channel wiring and deliveries, > interrupts, time travel, the store, and crons. Served by the server itself, at `/console`. ## What it is The console is a **client** and stores nothing of its own. Most screens use the standard Agent Protocol through the real [`@langchain/langgraph-sdk`](./react-sdk.md). Two small skein extensions cover operational state the upstream SDK does not model: a sanitized channel inventory and a run's durable delivery attempts. The console subclasses the SDK's transport for these calls, so connection, authentication, retries and errors still behave like the rest of the client. That is a deliberate constraint, not a coincidence. If a view cannot be built, it means the API is missing something, and we would rather feel that here than paper over it with a bespoke endpoint. (One gap found exactly this way: there is no cross-thread run search — runs list per thread — so "recent activity" fans out over threads. See [roadmap](./roadmap.md).) It ships as [`@skein-js/console`](https://github.com/skein-js/skein-js/tree/main/packages/console): the compiled UI plus a resolver, with **no runtime dependencies**. ## Running it `skein dev` serves it by default and prints the URL: ``` skein · Agent Protocol dev server API http://127.0.0.1:2024 Console http://127.0.0.1:2024/console/ Docs https://github.com/skein-js/skein-js/tree/main/docs ``` Pass `--no-console` to leave it out. Because it is served by the server, it is **same origin**: no CORS to configure, no second process, no account, and it works with no internet connection. ## Turning it on in production **Off by default** under `skein start` and the production image. The console can read every thread, memory and schedule on the server, and delete runs, memories and schedules, so enabling it is a decision you make: ```jsonc { "http": { "console": true, // serve at /console // or: "console": "/admin/console" — any path but "/" }, } ``` Requests from the console go through the **same** [`auth`](./agent-protocol.md#authentication--authorization) path as any other client; there is no bypass. On a server with custom auth, use the console's connection control (top right) to supply an API key — it is sent as `x-api-key` and held in `localStorage`, since a static bundle has no server of its own to set a cookie. Two things worth knowing before you enable it on a public host: - The **assets** are served unauthenticated (they are a UI shell; every byte of data behind them is authorized). If that is not acceptable, put the mount path behind your ingress' own auth. - `http.console` is honoured by the **Express** transport, which is `skein start`'s Node runtime. On Bun/Deno the flag logs a warning and does nothing — mount it yourself (below). ## What it shows Seven tabs, plus three views that live inside them rather than in the nav — runs open from a thread, and interrupts and time travel are panels on the thread and playground. | View | What it is for | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Playground** | Pick a graph and run it: a chat transcript for graphs with a `messages` channel, a JSON editor for everything else. Streams live, and surfaces interrupts inline. This is the landing page. | | **Overview** | **Waiting for you** (threads parked on `interrupt()`, linking straight to the filtered list), counts, recent threads, and the `GET /info` capability handshake. | | **Assistants** | Every registered graph, its schemas, its graph JSON, its version history. Read-only. | | **Threads** | Filter by status (`#/threads?status=interrupted` is a shareable link); per thread: state, runs, checkpoints, and pending interrupts. | | **Runs** | Live SSE tail, delivery status and safe replay controls, cancel, rollback, delete. Works on finished runs too. | | **Channels** | The effective inbound routes, provider identity, graph routing allowlist, and outbound support that booted from `skein.channels`. Read-only. | | **Interrupts** | The "Waiting for you" panel: approve, reject, or resume with any JSON value. | | **Time travel** | Open a past checkpoint, edit its state, fork it, and run forward — from the fork or from the original. | | **Store** | Namespace listing by prefix, item search with a `filter` and semantic `query`, delete. | | **Crons** | Schedules with their next occurrence, pause/resume, create, delete. | Opening the console lands on the **playground**, because the first question anyone has about a server is whether their graph works; everything else answers a question you only have later. Routing is on the URL **hash** (`#/threads/abc`), so deep links never reach the server and the console needs no SPA history fallback on any adapter. An unknown route renders a "no such view" page rather than a blank one. ### The playground
![The console playground: a graph picker, the live graph diagram with the visited node highlighted, and a chat transcript](/images/console/playground-light.png)
![The console playground: a graph picker, the live graph diagram with the visited node highlighted, and a chat transcript](/images/console/playground-dark.png)
Three things are happening in that screenshot. **The mode picked itself.** The console reads `GET /assistants/:id/schemas` and offers **Chat** only when the graph has a `messages` channel. For a graph that takes structured input the Chat tab is disabled with the reason on hover, and the **JSON** editor opens pre-filled with a type-correct sample built from the graph's own input schema — `$ref`, `anyOf` and `enum` resolved — so you are editing a template rather than guessing the shape. Each mode keeps its own draft. **The diagram is live.** Nodes light up as `updates` frames arrive and stay shaded once visited, and conditional edges are dashed. It is the graph the server reports, not a drawing, so it is a useful check that the server loaded what you think it did. (It appears on wide viewports, and only here — the Assistants tab shows the same graph as JSON.) **Stop actually stops it.** The stream is opened with `onDisconnect: "cancel"`, so the button cancels the run server-side rather than just closing your eyes. The thread is created lazily on the first send and tagged `metadata.source = "console-playground"`, which is how you tell your own traffic from the console's later. ### Overview
![The console overview: counts, what is waiting for a human, and recent threads](/images/console/overview-light.png)
![The console overview: counts, what is waiting for a human, and recent threads](/images/console/overview-dark.png)
The counts come from the dedicated `count` endpoints rather than from the length of a page, so "1,204 threads" means 1,204. **Waiting for you** is the one that changes how you work: it is a `threads.count({ status: "interrupted" })`, and it links to the filtered list. ### Threads, and what is waiting on you
![The threads list filtered to interrupted](/images/console/threads-light.png)
![The threads list filtered to interrupted](/images/console/threads-dark.png)
Five status filters — `all`, `interrupted`, `busy`, `idle`, `error` — and the filter lives in the URL, so `#/threads?status=interrupted` is a link you can paste to someone. An unrecognised status falls back to `all`. Open a thread and the interrupt panel is the first thing on it:
![A thread paused on an interrupt, with approve, reject and a free-text resume value](/images/console/interrupts-light.png)
![A thread paused on an interrupt, with approve, reject and a free-text resume value](/images/console/interrupts-dark.png)
**Approve** and **Reject** resume with literal `true` and `false`. The free-text box resumes with whatever you type, parsed as JSON when it parses and passed as a plain string when it does not — so both `approve` and `"approve"` do what you meant. The assistant is inferred from the thread's most recent run; a thread with no runs cannot be resumed from here and says so. ### Runs A run page tails the SSE stream, and works on runs that finished hours ago because the server replays what it persisted. Watching is **non-destructive** — the console joins with `cancelOnDisconnect: false`, so closing the tab never cancels somebody's run. The buffer holds the most recent 500 frames and tells you when it dropped earlier ones rather than silently truncating. Each frame collapses to one line; click to expand the payload. **Cancel** and **Rollback** are enabled only while the run is in flight, **Delete** only when it is not. The **Deliveries** panel tracks every durable callback the run created: lifecycle status, attempt count, last error, next retry, and timestamps. A dead delivery can be replayed after confirmation; the console then reloads the server's list rather than trusting an optimistic state. Delivery is at-least-once, so the receiver must deduplicate by the stable delivery id. Destination display is deliberately redacted. For HTTP callbacks it shows only the host and whether a non-root path exists; webhook paths, queries and userinfo often are credentials. For a channel reply it shows the channel name but never its opaque reply target. ### Channels The Channels tab answers “what did this server actually mount?” It reads `GET /channels`, which only exists when `skein.channels` configured at least one route, and shows: - the `POST /channels/{route_name}` path to give a provider; - the channel's provider identity and default assistant; - the bounded `allowed_assistants` routing set; and - whether the channel implements outbound delivery. It is an inventory, not an editor: `langgraph.json` and the channel module remain the source of truth, and changes take effect after restart. Module paths, `public_url`, provider credentials and raw configuration are never returned. With custom auth, the inventory uses `assistants:read`; inbound provider requests continue to authenticate through the channel's own signature verification. To test the whole flow, send a real or fixture provider event to the copied inbound path, open the thread and run it created, then inspect Deliveries. That keeps source routing and destination tracking connected without pretending they are the same concern. ### Time travel Every checkpoint is addressable, and the checkpoint panel gives you three separate things to do, which are easy to conflate: | Action | What it does | | --------------------------- | ------------------------------------------------------------- | | **Fork here** | Writes your edited values to a new checkpoint. Nothing runs. | | **Fork and run** | Writes the edit, then runs forward from the fork. | | **Run from here unchanged** | Runs forward from the original checkpoint. No write, no edit. | > **A note on editing state at a checkpoint:** values you write go through the graph's _reducers_. A > channel that appends (a message list, say) will add what you write rather than replace it. The > console says so inline, because this surprises everyone once. ### The store
![The store browser: namespace prefix search, a filter, and the items in a namespace](/images/console/store-light.png)
![The store browser: namespace prefix search, a filter, and the items in a namespace](/images/console/store-dark.png)
Search by namespace prefix, narrow with a `filter` (a JSON object matched against item values), and if the store is index-backed, rank by semantic `query`. Items show their key, value, last-updated time and — for a semantic search — the similarity score. Clicking a namespace drills into it. The search applies on submit rather than per keystroke, and invalid filter JSON tells you so with an example instead of returning nothing. ### Crons Schedules with their assistant, expression, timezone, target thread (or `stateless`), and next occurrence. Pause and resume without deleting, which is the fastest way to stop a noisy schedule while you look at it. The next-occurrence column counts forwards — `in 2h`, `in 3d`, `in 14mo` — and names the states a schedule can be in: `paused` when it is disabled, and `no upcoming run` when it is enabled but its expression will never fire again. That last one is the case the column exists for; it otherwise looks identical to a healthy schedule. A schedule already past due but not yet fired reads in the other direction (`2m ago`) — brief while a tick is pending, and a sign the scheduler is stuck if it grows. Creating one takes an assistant, a 5-field expression and an input. Sub-minute schedules are a [deliberate non-goal](./crons.md#semantics), and the form says so rather than failing at submit. ## What it deliberately does not do The console is a window onto a running server, not an admin tool, and a few of its limits are worth knowing before you reach for it: - **Nothing refreshes itself.** Every list loads on mount and has a Refresh button; only the run stream is live. A console that polls is a console that lies about when it last looked. - **Lists are capped and there is no pagination** — 50 threads, 50 runs, 20 checkpoints, 100 assistants, 100 schedules. Past that, use the API. - **Replay confirms; deletes do not.** Replaying may duplicate an external side effect, so it asks first. Deleting a run, schedule or store item still happens on the click. - **It is read-mostly.** It cannot create or edit assistants, write store items, copy or prune threads, or roll an assistant back to an earlier version — all of which the [API](./agent-protocol.md) supports. What is missing is tracked in [the issues](https://github.com/skein-js/skein-js/issues). ## Mounting it yourself The console is route bindings and bytes, so any adapter can serve it. It is not a dependency of the adapters on purpose — the compiled UI is about 956 KiB, and mounting the protocol should not cost that. ```ts import { consoleAssetHeaders, resolveConsoleRequest } from "@skein-js/console"; // Express, Fastify, Hono, a Fetch handler — the shape is the same. app.use((req, res, next) => { if (req.method !== "GET" && req.method !== "HEAD") return next(); const resolution = resolveConsoleRequest(req.path, { mountPath: "/console" }); if (resolution.kind === "miss") return next(); if (resolution.kind === "redirect") return res.redirect(302, resolution.location); res.set(consoleAssetHeaders(resolution.asset)); return res.status(200).send(Buffer.from(resolution.asset.bytes)); }); ``` Two rules the resolver enforces so you do not have to: - **A bare mount path redirects to its slashed form.** Assets are referenced relatively (so one build works at any mount point); at `/console` the browser would resolve them against the parent directory. - **An unknown path is a `miss`, not a fallback to `index.html`.** Hash routing means a real deep link never arrives, so answering 200-with-HTML would only turn a broken asset reference into a blank page. The SPA derives the API base by dropping the **last segment** of its own path: `/console/` → `/`, `/api/console/` → `/api`. Mount it at `/console` and it finds the server with no configuration. To point it somewhere else entirely, append `?baseUrl=https://your-deployment` (the choice is remembered), which is also how you would host the bundle statically. ## How it is built The UI is a static Vite + React SPA ([shadcn/ui](https://ui.shadcn.com), light/dark with a system-aware toggle), built by the `console-ui` Nx project. `nx serve console-ui` is the HMR dev loop. `nx build console` then chains: build the SPA → compile its files into `src/assets.generated.ts` as string constants → bundle. That last step is why the package can be a _library_: skein forbids reading package-relative files at runtime, because bundlers rewrite `import.meta.url` to the output location (see [bundling.md](./bundling.md)). `@skein-js/storage-postgres` solved the same problem for its SQL; the console does it for HTML, JS and CSS. A test pins the total size so the CLI's install cost cannot drift upward unnoticed. --- # Observability How to see what your agents are doing in production — traces in LangSmith or Langfuse, metrics and spans in OpenTelemetry, product analytics in PostHog — and how to write a sink for anything else. For **logs** and what happens when a graph throws, see [errors-and-logging.md](./errors-and-logging.md). This doc is about the other two surfaces. ## Three surfaces skein reports what a run did in three places, deliberately carrying different amounts of detail: | Surface | Who reads it | What it gets | | ------------ | ----------------- | ------------------------------------------------------------- | | **The wire** | your API client | `RunError` — a stack only when you set `exposeErrorStacks` | | **The log** | you, the operator | everything: the original `Error`, its stack and `cause` chain | | **A sink** | you, the operator | everything, same as the log — it's server-side too | That last row is the rule worth remembering: **`exposeErrorStacks` governs the wire, not telemetry.** A sink always receives the real `Error`. See [errors-and-logging.md](./errors-and-logging.md). Telemetry itself splits in two, and one interface covers both: - **Traces** — spans for what happens _inside_ a graph: each LLM call, tool, and chain. These come from LangChain's callback system, so any callback-based tracer works (LangSmith, Langfuse, Braintrust, OpenLLMetry). - **Events** — the run's own lifecycle: started, settled, how long, how many frames, what failed. skein emits these itself, from the one code path every run mode goes through. ## Turning it on Three ways, highest precedence first. **1. In code** — pass a sink as `ProtocolDeps.telemetry`. Works everywhere, CLI or not: ```ts import { createPostHogTelemetry } from "@skein-js/posthog"; import { embedInMemoryGraphs } from "@skein-js/server-kit"; const telemetry = createPostHogTelemetry(); const deps = embedInMemoryGraphs({ agent }, { overrides: telemetry ? { telemetry } : {} }); ``` Each `create*Telemetry()` returns `undefined` when its backend isn't configured, so that ternary is the whole "is it enabled" story. Pass an **array** to feed several backends at once. **2. In `langgraph.json`** — for `skein dev` / `skein start`: ```json { "graphs": { "agent": "./src/agent.ts:graph" }, "telemetry": { "langsmith": true, "posthog": { "host": "https://eu.i.posthog.com" }, "otel": true, "paths": ["./src/my-telemetry.ts:sink"] } } ``` `true` enables with defaults, `false` **hard-disables** even when the environment says otherwise, and an object is passed through to the adapter. This is a skein extension; it's additive, so a config carrying it still loads under `langgraph dev`. **3. From the environment** — a provider the config doesn't mention turns itself on when its variables are present: | Provider | Detected from | | ------------- | ------------------------------------------------------------------------------------------ | | LangSmith | `LANGSMITH_TRACING=true` **and** `LANGSMITH_API_KEY` (or the `LANGCHAIN_` equivalents) | | PostHog | `POSTHOG_API_KEY` | | OpenTelemetry | `OTEL_EXPORTER_OTLP_ENDPOINT` · `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` · `OTEL_SERVICE_NAME` | With nothing configured there is no telemetry and no cost — the engine skips building any context at all. A provider that is only _detected_ stays quietly off when it can't be built — its package isn't installed, or its API key is absent. One you **declared** in `langgraph.json` instead **fails at startup**, naming what is missing, because a silent no-op for something you explicitly asked for is worse: the first you'd hear of it is an incident with no traces to investigate. Remove the entry to run without it. > **Why LangSmith needs two variables.** Turning tracing on uploads every prompt, tool argument, and > model response — your users' messages — to a third party. A stray `LANGSMITH_API_KEY` (common in > environments that use LangSmith for something else) is not consent for that. `LANGSMITH_TRACING=true` > is LangChain's own opt-in, so that is the signal skein takes; declaring `telemetry.langsmith` in > `langgraph.json` is the other way to say yes. > **Building an image?** `skein build` can't see a dynamically imported adapter, so declare the > provider in `langgraph.json` and it gets pinned into the image. Environment-detected providers are > deliberately **not** pinned: the build machine's environment isn't the runtime's. ## LangSmith ```bash pnpm add @skein-js/langsmith langsmith export LANGSMITH_API_KEY=lsv2_pt_... export LANGSMITH_TRACING=true ``` Both variables, deliberately — see the note above. Declaring `{"telemetry": {"langsmith": true}}` in `langgraph.json` switches tracing on for you, so the key alone is enough on that path. **What this actually fixes.** LangSmith's tracer already instruments everything inside your graph. What it can't know is what a "run" is on your server, so without help every run lands as an anonymous root trace: no thread, no assistant, no user. This adapter supplies that identity — above all **`session_id`**, the key LangSmith's **Threads** view groups on, so a conversation reads as one thread there just as it does through the Agent Protocol. It deliberately attaches **no tracer of its own**. `@langchain/core` installs a global tracer when `LANGSMITH_TRACING` is on; a second one would send every span twice — you'd pay double and couldn't trust the numbers. The adapter enriches that one tracer instead. Credentials are **read from the environment, never written to it**: skein will not copy an API key into `process.env`, where every module and every child process would inherit it. Set `LANGSMITH_API_KEY` (and `LANGSMITH_ENDPOINT` for self-hosted) in the environment — that is where LangChain's tracer reads them from. | Set on every trace | | | ------------------ | --------------------------------------------------------------------------- | | `metadata` | `session_id` (thread id), `run_id`, `thread_id`, `assistant_id`, `graph_id` | | `metadata` | `ls_user_id` when auth is on, `skein_trigger` | | `tags` | `skein`, `graph:`, `trigger:`, `assistant:` | | run name | the graph id | Full options in the [package README](https://github.com/skein-js/skein-js/blob/main/packages/telemetry-langsmith/README.md). ## PostHog ```bash pnpm add @skein-js/posthog posthog-node export POSTHOG_API_KEY=phc_... ``` Two layers, correlated by `$ai_trace_id` (= skein's run id): - **`skein_run_started` / `skein_run_finished`** — status, duration, queue wait, frame count, and the graph, assistant, and thread. The operational picture. - **`$ai_generation`** — PostHog's LLM Analytics schema, per model call: `$ai_model`, `$ai_provider`, input/output/total tokens, `$ai_latency`, `$ai_is_error`. This is what fills PostHog's LLM dashboards — cost per user, tokens per conversation. Token counts come from the message's `usage_metadata` where the provider supplies it, falling back to the older `llmOutput.tokenUsage`. When neither is present the token fields are **omitted** rather than reported as zero — a missing number is more honest than a wrong one. Runs are attributed to the authenticated user's identity, falling back to the thread id so anonymous traffic groups per conversation instead of collapsing into one distinct id. Override with `distinctId`. Turn the LLM layer off with `captureGenerations: false`. See the [package README](https://github.com/skein-js/skein-js/blob/main/packages/telemetry-posthog/README.md). ## OpenTelemetry ```bash pnpm add @skein-js/otel @opentelemetry/api ``` `@skein-js/otel` depends on the OTel **API only** — never the SDK, never an exporter. Your app owns the SDK and decides where data goes, exactly as it already does for the rest of your service. That's why one small package covers every OTLP backend. If no SDK is registered, the API's no-op implementation takes over and this costs almost nothing. **Span** `skein.run `, one per run, with `skein.run.id` / `.thread.id` / `.assistant.id` / `.graph.id` / `.trigger` / `.status` / `.queue_ms` / `.frames` / `.failing_nodes`, plus the `gen_ai.*` semconv equivalents. A failure records the exception and sets an `ERROR` status. **Metrics** `skein.runs` (counter), `skein.run.duration` (histogram, ms), `skein.run.queue.duration` (histogram, ms), and `skein.run.frames` (histogram), dimensioned by graph, trigger, and status — deliberately **low-cardinality**, with no run, thread, or user id, so they stay cheap in Prometheus and friends. ### Spans inside the graph skein reports the _run_. For LLM- and tool-level spans, add a LangChain instrumentation — [`@traceloop/node-server-sdk`](https://github.com/traceloop/openllmetry-js) or [`@arizeai/openinference-instrumentation-langchain`](https://github.com/Arize-ai/openinference). The OTel sink makes the Skein run span active while LangGraph executes. Instrumentation that respects the OTel context therefore nests model, tool, and chain spans under the run span, including across awaits; the run and thread attributes remain available for correlation and filtering too. ### Datadog, Grafana, Honeycomb, Jaeger, New Relic, Sentry All of these are the OTel adapter plus their own exporter — no skein-specific code. Configure the SDK the way that vendor documents, then: ```json { "telemetry": { "otel": true } } ``` For most, exporting `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` at their collector is the entire integration. ## Other backends ### Langfuse Langfuse ships a LangChain callback handler, so it needs only a small sink: ```ts import { CallbackHandler } from "langfuse-langchain"; import type { TelemetrySink } from "@skein-js/core"; export const sink: TelemetrySink = { name: "langfuse", callbacks: (context) => [ new CallbackHandler({ sessionId: context.threadId, // groups a conversation in Langfuse userId: context.userId, metadata: { run_id: context.runId, graph_id: context.graphId }, }), ], }; ``` ```json { "telemetry": { "paths": ["./src/langfuse.ts:sink"] } } ``` ### Braintrust Same shape — Braintrust's LangChain handler in `callbacks`, with the thread id as the span's parent identifier. ### Sentry Sentry reads OTel spans, so `@skein-js/otel` plus Sentry's SDK covers tracing. For error _events_ specifically, a lifecycle sink is more direct: ```ts import * as Sentry from "@sentry/node"; import type { TelemetrySink } from "@skein-js/core"; export const sink: TelemetrySink = { name: "sentry", onRunEvent: (event) => { if (event.type !== "run.finished" || !event.cause) return; // `event.cause` is the original Error — stack and cause chain intact. Sentry.captureException(event.cause, { tags: { graph_id: event.context.graphId, status: event.status }, contexts: { skein: { run_id: event.context.runId, thread_id: event.context.threadId } }, }); }, }; ``` ### Your existing logger For skein's **own** reports — failed runs, webhook failures — the seam is `ProtocolDeps.logger`, already wired to the host's logger under NestJS and Fastify. See [errors-and-logging.md](./errors-and-logging.md#logging). For **run lifecycle events** in your structured logs, a sink is a ten-line bridge over `onRunEvent`, the same hook the Sentry example uses. ## Writing your own sink The whole interface, from `@skein-js/core`. Every method is optional — implement the half you need: ```ts export interface TelemetrySink { name: string; /** Run lifecycle. Fire-and-forget: never awaited. */ onRunEvent?(event: RunTelemetryEvent): void; /** LangChain callback handlers, so a tracer's spans nest under the run. */ callbacks?(context: RunTelemetryContext): unknown[]; /** Extra metadata / tags stamped on the graph call. */ traceMetadata?(context: RunTelemetryContext): Record; traceTags?(context: RunTelemetryContext): string[]; /** Make a backend context active while the graph executes. */ withRunContext?(context: RunTelemetryContext, body: () => Promise): Promise; /** Drain buffered data — called on shutdown. */ flush?(): Promise; shutdown?(): Promise; } ``` `RunTelemetryContext` carries `runId`, `threadId`, `assistantId`, `graphId`, `userId`, `trigger` (`wait` / `stream` / `background` / `invoke`), `streamModes`, and the run's `metadata`. `run.finished` adds `status`, `durationMs`, `frameCount`, and on failure `error` (the JSON-safe `RunError`), `failingNodes` (which graph node threw), and `cause` (the original `Error`). Two things to know: - **`assistantId` is absent for `trigger: "invoke"`.** The `POST /invoke/:graph_id` surface addresses a graph directly, with no assistant in between. It emits lifecycle telemetry using a synthetic run identity for correlation, but deliberately creates no persistent run row. - **Sinks may not throw or block.** Every call is guarded, so a throw is logged and swallowed rather than failing a run — but a sink doing inline I/O still slows every run it observes. Buffer, and implement `flush()`; skein calls it on shutdown so buffering is the safe default. Point `langgraph.json` at it: ```json { "telemetry": { "paths": ["./src/my-telemetry.ts:sink"] } } ``` The export may be the sink itself or a function returning one, so your module can read its own configuration. ## Cost and safety - **Off by default, free when off.** With no sink configured the engine builds no context and reads no metadata. There is no "disabled telemetry" tax. - **A broken sink can't break a run.** Every method is wrapped; a throw is logged through `ProtocolDeps.logger` and dropped. One sink failing doesn't stop the others. - **Flushed on shutdown.** The run worker and the runtime both drain sinks when stopping, so batching exporters don't lose the tail of a process — which is exactly the telemetry you want after a crash. - **Metric cardinality.** skein's own OTel metrics carry no run, thread, or user id. If you write a sink, resist adding them as metric dimensions — they belong on spans and events, not counters. - **What leaves your server.** Traces carry your graph's inputs and outputs, which for an agent means user messages. Check that against your data-handling obligations before pointing them at a hosted backend; self-hosted LangSmith, Langfuse, and any OTLP collector are all supported. ## Learn more - [Errors & logging](./errors-and-logging.md) — the other two reporting surfaces - [Runs & Redis](./runs-and-redis.md) — the run engine these events come from - [Deploy](./deploy.md) — running skein in production --- # Recipes Task-oriented pages: a problem and the smallest code that solves it. Runnable examples named here are in the repo and exercised by CI; provider integration snippets are checked against the linked official SDK documentation. For a terse API reference see [using-skein.md](../using-skein.md). ## Read working code first [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) covers crons, background runs, idempotency, human-in-the-loop, long-term memory and time travel in **one graph** — and runs with **no API key and no network** (bundled fixtures, deterministic fallback classifier). ```bash pnpm --filter @skein-js/example-triage-agent dev # console at http://127.0.0.1:2024/console/ pnpm --filter @skein-js/example-triage-agent seed # register the schedule + sweep once ``` ## The recipes | Page | Covers | | ---------------------------------------------------------- | --------------------------------------------------------------------------- | | [Serving](./serving.md) | Pick an adapter, serve a graph as a plain endpoint, CORS for a browser | | [Running agents](./running-agents.md) | Background runs, crons, idempotency, human-in-the-loop, run timeouts | | [Coupled WhatsApp workflow](./coupled-channel.md) | A workflow with the same provider as source and destination | | [Cross-provider workflow](./decoupled-channel-delivery.md) | Email source → LangGraph workflow → allowlisted WhatsApp/email destinations | | [Memory](./memory.md) | `getStore()`, semantic search, and the dedup trap | | [Authenticating requests](./authentication.md) | Better Auth, Clerk, Supabase, Firebase, OIDC/JWT, and authorization policy | | [Production](./production.md) | Auth, run-completion webhooks, durable storage and deploying | ## Which example shows what | Example | Shows | | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) | Crons, background runs, idempotency, HITL, memory, time travel | | [`decoupled-delivery`](https://github.com/skein-js/skein-js/tree/main/examples/decoupled-delivery) | Routed channels: email source → approval workflow → WhatsApp/email destinations | | [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) | Full-stack chat: streaming, thinking, tool cards, HITL, memory | | [`invoke-endpoint`](https://github.com/skein-js/skein-js/tree/main/examples/invoke-endpoint) | Non-chat graphs as plain HTTP endpoints | | [`embed-graph`](https://github.com/skein-js/skein-js/tree/main/examples/embed-graph) | A graph you already have, served with no `langgraph.json` | | [`migrated-langgraph`](https://github.com/skein-js/skein-js/tree/main/examples/migrated-langgraph) | The drop-in proof — a stock LangGraph project under `skein dev` | | [`react-usestream`](https://github.com/skein-js/skein-js/tree/main/examples/react-usestream) | A minimal `useStream` frontend against any skein server | --- # Serving Getting the protocol in front of clients: which adapter, the non-chat surface, and browser access. ## Pick a framework adapter Every adapter serves the identical protocol and takes the same `{ config } | { deps }` seam — pick the framework you already run. Each has a **standalone** entry (a dedicated server) and an **embedded** one (mount beside your existing routes). | Framework | Package | Standalone | Embedded | Examples | | --------- | ------------------- | --------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Express | `@skein-js/express` | `createExpressServer` | `skeinRouter` | [express-basic](https://github.com/skein-js/skein-js/tree/main/examples/express-basic), [embed-graph](https://github.com/skein-js/skein-js/tree/main/examples/embed-graph) | | Fastify | `@skein-js/fastify` | `createFastifyServer` | `skeinPlugin` | [fastify-basic](https://github.com/skein-js/skein-js/tree/main/examples/fastify-basic), [fastify-app](https://github.com/skein-js/skein-js/tree/main/examples/fastify-app) | | NestJS | `@skein-js/nestjs` | `createNestServer` | `SkeinModule.forRoot` | [nestjs-basic](https://github.com/skein-js/skein-js/tree/main/examples/nestjs-basic), [nestjs-app](https://github.com/skein-js/skein-js/tree/main/examples/nestjs-app) | | Next.js | `@skein-js/nextjs` | route handlers | `createSkeinRouteHandlers` (App) · `createSkeinPagesHandler` (Pages) | [nextjs-app](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-app), [nextjs-basic](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-basic) | Bun and Deno use [`@skein-js/fetch`](../deploy.md), selected with `skein build --runtime`. For a framework skein doesn't ship, the adapters are ~40-line shims over one handler table — see [building-an-adapter.md](../building-an-adapter.md). Mount snippets: [using-skein.md](../using-skein.md#mount-it-on-your-framework). ## Serve a graph as a plain endpoint For a classifier, extractor, or a workflow another service calls — no threads, no runs. The request body **is** the graph input; the response **is** its final state. ```ts const { router } = await skeinInvokeRouter({ deps: embedInMemoryGraphs({ triage }) }); app.use(router); // curl -X POST localhost:2024/invoke/triage -d '{"text":"…"}' ``` Working version: [`invoke-endpoint`](https://github.com/skein-js/skein-js/tree/main/examples/invoke-endpoint) — two non-chat graphs, no model, no API key. Details: [serving-a-single-graph.md](../serving-a-single-graph.md). ## CORS for a browser client CORS is **off by default**. Same-origin needs nothing — see [`nextjs-app`](https://github.com/skein-js/skein-js/tree/main/examples/nextjs-app), which serves the protocol and the UI from one app. ```jsonc // langgraph.json — matches the LangGraph CLI { "http": { "cors": { "allow_origins": ["http://localhost:3000"] } } } ``` Or pass `cors` to any adapter (`true` for permissive dev, `false` to force off). Cross-origin example: [`react-usestream`](https://github.com/skein-js/skein-js/tree/main/examples/react-usestream). --- # Running agents Getting work started, keeping it from duplicating, pausing it for a human, and bounding it. ## Background runs, join and cancel Kick off a long run, return immediately, stream it from anywhere. ```ts const run = await client.runs.create(threadId, "agent", { input }); // returns immediately for await (const ev of client.runs.joinStream(threadId, run.run_id)) console.log(ev); await client.runs.cancel(threadId, run.run_id); ``` Cross-instance join needs the Redis queue + bus ([runs-and-redis.md](../runs-and-redis.md)). Concurrent runs on **one** thread follow `multitask_strategy`; how many run at once across **different** threads is [run concurrency](../runs-and-redis.md#run-concurrency) (default 10). Working version: [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) — one background run per issue, each on its own thread. Details: [runs.md](../runs.md); for fire-and-forget work with no conversation to keep, [background-jobs.md](../background-jobs.md). ## Scheduled runs (crons) Fire a graph on a schedule. Schedules live in the store, so they survive restarts and fire exactly once across instances — no leader election. ```ts await client.crons.create("agent", { schedule: "*/5 * * * *", input: { source: "queue" } }); ``` Cron delivery is **at-least-once**: an occurrence committed but never queued is re-enqueued by the scheduler's sweep. Working version: [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) — a sweep that dispatches one run per issue. Details: [crons.md](../crons.md). ## Don't create the same run twice A retrying caller — Twilio, Stripe, GitHub, or your own sweep — should not start a second run. Send an `Idempotency-Key` and the original response replays. ```ts // `runs.create` has no `headers` option — a `headers` key in the payload is silently dropped and you // get a brand-new run every retry. A per-key client is the only way to send it. const client = new Client({ apiUrl, defaultHeaders: { "Idempotency-Key": issue.id } }); await client.runs.create(threadId, "agent", { input }); ``` The claim is an insert arbitrated by a uniqueness constraint, so 50 concurrent retries across two instances still produce exactly one run. Keys are scoped per principal, failures are never recorded, and the **streaming** creates reject the header rather than ignoring it — an SSE response has no body to replay. **LangGraph Platform has no equivalent.** Working version: [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) — re-sweeping creates nothing. Details: [agent-protocol.md](../agent-protocol.md#idempotent-run-creation-idempotency-key). ## Human-in-the-loop (interrupt / resume) Pause for approval, resume later — possibly hours later, from a different client. An interrupted run holds no connection and no timer, only a checkpoint. ```ts import { interrupt } from "@langchain/langgraph"; async function approve(state) { const decision = interrupt({ question: "Send this email?", draft: state.draft }); return { sent: decision === "yes" }; } ``` The thread's status becomes `interrupted` and the interrupt surfaces in the stream; resume by submitting a `command`. [`useStream`](../react-sdk.md) renders and resumes it for free. Working versions: [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) (reached by a conditional edge) and [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) (approval card in the UI). Details: [human-in-the-loop.md](../human-in-the-loop.md). ## Bound a runaway run A graph that hangs — a model call with no timeout of its own, a node that loops — holds a worker slot until the process restarts. Set `--run-timeout ` or `SKEIN_RUN_TIMEOUT_MS`; the run aborts and settles as `timeout`. **Off by default, deliberately.** A legitimate research or multi-step tool run takes minutes, so a default would turn slow-but-working into killed — the exact failure the timeout exists to prevent. Pick a number from your own graphs' worst honest case. ## Re-run a turn a different way Fork from any past checkpoint and run from there, leaving the original branch intact — useful for retrying a decision with different input, or debugging what a node saw. ```ts await client.runs.create(threadId, "agent", { input, checkpointId }); ``` The fork target is server-validated, so a client cannot redirect a run to an arbitrary checkpoint through config. Visible in the [console](../console.md); details: [threads.md](../threads.md#time-travel-re-run-a-turn-a-different-way). --- # Build a LangGraph WhatsApp workflow with a coupled channel Use a coupled channel when a workflow receives messages from a provider and its outcome naturally goes back through the same provider: WhatsApp → LangGraph → WhatsApp, Slack → LangGraph → Slack, or email → LangGraph → email. One `Channel` owns both translations, while Skein supplies authentication, deduplication, thread resolution, interrupt/resume, and durable delivery. Consider a customer who sends **“Where is order GT-1042?”** to a shop's WhatsApp number. The graph looks up the shipment and replies **“GT-1042 left our Nairobi warehouse and arrives tomorrow.”** The sender, conversation, and expected return provider are already the same, so splitting the source and destination would add ceremony without adding control. ## When coupling is the simpler design Coupling is useful, not a limitation, when the inbound event already contains the correct reply address. A WhatsApp sender expects a WhatsApp response, and keeping both provider mappings together means fewer names, maps, and failure paths. Use a [cross-provider workflow](./decoupled-channel-delivery.md) when LangGraph must choose a different destination—for example, an email source that should alert a manager over WhatsApp. ## Implement the WhatsApp channel The event's `replyTo` is opaque to Skein. It is stored with the run and passed back to the same channel's `deliver` method after the run settles. ```ts import type { Channel } from "@skein-js/channels"; import { z } from "zod"; const whatsappReplyTargetSchema = z.object({ to: z.string().min(1) }); export const channel: Channel = { name: "whatsapp", verify(request) { if (!verifyWhatsAppSignature(request)) return false; const message = request.form(); return { identity: `channel:whatsapp:${message["From"]}` }; }, parseEvent(request) { const message = request.form(); if (!message["Body"] || !message["From"]) return { kind: "ignore" }; return { kind: "event", event: { threadKey: message["From"], idempotencyKey: message["MessageSid"], input: { customerMessage: message["Body"] }, resumeWith: message["Body"], replyTo: { to: message["From"] }, }, }; }, async deliver(outcome, target) { if (outcome.reply === undefined) return; const { to } = whatsappReplyTargetSchema.parse(target); await sendWhatsAppMessage({ to, body: String(outcome.reply) }, { key: outcome.runId }); }, }; ``` Provider retries cannot create duplicate runs when `idempotencyKey` is stable. Delivery is at-least-once, so use `outcome.runId` as a provider idempotency key when the provider supports one. ## Return a reply from LangGraph A LangGraph node can declare the exact value delivered by the channel with Skein's existing `replyWith` helper. If it does not, Skein can fall back to the last AI message in message-shaped state. ```ts import { Annotation, END, START, StateGraph, type LangGraphRunnableConfig, } from "@langchain/langgraph"; import { replyWith } from "@skein-js/agent-protocol"; const State = Annotation.Root({ customerMessage: Annotation }); async function answerOrderQuestion(state: typeof State.State, config: LangGraphRunnableConfig) { const orderId = /GT-\d+/.exec(state.customerMessage)?.[0]; const shipment = orderId ? await orderSystem.findShipment(orderId) : undefined; const answer = shipment ? `${orderId} ${shipment.status} and arrives ${shipment.estimatedArrival}.` : "I could not find that order. Please send the order number, for example GT-1042."; config.writer?.(replyWith(answer)); return {}; } export const graph = new StateGraph(State) .addNode("answer-order-question", answerOrderQuestion) .addEdge(START, "answer-order-question") .addEdge("answer-order-question", END) .compile(); ``` Bind the channel to that graph: ```jsonc { "graphs": { "order-support": "./src/graph.ts:graph" }, "skein": { "channels": { "whatsapp": { "path": "./src/whatsapp-channel.ts:channel", "assistant": "order-support", "public_url": "https://api.example.com", }, }, }, } ``` The result follows one durable path: ```text WhatsApp webhook → channel.parseEvent → LangGraph → channel.deliver → WhatsApp ``` For a runnable implementation with `interrupt()` and resume, see [`examples/whatsapp-agent`](https://github.com/skein-js/skein-js/tree/main/examples/whatsapp-agent). --- # Build a cross-provider workflow with Skein channels Use a routed workflow when a source starts work in one system and LangGraph must choose a different destination: email → LangGraph → WhatsApp, WhatsApp → LangGraph → email, or one source routed to several application-owned provider adapters. The technical mechanism is decoupled channel delivery: Skein supplies the source and destination, while LangGraph fills the middle with the workflow. Here, a workflow means the complete business process, not merely forwarding a message: validate the request, collect approvals, pause and resume, decide the result, and notify the customer. The [workflows and channels guide](../channels.md#what-workflow-means-here) explains why these responsibilities are split across Skein and LangGraph. The source still uses Skein's normal channel pipeline. The only extra layer is an allowlisted destination map plus one explicit instruction written by the graph. The example below handles a concrete request: a customer emails **“Please refund KES 27,500 for order GT-1042; I was charged twice.”** LangGraph asks Finance for approval over WhatsApp, the Finance reply resumes the interrupted graph, and the customer receives the decision by email. ## See the refund approval workflow The recording follows the complete example: an email starts the workflow, LangGraph pauses for the three required approvals, each authenticated WhatsApp response resumes its own interrupt, and the approved result is delivered back to the customer by email. ## When a workflow should use separate source and destination Separate source and destination providers are appropriate when routing is a workflow decision rather than an inherent response: - an ERP order event alerts a sales team over WhatsApp; - an email refund request asks HR, a manager, and Finance for approval; - a personal assistant reads calendar or email events and sends a WhatsApp briefing. If a WhatsApp message simply needs a WhatsApp reply, use the smaller [coupled WhatsApp workflow recipe](./coupled-channel.md). Both forms use the same `Channel`, run, thread, LangGraph, and outbox primitives; decoupled delivery is an additional routing shape, not a replacement. ## 1. Define the workflow's inbound source A source verifies and parses only. `composeRoutedChannel` arms the existing durable callback path, so the source does not need a fake email `deliver` method. Both inbound providers use one trusted, tenant-scoped workflow ID when they intentionally converge on the same thread: ```ts const tenantId = "acme"; // Deployment configuration, never inbound request data. export function workflowThreadId(refundId: string): string { return `relay:${tenantId}:${refundId}`; } ``` Here `refundId` is issued by the application or verified provider metadata; do not derive an authoritative thread ID directly from email text or another user-controlled field. ```ts import type { Channel } from "@skein-js/channels"; import { z } from "zod"; import { workflowThreadId } from "./workflow-thread-id.js"; const refundEmailSchema = z.object({ messageId: z.string().min(1), refundId: z.string().min(1), from: z.string().email(), orderId: z.string().min(1), amountKes: z.number().positive(), reason: z.string().min(1), }); export const emailSource = { name: "email-source", verify(request) { if (!verifyEmailWebhook(request)) return false; return { identity: "channel:email:inbound" }; }, parseEvent(request) { const email = refundEmailSchema.parse(request.json()); return { kind: "event" as const, event: { threadKey: email.refundId, threadId: workflowThreadId(email.refundId), idempotencyKey: email.messageId, input: { source: "email", refundId: email.refundId, customerEmail: email.from, orderId: email.orderId, amountKes: email.amountKes, reason: email.reason, financeWhatsapp: "whatsapp:+254700000013", }, }, }; }, } satisfies Pick; ``` ## 2. Allowlist workflow destinations Destination callbacks own credentials, provider validation, authorization, and idempotency. Names are explicit and local to this composed channel. The map allowlists provider adapters, not recipients: authorize every target from trusted application data rather than treating an LLM-selected or user-supplied phone number as permission to send. ```ts import { composeRoutedChannel, type ChannelDestinationDelivery } from "@skein-js/channels"; import { z } from "zod"; const whatsappDeliverySchema = z.object({ target: z.object({ to: z.string().min(1) }), payload: z.object({ body: z.string().min(1) }), }); const emailDeliverySchema = z.object({ target: z.object({ to: z.string().email() }), payload: z.object({ subject: z.string(), body: z.string() }), }); export const destinations = new Map([ [ "whatsapp", async (delivery: ChannelDestinationDelivery) => { const message = whatsappDeliverySchema.parse(delivery); await assertAuthorizedRecipient(delivery.threadId, "whatsapp", message.target.to); await sendWhatsAppMessage( { to: message.target.to, body: message.payload.body }, { key: delivery.runId }, ); }, ], [ "email", async (delivery: ChannelDestinationDelivery) => { const message = emailDeliverySchema.parse(delivery); await assertAuthorizedRecipient(delivery.threadId, "email", message.target.to); await sendEmail( { to: message.target.to, subject: message.payload.subject, body: message.payload.body, }, { key: delivery.runId }, ); }, ], ]); export const channel = composeRoutedChannel(emailSource, destinations); ``` The map is copied at construction and is the complete adapter allowlist. Unknown destination names fail the durable outbox attempt instead of silently dropping or guessing a route. Recipient and operation policy remains application-owned; `assertAuthorizedRecipient` represents a lookup against trusted workflow or tenant data, not another check of the recipient's string shape. ## 3. Let LangGraph run the workflow and choose WhatsApp LangGraph passes `config.writer` to the node. The graph declares data; the destination callback performs the external side effect only after the run settles. ```ts import { Annotation, END, interrupt, START, StateGraph, type LangGraphRunnableConfig, } from "@langchain/langgraph"; import { declareChannelDestinationDelivery } from "@skein-js/channels"; const State = Annotation.Root({ source: Annotation<"email">, refundId: Annotation, customerEmail: Annotation, orderId: Annotation, amountKes: Annotation, reason: Annotation, financeWhatsapp: Annotation, financeDecision: Annotation<"approve" | "reject" | undefined>, }); function notifyFinance(state: typeof State.State, config: LangGraphRunnableConfig) { declareChannelDestinationDelivery(config.writer, { destination: "whatsapp", target: { to: state.financeWhatsapp }, payload: { body: `Approve KES ${state.amountKes.toLocaleString()} refund for ${state.orderId}? ` + `Reason: ${state.reason}. Reply APPROVE or REJECT.`, }, }); return {}; } function awaitFinance(state: typeof State.State) { const decision = interrupt({ kind: "refund-approval", refundId: state.refundId, assignedTo: state.financeWhatsapp, }); return { financeDecision: decision as "approve" | "reject" }; } function emailCustomer(state: typeof State.State, config: LangGraphRunnableConfig) { const approved = state.financeDecision === "approve"; declareChannelDestinationDelivery(config.writer, { destination: "email", target: { to: state.customerEmail }, payload: { subject: approved ? `Refund approved for ${state.orderId}` : `Refund update for ${state.orderId}`, body: approved ? `Finance approved your KES ${state.amountKes.toLocaleString()} refund.` : "Finance could not approve this refund. Our support team will contact you.", }, }); return {}; } export const graph = new StateGraph(State) .addNode("notify-finance", notifyFinance) .addNode("await-finance", awaitFinance) .addNode("email-customer", emailCustomer) .addEdge(START, "notify-finance") .addEdge("notify-finance", "await-finance") .addEdge("await-finance", "email-customer") .addEdge("email-customer", END) .compile(); ``` On the first run, `notify-finance` supplies the durable WhatsApp delivery and `await-finance` parks the thread. The WhatsApp approval webhook needs its own verified source channel, must address the same trusted workflow thread, and resumes the interrupt: ```ts import { composeRoutedChannel, type Channel } from "@skein-js/channels"; import { z } from "zod"; import { destinations } from "./destinations.js"; import { workflowThreadId } from "./workflow-thread-id.js"; const financeReplySchema = z.object({ messageId: z.string().min(1), refundId: z.string().min(1), interruptId: z.string().min(1), from: z.string().min(1), decision: z.enum(["approve", "reject"]), }); const whatsappSource = { name: "whatsapp-source", verify(request) { if (!verifyWhatsAppWebhook(request)) return false; const message = financeReplySchema.parse(request.json()); return { identity: `channel:whatsapp:${message.from}` }; }, parseEvent(request) { const message = financeReplySchema.parse(request.json()); return { kind: "event" as const, event: { threadKey: message.refundId, threadId: workflowThreadId(message.refundId), idempotencyKey: message.messageId, input: { source: "whatsapp", ...message }, resumeWith: { [message.interruptId]: message.decision }, }, }; }, } satisfies Pick; export const channel = composeRoutedChannel(whatsappSource, destinations); ``` `workflowThreadId` must derive a tenant-scoped ID from trusted workflow data, and both sources must use the same function. Derive the Finance principal from the verified WhatsApp sender and validate that the principal is assigned to this approval before accepting the decision. The runnable example performs both checks and keeps the actor, provider event ID, and timestamp in graph state as an audit trail. Only `declareChannelDestinationDelivery` triggers a routed destination. Ordinary `replyWith` output and inferred AI replies are ignored here, preventing an accidental chat response from becoming an external action. `target` and `payload` must be JSON-persistable; the destination validates their provider-specific shape. ## 4. Configure the workflow's source routes ```jsonc { "graphs": { "relay": "./src/relay-graph.ts:graph" }, "skein": { "channels": { "email": { "path": "./src/email-channel.ts:channel", "assistant": "relay", "public_url": "https://api.example.com", }, "whatsapp": { "path": "./src/whatsapp-channel.ts:channel", "assistant": "relay", "public_url": "https://api.example.com", }, }, }, } ``` Configured route keys and explicit source names must be unique. Skein rejects those collisions at boot so a delivery alias cannot resolve to the wrong channel. That does not namespace an explicit `threadId`: this recipe intentionally shares one between email and WhatsApp. Build explicit IDs from trusted, tenant-scoped workflow identifiers; when `threadId` is omitted, Skein safely namespaces the derived ID by channel name instead. The complete workflow is: ```text Customer email → LangGraph → Finance WhatsApp → interrupt/resume → LangGraph → customer email ``` For conditional email/WhatsApp routing plus parallel LangGraph `interrupt()` approvals, run [`examples/decoupled-delivery`](https://github.com/skein-js/skein-js/tree/main/examples/decoupled-delivery). --- # Memory Remembering things across threads and sessions. ## Long-term memory (`getStore()`) The store is injected into every run as a LangGraph `BaseStore`, so a node reads and writes it the native way — and the backend swaps (in-memory under `skein dev`, Postgres in production) with no code change. ```ts import { getStore } from "@langchain/langgraph"; async function remember(state, config) { const store = getStore(); const userId = config.configurable.langgraph_auth_user_id ?? "anon"; await store.put(["memories", userId], "prefs", { units: "metric" }); const hits = await store.search(["memories", userId], { query: "units" }); return { known: hits.map((h) => h.value) }; } ``` The same items are reachable over the `/store/items` HTTP endpoints. Working versions: [`chat-app`](https://github.com/skein-js/skein-js/tree/main/examples/chat-app) (recalls a user across sessions) and [`triage-agent`](https://github.com/skein-js/skein-js/tree/main/examples/triage-agent) (reads your conventions back into the prompt). > [!WARNING] > **Read this before writing a dedup rule** > > Both `storage-memory` and Postgres **without** `store.index` return `score: 1` for every text hit — so > the obvious "score >= 0.9 means duplicate" rule classifies everything as a duplicate and the agent > **silently stops recording memories**. It does not error. [memory.md](../memory.md) covers this and the > other shapes that bite. ## Rank by meaning, not substring On Postgres, configure an embedder and `store.search({ query })` uses pgvector. In-memory falls back to a naive scan, so dev behaviour matches. ```jsonc // langgraph.json { "store": { "index": { "embed": "openai:text-embedding-3-small", "dims": 1536, "fields": ["$"] } }, } ``` `embed` takes a `provider:model` string or a function path; `dims` is required with it. ## Expire what you don't need `store.ttl` (minutes) expires items on a background sweep, with `refresh_on_read` to keep active ones alive. Threads have their own `checkpointer.ttl`. Both in [storage.md](../storage.md#store-item-ttl). ## Bring your own store Long-term memory is the one repo you can swap without implementing the other five — point `store.adapter` at a LangGraph `BaseStore` (including `PostgresStore`, which brings hybrid text+vector search skein's own driver lacks) or a skein `StoreRepo`. Details: [storage.md](../storage.md#bringing-your-own-store-storeadapter). --- # Authenticating requests Keep the login flow you already have. Skein only needs the server-side step that turns an incoming request into a stable principal. Its `auth.path` uses LangGraph's `Auth` class, so the adapter is one function: ```text cookie or bearer token → your provider's server SDK → { identity, permissions } ``` The examples below were checked against the providers' official server documentation on 2026-09-17 (Better Auth 1.7, Auth.js `next-auth` 5.0 beta 32, `@clerk/backend` 3.16, `@supabase/supabase-js` 2.116, `firebase-admin` 14.3, and `jose` 6.2). Provider APIs move independently of Skein, so follow the linked provider documentation if your installed major differs. | If your app already uses… | Start here | | ----------------------------------------------- | ------------------------------------------------------------------ | | Better Auth sessions | [Better Auth](#better-auth) — pass Skein's request headers through | | Auth.js / NextAuth sessions | [Auth.js](#authjs--nextauth) — wrap your session reader | | Clerk users and organizations | [Clerk](#clerk) — authenticate the whole request | | Supabase Auth | [Supabase](#supabase-auth) — verify its access token | | Firebase Auth | [Firebase](#firebase-auth) — verify its ID token | | Auth0, Okta, Cognito, WorkOS, or another issuer | [OIDC/JWT](#auth0-okta-cognito-workos-or-another-oidc-issuer) | Nothing here replaces your sign-in page, callbacks, or session storage. Those stay with the provider you already chose; this page is only the bridge at the API boundary. ## Wire the provider into Skein Choose one `authenticate-request.ts` implementation below, then apply your authorization policy in `auth.ts`. The Better Auth section shows a co-located version instead, because its adapter must reuse the exact Better Auth instance your application already exports. ```ts // auth.ts import { Auth } from "@langchain/langgraph-sdk/auth"; import { authenticateRequest } from "./authenticate-request.js"; const tenantLabel = (identity: string) => encodeURIComponent(identity).replace(/\./g, "%2E").replace(/\*/g, "%2A"); export const auth = new Auth() .authenticate(authenticateRequest) // A filter hides other owners' rows on reads and stamps ownership onto writes. Runs authorize // through their thread, and crons fall back to this handler when no crons handler is registered. .on("threads", ({ user }) => ({ owner: user.identity })) // Store items have no metadata to filter. Root their namespace instead. .on("store", ({ user, value }) => { value.namespace = [tenantLabel(user.identity), ...(value.namespace ?? []).slice(1)]; }); ``` Point `langgraph.json` at that export: ```jsonc { "auth": { "path": "./src/auth.ts:auth", "disable_studio_auth": true }, } ``` Set `disable_studio_auth` to `false` only when you intentionally want LangGraph Studio traffic to bypass your provider during development. Authentication answers _who is calling_; the `.on(...)` handlers answer _what they may access_. Keep organization membership, roles, and other application policy in those handlers rather than in Skein config. Bearer-token providers can share this small helper: ```ts // auth-helpers.ts import { HTTPException } from "@langchain/langgraph-sdk/auth"; export function requireBearerToken(request: Request): string { const match = request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i); if (!match?.[1]) throw new HTTPException(401, { message: "Missing bearer token" }); return match[1]; } ``` ## Better Auth Better Auth's server API accepts the same `Headers` object Skein gives the authentication callback. This works with its normal session cookie; it also accepts `Authorization: Bearer ...` when you have enabled Better Auth's [Bearer plugin](https://better-auth.com/docs/plugins/bearer). ```ts // src/auth.ts — add `skeinAuth` beside the Better Auth instance your app already uses import { Auth as LangGraphAuth, HTTPException } from "@langchain/langgraph-sdk/auth"; import { betterAuth } from "better-auth"; export const auth = betterAuth({ // Keep your existing database, providers, plugins, and session options here. }); const tenantLabel = (identity: string) => encodeURIComponent(identity).replace(/\./g, "%2E").replace(/\*/g, "%2A"); export const skeinAuth = new LangGraphAuth() .authenticate(async (request) => { const session = await auth.api.getSession({ headers: request.headers }); if (!session) throw new HTTPException(401, { message: "Unauthorized" }); return { identity: session.user.id, display_name: session.user.name, email: session.user.email, permissions: [], }; }) .on("threads", ({ user }) => ({ owner: user.identity })) .on("store", ({ user, value }) => { value.namespace = [tenantLabel(user.identity), ...(value.namespace ?? []).slice(1)]; }); ``` This reuses your existing Better Auth instance and session store; do not create a second auth database for Skein. Let Better Auth's own client and endpoints continue to handle login, logout, and cookie refresh—Skein only reads the session presented on each request. See Better Auth's [server-side session API](https://better-auth.com/docs/basic-usage#get-session). For this variant, point `langgraph.json` at `"./src/auth.ts:skeinAuth"` so it does not confuse the Better Auth instance with LangGraph's `Auth` instance. ## Clerk Use Clerk's request-level verifier rather than decoding the session JWT yourself. Set `authorizedParties` to the origins that are allowed to send Clerk credentials to this server. ```ts // authenticate-request.ts import { createClerkClient } from "@clerk/backend"; import { HTTPException } from "@langchain/langgraph-sdk/auth"; const clerk = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY, publishableKey: process.env.CLERK_PUBLISHABLE_KEY, }); export async function authenticateRequest(request: Request) { const requestState = await clerk.authenticateRequest(request, { authorizedParties: [process.env.APP_ORIGIN!], }); if (!requestState.isAuthenticated) { throw new HTTPException(401, { message: "Unauthorized" }); } const principal = requestState.toAuth(); if (!principal.userId) throw new HTTPException(401, { message: "Unauthorized" }); return { identity: principal.userId, org_id: principal.orgId, permissions: principal.orgPermissions ?? [], }; } ``` Clerk documents the full [`authenticateRequest()` contract](https://clerk.com/docs/reference/backend/authenticate-request), including networkless verification with `CLERK_JWT_KEY` and separate machine-token modes. ## Supabase Auth For an API server, accept the user's Supabase access token as a bearer token and verify its claims. `getClaims(token)` uses the project's cached JWKS when possible; unlike `getSession()`, it does not trust client-side session storage. ```ts // authenticate-request.ts import { HTTPException } from "@langchain/langgraph-sdk/auth"; import { createClient } from "@supabase/supabase-js"; import { requireBearerToken } from "./auth-helpers.js"; const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!); export async function authenticateRequest(request: Request) { const { data, error } = await supabase.auth.getClaims(requireBearerToken(request)); if (error || !data || typeof data.claims.sub !== "string") { throw new HTTPException(401, { message: "Unauthorized" }); } const scope = data.claims.scope; return { identity: data.claims.sub, permissions: typeof scope === "string" ? scope.split(" ").filter(Boolean) : [], }; } ``` See Supabase's [`getClaims()` reference](https://supabase.com/docs/reference/javascript/auth-getclaims). If your project still uses an HS256 signing secret, Supabase may call its Auth server to verify each token; do not decode the JWT without verifying it. ## Firebase Auth The browser obtains a Firebase **ID token** after sign-in and sends it as a bearer token. Verify that ID token with the Admin SDK; do not send or accept a Firebase custom token here. ```ts // authenticate-request.ts import { applicationDefault, getApps, initializeApp } from "firebase-admin/app"; import { getAuth } from "firebase-admin/auth"; import { HTTPException } from "@langchain/langgraph-sdk/auth"; import { requireBearerToken } from "./auth-helpers.js"; const firebaseApp = getApps()[0] ?? initializeApp({ credential: applicationDefault() }); const firebaseAuth = getAuth(firebaseApp); export async function authenticateRequest(request: Request) { try { const token = await firebaseAuth.verifyIdToken(requireBearerToken(request)); return { identity: token.uid, permissions: [] }; } catch { throw new HTTPException(401, { message: "Unauthorized" }); } } ``` Firebase's [ID-token verification guide](https://firebase.google.com/docs/auth/admin/verify-id-tokens) explains service-account setup and the optional revocation check. Map only custom claims you created for authorization; do not turn every JWT claim into a permission. ## Auth0, Okta, Cognito, WorkOS, or another OIDC issuer For a standards-based issuer, verify the access-token signature, issuer, audience, and expiry from its JWKS. The `jose` package works across Node, Bun, and Deno: ```ts // authenticate-request.ts import { HTTPException } from "@langchain/langgraph-sdk/auth"; import { createRemoteJWKSet, errors, jwtVerify } from "jose"; import { requireBearerToken } from "./auth-helpers.js"; const issuer = process.env.OIDC_ISSUER!; // exact `iss` const audience = process.env.OIDC_AUDIENCE!; // this Skein API's identifier const issuerBase = issuer.endsWith("/") ? issuer : `${issuer}/`; const jwks = createRemoteJWKSet(new URL(".well-known/jwks.json", issuerBase)); export async function authenticateRequest(request: Request) { try { const { payload } = await jwtVerify(requireBearerToken(request), jwks, { issuer, audience, }); if (!payload.sub) throw new HTTPException(401, { message: "Unauthorized" }); return { identity: payload.sub, permissions: typeof payload.scope === "string" ? payload.scope.split(" ").filter(Boolean) : [], }; } catch (error) { if (error instanceof HTTPException) throw error; if (error instanceof errors.JOSEError) { throw new HTTPException(401, { message: "Unauthorized" }); } throw error; } } ``` Use an **access token whose audience is this API**, not an ID token meant for a browser client. Auth0, for example, makes that distinction explicit in its [token guide](https://auth0.com/docs/secure/tokens). Some issuers publish a discovery document at `/.well-known/openid-configuration`; use its `jwks_uri` when it differs from the conventional path above. ## Authorization: decide what callers can access Once a provider has proved who the caller is, stop thinking about Clerk, Firebase, or JWTs. Every provider now reaches the same LangGraph authorization handlers with a normalized `user`: - `user.identity` is the stable user ID. - `user.permissions` is the trusted permission list you returned during authentication. - Extra verified fields such as Clerk's `org_id` remain available on `user`. An authorization handler has three useful answers: | Return value | Meaning | | ---------------------------- | ----------------------------------------------------------------------- | | `false` | Deny with `403` | | `true`, `null`, or no return | Allow without an ownership filter | | `{ owner: user.identity }` | Allow and scope the resource; the filter also stamps newly created rows | | Rewrite `value.namespace` | Scope long-term store access | Handlers match from most specific to broadest: `threads:delete` → `threads` → `*:delete` → `*`. That lets one sensitive action be stricter without repeating the policy for every route. ### A practical per-user policy This is a good default for an app where every person owns their own conversations: ```ts export const auth = new Auth() .authenticate(authenticateRequest) .on("threads:delete", ({ user }) => user.permissions.includes("threads:delete") ? { owner: user.identity } : false, ) .on("threads", ({ user }) => user.permissions.includes("skein:admin") ? true : { owner: user.identity }, ) .on("assistants", ({ user }) => user.permissions.includes("assistants:read")) .on("store", ({ user, value }) => { value.namespace = [tenantLabel(user.identity), ...(value.namespace ?? []).slice(1)]; }); ``` Here an administrator can see all threads, ordinary users see only their own, deletion needs an additional permission, and assistant discovery is explicitly gated. Returning an ownership filter for `threads:delete` is important: returning only `true` would let any caller with that permission delete any user's thread. Thread policy automatically covers runs because runs belong to a thread. Crons have their own resource, but fall back to the `threads` handler when you do not register a `crons` handler. Add an explicit handler when scheduling is more privileged than chatting: ```ts auth.on("crons", ({ user }) => user.permissions.includes("crons:manage") ? { owner: user.identity } : false, ); ``` ### Share data inside an organization If threads belong to a Clerk organization, Auth0 organization, or your own workspace rather than one person, authenticate the organization ID as a trusted field and filter on it consistently: ```ts const organizationId = (user: { org_id?: unknown }) => typeof user.org_id === "string" ? user.org_id : undefined; auth .on("threads", ({ user }) => { const orgId = organizationId(user); return orgId ? { org_id: orgId } : false; }) .on("store", ({ user, value }) => { const orgId = organizationId(user); if (!orgId) throw new HTTPException(403, { message: "Choose an organization" }); value.namespace = [tenantLabel(orgId), ...(value.namespace ?? []).slice(1)]; }); ``` Do not accept `org_id`, `owner`, or permissions from request metadata or graph input. They must come from provider-verified claims. Decide explicitly what a user with no active organization should do; the example denies access instead of silently falling back to a personal tenant. ### Know which resources can be filtered | Resource | What authorization can do | | ------------ | --------------------------------------------------------------------------------------- | | `threads` | Deny or return metadata filters; the same policy protects their runs | | `crons` | Deny or return metadata filters; falls back to `threads` when no cron handler exists | | `assistants` | Gate access only; graph-backed assistants are shared, so ownership filters do not apply | | `store` | Rewrite `value.namespace`; returning a metadata filter does not isolate store items | The last row is the easy one to miss: authentication alone does **not** make long-term memory multi-tenant. Keep the namespace rewrite even if every other resource is owner-filtered. Calls to `getStore()` from inside a graph do not pass through HTTP authorization, so build their namespace from `config.configurable.langgraph_auth_user_id` (or another server-injected verified field), never from model output. ## Auth.js / NextAuth Auth.js already knows how to verify its own session. You only need a small wrapper that accepts two functions: - `readVerifiedSession(request)` returns your provider's verified session, or `null`. - `toPrincipal(session)` returns the user shape Skein needs. `identity` must be a stable user ID; everything else is application-defined context for authorization handlers. The wrapper returns exactly the function accepted by `.authenticate(...)`: `(request: Request) => Promise`. ```ts import { HTTPException } from "@langchain/langgraph-sdk/auth"; type SkeinPrincipal = { identity: string; permissions: string[]; [attribute: string]: unknown; }; type ReadVerifiedSession = (request: Request) => Promise; type ToPrincipal = (session: TSession) => SkeinPrincipal; function createSessionAuthenticator( readVerifiedSession: ReadVerifiedSession, toPrincipal: ToPrincipal, ): (request: Request) => Promise { return async (request) => { const session = await readVerifiedSession(request); if (!session) throw new HTTPException(401, { message: "Unauthorized" }); const principal = toPrincipal(session); if (!principal.identity) throw new HTTPException(401, { message: "Unauthorized" }); return principal; }; } ``` For example, in the same module where a Next.js app already configures Auth.js, use the exported `auth()` session reader and map your session fields: ```ts import { Auth as LangGraphAuth } from "@langchain/langgraph-sdk/auth"; import NextAuth, { type DefaultSession } from "next-auth"; import GitHub from "next-auth/providers/github"; declare module "next-auth" { interface Session { user: { id: string; permissions?: string[]; } & DefaultSession["user"]; } } const { auth: readAuthJsSession, handlers } = NextAuth({ providers: [GitHub], callbacks: { session({ session, token }) { // This example uses Auth.js's JWT session strategy. For database sessions, use `user.id`. if (!token.sub) throw new Error("Auth.js token is missing a user ID"); session.user.id = token.sub; return session; }, }, }); export { handlers }; export const skeinAuth = new LangGraphAuth() .authenticate( createSessionAuthenticator( async () => readAuthJsSession(), (session) => ({ identity: session.user.id, display_name: session.user.name, email: session.user.email, permissions: session.user.permissions ?? [], }), ), ) .on("threads", ({ user }) => ({ owner: user.identity })); ``` This is intentionally code you own: change only the session reader and field mapping when your framework or session shape differs. Auth.js documents how to expose a stable user ID for [JWT and database sessions](https://authjs.dev/guides/extending-the-session) and how to keep custom session fields type-safe with [module augmentation](https://authjs.dev/getting-started/typescript). If Skein runs as a separate server, the same wrapper still applies, but `readVerifiedSession(request)` should call a trusted session-validation endpoint. Alternatively, exchange the application session for a short-lived signed access token and use the [OIDC/JWT recipe](#auth0-okta-cognito-workos-or-another-oidc-issuer). Do not feed an Auth.js database-session cookie to a JWT decoder: that cookie is a lookup key, not a JWT. ## Browser cookies and CORS Same-origin cookies need no CORS configuration. Across origins, all three pieces must agree: 1. The browser request includes credentials. 2. The auth provider issued a cookie valid for the Skein origin/domain and appropriate `SameSite` and `Secure` attributes. 3. Skein allows the exact frontend origin and credentials—never `*` with credentials: ```jsonc { "http": { "cors": { "allow_origins": ["https://app.example.com"], "allow_credentials": true, }, }, } ``` Bearer tokens do not require credentialed cookies, but the browser still needs the `authorization` header allowed by its CORS preflight. Prefer same-origin mounting when your framework already hosts the frontend. ## What Skein does after authentication The returned object becomes `user` in every `.on(...)` callback. Skein also stamps it into graph run config as `langgraph_auth_user`, `langgraph_auth_user_id`, and `langgraph_auth_permissions`; clients cannot spoof those keys. Thread filters apply to runs as well. Long-term store access is separate and must be rooted by namespace, as in the shared policy above. For the full request lifecycle, route-to-permission map, and store-scoping traps, see [Authentication + authorization](../agent-protocol.md#authentication--authorization). --- # Production Locking it down, being told when work finishes, and running it durably. ## Custom auth The server is open by default. skein implements [LangGraph's custom-auth model](https://docs.langchain.com/langsmith/custom-auth), so an existing `Auth` file is drop-in. Using Better Auth, Clerk, Supabase, Firebase, Auth0, or another OIDC provider? Start with the [authentication provider recipes](./authentication.md). ```ts // auth.ts import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth"; export const auth = new Auth() .authenticate(async (request) => { const token = request.headers.get("authorization")?.replace(/^Bearer /, ""); const user = token ? await verify(token) : undefined; if (!user) throw new HTTPException(401, { message: "Unauthorized" }); return { identity: user.id, permissions: user.scopes }; }) .on("threads", ({ user }) => ({ owner: user.identity })); // scopes threads and their runs ``` Wire it with `"auth": { "path": "./auth.ts:auth" }` in `langgraph.json`, or in code as `embedInMemoryGraphs(graphs, { auth })` — note the second argument **is** the overrides bag here, while `embedPostgresGraphs` nests them under `overrides`. A returned filter both hides other owners' rows and stamps ownership onto new ones. **Inside a graph**, the principal arrives on the run config as `config.configurable.langgraph_auth_user`, `…_user_id` and `…_permissions` — server-owned and unspoofable, present only when auth is configured. > [!WARNING] > **The store is not scoped by an ownership filter** > > A store item carries no metadata to filter on, so **an authenticated caller can read every tenant's > items** unless an `@auth.on.store` handler narrows it. Scope it by rewriting `value.namespace` — the > pattern and its traps are in [agent-protocol.md](../agent-protocol.md#scoping-the-store). Full route → permission map: [agent-protocol.md](../agent-protocol.md#authentication--authorization). ## Get notified when a run finishes Pass a `webhook` URL on run creation; skein POSTs the settled run to it. ```ts await client.runs.create(threadId, "agent", { input, webhook: "https://example.com/hooks/run" }); ``` **The callback is recorded in the same transaction as the run's terminal status**, so a crash between "the run finished" and "someone was told" cannot lose it. skein attempts the first delivery inline, so a healthy receiver hears within milliseconds; a failure is recorded and retried — for about 34 minutes by default, which rides out a rolling deploy. **It is at-least-once, not exactly-once.** A retry can duplicate a callback your receiver already processed — after a network timeout that actually succeeded, say. Every attempt carries the same `X-Skein-Delivery-Id`; dedupe on it. The API remains the source of truth for run state. Set `SKEIN_WEBHOOK_SECRET` and every callback is signed, so a receiver can tell a real one from anybody who guessed the URL: ```ts import { verifySkeinSignature } from "@skein-js/agent-protocol"; const result = verifySkeinSignature({ header: req.headers["x-skein-signature"], body: rawBody, // the RAW bytes, not JSON.stringify(req.body) secrets: [process.env.SKEIN_WEBHOOK_SECRET!], }); if (!result.ok) return res.status(401).end(); ``` Working receiver: `src/webhook-receiver.ts` in [`examples/express-basic`](https://github.com/skein-js/skein-js/tree/main/examples/express-basic) (`pnpm webhook-receiver`) — accepts a genuine callback, refuses a forged and a replayed one. **Run Redis in production**: without it a retry still waiting is lost on exit (the delivery is not). Details — the retry tiers, signature verification and key rotation, listing and replaying failed deliveries, and what gets stored and for how long: [webhooks.md](../webhooks.md). ## Go durable and scale out Back skein with Postgres (state + checkpoints) and Redis (queue + cross-instance streaming). Your server code doesn't change — only how `deps` is built. ```ts import { buildRuntime } from "@skein-js/runtime"; const rt = await buildRuntime({ configPath: "./langgraph.json", store: "postgres", queue: "redis", }); // pass { deps: rt.deps, channels: rt.channels } to an adapter; call rt.dispose() on shutdown ``` Or use the CLI: `skein build` produces a deployable image, `skein up` brings up app + Postgres + Redis via Compose, and `skein dev --store postgres --queue redis` runs the durable stack locally. Redis is optional for one instance and **required for more than one**. ## Deploy it That image runs anywhere you can run a container. [deploy.md](../deploy.md) covers what every platform needs — Postgres, Redis, the port, the `/ok` probe, pool sizing, SIGTERM draining, SSE through proxies — with guides for [Cloud Run](../deploy-cloud-run.md), [Railway](../deploy-railway.md), [Fly.io](../deploy-fly.md), [Render](../deploy-render.md), [AWS](../deploy-aws.md), [Kubernetes](../deploy-kubernetes.md), [a VPS](../deploy-vps.md), and [what doesn't work on serverless](../deploy-serverless.md). ## Watch it run The [console](../console.md) at `/console` shows threads, live run tails, interrupt approvals, time travel, the store browser and crons — served by your own server, no account and no tunnel. It is **off by default** in production; opt in with `{"http": {"console": true}}`. For tracing and metrics, [observability.md](../observability.md) covers the `TelemetrySink` seam and the LangSmith, PostHog and OpenTelemetry adapters. --- # Building your own adapter skein-js ships adapters for [Express](https://github.com/skein-js/skein-js/tree/main/packages/server-express), [Fastify](https://github.com/skein-js/skein-js/tree/main/packages/server-fastify), [NestJS](https://github.com/skein-js/skein-js/tree/main/packages/server-nestjs), and [Next.js](https://github.com/skein-js/skein-js/tree/main/packages/server-nextjs) (App + Pages Router). If your stack isn't one of those — a raw Node `http` server, [Hono](https://hono.dev), Koa, an existing app on some other framework — you can write your own adapter in a few dozen lines. This guide shows how; the four shipped adapters are all built exactly this way. ## Why this is easy All of skein-js's protocol logic lives in [`@skein-js/agent-protocol`](https://github.com/skein-js/skein-js/tree/main/packages/agent-protocol) behind a **transport-neutral handler table**. An adapter adds _no protocol logic_ — it only does shape translation: turn your framework's request into a normalized `ProtocolRequest`, call the right handler, and write the returned `ProtocolResponse` back out. The shipped [Express adapter](https://github.com/skein-js/skein-js/tree/main/packages/server-express) is exactly this and nothing more; yours will mirror it. ```text your framework request ──▶ ProtocolRequest ──▶ handler ──▶ ProtocolResponse ──▶ your framework response (Step 3) (Step 2) (Step 4) ``` ## The contract Three types from `@skein-js/agent-protocol` are all you touch: ```ts interface ProtocolRequest { method: string; // "POST" url: string; // absolute URL (path + query) — an auth handler may read it params: Record; // path params, e.g. { thread_id } query: Record; body: unknown; // parsed JSON body headers: Record; // lowercased names } type ProtocolResponse = | { kind: "json"; status: number; body: unknown; headers?: Record } | { kind: "empty"; status: number; headers?: Record } | { kind: "sse"; status: number; events: AsyncIterable; headers?: Record; }; type ProtocolHandler = (req: ProtocolRequest) => Promise; ``` `ProtocolHandlers` is a table of named handlers (`createThread`, `createStreamRun`, `joinRunStream`, `putStoreItem`, …). Each validates the request (with Zod), calls the typed service, and returns a `ProtocolResponse`. You dispatch to them by name. For a worked example, `@skein-js/fetch` is the shortest one to read: a single file over WHATWG `Request`/`Response`, so it shows the whole contract — request mapping, the headers channel, a pull-driven SSE body, and a bounded request read — without a framework's conventions in the way. ## Step 1 — assemble a runtime Build a `ProtocolRuntime` from a `ProtocolDeps` (the injected storage/queue/graph bundle). The easiest way to get production `deps` from a `langgraph.json` is [`@skein-js/runtime`](https://github.com/skein-js/skein-js/tree/main/packages/runtime)'s `buildRuntime`: ```ts import { buildRuntime } from "@skein-js/runtime"; import { resolveProtocolRuntime } from "@skein-js/server-kit"; const assembled = await buildRuntime({ configPath: "./langgraph.json", store: "memory", // or "postgres" queue: "memory", // or "redis" }); const resolved = await resolveProtocolRuntime({ deps: assembled.deps, channels: assembled.channels, }); // resolved.runtime is running; resolved.routes includes optional channel routes. ``` > You can also construct `deps` by hand (your own `SkeinStore`, `RunQueue`, `RunEventBus`, and a > `GraphResolver`) — see [`@skein-js/core`](https://github.com/skein-js/skein-js/tree/main/packages/core) for the interfaces and > [storage.md](./storage.md) / [runs-and-redis.md](./runs-and-redis.md) for the drivers. ## Step 2 — the route table The paths mirror the `@langchain/langgraph-sdk` client exactly (that's the conformance oracle — don't invent your own spelling). Bind each `method + path` to a handler name. The canonical table is exported from `@skein-js/agent-protocol` as `skeinRoutes` (re-exported from `@skein-js/express` too, for back-compat). Mount `resolved.routes` when using `resolveProtocolRuntime`: it starts with that canonical table and appends configured channel routes without changing the core protocol surface. ```ts import { skeinRoutes, copyThreadIdIntoBody, matchSkeinRoute } from "@skein-js/agent-protocol"; // skeinRoutes: { method, path, handler, foldThreadIdIntoBody? }[] // resolved.routes: the same table plus deployment-specific channel bindings // e.g. { method: "post", path: "/threads/:thread_id/runs/stream", // handler: "createStreamRun", foldThreadIdIntoBody: true } // // copyThreadIdIntoBody(request) — the body-fold rule for foldThreadIdIntoBody routes // matchSkeinRoute(method, pathname) — match a catch-all path → { binding, params } // (handy for adapters that dispatch from one route, like the NestJS + Next.js adapters) ``` Three things to get right: - **Order most-specific first** within each method so literals win over params (e.g. `/threads/search` before `/threads/:thread_id`). - **`foldThreadIdIntoBody`** — the SDK addresses a thread-scoped run by its path (`POST /threads/{id}/runs/stream`) but the stateless run handlers read `thread_id` from the body. For those routes, copy the path `thread_id` into the body before dispatch (see the worked example). - **Strip your mount prefix before matching.** `skeinRoutes` paths are anchored at the protocol root, so if your adapter mounts a **catch-all** and matches by hand, the framework hands it the full external path (`/api/threads`) which will never match `^/threads$`. Use `stripBasePath` from `@skein-js/server-kit`, and treat its `null` as "not ours" — pass the request through untouched so the host app's own routes still resolve: ```ts import { stripBasePath } from "@skein-js/server-kit"; const pathname = stripBasePath(url.pathname, mountPrefix); if (pathname === null) return next(); // not under our mount — the host app's problem const match = matchSkeinRoute(method, pathname); ``` Adapters that mount each route **explicitly** (Express's `Router`, Fastify's plugin `prefix`) get this from their router for free and can skip it. Where `mountPrefix` comes from is framework-specific: the NestJS adapter reads Nest's own `app.setGlobalPrefix(...)` via `ApplicationConfig`, while the Next.js adapters take an explicit `basePath` option. ## Step 3 — map your request onto `ProtocolRequest` Pure shape translation. Header names must be **lowercased** (handlers look up `last-event-id`), and array-valued headers flattened to a single value: ```ts function toProtocolRequest(req /* your framework request */, params): ProtocolRequest { return { method: req.method, url: absoluteUrl(req), // e.g. `http://${host}${originalUrl}` — must include the query string params, // from your router match, e.g. { thread_id } query: parsedQuery(req), body: parsedJsonBody(req), headers: lowercasedSingleValueHeaders(req), }; } ``` If you're on Express specifically, `@skein-js/express` exports `toProtocolRequest` so you don't have to write this. ## Step 4 — serialize the `ProtocolResponse` Switch on `response.kind`: - **`json`** — **serialize with `serializeWireJson` from `@skein-js/core`, not your framework's `res.json`.** Bodies may contain LangChain messages (thread state, history, `runs.wait` values) that must be flattened to the wire shape clients expect. Send with `Content-Type: application/json`. - **`headers`** — present on every kind, and **optional to you but not invisible to clients**: this is how the engine returns response metadata it cannot put in the body, such as `x-pagination-total` on assistant search. Set them all before writing the body. Dropping them costs no test failure and no error — the client just silently loses the metadata — so it is worth wiring in from the start. - **`empty`** — just write the status and end. - **`sse`** — set the SSE headers (`SSE_HEADERS`), flush them, then write each string from `response.events` as-is. **Do not re-encode** — the core already produced complete frames (each ends in `\n\n`). When the client disconnects, call the iterator's `return()` so the run's frame subscription is torn down. The [complete adapter](#a-complete-minimal-adapter) below shows this switch assembled. ## Step 5 — handle errors Handlers throw `SkeinHttpError` for client-visible faults (it carries the intended `status`, `message`, and optional `code`/`details`). Anything else is an unexpected `500`. Once SSE headers are flushed you can no longer set a status — just end the stream. `SkeinHttpError` carries `code` and `details` alongside `status`/`message`; forward them when present. Log anything that is _not_ a `SkeinHttpError` — that is a bug, not a client fault. ## Step 6 — worker lifecycle & CORS - **Worker** — `runtime.worker.start()` drains the run queue (background runs). Call `runtime.worker.stop()` on shutdown so in-flight runs drain cleanly. If you wire `createProtocolRuntime` by hand rather than using the shortcut below, pass `{ worker: { maxConcurrency: resolveRunConcurrency(options.worker?.maxConcurrency) } }` — otherwise your adapter silently ignores `--concurrency` and `SKEIN_RUN_CONCURRENCY`, which is exactly the bug `resolveProtocolRuntime` centralizes away. See [run concurrency](./runs-and-redis.md#run-concurrency). - **CORS** — browser clients (Agent Chat UI, React `useStream`) run on a different origin than your server, so you must send `Access-Control-Allow-*` headers (and answer preflight `OPTIONS`) on every route, including the SSE streams. [`@skein-js/server-kit`](https://github.com/skein-js/skein-js/tree/main/packages/server-kit) exports `corsFromHttpConfig` / `toCorsOptions` (the shared, framework-agnostic home; also re-exported from `@skein-js/express`) to derive `cors`-style options from the `langgraph.json` `http.cors` block; on another framework, apply the equivalent middleware. - **Logging** — `resolveProtocolRuntime(options, frameworkLogger?)` takes an optional second argument: your framework's own logger, used only when the caller supplied neither `options.logger` nor `deps.logger`. Pass one **only if your framework owns a logger the host has already configured** (NestJS's `Logger`, `fastify.log`) — then defaulting it on borrows the host's decision, silence included. If it doesn't, pass nothing; a library should not decide on its host's behalf to start writing to stdout. Either way, read the resolved logger back off the result and use _that_ for your transport-fault logging, so the engine and the transport can't disagree about where output goes: ```ts const { runtime, cors, logger } = await resolveProtocolRuntime(options, myFrameworkLogger); // …later, in your error path: sendError(error, res, logger); // the try/catch shape shown in the complete adapter below ``` > **Shortcut:** [`@skein-js/server-kit`](https://github.com/skein-js/skein-js/tree/main/packages/server-kit)'s `resolveProtocolRuntime(options)` > does Steps 1 + the worker lifecycle in one call — resolve `{ config } | { deps }` into a running > runtime (assistants seeded, worker started) plus any CORS from the config and the resolved logger. > It's what the Express, Fastify, NestJS, and Next.js adapters all use. ## A complete minimal adapter A dependency-free adapter over Node's built-in `http` server — no Express, no framework: ```ts import { createServer } from "node:http"; import { SSE_HEADERS } from "@skein-js/agent-protocol"; import { isSkeinHttpError, serializeWireJson } from "@skein-js/core"; import { buildRuntime } from "@skein-js/runtime"; import { resolveProtocolRuntime } from "@skein-js/server-kit"; const assembled = await buildRuntime({ configPath: "./langgraph.json", store: "memory", queue: "memory", }); const { runtime, routes: routeBindings } = await resolveProtocolRuntime({ deps: assembled.deps, channels: assembled.channels, }); // Compile the resolved protocol + channel route patterns to matchers once. const routes = routeBindings.map((r) => ({ ...r, regex: new RegExp("^" + r.path.replace(/:(\w+)/g, "(?<$1>[^/]+)") + "$"), })); const server = createServer(async (req, res) => { try { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const match = routes.find( (r) => r.method === req.method?.toLowerCase() && r.regex.test(url.pathname), ); if (!match) { res.writeHead(404).end(); return; } const params = match.regex.exec(url.pathname)?.groups ?? {}; const body = await readJson(req); // parse the JSON body (omitted for brevity) let protocolRequest = { method: req.method, url: url.href, params, query: Object.fromEntries(url.searchParams), body, headers: req.headers, // already lowercased by Node }; // Thread-scoped run routes: fold the path thread_id into the body. if (match.foldThreadIdIntoBody && params.thread_id) { protocolRequest = { ...protocolRequest, body: { ...(body ?? {}), thread_id: params.thread_id }, }; } const response = await runtime.handlers[match.handler](protocolRequest); if (response.kind === "json") { res.writeHead(response.status, { "content-type": "application/json" }); res.end(serializeWireJson(response.body)); } else if (response.kind === "empty") { res.writeHead(response.status).end(); } else { res.writeHead(response.status, SSE_HEADERS); const it = response.events[Symbol.asyncIterator](); res.on("close", () => void it.return?.(undefined)); for (let n = await it.next(); !n.done; n = await it.next()) res.write(n.value); if (!res.writableEnded) res.end(); } } catch (error) { if (res.headersSent) { if (!res.writableEnded) res.end(); } else if (isSkeinHttpError(error)) { res.writeHead(error.status, { "content-type": "application/json" }); res.end(JSON.stringify({ status: error.status, message: error.message })); } else { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ status: 500, message: "Internal Server Error" })); } } }); server.listen(2024); // On shutdown: await runtime.worker.stop(); server.close(); await assembled.dispose(); ``` Point the official SDK at `http://localhost:2024` and it just works — because the wire format is produced by the same handler table the Express adapter uses. ## Checklist - [ ] Assembled a `ProtocolRuntime`; called `registerGraphAssistants()` and `worker.start()`. - [ ] Bound every route in `resolved.routes`, most-specific-first, with `foldThreadIdIntoBody` honored. - [ ] `ProtocolRequest` has lowercased single-value headers and an absolute `url` (with query). - [ ] JSON responses serialized with `serializeWireJson` (not a plain `JSON.stringify`/`res.json`). - [ ] `response.headers` forwarded on all three kinds (silently lost otherwise — see step 4). - [ ] SSE responses stream frames unmodified, set `SSE_HEADERS`, and tear down on client close. - [ ] `SkeinHttpError` mapped to its status; everything else → `500`; no status changes mid-stream. - [ ] CORS applied for browser clients; `worker.stop()` on shutdown. - [ ] Verified with the real `@langchain/langgraph-sdk` client (see [testing.md](https://github.com/skein-js/skein-js/blob/main/docs/testing.md)). Reference implementation: [`@skein-js/express`](https://github.com/skein-js/skein-js/tree/main/packages/server-express) — [`routes.ts`](https://github.com/skein-js/skein-js/blob/main/packages/server-express/src/routes.ts), [`to-protocol-request.ts`](https://github.com/skein-js/skein-js/blob/main/packages/server-express/src/to-protocol-request.ts), [`send-protocol-response.ts`](https://github.com/skein-js/skein-js/blob/main/packages/server-express/src/send-protocol-response.ts), [`error-response.ts`](https://github.com/skein-js/skein-js/blob/main/packages/server-express/src/error-response.ts). Built an adapter for a framework we don't ship? We'd love a PR — see [CONTRIBUTING.md](https://github.com/skein-js/skein-js/blob/main/CONTRIBUTING.md). --- # Serving the Agent Protocol with your own agent skein-js is built for [LangGraph.js](https://github.com/langchain-ai/langgraphjs), but the protocol engine is not. `@skein-js/agent-protocol` installs with **no graph runtime** — no `@langchain/langgraph`, no `@langchain/langgraph-checkpoint`, in the emitted JavaScript _or_ the generated type declarations. Both are asserted in CI by [`static-imports.test.ts`](https://github.com/skein-js/skein-js/blob/main/packages/test-support/src/static-imports.test.ts), as two separate checks, because they fail independently. So if you have an agent of your own — an AI SDK loop, a Mastra workflow, a hand-rolled tool loop — you can serve the Agent Protocol with it, and every LangGraph client ([`useStream`](./react-sdk.md), Agent Chat UI, LangGraph Studio) works against it unchanged. This page is the runtime seam. [Building your own adapter](./building-an-adapter.md) is the transport seam — one page per seam. ## What you implement The engine drives an **`AgentGraph`**. Two methods are required; the rest are optional, and the split is empirical rather than designed — it is what a measured, working server actually needed. ```ts interface AgentGraph { // REQUIRED — run the agent, yielding one chunk per step. stream( input: unknown, options?: unknown, ): Promise> | AsyncIterable; // REQUIRED — the thread's authoritative state after a run. getState(config: { configurable?: Record }): Promise; // Optional. Each maps to endpoints; see the capability table below. getStateHistory?(config, options?): AsyncIterable; updateState?(config, values, asNode?): Promise<{ configurable?: Record }>; bulkUpdateState?(config, supersteps): Promise<{ configurable?: Record }>; streamEvents?(input, options?): AsyncIterable; invoke?(input, options?): Promise; getGraphAsync?(options?): Promise<{ toJSON(): unknown }>; getSubgraphsAsync?(namespace?, recurse?): AsyncIterable<[string, unknown]>; } ``` A LangGraph `CompiledGraph` satisfies this by construction — that is what keeps the type additive. ### Streaming: the one trick worth knowing `stream` yields whatever you like, but a **`[mode, data]` tuple** is unwrapped into that stream mode. `StreamMode` is a plain string union from `@skein-js/core`; nothing LangGraph participates. So a plain async generator produces correct SSE: ```ts async *stream(input, options) { const threadId = String(options?.configurable?.thread_id ?? ""); for await (const step of myAgent(input)) { yield ["values", step]; // -> event: values } } ``` Anything that is _not_ such a tuple is published as an `updates` payload. ### State: seven fields `getState` returns an `AgentStateSnapshot`. skein projects it onto the wire `ThreadState`, and reads exactly these: | Field | Meaning | | -------------- | ---------------------------------------------------------------------------------------------- | | `values` | current state — what `GET /threads/{id}` mirrors | | `next` | nodes still to run. **Non-empty ⇒ the run is reported `interrupted`** | | `tasks` | `{ id, name, error?, interrupts, result? }` — carries pending interrupts and per-node failures | | `config` | its `configurable` carries the checkpoint coordinates | | `parentConfig` | the parent snapshot's config, if any | | `metadata` | passed through untouched | | `createdAt` | ISO timestamp | Only `values`, `next` and `tasks` are required. `{ values: state, next: [], tasks: [] }` is a valid snapshot for an agent that does not pause. ## Capabilities: what an absent method does An optional method you do not implement is a **handled 422**, not a crash: ```json { "status": 422, "message": "This agent does not implement \"updateState\", which this endpoint requires.", "code": "agent_capability_missing", "details": { "capability": "updateState" } } ``` | Method | Endpoints it serves | | ------------------- | ---------------------------------------- | | `getStateHistory` | `GET`/`POST /threads/{id}/history` | | `updateState` | `POST /threads/{id}/state` (time travel) | | `bulkUpdateState` | `POST /threads` carrying `supersteps` | | `streamEvents` | the `events` stream mode | | `invoke` | `POST /invoke/{graph_id}` | | `getGraphAsync` | `GET /assistants/{id}/graph` | | `getSubgraphsAsync` | `GET /assistants/{id}/subgraphs` | **Why 422 and not 501.** `@langchain/langgraph-sdk`'s `AsyncCaller` retries any status outside `STATUS_NO_RETRY = [400,401,402,403,404,405,406,407,408,409,422]`. A 501 would cost the official client five requests and exponential backoff to learn a fact that cannot change on retry. 422 is in that list, and "the request cannot be processed as sent" is honest: the route exists, the body is fine, this agent cannot serve it. `GET /threads/{id}/state` is deliberately **not** in that table: it falls back to `getState` when `getStateHistory` is absent, so the required tier alone serves it. ## A complete server No `langgraph.json`, no CLI, no `@langchain/*` in your file: ```ts import type { AgentGraph, AgentStateSnapshot } from "@skein-js/agent-protocol"; import { createExpressServer } from "@skein-js/express"; import { embedInMemoryGraphs } from "@skein-js/server-kit"; const stateByThread = new Map(); const threadId = (c?: { configurable?: Record }) => String(c?.configurable?.["thread_id"] ?? ""); const agent: AgentGraph = { stream(input, options) { const id = threadId(options as { configurable?: Record }); return (async function* () { const reply = { messages: [{ role: "ai", content: "hello" }] }; stateByThread.set(id, reply); yield ["values", reply]; })(); }, async getState(config): Promise { return { values: stateByThread.get(threadId(config)) ?? {}, next: [], tasks: [] }; }, }; // Pass a `GraphResolver` — not a graph map. A map's values are LangGraph compiled graphs by type. const deps = embedInMemoryGraphs({ ids: ["chat"], load: async () => agent, schemas: async (id) => ({ [id]: { graph_id: id } }) as never, }); await (await createExpressServer({ deps })).listen(2024); ``` That serves `POST /threads`, `GET /threads/{id}`, `POST /runs/wait`, `POST /runs/stream` (SSE with replay), `GET /threads/{id}/state`, `GET /info`, plus auth, `Idempotency-Key`, crons, the store, and multitask strategies — none of which you implement. `storeBridge`, `ephemeralCheckpointer` and `cloneCheckpoint` on `ProtocolDeps` are all **optional** and runtime-specific; omit them. `checkpointer` is a structural `ThreadCheckpointer` (five methods: `getTuple`, `list`, `put`, `putWrites`, `deleteThread`) — supply a stub if your agent keeps its own state, or a real one if you want thread copy / prune / rollback. ## Interrupts and resume `POST /runs` with a `command` body resumes an interrupted run. The engine does **not** construct a runtime's command type — that would be a runtime import. It hands your agent a branded envelope: ```ts import { isAgentCommand, agentCommandPayload } from "@skein-js/agent-protocol"; stream(input, options) { if (isAgentCommand(input)) { const { resume, update, goto } = agentCommandPayload(input); // …resume your agent with `resume` } } ``` Use `agentCommandPayload` rather than reading fields off the envelope: the wire schema passes unknown fields through, and the payload is the complete, unbranded object. To report a pause, return a snapshot with non-empty `next` (or tasks carrying `interrupts`). ## What you cannot change - **The wire types.** They are `@langchain/langgraph-sdk`'s, deliberately — that is why every LangGraph client works against skein by construction. See [reuse](./reuse.md). - **The run lifecycle** — statuses, multitask strategies, the SSE frame envelope. - **The auth model** — see [the Agent Protocol reference](./agent-protocol.md). Auth is the deployment's concern, not the agent's; a runner never sees it. ## Known limits - **`events` mode is effectively LangChain-only.** Its demux keys on `on_chain_stream`, root `run_id`, and `langsmith:hidden` tags. Your agent may yield `mode: "events"` chunks, but their payload is your contract with your own clients — `useStream` will not render them as token events. - **`useStream` hydration** calls `GET /threads/{id}/state`. Implement `getState` well (it is required anyway) or hydration is empty. - **Time travel** needs `updateState` + `getStateHistory`. Without them, `checkpoint_id` on a run is ignored rather than honoured. ## The reference implementation [`@skein-js/langgraph`](https://github.com/skein-js/skein-js/tree/main/packages/langgraph) is one implementation of this seam, and it imports only `@skein-js/agent-protocol`'s public entry point — no privileged access. If it ever needs an internal, the internal is missing from the public API. Reading it is the fastest way to see what a complete binding looks like. --- # Deploy anywhere **Deploy your LangGraph.js graphs anywhere you can run a container — or just Node.** skein has no `skein deploy` and no control plane — that's the point ([roadmap.md](./roadmap.md) lists it as an explicit non-goal). What it ships instead is an ordinary OCI image: `skein build` bundles your TypeScript graphs to plain JS and produces a Docker image that needs a Postgres, a Redis, and two environment variables. Nothing about it is specific to any host. Production artifacts can run on Node, Bun, or Deno. Node 24 LTS is the default and uses Express. Bun and Deno use the Web-standard `@skein-js/fetch` adapter and their native HTTP servers. Select one in `langgraph.json` or at build time: Node is the graduated production fallback. Bun and Deno are preview targets until each complete clean-image conformance matrix (real SDK, Postgres/Redis, multi-instance streaming, slow clients, telemetry parenting, and PID-1 shutdown) is green; the native launchers themselves are tested. ```json { "skein": { "runtime": { "name": "deno", "version": "2.9.4" } } } ``` ```bash skein build --runtime bun --runtime-version 1.3.14 -t my-agent ``` CLI flags override config. The image pins the official runtime image, imports every graph under that runtime during the build, starts the runtime directly as PID 1, and uses a non-root user. Deno gets explicit network, environment, artifact-read, system, and native-library permissions. Skein cannot make an arbitrary Node-native graph dependency portable; the compatibility probe fails that image build so the dependency can be replaced or isolated before deployment. This page is everything that is true on **every** platform. The per-platform guides are just the dashboard and CLI steps on top of it. ## Pick a platform **Background runs is the deciding column.** A platform that throttles or suspends your container between requests will stall a queued run until the next request wakes it — which is fine for a request/response graph and wrong for anything scheduled or fired-and-forgotten. :::tabs == Cloud Run - **Deploy from** — push image to Artifact Registry - **Postgres + Redis** — Cloud SQL + Memorystore, or any hosted - **Background runs** — ⚠️ needs `--no-cpu-throttling` + `--min-instances=1` - **Scales to zero** — yes — queued runs stall until a request - **Stop-signal window** — 10s default, configurable → [Google Cloud Run guide](./deploy-cloud-run.md) == Railway - **Deploy from** — Dockerfile in repo - **Postgres + Redis** — Railway plugins - **Background runs** — ✅ default - **Scales to zero** — no - **Stop-signal window** — ~30s → [Railway guide](./deploy-railway.md) == Fly.io - **Deploy from** — Dockerfile in repo - **Postgres + Redis** — Fly Postgres / Upstash Redis - **Background runs** — ⚠️ needs `min_machines_running = 1` - **Scales to zero** — yes, if auto-stop is on - **Stop-signal window** — `kill_timeout` (5s default) → [Fly.io guide](./deploy-fly.md) == Render - **Deploy from** — Dockerfile or image - **Postgres + Redis** — Render Postgres + Key Value - **Background runs** — ⚠️ paid instance (free ones spin down) - **Scales to zero** — free tier only - **Stop-signal window** — ~30s → [Render guide](./deploy-render.md) == AWS App Runner - **Deploy from** — push image to ECR - **Postgres + Redis** — RDS + ElastiCache - **Background runs** — ⚠️ CPU throttled between requests - **Scales to zero** — no (min 1 instance) - **Stop-signal window** — ~30s → [AWS App Runner guide](./deploy-aws.md#app-runner) == AWS Fargate - **Deploy from** — push image to ECR - **Postgres + Redis** — RDS + ElastiCache - **Background runs** — ✅ default - **Scales to zero** — no - **Stop-signal window** — `stopTimeout` (30s default) → [AWS ECS Fargate guide](./deploy-aws.md#ecs-fargate) == Kubernetes - **Deploy from** — push image to any registry - **Postgres + Redis** — whatever you run - **Background runs** — ✅ default - **Scales to zero** — no (unless KEDA/Knative) - **Stop-signal window** — `terminationGracePeriodSeconds` 30s → [Kubernetes guide](./deploy-kubernetes.md) == VPS - **Deploy from** — build on the box or pull - **Postgres + Redis** — containers or managed - **Background runs** — ✅ default - **Scales to zero** — no - **Stop-signal window** — `docker stop -t` (10s default) → [VPS / plain Docker guide](./deploy-vps.md) == Serverless - **Deploy from** — n/a — not a container - **Postgres + Redis** — any hosted - **Background runs** — ❌ not supported - **Scales to zero** — yes - **Stop-signal window** — none → [Vercel & serverless guide](./deploy-serverless.md) ::: > **How these were verified.** The image contract itself — port binding, the `/ok` probe, `SIGTERM` > draining in-flight runs to a terminal status, Postgres migrations on boot — was exercised > end-to-end against real Postgres and Redis containers, and the behaviors described below are what > was actually observed. The individual platform guides apply that same contract using each > platform's own documentation; they have not each been deployed for real. If a step is wrong on your > platform, please [open an issue](https://github.com/skein-js/skein-js/issues) — corrections to > these are very welcome. ## What the image already does for you `skein build` produces the image; `skein dockerfile` prints the same Dockerfile if you'd rather commit it and let your platform build it. Either way: - **Binds the port the platform gives it.** The CMD passes no `--port`, so the server binds `$PORT` when one is injected (Railway, Render, Cloud Run and AWS App Runner all do). When nothing is injected it falls back to **8123** — the same port the image `EXPOSE`s and health-checks — so a bare `docker run -p 8123:8123` works, as do platforms that route to a port you declare without setting `PORT` for you (Fly.io, ECS, Kubernetes). - **Handles `SIGTERM` properly.** The selected runtime is PID 1 (the CMD invokes it directly, not through `npx` — under `npx`, PID 1 is npm, which exits on `SIGTERM` without waiting for the server). On signal, skein stops accepting queued runs, gives in-flight runs [a grace window](#graceful-shutdown) to finish, aborts whatever is left so it lands in a **terminal** status rather than stranded as `running`, then closes the pools. - **Serves a health probe** at `GET /ok` → `200 {"ok":true}`, and declares a Docker `HEALTHCHECK` against it with a 20s start period. - **Runs unprivileged** as the runtime image's `node`, `bun`, or `deno` user. - **Reads config from the environment only** — `POSTGRES_URI` and `REDIS_URI`. The generated `.dockerignore` excludes `.env*` and `.npmrc*`, so secrets are never baked into a layer. - **Installs pinned production dependencies only.** No vite/tsx, no devDependencies, no runtime TypeScript transform: `skein build` resolved your tsconfig `paths` and workspace aliases once, on the host. Source maps stay on, so stack traces still point at your TypeScript. The pinned set is **derived from the bundle**: every published package your graphs still import after bundling is recorded at the exact version installed on the build host, and `skein build` fails on the host if the two ever disagree. Packages you load **by name at runtime** are the exception a bundler cannot see — declare those under `dependencies` in `langgraph.json` ([what `skein build` bundles](./bundling.md#what-skein-build-inlines-vs-externalizes)). - **Caches dependency installs** via a BuildKit cache mount, and accepts an optional `id=npmrc` build secret for private scoped packages — `skein build --npmrc `, or `docker build --secret id=npmrc,src=$HOME/.npmrc` for the standalone Dockerfile. See [langgraph-cli-compat.md](./langgraph-cli-compat.md). **Migrations run automatically on boot.** There is no `skein migrate` step. On startup skein applies its schema (tracked in a `skein_migrations` table), sets up LangGraph's checkpoint tables, registers one assistant per declared graph, and — because `skein start` warms graphs — imports every graph module. All of that finishes _before_ the server starts listening. Migrations take their own advisory lock, so several instances booting at once during a rolling deploy is safe — the ones that don't win the lock **wait**, then find nothing to apply. That covers **both** schemas, on two separate lock keys: skein's own, and LangGraph's checkpoint tables. The second one is skein's lock around someone else's migration — `PostgresSaver.setup()` in `@langchain/langgraph-checkpoint-postgres` reads its version ledger and then creates types and inserts rows with no exclusion of its own, so concurrent boots against a database with pending migrations collide on `checkpoint_migrations_pkey` or `pg_type`. Separate keys because the two schemas are independent: sharing one would make every boot wait on a migration it does not depend on. It only bites when migrations are genuinely pending — a first deploy, or an upgrade that bumps that package's schema — which is exactly when replicas start together. > **Building on Apple Silicon?** `skein build` doesn't pass `--platform`, so you'll get an arm64 image > that most hosts reject. Export `DOCKER_DEFAULT_PLATFORM=linux/amd64` before building. ## Without Docker A container is the default, not a requirement. `skein build --artifact-only` stops before invoking Docker and writes `.skein/build` — your graphs bundled to plain JS, a rewritten `langgraph.json`, the baked `schemas.json`, and a `package.json` whose dependencies are **exact-pinned**, so the install is deterministic without a lockfile. Ship that directory to a machine with Node and run it: ```bash skein build --artifact-only # on any machine with Node; no Docker daemon needed rsync -a .skein/build/ server:/srv/skein/ # on the server cd /srv/skein && npm install --omit=dev export POSTGRES_URI=postgres://… REDIS_URI=redis://… npx skein start # or: node node_modules/skein-js/dist/index.js start ``` `skein start` also reads a conventional `.env` from the directory you run it in, as well as one beside the config it loads — so running your own build from the project root (`skein start -c .skein/build/langgraph.json`) picks up that project's `.env` without exporting anything. An artifact never carries a `.env` of its own: it is the Docker build context, and `skein build` drops a file `env` from the config for that reason. The ambient environment still wins over both, which is what the `export` above relies on. `skein start` serves the artifact and is the same entrypoint the image uses — the container's `CMD` is literally `node /app/node_modules/skein-js/dist/index.js start --store postgres --queue redis --host 0.0.0.0`. `skein-js` is pinned into the artifact's own dependencies, so the install above puts the CLI on the box with it. Four differences from the containerised path: - **It binds `127.0.0.1` by default**, where the image passes `--host 0.0.0.0`. That default is right behind a reverse proxy on the same machine and wrong if something else must reach it directly — the image overrides it because a container is already isolated. - **`--store postgres --queue redis` are the defaults**, so a bare `skein start` reaches for both and fails with an actionable error if `POSTGRES_URI` / `REDIS_URI` are missing. There is no in-memory production mode. - **The port is 8123**, not `dev`'s 2024, and `PORT` from the environment wins over the default. - **Bun and Deno need `--runtime`.** The artifact records which runtime it was built for and refuses to start under a different one. Node needs no flag. Everything else on this page still applies — migrations run on boot, `/ok` is the probe, `SIGTERM` drains in-flight runs. What you give up is the pinned base image and the process supervision the platform would have done, which on a VM is a [systemd unit](./deploy-vps.md#run-it-without-docker). ## What every deployment needs ### 1. A Postgres Set `POSTGRES_URI`. It holds protocol resources (assistants, threads, runs, store items) and LangGraph checkpoints. The base schema needs **no extensions**. pgvector is needed **only if you set `store.index` in `langgraph.json`** for semantic search. skein runs `CREATE EXTENSION IF NOT EXISTS vector` on boot, which can only enable an extension the server already has — it cannot install one. If it's missing, boot fails with an error telling you so. | Provider | pgvector available | | ------------------- | ---------------------------------------------------------- | | Cloud SQL (PG 13+) | ✅ (`vector` is a supported extension) | | AWS RDS (PG 15+) | ✅ | | Neon | ✅ | | Supabase | ✅ (enabled by default) | | Render Postgres | ✅ | | Railway | ⚠️ use the **pgvector template**, not the default Postgres | | `postgres:16` image | ❌ — use `pgvector/pgvector:pg16` | ### 2. A Redis Set `REDIS_URI`. skein uses it for the run queue (BullMQ) and for cross-instance stream pub/sub with replay. Configure the instance with `maxmemory-policy noeviction` — BullMQ's job data must not be evicted. **The image requires it, and so does the entrypoint.** Its CMD runs `skein start --store postgres --queue redis` — but those are also `skein start`'s own defaults now, and it _rejects_ `--store memory` / `--queue memory` outright. Overriding the CMD, or running the binary by hand, can no longer produce a production server whose queue is process-local and whose state disappears on restart. The redis queue driver fails the boot if `REDIS_URI` is unset. (Redis is only _optional_ on the in-code embedding path — `embedPostgresGraphs` falls back to an in-memory queue and bus when no Redis URL is given, which keeps state durable but limits you to a single instance. See [embedding.md](./embedding.md#going-to-production).) ### 3. The port Nothing to do if your platform injects `PORT`. If it asks you which port the container listens on, answer **8123**. ### 4. A health probe Point it at **`/ok`**. It's a dependency-free liveness check that deliberately does _not_ touch Postgres or Redis, so a transient database blip can't flap an otherwise healthy instance. It also works as a **startup/readiness probe**: migrations, assistant registration and graph warming all complete before the server binds, so a responding `/ok` genuinely means "fully booted". Budget your startup probe accordingly — the image's own estimate is 20 seconds. ### 5. Auth — read this before you expose it > ⚠️ **skein's auth is off by default, and this is the production path.** With no `auth.path` > configured in `langgraph.json`, every protocol endpoint is open: anyone who can reach the URL can > create threads, run your graphs, and spend your model-provider tokens. Either configure auth (see > [agent-protocol.md](./agent-protocol.md) for the route→permission map) or keep the service private — > behind your platform's authenticated ingress, a VPC, or an authenticating proxy. > > `/ok` is registered ahead of the auth engine, so health probes keep working either way. ## Sizing & tuning ### Connection budget skein opens **three** Postgres pools per instance: one for protocol resources, one for LangGraph's `PostgresSaver`, and one for the per-thread execution claim (see [Scaling past one instance](#scaling-past-one-instance)). All three are capped by `PG_POOL_MAX`, so plan for: ```text max connections ≈ 3 × PG_POOL_MAX × instances ``` Check that against your database's limit — this is the most common way to exhaust a small managed Postgres once autoscaling kicks in. `PG_POOL_MAX=5` is a sane starting point. The execution-claim pool behaves differently from the other two, and it is worth knowing how: a connection is held for the **whole duration** of an executing run, not for the length of a query. So its working size is your run concurrency, not your request rate — and it is sized from `SKEIN_RUN_CONCURRENCY` plus headroom for the inline run modes (`/runs/wait`, `/runs/stream`, `/threads/{id}/stream`, `/threads/{id}/commands`), which execute without consuming a worker slot and are therefore bounded by request arrival rather than by run concurrency. Past that headroom an inline run waits for a free claim connection and then **fails** rather than executing unguarded — the safe direction, since executing without the claim is what interleaves two runs' checkpoint writes. Keep `PG_POOL_MAX` at or above run concurrency (`skein start` warns at boot when it is not), and raise it if you serve heavy concurrent streaming alongside a saturated background worker. ### When the database stops answering `pg` waits for a pool connection **forever** by default, which turns an unreachable database into a **hang** rather than an error: no status code, no log line, just a socket the client eventually abandons. skein applies a 30s `PG_CONNECTION_TIMEOUT_MS` so the fault surfaces. Thirty rather than something tighter, because `pg` uses that one timer for two different waits — the connection handshake **and** waiting for a free client when the pool is already at `PG_POOL_MAX`. A tight bound therefore fails two ordinary situations: a burst of slow-but-working queries against a small pool, and an autosuspended serverless Postgres (Neon, Supabase) waking up, which regularly takes longer than ten seconds and happens on the boot path. Set `PG_CONNECTION_TIMEOUT_MS=0` for `pg`'s original wait-forever behaviour. `PG_STATEMENT_TIMEOUT_MS` bounds a single statement server-side — the last line of defence against one pathological query pinning a pool connection. **On by default at 30s**; `0` disables it. It is per _statement_, not per request, so a legitimately long sequence of quick queries is unaffected; what it catches is one query that is stuck or scanning something it shouldn't. Suggested 15000 on a small instance, 60000 on a large one. It became a default only once the list/search paths were page-bounded and indexed — before that it would have turned slow-but-working queries into errors. If your deployment has a query that genuinely runs longer, raise it or set `0`; a cancelled statement surfaces as a `57014` error naming the statement, rather than as a hang. The shapes that can still take a while, all of them either bounded by you or inherently large: a deep `OFFSET`, an unindexed `values` filter, `POST /store/namespaces` (`SELECT DISTINCT` over the store), store search by text with no `store.index` configured (a whole-table read by design), and anything that walks a very large thread's whole checkpoint history — copying a thread, or a `multitask_strategy: "rollback"` on one. Rollback in particular reports a failure as a warning and continues, so on very large threads either raise the timeout or prefer another strategy. Schema DDL is exempt, and deliberately so: our migrations and the pgvector setup lift the timeout on their own connection, and the checkpointer's `setup()` runs on a separate untimed pool. Index builds are legitimately slow, a cancelled `CREATE INDEX CONCURRENTLY` leaves an _invalid_ index that the retry skips by name (recording the migration as applied while the index goes permanently unused), and a cancelled boot migration is a boot _loop_ rather than a slow boot. Those connections are destroyed rather than returned to the pool, so the lifted timeout can't leak into a later query that happens to reuse them. It is applied with a `SET` on each new connection rather than as a startup parameter, because PgBouncer and Supabase's pooler reject unrecognised startup parameters outright. Under **transaction** pooling a `SET` does not persist, so the timeout silently does not apply there — it is a backstop, not a guarantee. **Full sizing guidance, and every tuning knob in one table, is in [performance.md](./performance.md).** This section covers only what is specific to running the container. ### Heap size vs. the container's memory limit The image bakes **no** `--max-old-space-size`, because a Dockerfile cannot know the limit the container will be given. Instead `skein start` compares the two at boot and warns when V8's ceiling is above ~75% of the container's limit, naming the flag and a computed value. Node **is** cgroup-aware — since v12 it sizes the heap from the container's limit, not the host's — so the usual advice to set `--max-old-space-size` on every container is wrong here, and following it can make things worse. Measured on `node:22-slim`, V8's ceiling tracks about half the limit: 512Mi → 259MB, 1Gi → 524MB, 2Gi → 1048MB. Setting it to "75% of the limit" on a 512Mi container would _raise_ the heap from 259MB to 432MB and make an OOM kill more likely, not less. What the automatic sizing does not do is go below a floor of about **259MB**. So under roughly 345Mi — 256Mi and 128Mi are ordinary Cloud Run and Kubernetes settings — V8's ceiling meets or exceeds the whole container, it never feels the pressure that would trigger a full GC, and the kernel kills the process first. That appears as a restart with no stack, no log line, and nothing in your metrics. **Only act on this when the warning fires**, and then lower the heap rather than raising it. The warning names a computed value. Include the image's own flag when you set it — `NODE_OPTIONS` replaces the image's value rather than adding to it, so omitting `--enable-source-maps` silently costs you TypeScript stack traces: ``` NODE_OPTIONS="--enable-source-maps --max-old-space-size=153" ``` `skein start` also warns when run concurrency exceeds `PG_POOL_MAX`, for the same reason: runs then queue waiting for a connection rather than executing, which looks like flat throughput rather than like a pool limit. Budget **two** pools per instance (store + checkpointer). ### Heap pressure while running Separately from the boot check, skein samples heap usage every 30s and warns once when it passes 85% of the limit — then stays quiet until it drops back below 70%, so a sustained episode is one log line rather than one every 30 seconds. It runs for the life of the background worker and needs a `logger` to be configured; `SKEIN_HEAP_WARN_PERCENT=0` turns it off. The warning carries what makes it actionable, because the percentage alone does not: | What the line shows | What it means | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | | High `runs in flight`, at or near your concurrency | Too much work at once — lower `SKEIN_RUN_CONCURRENCY` or scale out. | | High `buffered frames` | A slow SSE consumer is holding a run's frames in memory — see `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN`, or move to Redis. | | Neither, and the heap stays high across episodes | A genuine leak. Capture a heap snapshot. | On Redis there is no `buffered frames` figure, because the frames are not in this process's heap — which is itself part of the answer. Two limits worth knowing. It watches the **JS heap only**: `used_heap_size` excludes external memory, so a process killed for RSS — large `Buffer`s, many sockets — never trips it. That is the right scope for what skein itself retains (buffered frames, mirrored graph state), but it does not make the boot check above redundant. And the re-arm band sits 15 points under whatever threshold you set, so `SKEIN_HEAP_WARN_PERCENT=60` re-arms at 45%; a value at or below 15 latches after its first warning for the life of the process, which is the safe direction rather than a flood. ### Run concurrency Each instance executes up to **10** queued runs at once. Set `SKEIN_RUN_CONCURRENCY` (or the LangGraph-compatible `N_JOBS_PER_WORKER`) to change it. Every in-flight run draws from both pools above, so `concurrency × instances` is the number to budget against the connection cap. Prefer more instances over higher concurrency when runs are CPU-bound. See [runs-and-redis.md](./runs-and-redis.md#run-concurrency) for head-of-line-blocking behavior. The environment variable is validated even when the flag is also passed, so a typo fails the boot loudly instead of silently reverting to the default. ### TLS to the database A URL with `?sslmode=require` and a real CA chain needs nothing extra — `pg` honors `sslmode`. For a database presenting a self-signed certificate, set `DATABASE_SSL_NO_VERIFY=true`. Over a private network (Railway's `*.railway.internal`, a VPC, a Unix socket) you need neither. If your provider offers a **pooled** connection endpoint (PgBouncer and friends), use the **direct** endpoint instead. Boot migrations take a session-level advisory lock, which transaction-mode pooling does not preserve. ### Graceful shutdown On `SIGTERM` skein stops pulling from the queue, waits `SKEIN_SHUTDOWN_GRACE_MS` (default **5000**) for in-flight runs to finish, then aborts the stragglers so they settle to a terminal status, closes the pools, and exits. If that whole sequence hasn't finished 3 seconds after the grace window, the process force-exits anyway — so the default worst case is ~8s, which fits inside the tightest common kill window. Raise the grace window where the platform allows a longer one, and keep it below what the platform actually grants — past that, you're just being SIGKILLed mid-drain: | Platform | Window between SIGTERM and SIGKILL | | ------------- | ----------------------------------------- | | Cloud Run | 10s default, configurable | | Railway | ~30s | | Fly.io | `kill_timeout` in `fly.toml` (5s default) | | Render | ~30s | | ECS | `stopTimeout` (30s default, 120s max) | | Kubernetes | `terminationGracePeriodSeconds` (30s) | | `docker stop` | `-t` (10s default) | Runs that get aborted are marked terminal, not lost: with Redis, BullMQ's stalled-job recovery re-delivers a job whose worker died, and the worker skips any run already in a terminal state. **Embedding skein in your own server instead of using `skein start`?** You get no signal handling — wire it yourself: `process.on("SIGTERM", …)` → `runtime.worker.stop()` → dispose. Drain first, dispose second; disposing while runs are still draining pulls the store out from under them. ### Cold starts Boot does real work: schema migrations, `PostgresSaver` setup, one get-or-create per declared graph, and eager-loading every graph module. Twenty seconds is a reasonable startup-probe budget. On platforms that scale to zero, this is paid on the first request after an idle period. ## Scaling past one instance With Postgres and Redis, replicas share state and streams — a client can join a run executing on another instance, rolling deploys are safe, **and the run semantics hold across instances**. No session affinity or single-instance restriction is needed for any of it: - **Cancellation** crosses instances. `POST …/runs/{id}/cancel` routed to instance B stops a run executing on instance A: the run row is settled immediately (which is what makes the cancel durable), and the _signal to stop now_ travels over a Redis pub/sub channel to whichever instance is executing it. Delivery is best-effort by design — a dropped message costs promptness, never correctness. - **One-active-run-per-thread** is decided by Postgres, not by a lock in one process: `multitask_strategy: "reject"` is an atomic check-and-insert, so two instances racing the same thread cannot both win. - **`multitask_strategy: "enqueue"`, `"interrupt"` and `"rollback"`** all hold. A run claims its thread with a Postgres **session advisory lock** held for the run's duration, so a queued run waits for the active one wherever that one is running. The displaced run's base checkpoint and the displacing run's rollback plan are persisted on the runs themselves, so any instance can apply them — and a run recovered after a crash still cleans up what it displaced. **Why a Postgres lock and not a Redis lease.** A run holds its claim for its whole execution, which can be minutes. A TTL lease has to be renewed for all of it, and a late renewal — a blocked event loop, a GC pause — expires the lease while the run is still writing, putting two instances on one thread's checkpoint history. A session lock has no TTL: Postgres holds it until the session releases it _or the connection dies_, so a crashed instance frees its threads at once and a slow one keeps them. This is the same split LangGraph Platform makes (Postgres for rows and exclusivity, Redis for ephemeral pub/sub). **Budget for it.** Each _concurrently-executing_ run holds one connection from a dedicated pool for the run's duration — the same trade LangGraph Platform makes. See [Connection budget](#connection-budget). The one remaining caveat is utilization, not correctness: a queued run waiting for a busy thread still occupies a worker slot, so a burst of `enqueue` runs on one thread can crowd out other threads' work. Tracked as per-thread partitioned dispatch on the [roadmap](./roadmap.md). See [runs-and-redis.md](./runs-and-redis.md). ## Streaming through proxies (SSE) skein sends `text/event-stream` with `cache-control: no-cache, no-transform` and flushes headers immediately. Streams are **back-pressured**: a client that reads slowly is paced rather than buffered in the server's memory, so a few hundred slow connections cost a bounded ~65 KB each instead of a full copy of each stream — see [streaming.md](./streaming.md#slow-clients-and-backpressure). Two things it does _not_ do, which matter in front of a proxy: - It sends **no `X-Accel-Buffering: no` header**. A buffering reverse proxy will hold the stream until the run finishes, which looks exactly like a hang. Turn buffering off: nginx `proxy_buffering off;`, ingress-nginx `nginx.ingress.kubernetes.io/proxy-buffering: "off"`, Caddy `flush_interval -1`. Don't put a caching CDN in front of the stream routes. - It sends **no heartbeat frame**. A stream that produces no tokens for a while can be culled by an idle timeout, so raise the proxy's read timeout to cover your longest quiet stretch. There is also **no server-side run timeout** under `skein start` — the engine supports one (`runTimeoutMs`), but no CLI flag or environment variable exposes it, so it is only reachable when you [embed skein in your own server](./embedding.md). With the image, the platform's request timeout is the only ceiling on a streaming run; set it generously (Cloud Run allows up to 60 minutes). Clients reconnect with `Last-Event-ID` and skein replays missed frames from a Redis stream (kept for an hour), so a dropped connection is recoverable. See [streaming.md](./streaming.md). ## Verify a deployment Every platform guide points here. Substitute your service's base URL. ```bash BASE=https://your-service.example.com # 1. Liveness — the same probe your platform uses. curl -s $BASE/ok # {"ok":true} # 2. Your graphs registered as assistants at boot. curl -s -X POST $BASE/assistants/search \ -H 'content-type: application/json' -d '{"limit":10}' # 3. An inline streaming run — exercises SSE and Postgres checkpointing end to end. THREAD=$(curl -s -X POST $BASE/threads -H 'content-type: application/json' -d '{}' \ | node -pe 'JSON.parse(require("fs").readFileSync(0)).thread_id') curl -N -X POST $BASE/threads/$THREAD/runs/stream \ -H 'content-type: application/json' \ -d '{"assistant_id":"agent","input":{"messages":[{"role":"user","content":"hi"}]}}' # 4. A BACKGROUND run — the one that catches CPU-throttling platforms. It returns immediately; # the work happens after the request ends, which is exactly when a throttled instance freezes. RUN=$(curl -s -X POST $BASE/threads/$THREAD/runs \ -H 'content-type: application/json' \ -d '{"assistant_id":"agent","input":{"messages":[{"role":"user","content":"hi"}]}}' \ | node -pe 'JSON.parse(require("fs").readFileSync(0)).run_id') # 5. Join it mid-flight (replayed from Redis), then confirm it finished. curl -N $BASE/threads/$THREAD/runs/$RUN/stream curl -s $BASE/threads/$THREAD/runs/$RUN | node -pe 'JSON.parse(require("fs").readFileSync(0)).status' ``` Step 4 is the one worth actually running. A background run that never leaves `pending`/`running` while the service is idle means the platform is suspending your instance between requests — see that platform's guide. ## Environment variables skein reads these and nothing else. Note there is **no `DATABASE_URL` or `REDIS_URL`** — those are platform names; map them onto skein's. | Variable | Required | Purpose | | ------------------------ | -------------------- | ----------------------------------------------------------------- | | `POSTGRES_URI` | yes (postgres store) | Postgres connection string (resources + checkpoints). | | `REDIS_URI` | yes (redis queue) | Redis connection string (run queue + stream pub/sub). | | `PORT` | usually injected | Port to bind. Defaults to 8123 — the port the image exposes. | | `HOST` | no | Host to bind. The image already passes `--host 0.0.0.0`. | | `DATABASE_SSL_NO_VERIFY` | no | `true` to skip TLS cert verification (self-signed database cert). | Everything else is **tuning**, and lives in one place so the numbers can't drift apart: **[performance.md](./performance.md#every-knob)** has every knob with its default and a suggested value for a small (256–512Mi) and a large (1–4Gi) deployment — run concurrency, the shutdown drain, page and stream bounds, the pool and statement timeouts, the heap monitor, and request logging. Two notes specific to the container: - The in-memory bus knobs (`SKEIN_MEMORY_BUS_*`) are **not** reachable from this image — `skein start` rejects `--queue memory`. They apply on the embedded path (`embedPostgresGraphs` with no `REDIS_URI`) and under `skein dev`; see [embedding.md](./embedding.md#going-to-production). - `PG_POOL_MAX` is per pool and skein opens **three** per instance, so budget three times your setting against the database's own connection cap — per replica. --- # Performance & memory How skein behaves under load, what bounds it, and which knob to reach for when something looks wrong. The short version: **every buffer in skein is bounded, and every bound has a knob.** The defaults are chosen so an ordinary deployment never loses a frame or truncates a page, which means they are not chosen to fit a small container. If you run on 256–512Mi, read [Sizing](#sizing) — a handful of knobs do almost all the work. ## What actually uses memory Four things, in the order they matter: 1. **Run frames in flight.** A streaming run produces frames faster than a client consumes them. Those frames live in the event bus until every subscriber has read them, and under `stream_mode: "values"` each frame is a copy of the whole graph state. This is the biggest and the most variable. 2. **Socket write buffers.** Every SSE connection whose client is slower than the graph. Bounded to the socket's own high-water mark (~64KB) — see [backpressure](#streaming-backpressure-drops-and-recovery). 3. **Rows read by one request.** A thread row carries the thread's mirrored graph state, so a search that returns 1000 threads is 1000 graph states, twice over (the rows, then the serialized response). 4. **The runtime itself.** ~130MB RSS to import an adapter and start a server, dominated by `@langchain/core` and `@langchain/langgraph`. This is a floor, not a variable — see [bundling.md](./bundling.md). Runs themselves are cheap when idle: a queued run is a row, not a process. ## Sizing ### Small: 256–512Mi, many replicas The shape most platforms default to. The defaults do **not** fit here — they are sized so a long, chatty run never loses a frame, and the worst case is roughly `MAX_FRAMES_PER_RUN × (concurrent runs + MAX_RETAINED_RUNS)`. At the defaults that ceiling is half a million frames. ```bash SKEIN_RUN_CONCURRENCY=3 SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN=2000 # only when queue=memory SKEIN_MEMORY_BUS_MAX_RETAINED_RUNS=20 # only when queue=memory SKEIN_STREAM_BUFFER_FRAMES=128 # only when queue=redis SKEIN_MAX_PAGE_SIZE=200 PG_POOL_MAX=5 PG_STATEMENT_TIMEOUT_MS=15000 ``` Worked example, 512Mi with Redis: the runtime floor is ~130MB. Three concurrent runs, each with one SSE subscriber, buffer at most `3 × 128` frames in this process; at ~2KB per frame that is under a megabyte. The store pool holds 5 connections, the checkpointer pool another 5. A `POST /threads/search` returns at most 200 threads. That leaves the bulk of the container for the graph's own working set — which is where you actually want it, and which skein cannot size for you. Below ~345Mi also check [the heap-limit warning](./deploy.md#heap-size-vs-the-containers-memory-limit): Node's automatic heap sizing has a floor and stops adapting there. ### Large: 1–4Gi, high concurrency The defaults are close to right; raise concurrency to match the database. ```bash SKEIN_RUN_CONCURRENCY=25 PG_POOL_MAX=30 # ≥ concurrency, and skein opens THREE pools per instance SKEIN_REDIS_STREAM_MAXLEN=50000 SKEIN_STREAM_BUFFER_FRAMES=2048 PG_STATEMENT_TIMEOUT_MS=60000 ``` **Budget three pools per instance** (store + checkpointer + the per-thread execution claim), so 25 concurrency at `PG_POOL_MAX=30` is 90 connections against your database's cap, per replica. `skein start` warns at boot when concurrency exceeds `PG_POOL_MAX`, because the symptom otherwise is flat throughput with nothing pointing at the pool. The claim pool is the one that makes `PG_POOL_MAX ≥ concurrency` a hard requirement rather than advice: it holds one connection per _executing_ run for that run's whole duration (see [deploy.md](./deploy.md#connection-budget)), so a pool smaller than the concurrency simply caps how many runs can execute at once. ### The one that isn't about size `SKEIN_RUN_TIMEOUT_MS` is off by default and stays off unless you set it. A legitimate agent run takes minutes; a research or multi-step tool graph takes longer. A default here would turn slow-but-working into killed. Set it from your own graphs' worst honest case, not from a round number. ## Every knob Defaults are what skein uses when the variable is unset. "Small" and "Large" are the starting points above, not requirements. ### Runs | Variable | Default | Small | Large | What it bounds | | --------------------------------------------- | ------- | ----- | ----- | ----------------------------------------------------------------- | | `SKEIN_RUN_CONCURRENCY` / `N_JOBS_PER_WORKER` | 10 | 3 | 25 | Queued runs one instance executes at once (`--concurrency`, `-n`) | | `SKEIN_RUN_TIMEOUT_MS` | off | off | off | Abort a run executing longer than this (`--run-timeout`) | | `SKEIN_SHUTDOWN_GRACE_MS` | 5000 | 5000 | 15000 | How long `SIGTERM` lets in-flight runs finish before aborting | | `SKEIN_WEBHOOK_TIMEOUT_MS` | 5000 | 5000 | 5000 | One webhook POST. Sized to the shutdown budget — raise both | ### Streaming | Variable | Default | Small | Large | What it bounds | | ------------------------------------- | ------- | ----- | ----- | --------------------------------------------------------- | | `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN` | 10000 | 2000 | 10000 | Frames one run may buffer — in-memory bus only | | `SKEIN_MEMORY_BUS_MAX_RETAINED_RUNS` | 50 | 20 | 200 | Finished runs still replayable to a late `join` | | `SKEIN_REDIS_STREAM_MAXLEN` | 10000 | 2000 | 50000 | Approximate cap on a run's Redis stream (`0` = TTL only) | | `SKEIN_STREAM_BUFFER_FRAMES` | 512 | 128 | 2048 | Frames one slow subscriber may queue before being dropped | The socket write buffer has no knob: the socket's own high-water mark sets it, and nobody could size a second one sensibly. ### Storage | Variable | Default | Small | Large | What it bounds | | -------------------------- | ---------- | ----- | ----- | ------------------------------------------------------ | | `SKEIN_MAX_PAGE_SIZE` | 1000 | 200 | 1000 | Largest page any list/search returns | | `PG_POOL_MAX` | pg's 10 | 5 | 30 | Connections per pool — skein opens three per instance | | `PG_CONNECTION_TIMEOUT_MS` | 30000 | 30000 | 30000 | Waiting for a free connection (`0` = wait forever) | | `PG_IDLE_TIMEOUT_MS` | pg's 10000 | 10000 | 30000 | How long an unused pooled client is kept | | `PG_STATEMENT_TIMEOUT_MS` | 30000 | 15000 | 60000 | One statement server-side (`0` = off). Not per request | ### Diagnostics | Variable | Default | What it does | | ------------------------- | ------- | ------------------------------------------------------------------ | | `SKEIN_HEAP_WARN_PERCENT` | 85 | Warn above this % of the heap limit (`0` disables the monitor) | | `SKEIN_HEAP_SAMPLE_MS` | 30000 | How often the heap monitor samples | | `SKEIN_REQUEST_LOG` | see doc | A line per HTTP request. On for `skein dev`, off for `skein start` | Most of these can also be set in code — `worker: { maxConcurrency, shutdownGraceMs }` on any adapter, `embedPostgresGraphs`' options, `runTimeoutMs` on the deps, or a store constructor. Where a code path exists, the environment is still read **and validated** even when you pass the option, so a typo fails at boot rather than sitting unnoticed in a deployment that also passes the value. Four are environment-only: `SKEIN_WEBHOOK_TIMEOUT_MS`, `SKEIN_REQUEST_LOG` (or the CLI flag), and the two Redis stream bounds — setting those in code means constructing `RedisRunEventBus` yourself. The webhook timeout and the request-log switch also _fall back_ on a malformed value rather than throwing: both sit on paths where refusing to boot would be the more damaging failure. ## Streaming: backpressure, drops, and recovery **Backpressure.** The SSE write loop waits for the socket to drain before pulling the next frame, so a slow client's unwritten bytes stay in the socket's own buffer (~64KB) instead of accumulating in the process. Measured: a slow consumer's per-connection buffer went from ~1.26MB (the whole stream) to a constant ~67KB; 100 slow streams went from 125.5MB to 6.5MB, with no change to throughput or p99 and no frames lost. The graph does not slow down — the bus decouples it from the socket. **Drops.** Backpressure moves the problem rather than solving it: the frames queue in the bus instead. So the bus is bounded too, and a subscriber that falls behind the bound has its stream **ended** rather than being handed a silent gap. **Recovery.** An ended stream is not a lost run. Reconnect with `Last-Event-ID` and skein replays from that point — from the in-memory buffer, or from the Redis stream, whichever bus you run. The LangGraph SDK's `joinStream` does this for you. What you cannot recover is a frame that has aged out of `SKEIN_REDIS_STREAM_MAXLEN` or been evicted from the in-memory buffer, which is why those bounds are the ones to raise if reconnecting clients see gaps. **Retention after a run ends.** A finished run's frames stay replayable so a late `join` still works — for `SKEIN_MEMORY_BUS_MAX_RETAINED_RUNS` runs on the in-memory bus, or until the stream's TTL on Redis. Beyond that, joining a finished run completes immediately with no frames rather than hanging. See [streaming.md](./streaming.md) for the wire format and [runs-and-redis.md](./runs-and-redis.md) for what a frame costs in Redis. ## Query bounds Every list and search path is bounded — **including when the caller passes no `limit` at all**. Before that, one `POST /threads/search` with an empty body pulled an entire table into the heap and then serialized it into a single response string. - A client-supplied `limit` above 1000 is rejected. - An absent `limit` means the first `SKEIN_MAX_PAGE_SIZE` rows, not all of them. - `POST /threads/{id}/history` returns 100 checkpoints by default, capped at 1000, and pages with `before`. Each element is a checkpoint's whole graph state, so this is bounded harder than a row-based page — and separately from `SKEIN_MAX_PAGE_SIZE`, since history comes from the checkpointer rather than the store. - `GET /threads/{thread_id}/runs` and `POST /store/namespaces` page too, defaulting to **100** rows (`limit`/`offset`; a query `limit` above 1000 is clamped, not rejected). 100 matches what the LangGraph SDK sends for `store.listNamespaces`. `POST /store/namespaces` also applies the driver's `SKEIN_MAX_PAGE_SIZE` bound — it was the one list path that escaped it, and an `offset` with no `limit` used to be ignored outright on Postgres, silently answering from row 0. - A **wildcard** namespace prefix or a `suffix` cannot use the slice-equality form and is matched per-position instead. Neither form is index-backed: `store_items`' only relevant index is its `(namespace, key)` primary key, and Postgres will not derive a range scan from a slice expression, so both were already sequential scans. `filter` is pushed into the `WHERE` clause on both search paths (including the pgvector one), so the page is the top-_n_ of the **matched** set rather than the matched subset of a page. - Assistant search reports the unpaginated match count in `x-pagination-total`. Other collections do not: a total costs a second query, and for those it would be a count over exactly the rows the bound exists to avoid touching. Page them until they return fewer rows than you asked for. With auth configured, the ownership filter is pushed into the driver query, so a tenant's search is an indexed lookup rather than a full read filtered in JS. Details in [storage.md](./storage.md) and [agent-protocol.md](./agent-protocol.md#authentication--authorization). ## Triage: symptom → knob | Symptom | Likely cause | What to do | | -------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Restarts with no stack, no log line, nothing in metrics | OOM kill | Check the boot warning; below ~345Mi see [heap sizing](./deploy.md#heap-size-vs-the-containers-memory-limit). Then lower `SKEIN_RUN_CONCURRENCY` and the bus bounds. | | `heap pressure` warning, high `runs in flight` | Too much work at once | Lower `SKEIN_RUN_CONCURRENCY`, or scale out. | | `heap pressure` warning, high `buffered frames` | A slow SSE consumer | Lower `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN`, or move to Redis so frames live outside the process. | | `heap pressure` warning, neither, across episodes | A genuine leak | Capture a heap snapshot. Please open an issue. | | Throughput flat, adding concurrency does nothing | Runs queuing on the Postgres pool | Raise `PG_POOL_MAX` to ≥ concurrency (×2 for both pools). `skein start` warns about this at boot. | | Requests hang, then fail late | Unreachable or wedged database | `PG_CONNECTION_TIMEOUT_MS` bounds the wait; `PG_STATEMENT_TIMEOUT_MS` bounds one query. | | A query errors with `57014` | Statement timeout | The query is scanning something. Deep `OFFSET`, an unindexed `values` filter, or a very large checkpoint history — see [deploy.md](./deploy.md). Raise the timeout only after checking which. | | Streaming clients see gaps after reconnecting | Frames aged out before the reconnect | Raise `SKEIN_REDIS_STREAM_MAXLEN` (or the memory bus's frame bound). | | A stream ends early under load, on **Redis** | The subscriber's mailbox overflowed | Raise `SKEIN_STREAM_BUFFER_FRAMES`, or fix the consumer. `Last-Event-ID` recovers it from the durable stream. | | A stream ends early under load, on the **in-memory bus** | Frames were evicted out from under the subscriber | Raise `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN`. Reconnecting resumes with a gap — the buffer _is_ the replay log. `SKEIN_STREAM_BUFFER_FRAMES` does nothing here. | | Searches return fewer rows than expected | The page bound | Expected — page with `offset`. Raise `SKEIN_MAX_PAGE_SIZE` only if you know the rows are small. | | One thread's runs all stall behind each other | A run holding the thread lock | Runs on a thread are serialized by design. If it is a hung graph, set `SKEIN_RUN_TIMEOUT_MS`. | | Shutdown takes ~8s and kills in-flight work | The drain window | Raise `SKEIN_SHUTDOWN_GRACE_MS`, and your platform's termination grace with it. | ## Measuring it yourself `packages/bench` boots a real skein server in-process, opens real SSE clients against it, and samples while they stream. It is deliberately not part of `pnpm test` — it takes minutes and needs Docker for the Postgres/Redis driver. ```bash nx build bench && nx bench bench # all scenarios, in-memory drivers, no Docker nx bench bench -- --driver postgres-redis # needs Docker nx bench bench -- --scenario slow-client --streams 500 ``` The measurement that matters is **RSS after idle + a forced GC**, not peak RSS: peak tells you about churn, retained tells you what is actually held. A slow-client run at increasing `--streams` should plateau, not climb — that plateau is the whole point of the bounds above. Alongside it, the counters are deterministic and make a regression provable rather than plausible: buffered frames, Redis commands per frame, socket `writableLength`, iterator pulls, connections opened. Those are integers with no timing in them, which is why they **do** gate CI where throughput and latency cannot. See [testing.md](https://github.com/skein-js/skein-js/blob/main/docs/testing.md) for how the same idea is applied in the unit tests. The run therefore ends in `all bounds hold.` or a non-zero exit listing what no longer does — the per-stream SSE buffer ceiling, the bus's retained-run and per-run frame caps, and that backpressure delayed frames rather than dropping them. CI's `perf bounds (bench)` job runs exactly this on the in-memory driver and publishes the numbers to the run summary without asserting any of them. So if you are reading this page because a bound above stopped holding for you, that job is what should have caught it first; if it was green, the shape you hit is one the scenarios do not cover, and it is worth adding one (see [`packages/bench/README.md`](https://github.com/skein-js/skein-js/blob/main/packages/bench/README.md)). --- # Profiling and performance tuning Skein This guide is the practical bridge between “the server feels slow” and an evidence-backed change. It assumes no profiling experience. ## The four tools and the tuning loop - A **benchmark** tells you how much: latency, throughput, memory, boot time. - A **profiler** tells you where: hot functions, allocation sites, retained objects, garbage collection. - **Telemetry** tells you when and why in production: graph, runtime, queue delay, failures, saturation. - A **correctness test** tells you whether a faster result is still the same product. Use the same loop every time: 1. Write an SLO, such as “p99 time to first frame below 250 ms at 100 streams.” 2. Reproduce it with a deterministic graph and fixed container CPU/memory. 3. Warm the runtime, then record a baseline and raw artifacts. 4. Profile the failing interval, not server startup or an idle process. 5. Change one variable. 6. Run protocol/conformance tests and a negative test designed to expose the suspected failure. 7. Repeat the identical benchmark and compare confidence intervals, not one lucky run. Create a directory per experiment; generated profiles are ignored by Git and production images: ```bash mkdir -p .profiles/2026-07-31-slow-sse/{before,after} nx build bench ``` ## Reading the numbers Latency percentiles answer different questions. p50 is the ordinary request. p95/p99 are the tail your users notice during queueing, GC, pool waits, or a noisy dependency. For streams, record both **time to first frame** and total duration; an agent that starts responding in 150 ms and finishes in 20 seconds feels different from one silent for 10 seconds. Throughput is completed requests/second or frames/second. It means little without concurrency and saturation. Increase concurrency until throughput stops rising; then inspect CPU, event-loop delay, Postgres pool waits, Redis, and the load generator. The first saturated component is the limit. Memory has several layers: - **JS heap used** is live/recent JavaScript data. - **Heap capacity/limit** is what the collector/runtime reserved or may grow to; it is not usage. - **RSS** is resident process memory: JS heap plus runtime, native clients, stacks, code, and buffers. - **Native/external memory** includes socket buffers and runtime allocations outside the JS heap. - A healthy GC graph is a sawtooth. A leak has a rising _post-GC floor_ after the same work becomes unreachable. A bounded cache rises and then plateaus. For CPU profiles, **self time** is time in the function itself. **Total time** includes callees. High self time points at the hot implementation; high total but low self time says to descend into its children. Wide flame-graph boxes consumed more samples. GC width means allocation pressure, not necessarily that the collector itself is defective. ## Skein’s cross-runtime benchmark Use deterministic graphs first—no model calls—then repeat with a separate realistic model workload. The existing harness exercises real HTTP/SSE and can use real Postgres and Redis: ```bash nx bench bench -- --scenario fast-client nx bench bench -- --scenario slow-client --streams 1 nx bench bench -- --scenario slow-client --streams 50 nx bench bench -- --scenario slow-client --streams 100 nx bench bench -- --scenario slow-client --streams 500 nx bench bench -- --driver postgres-redis ``` Do not model a slow client with `fetch()` plus delayed reads: the client runtime may eagerly drain the socket into its own heap. Skein’s slow-client harness pauses a raw socket so the TCP receive window closes and backpressure reaches the server. For Node/Bun/Deno comparisons there is **no harness support yet** — `packages/bench` starts the server inside its own process, so it cannot measure another runtime, and adding an external-target mode is tracked follow-up work. Today the comparison is a manual procedure: build each production artifact and run the load generator outside the measured container. Keep identical CPU/memory limits, graph bundle, payloads, Postgres/Redis versions, telemetry setting, warm-up, sample duration, and runtime image architecture. Record: - cold boot and readiness; - p50/p95/p99 and time to first frame; - requests/s and frames/s; - RSS, runtime heap values, and settled post-GC memory; - memory slope at 1/50/100/500 slow streams; - queue delay and Postgres pool saturation; - Redis commands, connections, and retained frames; - telemetry-off versus telemetry-on overhead; - SIGTERM drain duration and lost terminal states/events; - a sustained mixed-load soak. Run at least five measured repetitions after warm-up and report the median plus a 95% confidence interval. Save raw JSON beside runtime, image digest, CPU model, memory limit, OS/kernel, and commit. ## Node recipes Profile the built production entry, send representative traffic from another process, then terminate it cleanly so profiles flush: ```bash mkdir -p .profiles/node/cpu .profiles/node/heap node --cpu-prof --cpu-prof-dir=.profiles/node/cpu \ packages/cli/dist/index.js start --runtime node node --heap-prof --heap-prof-dir=.profiles/node/heap \ packages/cli/dist/index.js start --runtime node node --trace-gc packages/cli/dist/index.js start --runtime node node --inspect packages/cli/dist/index.js start --runtime node ``` Load `.cpuprofile` in Chrome DevTools’ Performance panel. Load `.heapprofile` in Memory. In an inspector session, take two heap snapshots only after the same forced-idle/GC point, repeat the workload between them, and compare retained size and retaining paths. Snapshots pause the process and can temporarily require roughly another heap’s worth of memory; capture them on a replica you can remove from traffic. ## Bun recipes ```bash mkdir -p .profiles/bun/cpu .profiles/bun/heap bun --cpu-prof --cpu-prof-dir=.profiles/bun/cpu \ packages/cli/dist/index.js start --runtime bun bun --heap-prof --heap-prof-dir=.profiles/bun/heap \ packages/cli/dist/index.js start --runtime bun ``` Bun has separate JavaScriptCore and native heaps. For an instrumented experiment, record `heapStats()` from `bun:jsc` for JS objects, `Bun.memoryUsage()` for Bun/native allocations, and RSS. A flat JS heap with rising RSS points away from retained JS objects and toward native/socket buffers. Bun’s heap profiler can also emit Markdown summaries; follow the options supported by the pinned Bun version used by the artifact. ## Deno recipes The production launcher needs explicit access to the artifact, environment, network, system data, and native dependencies. Deno’s profiler can produce the raw profile, a Markdown report, and an interactive flamegraph together: ```bash mkdir -p .profiles/deno/cpu deno run --allow-net --allow-env --allow-read=. --allow-sys --allow-ffi \ --cpu-prof --cpu-prof-dir=.profiles/deno/cpu \ --cpu-prof-md --cpu-prof-flamegraph \ packages/cli/dist/index.js start --runtime deno deno run --inspect --allow-net --allow-env --allow-read=. --allow-sys --allow-ffi \ packages/cli/dist/index.js start --runtime deno ``` Open the SVG directly or load `.cpuprofile` in DevTools. Deno reports transpiled JavaScript line numbers in CPU profiles, so use function names and sourcemaps to get back to TypeScript. ## Five diagnostic exercises ### 1. SSE backpressure Run `slow-client` at 1, 50, 100, and 500 streams. Watch server RSS, socket buffered bytes, and bus frames. Expected: socket bytes and bus retention plateau at configured bounds. Negative verification: temporarily replace the raw paused client with a fast client—the pressure disappears, proving a fast client cannot validate slow-client safety. ### 2. Event-bus retention Run `long-run`, let all clients disconnect, force idle+GC, and inspect retained objects. Expected: completed-run retention stops at `SKEIN_MEMORY_BUS_MAX_RETAINED_RUNS`, and frames per run stop at `SKEIN_MEMORY_BUS_MAX_FRAMES_PER_RUN`. A retaining path through a subscription after cancellation is a leak; a bounded map at its configured ceiling is not. ### 3. Serialization cost Compare `stream_mode: "values"` with `"updates"` using the same graph. If CPU self time concentrates in wire serialization and GC grows, reduce full-state frames or payload size. Verify final state and frame order before accepting the change. ### 4. Postgres pool saturation Sweep run concurrency through 1/5/10/20 while holding `PG_POOL_MAX=5`, then repeat at 20. Throughput should flatten and queue time rise when the pool saturates. Raising worker concurrency alone cannot fix a five-connection bottleneck; remember Skein uses separate store and checkpointer pools. ### 5. Telemetry shutdown leak Create a test sink that buffers events and whose `flush()` rejects. Send SIGTERM. The test passes only if `shutdown()` still runs, the other sinks flush, terminal run state is persisted, and the process exits within the configured grace. This distinguishes exporter failure from lifecycle loss. ## Benchmark traps - Warm-up/JIT: keep cold start as its own metric; do not mix it into steady state. - Coordinated omission: a closed-loop generator waits during stalls and under-samples the stall. - Noisy neighbours and CPU turbo: pin container resources and repeat. - A weak load generator: if its CPU is full, you measured the client. This is especially easy with a fast native Bun server. - Model/network variance: deterministic graphs establish runtime overhead; model-backed tests answer a different question. - Different hardware, regions, images, database sizes, or TLS paths: not a runtime comparison. - Average-only reporting: averages hide queue and GC tails. - Optimizing without a negative verification: a benchmark win can be dropped frames or skipped work. ## Comparing Skein with LangGraph Platform Treat Platform as a black box. Use the same graph, SDK client, client region, payloads, concurrency, warm-up, and observation window. Publish raw results and methodology. A “better” claim requires full protocol correctness plus a reproducible win in p99, memory, throughput, cold start, or operating cost, with no material regression in the others. If infrastructure cannot be made identical, label the result as an end-to-end deployment comparison rather than a runtime benchmark. Official references: [Node CLI profiling](https://nodejs.org/api/cli.html#--cpu-prof), [Bun benchmarking and profiling](https://bun.sh/docs/project/benchmarking), and [Deno CPU profiling](https://docs.deno.com/runtime/fundamentals/cpu_profiling/). --- # Deploy on Google Cloud Run Cloud Run runs your container, scales it, and bills per request — a good fit for skein, with **one sharp edge**: by default Cloud Run throttles a container's CPU to near-zero between requests, and skein's background runs do their work _after_ the request that created them has returned. Get that setting wrong and inline runs work perfectly while background runs mysteriously never finish. Everything platform-agnostic — environment variables, pool sizing, probes, scaling caveats — is in [deploy.md](./deploy.md). ## Before you start You need the `gcloud` CLI authenticated, a project with billing enabled, and Docker. Enable the APIs once: ```bash export PROJECT_ID=your-project REGION=us-central1 REPO=skein SERVICE=skein-app gcloud config set project $PROJECT_ID gcloud services enable run.googleapis.com artifactregistry.googleapis.com secretmanager.googleapis.com ``` ## 1. Build and push the image ```bash # Cloud Run runs x86. skein build doesn't pass --platform, so set this on Apple Silicon. export DOCKER_DEFAULT_PLATFORM=linux/amd64 export IMAGE=$REGION-docker.pkg.dev/$PROJECT_ID/$REPO/$SERVICE:v1 gcloud artifacts repositories create $REPO --repository-format=docker --location=$REGION gcloud auth configure-docker $REGION-docker.pkg.dev skein build -t $SERVICE docker tag $SERVICE $IMAGE docker push $IMAGE ``` ## 2. Provision Postgres + Redis Anything reachable works. Two shapes are common: **Managed outside GCP** (Neon, Supabase, Upstash, Redis Cloud) — simplest, no VPC needed, and the connection strings work as-is. Use the **direct** Postgres endpoint, not a pooled one ([why](./deploy.md#tls-to-the-database)). **GCP-native** — Cloud SQL and Memorystore: - **Cloud SQL** attaches over a Unix socket, which needs no TLS configuration. `POSTGRES_URI` is handed straight to `pg`, so the socket form works: ```text postgresql://USER:PASSWORD@/DATABASE?host=/cloudsql/PROJECT:REGION:INSTANCE ``` Add `--add-cloudsql-instances=PROJECT:REGION:INSTANCE` to the deploy below. - **Memorystore** is only reachable from inside your VPC, so the service needs Direct VPC egress or a Serverless VPC Access connector (`--network`/`--subnet`, or `--vpc-connector`). Store both URIs in Secret Manager rather than passing them as plain environment variables — `gcloud run services describe` prints env vars in cleartext: ```bash printf '%s' "$POSTGRES_URI" | gcloud secrets create skein-postgres-uri --data-file=- printf '%s' "$REDIS_URI" | gcloud secrets create skein-redis-uri --data-file=- ``` Grant the service's runtime service account `roles/secretmanager.secretAccessor`. ## 3. Deploy ```bash gcloud run deploy $SERVICE \ --image=$IMAGE \ --region=$REGION \ --port=8123 \ --set-secrets=POSTGRES_URI=skein-postgres-uri:latest,REDIS_URI=skein-redis-uri:latest \ --set-env-vars=PG_POOL_MAX=5,SKEIN_RUN_CONCURRENCY=5 \ --no-cpu-throttling \ --min-instances=1 \ --max-instances=3 \ --cpu=1 --memory=1Gi \ --timeout=3600 \ --no-allow-unauthenticated ``` The flags that matter, and why: | Flag | Why | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `--port=8123` | Cloud Run injects `PORT`, and skein binds it. This tells Cloud Run which port to _send_ traffic to. | | `--no-cpu-throttling` + `--min-instances=1` | **Required for background runs.** See [below](#background-runs-need-cpu-outside-requests). | | `--timeout=3600` | The ceiling on a streaming SSE run; skein imposes none of its own. | | `--no-allow-unauthenticated` | skein's auth is off by default — see [the warning](./deploy.md#5-auth--read-this-before-you-expose-it). | | `PG_POOL_MAX=5` | Two pools per instance × instances vs. your database's cap ([budget](./deploy.md#connection-budget)). | Cloud Run's built-in health checking uses the container port; you can also declare an explicit startup probe on `GET /ok` with a ~30s failure budget, since boot runs migrations before listening. ## 4. Verify With `--no-allow-unauthenticated`, reach the service through an authenticated tunnel rather than opening it up: ```bash gcloud run services proxy $SERVICE --region=$REGION --port=8123 ``` Then run the [verification sequence](./deploy.md#verify-a-deployment) against `http://localhost:8123`. Step 4 — the background run — is the one that proves the CPU settings are right. ## Cloud Run caveats ### Background runs need CPU outside requests This is the big one. `POST /threads/{id}/runs` enqueues a run and responds immediately; the worker executes it afterwards. Under Cloud Run's default _CPU-allocated-during-requests_ model, the instance's CPU is throttled to near-zero the moment the response is sent, so that run stops making progress — and with `--min-instances=0` the instance may be shut down entirely. Set **`--no-cpu-throttling`** (CPU always allocated) and **`--min-instances=1`**. This costs more — you're paying for an always-on vCPU rather than per-request — but it's what makes background runs, webhooks and cron-shaped work behave. If you only use inline runs (`/runs/wait`, `/runs/stream`), the work happens _during_ the request and the defaults are fine. Scale to zero freely. ### Request timeout vs. streaming Default 300s, max 3600s. A long SSE run is a long request. skein sends no heartbeat frame, so a stream that goes quiet still counts against the timeout — set `--timeout` to cover your worst case. Don't put Cloud CDN in front of the streaming routes; it buffers. ### Connections vs. autoscaling Each instance opens two Postgres pools, so a burst to `--max-instances=10` with `PG_POOL_MAX=10` wants 200 connections. Cap `--max-instances`, keep `PG_POOL_MAX` small, or front Cloud SQL with the Auth Proxy or a pooler. ### Shutdown Cloud Run sends `SIGTERM` and SIGKILLs 10 seconds later by default. skein drains in-flight runs for 5s, aborts the rest so they land in a terminal status, and exits — comfortably inside that window. If you raise `SKEIN_SHUTDOWN_GRACE_MS`, raise Cloud Run's termination grace period to match, or you'll be killed mid-drain. Note that Cloud Run has no `init` process. The image is fine as-is, but if your graphs spawn child processes you'll want `tini` — add it via `dockerfile_lines` in `langgraph.json`. ### Multi-instance semantics With `--max-instances` above 1 and Postgres + Redis configured, cross-instance cancellation and the one-run-per-thread guard both hold — no session affinity needed. See [Scaling past one instance](./deploy.md#scaling-past-one-instance) for what that costs in Postgres connections. --- # Bundling skein Most people never read this page: if you deploy the `skein build` image, or run `next start` with skein mounted as a route handler, bundling is already handled. You need this page when **you** own the bundler — an rspack/webpack server build, an esbuild bundle for Lambda, a Next.js app with an unusual config — and skein is inside its module graph. The short version: **skein is ESM-only, and everything except the CLI bundles cleanly.** Two packages must stay external, and only if you use the `langgraph.json` on-ramp. ## skein is ESM-only Every `@skein-js/*` package is `"type": "module"` and ships a single ESM entry. There is no CommonJS build and there won't be one. That does **not** mean you can't `require()` it. Each library package exposes a `default` export condition, so Node's `require(esm)` resolves it: ```js const { embedPostgresGraphs } = require("@skein-js/runtime"); // works on Node 20.19+ / 22.12+ ``` `require(esm)` landed unflagged in **Node 20.19** and **22.12**. On an older Node you get `ERR_REQUIRE_ESM` telling you to use `import()` instead — which is the honest answer, and what a CJS-emitting bundler should be configured to do. Plain `import` works on any Node ≥ 20. > Before 0.10.0 the packages declared only `types` + `import` conditions, so `require()` failed with > `ERR_PACKAGE_PATH_NOT_EXPORTED` — which reads like the package is broken rather than like a module > format mismatch. If you're pinned below 0.10.0, that's the fix. > > The `skein-js` CLI keeps the old shape deliberately: it has no exports, and its entry point runs a > command as a side effect. `require()`-ing it should fail. **`require()` does not tree-shake.** `require("@skein-js/runtime")` eagerly loads `@skein-js/config` and therefore `@langchain/langgraph-api`, even if you only use `embedPostgresGraphs` and never touch a `langgraph.json`. It works, it just costs ~0.5s of cold start. Prefer `import` where your toolchain allows it. ## What must stay external | Package | Bundle it? | Why | | -------------------------- | ------------------------- | --------------------------------------------------------------------------------------- | | `@langchain/langgraph-api` | **No** — mark external | skein's graph loader `import()`s a path computed at runtime; no bundler can follow that | | `@typescript/vfs` | **No** — mark external | pulled in by the same loader | | `skein-js` (the CLI) | **Never** | a `bin` with no exports and top-level await; importing it runs a command | | every other `@skein-js/*` | **Yes** — bundles cleanly | including `@skein-js/storage-postgres` as of 0.10.0 | The first two only matter if you point skein at a `langgraph.json` (`buildRuntime`, the CLI, the `{ config }` form of any adapter). If you embed graphs in code — `embedPostgresGraphs`, `embedInMemoryGraphs`, the `{ deps }` form — nothing reaches them: `@langchain/langgraph-api` is loaded with `await import()` at the two points that genuinely need it (analysing a graph's schema, and adapting a user's `Auth` instance), and the in-memory runtime loader is behind a dynamic import on the `{ config }` branch. So on an embedded path they are never in the module graph at all, rather than being present and merely tree-shakeable. That is asserted, not asserted-by-comment: `packages/test-support/src/static-imports.test.ts` walks each adapter's built output — following `@skein-js/*` edges into their own `dist` — and fails if `@langchain/langgraph-api`, `@typescript/vfs`, or `superjson` is statically reachable. The walk is transitive because the regression it caught was: no adapter imported `@langchain/langgraph-api`, but every adapter imported `@skein-js/server-kit`, which imported the `@skein-js/config` barrel for one error class, and that barrel imported `@langchain/langgraph-api`. One trade-off worth knowing: because `@langchain/langgraph-api` is now loaded on demand, a bundling mistake around it (the `serverExternalPackages` config below) surfaces when something first asks for a graph schema rather than at startup. The container boots and passes its probes, and `GET /assistants/{id}/schemas` returns a 500. Bake your schemas at build time (`skein build` does) and the path is never taken at all. Two consequences for the public API, both of which exist to keep that graph clean: - `SkeinConfigError` is importable from `@skein-js/config/errors` as well as the root, and internal code uses the subpath. The root barrel is the `langgraph.json` loader. - `readLanggraphDevState` / `loadSnapshotIntoStore` / `describeSnapshot` live at `@skein-js/server-kit/dev`, not on the root barrel — they carry `superjson` and `node:fs/promises`, and only `skein dev` / `skein import` call them. They are deliberately **not** re-exported from the root or from `@skein-js/express`: a re-export is still a static import, which would undo the split. ## Copy-paste configs **Next.js** (`next.config.mjs`): ```js export default { serverExternalPackages: ["@langchain/langgraph-api", "@typescript/vfs"], }; ``` **webpack / rspack** (server build): ```js export default { target: "node", externals: [ { "@langchain/langgraph-api": "commonjs @langchain/langgraph-api" }, { "@typescript/vfs": "commonjs @typescript/vfs" }, ], }; ``` **esbuild**: ```bash esbuild server.ts --bundle --platform=node --format=esm \ --external:@langchain/langgraph-api --external:@typescript/vfs ``` Prefer `--format=esm` if you can. With `--format=cjs`, anything the bundle `require()`s at runtime still needs Node 20.19+, per above. ## The "Critical dependency" warning webpack and rspack emit this when they meet skein's graph loader: ```text Critical dependency: the request of a dependency is an expression ``` It's expected and harmless — that expression is the `import()` of your graph module, resolved from `langgraph.json` at runtime. Externalizing `@langchain/langgraph-api` removes most of it; to silence the rest: ```js // next.config.mjs export default { serverExternalPackages: ["@langchain/langgraph-api", "@typescript/vfs"], webpack: (config) => { config.ignoreWarnings = [ ...(config.ignoreWarnings ?? []), { message: /Critical dependency: the request of a dependency is an expression/ }, ]; return config; }, }; ``` ## Postgres migrations are compiled in `@skein-js/storage-postgres` needs **no skein-side externals**. Its schema migrations ship as string constants inside `dist/index.js`, so the package makes no filesystem access at runtime — it imports only `node:crypto`, `pg`, and `@skein-js/core`. (`pg` itself has one wrinkle — see below.) Before 0.10.0 it located its `migrations/` directory with `new URL("../migrations", import.meta.url)` and handed it to `node-pg-migrate`. Bundlers rewrite `import.meta.url` to the **output** location, so a bundled build looked fine until boot, then failed to find its own SQL against a real database. If you hit that on an older version, externalize `@skein-js/storage-postgres` (which then has to be present in `node_modules` at runtime) or upgrade. Migrations still run automatically on boot, tracked in a `skein_migrations` table and serialized by a Postgres advisory lock — see [deploy.md](./deploy.md) and [storage.md](./storage.md). ### One caveat, from `pg` rather than skein `pg` has an optional native binding it reaches for at runtime: `pg/lib/native/client.js` does `require('pg-native')`, and `pg-native` is an optional peer that is normally not installed. pg wraps that call in a `try`/`catch` specifically so bundlers tolerate it, and **esbuild does** — it leaves a runtime `require` and emits nothing. **webpack and rspack are stricter** and report: ```text Module not found: Can't resolve 'pg-native' ``` You do not want the native binding; tell the bundler to ignore it: ```js // webpack / rspack import webpack from "webpack"; export default { plugins: [new webpack.IgnorePlugin({ resourceRegExp: /^pg-native$/ })], }; ``` Next.js keeps `pg` on its built-in server-externals list, so this never surfaces there. ## What `skein build` inlines vs. externalizes The section above is about bundling **skein**. This one is about the bundler skein itself runs: `skein build` compiles your graphs (plus auth, custom embed, custom telemetry sinks) into `.skein/build`, and the split it makes there is the reason the production image is small and the monorepo case works at all. **Inlined into the artifact** — your own source, including anything reached through a `tsconfig` `paths` alias or a workspace link (`@myorg/js`, the Nx/Turborepo/pnpm-workspace pattern). Those files exist nowhere a package manager could install them from, so resolving them once on the build host is what dissolves the "my Docker build context doesn't contain my monorepo" problem. **Left external and pinned** — every published `node_modules` package. `skein build` records each one at the exact version installed on the build host and writes it into the artifact's `package.json`, which the image installs with `npm install --omit=dev`. Externalizing is not a limitation to work around; it is load-bearing: - **One copy of each library.** The image installs `skein-js`, which brings `@langchain/langgraph` and `@langchain/core`, and that runtime is what imports your graph bundle. Inline `@langchain/core` into the graph and there are two copies: `instanceof BaseMessage` starts failing, and config/callbacks propagate through a different `AsyncLocalStorage` than the one the runtime reads. - **Native addons can't be inlined.** `pg-native`, `sharp`, `better-sqlite3` and friends are platform binaries; they have to be installed for the image's platform. - **Package-relative asset reads survive.** A bundler rewrites `import.meta.url`/`__dirname` to the output location, which breaks packages that load workers, wasm, or data files from beside themselves (pdfjs, `tiktoken`, …) — the same hazard that made skein [compile its own SQL in](#postgres-migrations-are-compiled-in). - **Cheaper rebuilds.** `COPY package.json` + install is a cached Docker layer; a graph edit re-ships only the bundle. The one thing a bundler structurally cannot see is a package imported **by name at runtime** — `initChatModel` doing `import("@langchain/" + provider)`, a plugin loaded from config. Those never appear in the module graph, so declare them under `dependencies` in `langgraph.json`: ```json { "graphs": { "agent": "./src/graph.ts:graph" }, "dependencies": ["@langchain/openai"] } ``` (skein pins the packages behind a declared `store.index.embed` provider and a declared `telemetry` provider for you — the field is for the ones only your code knows about.) `skein build` fails on the **host** if the artifact would ship an import it does not install, so a missing pin is a build-time error with a package name in it rather than an `ERR_MODULE_NOT_FOUND` from inside `docker build`. ## See also - [deploy.md](./deploy.md) — deploying the built image, env vars, probes, scaling - [embedding.md](./embedding.md) — the in-code on-ramp, which avoids the graph loader entirely - [storage.md](./storage.md) — the Postgres driver and its schema --- # Reuse-first architecture > **Contributor / design doc.** This explains the reuse-first architecture that shapes skein-js — what > it reuses from `@langchain/*` versus what it rebuilds. If you just want to _use_ skein-js, start with > the [README](https://github.com/skein-js/skein-js/blob/main/README.md) and [docs index](./index.md). To contribute, see > [CONTRIBUTING.md](https://github.com/skein-js/skein-js/blob/main/CONTRIBUTING.md) and [AGENTS.md](https://github.com/skein-js/skein-js/blob/main/AGENTS.md). > **Principle:** Reuse as much of the LangGraph open source as possible. Build only what > LangGraph OSS does not already give us — and build that well. LangChain publishes a large, **MIT-licensed** JavaScript ecosystem under [`langchain-ai/langgraphjs`](https://github.com/langchain-ai/langgraphjs). Crucially — and unlike the Python side, where the equivalent is not open — the **JS Agent Protocol dev server itself is open source** ([`@langchain/langgraph-api`](https://github.com/langchain-ai/langgraphjs/tree/main/libs/langgraph-api), MIT). So skein-js is deliberately _thin_. We stand on the OSS runtime, checkpointers, parser, schemas, and SDK, and add only the **durable-production, multi-framework, drop-in-CLI** layer that OSS does not provide. ## What skein-js reuses (dependencies, all MIT) | Concern | LangGraph OSS package | How skein-js uses it | | ----------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Graph runtime | `@langchain/langgraph` | Run graphs via `CompiledStateGraph.invoke` / `.stream`; interrupts + resume for human-in-the-loop. Never reimplemented. Reached through `@skein-js/langgraph`, which is the only package that imports it at runtime. | | Checkpoint base + dev saver | `@langchain/langgraph-checkpoint` | `BaseCheckpointSaver`, `MemorySaver` for dev. | | Postgres checkpoints | `@langchain/langgraph-checkpoint-postgres` | `PostgresSaver` for graph state in prod. Its `./store` `PostgresStore` is also usable as a drop-in long-term-memory store via `store.adapter` — see [storage.md](./storage.md#bringing-your-own-store-storeadapter) — which brings hybrid `text \| vector` search skein's own driver lacks. | | Long-term store interface | `@langchain/langgraph-checkpoint` | `BaseStore` in both directions: `SkeinBaseStore` (in `@skein-js/langgraph`, because it _extends_ `BaseStore`) exposes skein's store to graphs as one, and `fromBaseStore` (in `@skein-js/agent-protocol`, whose import is type-only) accepts anyone's as skein's store. `InMemoryStore` is the conformance target for that adapter. | | Redis checkpoints | `@langchain/langgraph-checkpoint-redis` | Optional Redis-backed checkpointer. (Note: distinct from `@skein-js/redis`, which is the run **queue**.) | | SQLite checkpoints | `@langchain/langgraph-checkpoint-sqlite` | Optional file-backed dev checkpointer. | | Checkpointer conformance | `@langchain/langgraph-checkpoint-validation` | Reused as-is in our test suite to validate any checkpointer wiring. | | **Agent Protocol dev server** | **`@langchain/langgraph-api`** (MIT) | Reuse its public exports: `./schema` (the `langgraph.json` parser), `./auth`'s pure `isAuthMatching` (so our ownership-filter `$eq`/`$contains` semantics match), `./experimental/embed` (store-search embeddings). Its Zod schemas + in-memory handler logic (MIT) are the reference we adapt for the durable drivers. | | CLI + config semantics | `@langchain/langgraph-cli` | Reference for `langgraph.json` fields and `dev/up/build/dockerfile` behavior we mirror. | | Wire types + JS client | `@langchain/langgraph-sdk` | **Reuse the SDK's TypeScript types** for Thread/Run/Assistant/etc. as our wire contract instead of regenerating; also our conformance oracle. | | React streaming | `@langchain/langgraph-sdk/react` | `useStream` — a target client we satisfy, not something we build. (`sdk-vue`, `sdk-svelte`, `sdk-angular` exist too.) | | Chat UI | `langgraph-ui` / Agent Chat UI | Interop target for smoke tests. | **Rule of thumb:** if a `@langchain/*` package already does it, we depend on it (as a `peerDependency` where the consumer should own the version). We do not fork or vendor it. ## What skein-js rebuilds (the gap) `@langchain/langgraph-api` is explicitly an **in-memory dev server** ("in-memory mode, suitable for development and testing"), built on Hono and oriented around the CLI. It does not aim to provide durable production infrastructure. That gap — the same one aegra fills for Python — is skein-js's actual product: | Gap in OSS | skein-js package | Why it's needed | | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Durable persistence of **protocol resources** (assistants / threads / runs / store rows) | `@skein-js/storage-postgres` + `SkeinStore` | The OSS server keeps these in memory; production needs Postgres (+ pgvector). Graph _checkpoints_ still reuse `PostgresSaver`. | | Durable **background-run queue** + **cross-instance pub/sub** streaming | `@skein-js/redis` | The OSS server runs runs in-process; horizontal scaling needs a real queue and fan-out. | | **Framework-native adapters** | `@skein-js/express` (· `@skein-js/fastify` · `@skein-js/nestjs`) | The OSS server is Hono-only; teams want to mount the protocol into their existing Express/Fastify/Nest app. | | **Normalized protocol core** tying runtime + checkpointer + store + queue together | `@skein-js/core` | Adapter- and driver-agnostic handlers so behavior is identical everywhere. | | **Drop-in production CLI** | `skein-js` | `skein dev/up/build/dockerfile` reading an unchanged `langgraph.json`, wiring the durable drivers. | | **`langgraph.json` loading orchestration** | `@skein-js/config` | Thin wrapper over `@langchain/langgraph-api`'s `./schema` parser, adding `skein.json` overrides and driver selection. | | **Channels for cross-provider workflows** | `@skein-js/channels` | Skein supplies durable sources and destinations; LangGraph owns the workflow and writes an explicit destination intent to the existing callback/outbox. | ## Consequences - **Small surface, few bugs.** Most agent behavior lives in battle-tested LangChain code; skein-js's own code is persistence, transport, and wiring. - **Version alignment.** Reused packages are `peerDependencies` so apps control the exact LangGraph version and avoid duplicate installs. - **Upgrades are cheap.** When LangGraph ships new stream modes or schema fields, skein-js inherits them through the shared runtime/types instead of chasing them by hand. - **Honest positioning.** skein-js is an open alternative to LangGraph Platform — "aegra for TypeScript" — with a narrower job to do, because on JS the server internals are already open. We add production durability, not a second server. See [code-practices.md](./code-practices.md) for how we keep the code we _do_ write small and neat, and [storage.md](./storage.md) / [runs-and-redis.md](./runs-and-redis.md) for the rebuilt pieces in detail.