Capturing an agent's system prompt

September 2, 2026 · View on GitHub

How to read the system prompt a coding agent assembled on your own machine, using OrcaReplay's proxy as the vantage point. Nothing here talks to a service about anything but the agent's own model call: the prompt is captured from the request the harness sent, on the way out.

node capture/capture.mjs <claude|codex|opencode> does all of this in one command. This document is the manual procedure behind it, for porting the approach to a harness the script does not cover or to a platform it does not run on.

Measured against Claude Code 2.1.258, Codex 0.149.0 and OpenCode 1.18.26 on Windows 10, node 22.23.2, OrcaReplay 0.1.2.

What you get

piecewhere it lives
the prompt on its ownprompt/<HARNESS>/<model>-system-prompt.md
the same text with block boundariescapture/<name>/<name>-prompt-annotated.txt
the whole request bodycapture/<name>/<name>-request.json
tool definitionscapture/<name>/<name>-tools.json
run id, sizes, token countscapture/<name>/<name>-meta.json
the raw orca runcapture/<name>/trace/

Everything but trace/ is scrubbed.

Prerequisites

node20.19+ or 22.12+
orcareplaynpm i -g orcareplay
the agentinstalled, signed in, and able to reach the model it names
terminalPowerShell is needed for step 4 on Windows

On Linux and macOS steps 3 to 5 collapse into a single orca record: a pipe there still sits behind a pty, so the harness assembles its interactive prompt anyway. Windows is the case that needs the extra work, for the reason in the pitfalls below.

Steps

1. Find out where orca will forward

If orca setup has configured a gateway, resolveUpstream() (packages/cli/src/config.ts:132) sends every Anthropic call there, and a third-party router usually does not serve the model.

$ cat ~/.config/orca/config.json
{ "gateway": { "url": "http://<your-gateway>:8317", "api_key": "<key>" },
  "models": [ "gpt-5.6-sol" ] }

If that file names a gateway, add --upstream-anthropic https://api.anthropic.com to every orca command below. Codex is the opposite case: its own config.toml already points at that gateway, so leaving the upstream alone is what makes its model resolve.

2. Capture the non-interactive prompt first

Optional, but cheap, and it confirms the proxy, the credential and the model id in one go.

$ orca record claude --no-fs --no-shell \
    --upstream-anthropic https://api.anthropic.com \
    -- -p "reply with just: ok" --model claude-fable-5-1
info recording run=run_8e6d567d4ca4 adapter=claude-code proxy=:58642
ok
info recorded run=run_8e6d567d4ca4 events=7 blobs=3 exit=0

exit=0 means the path works. Note this is the -p prompt, tagged cc_entrypoint=sdk-cli, not the interactive one.

3. Hold a proxy open

orca attach does not launch the agent. It holds the proxy and prints the variable to export. --port is not in --help but it exists, and a fixed port saves parsing the output.

$ orca attach --for claude --port 46001 \
    --upstream-anthropic https://api.anthropic.com
info attached run=run_bc749535e248 proxy=http://127.0.0.1:46001 for=claude-code

  # in the sandbox, before starting your agent:
  export ANTHROPIC_BASE_URL='http://127.0.0.1:46001'
  # your agent's own credential is unchanged - orca forwards it upstream

  Recording. Press ctrl-C when the agent is done.

This command stays up. Use a second window for the next step.

4. Start the agent in a console of its own

Start-Process without -NoNewWindow is the whole trick: Windows hands a console application a real console, so process.stdin.isTTY is true and the harness assembles its interactive prompt.

$claude = "$env:APPDATA\npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe"
foreach ($n in @('CLAUDECODE','CLAUDE_CODE_ENTRYPOINT','CLAUDE_CODE_CHILD_SESSION',
  'CLAUDE_CODE_SESSION_ID','CLAUDE_CODE_MESSAGING_SOCKET','CLAUDE_CODE_MESSAGING_TOKEN',
  'CLAUDE_PID','CLAUDE_EFFORT','CLAUDE_CODE_EXECPATH','AI_AGENT')) {
  if (Test-Path "Env:\$n") { Remove-Item "Env:\$n" }
}
$env:ANTHROPIC_BASE_URL = 'http://127.0.0.1:46001'
Start-Process -FilePath $claude -WindowStyle Minimized -PassThru `
  -WorkingDirectory 'D:\your\repo' `
  -ArgumentList @('--model','claude-fable-5-1','"say only the word ok"')

Wrap the prompt in embedded quotes so it arrives as a single argument. Choose a working directory the agent already trusts, or it stops on the trust dialog with nobody to answer it.

5. Wait for the request that carries tools

The first model.request is the title generator, tools=0, and it is not the one you want. Wait for the one where tools is greater than zero.

$ until grep -q '"tools":3' .orca/runs/*/events.jsonl; do sleep 2; done
seq 4  model.request   claude-fable-5-1  tools=35  blob=175255
seq 5  model.response  200  end_turn  cache_read=59811

6. Clean up

Both processes have to stop. Killing the shell that started orca attach does not reach the node process behind it, and the port stays bound.

Stop-Process -Id 18544 -Force
Get-NetTCPConnection -LocalPort 46001 -State Listen |
  ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }

7. Read the request body out of the trace

Events hold the blob's sha256, sharded by its first two characters. Take the first request with tools, not the largest: later turns load deferred tools, so the tool count climbs from 35 to 37 and the biggest request describes a state the first turn was never in.

const evs = readFileSync(dir + '/events.jsonl', 'utf8')
  .split('\n').filter(Boolean).map(JSON.parse);
const r = evs.filter(e => e.type === 'model.request' && e.attrs.tools > 0)[0];
const sha = r.payload.$blob.replace('sha256:', '');
let body = JSON.parse(readFileSync(`${dir}/blobs/${sha.slice(0,2)}/${sha}`, 'utf8'));
if (typeof body === 'string') body = JSON.parse(body);   // blobs are double-encoded

8. Assemble the prompt and scrub it

Where the prompt lives depends on the dialect.

Claude Code, Anthropic messages: the system array, then the injected role: "system" message that carries agent types, the skill list and a paragraph about the permission mode.

const prompt = body.system.map(s => s.text).join('\n\n')
  + '\n\n' + body.messages.find(m => m.role === 'system').content[0].text;
  [0]     70 chars  cache=none    billing header
  [1]     57 chars  cache=1h      identity
  [2]    907 chars  cache=none    reporting outcomes
  [3]  13919 chars  cache=1h      the body
  role:system  11478 chars

Codex, OpenAI responses: no instructions field and no top-level tools. The prompt arrives as developer-role messages inside input, and the tools hang off a custom additional_tools item as a namespace tree, which is why the recorded event reads tools: 0.

OpenCode, OpenAI chat completions: a single system message, tools in the standard tools array.

Then replace the machine-specific values with placeholders. Derive them from the machine rather than hard-coding them: home directory, username, git name and email, the gateway host from ~/.config/orca/config.json, the Claude Code project slug, the OS build, plus generic rules for emails, uuids and hex runs of 32 or more. Order matters, because the memory directory sits inside the home directory. Re-scan afterwards and refuse to write the file if anything survives.

How much the mode changes

print (sdk-cli)interactive (cli)
system[]13,641 chars14,953 chars
role:system7,585 chars11,529 chars
tools2935
request104,492 bytes168,303 bytes
tokens37,83159,811

Interactive adds the ! <command> hint, the whole Scratchpad Directory section, a gitStatus block when the working directory is a repository, and Artifact, AskUserQuestion, EnterPlanMode and ExitPlanMode.

The identity line changes too:

  • print — You are a Claude agent, built on Anthropic's Claude Agent SDK.
  • interactive — You are Claude Code, Anthropic's official CLI for Claude.

Pitfalls

A gateway rewrites the upstream silently. 400 unknown provider for model claude-fable-5-1 comes from the gateway you configured, not from Anthropic, and has nothing to do with whether the agent is installed. The agent works on its own because it talks to the official endpoint directly.

Credentials are forwarded upstream. SECRET_REQUEST_HEADERS in packages/proxy/src/server.ts forwards auth headers, though it never writes them to a trace. With the upstream pointed at a third-party gateway that sends an OAuth token there along with the whole prompt, and over plain HTTP if that is what the gateway speaks.

orca record claude cannot reach the interactive prompt. It launches through a pipe, isTTY is false, and the harness takes the non-interactive path. The adapter also hard-codes command: 'claude', so there is no room to slip a pty shim in front of it.

winpty will not start without a console. It asserts cols > 0 && rows > 0 at winpty.cc:924, because it cannot learn the window size from a pipe and the command line has no --cols. Letting Start-Process allocate a console is less work than building a pty. Node's own detached is no help either: on Windows it means DETACHED_PROCESS, so no console at all.

PowerShell splits an argument containing spaces. Passing 'reply with just: ok' in -ArgumentList delivers only reply, and the agent then goes looking through git and pull requests for context. The system prompt is unaffected, but the tokens are wasted.

A nested session inherits CLAUDE_*. Starting the agent from inside another Claude Code session passes down CLAUDECODE=1 and CLAUDE_CODE_CHILD_SESSION=1, which changes the prompt. That is what the Remove-Item loop in step 4 is for.

The working directory is part of the prompt. Capturing in a temporary non-repository loses the whole gitStatus block: 4,271 characters on a measured Opus 5 run. Capture where you actually work.

A failed turn still produces a prompt. A wrong --model and a transient overloaded_error fail the same way, and in both cases the request was already recorded. Check the response before filing the capture, or a typo creates a folder for a model that does not exist.

OpenCode keeps its origin in a config file, so redirect it at the socket instead. OPENAI_BASE_URL alone leaves the run talking straight past the proxy. A project-level opencode.json carrying the proxy origin does redirect it when the command is typed in a shell, and did not when the same file and command were launched from a script. --tls-intercept --tls-hosts '+opencode.ai,+*.opencode.ai' sidesteps the question: orca terminates the TLS OpenCode established itself and the config is left alone. Expect a few tls.handshake_failed warnings for connections that are not the model call; the model call still records.

Proving a capture is genuine

Run it twice and diff system[3]. Everything but the values below should be byte for byte identical.

differencewhy
scratchpad pathone uuid per session
memory directoryderived from the working directory
gitStatus blocktracks the repository state
cc_version suffixa hash over the assembled prompt, so the above change it

There is a second, sharper check. Compare a run the gateway rejected with a run that returned 200: system[3] differs only in the working directory and the memory hash, while the tool definitions and the role:system turn are identical. What the upstream returns does not affect the request already recorded — the prompt is assembled locally and written to disk before it is forwarded.

Reference runs

runharnessmoderesult
run_bc749535e248claudeinteractive200
run_8e6d567d4ca4claudeprint200
run_7a02fa0c8266claudeprintrejected by the gateway; request body still complete
run_31102b8bde19codexexec200
run_6e92b193d46dopencoderun200, redirected by a project config
run_578faf1d269eopencoderun200, --tls-intercept on a free model