Telling forms from their shadows[DRAFT]
In the allegory of the cave, Plato argued that forms exist independently of their physical examples. Prisoners, who’ve seen nothing but the cave walls, mistake the shadows of forms for reality.
Unbeknownst to us, we're no different from these prisoners when building our software systems.
Too often, we mistake implementation details for the system. Like the prisoners in the cave, we fail to see beyond the physical examples. This note shows how Effect TS could be a tool for you to sharpen your senses; tell the forms from the shadows. Opportunistic as I am, through a Tardigrade example.
Before you think otherwise, Effect is not a magic bullet that will save your codebase. And you can write great code without it. However, Effect gives you well designed primitives that you might not discover by yourself. Primitives that will help you discern form from their shadows in the things you build. If it doesn’t make sense now, it will as we go along. Let’s start with a practical example.
An agent connected to file system
The non-Effect way
Grep around and find out
Imagine you're building a general purpose agent. As coding tools like claude code and codex have shown, an agent connected to a file system with the ability to read, write, grep will take you very far. You decide to give your agent these capabilities by wiring them to your computer's files using node:fs.
Your code might look something like this:
import { execFile } from "node:child_process"
import { readFile, writeFile } from "node:fs/promises"
import { promisify } from "node:util"
import { generateText, tool, type LanguageModel } from "ai"
import { z } from "zod"
const exec = promisify(execFile)
export function runAgent(model: LanguageModel, prompt: string) {
return generateText({
model,
prompt,
tools: {
grep: tool({
description: "Search files under a path for lines matching a regular expression",
inputSchema: z.object({ pattern: z.string(), path: z.string() }),
execute: async ({ pattern, path }) => (await exec("grep", ["-rn", "-e", pattern, path])).stdout,
}),
read_file: tool({
description: "Read a text file at a path",
inputSchema: z.object({ path: z.string() }),
execute: ({ path }) => readFile(path, "utf8"),
}),
write_file: tool({
description: "Write a text file at a path",
inputSchema: z.object({ path: z.string(), text: z.string() }),
execute: async ({ path, text }) => {
await writeFile(path, text, "utf8")
return { path }
},
}),
},
})
}
You find out it runs well and you want to scale it up by deploying it remotely on a serverless platform. You would quickly come to the realisation that it wasn't a breeze like you would have pictured. The edge does not usually provide a filesystem. This agent that's running perfectly well on your computer can't run everywhere. It is a prisoner of the filesystem.
A file system greppable tree shaped store
There are capabilities, and physical implementations that help you realise those capabilities. Discerning between these two is what will set you free.
Socrates: Why does your agent need a filesystem?
You: Because only a filesystem gives it a tree shaped store it can grep.
Socrates: Is the filesystem the only tree shaped store you can grep?
You: ...
You: Aha!
You: I just need a greppable tree shaped store!
Step into the agent's shoes and think in terms of the capabilities that it needs to function. A write_file tool to add content into a path within a tree shaped directory. A read_file tool to get the content at a later time using the same path. A grep tool to search the whole tree for the content that matches a pattern. So long as the thing behind the tools provides these capabilities and honors its contracts, the underlying storage implementation is secondary!
In terms of Plato's allegory, the tree shape and the capabilities that such a store provides are the form. The implementation is a shadow, a physical example. Often we confuse the two.
An actual file system is the most common physical example of a greppable content store that implements a tree shaped directory. You can implement the same shape in hot-storage, or an sqlite table. Thinking of things this way is very liberating. Your agent can suddenly exist in so many places that you might not have initially expected, like a tardigrade.
The Effect way
In Effect terms, we call the shape of the storage as a Service. This is similar to what Plato described as forms. It's a description of the capabilities that your domain logic needs without concerning itself with the underlying physics. Layers are the recipes that project these descriptions into reality: like the puppeteers and the flame casting the shadow.
In the example below, we'll see the same agent implemented using Effect TS and Tardigrade.
First, define the forms
We start by defining the form of storage we need as a Service. The service lists the capabilities and contracts guaranteed to its user. Here, we guarantee that grepping a path for a pattern returns the matching lines (GrepMatch) and reading takes a string and returns an error or a string. Likewise, writing takes a string and returns nothing or an error.
import { Context, Effect } from "effect"
export type GrepMatch = {
readonly path: string
readonly line: number
readonly text: string
}
export class ContentStorage extends Context.Service<ContentStorage, {
readonly grep: (pattern: string, path: string) => Effect.Effect<readonly GrepMatch[], Error>
readonly read: (path: string) => Effect.Effect<string, Error>
readonly write: (path: string, text: string) => Effect.Effect<void, Error>
}>()("ContentStorage") {}
Define the agent tool contracts
Declare the tool contract using Tardigrade's defineLibrary:
import { Schema } from "effect"
import { Rpc } from "effect/unstable/rpc"
import { defineLibrary, MethodDescription } from "tardie/libraries"
export const storageContract = defineLibrary({
name: "storage",
description: "Read, write, and search text in a tree shaped content store.",
toolNames: { grep: "grep", read: "read_file", write: "write_file" },
methods: [
Rpc.make("grep", {
payload: Schema.Struct({ pattern: Schema.String, path: Schema.String }),
success: Schema.Array(Schema.Struct({
path: Schema.String,
line: Schema.Number,
text: Schema.String,
})),
error: Schema.String,
}).annotate(MethodDescription, "Search files under a path for lines matching a regular expression"),
Rpc.make("read", {
payload: Schema.Struct({ path: Schema.String }),
success: Schema.String,
error: Schema.String,
}).annotate(MethodDescription, "Read a text file at a path"),
Rpc.make("write", {
payload: Schema.Struct({ path: Schema.String, text: Schema.String }),
success: Schema.Struct({ path: Schema.String }),
error: Schema.String,
}).annotate(MethodDescription, "Write a text file at a path"),
],
})
Bind the tools to the service
Tardigrade then lets you connect the library definition to the ContentStorage service that you defined.
export const storage = storageContract.implement({
grep: ({ pattern, path }) => Effect.gen(function* () {
const storage = yield* ContentStorage
return yield* storage.grep(pattern, path)
}).pipe(Effect.mapError(String)),
read: ({ path }) => Effect.gen(function* () {
const storage = yield* ContentStorage
return yield* storage.read(path)
}).pipe(Effect.mapError(String)),
write: ({ path, text }) => Effect.gen(function* () {
const storage = yield* ContentStorage
yield* storage.write(path, text)
return { path }
}).pipe(Effect.mapError(String)),
})
A typed agent program
The tools method takes a list of libraries and returns an atom, which can then be passed to the infer method.
import { storage } from "./services/workspace"
export const agent = defineActor("file-agent", Effect.gen(function* () {
const system = atom("Use the workspace to read and write your notes.")
const files = yield* tools([storage])
const context = yield* compact(messages)
const inference = yield* infer(get => ({
system: get(system),
tools: get(files),
context: get(context),
}))
return { atom: inference, methods: agentMethods }
}))
Then, project it onto the world
What you have now is a pure, typed Effect program of the agent, waiting to be projected into the world. This is where all the ceremony becomes worth it. Your agent isn't bound to any particular physical implementation of the capabilities that it needs. You are free to project it onto different physical implementations so long as they satisfy the capabilities that your agent needs.
In Effect, the recipes we use to project our typed program into the world is called Layer. Here for example, it binds the ContentStorage services that the agent requires into a specific physics: in-memory, an sqlite table or a real file system.
import { Layer } from "effect"
import { toolActs } from "tardie/agent/services"
import { storage } from "./services/workspace"
import { memoryWorkspace } from "./services/workspace-memory"
import { sqliteWorkspace } from "./services/workspace-sqlite"
import { filesystemWorkspace } from "./services/workspace-filesystem"
const fileToolServices = toolActs([storage]).pipe(
Layer.provide(memoryWorkspace),
)
const sqliteToolServices = toolActs([storage]).pipe(
Layer.provide(sqliteWorkspace),
)
const filesystemToolServices = toolActs([storage]).pipe(
Layer.provide(filesystemWorkspace),
)
Merge capabilities
A sharp eyed reader might have realised by now that inference could also be expressed as a capability. Just like the content storage service, inference is also a capability that your typed agent program requires to function. Its contract could be something like this: when a conversation history of a certain shape is sent, I receive a stream of tokens through inference.
In Tardigrade, that capability is the Model service. dummyInference is a layer that implements it with a canned reply, which is enough to run the agent without calling a provider.
import { Effect, Layer } from "effect"
import { Model, modelActs } from "tardie/agent/services"
import { actorContext } from "tardie/agent"
import { createBunHost } from "tardie/platform/bun"
import { agent } from "./agent"
const dummyInference = Layer.succeed(Model, {
call: () => Effect.succeed({ text: "Hello from a dummy model.", toolCalls: [] }),
})
const inferenceServices = modelActs.pipe(Layer.provide(dummyInference))
const services = Layer.mergeAll(
fileToolServices,
inferenceServices,
)
const host = await Effect.runPromise(createBunHost({
actor: agent,
actorContext,
services: () => services,
storage: ".tardigrade/data",
}))
And there you have it, an agent that isn't tied to its physical implementations anymore. A prisoner who has left the cave.
Effect isn't a magic pill
Before you think otherwise, Effect will not magically solve all your problems. You can totally implement the structure above using vanilla typescript. For example, a simple way to add dependency injection in typescript would be like this:
interface ContentStorage {
grep(pattern: string, path: string): Promise<readonly GrepMatch[]>
read(path: string): Promise<string>
write(path: string, text: string): Promise<void>
}
export function runAgent(storage: ContentStorage /* other inputs omitted */) {
return generateText({
// model and prompt are supplied as above.
tools: {
grep: tool({
// inputSchema
execute: ({ pattern, path }) => storage.grep(pattern, path),
}),
read_file: tool({
// inputSchema
execute: ({ path }) => storage.read(path),
}),
write_file: tool({
// inputSchema
execute: async ({ path, text }) => {
await storage.write(path, text)
return { path }
},
}),
},
})
}
Effect is simply a library that gives you an ergonomic and battle tested way to structure your codebase into domain logic, the capabilities your domain requires and the recipe to layer these capabilities in different environments explicitly. In Plato's terms, it will help you think of your system as forms and shadows cast by those forms. And this is a beautiful way in itself to write your code, and think about the systems you build.
What's the payoff?
Writing effect code, or structuring your codebase this way, might feel too constraining initially. But as Runar says, "Constraints liberate, liberties constrain". It will eventually liberate your system.
In this small example above, we already noticed the case in point. By defining services, we were able to layer our agent onto different environments with interchangeable storage backends. The most important thing was a tree shaped content store, every implementation is secondary to that. Now this agent can run in-memory, on your device with a file system or in a Durable Object with sqlite backed storage.
I've intentionally not gone deeper into a guide on structuring your codebase. I think that's secondary to building good intuitions and understanding. So go out there and try organising your code into services and layers. You'll figure out a structure that works best for you. Free your system from the shackles of physics.
Happy effecting!
ps: This note didn't explore Effect<A, E, R>: the success value, possible errors, and required services. Maybe that's for another time. But here's a teaser: an Effect declares what it needs, services define those capabilities, and layers describe how to provide them.