ZIFFER home

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.0

The Python SDK is the package ziffer on PyPI:

pip install ziffer

The scanner, @ziffer-io/scan, and the MCP server, @ziffer-io/mcp, run with npx and need no install:

npx @ziffer-io/scan@0.3.0

The 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.

VariableWhat it is
ZIFFER_API_URLThe base URL of the decision API.
ZIFFER_API_KEYYour 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_ANCHORA 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_FLOORThe 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.

one proposal
{
  "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.

your MCP client's configuration
{
  "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

transfer.py
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, unchanged

wait_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

transfer.ts
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.

submit
curl -X POST "$ZIFFER_API_URL/v1/proposals" \
  -H "Authorization: Bearer $ZIFFER_API_KEY" \
  -H "Content-Type: application/json" \
  -d @proposal.json
read the decision, and the receipt when it exists
curl "$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: absent

ATTEST 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.

Python
verified = verify(decision.receipt, json.dumps(proposal).encode(), anchor)
TypeScript
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.

Next

  • Concepts: the objects, and what each one is not.
  • Refusals: every refusal name and what it means.

On this page