Skip to content

Latest commit

 

History

History
115 lines (80 loc) · 6.41 KB

File metadata and controls

115 lines (80 loc) · 6.41 KB
title Reactive vs proactive, with examples
summary The same agent written twice: reactive and proactive. Same goal, same provider, different posture. The difference is who waits for whom.
date 2026-05-10
lastModified 2026-05-18
accent sage
dropcap false

Let's pick a small, familiar agent and write it twice. One that closes a support ticket when the customer's last reply contains a thumbs-up. Once reactive, once proactive. Same model, same provider, same goal. Different posture toward the world.

The reactive version

// runs every five minutes via cron
async function tick() {
  const tickets = await zendesk.search({
    status: "open",
    updated_since: lastRun,
  });
  for (const t of tickets) {
    const reply = t.lastCustomerReply;
    if (containsApproval(reply)) {
      await zendesk.close(t.id, { reason: "customer-approved" });
    }
  }
  lastRun = Date.now();
}

This works, but it's fragile in ways you won't notice until production. Notice how much truth this code is asserting that nobody enforced:

  • lastRun lives in a global. The next deploy resets it to zero and we re-process the world.
  • The five-minute interval is a number we made up. Three minutes is too chatty; ten is too slow; nobody actually measured.
  • A burst of tickets at minute four means the agent acts on thousands of records at minute five. The next minute it sleeps again.
  • If containsApproval ever falsely fires, it will close a real ticket, and we'll find out from a customer.
  • We aren't holding a lease. Two instances racing means double-closes. Two pods means split-brain.

None of these are exotic problems. They are the bread and butter of every cron-based agent in production. They get patched as they show up (locks added, intervals tuned, idempotency keys retrofitted) until the loop has more scaffolding than logic. I go deeper on what makes proactive agents hard to build.

The proactive version

import { agent } from "@agent-relay/agent";

agent({
  workspace: "support",
  watch: ["/zendesk/tickets/**"],
  onEvent: async (ctx, event) => {
    if (event.type !== "relayfile.changed") return;

    const full = await event.expand("full");
    const reply = full.data.current.lastCustomerReply;
    const wasOpen = full.data.previous.status === "open";

    if (wasOpen && containsApproval(reply)) {
      await zendesk.close(full.data.current.id, { reason: "customer-approved" });
    }
  },
});

A few things changed. The agent isn't a tick() function any more. It's a handler. It receives a change, not a snapshot. The change has both previous and current, so the agent can see the transition — not just the state right now, but what it just stopped being.

The five-minute interval is gone. The agent runs the moment Zendesk publishes the update. There is no lastRun global, because there is no batch — each event is its own unit of work. Idempotency is the runtime's problem; the same change won't be delivered twice. Locks are the runtime's problem; one event, one handler, one lease.

The honest engineering work moved from plumbing to behavior — which is now basically the entire program.

The reactive version asks: *what changed in the last five minutes?* The proactive version asks: *what just changed?* The first is a query you have to invent. The second is a fact the world hands you.

What the difference actually buys

Three things that compound:

  1. Latency drops to provider speed. The agent acts the moment the world changes, not the next time the cron fires. For most providers that's ~1 second of webhook delivery. Reactive systems trade latency for cost; proactive systems don't pay the trade.

  2. Edge cases collapse. A huge amount of cron-loop code exists to detect what changed. When the runtime gives you the diff, that code disappears.

  3. State surface shrinks. The agent stops needing to remember what it did. Each event is self-contained. The runtime keeps the state; the agent uses it.

Each one is small on its own, but they compound — because the things they remove are exactly the things that make agents flake out at 3am.

When reactive is still the right answer

To be clear: reactive agents are not bad. They are appropriate for some shapes of work.

  • Batch-shaped jobs — "every Monday morning, generate a digest" — are reactive by design. Don't fight it.
  • Long compute that doesn't care about freshness — nightly backfills, weekly retraining — reactive is fine.
  • One-shot prompts — the user asked, the agent answers, done — reactive is the only answer.

What reactive is not great for is anything where the whole value of the agent is its responsiveness. If the agent's job is to notice things and act on them quickly, polling will lose to push every time.

So what's the takeaway

Push and persistence beat pull and statelessness for agents, same way they do in every other distributed system. The PARE benchmark bears this out: the models that observe carefully and propose selectively achieve far higher success rates than eager ones. MindStudio's overview frames the same shift as "reactive to anticipatory," and their definition lands in the same place: proactive means acting on your behalf without waiting for a prompt, maintaining persistent state across sessions. Most agents are still reactive because the runtime to make them proactive didn't exist as something you could just import. People get the tradeoff. The tooling just wasn't there.

We've been building that part. More on the runtime in Proactive agents need three primitives.

This site runs a proactive agent of its own. The source lives in [`agents/`](https://github.com/AgentWorkforce/proactive-agents/tree/main/agents) on GitHub; what it has actually done shows up live at [/agent](/agent), every entry committed by the agent itself.