Serving a graph as a plain endpoint — the simplified path
User guide. skein's main surface is the full Agent Protocol — threads, assistants, runs, streaming, history, human-in-the-loop. That's what a chat app needs. Plenty of LangGraph work isn't chat: a classifier, an extractor, an enrichment step another service calls. For those, this page describes a smaller surface — one graph, one endpoint, called like a function.
Which surface do I want?
| Full Agent Protocol | Single-graph invoke | |
|---|---|---|
| Mount | skeinRouter / skeinPlugin / SkeinModule / route handlers | skeinInvoke* / SkeinInvokeModule |
| Endpoint | /threads, /assistants, /runs, /store, … | POST /invoke/:graph_id |
| Request | { input, config, stream_mode, … } on a thread | the graph input, raw |
| Response | a run object (or an SSE run stream) | the final graph state |
| Conversation state | persisted per thread (resume, history, time travel) | none — each call is independent |
| Best for | chat, HITL, anything resumable | classification, extraction, batch, workflows |
| Clients | useStream, Agent Chat UI, LangGraph SDK | fetch, curl, any HTTP client |
Both run the same graph through the same LangGraph machinery. The difference is how much protocol sits in front of it. Mounting both is supported and safe: each call attaches its checkpointer and store to a per-call clone of the compiled graph, so an invoke never disturbs a concurrent protocol run's durable state.
The whole thing
import { skeinInvokeRouter } from "@skein-js/express";
import { embedInMemoryGraphs } from "@skein-js/server-kit";
import express from "express";
import { graph as triage } from "./triage-graph.js";
const app = express();
const { router } = await skeinInvokeRouter({ deps: embedInMemoryGraphs({ triage }) });
app.use(router);
app.listen(2024);curl -sX POST localhost:2024/invoke/triage \
-H 'content-type: application/json' \
-d '{"text":"Refund charge failed — urgent!"}'
# {"text":"Refund charge failed — urgent!","category":"billing","priority":"P1"}Runnable version: examples/invoke-endpoint.
The contract
The request body is the graph input. The response is the graph's final state. No envelope in either direction — the graph behaves like a function over HTTP.
- One endpoint per graph. Every id in the graph map (or in
langgraph.json) is mounted atPOST <prefix>/<graph_id>. The prefix defaults to/invoke. - An unregistered id is a 404, with
code: "graph_not_found". - An empty body runs the graph with its state defaults rather than surfacing LangGraph's opaque
EmptyInputError— useful for graphs that take no input at all. - Responses are serialized with the same wire encoder the protocol uses, so LangChain messages in a final state flatten to the
{ type, content }shape clients expect. - A graph that throws is an error response, mapped through the adapter's normal error path.
Streaming
The default is a single JSON response. Send Accept: text/event-stream and the same endpoint streams the graph's steps as SSE instead, ending with a terminal end (or error) event:
curl -NsX POST localhost:2024/invoke/triage \
-H 'content-type: application/json' -H 'accept: text/event-stream' \
-d '{"text":"Outage — everything is down"}'id: 1
event: values
data: {"text":"Outage — everything is down","category":"","priority":""}
id: 2
event: values
data: {"text":"Outage — everything is down","category":"general","priority":""}
event: end
data: {"status":"success"}The mode defaults to values (each frame is the full state after a step, ending at the value the JSON response would have returned). Override it per mount with streamMode, or per request with ?stream_mode=updates (comma-separate for several). Modes are validated at the boundary, so an unknown one is a 400 rather than an opaque failure deeper in. If the graph throws mid-stream the failure arrives as an error frame — headers are already sent, so it can't become an HTTP status.
stream_mode=events is not available here: it is not a Pregel stream mode (the run engine serves it from graph.streamEvents, while this surface drives graph.stream), so it is rejected with a 400. Use the Agent Protocol run endpoints for token-level events.
On every adapter
The surface is the same everywhere; only the mount idiom differs.
const deps = embedInMemoryGraphs({ triage, extract });
// Express — a Router you mount
const { router } = await skeinInvokeRouter({ deps });
app.use(router);
// Fastify — a plugin, encapsulated under its prefix
await app.register(skeinInvokePlugin, { prefix: "/agent", deps }); // → POST /agent/invoke/:graph_id
// NestJS — a dynamic module, alongside your controllers
@Module({ imports: [SkeinInvokeModule.forRoot({ deps })] })
export class AppModule {}
// Next.js App Router — app/api/invoke/[graph_id]/route.ts
export const runtime = "nodejs";
export const { POST } = createSkeinInvokeRouteHandlers({ deps, basePath: "/api/invoke" });Each accepts the same { config } | { deps } seam as the full protocol, so skeinInvokeRouter({ config: "./langgraph.json" }) works too — see embedding.md. The Express/Fastify/NestJS mounts claim only their own path and pass everything else through, so the host app's routes are untouched.
Auth
deps.auth, when configured, is enforced here exactly as on a run — invoking a graph runs it (spending model tokens), so this is not a way around the gate. The caller is authenticated (401 on failure) and authorized against threads:create_run (403 on deny), and the authenticated principal is stamped into the graph's configurable as langgraph_auth_user, just like a protocol run.
Auth runs before the graph-exists check, so an unknown id also returns 401 rather than 404 — an anonymous caller can't enumerate which graphs you serve by telling the two apart.
⚠️ With no
authconfigured, this endpoint is open — the same default as the full protocol (and aslanggraph dev). That's fine behind your own middleware or on a private network; anywhere public, wire anauthengine before you ship. See embedding.md.
What it deliberately doesn't do
Each call is independent: no thread is created, nothing is persisted between calls, and the run never appears in /runs. Concretely, this surface has no:
- conversation state — no thread id, no history, no time travel;
- interrupts / human-in-the-loop — there is no thread to resume into;
- background runs, run rows, or webhooks — the call is inline; there is no run to cancel by id;
- assistants — you address the
graph_iddirectly, with no assistant config layer.
The long-term store is still injected, so nodes reach cross-thread memory via getStore() as usual. If you need any of the above, use the full protocol — that's what it's for.
The call's lifetime really is the request: the graph runs under an AbortSignal that fires when the client disconnects (all four adapters wire this) and when deps.runTimeoutMs elapses — the same budget the run engine applies — so a disconnected or hung call doesn't keep burning model tokens.
API reference
From @skein-js/agent-protocol (the shared handler every adapter wraps):
// A ProtocolHandler for `POST <prefix>/:graph_id`. Resolves the graph, injects the store, invokes.
function createGraphInvokeHandler(
deps: ProtocolDeps,
options?: { streamMode?: StreamMode | StreamMode[] }, // SSE modes; default "values"
): ProtocolHandler;
// The one-route table, shaped like `skeinRoutes` so catch-all adapters can match it identically.
function graphInvokeRoutes(prefix?: string): RouteBinding[]; // default prefix "/invoke"
// Build a matcher over any route table (used by the NestJS/Next.js catch-all mounts).
function createRouteMatcher(bindings: readonly RouteBinding[]): RouteMatcher;Per adapter — each takes SkeinRuntimeOptions ({ config } | { deps }, plus logger/cors) and the streamMode option above:
| Adapter | Entry point | Path option |
|---|---|---|
| Express | skeinInvokeRouter(options) | prefix (/invoke) |
| Fastify | skeinInvokePlugin | invokePrefix |
| NestJS | SkeinInvokeModule.forRoot(options) | prefix |
| Next.js | createSkeinInvokeRouteHandlers(options) | basePath |
From @skein-js/server-kit:
// Resolve `{ config } | { deps }` to just the deps — no assistants seeded, no run worker started.
function resolveRuntimeDeps(options: SkeinRuntimeOptions): Promise<{ deps: ProtocolDeps; cors? }>;See also
- embedding.md — bringing a graph in code (
{ deps }), the on-ramp this builds on - agent-protocol.md — the full surface, and when you want it instead
- streaming.md — how skein maps run frames onto SSE
examples/invoke-endpoint·examples/embed-graph