May 10, 20264 min

Reactive vs proactive, with examples

The same agent written twice: reactive and proactive. Same goal, same provider, different posture. The difference is who waits for whom.

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.

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: event-driven, stateless handler.

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.

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. 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.

Posted May 10, 2026· AgentWorkforce

Issues, PRs, and arguments welcome on GitHub. Or email [email protected].