Events¶
Every observable step of an agent run is a typed Pydantic event. Not
a dict, not a callback, not a string — a frozen class with named
fields you can match on.
This is the reference page. For the how (consuming the stream, SSE, hooks), see Streaming. For the why (frozen, typed, write-protected), see Agent loop.
from tulip.core.events import (
ThinkEvent, ToolStartEvent, ToolCompleteEvent, TerminateEvent,
)
async for event in agent.run("Plan a trip"):
match event:
case ThinkEvent(reasoning=r) if r:
print("💭", r)
case ToolStartEvent(tool_name=n, arguments=a):
print(f"🔧 {n}({a})")
case ToolCompleteEvent(tool_name=n, result=r, error=e):
print(f" ↳ {e or r}")
case TerminateEvent(reason=r, final_message=m):
print(f"[{r}] {m}")
Common fields¶
Every event inherits from TulipEvent and carries:
| Field | Type | Meaning |
|---|---|---|
event_type |
Literal[...] |
Discriminator string — "think", "tool_start", etc. |
timestamp |
datetime |
UTC, populated at emit time. |
The streaming events on this page are frozen Pydantic models. A
consumer can read every field; mutating one raises ValidationError.
Steering is a separate surface: the hook events (tulip.hooks,
a ProtectedEvent family) let a hook steer by assigning to a
writable field — event.cancel = True (or a reason string),
event.retry = True, event.arguments = {...}. There are no
cancel() / retry() / replace_arguments() methods; assigning to a
read-only field raises AttributeError. See Hooks.
Core events¶
ThinkEvent¶
The model emitted reasoning, optionally with tool calls.
| Field | Meaning |
|---|---|
iteration |
ReAct turn index (0-based) |
reasoning |
The model's chain-of-thought, if the provider exposed it |
tool_calls |
Tool calls the model decided to make this turn |
Render this as a "thinking…" bubble. Most providers return None
unless extended thinking is enabled (Claude 4 / o-series).
ToolStartEvent¶
The agent is about to invoke a tool.
| Field | Meaning |
|---|---|
tool_name |
Tool registered with @tool |
tool_call_id |
Provider-issued id, used to correlate with the matching ToolCompleteEvent |
arguments |
The validated arguments dict |
Show a "calling X" indicator.
ToolCompleteEvent¶
A tool returned, errored, or was cancelled.
| Field | Meaning |
|---|---|
tool_name |
Same name as the matching start event |
tool_call_id |
Pairs with ToolStartEvent.tool_call_id |
result |
The serialised return value, or None on error |
error |
Exception message, or None on success |
duration_ms |
How long the body actually ran |
Always check error first — a non-None error means result is
None.
ModelChunkEvent¶
One streamed chunk from the LLM provider — the granularity that drives token-by-token rendering.
| Field | Meaning |
|---|---|
content |
Text delta (may be None for tool-call-only chunks) |
tool_calls |
Tool-call deltas, if the provider streams those |
done |
True on the final chunk of a turn |
None-guard before printing: if e.content: print(e.content, end="").
ModelCompleteEvent¶
A full model response was received (paired with the chunks above).
| Field | Meaning |
|---|---|
content |
The complete text |
tool_calls |
All tool calls in this turn |
usage |
{"input_tokens": ..., "output_tokens": ...} |
stop_reason |
Provider-specific stop reason |
Telemetry hooks key off usage for cost tracking.
ReflectEvent¶
Reflexion emitted a self-evaluation.
| Field | Meaning |
|---|---|
iteration |
Which turn this reflection concerns |
assessment |
"on_track", "stuck", "new_findings", or "loop_detected" |
confidence_delta |
Change vs the previous turn |
new_confidence |
Current value, 0.0–1.0 |
guidance |
Free-text steering for the next turn |
Pair new_confidence with ConfidenceMet for early
stopping.
GroundingEvent¶
Grounding finished evaluating claims.
| Field | Meaning |
|---|---|
score |
0.0–1.0, fraction of claims supported |
claims_evaluated |
How many claims the judge looked at |
ungrounded_claims |
The text of every unsupported claim |
requires_replan |
True if the run should re-research |
InterruptEvent¶
A tool requested human-in-the-loop input. The run pauses; resume by calling the agent with the user's reply.
| Field | Meaning |
|---|---|
question |
What to ask the human |
options |
If multiple-choice, the allowed answers |
interrupt_id |
Pass back to resume |
metadata |
Free-form context for the UI |
See Interrupts.
TerminateEvent¶
The run finished.
| Field | Meaning |
|---|---|
reason |
Which termination condition fired (its repr) |
iterations_used |
How many ReAct turns ran |
final_confidence |
Reflexion confidence at end of run |
total_tool_calls |
Distinct tool invocations |
final_message |
The assistant's last text, if any |
Always emitted exactly once per run.
Multi-agent events¶
These appear when an Orchestrator, Swarm, or StateGraph is
running.
| Event | Fired when |
|---|---|
SpecialistStartEvent |
Orchestrator dispatched to a specialist |
SpecialistCompleteEvent |
Specialist returned a result |
OrchestratorDecisionEvent |
Orchestrator picked its next step (invoke_specialist, correlate, summarize, finalize) |
SpecialistStartEvent/SpecialistCompleteEvent pair on
specialist_id, so you can attribute each IR phase to the agent that
ran it:
phase = {} # specialist id → wall-clock ms
async for event in orchestrator.run("Sev-1 ransomware on ws-0042: triage, then contain."):
match event:
case SpecialistStartEvent(specialist_id=s, task=t):
print(f"→ {s}: {t}")
case SpecialistCompleteEvent(specialist_id=s, duration_ms=d):
phase[s] = d
case ToolCompleteEvent(tool_name="isolate_host", error=None):
print(" ↳ containment fired") # which specialist? the last Start was 'containment'
# {'triage': 1840, 'forensics': 6120, 'containment': 410}
See Multi-agent.
Causal-reasoning events¶
The cause-effect graph (build_causal_chain()) has a pair of typed
events for surfacing graph growth to a streaming consumer. They're part
of the TulipEvent union and rendered by the console handler; emit them
from your own wiring as you add nodes and edges.
| Event | Represents |
|---|---|
CausalNodeEvent |
A new entity in the cause-effect graph (root cause / symptom / intermediate) |
CausalEdgeEvent |
A causal link between two nodes |
Hook events¶
BeforeInvocationEvent, AfterInvocationEvent, BeforeToolCallEvent,
AfterToolCallEvent — emitted to hooks around the same lifecycle
points the user-visible events come from. See Hooks.
Common gotchas¶
| Symptom | Likely cause |
|---|---|
match is non-exhaustive at the type checker |
Add a case _: pass fallthrough or handle the missing variant. |
ModelChunkEvent.content is None |
Tool-call-only chunk. Guard with if event.content:. |
TerminateEvent never arrives |
Generator was cancelled mid-stream. Check the consumer for exceptions. |
Tried to mutate a streaming event field and got ValidationError |
Streaming events are frozen by design. To steer, do it on the hook event by assigning a writable field — event.cancel = True, event.retry = True, or event.arguments = {...}. |
Source¶
tulip.core.events— every event class.
See also¶
- Streaming — how to consume the event stream.
- Hooks — observe the same events from inside the loop.
- Agent server — re-emit events over Server-Sent Events.