Proposal — Beyond LangGraph Platform
Design documents for work that is not yet built. Each proposal states the problem, what we hope to achieve, what we explicitly won't do, and a design sketch concrete enough to argue with.
The thesis
Almost every agent framework assumes the agent lives behind a web chat UI. LangGraph's whole client ecosystem is browser-shaped — useStream, Agent Chat UI, LangGraph Studio. A human opens a tab, types, and watches tokens stream back. skein-js already serves that path as well as LangGraph Platform does; the roadmap parity table is nearly all green.
But a browser tab is a small slice of where agents belong. Most software has no human staring at one:
- A WhatsApp number — for an enormous share of the world, chat is the interface, and there is no app to install.
- An inbox that answers support email in the thread the customer already started.
- A GitHub webhook that triages an issue and comments back.
- A Stripe dispute that drafts a response before anyone reads it.
- A Slack workspace where the agent is a colleague rather than a website.
- Any service-to-service call where the caller is another program and the "conversation" is an event stream.
The moment an agent leaves the browser, the guarantees stop. The run is durable; the news that it finished is not. Nothing is idempotent, so every retrying provider double-fires and the user gets two replies. There is no way to map an external identity — a phone number, an email thread — onto a conversation. And human-in-the-loop, which works beautifully when the human is watching a stream, silently breaks when the human answers six hours later by text.
That is the gap these proposals close. Not catching up to LangGraph Platform — going where it hasn't. Both capabilities are genuinely absent from LangGraph: diffing @langchain/langgraph-sdk v1.9.25 turns up zero hits for idempotency, and webhook is a bare URL string with no signing, no retries, and no delivery record.
It is also the right bet for a self-hosted project specifically. People who self-host are people integrating with infrastructure they already own — their own queues, their own numbers, their own inboxes. They are not the audience for a hosted chat widget.
The proposals
Shipped foundations
One round trip needs no browser: the outbound leg has to be trustworthy (the answer gets back) and the inbound leg has to be cheap (the event gets in). Both halves have now shipped, so what this file holds is the record of what each implementation rejected — which is the half worth keeping, because a design is only tested by building it.
Shipped: channels for external workflows
Authenticated provider events enter ordinary LangGraph workflows, which can return a coupled reply or select a durable destination through another provider. Read channels.md to see how workflows and channels fit together.
Its phase 1 was explicitly allowed to kill it: write the WhatsApp example against raw primitives with no pipeline at all, and if it came out short, ship only the helpers and stop. It did not — but the reason to proceed was not the one predicted. The hand-written version was 334 lines against a "under 60" target, and the pipeline brought it to 65, which is a near miss rather than a win. What justified the work was that three of the seven steps could not be made correct from user code at all, and each failed silently: a start that discarded a pending question (#35), a question unreachable from the callback (#36), and a retry refused for having done the right thing. All three shipped as defect fixes in their own right.
What the implementation rejected, each argued for in the design and each wrong:
EventSourceas a name. It ships asChannel— the word people already use for putting an agent on WhatsApp, and the one Twilio, Intercom and Zendesk use. The payload stayedInboundEvent, sofrom/to/body/typingare still absent from the core types and a GitHub webhook is still the same pipeline. Four other meanings of "channel" already existed in the repo (RunAbortChannel, Redis pub/sub, LangGraph state channels,stream_mode: "custom"); only one is public API and it is prefixed, so the collision was a discipline problem rather than a semantic one.onExistingas merely a better default. It was specified as a policy with a sensible default. That is not enough: nothing on the server enforced it, becauseinterruptedis a terminal run status. It is now built onif_thread_status, a precondition settled inside the driver's atomic create — #35.replyWithon the custom stream, as designed. Unbuildable. Run frames are published to the event bus and never persisted, so reading a declared reply from the stream loses it on exactly the crash the outbox exists to survive. It became an engine-side capture into the delivery payload — #36.Five
RunSignalkinds.accepted,interruptedandsettledwere cut: the acknowledgement is already the HTTP response, the interrupt now arrives durably in the callback, and the motivating provider's indicator clears itself when the reply lands, sosettledhad no call to make. Onlyprogressandkeepaliveshipped.A new
RouteGroup. A group is a member of a closed union that can never be withdrawn, and it is 1:1 with LangGraph'shttp.disable_*flags — so"channels"would have needed a skein-onlydisable_channelsin the un-namespacedhttpblock. The routes are appended only when a channel is configured, which is strictly stronger than a disable flag.rawBodyas the hard part of the transport work. Twilio signs the URL plus parsed, sorted params, so the motivating provider never needed raw bytes. The genuinely hard half was the public URL, and nothing in the repo resolves one — the one place that considered it refused, callingx-forwarded-protospoofable. It became configuration. What the transports actually needed was for a form-encoded body to arrive at all: Fastify 415'd it before any handler ran.A
skein+channel://URL the caller could name. The first delivery design keyed the dispatcher on a URL scheme — reachable from the caller-suppliedwebhookfield, sincez.string().url()accepts any scheme. That would have let a run create deliver an attacker's message through someone else's provider account.webhookis now restricted tohttp(s)at the schema, which is the only boundary that can tell a caller-supplied URL from a server-derived one."Resume with the same
input."inputis a graph input envelope;interrupt()returns whatever the node asked for, usually a scalar. Handing a message-shaped graph its own envelope makes the node read nonsense.InboundEvent.resumeWithsays which is which, and skein still coerces nothing.Under 60 lines. The Twilio integration came out at 65, against a 334-line hand-written baseline. The line count was never the strongest argument: three of the seven steps could not be made correct from user code at all, and each failed silently.
Shipped: durable outbound delivery
A run's completion notification could be lost with no record and no retry — a defect in the server whether or not anything was built on top of it. It now survives a receiver outage, a redeploy and a crash, and receivers can authenticate it. Read webhooks.md and langgraph-cli-compat.md to use it.
The proposal itself is gone from this directory, because this directory is for work that is not yet built. What is worth keeping is what the implementation rejected, since each was argued for in the design and turned out to be wrong:
- The stated problem was wrong. The proposal argued from "webhooks are best-effort and unsigned", but
webhookDispatcherwas already injectable — an embedder could roll retries, signing and allowlists themselves. Two things survived scrutiny and justified the work:buildRuntimehas no override seam at all, so every CLI deployment can inject nothing; and nobody, in any persona, can close the crash window between the terminal status write and the first line of a caller's dispatcher, because it opens before caller code runs. - A conditional delivery insert. Making the insert conditional on winning the finalize would have forced the cancel paths to enqueue deliveries too, and opened a window where a cancel that beats the engine notifies nobody. The insert is unconditional inside a conditional finalize, so the cancel paths are untouched; a
run_statuscolumn carries whichever status actually committed. secrets.path, and a globalGET /webhook-deliverieswith its ownRouteGroup. An embedder already has a more expressive hook than the proposedWebhookSecretsinterface, and a route group is a member of a closed union that can never be withdrawn. The delivery routes are run-scoped instead, which keeps a global list purely additive.- A retry worker of our own. On Redis the whole schedule — delayed jobs, exponential backoff with jitter, stalled-job recovery — is BullMQ's. In-memory is a development driver, and a design constrained by what it can do under-serves every real deployment. Our polling loop survives as that development path and as the recovery sweep for the one gap a queue cannot cover: a crash between the outbox COMMIT and the enqueue, since a Redis
add()cannot join a Postgres transaction.
Three things these used to depend on have since shipped, which is why the round trip is now buildable at all. Idempotent run creation (Idempotency-Key) landed in 0.14 as this proposal's first phase — see agent-protocol.md. And #7, where SDK thread/run options (if_exists, if_not_exists, after_seconds) were silently ignored, shipped in 0.13.1 — those are the primitives an external service needs to address a conversation it did not create, and if_exists making thread creation idempotent by construction is why Idempotency-Key ended up runs-only.
How to read these
These are not user documentation, and while a proposal is open they describe intent rather than behaviour — nothing is shipped until it appears in docs/roadmap.md as done. None of these files are part of the llms-full.txt bundle (that list is curated in scripts/generate-llms-full.mjs and covers user-facing docs only). When a proposal ships, the durable explanation moves into a real doc and the proposal becomes history.
Each carries its own non-goals and open questions sections. Those are the load-bearing parts: a proposal that only argues for itself is a pitch, not a design.
Contributors working in this repo can run that argument mechanically: /audit-plan audits a proposal against the shipped API before any of it is built, asking of each capability whether it is the library's problem or the user's, whether the primitives to solve it already exist, and what permanent public surface it would commit us to.