Concepts
The full contract is the protocol specification; this page explains it in order.
Records #
A record says what an agent decided or what happened. There are three kinds:
decision: written before a tool runs: the tool name and its input.outcome: written after it: the output, or the error. It points at its decision.note: anything else worth keeping, such as why the agent chose a tool.
The record's content is any JSON object, for example {"input": ..., "output": ..., "model": ..., "reason": ...}. The envelope around it holds the kind, the agent, a sequence number, the hash of the previous record, the time, the name and the salted hash of the content: sha256(salt || JCS(content)), where the salt is 16 random bytes drawn for each record and kept on your machine next to the content. The agent signs the hash of the envelope with its own Ed25519 key, which is created on your machine and never leaves it.
Agent chains #
Each agent's records form a chain: record 5 holds the hash of record 4. Removing, inserting or reordering a record breaks the chain at that point. A chain belongs to one project, one environment (test for the sandbox, live) and one agent name. The agent name defaults to the project folder name.
Batches and receipts #
The SDK ships records in batches. The service checks each one, builds a Merkle tree over the batch, and signs:
- the batch, which also chains to the project's previous batch, so the service keeps its own independent copy of the order;
- the agent's new head (its latest sequence number and record hash). Your SDK keeps the newest head it was given, so a service that later showed a shorter chain would be caught.
Each record gets a receipt: its Merkle proof, the signed batch and the signed head. The receipt is written next to the record in your local chain file.
Timestamps #
- Not before: each batch includes the newest round of the public drand randomness beacon, which nobody could know in advance.
- Not after: every hour the service puts the hashes of all new batches (live and sandbox) in a Merkle tree, signs an anchor over its root, and gets an independent public timestamp for that anchor. Only a hash is published, never customer data; anyone can check the public timestamp independently (Verify says how).
Together: the drand round says "not before", the anchor says "not after", and the two signatures say "both agreed".
Pending means co-signed, waiting for the public timestamp (an anchor is made every hour and usually confirms within a few hours); never counted as passed. The same word is used everywhere: in signafaide verify, the console, audit packs and verification reports. While an anchor waits, the timestamp servers have promised to include the root, and the proof is upgraded once it is confirmed. The service marks it confirmed only after checking the proof against the public record itself, and signafaide verify checks it again on your machine. Pending is normal for recent records and needs nothing from you.
Each anchor also commits to the service's key list at that moment (keys_hash), so the service cannot later rewrite when a key became valid.
Agent keys #
Each agent's key is made on your machine and never leaves it. The SDK registers it with a proof that it holds the key, so nobody can register your agent's public key in another project. Adding a second key to an agent needs a signature by its current key (signafaide keys rotate does this), or a developer or admin revoking the old key in the console first. A rotation retires the old key at the moment the service receives it, so it cannot sign a later rotation or add records. A retired or revoked key cannot add records, whatever time they carry.
An auditor checks records against your copy of the agent keys, not the service's: signafaide keys export -o agent-keys.json writes them, each self-signed, and the auditor passes --agent-keys agent-keys.json. Otherwise a service could add a key of its own. See Verify.
Hash-only mode #
By default the service receives only envelopes: the tool name, the agent name, the time, the chain links and the content hash, never inputs or outputs. The content hash is salted and the salt stays with you, so the service cannot learn the content from the hash, even content with few possible values such as {"approved": true}. Agent and tool names are sent in plain text as you write them, so keep personal data out of them. Inputs and outputs, with their salts, stay in .signafaide/chain/<project>/<env>/<agent>.jsonl, which is your evidence store. To prove what a record said, you show its content and salt; anyone can hash them and compare. When you store content with the service, its salt goes with it. To keep content with the service as well, turn on store_content (Python) or storeContent (TypeScript), for all tools or one: it is encrypted at rest, and live records need the Team plan.
Python
signafaide.init(store_content=True) # all tools
@signafaide.record(store_content=True) # one tool
def approve_payout(claim_id: str, amount: float): ...TypeScript
await init({ storeContent: true }); // all tools
const approvePayout = record(async (claimId: string, amount: number) => {
/* ... */
}, { name: "approve_payout", storeContent: true }); // one toolStrict mode #
Normally nothing waits for the network: records are signed locally and, once you opted in, shipped in the background; they wait on disk when the service can't be reached. In strict mode the decision is shipped first and the tool runs only after the service has returned its receipt. Use it where an action must never run unrecorded. Strict mode needs an API key: without one the tool does not run (no_key).
Python
@signafaide.record(strict=True)
def wire_funds(account: str, amount: float): ...TypeScript
const wireFunds = record(async (account: string, amount: number) => {
/* ... */
}, { name: "wire_funds", strict: true });If the receipt does not come, the tool does not run and the SDK raises not_recorded.
Local first, then a witness #
With no API key, records are signed with the agent's key, chained and kept in .signafaide/, and nothing is sent: no record, no request, no sandbox. signafaide verify checks the signatures, the chain links and the content hashes there, and says what is still missing:
co-signature, public timestamp: not yet. Run `signafaide init` to send hashes, or self-host (SIGNAFAIDE_URL)
PASS: 4 of 12 checks passed, 8 not checked (nothing co-signed yet)Co-signature and the public timestamp need a witness. A person opts in once: signafaide init, or setting SIGNAFAIDE_API_KEY (with SIGNAFAIDE_URL for a self-hosted witness). Records written before that ship afterwards with their original times. The service accepts records up to 7 days old (an agent key's validity starts at most 7 days before it is registered); a local chain that starts earlier stays local, still verifies, and is reported as too_old.
A record sent late has two times: the claimed time (the agent's own clock, signed into the record) and the proven window (not before the randomness round of the batch it arrived in, not after its public timestamp). The console, the auditor view and audit packs show both, with the note "written offline, sent later" when the claimed time is before the window. This 7-day backdating window is the limit of what an agent can claim: a record dated more than 7 days before its key was registered, or more than 5 minutes after the service received it, is refused.
Sandbox and claiming #
signafaide init creates an anonymous sandbox project (no account, email or card), saves SIGNAFAIDE_API_KEY to .env, adds .env to .gitignore and prints a claim link. Records never create one by themselves, and neither does init() in code. A person opens the link, signs in and claims the project into their organization, which keeps its records. Live keys are created in the console. A live key starts its own chains; the sandbox chains stay on disk.
Local state #
.signafaide/ in the project root holds the agent keys (agents/), the chains with their receipts (chain/; records written before the opt-in are in chain/_pending/nokey/), the records not yet shipped (outbox/) and the newest signed heads. The SDK adds it to .gitignore, and adds .env too when it saves a key there. Back up the keys. The Python and TypeScript SDKs share this folder and its formats.
What is sent #
Besides records, each request carries one header naming the SDK and its version, the runtime, the framework adapter, the coding agent that set the project up and whether the call came from the SDK, the CLI or the MCP server. signafaide verify and export also report that they ran, when there is an API key. No content, names, paths or hostnames. With no API key nothing at all is sent. SIGNAFAIDE_TELEMETRY=0 turns the optional fields and the events off.