Skip to main content

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):

LayerMechanismCapturesClients
AMCP server middleware (MET-496)actions (every tool call)all MCP clients, zero config
Bclient capture core + adapters (MET-497/498)actions + reasoningany client with hooks or a local transcript
Csession.* / twin.record_decision tools (MET-494/495)curated thoughts + typed decisionsany 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:

Code
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​

ClientMechanismActionsThoughtsStatus
Claude Codehooks (tools/session_capture/claude_code_adapter.py)✅✅ transcriptshipped (MET-497)
Codex CLItail parser over ~/.codex/sessions/*.jsonlvia tailer✅parser shipped (best-effort schema); notify adapter TODO
Cursornative hooks.json✅⚠️deferred — use tail if Cursor writes a local transcript; native hook adapter pending schema verification
OpenCodeTS 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 webnone (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):

Code
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):

Code
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. --project scopes to the current repo's .claude/settings.json.
  • --mode copy (default) stages the tool under ~/.metaforge/capture-tool/ and points the hook there; --mode link points at this checkout in place.
  • --gateway-url / --api-key are written to ~/.metaforge/capture/config.json, so the hook reaches the gateway without editing your shell profile (the env vars METAFORGE_GATEWAY_URL / METAFORGE_MCP_API_KEY still 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:

Code
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/Stop hooks no-op — no orphan sessions in the global firehose. SessionEnd still 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 use and later events attach to the new project's MetaForge session.
  • Override. METAFORGE_PROJECT_ID wins 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.use MCP tool that sets the active project from inside a conversation only works when the MCP server runs locally (stdio) — a remote mcp-http sidecar 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:

Situationactor_idTrust
OAuth bearer token acceptedthe actor bound to that tokenverified by the server
X-MetaForge-Actor header, no tokenwhatever the header saysan unverified claim
Static API key onlythe header, if anyan unverified claim
Nothing suppliedsystem:unattributedexplicit 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.

MetaForge documentationGateway schema