Inspiration

The obvious build for the Foxit challenge is prompt → PDF → send for signature. That pipeline works, and it is exactly what the challenge title is warning about. An agent that can draft an agreement and also dispatch it for signature has no boundary in it at all; the "human in the loop" is a dialog box that a retry, a race, or a re-rendered draft walks straight past.

So we made the boundary the product, and treated the drafting as plumbing.

What it does

One sentence in, a signed document out — with a gate in the middle that the agent cannot open.

The agent extracts intent, checks that the counterparty exists, drafts the document clause by clause, renders it through the Foxit PDF Services API, and audits the result. Then it stops. It has a finished PDF and no way to send it.

The audit is where the value is. Asked for a two-year mutual NDA, a real model returned twenty-one clauses and declared twenty-four values it had supplied on our behalf — the governing law, the forum, the liability cap, the notice period, the survival term. Every one of those is defensible boilerplate. Together they are a set of obligations nobody chose and nobody read, inside a document that looks finished. Ask again and the count moves: across our runs it has been eighteen, twenty, twenty-three and twenty-four. That drift is the argument rather than a caveat — there is no fixed set of defaults you can review once and then stop checking.

And the counterparty we told it to contract with does not have a domain that resolves. That is a blocking finding, not a footnote: the run cannot proceed until a person writes down why they are overriding it, and that reason is recorded in the ledger next to their name.

Once a human does authorise it, the rest is unremarkable on purpose: the document goes out through the Foxit eSign API, the counterparty signs it in Foxit's own UI under their own login, and collect fetches the executed file back and closes the ledger with it. We ran that end to end — a real envelope, a real person signing by hand, a 115,567-byte signed PDF retrieved and recorded against the digest that was approved. The agent's part of that sequence is the same as everywhere else: it prepares, and it waits.

How we built it

Python 3.11+, no third-party dependencies in the core — clone it and run it. Three guarantees, in increasing order of how much they actually hold:

  1. An approval names bytes, not documents. The authorisation binds to the SHA-256 of the rendered PDF, and send re-hashes the file it is about to transmit. Change one byte after approval and it refuses. This closes the time-of-check/time-of-use gap a confirmation prompt leaves open.
  2. The approval token never touches the disk. draft prints a random token and stores only its SHA-256. A process following the documented interface cannot approve its own output, because the only copy went to the operator's terminal.
  3. The record cannot be quietly rewritten. Every stage appends to a hash-chained JSONL ledger; editing or deleting any line is detectable.

We call the Foxit eSign API directly with our own credentials, as the challenge requires — Foxit's published MCP server wraps PDF Services only, and eSign is not in it. Both APIs authenticate the same way (client_id / client_secret in named headers, no OAuth exchange), so the whole client is about 280 lines of urllib.

Signature fields are placed with text tags (${signfield:1:y:____}, ${datefield:1:y::____}, plus processTextTags: true), so field placement lives in the document rather than in hard-coded page coordinates and survives a re-draft that changes the page count. A locally generated PDF has no public URL, so the bytes go up inline via base64FileString.

Counterparty verification needs no credentials at all: DNS resolution and a TLS handshake from the standard library tell you whether the other side's domain exists, who issued its certificate and how old that certificate is. A certificate issued three days ago on the domain of a company you are about to sign a two-year agreement with is worth seeing before you sign, not after.

The model layer has three interchangeable backends — a fixture-driven mock (which is how all 56 tests run with no credentials at all), the local Claude Code CLI, and the official Anthropic SDK.

Challenges we ran into

  • The API reported success on a document with no signature fields in it. Our text tags were in a plausible but non-existent syntax, and processTextTags neither rejected nor warned — it simply matched nothing. Every test passed, the envelope was created, the status was right, and the recipient opened a document with zero required fields. Nothing short of opening the signing screen ourselves would have caught it. The correct syntax is ${signfield:1:y:____}, and a single space anywhere inside the tag silently breaks it again, so that is now three regression tests.
  • Foxit's eSign needs separate activation, and until it is activated the endpoint answers 502, not 404 — which reads like a wrong route rather than an unprovisioned service. Activation then failed with a 422 that persisted through hard reloads, because the console caches the account profile in the session token; only re-authenticating cleared it.
  • Running a model through a local agent CLI is not a completion call. Left to its defaults it wrote files, explained itself in prose, and inherited the operator's own configuration — including which language to answer in. Isolating it (own system prompt, no setting sources, no MCP servers, no tools) turned it back into one hermetic call.
  • Testing against a real model found two bugs in our own audit that fixtures never could: it flagged the underscores of a signature line as an unfilled placeholder, and it reported a missing term clause for a document whose clause was headed "Effective Date and Term". Both are now regression tests.
  • Printing a model-written document to a Windows console crashed the run on a legacy code page — after the PDF had been rendered and billed.

Accomplishments that we're proud of

The gate is not a claim; it is three tests. Send before approval, approve with the wrong token, and edit one byte after approval — all three are refused, and the refusals are in the suite.

We also wrote down the threat model honestly. Guarantee 2 falls to anything that can read the operator's terminal, and all three fall to editing the source. What survives both is guarantee 1 plus guarantee 3: after the fact you can always prove which exact bytes a human authorised, and whether those were the bytes that went out.

One limit worth stating before anyone finds it: the signed file does not hash to the approved file, and it never can — signing adds content to the document. The ledger holds both digests and the envelope id that joins them, which lets you prove what was authorised and what was dispatched. Proving that the executed PDF contains those exact authorised bytes is the signing platform's signature to make, not ours, and we do not claim it.

What we learned

That the interesting question in an agentic document workflow is not "can the agent produce the artefact" — it plainly can — but "what did it decide on your behalf while producing it, and can you prove what you agreed to afterwards".

What's next

The gate generalises past documents. Anything an agent does that a person is answerable for — a payment, a deployment, an email to a customer list — has the same shape: the agent prepares an artefact, a human authorises that artefact, and the record has to survive the argument afterwards. Hash-bound approval plus an append-only ledger is a small, auditable primitive for all of it.

The commercial version is the compliance evidence, not the drafting. An organisation that lets agents prepare binding documents will need to show, per document, who approved what and that nothing changed in between. That artefact does not exist today.

Built With

  • anthropic
  • claude
  • dns
  • foxit-esign-api
  • foxit-pdf-services-api
  • hash-chain
  • python
  • sha-256
  • tls
  • urllib
Share this project:

Updates

Submission history