How I (ab)used OpenTelemetry to make Claude Code and Codex speak to me

I wanted my coding agents (Claude Code and Codex) to speak to me.

Not constantly. Not with a voice assistant persona. Just a short spoken summary when a completed turn arrived, so I could keep working without staring at the terminal.

Both Codex and Claude Code already emit useful telemetry. OpenTelemetry was already present in the workflow. The slightly unreasonable solution was to route that telemetry into a small local speech service.

The result looks like this:

The Collector remains the central routing point. Splunk’s existing exporters stay intact. Speech is an additional filtered branch.

Read more: How I (ab)used OpenTelemetry to make Claude Code and Codex speak to me

The basic idea

The speech bridge is a small FastAPI service.

It accepts authenticated OTLP/HTTP protobuf logs and traces, correlates them into completed agent turns, summarises the turn with Gemini, generates audio through Cartesia, writes the returned WAV bytes to a temporary file, and plays them sequentially with macOS afplay.

You can find the code on GitHub.

Getting events out of the agents

For Codex (as it doesn’t emit its response via OTEL), I used lifecycle hooks.

The prompt event is sent when a prompt is submitted:

printf '%s\n' \
  '{"session_id":"synthetic-codex","turn_id":"turn-1","prompt":"Add tests"}' \
  | python3 scripts/codex_otel_hook.py UserPromptSubmit

The final response is sent on stop:

printf '%s\n' \
  '{"session_id":"synthetic-codex","turn_id":"turn-1","last_assistant_message":"Tests added"}' \
  | python3 scripts/codex_otel_hook.py Stop

The hook does not send directly to the speech service. It sends unauthenticated OTLP to the local Collector:

http://127.0.0.1:4318/v1/logs

The Collector then routes the relevant records to the authenticated speech exporter.

For Claude Code, I used its prompt and assistant-response events together with enhanced OTLP traces. Claude’s event model is slightly different, so the speech bridge accepts both logs and traces.

The important point is that the agent does not need to know that speech exists. It just emits telemetry.

The Collector configuration

The agents do not send directly to the speech bridge. They send ordinary OTLP to the local OpenTelemetry Collector. The Collector keeps Splunk working as before, filters events by agent, and adds speech as a separate authenticated branch.

The configuration below is additive. It belongs in the existing Collector configuration (as described in Send Claude Code and Codex logs to Splunk); it is not a complete replacement for agent_config.yaml.

exporters:
  otlp_http/speech/codex:
    endpoint: http://host.docker.internal:17860
    headers:
      x-speech-ingest-token: "${SPEECH_INGEST_TOKEN}"
    sending_queue:
      enabled: true
      num_consumers: 1
      queue_size: 10000
    retry_on_failure:
      enabled: true
      initial_interval: 1s
      max_interval: 10s
      max_elapsed_time: 0s

  otlp_http/speech/claude:
    endpoint: http://host.docker.internal:17860
    headers:
      x-speech-ingest-token: "${SPEECH_INGEST_TOKEN}"
    sending_queue:
      enabled: true
      num_consumers: 1
      queue_size: 10000
    retry_on_failure:
      enabled: true
      initial_interval: 1s
      max_interval: 10s
      max_elapsed_time: 0s

processors:
  filter/drop_claude_logs:
    error_mode: ignore
    logs:
      log_record:
        - resource.attributes["service.name"] == "claude_code"
        - resource.attributes["service.name"] == "claude"
        - resource.attributes["service.name"] == "claude-code"

  filter/drop_non_claude_logs:
    error_mode: ignore
    logs:
      log_record:
        - resource.attributes["service.name"] != "claude_code" and resource.attributes["service.name"] != "claude" and resource.attributes["service.name"] != "claude-code"

  filter/drop_non_claude_traces:
    error_mode: ignore
    traces:
      span:
        - resource.attributes["service.name"] != "claude_code" and resource.attributes["service.name"] != "claude" and resource.attributes["service.name"] != "claude-code"

  batch/speech:
    timeout: 1s

service:
  pipelines:
    logs/codex:
      receivers: [otlp]
      processors: [filter/drop_claude_logs, transform/normalize, batch]
      exporters: [splunk_hec/codex, otlp_http/speech/codex]

    logs/claude:
      receivers: [otlp]
      processors: [filter/drop_non_claude_logs, transform/normalize, batch]
      exporters: [splunk_hec/claude, otlp_http/speech/claude]

    traces/claude:
      receivers: [otlp]
      processors: [filter/drop_non_claude_traces, batch/speech]
      exporters: [otlp_http/speech/claude]

The existing Collector already provides the otlp receiver, the Splunk exporters, transform/normalize, and the regular batch processor. This chapter only adds the speech-specific pieces.

What the pipelines do

The Codex pipeline drops known Claude service names, then sends the remaining logs to both Splunk and the Codex speech exporter. The Claude pipeline does the inverse. The accepted names are claude, claude_code, and claude-code because different Claude Code versions report different values.

The Claude trace pipeline applies the same filter to spans. Those traces help the bridge correlate a completed Claude turn. The separate speech exporters keep the Codex and Claude delivery queues independent.

The exporter endpoint is a base URL. OTLP/HTTP sends logs to /v1/logs and traces to /v1/traces. host.docker.internal is the Docker Desktop hostname that lets the Collector container reach the host-native FastAPI service.

Authentication and failure boundaries

The hooks send unauthenticated OTLP to the local Collector. The Collector adds x-speech-ingest-token when forwarding to the bridge. The token must be available inside the Collector container and must never be committed or printed.

services:
  splunk-otel:
    environment:
      - SPEECH_INGEST_TOKEN=${SPEECH_INGEST_TOKEN}

If the Collector cannot reach the bridge, its exporter queue retries delivery. If the bridge accepts the event but Gemini, Cartesia, or afplay fails, the speech worker increments failed, calls task_done(), and continues with the next FIFO item.

Validate before restarting

Validate the merged configuration before recreating the Collector:

docker compose --env-file /Users/ir/.config/otel2speech/collector.env -f /Users/ir/workspaces/splunk-pai-demo/docker/docker-compose.yaml config --quiet

docker compose --env-file /Users/ir/.config/otel2speech/collector.env -f /Users/ir/workspaces/splunk-pai-demo/docker/docker-compose.yaml up -d --force-recreate splunk-otel

The repository keeps this additive configuration in config/collector-speech-snippet.yaml. It is intentionally a snippet because the existing Collector configuration contains the Splunk receivers, exporters, and environment-specific settings.

Correlation is the real problem

Text-to-speech is easy.

Knowing when there is something worth speaking is harder.

Agent telemetry can arrive out of order. A response can arrive before the root trace. Multiple turns can be interleaved in the same session. There are also subagent and tool events that should not become spoken announcements.

The bridge therefore has a small in-memory correlator.

It tracks:

  • source;
  • session ID;
  • turn ID;
  • prompt;
  • final response;
  • trace and span identifiers;
  • timestamps;
  • whether the event belongs to the main agent.

A turn is emitted only when the required pieces are present. For Claude, the completed root span helps anchor the turn. For Codex, the prompt and final response lifecycle events provide the boundary.

The correlator also deduplicates events and filters out irrelevant agent activity.

One FIFO worker

Once a completed turn is emitted, it goes into one unbounded FIFO queue.

The worker processes exactly one turn at a time:

Sequential playback matters. If two agent turns complete close together, the audio should not overlap or race.

The worker also treats each turn as isolated work. A failure must not terminate the loop or prevent later turns from being spoken.

The statistics are deliberately simple:

  • enqueued: normalised turns accepted;
  • completed: successfully delivered turns, including dry-run;
  • failed: turns where generation or delivery failed;
  • last_source: source of the latest successful turn.

Every queue item calls task_done(), including failures.

Gemini is optional, but Cartesia is not

Gemini generates a short spoken summary of the completed turn.

The prompt asks for plain spoken language without Markdown or raw prompt quoting. It targets 20–30 words, but accepts any non-empty valid sentence within the configured maximum.

If Gemini is unavailable, lacks credentials, times out, or returns invalid output, the bridge uses a fixed fallback sentence:

Completed turn received and queued for speech; review the result when convenient, then continue with the next planned action safely.

That fallback is still sent through Gemini’s normal downstream path:

fallback sentence → Cartesia → afplay

This distinction matters. Gemini is a summary provider. It is not an audio backend.

Cartesia is the only speech synthesiser

The final design has one synthesis backend: Cartesia.

Codex and Claude retain distinct Cartesia voice IDs. Both use a Cartesia speed of 1.3 through SPEECH_RATE.

Cartesia returns audio bytes. The bridge writes those bytes to a temporary WAV file and calls:

afplay /path/to/temporary.wav

afplay is only a playback mechanism. It does not synthesise speech.

The temporary file is deleted in a finally block whether playback succeeds or fails.

There is no local macOS voice-synthesis fallback anymore. That makes failure behaviour much easier to reason about:

  • missing Cartesia credentials: delivery fails;
  • Cartesia request failure: delivery fails;
  • empty Cartesia audio: delivery fails;
  • afplay failure: delivery fails;
  • worker increments failed and continues to the next FIFO item.

No alternate speech synthesis is attempted.

Keeping logs content-free

The bridge handles prompt and response text in memory while correlation and speech work are pending.

Application logs contain source names, counts, backend names, and failure categories. They do not log raw prompts, responses, or generated speech text.

This is particularly important because OTEL pipelines often contain full-content telemetry. The Collector configuration should be reviewed carefully, especially when adding new exporters.

The speech branch should be filtered and isolated. Splunk’s existing full-content exporters should remain intact.

Running it

The setup is intentionally manual:

uv sync
cp .env.example .env
uv run python scripts/ensure_shared_token.py
uv run uvicorn app.main:app --host 0.0.0.0 --port 17860

The important configuration is roughly:

SPEECH_INGEST_TOKEN=replace-with-a-long-random-token

GEMINI_API_KEY=
GEMINI_MODEL=gemini-3.5-flash

CARTESIA_API_KEY=
CARTESIA_MODEL=sonic-3.5
CARTESIA_CONTAINER=wav

SPEECH_RATE=1.3

CODEX_CARTESIA_VOICE_ID=...
CLAUDE_CARTESIA_VOICE_ID=...

Secrets stay in ignored environment files. The repository does not contain API keys or ingest tokens.

Why abuse OpenTelemetry this way?

This is not a conventional voice-agent architecture.

It is a small adapter built on top of telemetry that already exists. The agents produce lifecycle events, the Collector provides routing, and the speech bridge turns selected completed events into audio.

The advantages are practical:

  • no agent-specific speech integration;
  • no changes to the agent’s core execution loop;
  • existing session and trace identifiers provide correlation;
  • Collector routing keeps the speech service separate from the agents;
  • the same telemetry can continue flowing to Splunk.

The tradeoffs are equally clear:

  • the queue is in memory;
  • restarting the bridge loses pending work;
  • speech depends on network access to Gemini and Cartesia;
  • playback is macOS-specific;
  • telemetry ordering and filtering require careful testing.

For a production system, I would probably add durable state, explicit event schemas, retries, health metrics, and a proper audio service.

For a local coding workflow, this is enough.

OpenTelemetry was already telling me what the agents had done. I just gave it a voice.

Find the code on https://github.com/ilyaresh/otel2speech

Send Claude Code and Codex logs to Splunk

I wanted to see what my local AI coding tools were actually doing (like token spend, tool calls, sessions, models) in Splunk, the same way I’d look at any other machine data. As both Claude Code and Codex can emit OpenTelemetry (OTEL), instead of scraping logs I used that to send Claude Code and Codex logs to Splunk.

This is the setup that ended up working. Two harnesses, one collector, one Splunk index (ai_usage) and two sourcetypes so I can tell them apart.

Read more: Send Claude Code and Codex logs to Splunk

An Overview

Both harnesses speak OTLP. I send both to the same collector on 127.0.0.1:4318, then split them inside the collector by service.name and export to Splunk HEC with different sourcetype values.

Only logs. I deliberately left metrics and traces off. The log events already have a lot of info like token counts, durations, and tool details.

Claude Code config

Claude Code is configured with env vars. Add them in ~/.claude/settings.json under env so you don’t have to export them in the shell:

{
  "env": {
    "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
    "OTEL_LOGS_EXPORTER": "otlp",
    "OTEL_METRICS_EXPORTER": "none",
    "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
    "OTEL_EXPORTER_OTLP_ENDPOINT": "http://127.0.0.1:4318",
    "OTEL_LOG_TOOL_DETAILS": "1"
  }
}

Notes on the bits that matter:

  • CLAUDE_CODE_ENABLE_TELEMETRY=1 – is the master switch. Nothing happens without it.
  • OTEL_METRICS_EXPORTER=none – logs only, on purpose.
  • OTEL_EXPORTER_OTLP_ENDPOINT – is just the base URL. Claude Code appends the signal path itself.
  • OTEL_LOG_TOOL_DETAILS=1 – includes which tool ran (Bash, Edit, MCP calls, etc.). That’s the part I actually care about.

Restart Claude Code after changing this, as the env block is read at startup.

Codex config

Codex config is a bit different to Claude. It’s not env vars — it’s a TOML block in ~/.codex/config.toml:

[otel]
environment = "local"
log_user_prompt = false

exporter = { otlp-http = {
  endpoint = "http://127.0.0.1:4318/v1/logs",
  protocol = "binary"
}}

trace_exporter = "none"
metrics_exporter = "none"

Two things I got wrong the first time:

  • endpoint – it needs the full path /v1/logs, not just the base URL like Claude. If you give it the base URL, it won’t land.
  • protocol = "binary" is protobuf. That lines up with Claude’s http/protobuf, so the same collector receiver handles both.

And a few other parameters worth mentioning:

  • log_user_prompt = false – keeps my actual prompts out of Splunk. I only want the metadata, not the text I typed.
  • trace/metrics_exporter = "none" – self-explanatory: we don’t want metrics or traces at this point

The collector

The collector is the Splunk distro of the OTEL collector, run in Docker. docker-compose.yaml:

services:
  splunk-otel:
    image: quay.io/signalfx/splunk-otel-collector:latest
    container_name: splunk-otel-collector
    environment:
      - SPLUNK_HEC_TOKEN=<your-hec-token>
      - SPLUNK_HEC_URL=https://host.docker.internal:38088/services/collector
      - SPLUNK_CONFIG=/etc/otel/collector/agent_config.yaml
    ports:
      - "4317:4317"   # OTLP gRPC
      - "4318:4318"   # OTLP HTTP
      - "8888:8888"   # collector's own metrics
    volumes:
      - ./agent_config.yaml:/etc/otel/collector/agent_config.yaml:ro
    restart: always

The important bit is host.docker.internal in SPLUNK_HEC_URL. The collector runs in a container; Splunk runs on the host, so localhost from inside the container would be the container itself. host.docker.internal is how the container reaches the host’s 38088.

HEC port would usually be 8088, but i had it already pre-occupied with something else :-).

The routing bit

This is the part that took the most fiddling. Both harnesses hit the same 4318, but I want them tagged differently in Splunk. The collector’s routing connector does the split, keyed on service.name. agent_config.yaml:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

connectors:
  routing:
    default_pipelines: [logs/codex]          # anything not matched -> codex
    error_mode: ignore
    table:
      - statement: route() where IsMatch(resource.attributes["service.name"], "^claude(-|$).*")
        pipelines: [logs/claude]

processors:
  transform/normalize:
    error_mode: ignore
    log_statements:
      - set(log.time_unix_nano, log.observed_time_unix_nano) where log.time_unix_nano == 0
      - set(log.body, log.attributes) where log.attributes["event.name"] != nil
      - merge_maps(log.body, resource.attributes, "insert") where IsMap(log.body)
      - keep_keys(log.attributes, []) where IsMap(log.body)
  batch:

exporters:
  splunk_hec/codex:
    token: "${SPLUNK_HEC_TOKEN}"
    endpoint: "${SPLUNK_HEC_URL}"
    source: "codex"
    sourcetype: "otel:ai:codex"
    index: "ai_usage"
    tls:
      insecure_skip_verify: true

  splunk_hec/claude:
    token: "${SPLUNK_HEC_TOKEN}"
    endpoint: "${SPLUNK_HEC_URL}"
    source: "claude_code"
    sourcetype: "otel:ai:claude"
    index: "ai_usage"
    tls:
      insecure_skip_verify: true

service:
  pipelines:
    logs:
      receivers: [otlp]
      exporters: [routing]
    logs/codex:
      receivers: [routing]
      processors: [transform/normalize, batch]
      exporters: [splunk_hec/codex]
    logs/claude:
      receivers: [routing]
      processors: [transform/normalize, batch]
      exporters: [splunk_hec/claude]

How the routing actually resolves:

  • Claude Code sends service.name = claude-code-desktop, which matches ^claude(-|$), so it goes down logs/claude.
  • Codex sends service.name = codex-app-server, which does not match, so it falls through to default_pipelines: [logs/codex].

So Codex isn’t matched by a rule; it’s the default. That’s fine for two tools, but worth remembering: if a third thing ever sent OTLP to this collector without matching ^claude, it would get labelled as codex. The default is a catch-all, not a Codex-specific match.

insecure_skip_verify: true is because my Splunk HEC is a self-signed cert on localhost. Fine here, not something I’d do against a real endpoint.

Why the transform is there

Without transform/normalize the events landed in Splunk with an empty body and everything buried in OTEL attributes, which is annoying to search. The transform moves the log attributes into the body, merges in the resource attributes (that’s where service.name, versions, etc. live), then drops the now-duplicate attribute copy. The result is a flat JSON event where the fields are just… fields.

Testing it

Of course, after you send Claude Code and Codex logs to Splunk, you want to make sure they are there.

Quick sanity check in Splunk to see if anything is landing, and from both tools?

index=ai_usage earliest=-7d
| stats count, min(_time) as first, max(_time) as last by source, sourcetype
| eval first=strftime(first,"%Y-%m-%d %H:%M"), last=strftime(last,"%Y-%m-%d %H:%M")

In my case:

source        sourcetype        count   first             last
claude_code   otel:ai:claude    6659    2026-06-30 09:30  2026-07-07 09:16
codex         otel:ai:codex     6317    2026-06-30 09:17  2026-07-07 09:16

Both flowing, most recent event a couple of minutes old. Good enough.

A single Claude Code event looks like this (I’ve masked the user fields):

{
  "event.name": "hook_execution_complete",
  "hook_event": "PostToolUse",
  "hook_name": "PostToolUse:mcp__splunk-mcp-server__splunk_run_query",
  "service.name": "claude-code-desktop",
  "service.version": "1.18286.0",
  "session.id": "f11b6155-…",
  "total_duration_ms": "2",
  "os.type": "darwin",
  "user.email": "you@example.com"
}

And a Codex one:

{
  "event.name": "codex.websocket_request",
  "service.name": "codex-app-server",
  "service.version": "0.142.5",
  "model": "gpt-5.5",
  "conversation.id": "019f39b0-…",
  "duration_ms": "2",
  "auth_mode": "Chatgpt",
  "success": "true",
  "telemetry.sdk.language": "rust"
}

Different fields per tool are expected as they’re different products. The common keys (service.name, event.name, event.timestamp, user.email) are enough to build usage dashboards across both.

And you can have Splunk without some pretty dashboards :-)!

What I missed / gotchas

  • Claude Code uses env vars, Codex uses a TOML block. I assumed both were env at first.
  • Codex needs the full /v1/logs path on the endpoint. Claude only wants the base URL.
  • Restart the harness after any config change. The telemetry config is read at startup, not live-reloaded.
  • Codex is the default route, not a matched one. Fine for now, but the routing rule only explicitly matches Claude.
  • Self-signed HEC on localhost → insecure_skip_verify: true. Don’t carry that into anything real.

Notes

  • Don’t commit the compose file with SPLUNK_HEC_TOKEN in it. My local spike had real values inline while I was testing. Before sharing the repo, move them to .env or a secret store, keep .env out of git, and rotate the token if it has already left the machine.
  • Metrics and traces are off here on purpose. If you want real OTEL metrics, not dashboard metrics derived from log events, flip OTEL_METRICS_EXPORTER / metrics_exporter back on and add a collector metrics pipeline. The log events already carry what I needed for this dashboard.
  • Telemetry field names can change between versions. This worked on the versions listed at the top; check the current field names if a dashboard suddenly goes empty after an update.
  • Pin the collector image before turning this into a repeatable setup. splunk-otel-collector:latest is fine for a spike, but it makes blog instructions drift over time.

References

The ABCs of Voice AI Agents

I have been building a voice AI receptionist, and there are a lot of terms and acronyms. Some of them are normal telephony acronyms that have been around forever, like DTMF and PSTN (and might need a refresher). Some are AI acronyms, like STT, TTS, LLM and RAG (that we are now sick of hearing every day). Then there are the “glue” terms that only start to matter once you actually try to connect Telephony, Voice and LLMs.
This is my attempt to write down the useful ones, A.K.A The ABCs of Voice AI Agents.

It is not a full architecture doc. It is more of a map of the words that kept coming up while building the bot.

Continue reading The ABCs of Voice AI Agents

Splunk O11y Deployment

I have a little project I’m working on playing with, MentionVault.com. It’s a platform that allows you to look for guests on various podcasts and what was mentioned in each episode. So I was thinking, I can’t be that shoeless cobbler, how come I have an application and don’t have any Observability for it?! That’s how I decided to try a Splunk O11y deployment for my app.

Continue reading Splunk O11y Deployment

n8n – The response was filtered due to the prompt triggering Azure OpenAI’s content management policy

I started playing with n8n.io, specifically with the “My first AI Agent in n8n” workflow that comes OOTB.

I didn’t have OpenAI subscription, but I do have an Azure subscription and Azure OpenAI deployment to play with, so I replaced the “standard” OpenAI node with the Azure OpenAI one.

But when I started the execution, the Azure OpenAI Chat Model node threw an exception, straight in my face: “The response was filtered due to the prompt triggering Azure OpenAI’s content management policy.”.

Continue reading n8n – The response was filtered due to the prompt triggering Azure OpenAI’s content management policy

“Create a Custom Skill for Azure AI Search” lab fails

I tried to follow the “Create a Custom Skill for Azure AI Search” but it failed with this error “The request is invalid. Details: The property ‘includeTypelessEntities’ does not exist on type ‘Microsoft.Skills.Text.V3.EntityRecognitionSkill’. Make sure to only use property names that are defined by the type.”

Continue reading “Create a Custom Skill for Azure AI Search” lab fails

Infrequent Smarts by Reshetnikov