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:
| Card | What it shows | Who sends it |
|---|---|---|
| Presentation card | Your HTTPS page in a web view. | A tool that returns presentation {type: "app"}. |
| Approval card | A 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:
| Card | On any other line or channel |
|---|---|
| Presentation | Nothing 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. |
| Approval | The 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:
{
"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"
}
}| Field | Rule |
|---|---|
id | Required, 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. |
url | Required absolute https: URL, up to 2,048 characters, no user:password@. |
live | Optional boolean, default false. |
caption | Optional, 1–60 characters. The bubble shows Open app without one. |
subcaption | Optional, 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 onkv. - 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: truedraws the page in the transcript bubble itself. Transcript views are not interactive, and a tap does not open the expanded card, so keepliveoff for any page with buttons or forms.
Navigation inside the card
| Navigation | Result |
|---|---|
https: link, same origin as the card (scheme, host and port; 443 by default) | Loads in the card. |
https: link to another origin | Opens outside Messages. |
target="_blank" / window.open to the same origin | Loads in the card. |
https: iframe or subresource | Allowed. |
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:
| Event | When |
|---|---|
card.opened | The card was opened. |
card.dwell_ms | Time spent on the card. |
card.load_failed | The page failed to load. |
card.nav_bounced | A navigation bounce. |
approval.tapped, approval.biometric, approval.signed, approval.failed | Steps 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
-
The extension reads the card:
POST /api/v1/vault/approvalwith{ "token": "<kv token>" }returnsactionId,status(pendingorsettled),summary,description,preview,toolName,card,expiresAtandenrolled. -
Face ID unlocks a P-256 key held in the phone's Secure Enclave (bound to the current Face ID enrollment). It signs:
plaintextkeiki-vault-approval-v1 <actionId> <approve|decline> <sha256 hex of the card token> <signedAt, epoch ms> -
The extension posts
POST /api/v1/vault/decidewithtoken,decision,signedAt,publicKey(base64 X9.63),signature(base64 DER ECDSA/SHA-256) andkeyStorage. -
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) withcontext.approval.decidedViaset tokeiki_vault.
/decide error | Status | Meaning |
|---|---|---|
not_found | 404 | Unknown or expired token. The card shows "This request has expired." |
not_pending | 409 | The action is no longer pending. |
signature_required, bad_signature | 401 | No valid signature. |
signature_expired | 401 | signedAt is more than 5 minutes off the server clock. |
confirm_device_key | 403 | Signed by a key other than the enrolled one. confirmText holds a code the contact sends in the chat; see Key enrollment. |
requires_speaker_scopes | 409 | The 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:
| Policy | Effect |
|---|---|
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:
{
"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
freshnessFieldschanged 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:
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/toolsChange 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[].