Skip to content

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.

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.

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.

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> { /* ... */ }
ParameterECS Task instantiationMeaning
ResourceTaskThe Resource contract above
ServicesCredentials | Region | ServerHost | AWSEnvironmentServices the runtime provides to the user’s init Effect
MainShapeMain<TaskServices>What the init Effect may return — { fetch?: HttpEffect }
RuntimeContextTaskRuntimeContext extends HostRuntimeContextThe 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,
});

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 an Output; 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’s fetch handler (and RPC shape); register it however your platform serves HTTP.
  • exports — resolves to whatever the bundled entrypoint needs to run — for hosts, a single program Effect.

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,
};

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.

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 — sha256

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

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.define
const 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).

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.

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.

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