Skip to main content
loomcycle
§ release · rfc bc

Client-executed tools: the agent's eyes and hands.

Loomcycle's tool model has been one-directional since v0.8. The runtime hosts tools; the agent calls a tool; the runtime executes it. Some tools run in-process (Memory, Channel, Path, Document). Some run inside the runtime's Bashbox sandbox. Some are HTTP calls to external MCP servers the runtime knows about. Every tool the model can see is either compiled into the binary or reachable from the binary. The agent picks a tool; the runtime runs it.

That works for headless workloads. It doesn't work for the thing I actually wanted next: an agent that reads the page I have open in my browser. Or fills a form. Or clicks through a wizard. The runtime doesn't have a browser. The runtime doesn't have my browser. The tool needs to run where the browser is, which is not the runtime.

RFC BC inverts the direction. A client (browser, IDE, mobile app, whatever) opens a WebSocket to the runtime and registers its own tools. When the agent calls one, the runtime routes the invoke over the socket to the connected client, which runs the tool locally and returns the result. From the model's point of view, it's an ordinary tool call. From the operator's point of view, the agent got a new capability with zero server code.

The short version. RFC BC ships across four layers in v1.16.0. Layer 1: a transport-agnostic registry that owns invoke↔result correlation, per-principal connection map, per-key connection cap, and a delegate-and-block Invoke that fails cleanly on disconnect / no-client / timeout. Layer 2: GET /v1/client-tools, a bearer-authed WebSocket endpoint that accepts bearer.<token> as a Sec-WebSocket-Protocol entry (browsers can't set Authorization headers on a WS handshake). Layer 3: the model sees the connected principal's client-tools as client__-prefixed tools; grants gate access via client__browser_* globs on the agent's tools: allowlist; each advertised tool is a delegating adapter whose Execute calls registry.Invoke. Layer 4: a TypeScript connectClientTools helper in @loomcycle/client that opens the WebSocket, sends the hello, dispatches invoke → onInvoke → result, and auto-reconnects. v1.16.1 fixes the wire-safe name (client:client__): Anthropic, OpenAI, and Ollama all reject colons in tool names, so v1.16.0 was uncallable end-to-end until the rename.

Why not a channel bridge

The obvious first design (and the one LoomBoard's Chrome extension shipped with in its early alpha) is a channel bridge. The runtime already has Channels (RFC S): persistent inter-agent message bus, cursor-based delivery. Add a channel per browser session, have the client subscribe, publish agent-side commands to the channel, publish client-side results back. Same shape as any pub-sub actuation layer.

It sort-of works. LoomBoard's early extension used exactly that shape for a couple of weeks. But it has three problems that stack up.

The agent doesn't see a tool. It sees a channel publish + an await + a parse. Every command needs a wrapper skill teaching the agent the specific channel protocol. Every capability the client wants to expose needs a matching skill in the agent's context. The tools: allowlist and the skills: allowlist don't compose cleanly because there's no first-class tool for the channel-bridged capability.

Delivery semantics are wrong. Channels are pub-sub with a cursor. Two things break: an old, unrelated subscriber might drain a command meant for the current session (RFC AC once burned a whole afternoon on exactly this bug in the extension); and there's no built-in "who acknowledged this" beyond the cursor advance, so timeout and retry become client-side gymnastics. The runtime's routing wants request/response semantics, not pub-sub.

Multi-client is ambiguous. If two browser tabs subscribe to the same channel for the same user, which one gets the command? Cursor-based drain answers "whichever is quickest to poll," which is not a routing decision an operator wants delegated to network jitter. The channel bridge has no notion of "the browser currently in focus" or "the most recently connected client."

RFC BC solves all three by making client-tools first-class. Advertised as tools with schemas. Delivered request/response over a stateful connection. Routed to the most-recently-registered connection for the caller's principal. LoomBoard's extension migrated from channels to RFC BC on 2026-07-07 in commit 2ff4ace. Same day the runtime patch that made the client-tool names actually wire-safe landed.

Layer 1: the registry

The registry is where invoke↔result correlation lives, and it's deliberately transport-agnostic. No WebSocket types in the package interface; the transport calls in with a "send this frame" function, and the registry decides which connection to route to. That structure comes from internal/steer (the interactive-run steering registry), which has the same pattern.

// internal/clienttools — registry.go (paraphrased)
type Registry struct {
    mu    sync.RWMutex
    conns map[PrincipalKey][]*Conn      // per-(tenant, subject)
}

func (r *Registry) Register(principal PrincipalKey, tools []ToolSpec, send SendFrame) (deregister func())
func (r *Registry) Provides(principal PrincipalKey) []ToolSpec                       // schema union for advertising
func (r *Registry) Invoke(ctx, principal, toolName, input) (Result, error)           // delegate-and-block

Three properties that mattered during the review:

Delegate-and-block Invoke. The agent's tool call arrives; the registry picks the most-recently-registered connection for that principal that provides the tool; it sends an invoke frame; it blocks on the caller's ctx. Two ways out: the client sends a result frame (unblocks; the reply flows back through the tool_result envelope), or the ctx expires (timeout, cancel, run-cancel; unblocks with ErrTimeout). Disconnect mid-call fails all pending invokes on that connection with ErrClientDisconnected. No waiter ever hangs.

Per-key connection cap. A single principal can register multiple connections (multiple browser tabs, browser + IDE, whatever), but there's a per-key cap (defaults to a small number, LOOMCYCLE_CLIENT_TOOL_MAX_CONNS). New connections that would exceed the cap are refused with ErrTooManyConnections. Prevents accidental fan-out from a misbehaving client.

Most-recently-registered wins. When multiple connections for the same principal provide the same tool, the registry picks the newest one. That's the "the browser currently in focus" heuristic; if you open a new browser tab, it registers, and subsequent invokes go to it. Old tabs are still registered and receive nothing; they don't compete for messages.

Unit tests cover the shape with a fake sender: round-trip, no-client, wrong-tool, wrong-principal, mid-call disconnect, ctx timeout, most-recently-registered wins, per-key cap + slot reuse. -race-clean.

Layer 2: the WebSocket endpoint

A new GET /v1/client-tools endpoint upgrades the connection to a WebSocket. The route is bearer-gated by the existing authMiddleware (composes ahead of the WS upgrade, so the authenticated principal is on ctx before the handshake completes). Scoped to runs:create.

There's one browser-hostile detail worth calling out. Browsers cannot set the Authorization header on a WebSocket upgrade request. The WebSocket constructor takes a URL and a subprotocol list; that's it. So instead of an Authorization: Bearer <token> header, RFC BC's client rides the bearer in the Sec-WebSocket-Protocol handshake:

Sec-WebSocket-Protocol: loomcycle.client-tools.v1, bearer.eyJhbGciOiJIUzI...

The runtime's extractBearer function reads the bearer.<token> entry as a fallback path when no Authorization header is present. The app subprotocol (loomcycle.client-tools.v1) is negotiated and echoed in the server's response; the bearer entry never is. Standard trick for browser WebSocket auth. Server-side clients (Node ws package, Go's coder/websocket) can use either an Authorization header or the subprotocol path.

The handshake goes:

// wire frames
client → server: hello    { tools: [{ name, description, input_schema }, ...] }
server → client: hello_ok { accepted_tools: [...], connection_id }

// invocations (either direction can send ping/pong for keepalive)
server → client: invoke   { call_id, tool_name, input }
client → server: result   { call_id, ok, output? | error? }

The server's read pump routes result frames back to pending invokes by call_id. A ping heartbeat runs every ~30s (single-writer-mutex so the read pump and heartbeat don't collide on the WebSocket write). On any read error or ctx cancel, the deferred deregister fires and fails all in-flight invokes on that connection.

Env knobs for the operator: LOOMCYCLE_CLIENT_TOOL_TIMEOUT_MS (per-invoke timeout, defaults to 30s), LOOMCYCLE_CLIENT_TOOL_MAX_BYTES (per-frame size cap), LOOMCYCLE_CLIENT_TOOL_MAX_CONNS (per-principal connection cap). Set LOOMCYCLE_CLIENT_TOOLS_ENABLED=0 to disable the feature entirely; the endpoint returns 503.

Layer 3: dispatch and advertising

With a connected client that advertised, say, browser_read_page and click, the runtime's candidateTools function now includes those two names in the tool list sent to the model, prefixed with client__. The agent's grant list can allow them via glob:

tools:
  - client__browser_*
  - Memory
  - Channel

filterTools narrows the model's tool set to what the agent's grant permits. The existing policy.Matches evaluator (the same one used for MCP tool grants) works unchanged: client__browser_read_page matches client__browser_*. Grants for client__click and client__fill follow the same shape.

Each advertised client-tool is a delegating adapter. Its Execute reads the routing key (tenant, subject) from RunIdentity, calls registry.Invoke under a per-call timeout, and maps the reply to a standard tools.Result. Three error paths surface as clear tool-errors: ErrNoClient (the client disconnected before the agent got to call), ErrClientDisconnected (disconnected mid-call), timeout (the client took too long to respond). A JSON-string output unwraps to plain text; other JSON passes through.

One design decision worth naming: RFC BC dropped the planned fallback function composition. MCP dynamic tools need a lazy fallback because MCP servers may need a handshake before their tool list is known. Client-tools have no handshake at invoke time; a client-tool is either advertised-or-absent from the in-memory registry. A fallback would be dead code. Client-tools are plain advertised tools whose Execute delegates. Simpler mental model.

The routing key comes from RunIdentity, not from the tool input. That's a security-critical detail. If the routing key came from the model, a compromised agent could route its own tool calls to another user's connection. The runtime authoritative answer is: the invoke routes to the connection registered under the run's own (tenant, subject), full stop. No cross-principal routing is possible.

Layer 4: the TypeScript helper

The @loomcycle/client npm package gained a connectClientTools helper in the same release. Dependency-free: uses the global WebSocket constructor (browsers, Node 22+), or an injected WebSocketImpl (the ws package) on older Node.

// consumer sketch (paraphrased)
import { createClient, connectClientTools } from "@loomcycle/client";

const client = createClient({ baseUrl, token });

const host = connectClientTools(client, {
  tools: [
    {
      name: "browser_read_page",
      description: "Return the visible text of the current tab.",
      input_schema: { type: "object", properties: {} },
    },
  ],
  onInvoke: async ({ name, input }) => {
    if (name === "browser_read_page") {
      const text = await chrome.tabs.query({ active: true }).then(readActiveTab);
      return { ok: true, output: text };
    }
    return { ok: false, error: `unknown tool: ${name}` };
  },
});

// auto-reconnects on disconnect; call host.stop() to tear down

The bearer rides the Sec-WebSocket-Protocol subprotocol under the hood; the app subprotocol (loomcycle.client-tools.v1) is negotiated separately. No developer-facing surface for the bearer trick. Auto-reconnect is on by default with a short backoff; a mid-call disconnect fails that specific invoke on the server side, and the next agent call finds no client and returns ErrNoClient. If the client reconnects and re-registers with the same tool set, subsequent invokes route to it normally.

The wire-safe name fix (v1.16.1)

RFC BC shipped in v1.16.0 with a name that isn't a valid LLM function name. The advertised name for a browser tool was client:browser.read_page. Two problems: the client: prefix contains a colon; the bare name contains a dot. Both fall outside [a-zA-Z0-9_-]{1,64}, which Anthropic and OpenAI both enforce at their tool-schema layer. Ollama's own tool-call recovery (looksLikeIdentifier) also rejects them.

The result was that a client-tool was uncallable end-to-end from launch. Qwen mangled client:browser.read_page into client (returning "tool not found: client"). Anthropic and OpenAI 400'd the entire turn. The registry and routing were fine, and the tool would have executed if the model had ever been able to call it. Only the model-facing name broke.

v1.16.1 changes the prefix from client: to client__. Same double-underscore convention mcp__ uses, and for the same reason (wire-safe by construction). The exposed name becomes client__browser_read_page. Bare names are validated at the WebSocket hello boundary: clienttools.ValidBareName requires [a-zA-Z0-9_-] and enforces that ToolPrefix + name stays under 64 characters (Anthropic's max tool name length). Invalid names are skipped (not registered, not advertised); hello_ok reflects only the accepted names, so the client sees what stuck.

Agent grant patterns updated at the same time: client__browser_* instead of client:browser.*. The policy.Matches evaluator needed no change; the pattern grammar is unchanged, just the concrete strings.

How LoomBoard's extension uses it

LoomBoard is the loomcycle chat app. It ships as a native desktop app (Tauri v2), a CLI runner, an embeddable React component, and a Chrome side-panel extension. The extension is the first RFC BC customer.

A side-panel opens in Chrome. The panel opens a WebSocket to the user's loomcycle and registers four tools: browser_read_page (returns the visible text and semantic structure of the active tab), fill (fills a form field by CSS selector), click (clicks an element), and navigate (navigates the active tab). A content script runs on <all_urls> and executes those tools in the tab.

The agent side is a bundled AgentDef called chrome-assistant. Its grant list includes client__browser_* plus WebFetch, WebSearch, Memory, and the Skill tool. When the user opens the side panel on a page and asks "summarize this," the agent calls client__browser_read_page, the runtime routes the invoke to the user's browser, the browser reads the tab, the text comes back as the tool result, the agent summarizes. Every step is an ordinary tool call in the transcript.

Actuation is gated by user confirmation. The extension has a Confirm-vs-Auto toggle: in Confirm mode (default), every fill / click / navigate raises an approval bar in the side panel that the user clicks through. In Auto mode, actions run immediately (with a Stop button to dismiss a pending one). Sensitive fields (password inputs, payment card fields) always require confirmation regardless of mode. The security posture is documented on the extension's README and repeated on the download page.

This is what "the agent's eyes and hands in your browser" means concretely. Not RPA-shaped automation with brittle selectors; the agent reads the page's semantic content, decides what to do, and asks the browser to do it. LoomBoard's extension is 0.1.7 as of this release; expect faster capability growth than the desktop app because RFC BC removes the runtime-side friction.

What this substrate move unlocks

The tool-execution direction inverting isn't a browser-specific win. It's a shape change with general applicability.

IDE integrations. An IDE extension can register editor_read_file, editor_write_file, editor_search, editor_open_at, and the agent reads and edits the buffers the developer is actually looking at, not the git working tree. Same shape as the browser but with a different content script. Cursor, VS Code, Zed, wherever the developer types.

Mobile app actuation. A mobile app can register phone_send_sms, phone_open_map, phone_scan_qr, and the agent has access to phone-only capabilities without the runtime needing a phone API. LoomBoard's mobile builds (Tauri Android scaffolded) will use this.

Local hardware. A homelab agent can register rpi_read_gpio, rpi_write_gpio, and control physical devices from a Raspberry Pi that speaks WebSocket to a loomcycle running elsewhere. The runtime doesn't need to know about GPIO.

Cross-tenant guest capabilities. A guest workstation running a WebSocket-connected tool provider can expose one-off capabilities to a specific agent without a permanent server-side integration. Ephemeral by nature.

The general shape is: any capability that lives where the runtime doesn't. Traditional MCP servers cover the case where the capability lives somewhere reachable by HTTP. RFC BC covers the case where the capability lives on the user's own device. Different shape, same primitive discipline: advertised tools with schemas, grants gated by allowlist, invocations routed by principal, results delivered by tool_result envelope.

Composition with earlier substrate

RFC BC reuses three primitives without touching them:

RFC L operator tokens. The WebSocket bearer is any operator token. Scoping, tenant-binding, per-principal identity all come from the existing token substrate. Client-tools don't invent a new auth model.

RFC AG per-principal MCP dispatch + RFC AO declared principals. The (tenant, subject) principal that owns a client-tool connection is the same principal that reads the tenant's Documents, has its own SQL Memory scope, and shows up in the audit log. LoomBoard's Chrome extension in your browser and your regular Web UI login share a principal by construction; your Documents are visible from either surface without a permission model rewrite.

RFC BA on-demand Skills. The chrome-assistant agent's skills: allowlist can include marketing/* or writing/* the same way any other agent does. Skills are the "instructions the agent needs to do X well"; client-tools are the "capabilities the agent can call to do X." Two different concerns, wired independently.

What isn't reused: MCPServerDef doesn't participate. A client-tool provider is not an MCP server; it doesn't ship with an MCPServerDef, isn't registered in the mcpservers substrate table, isn't advertised via the MCP tools list. A different Def family for a different failure model. The two shapes coexist without overlap.

What's next in this three-day arc

Monday closes the three-day arc that ships RFC BA, RFC BB, and RFC BC as separate primitives in the same window. Nine tags of loomcycle (v1.14.0 through v1.16.1) plus LoomBoard 0.1.2's Chrome extension migration. On paper it's a lot; each RFC is a small primitive that composes with a shape already in place, so the additive complexity is modest. The tenant-operator UI (RFC AS) and the substrate credential store (RFC AR) shipped earlier this month and did most of the surface-area work.

What's next in the running list: an IDE integration for RFC BC (loomboard as a Cursor / VS Code / Zed extension using the same client-tools shape), the ChromeWeb Store submission for the extension once signed builds land (v0.2 of LoomBoard), and RFC AP (TeamDef, agent teams with pushback loops) which has been in the RFC list since 2026-06-23 waiting for its Phase 1 landing window.

Companion reading: RFC BA on-demand skills (Saturday), RFC BB search-provider cascade (Sunday), the LoomBoard download page (Chrome extension in the "Other install paths" disclosure), and the client-tools help topic for the operator-side reference including the security posture notes.