Playbooks

How a playbook matches a signal, what each of the 13 action types changes, and how to build one in the dashboard.

Last reviewed September 18, 2026Fresh

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 typeWhat it changesResult fields
webhook_callbackPOSTs a signed JSON envelope to the action's url, or to the organization's saved callback URLstatus, queued_for_retry, latency_ms
slack_webhookPosts a Block Kit message to the registered Slack destination named by webhook_refstatus, latency_ms
email_alertEmails the group named by recipient_scope through Resendrecipients_count, status, response_status
log_eventRecords the chosen severity in the action result and nothing elseseverity, latency_ms
suspend_accountDeactivates every active agent delegate on the organization and sets its trust score to 0suspended_delegates, status
revoke_tokensTears down every open BLAST tunnel session on the organizationsessions_torn_down, status
revoke_sessionsRevokes A2A negotiations in active, negotiated or attested statenegotiations_revoked, status
freeze_credentialsInvalidates every valid cached trust proof on the organizationproofs_invalidated, status
suspend_agentsDeactivates active delegates, clears their scopes and stamps scope_narrowed_atagents_suspended, status
step_up_authDeactivates the organization's active shadow identitiesidentities_deactivated, status
quarantineRevokes cross-references, invalidates cached proofs and deactivates shadow identitiescross_refs_revoked, proofs_invalidated, shadows_deactivated
create_support_ticketOpens a support ticket on the organization with the signal type recordedticket_id, status
resolve_support_ticketCloses open tickets that a matching signal type openedresolved_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, with narrowing_reason recording Hard signal: <type>;
  • every A2A negotiation in active, negotiated, attested or pending state is set to status = 'revoked', trust_state = 'auto_revoked', combined_trust_coefficient = 0, transaction limits zeroed and allowed_scopes emptied.

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_alert needs 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_webhook needs a Slack destination picked from the registered list, or added inline with Add a Slack destination (a channel name plus an incoming webhook URL starting https://hooks.slack.com/, encrypted server-side and never shown again, up to 25 per organization).
  • webhook_callback needs 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.

Related from the blog