Blog · Launch week, part 3 of 3 · Deep dive · August 25, 2026
The customs declaration, in depth
Every AI product that uses a cloud model sends your context somewhere. Very few show you what. This is how SovereignAI shows you the exact bytes before they leave your machine, how it proves the preview is the request, and the two things we deliberately refused to build.
The problem with "we disclose it"
Since its first release, SovereignAI has disclosed which model answers and whether it runs locally. Every chat turn shows the persona, the provider, the model, and — for local models — the exact weight digest that replied. That is more than most products do, and it is not enough. Disclosure at the provider level tells you a remote model was used. It does not tell you what it was given.
What a chat turn gives a model is not your message. It is your message plus a system prompt, plus the durable memories the product decided were relevant, plus excerpts retrieved from your documents, plus the trimmed history of the conversation. A three-word question can carry two kilobytes of the most personal context you own. If that context is about to cross the internet to a company you have an API key with, you should be able to read it first — not a summary, not a category, the bytes.
Perplexity's Portable Computer, launched this week, does something close: it asks before escalating a step to a cloud model and shows what would be sent. We think that is the right instinct, and we were behind on it. So we built our version, and we built it differently in two places. Here is all of it.
The five rules
- 1. The preview is the request. What you see is not a description of what will be sent. It is assembled by the same function that assembles the real request, and a test proves the two are byte-identical.
- 2. Looking is free of side effects. Opening the declaration creates no conversation, writes no message, touches no memory. Retrieval runs, because retrieval is a read. Cancel means nothing happened.
- 3. Local never gates. If the provider's endpoint is on this machine — loopback, the Docker host alias, an Ollama or FreeToken on localhost — nothing leaves, so nothing asks. The rule is the same hostname test that governs "cognition stays home".
- 4. Every remote answer carries a receipt. Whether or not you looked first, an answer that came from a remote provider says how much left and for which host.
- 5. No classifier decides for you. There is no PII detector in front of the dialog. A classifier is one more model reading your data and making a judgment you cannot audit. You can read the bytes yourself; that is the point of showing them.
Assembling a turn without touching the database
The change that made everything else possible was a refactor. A chat turn used to be one function: validate the message, resolve the persona and model, create the conversation if needed, persist the user's message, build the system prompt, stream from the provider, persist the reply. Assembly and side effects were interleaved, which is fine for a chat and useless for a preview.
Now there is assembleChatRequest(): it resolves the persona and the model, loads and trims the prior turns, places memory notes and knowledge excerpts into the system prompt, and returns the exact messages array that will be handed to the provider — and it writes nothing. The streaming path calls it and then does its side effects; the preview endpoint calls it and returns what it found. Two callers, one assembly, no way for the declaration and the request to drift apart.
What crosses the wire is then a pure function of that assembled request:
export function outgoingContext(request) {
return [
...(request.system ? [{ role: 'system', content: request.system }] : []),
...request.messages,
];
}
export function outgoingTotals(request) {
const context = outgoingContext(request);
const chars = context.reduce((sum, part) => sum + part.content.length, 0);
return {
chars,
bytes: Buffer.byteLength(JSON.stringify(context), 'utf8'),
approxTokens: Math.ceil(chars / 4),
messages: context.length,
};
}
"Bytes" has one definition everywhere in the product: the UTF-8 length of that array serialized as JSON — the system prompt as a leading system-role message when there is one, then the trimmed history, then the new user turn. That is literally the messages array an OpenAI-compatible or Ollama endpoint receives; the Anthropic API carries the same content split into system and messages. The token figure is an estimate (four characters per token) and is labeled as one. We would rather show a number we can define than a number we cannot.
The manifest
POST /api/chat/preview takes the same body as POST /api/chat and passes the same validation. It returns the declaration:
{
"provider": { "id": "openai", "label": "OpenAI-compatible", "local": false, "host": "api.example-remote.test" },
"model": "gpt-5-mini",
"parts": {
"system": "You are Atlas ... Relevant long-term notes ... Knowledge excerpts ...",
"memories": [ { "id": 12, "content": "Prefers short answers with the reasoning underneath." } ],
"sources": [ { "documentId": 3, "title": "launch-notes.md", "excerpt": "...", "score": 0.81, "method": "hybrid" } ],
"history": [ { "role": "user", "content": "..." }, { "role": "assistant", "content": "..." } ],
"message": "When does the embargo lift?"
},
"totals": { "chars": 641, "bytes": 721, "approxTokens": 161, "messages": 2 },
"extraction": null
}
Three details are deliberate. The provider is identified by host only — never the full base URL, never an API key; the field is derived from the configured endpoint by the same helper that scrubs endpoints in the doctor output. The full text of memories and excerpts is in the manifest, while the live chat stream still carries only short excerpts of them; the preview is where you read, the stream is where you watch. And extraction names the model that would write memory after this exchange if automatic extraction is on — resolved by the same function the extractor itself uses, so the declaration names the model that will really run, including the case where "cognition stays home" would refuse a remote one and the field is null.
The dialog, and the receipt
In the web interface the gate lives in the send path. If the setting is on, the provider for this persona is not local, and you have not trusted that provider, the client calls the preview and opens a dialog: one line for the destination and size, then five rows — system prompt, memories, knowledge excerpts, prior messages, your message — each expandable to its full text, rendered as text. Send proceeds with the ordinary request. Cancel — nothing leaves does exactly that, and your message stays in the composer. Escape is cancel. If the preview itself fails, nothing is sent; failing open would defeat the feature.
A checkbox — don't ask again for this provider — writes the provider's id into your configuration (privacy.outgoingPreviewTrusted), where you can revoke it under Settings. The whole feature is one config key: privacy.outgoingPreview is ask by default and off if you would rather not be asked at all.
Whichever you chose, the answer that comes back from a remote provider carries a receipt in the stream's metadata — outgoing: { bytes, chars, approxTokens, host } — and the message shows it: left the machine · 721 B → api.example-remote.test. A local answer shows nothing new, because nothing left. The receipt is not a courtesy; it is the audit trail for the times you chose not to look.
The test that keeps it honest
Rule one is a claim, and claims here come with tests. The parity test stands up a mock OpenAI-compatible server that records the body of every request. It calls the preview, then the real chat, with the same message, persona, and conversation. Then it asserts that the provider's captured messages deep-equal the preview's outgoing context, in the same order, and that the receipt's byte count equals the serialized size of what the mock actually received. A second test counts conversations, messages, and memories before and after a preview and requires the numbers not to move. A third checks that the manifest never contains an API key or a scheme — host only. If any of those fail, the build fails.
What we did not build, on purpose
No PII classifier. The obvious feature — flag the sensitive parts, maybe redact them — means running a model over your context before the model you chose runs over it. That is a second reader, with its own errors, whose judgment you cannot see. We show the bytes instead. If a memory should not travel, you can strike it from the ledger; if a document should not be retrievable by a remote persona, that is what personas without knowledge access are for.
No gate on headless surfaces. The command line, the plain /api/ask endpoint, the MCP server inside Claude Desktop or Cursor, the editor and browser integrations, and the ChatGPT Actions bridge do not open a dialog, because a dialog there would hang a program. Their documentation says so. The receipt data is still on the wire for anything that wants to show it.
The limits, stated
The declaration shows what SovereignAI sends. It cannot show what a remote provider does with it afterwards — logging, retention, training — and it does not pretend to. Plain HTTP does not encrypt anything, including this context; use a local model, a trusted network, or HTTPS to the provider. "Local" is decided by hostname: an endpoint on your LAN is not local by this rule, which is the conservative reading and the intended one. And a token count is an estimate.
What it does do is make one thing impossible: sending your context to a company without being able to know exactly what you sent. That used to be the normal case. As of this build, on this product, it is not.
See it on your own machine.
Run the trial, add a remote provider with your own key, and send a message. The dialog is the first thing you will see. The ledger records the rest.
Sources
- The mechanism is decision record ADR-26 in the product's architecture notes, shipped with every install; the ledger rows for "the model" and "cognition" on the Sovereignty Ledger reflect it.
- Perplexity's per-step disclosure — its research post ("applies a PII classifier to flag sensitive information, and shows the user what would leave the device"), and coverage in VentureBeat and Tom's Guide.
- The example manifest and the 721-byte receipt come from a throwaway instance driven against a stubbed remote host during testing; the numbers are real, the host is not.