---
title: "Report agent activity"
method: POST
path: "/agent-activity"
tags: ["agent-activity"]
---

# Report agent activity

`POST /agent-activity`

## Request

- Base URL: `https://api.opal.dev/v1`
- URL: `https://api.opal.dev/v1/agent-activity`
- Auth: HTTP bearer

**Prototype (AG-285).** Reports activity observed by an agent runtime on a developer's machine - today, Claude Code hook events.

This is **telemetry from cooperating machines, not attestation**. Opal can only see what a configured, consenting client chooses to send, cannot verify that a fleet is configured at all, and has no way to detect a machine that is reporting nothing. Absence of activity for an agent therefore means "nothing was reported", never "the agent did nothing".

The request body is either a batch envelope (`{"events": [...]}`), a bare JSON array of events, or a single raw Claude Code hook payload - the last so a hook can POST here directly with no relay in between.

The bearer token establishes the **organization** and nothing finer. No field in the payload is treated as an identity claim Opal trusts: the actor headers below are used for best-effort attribution and display only. Events that cannot be attributed to an agent are retained and surfaced as unattributed activity rather than dropped.

## Headers

- `X-Opal-Actor` string
- `X-Opal-Host` string

## Request body

- AgentActivityIngestRequest: # AgentActivityIngestRequest Object ### Description A batch of agent activity events. Three body shapes are accepted: this envelope (`{"events": [...]}`), a bare JSON array of `AgentActivityEvent`, and a single `AgentActivityEvent` object. The last exists so a Claude Code hook can POST here directly, since a hook sends one bare payload per event and cannot wrap it.
  - `events` AgentActivityEvent[]
    - `hook_event_name` string, required: Which hook fired, e.g. `PostToolUse`, `PreToolUse`, `PermissionDenied`. Required; an event with no name cannot be interpreted and is rejected.
    - `tool_input` object: The call's arguments, as the runtime sent them. Server-defined for an MCP tool; a fixed shape per built-in (`command` for Bash, `file_path` for Edit, `url` for WebFetch). Stored as sent, and separately reduced to a normalized target the activity feed and policy can match on. The stored copy is the record of fact; the derived target is recomputed whenever the extractor improves. Capped before storage. A capped payload is no longer valid JSON, so the row records that it was capped rather than leaving each reader to discover it by failing to parse.
    - `session_id` string: The agent session this event belongs to. Opaque to Opal.
    - `prompt_id` string: Groups every tool call made under one user instruction, so a run can be read back as the instruction that caused it rather than as unrelated rows.
    - `agent_id` string: Set only when the call came from an autonomous subagent rather than the session a person is typing into. Stored as `subagent_id` - Opal's own `agent_id` already means something else.
    - `agent_type` string: The subagent's type, e.g. `general-purpose`. Set alongside `agent_id`.
    - `permission_mode` string: The session's permission mode, e.g. `default`, `acceptEdits`, `bypassPermissions`. A session running `bypassPermissions` had no approval gate in front of any tool call it made.
    - `actor_email` string: The identity the sender reports for this specific event. **Unverified**, the same as `X-Opal-Actor`, and used only when neither the API key nor that header identified a developer. An `http` hook cannot populate this; a `command`-type hook or a relay can (`$USER`, a hostname, `git config user.email`), which is why it is per-event rather than per-request - a relay can batch several developers.
    - `opal_reported` AgentActivityOpalReported: # AgentActivityOpalReported Object ### Description What the sending machine says about who and what it is, added by the Opal hook script. Every field is optional and **unverified**: whoever controls the machine controls them. They can disagree, and Opal keeps every one that is present, each cut to a fixed length (254 characters for an email, 64 for `os_user`, 255 for `hostname`) with control characters removed. The emails are also tried, in the order below and after the `X-Opal-Actor` header, to find the agent, so a person without a git email can still match through their Claude account.
      - `configured_email` string: An email set by an administrator, an MDM profile or the installer.
      - `claude_account_email` string: The email of the account Claude Code is signed in with.
      - `git_email` string: The machine's global `git config user.email`.
      - `os_user` string: The operating-system username. Not used to find an agent.
      - `hostname` string: The machine's name. The `X-Opal-Host` header wins when it names a different machine.
    - `duration_ms` unknown
    - `transcript_path` string: Read at ingest **only** to parse an operating-system username out of, as the last-resort attribution step, and then discarded. The path itself is never stored: it is an absolute path on an employee's laptop and Opal has no business accumulating those.
    - `tool_name` string: The tool invoked, for tool-scoped events. Empty for session-level events. An MCP tool is named `mcp__<server>__<tool>`. The whole string is stored as sent, and the server and the tool are also stored in their own fields (see `mcp_server`).
    - `mcp_server` AgentActivityMcpServer: # AgentActivityMcpServer Object ### Description The MCP server behind a tool call, as Claude Code reports it. Claude Code v2.1.274 and later adds this object to the hook input of an MCP tool call; an older client does not send it, and it is never present for a built-in tool such as `Bash`. When `name` is present it is stored as the call's server. When this object is absent, the server is read from `tool_name` instead: the part after the `mcp__` prefix and before the next `__`. The tool is always read from `tool_name`. `name` is stored as the call's server. `source` says where the server's definition came from (`plugin`, `sdk`, or a configuration scope such as `user` or `project`) and is stored as sent, cut to 64 characters. Neither field is an identity: `name` is whatever the user called the server in their own configuration, and `source` is what the machine said.
      - `name` string: The server's name.
      - `source` string: Where the server's definition came from, such as `plugin`, `sdk`, `user` or `project`. Stored as sent, cut to 64 characters.
    - `tool_use_id` string: The tool invocation's id, used together with `hook_event_name` to deduplicate a retried delivery. Absent on events that carry none, which are stored at-least-once.
    - `cwd` string: The working directory the agent was operating in.
    - `timestamp` string, date-time: When the client says the event occurred (RFC 3339). Clamped server-side to a window around receipt, because a client-controlled sort key is otherwise a way to pin rows to the top of every list. Defaults to receipt time when absent or unparseable.

## Response `200`

A per-batch accounting of what was accepted, stored, and attributed. An organization that has agent activity turned off gets a 200 with an empty body for every body shape, so a sender does not keep the events and try again.

- AgentActivityIngestResult: # AgentActivityIngestResult Object ### Description What one ingest request actually did. `received` and `stored` differ when a delivery was retried and deduplicated. `attributed` and `unattributed` sum to `received` rather than to `stored`, because attribution happens before deduplication. Unattributed events are kept, not dropped: activity Opal cannot tie to a governed agent is itself a governance finding.
  - `received` integer, required: Events parsed out of the request body.
  - `stored` integer, required: Rows actually written. Lower than `received` when a retried delivery was deduplicated on `tool_use_id`.
  - `attributed` integer, required: Events that resolved to an agent in this organization.
  - `unattributed` integer, required: Events that resolved to no agent, and are therefore visible only in the unattributed activity list. A persistently high value means the reporting clients are not sending an identity Opal can resolve.

## Other responses

- `400`: The body was unparseable, empty, or carried more events than one request may.
- `413`: The request body exceeded the maximum accepted size.

## Changes

- **2026-11-04** `e7877ea96eab`: 4 info
  - added the new optional `header` request parameter `X-Opal-Host`
  - added the new optional request property `events/items/duration_ms`
  - added the new optional request property `events/items/opal_reported`
  - removed the non-success response with the status `403`
- **2026-10-26** `33189b4d5538`: 1 info
  - added the new optional request property `events/items/mcp_server`
- **2026-10-22** `8fce5da23873`: 1 info
  - endpoint added

[Operation history](https://skmtc.dev/opal/apis/opal-api/changes/agent-activity/post.md)

---

[API](https://skmtc.dev/opal/apis/opal-api.md) - [All operations](https://skmtc.dev/opal/apis/opal-api/llms.txt) - [OpenAPI document](https://skmtc.dev/opal/apis/opal-api/revisions/e7877ea96eab?raw)
