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 deployOut 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.
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 },
  },

  // .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.
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 extensionpath: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.

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 — single Node process, in-memory (or file-backed) state, hot reload on source change. No 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.
--forceoffOverwrite 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.