Behavior Is Data, Not Code
By Arshad Ansari
Somewhere in AEGIS's second rewrite there was a line that, in spirit, looked like this:
if agent_id == "sebas":
...
It never felt wrong at the time. Sebas owns GTD; when the clarify flow needed someone to own a task, of course it asked for Sebas by name. Multiply that by four agents and a couple of years and you get a system where identity is load-bearing in places you've stopped noticing.
This week I took all of it out. Nothing in AEGIS branches on an agent's identity anymore. Behavior keys on capability tags, stored as data, editable from a UI. It was the biggest single refactor of the open-sourcing push, and it's the change that turned AEGIS from "my four agents" into a fleet you can reshape.
A name is not a role
The origin story introduced the cast: Sebas runs GTD, Raphael does research, Maou watches money, Pandora's Actor owns infrastructure. Permission boundaries with names — each with its own tools, model tier, memory, tone, and Slack channel. The anime flavoring was the point.
The trouble is that the code took the naming literally. if agent == "sebas" welds a name to a role. Rename Sebas and GTD quietly stops working. Hand finance to a different agent and that's a Python change. Add a fifth agent and it can't do anything, because no flow has ever heard of it.
For a personal system, that's a nuisance you live with. For an open-source one it's disqualifying — the first thing anyone forking AEGIS will want to do is delete my names.
An agent's name is not its role. The refactor is just the codebase finally admitting that.
Tags, not identities
The replacement is a small closed vocabulary of capability tags: gtd, finance, research, infra. Each agent carries a set of them in agents.capabilities. When a flow needs "whoever owns GTD," it resolves the tag — resolve_tag and agents_by_tag in core — and gets back whoever currently holds it. Today that's Sebas. Tomorrow it can be an agent you named after your cat.
The worker needed its own path. Temporal workflows are deterministic, replayed code — they can't query Postgres mid-flow. So resolution there is an activity, AgentRegistryActivities.resolve_agents: the workflow asks the question, the activity hits the database, and the answer is recorded into workflow history like any other side effect. Slightly more ceremony than a function call, and worth it — a flow that resolved its owner before you reshuffled tags keeps its original answer on replay, which is exactly what you want.
Capabilities are not the whole story
There turn out to be two kinds of behavior, and conflating them was half the original mess.
Capabilities answer what work does this agent own. That's the closed vocabulary above, and it's what flows resolve against.
Everything else — how do you address it, how does chat find it, what can it touch — lives in a second bucket, agents.metadata: intent_keywords steer chat routing, mention_aliases define how the agent is @-addressed, tool_set bounds what it's allowed to call, plus a few more knobs in the same spirit.
All of it is editable from a new Behavior tab in the admin panel:
The Behavior tab: capability tags and tools, set from the UI instead of in code.
That tab is the real deliverable. Add an agent, tick finance, give it an alias and a tool set — and Maou is out of a job without a single line of Python changing. This is what "forkable" actually means for a system like this: not that the code compiles on your machine, but that reshaping the fleet is configuration, not surgery.
The part that was hard
The refactor itself was not clever. It was large.
Identity had been assumed everywhere: in the flows, in chat routing, in the Slack @-mention dispatch, in the GTD clarify and handoff logic that decides which agent picks up a delegated task. Each of those call sites had grown its own private way of assuming — a string comparison here, a hardcoded alias there, a dispatch table keyed by name somewhere else. Every one had to learn to resolve by tag instead, and every one had its own edge cases about what happens when resolution comes back different from what the old code silently assumed.
There was no single elegant commit. It landed as a run of PRs, each one boring, each one tested, each one removing one more place where a name was doing a role's job.
When nobody holds the tag
Once behavior is data, the data can be wrong — and it can be wrong in a way hardcoded behavior never was. A user can delete the only agent holding gtd. Nothing stops them; that's the entire point of the UI.
So every resolution path shares one contract: zero holders means skip and warn, never crash. The clarify flow notes that nobody owns GTD and moves on. A missing owner is a configuration state, not an exception.
That sounds obvious written down. It was tedious to build, because every place that used to assume an agent existed had to decide, individually, what its degraded behavior should be. "Never crash" is a one-line rule and a few dozen small decisions.
Who wins, YAML or the database
The precedence question took actual thought. Agents seed from YAML on first boot, so a fresh install starts with a sensible fleet. But the moment capabilities or metadata has been set, the database owns it — the seed will merge in genuinely new keys, but it never clobbers a value you've touched.
The consequence has a sharp edge: if I ship a new capability tag in the seed file, an existing install won't pick it up automatically. Someone has to tick it in the UI. I chose that edge deliberately, because the alternative is worse — a seed that wins on restart means a redeploy silently undoes your configuration. Data you edited beats data I shipped. If that costs a manual tick per upgrade, fine.
Closed, for now
The vocabulary is deliberately closed: four tags, defined in one place. That's safe and legible — every tag is guaranteed to mean something, because some flow somewhere resolves it.
User-invented tags are the obvious next step, and the obvious trap. A tag that no flow reads isn't a capability; it's a sticker. Letting people mint pottery as a tag only makes sense once they can also define the flow that resolves it — the tag and its consumer have to arrive together. That's where this is heading. Until it does, the set stays closed, and I'd rather be honest about that than pretend an unbounded vocabulary is a feature.
The line if agent == "sebas" is gone. What replaced it is duller and better: a column, a resolver, a tab. If you end up running AEGIS yourself, the four names you'll find are just defaults now — the rest of what shipping it took is in AEGIS is open source, and the project lives at /aegis.
Building something data-heavy? Let's talk.