Communication with Haijun Managed Agents is event-based. You send user events to the agent, and receive agent and session events back to track status.
Event types
Events flow in two directions.
- User events and system events are what you send to the agent:
user.*events start a session and steer it as it progresses;system.messageappends system-level context that applies to the accompanying turn and all subsequent turns.
- Session events, span events, and agent events are sent to you for observability into your session state and agent progress. Stream connections that opt in also receive event deltas.
Session, span, agent, user, and system event type strings follow a {domain}.{action} naming convention. The stream-only delta preview events (event_start, event_delta) are the exception. See Event types in the reference for the full catalog. Webhook event types are separate, and some of their names differ from the stream's (for example, session.status_idled rather than session.status_idle).
Every persisted event includes a processed_at timestamp set when the event finishes processing. On events you send, processed_at is null while the event is still queued behind earlier events. The exceptions are user.define_outcome, user.custom_tool_result, and user.tool_result, which are processed on receipt and echoed back with processed_at already populated.
Integrating events
Sending events
Send a user.message event to start or continue the agent's work:
curl --fail-with-body -sS "https://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-d @- <<'EOF'
{
"events": [
{
"type": "user.message",
"content": [
{"type": "text", "text": "Analyze the performance of the sort function in utils.py"}
]
}
]
}
EOF ant beta:sessions:events send --session-id "$SESSION_ID" <<'YAML'
events:
- type: user.message
content:
- type: text
text: Analyze the performance of the sort function in utils.py
YAML client.beta.sessions.events.send(
session.id,
events=[
{
"type": "user.message",
"content": [
{
"type": "text",
"text": "Analyze the performance of the sort function in utils.py",
},
],
},
],
) await client.beta.sessions.events.send(session.id, {
events: [
{
type: "user.message",
content: [
{
type: "text",
text: "Analyze the performance of the sort function in utils.py",
},
],
},
],
}); await client.Beta.Sessions.Events.Send(session.ID, new()
{
Events =
[
new BetaManagedAgentsUserMessageEventParams
{
Type = BetaManagedAgentsUserMessageEventParamsType.UserMessage,
Content =
[
new BetaManagedAgentsTextBlock
{
Type = BetaManagedAgentsTextBlockType.Text,
Text = "Analyze the performance of the sort function in utils.py",
},
],
},
],
}); if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, juglow.BetaSessionEventSendParams{
Events: []juglow.BetaManagedAgentsEventParamsUnion{{
OfUserMessage: &juglow.BetaManagedAgentsUserMessageEventParams{
Type: juglow.BetaManagedAgentsUserMessageEventParamsTypeUserMessage,
Content: []juglow.BetaManagedAgentsUserMessageEventParamsContentUnion{{
OfText: &juglow.BetaManagedAgentsTextBlockParam{
Type: juglow.BetaManagedAgentsTextBlockTypeText,
Text: "Analyze the performance of the sort function in utils.py",
},
}},
},
}},
}); err != nil {
panic(err)
} client.beta().sessions().events().send(
session.id(),
EventSendParams.builder()
.addEvent(BetaManagedAgentsUserMessageEventParams.builder()
.type(BetaManagedAgentsUserMessageEventParams.Type.USER_MESSAGE)
.addTextContent("Analyze the performance of the sort function in utils.py")
.build())
.build()); $client->beta->sessions->events->send(
$session->id,
events: [
[
'type' => 'user.message',
'content' => [
[
'type' => 'text',
'text' => 'Analyze the performance of the sort function in utils.py',
],
],
],
],
); client.beta.sessions.events.send_(
session.id,
events: [
{
type: "user.message",
content: [
{
type: "text",
text: "Analyze the performance of the sort function in utils.py"
}
]
}
]
)Send a user.interrupt event to stop the agent mid-execution, then follow up with a user.message event to redirect it:
# Agent is currently analyzing a file...
# Interrupt with a new direction:
curl --fail-with-body -sS "https://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-d @- <<'EOF'
{
"events": [
{"type": "user.interrupt"},
{
"type": "user.message",
"content": [
{"type": "text", "text": "Instead, focus on fixing the bug in line 42."}
]
}
]
}
EOF # Agent is currently analyzing a file...
# Interrupt with a new direction:
ant beta:sessions:events send --session-id "$SESSION_ID" <<'YAML'
events:
- type: user.interrupt
- type: user.message
content:
- type: text
text: Instead, focus on fixing the bug in line 42.
YAML # Agent is currently analyzing a file...
# Interrupt with a new direction:
client.beta.sessions.events.send(
session.id,
events=[
{"type": "user.interrupt"},
{
"type": "user.message",
"content": [
{
"type": "text",
"text": "Instead, focus on fixing the bug in line 42.",
},
],
},
],
) // Agent is currently analyzing a file...
// Interrupt with a new direction:
await client.beta.sessions.events.send(session.id, {
events: [
{ type: "user.interrupt" },
{
type: "user.message",
content: [
{
type: "text",
text: "Instead, focus on fixing the bug in line 42.",
},
],
},
],
}); // Agent is currently analyzing a file...
// Interrupt with a new direction:
await client.Beta.Sessions.Events.Send(session.ID, new()
{
Events =
[
new BetaManagedAgentsUserInterruptEventParams
{
Type = BetaManagedAgentsUserInterruptEventParamsType.UserInterrupt,
},
new BetaManagedAgentsUserMessageEventParams
{
Type = BetaManagedAgentsUserMessageEventParamsType.UserMessage,
Content =
[
new BetaManagedAgentsTextBlock
{
Type = BetaManagedAgentsTextBlockType.Text,
Text = "Instead, focus on fixing the bug in line 42.",
},
],
},
],
}); // Agent is currently analyzing a file...
// Interrupt with a new direction:
if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, juglow.BetaSessionEventSendParams{
Events: []juglow.BetaManagedAgentsEventParamsUnion{
{
OfUserInterrupt: &juglow.BetaManagedAgentsUserInterruptEventParams{
Type: juglow.BetaManagedAgentsUserInterruptEventParamsTypeUserInterrupt,
},
},
{
OfUserMessage: &juglow.BetaManagedAgentsUserMessageEventParams{
Type: juglow.BetaManagedAgentsUserMessageEventParamsTypeUserMessage,
Content: []juglow.BetaManagedAgentsUserMessageEventParamsContentUnion{{
OfText: &juglow.BetaManagedAgentsTextBlockParam{
Type: juglow.BetaManagedAgentsTextBlockTypeText,
Text: "Instead, focus on fixing the bug in line 42.",
},
}},
},
},
},
}); err != nil {
panic(err)
} // Agent is currently analyzing a file...
// Interrupt with a new direction:
client.beta().sessions().events().send(
session.id(),
EventSendParams.builder()
.addEvent(BetaManagedAgentsUserInterruptEventParams.builder()
.type(BetaManagedAgentsUserInterruptEventParams.Type.USER_INTERRUPT)
.build())
.addEvent(BetaManagedAgentsUserMessageEventParams.builder()
.type(BetaManagedAgentsUserMessageEventParams.Type.USER_MESSAGE)
.addTextContent("Instead, focus on fixing the bug in line 42.")
.build())
.build()); // Agent is currently analyzing a file...
// Interrupt with a new direction:
$client->beta->sessions->events->send(
$session->id,
events: [
['type' => 'user.interrupt'],
[
'type' => 'user.message',
'content' => [
[
'type' => 'text',
'text' => 'Instead, focus on fixing the bug in line 42.',
],
],
],
],
); # Agent is currently analyzing a file...
# Interrupt with a new direction:
client.beta.sessions.events.send_(
session.id,
events: [
{type: "user.interrupt"},
{
type: "user.message",
content: [
{type: "text", text: "Instead, focus on fixing the bug in line 42."}
]
}
]
)The call returns as soon as the events are queued, and the interrupt's processed_at stays null until the agent applies it. A model response in progress stops immediately. The interrupt can take longer to apply while tool calls are running, and the session stays running until it does. The user.interrupt event then appears on the stream, and the interrupted turn ends with a session.status_idle event. Its stop_reason is end_turn, the same value as a turn that finishes on its own; there is no stop reason specific to interruption. The agent starts its next turn with the user.message you sent after the interrupt.
Streaming events
Stream events from the session to receive real-time updates as the agent works. Only events emitted after the stream is opened are delivered, so open the stream before sending events to avoid a race condition.
# Open the stream first, then send the user message
exec {stream}< <(
curl --fail-with-body -sS -N \
"https://haijun.my.id/v1/sessions/$SESSION_ID/events/stream?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-H "accept: text/event-stream"
)
curl --fail-with-body -sS \
"https://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-d @- >/dev/null <<'EOF'
{
"events": [
{
"type": "user.message",
"content": [{"type": "text", "text": "Summarize the repo README"}]
}
]
}
EOF
while IFS= read -r -u "$stream" event_line; do
[[ $event_line == data:* ]] || continue
event_json=${event_line#data: }
case $(jq -r '.type' <<<"$event_json") in
agent.message)
jq -j '.content[] | select(.type == "text") | .text' <<<"$event_json"
;;
session.status_idle)
break
;;
session.error)
printf '\n[Error: %s]\n' "$(jq -r '.error.message // "unknown"' <<<"$event_json")"
break
;;
esac
done
exec {stream}<&- # This workflow does not translate well to a one-off shell command.
# Use one of the SDK examples in this code group instead. # Open the stream first, then send the user message
with client.beta.sessions.events.stream(session.id) as stream:
client.beta.sessions.events.send(
session.id,
events=[
{
"type": "user.message",
"content": [{"type": "text", "text": "Summarize the repo README"}],
},
],
)
for event in stream:
match event.type:
case "agent.message":
for block in event.content:
if block.type == "text":
print(block.text, end="")
case "session.status_idle":
break
case "session.error":
error_message = event.error.message if event.error else "unknown"
print(f"\n[Error: {error_message}]")
break // Open the stream first, then send the user message
const stream = await client.beta.sessions.events.stream(session.id);
await client.beta.sessions.events.send(session.id, {
events: [
{
type: "user.message",
content: [{ type: "text", text: "Summarize the repo README" }]
}
]
});
events: for await (const event of stream) {
switch (event.type) {
case "agent.message":
for (const block of event.content) {
if (block.type === "text") {
process.stdout.write(block.text);
}
}
break;
case "session.status_idle":
break events;
case "session.error":
console.log(`\n[Error: ${event.error?.message ?? "unknown"}]`);
break events;
}
} // Open the stream first, then send the user message
using var stream = await client.Beta.Sessions.Events.WithRawResponse.StreamStreaming(session.ID);
await client.Beta.Sessions.Events.Send(session.ID, new()
{
Events =
[
new BetaManagedAgentsUserMessageEventParams
{
Type = BetaManagedAgentsUserMessageEventParamsType.UserMessage,
Content =
[
new BetaManagedAgentsTextBlock
{
Type = BetaManagedAgentsTextBlockType.Text,
Text = "Summarize the repo README",
},
],
},
],
});
await foreach (var streamEvent in stream.Enumerate())
{
if (streamEvent.Value is BetaManagedAgentsAgentMessageEvent message)
{
foreach (var block in message.Content)
{
if (block.Value is BetaManagedAgentsTextBlock textBlock)
{
Console.Write(textBlock.Text);
}
}
}
else if (streamEvent.Value is BetaManagedAgentsSessionStatusIdleEvent)
{
break;
}
else if (streamEvent.Value is BetaManagedAgentsSessionErrorEvent error)
{
Console.WriteLine($"\n[Error: {error.Error?.Message ?? "unknown"}]");
break;
}
} // Open the stream first, then send the user message
stream := client.Beta.Sessions.Events.StreamEvents(ctx, session.ID, juglow.BetaSessionEventStreamParams{})
defer stream.Close()
if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, juglow.BetaSessionEventSendParams{
Events: []juglow.BetaManagedAgentsEventParamsUnion{{
OfUserMessage: &juglow.BetaManagedAgentsUserMessageEventParams{
Type: juglow.BetaManagedAgentsUserMessageEventParamsTypeUserMessage,
Content: []juglow.BetaManagedAgentsUserMessageEventParamsContentUnion{{
OfText: &juglow.BetaManagedAgentsTextBlockParam{
Type: juglow.BetaManagedAgentsTextBlockTypeText,
Text: "Summarize the repo README",
},
}},
},
}},
}); err != nil {
panic(err)
}
events:
for stream.Next() {
switch event := stream.Current().AsAny().(type) {
case juglow.BetaManagedAgentsAgentMessageEvent:
// concrete-typed list: BetaManagedAgentsTextBlock
for _, block := range event.Content {
fmt.Print(block.Text)
}
case juglow.BetaManagedAgentsSessionStatusIdleEvent:
break events
case juglow.BetaManagedAgentsSessionErrorEvent:
fmt.Printf("\n[Error: %s]\n", cmp.Or(event.Error.Message, "unknown"))
break events
}
}
if err := stream.Err(); err != nil {
panic(err)
} // Open the stream first, then send the user message
try (var stream = client.beta().sessions().events().streamStreaming(session.id())) {
client.beta().sessions().events().send(
session.id(),
EventSendParams.builder()
.addEvent(BetaManagedAgentsUserMessageEventParams.builder()
.type(BetaManagedAgentsUserMessageEventParams.Type.USER_MESSAGE)
.addTextContent("Summarize the repo README")
.build())
.build()
);
Iterable<BetaManagedAgentsStreamSessionEvents> events = stream.stream()::iterator;
events:
for (var event : events) {
switch (event.type().value()) {
case AGENT_MESSAGE -> event.asAgentMessage().content().forEach(block -> block.text().ifPresent(textBlock -> IO.print(textBlock.text())));
case SESSION_STATUS_IDLE -> {
break events;
}
case SESSION_ERROR -> {
// The `message` field spans all error variants; read it from the raw JSON.
var errorMessage =
event.asSessionError().error()._json().orElse(null) instanceof JsonObject json
? json.values().get("message").asStringOrThrow()
: "unknown";
IO.println("\n[Error: " + errorMessage + "]");
break events;
}
}
}
} // Open the stream first, then send the user message
$stream = $client->beta->sessions->events->streamStream($session->id);
$client->beta->sessions->events->send(
$session->id,
events: [
[
'type' => 'user.message',
'content' => [['type' => 'text', 'text' => 'Summarize the repo README']],
],
],
);
foreach ($stream as $event) {
match (true) {
$event instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsAgentMessageEvent => array_walk(
$event->content,
static fn ($block) => $block instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsTextBlock ? print($block->text) : null,
),
$event instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsSessionErrorEvent => printf("\n[Error: %s]", $event->error?->message ?? 'unknown'),
default => null,
};
if ($event->type === 'session.status_idle' || $event->type === 'session.error') {
break;
}
}
$stream->close(); # Open the stream first, then send the user message
stream = client.beta.sessions.events.stream_events(session.id)
client.beta.sessions.events.send_(
session.id,
events: [{
type: "user.message",
content: [{type: "text", text: "Summarize the repo README"}]
}]
)
stream.each do |event|
case event
when Juglow::Beta::Sessions::BetaManagedAgentsAgentMessageEvent
event.content.each { print it.text }
when Juglow::Beta::Sessions::BetaManagedAgentsSessionStatusIdleEvent
break
when Juglow::Beta::Sessions::BetaManagedAgentsSessionErrorEvent
puts "\n[Error: #{event.error&.message || "unknown"}]"
break
else
# ignore other event types
end
endTo reconnect to an existing session without missing events:
- Open a new stream.
- List the full event history to seed a set of seen event IDs.
- Tail the live stream, skipping any events already returned by the history list.
exec {stream}< <(
curl --fail-with-body -sS -N \
"https://haijun.my.id/v1/sessions/$SESSION_ID/events/stream?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-H "accept: text/event-stream"
)
# Stream is open and buffering. List history before tailing live.
declare -A seen_event_ids
while IFS= read -r event_id; do
seen_event_ids[$event_id]=1
done < <(
curl --fail-with-body -sS \
"https://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" | jq -r '.data[].id'
)
# Tail live events, skipping anything already seen
while IFS= read -r -u "$stream" event_line; do
[[ $event_line == data:* ]] || continue
event_json=${event_line#data: }
event_id=$(jq -r '.id' <<<"$event_json")
[[ -n ${seen_event_ids[$event_id]+seen} ]] && continue
seen_event_ids[$event_id]=1
case $(jq -r '.type' <<<"$event_json") in
agent.message)
jq -j '.content[] | select(.type == "text") | .text' <<<"$event_json"
;;
session.status_idle)
break
;;
esac
done
exec {stream}<&- # 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:
# Stream is open and buffering. List history before tailing live.
history = client.beta.sessions.events.list(session.id)
seen_event_ids = {past_event.id for past_event in history}
# Tail live events, skipping anything already seen
for event in stream:
if event.type == "event_start" or event.type == "event_delta":
# Delta previews aren't enabled on this connection.
continue
if event.id in seen_event_ids:
continue
seen_event_ids.add(event.id)
match event.type:
case "agent.message":
for block in event.content:
if block.type == "text":
print(block.text, end="")
case "session.status_idle":
break const seenEventIds = new Set<string>();
const stream = await client.beta.sessions.events.stream(session.id);
// Stream is open and buffering. List history before tailing live.
for await (const event of client.beta.sessions.events.list(session.id)) {
seenEventIds.add(event.id);
}
// Tail live events, skipping anything already seen
tail: for await (const event of stream) {
// Preview events (event_start/event_delta) carry no top-level id
if (event.type === "event_start" || event.type === "event_delta") continue;
if (seenEventIds.has(event.id)) continue;
seenEventIds.add(event.id);
switch (event.type) {
case "agent.message":
for (const block of event.content) {
if (block.type === "text") {
process.stdout.write(block.text);
}
}
break;
case "session.status_idle":
break tail;
}
} using var stream = await client.Beta.Sessions.Events.WithRawResponse.StreamStreaming(session.ID);
// Stream is open and buffering. List history before tailing live.
HashSet<string> seenEventIds = [];
var history = await client.Beta.Sessions.Events.List(session.ID);
await foreach (var pastEvent in history.Paginate())
{
seenEventIds.Add(pastEvent.ID);
}
// Tail live events, skipping anything already seen
await foreach (var streamEvent in stream.Enumerate())
{
if (!seenEventIds.Add(streamEvent.ID))
{
continue;
}
if (streamEvent.Value is BetaManagedAgentsAgentMessageEvent message)
{
foreach (var block in message.Content)
{
if (block.Value is BetaManagedAgentsTextBlock textBlock)
{
Console.Write(textBlock.Text);
}
}
}
else if (streamEvent.Value is BetaManagedAgentsSessionStatusIdleEvent)
{
break;
}
} stream := client.Beta.Sessions.Events.StreamEvents(ctx, session.ID, juglow.BetaSessionEventStreamParams{})
defer stream.Close()
// Stream is open and buffering. List history before tailing live.
seenEventIDs := map[string]struct{}{}
history := client.Beta.Sessions.Events.ListAutoPaging(ctx, session.ID, juglow.BetaSessionEventListParams{})
for history.Next() {
seenEventIDs[history.Current().ID] = struct{}{}
}
if err := history.Err(); err != nil {
panic(err)
}
// Tail live events, skipping anything already seen
tail:
for stream.Next() {
event := stream.Current()
if _, seen := seenEventIDs[event.ID]; seen {
continue
}
seenEventIDs[event.ID] = struct{}{}
switch event := event.AsAny().(type) {
case juglow.BetaManagedAgentsAgentMessageEvent:
// concrete-typed list: BetaManagedAgentsTextBlock
for _, block := range event.Content {
fmt.Print(block.Text)
}
case juglow.BetaManagedAgentsSessionStatusIdleEvent:
break tail
}
}
if err := stream.Err(); err != nil {
panic(err)
} try (var stream = client.beta().sessions().events().streamStreaming(session.id())) {
// Stream is open and buffering. List history before tailing live.
// Every event variant carries `id`; read it from the raw JSON to dedup across variants.
var seenEventIds = new HashSet<String>();
for (var pastEvent : client.beta().sessions().events().list(session.id()).autoPager()) {
if (pastEvent._json().orElseThrow() instanceof JsonObject json) {
seenEventIds.add(json.values().get("id").asStringOrThrow());
}
}
// Tail live events; Set.add returns false for already-seen IDs, skipping the replay.
stream.stream()
.filter(event -> event._json().orElseThrow() instanceof JsonObject json
&& seenEventIds.add(json.values().get("id").asStringOrThrow()))
.takeWhile(event -> !event.isSessionStatusIdle())
.filter(BetaManagedAgentsStreamSessionEvents::isAgentMessage)
.forEach(event -> event.asAgentMessage().content()
.forEach(block -> block.text().ifPresent(textBlock -> IO.print(textBlock.text()))));
} $stream = $client->beta->sessions->events->streamStream($session->id);
// Stream is open and buffering. List history before tailing live.
$seenEventIds = [];
foreach ($client->beta->sessions->events->list($session->id)->pagingEachItem() as $event) {
$seenEventIds[$event->id] = true;
}
// Tail live events, skipping anything already seen
foreach ($stream as $event) {
if (isset($seenEventIds[$event->id])) {
continue;
}
$seenEventIds[$event->id] = true;
match (true) {
$event instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsAgentMessageEvent => array_walk(
$event->content,
static fn ($block) => $block instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsTextBlock ? print($block->text) : null,
),
default => null,
};
if ($event->type === 'session.status_idle') {
break;
}
}
$stream->close(); stream = client.beta.sessions.events.stream_events(session.id)
# Stream is open and buffering. List history before tailing live.
seen_event_ids = Set.new
client.beta.sessions.events.list(session.id).auto_paging_each { seen_event_ids << it.id }
# Tail live events, skipping anything already seen — Set#add? returns nil for duplicates
stream.each do |event|
next unless seen_event_ids.add?(event.id)
case event
when Juglow::Beta::Sessions::BetaManagedAgentsAgentMessageEvent
event.content.each { print it.text }
when Juglow::Beta::Sessions::BetaManagedAgentsSessionStatusIdleEvent
break
else
# ignore other event types
end
endListing past events
Retrieve the full event history for a session:
curl --fail-with-body -sS "https://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" ant beta:sessions:events list --session-id "$SESSION_ID" --format jsonl events = client.beta.sessions.events.list(session.id)
for event in events.data:
print(f"[{event.type}] {event.processed_at}") const events = await client.beta.sessions.events.list(session.id);
for (const event of events.data) {
console.log(`[${event.type}] ${event.processed_at}`);
} var events = await client.Beta.Sessions.Events.List(session.ID);
foreach (var sessionEvent in events.Items)
{
Console.WriteLine($"[{sessionEvent.Json.GetProperty("type").GetString()}] {sessionEvent.ProcessedAt}");
} events, err := client.Beta.Sessions.Events.List(ctx, session.ID, juglow.BetaSessionEventListParams{})
if err != nil {
panic(err)
}
for _, event := range events.Data {
fmt.Printf("[%s] %s\n", event.Type, event.ProcessedAt)
} var events = client.beta().sessions().events().list(session.id());
for (var event : events.data()) {
var eventJson = event._json().orElseThrow().convert(JsonNode.class);
var processedAt = eventJson.path("processed_at");
IO.println("[" + eventJson.get("type").asText() + "] "
+ (processedAt.isTextual() ? processedAt.asText() : "null"));
} $events = $client->beta->sessions->events->list($session->id);
foreach ($events->data as $event) {
$processedAt = ($event->processedAt ?? null)?->format(DATE_RFC3339) ?? 'null';
echo "[{$event->type}] {$processedAt}\n";
} events = client.beta.sessions.events.list(session.id)
events.data.each { puts "[#{it.type}] #{it.processed_at}" }Pass a types filter to return only specific event types:
curl --fail-with-body -sS "https://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true&types[]=agent.tool_use&types[]=agent.tool_result" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" ant beta:sessions:events list --session-id "$SESSION_ID" \
--type agent.tool_use --type agent.tool_result \
--format jsonl events = client.beta.sessions.events.list(
session.id,
types=["agent.tool_use", "agent.tool_result"],
)
for event in events.data:
print(f"[{event.type}] {event.processed_at}") const events = await client.beta.sessions.events.list(session.id, {
types: ["agent.tool_use", "agent.tool_result"],
});
for (const event of events.data) {
console.log(`[${event.type}] ${event.processed_at}`);
} var events = await client.Beta.Sessions.Events.List(session.ID, new()
{
Types = ["agent.tool_use", "agent.tool_result"],
});
foreach (var sessionEvent in events.Items)
{
Console.WriteLine($"[{sessionEvent.Json.GetProperty("type").GetString()}] {sessionEvent.ProcessedAt}");
} events, err := client.Beta.Sessions.Events.List(ctx, session.ID, juglow.BetaSessionEventListParams{
Types: []string{"agent.tool_use", "agent.tool_result"},
})
if err != nil {
panic(err)
}
for _, event := range events.Data {
fmt.Printf("[%s] %s\n", event.Type, event.ProcessedAt)
} var events = client.beta().sessions().events().list(
session.id(),
EventListParams.builder()
.addType("agent.tool_use")
.addType("agent.tool_result")
.build());
for (var event : events.data()) {
event.agentToolUse().ifPresent(toolUse ->
IO.println("[" + toolUse.type() + "] " + toolUse.processedAt()));
event.agentToolResult().ifPresent(toolResult ->
IO.println("[" + toolResult.type() + "] " + toolResult.processedAt()));
} // In PHP, pass the types you want on EventListParams; see the Juglow PHP SDK. events = client.beta.sessions.events.list(
session.id,
types: ["agent.tool_use", "agent.tool_result"]
)
events.data.each { puts "[#{it.type}] #{it.processed_at}" }Event deltas
By default, the agent's response text reaches the stream as buffered agent.message events, each emitted only after the model request that produced it finishes. Event deltas let you render that text incrementally, as a live preview, while the model is still generating it. A preview is not the response: previews are a best-effort display aid, and the buffered agent.message is always the authoritative record. A client that ignores previews still receives a complete, correct stream.
Opt in to previews
Previews are opt-in per stream connection. Add the event_deltas[] query parameter to the stream you're reading, repeating it once for each event type you want previewed. Because [] is a shell glob pattern, quote the URL whenever you build the request in a shell; the examples percent-encode the brackets as %5B%5D, which also works. Both stream endpoints accept the parameter: the session-level stream at GET /v1/sessions/{session_id}/events/stream, and each session thread's own stream at GET /v1/sessions/{session_id}/threads/{thread_id}/stream. The accepted values are agent.message and agent.thinking; any other value returns a 400 error, as does a request with more than 100 values. A subagent's previews appear on that subagent's own thread stream.
When a previewed event begins, the stream emits an event_start carrying the upcoming event's type and id:
{
"type": "event_start",
"event": {
"type": "agent.message",
"id": "sevt_01abc..."
}
}For agent.message, the start is followed by event_delta events carrying incremental text. Each delta names the event it extends in event_id and the content block it extends in delta.index:
{
"type": "event_delta",
"event_id": "sevt_01abc...",
"delta": {
"type": "content_delta",
"index": 0,
"content": {
"type": "text",
"text": "Here is the summary"
}
}
}When an agent.thinking event is previewed, only the event_start is emitted. No event_delta events follow, and the buffered agent.thinking event that concludes the preview carries no thinking content; it is a progress signal, not a content carrier.
Unlike persisted events, 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.
Note: Event deltas use a different wire format from Streaming messages, and the difference is intentional. A previewed
agent.messagegets a singleevent_startfollowed only byevent_deltaevents. There are no per-content-block start or stop events and no stop event for the previewed event itself. The delta type iscontent_delta, notcontent_block_delta. Accumulator code written for the Messages API does not carry over unchanged.
Accumulate and reconcile
Every SDK that supports event deltas includes an accumulator helper that handles the index bookkeeping for you. The Go, Java, Ruby, and C# helpers also key the accumulating preview by the event's id; with the Python, TypeScript, and PHP helpers you keep that map yourself and fold each delta into the entry for its id. The manual pattern also works in every language when you need custom bookkeeping: apply it to the generated event types.
In the manual pattern, treat the preview as a scratch buffer and the buffered event as the record. Key the buffer by (event_id, index). Reconcile per model request: a turn opens with a single session.status_running event, then on a turn that completes normally each model request produces, in order, span.model_request_start, event_start, the event_delta events, the buffered agent.message, and finally span.model_request_end (in the Span events tab). On the wire, this is the previewed portion of that sequence, interleaved with the connection's other buffered events:
event_start {"event": {"type": "agent.message", "id": "sevt_01abc..."}}
event_delta {"event_id": "sevt_01abc...", "delta": {"type": "content_delta", "index": 0, "content": {"type": "text", "text": "..."}}}
...
agent.message {"id": "sevt_01abc...", "content": [...]}The event_delta line repeats once per text fragment. Process each event as it arrives:
- On
event_start, note the announcedid. The identifiers always line up:event_start.event.id, everyevent_delta.event_id, and the bufferedagent.message'sidare the same value.
- On each
event_delta, appenddelta.content.textto the entry at(event_id, delta.index)and render the running text. The first delta for anindexcreates that entry.
- When the buffered
agent.messagearrives, match it byid, discard the accumulated preview, and render the message's content instead.
- On
span.model_request_end, close any preview that has not been reconciled by its buffered event. No more deltas are coming for it. If the turn errors or is interrupted, the buffered event might never arrive;span.model_request_endstill does.
Guarantees the pattern relies on:
- Concatenating a preview's deltas in arrival order, keyed by
(event_id, index), gives a prefix ofcontent[index].textin the buffered event (a prefix, not necessarily the whole text, because deltas might be shed under load).
- A connection emits at most one
event_startperevent_id, and the buffered event is the last thing that connection delivers for thatid.
# Opt in to agent.message previews via event_deltas, then accumulate manually.
exec {stream}< <(
curl --fail-with-body -sS -N \
"https://haijun.my.id/v1/sessions/$SESSION_ID/events/stream?beta=true&event_deltas%5B%5D=agent.message" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "accept: text/event-stream"
)
curl --fail-with-body -sS \
"https://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "content-type: application/json" \
-d @- >/dev/null <<'EOF'
{
"events": [
{
"type": "user.message",
"content": [{"type": "text", "text": "In one short sentence, describe what an event delta is."}]
}
]
}
EOF
# Accumulate deltas keyed by (message id, content index); the final
# agent.message carries the full text, so it replaces every preview for that id.
declare -A preview
while IFS= read -r -u "$stream" event_line; do
[[ $event_line == data:* ]] || continue
event_json=${event_line#data: }
case $(jq -r '.type' <<<"$event_json") in
event_start)
preview_id=$(jq -r '.event.id' <<<"$event_json")
printf '[event_start id=%s]\n' "$preview_id"
;;
event_delta)
preview_key=$(jq -r '.event_id + ":" + (.delta.index | tostring)' <<<"$event_json")
preview[$preview_key]+=$(jq -r '.delta.content.text' <<<"$event_json")
printf '[event_delta] %s\n' "${preview[$preview_key]}"
;;
agent.message)
msg_id=$(jq -r '.id' <<<"$event_json")
for preview_key in "${!preview[@]}"; do
[[ $preview_key == "$msg_id":* ]] && unset "preview[$preview_key]"
done
printf '[agent.message id=%s] ' "$msg_id"
jq -j '.content[] | select(.type == "text") | .text' <<<"$event_json"
printf '\n'
;;
span.model_request_end)
for preview_key in "${!preview[@]}"; do
printf '[closing unreconciled preview for %s]\n' "${preview_key%%:*}"
done
preview=()
;;
session.status_idle)
break
;;
esac
done
exec {stream}<&- # This workflow does not translate well to a one-off shell command.
# Use one of the SDK examples in this code group instead. # Preview snapshots, keyed by event id. accumulate_managed_agents_event folds each
# event_start / event_delta into an agent.message snapshot; the buffered
# agent.message replaces it.
previews: dict[str, BetaManagedAgentsAgentMessageEvent] = {}
# Opt in to agent.message previews on this connection
with client.beta.sessions.events.stream(
session.id, event_deltas=["agent.message"]
) as stream:
client.beta.sessions.events.send(
session.id,
events=[
{
"type": "user.message",
"content": [{"type": "text", "text": "Describe the repo in one sentence."}],
},
],
)
for event in stream:
match event.type:
case "event_start":
snapshot = accumulate_managed_agents_event(None, event)
if snapshot is not None:
previews[event.event.id] = snapshot
print(f"event_start {event.event.type} {event.event.id}")
case "event_delta":
preview = accumulate_managed_agents_event(previews.get(event.event_id), event)
if preview is not None:
previews[event.event_id] = preview
text = "".join(block.text for block in preview.content)
print(f"event_delta preview: {text!r}")
case "agent.message":
# The buffered event is the record: it replaces and closes the preview
preview = accumulate_managed_agents_event(previews.pop(event.id, None), event)
text = "".join(block.text for block in preview.content)
print(f"agent.message {event.id} {text!r}")
case "span.model_request_end":
# No more deltas are coming. Close any preview whose
# buffered event never arrived.
for event_id in previews:
print(f"span.model_request_end closing preview for {event_id}")
previews.clear()
case "session.status_idle":
break // Preview snapshots, keyed by event id. `accumulateManagedAgentsEvent`
// folds event_start / event_delta previews into an agent.message snapshot.
const previews = new Map<string, BetaManagedAgentsAgentMessageEvent>();
// Opt in to agent.message previews for this connection only
const stream = await client.beta.sessions.events.stream(session.id, {
event_deltas: ["agent.message"],
});
await client.beta.sessions.events.send(session.id, {
events: [
{
type: "user.message",
content: [{ type: "text", text: "Summarize the repo README" }]
}
]
});
deltas: for await (const event of stream) {
switch (event.type) {
case "event_start": {
// 1. Note the announced id and open the snapshot. Deltas and the
// buffered event carry the same id.
const preview = accumulateManagedAgentsEvent(undefined, event);
if (preview) previews.set(event.event.id, preview);
console.log(`event_start ${event.event.type} ${event.event.id}`);
break;
}
case "event_delta": {
// 2. Fold the fragment into the snapshot and render it
const preview = accumulateManagedAgentsEvent(previews.get(event.event_id), event);
if (preview) {
previews.set(event.event_id, preview);
const text = preview.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("");
console.log(`event_delta preview: ${JSON.stringify(text)}`);
}
break;
}
case "agent.message": {
// 3. The buffered event is the record: it replaces and closes the preview
const message = accumulateManagedAgentsEvent(previews.get(event.id), event);
previews.delete(event.id);
const text = message.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("");
console.log(`agent.message ${event.id} ${JSON.stringify(text)}`);
break;
}
case "span.model_request_end":
// 4. No more deltas are coming. Close any preview that was never reconciled.
for (const eventId of previews.keys()) {
console.log(`span.model_request_end closing preview for ${eventId}`);
}
previews.clear();
break;
case "session.status_idle":
break deltas;
}
}
stream.controller.abort(); // Opt in to event deltas: agent.message events are previewed as they are produced.
using var stream = await client.Beta.Sessions.Events.WithRawResponse.StreamStreaming(
session.ID,
new() { EventDeltas = [BetaManagedAgentsDeltaType.AgentMessage] }
);
await client.Beta.Sessions.Events.Send(session.ID, new()
{
Events =
[
new BetaManagedAgentsUserMessageEventParams
{
Type = BetaManagedAgentsUserMessageEventParamsType.UserMessage,
Content =
[
new BetaManagedAgentsTextBlock
{
Type = BetaManagedAgentsTextBlockType.Text,
Text = "Write a haiku about event streams.",
},
],
},
],
});
// Accumulate preview fragments per (event id, content index). The buffered
// agent.message that follows carries the complete content, so it replaces the
// accumulated preview rather than appending to it.
Dictionary<string, SortedDictionary<long, string>> previews = [];
await foreach (var streamEvent in stream.Enumerate())
{
if (streamEvent.TryPickStartEvent(out var start))
{
// A preview opened for the event with this id. This stream only opts in
// to agent.message deltas; TryPick* returns false instead of throwing,
// so other preview types (including ones added later) are skipped.
if (start.Event.TryPickAgentMessage(out var preview))
{
Console.WriteLine($"event_start {preview.Type.Raw()} {preview.ID}");
}
}
else if (streamEvent.TryPickDeltaEvent(out var delta))
{
// Insert at a new index, append at an existing one
if (!previews.TryGetValue(delta.EventID, out var fragments))
{
previews[delta.EventID] = fragments = [];
}
var index = delta.Delta.Index ?? 0;
fragments[index] = fragments.GetValueOrDefault(index, "") + delta.Delta.Content.Text;
Console.WriteLine($"event_delta preview: {fragments[index]}");
}
else if (streamEvent.TryPickAgentMessageEvent(out var message))
{
// Deltas are best-effort: discard the preview and use the buffered event
previews.Remove(message.ID);
var text = string.Concat(message.Content.Select(block =>
block.TryPickBetaManagedAgentsTextBlock(out var textBlock) ? textBlock.Text : ""));
Console.WriteLine($"agent.message {message.ID} {text}");
}
else if (streamEvent.TryPickSpanModelRequestEndEvent(out _))
{
// No more deltas are coming; close any preview that was never reconciled.
foreach (var eventId in previews.Keys)
{
Console.WriteLine($"span.model_request_end closing preview for {eventId}");
}
previews.Clear();
}
else if (streamEvent.TryPickSessionStatusIdleEvent(out _))
{
break;
}
} // Opt in to incremental previews of agent.message events
stream := client.Beta.Sessions.Events.StreamEvents(ctx, session.ID, juglow.BetaSessionEventStreamParams{
EventDeltas: []juglow.BetaManagedAgentsDeltaType{
juglow.BetaManagedAgentsDeltaTypeAgentMessage,
},
})
if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, juglow.BetaSessionEventSendParams{
Events: []juglow.BetaManagedAgentsEventParamsUnion{{
OfUserMessage: &juglow.BetaManagedAgentsUserMessageEventParams{
Type: juglow.BetaManagedAgentsUserMessageEventParamsTypeUserMessage,
Content: []juglow.BetaManagedAgentsUserMessageEventParamsContentUnion{{
OfText: &juglow.BetaManagedAgentsTextBlockParam{
Type: juglow.BetaManagedAgentsTextBlockTypeText,
Text: "Write a haiku about the ocean.",
},
}},
},
}},
}); err != nil {
panic(err)
}
// The accumulator folds event_start / event_delta fragments into
// per-event-id agent.message snapshots. The zero value is ready to use.
var previews juglow.BetaManagedAgentsEventAccumulator
deltas:
for stream.Next() {
event := stream.Current()
previews.Accumulate(event)
switch event := event.AsAny().(type) {
case juglow.BetaManagedAgentsStartEvent:
fmt.Printf("event_start %s %s\n", event.Event.Type, event.Event.ID)
case juglow.BetaManagedAgentsDeltaEvent:
fmt.Printf("event_delta preview: %q\n", previews.AgentMessageText(event.EventID))
case juglow.BetaManagedAgentsAgentMessageEvent:
// The buffered event carries the complete content: the accumulator
// replaces the preview with it
fmt.Printf("agent.message %s %q\n", event.ID, previews.AgentMessageText(event.ID))
case juglow.BetaManagedAgentsSpanModelRequestEndEvent:
// No more deltas are coming for this request. The accumulator
// drops its snapshots here, closing any preview that was never
// reconciled by a buffered agent.message.
fmt.Println("span.model_request_end no more deltas for this request")
case juglow.BetaManagedAgentsSessionStatusIdleEvent:
break deltas
}
}
if err := stream.Err(); err != nil {
panic(err)
}
stream.Close() // Preview text, keyed by event ID then content index. The buffered agent.message replaces it.
Map<String, Map<Long, StringBuilder>> previews = new HashMap<>();
// Opt in to agent.message previews on this connection
try (var stream = client.beta().sessions().events().streamStreaming(
session.id(),
EventStreamParams.builder()
.addEventDelta(BetaManagedAgentsDeltaType.AGENT_MESSAGE)
.build()
)) {
client.beta().sessions().events().send(
session.id(),
EventSendParams.builder()
.addEvent(BetaManagedAgentsUserMessageEventParams.builder()
.type(BetaManagedAgentsUserMessageEventParams.Type.USER_MESSAGE)
.addTextContent("Describe the repo in one sentence.")
.build())
.build()
);
Iterable<BetaManagedAgentsStreamSessionEvents> events = stream.stream()::iterator;
deltas:
for (var event : events) {
switch (event.type().value()) {
case EVENT_START -> {
if (event.asEventStart().event().isAgentMessage()) {
var preview = event.asEventStart().event().asAgentMessage();
IO.println("event_start " + preview.type().asString() + " " + preview.id());
}
}
case EVENT_DELTA -> {
var eventDelta = event.asEventDelta();
var fragment = eventDelta.delta();
var buffer = previews
.computeIfAbsent(eventDelta.eventId(), _ -> new HashMap<>())
.computeIfAbsent(fragment.index().orElse(0L), _ -> new StringBuilder());
buffer.append(fragment.content().text());
IO.println("event_delta preview: " + buffer);
}
case AGENT_MESSAGE -> {
// The buffered event is the record: drop its preview, render its content
var message = event.asAgentMessage();
previews.remove(message.id());
var text = message.content().stream()
.flatMap(block -> block.text().stream())
.map(textBlock -> textBlock.text())
.collect(Collectors.joining());
IO.println("agent.message " + message.id() + " " + text);
}
case SPAN_MODEL_REQUEST_END -> {
// No more deltas are coming. Close any preview whose buffered event never arrived.
previews.keySet().forEach(eventId ->
IO.println("span.model_request_end closing preview for " + eventId));
previews.clear();
}
case SESSION_STATUS_IDLE -> {
break deltas;
}
}
}
} // In PHP, set eventDeltas on EventStreamParams and accumulate with Juglow\Lib\Sessions\EventAccumulator. # Opt in to event deltas: agent.message previews stream as incremental fragments.
stream = client.beta.sessions.events.stream_events(
session.id,
event_deltas: [Juglow::Beta::BetaManagedAgentsDeltaType::AGENT_MESSAGE]
)
client.beta.sessions.events.send_(
session.id,
events: [{
type: "user.message",
content: [{type: "text", text: "Give a one-sentence project tagline."}]
}]
)
# Accumulate preview fragments by (event_id, index) into explicitly mutable
# (`+""`) buffers so `<<` can append in place. The buffered agent.message with
# the same id is authoritative and replaces whatever the deltas built up.
buffers = Hash.new do |by_event, event_id|
by_event[event_id] = Hash.new { |fragments, index| fragments[index] = +"" }
end
stream.each do |event|
case event
when Juglow::Beta::BetaManagedAgentsStartEvent
puts "event_start #{event.event.type} #{event.event.id}"
when Juglow::Beta::BetaManagedAgentsDeltaEvent
delta = event.delta
fragment = delta.content.text
buffers[event.event_id][delta.index || 0] << fragment
puts "event_delta preview: #{buffers[event.event_id][delta.index || 0].inspect}"
when Juglow::Beta::Sessions::BetaManagedAgentsAgentMessageEvent
# Replace: drop the accumulated preview and render the complete event.
buffers.delete(event.id)
puts "agent.message #{event.id} #{event.content.map(&:text).join.inspect}"
when Juglow::Beta::Sessions::BetaManagedAgentsSpanModelRequestEndEvent
# No more deltas are coming. Close any preview that was never reconciled.
buffers.each_key { |event_id| puts "span.model_request_end closing preview for #{event_id}" }
buffers.clear
when Juglow::Beta::Sessions::BetaManagedAgentsSessionStatusIdleEvent
break
else
# ignore other event types
end
endPreview session thread events
In a multiagent session, every session thread has its own event stream at GET /v1/sessions/{session_id}/threads/{thread_id}/stream, and it takes the same event_deltas[] parameter with the same values. Previews are thread-scoped by design: a connection previews only the thread it's reading. A child thread's previews are delivered on that child's own 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.
The thread stream's path is easy to get wrong: it is /threads/{thread_id}/stream, not /events/stream (which exists only at the session level), and there is no /threads/{thread_id}/events/stream endpoint.
The preview events themselves don't change. event_start and event_delta have the same shape on a thread stream as on the session-level stream, and the accumulate and reconcile pattern applies as written. The one adjustment is bookkeeping: run one accumulator instance per stream connection.
# List the session's threads and pick a child: child threads carry a non-null
# parent_thread_id, and the primary thread's parent_thread_id is null.
THREAD_ID=$(
curl --fail-with-body -sS \
"https://haijun.my.id/v1/sessions/$SESSION_ID/threads?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" |
jq -er 'first(.data[] | select(.parent_thread_id != null)).id'
)
# The child thread's stream takes the same event_deltas[] parameter as the
# session stream. Percent-encode the brackets (%5B%5D) and quote the URL.
exec {stream}< <(
curl --fail-with-body -sS -N \
"https://haijun.my.id/v1/sessions/$SESSION_ID/threads/$THREAD_ID/stream?beta=true&event_deltas%5B%5D=agent.message" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-beta: managed-agents-2026-04-01" \
-H "accept: text/event-stream"
)
while IFS= read -r -u "$stream" event_line; do
[[ $event_line == data:* ]] || continue
event_json=${event_line#data: }
case $(jq -r '.type' <<<"$event_json") in
event_delta)
jq -j '.delta.content.text' <<<"$event_json"
;;
agent.message)
# The buffered event is the authoritative record; render its content.
printf '\n'
jq -j '.content[] | select(.type == "text") | .text' <<<"$event_json"
printf '\n'
;;
session.thread_status_idle)
break
;;
esac
done
exec {stream}<&- # List the session's threads and pick a child: child threads carry a non-null
# parent_thread_id, and the primary thread's parent_thread_id is null
# (--transform's #(parent_thread_id!=~null) query matches non-null values).
THREAD_ID=$(ant beta:sessions:threads list \
--session-id "$SESSION_ID" \
--format raw --transform 'data.#(parent_thread_id!=~null).id' --raw-output)
# The child thread's stream takes the same event_deltas parameter as the
# session stream, one --event-delta flag per event type to preview. @tostr
# re-encodes each text field as a JSON string, so every value stays on one
# YAML line and jq's fromjson recovers the original text.
transform='{type,frag:delta.content.text|@tostr,text:content.#(type=="text").text|@tostr}'
exec {stream}< <(ant beta:sessions:threads:events stream \
--session-id "$SESSION_ID" \
--thread-id "$THREAD_ID" \
--event-delta agent.message \
--transform "$transform" \
--format yaml)
type=
while IFS= read -r -u "$stream" line; do
case "$line" in
type:\ session.thread_status_idle) break ;;
type:\ *) type=${line#type: } ;;
frag:*)
[[ $type == event_delta ]] || continue
jq -j fromjson <<<"${line#frag: }" ;;
text:*)
[[ $type == agent.message ]] || continue
# The buffered event is the authoritative record; render its content.
printf '\n'
jq -r fromjson <<<"${line#text: }" ;;
esac
done
exec {stream}<&- # List the session's threads and pick a child: child threads carry a non-null
# parent_thread_id, and the primary thread's parent_thread_id is null.
child_thread = next(
thread
for thread in client.beta.sessions.threads.list(session.id)
if thread.parent_thread_id is not None
)
# The child thread's stream takes the same event_deltas parameter as the
# session stream.
with client.beta.sessions.threads.events.stream(
child_thread.id,
session_id=session.id,
event_deltas=["agent.message"],
) as stream:
for event in stream:
match event.type:
case "event_delta":
print(event.delta.content.text, end="")
case "agent.message":
# The buffered event is the authoritative record; render its content
print()
for block in event.content:
if block.type == "text":
print(block.text, end="")
print()
case "session.thread_status_idle":
break // List the session's threads and pick a child: child threads carry a non-null
// parent_thread_id, and the primary thread's parent_thread_id is null.
let childThreadId: string | undefined;
for await (const thread of client.beta.sessions.threads.list(session.id)) {
if (thread.parent_thread_id !== null) {
childThreadId = thread.id;
break;
}
}
if (!childThreadId) throw new Error("No child thread found");
// The child thread's stream takes the same event_deltas parameter as the
// session stream.
const stream = await client.beta.sessions.threads.events.stream(childThreadId, {
session_id: session.id,
event_deltas: ["agent.message"],
});
threadDeltas: for await (const event of stream) {
switch (event.type) {
case "event_delta":
process.stdout.write(event.delta.content.text);
break;
case "agent.message": {
// The buffered event is the authoritative record; render its content.
process.stdout.write("\n");
const text = event.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("");
console.log(text);
break;
}
case "session.thread_status_idle":
break threadDeltas;
}
}
stream.controller.abort(); // List the session's threads and pick a child: child threads carry a non-null
// parent_thread_id, and the primary thread's parent_thread_id is null.
var threads = await client.Beta.Sessions.Threads.List(session.ID);
var childThread = threads.Items.First(thread => thread.ParentThreadID is not null);
// The child thread's stream takes the same event_deltas parameter as the
// session stream.
using var stream = await client.Beta.Sessions.Threads.Events.WithRawResponse.StreamStreaming(
childThread.ID,
new() { SessionID = session.ID, EventDeltas = [BetaManagedAgentsDeltaType.AgentMessage] }
);
await foreach (var streamEvent in stream.Enumerate())
{
if (streamEvent.TryPickDeltaEvent(out var delta))
{
Console.Write(delta.Delta.Content.Text);
}
else if (streamEvent.TryPickAgentMessageEvent(out var message))
{
// The buffered event is the authoritative record; render its content.
Console.WriteLine();
var text = string.Concat(message.Content.Select(block =>
block.TryPickBetaManagedAgentsTextBlock(out var textBlock) ? textBlock.Text : ""));
Console.WriteLine(text);
}
else if (streamEvent.TryPickSessionThreadStatusIdleEvent(out _))
{
break;
}
} // List the session's threads and pick a child: child threads carry a non-null
// parent_thread_id, and the primary thread's parent_thread_id is null.
var childThreadID string
threads := client.Beta.Sessions.Threads.ListAutoPaging(ctx, session.ID, juglow.BetaSessionThreadListParams{})
for threads.Next() {
if thread := threads.Current(); thread.ParentThreadID != "" {
childThreadID = thread.ID
break
}
}
if err := threads.Err(); err != nil {
panic(err)
}
// The child thread's stream takes the same event_deltas parameter as the
// session stream; run one read loop per stream connection.
stream := client.Beta.Sessions.Threads.Events.StreamEvents(ctx, childThreadID, juglow.BetaSessionThreadEventStreamParams{
SessionID: session.ID,
EventDeltas: []juglow.BetaManagedAgentsDeltaType{
juglow.BetaManagedAgentsDeltaTypeAgentMessage,
},
})
threadDeltas:
for stream.Next() {
switch event := stream.Current().AsAny().(type) {
case juglow.BetaManagedAgentsDeltaEvent:
fmt.Print(event.Delta.Content.Text)
case juglow.BetaManagedAgentsAgentMessageEvent:
// The buffered event is the authoritative record; render its content.
fmt.Println()
// concrete-typed list: BetaManagedAgentsTextBlock
for _, block := range event.Content {
fmt.Print(block.Text)
}
fmt.Println()
case juglow.BetaManagedAgentsSessionThreadStatusIdleEvent:
break threadDeltas
}
}
if err := stream.Err(); err != nil {
panic(err)
}
stream.Close() // List the session's threads and pick a child: child threads carry a non-null
// parent_thread_id, and the primary thread's parent_thread_id is null.
var childThread = client.beta().sessions().threads().list(session.id()).autoPager().stream()
.filter(thread -> thread.parentThreadId().isPresent())
.findFirst()
.orElseThrow();
// The child thread's stream takes the same event_deltas parameter as the session
// stream. Its params class shares the session-level one's simple name, so qualify it.
try (var stream = client.beta().sessions().threads().events().streamStreaming(
childThread.id(),
com.juglow.models.beta.sessions.threads.events.EventStreamParams.builder()
.sessionId(session.id())
.addEventDelta(BetaManagedAgentsDeltaType.AGENT_MESSAGE)
.build()
)) {
Iterable<BetaManagedAgentsStreamSessionThreadEvents> events = stream.stream()::iterator;
threadDeltas:
for (var event : events) {
switch (event.type().value()) {
case EVENT_DELTA -> IO.print(event.asEventDelta().delta().content().text());
case AGENT_MESSAGE -> {
// The buffered event is the authoritative record; render its content.
IO.println();
event.asAgentMessage().content().forEach(block -> block.text().ifPresent(textBlock -> IO.print(textBlock.text())));
IO.println();
}
case SESSION_THREAD_STATUS_IDLE -> {
break threadDeltas;
}
}
}
} // In PHP, set eventDeltas on the thread EventStreamParams and accumulate with Juglow\Lib\Sessions\EventAccumulator. # List the session's threads and pick a child: child threads carry a non-null
# parent_thread_id, and the primary thread's parent_thread_id is null.
child_thread = client.beta.sessions.threads.list(session.id).to_enum.find { it.parent_thread_id }
# The child thread's stream takes the same event_deltas parameter as the
# session stream.
stream = client.beta.sessions.threads.events.stream_events(
child_thread.id,
session_id: session.id,
event_deltas: [Juglow::Beta::BetaManagedAgentsDeltaType::AGENT_MESSAGE]
)
stream.each do |event|
case event
when Juglow::Beta::BetaManagedAgentsDeltaEvent
print event.delta.content.text
when Juglow::Beta::Sessions::BetaManagedAgentsAgentMessageEvent
# The buffered event is the authoritative record; render its content.
puts
event.content.each { print it.text }
puts
when Juglow::Beta::Sessions::BetaManagedAgentsSessionThreadStatusIdleEvent
break
else
# ignore other event types
end
endThe read loop exits on session.thread_status_idle, the event emitted when the session thread's turn finishes and the thread goes idle.
Limitations
Previews are tuned for responsiveness. Build against these constraints:
- Best effort: Under load, the server might shed deltas for an event. When it does, you receive a contiguous prefix of the text and then no further deltas for that event. The buffered
agent.messagestill 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 is open. This applies to the session-level stream and to each session thread stream alike, and a connection opened after a model request started receives no deltas for that in-flight event. If the stream drops, follow the reconnect procedure in the Streaming events tab: reopen the stream and list the event history. The history includes any buffered events emitted while you were disconnected, including the
agent.messageyour preview was waiting for. There is no way to re-request missed deltas.
- 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 session thread are never previewed on that connection.
- Start-only
agent.thinking: Anagent.thinkingpreview emits only theevent_startas a signal that a thinking block has started; noevent_deltaevents follow it.
- Never persisted:
event_startandevent_deltaexist only on the live stream. They do not appear in the session's event history (GET /v1/sessions/{session_id}/events) or in any session thread's event history.
Troubleshoot previews
If the stream doesn't behave as you expect:
| You see | What it means |
|---|---|
A stream with buffered events but no event_start or event_delta | The connection you're reading didn't opt in (event_deltas[] applies per connection, not per session), or the turn never touched the thread you're streaming. Previews are thread-scoped, so list the session's threads (GET /v1/sessions/{session_id}/threads) to find which one ran. |
| A 404 on the stream URL | The path or an ID is wrong, or the request carries no managed-agents beta header at all. The thread endpoints are beta-gated, so without the header they don't exist. |
A 400 naming event_deltas | Only agent.message and agent.thinking are accepted. |
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://haijun.my.id/v1/sessions/$SESSION_ID/events/stream?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-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://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-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":
break const 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, juglow.BetaSessionEventStreamParams{})
defer stream.Close()
loop:
for stream.Next() {
event, ok := stream.Current().AsAny().(juglow.BetaManagedAgentsSessionStatusIdleEvent)
if !ok {
continue
}
switch stopReason := event.StopReason.AsAny().(type) {
case juglow.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, juglow.BetaSessionEventSendParams{
Events: []juglow.BetaManagedAgentsEventParamsUnion{{
OfUserCustomToolResult: &juglow.BetaManagedAgentsUserCustomToolResultEventParams{
Type: juglow.BetaManagedAgentsUserCustomToolResultEventParamsTypeUserCustomToolResult,
CustomToolUseID: eventID,
Content: []juglow.BetaManagedAgentsUserCustomToolResultEventParamsContentUnion{{
OfText: &juglow.BetaManagedAgentsTextBlockParam{
Type: juglow.BetaManagedAgentsTextBlockTypeText,
Text: result,
},
}},
},
}},
}); err != nil {
panic(err)
}
}
case juglow.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 instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsSessionStatusIdleEvent && $event->stopReason) {
switch (true) {
case $event->stopReason instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsSessionRequiresAction:
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]],
],
],
);
}
break;
case $event->stopReason instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsSessionEndTurn:
break 2;
}
}
} client.beta.sessions.events.stream_events(session.id).each do |event|
case event
when Juglow::Beta::Sessions::BetaManagedAgentsSessionStatusIdleEvent
stop_reason = event.stop_reason
case stop_reason
when Juglow::Beta::Sessions::BetaManagedAgentsSessionRequiresAction
stop_reason.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
when Juglow::Beta::Sessions::BetaManagedAgentsSessionEndTurn
break
end
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://haijun.my.id/v1/sessions/$SESSION_ID/events/stream?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-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://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-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":
break const 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, juglow.BetaSessionEventStreamParams{})
defer stream.Close()
loop:
for stream.Next() {
event, ok := stream.Current().AsAny().(juglow.BetaManagedAgentsSessionStatusIdleEvent)
if !ok {
continue
}
switch stopReason := event.StopReason.AsAny().(type) {
case juglow.BetaManagedAgentsSessionRequiresAction:
for _, eventID := range stopReason.EventIDs {
// Approve the pending tool call
if _, err := client.Beta.Sessions.Events.Send(ctx, session.ID, juglow.BetaSessionEventSendParams{
Events: []juglow.BetaManagedAgentsEventParamsUnion{{
OfUserToolConfirmation: &juglow.BetaManagedAgentsUserToolConfirmationEventParams{
Type: juglow.BetaManagedAgentsUserToolConfirmationEventParamsTypeUserToolConfirmation,
ToolUseID: eventID,
Result: juglow.BetaManagedAgentsUserToolConfirmationEventParamsResultAllow,
},
}},
}); err != nil {
panic(err)
}
}
case juglow.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 instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsSessionStatusIdleEvent && $event->stopReason) {
switch (true) {
case $event->stopReason instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsSessionRequiresAction:
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',
],
],
);
}
break;
case $event->stopReason instanceof \Juglow\Beta\Sessions\Events\ManagedAgentsSessionEndTurn:
break 2;
}
}
} client.beta.sessions.events.stream_events(session.id).each do |event|
case event
when Juglow::Beta::Sessions::BetaManagedAgentsSessionStatusIdleEvent
stop_reason = event.stop_reason
case stop_reason
when Juglow::Beta::Sessions::BetaManagedAgentsSessionRequiresAction
stop_reason.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
when Juglow::Beta::Sessions::BetaManagedAgentsSessionEndTurn
break
end
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.
Note: 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://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-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, juglow.BetaSessionEventSendParams{
Events: []juglow.BetaManagedAgentsEventParamsUnion{{
OfUserMessage: &juglow.BetaManagedAgentsUserMessageEventParams{
Type: juglow.BetaManagedAgentsUserMessageEventParamsTypeUserMessage,
Content: []juglow.BetaManagedAgentsUserMessageEventParamsContentUnion{{
OfText: &juglow.BetaManagedAgentsTextBlockParam{
Type: juglow.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
Note:
system.messageis supported by Haijun Fable 5.1, Haijun Mythos 5.1, Haijun Fable 5, Haijun Mythos 5, Haijun Opus 5.5, Haijun Opus 5, and Haijun Opus 4.8. If the agent's primary model does not support mid-conversation system injection, the event is rejected with amodel_does_not_support_mid_conversation_systemvalidation error. Subagent models are not checked, becausesystem.messagelands 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://haijun.my.id/v1/sessions/$SESSION_ID/events?beta=true" \
-H "x-api-key: $JUGLOW_API_KEY" \
-H "juglow-version: 2023-06-01" \
-H "juglow-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."}
]
}
]
}
EOF ant 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."
YAML 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, {
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, juglow.BetaSessionEventSendParams{
Events: []juglow.BetaManagedAgentsEventParamsUnion{{
OfSystemMessage: &juglow.BetaManagedAgentsSystemMessageEventParams{
Type: juglow.BetaManagedAgentsSystemMessageEventParamsTypeSystemMessage,
Content: []juglow.BetaManagedAgentsSystemContentBlockParam{{
Type: juglow.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.
Console observability
The Haijun Console includes a session viewer for inspecting what an agent did without writing any code. In the Console sidebar, under Managed Agents, select Sessions to see every session in the workspace with its status, agent, token usage, cost, and creation time, then select a session to open it. The session viewer is only accessible to Developers and Admins. It shows:
- Timeline minimap: A zoomable overview of the session's activity over time, with one lane per thread in multiagent sessions. Select a lane to view that thread, or select a mark to jump to its event.
- Transcript: The conversation grouped by model request, including thinking, tool calls with their inputs and results, and message text as it streams. You can filter the events and copy or download them as JSON.
- Inspector: A resizable side panel with details about the session, in five tabs:
- Session shows the session's details and metadata, its cumulative cost over time, and spend against the session's budget when one is set.
- Events lists every raw event on the current thread in the order the server sent it; select an event to see its JSON. A message that streamed while the page was open also has a Deltas view of its event deltas.
- Tools lists the tools the session's agents are configured with, along with call counts, failures, and median duration; select a tool to see its calls and jump to one in the transcript.
- Resources lists mounted files, repositories, and memory stores at their container paths, including the memories in each store and the changes this session made to them, plus files the agent wrote to
/mnt/session/outputsand the tracks attached to the session's agents. - Threads lists every thread with its status, context size, and cost. Select a thread to view its details, such as the agent, model, context usage, and cost.
Append ?event={event_id} to a session URL to open the session at a specific event.
With ant beta:sessions connect, you can open the same viewer from the ant CLI or follow the session in your terminal. See Connect to a Managed Agents session from your terminal.
Debugging tips
- Check session events: Session errors are conveyed through the
session.errorevent
- Review tool results: Tool execution failures often explain unexpected agent behavior
- Track token usage: Monitor token consumption to optimize prompts and reduce costs
- Use system prompts: Add logging instructions to the system prompt to make the agent explain its reasoning
- Troubleshoot previews: If a stream that opts in to event deltas doesn't behave as you expect, see Troubleshoot previews