One Primitive for Every Interruption

By Arshad Ansari

If you asked me to name the single design decision in AEGIS I'm proudest of, it wouldn't be an agent, a prompt, or a model choice. It's a table.

Every time the system needs a human — me — it takes exactly the same shape: a row in Postgres, in a table called interactions; a card dispatched to my chat app; and a Temporal workflow quietly waiting for my answer. That's the whole mechanism. There is no second one.

I've written before about what AEGIS is — a small fleet of named agents running scheduled and event-driven Temporal workflows over my own data: tasks, email, money, knowledge, homelab alerts. Four personalities, each a permission boundary with a name. Local models first through a LiteLLM proxy, hosted ones when a job wants horsepower. All of it on a three-node Docker Swarm at home. This post is about the one seam where all that machinery meets a person.

The shape of an interruption

Mechanically, it's a child workflow called InteractionFlow. Any flow that reaches a point where it genuinely needs a decision spawns one. The child does three things: create the row, dispatch the card, await a submit_response signal. When I tap a button, the signal arrives, the child hands my answer back to its parent, and the parent carries on as if it had never stopped.

There are five kinds of card, and only five:

  • approval — yes or no.
  • choice — pick one of these.
  • input — I need a piece of text from you.
  • draft_review — here's what I intend to send; edit it or bless it.
  • ack — something happened; tap so I know you saw it.

Every button on every card resolves to a callback of the same form: interaction:{id}:{value}. One format, one parser, end to end. The chat layer never learns what a renewal or a pull request is. It knows how to render five kinds of card and route one kind of callback, and that's all it ever needs to know.

What was there before

This is the honest part. Interactions didn't arrive fully formed. They were roughly the third attempt.

In the early rewrites there was a decisions table that one flow wrote to. Then the finance side grew a notion of pending claims, which was almost the same thing but not quite. Then a couple of features wired their own chat callbacks directly, each with its own encoding, because that was the fastest way to ship them that week.

Each of these was locally reasonable. Together they didn't compose. Three notions of "waiting on a human." Three callback formats. Three places to look to answer the only question that matters at nine in the morning: what actually needs me? The admin UI couldn't render a single queue of pending decisions because there wasn't one — there were three, wearing different schemas.

The replacement wasn't the hard part. The shape above is not clever. The hard part was the discipline afterwards: every time a new decision type showed up, the reflex said this one's a bit special, give it its own table. The work was saying no — mapping it onto the primitive and trusting the abstraction to hold.

Most of design, it turns out, is refusing to add the second mechanism.

Waiting is the hard part

A card in a chat app looks trivial until you notice what stands behind it: a workflow that may wait hours, sometimes days, for a human to tap it.

With cron and a message queue, that's genuinely miserable. You end up hand-rolling a state machine in the database, polling it, reconciling it after every deploy, and worrying about the message that got consumed but never acted on. I know, because around then AEGIS contained partial versions of exactly that, and none of them were things I'd show anyone.

This is precisely what Temporal's durable execution is for. The workflow awaits the signal — that is, more or less, the code — and the platform makes the waiting survive restarts, redeploys, and the occasional node reboot my little swarm treats as a hobby. A three-day wait costs nothing and loses nothing. Shipping new code mid-wait is a non-event.

Durable execution turns "waiting for a human" from a distributed-systems problem into a line of code. That's not a small thing. It's the reason this primitive is viable at all.

Five kinds keep it honest

The failure mode of "one primitive for everything" is well known: you generalize until the thing means nothing. A type column, a JSON blob, and every call site quietly inventing its own semantics inside the blob. Congratulations — you've rebuilt the ad-hoc mess, now with indirection.

The guard is that the five kinds are concrete, few, and expensive to extend. A new decision type has to map onto one of them. If it genuinely can't, that's a design conversation about adding a sixth — a rare, deliberate event — not a migration somebody runs on a Tuesday afternoon.

So far the vocabulary has held. draft_review came closest to not existing — it's nearly approval with a payload — but editing a thing before blessing it is a different act from approving it, and collapsing the two made the cards noticeably worse. It earned its slot.

Five isn't a magic number. It's just the current size of the honest vocabulary.

What it actually bought

Here's the thesis: this one decision is what turned AEGIS from a notification machine into a queue of interruptions that have to earn their way in.

Every interruption now has a uniform cost and a uniform record — a row, who asked, why, when, and how long I took to respond. "What's waiting on me" is a single query. The daily brief can count open interactions without knowing anything about the flows behind them. And because interrupting me is a formal act with a paper trail rather than a free message send, flows get written differently: they do more work before asking, and the ask is sharper when it finally comes.

Fewer, better interruptions. That's the entire point of the system. The primitive is where that policy stops being a slogan and becomes something the schema enforces.

Where it's heading

It isn't finished. Two threads are open.

Richer cards. Five kinds cover the semantics, but the rendering is deliberately plain, and some decisions would land better with more context carried on the card itself instead of a link back to the admin UI. That's careful work — every pixel added to a card is an invitation to stop reading it.

Timeouts and escalation. Temporal makes patience free, which is a blessing and a trap: an unanswered card will wait indefinitely without complaint. There are blunt timeout policies today — a card can archive itself or fall back to a default — but that isn't escalation. What I want is the polite version of a good assistant: a nudge after a day, a different channel if the first one is clearly being ignored, and, where a safe default exists, taking it and saying so plainly. Deciding on behalf of an absent human is a delicate feature to get right. Which is exactly why it's next.

That's the primitive. One table, five kinds of card, one callback format, and a workflow engine doing the patient part.

If the wider system sounds interesting, the longer tour lives at /aegis — and if this seam, the place where automated work meets a human who's allowed to say no, is a problem you're staring at too, it's the kind of thing I occasionally take consulting work on, and always enjoy comparing notes about.

Building something data-heavy? Let's talk.