The engineering companion to "How We Keep the AI From Leaking Data or Going Rogue." Everything below is the shipped system, not a design doc.
The subsystem is the architecture, not an agent
The first three appendices each took apart one teammate. This one has no teammate to take apart, because safety in this system is not a feature that lives in Pulse or Scout or Memo. It is a set of properties of the runtime they all run on, and it is enforced in the plumbing every agent shares: the tool executor, the message assembly, the timeout wrapper, the HTTP layer. If you want to know why a specific agent will not leak or run wild, the answer is almost never in that agent's code. It is in the four places below.
None of this is a security certification. Read it as "here is exactly what we do and where it stops," not "here is a proof that nothing can go wrong." The last section before the cost split is the honest ledger of the edges.
Where the data goes, and where it does not
The most common question about a system like this is not about tool errors or timeouts. It is: when the agents read your GitHub, your calendar, your transcripts, where does that data physically go, and does anyone train on it? Here is the whole path.
The models are Anthropic's, wired through the Vercel AI SDK (claude-sonnet-4-6 for agent work, claude-haiku-4-5 for routing). Agent context is sent to the Anthropic API to be processed — that is the one unavoidable move, because a cloud model computes on the text you hand it. What matters is the terms. This runs on the commercial API, not a consumer chatbot. Anthropic's Commercial Terms of Service are explicit — "Anthropic may not train models on Customer Content from Services" (§B; Inputs and Outputs together are "Customer Content") — and the privacy center (updated March 16, 2026) confirms it for the API: "By default, we will not use your inputs or outputs from our commercial products … to train our models." There is one carve-out — content you explicitly send Anthropic as feedback via its thumbs up/down button — and it does not apply here: this system's own Good/Bad Response buttons write to our Postgres (agent_runs.feedback), not to Anthropic. That is the same protection every security thread converges on: paid, contracted, no training.
Two other processors touch data. Embeddings for semantic search run on Cloudflare Workers AI (@cf/baai/bge-base-en-v1.5); Cloudflare's Workers AI data-usage terms are equally plain — "Cloudflare does not use your Customer Content to (1) train any AI models made available on Workers AI or (2) improve any Cloudflare or third-party services" — and your inputs, outputs, and embeddings remain your Customer Content, never shared with another customer. Persistent memory lives in our own Neon Postgres, reached over a private Hyperdrive binding, not a shared consumer store. Sensitive credentials are encrypted at rest — Google refresh tokens with AES-256-GCM (Part 2's appendix), under keys held as Worker secrets.
Data minimization is enforced by scope, not by good intentions. Each agent's tool list is fixed at config time, so Pulse's tools reach GitHub and nothing else, Scout's reach the calendar and nothing else. There is no shared god-tool with blanket access, and message and transcript inputs are length-capped before they are sent anywhere.
The honest boundary: "untrusted" describes the content the model reads, not the provider that runs it. Anthropic and Cloudflare sit inside the trust boundary by design — named, contracted processors, not the open internet and not a free tool someone pasted into. A workload that cannot tolerate data leaving the building even to a contracted processor needs a model hosted entirely on your own hardware, which is a different and costlier architecture than this one.
Why a failed tool comes back as one flat sentence
Every tool an agent can call is wrapped the same way, in buildAgentTools. The Vercel AI SDK tool.execute handler runs the real tool inside a try, and the catch does three things and only three:
- Logs the real error to our own console (
console.error), so we can see a failing dependency. - Adds the tool's name to a
failedToolsset. - Returns a fixed string to the model:
'Tool execution failed. Proceed without this result.'
The comment in the code states the reason plainly: raw error details (connection strings, API bodies) must not enter the LLM context to prevent info disclosure and prompt injection. That single sentence is doing double duty. A raw exception can carry secrets (a database URL, an auth header, a slice of the payload it choked on), and it can carry attacker-controlled text if the failing call reached something poisoned. Handing either to the model is a leak in one direction and an injection vector in the other. The opaque string closes both.
The failure is not swallowed silently. The failedTools set is returned on the AgentResult as toolErrors, logged to agent_runs, and — for interactive runs — surfaced back to the user as a passive notice (see "Failing loud" below). The model is the one party that never sees the raw error. Observability keeps it; the LLM does not.
Everything the model reads is untrusted
The model gets a system prompt (its rules, which we control) and a list of messages (the conversation, which the outside world influences). The security boundary is the line between those two, and the rule is absolute: nothing from the outside is ever written into the system prompt.
Concretely, when a DM or @mention is assembled in buildSlackInteractionContext, two blocks of background — the user's profile built from past interactions, and recent cross-agent project activity — are each pushed as a user-role turn followed by a canned { role: 'assistant', content: 'Understood.' }. The comment in the code names the intent: both blocks are injected as user-role (untrusted) turns to mitigate stored prompt injection. Each block is even labelled in-band — [User profile from past interactions — background context only], [Recent project activity — cross-agent background context only] — so the model reads it as reference material, not as a command from its operator.
The same treatment covers user memory (formatted as a labelled user-role block) and, in the transcript path, the raw meeting text. Two more habits bound the surface: incoming message history is capped before it is embedded or summarized, explicitly to "prevent injection via oversized messages," and transcripts are length-capped "to control cost and reduce prompt injection surface." A poisoned transcript is still just a user-role turn with no authority to override the system prompt.
This is mitigation, not a solved problem — see the honest-limits section. But the architecture means an injected instruction is arguing from the weakest possible position in the message stack.
Read-only by construction
The cheapest guardrail is the capability you never grant. Across the squad:
- Pulse talks to GitHub through a GitHub App scoped read-only, over Crescentic-owned repos only. It can read commits, PRs, and CI status; it has no write scope to push, merge, or edit.
- Scout holds Google's
calendar.readonlyscope, validated on the OAuth callback (see Part 2's appendix). It can read your calendar; it cannot create, move, or cancel an event. - Memo surfaces decisions and action items from the knowledge layer. The read path (
/ask) queries; it does not mutate the record it reads from.
There is no agent tool in the registry that sends email, moves money, deletes a row a user cares about, or writes to an external system on the agent's own authority. The blast radius is bounded by the toolset, not by the model's good behavior — which is the only kind of bound you can actually rely on, because it holds even when the model is wrong.
Failing loud: timeouts, aborts, and the notice on the answer
Silent failure is the dangerous kind, so the runtime is built to fail visibly in two distinct ways.
Timeouts. Interactive runs are wrapped in withTimeout with AGENT_TIMEOUT_MS = 25_000. The comment explains the number: set below Cloudflare's ~30s waitUntil wall-clock limit so we have time to post the error. On timeout, generateText is aborted; the executor catches the abort, returns empty text with errorType: 'timeout', and the adapter posts buildTimeoutBlocks — a plain "this timed out" message with a Retry button whose value encodes {agentName}|{userId}|{query} so the exact request can be re-run. No hang, no half-message left hanging in the channel.
Partial failure. When a run completes but some tool failed along the way, addToolErrorNotice appends _Some data may be incomplete (tools with errors: …)_ to the interactive reply. The answer ships with an honest disclaimer rather than pretending the gap is not there.
The error taxonomy that drives this is small and explicit: AgentErrorType = 'llm_error' | 'tool_error' | 'timeout', recorded on every run. Retries are deliberate, not blanket: unattended cron runs use maxRetries: 2 (transient blips are worth retrying when nobody is watching), interactive runs use maxRetries: 1 (a person is waiting; fail fast and offer Retry).
Nothing leaks over HTTP
The same "no raw errors into untrusted places" rule that governs the LLM boundary governs the network boundary. Error messages that could reach an unauthenticated caller are length-capped (MAX_ERROR_MSG_LEN) and scrubbed of internals; the /health endpoint reports up/down without echoing a stack trace or a connection detail. A failed database ping, for instance, is logged server-side but not returned verbatim to whoever hit the URL.
The honest limits
Where the guardrails stop, stated plainly:
- No formal audit. No pen-test, no SOC 2, no compliance attestation. These are sound engineering defaults, not a certified control set.
- Injection is mitigated, not eliminated. Untrusted-by-default lowers the odds and weakens any injected instruction's standing; it does not prove immunity. Prompt injection is an open problem industry-wide, and we do not claim to have closed it.
- Read-only is today's luxury. The planned @admin (invoicing) will need write access. The moment an agent can act, this appendix needs a new section on approvals, scoped writes, and reversibility. We would rather flag that now than imply the current comfort transfers for free.
- We still trust named processors. Covered above: Anthropic and Cloudflare run the models on contracted, no-training terms, but they are inside the trust boundary. "Untrusted" is about the content the model reads, not the provider that runs it. This is not a local-only deployment.
The cost split, with the arithmetic
Here the arithmetic is refreshingly short. Every guardrail in this appendix is plain control-flow: a try/catch, a couple of extra array entries in the message list, an AbortController, a string cap, a length check. None of them adds a model call, a token, or a network round-trip on the hot path. The opaque-error swap runs only when a tool has already failed, and it replaces work rather than adding it.
So unlike the per-brief and per-capture costs in the earlier parts, the marginal cost of safety here is $0 — it is paid once, in design, not per run. The cheapest security is the kind that is structural: capabilities never granted, boundaries never crossed, errors never forwarded. You do not meter what you never spend.
Building something like this, or wondering whether the pattern fits your stack?
Back to the non-technical story.
