Docs Start here

KIFF Cards

A KIFF Card is what you give one AI agent: which actions it may take, under what conditions, how much over what period, until when, and what happens to a call past that. The agent asks KIFF before each action, and KIFF answers from its Cards. You change a Card without touching the agent, and every draw and change is on its statement. For examples, see Cards in practice.

Every other check in KIFF asks whether one action is allowed: is the entity in the right state, does this actor have the permission, are the parameters valid, does this need an approval. A card carries the answer to the one question no single action can answer, what has this agent already done, and is that enough?

An action can be correct in every way and still be the one that should not run, because of the twenty that came before it. That is what a card catches.

Why cards exist

As agents make consequential work cheap, approving each action by hand becomes the limit on how much of it they can do. A card is how an owner delegates instead of supervising: defined actions, the conditions the domain already enforces, and a bounded total, which the owner can widen as the record earns it or withdraw at once.

It is closer to a licence than to a payment card or an access badge. It counts any consequence you can count, not only money, and a limit bounds exposure without making an invalid action valid. That stays the domain’s job.

What a card is

A KIFF Card, a mandate in the API, the package and the schema, is a bounded, revocable instrument issued to one named agent:

refund-agent may run REFUND_ORDER, only while the order is PAID, up to a total of 500000 summed over the amount_cents parameter, each calendar day, until it is revoked.

What it says:

  • Who holds it: one agent by default. It must match the actor_id the agent already sends when it proposes an action. Two agents doing the same work usually hold two cards, each with its own balance. A card can also be shared with up to 20 agents: every holder draws on the same totals, and each decision still names the agent that asked. One agent may hold several cards, see below, where the tightest binds.
  • What it may do: the actions it covers. An action you leave out is still governed by the contract; it just does not draw on this card.
  • Under what conditions: the entity states the grant is confined to. Leave them unset and the action’s own states apply, which is every state the domain already allows it in. A card narrows; it never widens.
  • What it may act on: optionally, named values of one string parameter the action declares: refund only orders 1043 and 1044. A call on any other value, or with none, is outside the card, held or refused like a call over a limit. It never counts as uncovered, which would let it through. Up to 100 exact values; leave it unset for any value.
  • How much, over what period: a quantity, either count (how many times those actions are authorized) or sum(<parameter>) (the total of one numeric parameter the actions declare), with a ceiling and a window: each calendar day, any rolling 24 hours, any rolling hour, or the Card’s whole life (lifetime, a total that never resets; see Limits).
  • What each limit measures: attempts or effects. The default is attempts for count, effects for sum(<parameter>). Each limit may override its default; its statement shows the effective measure.
  • When it stops: an optional expiry, or the moment an owner revokes it.

When a card is required

By default a card constrains without conferring: an agent with no card covering an action is unaffected, and the check is a no-op. That is what lets you adopt cards one agent at a time without the first one breaking something already running.

An action opts out of that by declaring requires_authority in the domain:

actions:
  - name: REFUND_ORDER
    allowed_states: [PAID, PARTIALLY_REFUNDED]
    requires_authority: true

For that action, no card is a refusal rather than a no-op:

{
  "outcome": "blocked",
  "reasons": ["no_mandate_issued"]
}

That reason is its own, deliberately. The first integrator to meet it has wired everything correctly, and a generic blocked would send them to audit their domain, their permissions and their state machine before discovering that nothing has been issued yet.

It is declared per action, not per tenant: the author of the domain knows which consequences are worth an instrument, and a tenant-wide switch would make that judgement for every action at once.

Which key can use a card

A card names its holder, and the proposal names its actor in actor_id. So that one agent cannot name another and spend its card, the API key has to be issued for the holder:

  • A key issued for an agent acts only as that agent. A proposal from it naming anyone else is refused with actor_not_key_holder, on every action.
  • Drawing on a card needs such a key. A key not issued for any agent is refused with key_not_bound_to_agent on an action that requires a card, and on any action where the named agent holds a card that covers it, even if the card is optional there. It keeps working for actions no card covers.

“Create a key for this agent” on a card’s page issues the right key: the role the job needs, bound to the card’s holder. For a card scoped to an environment, the key is also bound to that environment. Its requests are decided there whether or not they name it, and a request naming another environment is refused, so the key cannot draw on a card for a different environment by asking for it.

If the agent already has its own key, for example the one issued when it was added to watch in observe, the card’s page offers to give that key the job’s role instead of issuing a second one. The agent keeps the key it has. Roles are only ever added to a key: to take one away, revoke the key and issue another. Through the API this is PATCH /v1/keys/{id} with {"add_roles": [...]}, under the same rules as creating a key: only an owner or admin may do it, and never a role the caller does not hold.

A test request sent from the card’s page while you are signed in is not held to any of this; it is you, the owner, exercising the card you issued.

What happens at the ceiling

Every Card says what happens to a call outside it, and both routes let calls inside the Card run:

  • Stop (on_exceed: refuse): the call is refused. Nothing is sent to the tool, and the agent is told why.
  • Ask (on_exceed: owner, the default): the call is held for an authorized approver. Nothing is sent until someone who may approve says yes. A hold nobody answers before it expires is refused; silence never approves. An approval is not the tool’s result: the tool can still refuse or fail, and Needs you shows what it reported.

At a cumulative ceiling (a total over a window) the action cannot run on either route: approval alone cannot waive it.

“At the ceiling” means the proposed action would exceed what the card has left for the window, not that the card is empty. A €2,000 refund against €1,000 of remaining authority is refused, and a €500 one right after it is allowed, same card, same window, same reason code for the first.

The owner must revise the budget explicitly to add room. Approve once waives only a reviewed per-action threshold. It still draws the full quantity against every applicable cumulative limit.

With Stop, a proposal the Card cannot cover comes back as:

{
  "outcome": "blocked",
  "reasons": ["mandate_limit_reached"],
  "message": "this action exceeds the remaining authority under mandate refund-agent-card: sum(amount_cents) limit 500000 per calendar_day reached: 300000 used, this action draws 250000"
}

The engine’s own words in that message are the precise rule: 300000 used of 500000, and an action drawing 250000 does not fit, €2,000 of authority is still on the card for something smaller.

If an agent needs more room, you raise the ceiling: one deliberate act, recorded with who made it, rather than approving past it one action at a time. That is the same decision, made visible and attributable instead of ambient.

Some properties worth knowing

A card narrows; it never confers. It selects from what the domain and its permissions already allow and caps how much of it happens. It cannot grant an action the domain does not have, or legalise one in a state it forbids. This is the opposite of a capability system.

Several cards can cover one action, and the tightest binds. Each is checked; the first that refuses decides. There is no precedence rule to reason about, and no way to widen an agent’s authority by adding another card.

A card that cannot be read refuses. If the ledger is unreachable, the action is blocked rather than allowed, because allowing means acting on authority nobody could confirm. Cards marks such a balance unknown rather than showing a comforting zero, an unused card and an unreadable one are very different facts.

A revoked card refuses; it does not disappear. Revoking does not return the agent to “unbounded” for an action that requires one, and re-issuing the same name does not quietly bring it back: a proposal under a revoked card is blocked with mandate_expired, and its statement stays readable.

Draws are recorded when an action is authorized. A retry of the same operation does not draw twice. An attempt limit retains that draw even when the tool refuses. An effect limit can release it once, only when a trusted executor proves a final no-effect outcome for that exact operation. A generic error, timeout, partial effect or absence from a lookup is not that proof. Without trusted outcome reporting, the draw stays charged.

The original draw remains unchanged. A separate release record names the operation, evidence, source, reason and original limit terms. Release cannot reopen an operation, restore a revoked Card, or create credit in another window or timebase. See the executor outcome contract.

A limit binds the approved path too. When an operator grants an approval, the re-decision runs the same check. Approval does not buy authority.

The domain’s observe posture

A card is checked even when the domain is in observe. In that posture the decision is advisory and the caller continues its existing flow; the authorization still draws on the card, whether or not the caller ultimately executes it. Without a trusted executor outcome, the statement records authorized authority rather than confirmed spend.

Counting it anyway is exactly what lets you see what your agent would have drawn before you enforce anything. That is the recommended on-ramp: issue a card deliberately high, run for a week, read the statement, and set the real ceiling from your own traffic instead of from a guess.

(This is the domain’s observe posture, which still calls the decide API. Guard’s own observe mode is a different thing: it watches an agent’s tool calls without asking KIFF at all, so nothing is decided and no card is drawn.)

The statement

A card’s History → Statement in the dashboard shows the ceiling, how much has been drawn in the current window, what remains, and every movement that got it there.

A movement names what it was for, REFUND_ORDER on ord_8812: carries how much it drew, and says how much of that limit’s window had been consumed once it landed. Where a card caps the same quantity over more than one window, each subtotal names its own: five an hour and twenty a day are both true of the same movement.

Two things the statement deliberately does not do:

  • It never claims execution. Every movement is an authorization. The original draw records KIFF’s decision. A tool outcome and an effect release are separate evidence, not a rewrite of that decision.
  • It never lists refusals. A refusal moved no balance, so it is not a movement. Refusals live in the domain’s activity, which the card links to, filtered to its holder.

The same numbers are in the API. GET /v1/me/mandates/{id}/statement returns one card’s statement. GET /v1/me/mandates?include=use lists every card you can see with its limits filled in: for each limit, limit, used, remaining (extra room included) and window_started in the current window. A day’s limit also has window_end, the next midnight in the card’s time zone (location), when it starts again. A rolling or lifetime limit never starts again, so it has none. These are the same lines as the statement, so an app can show “€212 of €500 today” without one call per card. Each limit’s quantity is also sent as data, quantity_of: {kind, parameter} (kind is sum or count), beside the quantity string (“sum(amount_cents)”), so an app reads it without parsing. Every summed limit, in the list and the statement, carries the unit and scale its action declares, so 20000 with unit EUR and scale 2 reads €200.00. An agent key lists only the cards it holds.

History → Changes is the card’s record of issuance: what its ceiling was at each issuance, and who changed it. Re-issuing a card overwrites the row it lives in, so this is the only record of what the authority used to be.

Changing a card

Limits on the card’s page changes its terms. A change is a re-issue under the same name: draws already recorded still count, so raising a ceiling adds only the difference, and the change is recorded with who made it. Extra room adds temporary room, for this window by default. Day boundaries, extra room and budget warnings are on Limits, warnings and extra room.

Setting one

Give an agent a Card on a connected tool, in the dashboard, walks through the agent, how much and how often, and what happens past that (see the quickstart). Cards → Issue a card starts from a job, such as refunding paid orders, and sets the rules from its template. Its advanced form offers the actions your domain declares, the entity states each of them allows, and only the parameters it types as a number, so a quantity that cannot be summed is not offered in the first place.

Or over HTTP, with a management credential:

curl -sS -X POST https://api.kiff.dev/v1/me/mandates \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "id": "refund-agent-card",
        "title": "Refunds on paid orders",
        "subject": "refund-agent",
        "domain": "card-refund",
        "grants": [{"action": "REFUND_ORDER", "entity_states": ["PAID"],
                    "resources": {"parameter": "order_id", "values": ["ord_1043", "ord_1044"]}}],
        "aggregates": [
          {"quantity": "sum(amount_cents)", "limit": 500000, "window": "calendar_day"}
        ],
        "valid_until": "2027-01-01T00:00:00Z"
      }'

resources is optional. Leave it out and the grant covers any order_id. A reissue that leaves it out removes the limit, so send the grant as it stands when you change other terms.

title is optional too: the card’s name for people, plain text of at most 60 characters. A reissue that leaves it out keeps the current title. To rename a card, send PUT /v1/me/mandates/{id}/title with {"title": "Refunds on Stripe"}, or "" to clear it. A title changes none of the card’s terms and isn’t recorded as a revision.

Issuing and revoking require a management identity, the signed-in account owner or an admin. An agent’s own runtime key is refused. A credential that could grant itself authority would make every card advisory.

Starting an agent from a draft

A runtime that starts agents for you, such as an app’s backend, shouldn’t hold a key that can issue Cards or mint keys. It submits a draft instead: a new agent and its Cards, which grants nothing.

  1. Submit. POST /v1/me/agent-drafts with {"agent_id", "label", "cards": [...]}. Each Card is held by the new agent alone, and the agent must not hold Cards yet. Any credential on the account can submit one, an agent’s key included. The answer carries the draft’s version and a claim_secret, shown once.
  2. Confirm. A person who may issue Cards and mint keys confirms the exact version they saw: POST /v1/me/agent-drafts/{id}/confirm with {"version"}. Confirming issues the Cards. When such a person submits the draft, from the dashboard or from their phone (signed), it is confirmed at once. While its Cards are being issued, the draft is confirming: it can’t be claimed or discarded. It becomes confirmed only once every Card is issued. If a Card fails to issue, the draft is rolling_back and can’t be claimed. It becomes failed once every Card it issued is revoked; if that can’t finish at once, KIFF keeps retrying.
  3. Claim. Whoever holds the claim secret calls POST /v1/agent-drafts/{id}/claim with Authorization: Bearer <claim_secret>. KIFF mints the agent’s gateway key then, bound to the agent, and returns it once. The key goes straight to whoever claims it and is never stored in plain text. If the person who confirmed has since lost the right to give keys, the claim is refused.

What the owner reads. A draft says its Cards in the owner’s words, so every app shows KIFF’s own and none builds them. card_title names it by its label (“Stripe refunds’ Card”). Each Card has lines, such as “Refund a payment: up to \(40 each", "\)300 in total a day” or “20 calls a day”. Each action is named by its tool’s title, and an amount is in the unit its tool declares. A number with no unit says “(no currency)”. Where two tools on the Cards read alike, each line adds its action. A Card the draft revises carries its terms now as current, with their own lines, and so does each Card in retires.

To show those words before anything is submitted, POST /v1/me/agent-drafts/preview takes the same body as a submit. It’s checked the same way and answers as the submitted draft would read (card_title, and cards with their lines), but stores nothing, confirms nothing and returns no claim secret. A person’s session may call it without the phone’s signature.

A draft expires after 24 hours. DELETE /v1/me/agent-drafts/{id} discards one that hasn’t been claimed, and revokes any Cards it issued. If a revoke fails, the draft stays discarding, can’t be claimed, and the answer is 503: call it again, or KIFF finishes it.

Changing an agent’s Cards. A draft for an agent that already holds Cards is a revision ("revision": true on the draft). Each Card it names with an id the agent holds is that Card’s next revision, and its usage so far is kept. A Card with a new id is added. The draft shows, on each revised Card, revises and the current terms. A revision mints no key and has no claim secret. Confirming it issues every Card or none. It is refused with 409 and "reason": "card_revised" if one of the Cards it revises changed after the draft was made: draft it again. Two routes make a revision that only waits for the owner: POST /v1/me/agent-drafts/restore with {"agent_id", "revisions": [{"id", "revision"}]} brings back the terms those Cards had at those revisions. It also retires the agent’s other Cards that it holds alone: the draft lists them as retires, each with its terms now. Confirming revokes them in the same step as the restored Cards are issued, so tools and limits go back together. POST /v1/me/agent-drafts/{id}/redraft submits an expired, failed or stale revision’s Cards again, and retires what it retired.

Trying a version. The owner can try a revision before installing it: POST /v1/me/agent-drafts/{id}/confirm with {"version", "as": "trial"}. KIFF issues the version’s Cards as trial Cards. They have their own ids and counters, starting from zero, and the agent’s Cards stay as they are. The draft is trying. A call through the gateway with the header KIFF-Trial: <draft id> is then sent, and decided on the trial Cards alone. Calls without the header never see them, so the live version keeps its Cards and limits. Each call made in the trial is recorded with trial_draft. One trial runs per agent: trying another version ends the first. DELETE /v1/me/agent-drafts/{id}/trial ends a trial, and the draft waits again. The agent’s own key may end its own trial. Confirming the version with {"version"} installs it and ends its trial in the same step. In every case the trial Cards are revoked, and a call held under them is refused. A trial nobody ends lapses after 30 days. GET /v1/me/agent-drafts/{id}/trial says whether the draft is being tried, how its trial ended, and its calls, allowed, held and refused.

What this is not

  • Not a payment card. No money moves through KIFF. A payment card knows a merchant and an amount; a KIFF Card knows that this order is refundable, and counts whatever the consequence is, three DELETE_CUSTOMER an hour is the same instrument as €5,000 a day.
  • Not a rate limit. A rate limit protects your infrastructure from volume. A card bounds consequence: what an agent is permitted to do, in the units the action is measured in.
  • Not a spend tracker. Nothing here reads your ledger or your bank. It counts what KIFF authorized, and says so on the statement.
  • Not portable. A card is enforced by your KIFF tenant. Signing one so a third party could verify it independently is a later question.

See also

  • Decisions, the proposal flow a limit is checked in, and the outcomes it can produce.
  • Govern, the evidence view per action, and the observe/enforce posture a limit is checked under either way.
  • Held calls and approvals, what happens to a call outside the card, and how the owner answers.