1---
2title: Custom Runtime
3description: Implement your own Runtime resource on the Platform type — a Provider that provisions the compute and bundles the runtime Effect.
4---
5
6Worker, Lambda, and Container cover the common cases. This guide is
7for bringing the [Runtime](/infrastructure-as-effects/runtime)
8model to a new compute target. A custom runtime is a
9[Provider](/infrastructure-as-code/provider) with two jobs: provision
10the compute infrastructure, and bundle + upload the runtime Effect.
11
12The running reference is `AWS.ECS.Task`
13([`AWS/ECS/Task.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/AWS/ECS/Task.ts))
14— it bundles an inline Effect program, builds a Docker image, and
15registers a Fargate task definition. Every built-in runtime —
16`Cloudflare.Worker`, `AWS.Lambda.Function`, `AWS.EC2.Instance` —
17follows the same two-part shape.
18
19## The two halves
20
21A runtime resource splits into a **constructor** and a **provider**:
22
23```typescript
24// 1. The constructor — what users call. Built on `Platform`.
25export const Task: Platform<Task, TaskServices, TaskShape, TaskRuntimeContext> =
26 Platform("AWS.ECS.Task", {
27 createRuntimeContext: createHostRuntimeContext("AWS.ECS.Task"),
28 });
29
30// 2. The provider — the lifecycle Layer. Ordinary `Provider.effect`.
31export const TaskProvider = () =>
32 Provider.effect(Task, Effect.gen(function* () { /* ... */ }));
33```
34
35`Platform` handles the Effect plumbing — running the user's constructor
36Effect, collecting handlers, wiring [Bindings](/infrastructure-as-effects/binding)
37— so the provider only has to do what every provider does:
38reconcile cloud state.
39
40## Declare the Resource
41
42The Resource contract is a plain `Resource` declaration. The fourth
43type parameter is the **Binding Contract** — the data shape upstream
44capabilities attach to this host:
45
46```typescript
47export interface Task extends Resource<
48 "AWS.ECS.Task",
49 TaskProps, // main, cpu, memory, port, env, ...
50 {
51 taskDefinitionArn: string;
52 imageUri: string;
53 taskRoleArn: string;
54 // ...
55 code: { hash: string };
56 },
57 {
58 env?: Record<string, any>;
59 policyStatements?: PolicyStatement[];
60 }
61> {}
62```
63
64On AWS the contract is env vars + IAM statements; a Cloudflare
65Worker instead accepts `{ bindings: Worker.Binding[] }`. Your
66contract is whatever *your* platform needs from a binding.
67
68## The `Platform` type
69
70`Platform` takes four type parameters:
71
72```typescript
73export interface Platform<
74 Resource extends ResourceLike<string, PlatformProps>,
75 Services,
76 MainShape,
77 RuntimeContext extends BaseRuntimeContext,
78> extends Effect.Effect<Resource & RuntimeContext, never, Resource> { /* ... */ }
79```
80
81| Parameter | ECS Task instantiation | Meaning |
82| ---------------- | ------------------------------------------------------- | -------------------------------------------------------------- |
83| `Resource` | `Task` | The Resource contract above |
84| `Services` | `Credentials \| Region \| ServerHost \| AWSEnvironment` | Services the runtime provides to the user's constructor Effect |
85| `MainShape` | `Main<TaskServices>` | What the constructor Effect may return — `{ fetch?: HttpEffect }` |
86| `RuntimeContext` | `TaskRuntimeContext extends HostRuntimeContext` | The mutable context that collects handlers, env, and exports |
87
88The value-level factory takes the type string and a
89`createRuntimeContext` hook (plus an optional `onCreate` hook —
90`Cloudflare.Worker` uses it to register child resources for async
91bindings):
92
93```typescript
94import { Platform, type Main } from "alchemy";
95
96export type TaskShape = Main<TaskServices>;
97
98export const Task: Platform<Task, TaskServices, TaskShape, TaskRuntimeContext> =
99 Platform("AWS.ECS.Task", {
100 createRuntimeContext: createHostRuntimeContext("AWS.ECS.Task") as (
101 id: string,
102 ) => TaskRuntimeContext,
103 });
104```
105
106## The RuntimeContext
107
108`createRuntimeContext(id)` returns the object that accumulates
109everything the constructor Effect registers. Its contract is
110`BaseRuntimeContext` from `alchemy/RuntimeContext`:
111
112```typescript
113export interface BaseRuntimeContext {
114 Type: string;
115 id: string;
116 env: Record<string, any>;
117 get<T>(key: string): Effect.Effect<T | undefined>;
118 set(id: string, output: Output): Effect.Effect<string>;
119 exports?: Effect.Effect<Record<string, any>>;
120 serve?<Req = never>(
121 handler: HttpEffect<Req>,
122 options?: { shape?: Record<string, unknown> },
123 ): Effect.Effect<void, never, Req>;
124}
125```
126
127- **`set`** — called during plan when a binding or config lookup
128 captures an `Output`; store it under an env key.
129- **`get`** — called at runtime inside the deployed artifact; read
130 the same key back from the environment.
131- **`serve`** — receives the user's `fetch` handler (and RPC shape);
132 register it however your platform serves HTTP.
133- **`exports`** — resolves to whatever the bundled entrypoint needs
134 to run — for hosts, a single `program` Effect.
135
136Server-style platforms don't write this from scratch —
137`createHostRuntimeContext` in
138[`Server/Process.ts`](https://github.com/alchemy-run/alchemy/blob/main/packages/alchemy/src/Server/Process.ts)
139is the shared implementation used by `AWS.ECS.Task` and
140`AWS.EC2.Instance`. It folds `run` (background loops) and `serve`
141(HTTP handlers) into one list of runners:
142
143```typescript
144run: (effect) => Effect.sync(() => {
145 runners.push(effect);
146}),
147serve: (handler) => Effect.sync(() => {
148 runners.push(Http.serve(handler));
149}),
150exports: Effect.sync(() => ({
151 program: Effect.all(runners, { concurrency: "unbounded" }),
152})),
153```
154
155After running the constructor Effect, `Platform` hands any returned `fetch`
156handler to `serve`, then folds the context back onto the resource's
157Props — so everything the constructor recorded reaches your provider:
158
159```typescript
160// inside Platform (Platform.ts) — what your provider receives as `news`
161instance.Props = {
162 ...props,
163 env: { ...props?.env, ...runtimeContext.env },
164 exports: runtimeContext.exports ? yield* runtimeContext.exports : undefined,
165};
166```
167
168## Implement the Provider
169
170The provider is a standard lifecycle Layer — `reconcile` + `delete`,
171optional `diff`/`read`/`list`. The
172[Custom Provider](/infrastructure-as-code/custom-provider) guide covers the
173contract in depth; the runtime-specific part is that `reconcile`
174also **bundles and ships the handler**:
175
176```typescript
177reconcile: Effect.fn(function* ({ id, news, bindings, output, session }) {
178 // 1. Observe/ensure the supporting infrastructure (idempotent helpers).
179 const taskRoleArn = output?.taskRoleArn
180 ?? (yield* createTaskRoleIfNotExists({ id, roleName: taskRoleName }));
181
182 // 2. Apply the Binding Contract data (IAM policy + env).
183 const bindingEnv = yield* attachBindings({
184 roleName: taskRoleName,
185 policyName: taskPolicyName,
186 bindings,
187 });
188
189 // 3. Bundle the runtime Effect and ship it.
190 const { files, hash } = yield* bundleProgram(id, news);
191 const imageUri = yield* buildAndPushImage({ id, repositoryUri, hash, files, props });
192 const taskDefinition = yield* registerTaskDefinition({ /* ... */ });
193
194 return { taskDefinitionArn: taskDefinition.taskDefinitionArn!, /* ... */ };
195}),
196```
197
198Each step is independently idempotent — the same observe → ensure →
199sync flow works for greenfield creates, updates, and adoption. Don't
200branch on `output === undefined`; see
201[Providers › reconcile](/infrastructure-as-code/provider#reconcile).
202
203`delete` tears down everything reconcile created (task definition,
204ECR repository, log group, IAM roles), treating "already gone" as
205success.
206
207## Bundle the handler
208
209`alchemy/Bundle` wraps rolldown. `Bundle.build` takes input/output
210options and returns every emitted file plus a content hash:
211
212```typescript
213import * as Bundle from "alchemy/Bundle";
214
215const bundleOutput = yield* Bundle.build(
216 { input: entry, cwd, platform: "node", external: ["bun", "bun:*"] },
217 { format: "esm", entryFileNames: "index.mjs" },
218);
219// bundleOutput.files — entry + chunks; bundleOutput.hash — sha256
220```
221
222Ship all of `files`, not just the entry — dynamic imports split into
223chunks, and dropping one crashes the artifact at boot. Use `hash` as
224the immutable artifact version (the Task tags its Docker image
225`${repositoryUri}:${hash}`).
226
227The user's `main` module exports the platform class, not a runnable
228program — so the bundle needs a generated entrypoint.
229`Bundle.virtualEntryPlugin` wraps `main` in a bootstrap that resolves
230the class, pulls `RuntimeContext.exports`, and runs the collected
231program under your platform's Layers:
232
233```typescript
234const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
235
236yield* Bundle.build({
237 input: realMain,
238 plugins: [virtualEntryPlugin((importPath) => `
239import { ${handler} as handler } from ${JSON.stringify(importPath)};
240
241const program = handler.pipe(
242 Effect.flatMap((task) => task.RuntimeContext.exports),
243 Effect.flatMap((exports) => exports.program),
244 Effect.provide(/* HTTP server + platform Layers */),
245 Effect.scoped,
246);
247
248Effect.runPromise(program);
249`)],
250}, { format: "esm", entryFileNames: "index.mjs" });
251```
252
253The bootstrap is the one place the Effect world meets the process
254entrypoint. For the Task it provides a Bun HTTP server bound to
255`PORT` so the `{ fetch }` handler is actually served and `host.run`
256loops stay alive; your platform provides whatever its host needs.
257
258## Honor the phase split
259
260The same constructor Effect runs twice — at plantime to record bindings,
261and at cold start inside the artifact to build live clients. See
262[Phases](/infrastructure-as-effects/phases). Two mechanisms keep the
263two runs honest, and `Bundle.build` wires the first automatically:
264
265```typescript
266// Bundle.ts — folded into every bundle via rolldown transform.define
267const ALCHEMY_DEFINE = {
268 "globalThis.__ALCHEMY_RUNTIME__": "true",
269};
270```
271
272Every `if (!globalThis.__ALCHEMY_RUNTIME__)` guard in a
273`Binding.Service` becomes `if (!true)` in the bundle and is
274dead-code-eliminated — provisioning code never ships.
275
276The second is the `ALCHEMY_PHASE` config key. Your provider must set
277it to `"runtime"` in the shipped environment:
278
279```typescript
280const alchemyEnv = {
281 ALCHEMY_STACK_NAME: stack.name,
282 ALCHEMY_STAGE: stack.stage,
283 ALCHEMY_PHASE: "runtime",
284};
285```
286
287`Platform` installs a `ConfigProvider` interceptor keyed on this
288phase: at `plan` it captures every config lookup into the
289RuntimeContext via `ctx.set`; at `runtime` the same lookup resolves
290through `ctx.get` — reading back the env var your provider shipped.
291That round-trip is why `set`/`get` must agree on how values are
292encoded (the host context JSON-serializes on `set` and
293`JSON.parse`s on `get`).
294
295## Bindings flow through the host
296
297When the user writes `yield* AWS.S3.GetObject(bucket)` in the constructor
298Effect, the capability calls ``host.bind`${bucket}`(data)`` with
299data matching your Binding Contract. The engine collects those and
300hands them to `reconcile` as the `bindings` argument:
301
302```typescript
303const attachBindings = Effect.fn(function* ({ roleName, policyName, bindings }) {
304 const activeBindings = bindings.filter((binding) => binding.action !== "delete");
305
306 const env = activeBindings
307 .map((binding) => binding?.data?.env)
308 .reduce((acc, value) => ({ ...acc, ...value }), {});
309
310 const policyStatements = activeBindings.flatMap(
311 (binding) => binding?.data?.policyStatements ?? [],
312 );
313
314 if (policyStatements.length > 0) {
315 yield* iam.putRolePolicy({
316 RoleName: roleName,
317 PolicyName: policyName,
318 PolicyDocument: JSON.stringify({
319 Version: "2012-10-17",
320 Statement: policyStatements,
321 }),
322 });
323 }
324
325 return env; // merged into the shipped environment
326});
327```
328
329Your provider decides what the contract data *means* — here IAM
330policies attach to the task role and env vars land in the container
331definition. A binding removed from code arrives with
332`action: "delete"` so you can converge permissions down, not just up.
333
334## What users get
335
336The finished runtime reads like any other Function — infrastructure
337props, a constructor Effect, a `{ fetch }` handler:
338
339```typescript
340import * as AWS from "alchemy/AWS";
341import { ServerHost } from "alchemy/Server";
342import * as Effect from "effect/Effect";
343
344export default class ApiTask extends AWS.ECS.Task<ApiTask>()(
345 "ApiTask",
346 { main: import.meta.filename, cpu: 256, memory: 512, port: 3000 },
347 Effect.gen(function* () {
348 const host = yield* ServerHost;
349 yield* host.run(pollLoop); // long-running background work
350
351 return {
352 fetch: Effect.gen(function* () {
353 return HttpServerResponse.text("hello from ecs task");
354 }),
355 };
356 }),
357) {}
358```
359
360Everything on this page sits behind that surface: `Platform` runs
361the constructor Effect and collects `host.run` + `fetch` into
362`exports.program`, and the provider bundles that program into an
363image and reconciles the task definition around it.
364
365## Where next
366
367- [Runtime](/infrastructure-as-effects/runtime) —
368 the model this guide implements a new target for.
369- [Custom Provider](/infrastructure-as-code/custom-provider) — the
370 lifecycle contract (`reconcile`, `delete`, `diff`, `read`) in depth.
371- [Phases](/infrastructure-as-effects/phases) — the Construction/Runtime split
372 your bundle must honor.