What is Baton
Baton is actionable product analytics for MCP servers. When a customer's agent calls your server and your API can't meet the request, you often get a 200 with an empty body or a vague error. The customer's intent vanishes with no ticket and no trace. Baton captures that friction at the moment it happens: what the customer was trying to do, the call the agent made, what it expected, and what it got.
You instrument your server once. Whatever agent your customers use (Claude, Cursor, ChatGPT) keeps working exactly as before.
Which path is mine
Three ways to capture. They emit the same events in the same wire format to the same sinks, so the choice is about where the code goes, not what you get. You only need one.
baton-sdk
A server you own. Goes inside it and captures your real customers. Python or TypeScript. Quickstart →
Gateway
Many servers behind a gateway. Capture once, in front of all of them, without touching any. Quickstart →
baton-proxy
A server you don't own, or your own before you change any code. Wraps it from outside. Quickstart →
Instrumenting is two lines. Here is the SDK path in full:
from baton import install_baton install_baton(mcp, dsn="https://baton_pk_...@baton.goodtiming.ai/ten_.../acme-kb")
The DSN is one string from /account holding your workspace, this server and the key that binds them, so nothing else needs configuring. You can also run without a key and read the events on your own machine first. Choosing paths →
Next steps
Send events to the Console
Every path can write to your own machine instead. One DSN string swaps the sink for the hosted Console, where friction is deduped and ranked across every customer. Console →
Capture intent, not just calls
The intent parameters are how a call carries why it was made. They are what turns an event log into a roadmap. Intent parameters →
Choose a sink
stderr, a file, an https:// collector of your own, or several at once. Sinks →
Check what gets scrubbed
Payloads run through a pattern-based scrubber before they reach any sink. Read what it does and does not catch. PII scrubbing →
capture friction from your customers
Overview
baton-sdk is native instrumentation for vendors who control their MCP server source. One call wires up capture, annotation, and sink routing, with the same wire format as the proxy and no subprocess hop. Because it lives inside what you ship, it captures friction from your real customers, who never have to install anything.
MCP middleware path
Your MCP server calls install_baton(mcp, dsn=…). Two lines. Captures every tool call at the transport boundary and adds the annotation tool. Best for vendors with MCP servers as their primary surface.
Library API path
Code that calls your API wraps each call in client.trace(…). Same event envelope, same sinks, different emission boundary. Best for vendors who ship Skills rather than an MCP server.
baton-sdk is open source under Apache-2.0; the source lives in the baton repo. The Python package needs 3.11+. There is a TypeScript SDK too, @goodtiming/baton-sdk on npm, which takes the same DSN and emits the same events; everything on this page applies to both unless it says otherwise.
Install
| Command | Includes |
|---|---|
| pip install "baton-sdk>=0.8.8" | Needed to send events to a Baton Console. httpx is a core dependency, so the Console sink needs no extra. 0.8.6 is the oldest release the Console accepts, because earlier ones send user_id, which was renamed principal_id. On 0.8.7 and earlier the sink also needed the [http] extra, which a fresh [mcp] or [fastmcp] install did not pull in. |
| pip install baton-sdk | Core. Library API and sinks, the Console sink included. Writes to stderr, a file, or https. |
| pip install "baton-sdk[mcp]" | + the official mcp SDK, mcp>=1.20,<3. |
| pip install "baton-sdk[fastmcp]" | + standalone fastmcp, fastmcp>=2.14,<5. |
| pip install "baton-sdk[all]" | Everything above. |
Quote the extras. zsh, the default shell on macOS, treats square brackets as a pattern and stops with no matches found before pip runs.
Quickstart
The SDK runs in-process inside your server and captures every tool call at the transport boundary, with no subprocess and your tools unchanged.
The following diagram illustrates where the SDK runs inside your MCP server:
- No second process. The SDK is a library inside your server, so there is nothing extra to deploy, supervise or keep alive.
- Capture is at the transport boundary: the tool name, params, result and error that crossed MCP. The SDK does not reach into your logs or your database.
- The sink is yours to choose, and it is the only thing that leaves the process.
from mcp.server.mcpserver import MCPServer
from baton import install_baton
mcp = MCPServer("Acme Knowledge Base")
install_baton(mcp, dsn="https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...")
@mcp.tool()
async def your_tool(...): ...
from mcp.server.fastmcp import FastMCP
from baton import install_baton
mcp = FastMCP("Acme Knowledge Base")
install_baton(mcp, dsn="https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...")
@mcp.tool()
async def your_tool(...): ...
from fastmcp import FastMCP
from baton import install_baton
mcp = FastMCP("Acme Knowledge Base")
install_baton(mcp, dsn="https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...")
@mcp.tool
def your_tool(...): ...
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { withBaton } from "@goodtiming/baton-sdk";
const server = new McpServer({ name: "acme-knowledge-base", version: "1.0.0" });
withBaton(server, {
dsn: "https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...",
});
That string is the whole configuration. It is the DSN for one server, and it carries four things: a publishable key (baton_pk_…), the host events go to, your workspace id (ten_…) and the server's id (srv-…). The SDK reads all four and builds the sink itself.
install_baton works out which library your server comes from, and raises TypeError before changing anything if the object is not an MCP server it supports. Tools registered before and after the call are both captured. It adds one tool to your server, named after it: a server called "Acme Knowledge Base" gets acme-knowledge-base_annotate.
Getting a DSN
Sign in at baton.goodtiming.ai/account and press Add a server, with a name if you want one. The page shows the new DSN with a Copy button, and every server's DSN stays listed under Publishable keys. The setup agent makes one for you when it wires up a server.
We assign the server id and it never changes. The name is a label on /account and in your workspace picker, and you can rename it there. A publishable key can only write events, and only for the server its DSN names, so it is safe in your source.
Where the DSN goes
A server you distribute, meaning a stdio server started on your user's machine, takes the DSN in source, as above. An MCP client starts a stdio server with a fixed allowlist of environment variables (six names on macOS and Linux) plus whatever the user wrote in their own client config, so a value in your .env never reaches it.
A server you host can read it from the environment. Set BATON_DSN and pass nothing:
install_baton(mcp) # reads BATON_DSN
An explicit dsn= wins over BATON_DSN, and either one wins over BATON_VENDOR_ID and BATON_TENANT_ID, so a new DSN is never overridden by values an earlier install left in the environment.
baton_pk_… key in place of the DSN stops at startup with a message saying to copy the full value from /account. A secret baton_sk_… key in a DSN works, and logs a warning: it reads and writes everything in your workspace, so it must never ship.Without a DSN
To send to your own collector, or to try the SDK before you have a key, name the parts. Leave out sink and events go to stderr as JSON Lines.
import os
from baton import install_baton, VendorConfig
from baton.sinks import HttpSink
install_baton(mcp, VendorConfig(
vendor_id="acme-kb",
tenant_id="acme",
vendor_display_name="Acme Knowledge Base",
sink=HttpSink("https://collector.example.com", api_key=os.environ["COLLECTOR_KEY"]),
))
vendor_id names the server and tenant_id names the account the collector authenticates. One account holds many servers, so giving both the same value merges every server in it into one. HttpSink posts to {url}/v0/events with the key as a bearer token.
A VendorConfig can carry a DSN too, as VendorConfig(dsn=…, scrubber=…), when you want a DSN and other settings. Passing a DSN alongside vendor_id, tenant_id or sink raises, because the DSN already sets them.
| Your server | Adapter, if you import one directly |
|---|---|
| from mcp.server.mcpserver import MCPServer | baton.integrations.official |
| from mcp.server.fastmcp import FastMCP | baton.integrations.official |
| from fastmcp import FastMCP | baton.integrations.standalone |
Configuration
Keyword-only. With a DSN, every other field is optional.
| Field | Default | What it does |
|---|---|---|
| dsn | BATON_DSN | The value from /account. Sets vendor_id, tenant_id and sink, and vendor_display_name when that is unset. |
| vendor_id | — | The server's id. Required without a DSN. Letters, digits, _ and -, up to 48 characters. |
| tenant_id | BATON_TENANT_ID, then vendor_id | The account the collector authenticates. |
| vendor_display_name | the DSN's server id | The name the calling agent reads, in the server instructions and the annotation tool's description. Required without a DSN. |
| sink | from the DSN, else StdoutSink() | Where events go. See Sinks. |
| annotation_tool_name | from the server's name | Pins the annotation tool's name. Without a usable server name it is {vendor_id}_annotate. |
| scrubber | Scrubber() | Runs on every payload. Pass identity_scrub to turn scrubbing off, or your own callable. See PII scrubbing. |
| intent_param_mode | "required" | Adds user_goal, expected_result and overall_task to every tool's input schema and removes them before your handler runs. "required" also lists all three as required, as an advertisement only: no call fails for omitting one. "optional" leaves them off the required list; "off" adds none. |
| proactive_mode | "off" | "on" asks the agent to file an annotation before each tool call. Turn it on if intent_param_mode is "off", or nothing captures intent. |
| resolve_principal | — | A function, sync or async, that says who is behind a call. Unset, no event names anyone. See Identity. |
Identity
Baton names the person behind a call only when you tell it who that is. You pass one function, resolve_principal. It gets the call's headers, _meta, tool name, arguments and the verified token's claims, and returns a Principal or None. The SDK sends what the function returns and does nothing else to it: no hashing, no key, no guessing.
If your server runs OAuth, two ready-made hooks cover it. Pass one as it is:
from baton import install_baton, VendorConfig, principal_from_oauth_email
install_baton(mcp, VendorConfig(
dsn="https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...",
resolve_principal=principal_from_oauth_email, # or principal_from_oauth_sub
))
principal_from_oauth_email uses the token's email claim as the id and the part before @ as the name your dashboard shows. principal_from_oauth_sub uses the token's subject. Both return None when the claim is missing. A token exists only on HTTP with auth configured.
For anything else, such as a gateway header, an argument or a stdio login, write your own. It is a few lines:
from baton import Principal
def resolve(ctx):
employee = (ctx.headers or {}).get("x-forwarded-user")
if not employee:
return None
return Principal(principal_id=employee, display_name=employee.split("@")[0])
Principal field | Default | What it does |
|---|---|---|
| principal_id | required | The id, sent exactly as you return it. Your dashboard groups a person's sessions by it. |
| display_name | None | What your dashboard shows for this person. Without it, the dashboard shows the id. |
| form | "raw" | What the id is. Leave it as "raw" for a real identity. Set "hashed" only when your function hashed the id itself. |
The id you return is stored by the collector as it is. Your function decides what is safe to send. If real identities must not leave your side, hash the id in the function and say so:
import hashlib, hmac, os
from baton import Principal
KEY = os.environ["MY_ID_HASH_KEY"].encode()
def resolve(ctx):
sub = (ctx.claims or {}).get("sub")
if not sub:
return None
digest = hmac.new(KEY, sub.encode(), hashlib.sha256).hexdigest()
return Principal(principal_id=digest, form="hashed")
A hook that raises, or returns nothing usable, costs that event its person and nothing else: the tool call and the event are unaffected. The two ready-made hooks are short and are the best examples to copy from: Python, TypeScript.
Sinks
| Sink | Use case |
|---|---|
| StdoutSink() | Events to stderr as JSON Lines. The default with no DSN and no sink=. |
| FileSink("./events.jsonl") | Capture to a local file. |
| HttpSink(url, api_key=…) | POST to {url}/v0/events. Bounded buffer, retry, circuit breaker. A DSN builds this one for you. |
| MultiSink([…]) | Fan out to several sinks at once. |
from baton import install_baton, VendorConfig
from baton.sinks import StdoutSink, FileSink, MultiSink
# development: stderr and a file
install_baton(mcp, VendorConfig(
vendor_id="acme-kb",
vendor_display_name="Acme Knowledge Base",
sink=MultiSink([StdoutSink(), FileSink("./events.jsonl")]),
))
Turning capture off
Set BATON_DISABLED=1 in the environment of the process that runs the server, and the SDK installs nothing: no wrapped tools, no annotation tool, no change to the server instructions, no collector connection, no background thread. The server runs as it would without the install_baton line. The SDK reads the variable once at startup, never writes to stdout, and never stops the server from starting, even with a config it would otherwise refuse. Any value other than empty, 0, false, no or off turns it on.
The switch belongs to whoever runs the server.
- A server you distribute runs on your user's machine, so the switch is theirs. It goes in the
envblock for your server in their MCP client's config file, not in their shell: the client passes the server a fixed allowlist of environment variables, and an exportedBATON_DISABLEDis not on it. - A server you host runs on your machine, so the switch is yours. Your users cannot set it, and there is no per-user opt-out.
{
"mcpServers": {
"acme-knowledge-base": {
"command": "acme-kb-mcp",
"env": { "BATON_DISABLED": "1" }
}
}
}
Library API
For vendors whose customers reach the API via agent-generated code (Skills pattern). Here Baton lives in the Skill code rather than in a server, so it fits vendors who have no MCP server at all.
The following diagram illustrates where the SDK sits on the library path:
- Your API is untouched. Baton lives in the Skill that calls it, not behind it, so nothing ships on the API side.
- One
client.trace(...)per call, inside oneclient.session(...)per request, is the whole integration; the events it emits are the same envelope the MCP path emits. - Use this path when you ship a Skill rather than an MCP server.
Sync (Client)
from baton import Client, Principal
client = Client(dsn="https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...")
with client.session(
session_id=request.conversation_id, # optional: your own id for the conversation
principal=Principal(principal_id=user.id), # optional: who is calling
client_info={"name": "claude-code", "version": "2.1"}, # optional: which app is calling
headers=request.headers, # optional: only User-Agent and X-Anthropic-Client are kept
):
with client.trace(
tool_name="chat.completions.create",
intent="summarize the user's question",
expected_outcome="2-3 sentence answer",
) as trace:
response = vendor_client.chat.completions.create(...)
trace.observed(response)
client.annotate(what_happened="asked for a CSV export; no endpoint returns one", tool_name="none")
client.close() # or: with Client(dsn=...) as client:
Open one client.session(...) per request or conversation. Every trace() and annotate() inside it shares its session, principal and client. A call outside any block gets a session of its own, so it does not join the others. Pass client_info or the request's headers when your request says which app the agent runs in; with neither, your dashboard shows "app not reported".
Async (AsyncClient)
from baton import AsyncClient
async with AsyncClient(dsn="https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...") as client:
with client.session(session_id=request.conversation_id):
async with client.trace(tool_name="...", intent="...") as trace:
response = await vendor_client.chat.completions.create(...)
trace.observed(response)
Both take the same keyword arguments: dsn, or vendor_id, tenant_id and sink without one. Both read BATON_DSN when no DSN is passed. On both, session() is a plain with block.
trace.annotate()
with client.trace(tool_name="search", intent="find docs for X") as trace:
try:
result = search_api.query(...)
trace.observed(result)
except ApiError as e:
trace.observed(error=e)
trace.annotate(what_happened="search returned 429 with no retry-after", tool_name="search", suggested_improvement="add retry-after on 429s")
A report says what went wrong in its own words. The collector assigns its group; there is no category to pick. Name the tool the report is about, or pass tool_name="none" when no tool exists for what was asked.
Choosing paths
| Concern | MCP integration | Library API |
|---|---|---|
| Instrumentation lives | Vendor side (MCP server) | Agent side (agent-generated code) |
| Setup | 2 lines in your MCP server | Vendor publishes a Baton-aware Skill |
| Reliability | Deterministic. Every tool call | Soft. Depends on agent following Skill |
| Annotation surface | MCP tool with MUST/REQUIRED framing | Python calls (trace.annotate(…)) |
TypeScript
The same SDK for Node. @goodtiming/baton-sdk emits the same envelope as the Python package to the same sinks, so a workspace can run both and the Console never learns which one produced an event. Node 20+.
npm install @goodtiming/baton-sdk
Your MCP server is the argument, as in Python. Both major versions of the official TypeScript MCP SDK are supported and both are optional peer dependencies, so you install whichever one you already use, either @modelcontextprotocol/sdk 1.x or @modelcontextprotocol/server 2.x, and nothing else.
import { McpServer } from "@modelcontextprotocol/server";
import { withBaton } from "@goodtiming/baton-sdk";
const server = new McpServer({ name: "acme-knowledge-base", version: "1.0.0" });
withBaton(server, {
dsn: "https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...",
});
Config fields are the Python names in camelCase (vendorId, tenantId, vendorDisplayName, intentParamMode, scrubber), and a dsn replaces the first three exactly as it does in Python. Tools registered before or after withBaton are both captured, and so are tools added or removed later.
Identity works as in Python: pass resolvePrincipal, which returns { principalId, displayName?, form? } or null. principalFromOAuthEmail and principalFromOAuthSub ship ready-made and read the token's claims from authInfo.extra. See Identity.
import { withBaton, principalFromOAuthEmail } from "@goodtiming/baton-sdk";
withBaton(server, {
dsn: "https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...",
resolvePrincipal: principalFromOAuthEmail,
});
Without MCP
The Library API is here too, as Client. The block and the call take callbacks instead of with: client.session(options, fn) and client.trace(options, fn). The option names are the Python names in camelCase. Put one trace() where every tool call passes through, such as a middleware, and every tool is covered.
import { Client } from "@goodtiming/baton-sdk";
const baton = new Client({ dsn: process.env.BATON_DSN });
app.use((req, res, next) =>
baton.session({ sessionId: req.get("x-conversation-id"), headers: req.headers }, () =>
baton.trace({ toolName: req.path, params: req.body }, () => next()),
),
);
A callback can be sync or async. Its return value is recorded as the result, and a throw is recorded and passed on unchanged. client.wrap(toolName, fn) traces a function on every call, for code with no single place to put trace().
What it captures
The same three intent parameters, the same annotation tool, the same surface_snapshot, and the same events. intentParamMode defaults to "required", which advertises all three in each tool's required list and never enforces them. Measured identical to the Python SDK and the proxy on the currently published versions.
PII scrubbing is on by default here too, a rule-for-rule port of Python's ruleset. Pass identityScrub to opt out, or your own (value: unknown) => unknown. Read what it does and does not catch before relying on it.
What is not here yet
Stated so you find out now rather than later. None of it blocks the paths above.
- Only
StdoutSinkandHttpSink.FileSinkandMultiSinkare deferred; the Python package has all four. - Only the high-level
McpServer. The low-levelServeris not wrapped. - Intent parameters need a Zod schema. On the 2.x SDK a tool registered with a non-Zod standard schema is still wrapped and still emits
tool_call_*; it just advertises no intent parameters. - The per-request
createMcpHandlershape is unscoped. It builds a fresh server per request, which would make the session id and the snapshot dedup request-lifetime state.
Wire compatibility
This is a tested property, not an intention. Both SDKs are checked against baton-spec, a neutral schema repo: every event type is validated against the shared JSON Schema, and the TypeScript emitter is diffed field-by-field against captured Python output for the same scenario. Envelope and payload keys must match exactly; values match except for ids, timestamps, timings and the genuinely language-specific fields.
PII scrubbing
Scrubbing is on by default. baton.scrub.Scrubber walks every event payload before it reaches the sink and redacts email addresses, Bearer tokens of 16 or more characters, sk- keys, AKIA access keys, JWTs, North American phone numbers, and card numbers that pass a Luhn check. It also redacts the whole value of any field named email, phone, ssn, api_key, token, secret, password or user_name.
It matches patterns. It does not find every piece of personal data. {"name": "Jane Doe", "address": "12 Elm St"} passes through unchanged, and a card number written with spaces or hyphens is not caught. Decide what your tools put in their arguments and results with that in mind.
To turn it off:
from baton import install_baton, VendorConfig from baton.scrub import identity_scrub install_baton(mcp, VendorConfig(dsn="https://baton_pk_...@baton.goodtiming.ai/ten_.../srv-...", scrubber=identity_scrub))
capture a whole fleet at once
Overview
For fleets of MCP servers. The other two paths instrument one server at a time: the SDK goes inside a server you own, the proxy wraps a server you do not. This one captures at the gateway itself, so every server behind it is covered at once, and neither your clients nor your backend servers change.
Baton runs as a processor beside your gateway, on the gateway's external-processor hook for MCP. Traffic still flows client to gateway to server. Baton sees the MCP messages as they pass and never becomes a hop in the path: it is not a subprocess wrap and not an HTTP bridge.
The following diagram illustrates where Baton sits relative to your gateway:
- Your gateway keeps the traffic. Client to gateway to server is unchanged; Baton is not inserted between any two of them.
- The hook hands Baton a copy. The external-processor call is what Baton sees. Configure that hook fail-open and a Baton outage costs you the event, not the call.
- Every server behind the gateway is covered at once, and none of them ships a change.
This is the path that reaches surfaces with no code to instrument. A client behind the gateway is captured without anyone shipping a change to it.
Quickstart
There is no package to install yet. The proxy and the SDK are published and you can start with either one today; the gateway processor ships with a runbook for your platform team instead, and we set it up with you. Talk to us and we will size it against your gateway.
Configuration
Two pieces: one hook in your gateway's own policy, pointed at the processor and set to see both the request and the response of each tool call along with its session id, and the same handful of environment variables the proxy reads.
| Variable | Purpose |
|---|---|
| BATON_VENDOR_ID | Labels the captured signal. |
| BATON_EVENT_SINK | Where events go: the Console over https, your own object store, a file, or stderr. Comma separate to fan out. |
| BATON_API_KEY | Bearer token for the Console sink, from your secret store. |
| BATON_CONSENT_TOKEN | Per install consent. The processor refuses to ship anywhere remote while this is still the placeholder. |
Nothing has to leave your walls. Point the sink at your own storage and the Console never sees an event.
Identity
This path resolves the caller from a request header, and which header is configuration rather than code. The value is hashed inside the processor and the raw identity never leaves your network, exactly as on the other two paths.
It needs two settings, and both matter: the header, which your gateway must pass on every call, and the hashing key, BATON_PRINCIPAL_ID_HMAC_KEY. A call without the header is captured with no caller, and nothing reports it. Without the key, the processor logs one warning and sends every event with no caller.
What it captures
Two parameters are added to the tool list the gateway returns: the agent's goal, and the result it expects. The agent fills them in on the next call. Baton reads them off that call and strips them before the request reaches your server, so your tool schemas and your handlers are unchanged and your customers never see them.
| MCP message | What Baton does |
|---|---|
| tools/list | Adds the two intent parameters to the response. |
| tools/call request | Reads the values off the request, strips them, emits tool_call_start. |
| tools/call response | Pairs the result with its call and emits tool_call_end or tool_call_error, with the duration. |
The events are the same wire format the other two paths emit, so one Console reads all three. A response carries no request id, so the gateway passes the session id to the processor on both halves of each call, and results pair within a session in the order calls were made. Pairing is exact while a session's calls complete in order. Calls running at the same time inside one session can pair with the wrong request, and a call with no session id gets no result event.
Scrubbing runs in the processor before any sink sees an event, with the same patterns as the SDK.
Trust & security
sample friction yourself
Overview
baton-proxy wraps an MCP server and captures friction events. From Claude's perspective it is the MCP server; the real server is either its child process or the endpoint it forwards to. Your server receives the arguments it would have received unwrapped: the proxy adds three intent parameters to each tool's advertised schema and strips them from every call before forwarding.
Two upstream forms. A stdio server is named after -- and run as a subprocess; a remote Streamable-HTTP server is named with --url, with BATON_UPSTREAM_AUTH_TOKEN sent upstream as a bearer token. They are mutually exclusive.
uvx baton-proxy -- python server.py # stdio, as a subprocess uvx baton-proxy --url "https://mcp.example.com/mcp" # remote, as a bridge
uvx (no install) or pipx · scan needs a server already configured in Claude (~/.claude.json).The following diagram illustrates how the proxy sits between Claude and your server:
- The proxy is the server, as far as Claude is concerned. Your real server runs as its child process and receives the arguments it would have received unwrapped.
- Green marks what Baton owns, here the proxy and the Console. Everything neutral is yours or your client's, and none of it changes.
- Three sinks, one event. The same envelope goes to stderr, a file, and an
https://collector; which ones are live is configuration.
Install
For a one-off scan, install nothing: uvx runs it on demand.
uvx baton-proxy scan --config <server-name>
To wrap a server permanently in Claude's config, put baton-proxy on your PATH so the config can invoke it directly:
pipx install baton-proxy
Plain pip install baton-proxy works if you manage your own Python env. Requires Python 3.11+. Currently 0.x. The wire format is stable and additive-only until v1.0; see the releases.
Quickstart
Replace your MCP server entry in Claude's config. The only change is wrapping your existing command:
// Before
{ "command": "npx", "args": ["@vendor/mcp-server"] }
// After. Events go to stderr + /tmp/baton-proxy.jsonl
{ "command": "baton-proxy", "args": ["--", "npx", "@vendor/mcp-server"] }
Restart Claude and drive a few tool calls. Then see what happened:
// In conversation, just ask Claude. It is told where the file is,
// and every line carries a session_id it can filter on:
"What went wrong in this session?"
// Or inspect raw:
cat /tmp/baton-proxy.jsonl | jq -c '{type: .event_type, payload}'
To ship events to a Console, add four env vars:
{
"command": "baton-proxy",
"args": ["--", "npx", "@vendor/mcp-server"],
"env": {
"BATON_EVENT_SINK": "https://console.example.com",
"BATON_TENANT_ID": "your-tenant",
"BATON_API_KEY": "bk_live_...",
"BATON_CONSENT_TOKEN": "customer-consented"
}
}
scan
Preview friction on a server you already have configured, with no permanent install and no change to your Claude config.
uvx baton-proxy scan --config my-server
scan reads the named entry from ~/.claude.json, reuses its credentials, drives a headless agent, and writes ./baton-report.md. Events are written to a temp file and deleted after the report, so nothing leaves your machine.
| Flag | Description |
|---|---|
| --config <name> | Entry name in ~/.claude.json (required). |
| --config-file <path> | Use a specific config file. |
| --timeout <seconds> | Bound the run (default: 300s). |
| --out <path> | Report output path (default: ./baton-report.md). |
Configuration
All knobs are environment variables. Every one has a default, so no config file is needed.
| Variable | Default | Purpose |
|---|---|---|
| BATON_EVENT_SINK | stderr:,file:///tmp/baton-proxy.jsonl | Where events go. Comma-separated for fan-out. https://… POSTs to {url}/v0/events, file:///path appends JSONL, stderr: writes to stderr. |
| BATON_TENANT_ID | local | Tenant identifier sent on every event. |
| BATON_CONSENT_TOKEN | local | Per-process consent token. Must be replaced before using an https:// sink. |
| BATON_API_KEY | unset | Bearer token. Required when sink is https://. |
| BATON_VENDOR_ID | local | Labels the install for the operator. A placeholder: a remote sink refuses to start while it is still local, because the Console buckets friction by vendor. |
| BATON_INTENT_PARAM | required | Injection mode for the intent parameters. required lists all three in the advertised required set; optional does not. That is an advertisement only: nothing validates them and no call fails for omitting one. |
| BATON_PRINCIPAL_ID_HMAC_KEY | unset | Per-tenant secret keying the principal_id hash. Named BATON_USER_ID_HMAC_KEY before 0.6.8, and no longer read under that name. An install still setting the old one logs a startup warning and captures no principal_id. Hashed at the edge, so no sink sees a raw principal. Unset means principal_id is skipped and events still emit. Generate it with openssl rand -hex 32. The input space is emails and user ids, small and guessable, so a memorable key is not a weaker pseudonym but none at all: it can be dictionary-attacked back to raw identities. |
| BATON_UPSTREAM_AUTH_TOKEN | unset | Credential for the --url bridge, sent upstream as Authorization: Bearer. Ignored by the stdio form. |
| BATON_UPSTREAM_TIMEOUT | 60 | Read timeout in seconds for the --url bridge. |
| BATON_PROXY_LOG_FILE | unset | Path to tee proxy logs to. |
The three rungs
| Rung | Sink | Env additions |
|---|---|---|
| 1. Default | stderr + /tmp/baton-proxy.jsonl | none |
| 2. Custom local | Your path | BATON_EVENT_SINK=file:///your/path.jsonl |
| 3. Ship to Console | Hosted | BATON_EVENT_SINK + BATON_API_KEY + BATON_TENANT_ID + BATON_CONSENT_TOKEN + BATON_VENDOR_ID |
https:// sink is configured but BATON_API_KEY is unset, or BATON_CONSENT_TOKEN or BATON_VENDOR_ID is still "local".Remote servers
The proxy also fronts a remote HTTPS MCP server over the Streamable HTTP transport, instead of spawning a subprocess. It stays stdio-facing to Claude and forwards each message as an HTTP POST, streaming the JSON or SSE response back. Injection, emission and correlation are the same code as the subprocess path.
{
"command": "baton-proxy",
"args": ["--url", "https://mcp.vendor.example.com/mcp"],
"env": { "BATON_UPSTREAM_AUTH_TOKEN": "..." }
}
--url and the -- <command> form are mutually exclusive.
baton_annotate stays usable instead of the session wedging.Injected tools
The proxy adds baton_annotate to the upstream server's tool list during the MCP handshake. The upstream server never sees it. When the proxy writes events to a local file, the instructions it appends name that file's path, so Claude can read it and explain what went wrong.
baton_annotate
Called by Claude (unprompted) when a call goes wrong or no tool fits the request. Claude says what happened in its own words; there is no category to pick. Emits an annotation event with what_happened, tool_name (or none when no tool exists), intent (the user's goal), and optionally suggested_improvement, expected_outcome, workflow and context. Available in all sink modes.
What it captures
Per real tool call, three event types are emitted matching the Baton wire format:
| Event | Payload fields |
|---|---|
| tool_call_start | tool_name, params |
| tool_call_end | tool_name, result, duration_ms |
| tool_call_error | tool_name, error_type, error_body, duration_ms, and result — the full response body, on the shape below |
Each line in the events file is one JSON object, { event_type, payload }. A failure the server returned rather than raised — MCP files one as a successful 200 whose body sets isError — lands like this:
{"event_type":"tool_call_start","payload":{"tool_name":"run_sql","params":{"statement":"ALTER TABLE orders ADD CONSTRAINT fk_customer FOREIGN KEY (customer_id) REFERENCES customers(id)"}}}
{"event_type":"tool_call_error","payload":{"tool_name":"run_sql","error_type":"tool_error","error_body":"foreign keys are not supported","result":{"content":[{"type":"text","text":"foreign keys are not supported"}],"isError":true},"duration_ms":412}}
{"event_type":"annotation","payload":{"what_happened":"asked run_sql to add a foreign key; it answered that foreign keys are not supported","intent":"add a foreign key so an order can't reference a missing customer","suggested_improvement":"list unsupported DDL in the tool description","tool_name":"run_sql"}}
error_type is the literal tool_error rather than an exception class, because there is no exception; result carries the whole response body, because the reason lives inside it and a flat error_body string would be all that survived. Proxy 0.6.11 and later; the Python SDK from 0.8.10 and the TypeScript SDK from 0.4.0 classify the same shape the same way. The gateway path also files it as tool_call_error, and is the one path that does not carry result.Intent parameters
This is how the proxy captures why a call was made, not just what happened. At tools/list it adds three string parameters to every upstream tool's advertised schema. The agent fills them in as it composes each call, and the proxy strips them at tools/call before forwarding.
| Parameter | Captured as |
|---|---|
user_goal | tool_call_start.payload.call_intent |
expected_result | tool_call_start.payload.call_expected, omitted when the agent did not fill it in |
overall_task | tool_call_start.payload.call_workflow. The number of the user's current message, a colon, then the task label (3: prepare campaign approval). The label is only useful when the agent repeats it across a task |
In the default mode, all three are advertised in each tool's required list. That is advertisement only: a call that omits one is served exactly as it would be unwrapped, and the matching field is simply null. From proxy 0.6.18, a wrapped tool's schema lists ["customer", "user_goal", "expected_result", "overall_task"] as required where the unwrapped one listed ["customer"].
A parameter description reaches the model at the moment it composes the call, on every client. Some clients drop InitializeResult.instructions entirely, so instructions alone are not a reliable channel. A tool that already declares a parameter of that name is left alone: its value is forwarded untouched and never read as intent.
Trust & security
- Open source, Apache 2.0. github.com/good-timing/baton-proxy
- Fail-open. A Console outage never breaks the MCP pipe. Emission is enqueued on a background thread.
- Outbound-only. The proxy never accepts inbound connections.
- No external dependencies. Pure stdlib: no pydantic, no httpx.
- Credential isolation.
BATON_*vars are filtered from the upstream subprocess env. - Scrubbed on your machine. Same rules as the SDK, applied before any event leaves. PII scrubbing →
turn the signal into a roadmap
Overview
The proxy and the SDK capture friction one server at a time. The Baton Console is the hosted service that collects those events across every customer and every session and turns them into something you can act on. It does what a single local report can't: it shows the friction across your whole customer base, ranked, with the evidence behind every number one click away.
- Many customers, one view. Sessions arrive from every customer running your server and are grouped together, so what you read is demand across your base, not one transcript at a time.
- Every output is text you copy. Baton drafts the fix and hands it to you. It does not open tickets or reach back into a customer's agent. Nothing ships without you.
What you see
Four tabs, one filter bar across all of them. Every number is a link: click it and you land on the sessions behind it with the filter already applied.
The Overview tab
How agents experience your server, on one screen. Four stat tiles, picked from what your traffic has: sessions, tasks, identified users, the share of tool calls that returned an error, sessions containing a failure, and reports filed by agents.
Below them, four categories with a count each: recurring errors, token costs, high-friction sessions, and tools whose calls fail more than 20% of the time. Then the three most recent sessions worth a person's attention. A server card lists your tools, the surface hash and when it was last seen, and warns you if your server sends no initialize instructions, because then every agent connects with zero guidance from you.
The Insights tab
Five panels. Each is a list of things you could go and fix.
High-friction sessions
Four counts: the last tool call failed, a tool failed and never succeeded again in that task, any call failed at all, and sessions you rated low. A session can sit in more than one.
Recurring errors
Errors that repeat across sessions, grouped into one pattern per tool with ids and quoted values ignored. Each row names where the fix belongs: the tool description, the error message, the implementation, or account config.
Token costs
What your surface costs every session before anyone calls anything. Tool definitions that are never called, mandated fetches, and responses large enough to push earlier results out of the agent's context.
Tool error rates
Every tool ranked by how often its calls come back as errors, with p50 and p95 latency and the error codes, plus the tools nothing has called.
The fifth is Filed feedback: what agents told you they needed and could not get, in their own words, through the annotation tool. That lane only fills when the agent chooses to report, so it is usually the smallest of the five.
The Sessions tab
The session list, filtered and paged. Open one and you get its tasks, the calls inside each task, and two lanes of markers: what the agent filed, and what Baton detected. Click a marker and the call list narrows to exactly the calls it counted, so a number always shows its evidence.
Run Explain on a session and a model reads it and writes what the user was trying to do and how it went, including a satisfaction rating. You can flag a session to come back to, or exclude it from your numbers. Excluding one requires a written reason, so the next person to read the number can see why it moved.
The Live tab
Every event as it arrives, newest first, refreshed every couple of seconds. One row shape, no highlighting. This is the raw stream, for watching your own setup work while you drive an agent at it. Events appear within seconds of the call. Anything that interprets them lives on the other three tabs.
Access & security
The Console is hosted by us and auditable by you. Your data is single-tenant, and every event is logged and inspectable, so you can see exactly what each agent sent, when, and why. Scrubbing runs before anything leaves your machine: emails, phone numbers, card numbers, keys and tokens are redacted at the SDK, on by default. What you don't capture can never reach us. It is pattern matching, not a guarantee: free text inside a tool argument or result is sent as written, so decide what your tools hand back.
The Console is where you act on what the proxy and the SDK capture. Get started →