Skip to content

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.

Every workflow expands into three operations, named from the workflow’s name:

OperationGenerated namePayloadReturns
Execute<Name>the workflow payloadthe workflow success / error
Discard<Name>Discardthe workflow payloadvoid (fire-and-forget start)
Resume<Name>Resume{ executionId: string }void
  • Execute runs the workflow and waits for its result, surfacing the workflow’s normal success and error schemas.
  • 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 both WorkflowProxy and the matching WorkflowProxyServer layer. They must agree or the handlers won’t line up with the contract.

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 contract
const myWorkflows = [EmailWorkflow] as const

Now 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, EmailWorkflowResume
class 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.

  1. Use RPC when both ends are Effect services and you want the tightest, fully-typed client with no manual route handling. The shared RpcGroup is the only contract.

  2. 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 POST routes.

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.

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"

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")

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<...>>

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<...>>

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", ...>

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>/resume

WorkflowProxyServer.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`>