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