# 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 <driver>` | `memory`, `postgres` | `memory` | `postgres` reads `POSTGRES_URI`; also selects `PostgresSaver`.  |
| `--queue <driver>` | `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 <count>` 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 <count>` 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 <path>` and `skein up --npmrc <path>` 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 — <https://docs.langchain.com/langsmith/cli>
- `@langchain/langgraph-cli` source — <https://github.com/langchain-ai/langgraphjs/tree/main/libs/langgraph-cli>

## 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("<resource>:<action>", ...)` (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 <driver>` | `memory`, `postgres` | `memory`                   | `memory` writes `.skein/dev-state.json`; `postgres` loads a live DB (`POSTGRES_URI`). |
| `--from <dir>`     | path                 | `<project>/.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.
