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, so an existing Auth file is drop-in.
// 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 runsWire 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.
Full route → permission map: agent-protocol.md.
Get notified when a run finishes
Pass a webhook URL on run creation; skein POSTs the settled run to it.
await client.runs.create(threadId, "agent", { input, webhook: "https://example.com/hooks/run" });It is best-effort and unsigned. A failed delivery is logged, never retried, and never fails the run — treat it as a notification and the API as the source of truth. Each POST is aborted after SKEIN_WEBHOOK_TIMEOUT_MS (default 5s), sized against the 8s shutdown budget so a slow receiver can't get you killed mid-POST. Delivery happens after the thread's execution lock is released, so a slow target no longer blocks other runs on that thread — which also means two webhooks for one thread are not guaranteed to arrive in run order.
A server accepting untrusted clients should inject a webhookDispatcher that allowlists the host. Durable, signed, retried delivery is proposed.
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.
import { buildRuntime } from "@skein-js/runtime";
const rt = await buildRuntime({
configPath: "./langgraph.json",
store: "postgres",
queue: "redis",
});
// pass rt.deps to any adapter's { deps }; call rt.dispose() on shutdownOr 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 covers what every platform needs — Postgres, Redis, the port, the /ok probe, pool sizing, SIGTERM draining, SSE through proxies — with guides for Cloud Run, Railway, Fly.io, Render, AWS, Kubernetes, a VPS, and what doesn't work on serverless.
Watch it run
The console 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 covers the TelemetrySink seam and the LangSmith, PostHog and OpenTelemetry adapters.