Additional scenarios
Handling custom tool calls
When the agent invokes a custom tool:
- The session emits an
agent.custom_tool_useevent containing the tool name and input. - The session pauses with a
session.status_idleevent containingstop_reason: requires_action. The blocking event IDs are in thestop_reason.event_idsarray. - Execute the tool in your system and send a
user.custom_tool_resultevent for each, passing the event ID in thecustom_tool_use_idparameter along with the result content. - Once all blocking events are resolved, the session transitions back to
running.
exec {stream_fd}< <(curl --fail-with-body -sS -N \
"https://api.anthropic.com/v1/sessions/$SESSION_ID/events/stream?beta=true" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-H "accept: text/event-stream")
while IFS= read -r -u "$stream_fd" line; do
[[ $line == data:* ]] || continue
event_json="${line#data: }"
stop_reason=$(jq -r 'select(.type == "session.status_idle") | .stop_reason.type // empty' <<<"$event_json")
case "$stop_reason" in
requires_action)
while IFS= read -r event_id; do
# Execute the tool and send the result back
result=$(call_tool "$event_id")
jq -n --arg id "$event_id" --arg result "$result" \
'{events: [{type: "user.custom_tool_result", custom_tool_use_id: $id, content: [{type: "text", text: $result}]}]}' |
curl --fail-with-body -sS \
"https://api.anthropic.com/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-d @-
done < <(jq -r '.stop_reason.event_ids[]' <<<"$event_json")
;;
end_turn)
break
;;
esac
done
exec {stream_fd}<&-# This workflow does not translate well to a one-off shell command.
# Use one of the SDK examples in this code group instead.with client.beta.sessions.events.stream(session.id) as stream:
for event in stream:
if event.type == "session.status_idle" and (stop_reason := event.stop_reason):
match stop_reason.type:
case "requires_action":
for event_id in stop_reason.event_ids:
# Look up the custom tool use event and execute it
tool_event = events_by_id[event_id]
result = call_tool(tool_event.name, tool_event.input)
# Send the result back
client.beta.sessions.events.send(
session.id,
events=[
{
"type": "user.custom_tool_result",
"custom_tool_use_id": event_id,
"content": [{"type": "text", "text": result}],
},
],
)
case "end_turn":
breakconst stream = await client.beta.sessions.events.stream(session.id);
for await (const event of stream) {
if (event.type !== "session.status_idle") continue;
if (event.stop_reason.type === "end_turn") break;
if (event.stop_reason.type !== "requires_action") continue;
for (const eventId of event.stop_reason.event_ids) {
// Look up the custom tool use event and execute it
const toolEvent = eventsById.get(eventId);
if (!toolEvent) continue;
const result = await callTool(toolEvent.name, toolEvent.input);
// Send the result back
await client.beta.sessions.events.send(session.id, {
events: [
{
type: "user.custom_tool_result",
custom_tool_use_id: eventId,
content: [{ type: "text", text: result }],
},
],
});
}
}await foreach (var streamEvent in client.Beta.Sessions.Events.StreamStreaming(session.ID))
{
if (streamEvent.Value is not BetaManagedAgentsSessionStatusIdleEvent idle) continue;
if (idle.StopReason?.Value is BetaManagedAgentsSessionRequiresAction requiresAction)
{
foreach (var eventId in requiresAction.EventIds)
{
// Look up the custom tool use event and execute it
var toolEvent = eventsById[eventId];
var result = await CallTool(toolEvent.Name, toolEvent.Input);
// Send the result back
await client.Beta.Sessions.Events.Send(session.ID, new()
{
Events =
[
new BetaManagedAgentsUserCustomToolResultEventParams
{
Type = BetaManagedAgentsUserCustomToolResultEventParamsType.UserCustomToolResult,
CustomToolUseID = eventId,
Content =
[
new BetaManagedAgentsTextBlock
{
Type = BetaManagedAgentsTextBlockType.Text,
Text = result,
},
],
},
],
});
}
}
else if (idle.StopReason?.Value is BetaManagedAgentsSessionEndTurn)
{
break;
}
} stream := client.Beta.Sessions.Events.StreamEvents(ctx, session.ID, anthropic.BetaSessionEventStreamParams{})
defer stream.Close()
loop:
for stream.Next() {
event, ok := stream.Current().AsAny().(anthropic.BetaManagedAgentsSessionStatusIdleEvent)
if !ok {
continue
}
switch stopReason := event.StopReason.AsAny().(type) {
case anthropic.BetaManagedAgentsSessionRequiresAction:
for _, eventID := range stopReason.EventIDs {
// Look up the custom tool use event and execute it
toolEvent := eventsByID[eventID]
result := callTool(toolEvent.Name, toolEvent.Input)
// Send the result back
if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, anthropic.BetaSessionEventSendParams{
Events: []anthropic.BetaManagedAgentsEventParamsUnion{{
OfUserCustomToolResult: &anthropic.BetaManagedAgentsUserCustomToolResultEventParams{
Type: anthropic.BetaManagedAgentsUserCustomToolResultEventParamsTypeUserCustomToolResult,
CustomToolUseID: eventID,
Content: []anthropic.BetaManagedAgentsUserCustomToolResultEventParamsContentUnion{{
OfText: &anthropic.BetaManagedAgentsTextBlockParam{
Type: anthropic.BetaManagedAgentsTextBlockTypeText,
Text: result,
},
}},
},
}},
}); err != nil {
panic(err)
}
}
case anthropic.BetaManagedAgentsSessionEndTurn:
break loop
}
}
if err := stream.Err(); err != nil {
panic(err)
}try (var stream = client.beta().sessions().events().streamStreaming(session.id())) {
stream.stream()
.filter(BetaManagedAgentsStreamSessionEvents::isSessionStatusIdle)
.map(idleEvent -> idleEvent.asSessionStatusIdle().stopReason())
.takeWhile(stopReason -> !stopReason.isEndTurn())
.filter(stopReason -> stopReason.isRequiresAction())
.flatMap(stopReason -> stopReason.asRequiresAction().eventIds().stream())
.forEach(eventId -> {
// Look up the custom tool use event and execute it
var toolEvent = eventsById.get(eventId);
var result = callTool(toolEvent.name(), toolEvent.input());
// Send the result back
client.beta().sessions().events().send(
session.id(),
EventSendParams.builder()
.addEvent(BetaManagedAgentsUserCustomToolResultEventParams.builder()
.type(BetaManagedAgentsUserCustomToolResultEventParams.Type.USER_CUSTOM_TOOL_RESULT)
.customToolUseId(eventId)
.addTextContent(result)
.build())
.build());
});
}$stream = $client->beta->sessions->events->streamStream($session->id);
foreach ($stream as $event) {
if ($event->type === 'session.status_idle' && $event->stopReason) {
if ($event->stopReason->type === 'requires_action') {
foreach ($event->stopReason->eventIDs as $eventId) {
// Look up the custom tool use event and execute it
$toolEvent = $eventsById[$eventId];
$result = callTool($toolEvent->name, $toolEvent->input);
// Send the result back
$client->beta->sessions->events->send(
$session->id,
events: [
[
'type' => 'user.custom_tool_result',
'custom_tool_use_id' => $eventId,
'content' => [['type' => 'text', 'text' => $result]],
],
],
);
}
} elseif ($event->stopReason->type === 'end_turn') {
break;
}
}
}client.beta.sessions.events.stream_events(session.id).each do |event|
case event
in {type: :"session.status_idle", stop_reason: {type: :requires_action, event_ids:}}
event_ids.each do |event_id|
# Look up the custom tool use event and execute it
tool_event = events_by_id[event_id]
result = call_tool.call(tool_event.name, tool_event.input)
# Send the result back
client.beta.sessions.events.send_(
session.id,
events: [
{
type: "user.custom_tool_result",
custom_tool_use_id: event_id,
content: [{type: "text", text: result}]
}
]
)
end
in {type: :"session.status_idle", stop_reason: {type: :end_turn}}
break
else
end
endTool confirmation
A tool call waits for your confirmation under an always_ask permission policy, or under auto when the server reaches no determination. When that happens:
- The session emits an
agent.tool_useoragent.mcp_tool_useevent. - The session pauses with a
session.status_idleevent whosestop_reason.typeisrequires_action. The blocking event IDs are in thestop_reason.event_idsarray. - Send a
user.tool_confirmationevent for each, passing the event ID in thetool_use_idparameter. Setresultto"allow"or"deny". Usedeny_messageto explain a denial. - Once all blocking events are resolved, the session transitions back to
running.
Each agent.tool_use and agent.mcp_tool_use event carries evaluated_permission (allow, ask, or deny), and only events whose evaluated_permission is "ask" wait for a confirmation. Most events also carry an evaluation object that records which policy produced that outcome, described under See how each call was evaluated. For example, a bash call paused under an always_ask policy appears on the stream as follows:
{
"type": "agent.tool_use",
"id": "sevt_01def...",
"name": "bash",
"input": {
"command": "pip install -r requirements.txt"
},
"evaluated_permission": "ask",
"evaluation": {
"type": "always_ask"
},
"processed_at": "2026-03-25T14:01:45Z"
}exec {stream_fd}< <(curl --fail-with-body -sS -N \
"https://api.anthropic.com/v1/sessions/$SESSION_ID/events/stream?beta=true" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-H "accept: text/event-stream")
while IFS= read -r -u "$stream_fd" line; do
[[ $line == data:* ]] || continue
event_json="${line#data: }"
stop_reason=$(jq -r 'select(.type == "session.status_idle") | .stop_reason.type // empty' <<<"$event_json")
case "$stop_reason" in
requires_action)
while IFS= read -r event_id; do
# Approve the pending tool call
jq -n --arg id "$event_id" \
'{events: [{type: "user.tool_confirmation", tool_use_id: $id, result: "allow"}]}' |
curl --fail-with-body -sS \
"https://api.anthropic.com/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-d @-
done < <(jq -r '.stop_reason.event_ids[]' <<<"$event_json")
;;
end_turn)
break
;;
esac
done
exec {stream_fd}<&-# This workflow does not translate well to a one-off shell command.
# Use one of the SDK examples in this code group instead.with client.beta.sessions.events.stream(session.id) as stream:
for event in stream:
if event.type == "session.status_idle" and (stop_reason := event.stop_reason):
match stop_reason.type:
case "requires_action":
for event_id in stop_reason.event_ids:
# Approve the pending tool call
client.beta.sessions.events.send(
session.id,
events=[
{
"type": "user.tool_confirmation",
"tool_use_id": event_id,
"result": "allow",
},
],
)
case "end_turn":
breakconst stream = await client.beta.sessions.events.stream(session.id);
for await (const event of stream) {
if (event.type !== "session.status_idle") continue;
if (event.stop_reason.type === "end_turn") break;
if (event.stop_reason.type !== "requires_action") continue;
for (const eventId of event.stop_reason.event_ids) {
// Approve the pending tool call
await client.beta.sessions.events.send(session.id, {
events: [
{
type: "user.tool_confirmation",
tool_use_id: eventId,
result: "allow",
},
],
});
}
}await foreach (var streamEvent in client.Beta.Sessions.Events.StreamStreaming(session.ID))
{
if (streamEvent.Value is not BetaManagedAgentsSessionStatusIdleEvent idle) continue;
if (idle.StopReason?.Value is BetaManagedAgentsSessionRequiresAction requiresAction)
{
foreach (var eventId in requiresAction.EventIds)
{
// Approve the pending tool call
await client.Beta.Sessions.Events.Send(session.ID, new()
{
Events =
[
new BetaManagedAgentsUserToolConfirmationEventParams
{
Type = BetaManagedAgentsUserToolConfirmationEventParamsType.UserToolConfirmation,
ToolUseID = eventId,
Result = BetaManagedAgentsUserToolConfirmationEventParamsResult.Allow,
},
],
});
}
}
else if (idle.StopReason?.Value is BetaManagedAgentsSessionEndTurn)
{
break;
}
} stream := client.Beta.Sessions.Events.StreamEvents(ctx, session.ID, anthropic.BetaSessionEventStreamParams{})
defer stream.Close()
loop:
for stream.Next() {
event, ok := stream.Current().AsAny().(anthropic.BetaManagedAgentsSessionStatusIdleEvent)
if !ok {
continue
}
switch stopReason := event.StopReason.AsAny().(type) {
case anthropic.BetaManagedAgentsSessionRequiresAction:
for _, eventID := range stopReason.EventIDs {
// Approve the pending tool call
if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, anthropic.BetaSessionEventSendParams{
Events: []anthropic.BetaManagedAgentsEventParamsUnion{{
OfUserToolConfirmation: &anthropic.BetaManagedAgentsUserToolConfirmationEventParams{
Type: anthropic.BetaManagedAgentsUserToolConfirmationEventParamsTypeUserToolConfirmation,
ToolUseID: eventID,
Result: anthropic.BetaManagedAgentsUserToolConfirmationEventParamsResultAllow,
},
}},
}); err != nil {
panic(err)
}
}
case anthropic.BetaManagedAgentsSessionEndTurn:
break loop
}
}
if err := stream.Err(); err != nil {
panic(err)
}try (var stream = client.beta().sessions().events().streamStreaming(session.id())) {
stream.stream()
.filter(BetaManagedAgentsStreamSessionEvents::isSessionStatusIdle)
.map(idleEvent -> idleEvent.asSessionStatusIdle().stopReason())
.takeWhile(stopReason -> !stopReason.isEndTurn())
.filter(stopReason -> stopReason.isRequiresAction())
.flatMap(stopReason -> stopReason.asRequiresAction().eventIds().stream())
// Approve each pending tool call
.forEach(toolUseId -> client.beta().sessions().events().send(
session.id(),
EventSendParams.builder()
.addEvent(BetaManagedAgentsUserToolConfirmationEventParams.builder()
.type(BetaManagedAgentsUserToolConfirmationEventParams.Type.USER_TOOL_CONFIRMATION)
.toolUseId(toolUseId)
.result(BetaManagedAgentsUserToolConfirmationEventParams.Result.ALLOW)
.build())
.build()));
}$stream = $client->beta->sessions->events->streamStream($session->id);
foreach ($stream as $event) {
if ($event->type === 'session.status_idle' && $event->stopReason) {
if ($event->stopReason->type === 'requires_action') {
foreach ($event->stopReason->eventIDs as $eventId) {
// Approve the pending tool call
$client->beta->sessions->events->send(
$session->id,
events: [
[
'type' => 'user.tool_confirmation',
'tool_use_id' => $eventId,
'result' => 'allow',
],
],
);
}
} elseif ($event->stopReason->type === 'end_turn') {
break;
}
}
}client.beta.sessions.events.stream_events(session.id).each do |event|
case event
in {type: :"session.status_idle", stop_reason: {type: :requires_action, event_ids:}}
event_ids.each do |event_id|
# Approve the pending tool call
client.beta.sessions.events.send_(
session.id,
events: [
{type: "user.tool_confirmation", tool_use_id: event_id, result: "allow"}
]
)
end
in {type: :"session.status_idle", stop_reason: {type: :end_turn}}
break
else
end
endResuming an idle session
Sessions persist between interactions. Conversation history is preserved unless the session is explicitly deleted. When a session goes idle, its sandbox is checkpointed, preserving the full sandbox state, including the filesystem, installed packages, and any files the agent created. This allows you to resume cleanly from inactivity.
While session history is persisted until deleted, sandbox state is only preserved for 30 days after the sandbox is created. Activity does not extend this window: after 30 days the sandbox state (files, installed tools, and so on) is unrecoverable, and a resumed session starts from a fresh sandbox. If your workflow depends on sandbox contents, have the agent write important artifacts to outputs before the window ends.
To resume a session, send a user.message event to it as usual:
# In production, pass the stored ID of the session you want to resume.
curl --fail-with-body -sS "https://api.anthropic.com/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-d @- <<'EOF'
{
"events": [
{
"type": "user.message",
"content": [
{"type": "text", "text": "Now run the tests against the changes you made earlier."}
]
}
]
}
EOF# In production, pass the stored ID of the session you want to resume.
ant beta:sessions:events send --session-id "$SESSION_ID" <<'YAML'
events:
- type: user.message
content:
- type: text
text: Now run the tests against the changes you made earlier.
YAML# Resume a previously created session by sending it a new user.message event.
# In production, pass the stored ID of the session you want to resume.
client.beta.sessions.events.send(
session.id,
events=[
{
"type": "user.message",
"content": [
{
"type": "text",
"text": "Now run the tests against the changes you made earlier.",
},
],
},
],
)// Resume a previously created session by sending it a new user event.
// In production, pass the stored ID of the session you want to resume.
await client.beta.sessions.events.send(session.id, {
events: [
{
type: "user.message",
content: [
{
type: "text",
text: "Now run the tests against the changes you made earlier.",
},
],
},
],
});// Resume a previously created session by ID. In production, pass the
// session ID you stored when the session was created.
await client.Beta.Sessions.Events.Send(session.ID, new()
{
Events =
[
new BetaManagedAgentsUserMessageEventParams
{
Type = BetaManagedAgentsUserMessageEventParamsType.UserMessage,
Content =
[
new BetaManagedAgentsTextBlock
{
Type = BetaManagedAgentsTextBlockType.Text,
Text = "Now run the tests against the changes you made earlier.",
},
],
},
],
});// Resume a previously created session by sending it a new user.message
// event. In production, pass the stored ID of the session to resume.
if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, anthropic.BetaSessionEventSendParams{
Events: []anthropic.BetaManagedAgentsEventParamsUnion{{
OfUserMessage: &anthropic.BetaManagedAgentsUserMessageEventParams{
Type: anthropic.BetaManagedAgentsUserMessageEventParamsTypeUserMessage,
Content: []anthropic.BetaManagedAgentsUserMessageEventParamsContentUnion{{
OfText: &anthropic.BetaManagedAgentsTextBlockParam{
Type: anthropic.BetaManagedAgentsTextBlockTypeText,
Text: "Now run the tests against the changes you made earlier.",
},
}},
},
}},
}); err != nil {
panic(err)
}// Resume a previously created session by ID. In production, pass the
// session ID you stored when the session was created.
client.beta().sessions().events().send(
session.id(),
EventSendParams.builder()
.addEvent(BetaManagedAgentsUserMessageEventParams.builder()
.type(BetaManagedAgentsUserMessageEventParams.Type.USER_MESSAGE)
.addTextContent("Now run the tests against the changes you made earlier.")
.build())
.build());// Resume a previously created session by sending it a new user.message event.
// In production, pass the session ID you stored when the session was created.
$client->beta->sessions->events->send(
$session->id,
events: [
[
'type' => 'user.message',
'content' => [
[
'type' => 'text',
'text' => 'Now run the tests against the changes you made earlier.',
],
],
],
],
);# Resuming a session is just sending the next event to it. In production,
# pass the session ID you stored when the session was created.
client.beta.sessions.events.send_(
session.id,
events: [
{
type: "user.message",
content: [
{type: "text", text: "Now run the tests against the changes you made earlier."}
]
}
]
)Reaching a session budget
A session created with a budget pauses instead of overspending. When the session's tracked list cost reaches the cap, the platform pauses each thread before its next model request, and the session goes idle with a stop_reason of budget_reached rather than terminating. The request that carried the total past the cap runs to completion, so the list_cost reported by the session.usage snapshot can read at or a fraction past the cap. On the stream, the pause arrives as three events, in order:
session.thread_status_idlewithstop_reason: budget_reached, for each thread as it pauses.session.usage, a snapshot of the session's cumulative usage and tracked list cost.session.status_idlewithstop_reason: budget_reached. Thesession.usageevent always immediately precedes this idle.
A thread whose final request both crosses the cap and completes its turn reports end_turn on its own session.thread_status_idle event while the session still reports budget_reached; key on the session-level stop_reason to detect the pause.
While the session is at its cap, it accepts only the events that settle work already in flight: user.tool_confirmation, user.tool_result, user.custom_tool_result, and user.interrupt. Any event that would start new work, including user.message, is rejected with a 400 error naming that list. When a session has both a thread waiting on a tool ask and a thread paused at the cap, the session-level stop_reason is requires_action, not budget_reached: settling the ask doesn't trigger a model request, so respond to it as usual.
No event resumes a session paused at its cap. Instead, update the session's budget: changing the cap to any value above the consumed list cost, or removing the budget by updating the session with "budget": null, resumes the paused work automatically. See Session budgets for how list cost is tracked and the full budget update semantics.
Sending system messages
system.message is supported by Claude Fable 5.1, Claude Mythos 5.1, Claude Fable 5, Claude Mythos 5, Claude Opus 5, and Claude Opus 4.8. If the agent's primary model does not support mid-conversation system injection, the event is rejected with a model_does_not_support_mid_conversation_system validation error. Subagent models are not checked, because system.message lands on the primary thread only.
Send a system.message event to give the agent privileged system-level context that applies to the accompanying turn and all subsequent turns. Unlike the system field on the agent definition (which sets the top-level system prompt), system.message content is appended to the session's system context as a role: "system" turn rather than replacing that prompt. Use it when the agent needs updated system-level guidance mid-session: a different persona, revised constraints, or context fetched at runtime that should shape the model's behavior going forward.
curl --fail-with-body -sS "https://api.anthropic.com/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "anthropic-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-d @- <<'EOF'
{
"events": [
{
"type": "system.message",
"content": [
{"type": "text", "text": "The user's current timezone is America/New_York."}
]
}
]
}
EOFant beta:sessions:events send --session-id "$SESSION_ID" <<'YAML'
events:
- type: system.message
content:
- type: text
text: "The user's current timezone is America/New_York."
YAMLclient.beta.sessions.events.send(
session.id,
events=[
{
"type": "system.message",
"content": [
{
"type": "text",
"text": "The user's current timezone is America/New_York.",
},
],
},
],
)await client.beta.sessions.events.send(session.id, {
events: [
{
type: "system.message",
content: [
{
type: "text",
text: "The user's current timezone is America/New_York.",
},
],
},
],
});await client.Beta.Sessions.Events.Send(session.ID, new()
{
Events =
[
new BetaManagedAgentsSystemMessageEventParams
{
Type = BetaManagedAgentsSystemMessageEventParamsType.SystemMessage,
Content =
[
new BetaManagedAgentsSystemContentBlock
{
Type = BetaManagedAgentsSystemContentBlockType.Text,
Text = "The user's current timezone is America/New_York.",
},
],
},
],
});if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, anthropic.BetaSessionEventSendParams{
Events: []anthropic.BetaManagedAgentsEventParamsUnion{{
OfSystemMessage: &anthropic.BetaManagedAgentsSystemMessageEventParams{
Type: anthropic.BetaManagedAgentsSystemMessageEventParamsTypeSystemMessage,
Content: []anthropic.BetaManagedAgentsSystemContentBlockParam{{
Type: anthropic.BetaManagedAgentsSystemContentBlockTypeText,
Text: "The user's current timezone is America/New_York.",
}},
},
}},
}); err != nil {
panic(err)
}client.beta().sessions().events().send(
session.id(),
EventSendParams.builder()
.addEvent(BetaManagedAgentsSystemMessageEventParams.builder()
.type(BetaManagedAgentsSystemMessageEventParams.Type.SYSTEM_MESSAGE)
.addTextContent("The user's current timezone is America/New_York.")
.build())
.build());$client->beta->sessions->events->send(
$session->id,
events: [
[
'type' => 'system.message',
'content' => [
[
'type' => 'text',
'text' => "The user's current timezone is America/New_York.",
],
],
],
],
);client.beta.sessions.events.send_(
session.id,
events: [
{
type: "system.message",
content: [
{type: "text", text: "The user's current timezone is America/New_York."}
]
}
]
)While the session is idle with stop_reason: requires_action, a system.message is accepted only when it trails a tool result event in the same request; sent on its own or with a user.message, it is rejected until the pending tool events are resolved. content accepts 1–1000 text items.
Tracking usage
The session object includes a usage field with the session's cumulative usage: token counts, server tool use, active time, and the tracked list cost. Fetch the session after it goes idle to read the latest totals.
{
"id": "sesn_01...",
"status": "idle",
"usage": {
"input_tokens": 5000,
"output_tokens": 3200,
"cache_read_input_tokens": 20000,
"cache_creation": {
"ephemeral_5m_input_tokens": 2000,
"ephemeral_1h_input_tokens": 0
},
"list_cost": {
"amount": "187",
"currency": "USD"
},
"active_seconds": 342.5,
"server_tool_use": {
"web_search_requests": 3,
"web_fetch_requests": 0
}
}
}input_tokens reports uncached input tokens and output_tokens reports total output tokens across all model calls in the session. The cache_read_input_tokens field reports tokens read from the prompt cache, and the cache_creation object breaks down cache-creation tokens by cache lifetime (ephemeral_5m_input_tokens and ephemeral_1h_input_tokens). Cache entries use a 5-minute TTL by default, so back-to-back turns within that window benefit from cache reads, which reduce per-token cost.
list_cost is the session's cumulative consumption priced at public list rates, as a whole number of cents in a string, with a currency code. active_seconds is the cumulative time during which the session had at least one thread running; overlapping activity from concurrent threads is counted once, unlike the active_seconds in the session's stats object, which sums each thread's own active time. This deduplicated figure is the duration the session's runtime cost is priced on. server_tool_use counts server-executed tool requests for pricing: web search requests are priced into list cost per request, and web fetch requests carry no per-request charge and aren't metered, so web_fetch_requests reads 0. Each session thread's own usage carries list_cost and active_seconds too. Per-thread figures are rounded independently and exclude the session's running-time cost, so they don't sum exactly to the session's list_cost; the session figure is the authoritative one.
You don't have to poll the session to observe these totals. The session.usage event carries the same cumulative snapshot (the usage object, plus the session's budget, which is null when the session has none) on the session stream and in the event history. It is emitted on idle transitions rather than on a timer: the session emits one immediately before it goes idle, whatever the stop reason, and one when a thread pauses at a session budget. A stream reader therefore sees the final cost of a turn, or of the work that hit a budget, without an extra fetch.
To enforce a spend limit, set a session budget rather than polling usage and stopping the session yourself. The platform prices the session's consumption continuously and pauses each thread before its next model request once the session's list cost reaches the cap; see Reaching a session budget for what that looks like on the stream.