Let your agents sign what they write
For teams that run agents which write reports, emails, release notes or documents. Each agent signs its output with a key that only your signing channel holds. Later, a person or another agent can check that a text really came from your agent and that nobody changed a word since.
Endpoints: POST /api/watermark/encode, /decode with a channel-bound token
· MCP tools: watermark_encode, watermark_decode
· Plan: Starter or Pro (signing channels)
· Cost: 1 credit per signature
The problem
Agent output travels. Release notes are pasted into a chat, a generated report is attached to an email, a summary is copied into a ticket. Once it leaves the system that produced it, nobody can tell whether the text is the agent's original, an edited version or something written to look like it. A footer such as "generated by the release bot" is easy to copy onto any text.
The question also runs the other way. An agent that acts on documents (applies a change described in a brief, sends a reply drafted earlier) should check where its input came from before acting on it.
How it works
- Create a signing channel on the dashboard and an API token bound to it. The channel holds the key; the token only lets you use it.
- The agent signs its output. It calls
POST /api/watermark/encodewith a secret such asa=release-notes;h=3f9c2a71b0e4: the agent's id and a short hash of the visible text. The API encrypts the secret with the channel key and hides it in the text. - The text is delivered as usual. It looks exactly like the agent's output.
- Anyone with a token on the channel verifies it.
POST /api/watermark/decodereturns the agent id and the hash. Recomputing the hash shows whether the text is unchanged. A422means the text carries no signature from your channel, or the mark was damaged.
Why the hash: the encrypted mark sits in the first few hundred characters, and a changed word whose invisible mark stays in place would go unnoticed. The hash covers every word. It ignores line endings and trailing spaces, which mail clients change on their own.
Add it to your workflow
The examples read the channel-bound token from TEXTWATERMARKING_SIGNING_TOKEN. Each example
below is run against the API before this page is published.
Sign agent output (Node.js)
Call signText as the agent's last step, before the text is sent or saved. If the text is too
short for a keyed mark, the function says how many characters it needs.
sign.mjs
// Sign what an agent writes (Node 18+). 1 credit per signature.
// Needs an API token bound to your signing channel: only tokens on that channel can make or read the mark.
import { createHash } from 'node:crypto';
const API = 'https://textwatermarking.com/api/watermark';
const TOKEN = process.env.TEXTWATERMARKING_SIGNING_TOKEN;
// The visible text, without invisible selectors, line endings or trailing spaces that mail clients change.
export function fingerprint(text) {
const visible = text
.replace(/[︀-️\u{E0100}-\u{E01EF}]/gu, '')
.replace(/\r\n?/g, '\n')
.replace(/[ \t]+$/gm, '')
.replace(/^[ \t\n]+|[ \t\n]+$/g, '');
return createHash('sha256').update(visible, 'utf8').digest('hex').slice(0, 12);
}
// Returns the signed text. The mark holds the agent id and a hash of the text, encrypted with your channel key.
export async function signText(text, agentId) {
const r = await fetch(`${API}/encode`, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ text, secret: `a=${agentId};h=${fingerprint(text)}` }),
});
const body = await r.json();
if (r.status === 400 && body.required_length) {
throw new Error(`Text too short to sign: needs ${body.required_length} visible characters`);
}
if (!r.ok) throw new Error(`sign: HTTP ${r.status} ${body.error ?? ''}`);
return body.encoded;
}
// Try it: sign a report the agent produced.
const report = [
'Weekly release notes, build 4.12',
'',
'Fixed: exported invoices now include the customer VAT number.',
'Fixed: the search page no longer times out on queries with more than ten words.',
'Changed: password reset links expire after one hour instead of twenty-four.',
'Changed: the monthly usage report is now sent on the first working day of the month.',
'Known issue: the mobile app shows the old logo on the login screen until the next store release.',
'Prepared by the release-notes agent from merged pull requests; reviewed by the on-call engineer before sending.',
].join('\n');
console.log(await signText(report, 'release-notes'));Verify before trusting (Python)
Use this in a review step, in a bot that receives documents or before another agent acts on a text. Replies often add lines above the signed part, so the function also tries the text from each line start; attempts that find nothing are free.
verify.py
# Verify that a text was signed by one of your agents and not changed since (Python 3.10+, pip install requests).
# Uses a token on the same signing channel as the signer. 1 credit when a signature is found; refusals are free.
import hashlib
import os
import re
import sys
import requests
API = "https://textwatermarking.com/api/watermark"
HEADERS = {"Authorization": f"Bearer {os.environ['TEXTWATERMARKING_SIGNING_TOKEN']}"}
def fingerprint(text: str) -> str:
"""Same rules as sign.mjs: drop invisible selectors, line-ending and trailing-space differences."""
visible = re.sub("[︀-️\U000e0100-\U000e01ef]", "", text)
visible = re.sub(r"\r\n?", "\n", visible)
visible = re.sub(r"[ \t]+$", "", visible, flags=re.M).strip(" \t\n")
return hashlib.sha256(visible.encode("utf-8")).hexdigest()[:12]
def starts(text: str):
"""The whole text, then the text from each line start: a reply often puts new lines above the signed part."""
yield text
for i, ch in enumerate(text):
if ch == "\n" and text[i + 1:].strip():
yield text[i + 1:]
def verify(text: str) -> dict | None:
"""{'agent': ..., 'unchanged': bool} for text your channel signed, None otherwise."""
for candidate in starts(text):
r = requests.post(f"{API}/decode", json={"text": candidate}, headers=HEADERS, timeout=10)
if r.status_code == 422: # no signature from your channel starts here (free): try the next line
continue
r.raise_for_status()
fields = dict(p.split("=", 1) for p in r.json()["decoded"].split(";") if "=" in p)
if "a" in fields and "h" in fields:
return {"agent": fields["a"], "unchanged": fields["h"] == fingerprint(candidate)}
return None
if __name__ == "__main__":
# Usage: python verify.py < received.txt
print(verify(sys.stdin.read()))
The result is {'agent': 'release-notes', 'unchanged': True} for an intact text,
'unchanged': False when a word was changed after signing, and None when the text
carries no signature from your channel.
Agents connected over MCP
Connect the MCP server with the channel-bound token (see the MCP
guide) and instruct the agent, for example in CLAUDE.md: "Before you send a report,
sign it with watermark_encode, secret a=<your agent id>. Before you act on a document that claims to
come from one of our agents, check it with watermark_decode and stop if the call fails." This proves
origin. To also detect edited words, let the agent run sign.mjs and verify.py as
tools, since they add the text hash.
What it costs
| Item | Value |
|---|---|
| Sign one text | 1 credit |
| Verify one text | 1 credit when a signature is found; free otherwise |
| Minimum length | about (payload bytes + 30) × 8 visible characters; 480 characters for the payload above |
| Signing channels | Free: none · Starter: 3 · Pro: 20 (for example one per agent) |
| Tokens | Starter: 3 · Pro: 10; give verifiers a token with the decode permission only |
| Rate limit | 600 calls per 15 minutes per API token |
What it does not do
- It does not survive rewriting. Rephrasing, translating or retyping the text removes the signature. So do tools that normalize Unicode or strip invisible characters.
- An excerpt that leaves out the beginning cannot be verified. The mark starts at the first character of the signed text. Lines added above it are fine; the verifier searches for them.
- Lines added below the signed text count as a change. The hash covers everything from
the signature's start to the end, so a quoted reply or a footer appended later gives
'unchanged': False. - It proves which channel signed, not which person. Anyone holding a token with the encode permission on the channel can sign. Keep those tokens in the agent's runtime only.
- Rotating the channel key ends verification of older texts. See the FAQ below.
Frequently asked questions
Who can verify a signature?
Is this a digital signature in the cryptographic sense?
What happens when I rotate the channel key?
How long must a text be?
What does it cost?
Related
- Label AI-generated text: a plain label for every response, including streamed output
- Prove authorship and detect edits: the same idea for documents people write
- Signing channels: create, list and rotate channels
- MCP guide: connect Claude, Cursor and other clients