| title | Tool Protocol |
|---|---|
| description | The NDJSON socket protocol, ToolResult envelope, capabilities, caller identity, timeouts, and auditing model. |
| section | Developers |
| sectionOrder | 2 |
| order | 30 |
| slug | developers/tool-protocol |
meith exposes its tool registry over a local newline-delimited JSON socket. The
renderer uses Electron IPC, but it calls the same ToolRegistry and receives the
same ToolResult shape.
The protocol types live in:
packages/protocol/src/messages.tspackages/protocol/src/tools.tspackages/shared/src/schemas.tspackages/shared/src/types.ts
The socket protocol is NDJSON: each line is one complete JSON frame.
The runtime writes its socket path to ~/.meith/config.json and also publishes
live instance records in ~/.meith/instances/. The CLI uses those files to find
the target runtime unless --socket or --instance is provided.
Every message may include protocol. The current value is:
PROTOCOL_VERSION = 1If a client sends a mismatched protocol version, the server returns a transport
error with PROTOCOL_ERROR.
Frames are capped by MAX_FRAME_BYTES in the NDJSON parser. Malformed frames are
reported and skipped rather than crashing the connection.
Lists serializable tool descriptors.
{
"type": "list_tools",
"protocol": 1,
"clientInfo": { "caller": "cli" }
}Response: tools_list.
Calls a registered tool.
{
"type": "tool_call",
"protocol": 1,
"requestId": "req_ab12cd34",
"toolName": "open_browser_tab",
"arguments": { "url": "http://localhost:3000" },
"clientInfo": {
"caller": "cli",
"cwd": "/work/project",
"spaceId": "space_1",
"tabId": "btab_1"
},
"timeoutMs": 30000
}Response stream:
- zero or more
tool_eventframes, - one final
tool_resultframe.
Cancels an in-flight call by request id.
{
"type": "cancel_tool_call",
"protocol": 1,
"requestId": "req_ab12cd34"
}Cancellation is cooperative. The registry aborts the call signal, and tools are
expected to observe ctx.signal. The registry still races execution against the
abort signal so a cancelled or timed-out call resolves with a structured failure
even if a tool does not observe the signal promptly.
{
"type": "tools_list",
"protocol": 1,
"tools": [
{
"name": "open_browser_tab",
"description": "Open a new browser tab pointing at a URL and focus it.",
"inputSchema": {},
"capabilities": ["controls-browser"]
}
]
}Tool descriptors are generated from registered ToolDefinitions. The
inputSchema is derived from each tool's Zod schema with zod-to-json-schema.
{
"type": "tool_event",
"protocol": 1,
"requestId": "req_ab12cd34",
"event": {
"kind": "progress",
"message": "starting",
"fraction": 0.1
}
}Event kinds:
progresslogpartial_textartifact
The CLI prints progress and partial text in human-readable mode. meith devlogs
uses streamed log events from attach_process_logs.
Tool success and tool failure both use tool_result.
{
"type": "tool_result",
"protocol": 1,
"requestId": "req_ab12cd34",
"result": {
"ok": true,
"content": { "id": "btab_123" }
}
}Transport/protocol-level errors only.
{
"type": "error",
"protocol": 1,
"requestId": "req_ab12cd34",
"code": "PROTOCOL_ERROR",
"message": "Unsupported protocol version"
}Do not use transport error for tool execution failures. Tool-level failures
belong inside tool_result.result.
Every tool resolves to this envelope:
interface ToolResult<T = unknown> {
ok: boolean;
content?: T;
meta?: Record<string, unknown>;
diagnostics?: Array<{
level: "info" | "warn" | "error";
message: string;
}>;
error?: {
code: ToolErrorCode;
message: string;
details?: unknown;
};
}Tools may return either a raw value or a full ToolResult.
- Raw values become
{ ok: true, content: value }. okResult()anderrorResult()create explicit envelopes.- Throwing
ToolErrorcontrols the returned error code. - Any other throw becomes
TOOL_FAILED.
Error codes:
UNKNOWN_TOOLVALIDATION_ERRORPERMISSION_DENIEDTIMEOUTTOOL_FAILEDRUNTIME_SHUTTING_DOWNCANCELLEDPROTOCOL_ERROR
Each tool can declare safety metadata:
read-onlywrites-filescontrols-browserstarts-processaccesses-networkdestructive
PermissionService treats these as authorization inputs. Renderer and internal
callers are trusted but audited. Socket, agent, and plugin callers are subject
to grants or approved plugin capabilities for privileged capabilities.
The current agent approval gate prompts for writes-files, controls-browser,
starts-process, and destructive. accesses-network remains part of the
declared capability model and audit trail, and plugins still need approved
capabilities that cover the tools they call.
git_diff is the built-in read-only working-tree diff tool. It reports staged
and unstaged changes against HEAD plus untracked files. Pass scope: "staged" or scope: "unstaged" to fetch a section-specific diff:
{
"cwd": "/work/project",
"includePatches": false,
"scope": "all"
}includePatches: false returns file status and line-count summaries without
patch bodies. git_status returns the corresponding branch, ahead/behind, and
staged/unstaged/untracked sections used by the Git panel. The renderer uses
these cheap summaries for the top-bar Git changes chip and visible Git tab,
refreshing while they are shown. The tab then requests a
selected file on demand:
{
"cwd": "/work/project",
"includePatches": true,
"path": "src/app/page.tsx",
"scope": "unstaged"
}Mutating git tools are capability-gated. git_stage, git_unstage,
git_commit, git_branch, and git_worktree declare writes-files.
git_restore and git_checkpoint_restore declare destructive and require a
literal confirm: true argument.
The Git panel stages individual files and its Stage all control by calling
git_stage with repository-relative paths. Its commit-message suggestion flow
first loads the full staged diff (includePatches: true, scope: "staged"),
or the full working-tree diff when nothing is staged, then sends that patch
through the renderer ai.complete bridge for a one-line Conventional Commit
subject. The deterministic filename heuristic is only a fallback when no real
agent completion is available.
The top-header branch switcher uses git_branch with action: "list" to show
the current branch, action: "switch" to change branches, and action: "create" followed by switch when creating a new branch.
git_commit reads the active Git identity profile from app settings. When a
profile is selected, Meith passes that profile as the commit author and
committer for commits created through the tool. When no profile is selected,
Git's repository/global config decides the identity.
git_identity_detect is read-only. It proposes commit identity profiles from
repo/effective Git config, global Git config, and installed provider CLI account
state such as gh auth status or glab auth status. It does not read credential
helper secrets, tokens, or private key material; the renderer only saves a
suggestion after the user explicitly adds it.
Checkpoints are hidden git commit objects stored under
refs/meith/checkpoints/* with metadata in .git/meith/checkpoints.json.
AgentService creates a git_checkpoint_create snapshot before each agent run
and records the agent sessionId; callers can later list, compare, or restore
those checkpoints without moving the current branch ref.
clientInfo carries requested caller context:
{
caller: "cli" | "renderer" | "agent" | "plugin" | "internal";
cwd?: string;
sessionId?: string;
spaceId?: string;
tabId?: string;
}The socket is a local but untrusted boundary. ToolSocketService does not trust
security-relevant caller claims from socket peers:
- socket clients can assert
cliorplugin, - attempts to assert
renderer,agent, orinternalare downgraded, - client-provided
sessionIdis replaced by a server-assigned per-connection id, cwd,spaceId, andtabIdare passed through as scope hints.
This matters for browser automation ownership. A socket peer cannot choose a session id to impersonate another owner.
Renderer IPC is in-process and trusted as caller: "renderer".
Agents do not use the public socket for privileged identity. AgentService
calls the registry in-process and passes caller: "agent" with the real session
id. External ACP agents call through a per-session MCP bridge that maps bearer
tokens back to the same gated agent session.
Plugin calls are resolved from the sender webContents id. The plugin page never supplies its own trusted identity.
PermissionService applies a deny-by-default policy for every caller that is
not renderer or internal:
| Caller | Privileged tool without a grant | Read-only tool |
|---|---|---|
renderer |
Allowed (trusted in-process) | Allowed |
internal |
Allowed (trusted in-process) | Allowed |
cli |
Denied unless session has a matching capability grant | Allowed |
agent |
Denied if no sessionId is present, or if the session has no matching capability grant |
Allowed |
plugin |
Denied if the plugin id resolves to null grants, or the approved grants do not cover the required capability |
Allowed |
Every call — allowed or denied — is written to audit.jsonl. Revoking a session
(PermissionService.revokeSession) immediately invalidates all grants for that
session; subsequent calls with the revoked session id receive PERMISSION_DENIED
even if the tool was previously allowed.
The test suite in permission.test.ts enforces these invariants directly:
renderer/internal trust, agent-without-session deny, plugin-with-null-grants
deny, plugin-with-empty-grants deny, and revoked-session deny are all covered
as explicit test cases.
Timeout order:
tool_call.timeoutMs, if supplied,- the tool definition's
timeoutMs, if supplied, DEFAULT_TOOL_TIMEOUT_MS.
On expiry, the final result is:
{
"ok": false,
"error": {
"code": "TIMEOUT",
"message": "Tool \"name\" timed out after 30000ms"
}
}Some tools intentionally override timeout. attach_process_logs, for example,
uses a very large timeout because it is designed to stream until the caller
cancels or disconnects.
Every registry call is logged and passed to PermissionService.auditToolCall.
Audit entries include caller, tool name, capabilities, decision, cwd, session,
space, tab, summarized arguments, summarized result, and duration.
Argument and result summaries redact likely secrets and large text payloads
before writing to audit.jsonl.