One Schema, many surfaces
One Effect Schema definition can feed a CLI, REST API, MCP server, and code mode21. This study records the proposal and implementation on 2026-09-18 against Effect rc.115.
Read One capability, every surface18 for today’s defineContract → implement pattern, including browser RPC, and current pins.
The API names, proposed RPC timing, test counts, and sandbox permissions below are historical. Use current guidance for sandbox permissions.
Joel (2026-09-18): rat-stack is meant to build agent-first tools. The interesting question is whether one Effect Schema definition can project into a CLI, a REST API with OpenAPI, an MCP27 server, and a code-mode surface23, and what Executor and Kody teach about the last two.
What Effect rc.115 already ships
Every target below is in effect/unstable/* today and takes Schema directly.
None of them talk to each other.
| Surface | Module | Entry | Schema goes in as |
|---|---|---|---|
| CLI | unstable/cli | Command.make(name, config, handler) | Flag.withSchema, Flag.FileSchema, Argument.* |
| REST + OpenAPI | unstable/httpapi | HttpApi / HttpApiGroup / HttpApiEndpoint.{get,post,...} | payload, success, error schemas |
| OpenAPI doc | unstable/httpapi/OpenApi | OpenApi.fromApi(api) | derived from the HttpApi |
| Typed client | unstable/httpapi/HttpApiClient | HttpApiClient.make(api) | derived from the HttpApi |
| MCP | unstable/ai | Tool.make(name, { parameters, success, failure, dependencies, needsApproval }), Toolkit.make(...), McpServer.toolkit(toolkit), McpServer.layerStdio / layerHttp | parameters, success, failure |
| JSON Schema | unstable/ai/Tool | Tool.getJsonSchema(tool), Tool.getJsonSchemaFromSchema(schema) | any Schema |
| RPC | unstable/rpc | Rpc.make, RpcGroup | payload, success, error |
Tool annotations already exist as Context references: Tool.Readonly, Tool.Destructive, Tool.Idempotent, Tool.OpenWorld. needsApproval is a
first-class option on Tool.make.
Effect has no Toolkit.fromHttpApi or Command.fromToolkit to feed all four surfaces from one definition. rat-stack can supply that connection.
What the two reference repos do
Executor (UsefulSoftwareCo, Effect 4)
- Integrates existing tools: ingests MCP servers, OpenAPI, GraphQL, and Google Discovery into one catalog. Adds auth and per-tool policy (allow / approve / block), then serves the catalog over MCP.
- MCP surface:
execute(code mode),skills(server-side guides for the model),tools search/describe/call, plus artifacts. The catalog is the product; code mode is one way to hold it. - The kernel (
packages/kernel) is the reusable idea.ir/registry.tsserializes a catalog as{ version: "v4.1", types: Record<string, JsonSchema>, tools: [{ path, description, integrationId, input?, output?, error? }] }. Live registrations carrySchema.Topfor input/output/error; the serialized form carries JSON Schema names. Runtimes are pluggable: QuickJS, Deno subprocess, Cloudflare dynamic worker, workerd subprocess. - Contract at the kernel edge is Standard Schema (
inputSchema: StandardSchema), Effect Schema inside plugins. Effect in, JSON Schema at the wire. execute-action(artifact channel) is parsed against a one-call grammar instead of accepting arbitrary code. This narrows the surface to what the producer can emit.
Kody (Kent C. Dodds, Cloudflare Workers, Zod)
- Deliberately compact MCP surface:
search(discovery over Vectorize plus lexical) andexecute(sandboxed capability calls). “Do not add a new public MCP tool per capability.” - Authoring:
defineDomainCapability(domain, { name, description, inputSchema, outputSchema?, handler, tags?, keywords?, readOnly?, idempotent?, destructive?, requiredRole?, requiredPermission?, featureFlag? }), grouped bydefineDomain, flattened into a registry. Zod is normalized to JSON Schema for both the MCP description and the code-mode types. - Same annotation vocabulary as Effect’s
Toolreferences, plus RBAC and flags.
Cloudflare Code Mode (the origin)
One tool, codemode({ code }). The MCP or OpenAPI schema becomes a typed TypeScript API with doc comments; the model writes TS against it; it runs in an
isolated Worker with no network, only bindings23. Inside the sandbox: search(query), describe(target), step(name, fn) (run once, replay), and run(snippet). Durable log, approval pause, rollback. The OpenAPI MCP server
variant exposes exactly two tools, search and execute.
Proposed rat-stack shape
Keep Effect Schema as the single source and add one domain object plus projections. Nothing here needs a new schema language.
Capability (Effect Schema in / out / error, Effect handler,
dependencies, annotations, approval)
│
┌──────────────┬──────────────────┼──────────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
Command.make HttpApiEndpoint Tool.make catalog IR (later)
(unstable/cli) (unstable/httpapi) Toolkit.make JSON Schema + Rpc.make
│ │ McpServer.toolkit generated d.ts
▼ ▼ │ ▼
rat-stack REST + OpenApi.fromApi │ search + execute
CLI + HttpApiClient ▼ (two MCP tools,
stdio / http sandboxed runner) - Capability is a
Schema-typed record:name,description,input,output,error,handler: (input) => Effect<Output, Error, Deps>, annotations (readOnly/destructive/idempotent/openWorld),needsApproval. This is Kody’sdefineDomainCapabilityand Executor’sLiveToolRegistrationsaid in Effect. - Projections are pure functions from a
Capability[](or aDomain) to the Effect surface:toToolkit,toHttpApi,toCommand. The handler’s dependencies flow through unchanged, so the composition root16 stays oneLayeras it is today inapps/cli/src/cli.ts. - Code mode is a fourth projection: serialize the
catalog (Executor IR shape: JSON Schema per type, path per capability), emit
.d.tsfrom the JSON Schema, exposesearchandexecuteas two ordinaryTools whoseexecutehandler runs model code in a sandbox that can only call capabilities by path. Executor’s kernel proves the runtime is swappable; start with a subprocess before using a Worker. - Approval and policy belong on the Capability, so all four surfaces agree
on what is destructive. The CLI can prompt (
Promptinunstable/cli), MCP can useneedsApproval, REST can return 202 with a resume token, code mode can pause like Cloudflare’s runtime.
Applied 2026-09-18 (slices 1 and 2)
packages/capabilityat that date: the now-removeddefineCapabilityAPI (since split intodefineContractandimplement) andtoCommand,toHttpApi,toToolkit. Twelve tests run each surface for real (CLI underTestConsole, HTTP viaHttpApiTest.groups, MCP through an in-memory web handler withRpcClient).packages/core:FileStatsbecame a Schema;inspectFileis the first capability; the lifecycle machine20 moved here from the CLI so every surface runs it.apps/cli:statsistoCommand(inspectFile), plusopenapi,serve --port(API,/openapi.json, Scalar at/docs), andmcpover stdio with logs on stderr. The e2e suite drives the built binary’s MCP server over stdio and checksreadOnlyHint.- Type lessons. A heterogeneous list of capabilities needs a handler typed
(input: never) => Effect<unknown, unknown, unknown>(contravariant parameters) and extractors that infer every type parameter at once. Schemas are bounded byPlainSchema(no decoding or encoding services) because projections decode at process edges.HttpApiEndpoint.postguards the error schema with a non-exported conditional type, so the runtime call goes through a loosely typed alias whileEndpointOfnames the precise type. The lint config bans namespaces and same-name redeclaration, so the API is plain named exports. - Casts live at exactly two boundaries per projection (tuple-to-record and handler record), each with a reason; the two projection files turn off three language-service diagnostics that cannot tell a typed boundary from a leak.
Applied 2026-09-18 (slices 3 and 4)
catalog.ts:toCatalog(JSON Schema per channel viaTool.getJsonSchemaFromSchema, the same schemas MCP and OpenAPI publish),toTypeScript(a.d.tsfor thetoolsobject with$defsas named aliases and@throwson the failure),searchCatalog(token overlap over name, input fields, description; deterministic, no dependencies).- The historical, now-removed
sandbox.ts(today split intosandbox-service.tsandsandbox-subprocess.ts): aSandboxservice andlayerSubprocess. The runner is passed withnode --permission --input-type=module -e <source>so the permission model never has to allow a file read (--allow-fs-readneeds the real path, which on macOS/tmpis not). File system, child processes, and workers are denied;fetchis not. Protocol is newline-delimited JSON over stdio throughChildProcessSpawner, aQueuefeedinghandle.stdin, andStream.decodeText+splitLineson stdout. to-code-mode.ts:searchandexecuteas two ordinaryTools.execute’s description carries the generated declarations. Everytools.<name>(input)call decodes through the capability’s input schema, runs the handler with the captured context, and encodes the output or the declared failure back; the program seesInvalidInput,UnknownCapability, or the failure’s_tag.apps/cli:mcp --code-modeandcatalog [--types]. The e2e suite runs a code-mode program against the built binary over stdio.- Gotchas.
it.effectruns on theTestClock, so a sandbox timeout never fires under it, andtest.liveinside anit.layerblock behaved the same; the timeout test is a top-levelit.livewith the layer provided inline. A ternary over two differently typedLayers cannot be piped further; pick theLayer.launchper branch instead. Theno-array-sortlint rule plus an ES2022 lib left no legal way to sort; the workspace target and lib are ES2023 now.
Template acceptance 2026-09-18
Scaffolded joelhooks/rat-stack-scaffold-test from the template (gh repo create --template), cloned it, and installed with a fresh pnpm store (npm_config_store_dir pointed at an empty directory). Install took 15.6s; corepack picked pnpm 11.3.0 from packageManager even with 11.10.0 on PATH. The prepare script patched tsc for @effect/tsgo and installed the lefthook hook. pnpm turbo run check test build passed: 14 tasks, 57 tests. stats README.md, catalog --types, and pnpm env:check all worked on the built output. Nothing needed fixing. The throwaway repo joelhooks/rat-stack-scaffold-test still exists (agent tooling blocks gh repo delete; Joel removes it). Rerun this after any dependency or vendor change; it is the only test of the template as a template.
Original seed plan (before slices 3 and 4)
inspectFile (input { path }, output FileStats, failure FileStatsError)
is the one capability shipped, projected to stats, POST /inspectFile, and
the inspectFile MCP tool. Slices 3 and 4 (catalog IR with generated types, search + execute, subprocess sandbox) are next.
Historical cautions (2026-09-18)
- Every module involved is
effect/unstable/*. Pin and vendor as now; expect renames between rc’s. McpServer.layerHttprequiresprotocols: NonEmptyReadonlyArray<ProtocolAdapter>and anHttpRouter;layerStdiois the cheap first target.- Do not build the sandbox first. Three projections ship on top of shipped Effect modules; the sandbox is the only new runtime and should come after the catalog IR exists.
- JSON Schema is the wire type everywhere (Executor, Kody, Cloudflare). Keep
Effect Schema inside the process and
Tool.getJsonSchemaFromSchemaat the edge; do not leak Effect types into the sandbox API.
Last changed 2026-10-06 in 9f29173. Source on GitHub. Change log.
Content updated .