Replies: 1 comment
|
Some thoughts about what the python version might look like: In python workflow, hooks are defined by subclassing class RespondingHook[TResponse](Hook):
response: TResponse
class BaseRespondingHook[TResponse](BaseHook):
async def respond(self, value: TResponse) -> None:
...
# returns a context manager that gives back something with a .write() method
async def respond_stream(self) -> _HookStreamWriter[TResponse]:
...and then using it looks like: class Response(pydantic.BaseModel):
...
class Query(BaseHook[Response], pydantic.BaseModel):
...
@wf.step
async def reply(hook: Query, response: Response) -> None:
await hook.respond(response)
@wf.workflow
async def wf() -> None:
async for query in Query.wait(token=...):
response = ...
await reply(query, response) |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
This RFC proposes
createRespondingHook(): a Hook that can send a value back to whoever resumed it.The capability already exists in the SDK, but only inside
createWebhook({ respondWith: 'manual' }), where it is welded to HTTP semantics and a public URL. This RFC lifts it out into its own primitive, so any resumer — an authenticated route, a Slack event handler, a queue consumer, an MCP tool — can get an answer back from a running workflow.createWebhook()then becomes what it should have been all along: a responding hook specialized toRequest/Response, on a URL we generate for you.Scope. This is a repackaging of the mechanism webhooks already use, exposed for userland consumption. It is not a new World-level capability: no changes to any World, to workflow-server, or to how anything is stored. The generality was recognized when webhooks were added; webhook deliberately kept the narrower single-
Responseshape for simplicity, and this RFC is about making the general shape available on its own.Motivation
Hookis one-way: something outside pushes a value in, and the resumer learns nothing except that the write landed.resumeHook()returns the hook entity.Webhooks are not one-way.
createWebhook({ respondWith: 'manual' })lets a step callrequest.respondWith(response), and the caller's HTTP request stays open until that response arrives. Underneath, the resumer mints a stream, hides its writable in the payload, and reads a value back off it. That machinery is entirely generic — nothing about it needsRequestorResponseor a URL.Today, wanting a reply forces you to accept three things you may not want:
createWebhook()setsisWebhook: true, which makes the hook resumable at/.well-known/workflow/v1/webhook/:tokenby anyone holding the token. The docs already tell you to prefercreateHook()behind your own authenticated route — but doing so gives up the ability to reply.Response, and your message must be aRequest, even when neither side of the interaction is HTTP.createWebhook()throws if you pass one, so the deterministic-token patterns thatcreateHook()supports (order:${orderId}) are off the table.You can call
resumeWebhook(token, request)from your own route, but the hook is still publicly resumable, you still have to construct aRequestand unwrap aResponse, and you still can't choose the token.The gap is that "can reply" is currently inseparable from "exposed over public HTTP." Those are two different decisions and users should get to make them independently.
Proposed API
Workflow side
createRespondingHook<TMessage, TResponse>(options?)returns aRespondingHook<TMessage, TResponse>, which is aHookin every respect — thenable, async-iterable,getConflict(),dispose(),using— except that it yieldsHookMessageenvelopes instead of bare payloads.The envelope is necessary rather than aesthetic: a hook payload can be a string or a number, and there is nowhere to hang a
respondmethod on a primitive..datais always where the payload lives, regardless of its type.respond()takes any serializable value, which is the same set of things a step can return — including aReadableStream. There is no separate streaming API, and there shouldn't be: streams already serialize across every other boundary in the SDK, so a streaming reply is just a reply whose value happens to be a stream. This is the one capability the webhook version doesn't expose today, because a webhook's single chunk is always aResponse(whosebodyis the only stream it can carry).Resumer side
It returns whatever the workflow responded with. If that's a stream, you get a stream:
Options
Notably absent:
isWebhook. A responding hook is not publicly resumable unless you also make it a webhook. That is the whole point.Typed variant
Both sides need the same two type parameters, so the
defineHook()pattern carries over:The ladder
The three primitives become strictly layered, each one a specialization of the previous:
HookRespondingHookWebhookRequest/ResponseStated as code,
createWebhook()collapses to:Rebuilding
createWebhook()on the new primitive is part of the proposal, not a follow-up — an abstraction nothing is built on isn't an abstraction. This is a pure refactor: no change to the public webhook API, no change to the bytes stored on the hook, no change to the.well-knownroute.Example use cases
Human-in-the-loop approval that answers the approver
Today the approver's request returns
202and the UI polls to find out what happened. With a responding hook the decision comes back on the same call:Streaming a reply into a chat UI
An agent workflow that stays alive across a conversation. Each user message is a resume; the assistant's tokens stream back to that specific caller, not to the run's output stream:
This is the case
getWritable()cannot serve:getWritable()writes to the run's stream, which every reader of the run sees. A responding hook gives you a reply channel scoped to one resume call, which is what a request/reply chat turn actually is.Validate at the door
The workflow inspects the payload and tells the caller immediately whether it was accepted, instead of accepting everything and failing invisibly later:
Non-HTTP transports
Slack Events, Discord interactions, an MCP tool handler, a gRPC service, a Kafka consumer that must ack with a result. All of them are request/reply, none of them want a public
.well-knownURL or aResponseobject. Today each one has to fake HTTP throughcreateWebhook; with this they use the primitive directly.Reading live state out of a run
Not the primary use case, but it falls out for free: a hook whose message is a query and whose reply is derived from the workflow's in-memory state.
Semantics
respond()call. A second one throws. Send many values by responding with a stream.respond()be called?"use step"function only, same asrequest.respondWith()today.HookResponseNotSentErroronce the run ends, or ontimeout/signal.createHook()reply?resumeRespondingHook()on a non-responding hook throws.The one that most deserves to be stated plainly in the docs rather than discovered:
respond()is step-only, exactly asrespondWith()is today.Naming
Prior art for the one-way vs. request-reply distinction is unusually consistent:
gen_servercastcalltellaskAlternatives considered:
QueryHook— Temporal's vocabulary, so people (and models) arriving from Temporal recognize the concept immediately and can read up on the nuances. Against: Temporal's Query is specifically a read-only, non-blocking view of workflow state; this is neither, so the word imports a contract we don't honor.UpdateHook— semantically the closer Temporal analogue (Update takes input, may mutate, returns a result). Against: "update" reads as modify a thing at the call site and says nothing about getting an answer back —resumeUpdateHook(token, payload)doesn't describe what happens.RequestHook— gives the tightest one-liner ("a Webhook is a RequestHook over HTTP"), but overloadsRequestin a codebase whereRequestalready means the DOM class, in exactly the API where both appear together.CallableHook/callHook— strongest industry precedent, butcallis close to meaningless in JS, andcreateCallableHookreads worse than the prior art suggests it would.DuplexHook,Exchange,AskHook— respectively jargon, and two that break theHooklineage that makes the concept easy to teach.createHook()(createHook({ respond: true })) — smallest surface, but it makesHook<T>vsHook<TMessage, TResponse>a discriminated mess in the types, and it gives the concept nowhere to live in the docs. It stays a hidden mode rather than the middle rung people learn between hooks and webhooks.There is a general argument against borrowing from Temporal here: Query and Update are heavy concepts in Temporal, each with their own lifecycle, validators, and delivery guarantees. Naming after them makes this look like a third top-level thing to learn. It isn't.
RespondingHookreads as a variant of Hook, which is exactly what it is, and it keepsRequest/Responsefree to mean what they already mean.The resumer-side name is the weakest link.
resumeRespondingHook()is chosen for symmetry withcreateHook/resumeHookandcreateWebhook/resumeWebhook, and it is unambiguous at the call site, but it is long and not pretty. See open questions.Docs
The concept slots directly after Hooks and before Webhooks, which is also the dependency order: a new
foundations/responding-hookspage betweenhooksandstreaming, with the webhook section of the hooks page gaining a line saying it is this primitive specialized to HTTP. Nothing about hooks needs to be learned differently, and nothing about webhooks changes.Non-goals
respondWithdoes today.createWebhook()'s public API, stored bytes, or route.getWritable(). That writes to the run's stream, visible to every reader of the run. This writes to one caller.Open questions
RespondingHookis proposed above;QueryHookandUpdateHookare the live alternatives, weighed in the naming section. The resumer-side verb is more open than the type name:resumeRespondingHook(),askHook(),requestHook(),resumeHookWithResponse(), or an options-driven overload ofresumeHook()that changes its return type.respond()work from workflow context? It is step-only today for a mechanical reason (the real stream only materializes in the step environment), and the existing docs already say "this requirement may be removed in the future." There is a plausible path via an SDK-owned hoisted step, likeworkflow'sfetch. Worth doing now, or later?defineRespondingHook()or extenddefineHook()? A seconddefine*is more discoverable; extending the existing one is less API.respond()throw or no-op?respondWith: 'manual'webhooks pay today. Acceptable, or worth a first-class field on the hook entity in a follow-up?All reactions