---
title: One Schema, many surfaces (CLI, REST/OpenAPI, MCP, code mode)
type: resource
status: applied
created: 2026-09-18
updated: 2026-09-18
tags: [effect, mcp, openapi, cli, code-mode, executor, kody, architecture]
sources:
  - https://github.com/UsefulSoftwareCo/executor
  - https://github.com/kentcdodds/kody
  - https://blog.cloudflare.com/code-mode/
  - https://developers.cloudflare.com/agents/api-reference/codemode/
  - .agent_sources/github.com/Effect-TS/effect/packages/effect/src/unstable/{ai,cli,httpapi,rpc}
related:
  - ./effect-4-reference-projects.svx
---

# One Schema, many surfaces

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 MCP server, and a code-mode surface, 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`.

**The gap:** there is no `Toolkit.fromHttpApi`, no `Command.fromToolkit`, nothing
that lets one definition feed all four. That is the seam rat-stack can own.

## What the two reference repos do

### Executor (UsefulSoftwareCo, Effect 4)

- Integration layer, not a tool author: ingests MCP servers, OpenAPI, GraphQL,
  Google Discovery into one catalog, adds auth and per-tool policy
  (allow / approve / block), serves the catalog back 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.ts`
  serializes a catalog as `{ version: "v4.1", types: Record<string, JsonSchema>,
  tools: [{ path, description, integrationId, input?, output?, error? }] }`.
  Live registrations carry `Schema.Top` for 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. Good instinct: narrow 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) and `execute` (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 by
  `defineDomain`, 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 `Tool` references, 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 bindings. 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's `defineDomainCapability` and Executor's
  `LiveToolRegistration` said in Effect.
- **Projections** are pure functions from a `Capability[]` (or a `Domain`) to
  the Effect surface: `toToolkit`, `toHttpApi`, `toCommand`. The handler's
  dependencies flow through unchanged, so the composition root stays one
  `Layer` as it is today in `apps/cli/src/cli.ts`.
- **Code mode** is a fourth projection, not a different system: serialize the
  catalog (Executor IR shape: JSON Schema per type, path per capability), emit
  `.d.ts` from the JSON Schema, expose `search` and `execute` as two ordinary
  `Tool`s whose `execute` handler 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, not a Worker.
- **Approval and policy** belong on the Capability, so all four surfaces agree
  on what is destructive. The CLI can prompt (`Prompt` in `unstable/cli`), MCP
  can use `needsApproval`, 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/capability`: `defineCapability` and `toCommand`, `toHttpApi`, `toToolkit`. Twelve tests run each surface for real (CLI under `TestConsole`, HTTP via `HttpApiTest.groups`, MCP through an in-memory web handler with `RpcClient`).
- `packages/core`: `FileStats` became a Schema; `inspectFile` is the first capability; the lifecycle machine moved here from the CLI so every surface runs it.
- `apps/cli`: `stats` is `toCommand(inspectFile)`, plus `openapi`, `serve --port` (API, `/openapi.json`, Scalar at `/docs`), and `mcp` over stdio with logs on stderr. The e2e suite drives the built binary's MCP server over stdio and checks `readOnlyHint`.
- 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 by `PlainSchema` (no decoding or encoding services) because projections decode at process edges. `HttpApiEndpoint.post` guards the error schema with a non-exported conditional type, so the runtime call goes through a loosely typed alias while `EndpointOf` names 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 via `Tool.getJsonSchemaFromSchema`, the same schemas MCP and OpenAPI publish), `toTypeScript` (a `.d.ts` for the `tools` object with `$defs` as named aliases and `@throws` on the failure), `searchCatalog` (token overlap over name, input fields, description; deterministic, no dependencies).
- `sandbox.ts`: a `Sandbox` service and `layerSubprocess`. The runner is passed with `node --permission --input-type=module -e <source>` so the permission model never has to allow a file read (`--allow-fs-read` needs the real path, which on macOS `/tmp` is not). File system, child processes, and workers are denied; `fetch` is not. Protocol is newline-delimited JSON over stdio through `ChildProcessSpawner`, a `Queue` feeding `handle.stdin`, and `Stream.decodeText` + `splitLines` on stdout.
- `to-code-mode.ts`: `search` and `execute` as two ordinary `Tool`s. `execute`'s description carries the generated declarations. Every `tools.<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 sees `InvalidInput`, `UnknownCapability`, or the failure's `_tag`.
- `apps/cli`: `mcp --code-mode` and `catalog [--types]`. The e2e suite runs a code-mode program against the built binary over stdio.
- Gotchas. `it.effect` runs on the `TestClock`, so a sandbox timeout never fires under it, and `test.live` inside an `it.layer` block behaved the same; the timeout test is a top-level `it.live` with the layer provided inline. A ternary over two differently typed `Layer`s cannot be piped further; pick the `Layer.launch` per branch instead. The `no-array-sort` lint 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.

## Seed

`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.

## Cautions

- Every module involved is `effect/unstable/*`. Pin and vendor as now; expect
  renames between rc's.
- `McpServer.layerHttp` requires `protocols: NonEmptyReadonlyArray<ProtocolAdapter>`
  and an `HttpRouter`; `layerStdio` is 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.getJsonSchemaFromSchema` at the
  edge; do not leak Effect types into the sandbox API.
