Doctor was a personal assistant I built for a lawyer in Hermes, and he got a voice note one morning: while walking to court, the lawyer dictated the facts of a custody case. A minute later the same assistant answered a stranger's Instagram DM asking what time he could come by the office. Same assistant, same conversation, same memory. The context that had just read a confidential case file was one message away from a person he'd never met.

As a personal assistant it was fine. It even spoke in the lawyer's own voice: I took voice notes he had sent on WhatsApp and fed them to ElevenLabs to clone it. The problem is that a law firm's full-featured assistant is not a personal assistant. It holds privileged client files, it reads documents and web pages we don't control, and it talks to the outside world over Instagram and email. Simon Willison has a name for those three capabilities landing in one place: the lethal trifecta. Private data, untrusted input, and a way to send data out. Any agent holding all three can be turned by a single poisoned message (a crafted DM, a booby-trapped PDF) into leaking a client's file, with no bug in your code. His conclusion is the uncomfortable one: detection isn't enough, because in security, 95% caught is a failing grade.

You cannot prompt your way out of that. "Please don't exfiltrate private data" is a promise, not a boundary. We needed an architectural solution, one that makes private data unreachable and prompt injection pointless. We needed the boundary to be structural: an agent that simply has no tool and no memory reaching client data cannot leak it, no matter what a message tells it to do.

A team, not a super-agent

The turn came reading Tute Costa's dino-assistant, a tidy personal assistant built on Mastra. The supervisor pattern clicked. A supervisor delegates to sub-agents through an agents map; each sub-agent gets its own tools, model, instructions, and memory; Mastra derives a separate resource id per agent so two never share memory by accident. Isolation is the default, not something you remember to switch on. So we stopped trying to make one careful agent and built a team where no single member can betray a client, because no single member holds all three legs.

We renamed the assistant from doctor (a lawyer's honorific) to Pretor, the Roman magistrate who dispensed justice.

        Owner (the lawyer)
              |
        Telegram, owner-only
              v
   +-------------------------------------------+
   |  PRETOR                                    |
   |  supervisor - voice - routing              |
   |  holds no client data; delegates by ref    |
   +-------------------------------------------+
      |             |             |           |
      v             v             v           v
 Investigator  Librarian     Scribes       Secretary
 legal + web   every case    draft docs    calendar +
 research      + document     per branch   meeting
 (no data)     SOLE data      (no send)     notes
                   ^              |
                   +-- ask for client facts

   PRETOR  ...A2A: DM text + sender id...>  COMMUNICATOR
                                            Instagram DM
                                            separate service
                                            (no client data)

Six roles, each deliberately partial:

  • Pretor is the supervisor and the lawyer's only contact on Telegram. It routes and speaks; it never pulls raw client data into its own context.
  • Investigator does legal and web research over official Argentine sources (SAIJ, CSJN, Infoleg). It touches untrusted pages and can fetch, but holds no client data, so a poisoned page has nothing to steal.
  • Librarian is the firm's memory: every case, every document, one store. It is the only agent that can read them, and it has no outward channel at all.
  • Scribes, one per branch (labor, civil, criminal, commercial, family, administrative), draft the documents of their branch. They see client facts only as the answer to a question they asked Librarian, and they cannot send anything. Their drafts match the official filing format down to the margins and numbering, rendered from a stored template of the real document type.
  • Secretary keeps the calendar, email, and meeting notes. Its outward actions, like inviting an external guest, are approval-gated.
  • Communicator answers Instagram. It runs as a separate service with its own webhook and no handle into client data. A poisoned DM finds nothing private.

Written as a table, the point is that every row is missing a leg:

Agent Private data Untrusted input External send Missing leg
Librarianyesyesnono way out
Investigatornoyesyesnothing private to steal
Communicatornoyesyesnothing private to steal
Secretaryschedule onlyyesapproval-gatedno case files, gated send
Pretorby referenceyesvia othersstays a router

Where the legs could still meet is Pretor, because it sees everything. Three things keep it a router and not a target: it delegates by reference instead of copying client files into its context, Communicator is out of process and receives only a DM's text and sender id, and its only real conversation is with the trusted owner. For anything outward and irreversible there is a human in the loop: the action suspends and surfaces in Telegram as one inline button. The lawyer approves, or it doesn't happen.

Never invent the norm

The trifecta split keeps data from leaking. A second rule keeps the assistant from lying about the law, and it matters just as much: never invent the norm. Cite an official source or say plainly you could not verify it.

Enforcing that starts with actually reaching the sources, which is its own fight. Argentina's official databases are not built to be read by machines: some block automated access outright, others gate every query behind a challenge meant to keep bots out. Getting to them reliably took tooling that reaches the crux of each obstacle rather than papering over it, and none of it was the trivial off-the-shelf fix. I'll keep the specifics out of this; what matters is the result, that a guarded source is still, in the end, one the research can open and cite.

Grounding the research is the firm's library. Librarian holds 811 documents an agent can search by meaning rather than exact words, pulling the passages relevant to a case. How that search is built, and the mess of real legal archives underneath it, is in the technical section below.

The real guarantee, though, is that we never trust the model's citations. When the research workflow builds a report, it does not let the model write the list of sources. A verification step re-opens each cited ruling on the official site, and the sources annex is rebuilt from only the rulings that actually opened; everything the model claimed but the verifier could not open is moved to a separate "not verified, do not cite" section. A model that hallucinates a Supreme Court ruling cannot smuggle it into the citable part of the document, because it never writes that part. Anti-hallucination lives in code, not in a prompt.

A meeting becomes a filing

The flagship is the unglamorous, valuable thing a firm does over and over. A lawyer records a client meeting, fills in a short questionnaire, and wants back a written report and an infographic, grounded in real law. We codified it as a Mastra workflow with six steps: transcribe the audio, research the applicable law and jurisprudence, pull stored case context if the matter already exists, draft the documents, compose the report, and render a PDF. The agents run in sequence, not fan-out, on purpose (more on why in a moment).

What production taught us

The production lessons were louder. One search over the library is cheap, well under a second; seventeen at once is not. Early on the bot went silent for forty minutes mid-request because a single turn fired seventeen concurrent library searches over two shared CPUs, starving the health check, the webhook, and the progress updates all at once. We serialized the searches so they stop piling up, added a heartbeat so a slow task still shows it is alive, and moved to dedicated CPUs. That is why the meeting workflow runs its agents one after another: fan-out is what nearly took the box down.

What we'd carry to the next system

  • Make isolation structural, not behavioral. An agent that can't reach the data can't leak it; a promise in a prompt can.
  • Split any single tool that carries two trifecta legs (email is untrusted-in and send-out) into a service that holds no private data.
  • Put the human at the outward, irreversible edge, and nowhere else. One approval button beats a wall of confirmations.
  • Rebuild the parts that must be true (citations, deliveries) in code from real events, not from the model's prose.
  • When the framework is wrong, fix it upstream.

"Doctor" is still running as a fallback while Pretor takes over. It answered everything from one context and did it well enough that nobody noticed the shape of the risk. Pretor is more machinery for the same job, and most of that machinery exists to make one kind of mistake impossible instead of merely unlikely.


Technical details

A few implementation notes for anyone building something similar. The whole system is TypeScript on Mastra. The supervisor is one agent whose delegation targets live in an agents map, and calling a sub-agent is just a tool call. Mastra scopes each agent's conversation memory to its own resource id, so the isolation the security argument depends on is enforced by the framework, not by a convention we have to remember: two roles cannot share memory unless you deliberately wire them to.

Delegations run inline or as background tasks, and that distinction is where we hit a bug worth fixing upstream. Midway through the workflow a sub-agent delegation started crashing the model provider with a 500. The cause was in Mastra: when a delegation runs as a background task the tool result is a placeholder string ("Background task started..."), but the code that formats it read output.text, which is undefined on a string, and serialized null content into the next request. One line:

value: typeof output === "string" ? output : (output.text ?? "")

We filed it, opened a PR, and patched our local install until it merged. That is one of the quieter arguments for building on an open framework: the fix went into Mastra, not a private monkeypatch we'd carry forever.

Communicator is not an in-process agent but a separate service with its own Instagram webhook, reached over an agent-to-agent boundary. All Pretor ever hands it is a DM's text and a sender id, and all it hands back is a reply. That process boundary is doing security work: the one agent touching the open internet lives permanently outside the address space that holds client data, so there is no in-memory path from a poisoned DM to a case file.

The library is retrieval over vector embeddings: each of the 811 documents is embedded and searched by meaning rather than exact words. The retrieval design took three tries. An exact index came first and ballooned to forty times the size of the corpus it indexed, rebuilding on every write, so we scrapped it for a brute-force similarity scan: simple, correct, and fast on its own, well under a second. Running seventeen of those at once was another matter, the outage above. The scans are gone now, replaced by an approximate-nearest-neighbor (ANN) index that answers a query without walking the whole corpus and is compact enough this time to earn its space. Ingestion held its own trap: about a tenth of the archive was RTF hiding behind a .doc extension, so the extractor sniffs the actual content type before parsing rather than trusting the filename. Trust the bytes, not the extension.

Two more pieces make the "never invent the norm" rule hold in code. The Scribes render from a stored model of each branch's real filing type, so a draft matches the official format down to margins and numbering instead of being reinvented per document. And because official rulings reference laws in an internal shorthand like LEY C 019101 ... 0076, a small decoder rewrites that as Ley 19.101, art. 76 so a citation reads like one.

I'm Fred Sapuppo, and I built Pretor, a secure AI framework for law firms.

More from OffTheRails