Skip to documentation
Documentation

Vault approval cards

Describe what a Face ID approval shows in Keiki Vault with policy.card templates or a card from your preview tool.

A Face ID approval shows the tool name and plain preview lines by default. A vendor card replaces those lines with your own layout: an email preview, a refund summary, a receipt. You describe it as JSON; Keiki Vault draws it natively.

Vendor cards only render in Keiki Vault. Slack, the dashboard and approvals asked in words keep the plain lines.

Two ways to provide a card

SourceWhen to use it
policy.cardA static template filled from the call's arguments.
card in the result of policy.previewToolYour server builds the finished card, with data the arguments don't carry (balances, names, images).

A valid preview card wins over the template. A preview with no card, or one Keiki cannot parse, falls back to the template; a template that cannot render falls back to plain lines. A preview field named card that is not card-shaped (no v plus a blocks array) stays ordinary preview data.

Templates and $. paths

A string value that starts with $. is a path into the call's arguments: $.subject reads args.subject, $.input.to reads args.input.to, $.items.0.name reads the first item. The whole string must be the path; "To $.to" is literal text.

Argument valueShown as
stringas is
number, booleanString(value)
arrayitems joined with , (empty items skipped)
{ "email": "...", "name": "..." }Name <email> (or the email alone)
other objectits JSON
missing, null, emptythe line is dropped

Paths work in block text, field values, email parts, image url / alt, and theme.logoUrl / logoDarkUrl. Field labels, accent, approveLabel and declineLabel are literal. A preview card is finished: $. strings in it are not filled.

Snapshot at proposal

Keiki renders the card when the model proposes the call and stores it on the action. Later edits to policy.card, or a changed preview, do not change a card already sent. A freshnessFields change supersedes the action, and the replacement gets a fresh card.

Schema

TypeScript
type  = {
  : 1
  : []
  ?: {
    ?: string // #RRGGBB, tints the Approve button
    ?: string // https, 40×40
    ?: string // https, used in dark mode; falls back to logoUrl
    ?: string // default "Approve"
    ?: string // default "Decline"
  }
}

type  =
  | { : 'header'; : string; ?: string }
  | { : 'fields'; : <{ : string; : string }> }
  | { : 'text'; : string }
  | { : 'email'; ?: string; ?: string; ?: string; ?: string; ?: string }
  | { : 'image'; : string; ?: string }
  | { : 'note'; : string }
  | { : 'divider' }
BlockRenders asNotes
headerLogo, bold title, secondary subtitle.title is required; if it resolves empty the card is invalid.
fieldsLabel/value rows.A field with no value is dropped.
textBody text with inline Markdown.
emailFrom, To, Cc rows, bold subject, body.Needs at least one part with a value.
imageImage up to 220 pt tall; alt if it can't load.A url that resolves empty drops the block.
noteOrange text with a warning icon, inline Markdown.Use for caveats ("This can't be undone").
dividerA rule.A card with only dividers is empty.

Limits

LimitValueOver the limit
Blocks1–20Invalid
Fields per fields block20Invalid
header, field labels, alt200 charactersTruncated with …
text, note, field values, email body8,000 charactersTruncated with …
Email from / to / cc / subject1,000 charactersTruncated with …
approveLabel, declineLabel1–24 charactersInvalid
accent#RRGGBBInvalid
Image and logo URLshttps: onlyInvalid

Validation and fallback

  • Saving a tool validates policy.card; an invalid template is a 400 such as policy.card.blocks must be 1-20 blocks.
  • A preview card, or a template that fails at proposal (a required value resolves empty, a resolved image URL is not https:), shows the plain lines instead.
  • Unknown block kinds are skipped, by the server and by the app, so a newer block degrades on an older app.
  • A card with no block that has a value falls back to plain lines.

What vendors cannot change

Keiki draws the parts that make an approval safe:

  • Exactly two buttons, Decline and Approve, both behind Face ID and signed by the phone's enrolled key. You can rename them; you cannot add, remove or restyle them beyond accent.
  • A Runs <toolName> footer under your card, with the real tool name.
  • What runs: the stored call, not anything in the card. The card describes the call; the call's arguments are what execute.

Examples

Email send

A send_email tool whose arguments are { "connectionId": "...", "input": { "to": [{ "name": "Axel", "email": "axel@0.email" }], "cc": [], "subject": "Hello", "message": "Hi Axel" } }:

JSON
{
  "approval": "ask",
  "approvalProof": "verified",
  "card": {
    "v": 1,
    "blocks": [
      { "kind": "header", "title": "Send email", "subtitle": "$.input.subject" },
      {
        "kind": "email",
        "to": "$.input.to",
        "cc": "$.input.cc",
        "subject": "$.input.subject",
        "body": "$.input.message"
      }
    ],
    "theme": {
      "accent": "#1F3A93",
      "logoUrl": "https://mail.example/logo.png",
      "logoDarkUrl": "https://mail.example/logo-dark.png",
      "approveLabel": "Send",
      "declineLabel": "Don't send"
    }
  }
}

The card the contact sees (empty cc is dropped):

JSON
{
  "v": 1,
  "blocks": [
    { "kind": "header", "title": "Send email", "subtitle": "Hello" },
    { "kind": "email", "to": "Axel <axel@0.email>", "subject": "Hello", "body": "Hi Axel" }
  ],
  "theme": {
    "accent": "#1F3A93",
    "logoUrl": "https://mail.example/logo.png",
    "logoDarkUrl": "https://mail.example/logo-dark.png",
    "approveLabel": "Send",
    "declineLabel": "Don't send"
  }
}

Refund from a preview tool

The arguments only carry { "chargeId": "ch_3Px", "amount": 4800 }, so the card comes from the preview tool, which can look the charge up:

JSON
{
  "approval": "ask",
  "previewTool": "preview_refund",
  "freshnessFields": ["amount"]
}

The model calls preview_refund with the same arguments first (Governed actions). It returns its preview data with a card:

JSON
{
  "amount": "$48.00",
  "customer": "Ana Ruiz",
  "card": {
    "v": 1,
    "blocks": [
      { "kind": "header", "title": "Refund $48.00", "subtitle": "Ana Ruiz · ana@example.com" },
      {
        "kind": "fields",
        "fields": [
          { "label": "Charge", "value": "ch_3Px · Oct 21" },
          { "label": "Card", "value": "Visa •••• 4242" },
          { "label": "Original", "value": "$96.00" },
          { "label": "Refund", "value": "$48.00 (partial)" }
        ]
      },
      { "kind": "image", "url": "https://shop.example/receipts/ch_3Px.png", "alt": "Receipt for order 1042" },
      { "kind": "divider" },
      { "kind": "note", "text": "Refunds take 5–10 days to reach the card and **can't be undone**." }
    ],
    "theme": {
      "accent": "#635BFF",
      "logoUrl": "https://shop.example/logo.png",
      "approveLabel": "Refund $48",
      "declineLabel": "Keep charge"
    }
  }
}

Set either policy with POST or PATCH /api/v1/developer/tools; see Set it up through the API.