Skip to main content

What are Events?

Events provide observability into agent execution. Every significant action—starting a run, calling a tool, emitting a reasoning step—fires a typed event that you can listen to. Unlike middleware (which can modify execution), events are read-only observers. Use them for:
  • Logging and debugging
  • Telemetry and metrics
  • UI updates (streaming, progress indicators)
  • Analytics and monitoring

Event Emitter

Agents use a fully-typed event emitter:
// Source: packages/utils/src/events.ts:14-69
export class EventEmitter<TEvents extends EventMap = EventMap> {
  private readonly handlers = new Map<keyof TEvents, EventHandler[]>()

  on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): this {
    const list = this.handlers.get(event) ?? []
    list.push(handler)
    this.handlers.set(event, list)
    return this
  }

  once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): this {
    const wrapper: EventHandler<TEvents[K]> = async (payload) => {
      this.off(event, wrapper)
      return await handler(payload)
    }
    return this.on(event, wrapper)
  }

  off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): this {
    const list = this.handlers.get(event)
    if (list) {
      this.handlers.set(
        event,
        list.filter((h) => h !== handler),
      )
    }
    return this
  }

  async emit<K extends keyof TEvents>(event: K, payload: TEvents[K]): Promise<void> {
    const list = this.handlers.get(event) ?? []
    await Promise.all(list.map((h) => h(payload)))
  }
}

Core Events

AgentLIB defines a typed event map:
// Source: packages/core/src/types/index.ts:298-327
export type CoreEvent =
  | 'run:start'
  | 'run:end'
  | 'step:start'
  | 'step:end'
  | 'model:request'
  | 'model:response'
  | 'tool:before'
  | 'tool:after'
  | 'memory:read'
  | 'memory:write'
  | 'cancel'
  | 'error'

export interface AgentEventMap extends Record<string, any> {
  'run:start': { input: string; sessionId: string }
  'run:end': { output: string; state: ExecutionState }
  'step:start': { engine: string }
  'step:end': { step: ReasoningStep }
  'step:reasoning': ReasoningStep
  'model:request': ModelRequest
  'model:response': ModelResponse
  'tool:before': { name: string; args: Record<string, unknown> }
  'tool:after': { name: string; args: Record<string, unknown>; result?: unknown; error?: string }
  'memory:read': { sessionId: string }
  'memory:write': { sessionId: string }
  'cancel': { reason?: any }
  'error': unknown
}

Subscribing to Events

Basic Subscription

import { createAgent } from '@agentlib/core'

const agent = createAgent({ name: 'assistant' })

agent.on('run:start', ({ input, sessionId }) => {
  console.log(`Starting run: ${input} (session: ${sessionId})`)
})

agent.on('run:end', ({ output, state }) => {
  console.log(`Completed with output: ${output}`)
  console.log(`Tokens used: ${state.usage.totalTokens}`)
})

One-time Listeners

agent.once('run:end', ({ output }) => {
  console.log('First run completed:', output)
  // This handler will automatically unsubscribe after firing once
})

Unsubscribing

const handler = ({ output }) => {
  console.log('Output:', output)
}

agent.on('run:end', handler)

// Later...
agent.off('run:end', handler)

Event Reference

run:start

Fired when agent.run() is called, before any processing begins.
agent.on('run:start', ({ input, sessionId }) => {
  console.log(`[${sessionId}] User: ${input}`)
})
Payload:
{ input: string; sessionId: string }
Source: packages/core/src/agent/agent.ts:88

run:end

Fired when execution completes successfully.
agent.on('run:end', ({ output, state }) => {
  console.log('Final answer:', output)
  console.log('Reasoning steps:', state.steps.length)
  console.log('Tools called:', state.toolCalls.length)
  console.log('Token usage:', state.usage)
})
Payload:
{
  output: string
  state: ExecutionState
}
Source: packages/core/src/agent/agent.ts:95

step:reasoning

Fired every time a reasoning engine pushes a step.
agent.on('step:reasoning', (step) => {
  switch (step.type) {
    case 'thought':
      console.log(`[THOUGHT] ${step.content}`)
      break
    case 'tool_call':
      console.log(`[TOOL] Calling ${step.toolName}`)
      break
    case 'tool_result':
      console.log(`[RESULT] ${step.toolName}:`, step.result)
      break
    case 'plan':
      console.log(`[PLAN] ${step.tasks.length} tasks`)
      break
    case 'reflection':
      console.log(`[REFLECT] ${step.assessment}`)
      break
    case 'response':
      console.log(`[RESPONSE] ${step.content}`)
      break
  }
})
Payload:
ReasoningStep // Union of all step types
Source: packages/core/src/reasoning/context.ts:47

model:request

Fired before sending a request to the LLM.
agent.on('model:request', ({ messages, tools }) => {
  console.log('Sending request to model')
  console.log('Messages:', messages.length)
  console.log('Tools:', tools?.length ?? 0)
})
Payload:
{
  messages: ModelMessage[]
  tools?: ToolSchema[]
  stream?: boolean
}

model:response

Fired when the LLM responds.
agent.on('model:response', ({ message, usage, toolCalls }) => {
  console.log('Model response:', message.content)
  console.log('Usage:', usage)
  if (toolCalls) {
    console.log('Tool calls requested:', toolCalls.length)
  }
})
Payload:
{
  message: ModelMessage
  toolCalls?: ToolCall[]
  usage?: TokenUsage
  raw?: unknown
}

tool:before

Fired before a tool is executed.
agent.on('tool:before', ({ name, args }) => {
  console.log(`Executing tool: ${name}`)
  console.log('Arguments:', args)
})
Payload:
{ name: string; args: Record<string, unknown> }
Source: packages/core/src/reasoning/context.ts:67

tool:after

Fired after a tool completes or fails.
agent.on('tool:after', ({ name, args, result, error }) => {
  if (error) {
    console.error(`Tool ${name} failed:`, error)
  } else {
    console.log(`Tool ${name} succeeded:`, result)
  }
})
Payload:
{
  name: string
  args: Record<string, unknown>
  result?: unknown
  error?: string
}
Source: packages/core/src/reasoning/context.ts:94-115

memory:read

Fired when memory is loaded.
agent.on('memory:read', ({ sessionId }) => {
  console.log(`Loading memory for session: ${sessionId}`)
})
Payload:
{ sessionId: string }
Source: packages/core/src/agent/agent.ts:124

memory:write

Fired when memory is persisted.
agent.on('memory:write', ({ sessionId }) => {
  console.log(`Saving memory for session: ${sessionId}`)
})
Payload:
{ sessionId: string }
Source: packages/core/src/agent/agent.ts:139

error

Fired when an error occurs during execution.
agent.on('error', (error) => {
  console.error('Agent error:', error)
  // Send to error tracking service
  errorTracker.capture(error)
})
Payload:
unknown // Any error thrown during execution
Source: packages/core/src/agent/agent.ts:98

cancel

Fired when execution is canceled.
agent.on('cancel', ({ reason }) => {
  console.log('Execution canceled:', reason)
})
Payload:
{ reason?: any }

Custom Events

You can emit custom events from tools or middleware:
const customTool = defineTool({
  schema: {
    name: 'custom_action',
    description: 'Performs a custom action',
    parameters: { /* ... */ }
  },
  execute: async (args, ctx) => {
    // Emit custom event
    ctx.emit('custom:progress', { step: 1, total: 3 })
    
    // Do work...
    
    ctx.emit('custom:progress', { step: 2, total: 3 })
    
    // More work...
    
    ctx.emit('custom:progress', { step: 3, total: 3 })
    
    return 'done'
  }
})

// Listen to custom events
agent.on('custom:progress', ({ step, total }) => {
  console.log(`Progress: ${step}/${total}`)
})

Common Event Patterns

Progress Tracking

let stepCount = 0

agent.on('step:reasoning', (step) => {
  stepCount++
  if (step.type === 'tool_call') {
    console.log(`Step ${stepCount}: Calling ${step.toolName}`)
  } else if (step.type === 'thought') {
    console.log(`Step ${stepCount}: Thinking...`)
  }
})

agent.on('run:end', () => {
  console.log(`Completed in ${stepCount} steps`)
  stepCount = 0
})

Streaming UI Updates

// Real-time updates to a UI
agent.on('step:reasoning', (step) => {
  // Send step to frontend via WebSocket
  ws.send(JSON.stringify({
    type: 'step',
    data: step
  }))
})

agent.on('run:end', ({ output }) => {
  ws.send(JSON.stringify({
    type: 'complete',
    output
  }))
})

Cost Calculation

let totalCost = 0

agent.on('model:response', ({ usage }) => {
  if (!usage) return
  
  // GPT-4 pricing
  const inputCost = (usage.promptTokens / 1000) * 0.03
  const outputCost = (usage.completionTokens / 1000) * 0.06
  totalCost += inputCost + outputCost
})

agent.on('run:end', () => {
  console.log(`Total cost: $${totalCost.toFixed(4)}`)
  totalCost = 0
})

Logging

const logger = winston.createLogger({ /* ... */ })

agent.on('run:start', ({ input, sessionId }) => {
  logger.info('Run started', { sessionId, input })
})

agent.on('tool:before', ({ name, args }) => {
  logger.debug('Tool called', { tool: name, args })
})

agent.on('error', (error) => {
  logger.error('Agent error', { error })
})

agent.on('run:end', ({ output, state }) => {
  logger.info('Run completed', {
    output,
    steps: state.steps.length,
    tokens: state.usage.totalTokens
  })
})

Analytics

agent.on('run:end', async ({ state }) => {
  await analytics.track('agent_run_completed', {
    steps: state.steps.length,
    tools_called: state.toolCalls.length,
    tokens: state.usage.totalTokens,
    duration: state.finishedAt.getTime() - state.startedAt.getTime()
  })
})

Events vs. Middleware

FeatureEventsMiddleware
PurposeObserve executionModify/control execution
Can modify contextNoYes
Can cancel executionNoYes (via throw/cancel)
Scoped executionNo (global)Yes (scope-specific)
Async handlersYesYes
Best forLogging, analytics, UI updatesAuth, validation, rate limiting

When to Use Events

  • Read-only observation
  • Telemetry and metrics
  • UI updates (progress, streaming)
  • Debugging and logging
  • Multiple independent listeners

When to Use Middleware

  • Modify execution context
  • Validate or transform data
  • Authentication/authorization
  • Rate limiting
  • Caching
  • Control flow (cancel, retry)

Event Handler Best Practices

  1. Keep handlers fast
    // Bad: blocking operation
    agent.on('step:reasoning', (step) => {
      const result = fs.readFileSync('/large-file.txt') // Blocks!
      // ...
    })
    
    // Good: async operation
    agent.on('step:reasoning', async (step) => {
      const result = await fs.promises.readFile('/large-file.txt')
      // ...
    })
    
  2. Handle errors
    agent.on('run:end', async ({ state }) => {
      try {
        await sendToAnalytics(state)
      } catch (err) {
        console.error('Analytics error:', err)
        // Don't let analytics errors crash the agent
      }
    })
    
  3. Avoid side effects in hot paths
    // Bad: runs on every step (potentially 100s of times)
    agent.on('step:reasoning', async () => {
      await db.insert({ timestamp: Date.now() })
    })
    
    // Good: batch or throttle
    const buffer = []
    agent.on('step:reasoning', (step) => {
      buffer.push(step)
    })
    
    agent.on('run:end', async () => {
      await db.insertMany(buffer)
      buffer.length = 0
    })
    
  4. Clean up listeners
    const handler = ({ output }) => console.log(output)
    
    agent.on('run:end', handler)
    
    // Later, when done:
    agent.off('run:end', handler)
    
  5. Use typed events
    // TypeScript infers the payload type
    agent.on('run:end', ({ output, state }) => {
      // output: string
      // state: ExecutionState
      console.log(output.toUpperCase()) // ✅ Type-safe
    })
    

Debugging with Events

// Log every event for debugging
const events: CoreEvent[] = [
  'run:start', 'run:end', 'step:reasoning',
  'tool:before', 'tool:after', 'memory:read',
  'memory:write', 'error', 'cancel'
]

events.forEach(event => {
  agent.on(event, (payload) => {
    console.log(`[${event}]`, payload)
  })
})

Next Steps

  • Agents - Learn about agent execution lifecycle
  • Middleware - Compare middleware vs. events
  • Reasoning - Understand reasoning step events
  • Tools - Monitor tool execution with events

Build docs developers (and LLMs) love