> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moda.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Coding agents

> Send Claude Code, Codex, and Cursor session telemetry to Moda's coding-agent ingestion endpoints.

Moda ingests coding-agent session telemetry — user prompts, tool activity, API usage, and session lifecycle events — from Claude Code, Codex, and Cursor. This page covers the coding-agent endpoints, how to point each agent's OpenTelemetry configuration at them, and exactly what is stored.

## Endpoints

All endpoints live on the Ingestion API base URL `https://moda-ingest.modas.workers.dev` and require your API key as a bearer token:

```
Authorization: Bearer moda_sk_...
```

The agent path segment is `claude`, `codex`, or `cursor`:

| Endpoint                        | Body                                | Purpose                                                  |
| ------------------------------- | ----------------------------------- | -------------------------------------------------------- |
| `POST /v1/otel/{agent}/logs`    | OTLP/HTTP logs, JSON or protobuf    | Agent event logs (prompts, tool results, API requests)   |
| `POST /v1/otel/{agent}/traces`  | OTLP/HTTP traces, **JSON only**     | Agent spans                                              |
| `POST /v1/otel/{agent}/metrics` | OTLP/HTTP metrics, JSON or protobuf | Accepted and validated, but not stored as queryable rows |
| `POST /v1/otel/{agent}/hooks`   | JSON object or array of objects     | Lifecycle hook events posted by hook scripts             |

The 5 MB request body limit applies to every route (413 when exceeded).

<Warning>
  The coding-agent traces routes accept OTLP/JSON only. Sending protobuf returns 400 with `OTLP/protobuf traces are not supported on coding-agent routes; send JSON or use /v1/traces`. Logs and metrics accept both encodings.
</Warning>

## Claude Code

Claude Code exports its telemetry over standard OpenTelemetry environment variables. Set them in your shell, or in the `env` block of `~/.claude/settings.json` so they apply to every session.

<Steps>
  <Step title="Configure the exporter">
    <CodeGroup>
      ```bash Shell theme={"dark"}
      export CLAUDE_CODE_ENABLE_TELEMETRY=1
      export OTEL_LOGS_EXPORTER=otlp
      export OTEL_METRICS_EXPORTER=otlp
      export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
      export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="https://moda-ingest.modas.workers.dev/v1/otel/claude/logs"
      export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="https://moda-ingest.modas.workers.dev/v1/otel/claude/metrics"
      export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_MODA_API_KEY"
      export OTEL_LOG_USER_PROMPTS=1
      ```

      ```json ~/.claude/settings.json theme={"dark"}
      {
        "env": {
          "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
          "OTEL_LOGS_EXPORTER": "otlp",
          "OTEL_METRICS_EXPORTER": "otlp",
          "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf",
          "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT": "https://moda-ingest.modas.workers.dev/v1/otel/claude/logs",
          "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT": "https://moda-ingest.modas.workers.dev/v1/otel/claude/metrics",
          "OTEL_EXPORTER_OTLP_HEADERS": "Authorization=Bearer YOUR_MODA_API_KEY",
          "OTEL_LOG_USER_PROMPTS": "1"
        }
      }
      ```
    </CodeGroup>

    <Note>
      Without `OTEL_LOG_USER_PROMPTS=1`, Claude Code sends only the prompt length — not the prompt text — so prompt events are stored without text. The variable controls what the agent emits; the ingestion service separately caps and redacts what it stores (see [What is stored](#what-is-stored-and-what-is-not)).
    </Note>
  </Step>

  <Step title="Run a Claude Code session">
    Start Claude Code in the same environment and use it normally. Events are exported in batches while the session runs.
  </Step>

  <Step title="Verify ingestion">
    You can check your key and endpoint independently of the agent by posting an empty envelope:

    ```bash theme={"dark"}
    curl -X POST https://moda-ingest.modas.workers.dev/v1/otel/claude/logs \
      -H "Authorization: Bearer YOUR_MODA_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"resourceLogs": []}'
    ```

    Successful OTLP exports receive `200` with an empty partial-success body:

    ```json theme={"dark"}
    {"partialSuccess":{}}
    ```

    A `401` response means the `Authorization` value (set through `OTEL_EXPORTER_OTLP_HEADERS`) is missing or carries an invalid key.
  </Step>
</Steps>

Claude Code log events are normalized into these event types (the `claude_code.` prefix is optional — short names are accepted):

| Log event                 | Stored event type                    |
| ------------------------- | ------------------------------------ |
| `claude_code.user_prompt` | `prompt`                             |
| `claude_code.tool_use`    | `tool_decision`                      |
| `claude_code.tool_result` | `tool_result`                        |
| `claude_code.api_request` | `api_request`                        |
| `claude_code.api_error`   | `api_error`                          |
| skill-activation events   | `skill_activated`                    |
| anything else             | `unknown` (all attributes preserved) |

## Codex and Cursor

If you run Codex or Cursor with an OTLP exporter, point it at the matching provider path. What Moda needs from the exporter is the endpoint URL and the `Authorization` header; for exporters configured through the standard OTLP environment variables, that is the same set as Claude Code with `claude` swapped for `codex` or `cursor` and no `CLAUDE_CODE_ENABLE_TELEMETRY`. A minimal logs-only configuration:

```bash theme={"dark"}
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="https://moda-ingest.modas.workers.dev/v1/otel/codex/logs"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_MODA_API_KEY"
```

Codex log events are normalized as follows; Cursor telemetry is normalized with the same rules:

| Log event                                  | Stored event type                                       |
| ------------------------------------------ | ------------------------------------------------------- |
| `codex.conversation_starts`                | `session_start`                                         |
| `codex.user_prompt`                        | `prompt`                                                |
| `codex.api_request`                        | `api_request` (token counts read from `gen_ai.usage.*`) |
| `codex.sse_event`, `codex.websocket_event` | `stream_event`                                          |
| `codex.tool_decision`                      | `tool_decision`                                         |
| `codex.tool_result`                        | `tool_result`                                           |
| events marked `skill.activated: true`      | `skill_activated`                                       |
| anything else                              | `unknown` (all attributes preserved)                    |

## Hook events

The hooks routes accept plain JSON — a single event object or an array — so hook scripts wired into an agent's hook system (for example Claude Code hooks) can report lifecycle events with a simple HTTP POST. Any process holding an API key can post them.

```bash theme={"dark"}
curl -X POST https://moda-ingest.modas.workers.dev/v1/otel/claude/hooks \
  -H "Authorization: Bearer YOUR_MODA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "hook": "UserPromptSubmit",
      "session_id": "sess-42",
      "user_prompt": "add a retry to the flaky uploader test",
      "timestamp": "2026-08-16T10:00:05.000Z"
    },
    {
      "hook": "PostToolUse",
      "session_id": "sess-42",
      "tool_name": "Bash",
      "tool_use_id": "tu-9",
      "command": "npm test",
      "success": true,
      "duration_ms": 4230,
      "timestamp": "2026-08-16T10:00:12.000Z"
    }
  ]'
```

Response:

```json theme={"dark"}
{
  "success": true,
  "count": 2,
  "malformed": 0,
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}
```

Only `hook` is required on Claude payloads; a missing `timestamp` defaults to the receive time. **Codex and Cursor hook payloads must carry a parseable `timestamp`** (ISO 8601 string, Unix seconds, or Unix milliseconds) — payloads without one are counted in `malformed` and skipped, never failing the batch.

Recognized hook names:

| Hook name                                                    | Claude                                | Codex / Cursor                         |
| ------------------------------------------------------------ | ------------------------------------- | -------------------------------------- |
| `SessionStart`                                               | `session_start`                       | `session_start`                        |
| `UserPromptSubmit`                                           | `prompt`                              | `prompt`                               |
| `PreToolUse`                                                 | `tool_decision`                       | `tool_decision`                        |
| `PermissionRequest`                                          | `permission_request`                  | `permission_request`                   |
| `PostToolUse`                                                | `tool_result`                         | `tool_result`                          |
| `PostToolUseFailure`                                         | `tool_result` (with `success: false`) | —                                      |
| `Stop`                                                       | `session_end`                         | `session_end`                          |
| `SessionEnd`                                                 | `session_end`                         | —                                      |
| `FileChanged`                                                | `file_changed`                        | —                                      |
| `WorkspaceSnapshot`                                          | `workspace_snapshot`                  | `workspace_snapshot`                   |
| `SubagentStart`, `SubagentStop`, `PreCompact`, `PostCompact` | —                                     | `hook` (structured metadata preserved) |
| anything else                                                | `hook` (attributes preserved)         | `hook` (attributes preserved)          |

Recognized optional fields include `session_id`, `cwd`, `repo`, `branch`, `git_commit`, `tool_name`, `tool_use_id`, `command`, `tool_input`, `tool_output`, `success`, `duration_ms`, `error` (`{type, message}`), `user_prompt`, `approval` (`{decision, source}`), `mcp_server`, `mcp_tool`, `skill_name`, `skill_scope`, `file_path`, `change_type`, `model`, `user_id`, and `user_email`. Codex and Cursor payloads additionally accept `conversation_id`, `sandbox_policy`, `subagent` (`{id, type}`), and `compaction` (`{input_tokens, output_tokens}`). Unknown keys are preserved on the stored event.

## What is stored and what is not

The ingestion service applies fixed caps and redaction to coding-agent events:

| Data                                                           | Stored?                                                  | Limit                                              |
| -------------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------- |
| Prompt text (`user_prompt` / prompt log events)                | Yes                                                      | Truncated to 8 KB                                  |
| Tool output snippets (`tool_output`)                           | **No** — not stored                                      | —                                                  |
| Shell commands (`command`)                                     | Yes                                                      | Truncated to 4,096 bytes, secret redaction applied |
| Error messages                                                 | Yes                                                      | Truncated to 2,048 bytes, secret redaction applied |
| Token counts, durations, tool names, session and repo metadata | Yes                                                      | —                                                  |
| Metrics (`/v1/otel/{agent}/metrics`)                           | Accepted and validated, **not stored as queryable rows** | —                                                  |

Secret redaction masks structured secret shapes — env-style assignments, header values, and explicit `key: value` pairs — without touching ordinary prose. Unknown event and hook names are never dropped; they are stored with their attributes preserved.

## Responses and errors

| Route                          | Success                                                                                                                                                                   | Errors                                                                                                                                                                                                              |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/logs`, `/traces`, `/metrics` | `200` `{"partialSuccess":{}}` — partially rejected batches report `rejectedLogRecords` / `rejectedSpans` and an `errorMessage` inside `partialSuccess` instead of failing | `400` `{"error": "Invalid OTLP ... envelope: ..."}` on an unparseable envelope                                                                                                                                      |
| `/hooks`                       | `200` `{"success": true, "count": n, "malformed": n, "requestId": "..."}`                                                                                                 | `400` `{"error": "Invalid JSON body: ..."}`                                                                                                                                                                         |
| All                            | —                                                                                                                                                                         | `401` `{"success": false, "count": 0, "message": "...", "requestId": "..."}` on auth failure; `413` when the body exceeds 5 MB; `503` `{"error": "ingest temporarily unavailable"}` on backpressure — safe to retry |

Always send a stable `timestamp` on hook payloads, and reuse the same one when you retry a delivery. A Claude hook payload without a timestamp is stamped at receive time, so two deliveries of the same payload are recorded as separate events.

## Next steps

* [OpenTelemetry](/ingestion/opentelemetry) — the general-purpose trace endpoint for your application's own LLM calls.
* [Ingestion overview](/ingestion/overview) — authentication, limits, and the full endpoint inventory.
* [Reliability](/ingestion/reliability) — retry semantics and error envelopes.
* [CLI overview](/cli/overview) — query captured data from the terminal.
