Playbooks
How a playbook matches a signal, what each of the 13 action types changes, and how to build one in the dashboard.
Overview
A playbook is a named set of response actions stored on your organization and run by the ingest pipeline when an inbound signal's event_type matches the playbook's trigger. It is the link between a signal arriving at POST /v1/shield-ingest/{tenant_id} and something happening: an outbound webhook to your systems, a Slack or email alert, or a change to PasskeyBridge objects your organization owns.
Who can use it. Playbooks are available on every plan. No function checks a plan before matching or running one, and the plan pages' playbook counts are terms rather than an enforced limit. They are created and switched on from the dashboard with a signed-in session: Dashboard > Playbooks > New playbook. There is no playbooks API key scope and no public endpoint that manages playbooks; the wizard writes the row directly under your session (tenant admins hold insert and update on the table), and the activation switch calls the console-only shield-admin-mutations action toggle_playbook.
What runs, and when. Exactly one playbook matches per signal: the oldest active one whose trigger matches. Its actions all start together and each gets its own 8-second budget, so total time is the slowest action rather than the sum. Every result is written to the actions_executed JSONB array on the shield_events row, one entry per action carrying type, status and latency_ms. A signal that matches nothing is still stored and answered with result: "no_matching_playbook".
What the actions reach. The enforcement actions change PasskeyBridge objects that belong to your organization: agent delegates, A2A negotiations, cached trust proofs, shadow identities, cross-references and BLAST tunnel sessions. None of them touches your own user accounts, sessions or registered passkeys. webhook_callback is the action that reaches your systems, so anything account-level happens in your backend when that call arrives.
Signal matching
A validated signal asks for one playbook. The lookup runs inside the shield_ingest_context database function, in the same round trip as the organization, API key and rate-limit reads, and is equivalent to:
SELECT * FROM shield_playbooks
WHERE tenant_id = :tenant_id
AND (trigger_type = :signal_type OR signal_type = :signal_type)
AND is_active = true
ORDER BY created_at ASC
LIMIT 1;Oldest wins. ORDER BY created_at ASC LIMIT 1 means that when two active playbooks share a trigger, the older one runs on every signal and the newer one never does. To run more actions for a type, add them to the playbook that already matches.
Both columns are checked. The wizard writes your chosen event type to signal_type and trigger_type. trigger_type carries a column default of sim_swap, so a row inserted by hand without it also matches sim_swap signals.
The trigger is an exact string. There are no wildcards or patterns. event_type is validated before the lookup: 1 to 64 characters of letters, digits, underscore and hyphen, and anything else is refused with 422 Payload validation failed. The value is then stripped of any character outside that set before it reaches the query.
Matching does not depend on classification. Hard, soft and unrecognised types all match playbooks the same way. Classification decides what PasskeyBridge does on its own account, which is described under hard and soft signal response below.
Action types
Actions live in the actions JSONB array on the playbook row, each an object with a type and whatever configuration that type needs. Thirteen types are dispatched.
| Action type | What it changes | Result fields |
|---|---|---|
webhook_callback | POSTs a signed JSON envelope to the action's url, or to the organization's saved callback URL | status, queued_for_retry, latency_ms |
slack_webhook | Posts a Block Kit message to the registered Slack destination named by webhook_ref | status, latency_ms |
email_alert | Emails the group named by recipient_scope through Resend | recipients_count, status, response_status |
log_event | Records the chosen severity in the action result and nothing else | severity, latency_ms |
suspend_account | Deactivates every active agent delegate on the organization and sets its trust score to 0 | suspended_delegates, status |
revoke_tokens | Tears down every open BLAST tunnel session on the organization | sessions_torn_down, status |
revoke_sessions | Revokes A2A negotiations in active, negotiated or attested state | negotiations_revoked, status |
freeze_credentials | Invalidates every valid cached trust proof on the organization | proofs_invalidated, status |
suspend_agents | Deactivates active delegates, clears their scopes and stamps scope_narrowed_at | agents_suspended, status |
step_up_auth | Deactivates the organization's active shadow identities | identities_deactivated, status |
quarantine | Revokes cross-references, invalidates cached proofs and deactivates shadow identities | cross_refs_revoked, proofs_invalidated, shadows_deactivated |
create_support_ticket | Opens a support ticket on the organization with the signal type recorded | ticket_id, status |
resolve_support_ticket | Closes open tickets that a matching signal type opened | resolved_count, status |
The wizard offers eleven of them. create_support_ticket and resolve_support_ticket are dispatched by the pipeline but are absent from the action picker, so they only appear in playbooks written straight to the table.
An unknown type is recorded, never guessed. A type the dispatcher does not recognise returns {"type": "<value>", "status": "unsupported", "latency_ms": 0} and nothing runs.
Test traffic suppresses the outbound three. On a request carrying x-pb-test-mode: true or authenticated with a pb_test_ sandbox key, email_alert, slack_webhook and webhook_callback record skipped_test_mode or skipped_sandbox and send nothing. The enforcement actions in the table above still run for real, and so does hard-signal revocation.
Each action is bounded. An action that has not settled within 8 seconds is abandoned and recorded as {"status": "timeout", "error": "exceeded 8000ms"}; the other actions are unaffected.
Webhook delivery and retries
webhook_callback is the only action that reaches your own systems. The envelope is built from the validated ingest body with raw identifiers removed:
{
"event": "sim_swap",
"phone_hash": "9f2c4a...64-char-hex",
"subject_ref": "your-opaque-handle-or-null",
"tenant_id": "YOUR_TENANT_ID",
"timestamp": "2026-09-16T14:30:00.000Z",
"metadata": { "risk_score": 0.94, "your_field": "passed through" }
}metadata echoes the fields you sent, minus phone, phone_number, msisdn, email, ip, ip_address and credential_sha1, which are stripped before delivery and before the delivery row is written.
Signing. When the organization has a callback signing secret, the body is signed with HMAC-SHA-256 and the hex digest is sent as x-pb-signature. Deliveries made by the retry worker also carry a detached ML-DSA proof in x-pb-pqc-* headers; the first, inline attempt carries the HMAC alone. The verification recipe is in Outbound webhook signatures.
One attempt inline, the rest on a queue. Attempt 1 runs inside the ingest request with its own 5-second deadline so a slow receiver cannot consume the 8-second action budget. A 429, a 5xx, a network error or that deadline hands the delivery to shield_webhook_queue, and the action result comes back with queued_for_retry: true and status: "retrying". Any other 4xx is terminal and is never queued. Two edges since 2026-09-18: when the DNS resolvers PasskeyBridge checks your hostname against are unreachable, the delivery is queued with no attempt spent (status: "blocked_dns", queued_for_retry: true) and the worker makes all four attempts; and a 2xx whose response body could not be read is a success, not a retry, because your endpoint acknowledged it.
The worker finishes it. shield-webhook-worker runs on a cron every 5 minutes, claims up to 50 queued deliveries, and makes attempts 2, 3 and 4 with exponential backoff capped at 30 seconds and a 15-second deadline each. Because the retries outlive your request, a retried delivery arrives minutes later. Acknowledge quickly and do the work afterwards.
Every attempt is logged. shield_webhook_deliveries records the URL, the request body, the response status, the response body truncated to 2,000 characters, the attempt number against a maximum of 4, the status (success, failed or retrying), any error and the latency. The queue row carries the attempt already spent, so one delivery reads as a single sequence of four rather than restarting its count.
Hard and soft signal response
Besides playbook actions, the pipeline acts on its own account according to how the signal type is classified. This runs before the playbook actions, so revocation never waits on a Slack post and its counts are not pre-empted by an enforcement action in the same playbook.
Both paths need an identifier: the response only runs when the signal carried phone or phone_hash.
Hard signals revoke two subsystems inline, in parallel:
- every active agent delegate for the organization is set to
is_active = false,trust_score = 0, withnarrowing_reasonrecordingHard signal: <type>; - every A2A negotiation in
active,negotiated,attestedorpendingstate is set tostatus = 'revoked',trust_state = 'auto_revoked',combined_trust_coefficient = 0, transaction limits zeroed andallowed_scopesemptied.
The result is recorded as a parametric_revocation entry carrying revoked_agent_delegates, revoked_a2a_negotiations and trigger, and an audit row is written with action parametric_revocation.triggered. The nine hard types are sim_swap, sim_swap_detected, port_out, number_port, number_porting, device_compromise, ss7_intercept, account_takeover and scope_poisoning.
Shadow identities and BLAST tunnels are not touched by ingest. The four-subsystem cascade that adds them is the separate shield-cascade endpoint, described in CASCADE signal classification.
Soft signals dispatch a trust evaluation instead. One evaluation per organization per signal type per 30 seconds is sent to shield-agent-trust as evaluate_tenant, fire and forget; the action result is graduated_trust_evaluation with mode: "async", or mode: "debounced" when the window has not elapsed. The ten soft types are velocity_anomaly, behavioral_anomaly, geo_anomaly, credential_leak, credential_stuffing_attempt, suspicious_login, failed_verification, device_change, unusual_device and number_recycle.
Any other type is stored and matched against playbooks like the rest, and triggers neither revocation nor trust evaluation. test_signal, the type the dashboard's test button sends, is one of these: it is ordinary unrecognised traffic, and what suppresses its outbound actions is the test-mode header or the sandbox key, never the type name.
Industry templates
The template gallery reads playbook rows with is_template = true that belong to the reserved platform library organization 00000000-0000-0000-0000-000000000000. That restriction is an RLS policy, so a template flag on your own playbook makes it visible to nobody else.
The library is empty today. No template rows have been seeded, so the Playbooks tab shows "No templates yet." and step 1 of the wizard shows "No playbook templates are available yet." with a prompt to build from scratch. The recipes in Playbook recipes are the substitute: they list the trigger and the actions to enter by hand.
Cloning, when a template exists. Clone in the Playbooks tab copies the template's name, signal type, actions and industry into your organization with is_template = false and is_active = false, so you edit it and then switch it on. The copy is independent; later changes to the source do not reach it. The button reads Clone again when a playbook of the same name already exists.
Playbook Builder wizard
Dashboard > Playbooks > New playbook opens a four-step wizard. A draft is kept in the browser between visits, with URLs and addresses stripped out of it.
Step 1, "Create a playbook". The platform template gallery, or Start from scratch. With no templates seeded the gallery is a single line saying so.
Step 2, "Configure playbook". A Playbook Name, then the trigger, chosen from one of three places: the Trigger Signal grid of carrier and behavioural types, the Application Events grid of types your own backend posts (otp_failed, otp_verified, passkey_enrolled, passkey_login_succeeded, passkey_login_failed, auth_email_sent), or the Custom Event Type input, which takes lowercase snake_case of 3 to 64 characters and must match the event_type your backend sends. Configure Actions is disabled until a name and a valid trigger exist.
Step 3, "Add response actions". Each type can be added once. Three types need configuration before the step will pass, because without it the pipeline skips the action at run time:
email_alertneeds a Send to group, either Tenant admins or All tenant members. Addresses are resolved when the alert fires, so none is stored on the playbook.slack_webhookneeds a Slack destination picked from the registered list, or added inline with Add a Slack destination (a channel name plus an incoming webhook URL startinghttps://hooks.slack.com/, encrypted server-side and never shown again, up to 25 per organization).webhook_callbackneeds a Callback URL, or a callback URL already saved in Settings for the organization.
Step 4, "Review & activate". The summary lists the name, the trigger and the actions in order. Activate Playbook inserts the row, which is active immediately, and the playbook starts matching the next signal.
Versioning, audit and plan limits
Version. shield_playbooks.version defaults to 1 and nothing increments it. The Playbooks tab renders it beside the name, so every playbook reads v1 today. There is no history table and no stored diff of an edit.
Audit. Switching a playbook on or off calls shield-admin-mutations, which writes an audit row with action playbook.activate or playbook.deactivate, resource_type: "playbook" and the resource id. Creating and cloning write the playbook row directly under your session and leave no audit entry, so the created timestamp on the row is the record of those.
Plan limits. The pricing page describes one active playbook on Starter, ten on Pro and no stated limit on Enterprise. Those are commercial terms: no function counts playbooks, and neither the wizard nor the database refuses the eleventh.
Deactivation keeps the row. A paused playbook is excluded from matching by the is_active = true predicate and is otherwise untouched.
Confirm a playbook ran
The ingest response is the first answer: result is playbook_executed or no_matching_playbook, and actions_count and actions_failed_count say how many entries the event row carries and how many of them recorded an error.
In the dashboard, open Events. The row for the signal shows the event type, an Executed or No match badge, the decision and response times and the action count; the Live only and Test only filters separate real traffic from test traffic. Opening the row gives a drawer with the matched Playbook id, the Correlation id to match against the correlation_id in your ingest response, and every action with its status badge, its latency and its own detail fields (suspended_delegates, proofs_invalidated, queued_for_retry and so on).
For webhook_callback specifically, Webhooks lists each delivery attempt with the URL, the attempt number out of four, the response status and, expanded, the exact request body that was sent.
A playbook that matched but did nothing visible usually shows a skipped_ status on the action: skipped_no_url, skipped_no_webhook_ref, skipped_no_recipients, skipped_daily_cap, skipped_test_mode or skipped_sandbox each name the reason.