kolu
Docs

How to expose a surface to a terminal

A shell drives a surface through @kolu/surface-cli: your cells and collections become readable members, your procedures become callable verbs. It is the argv sibling of exposing a surface to agents — the same surface, the same allowlist, the same verb table, spelled for a terminal.

1. Install and import

{
  "dependencies": {
    "@kolu/surface-cli": "workspace:*",
    "@effect/platform-node": "catalog:"
  }
}

@effect/platform-node is for the run edge in step 5 (NodeRuntime.runMain), not for the projection — surfaceCommands itself is platform-blind.

import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
  buildSurfaceFace,
  type SurfaceClientCallable,
} from "@kolu/surface/client";
import { unixSocketLink } from "@kolu/surface/links/unix-socket";
import { getRuntimeSocketPath } from "@kolu/surface/unix-socket";
import type { SurfaceVerb } from "@kolu/surface/verbs";
import {
  type EndpointSeam,
  reportingRunEdge,
  type SurfaceCliConnection,
  surfaceCommands,
} from "@kolu/surface-cli";
import { Effect } from "effect";
import { Command, Flag } from "effect/unstable/cli";

2. Say where to dial

This is the one thing a CLI needs that the MCP adapter does not: a command reaches a server, so something has to decide which one. That decision is yours — the resolution order (--socket, then an environment variable, then a dev file, then the per-user runtime socket) is your app’s policy and nothing in the adapter could guess it.

The seam is one step, and its shape is the point: resolve reads the flags once and answers with the endpoint’s name beside the thunk that opens it. The name is needed exactly when the dial fails — which is when there is no connection left to ask — and a describe beside a connect would walk your resolution order twice and could name one endpoint while dialling another.

It returns an Effect, so an order that can come up empty has somewhere to say so: fail it and the user gets exit 3 (“no endpoint to dial”), the same arm as a failed dial, instead of a crash. And values is typed from your own flag record, so renaming a flag is a compile error rather than an undefined your app dials.

// WHERE to dial is the app's policy, so the app owns the resolution: the flags a
// user spells it with, and one step that reads them. Nothing in `surface-cli`
// knows what a socket is.
//
// ONE step, answering with the endpoint's NAME beside the thunk that opens it —
// the name is what a FAILED dial has to report, which is exactly when there is
// no connection left to ask. A separate `describe` beside a `connect` would walk
// the resolution order twice and could name one endpoint while dialling another.
const endpointFlags = {
  socket: Flag.string("socket").pipe(
    Flag.withDefault(
      getRuntimeSocketPath({ app: "example", file: "surface.sock" }),
    ),
  ),
};

// `values` is TYPED from the flag record above — nothing restates its shape, so
// renaming the flag is a compile error here rather than an `undefined` the app
// would dial as the string "undefined".
//
// `resolve` returns an Effect: a resolution order that can come up empty ("no
// $APP_SOCKET, no runtime dir") has somewhere to say so, and a host whose flags
// sit on its OWN parent (`Command.withSharedFlags`) reads them from the parent's
// context here instead — omit `flags` in that case and this face adds none.
const endpoint: EndpointSeam<typeof endpointFlags> = {
  flags: endpointFlags,
  resolve: (values) =>
    Effect.succeed({
      where: values.socket,
      open: async (): Promise<SurfaceCliConnection> => {
        const link = await unixSocketLink({
          group: surface.group,
          socketPath: values.socket,
        });
        return {
          client: buildSurfaceFace(
            surface,
            link.dispatch,
          ) as SurfaceClientCallable,
          // Required, not optional: a CLI dials, does one thing and exits, and
          // the one failure that costs a user something is a socket left open in
          // a shell loop.
          dispose: () => link.dispose(),
        };
      },
    }),
};

3. Hand over the same verb table

A hand-authored verb is a SurfaceVerb — the same record you hand serveSurfaceAsMcp as its tools. One table, two faces, one set of names.

// The SAME record `serveSurfaceAsMcp` takes as its `tools` — one table, two
// faces, one set of names.
const verbs: Record<string, SurfaceVerb> = {
  top: {
    description: "The busiest process right now.",
    mutates: false,
    handler: (_args, _client: SurfaceClientCallable) =>
      Effect.succeed({ pid: 1, command: "init" }),
  },
};

4. Project it

expose is default-deny and it is the same map the agent face and the wire faces take. surfaceCommands returns an array of commands — a value, not a program.

const commands = surfaceCommands({
  surface,
  // The default-deny map — the same one the MCP face and the wire faces read.
  expose: {
    load: "resource",
    processes: "resource",
    "proc.kill": "tool",
  },
  verbs,
  endpoint,
  // CLI-only ergonomics, BESIDE the verb table rather than inside it: `pid`
  // becomes an argv position, so it is `proc_kill 4321`, not `--pid 4321`.
  annotate: { proc_kill: { positional: ["pid"] } },
  info: { name: "example" },
});

CLI-only ergonomics ride in annotate, beside the verb table rather than inside it: a positional is a shell concern, and the verb record has to stay a value the MCP face can take verbatim.

5. Mount them in your binary

// The host binary mounts them beside its own faces and keeps the run edge —
// `surfaceCommands` returns values and runs no program.
//
// `reportingRunEdge` is not optional garnish: it is what makes the published
// exit matrix true of a real binary. Every failure this face raises carries
// `Runtime.errorReported = false` (its line is its own, and Effect's pretty cause
// dump on top would be noise), so a host that re-fails without writing that line
// exits with the right code and says NOTHING. And a refusal from the CLI
// *library* — a rejected flag, an unknown subcommand — carries no code of ours at
// all until the edge gives it one.
const root = Command.make("example").pipe(
  Command.withSubcommands([...commands]),
);

export const cli = Command.run(root, { version: "1.0.0" }).pipe(
  // The whole edge, in one line: it catches the CAUSE rather than the failure (a
  // DEFECT is not a failure, so `Effect.catch` never sees one — and the runtime
  // then reports it itself, through the default logger, which writes to STDOUT
  // and drops a log line into the data channel), passes an INTERRUPT through
  // untouched (that is Ctrl-C, and 130 is the runtime's own teardown), writes
  // the arm's line, and re-fails with the verdict the code is read off.
  reportingRunEdge,
);

// The edge is only HALF of the recipe; this is the other half, and it stays
// yours because it is `runMain`'s own argument. The line is already written, and
// without this the runtime prints its own second, differently-worded report of
// the same failure. On stdout. In the middle of the data.
NodeRuntime.runMain(cli.pipe(Effect.provide(NodeServices.layer)), {
  disableErrorReporting: true,
});

Which faces your binary offers is your decision, so the adapter hands back values and runs nothing. Your Command.run is already the run edge; there is no runSurfaceCli to learn.

Do not skip reportingRunEdge. Every failure this face raises carries its own line and its own exit code, and marks itself already-reported so Effect’s cause dump does not print on top of it — which means a host that re-fails without writing that line exits with the right code and says nothing at all. A refusal from the CLI library (a rejected flag, an unknown subcommand) carries no code of ours until the edge gives it one. And a defect is not a failure, so Effect.catch never sees one and the runtime reports it through the default logger — onto stdout, in the middle of your data; the edge catches the cause for exactly that reason. That one line is what makes the exit matrix true of your binary rather than only of this package’s own failures.

And take both lines above, not just the edge. disableErrorReporting stays yours because it is runMain’s own argument, and without it the runtime prints its own second, differently-worded copy of the line the edge already wrote, on stdout. The edge and the flag are one recipe.

What you get

example proc_kill 4321 --signal TERM     # a procedure, by its flat name
example top                              # a hand-authored verb
example get load                         # a cell's current value
example get processes 4321               # one item of a collection
example keys processes                   # the key set
example watch processes                  # snapshot, then ndjson deltas
example get load --follow                # the live subscription as ndjson
example list                             # what this face offers

Every verb also takes --input '{…}' (or --input - to read it from stdin) as the alternative to the field flags — the whole input, as data, for the cases a shell would fight you over. And every verb takes --json, which asks for the whole answer as data instead of whatever summary the host wrote for it.

Read it from a script

The two facts a script needs are the shape of the output and the exit code.

  • stdout is data: one JSON value for a read, one compact JSON line per frame for anything streamed. Indented only when stdout is a terminal — which decides the SPACING and nothing else. What the answer is is decided by --json and by nothing about the descriptor, so a command prints the same thing in a pipe, in a CI log and in front of a person.
  • stderr is prose, with one exception that proves the rule: a verb’s declared refusal is JSON on stderr, because it is data you can act on but is not the verb’s answer.
  • the exit code says which happened — see the matrix.

An absent collection item is a successful read whose answer says so, not a failure — the read genuinely happened, and “not there” is what it found — so branch on the payload and keep the exit code for the things that are not answers.

A read that never found out is the other thing. A one-shot item read is bounded against a deadline as well as against membership, and a quiet producer or a collection with no key set to resolve against can run it out while the item is perfectly alive. That is a fact about the read, not about the item, so it is not an answer and does not arrive as one: it is exit 3, the endpoint’s arm, naming the member, the key and the budget. Your script therefore never has to tell “gone” from “could not tell” by reading a payload — the exit code has already done it:

if ! proc=$(example get processes 4321); then
  case "$?" in
    3) echo "no answer — nothing serving, or the read ran out of time" >&2 ;;
  esac
  exit 1
fi
if [ "$(jq -r '.present // true' <<<"$proc")" = "false" ]; then
  echo "4321 is gone"        # membership said so: safe to reap
else
  echo "still running: $(jq -r .command <<<"$proc")"
fi

Where to go next