Repository navigation
[coding agents 3/3] Claude Code and Codex in the client: the MCP entry, the Stop hook, and capture - #1693
Draft
edwinyyyu wants to merge 19 commits into
Draft
[coding agents 3/3] Claude Code and Codex in the client: the MCP entry, the Stop hook, and capture#1693edwinyyyu wants to merge 19 commits into
edwinyyyu wants to merge 19 commits into
Conversation
`memmachine agent install {claude-code,codex} --server <url> --tenant
<name>` points an agent at a MemMachine server's memory tools, and
`memmachine agent disable` takes it back out. Both agents speak MCP over
streamable HTTP with static headers, so one endpoint (`<server>/v1/mcp`)
and one header (`X-MemMachine-Tenant`) serve both, and the tools live on
the server.
Claude Code's user scope belongs to the `claude` executable, so the
installer runs `claude mcp add --transport http --scope user` through it,
preceded by `claude mcp remove` because `add` refuses a name it already
holds; without that executable on PATH the error names the command to run
by hand. Project scope writes `.mcp.json` in the current directory.
Codex gets `[mcp_servers.memmachine]` and its `http_headers` table in
`$CODEX_HOME/config.toml`, or in `<cwd>/.codex/config.toml` for project
scope.
A file this installer writes is parsed before it is edited, backed up to
`<file>.bak`, and left alone when it already holds the entry, so a second
install changes nothing. The Codex tables are replaced or appended as
text and every other line is carried over, and the result is parsed back
before it is written, so a config the installer cannot edit safely (an
inline table, a dotted key) is refused rather than duplicated. Reading
TOML needs Python 3.11's tomllib, so on 3.10 the Codex commands say that
instead of guessing.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The tests cover what the installer promises: the exact `claude mcp add` and `claude mcp remove` commands (with the remove that makes a second install leave one entry), the `.mcp.json` shape, Codex's two tables in `$CODEX_HOME`, in `~/.codex` and in a project's `.codex`, a second install that writes nothing, disable that leaves other servers and other tables in place, `--dry-run` that prints a diff and writes nothing, and the `<file>.bak` copy of what a write replaced. A Codex config with comments, other tables and a multi-line string that holds table headers as text comes back byte for byte after an install and a disable, which is what the text-level edit has to hold. Installs run through `cli.main`, so they also hold that the agent commands need no server URL for the REST client. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The page says what an agent gets (the two MCP tools and the endpoint that serves them), how to read the markers a result carries and which of them `memory_expand` takes, how a tenant name is chosen, and the install and disable steps for Claude Code and Codex in both scopes, with the command and the file each one writes. It also states what the installer keeps: one entry however often it runs, a `<file>.bak` beside every file it writes, and a `--dry-run` that writes nothing. Capture through the agents' Stop hooks is named as what comes next, and ambient recall as designed and off, so a reader knows the tools are the whole recall surface today. The page joins the Open Source group in the navigation. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
One verb for the operation from EventMemory up: query. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A coding agent records a session as a JSONL file: Claude Code one entry
per line under `~/.claude/projects`, Codex one record per line in the
rollout file under `$CODEX_HOME/sessions`. Each reader turns the entries
after a byte offset into bodies for `POST /v1/tenants/{tenant}/events`,
one event per message, tool call, tool result and injected passage, in
transcript order.
What holds:
- An event is held under the identity the transcript fixes, so reading
the same entry twice produces the same event. A Claude Code entry
carries its own uuid; a second event of that entry is named under it.
A Codex record carries none, so its event is named under the session
and the record's index in the file, which is why a mark carries both
an offset and an index.
- Only `text` blocks reach the search surface, so a message is what a
query finds and a tool call, its result and injected text stay on the
timeline, reached by expanding from a message.
- Injected text says where it came from: a hook, a skill, a compaction
summary, a system reminder or a slash command. Claude Code's own flags
are read first and the opening of the text after them; Codex's
developer channel is injected by construction and its tagged user
passages by their tag.
- A reader never returns a line the agent has not finished writing, and
it yields entries that carry nothing to remember with their offset, so
a session that ends in them is not read again.
- A subagent runs in a session of its own, named by the agent id its
entries carry or after the turn that launched it, with the parent in
`properties.parent_session`.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
`memmachine agent capture {claude-code,codex} --server <url> --tenant
<name>` reads a hook's `session_id`, `transcript_path` and `cwd` from
standard input, reads the transcript from where the last run left off,
and posts the new events to `<server>/v1/tenants/<tenant>/events`.
`--transcript` and `--session-id` name them by hand instead.
What holds:
- How far a session has been posted is kept under the agent's own
directory, `~/.claude/memmachine/capture-state.json` for Claude Code
and `$CODEX_HOME/memmachine/capture-state.json` for Codex. The mark is
a shortcut, not a record: a batch is stored whole or rejected whole
under the ids the transcript fixes, so a lost mark costs one batch the
server answers `event_exists` to, never a second copy of anything.
- A batch holds at most 200 events and no entry is split across two, so
the mark a batch carries is past every event in it. The mark moves
after each batch the server holds, so a failure part way through
repeats only the rest.
- 200 and 409 `event_exists` are both "the server holds it". Anything
else leaves the mark where it was and answers non-zero, so the next
`Stop` posts the same entries again. The whole capture runs under a
budget of five seconds by default, and running out of it is one of
those failures.
- Nothing is written to standard output: an agent reads a hook's output
as context, so what happened is reported on standard error.
- Events carry the agent, the project (the repository root of the
session's directory, or the directory) and, on tool events, the tool's
name.
`codex_home_directory` and the two argument validators are named for the
capture client to use, which is the only change to the installer here.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
`memmachine agent install` now writes the `Stop` hook that runs capture, beside the MCP entry it already wrote, and `disable` takes both back out. Claude Code reads it from `~/.claude/settings.json` for user scope and `.claude/settings.json` for project scope; Codex reads it from `$CODEX_HOME/hooks.json` and `<cwd>/.codex/hooks.json`. What holds: - Both agents read the same shape, a `hooks` object whose `Stop` key holds groups of handlers, so one edit serves both. - The handler names the interpreter this client is installed in by its own path and reaches the client as a module of it, because both agents run a handler's command through a shell whose PATH need not hold that interpreter. - A handler is recognized as ours by the capture command in it, whatever server, tenant and interpreter it names, and is replaced where it stands, so a second install leaves one behind and does not rewrite a file that already reads as the edit would leave it. - Every other hook, every other event and every other key of the file is carried over, a write is backed up to `<file>.bak` like the other files, and disabling removes our handler and nothing else: a `Stop` key or a `hooks` key left holding nothing goes with it. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The page said capture was coming; it is here, so it says what capture records instead: messages, tool calls with their arguments, tool results and injected text, of which only messages are embedded and the rest is read by expanding from a message. It also states the `Stop` hook the installer writes for each agent and scope, the state file that holds how far a session has been posted and why deleting it costs one rejected batch rather than a duplicate, and the command that posts a transcript by name, which is how a session from before the install reaches memory. "What is coming" now names the session outline and ambient recall, which are what is left. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The capture's report on standard error read "captured 1 events". It now agrees with the count it carries. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A `tool_result` block names a tool, and the server's block takes no empty name, so a result the reader could not pair with a call would have been rejected -- and a batch is rejected whole, so that one result would have held up every event after it for as long as the session ran. Such a result is now named `unknown`, which says the transcript carried no name. Claude Code still reads the entries before its mark for the call's real name first; a Codex output whose call id is in no record of the file has nowhere else to look. Checked against the block kinds the server registers: every event body both readers produce validates as an `EventSpec` and encodes as an `Event`, over text, tool_call, tool_result and injected blocks. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The server holds a tool result and an injected passage as one segment each, however long they are, and an expansion step is counted in segments, so one long result would spend a whole step on itself and crowd out the turns around it. Both readers now cap those two blocks at `ONE_SEGMENT_MAX_BYTES`, 8192 bytes of UTF-8, cutting at the character boundary at or before the cap and writing one line after it, `[truncated: N of M bytes]`, so a reader of the memory knows the passage goes on and by how much. The marker is the reader's rather than the agent's, so it follows the cap instead of eating into it. A message's text is not capped: it is what a query matches, and it is already segmented by the server. The cap and the cut are one constant and one function in a module both readers hold to, since the two must agree on them. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A batch is rejected whole, so `event_exists` for a batch says the server holds one of its events, not that it holds them all. A session resumed or forked from another repeats entries under the ids they were stored under, so such a batch mixes events already held with events that are new, and moving the mark over it lost the new ones. The batch is now posted again an event at a time, in the order it was read, where `event_exists` is that one event and says it is held. The mark moves only once the server holds every event of the batch, so nothing is stored twice and nothing is lost, and the second pass is paid for only on the conflict path. A failure part way through that pass leaves the mark before the whole batch, which the next `Stop` posts again. The count reported on standard error is now what the server stored, not what was sent, so a batch that was mostly held says so. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The event-by-event pass a conflicting batch falls back to started over on every failure, so a batch that conflicts over a slow link could run out of the budget at the same place each turn and never get past it. Events go in the order the transcript holds them, so once the server holds one, every event before it is held as well. Each event now carries the mark that stands once it is held -- the mark past its entry for the last event that entry produced, and the mark before the entry for the events ahead of it, since an entry is passed only when every event of it is held -- and the mark is written where the pass reached, whether it reached the end or stopped for any reason on the way. A failure part way through therefore keeps what was held and the next `Stop` posts only what is left, while an entry whose events are half held is still read again whole, so nothing is stored twice. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
This was referenced Sep 17, 2026
Ingestion holds everything the session log holds, and no tool the model
can call writes to memory, so what a session remembers is what it did
rather than what it chose to record. Reasoning was the one thing a
transcript carried and capture dropped.
A Claude Code `thinking` block and a Codex `reasoning` record now become
`{"kind": "thinking", "text": ...}` events, on the timeline and off the
search surface like a tool call, capped at `ONE_SEGMENT_MAX_BYTES` with
the marker, in the order the turn held them.
Codex carries its reasoning encrypted: across all 2631 reasoning records
written on this machine, `summary` is empty and `content` is null, so
such a record produces nothing. Only the `summary` and `content` text is
ever posted; `encrypted_content` is opaque rather than text.
The `event_msg` records stay out, since Codex writes each message and
tool call twice, once as the conversation and once for its interface;
the docs name that as the one thing left out and why.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
The installer makes no request to the server, so the page now says
the tenant is created once with PUT /v1/tenants/{tenant} and what
the tools and the hook do until then. An expansion that runs out
answers with a sentence, not with nothing, and the page quotes it.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
A dry run of the user-scope install printed only the add, and failed outright where no claude executable was on PATH, although it runs nothing. It now prints the remove and the add it would run, and the PATH lookup happens only for a real run. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE
edwinyyyu
marked this pull request as draft
September 18, 2026 16:45
This was referenced Sep 28, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Slice 3 of
design/coding_agent_integration.md(#1579), the whole client side: the installer that points Claude Code and Codex at a server's memory tools, theStophook it registers, and capture. Directly onmain; depends functionally on #1691 (the endpoint it configures) and #1692 (the kinds it writes). It absorbs #1689, the installer alone, which is closed in its favor; the two review comments on #1689's docs are answered by commits here.The installer
memmachine agent install {claude-code,codex} --server <url> --tenant <name> [--scope user|project] [--dry-run]andmemmachine agent disable {claude-code,codex} [--scope ...], inmemmachine_client.coding_agent. The MCP entry is<server>/v1/mcpover streamable HTTP with the headerX-MemMachine-Tenant: <tenant>, one tenant per human user. The installer makes no request to the server: the tenant is created once withPUT /v1/tenants/{tenant}(#1690), and the docs say so and what the tools and the hook do until then.claude mcp remove --scope user memmachinethenclaude mcp add --transport http --scope user memmachine <url> --header "X-MemMachine-Tenant: <tenant>"through theclaudeonPATH(the add refuses a name it already holds, so a reinstall removes first); without one, prints the exact command and exits non-zero. Project scope writes.mcp.jsonin the current directory, merging with what is there.[mcp_servers.memmachine](url) and[mcp_servers.memmachine.http_headers]into$CODEX_HOME/config.toml(default~/.codex/config.toml) or<cwd>/.codex/config.toml, by text, replacing exactly those two tables where they stand or appending them, with the rest of the file preserved; the result is parsed back before it is written, and an entry in a form the editor does not own (inline table, dotted key) is refused rather than duplicated.<file>.bakfirst; an unchanged file is not rewritten;--dry-runprints the commands (for Claude Code's user scope, the remove and the add) or a unified diff, and needs noclaude.docs/open_source/coding_agents.mdx, in the navigation, quoting what an expansion side that has run out answers (Nothing earlier: the session starts here.,Nothing later: the session ends here.).Verified, installer
Flag names and file shapes against the current Claude Code MCP documentation and the Codex configuration reference, and empirically against
claude2.1.274 (isolatedHOMEandCLAUDE_CONFIG_DIR) andcodex-cli0.149.1 (CODEX_HOMEon a temp dir): the add command produces the documented entry, a second add fails without the preceding remove,codex mcp get memmachinereportsstreamable_httpwith the header, and disable leavescodex mcp listempty. The installer tests cover both agents, both scopes, reinstall, disable, dry run with and withoutclaude, preservation of unrelated content, the backup, and the missingclaude.Two calls to review, installer
tomllibdoes not exist. The Codex commands refuse on 3.10 with a clear message rather than adding atomlidependency; the alternative istomli>=2; python_version < "3.11"in the client's dependencies.claudeitself, which keeps its own backups, so the installer makes no.bakthere.Capture
claude_code_transcript.pyandcodex_transcript.pyover a sharedcoding_agent_transcript.py: one event per message, tool call, tool result, or injected passage, in transcript order. Eventidis the entry's uuid (a second event of one entry isuuid5(entry uuid, index); Codex records, which carry none, getuuid5(session, record index));session_idthe agent's session (an entry's own when it carries one, so a resumed transcript keeps its events in the session they happened in);source_idclaude-codeorcodex; the author partuserorassistant;properties.agent,properties.project(the repository root ofcwd, elsecwd),tool_nameon tool events,parent_sessionon a subagent's. Blocks aretext,tool_call,tool_result,injectedwith the fields [event memory 5/7] Block kinds for tool calls, tool results, injected text and thinking #1692 registers; injected text is classified by the same markers the in-process reader used (reminder, hook, skill, command, compaction, other). Reasoning is captured underthinking; in every Codex rollout on this machine the reasoning body is encrypted with an empty summary, so those records produce nothing, which the docs say. Codex is read from its rollout JSONL, verified against ten real rollouts and the installed binary;event_msgrestatements are skipped.tool_result.outputandinjected.textare capped at 8192 bytes at a character boundary with a[truncated: N of M bytes]line, since the server stores a tool result as one segment however long and expansion is counted in segments; message text is not capped. Image results are written as[image].coding_agent_capture.py: a high-water mark per (agent, session) in~/.claude/memmachine/capture-state.jsonor$CODEX_HOME/memmachine/capture-state.json(atomic writes, re-read before each write, 500 sessions kept); batches of at most 200 withrequestsunder one deadline of a few seconds; 200 and 409event_existsboth mean held; on a 409 for a batch, each event is posted on its own in transcript order and the mark advances past each one as it is held, so a resumed or forked transcript that mixes held and new entries loses nothing and a slow link still makes progress on everyStop; any other failure leaves the mark at the last held event; stdout stays empty, since a hook's stdout is injected as context.memmachine agent capture {claude-code,codex} --server --tenantreads the hook's stdin (session_id,transcript_path,cwd), with--transcriptand--session-idfor use by hand.installregisters theStophook (~/.claude/settings.jsonor.claude/settings.json;$CODEX_HOME/hooks.jsonor.codex/hooks.json) as<absolute interpreter> -m memmachine_client.cli agent capture ..., since a hook's shell need not see the client's environment, idempotently and with backups;disableremoves exactly that handler. Docs page extended.Verified, capture
The hook command run under
/bin/sh -cwith an emptyPATHand hook JSON on stdin: exit 0, event posted, mark written, stdout empty. Every event body validated against the server'sEventSpecon #1692's branch, which caught an empty tool name on unpairable results (nowunknown). 350 client tests, against a real loopback HTTP server: 200, 409, other 409s, 500, timeout, batching, a failed second batch keeping the first's mark, the per-event pass on 409, state per home, the trim, an unreadable state file, by-hand use, stdout empty; transcript fixtures for both agents including subagent, pairing, classification, and re-reads from the mark; installer hook entries for both agents and scopes. Ruff, ty clean onpackages/clientas CI runs it, and the client suite.Not verifiable here, stated in the docs
That Codex's
Stophooktranscript_pathpoints at the rollout file (its docs call the format unstable; no Codex session has run on this machine since the hooks existed); the by-hand--transcriptcovers the case where it does not. Subagent entries of Claude Code were not seen in 400 recent transcripts, so that path rests on the documented fields. Codex also reads a[hooks]table inconfig.toml, which the installer does not write.Stack
Two stacks, one line of branches. Every PR but #1693 targets
feat/horizontal-scaling, so a diff shows everything below it on that branch until that merges; #1693 is client-only, branches frommainand targets it. Rebuilt on 2026-09-28: #1684 split into time bounds and sources (#1684) and sessions (#1715), the block kind moved to #1687, and each PR restacked in dependency order. Rebased on 2026-10-01 after #1713 merged, dropping the merge commit that carried it; on 2026-10-06 after #1733 was squash-merged intofeat/horizontal-scaling; on 2026-10-07 after that branch took main's #1707, which makes episode uids UUIDs; and on 2026-10-09, after #1736 was squash-merged intofeat/horizontal-scaling, onto #1663's head c0a2bbd, and the same day onto fb38a72, after #1628 moved beneath #1663 and #1813 was squash-merged intofeat/horizontal-scaling. #1715 and everything above it stay deferred with the coding-agent features.Event memory, on #1663:
tool_call,tool_result,injected,thinkingsession_ids(deferred)Coding agents, slices of
design/coding_agent_integration.md(#1579), on the event memory stack; 3/3 shares no code with the server, so its branch is onmain, but it configures the endpoint 2/3 serves and writes the kinds #1692 registers, so it merges after both:memory_queryandmemory_expandserved at/v1/mcpStophook, and capture🤖 Generated with Claude Code
https://claude.ai/code/session_01YBbQgZiCqeoLu83EkbEFHE