kolu
Blog
← all posts

Effect, everywhere

Five plumbing libraries out, one in — the wire, the schemas, the HTTP app, the daemons, the reactive engine. And the real reason: most of this code is written by agents, and Effect is the shape agents fail loudly in.

·Sridhar Ratnakumar

Kolu — a terminal app for running many coding agents at once — used to do its plumbing with five libraries: zod for schemas, oRPC for the wire, partysocket for reconnects, hono for HTTP, @preact/signals-core for reactivity. As of #2101 they’re all gone from package.json. Underneath everything now sits one library: Effect, pinned at 4.0.0-beta.106. Yes, a beta. Hold that thought.

This isn’t the migration war story — that diff was +78,690/−31,896 across 848 files, and the PR carries its own log. This is what’s there now, and why. And if you read Haskell but only basic TypeScript, you’re closer to home here than in most TS codebases: Effect is the monad-transformer stack you already know, shipped as a library with a runtime attached.

Why Effect

An Effect<A, E, R> is a value describing a computation that succeeds with A, fails with E, and needs an environment R. A Haskeller reads it as ReaderT R (ExceptT E IO) A — with structured concurrency, retry, and resource tracking built into the runtime instead of bolted on per-library. And Effect.gen is do-notation, spelled with generators. Here’s the whole model as a hello world, no Kolu required:

import { Context, Effect, Layer, Schedule, Schema } from "effect";

class UserNotFound extends Schema.TaggedError<UserNotFound>("UserNotFound")(
  "UserNotFound",
  { id: Schema.String },
) {}

class Users extends Context.Service<
  Users,
  { lookup: (id: string) => Effect.Effect<string, UserNotFound> }
>()("Users") {}

const hello = Effect.gen(function* () {
  const users = yield* Users;              // R: ask the environment
  const name = yield* users.lookup("42");  // E: may fail, and the type says how
  return `hello, ${name}`;                 // A
});
// inferred: Effect<string, UserNotFound, Users> — nobody wrote that type

Nothing has run yet — hello is a description, an IO action you haven’t handed to main. Running it is where the rest of the vocabulary shows up:

const usersLive = Layer.succeed(Users)({  // a Layer: one way to build Users
  lookup: (id) =>
    id === "42"
      ? Effect.succeed("world")
      : Effect.fail(new UserNotFound({ id })),
});

const main = hello.pipe(
  Effect.retry(Schedule.spaced(1000)),  // the retry policy is a plain value
  Effect.provide(usersLive),            // the Layer supplies the Users service
);
Effect.runPromise(main);                // the run edge — unsafePerformIO

That type is the whole bet, three ways:

  1. One semantics. Every networked program has three chores: things fail, failed things get retried, acquired things get cleaned up. Each of the five libraries did those its own way — zod threw, partysocket had a private reconnect loop, every server had its own shutdown hooks — and we wrote the glue between them. Now failure is the E in Effect<A, E, R>, a retry policy is a Schedule value, and cleanup is tied to a Scope. Same three words at every level of the stack.
  2. Types with teeth. Errors and dependencies live in the type and survive across the wire, so “this call can fail this way” is checked, not documented.
  3. Agents write most of this code, and Effect turns their nastiest failure mode — code that compiles and silently does nothing — into compile errors and CI scans.

The rest of this post cashes those claims out, one piece of the stack at a time. The map:

five dependencies, one librarygone from package.jsonzod@orpc/*partysockethono@preact/signals-core[email protected]Schemaunstable/rpcSocket + Scheduleunstable/httpAtom

The wire: RpcGroups over ndjson

Kolu’s client and its daemons speak one RPC contract, and that contract is a flat RpcGroup — a typed map from slash-tagged method names to request/response schemas, the moral equivalent of a Servant API type. (It lives at effect/unstable/rpc; being unstable, its documentation is the source.) Frames are ndjson — one JSON value per line — over an Effect Socket:

// packages/common/src/contract.ts
export const koluRootGroup = RpcGroup.make(
  Rpc.make("server/info", { success: ServerInfoSchema }),
  Rpc.make("daemon/restart"),
  Rpc.make("hosts/add", { payload: HostRefSchema }),
  Rpc.make("hosts/reconnect", { payload: HostRefSchema }),
);

export const contract = koluSurfaceGroup.merge(koluRootGroup);

The surface framework — the typed reactive-state layer everything in Kolu is served through — kept its five shapes (cell, collection, stream, event, procedure), and each one now mints its verbs as Rpc.make entries; a collection’s delta feed is just an RPC with stream: true. Its client-side reactivity moved too: Effect’s Atom sits where @preact/signals-core used to. And oRPC-over-ssh still works exactly as that post describes, minus the oRPC: the boundary held, and swapping the wire vendor never touched a domain module.

Reconnect — partysocket’s old job — is now a Schedule handed to the client protocol as a retry policy. A Schedule is a first-class value describing “when to try again” (think the retry package’s RetryPolicy, composable with .pipe), so backoff logic is data you test, not a config knob on somebody’s WebSocket wrapper.

Schemas: types and errors from one codec

Every zod schema is now Effect Schema. A Schema is one value that is simultaneously the decoder, the encoder, and the source of the TypeScript type — FromJSON, ToJSON, and the data declaration fused (Haskellers: it’s autodocodec, not aeson):

// packages/common/src/hostKey.ts
export const HostKeySchema = Schema.Union([
  Schema.Struct({ kind: Schema.Literal("local") }),
  Schema.Struct({
    kind: Schema.Literal("remote"),
    target: Schema.String.check(Schema.isMinLength(1)),
  }),
]);
export type HostKey = typeof HostKeySchema.Type;

Errors are schemas too. A failure the caller can act on is a class — a constructor in the error sum type — that travels the wire and decodes on the far side as itself:

// packages/padi/src/errors.ts
export class TerminalNotFound extends Schema.TaggedError<TerminalNotFound>(
  "padi/TerminalNotFound",
)("TerminalNotFound", { id: Schema.String }) {
  override get message(): string {
    return `Terminal ${this.id} not found`;
  }
}

Effect splits failure the way Haskell splits ExceptT from error: an expected error is typed and in the E channel; a defect is a crash. The rule written in that file’s header: if a failure means “padi is broken” rather than “you can handle this,” it stays out of the type entirely and crashes loud. No error-code string soup, no catch (e: unknown).

One bound does a lot of quiet work: every wire schema must satisfy Schema.Codec<T, unknown, never, never> — decode unknown to T, where the two nevers say decoding needs no services from the environment and fails into no untyped channel. It’s a purity annotation on the codec. zod had no way to even state that — and its habit of tolerating a key that’s present but undefined (where Effect Schema refuses) had let eight producer/schema mismatches into the tree, two of them live in production paths. The bugs were ours all along. The strictness found them.

Daemons: boot as a Layer graph

Kolu runs on two daemons: padi, the workspace daemon that remembers your sessions, and kaval, the PTY daemon that owns the live terminals underneath. A daemon’s boot is the classic init litany: take the lock so two copies can’t run, open the on-disk stores, establish the process identity, build the live state, and only then listen on a socket. Shutdown has to undo all of that in reverse — including when boot only got halfway. The imperative version of that is a pyramid of try/finally and a prayer.

Effect’s answer is the Layer. A Layer<Out, E, In> is a recipe: “give me environment In and I’ll build service Out — and I know how to tear it down when my Scope closes.” It’s the ReaderT-pattern environment record plus bracket, reified as a value you compose. Here’s one of padi’s real ones:

// packages/padi/src/daemonBoot/daemonMain.ts
// "builds PadiStores; needs PadiGate first" — the dependency is in the type
const storesLayer = (
  stateRoot: string,
  log: Logger,
): Layer.Layer<PadiStores, never, PadiGate> =>
  Layer.effect(PadiStores,
    PadiGate.useSync(() => openStateStores(stateRoot, log)));

You can’t build the stores until the gate — the “only one padi per state root” lock — is held, and the type says so. Boot, then, is just composition. Read it bottom-up; each Layer.provideMerge line means “build this first, and everything above me may use it”:

// (abridged — same file)
const bootLayer = endpointLayer({ stateRoot, log }).pipe(       // 5. the socket
  Layer.provideMerge(surfacesLayer({ stateRoot, log })),        // 4. live state
  Layer.provideMerge(identityLayer(opts, socketPath)),          // 3. identity
  Layer.provideMerge(storesLayer(stateRoot, log)),              // 2. the stores
  Layer.provideMerge(gateLayer),                                // 1. the gate
);

Swap two of those lines and the missing dependency is a compile error, not a 3am log line. And teardown comes free: when the daemon’s Scope closes — clean exit or crash — each layer’s release runs in reverse build order via Effect.acquireRelease (that’s bracket again). The hand-written try { ... } finally { await served.close() } is gone.

hono went the same way: the HTTP app is HttpRouter layers (from effect/unstable/http) merged together, and since that router ranks routes by specificity instead of registration order, the old “register the preview route before the static catch-all or it silently loses” footgun died with it.

Agents: code that cannot silently no-op

Here’s the part nobody prints on the box. Kolu is built mostly by coding agents, and an agent’s nastiest failure mode isn’t a crash — it’s code that compiles and silently does nothing. await on a function that now returns an Effect typechecks fine and never runs, because an Effect is an inert description until something executes it — exactly like an IO action you built and never bound. That pattern bit us eleven times across kolu and its consumers before we closed it.1

Effect moves that class of wrong to where an agent actually sees it: into the compiler. And where types can’t reach, scanners do — every Effect.run* edge in the tree (the unsafePerformIO of this world) is a named allowlist row, 109 sites in 54 files at merge, and the await-an-Effect and build-but-never-run patterns are banned by CI checks that were themselves tested against the known dodges. A human reviewer skims. The compiler and the scanners don’t.

The beta: sharp edges, fenced

About that beta. Sharp edges exist and we hit them. Effect RPC’s default treats one handler’s defect as fatal to the whole multiplexed connection — a single dead terminal tap took down the entire daemon link before we set disableFatalDefects at the one shared server layer. RpcGroup.merge is last-writer-wins with no collision detection, so every contract assembly is followed by a tag-count guard that crashes at import. And every place we depend on beta behavior — not API, behavior — carries a grep-able BETA-ASSUMPTION(beta.106) marker; bumping the pin fails CI until each one is re-measured. That gate has already been exercised for real: beta.104 deleted Schema.TaggedErrorClass outright — it’s Schema.TaggedError now — and #2126 moved the pin, all 37 renamed call sites, and every re-measured marker as one commit. The library is beta. The discipline isn’t.

The wire, the schemas, the HTTP app, both daemons (padi and kaval), the reactive engine, the CLIs — 512 files import effect, one library speaking one semantics, and 509/509 e2e scenarios rode it green the day the migration landed. That’s the trade: one beta library watched hard, instead of five stable ones half-glued together. So far it’s a good trade.

Footnotes

  1. The tally spans kolu and its consumer repos (drishti, odu) — one of the eleven was a test that had awaited away the very drain it existed to prove. The per-incident notes live in PR #2101’s campaign gist; the CI scanner that now bans the pattern — including the alias and stored-promise dodges — is awaitedFace.ts.