Calyx is a macOS terminal for running and supervising coding agents in parallel: Claude Code, Codex, OpenCode, Hermes, Grok, and pi. Supporting six CLIs meant writing Calyx into each one's configuration and speaking whatever event mechanism it has. This post compares what each CLI exposes for four things:
- Where it reads MCP servers and hooks from, and in what format
- How a hook or plugin gets called, and with which events
- Whether an external program can answer a permission prompt, and what happens when it does not
- What it reports about subagents
Versions are pinned where Calyx's code pins them: Claude Code 2.1.251, Codex 0.148.0, OpenCode 1.18.18. Hermes, Grok, and pi are described as of mid-September 2026. Every claim comes from the integration code listed under Source at the end. Where a claim rests on a vendor's documentation rather than observed behavior, the text says so.
Summary
| Claude Code 2.1.251 | Codex 0.148.0 | OpenCode 1.18.18 | Hermes | Grok | pi | |
|---|---|---|---|---|---|---|
| MCP config |
~/.claude.json, JSON |
~/.codex/config.toml, TOML |
opencode.json, JSON |
config.yaml, YAML |
config.toml, TOML |
none; the extension speaks JSON-RPC itself |
| Env var syntax Calyx writes |
${VAR:-} (default required) |
env_http_headers mapping |
{env:VAR} |
${VAR} |
${VAR:-} in any string field |
process.env in TypeScript |
| Event transport | command hook, stdin JSON | command hook, stdin JSON | plugin callback (Bun) | none | command hook, stdin JSON, camelCase | extension API |
| Events | 10 | 9 | 7 | 0 | 12 | 5 + gate |
| Blocking event | PermissionRequest |
PermissionRequest |
none used | none | PreToolUse |
tool_call |
| Response schema |
hookSpecificOutput.decision.behavior and friends |
same envelope, allow/deny only, rejects extras |
n/a | n/a | flat {decision, reason}
|
flat {decision, reason}
|
| No answer means | CLI's own prompt | CLI's own prompt | n/a | n/a | deny | deny |
| Calyx unreachable means | CLI's own prompt | CLI's own prompt | n/a | n/a | Grok's own pipeline | the call runs |
| Subagents | state + current tool | lifecycle | lifecycle (synthesized) | none | state + current tool | none |
| Session id field | session_id |
session_id |
four spellings | none | sessionId |
from ctx.sessionManager
|
How Calyx talks to a CLI
The server
- One local HTTP server, bound to 127.0.0.1.
- Ports 41830 through 41839 are tried in order, then a kernel-assigned port.
- Every route authenticates with a bearer token: 32 random bytes, hex encoded.
The routes that matter here
-
/mcp: the MCP tools an agent calls (command history, messaging other agents, and so on). -
/agent-event: state pings (session started, tool call started, turn finished). -
/approval-request: a permission request; the connection stays open until a human answers it in Calyx or the request expires.
What each CLI needs from Calyx
- The port and token.
- A way to say which pane it runs in.
- A way to report state.
- Optionally, a way to hand over permission decisions.
Pane identity
- Calyx sets
CALYX_SURFACE_ID(the Ghostty surface) in the shell's environment before the CLI starts. - In persistent-session panes it also sets
CALYX_SESSION_ID(the session, which survives surface re-creation). - Each CLI gets those values into an HTTP header in its own way (section 2).
How the port and token travel
- MCP config is static: the URL and token are written as literals when the integration is enabled. Calyx keeps a running server alive across re-enables rather than restarting it, so the literals stay valid.
- Hook and plugin traffic is dynamic: the shell scripts read
~/Library/Application Support/Calyx/agent-endpoint.jsonon every invocation, and the JavaScript and TypeScript pieces cache it by modification time. A restart or a rotated token never leaves a hook pointing at a dead port.
1. Configuration files
| CLI | Files Calyx writes | Format | How Calyx edits it |
|---|---|---|---|
| Claude Code |
~/.claude.json (MCP), ~/.claude/settings.json (hooks) |
JSON | Key-level merge. Calyx's own hook entries are recognized by the script filename in command
|
| Codex |
~/.codex/config.toml (MCP and hooks) |
TOML | MCP: replace the [mcp_servers.calyx-ipc] section. Hooks: a # BEGIN CALYX AGENT HOOKS / # END CALYX AGENT HOOKS block at the end of the file |
| OpenCode |
~/.config/opencode/opencode.json (MCP), ~/.config/opencode/AGENTS.md (instructions), ~/.config/opencode/plugins/calyx-agent-monitor.js (events) |
JSON, Markdown, JavaScript | JSON: key-level merge. AGENTS.md: an HTML-comment marker block. Plugin: a file Calyx owns outright |
| Hermes |
~/.hermes/config.yaml (MCP) |
YAML | A # BEGIN CALYX IPC / # END CALYX IPC block spliced under mcp_servers: at the file's own indentation |
| Grok |
~/.grok/config.toml (MCP), ~/.grok/hooks/calyx.json (hooks) |
TOML, JSON | TOML: replace the [mcp_servers.calyx-ipc] section. Hooks: a file Calyx owns outright |
| pi | ~/.pi/agent/extensions/calyx.ts |
TypeScript | One file Calyx owns outright. No MCP entry and no hook entry, because pi has neither an MCP client nor a hook system |
Files Calyx owns outright
- OpenCode's plugin directory, Grok's hooks directory, and pi's extensions directory each give Calyx a file of its own.
- Install is a write, removal is a delete. No markers, no merge.
JSON (Claude Code, OpenCode)
- Both Claude Code files and
opencode.jsonround-trip through a JSON parser. - Calyx touches one key for MCP (
mcpServers.calyx-ipcormcp.calyx-ipc) and only its own entries underhooks. Everything else comes back unchanged in meaning. - If a hook event's existing value has a shape Calyx does not recognize, Calyx skips that event rather than overwriting it.
TOML (Codex, Grok)
- TOML has no primitive for "replace my entry inside your array".
- Codex MCP: the entry is a named table, so Calyx finds
[mcp_servers.calyx-ipc]and replaces everything up to the next table header. - Codex hooks:
[[hooks.PreToolUse]]is an array of tables with no name to search for, so Calyx appends a comment-delimited block at the end of the file. - Codex also writes to the file, and in practice it has placed its own tables between Calyx's markers. On removal Calyx extracts every table that is not positively its own (an exact
[[hooks.<Event>]]plus[[hooks.<Event>.hooks]]pair whose command names a Calyx script) and re-appends it outside the block, verbatim. - Grok MCP: same section replacement, with one stricter rule. A
[mcp_servers.calyx-ipc.headers]sub-table is always treated as part of Calyx's section, even with no parent header in scope, because two definitions of one TOML table are a parse error that costs the user every other MCP server in the file. - Grok's MCP entry carries no ownership marker. The entry name is Calyx's own, so a hand-written
[mcp_servers.calyx-ipc]is adopted and rewritten into the managed shape rather than duplicated.
YAML (Hermes)
- Calyx edits
config.yamlwithout a YAML parser, with a regex splice in two cases. - No top-level
mcp_servers:key: the block is appended at the end carrying its own parent. - Key present: only the
calyx-ipc:child is inserted, indented by the unit the file already uses for its other children. - Refused rather than guessed: an inline
mcp_servers: {}, tab indentation, and control characters in a scalar all raise an error.
2. MCP registration
Every entry points at http://127.0.0.1:<port>/mcp with Authorization: Bearer <token> and two identity headers, X-Calyx-Surface-ID and X-Calyx-Session-ID. What differs is how a config file says "this header's value is this environment variable at connect time".
Claude Code
"mcpServers": {
"calyx-ipc": {
"type": "http",
"url": "http://127.0.0.1:41830/mcp",
"headers": {
"Authorization": "Bearer <token>",
"X-Calyx-Surface-ID": "${CALYX_SURFACE_ID:-}",
"X-Calyx-Session-ID": "${CALYX_SESSION_ID:-}"
}
}
}
- Claude Code interpolates
${VAR}inside string values. - The
:-empty default is required. A${VAR}reference to an undefined variable fails the parse of the whole~/.claude.json, which breaks MCP for every other terminal the user runs Claude Code in.
Codex
[mcp_servers.calyx-ipc]
url = "http://127.0.0.1:41830/mcp"
http_headers = { "Authorization" = "Bearer <token>", "X-Calyx-Agent-Kind" = "codex" }
env_http_headers = { "X-Calyx-Surface-ID" = "CALYX_SURFACE_ID", "X-Calyx-Session-ID" = "CALYX_SESSION_ID" }
- Codex takes the variable name rather than a placeholder string:
env_http_headersmaps a header name to an environment variable name, next to the literalhttp_headers. - Measured on Codex 0.148.0: when the variable is unset, Codex omits the header entirely rather than sending it empty.
OpenCode
"mcp": {
"calyx-ipc": {
"type": "remote",
"url": "http://127.0.0.1:41830/mcp",
"headers": {
"Authorization": "Bearer <token>",
"X-Calyx-Surface-ID": "{env:CALYX_SURFACE_ID}",
"X-Calyx-Session-ID": "{env:CALYX_SESSION_ID}",
"X-Calyx-Agent-Kind": "opencode"
}
}
}
- Calyx writes a third syntax,
{env:VAR}. - The transport is called
remote.
Hermes
mcp_servers:
calyx-ipc:
url: "http://127.0.0.1:41830/mcp"
headers:
Authorization: "Bearer <token>"
X-Calyx-Surface-ID: "${CALYX_SURFACE_ID}"
X-Calyx-Session-ID: "${CALYX_SESSION_ID}"
X-Calyx-Agent-Kind: "hermes"
- Calyx writes
${VAR}without a default.
Grok
[mcp_servers.calyx-ipc]
url = "http://127.0.0.1:41830/mcp"
[mcp_servers.calyx-ipc.headers]
Authorization = "Bearer <token>"
X-Calyx-Agent-Kind = "grok"
X-Calyx-Session-ID = "${CALYX_SESSION_ID:-}"
X-Calyx-Surface-ID = "${CALYX_SURFACE_ID:-}"
- Grok expands
${VAR}and${VAR:-default}inside every string field of every[mcp_servers.*]table, so no mapping table is needed. - Headers go in a sub-table.
pi
- pi has no MCP client.
- The extension speaks JSON-RPC to
/mcpdirectly withfetchand sends oneinitializeat session start to bind the pane. - Calyx's tools reach the model through a single dispatcher tool named
calyxthat takes{tool, args}. One tool instead of one per IPC tool, because pi puts every registered tool's name and description into the system prompt on every turn.
The agent kind header
- Codex, OpenCode, Hermes, and Grok entries carry
X-Calyx-Agent-Kind. - Claude Code's entry omits it. The hook routes default to
claude-code. - The
/mcproute has no default kind, so a generic MCP client that has the URL and token cannot manufacture an agent row in the sidebar just by connecting.
3. Event reporting
Three transports
| Transport | CLIs | Mechanism |
|---|---|---|
| Command hook, JSON on stdin | Claude Code, Codex, Grok | The CLI runs a program, writes the event as JSON to its stdin, reads its stdout. Calyx installs one shell script, calyx-agent-hook, that reads the endpoint file, guards on the pane identity variables, and forwards stdin verbatim with curl --data-binary @- to /agent-event. Codex and Grok pass their name as an argument (codex, grok); with no argument the script assumes Claude Code. Every path ends in exit 0, so a failed POST never breaks the user's hook chain |
| Plugin API callback | OpenCode | OpenCode auto-loads any JavaScript file in ~/.config/opencode/plugins/ under its Bun runtime, with no entry in opencode.json. The plugin exports a function that receives the working directory and returns an event handler, which maps OpenCode's events onto the command-hook event names and posts them with fetch
|
| Extension API | pi | A TypeScript file in ~/.pi/agent/extensions/ exports a function that receives pi's extension API and registers handlers with pi.on(event, handler). Same posting shape as the OpenCode plugin |
| None | Hermes | Calyx registers no hooks for Hermes. A Hermes row comes from the MCP connection itself; its working, blocked, and idle state comes from classifying the pane's on-screen text, the same way Calyx treats any agent it only knows through an MCP connection. The pane's own exit signal marks the row finished, when Command Tracking is on and the pane runs an interactive zsh or fish |
Events per CLI
| CLI | Events Calyx registers | Count |
|---|---|---|
| Claude Code |
SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Notification (matcher permission_prompt), Stop, SessionEnd, PermissionRequest, SubagentStart, SubagentStop
|
10 |
| Codex | The same minus Notification
|
9 |
| OpenCode |
session.created, tool.execute.before, tool.execute.after, permission.asked, permission.replied, session.idle, session.deleted
|
7 |
| Hermes | none | 0 |
| Grok |
SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, Stop, StopFailure, StopCancelled, SessionEnd, Notification (matcher permission_prompt), SubagentStart, SubagentStop
|
12 |
| pi |
session_start, before_agent_start, tool_execution_end, agent_end, session_shutdown, plus tool_call for the gate |
5 + 1 |
Per-CLI notes
Codex
-
SessionEndmust be registered for a session to settle as finished. Without it the last event isStop, which only means the turn ended. - Hook entries are TOML arrays of tables. The command is a TOML literal string (single quotes), so the double quotes around the script path need no escaping. A single quote in the script path is rejected up front.
OpenCode
- 1.18.18's plugin vocabulary has no process-exit event.
session.deletedfires only when a session is actually deleted, so the pane's own exit signal marks the row finished, when Command Tracking is on and the pane runs an interactive zsh or fish. - The session identifier arrives under four spellings depending on the event:
sessionID,session_id,sessionId,info.id. An event whose session cannot be resolved is dropped rather than guessed, because a guess could land on the wrong pane's row. - The working directory comes from the plugin's own
directoryargument.
Grok
- Payload keys are camelCase (
hookEventName,sessionId,toolName), event values are snake_case (pre_tool_use), and the hook file uses PascalCase keys (PreToolUse). - A
/bin/shhook script cannot rewrite JSON, so the script forwards Grok's payload untouched and Calyx's server normalizes it. Detection is on key presence:hookEventNamepresent,hook_event_nameabsent. - Mapping:
post_tool_use_failuretoPostToolUse(a failed call still means the turn is running);stop_failureandstop_cancelledtoStop;subagent_endaccepted as an alias ofsubagent_stop;notificationtoPermissionRequestonly whennotificationTypeispermission_prompt. - Grok fires an observe-only
stopat teardown whosereasonischannel_closedorshutdown. Astopwhose reason is notend_turnpasses through unmapped. - Grok defaults
StopandSubagentStophooks to 600 seconds because those gates commonly run builds. Calyx pins its fire-and-forget entries there to 10 seconds to keep them off the turn's critical path.
pi
- Event payloads carry neither a session identifier nor a working directory. The extension reads the session id from
ctx.sessionManager.getSessionId()and the directory fromctx.cwd.
4. Permission prompts
Which event blocks
| CLI | Blocking event | When Calyx gates |
|---|---|---|
| Claude Code | PermissionRequest |
Every request the CLI raises |
| Codex | PermissionRequest |
Every request the CLI raises |
| OpenCode | none used | Never; permission.asked and permission.replied are forwarded as state only |
| Hermes | none | Never; no hooks registered |
| Grok | PreToolUse |
Only under bypassPermissions
|
| pi | tool_call |
Every tool call |
Claude Code and Codex
-
PermissionRequestfires only when a tool call actually needs approval.PreToolUsefires for every tool call, so gating on it would ask about calls the CLI was going to run anyway. - The approval entry has a 600 second timeout and no
asyncflag. A separate 5 second entry carries the state ping; Claude Code's version of that entry additionally setsasync.
Grok
- Grok has no
PermissionRequestevent.PreToolUseis its only blocking event, and it runs before Grok's own permission system rather than in place of it: a hook deny stops the call, but a hook allow, or no answer, falls through to Grok's own deny-or-ask rules, its remembered grants, and its in-pane prompt. - Under Grok's
default,auto, andplanmodes (names from Grok's hook documentation), a Calyx prompt would ask a question Grok is about to ask again, and Calyx's allow could not grant anything. Only underbypassPermissionsdoes the call run without anyone asking, so that is the one mode where Calyx gates. - The hook entry carries no matcher and fires for every tool call in every mode. The filtering happens server-side on the payload's
permissionMode, which is why the hook script forwards stdin verbatim and carries no branch of its own. - Both handlers are registered on
PreToolUse, state ping first, because Grok runs handlers in config order until one returns deny, and a state ping must not sit behind a gate that can block for minutes. - Grok's default hook timeout is 5 seconds, so the gate entry writes
timeout: 600explicitly. Otherwise a gate waiting on a human would be killed at 5 seconds and fail open.
pi
- pi ships no permission prompt of its own; its
docs/usage.mdsays it intentionally does not include permission popups. - The extension registers a
tool_callhandler, the one place where returning{ block: true, reason }stops a call. Nothing behind that gate would ever ask the user, so Calyx gates every pi tool call, and a pi request cannot be dismissed the way a Claude Code or Codex request can.
OpenCode
-
permission.askedandpermission.repliedare forwarded as state (PermissionRequest, thenPostToolUse), so the row shows blocked while the prompt is open. OpenCode's own prompt answers the question. - A session with an unanswered
permission.askedsuppressessession.idle, so an idle event arriving ahead of the reply cannot overwrite a blocked row with idle.
Response schemas
Claude Code and Codex read a hookSpecificOutput envelope:
{
"hookSpecificOutput": {
"hookEventName": "PermissionRequest",
"decision": { "behavior": "allow" }
}
}
- Claude Code accepts the widest vocabulary under
decision:behavior: "allow";allowwithupdatedPermissions;behavior: "deny"withmessage;denywithmessageandinterrupt: true;allowwithupdatedInputfor theAskUserQuestiontool. - Codex reads the same envelope but only
allow, ordenywithmessage. It rejects the entire response ifupdatedPermissions,updatedInput, orinterruptis present, so the Codex path never emits them. - The field is
decision.behavior. The olderhookSpecificOutput.permissionDecisionshape fromPreToolUseis not recognized by either CLI underPermissionRequest.
Grok and pi read a flat object:
{ "decision": "deny", "reason": "Denied from the Calyx approval inbox." }
Timeouts and silence
- Hook-based approval paths have three timers, and their order is the invariant: the server gives up at 570 seconds, curl at 585, the hook entry at 600. Each inner layer fails before the layer outside it can.
- pi's extension has the inner two: the server's 570 seconds and its own fetch deadline at 585.
| CLI | Nobody answers | Calyx unreachable |
|---|---|---|
| Claude Code | Empty body; the CLI's own prompt takes over | Same |
| Codex | Empty body; the CLI's own prompt takes over | Same |
| Grok | Explicit deny | Grok's own pipeline runs |
| pi | Explicit deny | The handler returns nothing and the call runs |
- For Claude Code and Codex there is no
"ask"value in the vocabulary. Silence is the ask. - The approval script treats curl's connection-refused exit as silent, so a stale endpoint file after a crash leaves the hook inert instead of turning every prompt into an error.
curl --failroutes any non-2xx response through the same silent path rather than printing it to the CLI as if it were a decision. - Grok and pi have no fallback prompt in the situations where Calyx gates, so an unanswered request resolves to an explicit deny with its own reason text: "No approval was given in the Calyx approval inbox before the request expired."
- pi fails open when Calyx is unreachable because pi turns a thrown error into a block, and an unreachable Calyx must not become a pi that cannot run any tool at all.
- On no path, ever, does the approval script print
allowon its own.
Cross-CLI bleed
- Grok reads
~/.claude/settings.jsonfor hooks by default, so Calyx's Claude Code hook entries also fire inside a Grok session. The approval script's stdout would become Grok's decision in a vocabulary Grok does not recognize. - Both scripts exit immediately when invoked as
claude-codewhile Grok'sGROK_HOOK_EVENTvariable is set. - The user's own
[compat.*]settings in Grok's config are never touched.
5. Subagents
| CLI | What arrives | What the sidebar can show |
|---|---|---|
| Claude Code |
SubagentStart and SubagentStop, plus agent_id on every event fired inside a subagent, including PreToolUse and PostToolUse
|
Child rows with state and the current tool (the command, file path, or URL) |
| Codex |
SubagentStart and SubagentStop carry agent_id. Per Codex's documentation, the other hooks inside a subagent reuse the parent session's id |
Child rows with lifecycle state only |
| OpenCode | No subagent events. session.created carries info.parentID for a child session; the plugin synthesizes SubagentStart, rewrites that session's later events to carry agent_id in place of session_id, and posts SubagentStop on session.idle or session.deleted
|
Child rows with lifecycle state only; the synthesized child events carry no tool fields |
| Hermes | Nothing | No child rows |
| Grok |
SubagentStart and SubagentStop plus subagentId. subagent_start carries the parent's sessionId and names the child only through subagentId; every later child-scoped event carries the child's own sessionId and no subagentId
|
Child rows with state and the current tool, read from camelCase toolName and toolInput
|
| pi | Nothing | No child rows |
- Claude Code: a session started with
claude --agent foosendsagent_typewithoutagent_idon its main thread. Deciding "this is a subagent event" fromagent_typewould freeze that session's parent row forever. The only reliable signal is the presence ofagent_id. - Grok: a subagent has its own session, so its teardown arrives as
SessionEndrather thanSubagentStop, and both names have to retire a child row. Session ids and working directories are stripped from child events so a later reattach never offersgrok --resume <child>.
6. Guards outside Calyx
All six integrations live in the user's global configuration, so they run in every session of that CLI, not only in Calyx.
- Shell scripts: exit 0 immediately when neither
CALYX_SURFACE_IDnorCALYX_SESSION_IDis set. Aclaudestarted in Terminal.app passes through the hook without contacting anything. - OpenCode plugin and pi extension: register nothing at all when the variables are absent. For pi that matters, because a registered gate with no server behind it would block every tool call.
- Grok: the hooks directory is auto-trusted, and Grok merges every
.jsonfile in it, so Calyx's file has a dedicated name that cannot collide with a user's own hook file. On a launch-time resync, a file at that path that names no Calyx script is treated as somebody else's and left alone. - herdr: an agent multiplexer that can run inside a Calyx pane. Every shell it spawns inherits that pane's
CALYX_SURFACE_ID, so the value is non-empty but wrong for that shell. The scripts and the pi extension checkHERDR_PANE_ID, the one variable that reliably means "this shell is inside a herdr-managed pane", and stand down. herdr reports those agents to Calyx over its own Unix socket, a separate integration outside this post.
Takeaways
- Pin versions in code comments. Codex's header behavior and OpenCode's event vocabulary are facts of one measured version, and Claude Code's
PermissionRequestschema replaced an olderPreToolUseone. The comment that says "measured on 0.148.0" tells you which assumptions to re-check after an upgrade. - "Hook" is three transports, not one. Stdin JSON, a plugin callback, and an extension API differ in where the process boundary is, and that decides what can go wrong: a shell script cannot transform JSON, a Bun plugin has no process-exit event, an extension's thrown error becomes a block.
- Decide what silence means before writing a gate. Where the CLI has its own prompt, an empty answer hands the question back. Where it does not, an empty answer is a decision, and it had better be deny.
- Do not derive subagent identity from the field that looks right.
agent_typelooks like the signal in Claude Code;agent_idis.sessionIdlooks like the child's id in Grok'ssubagent_start; it is the parent's. - Guard every place the hook will run that is not your app. Global configuration means every session, including sessions started inside a tool that inherited your environment variables from a parent shell.
Source
- Repository: https://github.com/yuuichieguchi/Calyx (MIT)
- Install:
brew install --cask calyx
Top comments (2)
The permissions row is the one I would read first: whether an external program can answer a permission prompt decides if a supervisor can actually supervise or just watch. Pinning versions and marking doc-based claims versus observed behavior is rare rigor for a comparison post - most "X vs Y" articles are vibes with a table. This one is a table with receipts.
Thanks. That row is also where the six disagree most. The same silence means three different things depending on whether the CLI has a prompt of its own to fall back to. The version pins are there because each of those behaviors was measured against one binary, and the pin is what tells me what to re-check after an upgrade.