Skip to content

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 CLIskein-jsBehavior
langgraph devskein devIn-process dev server, hot reload, no Docker. Local state.
langgraph upskein upDocker Compose stack (app + Postgres + Redis).
langgraph buildskein buildBuild a deployable Docker image from the config.
langgraph dockerfileskein dockerfileEmit 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. 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:

FlagValuesDefaultNotes
--store <driver>memory, postgresmemorypostgres reads POSTGRES_URI; also selects PostgresSaver.
--queue <driver>memory, redismemoryredis 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 andskein 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.

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:

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). 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:

Concernskein-js implementation
CLIcommander — the skein dev/up/build/dockerfile command surface, plus skein start (below).
Dev graph loadingvite 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 buildskein 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 reloadState-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 persistenceDev state is snapshotted to .skein/ so it survives restarts (opt out with --no-persist).
Run queue (prod)BullMQ on Redis — background runs, retries, backoff, and crash recovery.
Cross-instance streamingioredis + Redis Streams/pub-sub — join a run's SSE stream from any instance.
Postgres store (prod)pg + compiled-in schema migrations + pgvector semantic search.
Checkpoints (prod)LangGraph-native PostgresSaver — 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.

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 fieldskein-js wiring
graphs@skein-js/config resolves each path:export, loading a compiled graph or makeGraph factory. Drives /agents introspection + run execution.
node_versionUsed 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.runtimeNative 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.idempotencyRetention for Idempotency-Key records. Tuning only — the header is honoured either way. See below.
skein.webhooksHow run-completion callbacks are retried and bounded. Tuning only — a run carrying a webhook owes a callback either way. See below.
envLoaded into process.env at boot (dev) / baked into the image (build).
storestore.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.
checkpointer"default" → PostgresSaver; dev falls back to an in-memory MemorySaver.
httphttp.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 at /console (off unless set; skein dev serves it regardless). http.app is still accepted and ignored.
authauth.path loads an Auth from @langchain/langgraph-sdk/auth; every request is authenticated + authorized; disable_studio_auth honored.
telemetryskein extension. Builds the telemetry sinks runs report to — see observability.md. Unknown to langgraph dev, which ignores it.
dependenciesskein extension on the JS side (LangGraph's schema has it for Python only). Extra packages skein build pins into the artifact — see below.
dockerfile_linesAppended 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.

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

Authentication + authorization (auth) ​

skein-js implements LangGraph's custom-auth model. Point auth.path at a module exporting a @langchain/langgraph-sdk/auth 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 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.

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:

FlagValuesDefaultNotes
-c, --configpathlanggraph.jsonResolves the project directory.
--store <driver>memory, postgresmemorymemory writes .skein/dev-state.json; postgres loads a live DB (POSTGRES_URI).
--from <dir>path<project>/.langgraph_apiSource directory to read.
--force—offOverwrite 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.