-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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:
-
Session file — the path the caller supplied via
--sessionorTERMSPRAWL_SESSION_FILE. Parsed as aToolSession; the only value this code takes from it istoken. -
Endpoint file —
endpoint.json, resolved asjoin(dirname(dirname(sessionFile)), 'endpoint.json'), i.e. one directory above the session file's own directory. Parsed as aToolEndpoint; the client usesurl.
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
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.
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 inargvand spliced out as a pair; if it is absent,TERMSPRAWL_SESSION_FILEis 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.jsonto 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.
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 * 1024bytes are rejected before parsing (Message too large). - Messages without an
idare treated as notifications and skipped without a response — this is whatnotifications/initializedrelies on. -
initializefirst issues a realsession_infotool call; if the app is unreachable the handshake fails instead of advertising a server that cannot work. On success it returns protocol version2024-11-05,capabilities: { tools: {} },serverInfo: { name: 'termsprawl', version: '1.0.0' }, andinstructions: TOOL_GUIDES.overview. -
pingreturns{}. -
tools/listreturnsAGENT_TOOLSverbatim — the shared operation table fromagent-tools.ts, so the MCP catalog and the CLI derive from the same source and cannot drift apart. -
tools/callmapsparams.nameto the operation andparams.arguments ?? {}to the args, then converts theToolResultinto MCP content: if the result is ok and its value carries a base64image, the response is a singleimagecontent block (PNG); otherwise it is one text block containingJSON.stringify(reply), preserving the raw{ ok, value | error }envelope for the model.isErrormirrors!reply.ok. - Any other method returns
-32601 Method not foundwithout the success write. - Any thrown error is reported as
-32603with the message; this includes JSON parse failures, which therefore look like an internal error withid: nullrather than a JSON-RPC parse error.
The loop ends when stdin hits EOF; this mode never exits on its own.
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:
-
Explicit
ExternalTerminalConfig({ executable, args }, sourced fromagent-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 throughfindExecutable, injectable so tests can exercise selection without touching PATH. -
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".
-
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 aToolResult. 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 handssessionFileplus a{ operation, args }object to the client. -
src/core/agent-tool-external.ts— orthogonal to transport. Command construction (pure enough to test with an injectedfind) 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 fromsrc/core/agent-tools.tsandsrc/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.
-
App restarted mid-session — works by design: the new
endpoint.jsonis 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
ToolResultwithout checkingresponse.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.login this mode corrupts the JSON-RPC stream; use stderr. -
Oversized MCP messages — rejected at 256 KiB with
-32603and 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
spawnis 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.
-
New tool operation — add it to the shared table in
agent-tools.ts;doctor/call,tools/list, andtools/callall pass operations through by name, so no change is needed in this page's files. -
New MCP method — extend the
if / else ifchain in the readline loop. Notifications (noid) are already dropped before dispatch, and unknown methods already return-32601. -
New result media type —
tools/callcurrently special-casesvalue.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.jsonentry containing exactly one{command}placeholder. -
Timeout / size limits —
45_000in the client and256 * 1024in 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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance