Perstack emits events during Expert execution for observability and state management. This document explains the event system architecture and how to consume events.
PerstackEvent = RunEvent | RuntimeEvent
RunEvent = ExpertStateEvent | StreamingEvent
Event Type Purpose Accumulation ExpertStateEventState machine transitions Accumulated log StreamingEventReal-time streaming content Latest state only RuntimeEventInfrastructure-level side effects Latest state only
RunEvent represents events related to a Run — the execution unit of an Expert. RunEvent includes both state machine transitions (ExpertStateEvent) and streaming events (StreamingEvent).
└── Events → Steps → Checkpoints
Concept Description Job Top-level execution unit (one per perstack run) Run Single Expert execution within a Job Step One cycle of the agent loop Checkpoint Snapshot at step end — everything needed to resume
Steps and Checkpoints are designed to be deterministic and observable through the agent loop state machine. ExpertStateEvents represent transitions in this state machine. StreamingEvents provide real-time content during generation.
All RunEvents share these properties:
id : string // Unique event ID
expertKey : string // Expert that emitted this event
timestamp : number // Unix timestamp
stepNumber : number // Step number when emitted
Event Type Description Key Payload startRunRun started initialCheckpoint, inputMessagescompleteRunRun completed successfully checkpoint, step, text, usage
Event Type Description Key Payload startGenerationLLM generation started messagesretryGeneration failed, retrying reason, newMessages, usage
Event Type Description Key Payload callToolsRegular tool calls newMessage, toolCalls, usagecallInteractiveToolInteractive tool call (needs user input) newMessage, toolCall, usagecallDelegateDelegation to another Expert newMessage, toolCalls, usageresolveToolResultsTool results received toolResultsattemptCompletionCompletion tool called toolResultfinishToolCallSingle tool call finished newMessagesresumeToolCallsResume pending tool calls pendingToolCalls, partialToolResultsfinishAllToolCallsAll tool calls finished newMessages
Event Type Description Key Payload continueToNextStepProceeding to next step checkpoint, step, nextCheckpoint
Event Type Description Key Payload stopRunByInteractiveToolStopped for user input checkpoint, stepstopRunByDelegateStopped for delegation checkpoint, stepstopRunByExceededMaxStepsStopped due to max steps limit checkpoint, stepstopRunByErrorStopped due to error checkpoint, step, error
StreamingEvents provide real-time content during LLM generation. They include expertKey for proper attribution in parallel runs.
Event Type Description Key Payload startStreamingReasoningStart of reasoning stream (empty) streamReasoningReasoning delta deltacompleteStreamingReasoningReasoning complete textstartStreamingRunResultStart of result stream (empty) streamRunResultResult delta deltacompleteStreamingRunResultResult complete text
ExpertStateEvents should be accumulated as execution history. StreamingEvents should be processed as current state for real-time display.
// Example: Processing RunEvents
function processEvent ( event : PerstackEvent ) {
if ( ! isRunEvent (event)) return
// StreamingEvents for real-time display
if ( isStreamingEvent (event)) {
updateStreamingReasoning (event . delta )
updateStreamingResult (event . delta )
// ... handle other streaming events
// ExpertStateEvents for execution log
addActivity ({ type: " query " , text: extractQuery (event) })
addActivity ({ type: " toolCall " , tools: event . toolCalls })
addActivity ({ type: " complete " , text: event . text })
// ... handle other events
RuntimeEvent represents infrastructure-level side effects — the runtime environment state rather than the agent loop itself.
Only the latest state matters — past RuntimeEvents are not meaningful
Includes infrastructure-level information (skills, Docker, proxy)
Not tied to the agent loop state machine
All RuntimeEvents share these properties:
interface BaseRuntimeEvent {
id : string // Unique event ID
timestamp : number // Unix timestamp
Event Type Description Key Payload initializeRuntimeRuntime initialized runtimeVersion, expertName, model, etc.
Event Type Description Key Payload skillStartingMCP skill starting skillName, command, argsskillConnectedMCP skill connected skillName, serverInfo, timing metricsskillStderrSkill stderr output skillName, messageskillDisconnectedMCP skill disconnected skillName
Event Type Description Key Payload dockerBuildProgressDocker build progress stage, service, message, progressdockerContainerStatusContainer status change status, service, message
Event Type Description Key Payload proxyAccessNetwork access allow/block action, domain, port, reason
RuntimeEvents should be processed as current state — only the latest value matters.
// Example: Managing RuntimeEvent as current state
skills : Map < string , SkillStatus >
function handleRuntimeEvent ( state : RuntimeState , event : PerstackEvent ) : RuntimeState {
if ( ! isRuntimeEvent (event)) return state
skills: new Map (state . skills ) . set (event . skillName , { status: " connected " })
// ... handle other events
Activity provides a human-friendly abstraction for understanding Expert behavior. It combines the action data with metadata for tracking execution across multiple Runs.
A single Step may contain multiple actions:
Parallel tool calls (e.g., reading multiple files simultaneously)
Multiple delegations
Tool calls followed by result processing
RunEvents capture every state transition, but for human users who want to understand “what did the Expert do?”, this level of detail can be overwhelming. Activity extracts meaningful actions with full context.
/** Activity type (e.g., "readTextFile", "exec", "delegate") */
/** Unique identifier for this activity */
/** Expert that executed this action */
/** Run ID this activity belongs to */
/** Previous activity ID within the same Run (daisy chain) */
previousActivityId ?: string
/** Parent Run information (for delegated Runs) */
/** LLM's reasoning before this action (if available) */
// ... action-specific fields
Type Description queryUser input that started the run completeRun completed with final result errorRun stopped due to error retryGeneration failed, will retry
Type Description readTextFileRead a text file readImageFileRead an image file readPdfFileRead a PDF file writeTextFileWrite/create a text file editTextFileEdit existing file content appendTextFileAppend to a file deleteFileDelete a file moveFileMove/rename a file getFileInfoGet file metadata
Type Description listDirectoryList directory contents createDirectoryCreate a directory deleteDirectoryDelete a directory
Type Description execShell command execution
Type Description todoUpdate todo list clearTodoClear all todos attemptCompletionSignal task completion
Type Description delegateDelegate to another Expert delegationCompleteAll delegations returned interactiveToolTool requiring user input generalToolAny other MCP tool call
Activity uses a two-level daisy chain to maintain ordering:
Within-Run ordering : previousActivityId links activities in the same Run
Cross-Run ordering : delegatedBy links child Runs to their parent Run
This architecture supports:
Flat storage : All activities in a single append-only array
Run isolation : Each Run forms an independent chain via previousActivityId
Parallel delegation : Multiple child Runs can share the same delegatedBy.runId
Flexible rendering : Group by runId, or flatten with expertKey labels
Run: parent-run (delegatedBy: undefined)
activity-1 (prev: null) → query: "Process data"
activity-2 (prev: activity-1) → readFile: config.json
activity-3 (prev: activity-2) → delegate: [child-math, child-text]
Run: child-math-run (delegatedBy: { expertKey: "parent", runId: "parent-run" })
activity-4 (prev: null) → query: "Calculate sum"
activity-5 (prev: activity-4) → complete: "Math result: 42"
Run: child-text-run (delegatedBy: { expertKey: "parent", runId: "parent-run" })
activity-6 (prev: null) → query: "Format text"
activity-7 (prev: activity-6) → complete: "Text result: hello"
Run: parent-run (resumed)
activity-8 (prev: activity-3) → complete: "All done"
Activity is designed for:
UI Display — Show users what the Expert is doing in a clear, actionable format
Interactive Sessions — Help users understand Expert behavior for effective collaboration
Logging — Create human-readable execution logs
Debugging — Trace specific actions without parsing raw events
// Example: Displaying activities in a UI
function ActivityLog ( { activities } : { activities : Activity [] } ) {
{ activities . map (( activity ) => (
{ activity . type === " readTextFile " && `📄 Read $ { activity . path }`}
{ activity . type === " writeTextFile " && `✏️ Write $ { activity . path }`}
{ activity . type === " exec " && `⚡ Run $ { activity . command }`}
{ activity . type === " delegate " && `🤝 Delegate to $ { activity . delegateExpertKey }`}
{ activity . type === " complete " && `✅ Complete: $ { activity . text } ` }
Aspect RunEvent (Primary) RuntimeEvent (Side Effect) What it tracks State machine + streaming Runtime environment state Accumulation State: history, Streaming: latest Only latest state matters Determinism State: deterministic Environment-dependent Persistence Stored with checkpoints Typically not persisted Consumer use Execution logs, replay, audit UI updates, monitoring
┌─────────────────────────────────────────────────────────────┐
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Agent Loop State Machine │ │
│ │ Init → Generate → CallTools → Resolve → Finish │ │
│ │ └─────────┴──────────┴──────────┴─────────┘ │ │
│ │ ExpertStateEvents │ │
│ │ (state transitions) │ │
│ │ (real-time content) │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ ┌──────────────────────┼───────────────────────────────┐ │
│ │ Skills, Docker, Proxy │ │
│ │ (environment state) │ │
│ └──────────────────────┴───────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
When using perstack run, output contains JSON events. Filter them for readability:
import * as readline from " node:readline "
function formatEvent ( event : Record < string , unknown > ) : string | null {
const type = event . type as string
const expertKey = event . expertKey as string
// RunEvents have expertKey
case " startRun " : return ` [ ${ expertKey } ] Starting... `
const toolCalls = event . toolCalls as Array <{ toolName : string }>
return ` [ ${ expertKey } ] Tools: ${ toolCalls . map ( t => t . toolName ) . join ( " , " ) } `
case " completeRun " : return ` [ ${ expertKey } ] Done: ${ event . text } `
case " stopRunByError " : return ` [ ${ expertKey } ] Error: ${ (event . error as { message : string } ) . message } `
case " streamReasoning " : return ` [ ${ expertKey } ] Thinking: ${ event . delta } `
case " skillConnected " : return ` Skill connected: ${ event . skillName } `
case " dockerBuildProgress " : return ` Docker: ${ event . message } `
const action = event . action === " allowed " ? " ✓ " : " ✗ "
return ` Proxy ${ action } ${ event . domain } : ${ event . port } `
const rl = readline . createInterface ( { input: process . stdin , terminal: false } )
rl . on ( " line " , ( line ) => {
const event = JSON . parse (line)
const formatted = formatEvent (event)
if (formatted) console . log (formatted)
Use the provided hooks from @perstack/react:
import { useRun } from " @perstack/react "
function ExpertRunner () {
// RunEvents → accumulated activities + streaming state
const { activities , streaming , isComplete , addEvent } = useRun ()
const eventSource = new EventSource ( " /api/events " )
eventSource . onmessage = ( e ) => {
addEvent ( JSON . parse (e . data ))
return () => eventSource . close ()
{ /* Show streaming content (grouped by run for parallel execution) */ }
{ Object . entries ( streaming . runs ). map (([ runId , run ]) => (
run . isReasoningActive && (
< ReasoningDisplay key = {runId} expertKey = {run.expertKey} text = {run.reasoning} />
{ /* Show accumulated activities */ }
< ActivityLog activities = {activities} />
{ isComplete && < div > Run complete !</ div >}
Full type definitions are available in @perstack/core:
const isRunEvent = ( event : PerstackEvent ) : event is RunEvent =>
const isStreamingEvent = ( event : PerstackEvent ) : event is StreamingEvent =>
" expertKey " in event && [ " startStreamingReasoning " , " streamReasoning " , " completeStreamingReasoning " , " startStreamingRunResult " , " streamRunResult " , " completeStreamingRunResult " ] . includes (event . type as string )
const isRuntimeEvent = ( event : PerstackEvent ) : event is RuntimeEvent =>