Integrating the SDK
Who this is for: the engineer who owns the code that runs your agent's tool calls.
Who this is for: the engineer who owns the code that runs your agent's tool calls. You should be comfortable adding a dependency and reading an environment variable. You do not need to know anything about the protocol, and nothing here asks you to move a credential.
What you end up with: the tool handler you already have, plus about ten lines. Before it acts, it asks ZIFFER; it checks the answer's signature in your own process; then your existing execution line runs unchanged. ZIFFER never holds the credential the action is performed with, and never sees it.
Read section 6 before you plan around any of this. It is placed before the code on purpose.
The positive claims here are narrow, the scope that is missing is wide, and a reader who takes
"the SDK verifies the receipt" to mean "the SDK stops the action" will have taken the opposite
of what ships. The SDK verifies. Your if stops the action.
1. The idea in one paragraph
Everything ZIFFER does ends in one artifact: a Decision Receipt, signed by our KMS, saying that a specific proposed action was allowed. Your handler proposes an action, waits for the decision, and then does the one thing that is the whole of it: checks the receipt's signature itself, in its own process, against a public key you configured out of band. If the check passes, the action was allowed by the policy your organisation signed. If it fails, you get a named refusal and you do not act.
The consequence worth stating: the gate is the receipt, not the network. A receipt that arrives over a perfect TLS connection is checked exactly as hard as one that does not. Nothing about who sent it, or over which link, weakens a single step. You are not trusting our API to tell you the truth; you are checking a signature we cannot forge without the key we hold, over bytes you recomputed yourself.
2. Two rungs, and the second one is the default
Rung 1: the raw HTTP API. Nothing installed. You POST /v1/proposals, you poll
GET /v1/decisions/{id}, you keep the receipt for your audit trail. What you are trusting is
TLS: that the host answering is ours, and that the outcome field it sent is true. That is a
real, defensible integration, and for a read-only or low-stakes tool it may be all you want.
Rung 2: the SDK. This is the default, and the difference is one line. The SDK does
everything rung 1 does and then verifies the receipt cryptographically inside your process. The
line that makes the difference is the verify call. It is worth being exact about what it buys,
because "we use the SDK" is otherwise a sentence with no content:
| what rung 1 trusts | what rung 2 checks instead |
|---|---|
the outcome field is what our engine decided | the receipt's signature verifies under your configured key, every primitive, never any |
| this receipt is about the action you proposed | the proposal hash is recomputed from your own bytes and compared to the receipt's |
| the receipt is current | its validity window is checked against your clock |
| the receipt is one we issued | a receipt of the wrong version, suite or decision domain is refused by name |
The third column that does not exist is "and then it refuses to run your action". There isn't
one. verify returns or it raises, and the branch is yours to write. Section 6 says why that
boundary is where it is.
Rung 3 is a separate Executor process and is described in the Running the Executor guide. It is not the entry point and you should not start there.
3. What you need before you start
Three things, and only the first comes from us.
-
An API key. Format
zfr_followed by 43 characters. The key determines your tenant. You never put a tenant name in a request, and if a proposal body carries atenant_idthat is not the key's, the request is refused withTenantMismatchrather than quietly rewritten. That refusal exists because the proposal is signed material further down: a gateway that edited your body would have become its author.The key expires: 90 days by default, and never more than a year. Ask for a different lifetime when you ask for the key. An expired key gets exactly the answer a revoked one gets,
401 {"error":"ApiKeyUnknown"}, byte for byte: telling the two apart in a response would tell anyone guessing keys which strings were once credentials.The API tells you when the key ends, on every successful call. Each 2xx answer, the POST and the GET, carries
X-Ziffer-Api-Key-Expires: <RFC 3339, UTC>for the key that authenticated it. Both SDKs read it: for the last fourteen days,ziffer.ClientandZifferClientwarn once per process on the language's standard channel (warnings.warnwith theziffer.ApiKeyExpiringcategory;console.warn), naming the date and the rotation. The header is never on a refusal, so an expired key still answers exactly like an unknown one, which means the warning can only reach a process that is still making successful calls. Route it somewhere a person reads (-W error::ziffer.ApiKeyExpiringturns it into a failing CI job), and keep the date in your renewals list as well.Rotate with overlap: get the next key, deploy it, then have the old one revoked. Both keys are live at once, on purpose, and nothing revokes the old one for you. Ask us for a successor (we mint it carrying the same tenant and label, read from our own records rather than retyped); put it in
ZIFFER_API_KEYwherever your service runs; confirm your traffic is flowing under it; then tell us to revoke the old one. Doing it the other way round, revoke and then deploy, is an outage as long as your deploy takes, which is why the tooling on our side refuses to rotate and revoke in one step.What a stolen key buys, stated plainly. It is a bearer token: possession is the whole of the proof, it replays, and it sits in an environment variable. Someone holding it can submit proposals as your tenant and read the decisions and receipts for your tenant, and nothing else. It cannot change policy, release a held action, acknowledge a notice, read another tenant's anything, or mint another credential. It buys no execution at all: the credential that actually does things is yours and never leaves your infrastructure, so what an attacker gets is a graded decision and a receipt for a proposal they wrote themselves. That lasts until the key expires or you have it revoked, which is why the lifetime is 90 days and not a year.
-
Your trust anchor file. This is the public half of the identity that signs your receipts, written by
ziffer pubkey --key <keyfile> --out anchor.json. It is a small JSON document withed25519_pk_hexandmldsa65_pk_hexin it. Put it on the filesystem your service reads and pointZIFFER_TRUST_ANCHORat it.Get this file out of band, not over the API you are verifying. An anchor fetched from the service it is used to check is not an anchor; it is that service asserting its own identity, which is the one thing the signature was supposed to establish independently.
-
Your suite floor, in
ZIFFER_SUITE_FLOOR: the weakest signature suite you will accept. It is separate from the anchor file and it has no default. A suite name is a property of a signature, not of a key, so it is not in the key document; and a default here would be a minimum cryptographic strength that this library chose for you.Omit it and the SDK refuses to build an anchor at all. In Python,
TrustAnchor(pub)andTrustAnchor.from_file(path)with nomin_suiteraiseAnchorErrornamingMinSuiteRequired; in TypeScript,minSuiteis a required field of theTrustAnchorinterface and the compiler refuses the object. The three names it accepts areed25519,hybrid-ed25519-mldsa65andslhdsa128s. The last of those is declared, not implemented, so a floor naming it refuses every receipt today rather than accepting a lattice signature in its place. Until 2026-09-05 the Python SDK filled an absent floor from the reference bundle's own field default while this paragraph said there was none; the paragraph was the half that was right.
4. The shape of the change
Here is a tool handler that transfers money. It has its own credentials, it knows how to do its job, and it is not going to stop doing it.
def transfer(amount, to_account):
bank.transfer(amount, to_account) # your line, your credentialRung 2 wraps that line and changes nothing else:
def transfer(amount, to_account):
proposal = {...} # 1 describe the action
decision = ziffer.propose(proposal) # 2 ask
decision = ziffer.wait(decision.id) # 3 wait for a verdict
if decision.outcome != "ALLOW": # 4 refused? stop.
raise Refused(decision.clause)
verify(decision.receipt, # 5 check the signature
json_bytes(proposal), anchor) # -- raises if it does not hold
bank.transfer(amount, to_account) # 6 your line, unchangedSix lines around one. Two of them are the point:
Line 5 is the whole product. Delete it and you have rung 1 wearing rung 2's dependency: you
are back to believing the outcome field. Nothing warns you, no test fails, and the integration
still "works", which is exactly why it is called out here rather than left to be inferred.
The proposal is passed to verify a second time, and that is the point. verify recomputes
the hash from the bytes you hand it and compares the receipt's field against it. Handing it the
receipt's own copy of the hash would prove nothing at all: a transmitted identifier is a name for
a binding, not evidence of one. So the client does not remember your proposal for you: you
present it again, and the check is against your copy.
Key order and whitespace do not matter. verify parses your bytes and canonicalises them
itself (RFC 8785), because the hash is defined over the canonical encoding and not over whatever
spacing your transport used. json.dumps(proposal), JSON.stringify(proposal) and a pretty-printed
copy of the same object all produce the same hash. What must match is the object: one changed
field value is a 9.3-3 refusal, correctly.
The proposal, in full
One shape, whichever rung you are on, and the gateway validates it against a published schema before anything else happens. Every field is required, and the two objects at the bottom are empty rather than absent when an action has no parameters: an optional field would give one action two encodings and therefore two hashes.
{
"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": {}
}
}schema_id is lower-case letters, digits, underscores and hyphens, at most 32 of them: a dot in
it is refused. schema_version is three numbers with dots between them. fidelity is F-HIGH
or F-LOW, it is restamped by the door from what the door itself verified, and your copy of it
decides nothing. 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.
A body that misses any of that is refused as ProposalMalformed before your tenant, your
policy or your action is looked at. That refusal is the schema disagreeing with your bytes; it
is not a verdict on the action.
Line 3 can return before the receipt exists, and a high-risk action is when it does. An action your policy grades at or above its HIGH floor is not signed on the strength of the grading alone: it needs a quorum of approvers first. So the API answers immediately, and the answer is
status: "decided" outcome: "ATTEST" receipt: absentATTEST is the gate, not a refusal. It says the decision reached the attestation stage and
approvers are being presented with it. Do not treat it as a DENY and do not re-propose: the
proposal is already in flight, and a second one is a second action. The receipt is signed once the
quorum forms and attaches to the same decision on a later GET /v1/decisions/{id}.
wait returns at the decision; the receipt is a further poll. wait stops when status
reaches decided, and on this path status is already decided in the answer to your POST.
So it returns at once, carrying ATTEST and no receipt. Poll GET /v1/decisions/{id} yourself
until receipt is present, then run line 5. There is no helper for that in the SDK today, and
wait is not it. Watch the receipt, not the outcome: outcome stays ATTEST on that
decision, and the receipt appearing is the change.
Measured rather than described, and measured on a laptop: on 2026-09-03 a run of
a rehearsal drove the twelve services in a local docker compose stack on one machine, from a
real sandbox tenant's deployed configuration tree, and got
decided/ATTEST/no receipt from the POST with the receipt attached to the next GET
within a second, recorded in a dated transcript we keep.
A deployed control plane is still untried. This sentence said "against a live sandbox
deployment" until 2026-09-05, which was a second and incompatible description of one run.
It is a fact about that stack on that day; your quorum is formed by whoever your
bundle enrols, and people are slower than robots.
On a deployment with no notification account, the quorum path does not complete the way this section describes, and a sandbox is such a deployment today. The Running a sandbox tenant guide states exactly what happens and what the receipt you can read does and does not prove.
5. What verify refuses, and what a refusal means
Every refusal carries a clause id. That id is the machine-readable half; the message beside it is for a human and is not part of the contract. Both SDKs and the Rust engine spell every id in the table below identically, and that is asserted rather than asserted-about: one shared corpus of vectors is run by the Python test suite and by the TypeScript one, over the same inputs. Where an implementation of this checklist still differs is stated under the table rather than left for you to discover.
| the receipt is refused when | clause |
|---|---|
| it is not a version-3 receipt | AB-0 |
| its signed body is over the size cap | AB-6 |
| its suite is not one this build knows, or it declares none | CR-1 |
your ZIFFER_SUITE_FLOOR is not a suite this build knows | CR-1 |
its suite does not contain every primitive of your ZIFFER_SUITE_FLOOR | CR-4 |
| any signature primitive fails, the composition being conjunctive and never "any" | 9.3-1 |
| its decision is not one this domain defines | 9.3-2 |
| the bytes you passed are not UTF-8 JSON at all | AT-8a |
| it is not bound to the proposal you passed | 9.3-3 |
| your clock says it has expired, or its window is implausibly long | 9.3-5 / L-14 |
| its nonce is not a well-formed 128-bit value | WE-4 / L-17 |
Two places where an id is not spelled identically everywhere. Neither changes a verdict.
Both are recorded in the corpus above, pinned from both sides so they cannot move without a test
going red, and both are facts about engine pin fed43d1 on 2026-09-09 rather than for ever.
- An unknown suite name. The SDKs and the Rust engine say
CR-1. The ACP reference implementation (the Pythonacp_executor.pythe specification ships) saysCR-4, because its floor test answers "no" for a name it does not recognise instead of refusing it as unrecognised.CR-4asks whether one suite contains another's primitives, and an unregistered name names no primitives at all, so the SDKs followCR-1: "an unregistered or unknown suite MUST fail closed". Closing the gap is a change in four places in the engine, and it is open. - A receipt wrong in more than one way. Each verifier stops at the first check it reaches,
and the two SDKs do not order the floor check and the signature-shape check the same way. A
receipt that is both below your floor and carrying a signature whose primitives are not its
declared suite's is
CR-4from the Python SDK and9.3-1from the TypeScript one (the Rust engine agrees with TypeScript). Both refuse; only the name differs.
A refusal is not a network error, and you should not retry it. A receipt that fails one of these fails it deterministically; asking again gets the same answer with an extra round trip. Treat it as you would a failed authorisation: stop, and record the clause.
The one refusal in this section that is worth retrying, and it is not verify's
| the decision is refused when | clause | what you do |
|---|---|---|
| the receipt's policy basis is not the bundle the Executor holds — the window right after you publish policy | 9.3-4 | retry once, after the activation window. This is not a policy refusal |
Everything above this row is your own verify refusing a receipt in your process. This one is
the deployment refusing, and it has a cause you can plan around: activation is not
instantaneous. When your CI publishes a new policy, each process that reads policy picks it up
on its own poll — 15 seconds by default — so for up to two intervals, 30 seconds at the
default, an Executor can still hold epoch N while the engine has already graded under N+1.
The receipt then names a policy basis the Executor does not have, and it refuses at 9.3-4
rather than executing under a rule set it cannot see. That is the control working.
Two things follow, and the second is the one people get wrong:
- Retry once, after the window. The same proposal under the settled epoch gets a receipt that verifies. A single retry after 30 seconds is the whole handling.
- Do not treat it as
ALLOWand do not treat it as a rule saying no. It is neither: it is two halves of the deployment being momentarily one epoch apart.policy-ci.mdsection 7 has the publisher's side — your workflow printsactivewhen every reader has the new bundle, and that is the moment the window closes.
9.3-4 outside that window means something else: an Executor that never picked up a bundle, or
a receipt from a different deployment. If it persists after a publish has printed active,
stop and report it with the receipt.
What verify does not check is as important as what it does. It runs the stateless half
of the checklist: the part answerable from a receipt, a proposal and a key. It does not and
cannot check that this receipt has not already been used, because that is a claim against a
durable ledger and there isn't one in your process. Section 6.
6. What this does not give you yet
Read this section twice. Everything above is true and none of it adds up to "the SDK stops your agent".
-
verifyreturns; it does not intervene. It is a function that raises or returns a value. If your handler calls it and ignores the result, the action runs. If your handler never calls it, the action runs. Nothing in this library sits between your agent and its credentials. That is rung 3's shape, and even there it is a separate process you route through, not a hook that catches you. The gate is a line of your code, and you have to write it. -
No replay protection in-process. The claims that make a receipt single-use are made against a ledger inside our deployment perimeter. Your process cannot reach it. So a receipt your handler verified once will verify again, and again: if an attacker can get your handler to run twice with the same receipt,
verifywill not be what stops them. Make your own execution idempotent, on your own key, the way you would without us. -
The MCP server routes; it does not enforce. The MCP server (section 9) lets a coding agent ask ZIFFER things and read this guide while it writes your integration. It runs on the developer's machine, at development time, and it has no hands on your production path. MCP alone routes the question; only the SDK's
verifyline enforces the answer. An agent that has talked to the MCP server has not thereby been gated by anything. -
The trust anchor's freshness is yours to manage. The SDK reads the key file you point it at and has no idea whether that key was rotated last week. There is no revocation channel and no expiry on the anchor itself. Rotation today is: we tell you, you replace the file.
-
Nothing here is an audit trail. Keeping the receipt is your side of it. The SDK does not store, forward or log receipts, and a verified receipt you threw away is a check you can no longer show anyone.
-
A sandbox is a separate tenant, not a dry run. If you were given sandbox credentials, they are a different API key and a different trust anchor, because a sandbox tenant signs its receipts under its own identity, which is exactly what makes a sandbox receipt fail your production
verify(clause9.3-1) rather than needing a flag somebody could forget. Its decisions run the real path and are approved by a robot with no human in it, so anALLOWthere means the path worked, never that anyone agreed. And it changes nothing about your code: your handler still performs the action, so a sandboxALLOWhanded to a handler that transfers money transfers money. The Running a sandbox tenant guide states what the sandbox marker does and does not guarantee; the integration itself is the same in both, which is the point of having one.
7. Python
Install ziffer, published on PyPI at version 0.1.0, and read three environment variables.
wait polls
GET /v1/decisions/{id} until the status leaves pending or the timeout elapses.
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"], # ziffer pubkey output
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, # the principal the action runs for
"targets": ["bank-api"],
"params": {"amount": amount, "to_account": to_account},
"cidrs": {},
},
}
decision = client.wait(client.propose(proposal).decision_id, timeout=30.0)
if decision.outcome != "ALLOW":
raise PermissionError(f"ziffer refused: {decision.clause}")
try:
verify(decision.receipt, json.dumps(proposal).encode(), anchor)
except RefusedError as refusal:
# refusal.name is the clause id -- log it, do not retry it.
raise PermissionError(f"receipt refused: {refusal.name}") from refusal
bank.transfer(amount, to_account) # your line, unchangedjson.dumps is fine here. verify parses the bytes and canonicalises them itself, so key
order and spacing are not your problem. The SDK does not export a canonicaliser and does not need
to.
A ValueError from verify is not a refusal. If the bytes are not JSON at all, verify
raises ValueError, deliberately: a broken integration must not be mistaken for a caught attack.
Only RefusedError means the receipt did not hold.
Catch RefusedError, not Exception. A refusal means the receipt did not hold. A
ConnectionError from propose means you never got one. Collapsing the two into a single
except turns "our gateway was briefly unreachable" into "the policy denied this", and you will
debug the wrong thing.
8. TypeScript
All four npm packages are published, at 0.1.0 (2026-09-14): @ziffer-io/client,
@ziffer-io/verify, @ziffer-io/types and @ziffer-io/mcp. npm install @ziffer-io/client is
the whole of it; install.md section 4 has the install for every machine. The Python package is
on PyPI at the same version: pip install ziffer.
@ziffer-io/client re-exports the verifier, so there is one verifyReceipt in your
dependency tree rather than two implementations that can disagree. canon comes from
@ziffer-io/verify, its own home. The client re-exports the verification surface it needs you
to have, not the whole of somebody else's package.
import { ZifferClient, verifyReceipt, Refusal, type TrustAnchor } 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 loadAnchor(env('ZIFFER_TRUST_ANCHOR'));
async function transfer(amount: number, toAccount: string): Promise<void> {
const proposal = {
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.wait(submitted.decision_id, { timeoutMs: 30_000 });
if (decision.outcome !== 'ALLOW') {
throw new Error(`ziffer refused: ${decision.clause}`);
}
try {
verifyReceipt(decision.receipt, canon(proposal), anchor);
} catch (error) {
if (error instanceof Refusal) {
// error.clause is the clause id -- log it, do not retry it.
throw new Error(`receipt refused: ${error.clause}`);
}
throw error;
}
await bank.transfer(amount, toAccount); // your line, unchanged
}process.env is string | undefined, so the three values go through env. The client's
constructor takes strings; passing the environment straight in is a type error under the settings
this repository builds with, and an as string there would be a second definition of the wire
type. The helper throws at start, where a missing variable is a deployment mistake you can see.
instanceof Refusal, never a cast. A catch binds unknown, and the honest reason to
narrow rather than assert is that this catch also sees the errors you did not plan for. An
as Refusal here would read a clause off a TypeError and report a protocol refusal that
never happened.
canon here, json.dumps in the Python example, and both are correct. verifyReceipt parses
your bytes and canonicalises them itself, so any JSON spelling of the same object gives the same
hash. canon is used here only because @ziffer-io/verify exports it; new TextEncoder().encode( JSON.stringify(proposal)) verifies identically. Bytes that are not UTF-8 JSON at all are refused
under AT-8a, which is a bug in your call rather than a verdict on the receipt.
9. The MCP-assisted path
If you are writing this integration with a coding agent, you can give the agent direct access to this guide and to a live decision loop:
npx @ziffer-io/mcpThat starts a local MCP server over stdio with five tools, and no others: get_integration_guide (this
document, by language), propose and check_decision (the live loop, against whatever
ZIFFER_API_URL you configure), explain_receipt (hand it a receipt and the proposal bytes
and it tells you valid or the named clause), and sandbox_status (whether the tenant id you
are sending is a sandbox, and whether one decision is still waiting on its robot approver).
It is configured by the same environment variables as the SDK, and it starts without them.
Tools that need a value return a named error saying which variable to set, rather than the
server dying during startup where an agent cannot see why. get_integration_guide needs nothing
at all, so an agent can read this document before any credential exists.
Two things it deliberately does not have. There is no approve tool and no simulated approval:
an approval minted on a developer's laptop is a fake receipt factory, which is precisely what this
product exists to prevent. sandbox_status is not one: a ZIFFER sandbox is a separate tenant
approved by a service inside our deployment, under keys your machine does not hold, so that tool
reports and approves nothing. And there are no file-writing tools: the server serves
knowledge and ZIFFER-side calls; your coding agent edits your code.
To say it once more, because it is the sentence most easily lost: MCP alone routes the
question; only the SDK's verify line enforces the answer. An integration written with the
MCP server's help is gated by exactly the lines of verify that ended up in your handler.
10. Checklist
- Your API key is in
ZIFFER_API_KEYand is not in your source tree. - You know the date your key expires, and it is written down somewhere that will remind you. The SDK warns for the last fourteen days, once per process and only while calls still succeed, and an expired key then answers exactly like a key that never existed.
- Your rotation is overlap-then-revoke: new key deployed and serving traffic BEFORE the old one is revoked.
- No request body sets a
tenant_idother than your key's. The key is the tenant. -
ZIFFER_TRUST_ANCHORpoints at aziffer pubkeydocument you obtained out of band, not at a signing key and not at something the API served you. -
ZIFFER_SUITE_FLOORis set to a floor you chose. - Your handler calls
verifyand branches on it. Grep for the call; then grep for theif. Section 6, item 1. - Your execution is idempotent on your own key. Section 6, item 2.
- You keep the receipt somewhere you can produce it later.
- You have read section 6 and know which six things this does not do.
- If you were given sandbox credentials, your production configuration still points at your production key and anchor. Section 6, item 6.
- You know where to write when something is refused, and what to put in the report. The support guide has the addresses, the severities, and a row per refusal in section 5 above saying what it means and who moves next.
Synced from ziffer docs/onboarding/sdk.md at 32bc4db; edit the source, never this page.