Skip to content
Perstack

Events Reference

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 TypePurposeAccumulation
ExpertStateEventState machine transitionsAccumulated log
StreamingEventReal-time streaming contentLatest state only
RuntimeEventInfrastructure-level side effectsLatest 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).

Job
└── Run (has many)
└── Events → Steps → Checkpoints
ConceptDescription
JobTop-level execution unit (one per perstack run)
RunSingle Expert execution within a Job
StepOne cycle of the agent loop
CheckpointSnapshot 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:

interface BaseEvent {
id: string // Unique event ID
expertKey: string // Expert that emitted this event
timestamp: number // Unix timestamp
jobId: string // Job ID
runId: string // Run ID
stepNumber: number // Step number when emitted
}
Event TypeDescriptionKey Payload
startRunRun startedinitialCheckpoint, inputMessages
completeRunRun completed successfullycheckpoint, step, text, usage
Event TypeDescriptionKey Payload
startGenerationLLM generation startedmessages
retryGeneration failed, retryingreason, newMessages, usage
Event TypeDescriptionKey Payload
callToolsRegular tool callsnewMessage, toolCalls, usage
callInteractiveToolInteractive tool call (needs user input)newMessage, toolCall, usage
callDelegateDelegation to another ExpertnewMessage, toolCalls, usage
resolveToolResultsTool results receivedtoolResults
attemptCompletionCompletion tool calledtoolResult
finishToolCallSingle tool call finishednewMessages
resumeToolCallsResume pending tool callspendingToolCalls, partialToolResults
finishAllToolCallsAll tool calls finishednewMessages
Event TypeDescriptionKey Payload
continueToNextStepProceeding to next stepcheckpoint, step, nextCheckpoint
Event TypeDescriptionKey Payload
stopRunByInteractiveToolStopped for user inputcheckpoint, step
stopRunByDelegateStopped for delegationcheckpoint, step
stopRunByExceededMaxStepsStopped due to max steps limitcheckpoint, step
stopRunByErrorStopped due to errorcheckpoint, step, error

StreamingEvents provide real-time content during LLM generation. They include expertKey for proper attribution in parallel runs.

Event TypeDescriptionKey Payload
startStreamingReasoningStart of reasoning stream(empty)
streamReasoningReasoning deltadelta
completeStreamingReasoningReasoning completetext
startStreamingRunResultStart of result stream(empty)
streamRunResultResult deltadelta
completeStreamingRunResultResult completetext

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)) {
switch (event.type) {
case "streamReasoning":
updateStreamingReasoning(event.delta)
break
case "streamRunResult":
updateStreamingResult(event.delta)
break
// ... handle other streaming events
}
return
}
// ExpertStateEvents for execution log
switch (event.type) {
case "startRun":
addActivity({ type: "query", text: extractQuery(event) })
break
case "callTools":
addActivity({ type: "toolCall", tools: event.toolCalls })
break
case "completeRun":
addActivity({ type: "complete", text: event.text })
break
// ... 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
jobId: string // Job ID
runId: string // Run ID
}
Event TypeDescriptionKey Payload
initializeRuntimeRuntime initializedruntimeVersion, expertName, model, etc.
Event TypeDescriptionKey Payload
skillStartingMCP skill startingskillName, command, args
skillConnectedMCP skill connectedskillName, serverInfo, timing metrics
skillStderrSkill stderr outputskillName, message
skillDisconnectedMCP skill disconnectedskillName
Event TypeDescriptionKey Payload
dockerBuildProgressDocker build progressstage, service, message, progress
dockerContainerStatusContainer status changestatus, service, message
Event TypeDescriptionKey Payload
proxyAccessNetwork access allow/blockaction, domain, port, reason

RuntimeEvents should be processed as current state — only the latest value matters.

// Example: Managing RuntimeEvent as current state
type RuntimeState = {
skills: Map<string, SkillStatus>
// ...
}
function handleRuntimeEvent(state: RuntimeState, event: PerstackEvent): RuntimeState {
if (!isRuntimeEvent(event)) return state
switch (event.type) {
case "skillConnected":
return {
...state,
skills: new Map(state.skills).set(event.skillName, { status: "connected" })
}
// ... handle other events
}
return state
}

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.

type Activity = {
/** Activity type (e.g., "readTextFile", "exec", "delegate") */
type: string
/** Unique identifier for this activity */
id: string
/** Expert that executed this action */
expertKey: string
/** Run ID this activity belongs to */
runId: string
/** Previous activity ID within the same Run (daisy chain) */
previousActivityId?: string
/** Parent Run information (for delegated Runs) */
delegatedBy?: {
expertKey: string
runId: string
}
/** LLM's reasoning before this action (if available) */
reasoning?: string
// ... action-specific fields
}
TypeDescription
queryUser input that started the run
completeRun completed with final result
errorRun stopped due to error
retryGeneration failed, will retry
TypeDescription
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
TypeDescription
listDirectoryList directory contents
createDirectoryCreate a directory
deleteDirectoryDelete a directory
TypeDescription
execShell command execution
TypeDescription
todoUpdate todo list
clearTodoClear all todos
attemptCompletionSignal task completion
TypeDescription
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:

  1. Within-Run ordering: previousActivityId links activities in the same Run
  2. 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:

  1. UI Display — Show users what the Expert is doing in a clear, actionable format
  2. Interactive Sessions — Help users understand Expert behavior for effective collaboration
  3. Logging — Create human-readable execution logs
  4. Debugging — Trace specific actions without parsing raw events
// Example: Displaying activities in a UI
function ActivityLog({ activities }: { activities: Activity[] }) {
return (
<ul>
{activities.map((activity) => (
<li key={activity.id}>
{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}`}
</li>
))}
</ul>
)
}
AspectRunEvent (Primary)RuntimeEvent (Side Effect)
What it tracksState machine + streamingRuntime environment state
AccumulationState: history, Streaming: latestOnly latest state matters
DeterminismState: deterministicEnvironment-dependent
PersistenceStored with checkpointsTypically not persisted
Consumer useExecution logs, replay, auditUI updates, monitoring
┌─────────────────────────────────────────────────────────────┐
│ Runtime │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Agent Loop State Machine │ │
│ │ │ │
│ │ Init → Generate → CallTools → Resolve → Finish │ │
│ │ │ │ │ │ │ │ │
│ │ └─────────┴──────────┴──────────┴─────────┘ │ │
│ │ │ │ │
│ │ ExpertStateEvents │ │
│ │ (state transitions) │ │
│ │ │ │
│ │ StreamingEvents │ │
│ │ (real-time content) │ │
│ └──────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────────────┼───────────────────────────────┐ │
│ │ Skills, Docker, Proxy │ │
│ │ │ │ │
│ │ RuntimeEvents │ │
│ │ (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
if (expertKey) {
switch (type) {
case "startRun": return `[${expertKey}] Starting...`
case "callTools": {
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}`
}
}
// RuntimeEvents
switch (type) {
case "skillConnected": return `Skill connected: ${event.skillName}`
case "dockerBuildProgress": return `Docker: ${event.message}`
case "proxyAccess": {
const action = event.action === "allowed" ? "" : ""
return `Proxy ${action} ${event.domain}:${event.port}`
}
}
return null
}
const rl = readline.createInterface({ input: process.stdin, terminal: false })
rl.on("line", (line) => {
try {
const event = JSON.parse(line)
const formatted = formatEvent(event)
if (formatted) console.log(formatted)
} catch {}
})

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()
useEffect(() => {
const eventSource = new EventSource("/api/events")
eventSource.onmessage = (e) => {
addEvent(JSON.parse(e.data))
}
return () => eventSource.close()
}, [addEvent])
return (
<div>
{/* 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>}
</div>
)
}

Full type definitions are available in @perstack/core:

import type {
PerstackEvent,
RunEvent,
ExpertStateEvent,
StreamingEvent,
RuntimeEvent,
EventType,
ExpertStateEventType,
StreamingEventType,
RuntimeEventType,
EventForType,
RuntimeEventForType,
Activity,
} from "@perstack/core"
// Type guard functions
const isRunEvent = (event: PerstackEvent): event is RunEvent =>
"expertKey" in event
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 =>
!("expertKey" in event)