Skip to content

Agent Tool Client, CLI & MCP Entry

dazeb edited this page Sep 17, 2026 · 1 revision

Agent Tool Client, CLI & MCP Entry

An agent process running inside a terminal node (or in a desktop terminal attached to the same tmux session) cannot import the Electron main process. Every read or mutation it wants to perform has to travel as an HTTP call to a tool server the running app exposes on loopback. This page covers the agent side of that hop: how the process learns which session it belongs to, where the app is listening, how it authenticates, and the three entry surfaces that expose the hop — the bundled termsprawlctl CLI, the MCP stdio bridge, and the external terminal spawn.

The server side of the contract (ToolRequest, ToolResult, the operation table, and the main-process runtime that fulfills operations) is covered by "Agent Tool Protocol & In-Process Server". This page assumes that contract and describes only the caller.

The call path: discovery, authentication, transport

All entry surfaces bottom out in one function, callAgentTool(sessionFile, request) in src/core/agent-tool-client.ts. Nothing is cached and nothing is baked in at process start; every call re-reads two files:

  1. Session file — the path the caller supplied via --session or TERMSPRAWL_SESSION_FILE. Parsed as a ToolSession; the only value this code takes from it is token.
  2. Endpoint file — endpoint.json, resolved as join(dirname(dirname(sessionFile)), 'endpoint.json'), i.e. one directory above the session file's own directory. Parsed as a ToolEndpoint; the client uses url.

Re-reading both per call is the mechanism that lets "a running agent survive an app restart" (the doc comment on the function): the app can restart, re-bind its tool HTTP port, rewrite endpoint.json, and the next call from the already-running agent finds the new address without the agent being relaunched or re-configured.

The endpoint is validated before the token is ever attached: protocol must be http:, hostname exactly 127.0.0.1, pathname exactly /call. Anything else throws locally as Invalid local endpoint, so a tampered or stale endpoint.json cannot redirect the bearer token off the machine. The request is a POST with authorization: Bearer <session.token>, content-type: application/json, redirect: 'error' (a redirect would be another way to move the token), and AbortSignal.timeout(45_000).

callAgentTool never rejects. It does not check response.ok either — it parses the body as ToolResult and returns it — and every failure along the way (unreadable file, malformed JSON, invalid endpoint, connection refused, timeout) is caught and converted into a single shape:

{ ok: false, error: 'Tool connection unavailable: … Check that termsprawl is running. Mutations are not automatically retried.' }

The last sentence is a contract, not a nicety: after a transport failure the caller cannot know whether the server executed a mutating operation, so the client deliberately refuses to be the thing that replays it. Callers branch on result.ok and nothing else.

sequenceDiagram
    autonumber
    participant Agent as Agent process
    participant Entry as agent-tool-entry.ts
    participant Client as agent-tool-client.ts
    participant Files as session.json + endpoint.json
    participant Server as Tool server (127.0.0.1/call)

    Agent->>Entry: termsprawlctl call OP / doctor / mcp JSON-RPC line
    Entry->>Client: callAgentTool(sessionFile, { operation, args })
    Client->>Files: read session.json (token)
    Client->>Files: read endpoint.json (url)
    Client->>Client: require http://127.0.0.1/call
    Client->>Server: POST /call, Bearer token, 45s timeout
    Server-->>Client: ToolResult JSON
    Client-->>Entry: ToolResult (or ok:false + reason)
    Entry-->>Agent: pretty JSON / JSON-RPC result or error
Loading

Key nodes: the two file reads happen on every call, which is what decouples the agent's lifetime from the app's; the host/scheme/path check is the last gate before the token leaves the process; and error normalization happens in the client, so the entry layer never has to distinguish "HTTP failed" from "tool said no" — both arrive as a ToolResult.

Entry surfaces

termsprawlctl CLI mode

src/core/agent-tool-entry.ts is the process entry that ships as the termsprawlctl command (the usage string in the file names it). Argument handling happens before any transport:

  • --session <path> is located anywhere in argv and spliced out as a pair; if it is absent, TERMSPRAWL_SESSION_FILE is used. The flag wins when both are present, and because the pair is removed, argv[0] afterwards is always the mode — the flag works before or after the subcommand.
  • If no session resolves, the process writes Launch this helper from a termsprawl agent, or provide --session /absolute/path/to/session.json to stderr and sets exit code 2 before touching the network. The path's existence is not checked here; a nonexistent file fails later as a tool-connection error (exit 1), not as a usage error.

Then argv[0] selects the mode:

Invocation Behavior Exit code
termsprawlctl doctor sends operation session_info, prints the pretty-printed ToolResult 0, or 1 when ok:false
termsprawlctl call OP [JSON_ARGS] sends OP with JSON_ARGS parsed as JSON (default {}), prints the result 0, or 1 when ok:false
termsprawlctl mcp switches to the stdio bridge below —
anything else Usage: termsprawlctl doctor | call OPERATION [JSON_ARGS] | mcp on stderr 2

Argument parse failures and the usage error share the same catch: message to stderr, exit 2. Tool-level failures are printed to stdout as normal output and set exit 1, so scripts can tell "the app answered no" from "the call never happened". A call with no operation name reaches the server with the operation property absent (JSON.stringify drops undefined); this is not validated locally.

MCP stdio mode

termsprawlctl mcp turns the same process into a stdio MCP server. The loop is a readline interface over stdin with crlfDelay: Infinity (CRLF clients work), one JSON-RPC message per line in, one response — or none — per line out. stdout is the protocol channel; the file carries the comment "MCP stdio uses one JSON-RPC message per line. Never log to stdout." Any diagnostics added to this mode must go to stderr or they corrupt the stream.

Per message:

  • Lines longer than 256 * 1024 bytes are rejected before parsing (Message too large).
  • Messages without an id are treated as notifications and skipped without a response — this is what notifications/initialized relies on.
  • initialize first issues a real session_info tool call; if the app is unreachable the handshake fails instead of advertising a server that cannot work. On success it returns protocol version 2024-11-05, capabilities: { tools: {} }, serverInfo: { name: 'termsprawl', version: '1.0.0' }, and instructions: TOOL_GUIDES.overview.
  • ping returns {}.
  • tools/list returns AGENT_TOOLS verbatim — the shared operation table from agent-tools.ts, so the MCP catalog and the CLI derive from the same source and cannot drift apart.
  • tools/call maps params.name to the operation and params.arguments ?? {} to the args, then converts the ToolResult into MCP content: if the result is ok and its value carries a base64 image, the response is a single image content block (PNG); otherwise it is one text block containing JSON.stringify(reply), preserving the raw { ok, value | error } envelope for the model. isError mirrors !reply.ok.
  • Any other method returns -32601 Method not found without the success write.
  • Any thrown error is reported as -32603 with the message; this includes JSON parse failures, which therefore look like an internal error with id: null rather than a JSON-RPC parse error.

The loop ends when stdin hits EOF; this mode never exits on its own.

External terminal spawn

src/core/agent-tool-external.ts answers a different question — "show me this agent in a real terminal window" — but shares the same session identity: the emulator is attached to the node's tmux session, so the agent inside it uses the CLI/MCP entry and resolves discovery through the environment it was launched with. This module only consumes TERMSPRAWL_SESSION_FILE; the launcher that exports it belongs to the agent-launch path.

externalTerminalCommand(tmux, nodeId, config?, find = findExecutable) builds the argv. The attach command is always [tmuxPath, ...tmux.baseArgs, 'attach-session', '-t', sessionNameFor(nodeId)] — session naming comes from src/core/tmux.ts. Two ways to pick the emulator:

  1. Explicit ExternalTerminalConfig ({ executable, args }, sourced from agent-tools/preferences.json, the path named in the error message). Validation is strict: executable non-empty, args an array of strings, and exactly one element equal to {command}. The placeholder slot is replaced by the whole attach argv, so wrappers like -- {command} or -e {command} work. The executable is resolved through findExecutable, injectable so tests can exercise selection without touching PATH.
  2. Fallback probe, in order: x-terminal-emulator -e, kitty --, alacritty -e, gnome-terminal --, konsole -e, xterm -e. The first one present on PATH wins.

If neither yields a terminal, the call throws No supported Linux terminal emulator found… The canvas terminal is still available. — note the Linux-only probe list and the explicit fallback to the in-app terminal.

launchExternalTerminal(command) performs the spawn: detached: true, stdio: 'ignore', and an environment copy with TMUX/TMUX_PANE deleted. Without that deletion the new emulator inherits the app's "I am inside tmux" identity and the nested attach-session behaves as an in-tmux client instead of a fresh terminal. The promise resolves on the child's spawn event and rejects on error; the child is unref'd. Success means "the process started", not "the terminal is usable".

File responsibilities and how they collaborate

  • src/core/agent-tool-client.ts — the transport. The only place that reads the discovery files, validates the endpoint, attaches the bearer token, applies the 45 s cap, and converts every failure into a ToolResult. No CLI or terminal concerns.
  • src/core/agent-tool-entry.ts — the process boundary. Owns argv/env parsing, mode dispatch, per-mode output format, stdout discipline, and exit codes. It contains no HTTP and no file reading; it hands sessionFile plus a { operation, args } object to the client.
  • src/core/agent-tool-external.ts — orthogonal to transport. Command construction (pure enough to test with an injected find) plus one fire-and-forget spawn. It knows tmux and terminal emulators, not tools.
  • The contracts (ToolRequest, ToolResult, ToolSession, ToolEndpoint, AGENT_TOOLS, TOOL_GUIDES) are imported from src/core/agent-tools.ts and src/core/agent-tool-server.ts; none of these three files defines a parallel type, so changing the protocol shape is a change in one place.

In one sentence: the entry resolves which session and what output contract, the client resolves where the app is and how to authenticate, the external module decides how to display the session — and only the client touches the wire.

Boundary conditions

  • App restarted mid-session — works by design: the new endpoint.json is read on the next call, nothing is cached.
  • App not running / stale files — reads, connect, or the 45 s timeout all collapse into one "Tool connection unavailable" result; treat it as unavailable, not failed.
  • No automatic retry of mutations — the error text states this explicitly; a caller that retries anyway can double-apply an operation that succeeded server-side before the connection dropped.
  • HTTP status is ignored — the client parses the body as ToolResult without checking response.ok; a JSON error body is trusted as the result, a non-JSON error page throws and is reported as a connection problem. The server contract must hold on every path.
  • Endpoint tampering — scheme/host/path checks plus redirect: 'error' keep the token on loopback and on /call.
  • MCP stdout — a stray console.log in this mode corrupts the JSON-RPC stream; use stderr.
  • Oversized MCP messages — rejected at 256 KiB with -32603 and a null id; the response direction has no cap.
  • Malformed JSON-RPC — surfaces as -32603, not -32700; clients distinguishing parse errors cannot rely on the code.
  • Exit codes (CLI) — 0 success, 1 tool-level ok:false, 2 usage/parse/missing session.
  • External spawn is fire-and-forget — an emulator that dies immediately after spawn is still reported as success, and no output is captured (stdio: 'ignore').
  • Linux-only fallback list — on other platforms only an explicit config (or a PATH-installed binary with a matching name) resolves.

Extension points

  • New tool operation — add it to the shared table in agent-tools.ts; doctor/call, tools/list, and tools/call all pass operations through by name, so no change is needed in this page's files.
  • New MCP method — extend the if / else if chain in the readline loop. Notifications (no id) are already dropped before dispatch, and unknown methods already return -32601.
  • New result media type — tools/call currently special-cases value.image (base64 PNG) and falls back to JSON text; mirror that shape when a tool starts returning other binary payloads.
  • New terminal emulator — either add a tuple to the probe list with the emulator's pass-through flag convention, or ship an agent-tools/preferences.json entry containing exactly one {command} placeholder.
  • Timeout / size limits — 45_000 in the client and 256 * 1024 in the MCP loop are the two constants that bound every call.

Sources: src/core/agent-tool-client.ts, src/core/agent-tool-entry.ts, src/core/agent-tool-external.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally