Exposing Workflows over RPC & HTTP
A durable Workflow is usually invoked by importing its
implementation and calling MyWorkflow.execute(payload). That only works inside
the process that holds the workflow engine. When the caller lives somewhere else
— a different service, a frontend, another team’s API — you instead want a
transport contract the caller can speak to.
The WorkflowProxy and WorkflowProxyServer modules
(effect/unstable/workflow) derive that contract directly from your workflow
definitions. WorkflowProxy turns a list of workflows into an
RpcGroup or an HttpApiGroup that clients and servers
share; WorkflowProxyServer provides the server-side layer that routes the
generated operations back into the real workflows. You never hand-write a
contract, and the client and server cannot drift apart because both are
generated from the same source.
The three derived operations
Section titled “The three derived operations”Every workflow expands into three operations, named from the workflow’s
name:
| Operation | Generated name | Payload | Returns |
|---|---|---|---|
| Execute | <Name> | the workflow payload | the workflow success / error |
| Discard | <Name>Discard | the workflow payload | void (fire-and-forget start) |
| Resume | <Name>Resume | { executionId: string } | void |
- Execute runs the workflow and waits for its result, surfacing the
workflow’s normal
successanderrorschemas. - Discard starts the workflow on its discard path. It deliberately does not expose the success or error schema — the caller starts the run and moves on.
- Resume re-activates a suspended execution. It takes only the persisted
executionId, because that boundary value identifies the in-flight run and cannot be recomputed from the original payload.
The generated names and HTTP paths come straight from the workflow definitions. Two rules follow from that:
- Keep workflow
names stable. Renaming a workflow renames its operations and breaks existing clients. - Pass the same workflow list (and same
prefix) to bothWorkflowProxyand the matchingWorkflowProxyServerlayer. They must agree or the handlers won’t line up with the contract.
Common case
Section titled “Common case”Start by defining your workflows and pinning the list with as const — that
keeps each workflow’s literal name, payload, success, and error types in the
generated contract.
import { Schema } from "effect"import { Workflow } from "effect/unstable/workflow"
const EmailWorkflow = Workflow.make({ name: "EmailWorkflow", payload: { id: Schema.String, to: Schema.String }, idempotencyKey: ({ id }) => id})
// `as const` preserves the literal names/types for the derived contractconst myWorkflows = [EmailWorkflow] as constNow expose them over either RPC or HTTP. Both follow the same shape: derive a
group with WorkflowProxy, then provide the matching server handler layer from
WorkflowProxyServer.
import { Layer, Schema } from "effect"import { RpcServer } from "effect/unstable/rpc"import { Workflow, WorkflowProxy, WorkflowProxyServer } from "effect/unstable/workflow"
const EmailWorkflow = Workflow.make({ name: "EmailWorkflow", payload: { id: Schema.String, to: Schema.String }, idempotencyKey: ({ id }) => id})
const myWorkflows = [EmailWorkflow] as const
// Derive the shared RpcGroup from the workflows.// Generates: EmailWorkflow, EmailWorkflowDiscard, EmailWorkflowResumeclass MyRpcs extends WorkflowProxy.toRpcGroup(myWorkflows) {}
// Serve it: RpcServer.layer attaches the transport, layerRpcHandlers// routes each RPC back into the real workflow.const ApiLayer = RpcServer.layer(MyRpcs).pipe( Layer.provide(WorkflowProxyServer.layerRpcHandlers(myWorkflows)))The client uses the same MyRpcs group with RpcClient.make (see
RPC: client and server) and gets typed methods
EmailWorkflow, EmailWorkflowDiscard, and EmailWorkflowResume.
import { Layer, Schema } from "effect"import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"import { Workflow, WorkflowProxy, WorkflowProxyServer } from "effect/unstable/workflow"
const EmailWorkflow = Workflow.make({ name: "EmailWorkflow", payload: { id: Schema.String, to: Schema.String }, idempotencyKey: ({ id }) => id})
const myWorkflows = [EmailWorkflow] as const
// Derive an HttpApiGroup named "workflows" and add it to the API.// Paths come from the lower-cased workflow name:// POST /emailworkflow (execute)// POST /emailworkflow/discard (discard)// POST /emailworkflow/resume (resume)class MyApi extends HttpApi.make("api").add( WorkflowProxy.toHttpApiGroup("workflows", myWorkflows)) {}
// Serve it: HttpApiBuilder.layer builds the API, layerHttpApi implements// the "workflows" group against the real workflows.const ApiLayer = HttpApiBuilder.layer(MyApi).pipe( Layer.provide( WorkflowProxyServer.layerHttpApi(MyApi, "workflows", myWorkflows) ))Note the endpoint paths are the lower-cased workflow name, so
EmailWorkflow is served at /emailworkflow. See
HTTP API: serving and clients for how to mount
ApiLayer and generate a client.
Choosing RPC vs HTTP
Section titled “Choosing RPC vs HTTP”-
Use RPC when both ends are Effect services and you want the tightest, fully-typed client with no manual route handling. The shared
RpcGroupis the only contract. -
Use the HTTP API when callers are not Effect (browsers, other languages, webhooks) or you want REST-shaped, OpenAPI-documentable endpoints. Each workflow gets predictable
POSTroutes.
Either way, remember the same three rules: stable workflow names, the same
workflow list on both sides, and a matching prefix (RPC) or group name
(HTTP) between the WorkflowProxy contract and the WorkflowProxyServer layer.
Reference
Section titled “Reference”WorkflowProxy.toRpcGroup
Section titled “WorkflowProxy.toRpcGroup”Derives an RpcGroup from a non-empty list of workflows. Each workflow becomes
three RPCs: <Prefix><Name> (execute, with the workflow success/error),
<Prefix><Name>Discard (payload only), and <Prefix><Name>Resume (takes
{ executionId }). Pass { prefix } to namespace the RPC tags — the same
prefix must be used by layerRpcHandlers.
import { Schema } from "effect"import { Workflow, WorkflowProxy } from "effect/unstable/workflow"
const Email = Workflow.make({ name: "Email", payload: { to: Schema.String }, idempotencyKey: ({ to }) => to})
class MyRpcs extends WorkflowProxy.toRpcGroup([Email] as const, { prefix: "wf."}) {}// => RpcGroup with RPCs: "wf.Email", "wf.EmailDiscard", "wf.EmailResume"WorkflowProxy.toHttpApiGroup
Section titled “WorkflowProxy.toHttpApiGroup”Derives an HttpApiGroup with the given group name. Each workflow adds three
POST endpoints whose paths are the lower-cased workflow name: /<name>
(execute), /<name>/discard, and /<name>/resume. The endpoint names keep
the original casing (<Name>, <Name>Discard, <Name>Resume).
import { Schema } from "effect"import { HttpApi } from "effect/unstable/httpapi"import { Workflow, WorkflowProxy } from "effect/unstable/workflow"
const Email = Workflow.make({ name: "Email", payload: { to: Schema.String }, idempotencyKey: ({ to }) => to})
class MyApi extends HttpApi.make("api").add( WorkflowProxy.toHttpApiGroup("workflows", [Email] as const)) {}// => group "workflows" with endpoints:// POST /email (name "Email")// POST /email/discard (name "EmailDiscard")// POST /email/resume (name "EmailResume")WorkflowProxyServer.layerRpcHandlers
Section titled “WorkflowProxyServer.layerRpcHandlers”Builds a Layer of RPC handlers for the workflows, wiring each generated RPC to
the matching workflow operation: execute calls workflow.execute(payload),
discard calls workflow.execute(payload, { discard: true }), and resume calls
workflow.resume(executionId). Requires WorkflowEngine and the workflows’
RequirementsHandler. Pass the same { prefix } you gave to toRpcGroup.
import { Layer, Schema } from "effect"import { RpcServer } from "effect/unstable/rpc"import { Workflow, WorkflowProxy, WorkflowProxyServer } from "effect/unstable/workflow"
const Email = Workflow.make({ name: "Email", payload: { to: Schema.String }, idempotencyKey: ({ to }) => to})const workflows = [Email] as const
class MyRpcs extends WorkflowProxy.toRpcGroup(workflows, { prefix: "wf." }) {}
const ApiLayer = RpcServer.layer(MyRpcs).pipe( // same prefix on both sides, or the handlers won't match the contract Layer.provide(WorkflowProxyServer.layerRpcHandlers(workflows, { prefix: "wf." })))// => Layer<RpcHandlers, never, WorkflowEngine | RequirementsHandler<...>>WorkflowProxyServer.layerHttpApi
Section titled “WorkflowProxyServer.layerHttpApi”Builds a Layer implementing the HTTP API group produced by toHttpApiGroup,
routing execute/discard/resume endpoints to the workflows. Takes the HttpApi,
the group name, and the workflow list — all three must match what you passed
to toHttpApiGroup. Requires WorkflowEngine and the workflows’
RequirementsHandler.
import { Layer, Schema } from "effect"import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"import { Workflow, WorkflowProxy, WorkflowProxyServer } from "effect/unstable/workflow"
const Email = Workflow.make({ name: "Email", payload: { to: Schema.String }, idempotencyKey: ({ to }) => to})const workflows = [Email] as const
class MyApi extends HttpApi.make("api").add( WorkflowProxy.toHttpApiGroup("workflows", workflows)) {}
const ApiLayer = HttpApiBuilder.layer(MyApi).pipe( Layer.provide( // name "workflows" matches the group above WorkflowProxyServer.layerHttpApi(MyApi, "workflows", workflows) ))// => Layer<ApiGroup<"api", "workflows">, never, WorkflowEngine | RequirementsHandler<...>>Type-level helpers
Section titled “Type-level helpers”These types describe the generated contract; you rarely reference them directly, but they are useful when writing generic wrappers over the proxy.
WorkflowProxy.ConvertRpcs<Workflows, Prefix>
Section titled “WorkflowProxy.ConvertRpcs<Workflows, Prefix>”Maps each workflow to the union of its three generated RPC types
(Rpc<`${Prefix}${Name}`, ...>, ...Discard, ...Resume). This is the type
parameter of the RpcGroup returned by toRpcGroup.
import type { WorkflowProxy } from "effect/unstable/workflow"import type { Workflow } from "effect/unstable/workflow"
type Rpcs = WorkflowProxy.ConvertRpcs<Workflow.Any, "wf.">// => Rpc<"wf.${Name}", ...> | Rpc<"wf.${Name}Discard", ...> | Rpc<"wf.${Name}Resume", ...>WorkflowProxy.ConvertHttpApi<Workflows>
Section titled “WorkflowProxy.ConvertHttpApi<Workflows>”Maps each workflow to the union of its three generated HttpApiEndpoint types,
each a POST at the lower-cased path. This is the contract carried by the
HttpApiGroup from toHttpApiGroup.
import type { WorkflowProxy } from "effect/unstable/workflow"import type { Workflow } from "effect/unstable/workflow"
type Endpoints = WorkflowProxy.ConvertHttpApi<Workflow.Any>// => POST /<name> | POST /<name>/discard | POST /<name>/resumeWorkflowProxyServer.RpcHandlers<Workflows, Prefix>
Section titled “WorkflowProxyServer.RpcHandlers<Workflows, Prefix>”The union of Rpc.Handler services produced by layerRpcHandlers — the
services it provides into the environment.
import type { WorkflowProxyServer } from "effect/unstable/workflow"import type { Workflow } from "effect/unstable/workflow"
type Handlers = WorkflowProxyServer.RpcHandlers<Workflow.Any, "">// => Rpc.Handler<Name> | Rpc.Handler<`${Name}Discard`> | Rpc.Handler<`${Name}Resume`>