A month into building autonomous agents at Eko, the question I get most, from engineers and from people who just heard the phrase "AI writing to a bank database," is some version of: are you out of your mind?
It is a fair thing to ask. We run an autonomous agent that resolves real support tickets against a production financial system. It reads a request, works out what to do, and makes the actual change to live records. Nobody clicks go.
This post is the whole stack, not the highlight reel. The credential-free agent and the deterministic core that make a write safe. The vault we compile instead of retrieve. The revamp that taught the system to act on what the model already understood. The operator that runs the whole thing. And the research loop that improves the system without me in the room. I am going to go deep on each, because the interesting part is how the pieces hold each other up.
One idea sits under all of it:
The model decides what to propose. Plain, deterministic code decides whether it is allowed and how it runs. The model never holds the keys.
1. The agent lives in a cage
We do not treat the model as a trusted operator. It reads untrusted text off a ticket, so we treat everything it produces as untrusted too. In practice:
- It runs sandboxed with networking switched off. No database, no secrets, no internet.
- It holds zero credentials and no database handle.
- Its entire output is a single typed proposal. Nothing else leaves the box.
propose(
action_type, # a registered action, e.g. "write.change_phone"
target_node, # the kind of record it wants to touch
params, # a closed, schema-checked type
identity_proof_ref, # a reference it cannot forge
)
The model cannot invent an identity, cannot call an action that is not registered, and cannot slip a "who to act on" field into the parameters, because the parameter types structurally refuse it. That last one matters more than it looks. The subject of an action lives on the proposal envelope as a typed hint with provenance, never inside the per-action params, so "subject id smuggled into params" is not a check we run, it is a shape the code will not compile into.
And the model never sees the sensitive value. For a phone change, the new number is captured at the trusted edge and swapped for an opaque reference before anything reaches the model. Only that reference travels. You cannot inject a value the model was never handed, and you cannot leak a value it never held.
2. The core holds all the authority
Every proposal lands at a plain, rule bound core that does not branch on what the model said. It runs the same fixed sequence, in the same order, every single time:
- Validate the schema.
- Resolve the subject from verified identity.
- Authorize via the org hierarchy.
- Log consent.
- Bind identity.
- Execute with an idempotency key, audit written first.
- Compensate, and roll back on failure.
If any of the first three fail, the whole thing denies before a single side effect touches a record.
Two of those steps carry most of the safety.
Step 2, resolve the subject from verified identity, not from the ticket. The model's guess about who a ticket concerns is only a hint. The core works out the real subject from the authenticated request. That one decision kills a whole class of prompt injection, because untrusted text can name anyone, but it cannot grant authority over them.
Step 3, the org hierarchy is the permission check. There is no separate access list to drift out of date. Either the requester provably sits above the subject in the hierarchy we already trust, or the write is denied before anything happens. This is also how we closed an IDOR hole: authorization is a reachability check on data, not a string comparison on a name.
Two more properties I care about:
If we cannot prove it, it does not happen. Before any real change, the core appends what it is about to do to an append only, hash chained audit log, and the change runs only if that append succeeds. Append only is enforced down at the database, so the application role cannot quietly edit or delete history, and a tampered chain shows up as a broken link. A fork of the chain is a uniqueness violation, not a silent second reality. Personal data is masked by default. If the audit log is down, you do not get an unlogged write. You get no write. The system would rather do nothing than do something it cannot account for.
Exactly once, with a way back. The write carries a unique key built from the action, the account, the subject, and the original request. Run the same thing twice and you get the first result back, not a second write. If a change half commits and then fails, a compensating action puts the record back the way it was. If even that fails, the system freezes and calls a human instead of guessing.
3. The wall that stops a confident mistake
None of that helps if the model occasionally proposes a write it should never make. So we keep a frozen set of "must never act" tickets, written on purpose to bait a bad decision, and we gate every release on it. The score has to be a perfect 1.0. One wrong action on that set and nothing ships.
The part I like: the verdict is not a label we type in. For each case we build the real inputs and let the real validation code decide, then check what it actually did. If someone weakens a check, cases quietly flip from "do not act" to "act," and the gate goes red on its own. The test cannot rot into agreement with the bug.
4. The vault: we compile our knowledge, we do not retrieve it
Here is where we go against the current fashion.
The agent has to recognize which of about a hundred and seven kinds of request a ticket is, from a short menu of things it is allowed to do. The obvious 2026 answer is a vector database: embed the request, retrieve the top few similar use-cases, ask the model to pick. We do not do that. We compile.
An offline build step reads every use-case, which lives as a structured markdown file, and pulls out only what matching needs: the slug, the intent, a handful of keywords, the target. It drops all the execution detail. The output is one committed JSON catalog. At match time the model is handed the whole catalog as a compact menu, one line per use-case, slug and intent, and it answers with one id or with nothing.
The whole menu. Not a retrieved subset. Keywords do not narrow what the model sees, they only nudge a confidence score after the fact.
Why fight retrieval? Because retrieval can hide the right answer. We learned this the hard way. An earlier version narrowed the menu with keyword matching before the model chose, to save tokens. It worked until we started teaching the system new cases, and then matching got worse the more we taught it. Every new case quietly shrank the menu the model reasoned over and buried the correct answer behind a narrow keyword hit. A top-k retriever is the same failure mode with fancier math: the day the right use-case sits at rank six of a top-five, it is simply gone, and you will not see why.
The full menu costs a couple of thousand tokens. The context window has roughly thirty times that to spare. So we pay the tokens and hide nothing. Correct beats clever, and the whole catalog in front of the model is the most correct thing we can do.
Once a use-case is matched, execution is boring on purpose. A static map sends the slug to one deterministic Python handler, one per report or write, built with the identifier and the date range. The model never writes SQL and never picks a table.
5. The revamp: teaching the dispatch layer to listen
For a while we had a recurring, annoying pattern I started calling the rigidity treadmill. Every couple of weeks a ticket would come in that the model actually understood correctly. It knew the request was about a whole business vertical, or the whole org rather than one user, or that it needed to fan out across several accounts, or that the real request was sitting in the email thread rather than the visible comment. And we would still get it wrong.
The model was not the problem. The dispatch layer was deaf. It had no interface to receive the signals the model was already producing. So we would hand-code one more degree of freedom, ship a deploy, and wait for the next missing knob.
The fix was a revamp, not a rewrite. We looked hard at the general solution, a full parameter protocol between the model and the handlers, and deliberately gated it off as over-engineered for the three or four real degrees of freedom we actually had. Instead we shipped the specific seams, as a set of independent lanes:
- Ambiguity fails closed. If a request names more than one vertical, or is otherwise genuinely ambiguous, the system asks a clarifying question instead of silently picking a default. The old code quietly defaulted, which is the worst possible behavior when money is involved.
- Scope detection is honest about word boundaries. Deciding whether a request means one user or a whole org used to run on loose substring matching, which turned an innocent phrase like "install csp" into an org wide data dump. Now the detectors read a customer-only span with real word boundaries, and the model reads instruction-clean text, so a phrase inside a word cannot trigger a scope.
- Scopes are a closed set. There are exactly four: one user, the org, the org total, and the org broken down per agent. Each is authorized on its own terms. A request cannot widen its own blast radius past what the requester is allowed to see, because the permission check consumes every width affecting parameter before it generalizes.
- Wrong keys ask, they do not shrug. Hand a report the wrong kind of identifier and it used to answer "no data found," which is a lie that looks like an answer. Now it asks for the right identifier.
- Live construction is forced. The seams that enforce isolation and audit cannot be left unset in production. A factory assertion refuses to build the live path with them missing, so "forgot to wire the safety seam" is a startup failure, not a quiet hole.
The result is that when the model gets something right, the machinery can finally act on it. And the coverage frontier is now a list, not a mystery: of the full catalog, a chunk is wired end to end, a chunk is out of our domain and deliberately left alone, and the rest is a known, sized queue of capabilities to wire, not a fog.
6. The operator: a claw that runs the system
Everything above is a component. Something has to run it, keep it alive, notice when it drifts, and extend it. That is a second kind of agent entirely, and it is the mirror image of the first.
We think about it as two opposite agents.
The first is the caged one from section one. Lowest privilege, no credentials, no network, processes untrusted content, can only propose. It is hand built and boxed in.
The second is the operator, a long running claw with full access to its machine, a persistent memory, and an owner-only channel. You cannot cage it, because its whole job is to operate. So it is kept safe by process, not by walls.
The same sentence describes both, applied at two levels. Intelligence in the agent, authority in the API. For the caged agent, the authority is the deterministic core. For the operator, the authority is a definition-of-done gate and a human approval. The smart thing proposes, a dumb thing with the real power decides.
Concretely, the operator lives under a few rules it cannot talk its way out of:
- Code changes are autonomous only behind a green gate. It can edit and ship, but never past a failing test suite, and never by lowering a baseline.
- Anything that touches the outside world is propose first. A production change, or flipping the system from dry run to live, goes through a proposal, a dry run, and a human approval, using the same front door as everyone else.
- There is a protected set it cannot weaken. The red-line files, the security baselines, the audit chain checks, the sandbox manifest for the caged agent. It is allowed to strengthen these and forbidden from loosening them.
- Escalation is a ladder, not a vibe. Autonomous for the safe stuff, propose first for the reversible-with-effort stuff, hard stop and page the owner for anything that trips a red line.
We also have a small tool that designs these agents from a one-line task. It reads the verbs in the task, works out the access level it truly needs on a ladder from read-only monitor up to full-system operator, and emits the minimal agent that boots safely at that level, then smoke tests it. The interesting design choice is that the rubric splits hard floors from soft preferences. The floors are machine-checkable and fail loud: a valid config, no open channel combined with the ability to send or execute, no raw credentials in the files, a green smoke test. The preferences, like keeping the persona short, only ever break ties. Validation is the deliverable. If the floor is not green, the tool exits non-zero and you get nothing.
7. The research loop that improves itself
This is the part I find the most fun, and the reason I think the leap people put five years out is closer than that.
The matcher's behavior is driven by a versioned configuration: prompts, keywords, scope rules, confidence bands. Call it the policy. There is a loop whose entire job is to make that policy better, on its own, and whose only output is a new version of it.
The cycle is: monitor, then experiment, then evaluate, then deploy, then watch, then keep or revert.
Monitor is cheap and constant. A zero-cost, no-model step runs every cycle, reads the live record of what the system did, and folds it into an objective. The objective is deliberately not "how many tickets did we close." It is closer to "how many did we close correctly," measured as exact-slug correct resolution, averaged so a rare use-case counts as much as a common one, and counting only the ones the system actually resolved rather than the ones a human quietly finished. Optimizing for raw throughput is how you teach a system to cut corners. We do not give it that option.
When the monitor sees the objective slip, or spots an opportunity, a use-case that keeps getting missed, it forms a hypothesis and proposes an experiment. An experiment is a candidate policy: the current one with a small change. Each candidate is content addressed and immutable, so a version cannot be quietly edited under you, and a rejected one can be blocked by identity forever.
Then the candidate has to earn its place. Evaluation is a stack of hard floors checked in order, and a floor is not a penalty you can outscore, it is a gate that returns you to start. Does the candidate keep the write-path precision at a perfect score. Does it regress any case the current policy passes, checked over repeated runs so a lucky flip does not sneak through. Does any free text it introduces pass a personal-data screen. Only after every floor is green does anything look at whether the candidate is actually better, and even then it needs to clear a real statistical bar rather than a flattering single number.
Promotion is dual controlled. Two distinct people, never the same one twice, and the action is written to the audit chain before the pointer moves to the new version, never after. Then a canary watches the new version in the wild, reading its real error and injection rates from the audit chain rather than from anything the loop says about itself, and rolls back to the last ratified version automatically if it misbehaves.
The thing that makes this safe to run at all is one rule about what the loop is allowed to touch. Security is a floor, never a dial. Every dangerous surface, the set of write actions, the authorization logic, the reconciliation gate, the frozen test corpora, lives in code, in schemas, in the environment, or in git. None of it is a field the policy can carry. So the loop can rewrite how the system reasons about a ticket, and it structurally cannot rewrite what the system is allowed to do. The type system is the first wall.
There is one more human-shaped input: a research directive, a plain document I own, that the loop reads to know what to work on next. The loop can read it and can never edit it, it is screened for personal data, and it can only reorder priorities, never introduce a new target or reach into a model prompt. It is a steering wheel with the throttle mechanically limited.
If this shape sounds familiar, it is the autoresearch pattern Karpathy sketched: one surface to change, one comparable metric, keep or discard, run it continuously. The twist I keep coming back to, and wrote about in my last post, is that the verifier does not have to be a human forever. It can become the system itself, as long as the thing being verified and the thing doing the verifying are kept rigorously apart.
And it composes with a read executor that turns a matched request into a delivered report by running a human-ratified query plan, where the rate limiter is not the model's cleverness but a person having signed off on the shape of the query in the first place. The loop decides what a ticket is. The executor carries out the read. The human ratifies the map. Nobody hands the model a blank check.
What I actually took away
You do not make an autonomous system trustworthy by trusting the model more, or by parking a person in front of it. You build the structure around it so well that you barely have to trust it at all. Zero credentials in the model, a deterministic core that holds the authority, a full menu instead of a lossy retriever, a protected set the operator cannot weaken, a research loop that can change how the system thinks but not what it is allowed to do. Each one is a wall, and the walls are what make the autonomy safe rather than reckless.
We are building this on open source rails, and we intend to give the safety patterns back as they harden. If you are putting agents anywhere near money or production data, I would genuinely like to hear how you are thinking about it. Subscribe and I will keep writing this up as we build.
Top comments (0)