Peeking Into Claude Code

Clud (Claude Code) is awesome. So, why not figure out the harness and prompts it has?

Install chain (old npm era vs Bun binary)

There are two eras:

Old way:

npm install @anthropic-ai/claude-code
npm pack @anthropic-ai/claude-code
tar -xvf anthropic-ai-claude-code-*.tgz

You could just unpack the minified JS and read it.

New way:

curl -fsSL https://claude.ai/install.sh | bash

That redirects to a GCS bootstrap script which does the following:

  1. Reads latest.
  2. Downloads manifest.json.
  3. Selects the platform artifact (darwin-arm64 for my machine).
  4. Downloads the claude binary.
  5. Verifies SHA-256.
  6. Runs claude install.

As of writing about this run, latest resolved to 2.1.50, and the checksum matched.

Decompiling the Bun binary

Quick fingerprint:

strings claude-2.1.50-darwin-arm64 | tail -n1
# ---- Bun! ----

This tells you that it was "compiled" by Bun.

From binary to claude.js (and what the Bun markers are)

The Bun binary isn’t a single JS file; it’s a bundle with a module table embedded near the end of the file. Bun drops markers and a trailer so its runtime can locate the module graph.

The ---- Bun! ---- string is the obvious marker, but the real work is the module table that sits near it. The table contains entries like:

The main trick is the entry size. The upstream tool expected 36‑byte entries, but this binary uses 52‑byte entries, so the parser “walked off” the buffer and failed. I wrote a custom extractor that:

  1. Scans for Bun markers.
  2. Reads the trailer and module table offsets.
  3. Tries multiple entry sizes (52, 40, 36, 32, 28) and scores them based on pointer sanity.
  4. Extracts the bundled payloads into a folder.

Output highlights:

Then de‑minify the JS wrapper into a readable claude.js. This step uses bun-decompile.

Result:

That file is what the prompt/tool/schema extractor consumes.

Prompt assembly and system reminders

The system prompt is not a single static string. It is assembled from section builders like # System, # Doing tasks, and # Using your tools. That’s why “same prompt, different behavior” happens when mode/tool state/env context changes.

Here’s a real prompt snippet from a trace (interactive mode):

You are an interactive agent that helps users with software engineering tasks.

System reminders are a real control channel. Example reminder text:

Tool results and user messages may include <system-reminder> or other tags.

There’s even a non-interactive reminder that changes behavior in --print mode:

You are running in non-interactive mode and cannot return a response to the user until your team is shut down.

Tools and schemas

I extracted the tool schemas from the de-minified bundle. Example schema snippet:

WI8 = NR(() => y.strictObject({
  file_path: y.string().describe("The absolute path to the file to read"),

The extracted tool universe for this version is 30 tools:

AskUserQuestion, Bash, Edit, EnterPlanMode, EnterWorktree, ExitPlanMode, Glob, Grep,
ListMcpResourcesTool, LSP, mcp, NotebookEdit, Read, ReadMcpResourceTool, SendMessage,
Skill, StructuredOutput, Task, TaskCreate, TaskGet, TaskList, TaskOutput, TaskStop,
TaskUpdate, TeamCreate, TeamDelete, TodoWrite, ToolSearch, WebFetch, Write

Non-interactive mode

The prompt actually changes with -p / --print. In my --print capture, the system prompt includes the non-interactive reminder above, and the tools array is smaller.

Tools in the --print capture (18 total):

AskUserQuestion, Bash, Edit, EnterPlanMode, EnterWorktree, ExitPlanMode, Glob, Grep,
NotebookEdit, Read, Skill, Task, TaskOutput, TaskStop, TodoWrite, WebFetch, WebSearch, Write

Tools that did not appear in the --print tools list:

ListMcpResourcesTool, LSP, mcp, ReadMcpResourceTool, SendMessage, StructuredOutput,
TaskCreate, TaskGet, TaskList, TaskUpdate, TeamCreate, TeamDelete, ToolSearch

MITM vs Bun hook (and why HTTP_PROXY didn’t help)

MITM showed telemetry/config/update endpoints, but it did not show the real model prompt/response. The Bun-compiled binary does not respect HTTP_PROXY/HTTPS_PROXY in the way you’d expect, so I stopped fighting it and hooked Bun directly.

The Bun preload hook patches fetch and dumps /v1/messages requests + SSE responses. This gets you the full system[], tools[], and tool-use event stream.

Here is the exact run pattern (print mode):

BUN_OPTIONS='--preload <path_to_dir>/trace-claude-messages.cjs' \
CLAUDE_TRACE_DIR=artifacts/trace/messages \
claude -p "Reply exactly: TRACE_FETCH_OK2" --output-format json \
  > artifacts/trace/trace-fetch-out.json \
  2> artifacts/trace/trace-fetch-err.log

The interesting part is the request URL: it hits loopback first:

http://127.0.0.1:<port>/v1/messages?beta=true

That explains why MITM mostly showed telemetry unless you hook Bun at runtime.

Full hook source:

/* eslint-disable no-console */
const fs = require("fs");
const path = require("path");
const os = require("os");
const { randomUUID } = require("crypto");

const outDir =
  process.env.CLAUDE_TRACE_DIR ||
  path.join(process.cwd(), "artifacts", "trace", "messages");
fs.mkdirSync(outDir, { recursive: true });

const maxBytes = Number(process.env.CLAUDE_TRACE_MAX_BYTES || 8 * 1024 * 1024);
const onlyMessages = process.env.CLAUDE_TRACE_ONLY_MESSAGES !== "0";
const redactAuth = process.env.CLAUDE_TRACE_REDACT_AUTH !== "0";

const originalFetch = globalThis.fetch?.bind(globalThis);
if (!originalFetch) {
  throw new Error("globalThis.fetch is not available");
}

function lowerHeaders(input) {
  const out = {};
  const h = new Headers(input || {});
  for (const [k, v] of h.entries()) {
    const key = k.toLowerCase();
    if (
      redactAuth &&
      (key === "authorization" ||
        key === "x-api-key" ||
        key === "cookie" ||
        key === "set-cookie")
    ) {
      out[key] = "***";
    } else {
      out[key] = v;
    }
  }
  return out;
}

async function readBodySafe(body) {
  if (!body) return { text: "", truncated: false };
  try {
    const txt = await body.text();
    if (Buffer.byteLength(txt, "utf8") > maxBytes) {
      return { text: txt.slice(0, maxBytes), truncated: true };
    }
    return { text: txt, truncated: false };
  } catch (err) {
    return { text: <!--CODE_BLOCK_1127-->, truncated: false };
  }
}

function shouldCapture(url) {
  if (!onlyMessages) return true;
  return /\/v1\/messages(\?|$)/.test(url);
}

globalThis.fetch = async function tracedFetch(input, init = {}) {
  const request = new Request(input, init);
  const url = request.url;

  if (!shouldCapture(url)) {
    return originalFetch(input, init);
  }

  const id = randomUUID();
  const ts = new Date().toISOString();
  const reqClone = request.clone();
  const reqBody = await readBodySafe(reqClone);

  let response;
  let fetchErr;
  try {
    response = await originalFetch(request);
  } catch (err) {
    fetchErr = err;
  }

  const baseRecord = {
    id,
    ts,
    pid: process.pid,
    hostname: os.hostname(),
    request: {
      method: request.method,
      url,
      headers: lowerHeaders(request.headers),
      body: reqBody.text,
      body_truncated: reqBody.truncated,
    },
  };

  if (fetchErr) {
    const rec = {
      ...baseRecord,
      error: String(fetchErr),
      stack: fetchErr && fetchErr.stack ? String(fetchErr.stack) : null,
    };
    fs.writeFileSync(
      path.join(outDir, <!--CODE_BLOCK_1128-->),
      JSON.stringify(rec, null, 2)
    );
    throw fetchErr;
  }

  // Return the response immediately so the caller can start reading the
  // SSE stream without waiting. Capture the response body in the background.
  const respClone = response.clone();
  readBodySafe(respClone)
    .then((respBody) => {
      const record = {
        ...baseRecord,
        response: {
          status: response.status,
          status_text: response.statusText,
          headers: lowerHeaders(response.headers),
          body: respBody.text,
          body_truncated: respBody.truncated,
        },
      };
      fs.writeFileSync(
        path.join(outDir, <!--CODE_BLOCK_1129-->),
        JSON.stringify(record, null, 2)
      );
    })
    .catch((err) => {
      const record = {
        ...baseRecord,
        response: {
          status: response.status,
          status_text: response.statusText,
          headers: lowerHeaders(response.headers),
          body: <!--CODE_BLOCK_1130-->,
          body_truncated: false,
        },
      };
      fs.writeFileSync(
        path.join(outDir, <!--CODE_BLOCK_1131-->),
        JSON.stringify(record, null, 2)
      );
    });

  return response;
};

console.error(
  <!--CODE_BLOCK_1132-->
);

Context management and git state

Prompt assembly includes dynamic environment state: cwd, platform, shell, permission mode, tool availability. There are also prompt sections that explicitly reference task management and environment context.

Git state is first-class too. The EnterWorktree tool and related hooks show repo state is meant to be part of the agent loop, and you can see that in the tool schemas and traces.

Skills and plugins

Skills are exposed as tools (Skill, AskUserQuestion) and appear in the tool schemas. MCP adapters (mcp, ListMcpResourcesTool, ReadMcpResourceTool) are also part of the tool surface.

Automating the whole pipeline

I ended up with two automation layers:

  1. Binary + decompile pipeline

    • versioned artifacts under claude-code/versions/<version>/...
    • diffs under claude-code/diffs/<old>_to_<new>.md
  2. Prompt/tool/schema extraction

    • scripts/extract-claude-intel.ts
    • scripts/render-claude-intel-report.ts
    • scripts/extract-tool-descriptions.py

For model I/O inspection I use the Bun preload hook so I always get the real /v1/messages payloads, not just the telemetry.

Extraction scripts (self‑contained)

I’m not linking the repo, so here are the actual snippets and what they do.

1) extract-claude-intel.ts — parse the de‑minified bundle

This reads claude.js, finds prompt anchors, tool implementation blocks, schema snippets, and system reminders, then writes a raw JSON blob.

function extractByPatterns(text: string, lines: string[], patterns: RegExp[], before = 8, after = 16): LineContext[] {
  const out: LineContext[] = [];
  for (const pattern of patterns) {
    let m: RegExpExecArray | null;
    const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : <!--CODE_BLOCK_1147-->);
    while ((m = re.exec(text)) !== null) {
      const line = lineNumberAt(text, m.index);
      out.push({
        line,
        match: m[0].slice(0, 120),
        snippet: getLineWindow(lines, line, before, after),
      });
      if (out.length >= 50) return out.sort((a, b) => a.line - b.line);
    }
  }
  return out.sort((a, b) => a.line - b.line);
}

function parseToolBlocks(text: string): ToolRecord[] {
  const tools: ToolRecord[] = [];
  const assignRe = /([A-Za-z_$][\w$]*)\s*=\s*\{/g;
  let m: RegExpExecArray | null;
  while ((m = assignRe.exec(text)) !== null) {
    const symbol = m[1];
    const openBraceIndex = text.indexOf("{", m.index);
    const closeBraceIndex = findMatchingBrace(text, openBraceIndex);
    const block = text.slice(openBraceIndex, closeBraceIndex + 1);
    if (!block.includes("name:")) continue;
    if (!block.includes("inputSchema")) continue;
    if (!block.includes("call(") && !block.includes("async call(")) continue;
    // extract name + input/output schema expressions
    // store the block + snippets for later rendering
  }
  return tools;
}

What it gives me:

Raw output:

2) render-claude-intel-report.ts — normalize + render markdown

This takes the raw JSON and turns it into human‑readable artifacts.

const tools = intel.tools
  .map((t) => {
    const resolvedName = resolveExpr(t.nameExpr, constMap) ?? t.nameExpr;
    const resolvedInput = resolveExpr(t.inputSchemaExpr, constMap) ?? t.inputSchemaExpr;
    const resolvedOutput = resolveExpr(t.outputSchemaExpr, constMap) ?? t.outputSchemaExpr;
    return { ...t, resolvedName, resolvedInput, resolvedOutput };
  })
  .sort((a, b) => a.resolvedName.localeCompare(b.resolvedName));

await Bun.write(<!--CODE_BLOCK_1150-->, systemMd.join("\n"));
await Bun.write(<!--CODE_BLOCK_1151-->, implMd.join("\n"));
await Bun.write(<!--CODE_BLOCK_1152-->, JSON.stringify(schemaRecords, null, 2));
await Bun.write(<!--CODE_BLOCK_1153-->, schemaMd.join("\n"));
await Bun.write(<!--CODE_BLOCK_1154-->, sandboxMd.join("\n"));

Outputs:

3) extract-tool-descriptions.py — description strings

This scrapes the long tool descriptions from the bundle and writes:

How I run it

# 1) Extract raw intel from the de‑minified bundle
bun scripts/extract-claude-intel.ts \
  claude-code/versions/2.1.50/analysis/claude-intel/claude-intel.json \
  claude-code/versions/2.1.50/deminified/claude-openai/deminified/claude.js \
  claude-code/versions/2.1.50/analysis/claude-intel

# 2) Render reports
bun scripts/render-claude-intel-report.ts \
  claude-code/versions/2.1.50/analysis/claude-intel/claude-intel.json \
  claude-code/versions/2.1.50/deminified/claude-openai/deminified/claude.js \
  claude-code/versions/2.1.50/analysis/claude-intel

# 3) Extract tool descriptions
python3 scripts/extract-tool-descriptions.py \
  claude-code/versions/2.1.50/deminified/claude-openai/deminified/claude.js \
  claude-code/versions/2.1.50/analysis/claude-intel/tool-descriptions.json \
  claude-code/versions/2.1.50/analysis/claude-intel/tool-descriptions.md

Appendix: tool schema reference

These are the tool parameters as observed in captured /v1/messages payloads.

Observed in trace payloads

AskUserQuestion

Bash

For simple commands (git, npm, standard CLI tools), keep it brief (5-10 words): - ls → "List files in current directory" - git status → "Show working tree status" - npm install → "Install package dependencies"

For commands that are harder to parse at a glance (piped commands, obscure flags, etc.), add enough context to clarify what it does: - find . -name "*.tmp" -exec rm {} \; → "Find and delete all .tmp files recursively" - git reset --hard origin/main → "Discard all local changes and match remote main" - curl -s url | jq '.data[]' → "Fetch JSON from URL and extract data array elements" Type: string - run_in_background: Set to true to run this command in the background. Use TaskOutput to read the output later. Type: boolean - dangerouslyDisableSandbox: Set this to true to dangerously override sandbox mode and run commands without sandboxing. Type: boolean

Edit

EnterPlanMode

EnterWorktree

ExitPlanMode

Glob

Grep

NotebookEdit

Read

SendMessage

Skill

Task

TaskOutput

TaskStop

TeamCreate

TeamDelete

TodoWrite

WebFetch

WebSearch

Write

Schemas not observed in trace payloads yet (gated)

These tools are in the de-minified bundle but did not appear in the live /v1/messages tool arrays I captured.

So the parameters below are best-effort snippets and may be incomplete:

If you have scrolled this far, consider subscribing to my mailing list here. You can subscribe to either a specific type of post you are interested in, or subscribe to everything with the "Everything" list.