Embedding a graph you already have — the in-code path
User guide. skein-js has two on-ramps. If you already run the LangGraph CLI, the drop-in (
langgraph dev→skein dev, unchangedlanggraph.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 — nolanggraph.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) |
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
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:
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) 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.
⚠️ Auth is off by default.
embedInMemoryGraphssets noauth, so the server it produces authenticates nothing — every request is allowed (the same default as alanggraph.jsonwith noauthblock). That's fine behind your own middleware or on a private network, but mounting{ deps }on a public app exposes/threads,/runs, and/storeto anyone — including running your graph (spending model tokens) and reading/writing the long-term store. Add anauthengine before you go public — see 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:
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 type leaves the graph's generics open on purpose.
Standalone or embedded
{ deps } works with every adapter, in both its standalone and embedded form:
const deps = embedInMemoryGraphs({ agent: graph });
// Express — standalone server, or mounted on your existing app:
await (await createExpressServer({ deps })).listen(2024);
app.use(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.
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:
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.
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:
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,embedPostgresGraphssets noauth, so the server authenticates nothing. That's easy to miss here precisely because you reach for this helper to deploy: shipping it public with noauthexposes/threads,/runs, and/store— and running your graph (spending model tokens) — to anyone. Pass anauthengine viaoverridesbefore you go public (see below).
It lives in @skein-js/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:
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 has the sizing table and the triage symptoms.
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:
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'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 and 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.
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 — the langgraph.json loader runs getStaticGraphSchema over the graph source at build time.
API reference
From @skein-js/server-kit:
// Build a ProtocolDeps around in-process drivers. Pass a graph map OR a ready GraphResolver.
function embedInMemoryGraphs(
graphs: GraphResolver | Record<string, EmbeddableGraph>,
overrides?: Omit<Partial<ProtocolDeps>, "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<string, EmbeddableGraph>): GraphResolver;
// A graph you can embed: any compiled LangGraph.js graph, or a factory that builds one per run.
type EmbeddableGraph = CompiledGraph<any> | ((config: { configurable?: Record<string, unknown> }) => …);
embedInMemoryGraphswas previously namedcreateInMemoryDeps. The old name is still exported as a deprecated alias, so existing imports keep working — preferembedInMemoryGraphsin new code.
graphMapToResolver is useful on its own when you want the resolver but your own ProtocolDeps (e.g. Postgres/Redis drivers): buildRuntime-style deps with graphs: graphMapToResolver({ agent }). normalizeEmbeddableGraphs(graphs) accepts either a graph map or a ready GraphResolver and returns a GraphResolver — the same normalization both embed helpers apply.
From @skein-js/runtime (the durable path — see Going to production):
// 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<string, EmbeddableGraph>,
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<Partial<ProtocolDeps>, "graphs" | "store" | "queue" | "bus" | "checkpointer">;
},
): Promise<{ deps: ProtocolDeps; dispose(): Promise<void> }>;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 — the other on-ramp (drop-in CLI +
langgraph.json) - agent-protocol.md — the endpoints you get either way
- building-an-adapter.md — putting the engine on any HTTP framework
- storage.md · runs-and-redis.md — swapping in production drivers
examples/embed-graph·examples/nextjs-app