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

# Send session events

> Send prompt, control, and result events to a session.

```
POST /v1/sessions/{sessionId}/events
```

Use this endpoint to send prompt, control, and result events to an existing
session. A request contains 1 to 100 events, and only one event may start a new
turn. Control events and immediate custom tool results can be sent while a turn
is running or paused for required action. A final long-running custom tool result
starts a new turn and requires an idle session.

Exceeding a limit — more than 100 events per request, more than 100 text blocks
in a `content` array, or a text value over 20,000 characters — rejects the whole
request with `400 validation_error`. Nothing is truncated, and the error message
names the failing field and limit. See
[Text content block](/api-reference/events-reference#text-content-block).

A `user.message` starts a new turn, and a session runs one turn at a time: by
default a prompt sent while a turn is running is rejected with `409`. Set the
request-level `delivery` field to `"when_idle"` to queue the prompt instead — see
[Queue a message while a turn is running](#queue-a-message-while-a-turn-is-running).

## Send a message

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST "https://api.herm.run/v1/sessions/session_123/events" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $PRISM_API_KEY" \
  -d '{
    "events": [
      {
        "type": "user.message",
        "content": [
          {
            "type": "text",
            "text": "Say hello in one sentence."
          }
        ],
        "metadata": {
          "source": "dashboard",
          "accountId": "acct_123",
          "priority": true
        }
      }
    ]
  }'
```

`metadata` is optional turn context for the agent. It is persisted on the
`user.message` event payload and returned by list/stream APIs, but it is not part
of the visible `content` text your UI renders as the user's message. Metadata
values must be strings, numbers, booleans, or `null`.

## Send a message with attachments

`user.message` accepts up to 10 turn-scoped file attachments. Attachments are
sent as base64 `data_url` sources. The accepted event, including each
`source.dataUrl`, is retained in session event history.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST "https://api.herm.run/v1/sessions/session_123/events" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $PRISM_API_KEY" \
  -d '{
    "events": [
      {
        "type": "user.message",
        "content": [
          {
            "type": "text",
            "text": "Summarize the attached notes and list action items."
          }
        ],
        "attachments": [
          {
            "type": "file",
            "filename": "notes.txt",
            "mediaType": "text/plain",
            "sizeBytes": 37,
            "source": {
              "type": "data_url",
              "dataUrl": "data:text/plain;base64,VGhlIGN1c3RvbWVyIGFza2VkIGZvciBhIFFCUiBwbGFuLg=="
            }
          }
        ]
      }
    ]
  }'
```

Supported attachment types are PDF, CSV, TXT, Markdown, DOCX, PNG, JPEG, WebP,
GIF, and BMP. The agent can inspect images and documents as part of the turn.
PDFs default to document mode; set `analysisMode: "visual"` on a PDF attachment
only when you want page-image analysis.

| Field            | Type    | Required | Description                                          |
| ---------------- | ------- | -------- | ---------------------------------------------------- |
| `type`           | string  | Yes      | Must be `file`.                                      |
| `filename`       | string  | Yes      | Display name used after sanitization.                |
| `mediaType`      | string  | Yes      | One of the supported MIME types.                     |
| `sizeBytes`      | integer | No       | Declared byte size. Enforced when present.           |
| `source.type`    | string  | Yes      | Must be `data_url`.                                  |
| `source.dataUrl` | string  | Yes      | Base64 data URL whose MIME type matches `mediaType`. |
| `analysisMode`   | string  | No       | `default` or `visual`; `visual` is PDF-only.         |

Limits: images, CSV, TXT, Markdown, and DOCX are capped at 25 MB per attachment;
PDFs are capped at 50 MB.

## Queue a message while a turn is running

By default, sending a prompt-starting event while a turn is already running fails
with `409 invalid_state` ("A turn is already in progress"), and your application
owns the retry. When your caller means "deliver this whenever the agent can take
it" — for example a background job posting a completion notification into a
session — set the request-level `delivery` field to `"when_idle"`:

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST "https://api.herm.run/v1/sessions/session_123/events" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $PRISM_API_KEY" \
  -d '{
    "delivery": "when_idle",
    "events": [
      {
        "type": "user.message",
        "content": [
          {
            "type": "text",
            "text": "Render job 42 finished successfully."
          }
        ],
        "metadata": {
          "source": "workflow_completion",
          "jobId": "42"
        }
      }
    ]
  }'
```

| `delivery`              | Behavior while a turn is running                                                                                                                                                |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"immediate"` (default) | The request is rejected with `409 invalid_state`. Identical to omitting the field.                                                                                              |
| `"when_idle"`           | The prompt-starting event is persisted with `status: "queued"` and the request returns `202` immediately. Herm starts the queued turn automatically when the running turn ends. |

With `"when_idle"`:

* If the session is idle, the prompt starts a turn immediately — exactly like
  an immediate send.
* Queued prompts start oldest-first, one per turn end, and each runs as its own
  turn.
* The `202` response echoes the persisted events; `data[].status === "queued"`
  tells you the prompt was parked rather than started. The status moves to
  `received` when its turn starts, then `processed` like any other prompt.
* Queued prompts remain durable until they are delivered once the session is
  idle again.

A `when_idle` request must contain only prompt-starting `user.message` events.
Control and result events (`user.steer`, `user.approval_response`, and so on)
target the currently running turn and are never queued for a later one, so a
`when_idle` request that includes them is rejected with `400 validation_error` —
send them in a separate request with the default delivery.

## Rate-limit counting

The agent's message limit defaults to 100 accepted inbound `user.*` events per
stable subject (`subjectId`) per fixed 60-second window. Every event in a request
consumes one point from this allowance.

* Immediate events count when they are accepted for persistence.
* A `delivery: "when_idle"` prompt counts when it is accepted into the durable
  queue. It is not counted again when the queue drains.
* Validation, not-found, archived-session, and prompt-conflict failures rejected
  before persistence do not count. A control event that is persisted but cannot
  reach a live turn does count.
* `user.steer`, `user.interrupt`, `user.approval_response`,
  `user.clarify_result`, and `user.custom_tool_result` each count.

The subject comes from the stored session; it cannot be changed on this route.
Limited successful event requests include `RateLimit-Limit`,
`RateLimit-Remaining`, and `RateLimit-Reset`. See
[Rate limits](/api-reference/rate-limits) and
[Errors](/errors#rate-limits).

## Supported inbound events

| Event                     | Description                                                                                                                   | Required fields          |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `user.message`            | Start a new agent turn with user text, optional metadata, and optional attachments.                                           | `content`                |
| `user.steer`              | Add guidance to the current session without starting a normal prompt.                                                         | `message`                |
| `user.interrupt`          | Interrupt the active turn.                                                                                                    | none                     |
| `user.approval_response`  | Allow or deny a pending tool approval request.                                                                                | `approval_id`, `result`  |
| `user.custom_tool_result` | Return the result of an application-owned custom tool. Use `status` for long-running workflow kickoff and completion updates. | `tool_use_id`, `content` |
| `user.clarify_result`     | Answer an `agent.clarify_request`.                                                                                            | `request_id`, `answer`   |

See the [Events Reference](/api-reference/events-reference#inbound-events) for the
complete schema for each event.

## Action responses

When the stream emits `session.status_idle` with
`payload.stop_reason.type: "requires_action"`, the `event_ids` array contains the
IDs your application needs to answer.

| Pending event            | Respond with              | ID mapping                                         |
| ------------------------ | ------------------------- | -------------------------------------------------- |
| `agent.approval_request` | `user.approval_response`  | `agent.approval_request.id` -> `approval_id`       |
| `agent.custom_tool_use`  | `user.custom_tool_result` | `agent.custom_tool_use.id` -> `tool_use_id`        |
| `agent.clarify_request`  | `user.clarify_result`     | `agent.clarify_request.request_id` -> `request_id` |

## Confirm a tool request

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST "https://api.herm.run/v1/sessions/session_123/events" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $PRISM_API_KEY" \
  -d '{
    "events": [
      {
        "type": "user.approval_response",
        "approval_id": "approval_123",
        "result": "allow",
        "scope": "once"
      }
    ]
  }'
```

`result` is `allow` or `deny`. `scope` is optional and can be `once`, `session`,
or `always`; omit it to allow once.

## Answer a clarify request

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST "https://api.herm.run/v1/sessions/session_123/events" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $PRISM_API_KEY" \
  -d '{
    "events": [
      {
        "type": "user.clarify_result",
        "request_id": "clarify_123",
        "answer": "Use the production environment."
      }
    ]
  }'
```

Use an empty `answer` to skip the question and let the agent continue with its
own default.

## Return a custom tool result

Custom tools are executed by your application, not by the managed agent. When
the stream emits `agent.custom_tool_use`, execute the named tool and return the
result with `user.custom_tool_result`.

```bash theme={"theme":{"light":"github-light","dark":"github-dark"}}
curl -X POST "https://api.herm.run/v1/sessions/session_123/events" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $PRISM_API_KEY" \
  -d '{
    "events": [
      {
        "type": "user.custom_tool_result",
        "tool_use_id": "custom_toolu_123",
        "content": [
          {
            "type": "text",
            "text": "Order #123 ships tomorrow."
          }
        ],
        "is_error": false
      }
    ]
  }'
```

Set `is_error: true` when your application could not complete the tool. The text is still sent back to
the model as tool output, so include a concise, model-readable error message.

For long-running workflows, return a kickoff result within approximately five
minutes:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "events": [
    {
      "type": "user.custom_tool_result",
      "tool_use_id": "custom_toolu_123",
      "content": [{ "type": "text", "text": "Workflow started." }],
      "status": "running",
      "is_error": false
    }
  ]
}
```

The kickoff resolves the pending tool call and lets the original turn continue.
When the workflow finishes and the session is idle, send a second result with
the same `tool_use_id`:

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "events": [
    {
      "type": "user.custom_tool_result",
      "tool_use_id": "custom_toolu_123",
      "content": [{ "type": "text", "text": "Workflow finished." }],
      "status": "completed",
      "is_error": false
    }
  ]
}
```

When a matching running result exists, the completion replaces it in event
history, emits `session.tool_updated`, and starts a new agent turn with the final
text. It cannot use `delivery: "when_idle"`; wait for
`session.status_idle` before posting it. See
[Long-running custom tools](/api-reference/long-running-tools) for the complete
flow, UI reconciliation, and retry constraints.

## Response

The endpoint returns the events persisted by the request. Additional agent events
can be retrieved with [List Session Events](/api-reference/stream-events#list-events)
or streamed with [Stream Events](/api-reference/stream-events#stream-events).
A message parked by `delivery: "when_idle"` is returned with
`status: "queued"` instead of `received`.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "object": "list",
  "data": [
    {
      "id": "evt_123",
      "type": "user.message",
      "sessionId": "session_123",
      "sequence": 0,
      "turnId": "turn_123",
      "status": "received",
      "payload": {
        "type": "user.message",
        "content": [{ "type": "text", "text": "Say hello in one sentence." }],
        "metadata": { "source": "dashboard", "accountId": "acct_123" }
      },
      "processedAt": null,
      "createdAt": "2026-06-20T23:27:54.866Z"
    }
  ]
}
```

## Errors

| Status | Error              | When                                                                                                                                                                                                                                                                                                                                                                                               |
| ------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `validation_error` | Invalid request body, text over 20,000 characters, more than 100 text blocks or events, unsupported attachment, malformed data URL, oversized attachment, more than one prompt-starting event, or a `delivery: "when_idle"` request containing control or result events. The message names the failing field, e.g. `events[0].content[0].text: Expected a string at most 20000 character(s) long`. |
| `404`  | `not_found`        | Session does not exist.                                                                                                                                                                                                                                                                                                                                                                            |
| `409`  | `invalid_state`    | Session is archived, or a prompt-starting event was sent while a turn is already running. This includes final long-running custom tool results.                                                                                                                                                                                                                                                    |
| `409`  | `no_active_turn`   | The session is no longer waiting for this custom tool result. The event may already be present in history; reconcile history before retrying.                                                                                                                                                                                                                                                      |
| `429`  | `rate_limited`     | The agent's per-user event allowance is exhausted. The events are not persisted, queued, or executed.                                                                                                                                                                                                                                                                                              |
