Agent session capture
Capturing what an agent does and thinks while driving MetaForge over MCP,
into the digital thread's /sessions. Three layers (see MET-492):
| Layer | Mechanism | Captures | Clients |
|---|---|---|---|
| A | MCP server middleware (MET-496) | actions (every tool call) | all MCP clients, zero config |
| B | client capture core + adapters (MET-497/498) | actions + reasoning | any client with hooks or a local transcript |
| C | session.* / twin.record_decision tools (MET-494/495) | curated thoughts + typed decisions | any cooperating agent |
Layer A is always on (server-side). Layer B is what this doc covers.
The core
tools/session_capture/metaforge_capture.py — stdlib + httpx only, no MetaForge
imports, so it runs in any client's environment. CLI:
metaforge-capture --client <name> --session <id> ensure-session [...]
metaforge-capture --client <name> --session <id> push-event --type <t> --message <m> [--data JSON]
metaforge-capture --client <name> --session <id> push-transcript-delta --transcript <file>
metaforge-capture --client <name> --session <id> complete [--status ...] [--summary ...]
metaforge-capture --client <name> tail --path '<glob>' [--parser <name>] [--follow]
Config: METAFORGE_GATEWAY_URL (default http://localhost:8000),
METAFORGE_MCP_API_KEY, METAFORGE_SESSION_CAPTURE=off (kill-switch). Always
exits 0 — capture never breaks the host turn.
The universal fallback: tail
tail --client <name> --path '<glob>' watches a client's local transcript
files and pushes deltas (one MetaForge session per file, byte-cursor keyed
so a restart never re-emits). It works for any client that writes a local
transcript — you only need a parser for that client's JSONL shape
(tools/session_capture/parsers.py, registry keyed by client name). Adding a
client = one parser function.
One-shot (cron-friendly) by default; --follow polls.
Per-client status
| Client | Mechanism | Actions | Thoughts | Status |
|---|---|---|---|---|
| Claude Code | hooks (tools/session_capture/claude_code_adapter.py) | ✅ | ✅ transcript | shipped (MET-497) |
| Codex CLI | tail parser over ~/.codex/sessions/*.jsonl | via tailer | ✅ | parser shipped (best-effort schema); notify adapter TODO |
| Cursor | native hooks.json | ✅ | ⚠️ | deferred — use tail if Cursor writes a local transcript; native hook adapter pending schema verification |
| OpenCode | TS plugin on its event bus | ✅ | ✅ | deferred — plugin pending; tail works if it persists a transcript |
| Gemini CLI | (no stable hook system at time of writing) | — | ⚠️ | deferred — tail + a parser once its chat-log format is confirmed |
| claude.ai web | none (cloud, no local exec) | via Layer A | ✗ | not adaptable client-side — Layer A only; sessions labelled by OAuth subject (MET-480) |
Why Cursor / OpenCode / Gemini are deferred
Their native hook/plugin schemas and local-transcript formats move fast and
weren't verifiable at implementation time. Rather than ship guesses, the
tail fallback + parser registry is the supported path for them today:
point tail at their transcript directory and add a parser. Native adapters
(richer, real-time) are follow-ups tracked on MET-498.
Install (Claude Code)
From the cloud — no clone (MET-500)
Install the tool straight from the repo (private → uses your GitHub auth):
pipx install "git+https://github.com/FidelOdok/MetaForge.git#subdirectory=tools/session_capture"
metaforge-capture install --user --gateway-url http://fidel-dev:8000
pip install "git+…#subdirectory=tools/session_capture" works too. This puts a
metaforge-capture console script on PATH — handy on a fresh machine or a
cloud Claude Code sandbox (point --gateway-url at the Cloudflare tunnel
hostname, MET-482, so the sandbox can reach the gateway). Note: the claude.ai
web connector can't run a local install — it's covered by Layer A
server-side capture over the tunnel.
From a clone
One command sets up the hook across every Claude Code session in every repo (MET-499):
python -m tools.session_capture.metaforge_capture install --user --gateway-url http://fidel-dev:8000
--user(default) registers in~/.claude/settings.json→ fires in any repo.--projectscopes to the current repo's.claude/settings.json.--mode copy(default) stages the tool under~/.metaforge/capture-tool/and points the hook there;--mode linkpoints at this checkout in place.--gateway-url/--api-keyare written to~/.metaforge/capture/config.json, so the hook reaches the gateway without editing your shell profile (the env varsMETAFORGE_GATEWAY_URL/METAFORGE_MCP_API_KEYstill override).- Idempotent — re-run to update; preserves your other hooks. Restart Claude Code
to load. Remove with
… metaforge_capture uninstall [--user|--project].
Manual alternative: merge tools/session_capture/claude_code/settings.snippet.json
into .claude/settings.json yourself.
Scope: bind capture to a project (MET-501)
The hook is installed --user, so it fires in every session — but capture is
bound to an active project, and most sessions aren't project work, so by
default nothing is captured. You declare a project before working on it:
metaforge-capture use <project_id> # set active project for this repo
metaforge-capture active # show what's active here
metaforge-capture clear # stop capturing for this repo
Key properties:
- Compulsory binding. With no active project the
PostToolUse/Stophooks no-op — no orphan sessions in the global firehose.SessionEndstill closes anything that was opened. - Per repo (cwd-keyed). The pointer lives at
~/.metaforge/capture/active/<cwd-hash>.json, set at the repo root and resolved by walking up — so it's visible from any subdirectory, and two repos open at once never cross-contaminate. - Per-event, multi-project. Each hook firing resolves the project at that
moment, so one Claude session can drive several projects — switch with another
useand later events attach to the new project's MetaForge session. - Override.
METAFORGE_PROJECT_IDwins over the pointer for a shell/CI run.
Once bound, a session's actions, thoughts, and decisions show up under that
project on /projects instead of only in the global /sessions stream.
In-session setting (MCP tool). A
project.useMCP tool that sets the active project from inside a conversation only works when the MCP server runs locally (stdio) — a remotemcp-httpsidecar can't write your laptop's filesystem. For a remote sidecar, set it from the agent via Bash (metaforge-capture use <id>). Tracked as a follow-up to MET-501.
Tracing a claim back to a call (FORGE-362)
Every MCP tool call returns _meta.callId, and the captured action
event carries the same value as data.call_id. That makes a claimed
action checkable: given a call id from a reply, either the timeline has
an event with it or the claim has nothing behind it.
This is the server-side half of F4. Flagging a reply that made no tool calls at all is a client-side concern — the server never sees the reply — but the session record is what any such check has to read.
Who a captured action is attributed to (FORGE-330)
Every call carries an actor_id in the form <kind>:<name> —
user:fidel, agent:claude_code, system:cron — and that is what the
session record shows.
Where it comes from depends on what the server can verify:
| Situation | actor_id | Trust |
|---|---|---|
| OAuth bearer token accepted | the actor bound to that token | verified by the server |
X-MetaForge-Actor header, no token | whatever the header says | an unverified claim |
| Static API key only | the header, if any | an unverified claim |
| Nothing supplied | system:unattributed | explicit absence |
A verified token always wins. It used to be the other way round: the
actor bound to the token was discarded and the header was taken at face
value, so a remote caller could send X-MetaForge-Actor: user:anyone and
that is what landed in the record. Self-asserted attribution is not an
audit trail.
A static API key authorises a call without identifying anyone, so it does not manufacture an identity and does not promote the header into one — authorised and attributable are different claims. When reading a session for a review, treat an actor that arrived without a verified token as the claim it is.