Skip to documentation
Documentation

Keiki Vault

How agents open web cards and ask for Face ID approvals inside Messages with the Keiki Vault iOS app.

Keiki Vault is Keiki's own iMessage app (App Store bundle com.keiki.vault, Messages extension com.keiki.vault.messages). Every agent on Keiki uses the same app: there is no per-agent build, and a card opens inside Messages without opening the full app.

It does two things for an agent:

CardWhat it showsWho sends it
Presentation cardYour HTTPS page in a web view.A tool that returns presentation {type: "app"}.
Approval cardA native card with Approve and Decline, both behind Face ID.Keiki, for a tool with policy.approval: "ask".

The recipient installs Keiki Vault once from the App Store. Keiki picks the line, recipient and credentials; a tool never passes them.

Where it works

Cards, presentation and approval alike, are sent only from a dedicated Keiki iMessage line (a dedicated iMessage line bought through Keiki), in a 1:1 chat with the contact. Imported, shared and pool lines, group chats and other channels get no card:

CardOn any other line or channel
PresentationNothing is sent. The tool result's presentationDelivery is unavailable with reason line_not_eligible (or unsupported_channel off iMessage or in a group); imessage_send_app returns line_not_eligible.
ApprovalThe approval is asked in words, or refused for verified-only tools. See When no card can be sent.

Presentation cards also need the Dynamic UI add-on (feature dynamicUi; the older imessageApps key is an alias). See iMessage Apps. Approval cards do not need the add-on.

Presentation cards

Send one

Any non-builtin tool (webhook, code, capability, or MCP structuredContent) can return a presentation beside its normal result:

JSON
{
  "status": "ready",
  "url": "https://your-app.example/checkout/123",
  "presentation": {
    "type": "app",
    "id": "checkout-123",
    "url": "https://your-app.example/checkout/123",
    "caption": "Review your order",
    "subcaption": "2 items · $48.00"
  }
}
FieldRule
idRequired, 1–128 characters. The same tool, id and url in one conversation send once; a retry gets the first delivery back. Use a new id for a new card.
urlRequired absolute https: URL, up to 2,048 characters, no user:password@.
liveOptional boolean, default false.
captionOptional, 1–60 characters. The bubble shows Open app without one.
subcaptionOptional, 1–80 characters. The bubble shows Tap to open without one.

Keiki adds presentationDelivery (accepted, unavailable, failed or unknown) to the result the model reads. The full table, the imessage_send_app builtin, and choice / confirm presentations are in iMessage Apps. Keep the URL in the result so the agent can send it as a link when the card is not accepted.

How it renders

Tapping the bubble opens the card expanded and loads url in a web view.

  • The card URL must be https: with a host and no credentials, or the card shows as invalid.
  • Keiki appends #kv=<token> to the URL's fragment (other fragment parts are kept). The token names this one send for telemetry. A fragment never reaches your server; do not depend on kv.
  • A main-frame HTTP status of 400 or more, or a failed load, shows the load error and is reported as card.load_failed.
  • live: true draws the page in the transcript bubble itself. Transcript views are not interactive, and a tap does not open the expanded card, so keep live off for any page with buttons or forms.
NavigationResult
https: link, same origin as the card (scheme, host and port; 443 by default)Loads in the card.
https: link to another originOpens outside Messages.
target="_blank" / window.open to the same originLoads in the card.
https: iframe or subresourceAllowed.
tel:, mailto:, sms:Opens the system app.
Anything else (http:, javascript:, custom schemes)Blocked.

Put the whole flow on one origin. A sign-in or payment step on another origin leaves the card.

Updating a sent card

Keiki stores the card session for each accepted send in presentation_intents.card_session and can rewrite that same bubble in place with updateApp: no second message. Keiki uses this for approval cards when they settle (see below). A tool cannot edit its own card; to show something new, return a presentation with a new id.

Telemetry

The extension posts events to POST /api/v1/vault/events, keyed only by the per-send token:

EventWhen
card.openedThe card was opened.
card.dwell_msTime spent on the card.
card.load_failedThe page failed to load.
card.nav_bouncedA navigation bounce.
approval.tapped, approval.biometric, approval.signed, approval.failedSteps of an approval.

Events carry no page content, URLs or recipient identity. Keiki logs approval.decided itself when a decision lands.

Face ID approvals

A tool with policy.approval: "ask" never runs on the model's say-so. In a direct iMessage conversation on an eligible line, Keiki sends an approval card instead of asking in words. Approval policy fields are in Governed actions.

What the recipient sees

The bubble reads Approval needed with the tool's name under it. Tapping it opens a compact native card above the keyboard, so the chat stays visible:

  • your vendor card if the tool has one, otherwise the tool name and the preview or argument lines;
  • a Runs <toolName> footer that Keiki draws;
  • exactly two buttons, Decline and Approve. Both run Face ID.

Decline is signed too, so a forwarded card cannot cancel the contact's action from another phone.

What happens on a tap

  1. The extension reads the card: POST /api/v1/vault/approval with { "token": "<kv token>" } returns actionId, status (pending or settled), summary, description, preview, toolName, card, expiresAt and enrolled.

  2. Face ID unlocks a P-256 key held in the phone's Secure Enclave (bound to the current Face ID enrollment). It signs:

    plaintext
    keiki-vault-approval-v1
    <actionId>
    <approve|decline>
    <sha256 hex of the card token>
    <signedAt, epoch ms>
  3. The extension posts POST /api/v1/vault/decide with token, decision, signedAt, publicKey (base64 X9.63), signature (base64 DER ECDSA/SHA-256) and keyStorage.

  4. Keiki checks the signature, the clock (within 5 minutes), the enrolled key, and the tool's requiredScopes, then decides the action. The stored call runs through the action lifecycle (pending → executing → completed | failed) with context.approval.decidedVia set to keiki_vault.

/decide errorStatusMeaning
not_found404Unknown or expired token. The card shows "This request has expired."
not_pending409The action is no longer pending.
signature_required, bad_signature401No valid signature.
signature_expired401signedAt is more than 5 minutes off the server clock.
confirm_device_key403Signed by a key other than the enrolled one. confirmText holds a code the contact sends in the chat; see Key enrollment.
requires_speaker_scopes409The contact lacks the tool's requiredScopes.

GET /api/v1/vault/approval (the card URL opened in a browser or on a Mac) only says to open the approval in Keiki Vault on an iPhone. It cannot approve.

Key enrollment

Keys are trust on first use, per API key and phone number. The first valid signed decision from a contact enrolls their key for that API key; after that only that key can decide. A different key (a new phone, or a key lost when Face ID was re-enrolled) is held back: the card shows a six-digit code, and once the contact sends it from their own number in the chat, within 10 minutes and 5 attempts, the new key replaces the old one. The contact then approves again.

When no card can be sent

The card is only sent on a dedicated Keiki iMessage line in a 1:1 chat. Elsewhere, or when the send fails, the agent asks in words and the contact's reply decides through action_respond. Two policy fields change that:

PolicyEffect
approvalProof: "verified"A reply in words cannot approve (it can still decline). With no card or button to show, approvalFallback decides.
approvalFallback: "refuse" (default)The tool does not run; the model gets approval_unavailable.
approvalFallback: "tool"The tool runs without context.approval, so its own approval flow applies, such as returning a link to approve in your web app.

approvalFallback: "tool" is how to keep a browser approval link as the fallback: when the call arrives without context.approval, return your link instead of acting.

Preflight

A bad call should fail before the contact sees Face ID, not after. Webhook tools can set policy.preflight: true (requires approval: "ask"; other tool types are rejected with 400). Before the card is shown, Keiki calls the webhook once with context.preflight: true:

JSON
{
  "input": { "to": "ana@example.com", "subject": "Invoice" },
  "context": { "phone": "+15551234567", "agentId": "…", "preflight": true }
}

Validate the input and return 2xx without acting. Any non-2xx answer goes back to the model as status: "preflight_rejected" with the first 2,000 characters of your body, and nobody is asked. Only enable it if your server handles context.preflight; otherwise the check runs the call for real.

After the decision

  • The bubble is rewritten in place: Approved, Declined or Didn't go through.
  • If freshnessFields changed since the proposal, the action is superseded and the same card reloads with the new details.
  • If an approved action fails, the agent gets a turn with the error, so it can tell the contact and propose a fixed call. It never claims success.

Set it up through the API

Create a webhook tool with an approval policy, preflight and a vendor card. An organization-wide key also passes agent_id:

Shell
curl -X POST -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_id": "<agent-id>",
    "name": "send_email",
    "description": "Send an email for the user",
    "inputSchema": {
      "type": "object",
      "properties": {
        "to": {"type": "string"},
        "subject": {"type": "string"},
        "body": {"type": "string"}
      },
      "required": ["to", "subject", "body"]
    },
    "executionType": "webhook",
    "webhookUrl": "https://mail.example/keiki/send",
    "policy": {
      "approval": "ask",
      "approvalProof": "verified",
      "preflight": true,
      "card": {
        "v": 1,
        "blocks": [{"kind": "email", "to": "$.to", "subject": "$.subject", "body": "$.body"}],
        "theme": {"approveLabel": "Send", "declineLabel": "Don'\''t send"}
      }
    }
  }' \
  https://<your-host>/api/v1/developer/tools

Change it later with PATCH /api/v1/developer/tools/:id and a policy body. Send the whole policy: it replaces the stored one. An invalid policy is a 400 naming the field, for example policy.card.theme.accent must be a #RRGGBB colour. SDK tools set the same policy beside execute in defineTool, and deployments set it per entry in tools[].