Skip to content

2.0.0-beta.86 - Domain on Rivet & Effect Cluster, Sandboxes

beta.86 is 150 pull requests from 24 contributors: 65 features and 60 bug fixes.

Alchemy Domain models now run on Rivet and Effect Cluster, as well as on Cloudflare. Which platform hosts the model is one Layer, and your model code stays the same. Both platforms are new packages, @alchemy.run/rivet and @alchemy.run/effect-cluster, and both deploy to Docker, AWS, GCP, Fly, Hetzner, Kubernetes and more.

The release also adds six sandbox providers for running untrusted code: E2B, Daytona, Modal, Archil, Boat and AWS ECS, where each sandbox is a Nitro Sandbox in your own account. Each one creates sandboxes from any Worker, Lambda or container, and they share one handle (exec, start, files, pty, getTcpPort), so you can pick a provider by providing its Layer. Resource.lookup now binds resources your stack doesn’t manage, on every provider, and RemovalPolicy.protect() refuses any plan that would replace or delete a resource.

Two new packages run actors, stateful objects addressed by name, on any cloud: Rivet’s Durable Objects and Effect Cluster’s entities.

@alchemy.run/rivet deploys Durable Objects to Rivet control planes. The Durable Object is what you deploy. The control plane and the pool its nodes run in are Layers, so moving to another cloud changes two lines (#2849):

src/cluster.ts
export const Cluster = RivetDocker.SwarmControlPlane("Rivet");
export const Default = RivetDocker.SwarmPool("Default").pipe(
export const Cluster = RivetAWS.Ec2ControlPlane("Rivet");
export const Default = RivetAWS.EcsPool("Default").pipe(
Layer.provide(Cluster),
);
src/room.ts
export default class Room extends Rivet.DurableObject<Room>()(
"Room",
{ main: import.meta.url },
Effect.gen(function* () {
return Effect.gen(function* () {
const state = yield* Rivet.DurableObjectState;
return {
join: () =>
state.storage.transaction(
Effect.gen(function* () {
const n = ((yield* state.storage.get<number>("n")) ?? 0) + 1;
yield* state.storage.put("n", n);
return n;
}),
),
};
});
}).pipe(Effect.provide(Default)),
) {}

Any runtime calls an instance by name: a Worker, a Lambda, a container or another Durable Object.

const rooms = yield* Room;
const visitors = yield* rooms.getByName("lobby").join();

Every cloud has a control plane and a pool, and a pool can join a control plane on another cloud:

// Docker Swarm, on your machine or a server
RivetDocker.SwarmControlPlane("Rivet");
RivetDocker.SwarmPool("Default");
// AWS
RivetAWS.Ec2ControlPlane("Rivet"); // or EcsControlPlane
RivetAWS.EcsPool("Default"); // or Ec2Pool
// GCP
RivetGCP.ComputeEngineControlPlane("Rivet");
RivetGCP.CloudRunPool("Default"); // or ComputeEnginePool
// Fly Machines
RivetFly.MachineControlPlane("Rivet");
RivetFly.MachinePool("Default");
// Hetzner
RivetHetzner.ServerControlPlane("Rivet");
RivetHetzner.ServerPool("Default");
// Kubernetes: EKS, GKE, kind or any cluster
RivetKubernetes.StatefulSetControlPlane("Rivet", { cluster: Eks });
RivetKubernetes.DeploymentPool("Default");
// Railway, against a control plane on another cloud
RivetRailway.ServicePool("Default");
// Rivet Cloud
RivetCloud.ControlPlane("Rivet");
RivetCloud.Pool("Default");
// a control plane you already run
Rivet.ControlPlane("Rivet", { endpoint, token });

A self-hosted control plane serves its gateway over HTTPS without a domain of your own, and its engines have no public address.

Durable Streams are typed logs next to your objects:

export class ChatLog extends Rivet.DurableStream<ChatLog>()("ChatLog", {
schema: Schema.Struct({ user: Schema.String, text: Schema.String }),
ttl: "7 days",
}) {}
const log = yield* ChatLog;
yield* log.append("lobby", { user: "sam", text: "hello" });
const live = log.tail("lobby"); // a Stream that reconnects from its offset

The wire format is a Layer. Use StreamFormat.Json, StreamFormat.Ndjson, or StreamFormat.SchemaBinary() for Effect’s compact binary codec, derived from the stream’s Schema:

Effect.provide(Layer.mergeAll(Default, Services, StreamFormat.SchemaBinary()));

Rust Actors written with Rivet’s rivetkit crate deploy on every pool. Alchemy compiles the crate into the pool’s node image, with dependencies in their own cached layer (#3118):

export const Counter = Rivet.DurableObject("Counter", {
crate: "./crates/counter",
build: { toolchain: "1.85", runtimePackages: ["libssl3"] },
}).pipe(Effect.provide(Counters));

Docs: Rivet · What is Rivet? · Pools · Durable Streams · Stream formats · Rust Actors.

@alchemy.run/effect-cluster deploys Effect Cluster entities, singletons, crons and workflows. The database the runners coordinate through and the pool they run in are Layers, so moving to another host changes two lines (#2924):

src/Cluster.ts
export const Database = ClusterDocker.Database("Cluster");
export const Default = ClusterDocker.SwarmPool("Default").pipe(
export const Database = ClusterAWS.Database("Cluster"); // RDS
export const Default = ClusterAWS.EcsPool("Default").pipe( // Fargate
Layer.provide(Database),
);

An entity is declared like Effect Cluster’s Entity.make, and implemented as a Layer:

src/Counter.ts
export const Increment = Rpc.make("Increment", {
payload: { amount: Schema.Int },
success: Schema.Int,
});
export class Counter extends Cluster.Entity<Counter>()("Counter", [
Increment,
]) {}
// src/CounterLive.ts
export default Counter.make(
{ main: import.meta.url },
Effect.gen(function* () {
return Effect.gen(function* () {
const count = yield* Ref.make(0);
return Counter.of({
Increment: ({ payload }) =>
Ref.updateAndGet(count, (n) => n + payload.amount),
});
});
}).pipe(Effect.provide(Default)),
);

Any runtime calls it through the pool’s gateway:

const counters = yield* Counter;
const counter = yield* counters.getByName("visits");
yield* counter.Increment({ amount: 1 });

Every host is a database and a pool:

// Docker Swarm, on your machine or a server
ClusterDocker.Database("Cluster");
ClusterDocker.SwarmPool("Default");
// AWS: RDS and ECS Fargate
ClusterAWS.Database("Cluster");
ClusterAWS.EcsPool("Default");
// GCP: Compute Engine
ClusterGCP.Database("Cluster");
ClusterGCP.ComputeEnginePool("Default");
// Fly Machines
ClusterFly.Database("Cluster");
ClusterFly.MachinePool("Default");
// Hetzner
ClusterHetzner.Database("Cluster");
ClusterHetzner.ServerPool("Default");
// Kubernetes: EKS, GKE, kind or any cluster
ClusterKubernetes.Database("Cluster", { cluster: Eks });
ClusterKubernetes.DeploymentPool("Default");
// Cloudflare Durable Objects: no database
ClusterCloudflare.DurableObjectPool("Default");
// a Postgres you already run: Neon, PlanetScale, RDS
Cluster.Database.fromUrl({ url: Config.Redacted("DATABASE_URL") });

Cluster.Workflow, Cluster.Singleton and Cluster.Cron work the same way, and an existing Effect Cluster program runs as it is on a pool of its own. Every host passes the same 20 conformance cases.

Docs: Effect Cluster · What is Effect Cluster? · Pools · Databases · Workflows.

A Domain model used to run on Cloudflare Durable Objects. It now runs on Rivet Durable Objects and on Effect Cluster entities too. The model, its handlers and its Apis don’t change. The host is one Layer (#3143, #3161):

// src/BankLive.ts: the model on Cloudflare Durable Objects
export default Bank.layer(BankHandlers).pipe(
Layer.provide(DomainCloudflare.layerDurableObjects()),
);
// src/BankLive.ts: the same model on Rivet
export default Bank.layer(BankHandlers).pipe(
Layer.provide(DomainCloudflare.layerDurableObjects()),
Layer.provide(
DomainRivet.layerDurableObjects({
name: "Bank",
main: import.meta.url,
pool: Default,
}),
),
);

DomainCluster.layerEntities takes the same options for Effect Cluster. The pool’s nodes load the file’s default export and host the model there.

Provide BankLive wherever you serve the Api: a Cloudflare Worker, a Lambda or a container. On Rivet and Effect Cluster, that caller deploys the class and reaches it through the pool’s gateway, so it needs no database access or route into the pool.

src/BankWorker.ts
import BankLive from "./BankLive.ts";
export default class BankWorker extends Cloudflare.Worker<BankWorker>()(
"BankWorker",
{ main: import.meta.url },
Effect.gen(function* () {
const api = yield* CustomerApi;
return { fetch: api.fetch };
}).pipe(Effect.provide(BankLive)),
) {}

Every aggregate instance, view key and policy runs as its own Rivet actor or Cluster entity, named like Bank.AccountAggregate/a-1. Sagas run on Rivet’s Durable Object workflow engine or Effect Cluster’s ClusterWorkflowEngine. One Domain conformance suite runs on all three platforms.

Docs: Domain on Rivet · Domain on Effect Cluster · Domain on Cloudflare.

On Cloudflare, a model now lives in one Durable Object class named after it, instead of one class per aggregate, view, policy and saga. Every instance is still its own object, named by what it holds. A Worker declares one class per model however many definitions it has, so it stays far from Cloudflare’s limit of 100 classes per upload (#3049):

Worker
class Bank.AccountAggregate
class Bank.AccountSummaryView
class Bank.OpenAccountWorkflow
class Bank.Domain
Bank.Domain / "Bank.AccountAggregate/a-1"
Bank.Domain / "Bank.AccountSummaryView/a-1"
Bank.Domain / "Bank.OpenAccountWorkflow/s-1"

You don’t migrate anything. The next deploy moves every object of the old classes into its model’s class while the Worker keeps serving, then deletes the old classes. A deploy killed partway leaves every key served, and the next deploy finishes the move:

Moving 2538 Domain objects of 4 classes to Bank.Domain ...
Moved 2538 Domain objects to Bank.Domain
Deleting 4 old Domain classes: every object of theirs has moved.

Classes from before beta.85 move the same way, without a rename (#3165). If two models in one Worker listed the same aggregate, they shared its class back then. The old data goes to every model by default, so the models that should start empty say so:

export const AuditLive = Audit.layer(AuditHandlers, { legacyData: false });

Every Domain declaration is now plain data plus, where it needs one, a Layer. Rules that only compute stay on the declaration. Code that reads services or checks who may read lives on a Layer (#3078):

export class AccountSummary extends Domain.View("AccountSummary", {
from: [Account],
key: Account,
middleware: [Session],
rejects: [NotOwner],
state: Summary,
initial,
evolve: { MoneyWithdrawn: setBalance },
}) {}
export const AccountSummaryLive = AccountSummary.toLayer(
Effect.succeed(
Effect.fn(function* (_request, summary) {
yield* mustOwn(summary?.customerId ?? null);
}),
),
);

Bank.layer(handlers) is a type error until every guarded view and stream has its Layer. Clients never read an aggregate. They read views, streams and queries.

Docs: View security · Aggregate.

Domain.Query is a typed read across keys, answered from a store a projection writes. After its own command, a client passes the receipt, and the query waits until that write is in the store (#2803, #3012). Thanks Taras Kornichuk!

export class AccountLedger extends Domain.Query("AccountLedger", {
input: { account: AccountId },
success: LedgerRow,
paged: true,
waitsFor: [Ledger],
middleware: [Session],
rejects: [NotOwner],
}) {}
const receipt = yield* bank.WithdrawMoney(accountId, { amount: 30 });
const page = yield* bank.AccountLedger(
{ account: accountId },
{ atLeast: receipt, take: 20 },
);

A paged query also streams every entry, fetching each page on demand, so Stream.take stops early (#3174):

const latest = yield* bank.AccountLedger.entries(input, { take: 100 }).pipe(
Stream.take(10),
Stream.runCollect,
);

Queries are served over HTTP (as QUERY /AccountLedger), the CLI and MCP.

Terminal window
$ bank account-ledger --account a-1 --take 20

Docs: Query.

toHttpApi, toMcp, toToolkit and toCli publish only the calls you list in expose, and the list is typed (#2950):

const BankHttp = DomainHttp.toHttpApi(CustomerApi, {
expose: ["DepositMoney", "AccountSummary.get", "AccountSummary.watch"],
});
DomainAi.toMcp(CustomerApi, { expose: ["AccountSummary.watch"] });
// type error: a stream is not a tool

Every declaration takes annotations as its last argument. A description becomes the MCP tool’s text, the CLI help and the OpenAPI description (#2990):

export class Withdraw extends Domain.Command(
"Withdraw",
{ target: WalletId, input: { amount: Schema.Int }, events: [Withdrawn] },
{ description: "Take money out of a wallet. The amount is in cents." },
) {}

Docs: Annotations.

Stories run every order of concurrent work

Section titled “Stories run every order of concurrent work”

A story now runs every order of its concurrent work that can change the outcome, and every run must pass. When the model doesn’t order two events, say so with Story.anyOrder (#3144):

story(
Story.when(subject, new Request()),
Story.then(subject, new Requested(), new Settled()),
Story.then(
registry,
Story.anyOrder(new Recorded(), new Admitted({ payloadHash })),
),
);
// runs twice, policy first and saga first; both must pass

A then that holds in only some orders fails every time, and names the order that broke it, instead of flaking.

Docs: Story.

Six new providers create sandboxes: VMs you can throw away, for code you didn’t write. Use them for agents, code interpreters, CI jobs and previews: E2B, Daytona, Modal, Archil, Boat and AWS ECS (#2930, #2931, #2934, #2933, #3156, #3242).

Each provider builds what its sandboxes start from at deploy, and a runtime binds to it and starts a sandbox per request (#3171).

E2B builds a template from a Dockerfile and snapshots it after setup. Sandboxes start from the snapshot in about a second, pause idle with their memory, and resume on the next call:

// src/Sandbox.ts: built once, at deploy
export const Node = E2B.Template("Node", {
dockerfile: Dockerfile.inline`
FROM node:22-bookworm
RUN apt-get update && apt-get install -y git
`,
});
export const Warm = E2B.Snapshot("Warm", {
template: Node,
run: ["git clone --depth 1 https://github.com/sindresorhus/is repo"],
});
// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime works
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const sandboxes = yield* E2B.CreateSandbox(Warm);
return {
fetch: Effect.gen(function* () {
const sandbox = yield* sandboxes.create({ timeout: 120 });
const result = yield* sandbox.exec("npm test", {
cwd: "/home/user/repo",
});
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(E2B.CreateSandboxHttp)),
) {}

Daytona gives the whole toolbox (processes, files, git, LSP, code interpreter, computer use, SSH), snapshots built from other snapshots, and warm pools that keep sandboxes ready to hand out:

// src/Sandbox.ts: built once, at deploy
export const Base = Daytona.Snapshot("Base", {
image: "node:22-bookworm",
cpu: 2,
memory: 4,
});
export const Warm = Daytona.Snapshot("Warm", {
from: Base,
run: ["git clone --depth 1 https://github.com/sindresorhus/is repo"],
});
export const Pool = Daytona.WarmPool("Pool", { snapshot: Warm, size: 4 });
// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime works
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const sandboxes = yield* Daytona.CreateSandbox(Warm);
return {
fetch: Effect.gen(function* () {
const sandbox = yield* sandboxes.create();
const result = yield* sandbox.exec("npm test", {
cwd: "/home/daytona/repo",
});
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(Daytona.CreateSandboxHttp)),
) {}

Modal builds images on its own builders from a base or another image, and keeps persistent sandboxes whose files survive between sessions:

// src/Sandbox.ts: built once, at deploy
export const Coder = Modal.App("Coder", {});
export const Node = Modal.Image("Node", {
app: Coder,
image: "node:22-bookworm",
commands: ["RUN apt-get update && apt-get install -y git"],
});
export const Warm = Modal.Image("Warm", {
app: Coder,
from: Node,
run: ["git clone --depth 1 https://github.com/sindresorhus/is /work"],
});
// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime works
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const sandboxes = yield* Modal.CreateSandbox(Warm);
return {
fetch: Effect.gen(function* () {
const sandbox = yield* sandboxes.create({ timeout: "5 minutes" });
const result = yield* sandbox.exec("npm test", {
cwd: "/work",
});
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(Modal.CreateSandboxHttp)),
) {}

Modal goes beyond sandboxes, with Functions, Servers, Volumes, Dicts, Queues and model Endpoints. A Modal.Function holds its own code, runs on a GPU, and binds Volumes, Dicts and other Functions like any other runtime. Callers reach its methods by name, each with its own key, and the Function calls Modal without a stored token (#3137):

src/Embed.ts
export default class Embed extends Modal.Function<Embed>()(
"Embed",
{ main: import.meta.url, app: Coder, gpu: "L4" },
Effect.gen(function* () {
const weights = yield* Modal.MountVolume(Weights, {
path: "/models",
readOnly: true,
});
const cache = yield* Modal.ReadWriteDict(Cache);
const model = yield* loadModel(weights.path);
return {
embed: Effect.fn(function* (text: string) {
const vector = yield* model.embed(text);
yield* cache.put(text, vector);
return vector;
}),
};
}).pipe(Effect.provide(Modal.ReadWriteDictHttp)),
) {}
// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime works
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const embed = yield* Modal.InvokeFunction(Embed);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const result = yield* embed.embed(yield* request.text);
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(Modal.InvokeFunctionHttp)),
) {}

Archil sandboxes run on disks that branch and checkpoint. A sandbox set up at deploy is forked for each request, and the same disks mount into a Cloudflare Container or an ECS task, with S3-style object access on a host’s own least-privilege keys:

// src/Sandbox.ts: set up once, at deploy
export const Golden = Archil.Sandbox("Golden", {
run: ["git clone --depth 1 https://github.com/sindresorhus/is /work/is"],
});
// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime works
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const sandboxes = yield* Archil.CreateSandbox(Golden);
return {
fetch: Effect.gen(function* () {
const sandbox = yield* sandboxes.create({
name: `run-${crypto.randomUUID()}`,
});
const result = yield* sandbox.exec("npm test", {
cwd: "/work/is",
});
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(Archil.CreateSandboxHttp)),
) {}

Thanks Dallen Pyrah for typing Archil’s errors (#3154)!

Boat stops a sandbox with its disk, forks it into a copy, and snapshots it. Environments hold the secrets for the sandboxes started in them, and it works in your Boat organization, which pays for its sandboxes and shares its environments (#3206):

// src/Sandbox.ts: built once, at deploy
export const Golden = Boat.Snapshot("Golden", {
run: ["git clone --depth 1 https://github.com/sindresorhus/is ~/repo"],
});
// src/Api.ts: a Cloudflare Worker here; any Alchemy runtime works
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const sandboxes = yield* Boat.CreateSandbox(Golden);
return {
fetch: Effect.gen(function* () {
const sandbox = yield* sandboxes.create({ autoStop: "5 minutes" });
const result = yield* sandbox.exec("npm test", {
cwd: "/home/user/repo",
});
const copy = yield* sandbox.fork({ autoStop: "10 minutes" });
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(Boat.CreateSandboxHttp)),
) {}

AWS.ECS.CreateSandbox starts sandboxes in your own account, from a Lambda, a Task or any other AWS runtime. Each one is an ECS Task in its own Nitro Sandbox, a child VM on the instance. The image is pushed unchanged, and the handle is the same one every other provider returns (#3242):

src/Sandbox.ts
export const Python = AWS.ECS.Task("Python", {
image: "python:3.12-slim", // or context: "./sandbox"
cpu: 1024,
memory: 2048,
isolation: "isolated",
});
// Managed Instances; roles, security groups and instance types are created
export const Capacity = AWS.ECS.CapacityProvider("Capacity", {
cluster: Cluster.clusterName,
managedInstancesProvider: {
instanceLaunchTemplate: {
networkConfiguration: { subnets: Network.privateSubnetIds },
nitroEnclavesEnabled: true,
},
},
});
src/Api.ts
export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url, functionUrl: true },
Effect.gen(function* () {
const sandboxes = yield* AWS.ECS.CreateSandbox(Capacity, Python);
return {
fetch: Effect.gen(function* () {
const sandbox = yield* sandboxes.create({
tags: { user: "user-42" },
idleTimeout: "15 minutes",
});
yield* sandbox.files.write("/work/main.py", `print("hi")`);
const result = yield* sandbox.exec("python3 main.py", {
cwd: "/work",
});
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(AWS.ECS.CreateSandboxHttp)),
) {}

The binding grants the host RunTask, StopTask and DescribeTasks on the cluster, puts a Lambda in the capacity’s subnets, and opens the sandboxes’ ports to the host only. Every call carries a token minted for that sandbox. A Task can also be an Effect program, and its methods appear on the handle, typed.

The Nitro Sandbox settings are props on the resources you already use, named as in the AWS and Kubernetes APIs: trustedExecutionConfiguration on an ECS task definition, runtimeClassName: "isolated-sandbox" on an EKS Auto Mode Deployment, and enclaveOptions on an EC2 host (#3205).

Every provider has a CreateSandbox binding. A Worker creates a sandbox for a request, runs the code with no network access, and deletes the sandbox:

export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const sandboxes = yield* E2B.CreateSandbox(Agent);
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const sandbox = yield* sandboxes.create({
timeout: 120,
network: { allowOut: [] },
});
yield* sandbox.files.write("main.mjs", yield* request.text);
const result = yield* sandbox.exec("node main.mjs");
yield* sandbox.kill();
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(E2B.CreateSandboxHttp)),
) {}

Each binding mints its host’s own key, scoped to what the binding needs. Sandboxes a host creates are labeled with its stack and stage, and deleted when the host is destroyed.

Every provider’s handle has the same calls for what every platform does (#2928), plus its own platform’s calls next to them:

// a non-zero exit is returned, not failed
yield* sandbox.exec(["pnpm", "test"], { cwd: "/work" });
// a scoped process: output, write, kill, wait
const proc = yield* sandbox.start("pnpm dev");
yield* sandbox.files.write("/work/a.txt", "hi");
const pty = yield* sandbox.pty.open({ cols: 120, rows: 40 });
// a port, as on Cloudflare Containers: proxy the incoming request
const port = yield* sandbox.getTcpPort(3000);
yield* port.fetch(yield* HttpServerRequest);

A Worker uses a sandbox through a service of your own, and switching providers is the one line that provides it:

src/Api.ts
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const runner = yield* Runner;
return {
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const result = yield* runner.run(yield* request.text);
return yield* HttpServerResponse.json(result);
}),
};
}).pipe(Effect.provide(E2BRunner)),
}).pipe(Effect.provide(DaytonaRunner)),
) {}

The service is written once, with one Layer per provider:

src/Runner.ts
interface RunResult {
exitCode: number;
output: string;
}
export class Runner extends Context.Service<
Runner,
{ run: (code: string) => Effect.Effect<RunResult, never, RuntimeContext> }
>()("Runner") {}
export const E2BRunner = Layer.effect(
Runner,
Effect.gen(function* () {
const sandboxes = yield* E2B.CreateSandbox(NodeTemplate);
return {
run: Effect.fn(function* (code) {
const sandbox = yield* sandboxes.create({ network: { allowOut: [] } });
const r = yield* sandbox.exec(["node", "-e", code]);
yield* sandbox.kill();
return { exitCode: r.exitCode, output: r.stdout };
}),
};
}),
).pipe(Layer.provide(E2B.CreateSandboxHttp));
export const DaytonaRunner = Layer.effect(
Runner,
Effect.gen(function* () {
const sandboxes = yield* Daytona.CreateSandbox(NodeSnapshot);
return {
run: Effect.fn(function* (code) {
const sandbox = yield* sandboxes.create();
const r = yield* sandbox.exec(["node", "-e", code]);
yield* sandboxes.delete(sandbox.sandboxId);
return { exitCode: r.exitCode, output: r.result };
}),
};
}),
).pipe(Layer.provide(Daytona.CreateSandboxHttp));

E2B templates, Daytona snapshots and Modal images build on the platform from a Dockerfile, so a deploy needs no local Docker. A Dockerfile the platform can’t build fails at plan, before anything is uploaded:

export const Agent = E2B.Template("Agent", {
dockerfile: Dockerfile.inline`
FROM node:22-bookworm
RUN apt-get update && apt-get install -y git ripgrep
`,
});
E2B.Template cannot build this Dockerfile:
line 3: FROM: multi-stage builds are not supported
line 4: COPY --from: multi-stage builds are not supported

Give a sandbox main, and it runs your Effect program, with bindings, at its own URL. A code change uploads the program and restarts it in place:

export default class Search extends Boat.Sandbox<Search>()(
"Search",
{ main: import.meta.url, public: true },
Effect.gen(function* () {
return { fetch: HttpServerResponse.json({ ok: true }) };
}),
) {}

Every provider can hand a sandbox a placeholder and swap the real key in as the request leaves, so the code you run never holds it. Daytona has secrets scoped to hosts, Archil and E2B have egress rules, and E2B can route through your own SOCKS5 proxy. A Modal sandbox can carry an identity Modal signs, so AWS or GCP give it short-lived credentials and no key is stored (#3238):

const sandbox = yield* sandboxes.create({
image: "amazon/aws-cli",
includeOidcIdentityToken: true,
});

Boat keeps AI keys and API secrets in credential profiles, and its AI credentials proxy swaps them in for each request (#3231):

export const Keys = Boat.CredentialProfile("Keys", {
anthropic: Config.Redacted("ANTHROPIC_API_KEY"),
});
export const Proxy = Boat.AiProxy("Proxy");
export const Tenants = Boat.Environment("Tenants", {
safeForThirdParties: true,
credentialProfile: Keys,
proxyAiCredentials: Proxy,
});

Docs: E2B · Daytona · Modal · Archil · Boat · AWS ECS.

Resource.lookup binds a resource your stack doesn’t manage. It resolves at plan time, binds and grants like a declared resource, and never writes state or deletes anything. Every provider has it, on every resource another resource can point at (#2974, #3002, #3076, #3094, #3120, #3124):

const uploads = AWS.S3.Bucket.lookup({ bucketName: "acme-uploads" });
const vpc = AWS.EC2.Vpc.lookup({ isDefault: true });
const zone = Cloudflare.Zone.lookup({ name: "example.com" });
const orders = GCP.PubSub.Topic.lookup({ topicId: "orders" });
const project = Neon.Project.lookup({ projectId: "plain-wave-12345678" });
// binds and grants like a declared bucket
const getObject = yield* AWS.S3.GetObject(uploads);

A missing resource fails with LookupNotFoundError, and a lookup of one this stack manages fails with LookupOfManagedResourceError.

RemovalPolicy.protect() refuses any plan that would replace or delete a resource, before anything is applied (#3074):

const cluster = yield* AWS.EKS.Cluster("Cluster", props).pipe(
RemovalPolicy.protect(stack.stage === "prod"),
);
ProtectedResource: Refusing to replace 'Cluster': protected by RemovalPolicy.protect().
Deploy it without protect() first to replace or delete it deliberately.

The policy is saved in state, so removing protect() in the same deploy as the replacement is still refused. Thanks Rahul Mishra!

AWS and GCP bindings on a Fly Service exchange the Machine’s Fly OIDC token for credentials, instead of an access key or a service account key. The trust covers only that App (#3034, #3039). Your code doesn’t change:

export default class Api extends Fly.Service<Api>()(
"Api",
{ main: import.meta.url, port: 3000 },
Effect.gen(function* () {
const putObject = yield* AWS.S3.PutObject(yield* Uploads);
// ...
}).pipe(Effect.provide(AWS.S3.PutObjectHttp)),
) {}

AWS Batch jobs and EKS pods reach GCP through their AWS role (#3040), and Fly Service RPC authorizes callers by the App Fly signs into Fly-Src (#3042).

Docs: Identity federation · Fly federated identity.

Every resource that builds a container image takes the same opt-in layer cache, so a fresh CI runner resumes from the last unchanged step, earlier stages included (#3111):

export class Agent extends Cloudflare.Container<Agent>()("Agent", {
context: `${import.meta.dirname}/context`,
build: { cache: true }, // every stage's layers at <repository>:buildcache
}) {}
yield* AWS.ECS.Service("Api", { context: "./api", build: { cache: true } });
Docker.Image("App", {
build: {
context: "./app",
cacheFrom: ["type=gha,scope=app"],
cacheTo: ["type=gha,scope=app,mode=max"],
cache: {
from: ["type=gha,scope=app"],
to: ["type=gha,scope=app,mode=max"],
},
},
});

Image tags now follow what Docker receives, so a lockfile above the context or a .dockerignored file no longer retags the image (#3121). Thanks Malcolm, who designed the cache shape!

SQL.* and Drizzle.* return the driver’s own client when the runtime is constructed. Only the connection waits for a request, so every builder API works, where the old proxyChain recording broke some of them (#3209):

const db = yield* Drizzle.Postgres(hd.connectionString);
// a real drizzle database: no connection yet
const children = db.$with("c").as(db.select().from(items));
return {
fetch: Effect.gen(function* () {
// running a query opens the pool, once per request
return yield* HttpServerResponse.json(yield* db.select().from(items));
}),
};

The CLI’s output can be piped. --log-format json writes only JSON records to stdout, and status lines go to stderr when output is piped (#2874, #3041):

Terminal window
alchemy dev --log-format json | jq 'select(.kind == "event") | .data.type'
alchemy state read app/prod/output > out.json # progress on stderr

alchemy drift exits 2 when it finds drift it didn’t repair (#2992):

Terminal window
alchemy drift --stage prod # 0 in sync · 1 couldn't check · 2 drifted

Also: the status line shows total deploy time (#3057), terminals that support program status show deploy progress (#2887), nested stack outputs print in full (#3037), alchemy state takes --domain for a Cloudflare store on a custom domain (#2966), and nuke scans have their own 60 s timeout (#2958). Thanks Rahul Mishra and gegi!

  • waitUntil work keeps what the handler acquired and exports its telemetry, in every event, effect/rpc request and Durable Object (#2886, #2989). A streamed body keeps the event’s telemetry open until it ends (#1639).
  • Cron, email and queue handlers get the services init provides (#2972), and alchemy dev delivers email to Effect Workers (#2989).
  • AsyncLocalStorage stores stay visible under Cloudflare.Telemetry() (#1627).
  • A storage call right after a Durable Object transaction waits for its commit (#3107).
  • Workers that bind a Durable Object class added in the same deploy, or reference each other through a ref, deploy in order and plan again (#3113, #3115).
  • A Container rebuilds when only its program changes, and image builds have no 3-minute deadline (#2971, #3151).
  • alchemy dev no longer sends CF-Worker, which Cloudflare-fronted sites answered with 403 (#2965).
  • A stopped local Worker proxy frees its port at once, so a stage restarted in the same process gets its port back instead of failing for 30 s (#3203).
  • A second TXT, MX or A record at a name is created beside the first instead of adopting it (#3073). Hyperdrive and certificate packs stop reporting false drift (#2993, #3108).
  • DurableObjectState.raw no longer hangs the type checker with ambient workers types (#3123), and Cloudflare.upgrade() types its requirements instead of any (#3193).
  • A Worker with rolldown build plugins redeploys (#3176), a new Worker no longer fails with WorkerNotFound while it propagates (#3000), and a Website.Vite whose old workspace is gone rebuilds instead of failing to plan (#3001).
  • A Container whose image was changed outside Alchemy is restored on the next deploy (#2982). Thanks Yannic Charlon!
  • A Preview starts its Durable Objects’ containers (#3085) and applies observability (#3244).
  • A Worker’s runtime bundle no longer pulls in the local runtime (#3240).
  • A Durable Object workflow commits a run together with its first wake-up, so a Saga can’t stay Running forever (#3197).
  • alchemy dev skips restarts when a watch event leaves the build input unchanged (#3246), and re-applies D1 migrations when the simulator’s data is gone (#3211).
  • DNSSEC stops reporting drift when only modifiedOn moved (#3224).

Thanks Alex, Juliaan, Rahul Mishra, Matthew Aylward, Dallen Pyrah, Rook and apostoli!

  • CloudFront website domains get an AAAA alias next to the A alias (#3008). Thanks Altay!
  • EKS updates apply on every deploy, not just the first (#3077). Thanks Jacob Hands!
  • A second private hosted zone with the same name no longer takes over the first (#3100). Thanks Krzysztof Lis!
  • ECS blue/green services deploy with a new role and stabilize after a traffic shift (#3093). Thanks Dawson for the reports!
  • Aurora migrations default to the Data API, and run on Floci in alchemy dev (#3109).
  • A resource declared without props reaches its provider with {} (#3084).
  • A destroy that meets an RDS, DocDB, Neptune, ElastiCache or Timestream instance already deleting waits for it instead of failing (#3216, #3234).
  • Migrations refuse a pending file that sorts before an applied one, and keep a renamed file applied (#2917).
  • A failed Drizzle.Schema diff no longer hangs the plan (#2984), and Drizzle.Schema takes drizzle-kit’s breakpoints (#2964).
  • A Hetzner replacement no longer re-adopts the resource it replaces (#3047).
  • Fly multi-region scale-downs, App deletes and lease renewals no longer fail (#2957).
  • Rivet Cloud sign-in works for an account in several organizations (#3258). Thanks Julian Archila!
  • Redacted outputs stay opaque in their types (#2626), and Infisical applies personal overrides (#3066). Thanks Jason Kuhrt!
  • Dev servers require a per-process token, and Floci binds to loopback (#3027).
  • Telemetry.layerOtlp exports from every server and job runtime: Batch, App Runner, Lambda Web Functions, Fly, Hetzner, Railway, Docker, Prisma Compute and every sandbox (#3202, #3172).
  • A failed delete or drift repair prints its error on its own line (#3218), and a stack that fails to load auth providers says why (#3192). Thanks Dallen Pyrah!

Cloudflare

  • Cloudflare.Telemetry() turns on Workers Logs (#2622). Thanks Jordan!

  • A class-form Durable Object keeps a className that differs from its id (#1499). Thanks Alex!

  • A Durable Object takes over another class’s namespace and objects with renamedFrom (#3164):

    env: { Meter: Cloudflare.DurableObject("Meter", { renamedFrom: "Counter" }) },
  • Access takes passkeys as an identity provider (#3116).

  • Cloudflare.Workflows.withStepConfig sets an Activity’s step.do timeout and retries on the native engine (#3186). Thanks Alessandro Cheli!

    Activity.make({ name: "encode", success: Schema.String, execute }).pipe(
    Cloudflare.Workflows.withStepConfig({ timeout: "16 minutes" }),
    );
  • Logpush.Job takes a Redacted destinationConf (#2840). Thanks Henry Marks!

  • API tokens can name the permission groups Cloudflare added since the last sync (#2927).

  • A host that embeds the local Workers runtime sees each workerd process’s lifecycle: its start with the PID, an unexpected exit, a scheduled restart, and why recovery stopped (#3203). The events are a Schema union, so they log as JSON, and onEvent replaces onRestart. Thanks Umut Sirin for this and the port fix above!

    runtime.start({
    ...worker,
    onEvent: (event) => {
    if (event.type === "workerd.process.started") watchMemory(event.pid);
    if (event.type === "workerd.recovery.stopped") saveState(event.reason);
    },
    });

Alchemy Domain

  • DomainCloudflare.layerDurableObjects({ locationHint: "wnam" }) creates a model’s Durable Objects near a region (#3248).
  • A schedule’s commandId names it, so a deadline can be armed again (#2806). Thanks Taras Kornichuk!

AWS

  • Lambda Web Functions run in Floci under alchemy dev, on Floci 2.4, which also emulates RDS Data, Redshift and CloudWatch Logs live tail (#3163). Every AWS test that Floci can run now passes on it (#3198).

Other providers

  • A Fly.Service stores its bound Config values as App secrets, so they no longer show in plain text in the Machine config (#2148). Thanks Jarred Norris!
  • Planetscale.ServiceToken manages database-scoped tokens (#2771). Thanks Jason Kuhrt!
  • GitHub.fromApp acts as a GitHub App installation (#3132).
  • A Prisma host can supply a token that refreshes during a deploy (#3101, #3122). Thanks Will Madden!
  • Docker.Network takes the rest of docker network create: internal, attachable, IPv4/IPv6 toggles, driver options and IPAM (#3190). Thanks Yuzu!
  • GCP.StorageInsights.ReportConfig schedules Cloud Storage inventory reports (#2949). Thanks Bartek!
  • Every distilled SDK is regenerated from the latest specs (#3201). Thanks Michael K!
  • Fly.Service, AWS.EC2.Instance, Hetzner.Service and GCP.Run.WorkerPool build from a Dockerfile context (#3118).

Core

  • The Workers runtime (Worker and Durable Object bridges, RPC, the workflow engine) lives in alchemy/Workers, so any engine that runs Workers bundles reuses it (#2819).
  • Every provider’s docs were rewritten platform-first, with a What is page and about 150 diagrams that read on a phone and draw how their pieces connect (#2948, #3044, #3168).

See the CHANGELOG for every other change, and thanks to everyone who contributed.