Skip to content

Each recipe is one policy (or a small set) to copy into .swytchcode/integrations/policies.json. Replace the targets with your own canonical IDs - swy list tooling shows them - and run swy policy validate after every change.

Most recipes use features of version 2, so the file starts with "version": 2:

{
"version": 2,
"policies": [
...
]
}

Every field and operator used here is described in the policies.json reference.


Email and messaging

Block personal email domains on every provider

$email_domains holds the domain of every email address anywhere in the request, so one policy covers every email and messaging tool.

{
"version": 2,
"lists": { "personal_domains": ["gmail.com", "yahoo.com", "outlook.com", "hotmail.com"] },
"policies": [
{
"id": "no-personal-email",
"target": ["*"],
"when": { "field": "$email_domains", "operator": "in", "value": { "$list": "personal_domains" }, "ignore_case": true },
"action": {
"type": "POLICY_BLOCKED",
"message": "Emails to {{$email_domains}} are not allowed",
"hint": "Use the customer's business email address"
},
"tests": [
{ "tool": "sendgrid.mail.send", "args": { "to": "[email protected]" }, "expect": "block" },
{ "tool": "sendgrid.mail.send", "args": { "to": "[email protected]" }, "expect": "allow" }
]
}
]
}

Only send to internal recipients

not_in on a list of recipients matches when any address is outside the list.

{
"id": "internal-only",
"target": ["sendgrid.mail.send", "slack.chat.postMessage"],
"when": { "field": "$email_domains", "operator": "not_in", "value": ["acme.com", "acme.io"] },
"action": { "type": "POLICY_BLOCKED", "message": "Messages may only go to acme.com addresses" }
}

Limit recipients per message

{
"id": "max-recipients",
"target": ["sendgrid.mail.send"],
"when": { "field": "$emails", "transform": "count", "operator": ">", "value": 10 },
"action": { "type": "POLICY_BLOCKED", "message": "At most 10 recipients per email" }
}

No personal data or secrets in outgoing messages

{
"id": "no-sensitive-data-out",
"target": ["slack.*", "sendgrid.*"],
"when": {
"operator": "any",
"conditions": [
{ "field": "$strings", "operator": "contains_pii", "value": ["card", "ssn", "iban"] },
{ "field": "$strings", "operator": "contains_secret" }
]
},
"action": { "type": "POLICY_BLOCKED", "message": "The message contains card numbers, ID numbers, or secrets" }
}

Detectors catch common formats, not all of them. See Detectors.

Limit emails per day

{
"id": "daily-email-limit",
"target": ["sendgrid.mail.send"],
"when": { "operator": "rate", "window": "24h", "max": 500 },
"action": { "type": "RATE_LIMITED", "message": "Daily limit of {{$limit}} emails reached; try again in {{$retry_after}}s" }
}

Always add a compliance BCC

{
"id": "compliance-bcc",
"target": ["sendgrid.mail.send"],
"when": { "field": "$tool", "operator": "exists" },
"action": { "type": "MODIFY", "add": { "bcc": "[email protected]" }, "message": "compliance copy added" }
}

Payments

Maximum amount and allowed currencies

These two work in version-1 files too.

[
{
"id": "max-payment",
"target": ["stripe.payment_intent.create"],
"when": { "field": "amount", "operator": ">", "value": 100000 },
"action": { "type": "POLICY_BLOCKED", "message": "Payments above 1000.00 are blocked" }
},
{
"id": "allowed-currencies",
"target": ["stripe.payment_intent.create"],
"when": { "field": "currency", "operator": "not_in", "value": ["usd", "eur"] },
"action": { "type": "POLICY_BLOCKED", "message": "Only USD and EUR payments are allowed" }
}
]

Daily spend cap

sum adds up amount over the window, counted separately per currency.

{
"id": "daily-spend-cap",
"target": ["stripe.payment_intent.create", "stripe.charge.create"],
"when": { "operator": "sum", "field": "amount", "window": "24h", "max": 500000, "per": ["currency"] },
"action": { "type": "QUOTA_EXCEEDED", "message": "Daily spend of {{$sum}} {{currency}} would exceed {{$limit}}" }
}

Approval above an amount, reused for a day

After one refund to a customer is approved, more refunds for that customer go through for 24 hours.

{
"id": "big-refunds",
"target": ["stripe.refund.create"],
"when": { "field": "amount", "operator": ">", "value": 50000 },
"action": { "type": "REQUIRES_APPROVAL", "message": "Refunds over 500.00 need approval" },
"reuse": { "for": "24h", "same": ["customer"] },
"approval_timeout": "2h"
}

See Human approval.

Refund only charges the agent created

{
"id": "refund-own-charges",
"target": ["stripe.refund.create"],
"when": {
"operator": "not",
"conditions": [{
"field": "charge",
"operator": "returned_by",
"value": { "tools": ["stripe.charge.create"], "field": "id" },
"window": "30d"
}]
},
"action": { "type": "POLICY_BLOCKED", "message": "Only charges created by this agent can be refunded" }
}

No duplicate payment within 10 minutes

{
"id": "no-duplicate-payment",
"target": ["stripe.payment_intent.create"],
"when": { "operator": "repeat", "window": "10m", "max": 1, "per": ["customer", "amount"] },
"action": { "type": "POLICY_BLOCKED", "message": "Same payment to the same customer within 10 minutes" }
}

Data safety

Read-only agent

on_no_match: "deny" blocks every call no ALLOW policy matches. Switch it to "monitor" first to see what would be blocked.

{
"version": 2,
"defaults": { "on_no_match": "deny" },
"policies": [
{
"id": "reads-only",
"target": ["*"],
"when": { "field": "$http_method", "operator": "==", "value": "GET" },
"action": { "type": "ALLOW" }
}
]
}

No deletes in production

{
"id": "no-prod-deletes",
"target": ["*"],
"when": {
"operator": "all",
"conditions": [
{ "field": "$http_method", "operator": "==", "value": "DELETE" },
{ "field": "$env", "operator": "==", "value": "production" }
]
},
"action": { "type": "POLICY_BLOCKED", "message": "Deletes are disabled in production" }
}

No bulk operations

$item_count is the length of the largest list in the request.

{
"id": "no-bulk",
"target": ["hubspot.*"],
"when": { "field": "$item_count", "operator": ">", "value": 50 },
"action": { "type": "POLICY_BLOCKED", "message": "At most 50 records per call" }
}

Cap page size instead of failing

{
"id": "cap-page-size",
"target": ["stripe.customer.list"],
"when": { "field": "limit", "operator": ">", "value": 100 },
"action": { "type": "MODIFY", "set": { "limit": 100 }, "message": "limit capped at 100" },
"tests": [
{ "args": { "limit": 500 }, "expect": "modify", "modified_args": { "limit": 100 } }
]
}

Limit records read per day

Each call adds the number of items its response returned.

{
"id": "daily-read-limit",
"target": ["hubspot.contact.list"],
"when": { "operator": "sum", "field": "$response_items", "window": "24h", "max": 5000 },
"action": { "type": "QUOTA_EXCEEDED", "message": "At most 5000 contacts can be read per day" }
}

Hide card numbers, ID numbers, and tokens from the agent

The response is changed before the agent - and the audit log - sees it.

{
"id": "hide-sensitive-response-data",
"target": ["*"],
"stage": "response",
"when": { "field": "$status_code", "operator": "<", "value": 300 },
"action": { "type": "REDACT", "detect": ["card", "ssn", "iban", "secret"] }
}

To hide specific fields instead, use "fields": ["$response.data[*].email"].


Code and infrastructure

No pushes or merges to main, and only one organization

[
{
"id": "protect-main",
"target": ["github.pull.merge.update", "github.git.refs.update"],
"when": { "field": "ref", "operator": "matches", "value": "^(refs/heads/)?main$" },
"action": { "type": "POLICY_BLOCKED", "message": "Changes to main go through a reviewed pull request" }
},
{
"id": "only-acme-org",
"target": ["github.*"],
"when": { "field": "owner", "operator": "not_in", "value": ["acme"], "ignore_case": true },
"action": { "type": "POLICY_BLOCKED", "message": "Only repositories in the acme organization" }
}
]

Deploy only after the tests passed

{
"id": "deploy-after-tests",
"target": ["vercel.deployment.create"],
"when": {
"operator": "not",
"conditions": [{ "operator": "called_before", "value": "github.action.dispatches.create", "window": "1h" }]
},
"action": { "type": "POLICY_BLOCKED", "message": "Run the test workflow before deploying" }
}

called_before only remembers calls that got a 2xx response.

Only delete sandboxes the agent created

{
"id": "delete-own-sandboxes",
"target": ["e2b.sandbox.delete"],
"when": {
"operator": "not",
"conditions": [{
"field": "sandbox_id",
"operator": "returned_by",
"value": { "tools": ["e2b.sandbox.create"], "field": "sandbox_id" },
"window": "7d"
}]
},
"action": { "type": "POLICY_BLOCKED", "message": "Only sandboxes this agent created can be deleted" }
}

Network

Webhook and callback URLs only to allowed hosts

$hosts holds the host of every URL anywhere in the request. The list lives in a file so it can be reviewed separately.

{
"version": 2,
"lists": { "allowed_hosts": { "file": "lists/allowed_hosts.txt" } },
"policies": [
{
"id": "allowed-callback-hosts",
"target": ["stripe.webhook_endpoint.create", "github.hook.create"],
"when": { "field": "$hosts", "operator": "not_in", "value": { "$list": "allowed_hosts" } },
"action": { "type": "POLICY_BLOCKED", "message": "{{$hosts}} is not an allowed host" }
}
]
}

.swytchcode/integrations/lists/allowed_hosts.txt:

# one host per line
hooks.acme.com
api.acme.com

Time

Set defaults.timezone so times are read in your zone.

Business hours and weekdays

$time is HH:MM, so it is compared as text with matches.

{
"id": "business-hours",
"target": ["stripe.payout.create"],
"when": {
"operator": "any",
"conditions": [
{ "field": "$weekday", "operator": "in", "value": ["saturday", "sunday"] },
{ "field": "$time", "operator": "matches", "value": "^(0[0-8]|1[89]|2[0-3]):" }
]
},
"action": { "type": "POLICY_BLOCKED", "message": "Payouts run Monday to Friday, 09:00-18:00" }
}

Change freeze

The policy applies only between the two dates, then stops. swy policy validate warns once it has expired.

{
"id": "year-end-freeze",
"target": ["github.*", "vercel.*"],
"active_from": "2026-12-20",
"expires_at": "2027-01-02",
"when": { "field": "$http_method", "operator": "!=", "value": "GET" },
"action": { "type": "POLICY_BLOCKED", "message": "Change freeze until January 2" }
}

Customer separation

An agent working for one customer touches only that customer’s records. The customer ID comes from an environment variable set where the agent runs; if it isn’t set, calls stop.

{
"id": "one-customer-only",
"target": ["stripe.*"],
"when": { "field": "customer", "operator": "!=", "value": { "$env_var": "AGENT_CUSTOMER_ID" } },
"action": { "type": "POLICY_BLOCKED", "message": "This agent may only act for its own customer" }
}

Environment and caller

Stricter over MCP: force a dry run for agents

{
"id": "agents-dry-run-payments",
"target": ["stripe.payment_intent.create"],
"when": { "field": "$caller", "operator": "==", "value": "mcp" },
"action": { "type": "MODIFY", "dry_run": true, "message": "agents can only preview payments" }
}

Tool access

The agent may only call these tools

{
"version": 2,
"defaults": { "on_no_match": "deny" },
"policies": [
{
"id": "allowed-tools",
"target": ["hubspot.contact.get", "hubspot.contact.list", "sendgrid.mail.send"],
"when": { "field": "$tool", "operator": "exists" },
"action": { "type": "ALLOW" }
}
]
}

Volume and loops

Rate limit per tool, and stop an agent repeating itself

A rate limit counts calls from every process on this machine, so it also holds across several agents running side by side.

[
{
"id": "crm-rate-limit",
"target": ["hubspot.*"],
"when": { "operator": "rate", "window": "1m", "max": 60, "per": ["$tool"] },
"action": { "type": "RATE_LIMITED", "message": "At most 60 calls a minute per tool" }
},
{
"id": "stop-loops",
"target": ["*"],
"when": { "operator": "repeat", "window": "5m", "max": 3 },
"action": { "type": "POLICY_BLOCKED", "message": "The same call was made 3 times in 5 minutes", "hint": "Stop and tell the user what went wrong" }
}
]

Compliance

PCI, GDPR deletes, and EU hosts

{
"version": 2,
"lists": { "eu_hosts": ["api.eu.example.com", "eu.api.example.com"] },
"policies": [
{
"id": "pci-no-card-numbers",
"target": ["*"],
"when": { "field": "$strings", "operator": "contains_pii", "value": ["card"] },
"action": { "type": "POLICY_BLOCKED", "message": "Raw card numbers may not be sent" }
},
{
"id": "gdpr-deletes-need-approval",
"target": ["hubspot.contact.delete", "intercom.contact.delete"],
"when": { "field": "$http_method", "operator": "==", "value": "DELETE" },
"action": { "type": "REQUIRES_APPROVAL", "message": "Deleting personal data needs approval" }
},
{
"id": "eu-hosts-only",
"target": ["*"],
"when": { "field": "$host", "operator": "not_in", "value": { "$list": "eu_hosts" } },
"action": { "type": "POLICY_BLOCKED", "message": "{{$host}} is outside the EU" }
}
]
}

Alerts

Allow, but tell a channel

A monitor-mode policy never blocks; with notify, each match also posts a message.

{
"id": "watch-large-refunds",
"target": ["stripe.refund.create"],
"mode": "monitor",
"when": { "field": "amount", "operator": ">", "value": 20000 },
"action": { "type": "POLICY_BLOCKED", "message": "Large refund" },
"notify": {
"tool": "slack.chat.postMessage",
"args": { "channel": "#payments", "text": "Refund of {{amount}} on charge {{charge}}" }
}
}

Monitor mode is also the safe way to try any new rule on real traffic: matches show as monitored in swy audit policy.


Upkeep

  • Temporary rules: set expires_at (see Change freeze).
  • Tests in CI: add tests to each policy and run swy policy validate in CI. See Tests.
  • Provider updates: swy sync, swy get, and swy add warn when a policy uses a field a tool no longer has.