Files
anthropics_skills/skills/claude-api/shared/managed-agents-events.md
T
Lance Martin 1f630fdf92 Update claude-api skill: Managed Agents July launch wave, partner pricing, tool-runner corrections (#1463)
Syncs the claude-api skill with the current upstream source.

## Managed Agents — five new features

- `effort` on the agent's `model` object (a level string or `{"type": "<level>"}`).
  It is agent-configuration only: setting it in a per-session `model` override is
  silently ignored.
- Optional `version` on agent update, for optimistic concurrency. Omit it for
  last-write-wins.
- `initial_events` on session create, collapsing create plus first send into one
  call. Validation is all-or-nothing and only `user.message` and
  `user.define_outcome` are accepted.
- Environment and memory-store webhooks: four `environment.*` events and three
  `memory_store.*` events.
- Event deltas on per-thread streams. Previews are thread-scoped, so a child
  thread's previews never reach the session-level stream.

## Corrections to behavior the skill already documented

- Tool output offload triggers at 100,000 characters (~25k tokens), not 100K
  tokens, and covers built-in tools rather than MCP alone.
- `system.message` works on four models, checks only the primary model, appends
  system context instead of replacing the prompt, and is accepted during a
  `requires_action` idle when it trails a tool result in the same request.
- Vault-to-MCP credential matching is normalized (scheme and host lowercased,
  default ports and trailing slashes stripped), not byte-exact.
- Multiagent depth greater than 1 is a validation error, not silently ignored.
- Outcome `interrupted` fires even when evaluation never started, and then
  carries an empty-string `outcome_evaluation_start_id`.
- Deployment jitter is 15% of the run interval, floor 5s, cap 9 minutes.
- Webhooks retry three times with jittered 5-120s backoff, then drop silently.
  Auto-disable is duration-based with three named triggers.
- `session.status_terminated` means completion or error.
- `agent.thinking` is a progress signal and carries no thinking content.
- `processed_at` is already populated on first sighting for
  `user.define_outcome`, `user.custom_tool_result`, and `user.tool_result`.
- Session creation does not provision the sandbox.
- Skill `version` applies to Anthropic-authored skills too.
- Console-created vault credentials are header-injection only, and vault
  environment-variable substitution skips secrets in URL paths.
- Console trace URLs need the real workspace ID when the API key is not in the
  Default workspace.

## Elsewhere in the skill

- Note partner pricing under the first-party price table: Microsoft Foundry
  bills at standard API rates, while Amazon Bedrock and Vertex AI are
  partner-operated with separate pricing.
- Correct the tool-runner human-in-the-loop guidance, which previously pointed
  readers at the manual loop for approval gates the runner's per-turn hooks
  already cover.
- Repoint links away from the retired documentation hosts.
- Dedupe eagerly loaded SKILL.md content that the per-section files already
  cover.
2026-07-22 14:20:09 -04:00

18 KiB
Raw Blame History

Managed Agents — Events & Steering

Events

Sending Events

Send events to a session via POST /v1/sessions/{id}/events.

Event Type When to Send
user.message Send a user message
user.interrupt Interrupt the agent while it's running
user.tool_confirmation Approve/deny a tool call (when always_ask policy)
user.custom_tool_result Provide result for a custom tool call
user.define_outcome Start a rubric-graded iterate loop — see shared/managed-agents-outcomes.md
system.message Append privileged system-level context for this turn and every turn after it; see § Adding system context mid-session

Adding system context mid-session (system.message)

The system field on the agent definition sets the top-level system prompt and is fixed for the session's lifetime. A system.message event appends to the session's system context as a role: "system" turn — it does not replace that prompt. The content applies to the accompanying turn and all subsequent turns. Use it for a different persona, revised constraints, or runtime-fetched context that should shape behavior going forward:

client.beta.sessions.events.send(
    session.id,
    events=[
        {
            "type": "system.message",
            "content": [
                {"type": "text", "text": "The user's current timezone is America/New_York."},
            ],
        },
    ],
)

Constraints:

  • Model-gated: Claude Opus 4.8, Claude Sonnet 5, Claude Fable 5, and Claude Mythos 5. Only the agent's primary model is checked — system.message lands on the primary thread only, so subagent models are not considered. On an unsupported primary model the event is rejected with a model_does_not_support_mid_conversation_system validation error.
  • While the session is idle with stop_reason: requires_action (blocked on user.custom_tool_result / user.tool_confirmation), a system.message is accepted only when it trails a tool result event in the same request. Sent on its own — or alongside a user.message — it is rejected until the pending tool events are resolved.
  • content accepts 11000 text items.

Receiving Events

Three methods:

  1. Streaming (SSE): GET /v1/sessions/{id}/events/stream — real-time Server-Sent Events. Long-lived — the server sends periodic heartbeats to keep the connection alive.
  2. Polling: GET /v1/sessions/{id}/events — paginated event list (query params: limit default 1000, page). Returns immediately — this is a plain paginated GET, not a long-poll.
  3. Webhooks: Anthropic POSTs session state transitions to your HTTPS endpoint — thin payloads (IDs only), HMAC-signed, Console-registered. See shared/managed-agents-webhooks.md.

All persisted events carry id, type, and processed_at (ISO 8601), set when the event finishes processing. On events you send, processed_at is null while the event is still queued behind earlier ones — except user.define_outcome, user.custom_tool_result, and user.tool_result, which are processed on receipt and echoed back with processed_at already populated. The stream-only event_start / event_delta preview events (see § Live previews) carry only the id of the event they preview.

⚠️ Robust polling (raw HTTP). If you bypass the SDK and roll your own poll loop, don't rely on requests or httpx timeouts as wall-clock caps — they're per-chunk read timeouts, reset every time a byte arrives. A trickling response (heartbeats, a wedged chunked-encoding body, a misbehaving proxy) can keep the call blocked indefinitely even with timeout=(5, 60) or httpx.Timeout(120). Neither library has a "total wall-clock" timeout built in. For a hard deadline: track time.monotonic() at the loop level and break/cancel if a single request exceeds your budget (e.g. via a watchdog thread, or asyncio.wait_for() around async httpx). Prefer the SDKclient.beta.sessions.events.stream() and client.beta.sessions.events.list() handle timeout + retry sanely.

If GET /v1/sessions/{id}/events (paginated) ever hangs after headers, you've likely hit GET /v1/sessions/{id}/events by mistake or a server-side stall — report it; don't treat it as a client-config problem.

Event Types (Received)

Event types use dot notation, grouped by namespace:

Event Type Description
agent.message Agent text output
agent.thinking Progress signal that the agent is thinking — it does not carry the thinking content
agent.tool_use Agent used a built-in tool (agent_toolset_20260401)
agent.tool_result Result from a built-in tool
agent.mcp_tool_use Agent used an MCP tool
agent.mcp_tool_result Result from an MCP tool
agent.custom_tool_use Agent invoked a custom tool — session goes idle, you respond with user.custom_tool_result
agent.thread_context_compacted Conversation context was compacted
session.status_idle Agent has finished the current task, and is awaiting input. It's either waiting for input to continue working via a user.message or blocked awaiting a user.custom_tool_result or user.tool_confirmation. The stop_reason attached contains more information about why the Agent has stopped working.
session.status_running Session has starting running, and the Agent is actively doing work.
session.status_rescheduled Session is (re)scheduling after a retryable error has occurred, ready to be picked up by the orchestration system.
session.status_terminated Session ended and is irreversibly unusable — on completion or on error, not error-only.
session.error Error occurred during processing
span.model_request_start Model inference started
span.model_request_end Model inference completed
span.outcome_evaluation_start / _ongoing / _end Grader progress for outcome-oriented sessions — see shared/managed-agents-outcomes.md
session.thread_created Subagent thread spawned (multiagent) — see shared/managed-agents-multiagent.md
session.thread_status_running / _idle / _rescheduled / _terminated Subagent thread status transitions (multiagent). _idle carries stop_reason.
agent.thread_message_sent / _received Cross-thread message, carries to_session_thread_id / from_session_thread_id (multiagent)

The stream also echoes back user-sent events (user.message, user.interrupt, user.tool_confirmation, user.custom_tool_result, user.define_outcome).

Stream-only delta preview events (event_start, event_delta) are the one exception to the {domain}.{action} naming convention — see § Live previews below; they never appear in GET /v1/sessions/{id}/events.


Live previews

By default, assistant text reaches the stream as buffered agent.message events — emitted only after the model request that produced them finishes. Live previews let you render that text incrementally while the model is still generating. The buffered agent.message is always the authoritative record; a client that ignores previews still receives a complete, correct stream. The wire format is not Messages-API streaming: the delta type is content_delta, not content_block_delta, so Messages-API accumulator code does not carry over unchanged.

Opt in per stream connection by adding the event_deltas[] query parameter, repeated once per event type to preview. Accepted values: agent.message, agent.thinking — any other value returns a 400, as does a request with more than 100 values. Both stream endpoints accept it: the session-level stream (GET /v1/sessions/{id}/events/stream) and each session thread's own stream (GET /v1/sessions/{sid}/threads/{tid}/stream). In a shell, quote the URL or percent-encode the brackets as %5B%5D — bare [] is a glob pattern.

Previews are thread-scoped. A connection previews only the thread it is reading. A child thread's previews are delivered on that child's stream and are never cross-posted to the session-level stream, whose previews stay scoped to the primary thread. To watch a subagent's text as the model generates it, open that subagent's thread stream — see shared/managed-agents-multiagent.md. Run one accumulator instance per connection.

stream = client.beta.sessions.events.stream(
    session_id=session.id,
    event_deltas=["agent.message"],
)

When a previewed event begins, the stream emits an event_start carrying the upcoming event's type and id; for agent.message it's followed by event_delta events carrying incremental text:

{"type": "event_start", "event": {"type": "agent.message", "id": "sevt_01abc..."}}
{"type": "event_delta", "event_id": "sevt_01abc...", "delta": {"type": "content_delta", "index": 0, "content": {"type": "text", "text": "Here is the summary"}}}

event_start and event_delta have no id or processed_at of their own — the only identifier they carry is the id of the event they preview. For agent.thinking, only the event_start is emitted (a "thinking has started" signal) — no deltas follow, and the buffered agent.thinking that concludes the preview carries no thinking content either. It is a progress signal, not a content carrier; there is nothing to read out of it.

Accumulate-and-reconcile pattern. Treat the preview as a scratch buffer keyed by (event_id, index). On event_start, create an empty entry for the announced id. On each event_delta, append delta.content.text to (event_id, delta.index) and render the running text. When the buffered agent.message arrives, match it by id, discard the accumulated preview, and render the message's content instead. The identifiers always line up: event_start.event.id, every event_delta.event_id, and the buffered event's id are the same value. On a normal turn the order is fixed: session.status_runningspan.model_request_startevent_startevent_delta* → buffered agent.messagespan.model_request_end. If the turn errors or is interrupted the buffered event may never arrive, but span.model_request_end still does — close any unreconciled preview when you see it. Python/TypeScript/Go SDKs ship an accumulator helper that implements this; in other SDKs apply the manual pattern to the generated event types.

Two guarantees the pattern relies on: concatenating a preview's deltas in arrival order, keyed by (event_id, index), yields a prefix of content[index].text in the buffered event (a prefix, not necessarily the whole text — deltas may be shed under load); and a connection emits at most one event_start per event_id, with the buffered event as the last thing that connection delivers for that id.

Limitations:

  • Best effort — under load the server may shed deltas for an event; you receive a contiguous prefix and then no further deltas for that event. The buffered agent.message still arrives complete. Never treat an accumulated preview as final.
  • No replay on reconnect — deltas are delivered only to the connection that opted in, while it's open; this holds for the session-level stream and each thread stream alike. A connection opened after a model request started receives no deltas for that in-flight event. After a drop, follow the consolidation pattern in § Reconnecting after a dropped stream — the history fetch returns any buffered events emitted during the gap; missed deltas cannot be re-requested.
  • One thread, text only — previews cover assistant text on the thread the connection is reading. Tool use, tool results, MCP results, and activity on any other thread are never previewed on that connection.
  • Never persistedevent_start / event_delta exist only on the live SSE stream, never in GET /v1/sessions/{id}/events or any thread's event history.

Troubleshooting:

You see What it means
Buffered events but no event_start / event_delta This connection didn't opt in (event_deltas[] is per connection, not per session), or the turn ran on a different thread. List GET /v1/sessions/{sid}/threads to find which one ran.
404 on the stream URL Wrong path or ID, or the request carries no managed-agents beta header — the thread endpoints are beta-gated, so without it they don't exist. The thread path is /threads/{tid}/stream, not /threads/{tid}/events/stream (which doesn't exist) and not /events/stream (session level only).
400 naming event_deltas Only agent.message and agent.thinking are accepted, max 100 values.

Steering Patterns

Practical patterns for driving a session via the events surface.

Stream-first ordering

Open the stream before sending events. The stream only delivers events that occur after it's opened — it does not replay current state or historical events. If you send a message first and open the stream second, early events (including fast status transitions) arrive buffered in a single batch and you lose the ability to react to them in real time.

// ✅ Correct — stream and send concurrently
const [response] = await Promise.all([
  streamEvents(sessionId),   // opens SSE connection
  sendMessage(sessionId, text),
]);

// ❌ Wrong — events before stream opens arrive as a single buffered batch
await sendMessage(sessionId, text);
const response = await streamEvents(sessionId);

For full history, use GET /v1/sessions/{id}/events (paginated list) — the stream only gives you live events from connection onward.

Reconnecting after a dropped stream

The SSE stream has no replay. If your connection drops (httpx read timeout, network blip) and you reconnect, you only get events emitted after reconnection. Any events emitted during the gap are lost from the stream.

The consolidation pattern: on every (re)connect, overlap the stream with a history fetch and dedupe by event ID:

def connect_with_consolidation(client, session_id):
    # 1. Open the SSE stream first
    stream = client.beta.sessions.events.stream(session_id=session_id)

    # 2. Fetch history to cover any gap
    history = client.beta.sessions.events.list(
        session_id=session_id,
    )

    # 3. Yield history first, then stream — dedupe by event.id
    seen = set()
    for ev in history.data:
        seen.add(ev.id)
        yield ev
    for ev in stream:
        if ev.id not in seen:
            seen.add(ev.id)
            yield ev

Message queuing

You don't have to wait for a response before sending the next message. User events are queued server-side and processed in order. This is useful for chat bridges where the user sends rapid follow-ups:

// All three go into one session; agent processes them in order
await sendMessage(sessionId, "Summarize the README");
await sendMessage(sessionId, "Actually also check the CONTRIBUTING guide");
await sendMessage(sessionId, "And compare the two");
// Stream once — agent responds to all three as a coherent turn

Events can be sent up to the Session at any time. There is no need to wait on a specific session status to enqueue new events via client.beta.sessions.events.send()

Interrupt

A user.interrupt event jumps the queue (ahead of any pending user messages) and forces the session into idle. Use this for "stop" / "nevermind" / "cancel" commands:

await client.beta.sessions.events.send(sessionId, {
  events: [{ type: 'user.interrupt' }],
});

The agent stops mid-task. It does not see the interrupt as a message — it just halts. Send a follow-up user event to explain what to do instead. If an outcome is active, the interrupt also marks span.outcome_evaluation_end.result: "interrupted" (see shared/managed-agents-outcomes.md).

The interrupted turn ends with stop_reason: end_turn — the same value a turn that finishes on its own carries. There is no interruption-specific stop reason, so a drain loop can't distinguish the two from stop_reason alone; track that you sent the interrupt.

In a multiagent session, omitting session_thread_id interrupts every non-archived thread, including the primary — it is not primary-only. Pass session_thread_id to stop one thread. See shared/managed-agents-multiagent.md.

Note

: Interrupt events may have empty IDs in the current implementation. When troubleshooting, use the processed_at timestamp along with surrounding event IDs.

Event payloads

some events carry useful metadata beyond the status change itself:

session.status_idle — includes a stop_reason field which elaborates on why the session stopped and what type of further action is required by the user.

{
  "id": "sevt_456",
  "processed_at": "2026-04-07T04:27:43.197Z",
  "stop_reason": {
    "event_ids": [
      "sevt_123"
    ],
    "type": "requires_action"
  },
  "type": "status_idle"
}

span.model_request_end contains a model_usage field for cost tracking and efficiency analysis:

{
  "type": "span.model_request_end",
  "id": "sevt_456",
  "is_error": false,
  "model_request_start_id": "sevt_123",
  "model_usage": {
    "cache_creation_input_tokens": 0,
    "cache_read_input_tokens": 6656,
    "input_tokens": 3571,
    "output_tokens": 727
  },
  "processed_at": "2026-04-07T04:11:32.189Z"
}

agent.thread_context_compacted — emitted when the conversation history was summarized to fit context. Includes pre_compaction_tokens so you know how much was squeezed:

{
  "id": "sevt_abc123",
  "processed_at": "2026-03-24T14:05:15.787Z",
  "type": "agent.thread_context_compacted"
}

Archive

When done with a session, archive it to free resources:

await client.beta.sessions.archive(sessionId);

Archiving a session is routine cleanup — sessions are per-run and disposable. Do not generalize this to agents or environments: those are persistent, reusable resources, and archiving them is permanent (no unarchive; new sessions cannot reference them). See shared/managed-agents-overview.md → Common Pitfalls.