rat-stack 🐀
An Effect stack so pure (aspirational) Kit Langton will blush.
Build an Effect app and its cloud as one typed program. Joel’s goal is the best Effect + Alchemy application we can build; VISION.md explains why. AGENTS.md sets exact pins, checks, and hooks. The public tree lets you steal ideas. It carries no product support promise.
The pnpm + Turborepo workspace includes a working Effect v4 CLI, tests, formatting, and type-aware linting. Vendored source mirrors cover Effect, effect-solutions, XState, and Alchemy.
Define a Contract with Effect input, output, and failure schemas plus annotations. Bind a server-side handler, then project the capability onto every agent surface. The same inspectFile capability is the stats command, POST /inspectFile with an OpenAPI document, and an MCP tool over stdio.
Create a repository
gh repo create <name> --template joelhooks/rat-stack
Clone the new repository, then install and verify it:
nvm use
pnpm install
pnpm vendor:agent-sources
pnpm turbo run check test build
Node 24.18.0 and pnpm 11.3.0 are required. The requirement is declared in .nvmrc, engines, devEngines, and packageManager.
Workspace layout
| Path | Package | Role |
|---|---|---|
apps/cli | @rat-stack/cli | Composition root: stats, openapi, serve, mcp |
packages/capability | @rat-stack/capability | defineContract, implement, and the toCommand, toHttpApi, toToolkit, toRpc, and toCodeMode projections |
packages/core | @rat-stack/core | Domain example: the inspectFile contract, handler, and lifecycle machine |
packages/flags | @rat-stack/flags | Typed flags, Config, memory and KV adapters, stable rollouts, audited writes, and removal dates |
packages/events | @rat-stack/events | Request analytics with an EventSink port and Basin resources |
packages/lore | @rat-stack/lore | Public content graph and traversal service |
packages/subscriber-delivery | @rat-stack/subscriber-delivery | External subscriber intake and confirmation adapter behind core’s job-shaped ports |
apps/infra | @rat-stack/infra | Alchemy Stack (Cloudflare by default) |
apps/site | @rat-stack/site | Cloudflare Worker for the public site, agent discovery, and sandboxed execute |
.agent_sources/ | — | Shallow upstream mirrors (gitignored clones; see README there) |
Try the example CLI
The included stats command counts bytes, Unicode characters, words, and lines in a file:
pnpm cli stats README.md
pnpm cli stats README.md --json
After pnpm build, you can run the built entrypoint directly:
node apps/cli/dist/cli.js stats README.md
The same capability on the other surfaces:
pnpm cli openapi # OpenAPI 3.1 document for the REST projection
pnpm cli serve --port 3000 # POST /inspectFile, GET /openapi.json, GET /docs
pnpm cli mcp # MCP server over stdio, one tool per capability
pnpm cli mcp --code-mode # MCP server with two tools: search and execute
pnpm cli catalog --types # the \`tools\` declarations a code-mode program sees
Code mode is the fourth projection. The model gets search for ranked matches with TypeScript signatures and execute for JavaScript with tools in scope. The program runs in a fresh Node subprocess under --permission. It cannot touch the file system or spawn processes. To call the host, it uses tools.<name>(input). The host validates the input schema and runs the same handler used by every other surface. Node’s permission model allows network egress. Put a Worker or Deno runtime behind the same Sandbox service to isolate the network too.
packages/capability/src is where a capability becomes a Command, an HttpApiEndpoint, a Tool, and a catalog entry. packages/core/src/inspect-file.ts supplies the CLI’s example capability. Hosted content has its own registry in apps/site/src/capabilities/index.ts. Provider adapters stay outside core (rat-stack-boundaries/no-core-adapters); apps provide them to core’s job-shaped ports at composition. Add another to capabilities, then verify every projection you keep; CLI command registration lives in apps/cli/src/command.ts.
What is in the stack?
- pnpm workspaces + Turborepo
2.11.2: cachedtypecheck/test/buildacross packages from day one. - Effect
4.0.0: typed runtime, errors, filesystem service, and the three surfaces the capabilities project onto:effect/cli,effect/http-api(withOpenApi.fromApi), andeffect/ai(Toolkit+McpServer). @effect/platform-node4.0.0: Node-backed services; keep adapter and core pins matched.- TypeScript
7.0.2: strict module and index-access checks. - XState
6.0.0-alpha.65: real lifecycle states; do not replace those with boolean soup. @xstate/effect0.1.0-alpha.7: the official XState v6 to Effect 4 bridge:createEffectActorruns a machine as a scoped Effect,fromEffectmakes Effects into actors with typed failures and requirements.packages/core/src/inspect-machine.tsis the example.- Alchemy
2.0.0-beta.81: Infrastructure as Effects:apps/infra/alchemy.run.tsis the Stack;pnpm infra:plan/infra:deploy/infra:destroy; auth viapnpm alchemy profile edit. - Oxlint + Ultracite + Oxfmt: native lint and format.
- Vitest
5.0.3+@effect/vitest4.0.0:it.effectandit.layerfor every Effect test; running Effects by hand in a test file is a lint error. @effect/tsgo0.51.1: patches TypeScript 7 inprepareso Effect language-service diagnostics (leaked requirements,any/unknownin channels, global Date/fetch/console inside Effect, Node built-ins where Effect has a service) failtsc.- varlock
1.20.0:.env.schemadeclares every variable with@env-specdecorators;pnpm checkrunsvarlock load, secrets stay in gitignored.env.local. - Vendored agent sources: Effect, effect-solutions, XState, Alchemy, Better Auth, and Foldkit via
./scripts/vendor-agent-sources.sh, each at the ref its workspace pin names (not x-algorithm; that stays app-specific). - Agent fence: lefthook pre-commit + Pi/Cursor/Claude hooks that block
git … --no-verify. Cheating should be uncomfortable and obvious.
Every dependency is pinned exactly. Review pin upgrades as stack changes.
Commands
| Command | Purpose |
|---|---|
pnpm typecheck | Check TypeScript without emitting files |
pnpm check | Typecheck, check formatting, and run type-aware linting |
pnpm fix | Apply Oxfmt and safe Oxlint fixes |
pnpm test | Run the Vitest suite once |
pnpm build | Compile package outputs |
pnpm cli -- ... | Build and run the CLI |
pnpm vendor:agent-sources | Clone core lib mirrors |
pnpm turbo run check test build | Cached verification pipeline |
Agentic surface
Every clone includes AGENTS.md for repo law and commands, CLAUDE.md as Claude Code’s pointer to that law, and VISION.md for project intent. Source mirrors live under .agent_sources/ after you run the vendor script. Repo-local Pi extensions belong in .pi/extensions/.
Make it yours
Start with the template:
gh repo create <name> --template joelhooks/rat-stack
Clone the new repository, then install it:
pnpm install
pnpm sources:fetch
pnpm sources:fetch imports missing pinned source commits from the configured repository remotes once, including in template children. Generation reads only local Git objects and never fetches. Re-run the command when adding a new pin that is not present locally.
Rename the workspace package names before you build on the example. The current name fields are:
package.json:rat-stackapps/cli/package.json:@rat-stack/cliapps/infra/package.json:@rat-stack/infraapps/site/package.json:@rat-stack/sitepackages/capability/package.json:@rat-stack/capabilitypackages/core/package.json:@rat-stack/core
Rename the bin entry in apps/cli/package.json, the root command in apps/cli/src/command.ts, and the version in apps/cli/src/version.ts. Then change every matching import. The current workspace imports are @rat-stack/capability, @rat-stack/cli, @rat-stack/core, @rat-stack/infra, and @rat-stack/site. Find them with:
rg -n '@rat-stack/' --glob '*.json' --glob '*.ts' -l
A new scope changes line lengths, so run the formatter once after the rename:
pnpm fix
Replace inspectFile in packages/core with one useful capability. Add it to capabilities, then verify each surface you keep. Rewrite the Project law, Architecture, and Boundaries sections of AGENTS.md and the top of .pi/APPEND_SYSTEM.md; they describe rat-stack until you do. Keep expected failures typed and map them to deliberate exit codes.
apps/site is the public site and Worker. apps/infra is its Alchemy Stack. Delete both if you do not want a public site. If you keep them, change the Cloudflare Zone name and the DNS names and targets derived from it in apps/infra/alchemy.run.ts. Change the Worker domain and redirects in apps/site/src/worker.ts. Choose an Alchemy stage for each plan or deploy. The stage is a command-line choice, for example --stage <stage>. Configure credentials with:
pnpm alchemy profile edit --add Cloudflare
Run the acceptance gate before the first push:
pnpm turbo run check test build
What it promises and how to check
- One capability reaches every kept surface. Proof:
packages/capability/test/to-command.test.ts,packages/capability/test/to-http-api.test.ts,packages/capability/test/to-toolkit.test.ts,packages/capability/test/to-code-mode.test.ts, andapps/cli/test/cli.e2e.test.ts. - Schemas are the contract. Proof:
packages/capability/test/catalog.test.ts,packages/capability/test/to-http-api.test.ts, andpackages/capability/test/to-toolkit.test.tscheck derived JSON Schema and typed failures. - A fresh sandbox isolates model code. The Node sandbox has a 10-second wall-clock limit and a 1-second limit per synchronous evaluation. Both run inside the child. Scope close and interruption send SIGTERM, then SIGKILL after 250 milliseconds. Stdin EOF, a lost parent, or a failed protocol write ends the child. Child-process imports remain blocked. Proof:
packages/capability/test/sandbox.test.tschecks isolation and VM budgets;sandbox-lifetime.test.tsandsandbox-orphan.test.tscheck deadlines, parent death, and process-group cleanup. - Rate limits fail closed. Proof:
apps/site/test/worker.test.tschecks 429 responses before another execute worker starts and when the global limit denies. - Markdown is the default machine representation. Proof:
apps/site/test/worker.test.tschecks Markdown withoutAccept: text/htmland HTML only when requested.
Keep or cut
The template is a working project. Delete what you will not use on day one; the fence will identify dependent code that must go too. After each cut, trim packages/capability/src/index.ts and run pnpm install, pnpm fix, then pnpm turbo run check test build. Update the package table in AGENTS.md.
| Want | Keep | Delete |
|---|---|---|
| Only the CLI | packages/capability/src/contract.ts, packages/capability/src/implement.ts, packages/capability/src/to-command.ts, call-watch.ts, actor-watch.ts, every dependency imported by retained code, and their tests; all of packages/core; the toCommand composition in apps/cli/src/surfaces.ts | unused projections and their matching tests after tracing the retained imports; unused compositions in apps/cli/src/surfaces.ts; the catalog, openapi, serve, and mcp commands in apps/cli/src/command.ts; apps/cli/test/serve.test.ts and the MCP and catalog cases in apps/cli/test/cli.e2e.test.ts |
| No code mode | packages/capability/src/catalog.ts, packages/capability/src/code-mode.ts, packages/capability/src/sandbox-error.ts, packages/capability/src/sandbox-service.ts, packages/capability/src/sandbox-subprocess.ts, and packages/capability/src/to-code-mode.ts; packages/capability/test/catalog.test.ts, packages/capability/test/sandbox.test.ts, packages/capability/test/sandbox-lifetime.test.ts, packages/capability/test/sandbox-orphan.test.ts, packages/capability/scripts/sandbox-lifetime-proof.mjs, and packages/capability/test/to-code-mode.test.ts; the ./sandbox and ./code-mode exports in packages/capability/package.json; codeMode and mcpServer.codeMode in apps/cli/src/surfaces.ts; the catalog command and the --code-mode flag in apps/cli/src/command.ts; the code-mode and catalog cases in apps/cli/test/cli.e2e.test.ts | |
| No HTTP | packages/capability/src/to-http-api.ts, packages/capability/src/http-api.ts, and their tests; the ./http-api export in packages/capability/package.json; http, routes, and webServer in apps/cli/src/surfaces.ts; the openapi and serve commands; apps/cli/test/serve.test.ts; the OpenAPI case in apps/cli/test/cli.e2e.test.ts | |
| No MCP | packages/capability/src/to-toolkit.ts, packages/capability/src/toolkit.ts, their test, and packages/capability/test/mcp-harness.ts; the ./toolkit export in packages/capability/package.json; tools and mcpServer in apps/cli/src/surfaces.ts; the mcp command; the MCP cases in apps/cli/test/cli.e2e.test.ts. Code mode imports from packages/capability/src/to-toolkit.ts, so cutting MCP cuts code mode too | |
| Default flags only | Core’s Flags port and declarations | Replace apps/site/src/flags.ts with core imports and export const flagsLayer = Flags.defaults(eventFlags); export const liveFlagsLayer = flagsLayer;; remove packages/flags and its dependency in apps/site/package.json. Readers use defaults; the CLI writer fails with a named read-only error |
| No analytics | packages/events; @rat-stack/events in apps/site/package.json; the Basin and basinFoundation calls, EVENTS_ENABLED, withEventCapture, inBackground, EVENTS_IDENTITY_MODE, and the events parameter in apps/site/src/worker.ts; EVENTS_ENABLED, EVENTS_SINK_TOKEN, and EVENTS_IDENTITY_MODE in apps/site/.env.schema and apps/site/src/config-fingerprint.ts; apps/site/test/worker-events.test.ts | |
| No XState (only after removing interest and every other lifecycle except file inspection) | Keep XState and its bridge while any retained lifecycle imports them | packages/core/src/inspect-machine.ts and its test (call FileInspector.inspect directly from packages/core/src/inspect-file.ts); xstate and @xstate/effect in packages/core/package.json and their minimumReleaseAgeExclude entries in pnpm-workspace.yaml; scripts/oxlint-plugin-xstate-effect.ts and its entry in oxlint.config.ts |
defineContract, implement, and toCommand are the minimum that keep stats working. The contract module has no dependency on the other projections.
License
MIT
The /uncomplect skill (2026-10-13, the capture that brought this repo in)
joelhooks (14❤; tweet + full skill page captured): “uncomplect skill is interesting because it works very well at a macro/meta layer as well as zoomed in to feature level — pair it with a prompt that scopes and see what you get.”
Uncomplect = “separate responsibilities that currently change together. Preserve behavior proved by tests or runtime observations. Replace structure that combines unrelated responsibilities. Make the separation hold under the next change.” Lineage named in the skill itself: Rich Hickey’s Simple Made Easy (2011, simplicity ≠ familiarity) + Greg Young’s The Art of Destroying Software (“optimise for deletion”).
The skill page is a specimen of rule-density worth studying: a terms table (capability/contract/projection/cartridge/fence/port/adapter), then per-smell sections each with the look-for (code location), the move-to (with a file to copy), and the fence that makes the fix stick — named lint rules like rat-stack-boundaries/no-feature-transport, plus honest “no fence yet” gaps with candidate property tests. Every recommendation “must name its source file, preserved behavior, deleted complexity, and enforced check” — swizec’s turn-review-comments-into-linters doctrine, pre-systematized as a skill.
Kin: Dipping my toes into clojure (Hickey’s Simple Made Easy already vault kin — uncomplect is its agentic operationalization), State machines (rat-stack = XState 6 + Effect 4), Agent skills (the skills-as-review-discipline family: jnsahaj’s process skills, crisp-diagrams, state-machine), rat-king (OSS) (the pre-alpha app built on this stack).
The origin story: “The hold that could not release itself” (2026-10-13)
The article behind the /uncomplect skill (captured in full, 12.5 KB) — a production post-mortem that reads as the skill’s proof-of-work. The bug: a course-sync pipeline stopped applying updates because a contract-v3 safety hold (written when the target violated v3) was restored on every poll before the v4 migration path could run — contract v4 had intentionally accepted the new shape. “A true fact from yesterday had become a command that could govern forever… we had made the past block the future.” The patch was 53 lines; the design lesson is the skill.
The anatomy: one held status complected four values — a historical observation under v3, the current authority to stop work, the contract version giving it meaning, and an operator policy. The fixes, each attributed to its source (the article is a bibliography-as-post-mortem):
- Hickey (Simple Made Easy, The Value of Values, Are We There Yet?): state complects value and time; “you cannot update a fact — a fact is not a place”; the current decision must be derived again from current values (
decideSync({contract: v4, target, source, unresolvedFacts})), never read off a mutable slot’s mood (if (status === "held") return). - Ousterhout (Philosophy of Software Design): “define errors out of existence” —
loadCurrentBinding(id)migrates the known predecessor internally, so “old contract blocked its own migration” stops being a state at all; also the temporal-decomposition diagnosis (organizing code by when work happens scatters one piece of knowledge across the lifecycle). - Greg Young (The Art of Destroying Software): “optimize to be able to delete code”; a component should take ~a week to rewrite — not doctrine, a forcing function for the boundary question.
- DDD: one
heldvocabulary collapsed four bounded contexts (Authoring Source / Sync Decision / Target Course / Operations); the aggregate root owns one invariant; “the [dashboard] badge does not become stronger than the aggregate that produced it. Our stale hold did.” - Effect + XState split: Effect at effectful boundaries (decoding, retries, transactions), XState on essential lifecycle (legal events, cancellation, rollback) — “neither should own the pure question ‘does this historical v3 hold still block a v4 decision?’ That is a function over values.”
And the skill’s own receipt: 24/24 with the skill vs 11/24 without on the same model — with the honest label “one run is a receipt, not a crown.” The closing design rule joins the vault’s collection: “a redesign that cannot name what disappears is probably just new nouns sitting on old complexity.”