Skip to main content

Command Palette

Search for a command to run...

The Best Agent Architectures Are Event-Driven, Not Chat-Driven

Chat is one trigger among many. Design for events first, and conversation becomes a natural special case.

Published
6 min read
The Best Agent Architectures Are Event-Driven, Not Chat-Driven
A
I love building with and sharing about AI.

Most agent tutorials start the same way: a user sends a message, the agent responds. Chat in, chat out. It's the default mental model, and it's limiting what teams build.

The agents that do the most useful work in production aren't waiting for someone to type. They're reacting to events — a webhook fires, a document lands in a queue, a timer ticks, a row changes in a database. The conversation interface is one trigger among many, not the only one.

If your agent architecture assumes chat, you'll keep bolting on workarounds. If it assumes events, chat becomes a natural special case.

The Chat Trap

When you model your agent around a conversation loop, every interaction gets squeezed through the same pipe: user sends message → agent processes → agent responds.

This works fine for support bots and coding assistants. But the moment you need an agent to:

  • Process an incoming webhook from Stripe when a payment fails
  • Analyze a document the moment it's uploaded to S3
  • Run a nightly reconciliation across two data sources
  • React to a monitoring alert and create a ticket

...you're fighting the architecture. You start wrapping your chat-based agent in cron jobs, queue consumers, and API routes that fake a "user message" to trigger the behavior you want. The agent becomes a chat interface with duct tape on the edges.

Events Are the Better Primitive

An event-driven agent architecture starts from a different assumption: the agent is a thing that responds to triggers, and a user message is just one type of trigger.

This isn't a hypothetical. It's how Octavus protocols work. Every agent declares its triggers explicitly:

triggers:
  user-message:
    description: User sends a chat message
    input:
      USER_MESSAGE:
        type: string

  payment-failed:
    description: Stripe webhook for failed payment
    input:
      CUSTOMER_ID:
        type: string
      INVOICE_ID:
        type: string
      FAILURE_REASON:
        type: string

  document-uploaded:
    description: New document ready for processing
    input:
      DOCUMENT_URL:
        type: string
      ANALYSIS_TYPE:
        type: string

Each trigger has a name, a typed input schema, and a corresponding handler that defines what the agent does when that trigger fires. The user-message trigger is just another entry in the list — no more special than payment-failed or document-uploaded.

Handlers: Different Events, Different Behavior

Each trigger maps to its own handler. This is where event-driven design pays off — you're not routing everything through a single prompt and hoping the model figures out context from the message text.

handlers:
  user-message:
    Add user message:
      block: add-message
      role: user
      prompt: user-message
      input: [USER_MESSAGE]

    Respond:
      block: next-message

  payment-failed:
    Look up customer:
      block: tool-call
      tool: get-customer
      input:
        customerId: CUSTOMER_ID

    Assess failure:
      block: add-message
      role: user
      prompt: payment-failure-assessment
      input: [CUSTOMER_ID, INVOICE_ID, FAILURE_REASON]

    Decide action:
      block: next-message

  document-uploaded:
    Analyze document:
      block: add-message
      role: user
      prompt: document-analysis
      input: [DOCUMENT_URL, ANALYSIS_TYPE]

    Generate analysis:
      block: next-message

The payment-failed handler doesn't start with a chat message. It starts with a deterministic tool call to look up customer data, then feeds structured context to the model. The document-uploaded handler goes straight to analysis. Each path is designed for its event, not shoehorned through a conversational flow.

Firing Triggers from Anywhere

Once your agent speaks events, invoking it from any part of your stack becomes straightforward. The server SDK's execute() method takes a trigger name and typed input — that's it:

// From a webhook endpoint
app.post('/webhooks/stripe', async (req, res) => {
  const event = req.body;

  if (event.type === 'invoice.payment_failed') {
    const session = client.agentSessions.attach(sessionId, {
      tools: { 'get-customer': getCustomerHandler },
    });

    const events = session.execute({
      type: 'trigger',
      triggerName: 'payment-failed',
      input: {
        CUSTOMER_ID: event.data.object.customer,
        INVOICE_ID: event.data.object.id,
        FAILURE_REASON: event.data.object.last_payment_error?.message,
      },
    });

    for await (const ev of events) {
      // Process streaming events
    }
  }
});
// From a scheduled job
cron.schedule('0 2 * * *', async () => {
  const sessionId = await client.agentSessions.create('reconciliation-agent');
  const session = client.agentSessions.attach(sessionId, {
    tools: { 'query-database': queryHandler },
  });

  const events = session.execute({
    type: 'trigger',
    triggerName: 'run-reconciliation',
    input: { DATE: new Date().toISOString().split('T')[0] },
  });

  for await (const ev of events) {
    // Handle results
  }
});

No fake user messages. No prompt injection to set context. The trigger carries the event type and the data it needs. The handler knows what to do with it.

Workers for Pure Event Processing

Some events don't need a conversation at all. They need execution: take input, run steps, produce output. That's what workers are for.

Workers are a separate agent format in Octavus — no triggers, no session persistence, just sequential steps with a return value:

input:
  DOCUMENT_URL:
    type: string
  ANALYSIS_TYPE:
    type: string

variables:
  EXTRACTED_DATA:
    type: string
  ANALYSIS_RESULT:
    type: string

steps:
  Start extraction:
    block: start-thread
    thread: extraction
    model: anthropic/claude-sonnet-4-5
    system: extraction-system
    tools: [fetch-document]
    maxSteps: 3

  Request extraction:
    block: add-message
    thread: extraction
    role: user
    prompt: extract-prompt
    input: [DOCUMENT_URL]

  Extract:
    block: next-message
    thread: extraction
    output: EXTRACTED_DATA

  Start analysis:
    block: start-thread
    thread: analysis
    model: anthropic/claude-sonnet-4-5
    system: analysis-system

  Request analysis:
    block: add-message
    thread: analysis
    role: user
    prompt: analyze-prompt
    input: [EXTRACTED_DATA, ANALYSIS_TYPE]

  Analyze:
    block: next-message
    thread: analysis
    output: ANALYSIS_RESULT

output: ANALYSIS_RESULT

Workers can use different models at different steps, run tools agentically, and return a structured output. Call them from a queue consumer, a webhook handler, or from inside another agent:

const { output } = await client.workers.generate(
  'document-analyzer',
  { DOCUMENT_URL: url, ANALYSIS_TYPE: 'summary' },
  {
    tools: {
      'fetch-document': async (args) => await fetchDoc(args.url),
    },
  },
);

No session. No conversation history. Just input → processing → output. The event-driven agent for cases where statefulness is unnecessary weight.

Sessions Still Matter — For the Right Events

This isn't an argument against sessions or conversation. Stateful sessions are powerful when an agent needs to maintain context across a series of related events. A support agent handling an ongoing customer issue, for example, benefits from remembering what happened three messages ago.

The point is that sessions serve the event architecture, not the other way around. You create a session when an event stream needs continuity. You skip it when an event is self-contained.

// Self-contained event → Worker (no session)
const result = await client.workers.generate('analyze-doc', { DOCUMENT_URL: url });

// Ongoing event stream → Session (stateful)
const sessionId = await client.agentSessions.create('support-agent', { USER_ID: userId });
// Multiple triggers over time share this session's state

The Design Shift

Moving from chat-driven to event-driven agent architecture changes how you think about several things:

Agent boundaries. Instead of one mega-agent that handles everything through conversation, you design agents around the events they respond to. An agent that handles payment failures is separate from one that analyzes documents. Clearer boundaries, easier testing, better separation of concerns.

Input contracts. Every trigger has a typed input schema. When a webhook fires, the data it carries is validated against that schema. No more parsing intent from natural language when you already have structured data.

Composition. Workers can be called from other agents. An interactive agent handling a customer conversation can delegate to a worker for document analysis mid-conversation, then continue the dialogue with the results. Events compose naturally because they have defined inputs and outputs.

Observability. When every invocation is a named trigger with typed input, logging and debugging get dramatically simpler. You can trace exactly which event fired, what data it carried, and which handler processed it. Compare that to debugging a chat log to figure out what prompt led to what behavior.

Start with Events

If you're designing a new agent system, resist the urge to start with a chat interface and add event handling later. Start with triggers. Define the events your agent needs to respond to. Chat will be one of them — probably the one you build a UI for — but it won't constrain your architecture.

The agents that do the most work in production are the ones you never talk to.