How to see the exact request your AI agent sends, without an API key
Agent frameworks build the request for you, then show you a summary of it. Here is a 60-line local server that records the exact JSON body your SDK sends, with no API key, no proxy and no certificate, plus the CI assertion a proxy cannot give you.
On this page
Quick answer (September 2026). Every agent framework eventually makes one ordinary HTTPS POST to a model API. You can read that POST without an API key, without a proxy and without a certificate: start a 60-line Node server on localhost, point the SDK's baseURL at it, and it will record the exact JSON body your framework built. Measured this way on openai 7.20.0, @anthropic-ai/sdk 0.127.0 and @openai/agents 0.18.0, a plain "say hi" costs 67 bytes through the raw OpenAI SDK and 171 bytes through the agent framework, and adding a single tool makes the tool schema 69 percent of the request, re-sent byte for byte on every turn. Because the capture is a file, you can also assert on it in CI, which is the part a traffic proxy cannot do.
The problem: the framework's log is a summary, not the wire
When an agent loops, burns tokens, or ignores an instruction you are certain you set, the framework tells you a story: called tool X, got result Y, continued. That story is a reconstruction. It is not the bytes that left your machine.
The thing you actually want to see is the request body, because that is what the model conditioned on and that is what you paid for. And the gap between "what I wrote" and "what was sent" is exactly where agent frameworks live. They assemble a system message, serialise your tool definitions into JSON Schema, and add model settings of their own. None of that is visible from your own source code.
There is a well-known way to see it: put a man-in-the-middle proxy between the agent and the API. Fluxzy wrote a good walkthrough of that approach in How to debug LLM API calls from your AI agent, and it has a real advantage this article does not, which I will come back to at the end.
It also has three costs. You install a desktop application. You enable full TLS decryption, which means trusting a local certificate authority. And you run the agent against the real API with a real key, so every debugging run costs money and every captured request has your key in the headers.
This article takes the other route. Every one of these SDKs lets you change where it sends the request. So send it to yourself.
The whole capture server
No dependencies. Node's built-in http module is all of it. Save this as capture.js.
// capture.js - a local stand-in for a model API.
// Records every request an SDK sends, then answers with a canned reply
// (or a scripted one) so the caller keeps going. No API key. No network.
const http = require("http");
const fs = require("fs");
const PORT = Number(process.env.CAPTURE_PORT || 8811);
const LOG = process.env.CAPTURE_LOG || "turns.json";
const SCRIPT = process.env.CAPTURE_SCRIPT;
const scripted = SCRIPT ? JSON.parse(fs.readFileSync(SCRIPT, "utf8")) : [];
const turns = [];
let n = 0;
function fallback(path) {
if (path.indexOf("/messages") !== -1) {
return {
id: "msg_capture",
type: "message",
role: "assistant",
model: "capture-stub",
content: [{ type: "text", text: "done" }],
stop_reason: "end_turn",
usage: { input_tokens: 0, output_tokens: 0 }
};
}
return {
id: "chatcmpl_capture",
object: "chat.completion",
created: 0,
model: "capture-stub",
choices: [
{
index: 0,
message: { role: "assistant", content: "done" },
finish_reason: "stop"
}
],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
};
}
const server = http.createServer(function (req, res) {
const chunks = [];
req.on("data", function (c) {
chunks.push(c);
});
req.on("end", function () {
const raw = Buffer.concat(chunks).toString("utf8");
turns.push({
turn: n + 1,
path: req.url,
bytes: raw.length,
body: raw ? JSON.parse(raw) : null
});
fs.writeFileSync(LOG, JSON.stringify(turns, null, 2));
const out = scripted[n] ? scripted[n] : fallback(req.url);
n = n + 1;
console.log("turn " + n + " " + req.method + " " + req.url + " " + raw.length + " bytes");
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(out));
});
});
server.listen(PORT, "127.0.0.1", function () {
console.log("capture server on http://127.0.0.1:" + PORT);
});
Three things it does. It appends every request to turns.json, so a multi-turn agent run leaves a complete transcript rather than only its last call. It answers with a minimal valid response so the caller does not throw and the run continues. And it picks the response shape from the request path, so the same server can stand in for two different providers at once.
Set it up like this:
mkdir capture-lab && cd capture-lab
npm init -y
npm install --save-exact openai@7.20.0 @anthropic-ai/sdk@0.127.0 @openai/agents@0.18.0
node capture.js
Point three clients at one server
Two official SDKs first, so we have a baseline of what an unassisted request looks like. Both the OpenAI SDK and the Anthropic TypeScript SDK take a baseURL, and neither validates the key, because the key is only ever checked by the server that receives it.
// probe_sdks.js - two official SDKs, two providers, one capture server.
const OpenAI = require("openai");
const Anthropic = require("@anthropic-ai/sdk");
async function main() {
const openai = new OpenAI({
apiKey: "not-a-real-key",
baseURL: "http://127.0.0.1:8811/v1"
});
const a = await openai.chat.completions.create({
model: "gpt-5.2",
messages: [{ role: "user", content: "say hi" }]
});
console.log("openai sdk replied:", a.choices[0].message.content);
const anthropic = new Anthropic({
apiKey: "not-a-real-key",
baseURL: "http://127.0.0.1:8811"
});
const b = await anthropic.messages.create({
model: "claude-opus-5",
max_tokens: 64,
messages: [{ role: "user", content: "say hi" }]
});
console.log("anthropic sdk replied:", b.content[0].text);
}
main();
Now the same prompt through an agent framework. @openai/agents does not take a base URL directly. You hand it a configured client instead, which is the mechanism OpenAI documents on its Agents SDK configuration page for pointing the SDK at a different provider. We are pointing it at ourselves.
// probe_agents.js - the same prompt, through an agent framework.
const { Agent, run, setDefaultOpenAIClient, setTracingDisabled, setOpenAIAPI } = require("@openai/agents");
const OpenAI = require("openai");
setTracingDisabled(true);
setOpenAIAPI("chat_completions");
setDefaultOpenAIClient(
new OpenAI({ apiKey: "not-a-real-key", baseURL: "http://127.0.0.1:8811/v1" })
);
const agent = new Agent({
name: "Helper",
instructions: "You are terse.",
model: "gpt-5.2"
});
run(agent, "say hi")
.then(function (r) {
console.log("agent replied:", r.finalOutput);
})
.catch(function (e) {
console.log("err:", e.message);
});
setTracingDisabled(true) matters. Without it the SDK tries to ship traces to OpenAI's real endpoint with a key that does not exist, and you get noise that has nothing to do with what you are measuring.
Run both against the server and it prints one line per request:
turn 1 POST /v1/chat/completions 67 bytes
turn 2 POST /v1/messages 89 bytes
turn 3 POST /v1/chat/completions 171 bytes
Three clients, two providers, two different endpoint shapes, one 60-line server. Here is what each one actually sent:
Scroll to see more
| client | endpoint | bytes | top-level keys in the body |
|---|---|---|---|
openai 7.20.0 | /v1/chat/completions | 67 | model, messages |
@anthropic-ai/sdk 0.127.0 | /v1/messages | 89 | model, max_tokens, messages |
@openai/agents 0.18.0 | /v1/chat/completions | 171 | model, messages, stream, reasoning_effort, verbosity |
The two raw SDKs send exactly the fields you typed and nothing else. That is worth knowing on its own, because it tells you that when something unexpected is in the request, the SDK is not where it came from.
The framework request is 2.5 times the size for the same three words, and it carries three keys you never wrote. stream: false is harmless bookkeeping. The other two are sampling settings that change how the model answers, chosen for you based on the model name. That behaviour is worth its own article and I wrote one: the model name you pass to @openai/agents decides your reasoning and verbosity settings. The point here is narrower, and it is the point of the whole technique: you would not know either field existed by reading your own code.
Add one tool, and watch the request fill up
A single-message request is not where the bytes go. Tools are. Add one tool and run the agent again, this time scripting the reply so the model "calls" it and we get a second turn.
// probe_tool.js - one tool, so we can see what a tool costs.
const { Agent, run, tool, setDefaultOpenAIClient, setTracingDisabled, setOpenAIAPI } = require("@openai/agents");
const OpenAI = require("openai");
const { z } = require("zod");
setTracingDisabled(true);
setOpenAIAPI("chat_completions");
setDefaultOpenAIClient(
new OpenAI({ apiKey: "not-a-real-key", baseURL: "http://127.0.0.1:8811/v1" })
);
const getWeather = tool({
name: "get_weather",
description: "Look up the current weather for a city.",
parameters: z.object({
city: z.string().describe("City name, for example Lisbon"),
units: z.enum(["c", "f"]).describe("Temperature units")
}),
execute: async function (args) {
return "18 degrees and clear in " + args.city;
}
});
const agent = new Agent({
name: "Helper",
instructions: "You are terse.",
model: "gpt-5.2",
tools: [getWeather]
});
run(agent, "weather in Lisbon?")
.then(function (r) {
console.log("agent replied:", r.finalOutput);
})
.catch(function (e) {
console.log("err:", e.message);
});
The scripted reply is one file. The capture server returns entry n on turn n, then falls back to its canned answer, so a one-entry script gives you exactly two turns: the tool call, then the wrap-up.
[
{
"id": "chatcmpl_1",
"object": "chat.completion",
"created": 0,
"model": "capture-stub",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": { "name": "get_weather", "arguments": "{\"city\":\"Lisbon\",\"units\":\"c\"}" }
}
]
},
"finish_reason": "tool_calls"
}
],
"usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0 }
}
]
Run it with CAPTURE_SCRIPT=script.json node capture.js and the agent completes a full tool round trip against a server that has never spoken to a model:
turn 1 POST /v1/chat/completions 620 bytes
turn 2 POST /v1/chat/completions 884 bytes
Measured from turns.json:
Scroll to see more
| turn 1 | turn 2 | |
|---|---|---|
| whole request | 620 bytes | 884 bytes |
| messages | 2 | 4 |
| tool schema block | 428 bytes | 428 bytes |
| tool schema as a share of the request | 69 percent | 48 percent |
The tool schema is byte-for-byte identical on both turns. It is not a header you send once and refer back to; it is re-transmitted in full on every single request for the lifetime of the conversation. With one small tool that is 428 bytes a turn. Ten tools of that size is roughly 4 KB of schema on every turn of every conversation, before anybody says anything.
What your Zod schema actually turns into
The other half of that 428 bytes is worth reading, because most people never see it. This is what a six-line Zod object becomes on the wire:
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Look up the current weather for a city.",
"parameters": {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"city": { "type": "string", "description": "City name, for example Lisbon" },
"units": { "type": "string", "enum": ["c", "f"], "description": "Temperature units" }
},
"required": ["city", "units"],
"additionalProperties": false
},
"strict": true
}
}
Three things fall out of looking at it:
- Every
.describe()string is shipped to the model, on every turn. In this tool they total 85 characters. They are prompt text, not documentation, and they should be written like prompt text. - The framework emitted
strict: trueandadditionalProperties: false, and declared both parametersrequired. Nothing in the tool definition asked for that. - It also emitted a draft-07
$schemadeclaration, which is pure overhead on the wire.
None of this is wrong. It is simply invisible unless you look.
Turn the capture into a check that runs in CI
Here is the part a proxy cannot give you. The capture is a plain JSON file, so it is an assertion target. This is a test, and it passes or fails with an exit code.
// check.js - assert on what the agent actually sent. Run this in CI.
const assert = require("node:assert/strict");
const fs = require("fs");
const turns = JSON.parse(fs.readFileSync("turns.json", "utf8"));
const first = turns[0].body;
// 1. The system prompt is the one you wrote, and nothing else.
const system = first.messages.filter(function (m) {
return m.role === "system";
});
assert.equal(system.length, 1, "expected exactly one system message");
assert.equal(system[0].content, "You are terse.");
// 2. No sampling settings you did not ask for.
const unexpected = ["temperature", "top_p", "reasoning_effort", "verbosity"].filter(
function (k) {
return Object.prototype.hasOwnProperty.call(first, k);
}
);
assert.deepEqual(unexpected, [], "framework injected: " + unexpected.join(", "));
console.log("all assertions passed");
Against the run above it fails, which is the whole point:
AssertionError [ERR_ASSERTION]: framework injected: reasoning_effort, verbosity
+ actual - expected
+ [
+ 'reasoning_effort',
+ 'verbosity'
+ ]
- []
Exit code 1, in CI, naming the two fields. The first assertion is the one I would keep permanently: agent frameworks compose system prompts from several sources, and "exactly one system message, and it is mine" is a cheap guard against a library quietly prepending its own. If you already run behavioural tests on your agents, this sits underneath them, checking the input rather than the output. It pairs well with the output-side checks in testing an AI agent.
Three things this does not do
I would rather tell you where this breaks than have you find out at 2am.
Streaming is captured but not replayed. Ask for stream: true and the server still records the request perfectly, which is what you came for. But it answers with a normal JSON body instead of an event stream, so the client iterates zero chunks and reports success having received nothing. Measured: a 77-byte streaming request was captured correctly and the consumer finished with no parts. Fine for inspection, useless for exercising your streaming code path.
A different API surface means a different body, and a different reply. The run above forced setOpenAIAPI("chat_completions"). Drop that line and @openai/agents 0.18.0 uses the Responses API instead, and the same agent code produces a structurally different request:
{
"model": "gpt-5.2",
"instructions": "terse",
"input": [{ "role": "user", "content": "hi" }],
"include": [],
"tools": [],
"stream": false,
"text": { "verbosity": "low" },
"reasoning": { "effort": "none" }
}
No messages. No flat reasoning_effort. The same two injected settings, nested one level down as text.verbosity and reasoning.effort. The capture still worked, because capture is just an HTTP server and does not care about shapes; only the canned reply was wrong, and the agent failed to parse it. Add a branch for /v1/responses if you need that path.
Which makes my own check.js surface-specific, and that is worth saying plainly. It looks for flat reasoning_effort and flat verbosity. On the Responses API those keys are not flat, so that assertion has nothing to find. Here it crashed loudly on the missing messages array, which is the lucky outcome. Written slightly more defensively it would have printed "all assertions passed" while the identical settings were being injected two keys deeper. If you adopt this pattern, assert against the shape you are actually sending, and re-check it when you change API surface.
When to use a proxy instead
Use the capture server when you own the code that constructs the client, which covers almost all application work. It costs nothing, needs no key, runs unattended in CI, and lets you drive the agent down a chosen path by scripting the replies.
Reach for a proxy like Fluxzy or mitmproxy when you do not. If you want to see what a closed-source binary, a packaged desktop tool, or somebody else's container is sending, there is no baseURL for you to change, and a proxy is the only thing that works. It also observes real traffic against the real API, which you sometimes genuinely need.
They answer different questions. A proxy tells you what happened. A capture server lets you ask what would happen.
One security note
Your SDK sends its credentials to whatever address you give it, and it does not care that the address is yours. Measured against the capture server: the OpenAI SDK sent authorization: Bearer not-a-real-key and the Anthropic SDK sent x-api-key: not-a-real-key, along with anthropic-version: 2023-06-01. The key arrives. It is simply never checked, because the only thing that would have checked it is the server you replaced.
The version published above records the path, the size and the body, and deliberately does not record headers, so nothing secret reaches the disk. That is a default worth keeping. If you add headers: req.headers to the recorded object, which is genuinely useful for seeing auth and API-version headers, then you have started writing credentials into a JSON file in your working directory, quite possibly inside a repository. Use a placeholder key, as every example here does. If you ever capture with a real one, log the headers somewhere git ignores and delete it afterwards.
Written by
Ren OkabeRen builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.
Frequently asked questions
Do I need an API key to capture what my agent sends?
No. The capture server is an ordinary local HTTP server, and it never checks credentials. Point the SDK's baseURL at it and pass any placeholder string as the key. Measured on openai 7.20.0 and @anthropic-ai/sdk 0.127.0, both SDKs send the key in a header and neither validates it client side, so the request is built and sent exactly as it would be against the real API.
How is this different from using a MITM proxy like Fluxzy or mitmproxy?
A proxy observes real traffic going to the real API, so it needs a certificate, a real key and real spend, and it works even on software you cannot modify. A capture server replaces the API, so it needs none of those but only works where you control the client construction. The practical split: use a proxy to see what a closed-source binary is doing, and a capture server when you own the code, because it costs nothing, runs in CI, and lets you script the replies.
How much of my request is the tool schema?
Measured on @openai/agents 0.18.0 with a single two-parameter tool: 428 bytes of a 620-byte first request, which is 69 percent. On the second turn the same 428 bytes are re-sent byte for byte, then 48 percent of an 884-byte request. Tool schemas are re-transmitted in full on every turn, so ten tools of that size is roughly 4 KB per turn before anyone says anything.
Does this work with streaming responses?
The capture works, the replay does not. A request with stream set to true is recorded correctly, measured at 77 bytes, but the server answers with a normal JSON body rather than an event stream, so the client iterates zero chunks and reports success having received nothing. That is fine for inspecting the request and useless for exercising your streaming code path.
Why does my captured body look completely different on the Responses API?
Because it is a different endpoint with a different schema. Measured on @openai/agents 0.18.0, the same agent code sends messages to /v1/chat/completions but instructions plus input to /v1/responses, and the injected model settings move from flat reasoning_effort and verbosity to nested reasoning.effort and text.verbosity. The capture still succeeds, since an HTTP server does not care about shapes. Only the canned reply has to match the endpoint.
Is it safe to point a client with a real API key at this?
The key is sent to your server, so treat it as exposed. The server published here records the path, the size and the body and deliberately does not record headers, so nothing secret reaches disk. If you add headers to the recorded object, which is useful for seeing auth and version headers, you are writing credentials into a file in your working directory. Use a placeholder key instead, since nothing validates it.
Related tutorials
@openai/agents adds reasoning.effort and text.verbosity by matching your model name
The SDK chooses reasoning.effort and text.verbosity from how your model name is spelled, not from what the model supports. Measured on the wire on 0.18.0, including what it does to a custom gateway deployment name.
AI Agent Testing: A Runnable Pytest and LLM-Judge Harness (2026)
You cannot unit-test an agent like a pure function. Build a two-layer pytest harness: deterministic tool-call assertions plus an LLM-as-judge grader, a frozen eval dataset, and a CI gate. Runnable Python, no eval framework required.
Build an AI Agent with the Claude Agent SDK in TypeScript (2026)
A runnable 2026 quickstart: install the Claude Agent SDK, wire a custom tool with tool() and createSdkMcpServer(), and let the agent loop call it for you in about 40 lines of TypeScript.