Custom Runtime
Worker, Lambda, and Container cover the common cases. This guide is for bringing the Functions & Servers model to a new compute target. A custom runtime is a Provider with two jobs: provision the compute infrastructure, and bundle + upload the runtime Effect.
The running reference is AWS.ECS.Task
(AWS/ECS/Task.ts)
— it bundles an inline Effect program, builds a Docker image, and
registers a Fargate task definition. Every built-in runtime —
Cloudflare.Worker, AWS.Lambda.Function, AWS.EC2.Instance —
follows the same two-part shape.
The two halves
Section titled “The two halves”A runtime resource splits into a constructor and a provider:
// 1. The constructor — what users call. Built on `Platform`.export const Task: Platform<Task, TaskServices, TaskShape, TaskRuntimeContext> = Platform("AWS.ECS.Task", { createRuntimeContext: createHostRuntimeContext("AWS.ECS.Task"), });
// 2. The provider — the lifecycle Layer. Ordinary `Provider.effect`.export const TaskProvider = () => Provider.effect(Task, Effect.gen(function* () { /* ... */ }));Platform handles the Effect plumbing — running the user’s init
Effect, collecting handlers, wiring Bindings
— so the provider only has to do what every provider does:
reconcile cloud state.
Declare the Resource
Section titled “Declare the Resource”The Resource contract is a plain Resource declaration. The fourth
type parameter is the Binding Contract — the data shape upstream
capabilities attach to this host:
export interface Task extends Resource< "AWS.ECS.Task", TaskProps, // main, cpu, memory, port, env, ... { taskDefinitionArn: string; imageUri: string; taskRoleArn: string; // ... code: { hash: string }; }, { env?: Record<string, any>; policyStatements?: PolicyStatement[]; }> {}On AWS the contract is env vars + IAM statements; a Cloudflare
Worker instead accepts { bindings: Worker.Binding[] }. Your
contract is whatever your platform needs from a binding.
The Platform type
Section titled “The Platform type”Platform takes four type parameters:
export interface Platform< Resource extends ResourceLike<string, PlatformProps>, Services, MainShape, RuntimeContext extends BaseRuntimeContext,> extends Effect.Effect<Resource & RuntimeContext, never, Resource> { /* ... */ }| Parameter | ECS Task instantiation | Meaning |
|---|---|---|
Resource | Task | The Resource contract above |
Services | Credentials | Region | ServerHost | AWSEnvironment | Services the runtime provides to the user’s init Effect |
MainShape | Main<TaskServices> | What the init Effect may return — { fetch?: HttpEffect } |
RuntimeContext | TaskRuntimeContext extends HostRuntimeContext | The mutable context that collects handlers, env, and exports |
The value-level factory takes the type string and a
createRuntimeContext hook (plus an optional onCreate hook —
Cloudflare.Worker uses it to register child resources for async
bindings):
import { Platform, type Main } from "alchemy";
export type TaskShape = Main<TaskServices>;
export const Task: Platform<Task, TaskServices, TaskShape, TaskRuntimeContext> = Platform("AWS.ECS.Task", { createRuntimeContext: createHostRuntimeContext("AWS.ECS.Task") as ( id: string, ) => TaskRuntimeContext, });The RuntimeContext
Section titled “The RuntimeContext”createRuntimeContext(id) returns the object that accumulates
everything the init Effect registers. Its contract is
BaseRuntimeContext from alchemy/RuntimeContext:
export interface BaseRuntimeContext { Type: string; id: string; env: Record<string, any>; get<T>(key: string): Effect.Effect<T | undefined>; set(id: string, output: Output): Effect.Effect<string>; exports?: Effect.Effect<Record<string, any>>; serve?<Req = never>( handler: HttpEffect<Req>, options?: { shape?: Record<string, unknown> }, ): Effect.Effect<void, never, Req>;}set— called during plan when a binding or config lookup captures anOutput; store it under an env key.get— called at runtime inside the deployed artifact; read the same key back from the environment.serve— receives the user’sfetchhandler (and RPC shape); register it however your platform serves HTTP.exports— resolves to whatever the bundled entrypoint needs to run — for hosts, a singleprogramEffect.
Server-style platforms don’t write this from scratch —
createHostRuntimeContext in
Server/Process.ts
is the shared implementation used by AWS.ECS.Task and
AWS.EC2.Instance. It folds run (background loops) and serve
(HTTP handlers) into one list of runners:
run: (effect) => Effect.sync(() => { runners.push(effect);}),serve: (handler) => Effect.sync(() => { runners.push(Http.serve(handler));}),exports: Effect.sync(() => ({ program: Effect.all(runners, { concurrency: "unbounded" }),})),After running the init Effect, Platform hands any returned fetch
handler to serve, then folds the context back onto the resource’s
Props — so everything init recorded reaches your provider:
// inside Platform (Platform.ts) — what your provider receives as `news`instance.Props = { ...props, env: { ...props?.env, ...runtimeContext.env }, exports: runtimeContext.exports ? yield* runtimeContext.exports : undefined,};Implement the Provider
Section titled “Implement the Provider”The provider is a standard lifecycle Layer — reconcile + delete,
optional diff/read/list. The
Custom Provider guide covers the
contract in depth; the runtime-specific part is that reconcile
also bundles and ships the handler:
reconcile: Effect.fn(function* ({ id, news, bindings, output, session }) { // 1. Observe/ensure the supporting infrastructure (idempotent helpers). const taskRoleArn = output?.taskRoleArn ?? (yield* createTaskRoleIfNotExists({ id, roleName: taskRoleName }));
// 2. Apply the Binding Contract data (IAM policy + env). const bindingEnv = yield* attachBindings({ roleName: taskRoleName, policyName: taskPolicyName, bindings, });
// 3. Bundle the runtime Effect and ship it. const { files, hash } = yield* bundleProgram(id, news); const imageUri = yield* buildAndPushImage({ id, repositoryUri, hash, files, props }); const taskDefinition = yield* registerTaskDefinition({ /* ... */ });
return { taskDefinitionArn: taskDefinition.taskDefinitionArn!, /* ... */ };}),Each step is independently idempotent — the same observe → ensure →
sync flow works for greenfield creates, updates, and adoption. Don’t
branch on output === undefined; see
Providers › reconcile.
delete tears down everything reconcile created (task definition,
ECR repository, log group, IAM roles), treating “already gone” as
success.
Bundle the handler
Section titled “Bundle the handler”alchemy/Bundle wraps rolldown. Bundle.build takes input/output
options and returns every emitted file plus a content hash:
import * as Bundle from "alchemy/Bundle";
const bundleOutput = yield* Bundle.build( { input: entry, cwd, platform: "node", external: ["bun", "bun:*"] }, { format: "esm", entryFileNames: "index.mjs" },);// bundleOutput.files — entry + chunks; bundleOutput.hash — sha256Ship all of files, not just the entry — dynamic imports split into
chunks, and dropping one crashes the artifact at boot. Use hash as
the immutable artifact version (the Task tags its Docker image
${repositoryUri}:${hash}).
The user’s main module exports the platform class, not a runnable
program — so the bundle needs a generated entrypoint.
Bundle.virtualEntryPlugin wraps main in a bootstrap that resolves
the class, pulls RuntimeContext.exports, and runs the collected
program under your platform’s Layers:
const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
yield* Bundle.build({ input: realMain, plugins: [virtualEntryPlugin((importPath) => `import { ${handler} as handler } from ${JSON.stringify(importPath)};
const program = handler.pipe( Effect.flatMap((task) => task.RuntimeContext.exports), Effect.flatMap((exports) => exports.program), Effect.provide(/* HTTP server + platform Layers */), Effect.scoped,);
Effect.runPromise(program);`)],}, { format: "esm", entryFileNames: "index.mjs" });The bootstrap is the one place the Effect world meets the process
entrypoint. For the Task it provides a Bun HTTP server bound to
PORT so the { fetch } handler is actually served and host.run
loops stay alive; your platform provides whatever its host needs.
Honor the phase split
Section titled “Honor the phase split”The same init Effect runs twice — at plantime to record bindings,
and at cold start inside the artifact to build live clients. See
Phases. Two mechanisms keep the
two runs honest, and Bundle.build wires the first automatically:
// Bundle.ts — folded into every bundle via rolldown transform.defineconst ALCHEMY_DEFINE = { "globalThis.__ALCHEMY_RUNTIME__": "true",};Every if (!globalThis.__ALCHEMY_RUNTIME__) guard in a
Binding.Service becomes if (!true) in the bundle and is
dead-code-eliminated — provisioning code never ships.
The second is the ALCHEMY_PHASE config key. Your provider must set
it to "runtime" in the shipped environment:
const alchemyEnv = { ALCHEMY_STACK_NAME: stack.name, ALCHEMY_STAGE: stack.stage, ALCHEMY_PHASE: "runtime",};Platform installs a ConfigProvider interceptor keyed on this
phase: at plan it captures every config lookup into the
RuntimeContext via ctx.set; at runtime the same lookup resolves
through ctx.get — reading back the env var your provider shipped.
That round-trip is why set/get must agree on how values are
encoded (the host context JSON-serializes on set and
JSON.parses on get).
Bindings flow through the host
Section titled “Bindings flow through the host”When the user writes yield* AWS.S3.GetObject(bucket) in the init
Effect, the capability calls host.bind`${bucket}`(data) with
data matching your Binding Contract. The engine collects those and
hands them to reconcile as the bindings argument:
const attachBindings = Effect.fn(function* ({ roleName, policyName, bindings }) { const activeBindings = bindings.filter((binding) => binding.action !== "delete");
const env = activeBindings .map((binding) => binding?.data?.env) .reduce((acc, value) => ({ ...acc, ...value }), {});
const policyStatements = activeBindings.flatMap( (binding) => binding?.data?.policyStatements ?? [], );
if (policyStatements.length > 0) { yield* iam.putRolePolicy({ RoleName: roleName, PolicyName: policyName, PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: policyStatements, }), }); }
return env; // merged into the shipped environment});Your provider decides what the contract data means — here IAM
policies attach to the task role and env vars land in the container
definition. A binding removed from code arrives with
action: "delete" so you can converge permissions down, not just up.
What users get
Section titled “What users get”The finished runtime reads like any other Function — infrastructure
props, an init Effect, a { fetch } handler:
import * as AWS from "alchemy/AWS";import { ServerHost } from "alchemy/Server";import * as Effect from "effect/Effect";
export default class ApiTask extends AWS.ECS.Task<ApiTask>()( "ApiTask", { main: import.meta.filename, cpu: 256, memory: 512, port: 3000 }, Effect.gen(function* () { const host = yield* ServerHost; yield* host.run(pollLoop); // long-running background work
return { fetch: Effect.gen(function* () { return HttpServerResponse.text("hello from ecs task"); }), }; }),) {}Everything on this page sits behind that surface: Platform runs
the init Effect and collects host.run + fetch into
exports.program, and the provider bundles that program into an
image and reconciles the task definition around it.
Where next
Section titled “Where next”- Functions & Servers — the model this guide implements a new target for.
- Custom Provider — the
lifecycle contract (
reconcile,delete,diff,read) in depth. - Phases — the init/runtime split your bundle must honor.