GuacaDoc — Devpost submission text
Inspiration
There is a whole class of documents you cannot hand to an AI. A client contract, a patient record, an unsigned deal. You have the analysis problem, the tool that could solve it, and no right to hand over the file.
What struck me is that the constraint is not really about privacy. It is about what an agent actually needs in order to be useful. An agent reasoning about a liability clause does not need to know the counterparty is Marceline Dubreuil. It needs to know that the party in section 2 is the party in section 7. That is a structural fact, not an identity. Separate those two and the impossible task becomes possible: the agent gets the substance, never the identities.
WebMCP is what makes that separation enforceable rather than aspirational, because the tools run in the page, next to the person who owns the document.
What it does
GuacaDoc opens a document in your tab, parses it there (TXT, MD, PDF, DOCX), scans it locally for identifying values, and turns it into five WebMCP tools an agent can call. The agent outlines, searches and reads the document, and never receives a name, an account number or an address from it.
You review what was found and choose, per value or per type, whether it goes out as written, as a stable token, or never. Bank details default to never, because an account number carries no analytical value.
Names come back as [PERSON_01], stable across the whole document. That
stability is what keeps the analysis useful: the agent can reason that the
signatory of section 2 is the person named in section 7 without knowing who
either is. When it answers in tokens, you paste its answer into a decode panel
and get the real names back, assembled in your tab from your key. Withheld
values have no token, so they are not reversible by construction.
A running counter shows exactly how many characters the agent has read, and a strip lists every chunk that left the tab, in order, with its tool and timestamp.
How we built it
Static Next.js export, React, TypeScript, no backend and no network access at all. Five layers, and the boundary between them is enforced by which module is allowed to talk to the agent.
extract file → text, in the tab (pdf.js, mammoth)
sections text → addressable sections (s01…sNN)
detect text → entities (regex rules + capitalisation heuristic + aliases)
↓ the user marks: shown | token | withheld
policy substitute · verify · confirm · record
webmcp document.modelContext.registerTool()
The load-bearing decision is the one-door rule: exactly one function in the project hands a tool to the browser, and it scrubs the entire result tree after the handler returns. The handler has no say in it. The alternative, asking every handler to remember to redact, is a design that fails the first time someone adds a sixth tool at 2 a.m. Here a leak requires defeating the scrubber, not forgetting to call it.
The second decision was to make the strongest guarantee one I did not write
myself. The page ships connect-src 'none', so it cannot open a network
connection: no fetch, no XHR, no WebSocket, no beacon. Anyone can confirm that
from the Network tab in seconds, which is stronger than any sentence in a
README. Runtime traps sit behind the CSP so an attempt shows up in the interface
instead of in a console nobody opens.
Detection is local and deliberately blunt: regex plus a capitalisation heuristic, tuned to over-detect, because a false positive costs one click and a false negative silently leaves a name in the outbound text. Sending the document to a remote recogniser would have recreated the exact leak the app exists to prevent.
Challenges we ran into
The search oracle. Search first ran over the source text and returned
redacted snippets, which looks correct and is completely broken. An agent
could query Dubreuil and learn from a hit or a miss whether that name is in
the document, without one byte of it being returned. The channel leaks through
its own metadata. Search now runs over the redacted text, never the source,
and the cost is real: withheld values are genuinely unfindable, even by their
owner. There is a test named searching a redacted name returns nothing (no
oracle) because I wanted the property pinned down, not remembered.
Anonymisation that survives a real document. Substituting a name once is
trivial; substituting it everywhere is not. A document introduces Marceline
Dubreuil on page one and says Dubreuil for twelve pages after, so surname
aliases are derived and mapped to the same token, but kept only when they
actually occur outside the full form, otherwise the counts start lying. Phone
numbers appear as +33 6 41 88 07 23 and 06 41 88 07 23, so token identity
is decided on a normalised key, not the surface string. Addresses are matched
as one entity across number, street and postcode, and a longer hand-marked span
absorbs the town entity inside it rather than minting two tokens for one place.
Accented names break word boundaries, so substitution uses Unicode letter
lookarounds instead of \b. And after all that, the scrubber verifies its own
output and hardens anything that survived, because the honest assumption is that
the detector missed something.
No unregisterTool() in the API. A tool is withdrawn by aborting the signal
it was registered with. Without handling that, a second mount re-offers five
names the browser already holds, every call is rejected, and the page advertises
tools while the browser answers an older set of closures. Registration is
therefore generational.
Trusting the browser over myself. I keep my own registry of tools and I do
not believe it. The header count comes from the browser's own getTools(),
because the browser's answer is the only one that cannot be wrong. "5 tools"
above an empty record is made structurally impossible.
The screen is a channel WebMCP does not govern. My tool surface is sealed,
but an agent that can screenshot the tab is not reading through my tools at all.
So the token map is absent from the DOM by default rather than blurred,
revealed on demand for 45 seconds with a visible countdown, ending immediately
on blur, pagehide or visibilitychange, which are facts the browser states
rather than infers. The activity bar deliberately does not claim the page is
being read, because a screenshot is invisible to the page and a bar that lit up
for it would be guessing. Tool-mediated disclosure is what WebMCP governs, and
that is what this enforces. The rest is written into the interface itself,
because a threat model you hide is not a threat model.
Accomplishments that we're proud of
It works end to end in a real WebMCP browser. You ask ChatGPT to find the
liability clauses, it calls search_document then get_section, and comes back
with a correct answer in which every party is a token and the bank details
simply are not there.
- 28 assertions turn the README into something executable.
npm run verifyre-derives every claim from the real modules and exits non-zero on failure. The ones that matter are adversarial: the search oracle is closed, a surname used alone is still substituted, decode does not restore withheld values, and no section leaks after scrubbing. - Every refusal is a structured answer, never an exception, so an agent that hits a wall learns why and adapts instead of retrying blind.
- Session boundaries are marked, not erased. Opening a new document restarts the token registry but keeps the record, with a boundary row at the seam. An agent's first probes cannot vanish from a trail that claims to be gapless.
- The export contains not one source value, because exporting the key would put the protected data on a disk.
- There is no red anywhere in the interface. A refusal is the policy working, and colouring it as an error teaches people the wrong thing about their own tool.
What we learned
WebMCP, properly. The imperative API, JSON Schema declarations, abort-signal
lifecycle, and why Origin-Agent-Cluster: ?1 is mandatory: the API is exposed
only to origin-isolated documents, so without the header registerTool()
rejects and your page advertises nothing.
That tool descriptions are the real interface. More time went into the wording of five descriptions and their error hints than into the code behind them. An empty search returns a hint explaining that search runs on redacted text, so real names are not findable by design. Those sentences changed agent behaviour more than any handler I wrote.
That the interesting question is not "what did I hide" but "what can be inferred". Substitution shrinks the exposed surface, it does not remove it. A pseudonym protects a name while someone with distinctive behaviour stays recognisable by inference. Naming that limit turned out to be more useful than pretending to have closed it, which is why the counter and the strip show every character that left.
That the guarantees worth making are the ones someone else enforces. A
sentence in a README is a promise; connect-src 'none' verified in the Network
tab is a fact. I would reach for a browser-level constraint before a TypeScript
one every time.
What's next for GuacaDoc
The direction I want to pursue is anonymisation borrowed from cryptography rather than from redaction: giving a model enough structure to reason correctly while the personal data stays mathematically out of reach.
- Keyed, deterministic token derivation. Deriving tokens from a keyed function of the normalised value would make them stable across documents and sessions without ever storing a mapping, so the same person carries the same token through a whole corpus and the key never has to exist on disk.
- Format-preserving substitution. A tokenised date destroys the ordering an agent needs for deadlines; a tokenised amount destroys the arithmetic. Format-preserving encryption would keep dates orderable and amounts comparable while the true values stay unrecoverable, so the agent can conclude a payment is late without learning when it was due.
- Formal disclosure accounting. The character counter measures text, not information. What I want is a measure of how much has actually been revealed across a session, in the spirit of differential privacy, rather than how much text went through the door.
- Inference-aware review. Flagging the quasi-identifiers a model could triangulate (a role, a date, a city that together identify one person) rather than only the direct identifiers a regex can see. That is where the real residual risk lives, and it currently rests on the user's judgment alone.
- Detection that does not depend on a regex. A small local model in the tab, under the same no-network constraint, would catch the misspellings and unusual name forms the heuristic misses. The constraint is non-negotiable: the moment detection needs a remote call, the premise dies.
Built With
- content-security-policy
- mammoth.js
- nextjs
- pdf.js
- railway
- webmcp
Log in or sign up for Devpost to join the conversation.