Framework guides
MCP servers
Instrument an MCP server so calling agents can record their intent and file actionable feedback on your tools.
@runotis/sdk/mcp instruments an MCP server with one call. It adds two things to every tool, both optional and non-breaking:
- Intent capture — an optional
_otisblock in each tool's input schema where the calling agent records what it is trying to do. - Tool feedback — every tool result carries a short ticket, and an injected
otis_feedbacktool lets the agent report concrete improvements to your tools (a misleading description, an ambiguous input, a missing field in the output). Feedback shows up in the Otis app against the exact call it refers to.
It works with the official MCP TypeScript SDK (@modelcontextprotocol/sdk in version 1, @modelcontextprotocol/server in version 2), @mastra/mcp, Vercel's mcp-handler, and hand-rolled servers, on both long-running and serverless runtimes. Supported servers has a setup snippet for each.
Install
npm install @runotis/sdkzod and your MCP server framework are optional peers — the SDK uses whatever you already have. Initialize Otis once at startup, as in the Node.js or Serverless guides:
import { initOtis } from "@runotis/sdk";
initOtis({ apiKey: process.env.OTIS_API_KEY, serviceName: "my-mcp-server" });No Ingest Key set? Instrumentation is a safe no-op. If OTIS_API_KEY is missing, initOtis disables itself (one startup warning, no crash) and instrumentMcpServer leaves your server exactly as written — no _otis block, no otis_feedback tool, no tickets, no spans, no network calls, no per-call logs. Your tools behave as if uninstrumented. So it is safe to add instrumentation before the key is provisioned.
Quick start
This example uses McpServer from version 1 of the official MCP TypeScript SDK (@modelcontextprotocol/sdk); in version 2, import it from @modelcontextprotocol/server. If you build your server with Mastra, Vercel's mcp-handler, the low-level Server class or your own tools map, follow its snippet in Supported servers instead. The call is the same, but where you place it differs.
Call instrumentMcpServer on your server, immediately after constructing it and before you register any tools. Pass your zod so the SDK can extend zod-based input schemas with a version-matched block.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
import { z } from "zod";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
instrumentMcpServer(server, { zod: z }); // ← before registerTool, not after
server.registerTool(
"search_docs",
{ description: "Search the docs", inputSchema: { query: z.string() } },
async ({ query }) => ({ content: [{ type: "text", text: await search(query) }] }),
);That is the whole integration. instrumentMcpServer resolves the Otis instance you created with initOtis() automatically; pass { otis } if you manage the instance yourself.
On the official McpServer, tools registered before the instrumentMcpServer call are not instrumented — no intent, no feedback ticket. The SDK logs a warning if it detects this, but the call still succeeds, so put it right after new McpServer(...). This ordering rule applies to the official McpServer, including the one Vercel's mcp-handler passes to your callback. A Mastra MCPServer or a low-level Server is instrumented after construction; see Supported servers.
The _otis input block
Each instrumented tool gains one optional, namespaced field. The calling agent fills it in; you never see it (it is stripped before your handler runs):
{
"query": "billing webhooks",
"_otis": {
"task_intent": "find the webhook signature header name",
"session_intent": "wire up Stripe webhooks end to end",
"chat_id": "conv_7fa3"
}
}task_intent— why the agent is calling this tool right now.session_intent— the user's broader goal. The most recent value wins.chat_id— a stable id for the current conversation/thread. When the agent sends the same value on every call in a conversation, Otis groups those calls into one session (one narrative per conversation) instead of blending every conversation a long-lived server connection happens to carry. See Sessions.session_id— optional: the agent's own session id, for stricter cross-call correlation. Usually unnecessary —chat_idalone is enough.
All four are optional. Your tool keeps working unchanged whether or not the agent provides them. Capture is opportunistic: agents that recognize the _otis block fill it in, agents that don't simply omit it — so treat these as bonus signals, not something present on every call.
When a run of calls carries no task_intent, Otis describes the work from the calls' inputs and outputs instead. Leave captureInput and captureOutput on (see Options) for the tools you want described this way. Intent explains how each summary is produced.
Tool feedback
Every tool result ends with a compact ticket:
⟦otis:tkt_a1b2c3d4e5f6a7b8⟧The injected otis_feedback tool lets the agent turn friction into a report tied to that ticket:
{
"ticket": "tkt_a1b2c3d4e5f6a7b8",
"category": "output",
"severity": "friction",
"comment": "return the document id so I can fetch it without a second search",
"suggested_change": "add an `id` field to each result"
}The full instruction for when and how to give feedback lives in the otis_feedback tool's own description, so it costs the agent nothing on a normal call. The ticket is also available on the result's _meta.otis.ticket for non-text clients.
Feedback inherits the session of the tool call it rates, including the conversation fold from that call's chat_id — so the report joins the same conversation's narrative as the tool call it refers to, not the blended base session.
On a server with only one tool, the feedback tool is suppressed by default so it does not crowd your tool list. Set feedback: true to force it, or feedbackMinTools to change the threshold. When the feedback tool is suppressed, tool results carry no ticket either.
Errors and failures
A tool fails in one of two ways, and both are recorded the same way on the mcp.tool.<name> span — no extra wiring:
- Throwing — the handler rejects/throws. The exception is attached to the span and the status is set to
ERROR. - In-band error — the handler resolves with
isError: true(the MCP convention for a tool-level failure, e.g. an upstream 503). The SDK detects the flag, sets the span status toERROR, sets anerrorattribute, and records the failure cause.
The span records the failure in these attributes:
| What | Where |
|---|---|
| Failure cause (text) | exception.message. For a throw, the error's message. For an in-band error, the result's content text blocks joined together. Both are scrubbed for PII. |
| Error type | exception.type and error.type. For a throw, the error's name. For an in-band error, structuredContent.name, or structuredContent.error when there is no name. A type longer than 64 characters, or with characters other than letters, digits, _, ., : and -, is recorded as McpToolError, and so is a failure with no type. |
| Error code | mcp.tool.error_code, from structuredContent.code (a string or a number). In-band errors only. |
To get a useful type and code rather than a generic flag, return them on structuredContent. Put a short type name such as UpstreamError in name. A sentence placed in error is recorded as McpToolError.
{
"isError": true,
"content": [{ "type": "text", "text": "upstream 503: service unavailable" }],
"structuredContent": { "name": "UpstreamError", "code": "E_503" }
}Failures roll up into the task outcome: a tool error drives the task to struggled in analytics, the same as explicit negative feedback. The host result is always passed through untouched — instrumentation never changes what your tool returns to the caller.
The calling host and the tool surface
On a server you pass to instrumentMcpServer, every mcp.tool.<name> span and each feedback.tool span also records which client is calling and which version of your tools it is calling into:
| What | Where | Value |
|---|---|---|
| Calling host | mcp.client.name, mcp.client.version | The clientInfo the host sent, verbatim: claude-code, cursor-vscode, Visual Studio Code, openai-mcp (ChatGPT), ... |
| Host, normalized | otis.mcp.client | The product the name maps to (claude-code, cursor, vscode, chatgpt, codex, claude-desktop, gemini-cli, windsurf, ...), for grouping. A name Otis does not know passes through unchanged, so a new host shows up under its own name. |
| Calling host's trace | mcp.client.trace_id | The trace id of the traceparent the host sent in the request's _meta (W3C Trace Context, MCP SEP-414). Only a host that propagates trace context sends one. It is an attribute, not the span's parent, so the call is still a root in Otis and the host's trace is never adopted. |
| Tool-surface version | mcp.tool_surface.hash | SHA-256 over the tool list your server serves, as you wrote it: each tool's name, description and input schema, sorted by name. The _otis block and the otis_feedback tool the SDK adds are stripped first, so an Otis SDK upgrade never changes the hash and a description or schema edit always does. |
The hash is computed from the tools/list response, so it needs a server to list from. Spans from instrumentTools and instrumentTool carry no hash. An instrumented server that has not served a list by the time a call arrives lists its own tools first, so every call carries the hash, including on a server built per request. One hash per instance: a server that serves a different list per caller (filtered by auth, or tools enabled per session) records whichever caller listed last. On an McpServer the schema hashed is the MCP SDK's JSON-Schema rendering of your zod shape, so an upgrade that changes that rendering (an MCP SDK release, including the move from version 1 to version 2, or a zod major) can move the hash without an edit; a low-level Server serves the JSON Schema you wrote. A title, annotations or outputSchema edit does not move it. For a tool that takes no input, turning the intent option on or off, or starting or stopping passing zod, also moves the hash. Versions are kept per surface, so give each server its own surface when several run in one service, or their histories interleave.
A host that speaks MCP revision 2026-07-28 names itself on every request, and the SDK reads the host from there. A host on an earlier revision names itself once, in the initialize handshake, so its calls carry no host in two cases: a stateless HTTP server that builds a fresh instance per request (the common mcp-handler pattern) usually never sees initialize on the instance that serves the call; and instrumentTools / instrumentTool take no server to read the handshake from.
Supported servers
instrumentMcpServer detects which kind of server you pass it. Where you place the call depends on the package your server is built with. Find your server in the table, then follow its snippet.
| Server | Package | Where to call it |
|---|---|---|
McpServer | @modelcontextprotocol/sdk (version 1) or @modelcontextprotocol/server (version 2) | Right after new McpServer(...), before you register tools |
Low-level Server | @modelcontextprotocol/sdk (version 1) or @modelcontextprotocol/server (version 2) | After you set the tools/list and tools/call handlers |
createMcpHandler | @modelcontextprotocol/server (version 2) | In your factory, right after new McpServer(...) |
createMcpHandler | mcp-handler | On the first line of the builder callback |
MCPServer | @mastra/mcp | After construction, before you start the server |
| Your own tools map | None | Call instrumentTools on the map instead |
Pass your zod as { zod: z } when you instrument an McpServer, including the one mcp-handler passes to your callback, or a tools map whose input schemas are zod. The SDK uses it to add the _otis block to each zod schema and to each tool registered with no input schema. A Mastra MCPServer and a low-level Server need no zod, even when you write your tools with zod. On those servers the SDK adds the block to the JSON Schema that the server sends to clients. In version 2 of the MCP SDK, a tool whose input is JSON Schema wrapped in fromJsonSchema(...) needs no zod either. Version 2 of the MCP SDK needs zod 4.2 or later, so pass that z.
A tool whose input schema the SDK can't extend, such as a zod union, keeps working but records no intent, and the SDK logs one warning. Declare the tool's input as an object schema to capture its intent.
Official SDK: McpServer
McpServer is the high-level server class in the official MCP TypeScript SDK, imported from @modelcontextprotocol/sdk/server/mcp.js in version 1 and from @modelcontextprotocol/server in version 2. Call instrumentMcpServer right after constructing it. Tools registered with registerTool are instrumented, and so are tools registered with the older server.tool(...) form, which only version 1 has. In version 2, a tool whose input is JSON Schema wrapped in fromJsonSchema(...) gets the _otis block too, with or without zod. Your schema still validates each call: the SDK lists _otis beside your own properties and checks the arguments against your schema with _otis removed. A schema with additionalProperties: false therefore keeps accepting calls that carry the block.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
import { z } from "zod";
const server = new McpServer({ name: "my-server", version: "1.0.0" });
instrumentMcpServer(server, { zod: z });
server.registerTool(
"get_weather",
{ description: "Weather for a city", inputSchema: { city: z.string() } },
async ({ city }) => ({ content: [{ type: "text", text: await weather(city) }] }),
);
// The older server.tool(...) form is instrumented the same way.
server.tool("echo", "Echo back", { text: z.string() }, async ({ text }) => ({
content: [{ type: "text", text }],
}));Tools with an outputSchema
Structured-output tools work unchanged. The ticket rides in _meta and as an extra content block; your structuredContent and outputSchema are never touched.
server.registerTool(
"lookup",
{
description: "Look up a record",
inputSchema: { id: z.string() },
outputSchema: { name: z.string() },
},
async ({ id }) => {
const name = await lookup(id);
return { content: [{ type: "text", text: name }], structuredContent: { name } };
},
);Official SDK: low-level Server
Server is the low-level server class in the same official SDK, imported from @modelcontextprotocol/sdk/server/index.js. On a Server you write the tools/list and tools/call handlers yourself with setRequestHandler. Call instrumentMcpServer after you set both handlers, because it wraps the handlers that exist at the time of the call. Called before the handlers exist, it instruments nothing and logs no warning. Clients then see no _otis block and no otis_feedback tool, and no tool-call spans are recorded.
Tools on a low-level Server declare their input as JSON Schema, so this server needs no zod. In version 2, import Server from @modelcontextprotocol/server, and name each method instead of passing its schema: server.setRequestHandler("tools/list", ...) and server.setRequestHandler("tools/call", ...).
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
const server = new Server({ name: "my-server", version: "1.0.0" }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "echo",
description: "Echo back",
inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => ({
content: [{ type: "text", text: String(request.params.arguments?.text) }],
}));
instrumentMcpServer(server); // ← after both handlers, not beforeOfficial SDK version 2: createMcpHandler
Version 2 of the official SDK has its own createMcpHandler, which calls a factory you write once for each request. Build the McpServer in the factory and instrument it right after constructing it, as for any McpServer. On serverless, set flush: "await" (see Serverless flush). The handler does not verify tokens: verify the request yourself and pass the result as authInfo, with the end-user id on authInfo.extra.userId, which the SDK maps to user.id.
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
import { z } from "zod";
const handler = createMcpHandler(() => {
const server = new McpServer({ name: "my-server", version: "1.0.0" });
instrumentMcpServer(server, { zod: z, flush: "await" }); // ← before registerTool
server.registerTool(
"roll",
{ description: "Roll an N-sided die", inputSchema: { sides: z.number() } },
async ({ sides }) => ({
content: [{ type: "text", text: String(1 + Math.floor(Math.random() * sides)) }],
}),
);
return server;
});
export async function POST(request: Request) {
const user = await verify(request);
const authInfo = { token: user.token, clientId: user.appId, scopes: [], extra: { userId: user.id } };
return handler.fetch(request, { authInfo });
}Vercel mcp-handler (serverless)
createMcpHandler from Vercel's mcp-handler package passes an official McpServer to your builder callback, so the McpServer ordering rule applies. Version 2 of mcp-handler builds on version 2 of the MCP SDK, so register tools there with registerTool; the older server.tool(...) form in the snippet below is version 1 only. Instrument as the first line of the builder callback. On serverless, set flush: "await" so each call's spans are exported before the function freezes (no waitUntil needed — see Serverless flush). For user attribution, put your stable user id on authInfo.extra.userId in your withMcpAuth verifier; the SDK maps it to user.id automatically (see Sessions & identity). To group calls into sessions, send a correlation header or supply resolveSession.
import { createMcpHandler, withMcpAuth } from "mcp-handler";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
import { z } from "zod";
const handler = createMcpHandler((server) => {
instrumentMcpServer(server, { zod: z, flush: "await" });
server.tool("roll", "Roll an N-sided die", { sides: z.number() }, async ({ sides }) => ({
content: [{ type: "text", text: String(1 + Math.floor(Math.random() * sides)) }],
}));
});
// Put the END-USER id on authInfo.extra.userId → it becomes user.id (not clientId,
// which is the OAuth app, shared across users and used only as a coarse session).
const authed = withMcpAuth(handler, async (_req, token) => {
const user = await verify(token);
return { token, clientId: user.appId, scopes: [], extra: { userId: user.id } };
});
export { authed as GET, authed as POST, authed as DELETE };Mastra MCPServer
MCPServer comes from Mastra's @mastra/mcp package. Mastra registers tools in the constructor (there is no registerTool), so here you instrument the already-constructed server — the rule is before you start it, not before tools exist. Pass the MCPServer instance itself, not its internal .server; the SDK reaches the wire handlers for you, so structured output and the rest of mastra's behavior are preserved. Use instrumentMcpServer, not instrumentTools — your tools live inside an MCPServer.
import { MCPServer } from "@mastra/mcp";
import { createTool } from "@mastra/core/tools";
import { instrumentMcpServer } from "@runotis/sdk/mcp";
import { z } from "zod";
const server = new MCPServer({
id: "my-server",
name: "my-server",
version: "1.0.0",
tools: {
add: createTool({
id: "add",
description: "Add two numbers",
inputSchema: z.object({ a: z.number(), b: z.number() }),
// Tools return mastra's normal arbitrary shape — the SDK serializes it
// and attaches the ticket without you changing any return values.
execute: async ({ a, b }) => ({ sum: a + b }),
}),
},
});
instrumentMcpServer(server); // no zod needed: the SDK extends the JSON Schema mastra sends
await server.startStdio(); // or startSSE(...) / startHTTP(...) — instrument before thisHand-rolled tools map
If you dispatch tools from your own map, with no McpServer, MCPServer or Server object, instrument the map with instrumentTools. It returns a new map in which each tool carries the _otis block and result tickets, plus the injected otis_feedback tool. Serve your tool list and tool calls from the returned map, not the original. If your map lives inside an McpServer or a Mastra MCPServer, call instrumentMcpServer on the server instead.
import { instrumentTools } from "@runotis/sdk/mcp";
const tools = instrumentTools({
echo: {
name: "echo",
description: "Echo back",
inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
execute: async ({ text }) => ({ content: [{ type: "text", text: String(text) }] }),
},
}); // pass { zod: z } as the second argument if your schemas are zod
// Answer tools/list and tools/call from `tools`, not from the original map.Sessions & identity
For the general session model — what counts as a session, the (sessionId, chatId) grouping, and auth-provider pass-through — see Sessions. This section covers only what's specific to MCP.
user.id vs session.id
withMcpAuth verifies the end-user and exposes their id on authInfo.extra.userId. The SDK maps that to user.id (hashed) on every tool span — the source for user-level analytics (segmentation, retention, cohorts). It is not used as the session: keying the session on a user id would make session count ≈ user count. (clientId is the OAuth app, shared across users, so it never populates user.id.)
How the session id is resolved
Otis groups a server's calls into sessions. You do not have to wire anything — the SDK resolves a stable session id automatically, in this order:
resolveSession(extra), if you provide it.- the calling agent's own
session_id, when it sends one in the_otisblock. - a client correlation header (
X-Otis-Session-Id). - the verified OAuth client id (
authInfo.clientId). - the transport session id (
Mcp-Session-Id, on stateful HTTP). - a stable per-server-instance id, so a session is never missing.
The X-Otis-Session-Id header is the same cross-domain header documented in Sessions → Cross-domain APIs — a client already sending it for a browser surface works for MCP unchanged.
MCP has no browser working-session, so the clientId and per-instance fallbacks land in the Acceptable (coarser) tier of Choosing what to use as your sessionId: stable, but closer to per-app / per-instance than a real working session. For a true per-user or per-conversation session, supply one explicitly via a correlation header or resolveSession.
For a local / stdio server (one process per client) the automatic id is exactly right with no configuration. Each instrumented server instance gets its own session namespace, so running more than one MCP server in a codebase never makes them collide.
One server connection can carry many conversations. A long-lived stdio process, a warm serverless instance, or a single OAuth client can span several unrelated chats — all of which the rules above resolve to one session id, blending them into one narrative. When the calling agent passes a per-conversation chat_id, the SDK folds it into the session id so each conversation becomes its own session. Nothing extra to wire on your side — the agent supplies chat_id; the resolution chain above still picks the base.
resolveSession is the lever on tool spans
resolveSession (rank 1) is how you inject a custom session. An inherited withContext({ sessionId }) does not drive mcp.tool.* span sessions the way it does on ordinary spans. It is used only in place of the per-instance id (rank 6), and only when no chat ID applies, from the call's _otis block or from withContext({ chatId }). Every other source in the chain wins over it. To force a session on MCP tool spans, use resolveSession — it receives the per-call MCP extra and may return undefined to fall through to the chain above:
instrumentMcpServer(server, {
zod: z,
// e.g. a per-conversation id your client controls; return undefined to fall through.
// Version 1 of the MCP SDK hands over the headers as `requestInfo.headers`;
// version 2 hands over the Web `Request` as `http.req`.
resolveSession: (extra) =>
(extra?.requestInfo?.headers?.["x-conversation-id"] as string | undefined) ??
(extra?.http?.req?.headers?.get("x-conversation-id") ?? undefined),
});(Every other withContext field applies to tool spans: userId, chatId, documentId, artifactId, groups, agent and metadata. A userId set this way replaces the one from authInfo.extra.userId. Only sessionId follows the resolution chain above. A surface from withContext applies when instrumentMcpServer was given none. An agent set with initOtis({ agent }) or withContext applies to tool spans as it does to every other span.)
On stateless serverless the per-instance fallback is stable only within a warm instance — and if your handler rebuilds the server per request (the common createMcpHandler pattern), it is effectively per-call. The transport's Mcp-Session-Id is also not stable across cold invocations. So to group a user's calls at all, send a client correlation header (X-Otis-Session-Id) or supply resolveSession. (withMcpAuth restores user.id, but not the session.)
Serverless flush
On a runtime that freezes after responding, set flush: "await" so each call's telemetry is sent before the function suspends:
instrumentMcpServer(server, { zod: z, flush: "await" });With flush: "await" the SDK awaits the export inside each tool call, so you do not need waitUntil / ctx.waitUntil around an MCP route — that pattern is only for streaming AI-SDK responses. The trade-off is that the export round-trip is added to each tool response's latency. This single setting is the whole story on AWS Lambda and Cloudflare Workers too (neither needs waitUntil for MCP). On a long-running server the default ("auto") is correct — telemetry is sent in the background.
On Cloudflare Workers, enable nodejs_compat in wrangler.toml. @runotis/sdk/mcp relies on AsyncLocalStorage (node:async_hooks) for context propagation, which nodejs_compat provides. See the Serverless guide.
Options
| Option | Default | Purpose |
|---|---|---|
otis | the initOtis() instance | The Otis instance to use. |
zod | — | Your z, used to extend zod-based input schemas (v3 or v4). |
intent | true | Inject the _otis intent block. |
feedback | true | Inject the otis_feedback tool + result tickets. |
feedbackMinTools | 2 | Suppress the feedback tool below this many tools (unless feedback: true). |
resolveSession | — | Derive a stable session id from the call's extra. |
surface | the initOtis() default | Named origin for this server's spans (otis.surface → Surface column), to tell apart multiple MCP servers sharing one service — e.g. "docs-mcp" vs "admin-mcp". Keep it stable and low-cardinality. A value longer than 128 characters is ignored, and the surface from withContext or initOtis() applies instead. See Customization → Surface. |
exclude | [] | Tool names to leave completely untouched. |
captureInput | true | Record the tool's input arguments (otis.input). Set false for sensitive inputs. |
captureOutput | true | Record the tool's return value (otis.output). Set false for sensitive outputs. |
redact | true | Run captured input/output and intent text through client-side PII redaction. |
flush | "auto" | "await" on serverless; "auto" for long-running; "off" to manage flushing yourself. |
Privacy
A tool's input arguments and return value are captured (as otis.input / otis.output) so they're visible in the Otis app. Because that content can contain PII, the SDK applies your project's client-side PII redaction to it by default — the same regex layer that scrubs emails, phone numbers, cards, SSNs, JWTs, and API keys from AI prompts. The agent's task_intent / session_intent are scrubbed too. The redaction honors your initOtis({ piiRedaction }) config and is a no-op if you've disabled redaction.
Controls:
instrumentMcpServer(server, {
zod: z,
captureInput: false, // don't record arguments at all (e.g. a tool that takes credentials)
captureOutput: false, // don't record the return value
redact: true, // default — scrub captured I/O for PII; set false to record raw
});For all-or-nothing per tool, exclude: ["my_tool"] leaves a tool completely uninstrumented.
Tool I/O is redacted by both layers: the client-side regex (structured PII — emails, phones, cards, SSNs, keys) before it leaves your process, and the server-side ML pass (unstructured PII — names, addresses, organizations) in the collector. For tools that return free-form personal data you can still set captureOutput: false to record nothing at all. Identifier hashing of userId / sessionId applies as everywhere else.
Next steps
- Privacy — PII redaction and identifier hashing
- Feedback signals — feedback from your own UI
- Serverless functions — flush patterns for short-lived runtimes
- Node.js server — long-running server setup
Agent frameworks
Send agents built on the Claude Agent SDK, the OpenAI Agents SDK, Google ADK, Mastra or an OpenInference-instrumented framework to Otis.
CLI tools
Instrument a Node CLI so that, when a coding agent like Claude Code or Codex runs it, Otis records what the agent was trying to do and how the call went.