Quickstart
How an AI agent reaches ZIFFER - MCP, the Python SDK, the TypeScript SDK or plain HTTPS - the real calls, and the one line that gates the action.
Install
The TypeScript SDK, version 0.3.0, is one package on npm. It brings the
receipt verifier, @ziffer-io/verify, and the wire types, @ziffer-io/types,
with it:
npm install @ziffer-io/client@0.3.0The Python SDK is the package ziffer on PyPI:
pip install zifferThe scanner, @ziffer-io/scan, and the MCP server, @ziffer-io/mcp, run with
npx and need no install:
npx @ziffer-io/scan@0.3.0The API key and the trust anchor come from us. Write to hello@ziffer.io.
Before you start
Four values. The first three come from us, the suite floor is yours to choose, and none of them is a secret you hand over: the credential that performs the action stays yours and we never see it.
| Variable | What it is |
|---|---|
ZIFFER_API_URL | The base URL of the decision API. |
ZIFFER_API_KEY | Your API key, zfr_ and 43 characters. The key is the tenant. You never put a tenant name in a request, and a body naming a different one is refused rather than rewritten. It expires, 90 days by default. |
ZIFFER_TRUST_ANCHOR | A path to the public half of the identity that signs your receipts. Get this file out of band. An anchor fetched from the service it is used to check is that service asserting its own identity, which is the one thing the signature was meant to establish independently. |
ZIFFER_SUITE_FLOOR | The weakest signature suite you will accept: ed25519, hybrid-ed25519-mldsa65 or slhdsa128s. There is no default, deliberately, and both SDKs refuse to build an anchor without it. A default here would be a minimum cryptographic strength a library chose for you. Set hybrid-ed25519-mldsa65: a floor naming slhdsa128s refuses every receipt rather than accepting a different signature in its place. |
Set them to your own values:
export ZIFFER_API_URL='<your value>'
export ZIFFER_API_KEY='<your value>'
export ZIFFER_TRUST_ANCHOR='<your value>'
export ZIFFER_SUITE_FLOOR='hybrid-ed25519-mldsa65'The proposal
One shape, whichever edge you use. Every field is required, and the two objects at the bottom are empty rather than absent when an action has no parameters.
{
"schema_id": "transfer",
"schema_version": "1.0.0",
"schema_hash": "sha256:<the 64 hex characters of your registered schema>",
"fidelity": "F-HIGH",
"tenant_id": "<your tenant>",
"payload": {
"task_type": "transfer",
"operator": "<the principal the action runs for>",
"targets": ["bank-api"],
"params": { "amount": 4200, "to_account": "48812" },
"cidrs": {}
}
}targets are the names your policy's sensitivity table is keyed by, and a
target you never listed is graded at the highest tier rather than the lowest.
Each value in params is an integer or a string, and nothing else. fidelity
is carried because the format requires it and is restamped by the door from
what the door itself verified, so your copy decides nothing.
The AI agent edge
Four ways in. They carry the same proposal and get back the same receipt; the difference is what your code already speaks.
MCP
The MCP server is a development-time tool, not a production edge. It runs on
your machine over stdio and gives an AI coding assistant tools to read the integration
guide, check a proposal against the wire schema, submit one, read decisions,
verify a receipt, check your policy repository and list the tools your AI
agents can reach. It edits no file in your repository or your policy tree. Its
policy tools run ziffer list and ziffer decide --unsigned, which need no key
and sign nothing, and its scan starts the MCP servers your AI agent clients are
configured to start only after you confirm.
{
"mcpServers": {
"ziffer": {
"command": "npx",
"args": ["-y", "@ziffer-io/mcp@0.3.0"],
"env": {
"ZIFFER_API_URL": "<your value>",
"ZIFFER_API_KEY": "<your value>",
"ZIFFER_TRUST_ANCHOR": "<your value>",
"ZIFFER_SUITE_FLOOR": "<your value>"
}
}
}
}There is no approve tool and no simulated approval. An approval minted on a developer's laptop is a fake receipt factory, which is the artifact this product exists to make impossible.
MCP alone routes the question. Only the verify line in your handler enforces
the answer.
Python
import json
import os
from ziffer import Client, RefusedError, TrustAnchor, verify
client = Client(
base_url=os.environ["ZIFFER_API_URL"],
api_key=os.environ["ZIFFER_API_KEY"], # the key IS the tenant
)
anchor = TrustAnchor.from_file(
os.environ["ZIFFER_TRUST_ANCHOR"],
min_suite=os.environ["ZIFFER_SUITE_FLOOR"],
)
def transfer(amount: int, to_account: str) -> None:
proposal = {
"schema_id": "transfer",
"schema_version": "1.0.0",
"schema_hash": SCHEMA_HASH, # your schema's hash, fixed at build
"fidelity": "F-HIGH",
"tenant_id": TENANT_ID, # must equal the key's tenant
"payload": {
"task_type": "transfer",
"operator": OPERATOR,
"targets": ["bank-api"],
"params": {"amount": amount, "to_account": to_account},
"cidrs": {},
},
}
decision = client.wait_for_receipt(client.propose(proposal).decision_id, timeout=30.0)
if decision.receipt is None:
raise PermissionError(f"ZIFFER refused: {decision.refusal_category}")
try:
verify(decision.receipt, json.dumps(proposal).encode(), anchor)
except RefusedError as refusal:
# refusal.name is the refusal, refusal.clause the rule. Log both; do not retry.
raise PermissionError(f"receipt refused: {refusal.name} ({refusal.clause})") from refusal
bank.transfer(amount, to_account) # your line, unchangedwait_for_receipt keeps polling until the receipt is readable, or the
decision is final with none: What comes back says why the
two differ.
json.dumps is fine. verify parses your bytes and canonicalises them itself,
so key order and spacing are not your problem.
Catch RefusedError, not Exception. A refusal means the receipt did not
hold. A connection error from propose means you never got one. Collapsing the
two turns "our gateway was briefly unreachable" into "the policy denied this",
and you will debug the wrong thing.
TypeScript
import { ZifferClient, loadTrustAnchor, verifyReceipt, Refusal, type TrustAnchor, type WireProposal } from '@ziffer-io/client';
import { canon } from '@ziffer-io/verify';
function env(name: string): string {
const value = process.env[name];
if (value === undefined) throw new Error(`${name} is not set`);
return value;
}
const client = new ZifferClient(env('ZIFFER_API_URL'), env('ZIFFER_API_KEY'));
const anchor: TrustAnchor = await loadTrustAnchor(env('ZIFFER_TRUST_ANCHOR'), env('ZIFFER_SUITE_FLOOR'));
async function transfer(amount: number, toAccount: string): Promise<void> {
const proposal: WireProposal = {
schema_id: 'transfer',
schema_version: '1.0.0',
schema_hash: SCHEMA_HASH,
fidelity: 'F-HIGH',
tenant_id: TENANT_ID,
payload: {
task_type: 'transfer',
operator: OPERATOR,
targets: ['bank-api'],
params: { amount, to_account: toAccount },
cidrs: {},
},
};
const submitted = await client.propose(proposal);
const decision = await client.waitForReceipt(submitted.decision_id, { timeoutMs: 30_000 });
if (decision.receipt === undefined) {
throw new Error(`ZIFFER refused: ${decision.refusal_category}`);
}
try {
verifyReceipt(decision.receipt, canon(proposal), anchor);
} catch (error) {
if (error instanceof Refusal) {
// error.name is the refusal, error.clause the rule. Log both; do not retry.
throw new Error(`receipt refused: ${error.name} (${error.clause})`);
}
throw error;
}
await bank.transfer(amount, toAccount); // your line, unchanged
}waitForReceipt waits the same way: decision.receipt is present when it
returns, or the decision is final without one.
instanceof Refusal, never a cast. This catch also sees the errors you did
not plan for, and asserting a type here would read a clause off a TypeError
and report a protocol refusal that never happened.
canon is used only because @ziffer-io/verify exports it.
new TextEncoder().encode(JSON.stringify(proposal)) verifies identically.
HTTPS
Two calls. Submit, then read.
curl -X POST "$ZIFFER_API_URL/v1/proposals" \
-H "Authorization: Bearer $ZIFFER_API_KEY" \
-H "Content-Type: application/json" \
-d @proposal.jsoncurl "$ZIFFER_API_URL/v1/decisions/<decision_id>" \
-H "Authorization: Bearer $ZIFFER_API_KEY"Receipts are served in exactly one place: the read. The answer to a submission never carries one.
Going this way means trusting the outcome field the API sent you, and TLS
that the host answering is ours. That is a defensible integration for a
low-stakes tool. It is not the same as checking a signature, which is what the
SDKs add and what the next section is about.
What comes back
One of three answers, and the mapping is the one from
Concepts: granted: the API answers ALLOW; held for a
quorum: ATTEST; refused: DENY.
Granted. A receipt follows, but it may not be readable yet: it attaches to
the decision only when the Executor's part is done, and decided can come
first. wait_for_receipt and waitForReceipt poll until receipt is present;
verify it, then act.
Held. The action met a floor and needs a quorum: the number of distinct approvers your policy sets, two named humans in the example policy. The answer arrives immediately, with no receipt:
status: "decided" outcome: "ATTEST" receipt: absentATTEST is the gate, not a refusal. Do not treat it as a DENY and do not
propose again: the proposal is already in flight, and a second one is a second
action. wait returns here, because the status is already decided;
wait_for_receipt and waitForReceipt keep polling until a receipt is present,
so give them a deadline as long as your approvers need. Then verify it. Watch the
receipt, not the outcome: the outcome stays ATTEST, and the receipt appearing
is the change. While the approved action waits out your policy's hold window,
the decision carries held_until and still no receipt.
That receipt carries the approvals, so verify it against your own attester
registry, the policy/attesters/registry.json of your policy repository: pass
registry=AttesterRegistry.from_file(...) to Python's verify, or set quorum
on the TypeScript trust anchor. Without it, a receipt that carries approvals is
refused as ApprovalsUnverifiable, under AB-1.
Refused. The action is outside policy, or policy has nothing to say about
it. An action with no risk function is refused rather than graded down, so a
gap in policy stops the action instead of quietly permitting it. The decision
carries a refusal_category that says whether to try again, never which rule
refused: PolicyRefused, PolicyBasisMoved, RateBounded or
ProposalMalformed. The rule is in the console and in your audit chain.
Verify before you execute
This is the step the rest of it is for. Delete it and you are back to believing a field in a message: nothing warns you, no test fails, and the integration still appears to work.
verified = verify(decision.receipt, json.dumps(proposal).encode(), anchor)const verified = verifyReceipt(decision.receipt, canon(proposal), anchor);Both take the proposal a second time, and that is the point. The verifier recomputes the hash from the bytes you hand it and compares the receipt's field against that. Handing it the receipt's own copy of the hash would prove nothing: a transmitted identifier is a name for a binding, not evidence of one.
Both signature algorithms must verify. The composition is conjunctive, never any: one primitive failing is a refusal, and that is what makes a receipt survive one algorithm breaking.
Both refuse by raising, with the refusal's name and its rule on the exception. What each id means, and what to do about it, is Refusals.