Multi-Tenant Agents: a Capability Firewall in Code, Human Approval in the Channel
An AI agent can run a business’s day-to-day: it answers on the owner’s channel, schedules, issues documents, proposes actions. That is an engineering problem before it is a prompt problem. And when the same service is designed to handle several businesses, the two classic problems multiply. The first is isolating one tenant from another, meaning one client from another. The second is limiting what the model can actually do, not by polite request in the system prompt.
This note describes the architecture of one such service running in production at this house. One caveat about scale, so the text does not suggest what is not there: today there is one real tenant — my own business — plus one client pilot provisioned that has not opened its channel yet. Multi-tenancy here is design, not a client roster. The description stays generic, because the patterns matter more than the product. There are four layers. Isolation by schema, a capability firewall in code (which limits what the agent can do) and human approval with strict semantics. Plus the resilience guards that let the whole thing survive a slow provider. Two of those layers were inverted after near-incidents, and the inversions are the most instructive part.
Layer 1: one schema per tenant, and the acceptance test is DROP
Each client lives in its own Postgres schema (c_<slug>), a separate drawer inside the same database, created on connection, with search_path pointed at it. The consequence that pays for the pattern: no query in the codebase has a WHERE cliente = ?. The bug class “forgot the tenant filter” doesn’t exist, because isolation is not a clause every query must remember. It’s the ground the connection stands on.
Two operational bonuses that come for free and turn into compliance arguments:
pg_dump -n c_xexports one entire client, and nothing else.DROP SCHEMA c_x CASCADEis the LGPD exit: deleting a client is a database operation, not a hunt for scattered rows.
The agent graph’s checkpointer, which stores each conversation’s state, uses the same schema. So conversation threads from different clients can’t collide even by id accident.
And the isolation doesn’t stop at the database. The virtual filesystem the agent sees ends in deny /**: allow read/write on the client’s own area, allow read on the shared area, and explicitly deny everything else. The internal documentation’s phrase sums up the posture: the deny is not hygiene, it’s LGPD.
A concurrency detail: each client’s graph is built under a per-client lock, not a global one. Under a global lock, building one tenant’s graph blocks building every other one. And on a cold process, where no graph exists yet, the webhooks (calls arriving from outside) queue up at an effective parallelism of 1. Per-tenant locking is the same code with the contention in the right place.
Layer 2: the capability firewall — the tool is gone before the graph exists
Here is the design’s central rule, and the difference between a firewall and a sticker. Capability is controlled by removing the tool from the registry, not by asking the model not to use it.
The service has a closed catalog of tools (~57 names). Each area of each client declares which ones it uses. A name outside the catalog blows up at configuration load, not in the middle of a conversation. Modules can be switched off per client in the manifest, the client’s configuration file. And when a module is off, its tools are filtered out of the registry before the graph is assembled. The internal phrase worth framing: the model doesn’t receive an instruction not to use it; it doesn’t know it exists. It’s code, not persuasion.
The firewall’s top floor is an absolute prohibition: no tool moves money. What makes it interesting is how it’s enforced. Not by convention, but by a test that runs a regex over the names of every registered function:
(^|_)(pagar|pagamento|pix|ted|saque|boleto|remessa|transferir|estornar|...)
The test’s reasoning is security UX, not style. It goes like this: the tool’s name is what the owner reads in the approval request — and the pay tool is always named pay. If someone ever tries to register one, CI fails before any conversation happens. Reading a bank statement (read-only) is allowed. Generating a payment batch file is not: the line runs exactly between observing money and moving it.
The first inversion: the list protected the wrong thing
The service interrupts on a fixed set of tools, meaning it pauses the graph and asks for human approval. The current ruler is clean. Interrupt what leaves the company, that is, reaches a third party: message, email, publication, document issuance. Interrupt too what is expensive to undo, such as bulk-rewriting business data. Don’t interrupt what is internal and reversible in a sentence. And there’s a test proving the interrupt list is exactly the union of those two sets. The ruler isn’t a comment, it’s an assertion.
Except the first version of the list was different, and the lesson is in the diff. It interrupted things like “propose a new skill” and “store knowledge”, the agent’s memory. And it did not interrupt the import that rewrote a client’s data portfolio. It protected the system’s introspection and left the client’s asset open.
The abstract ruler (“what’s risky?”) had produced a list reflecting what was interesting to the builder, not what was expensive for the user. The fix inverted the list. And it created the test binding list to ruler, so the next new tool gets classified by the rule and not by instinct.
A refinement avoids approval fatigue: interruption can be per argument. The same import tool doesn’t interrupt with confirmar=False, because the preview writes nothing. It interrupts with confirmar=True. Human approval is a scarce resource. Spending it on a preview is training the human to approve without reading.
Layer 3: human approval where silence is never a yes
Approval happens in the channel where the owner already lives (messaging, via the support platform). The graph pauses at the checkpoint, the pending action is stored, and the owner receives a request in plain human language. A dictionary maps each tool to a readable sentence and to which arguments the owner sees.
Conversation ids, function names, container paths never appear. That too is a test: no approval request may contain arg=value or a Python function name. The old version showed exactly that, truncated. In the internal record’s words, it trained the owner to answer “yes” without reading. Readability of the approval request is a security requirement, not polish.
The response semantics are deliberately strict, and each rule closes a specific hole:
- Only a closed set of words (and emojis) approves. And only if all the words in the reply are affirmative.
- Any other text rejects, with the text itself as the reason.
- An empty reply rejects. Absence of a reply decides nothing: the pending action stays pending. The agent is incapable of self-approving through silence, by construction.
- The approver is a role, not a channel. Someone without the owner role who replies to a pending action doesn’t consume it. They neither approve nor refuse; the system records it and says who authorizes. And an external action requested by a non-owner never becomes a pending action at all.
The second inversion: an empty list denied… nothing
The filter for who may talk to the agent through the owner’s line had a treacherous default. An empty phone list switched the filter off, and that was the state of three of the four manifests at the time. The near-incident became an inverted rule. Today, an empty list denies everything, and switching the filter off requires the caller to state explicitly that no filtering is wanted.
It’s the same principle as the panel answering 503 without auth configuration, instead of opening up. Closed by default isn’t a slogan. It’s the value of the default when someone forgets to configure.
Layer 4: the guards the LLM provider demands
Two resilience guards any agent service in production ends up learning, here with the numbers that motivated them:
- An explicit, short provider timeout: 90 seconds, one retry. The SDK default was 600s with two retries. One hung call would hold a thread-pool slot for up to 30 minutes. And with the pool full the whole service stops accepting webhooks from any client, mute, with no exception and no alert. The ruler behind the 90s wasn’t taste. The measured p95 for a whole message, the time 95% of them stay under, was ~22s. So a single call exceeding 90s is a hang, not slowness. And the env var’s parser falls back to the default with a warning if someone misconfigures it. Invalid config must not silently re-enable the 10-minute SDK timeout.
- A turn ceiling as a cut, not an error. The agent framework effectively gave subagents an infinite recursion limit. The service imposes a per-delegation call ceiling via middleware, the code that runs between calls. And hitting the ceiling is not an exception: the area returns a limit marker, with a metric and an event. A looping agent is a cost linear in time. The difference between “error 500” and “clean cut with telemetry” is who finds out first: you or the invoice.
The pattern, stacked
- Isolation that doesn’t depend on query authors’ memory: schema per tenant, checkpointer inside it, filesystem ending in
deny /**. The acceptance test is answering “how do I delete a client?” with one command. - Capability is the tool registry, not the prompt. A closed catalog that blows up at load; module off = tool nonexistent; absolute prohibitions enforced by a test over the names.
- Interruption by the ruler “leaves the company or expensive to undo”. The list is bound to the ruler by assertion, and per-argument granularity keeps the approver’s attention from being burned.
- Strict approval semantics: a closed set approves, everything else rejects with a reason, empty rejects, silence decides nothing, role decides. And the request is human-readable, with a test enforcing the readability.
- Closed defaults: the empty list denies; dev mode doesn’t exist in production by construction.
- Timeouts and ceilings sized by measurement (p95 → 90s; a cut with telemetry instead of a loop with an invoice).
None of this makes the agent smarter. All of it bounds the damage on the day it’s dumb. That asymmetry is what separates an agent system you can operate in your sleep from a demo with a webhook.
AI agents that survive production
I write here about the agents I run myself: memory in Postgres, tools registered in code, and limits the prompt cannot talk its way around. The method and the measured numbers ship with every post.
Read the agent posts →