> ## 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. Final long-running custom tool results
are queued when necessary.

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. A
prompt sent while a turn is running is queued automatically — 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: $HERM_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`.

## Ralph Wiggim loop

Send `/goal <objective>` as a plain text message when you want Herm to keep
working across turns until the objective is done. This is the Ralph Wiggim loop:
the agent takes a turn, a goal judge checks whether the objective is complete,
and Herm continues the same session if more work remains.

```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: $HERM_API_KEY" \
  -d '{
    "events": [
      {
        "type": "user.message",
        "content": [
          {
            "type": "text",
            "text": "/goal Audit the billing settings page and fix every broken empty state."
          }
        ]
      }
    ]
  }'
```

Use `/goal` for open-ended work where one model turn may not be enough, such as
debugging a failing workflow, iterating until tests pass, or completing a
multi-step internal operation. Write the objective like acceptance criteria: the
clearer the finish line, the better the loop can decide when to stop.

For the API to dispatch this as a goal command, the `user.message` must contain
only text and must not include attachments or metadata. Messages such as
`/goal status`, `/goal pause`, `/goal resume`, `/goal clear`, `/goal stop`, and
`/goal done` are reserved control phrases, not new goal objectives.

## 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. Attachments are not
written onto the subject's sandbox volume; persist files with the
[Files API](/api-reference/files) when later sessions should see them.

```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: $HERM_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, XLSX, 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: each attachment is capped at 40 MB (PDF, CSV, XLSX, TXT, Markdown,
DOCX, and images).

## Queue a message while a turn is running

Sending a prompt while a turn is already running persists it with
`status: "queued"` and returns `202`. Herm starts it automatically when the
running turn ends. The optional `delivery: "when_idle"` value remains supported
for existing callers:

```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: $HERM_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 prompt is queued and the request returns `202`.                                                                                                                             |
| `"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. |

For queued prompts:

* 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 immediate 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: $HERM_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: $HERM_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: $HERM_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, 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. If another turn is active, the continuation is queued automatically. 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 behind an active turn 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 busy request mixes a prompt with events that cannot be queued.                                                                                                                                                                                                                                                                                                           |
| `409`  | `no_active_turn`   | The session has no live turn for a steer or interrupt control event. Refresh session state before deciding whether a new control event is appropriate.                                                                                                                                                                                                                                             |
| `410`  | `response_expired` | The turn that requested this approval, clarification, or synchronous custom-tool result has ended. The response includes `"retryable": false`; do not retry it. Send a new user message if you want the agent to try the action again.                                                                                                                                                             |
| `429`  | `rate_limited`     | The agent's per-user event allowance is exhausted. The events are not persisted, queued, or executed.                                                                                                                                                                                                                                                                                              |
