kolu
Docs

How to expose a surface to agents

An agent (Claude Code, Codex, opencode) drives a surface through the Model Context Protocol: your cells and streams become readable resources, your procedures become callable tools. @kolu/surface-mcp does the projection; you decide what to expose.

1. Install and import

{ "dependencies": { "@kolu/surface-mcp": "workspace:*" } }
import { buildSurfaceFace } from "@kolu/surface/client";
import { directDispatch } from "@kolu/surface/links/direct";
import { implementSurface } from "@kolu/surface/server";
import {
  serveSurfaceAsMcp,
  type SurfaceClientCallable,
} from "@kolu/surface-mcp";

2. Build a client for the surface

Choose one of two shapes:

  • Serve fresh when the MCP server is the backend — buildSurfaceFace(surface, directDispatch(runtime)) over an in-process implementSurface.
  • Bridge a live surface when a server is already running — dial it over its socket or ssh stdio instead.
const runtime = implementSurface(surface, {
  cells: { load: { store: inMemoryStore(ZERO) } },
  collections: {
    processes: {
      readAll: () => table,
      upsert: (pid, proc) => {
        table.set(pid, proc);
      },
      remove: (pid) => {
        table.delete(pid);
      },
    },
  },
  streams: {
    nodeLog: {
      source: (nodeId) =>
        Stream.succeed({
          kind: "snapshot" as const,
          text: `opened ${nodeId}`,
          done: false,
        }),
    },
  },
  events: { autosave: {} },
  procedures: {
    proc: {
      kill: ({ input, ctx }) =>
        Effect.sync(() => {
          ctx.collections.processes.remove(input.pid);
          return { ok: true };
        }),
    },
  },
});
// The member face over the in-process dispatch — serve-fresh, no wire.
const client = buildSurfaceFace(surface, directDispatch(runtime));

3. Name what to expose

expose is default-deny: an unlisted member never reaches the agent. Map each key to "resource" (cells, collections, streams, events) or a tool (procedures).

await serveSurfaceAsMcp({
  core: {
    surface,
    expose: {
      load: "resource", // cell   → readable, subscribable
      nodeLog: "resource", // stream → readable, subscribable
      "proc.kill": { tool: { mutates: true } }, // procedure → mutating tool
      // "proc.configure" omitted → never reaches the agent
    },
  },
  client: () => ({ core }),
});

The map is not the MCP adapter’s own shape — it is @kolu/surface/expose’s, and serveSurfaceApp / serveOverUnixSocket take the same one. So the agent face and the browser face are curated in the same vocabulary and can be curated differently: a verb the agent may call is not thereby reachable from a tab someone left open.

4. Teach the agent your domain

An expose map says what an agent may touch; it says nothing about what any of it means. Pass instructions and the agent reads it at initialize, before it calls anything:

await serveSurfaceAsMcp({
  core: { surface, expose: EXPOSE },
  client: () => ({ core: client }),
  instructions:
    "Everything here is about NODES, not files. There is no file access — a " +
    "node is the smallest thing you can name, and that is deliberate.",
});

5. Refuse with data, not a sentence

A tool that says “no” is answering, and the answer should be as machine-readable as a success. Fail with a ToolFailure and the agent gets both halves — prose to read, detail to act on:

import { ToolFailure } from "@kolu/surface-mcp";

throw new ToolFailure(
  "text and key can't be combined in one send — send the text, wait, then submit",
  { kind: "text-and-key" },
);

The agent receives an isError result whose structuredContent is that object — through the same JSON round-trip a success takes, so a refusal cannot publish a shape a success would have been refused for — and a driver branches on kind instead of matching English. Any other failure comes back as its message alone — structure is something you declare, never something the adapter guesses from an error’s fields.

6. Curate a dangerous surface (optional)

If the surface has procedures no agent should reach, do not rely on omission alone — project a safe view with projectSurface (from @kolu/surface/project): drop the dangerous procedures, bound the logs, derive read-only verdicts, and expose that second surface. It is the difference between hiding a control and not wiring it at all.

7. Serve several surfaces on one endpoint

A core and no siblings is the ordinary case. When the endpoint should carry a set of surfaces whose roster moves — one per tenant, per plugin, per open document — hand them in as surfaces and the sibling key becomes a segment of every name that sibling contributes: surface://collections/tenantA/rows, tenantA_ops_run. The core keeps its bare names.

// SEVERAL surfaces on one endpoint: each sibling brings its own expose map and
// its own bespoke tools, and its key is a segment of every name it contributes —
// `surface://collections/tenantA/processes`, `tenantA_proc_kill`. The core stays
// bare. `reroster` takes a new sibling map in place and tells the host the lists
// moved.
const served = await serveSurfaceAsMcp({
  core: { surface, expose: { load: "resource" } },
  surfaces: {
    tenantA: {
      surface,
      expose: { processes: "resource", "proc.kill": "tool" },
    },
    tenantB: { surface, expose: { processes: "resource" } },
  },
  client: () => ({ core, clients: { tenantA: core, tenantB: core } }),
});

// tenantB left; tenantA stays, and its standing subscriptions stay with it.
await served.reroster({
  tenantA: { surface, expose: { processes: "resource", "proc.kill": "tool" } },
});

served.reroster(surfaces) replaces the sibling map whole and tells the host both lists changed; a subscription the new roster cannot serve is ended, and a call to a departed sibling’s name is refused by name rather than as an unknown one. The core never moves — serving a different core is a different endpoint.

When each sibling has a sentence of its own to teach — “the chat sibling shows your answer to a person” is true only while chat is standing — pass instructions as a function and compose it from the roster the way the tool list already is. It is read at each initialize, so a host that connects after a move is told the roster it gets:

const served = await serveSurfaceAsMcp({
  core: { surface, expose: EXPOSE },
  surfaces: roster(),
  client: () => bundleFor(roster()),
  instructions: () =>
    [CORE_CHARTER, ...Object.values(roster()).map((row) => row.charter)].join("\n\n"),
});

A host already connected keeps what it was told at its own initialize: MCP has no instructions_changed notification, so only the next host sees the move.

8. Point the agent at it

The server speaks stdio by default. Register it in the agent’s .mcp.json and the tools and resources appear in its next session. For the full option list and the resource-URI scheme, see the @kolu/surface-mcp reference.