{"prompt":"## Skills\n\nSkills describe API endpoints, data structures, and field formats. **Read the relevant skill before making API calls** — the API has non-obvious required parameters, field encodings, and constraints that are not guessable from endpoint paths alone.\n\n- Journeys API (automations, segments, customers, templates, billing, analytics, etc.): cio skills read fly-api — then follow to the relevant sub-file\n- CDP Pipelines API (sources, destinations, syncs, reverse ETL): cio skills read cdp-api\n- Design Studio API (email nodes, components, global styles, publishing, accessibility): cio skills read design-studio/nodes or design-studio/journeys\n- Email QA / readiness (\"is this email ready to send?\", review before publish): cio skills read design-studio/email_review.md\n- Workflow playbooks (automation analysis, goal-based segments, mobile SDK geofencing, Liquid syntax, consultative analysis): cio skills read recipes — then follow to the relevant sub-file\n- New-account onboarding, Customer.io CLI setup and login, first app integration, Premium Live Notifications and Live Activities, and plan/billing questions: cio skills read cio — then follow to the relevant sub-file\n- Transactional sends from backend/app code (App API key, request shape, Design Studio-authored content, delivery verification): cio skills read fly-api/transactional_send.md\n\nUse `cio schema \u003cresource.method\u003e` for endpoint schemas and `cio skills read \u003cpath\u003e` for field-level details and gotchas.\n\n\u003cavailable_skills\u003e\n  \u003cskill name=\"Customer.io CLI Onboarding\" path=\"cio\"\u003eCustomer.io CLI path for new-account onboarding and app integration — use for agentic signup and login, recording how the user plans to use Customer.io, SDK/app wiring and event verification, Premium Live Notifications and Live Activities, first-asset setup, and plan/billing questions.\u003c/skill\u003e\n  \u003cskill name=\"Fly (UI) API Reference\" path=\"fly-api\"\u003eEndpoint reference for Customer.io's Fly API (ui_api) — automations, segments, customers, templates, deliveries, newsletters, A/B tests, goals, webhooks, collections, snippets, imports/exports, channels, billing and plan limits (how many profiles/people a plan includes, current usage against that allowance, overage), analytics, workspace settings, forms, tags, and audit logs.\u003c/skill\u003e\n  \u003cskill name=\"CDP (Pipelines) API Reference\" path=\"cdp-api\"\u003eCDP Data Pipelines API — sources, destinations, syncs, reverse ETL, database configs, and Journeys-native integrations (Segment, warehouse sync, ad audiences).\u003c/skill\u003e\n  \u003cskill name=\"Design Studio API Reference\" path=\"design-studio\"\u003eDesign Studio API — node CRUD, component properties, global styles, Journeys template linking, publishing, email review (QA findings + readiness score: is this email ready to send?), and accessibility checking.\u003c/skill\u003e\n  \u003cskill name=\"Recipes\" path=\"recipes\"\u003eNon-API workflow and language-reference playbooks — consultative analysis for subjective questions, automation performance analysis workflow, goal-based segment building, mobile SDK geofencing, and Liquid templating syntax. Cross-referenced from the API skills above.\u003c/skill\u003e\n\u003c/available_skills\u003e","skills":[{"path":"cio","name":"Customer.io CLI Onboarding","description":"Customer.io CLI path for new-account onboarding and app integration — use for agentic signup and login, recording how the user plans to use Customer.io, SDK/app wiring and event verification, Premium Live Notifications and Live Activities, first-asset setup, and plan/billing questions.","content":"# Customer.io CLI Onboarding\n\nCustomer.io CLI path for new-account onboarding and app integration — use for agentic signup and login, recording how the user plans to use Customer.io, SDK/app wiring and event verification, Premium Live Notifications and Live Activities, first-asset setup, and plan/billing questions.\n\nRead the file matching the task (`cio skills read cio/\u003cfile\u003e`):\n\n- **auth.md** - cio CLI authentication reference — service account tokens, OAuth exchange, JWT caching, region handling, signup flow, resume, troubleshooting.\n- **billing.md** - Billing, pricing, plan state, limits, invoices, and payment questions for Customer.io CLI users.\n- **integration.md** - Router for Customer.io app integration - choose web, backend, or mobile; scoped credentials; transactional sends; and event verification.\n- **integration_backend.md** - Customer.io backend integration - Node, Python, Go, HTTP CDP analytics sources, identify/track instrumentation, env vars, and transactional handoff.\n- **integration_mobile.md** - Customer.io mobile integration - iOS, Android, React Native, Expo, Flutter SDK setup, Live Notifications/Live Activities, screen events, push/in-app readiness, and backend transactional patterns.\n- **integration_web.md** - Customer.io web integration - prefer client-side cdp-analytics-js/browser setup, page/identify/track, in-app readiness, and backend transactional handoff.\n- **live_notifications.md** - Set up and troubleshoot Customer.io Live Notifications on Android and Live Activities on iOS, including native SDKs, React Native, Expo, Flutter, and backend lifecycle calls.\n- **onboarding.md** - New-account CLI/MCP onboarding entry point - use when a user is starting with Customer.io or setting it up for a project. Signup/auth, learning how the user plans to use Customer.io (shared onboarding answers), integration-first app wiring, and building a first asset. Not for existing paid-workspace operations.\n- **sdk_setup.md** - Quick-start install + initialization snippets for every Customer.io SDK (Expo, React Native, iOS, Android, Flutter, JavaScript, Node.js, Python, Go).\n- **sdk_troubleshooting.md** - Troubleshooting workflow for Customer.io SDK push, event tracking, in-app, deep links, iOS AppDelegate issues.\n","files":{"auth.md":"---\nname: auth\ndescription: cio CLI authentication reference — service account tokens, OAuth exchange, JWT caching, region handling, signup flow, resume, troubleshooting.\ncli_only: true\n---\n\n# Auth\n\nThe cio CLI authenticates with service account tokens (`sa_live_...`)\nexchanged for short-lived JWTs via OAuth 2.0 client credentials against the\nUI API at `fly.customer.io`.\n\nWhich flow:\n\n- Check `cio auth status` first. Working token → nothing to do.\n- No account yet → agentic signup below.\n- Account exists but no usable token (UI signup, lost/rejected token,\n  broken mid-session) → *Agent-driven login* below.\n\n## Invariants\n\n- Tokens live in `~/.cio/config.json` (0600). Resolution order: `--token`\n  flag → `CIO_TOKEN` env var → config file.\n- Raw `sa_live_` tokens cannot be used as Bearer tokens against CLI/UI API\n  endpoints - they must be exchanged.\n- Exchange endpoint: `POST /v1/service_accounts/oauth/token`\n  (unauthenticated, IP rate-limited). Returns a JWT with `expires_in`; the\n  CLI caches it and re-exchanges automatically (60s buffer), retrying once on\n  unexpected 401s.\n- Two data centers: US `https://us.fly.customer.io`, EU\n  `https://eu.fly.customer.io`. Region is set at account creation and stored\n  in config. `auth login` discovers it by exchanging the token at the US\n  endpoint and reading the account's data center. The US exchange rejects EU\n  tokens, so on a fresh config an EU login fails with \"Authentication\n  failed\" - re-run the same login command with\n  `--api-url https://eu.fly.customer.io` appended. Ask the user for region\n  only at new signup or after that failure.\n- `auth status` exchanges the token to verify it. `auth token` prints the raw\n  token - explicit export/debugging only, never as a resume step.\n- `auth signup start`/`verify` run unauthenticated; `verify` persists the\n  bootstrap token + `account_id` to config, so no separate login afterward.\n\n## OAuth exchange\n\n```\nPOST /v1/service_accounts/oauth/token\nContent-Type: application/x-www-form-urlencoded\n\ngrant_type=client_credentials\u0026client_secret=sa_live_...\n\nResponse: {\"access_token\":\"\u003cjwt\u003e\",\"token_type\":\"Bearer\",\"expires_in\":3600}\n```\n\nHTTP Basic (`base64(\u003cservice_account_id\u003e:\u003ctoken\u003e)`) also works; `client_id`\nis optional and must match the service account ID if given.\n\n## Signup (agentic, new account)\n\nCreates a normal Free account - the same account shape a web signup gets - and\nreturns a live bootstrap token (`sa_live_...`) on a service account tied to the\nsignup user. Both subcommands support `--dry-run`. Wait for the user's emailed\ncode; never guess one.\n\nLike a web signup, the account starts a 14-day trial and a 500-emails-per-24h\nsending cap, and its workspace redirects every email to the signup address\n([onboarding.md](onboarding.md), *Step 1*).\n\n```bash\ncio auth signup start --json '{\"email\":\"agent+demo@example.com\"}'\n\ncio auth signup verify --json '{\n  \"email\": \"agent+demo@example.com\",\n  \"code\": \"123456\",\n  \"company_name\": \"Acme\",\n  \"first_name\": \"Ada\",\n  \"last_name\": \"Lovelace\",\n  \"data_center\": \"us\"\n}'\n```\n\nThe full response (including `token`) prints once; the server will not return\nit again. `data_center` is a request field; there are no plan or mode fields\nto pass. The new account starts with delivery held pending verification\nreview, like every new account ([onboarding.md](onboarding.md), *Verification\nand sending*).\n\n`signup start` on an already-registered email returns \"check your email\" but\nsends a \"you already have an account\" notice and no code, so `signup verify`\ncan never succeed. On that notice, switch to *Agent-driven login*.\n\n## Login / logout\n\n```bash\ncio auth login                                  # browser handoff, prompts for token\necho \"$CIO_TOKEN\" | cio auth login --with-token # stdin, CI/automation\ncio auth status                                 # verify; shows region + masked token\ncio auth logout\nCIO_TOKEN=sa_live_xxx CIO_REGION=eu cio campaigns list  # env-var direct use\n```\n\nBare `cio auth login` branches on token *presence, not validity*:\n\n- **Stored token** (even expired or revoked): mints a short-lived signed-in\n  web URL and exits. With a valid token this is how to send the user into\n  the Customer.io UI (billing, domains, settings). With a rejected token it\n  errors; it never falls back to prompting.\n- **No stored token**: prints the token page URL, then prompts interactively\n  on stdin - it hangs or errors in an agent shell. Never run this mode from\n  the agent.\n\n`--with-token` and `--from-clipboard` bypass both modes and ignore any\nstored token (valid or dead) - no `cio auth logout` needed first.\n\n### Agent-driven login\n\nUse whenever a working account exists but the CLI has no usable token: the\nuser signed up in the UI, the stored token is missing or rejected on resume,\nor auth broke mid-session. The user never opens a terminal and the token\nnever enters the conversation.\n\n1. Run the waiting login and, in the same message, give the user numbered\n   steps - the URL must appear in the chat (the command's own output may\n   not be visible to them):\n\n```bash\ncio auth login --from-clipboard --wait\n```\n\n   1. I've started a login command that watches your clipboard.\n   2. Open https://fly.customer.io/cli and sign in.\n   3. Copy the token shown - it's picked up automatically; nothing to\n      paste anywhere.\n\n   The CLI polls the clipboard and logs in the moment the token is copied.\n   Non-token clipboard contents are ignored - never echoed or stored; on a\n   prefix rejection, ask the user to re-copy the token. The wait budget is\n   90 seconds and signing in often takes longer: on timeout, re-run\n   immediately and keep waiting. Do not end the turn or ask for\n   confirmation - nothing notices the copy once you stop; the command\n   succeeding is the signal.\n\n2. Verify; region is discovered from the token (EU accounts: see the\n   region invariant):\n\n```bash\ncio auth status\n```\n\n**Token intake rules.** `--from-clipboard` and `--with-token` (stdin pipe)\nare the only safe inputs:\n\n- The global `--token` flag is per-call auth that login ignores - it still\n  waits on stdin and fails with \"token must not be empty\".\n- Positional `cio auth login \u003ctoken\u003e` and `echo \"\u003ctoken\u003e\" | ...` leak the\n  token into argv/shell history.\n- The user must never paste a token into the conversation - a bootstrap\n  token carries the account owner's permissions.\n\n**Fallbacks:**\n\n- Older CLI builds (\"unknown flag: --from-clipboard\"): ask the user to copy\n  the token, confirm in chat, then pipe it:\n\n```bash\npbpaste | cio auth login --with-token   # macOS; Linux: wl-paste or xclip -o -selection clipboard; Windows: powershell Get-Clipboard\n```\n\n- No clipboard tooling, or a remote shell (container, Codespace, SSH -\n  clipboard reads see the *remote* clipboard and come up empty): the user\n  runs `cio auth login` in their own terminal and pastes at its interactive\n  prompt. An IDE terminal attached to the same remote writes the same\n  config. On this path only: if a rejected token is still stored, the agent\n  runs `cio auth logout` first (bare login errors on a dead token instead\n  of prompting).\n\nThis file covers CLI/account authentication only. Credentials for\napplication code - CDP write keys, App API keys - are an integration\nconcern: see [integration.md](integration.md).\n\n## Resume\n\n```bash\ncio auth status\n```\n\nIf it succeeds: rediscover account/workspace state with read-only calls, using\nthe region it reports. If it fails:\n\n- Signup start ran but verify didn't finish → no account exists yet; continue\n  with the same email/code or rerun start.\n- An account exists but the token is missing or rejected → *Agent-driven\n  login* above. Never create another account to recover access; if the user\n  can't sign in at all, direct them to account recovery or support.\n- Start a new signup only when the user explicitly abandons the current\n  account.\n\n## Troubleshooting\n\n**\"sa_live_ credentials cannot be used directly\"** - something is sending the\nraw token as a Bearer header to the UI API instead of exchanging it. The CLI\nhandles exchange automatically; this appearing means the exchange was\nbypassed.\n\n**Stored token is `sa_sandbox_...`** - the account is a sandbox (Builder)\naccount. While it is still in sandbox, re-running login returns the same token,\nso don't loop on it; see [onboarding.md](onboarding.md), *Sandbox accounts*. Once the account has gone live, the CLI swaps to a live token on a\nlater command by itself.\n\n**Token exchange failed / wrong data center** - check `cio auth status` for\nregion; override with `CIO_REGION=eu` (or `us`) for direct token use. For\n`auth login` on an EU account, see the region invariant (`--api-url`).\n\n**\"token must not be empty\" from `auth login`** - empty stdin: `--token` was\npassed (login ignores it) or nothing was piped. Use the token intake rules\nin *Agent-driven login*.\n\n**JWT expired mid-session** - auto-refresh should handle it; if problems\npersist, `cio auth login` to force a re-exchange.\n","billing.md":"---\nname: billing\ndescription: Billing, pricing, plan state, limits, invoices, and payment questions for Customer.io CLI users.\ncli_only: true\n---\n\n# Billing: Plans, pricing, and limits\n\nUse this when the user asks about pricing, plans, current limits, billing\nstatus, invoices, receipts, payment methods, or whether they need to pay\nbefore sending.\n\nKeep the answer short, account-specific, and grounded in current sources. Do\nnot paste raw billing JSON unless the user asks for it. Do not quote prices,\nminimums, message rates, or allowances from memory.\n\n## Workflow\n\n1. Check the account's current billing state.\n2. Check the current pricing page for the relevant plan or product path.\n3. Explain what the user's current plan allows for their goal.\n4. Point them to the next product-supported step in Account Settings, the\n   pricing page, or sales. When billing setup must happen in Customer.io, use\n   `cio auth login` to get the user into the UI.\n\nDo not assume the user is on Free, Essentials, Premium, or any other plan.\nDo not infer a payment path from route names alone. A new self-serve account\nstarts on the Free plan with no subscription row; that is a normal state, not\na problem to fix. Payment is not what unblocks a first send - the account's\nverification review and a verified sending domain are\n([onboarding.md](onboarding.md), *Verification and sending*).\n\nAn account created by agentic/CLI signup is on the same trial as a web signup:\nit is suspended 14 days after signup and cancelled 3 days later unless the user\npicks a plan.\n\nWhat does bind such an account is its compliance tier, not its plan: tier 0 caps\nemail at 500 per 24h and tier 1 at 10,000, while tier 2 falls through to the\nplan's own limits. A new account is tier 0, so the plan's message allowance is a\nceiling the account will not reach. When a user asks why sends stopped, check the\ntier before the plan - the block reason is \"Daily email limit reached for\ncompliance tier\".\n\n## Check billing state\n\nGet the account ID if needed:\n\n```bash\ncio auth status --jq '.account_id' -r\n```\n\nSettle the billing provider first — it decides which endpoint holds the\naccount's real plan limits:\n\n```bash\ncio api /v1/accounts/{account_id} --jq '.account.billing_vendor'\n```\n\nEvery account has one: `ordway`, `legacy_stripe`, `metronome` or `modern_stripe`. These are\ninternal billing-system names — use them to pick an endpoint, never say them\nto the user.\n\nInspect billing. Match `subscription.plan_id` to `plans[].id`; do not treat\nevery item in `plans` as the current plan.\n\n```bash\ncio api /v1/accounts/{account_id}/billing/info \\\n  --jq '. as $b | {\n    subscription: $b.subscription,\n    billing_info: ($b.billing_info | {\n      active,\n      active_subscription,\n      basic_trial,\n      premium_trial,\n      billing_estimates,\n      estimated_invoice_cost,\n      overdue,\n      has_failed_overage_charge,\n      can_retry_payment\n    }),\n    current_plan: ([$b.plans[]? | select(.id == ($b.subscription.plan_id // null))] | first)\n  }'\n```\n\n**The plan and limits `billing/info` returns are the legacy Stripe plan\nrecord.** The endpoint has no provider gate, so it answers 200 for every\naccount — but an Ordway or Metronome account has no such record and falls\nback to the free-trial plan's literals. Only quote its `customers` /\n`emails_sent` as the account's limits for `legacy_stripe`.\n\nFor an **Ordway** account the allotment lives on the subscription, under\n`billing/profile` → `ordway_subscriptions`. Don't work it out from here —\nthat read has several ways to come back empty or ambiguous (a future-dated\ncontract, a suspended account, an unprovisioned `TEMP-` id, concurrent\ncharge lines). Run `cio skills read fly-api/billing.md` and follow\n§ Plan Entitlements; the product ids and the handling live there.\n\nFor a **Metronome** account, or any `billing_vendor` you don't recognize,\nneither endpoint holds the real limit: say the limit isn't available, give\nthe usage you do have, and point the user at Account Settings → Billing.\nReporting `billing/info`'s trial figures to a paying account is the failure\nthis routing prevents.\n\nBoth `billing/info` and `billing/profile` need billing-view permission, while\nthe account read above needs none — so a user on a role without billing view\nresolves the vendor and then 403s on every limit read. That's the \"limit isn't\navailable\" answer: say they don't have billing access on this account, and\ndon't name the permission constant.\n\nTo list the self-serve plans available for checkout:\n\n```bash\ncio api /v1/accounts/{account_id}/billing/plans\n```\n\nThis endpoint never reads the account — it returns the Ordway self-serve\ncatalog for anyone, including legacy Stripe and Metronome accounts, and each\ncharge carries an `included_units`. **Never read the account's limit off\nit**; those numbers belong to plans the account isn't on.\n\nTranslate results into plain language:\n\n- current plan or billing model\n- active, trial, or inactive subscription state\n- limits and allowances from the source that matches the account's provider —\n  never `billing/info`'s plan for a non-Stripe account\n- current usage or overage estimate, if relevant\n- next step shown by the product or current pricing page\n\nDon't use `billing_info.active` as a \"can this account send\" signal. A\nself-serve Free account has no subscription row, so it reads `false` while\nthe account is perfectly able to send within its plan limits once verified.\nRead sending readiness from the account's `verification_state` and domain\nlist instead ([onboarding.md](onboarding.md), *Verification and sending*).\n\n`billing_estimates`, `estimated_invoice_cost` and `has_overage` are computed\nfrom the same legacy plan record as the limits, so they are\n`legacy_stripe`-only too. For an Ordway account the estimate is derived from\na self-serve plan it isn't on — the handler substitutes Basic/Essentials\nwhen the record falls back to the trial — and `has_overage` compares usage\nagainst the trial's 5,000, so it reads `true` for nearly every Ordway\naccount. Don't quote a dollar figure or an overage flag for a non-Stripe\naccount; give the usage and point at Account Settings → Billing.\n\nDo not call `tiers` or `tiers_count` usage, limits, or overages. Tiers are\nplan pricing rows unless an endpoint explicitly labels them as account usage.\nDo not turn boolean flags like `has_overage` into a bill estimate by\nthemselves. For a `legacy_stripe` account the source is `billing_estimates`\nfrom the `billing/info` call above — no extra request needed. For Ordway,\n`billing/overage_estimate` is the only estimate endpoint, and it's usually\nout of reach: it needs billing-*edit* permission plus a `subscription_id`\nfrom the profile read, and it answers 200 with a `null`\nbody for any non-Ordway account. When it isn't reachable, give the usage and\npoint at Account Settings → Billing.\n\n## Check current pricing\n\nUse web/browser tooling to read these pages before quoting exact pricing,\nlimits, minimum purchases, or rates:\n\n- Plans and the Free tier: https://customer.io/pricing\n- Billing mechanics and overages: https://docs.customer.io/accounts-and-workspaces/how-we-bill/\n\nDo not scrape partial HTML and guess. If you cannot read the page, link the\nsource and avoid exact prices.\n\n## Sending to customers\n\nThere is no launch or activation step and no billing gate on a first send.\nIf the user asks what they need before sending to real customers, the answer\nis account verification and a verified sending domain - follow *Verification\nand sending* in [onboarding.md](onboarding.md). Answer billing-specific\nquestions here from live billing state and current pricing sources.\n\nSay what the current account state means for what the user described:\n\n- If their plan's limits cover it, say so.\n- If the product directs them to add payment or upgrade, say which path the\n  current source shows.\n- If they need billing access or sales, say that.\n\nDo not issue `POST`, `PUT`, `PATCH`, or `DELETE` requests to billing endpoints.\nIf the user wants to change billing, add funds, add a payment method, or update\naccount settings, direct them to the Customer.io UI, pricing page, or sales path\nunless a product owner explicitly instructs otherwise. Use `cio auth login` to\nopen/connect the UI, then ask the user to return when complete and re-check\nbilling state.\n","integration.md":"---\nname: integration\ndescription: Router for Customer.io app integration - choose web, backend, or mobile; scoped credentials; transactional sends; and event verification.\ncli_only: true\n---\n\n# Integration: Wire Customer.io into an app\n\nUse this when the user has an app or codebase and wants Customer.io wired into\nit. This file is the decision tree. Read only the platform-specific file you\nneed after detection.\n\nWhen the user has not specified a channel, default to the smallest integration\nthat proves value:\n\n1. Detect the stack.\n2. Present the plan and get approval before touching the project (see\n   *Step 2*).\n3. Wire tracking first: page/screen, identify, and one meaningful track event.\n4. Verify the events reached Customer.io through the CDP source events list.\n5. Stop and ask whether to add transactional messaging. Explain that\n   transactional messages are welcome emails, password resets, receipts, and\n   account notifications.\n6. If the user says yes, wire transactional through backend-owned code and keep\n   transactional credentials out of browser/mobile code.\n\n## Before you start\n\n1. Load CLI context and confirm auth.\n\n   ```bash\n   command -v cio \u0026\u0026 cio prime\n   cio auth status\n   ```\n\n2. Find the workspace.\n\n   ```bash\n   cio api /v1/accounts/{account_id}/environments \\\n     --jq '.environments[] | {id, name}'\n   ```\n\nEvent ingestion works from the moment the account exists. Only *sending* waits\non the account's verification review and a verified sending domain\n([onboarding.md](onboarding.md), *Verification and sending*) - don't hold the\nintegration for either.\n\n## Step 1 - Detect the stack\n\nScan the project before choosing an SDK:\n\n| Marker | Integration path |\n|---|---|\n| Browser app, `index.html`, Vite, React, Vue, Svelte, Angular, Next.js frontend | [integration_web.md](integration_web.md) |\n| Node backend, API routes, workers, queues | [integration_backend.md](integration_backend.md) |\n| Python backend | [integration_backend.md](integration_backend.md) |\n| Go backend | [integration_backend.md](integration_backend.md) |\n| `Podfile`, `Package.swift`, `.xcodeproj` | [integration_mobile.md](integration_mobile.md) |\n| `build.gradle`, `AndroidManifest.xml` | [integration_mobile.md](integration_mobile.md) |\n| `react-native`, `expo`, or `flutter` | [integration_mobile.md](integration_mobile.md) |\n\nIf multiple apply, choose the minimal set that proves the user's goal. Do not\nwire transactional messaging before the user chooses it, unless the user\nexplicitly requested transactional messages. A web app with a backend often\nuses browser analytics plus backend transactional sends after that decision\npoint. A mobile app with purchases usually uses mobile SDK instrumentation plus\nbackend transactional sends after that decision point.\n\n## Step 2 - Present the plan and get approval\n\nLay out the plan in plain language, then wait for approval. Cover:\n\n- the project you detected and where it lives\n- what you'll wire up (e.g. \"browser tracking\"), and the specific events\n  you'll send (one page/screen, one identify, one meaningful track)\n- the files you'll add or edit, and any package you'll install\n- what you'll create or reuse in Customer.io\n\nThis is one approval of the plan, not a gate on every file: once the user\nagrees, carry it out without stopping for each edit. Re-check only if the work\ndiverges from what they approved - a different package, files you didn't\nmention, a new credential. If the app target is ambiguous (no clear markers,\nmultiple plausible apps, or the current directory looks like a home folder or\nunrelated repo), confirm the path first. Transactional stays out of this\napproval; it gets its own decision after tracking is proven.\n\n## Step 3 - Choose credentials\n\nUse scoped credentials in application code:\n\n- CDP source write key for identify/track/page/screen ingestion.\n- Backend-only App API key for transactional sends in normal customer backend\n  code.\n\nThe keys you create are the production keys - there is no later swap. Never\nuse the CLI's own `sa_live_...` token as an app secret.\n\nDo not treat every credential type as mandatory for every integration. Create\nthe credentials needed by the paths being tested:\n\n- Browser analytics: JavaScript CDP source write key.\n- Backend analytics: backend CDP source write key.\n- Transactional messages: backend-only App API key.\n\nFor web apps that need transactional messages, the expected proof is: browser\naction -\u003e backend endpoint/server action -\u003e Transactional API call from\nbackend. Never put App API keys or service account tokens in browser code.\n\nWhen extracting a key, add the `-r`/`--raw-output` flag: `--jq '.api_key' -r`\nprints it unquoted. Without `-r`, `--jq` emits a JSON-quoted string (`\"key\"`) —\ncheck any stored key for stray quotes or `%22`.\n\n## Step 3.5 - Keep credentials out of code and logs\n\nDo not add diagnostic logging that prints tokens, keys, config objects, env\nvars, authorization headers, SDK initialization options, or full API payloads.\nBrowser CDP write keys are usable in browser code by design, but still should\nnot be printed into logs or shared transcripts.\n\nTemporary debug output is okay only when it is narrowly scoped, redacted, and\nremoved or gated before handoff. It is fine to log semantic milestones such as\n`customerio-page-sent` or `welcome-email-requested`; do not log secrets.\n\n## Step 4 - Route to the right implementation\n\n### Website or web app\n\nRead [integration_web.md](integration_web.md).\n\nDefault to Customer.io's CDP browser analytics package/snippet loaded on the\nclient. This is preferred for websites because client-side loading is what\nenables web in-app functionality.\n\nOnly use a server-side-only web integration when the user explicitly requires\nit or the codebase has no browser runtime.\n\n### Backend or server-side app\n\nRead [integration_backend.md](integration_backend.md).\n\nUse analytics SDKs for identify/track ingestion where possible. For\ntransactional messages, read `cio skills read fly-api/transactional_send.md`\nand use raw Transactional API HTTP from backend code.\n\n### Mobile app\n\nRead [integration_mobile.md](integration_mobile.md).\n\nInstall the mobile SDK for identify/screen and push/in-app readiness. Push and\nin-app delivery waits on the account's verification review like every other\nsend. Transactional sends should still be triggered by the backend after the\nbackend validates the business action.\n\n## Step 5 - Verify before calling it done\n\nDo not stop at package installation. Verify at least one real signal:\n\n- identified test person exists, when profile creation is part of the path\n- transactional test send was accepted and its delivery state read back, when\n  transactional is in scope (a held delivery on an unverified account is\n  expected, not a bug)\n\nFor browser/mobile tracking, CDP source status/events are the verification\nsurface. Do not substitute management endpoints such as\n`/v1/environments/{environment_id}/track_event`, `/events`, or `/capture` for\na CDP SDK proof. Those endpoints use different permissions and do not prove\nbrowser/mobile SDK ingestion.\n\nUseful checks:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/status \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\"}'\n```\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/events \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\",\"limit\":\"5\"}' \\\n  --jq '.events[] | {type, event: (.properties.event // .event // null), userId: (.properties.userId // .userId // null), timestamp}'\n```\n\n```bash\ncio api /v1/environments/{environment_id}/customers \\\n  --params '{\"environment_id\":\"\u003cenvironment_id\u003e\",\"filter\":\"email = \\\"\u003ctest-email\u003e\\\"\"}' \\\n  --jq '.customers[] | {id, email}'\n```\n\n## Step 6 - If the user is ready to send to customers\n\nThere is no launch step and no key swap - the integration you wired is the\nproduction integration. What sending needs is the account's verification\nreview to approve and a verified sending domain: follow *Verification and\nsending* in [onboarding.md](onboarding.md).\n\n## Done\n\nReport the exact boundary reached: tracking-only, or tracking plus\ntransactional. Include the concrete proof Customer.io received, whether\nsending is still held for verification or a domain, and the next available\nstep.\n\nOnly include a numeric scorecard when the user explicitly says this is an\ninternal eval or asks for scores.\n","integration_backend.md":"---\nname: integration_backend\ndescription: Customer.io backend integration - Node, Python, Go, HTTP CDP analytics sources, identify/track instrumentation, env vars, and transactional handoff.\ncli_only: true\n---\n\n# Backend Integration\n\nUse this for server-side identify/track instrumentation, workers, API routes,\njobs, and backend-owned transactional sends.\n\nFor transactional messages, also read `cio skills read fly-api/transactional_send.md`.\n\n## Choose the source type\n\n| Stack | Source option_id | Preferred package |\n|---|---|---|\n| Node.js | `node` | `@customerio/cdp-analytics-node` |\n| Python | `python` | `customerio-cdp-analytics` |\n| Go | `go` | `github.com/customerio/cdp-analytics-go` |\n| Other backend | `http` | raw CDP API |\n\nUse analytics SDKs for identify/track ingestion because they handle batching\nand retry. Do not use old Track/App API wrappers for transactional sends; raw\nApp API HTTP is the contract.\n\n## Create or select a source\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --jq '.sources[] | {id, name, option_id: (.option_id // .slug), enabled}'\n```\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --json '{\"source\":{\"name\":\"\u003cCompany\u003e Backend Source\",\"option_id\":\"\u003cnode|python|go|http\u003e\"}}' \\\n  --dry-run\n```\n\nRun without `--dry-run` after confirmation.\n\nGet or create the write key:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/api_keys \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\"}' \\\n  --jq '.api_keys[] | {id, key_available: (.api_key != null)}'\n```\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/api_keys \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\"}' \\\n  -X POST\n```\n\nFetch the actual write key only when writing it into the app's secret/config\nstore. Do not print CDP write keys in summaries, logs, or broad command\noutput.\n\nExtract the key with `--jq '.api_key' -r` — the `-r`/`--raw-output` flag prints it unquoted (without `-r`, `--jq` emits a JSON-quoted string).\n\n## Install and initialize\n\nFetch the current README for the stack before editing:\n\n- Node: `https://raw.githubusercontent.com/customerio/cdp-analytics-js/main/packages/node/README.md`\n- Python: `https://raw.githubusercontent.com/customerio/cdp-analytics-python/master/README.md`\n- Go: `https://raw.githubusercontent.com/customerio/cdp-analytics-go/master/README.md`\n\nUse the project's existing secret/config pattern. Common env vars:\n\n- `CIO_CDP_WRITE_KEY`\n- `CIO_CDP_REGION`\n- `CIO_CDP_HOST` for explicit EU/custom host config if the SDK requires it\n\n## Instrument real backend behavior\n\nFind meaningful server-side points:\n\n- user signup/account creation -\u003e `identify`\n- login/session creation -\u003e `identify` if profile traits changed\n- purchase/payment/order completion -\u003e `track`\n- subscription or plan change -\u003e `track`\n- backend-only workflows or jobs -\u003e `track`\n\nServer SDKs generally require `userId` on every `track` call. Flush or close\nthe client where required by the SDK, especially in short-lived jobs.\n\n## Transactional messages\n\nTransactional messages should be triggered from backend code after the backend\nvalidates the business action.\n\nExamples:\n\n- Signup completes -\u003e send welcome email.\n- Payment succeeds -\u003e send receipt.\n- Password reset token created -\u003e send password reset.\n- Mobile purchase is confirmed by server -\u003e send email/push/in-app as needed.\n\nRead `cio skills read fly-api/transactional_send.md`. Use a backend-only\nworkspace-scoped App API key, not the CDP write key. Do not reuse\nsignup/bootstrap or CLI automation tokens as app secrets.\n\n## Verify\n\nCheck recent source events:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/events \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\",\"limit\":\"5\"}' \\\n  --jq '.events[] | {type, event: (.properties.event // .event // null), userId: (.properties.userId // .userId // null), timestamp}'\n```\n\nIf events do not appear:\n\n- confirm the write key is the CDP source write key\n- confirm region/host\n- confirm the code path ran\n- flush/close the SDK client\n- check logs for network/auth errors\n","integration_mobile.md":"---\nname: integration_mobile\ndescription: Customer.io mobile integration - iOS, Android, React Native, Expo, Flutter SDK setup, Live Notifications/Live Activities, screen events, push/in-app readiness, and backend transactional patterns.\ncli_only: true\n---\n\n# Mobile Integration\n\nUse this for iOS, Android, React Native, Expo, and Flutter apps.\n\nMobile SDKs are right for identify/screen events, device registration, push,\nand in-app readiness. Transactional business sends should normally be triggered\nby the backend after server-side validation, not directly by the mobile app.\n\n## Set expectations on push and in-app early\n\nBefore investing in push or in-app setup, explain:\n\n- Push and in-app delivery, like every send, waits on the account's\n  verification review ([onboarding.md](onboarding.md), *Verification and\n  sending*). End-to-end device delivery also needs the platform's push\n  credentials (APNs/FCM) configured in the workspace.\n- Identify/screen/track events flow immediately, so wiring the SDK now proves\n  the integration and prepares the code path even while sends are held.\n- Sends may have real cost at volume. Use [billing.md](billing.md) for exact\n  current pricing/account-state checks.\n\n## Choose platform\n\n| Marker | Platform |\n|---|---|\n| `Podfile`, `Package.swift`, `.xcodeproj` | iOS |\n| `build.gradle`, `AndroidManifest.xml` | Android |\n| `react-native` in package.json | React Native |\n| `expo` in package.json or `app.json` | Expo |\n| `pubspec.yaml` with `flutter` | Flutter |\n\nFor install snippets and platform gotchas, read [sdk_setup.md](sdk_setup.md).\nFor issues after installation, read [sdk_troubleshooting.md](sdk_troubleshooting.md).\nFor Premium Live Notifications on Android or Live Activities on iOS — including\nnative, React Native, Expo, Flutter, and backend lifecycle setup — read\n[live_notifications.md](live_notifications.md).\nGeofencing is gated on the account's `geofences` feature. For accounts that have\nit, read `@skills/recipes/mobile_geofencing.md` before proposing setup or\ntransition changes. Geofencing is supported in native Android/iOS, React Native, Flutter,\nand Expo SDK 53+ through the Expo config plugin.\n\n## Create or select a mobile source\n\nSource `option_id` values:\n\n- `ios`\n- `android`\n- `reactnative`\n- `expo`\n- `flutter`\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --jq '.sources[] | {id, name, option_id: (.option_id // .slug), enabled}'\n```\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --json '{\"source\":{\"name\":\"\u003cCompany\u003e \u003cPlatform\u003e Source\",\"option_id\":\"\u003cios|android|reactnative|expo|flutter\u003e\"}}' \\\n  --dry-run\n```\n\nGet or create the write key:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/api_keys \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\"}' \\\n  --jq '.api_keys[] | {id, key_available: (.api_key != null)}'\n```\n\nFetch the actual write key only when writing it into the app's secret/config\nstore. Do not print CDP write keys in summaries, logs, or broad command\noutput.\n\nExtract the key with `--jq '.api_key' -r` — the `-r`/`--raw-output` flag prints it unquoted (without `-r`, `--jq` emits a JSON-quoted string).\n\nMobile SDKs also need the site ID for in-app:\n\n```bash\ncio api /v1/environments/{environment_id}/site_ids \\\n  --params '{\"environment_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --jq '.site_ids[0]' -r\n```\n\n## Configure secrets\n\nUse platform-appropriate config:\n\n- iOS: `.xcconfig`, build settings, or the app's existing secret/config layer\n- Android: `local.properties`, Gradle build config, or the app's existing layer\n- React Native/Expo: env/config plugin pattern already used by the project\n- Flutter: `--dart-define`, `flutter_dotenv`, `envied`, or existing config\n\n## Instrument\n\nWire:\n\n- identify after login/signup/session restoration\n- screen on navigation changes\n- 1-3 meaningful track events\n- device/push registration only if the platform is ready and the user wants it\n\nDo not use email as the only identifier if the app has a stable user ID.\n\n## Transactional mobile pattern\n\nThe mobile app should normally send the business action to the backend, and the\nbackend should trigger the transactional message after validation.\n\nExamples:\n\n- purchase initiated in app -\u003e backend confirms payment -\u003e backend sends receipt\n- password reset requested in app -\u003e backend creates token -\u003e backend sends reset email\n- booking confirmed -\u003e backend sends confirmation email/push/in-app\n\nRead `cio skills read fly-api/transactional_send.md` and\n[integration_backend.md](integration_backend.md) for the backend send path.\n\n## Verify\n\n- verify identify/screen/track events on the mobile source\n- do not promise push/in-app rendering until the account is verified and push\n  credentials are configured\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/events \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\",\"limit\":\"5\"}' \\\n  --jq '.events[] | {type, event: (.properties.event // .event // null), userId: (.properties.userId // .userId // null), timestamp}'\n```\n","integration_web.md":"---\nname: integration_web\ndescription: Customer.io web integration - prefer client-side cdp-analytics-js/browser setup, page/identify/track, in-app readiness, and backend transactional handoff.\ncli_only: true\n---\n\n# Web Integration\n\nUse this for browser-based websites and web apps.\n\nDefault to Customer.io's CDP browser analytics package/snippet loaded on the\nclient. Client-side loading is preferred for websites because it supports web\nin-app functionality and captures page context naturally.\n\n## Choose the web path\n\nUse the browser SDK when any of these are true:\n\n- the app has a browser runtime\n- the user may want in-app messages\n- page views and client-side interactions matter\n- the framework exposes public client env vars, such as `NEXT_PUBLIC_*`\n\nUse server-side analytics only when the user explicitly requires it or the app\nhas no browser runtime. For that path, read [integration_backend.md](integration_backend.md).\n\nNever expose App API keys or service account tokens in browser code.\nTransactional sends must be triggered by backend code; read\n`cio skills read fly-api/transactional_send.md`.\n\n## Create or select a JavaScript source\n\nList sources:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --jq '.sources[] | {id, name, option_id: (.option_id // .slug), enabled}'\n```\n\nCreate one if needed:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --json '{\"source\":{\"name\":\"\u003cCompany\u003e Web Source\",\"option_id\":\"javascript\"}}' \\\n  --dry-run\n```\n\nThen run without `--dry-run` after confirmation.\n\nThe create response is the created source; its top-level `id` is the\n`source_id` used in the calls below.\n\nGet or create the write key:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/api_keys \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\"}' \\\n  --jq '.api_keys[] | {id, key_available: (.api_key != null)}'\n```\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/api_keys \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\"}' \\\n  -X POST\n```\n\nFetch the actual `api_key` only when writing it into the app's config or\nruntime environment. Do not print the key in summaries, logs, or broad\nsettings reads.\n\nExtract the key with `--jq '.api_key' -r` — the `-r`/`--raw-output` flag prints it unquoted (without `-r`, `--jq` emits a JSON-quoted string).\n\n## Install and initialize\n\nChoose the install path based on whether the app has a build step. Do not hand-write\nthe SDK loader from memory in either case. Fetch the package instructions or\ncopy the source snippet as described below.\n\n**If the app has a bundler/build step** (Vite, webpack, Next, Rollup, esbuild,\netc.), this is the preferred path: install the package and import it. Fetch the\ncurrent instructions before editing:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/customerio/cdp-analytics-js/main/packages/browser/README.md\n```\n\nThe README assigns the client to a local variable named `cioanalytics`. That is\njust a variable name with no effect on how events are sent; call\n`page`/`identify`/`track` on whatever variable you store it in. This path has no\nglobal-window object to misconfigure.\n\nOn this path nothing is sent for you: the app owns the single load call *and*\nthe single initial `page()`, plus a `page()` per route change. Route all of them\nthrough the module-scope last-path guard in\n[sdk_setup.md](sdk_setup.md)'s package-path example and let the router hook's\nown first run be the initial view — seeding that guard from `window.location`\nwhile the hook reports `useLocation()` paths double-counts the first view under\na `basename` or hash router.\n\nUnder an SSR framework (Next.js, Remix, Nuxt, SvelteKit) that load must be\nclient-only. `\"use client\"` does not make it so — client components still\npre-render on the server, and the browser client reaches for `document` during\nthat render. Guard the load with `typeof window !== \"undefined\"` or move it into\na mount effect, and read the key from the framework's public client env name\n(below) rather than Vite's `import.meta.env.VITE_*`.\n\n**If the app has no build step** (static site, snippet-based app, double-click\n`index.html`), fetch the canonical browser snippet from the source catalog. Never\nwrite or edit the snippet by hand. The `javascript` source's `code` field is the\nfull snippet:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/available_sources \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --jq '.sources[] | select(.option_id==\"javascript\") | .code' -r\n```\n\nFill the three placeholders and change nothing else:\n\n- `${apiKey}` → the source write key\n- `${analyticsKey}` → the global variable name (an identifier like `cioanalytics`;\n  this is NOT the write key — it just names the `window` property the snippet uses,\n  and the snippet copies it into `data-global-customerio-analytics-key` for you)\n- `${edgeUrl}` → the region edge URL (US `https://cdp.customer.io`, EU `https://cdp-eu.customer.io`)\n\nThen read the snippet you fetched rather than assuming what it contains: search\nit for `.load(` and `.page()`. Add one initial `page()` only if the snippet has\nnone, never a second `load(...)`, then the `identify`, `track`, and\nroute-change `page()` calls on the same global (e.g. `window.cioanalytics`). Run\nthat check every time — the served template changes, and a workspace can be on\nan older one. A source's `setup_tips` (\"Call analytics.load() and\nanalytics.page() to initialize\") describes the package path; applying it on top\nof a snippet that already loads the library loads it twice. See\n[sdk_setup.md](sdk_setup.md) for the check and for marking the snippet's own\ninitial view in the route-change guard without sending a second one.\n\nDon't add a bundler just to use the package import (`npm install` alone won't work\nin the browser — the package has bare-specifier deps a bundler must resolve).\n\nWhen using the browser package or UMD bundle, keep the analytics client object\nitself, not only the resolved load promise. The browser client may be thenable;\nif you return it from an `async` initializer, JavaScript can unwrap it and\nleave later code without buffered `page`, `identify`, or `track` methods.\nStore the client in module state and use a separate readiness promise when you\nneed one.\n\nStore the write key using the app's existing config convention. Browser write\nkeys are public by design; common names:\n\n- `NEXT_PUBLIC_CIO_CDP_WRITE_KEY`\n- `VITE_CIO_CDP_WRITE_KEY`\n- `PUBLIC_CIO_CDP_WRITE_KEY`\n\nFor EU workspaces, configure the EU CDP endpoint/region according to the\ncurrent SDK docs.\n\nDo not log `window` config objects, SDK initialization options, write keys,\nheaders, env vars, or full request/response payloads. If you add a browser\ndriver or verification script, redact the CDP write key in its output.\n\n## Instrument real behavior\n\nWire the smallest useful set:\n\n- one `page()` on initial load — the generated snippet sends it when it has a\n  `page()` call of its own; otherwise the app does\n- `page()` on every route change, guarded so the hook's own mount run does not\n  re-send the initial view. A `useEffect` keyed on the location fires on mount\n  as well as on navigation, and React StrictMode fires it twice on mount in\n  development — so guard by comparing the current path against the last path\n  sent, held in module scope, not with a one-shot boolean a remount resets.\n  Without that, the first page view is counted twice on either path. The\n  tradeoff: a navigation to the path already showing sends nothing\n- `identify()` after signup/login/session hydration\n- 1-3 `track()` calls for meaningful actions\n\nUse stable database IDs for user IDs when available. Include email/name as\ntraits, not as the only identifier unless the app has no stable ID yet.\n\nFor SPAs, hook route changes rather than only initial page load.\n\nDo not mark the web integration complete until the code path has attempted all\nthree browser analytics calls when the app supports them:\n\n- a page call from browser load or route change — a snippet that calls `page()`\n  itself already satisfies this; verify the call arrived rather than adding a\n  second one\n- an identify call tied to the current/test user\n- a track call tied to a meaningful user action\n\nIf only one call type works, report that as partial success and continue\ndebugging the SDK/client shape, initialization timing, write key, and region.\n\n## Transactional sends from web apps\n\nDo not call the Transactional API directly from browser code. Add a backend\nendpoint, server action, API route, worker, or job that owns the transactional\ncredential and sends after validating the business action.\n\nExamples:\n\n- Signup form -\u003e backend creates user -\u003e backend sends welcome email.\n- Checkout -\u003e backend confirms payment -\u003e backend sends receipt.\n- Password reset request -\u003e backend creates reset token -\u003e backend sends reset\n  email.\n\nRead `cio skills read fly-api/transactional_send.md` for the Transactional API\nshape.\n\nWhen wiring a test app, prefer the smallest real path:\n\n1. Browser submits a meaningful action, such as signup or request welcome\n   email.\n2. Backend validates the request and calls the Transactional API.\n3. Backend returns a simple success/failure response to the browser.\n4. Verify the recipient received the transactional email or inspect the\n   resulting delivery state.\n\nIf the app has no backend at all, explain that transactional sends cannot be\ntested end-to-end until a backend-owned path exists.\n\n## Verify\n\nAsk the user to run the app or start the dev server using the repo's normal\nworkflow. First verify source events. Poll long enough for ingestion latency;\ndo not call success from the first empty response:\n\n```bash\ncio api /cdp/api/workspaces/{workspace_id}/sources/{source_id}/events \\\n  --params '{\"workspace_id\":\"\u003cenvironment_id\u003e\",\"source_id\":\"\u003csource_id\u003e\",\"limit\":\"5\"}' \\\n  --jq '.events[] | {type, event: (.properties.event // .event // null), userId: (.properties.userId // .userId // null), timestamp}'\n```\n\nExpected web proof is recent events that include:\n\n- `type: \"page\"`\n- `type: \"identify\"` with the test user ID when available\n- `type: \"track\"` with the meaningful event name\n\nIf no events appear:\n\n- check the write key and region\n- check browser console/network errors. `Failed to load Write Key...` with the\n  bundle loaded (HTTP 200) but no event POSTs means the snippet is broken —\n  refetch it from `available_sources` (above) rather than hand-editing\n- confirm the initialization code runs only on the client\n- confirm route/page hooks are firing\n- confirm the analytics client still exposes `page`, `identify`, and `track`\n  after any async wrapper or framework lifecycle code\n\nIf transactional is in scope, also verify the backend path:\n\n- the browser request reached the backend\n- the backend used the transactional credential; the browser did not\n- the transactional send was accepted, and the delivery state read back shows\n  either delivery or a concrete reason (a hold for account verification or a\n  missing verified domain is expected on a new account)\n","live_notifications.md":"---\nname: live_notifications\ndescription: Set up and troubleshoot Customer.io Live Notifications on Android and Live Activities on iOS, including native SDKs, React Native, Expo, Flutter, and backend lifecycle calls.\nminimum_plan: premium\ncli_only: true\n---\n\n# Live Notifications and Live Activities\n\nUse this guide only when the user asks about **Live Notifications** (Customer.io\nand Android terminology) or **Live Activities** (Apple terminology). First read\nthe app's existing Customer.io and push configuration; extend the pattern it\nalready uses instead of replacing its SDK initialization.\n\n## Confirm the intended path\n\nAsk or infer these before editing:\n\n1. Platform and wrapper: native iOS, native Android, React Native, Expo, or Flutter.\n2. Local lifecycle (the app starts/updates/ends it) or remote lifecycle (the\n   customer's backend calls Customer.io and APNs/FCM updates the device).\n3. Built-in template or a custom type with app-owned rendering.\n\nFor a setup or troubleshooting answer, carry the applicable prerequisites into\nthe answer instead of leaving them implicit in this guide: every released\nminimum (SDK, plugin, and framework, e.g. Expo SDK), the workspace's Live\nNotifications entitlement (name it as an entitlement, not only a plan), the\nrelevant OS gate, and identity plus APNs/FCM device readiness when remote\ndelivery is in scope. When the backend lifecycle is in scope, also include the\n`identifiers` rule (never a top-level `id`/`email`), complete-state replacement,\nasynchronous status, the size cap's synchronous 400 on start, update, and end,\nand that the named token-registration endpoints below are out-of-band only.\n\nThe feature requires the workspace's **Live Notifications entitlement**, which comes with the plan. If the workspace or\nApp API returns a plan-restricted response, explain that an upgrade is required;\ndo not try another endpoint or bypass the gate.\n\nA fresh account can prove identify and tracking setup, but it does not prove\nend-to-end APNs/FCM Live Notification delivery. Full remote lifecycle testing\nrequires a verified account with production mobile push credentials; call out\nthat boundary before proposing device-delivery debugging.\n\n## Shared contract\n\n- Built-in identifiers are\n  `io.customer.livenotifications.segments` and\n  `io.customer.livenotifications.countdowntimer`. SDK enums such as\n  `LiveNotificationType.SEGMENTS` stand for these strings; name the string\n  identifier in the answer too, since the backend's `notification_type` uses it.\n- A custom type uses one stable reverse-DNS identifier. The SDK configuration,\n  workspace live-notification type, backend request, and custom renderer must\n  use the exact same identifier.\n- Built-in templates render in the SDK. A custom type always needs app-owned UI:\n  a WidgetKit view on iOS and a render callback on Android.\n- Initialize normal APNs/FCM push first. Identify the user and wait for device\n  registration before relying on remote updates. A locally rendered activity\n  can appear before those prerequisites, but a lifecycle event dropped while\n  identity or a device token is missing is not retroactively registered.\n- App API keys belong in the customer's backend, never in mobile code. The\n  mobile SDK reports push-to-start and instance tokens itself.\n\nCurrent released minimums for this feature are iOS SDK **4.7.0**, Android SDK\n**4.20.0**, React Native SDK **6.6.0**, Flutter SDK **4.2.0**, and Expo plugin\n**3.7.0**. Do not copy Live Notifications configuration into an older SDK.\n\n## Native iOS\n\nLive Activities render on iOS 16.2+. Remote push-to-start additionally requires\niOS 17.2+; iOS 16.2-17.1 can still use local start/update/end and instance-token\nupdates.\n\nKeep those gates distinct in guidance: registering/rendering a Live Activity on\niOS 16.2 does not mean the backend can push-start it before iOS 17.2.\n\n1. Add the `LiveActivities` and `LiveActivities_Attributes` package products to\n   the app target. Add `LiveActivities_Templates` to the app and Widget Extension\n   when using a built-in template. CocoaPods equivalents are\n   `CustomerIOLiveActivities`, `CustomerIOLiveActivitiesAttributes`, and\n   `CustomerIOLiveActivitiesTemplates`.\n2. Add `NSSupportsLiveActivities = true` to the app Info.plist and create a\n   Widget Extension. The app target owns the runtime module; the extension only\n   needs the attributes/templates it renders.\n3. Before `CustomerIO.initialize`, add `LiveActivitiesModule` to the existing\n   `SDKConfigBuilder` and register every type the app will use:\n\n   ```swift\n   if #available(iOS 16.2, *) {\n       config.addModule(LiveActivitiesModule(\n           config: LiveActivityConfigBuilder()\n               .register(CIOSegmentsAttributes.self)\n               .register(MyDeliveryAttributes.self,\n                         identifier: \"com.example.delivery\")\n               .build()\n       ))\n   }\n   ```\n\n4. For push-to-start, custom attributes conform to `CIOActivityAttribute` and\n   include the SDK-managed `cioInstanceId`. Plain `ActivityAttributes` support\n   local lifecycle and instance-token updates, but not push-to-start.\n5. Start/update/end through `CustomerIO.liveActivities`. Route every opened\n   widget URL through `CustomerIO.liveActivities.handleWidgetUrl(_:)` before the\n   app's own deep-link router so the exact delivery receives its opened metric.\n\nDo not add the full SDK runtime to the Widget Extension, and do not promise Live\nActivities on an iOS simulator as proof of remote APNs delivery.\n\n## Native Android\n\nLive Notifications use the push module. They render as ordinary ongoing\nnotifications on supported older Android versions. The promoted status-bar and\nlock-screen treatment is Android 16/API 36+ and also requires the app to declare\n`android.permission.POST_PROMOTED_NOTIFICATIONS`; the SDK intentionally does not\nadd that opt-in permission. Without it, an API 36+ device still posts the Live\nNotification as a standard ongoing notification. Android 13+ separately\nrequires the normal `POST_NOTIFICATIONS` runtime permission.\n\nConfigure the existing `ModuleMessagingPushFCM` before adding it to the SDK:\n\n```kotlin\nval push = ModuleMessagingPushFCM(\n    MessagingPushModuleConfig.Builder()\n        .enableLiveNotificationTypes(\n            LiveNotificationType.SEGMENTS\n        )\n        .enableCustomLiveNotificationTypes(\"com.example.delivery\")\n        .setLiveNotificationCallback(myCustomRenderer)\n        .build()\n)\nconfig.addCustomerIOModule(push)\n```\n\nEnable only the built-ins the app renders. Custom identifiers require\n`setLiveNotificationCallback`. Local lifecycle is exposed by\n`ModuleMessagingPushFCM.startLiveNotification`, `updateLiveNotification`, and\n`endLiveNotification`; keep the returned activity ID for later calls.\nBefore a backend starts the remote lifecycle, confirm the app identified the\nuser and registered its FCM token; a returned backend instance ID does not prove\nthat a device was ready to receive it.\n\n## React Native, Flutter, and Expo\n\nThese wrappers expose one cross-platform configuration and use the same native\ncontracts above:\n\n- **React Native:** set `liveNotifications` in the SDK configuration. On iOS,\n  opt into the `liveactivities` pod subspec, add `NSSupportsLiveActivities`, add\n  a Widget Extension, and forward opened URLs through\n  `NativeLiveActivities.handleWidgetUrl` before `RCTLinkingManager`.\n- **Flutter:** set `liveNotificationsConfig` on `CustomerIOConfig`. On iOS, opt\n  into the `liveactivities` CocoaPods subspec (or the documented Swift Package\n  Manager flag), add `NSSupportsLiveActivities`, add a Widget Extension, and\n  forward opened URLs through `CustomerIOLiveActivities.handleWidgetUrl`.\n- **Expo:** requires Expo SDK 53+, `customerio-expo-plugin` 3.7.0+, and a plan\n  with Live Notifications. With plugin auto-initialization, put types under\n  `config.liveNotifications`. When\n  JavaScript calls `CustomerIO.initialize`, set top-level plugin option\n  `liveNotifications.enabled: true` to generate the native artifacts, and pass\n  the types at runtime under the initializer's `liveNotifications` key:\n\n  ```json\n  {\n    \"expo\": {\n      \"plugins\": [\n        [\n          \"customerio-expo-plugin\",\n          {\n            \"liveNotifications\": {\n              \"enabled\": true,\n              \"branding\": {\n                \"logo\": \"./assets/brand-logo.png\",\n                \"backgroundColorHex\": \"#101820\",\n                \"textColorHex\": \"#FFFFFF\",\n                \"accentColorHex\": \"#4B32C3\"\n              }\n            }\n          }\n        ]\n      ]\n    }\n  }\n  ```\n\n  ```javascript\n  CustomerIO.initialize({\n    // existing SDK configuration\n    liveNotifications: {\n      types: [\"io.customer.livenotifications.countdowntimer\"],\n      branding: {\n        logo: \"./assets/brand-logo.png\",\n        backgroundColorHex: \"#101820\",\n        textColorHex: \"#FFFFFF\",\n        accentColorHex: \"#4B32C3\"\n      }\n    }\n  })\n  ```\n\n  `customWidget` and `customRenderer` remain build-time options under the\n  plugin's top-level `liveNotifications` key on both initialization paths.\n  Branding also belongs there so it is compiled into the iOS widget. When\n  JavaScript initializes the SDK, pass the same branding under the runtime\n  `liveNotifications` configuration as well; Android applies that runtime value.\n  Describe it as passing the same values in both places: the plugin value is\n  built into the iOS widget, and Android applies the runtime value. Say nothing\n  about what iOS does with the runtime value, and do not invent a\n  duplicate-handling mechanism for AppDelegate forwarding. The supported branding keys are `companyName`, `logo`,\n  `backgroundColorHex`, `textColorHex`, and `accentColorHex`; use only the keys\n  the user needs, label unspecified example values as placeholders, and keep an\n  `app.json` example valid JSON. A local `logo` path\n  works on both platforms; a remote URL is not supported by the compiled iOS\n  widget. Background and text colors are iOS-only, while the accent color is\n  used by both platforms; pass every requested value in both places and leave\n  per-key platform notes out unless asked. `companyName` is reserved for future templates and is\n  not rendered today; do not assign it a platform effect.\n  For a built-in type such as countdown, explicitly state that Android uses the\n  SDK's built-in renderer and needs neither `customRenderer` nor `customWidget`.\n  Prebuild generates the native widget setup; the runtime `types` array selects\n  the countdown type on the JavaScript-initialization path, so do not claim that\n  prebuild selects or wires that runtime type.\n  Carry the iOS 16.2+ rendering gate into Expo setup answers. Mention the iOS\n  17.2+ push-to-start gate only when remote start is in scope.\n  Run `npx expo prebuild`. The plugin creates the iOS widget setup and Live\n  Activity tap forwarding; do not add the React Native manual AppDelegate\n  forwarding on top of it.\n\nReact Native and Flutter support one custom type in their shared wrapper\nconfiguration. Expo's custom type must be paired with both `customWidget`\n(SwiftUI) and `customRenderer` (Kotlin) for cross-platform rendering.\n\n## Remote lifecycle from the customer's backend\n\nUse a workspace-scoped App API bearer key and the regional App API host\n(`https://api.customer.io`, or `https://api-eu.customer.io`). Do not call these\nfrom the app. The lifecycle a customer's backend drives is:\n\n1. Against the regional host (`https://api.customer.io` or\n   `https://api-eu.customer.io`), call `POST /v1/live_notifications/start` with\n   an `identifiers` object containing exactly one key (`id`, `email`, `phone`,\n   or `internal`), `notification_type`, full non-empty\n   `attributes`, and full non-empty `content_state`. An iOS start also requires\n   `push_payload.alert.title` and `.body`. Store the returned ULID\n   `instance_id`. For example, identify a person as\n   `\"identifiers\": {\"id\": \"person-123\"}`; do not put `id` at the request root.\n2. `POST /v1/live_notifications/update` with `instance_id` and the complete new\n   `content_state`. An absent alert is a silent update; if an alert is supplied,\n   both title and body are required.\n3. `POST /v1/live_notifications/end` with `instance_id`; optional final content,\n   when present, is also a complete state rather than a partial patch and follows\n   the same alert rules.\n4. `GET /v1/live_notifications/{instance_id}` to check asynchronous state and\n   the last delivery. A just-started instance can briefly return not found.\n\nA live notification instance remains active for at most six hours. Omit\n`expiration` to use that cap; to end sooner, send a future absolute Unix\ntimestamp (seconds) no more than six hours from the start.\n\nA successful lifecycle POST means the asynchronous operation was accepted,\nnot that a device displayed the update. Confirm the result with the GET\nendpoint and inspect `last_delivery` and `failure_reason` when delivery is not\nsuccessful.\n\n`POST /v1/live_notifications/register_push_to_start` and\n`POST /v1/live_notifications/register_instance_token` are public for\nintegrations that obtain device tokens out of band. Normal mobile SDK\nintegrations report these tokens automatically through the device/edge path;\nthe SDK does not call these App API endpoints. Do not ask the customer to\nduplicate token registration from their backend.\n\n`device_id`, `platform`, and `app_identifier` can narrow the start target; when\nomitted, Customer.io selects an eligible device. Combined serialized\n`attributes` and `content_state` are capped at 3072 bytes so the complete APNs\nor FCM envelope remains within the 4096-byte push limit. App API start, update,\nand end requests over that cap are rejected synchronously with a 400 response;\ndo not describe them as a silent push-layer failure. If start returned an\n`instance_id`, its caller-content size already passed this validation.\n\nBuilt-in payload minimums:\n\n| Type | Attributes | Content state |\n|---|---|---|\n| Segments | `header` string | `status` string, `segmentsTotal` number, `segmentsComplete` number |\n| Countdown timer | `header` string | `title` string; optional positive epoch-seconds `endTime` |\n\nThere is no public type-management call: the product UI manages a type's\nmetadata and testing — say both in the answer. A backend start or SDK token\nregistration automatically registers the matching workspace type on first use. If the user needs to configure\nthe type in advance, guide them to the product UI, but do not invent an exact\nsettings-page path when the current navigation has not been confirmed. When the\nuser asks to register a type without sending, do not imply that a lifecycle\nstart is the only automatic-registration path: show the non-sending SDK\nregistration or allowlist for their platform, or ask which platform applies.\nDo not offer the public token-registration endpoints merely to mint a type;\nthey require real tokens obtained by an out-of-band integration, while normal\nSDK integrations report tokens automatically.\nCarry the stable identifier into concrete downstream guidance: for example,\nshow the iOS `register(..., identifier:)` or Android\n`enableCustomLiveNotificationTypes(...)` mapping appropriate to the app and the\nbackend `notification_type` field. If the platform is unknown, show both SDK\nmappings or ask which platform applies.\n\n## Troubleshooting order\n\n1. For a remote operation that returned an `instance_id`, inspect\n   `GET /v1/live_notifications/{instance_id}` and its `failure_reason` /\n   `last_delivery` first. Do not blindly repeat `start`; it creates a new\n   instance. When start omitted `platform`, an iOS target missing\n   `push_payload.alert.title` or `.body` can be accepted first and then fail\n   asynchronously; check `failure_reason` for that case.\n2. Confirm this is a live workspace with the Live Notifications entitlement,\n   production APNs/FCM credentials, and the released SDK minimum above. Confirm\n   the backend kept its workspace-scoped App API bearer key server-side and\n   selected `https://api.customer.io` or `https://api-eu.customer.io` for the\n   workspace's region. An account still pending verification is not an\n   end-to-end device-delivery proof.\n3. Confirm the configured type exactly matches the workspace/backend identifier.\n4. Confirm the user is identified and APNs/FCM device registration completed\n   before local start or remote delivery.\n5. iOS: check OS version, `NSSupportsLiveActivities`, Widget Extension target\n   membership, type registration before SDK initialization, and URL forwarding.\n6. Android: check the type allowlist, custom renderer, `POST_NOTIFICATIONS`, and\n   (only for promoted treatment) `POST_PROMOTED_NOTIFICATIONS` on API 36+.\n7. If an App API start, update, or end was rejected before returning an instance\n   or accepting the operation, confirm serialized `attributes` plus\n   `content_state` stays within the 3072-byte caller-content limit. A returned\n   `instance_id` already rules this out for that start payload.\n   A `404 cannot find customer` on start means the person doesn't exist yet:\n   identify them first, then retry, because an identify sent just before the\n   start may not be processed yet.\n","onboarding.md":"---\nname: onboarding\ndescription: New-account CLI/MCP onboarding entry point - use when a user is starting with Customer.io or setting it up for a project. Signup/auth, learning how the user plans to use Customer.io (shared onboarding answers), integration-first app wiring, and building a first asset. Not for existing paid-workspace operations.\ncli_only: true\n---\n# Onboarding: get a new account set up from the code\n\nCLI/MCP onboarding is **integration-first** - you are in the user's codebase, so\nwire the app and get real events flowing before building a first message. This\ndeliberately inverts the web wizard, which creates an asset first and asks about\nintegration after. Both surfaces write to the same onboarding state, so they stay\nin sync and the user can move between them (see [Step 2](#step-2--how-they-plan-to-use-customerio)).\n\nThe flow:\n1. Account - agentic signup, or connect an existing UI account ([Step 1](#step-1--account)).\n2. Learn how they plan to use Customer.io and record it ([Step 2](#step-2--how-they-plan-to-use-customerio)).\n3. Wire the app and verify real events - the first success ([Step 3](#step-3--wire-the-app-integration-first), [integration.md](integration.md)).\n4. Build a first asset for their starting point ([Step 4](#step-4--build-the-first-asset)).\n\nRun it in three beats: **collect the answers in one batch** ([Step\n2](#step-2--how-they-plan-to-use-customerio)), **give the user a concise plan**\nfor the session, then **work through it**, each gate restating the step of the\nplan it covers ([Consent gates](#consent-gates)). The user answers once, sees\nwhere the session is going, and is never asked a question that arrives without\ncontext.\n\nWhen a project/codebase is open, the first success is a working integration, not\na standalone message. For existing-workspace operations (automations, segments,\ntemplates, CDP pipelines, Design Studio editing), use the `fly-api`, `cdp-api`,\n`design-studio`, or `recipes` skills instead of this flow.\n\n## Consent gates\n\nNothing mutates without approval: no package installs, file edits, resource\ncreation, credentials, form submissions, sends, or domain changes until the user\napproves that specific step. Start with a read-only project scan. Ask in the\nuser's language (\"Do you want me to set up the real sending domain?\") and keep\ninternal IDs and endpoint names out of the question. Keep approvals narrow - no\nblanket approval for the whole flow.\n\n**Plan the session up front, then gate against that plan.** Once you have the\nStep 2 answers, give the user one concise plan for the whole session - what you\nwill wire, which files you will touch, what you will create in Customer.io, and\nwhat you will build at the end. A few lines, not a document.\n\nEach gate then restates the line of the plan it is about instead of asking cold:\n\"Next up, the part of the plan where I add the SDK to `src/app.js` and send a\nfirst event - go ahead?\" The user keeps saying yes to each mutating step, so the\nplan costs them nothing in control; it just means no gate arrives without\ncontext, and they can see how much is left. A step whose shape has changed since\nthe plan is a fresh question, not a restatement - say what changed.\n\n## Step 1 - Account\n\n**New user (default).** Unauthenticated agentic signup creates a normal Free\naccount and returns a live bootstrap token - no separate login needed afterward:\n\n```bash\ncio auth signup start --json '{\"email\":\"\u003cuser-email\u003e\"}'\n```\n```bash\ncio auth signup verify --json '{\n  \"email\": \"\u003cuser-email\u003e\",\n  \"code\": \"\u003c6-digit-code\u003e\",\n  \"company_name\": \"\u003ccompany\u003e\",\n  \"first_name\": \"\u003cfirst\u003e\",\n  \"last_name\": \"\u003clast\u003e\",\n  \"data_center\": \"\u003cus-or-eu\u003e\"\n}'\n```\n\n- Confirm email, name, company, and data center with the user before running.\n- Wait for the user to provide the emailed code; never guess one.\n- The returned `sa_live_...` token is the account's API credential. A new signup\n  has no sandbox and no go-live step - the account is live from creation, gated\n  only by verification review and sending-domain setup (see\n  [Verification and sending](#verification-and-sending)). An existing account\n  may be a [sandbox account](#sandbox-accounts).\n- **Offer the user a browser link into their dashboard** (no password needed):\n  `cio auth login`, when already signed in, prints a one-time dashboard URL. A CLI\n  signup and the web UI are one account; the user can work in either.\n\n**Tell the user what the account can send, once, when it is created.** The\nfacts worth saying, and the one thing not to say:\n\n- **A 14-day trial.** Like a web signup, the account is suspended 14 days after\n  signup unless the user picks a plan. Say it once; don't push them toward a\n  card in the first session.\n- **Every email goes to the signup address.** The workspace starts with\n  `delivery_settings: \"send_to_one\"`, which redirects every send to the address\n  the user signed up with, so a test can't reach real customers. Real\n  recipients need the workspace switched to `normal`.\n- **500 emails per 24 hours.** New accounts land on compliance tier 0, whose\n  daily email cap is 500. Deliveries past it are held with the reason \"Daily\n  email limit reached for compliance tier\", so it reads as a delivery problem\n  rather than a plan problem unless you've told them. This is the number that\n  matters in a first week, and it's the one to quote.\n- **Don't quote the plan allowance as a send rate.** A million monthly messages\n  and 5,000 monthly customers are plan *ceilings*; the 500/day cap binds long\n  before either. Quoting the million invites the user to plan a launch they\n  cannot actually send.\n\nThe 500 is deliberately set to the final stage of the published domain-warming\nschedule, so a new sender following that schedule isn't blocked mid-warmup.\nThat makes \"what can I send in week one\" a deliverability question about warming,\nnot a billing one - answer it from the warming guidance rather than inventing a\nramp here. Plan state and limits are [billing.md](billing.md).\n\n**Already signed up in the UI.** Trigger on \"I already have an account\" / \"I\nstarted in the UI\" - or on the signup dead end: `signup start` on a registered\nemail returns \"check your email\" but sends a \"you already have an account\" notice\ninstead of a code, so `signup verify` can never succeed.\n\nThe agent drives login from the conversation; the user never opens a terminal.\nStart the waiting login and, in the same message, give numbered steps with the URL\nin chat: (1) a login command is watching the clipboard, (2) open\nhttps://fly.customer.io/cli and sign in, (3) copy the token shown - it's picked up\nautomatically, nothing to paste. Never have the user paste a token into chat.\n\n```bash\ncio auth login --from-clipboard --wait   # older builds: pbpaste | cio auth login --with-token (auth.md)\n```\n\nOn timeout, re-run immediately and keep waiting ([auth.md](auth.md)). Then confirm:\n\n```bash\ncio auth status\n```\n\nRegion is discovered from the token for US accounts; if login fails with\n\"Authentication failed\", the account is likely EU - re-run with\n`--api-url https://eu.fly.customer.io` appended ([auth.md](auth.md)).\n\n**Resuming.** `cio auth status`, then rediscover account/workspace state with\nread-only calls and the onboarding answers ([Step 2](#step-2--how-they-plan-to-use-customerio)),\nand check whether it is a [sandbox account](#sandbox-accounts).\nDon't re-ask region. If signup verify never finished, continue with the same\nemail. If an account exists but the token is missing or rejected, run the login\nflow above (`--from-clipboard` and `--with-token` ignore a dead stored token - no\nlogout needed). Never create a second account to recover access ([auth.md](auth.md)).\n\n## Step 2 - How they plan to use Customer.io\n\nAsk a few direction-setting questions and record them, so the CLI and the web\nwizard read the **same** onboarding state. This is the same store Hydra's wizard\nuses: `GET/PUT /v1/onboarding/answers`, shared by every member of the account. Recording\nanswers here means a user who later opens the web UI sees them already filled, and\nvice versa.\n\n**Ask them all in one batch, not one at a time.** The user answers once, reads\nback a plan, and then watches it run - they should not have to tend to a prompt\nevery few seconds. Put every question below into a single prompt, most relevant\nfirst; on a client that renders a multi-question prompt, use one. Follow up on an\nindividual answer only when it changes what you would do next - a\n`starting_point` the dashboard won't count is the usual case.\n\nFour questions, not five: `is_implementer` is assumed rather than asked (below).\n\nAsk in the user's language:\n\n- **Goal** (`goal`, free text) - \"What's one thing you want to achieve with\n  Customer.io?\" The web wizard offers four of these as presets; when the user's\n  answer is one of them, record the preset wording so both surfaces read the goal\n  back the same way:\n  - `Re-engage customers who stopped logging in`\n  - `Convert more trial signups to paid`\n  - `Get new users to their first key action faster`\n  - `Recover failed payments before churn`\n- **Starting point** (`starting_point`) - one of `workflows`, `broadcasts`,\n  `transactional`, `user_segmentation` (\"What do you want to build first?\"). This\n  answer decides what you build in [Step 4](#step-4--build-the-first-asset), so\n  record it before building anything. The web wizard offers the first three;\n  `user_segmentation` is reachable only from here. On the setup checklist the\n  answer also names the dashboard's create step (\"Create an automation\", \"Build\n  your first broadcast\", and so on), and creating that asset closes it. The older\n  Workspace setup screen counts only a started campaign or a sent newsletter, so\n  there a segment or a transactional message leaves its message card open. Check\n  which screen the user has before promising a step will close ([Onboarding\n  progress](#onboarding-progress)).\n- **Platform** (`listen_targets`, array) - any of `website`, `mobile_app`.\n- **Channels** (`channels`, array) - any of `email`, `sms`, `push`, `in_app`,\n  `whatsapp`, `slack`, `webhooks`.\n**Don't ask whether they're the implementer** (`is_implementer`) - someone\ndriving a CLI in their own codebase is almost always the person doing the\nwiring, so the question spends a turn confirming the obvious. Record `yes` and\nmove on, which also leaves the field filled for the web wizard. If the field\nalready has an answer, leave it: a teammate may have recorded `no` in the web\nwizard.\n\nHandle the exception when the user raises it, because they will: anyone setting\nCustomer.io up for a colleague says so unprompted. Then record the real answer\n(`no` or `unsure`), and treat [Step\n3](#step-3--wire-the-app-integration-first) as aimed at the wrong person - offer\nto prepare a handoff for whoever does the wiring, and a starting point that\ndoesn't wait on the app. Leave the app's code to its developer: give the\nhandoff in chat, or write it to a file outside the app's directory, never into\nit.\n\nThe API validates these values exactly (e.g. `web` is rejected; use `website`).\nAsk in plain language and map the answer to the value yourself.\n\nOn an account that already exists, `GET /v1/onboarding/answers` first and ask\nonly for the answers still missing. Record them as you go (partial updates are\nfine - send only the answers you have; an absent field is left unchanged):\n\n```bash\ncio api /v1/onboarding/answers -X PUT \\\n  --json '{\"answers\":{\"goal\":\"\u003cfree text\u003e\",\"starting_point\":\"workflows\",\"listen_targets\":[\"website\"],\"channels\":[\"email\"],\"is_implementer\":\"yes\"}}'\n```\n\nThe PUT answers with the full merged set, so one call records and confirms. A\nmistake is a hard error rather than a silent no-op, because `cio` sends\n`X-Validate: strict`: an unknown field name comes back as\n`400 json: unknown field \"platform\"`, and a value outside the accepted set as a 422\nnaming the field (`must be one of [website, mobile_app]; got \"web\"`). Fix and\nresend rather than reading the error as \"recorded\".\n\nRead the answers back on resume with `cio api /v1/onboarding/answers`.\n\nKeep integration specifics - the detected app type, SDK, and language - **in-session\nonly**. Read them from the code; do not write them to this store or fork its schema.\n\n## Step 3 - Wire the app (integration-first)\n\nScan the project read-only (`pwd`, `git rev-parse --show-toplevel`, `rg --files`),\ninfer the app type, and ask when the target is ambiguous. Then continue with\n[integration.md](integration.md), passing along: environment ID, region, the\nplatform and channels the user chose in Step 2, whether they're the implementer,\nthe signup email, and company/brand context.\n\nThe integration proves one identify, one page/screen event, and one action event\nin the CDP source's events list - a package install is not proof.\n\n**This is the first win.** Read the events and people back and show the user they\nlanded - seeing real data in their own workspace is the payoff of the first\nsession, and it's available even while sending is held for verification. Confirm\nwith the integration status, and point the user to the same people and events in\nthe dashboard (the `cio auth login` link):\n\n```bash\ncio api /v1/environments/{environment_id}/setup_guide/integration_status \\\n  --params '{\"environment_id\":\"\u003cenvironment_id\u003e\"}' --jq '{customers, events, attributes}'\n```\n\nThis counts as the integration working, and it is worth showing off. Whether it\nalso closes a dashboard step depends on the screen ([Onboarding\nprogress](#onboarding-progress)): on the setup checklist one person or one event\ncloses \"Add your people or events\", while the older Workspace setup screen's\npeople cards want 40 profiles. Report what the user's own screen shows. A user who\nhears \"done\" and then finds an open step on their dashboard stops trusting the rest\nof the summary.\n\nThen move on to their first asset.\n\n## Step 4 - Build the first asset\n\nWith events flowing, build the first thing for their `starting_point` from Step 2:\n\n- `workflows` / `broadcasts` / `user_segmentation` - build it via the `fly-api`\n  and `design-studio` skills.\n- `transactional` - author and test a message\n  (`cio skills read fly-api/transactional_send.md`).\n\nOn the setup checklist, building the asset closes the create step as soon as it\nexists, drafts included ([Onboarding progress](#onboarding-progress)). The older\nWorkspace setup screen is stricter, and two separate things stop the asset from\ncounting there - say so rather than reporting the step done:\n\n- **The dashboard reads a stored pointer, not your new asset.** Its \"Send your\n  first real message\" card looks up `first_message_id` / `first_message_type` on\n  the setup guide and reads nothing at all when they are absent. Record them when\n  you build the asset, whichever screen the user has, so the pointer is in place\n  if they ever see that card.\n\n  The PUT replaces the **whole** setup guide, not just the fields you send.\n  Anything left out, such as the skip flags that mark cards done and the\n  messaging-channel state, is erased, and the call still returns 200. Read the\n  current guide first, add the two fields to the `setup_guide` object it\n  returns, and PUT the whole object back:\n\n  ```bash\n  cio api /v1/environments/{environment_id}/setup_guide/current \\\n    --params '{\"environment_id\":\"\u003cenvironment_id\u003e\"}'\n\n  cio api /v1/environments/{environment_id}/setup_guide/current -X PUT \\\n    --params '{\"environment_id\":\"\u003cenvironment_id\u003e\"}' \\\n    --json '{\"setup_guide\":{\u003cevery field from the GET\u003e,\"first_message_id\":\u003ccampaign_id\u003e,\"first_message_type\":\"campaign\"}}'\n  ```\n\n  `first_message_type` is `campaign` or `newsletter`; a segment or a transactional\n  message has no card to satisfy.\n\n- **A draft never counts there.** That card wants a campaign whose state has left\n  `draft` (or a newsletter with a `sent_at`), so the asset only completes it once\n  the automation is started. On a fresh account, starting an email automation\n  needs a verified sending domain and a sender identity first, so this card\n  usually stays open at the end of a first session. That is the expected outcome,\n  not a failure - tell the user what it is waiting for\n  ([Verification and sending](#verification-and-sending)) instead of leaving them\n  to find an unexplained open step.\n\nActually **sending** the asset is the strong secondary win, and it is gated the\nsame way: delivery stays disabled until verification review approves the account,\nand real email needs a verified sending domain. Frame the send as the next\nmilestone once those clear, not something to promise in the first session.\n\n## Verification and sending\n\nA new account is created **pending verification review**, with delivery disabled\nuntil approved - the same gate a web signup gets, not a sandbox. Real email also\nneeds a **verified sending domain** (there is no starter domain).\n\nTreat verification as a background step, not a blocker - the same way the\nEssentials onboarding does, where it's a setup item rather than a gate on the\nrest of the flow. Keep going: the user can wire the app, see their events and\npeople, and build their first asset while review is pending; only *sending*\nwaits. Tell them plainly that they're set up and that sending unlocks once the\naccount is verified and a sending domain is added, then move on rather than\nstopping to wait. If they ask about status, read it back:\n\n```bash\ncio api /v1/accounts/{account_id} --params '{\"account_id\":\"\u003caccount_id\u003e\"}' \\\n  --jq '{verification_state: .account.verification_state}'\n```\n\nSet up a real sending domain when the user is ready to send:\n\n```bash\ncio domains --env-id \u003cenvironment_id\u003e list --jq '[.domains[]? | {domain, verified}]'\n```\n\nIf none is present, ask which domain they will send from (confirm it with them\nfirst), then:\n\n```bash\ncio domains --env-id \u003cenvironment_id\u003e add \u003cdomain\u003e\ncio domains --env-id \u003cenvironment_id\u003e configure \u003cdomain\u003e\ncio domains --env-id \u003cenvironment_id\u003e verify \u003cdomain\u003e\n```\n\n- `configure` starts the one-click DNS flow; only pass `--cname` if the user\n  explicitly wants link tracking.\n- DNS can take time - verification can be retried; don't block on it.\n\nThen add a from address on it:\n\n```bash\ncio domains --env-id \u003cenvironment_id\u003e from_addresses add \\\n  --name \"\u003cDisplay Name\u003e\" \\\n  --email \"hello@\u003cdomain\u003e\"\n```\n\nBilling is not a gate here: a Free account can send within its plan limits\nonce verified. For plan and pricing questions, read [billing.md](billing.md).\n\n## Sandbox accounts\n\nAccounts provisioned through Stripe's free Builder tier, and accounts from the\nretired Builder signup, are sandbox accounts. Check once when you resume or log\nin to an existing account:\n\n```bash\ncio api /v1/account_by_environment/{environment_id} \\\n  --params '{\"environment_id\":\"\u003cenvironment_id\u003e\"}' --jq '{sandbox: .account.sandbox}'\n```\n\nIf `sandbox` is `true`, these rules replace the sending guidance elsewhere in\nthis skill:\n\n- **Send email only from the starter domain, only to verified test people.**\n  The server holds everything else with the reason \"Account is in sandbox mode -\n  complete go-live to send\". Use the domain with `starter: true` from\n  `cio domains --env-id \u003cenvironment_id\u003e list`. Push, in-app, SMS and webhooks\n  can't be tested until go-live.\n- **Test people (up to 10) need double opt-in.** The account creator is usually\n  already verified, so send the first test there. List verified recipients with\n  `GET /v1/environments/{environment_id}/test_users` and\n  `\"verified\":\"true\"` in `--params`. To add someone, create the profile with an\n  email, then `PUT /v1/environments/{environment_id}/customers/{customer_id}/double_opt_in`\n  with `{}`. They can receive sends once they click the confirmation email. A\n  send to anyone else fails with \"Profile has not completed double opt-in\".\n- **The token stays `sa_sandbox_...`.** Re-running login returns the same token,\n  so don't try to swap it.\n- **Sending to real recipients takes go-live.**\n  `POST /v1/accounts/{account_id}/go_live` needs a sending domain the user owns\n  (not `starter: true`) and payment on file. Until both exist it returns 422\n  with a `missing` list (`domain`, `payment`). Get the user's approval before\n  calling it. Afterward the CLI swaps to a `sa_live_` token on a later command\n  by itself. Don't call `promote_sandbox_token`: its response prints the\n  plaintext token.\n\n## Resume or recommend next step\n\nUse this read-only pass whenever the user asks what's left or what to do next.\nBuild a short state snapshot, then recommend exactly one next action. Do not assume\na fresh signup.\n\n```bash\ncio auth status --jq '{account_id, environment_id, region, verified}'\n```\n```bash\ncio api /v1/onboarding/answers\n```\n\nFix auth first when it's missing or unverified ([auth.md](auth.md)). When no\nworkspace can be selected, list environments and ask which one to use. Otherwise\nread the onboarding steps below and recommend one of them.\n\n### Onboarding progress\n\nThe dashboard's home page shows one of two setup screens, and each recomputes its\nsteps from live state on every render. Read the same state and your \"what's next\"\nagrees with the screen the user is looking at. Get a rule wrong and you report a\nstep done that their dashboard still shows open, which is the worst outcome here -\nso use the rules below rather than inferring a step from the work you just did.\n\nWhich screen the user has depends on an account feature flag:\n\n```bash\ncio api /v1/accounts/{account_id} --params '{\"account_id\":\"\u003caccount_id\u003e\"}' \\\n  --jq '.account | {account_state, essentials_onboarding: .feature_flags.essentials_onboarding}'\n```\n\n`essentials_onboarding: true` means the **setup checklist** below. Otherwise a\n`free` account sees the older **Workspace setup** screen. Describe only the\nscreen the user has; never mix the two sets of steps in one answer.\n\n#### Setup checklist (`essentials_onboarding` on)\n\nA \"Get set up\" rail with three steps, in this order:\n\n1. **Create step** - its title follows `starting_point`: \"Create an automation\"\n   (`workflows`), \"Build your first broadcast\", \"Build a transactional message\",\n   or \"Segment your audience\". Done once the asset **exists**, drafts included:\n   any non-deleted campaign for `workflows`; a newsletter or an API-triggered\n   broadcast for `broadcasts`; a message you created (not the default catch-all)\n   for `transactional`; a segment beyond the seeded samples for\n   `user_segmentation`. Nothing needs to be started or sent.\n2. **Add your people or events** - done once the workspace has **one** person or\n   **one** event (`customers \u003e 0` or `events \u003e 0` from `integration_status`), or a\n   source you added has received data. A wired integration's first test events\n   close it.\n3. **Set up your sending domain** - done once a verified custom domain has a from\n   address on it (`cio domains ... list` and `cio domains ... from_addresses\n   list`, [Verification and sending](#verification-and-sending)). A domain with no\n   from address doesn't count.\n\nWhen all three are done, the checklist stays on home until a workspace leaves\ntest mode with a verified domain; then the standard home takes over. Account\nverification is shown separately and is not one of the three steps.\n\n#### Workspace setup screen (`essentials_onboarding` off)\n\nSix cards in two groups.\n\n**Top group - the three setup cards.** Each one is skippable, and a skip counts as\ndone for that card:\n\n1. **Email setup** - needs all three: a verified sending domain, an email sender\n   identity on it, and the workspace out of test mode. Skipped by\n   `skip_email_channel_setup`. Reads: `cio domains ... list` and\n   `cio domains ... from_addresses list` ([Verification and\n   sending](#verification-and-sending)).\n2. **Messaging setup** - any one of `twilio`, `slack`, `in_app`, `push`, or\n   `whatsapp` on the environment's `enabled_actions` (a custom-SMS `sms` action\n   satisfies the Twilio slot). Skipped by `skip_messaging_channels_setup`. Email is\n   **not** on this list - it has its own card.\n3. **Integration** - at least **40 people** in the workspace. Skipped by\n   `skip_integration_step`.\n\n**Bottom group - \"Up next\".** Not skippable:\n\n4. **Invite team member** - more than one team member on the account.\n5. **Add people with an integration** - the same 40-people rule as card 3; the\n   card shows the user how many more it wants.\n6. **Send your first real message** - a stored pointer plus a non-draft asset. See\n   [Step 4](#step-4--build-the-first-asset), which covers both halves.\n\nTwo rules on this screen that are easy to get wrong, and were wrong here before:\n\n- **The people threshold is 40, not one.** The read is\n  ```bash\n  cio api /v1/environments/{environment_id}/setup_guide/integration_status \\\n    --params '{\"environment_id\":\"\u003cenvironment_id\u003e\"}' --jq '{customers, events, attributes}'\n  ```\n  and only `customers` counts - the cards ignore `events` entirely. A freshly\n  wired integration that has sent a handful of test events therefore leaves both\n  people cards open, correctly. Events landing is still the real first win of\n  [Step 3](#step-3--wire-the-app-integration-first): report it as the integration\n  working, which it is, and say plainly that the dashboard's people cards want 40\n  profiles before they close. Don't call those cards done, and don't treat the\n  gap as a bug to chase.\n- **Account verification is not one of these cards.** Verification review gates\n  *sending*, and it has no step on this screen. Read it when the user asks why a\n  send is held, not to answer \"what's left\".\n\n#### Recommending the next step\n\nRecommend the first incomplete step, in **this flow's** order rather than the\nscreen's: with a project open, wiring the app comes before building an asset,\nwhich is the inversion this skill opens with. The rules above are what each screen\ncomputes; the order and grouping are just how it draws them.\n\nTwo things to keep straight when you recommend:\n\n- A step this user can't complete themselves, because their role lacks permission\n  for that asset type, is not their next action. Say who needs to do it and move to\n  the next step.\n- When the user now wants a different first asset than they answered, update\n  `starting_point` before building it ([Step\n  2](#step-2--how-they-plan-to-use-customerio)). Otherwise the dashboard keeps\n  asking them for the old one.\n\n## Done\n\nSummarize what's set up, then offer the next step: keep building, wire more of the\napp, or set up a sending domain to start sending.\n","sdk_setup.md":"---\nname: sdk_setup\ndescription: Quick-start install + initialization snippets for every Customer.io SDK (Expo, React Native, iOS, Android, Flutter, JavaScript, Node.js, Python, Go).\ncli_only: true\n---\n\n# SDK Setup Guides\n\nQuick-start setup guides for all Customer.io SDKs. For troubleshooting, read\n[sdk_troubleshooting.md](sdk_troubleshooting.md).\n\nFor the opt-in Premium Live Notifications / iOS Live Activities feature, read\nthe platform-specific prerequisites and released version floors in\n[live_notifications.md](live_notifications.md); do not infer them from the\ngeneral push setup below.\n\n**Every Data In SDK setup requires a CDP source.** Before giving setup\ninstructions, ensure the matching source exists in the workspace. For web and\nserver-side SDKs, create one via `@skills/cdp-api/sources.md` if needed. For\nmobile SDKs, follow [integration_mobile.md](integration_mobile.md). The source\nprovides the Write Key used in the SDK setup code below.\n\n## Mobile SDKs\n\nGeofencing is gated on the account's `geofences` feature. For accounts that have\nit, do not extend the generic snippets below; read\n`@skills/recipes/mobile_geofencing.md` for the supported SDK versions, optional\nmodules, permissions, background-delivery setup, wrapper build flags, and Expo\nconfig-plugin shape.\n\nThe mobile SDKs use a **CDP source write key** (`cdpApiKey`), not an App API key.\nRead it from the app's existing configuration layer and keep sandbox and\nproduction values separate. The examples below use placeholders only; do not\nhardcode a real key in source control or print it while verifying the setup.\n\n### Expo\n\n```bash\nnpx expo install customerio-expo-plugin customerio-reactnative\n```\n\nOn Expo 53 or newer, prefer the plugin's auto-initialization path. Add it to\n`app.config.js` (or adapt the same shape to the app's existing config layer):\n\n```javascript\nexport default {\n  expo: {\n    plugins: [\n      [\n        \"customerio-expo-plugin\",\n        {\n          config: {\n            cdpApiKey: process.env.CUSTOMERIO_CDP_WRITE_KEY,\n            siteId: process.env.CUSTOMERIO_SITE_ID,\n            region: \"us\",\n          },\n        },\n      ],\n    ],\n  },\n};\n```\n\nUse `\"eu\"` for an EU workspace. Do not add `android` or `ios` push options\nunless push is requested.\n\n```bash\nnpx expo prebuild\n```\n\nThe plugin initializes the SDK, so do not call `CustomerIO.initialize()` again.\nUse the runtime SDK for identify and track calls:\n\n```javascript\nimport { CustomerIO } from \"customerio-reactnative\";\n\nawait CustomerIO.identify({\n  userId: \"user-123\",\n  traits: { email: \"user@example.com\" },\n});\nawait CustomerIO.track(\"Signed In\", { method: \"password\" });\n```\n\n⚠️ Expo Go is **not supported**. Use Development Build (Dev Client) or Bare workflow.\n\nFor Expo 52 or older, use the documented manual-initialization path instead of\nthe `config` object. Never combine auto-initialization and manual initialization.\n\n📚 https://customer.io/docs/sdk/expo/\n\n### React Native\n\n```bash\nnpm install customerio-reactnative\ncd ios \u0026\u0026 pod install\n```\n\n```javascript\nimport { CioRegion, CustomerIO } from \"customerio-reactnative\";\nimport { CUSTOMERIO_CDP_WRITE_KEY, CUSTOMERIO_SITE_ID } from \"./config\";\n\nexport async function initializeCustomerIoSDK() {\n  await CustomerIO.initialize({\n    cdpApiKey: CUSTOMERIO_CDP_WRITE_KEY,\n    region: CioRegion.US,\n    inApp: {\n      siteId: CUSTOMERIO_SITE_ID,\n    },\n  });\n}\n```\n\nDefining that helper is not the setup — the integration is only wired once a\nstartup path calls it. Ship the call site as code, awaited, and once per app\nlaunch; an answer that defines `initializeCustomerIoSDK` and only tells the\nreader to \"call it from your startup path\" leaves the SDK uninitialized:\n\n```javascript\n// App.tsx — the app's existing root component, mounted once at launch.\nimport { useEffect } from \"react\";\nimport { initializeCustomerIoSDK } from \"./customerio\";\n\nexport default function App() {\n  useEffect(() =\u003e {\n    (async () =\u003e {\n      await initializeCustomerIoSDK();\n    })();\n  }, []);\n\n  return \u003cRootNavigator /\u003e;\n}\n```\n\nAdapt the call site to whatever already runs at launch in the app (root\ncomponent, navigation container, existing bootstrap module) rather than adding\na new entry point, and keep the `await` on the initializer as shown. Only\n`AppRegistry.registerComponent` in `index.js` stays synchronous — React Native\nmust find the component registered before anything is awaited there.\n\nIdentify and track after initialization resolves — at sign-in and where the\nevent occurs:\n\n```javascript\nawait CustomerIO.identify({\n  userId: \"user-123\",\n  traits: { email: \"user@example.com\" },\n});\nawait CustomerIO.track(\"Signed In\", { method: \"password\" });\n```\n\n📚 https://customer.io/docs/sdk/react-native/\n\n### iOS (Swift)\n\nAdd `https://github.com/customerio/customerio-ios.git` through Swift Package\nManager and select the products the app needs. The Swift package product names\nare `DataPipelines` and `MessagingInApp`; the modules imported in code are\n`CioDataPipelines` and `CioMessagingInApp`. `DataPipelines` is required; add\n`MessagingInApp` only when the app uses in-app messaging.\n\n```swift\nimport CioDataPipelines\nimport CioMessagingInApp\n\nfunc initializeCustomerIoSDK() {\n    let region = Region.US\n    let config = SDKConfigBuilder(cdpApiKey: AppConfig.customerIOCDPWriteKey)\n        .region(region)\n        .build()\n    CustomerIO.initialize(withConfig: config)\n\n    MessagingInApp.initialize(\n        withConfig: MessagingInAppConfigBuilder(\n            siteId: AppConfig.customerIOSiteID,\n            region: region\n        ).build()\n    )\n}\n```\n\nCall the initializer once from the app's existing startup path. Use\n`CustomerIO.shared.identify` and `CustomerIO.shared.track` where those events\noccur.\n\n```swift\nCustomerIO.shared.identify(\n    userId: \"user-123\",\n    traits: [\"email\": \"user@example.com\"]\n)\nCustomerIO.shared.track(\n    name: \"Signed In\",\n    properties: [\"method\": \"password\"]\n)\n```\n\nFor push: Add Notification Service Extension and initialize MessagingPush.\n\n📚 https://customer.io/docs/sdk/ios/\n\n### Android (Kotlin)\n\nPick the version before you write the dependency block. Keep an existing\nsupported Customer.io version when the project already pins one. Otherwise use\nthe latest stable release, and use the same version for both modules. The pin\nbelow was verified as the latest stable release on **2026-09-21** against\nhttps://github.com/customerio/customerio-android/releases — check that page\nagain rather than trusting this number.\n\n```gradle\nimplementation(\"io.customer.android:datapipelines:4.21.1\")\nimplementation(\"io.customer.android:messaging-in-app:4.21.1\")\n```\n\n```kotlin\nimport android.app.Application\nimport io.customer.messaginginapp.MessagingInAppModuleConfig\nimport io.customer.messaginginapp.ModuleMessagingInApp\nimport io.customer.sdk.CustomerIO\nimport io.customer.sdk.CustomerIOConfigBuilder\nimport io.customer.sdk.data.model.Region\n\nclass App : Application() {\n    override fun onCreate() {\n        super.onCreate()\n\n        val config = CustomerIOConfigBuilder(\n            applicationContext = this,\n            cdpApiKey = BuildConfig.CUSTOMERIO_CDP_WRITE_KEY,\n        )\n            .region(Region.US)\n            .addCustomerIOModule(\n                ModuleMessagingInApp(\n                    config = MessagingInAppModuleConfig.Builder(\n                        siteId = BuildConfig.CUSTOMERIO_SITE_ID,\n                        region = Region.US,\n                    ).build(),\n                ),\n            )\n            .build()\n\n        CustomerIO.initialize(config)\n    }\n}\n```\n\nKeep `onCreate` to configuration and initialization only. Identify and track\nwhere those events actually happen — for example in the sign-in callback and at\nthe point the tracked action occurs:\n\n```kotlin\n// Sign-in callback — not Application.onCreate.\nfun onSignInSucceeded(user: User) {\n    CustomerIO.instance().identify(\n        userId = user.id,\n        traits = mapOf(\"email\" to user.email),\n    )\n    CustomerIO.instance().track(\n        name = \"Signed In\",\n        properties = mapOf(\"method\" to \"password\"),\n    )\n}\n```\n\n`user-123` and `user@example.com` are non-production test values for verifying\nthe integration — show that call in code, with those literal values, from a\ndebug-only path or a one-off manual run, to confirm the profile and event land in the workspace.\nNever wire them, or any fixed identifier, into `Application.onCreate`: every app\nstart would identify the same profile and attribute the event to it, regardless\nof who is signed in.\n\nRegister the application subclass in `AndroidManifest.xml`, adapting the class\nname when the app already has one:\n\n```xml\n\u003capplication\n    android:name=\".App\"\n    ...\u003e\n\u003c/application\u003e\n```\n\nAdd `messaging-push-fcm` and `google-services.json` only when the app needs push.\nFor push on **Android 13+**, declare the `POST_NOTIFICATIONS` permission in\n`AndroidManifest.xml` and request it at runtime; without it, notifications are\nnot displayed.\n\n📚 https://customer.io/docs/sdk/android/\n\n### Flutter\n\n```bash\nflutter pub add customer_io\n```\n\n```dart\nimport 'package:customer_io/customer_io.dart';\nimport 'package:customer_io/customer_io_config.dart';\nimport 'package:customer_io/customer_io_enums.dart';\n\nFuture\u003cvoid\u003e initializeCustomerIoSDK() async {\n  await CustomerIO.initialize(\n    config: CustomerIOConfig(\n      cdpApiKey: AppConfig.customerIOCDPWriteKey,\n      region: Region.us,\n      inAppConfig: InAppConfig(siteId: AppConfig.customerIOSiteID),\n    ),\n  );\n}\n```\n\nCall the initializer once from the app's existing startup path. Identify and\ntrack at the relevant application events:\n\n```dart\nCustomerIO.instance.identify(\n  userId: \"user-123\",\n  traits: {\"email\": \"user@example.com\"},\n);\nCustomerIO.instance.track(\n  name: \"Signed In\",\n  properties: {\"method\": \"password\"},\n);\n```\n\nFor push on Flutter:\n- **iOS**: add a Notification Service Extension target in Xcode and initialize `MessagingPush` inside it (use the nested wrapper pattern in [sdk_troubleshooting.md](sdk_troubleshooting.md))\n- **Android**: configure FCM by adding `google-services.json` to `android/app/` and applying the Google Services Gradle plugin\n\n📚 https://customer.io/docs/sdk/flutter/\n\n## Web SDK\n\n### JavaScript (Browser)\n\nFor websites, prefer the browser/client-side CDP analytics package or source\nsnippet. Client-side loading is the path that supports web in-app\nfunctionality. Only use server-side web tracking when the user explicitly\nrequires it or there is no browser runtime.\n\nFor modern JS apps, fetch the current package README and install the browser\npackage:\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/customerio/cdp-analytics-js/main/packages/browser/README.md\n```\n\nEvery browser integration needs the library loaded exactly once and exactly one\ninitial `page` call on load. Which side writes those two calls depends on the\ninstall path — write them on both sides and the library loads twice and the\nfirst page view is counted twice.\n\n**Package import (bundler/build step).** You own both calls. Load the client at\nmodule scope, keep the client itself in module state, and route every `page()`\nthrough one function that remembers the path it last sent:\n\n```javascript\n// src/lib/analytics.js\nimport { AnalyticsBrowser } from \"@customerio/cdp-analytics-browser\";\n\nexport const cioanalytics = AnalyticsBrowser.load({\n  writeKey: import.meta.env.VITE_CIO_CDP_WRITE_KEY,\n});\n\n// Module scope, not component state — both have to survive a remount.\nlet sentPath = null;\nlet initialPageAlreadySent = false;\n\n// Snippet path only — see below. Records that the first page view is already\n// out without sending one, so the next path reported is adopted as that view.\n// On the package path nothing calls this and nothing has been sent yet.\nexport function markInitialPageSent() {\n  initialPageAlreadySent = true;\n}\n\nexport function sendPageOnce(path) {\n  if (sentPath === path) return;\n  sentPath = path;\n  if (initialPageAlreadySent) {\n    initialPageAlreadySent = false;\n    return;\n  }\n  cioanalytics.page();\n}\n```\n\n`import.meta.env.VITE_*` is Vite's accessor and prefix. Use whatever public\nclient env name the app's framework exposes — `process.env.NEXT_PUBLIC_*` on\nNext.js, `PUBLIC_*` on SvelteKit and Astro, `REACT_APP_*` on CRA — rather than\ncopying the Vite form into a project that doesn't use Vite.\n\nA router hook feeds every path through that same function — the first one\nincluded — so the hook needs no guard of its own:\n\n```jsx\n// src/analytics/PageTracker.jsx — mount once inside the router.\nimport { useEffect } from \"react\";\nimport { useLocation } from \"react-router-dom\";\n\nimport { sendPageOnce } from \"../lib/analytics\";\n\nexport function PageTracker() {\n  const { pathname, search } = useLocation();\n\n  useEffect(() =\u003e {\n    sendPageOnce(pathname + search);\n  }, [pathname, search]);\n\n  return null;\n}\n```\n\nEvery path the guard compares has to come from one source. Once there is a\nrouter, that source is the router: the hook's first run sends the initial page\nview, its later runs send each route change on top of it, and do not *also* seed the guard from `window.location` at module\nscope. `useLocation()` reports the path inside the router — a `basename` is\nstripped out of it, and a `HashRouter` reads it from the fragment — so under\neither of those it never equals `window.location.pathname`, the comparison\nmisses, and the first view goes out twice.\n\nAn app with no router has no hook and no second source, so there the\nmodule-scope call is what sends the first view:\n\n```javascript\nsendPageOnce(window.location.pathname + window.location.search);\n```\n\nCompare against the last path sent, not a one-shot `useRef(true)` first-render\nflag. React StrictMode mounts, unmounts and remounts in development, so the\neffect runs twice on the initial route: a boolean flips on the first run and\nlets the second through, sending the initial view again. `sentPath` sits in\nmodule scope, so it survives that remount and both runs collapse into one call.\nThe tradeoff is that re-navigating to the path already showing sends nothing —\nincluding `search` keeps query-string changes counted, and losing the rare\nsame-path navigation beats double-counting every load.\n\n**SSR frameworks (Next.js, Remix, Nuxt, SvelteKit): make the load client-only.**\nA `\"use client\"` directive is not enough — client components still pre-render on\nthe server, and `AnalyticsBrowser.load` reaches for `document`. Guard the load\nwith `typeof window !== \"undefined\"` or move it into a mount effect. Use the\nframework's own location hook (`usePathname` plus `useSearchParams` on the\nNext.js App Router) in place of `useLocation`, and keep `sentPath` in module\nscope — that is what makes the StrictMode double invocation and the framework's\nown remounts send one call instead of two.\n\nWhatever the router, the arithmetic is the same: one initial call plus one per\nnavigation. Pairing a second initial `page()` with the hook is the wrong count,\nand so is a guard the hook's paths can never match.\n\n**Generated source snippet (static/snippet-based sites).** Paste the snippet\nunchanged, then read what it actually does before writing anything beside it —\nthe fetched snippet, not this file, decides what is left to add. Search it for\n`.load(` and `.page()`:\n\n- **both present** — add only `identify`, `track`, and route-change `page()`\n  calls\n- **`load` but no `page()`** — add exactly one initial `page()` after it\n- **neither** — this is not the generated snippet; refetch it from the source\n  rather than hand-writing one\n\nRun that check every time. The template the catalog serves changes, and a given\nworkspace can be served an older one, so which of the first two branches applies\nis not something to carry over from a previous integration or assume from this\nfile.\n\nNever add a second `load(...)`. A source's `setup_tips` (\"Call analytics.load()\nand analytics.page() to initialize\") describes the hand-written package path;\napplied on top of a snippet that already loads the library it loads it twice.\nCheck the snippet against the tips, not the other way round.\n\nThe snippet names its global from the `${analyticsKey}` placeholder rather than\na fixed identifier: read that name out of the snippet's own `var i=\"…\"` (the\nsame value it copies into `data-global-customerio-analytics-key`) and call\nthrough `window[\u003cthat name\u003e]`. It is `cioanalytics` in the usual setup:\n\n```javascript\ncioanalytics.identify(\"user-123\", {\n  email: \"user@example.com\",\n  name: \"Cool Person\"\n});\n\ncioanalytics.track(\"Signup Form Submitted\", {\n  location: \"homepage\"\n});\n```\n\nFor SPAs on either path, call `page()` on every route change — *in addition to*\nthe initial page call, never a replacement for it. A snippet-based SPA reuses\nthe module above with one edit: take the client from the global the snippet\ndeclared instead of importing and loading one.\n\n```javascript\n// src/lib/analytics.js — snippet path: the snippet already loaded the client.\nconst cioanalytics = window.cioanalytics;\n\n// sentPath, markInitialPageSent and sendPageOnce are otherwise unchanged.\n```\n\nThen, only when the check above found a `page()` call in the snippet, mark that\nview before the router mounts:\n\n```javascript\n// src/main.jsx — runs before PageTracker's first effect.\nimport { markInitialPageSent } from \"./lib/analytics\";\n\nmarkInitialPageSent();\n```\n\nThat call sends nothing. It tells the guard that the first path the hook reports\nis already counted, so the guard is still seeded from the router's own path and\na `basename` or hash router cannot desynchronize it. A snippet that loads\nwithout a `page()` of its own needs no mark — the hook's first run is the\ninitial view, exactly as on the package path.\n\nFor EU workspaces, configure the EU endpoint/region according to the current\nSDK docs.\n\nNever expose App API keys or service account tokens in browser code.\nTransactional sends belong in backend code; read\n`cio skills read fly-api/transactional_send.md`.\n\nDocs: https://docs.customer.io/cdp/sources/connections/javascript/js-source/\n\n## Server-Side SDKs\n\nServer-side write keys are secrets. Read them from the process environment or\nthe project's secret store — never a literal in source. The `CIO_CDP_WRITE_KEY`\nname below is the convention in\n[integration_backend.md](integration_backend.md); use the project's existing one\nwhen it has one.\n\n### Node.js\n\n```bash\nnpm install @customerio/cdp-analytics-node\n```\n\n```javascript\nimport { Analytics } from '@customerio/cdp-analytics-node';\n\nconst analytics = new Analytics({\n  writeKey: process.env.CIO_CDP_WRITE_KEY,\n  // host: 'https://cdp-eu.customer.io'  // for EU region\n});\n\nanalytics.identify({\n  userId: '019mr8mf4r',\n  traits: { name: 'Cool Person', email: 'cool.person@example.com', plan: 'Enterprise' }\n});\n\nanalytics.track({\n  userId: '019mr8mf4r',\n  event: 'Item Purchased',\n  properties: { revenue: 99.99, product: 'Premium Plan' }\n});\n```\n\n📚 https://docs.customer.io/cdp/sources/connections/nodejs/\n\n### Python\n\n```bash\npip install customerio-cdp-analytics\n```\n\n```python\nimport os\n\nfrom customerio import analytics\n\nanalytics.write_key = os.environ['CIO_CDP_WRITE_KEY']\n# analytics.host = 'https://cdp-eu.customer.io'  # for EU region\n\nanalytics.identify('019mr8mf4r', {\n  'email': 'cool.person@example.com',\n  'first_name': 'Cool',\n  'last_name': 'Person',\n})\n\nanalytics.track('019mr8mf4r', 'Item Purchased', {\n  'revenue': 99.99,\n  'product': 'Premium Plan'\n})\n```\n\n📚 https://docs.customer.io/cdp/sources/connections/python/\n\n### Go\n\n```bash\ngo get github.com/customerio/cdp-analytics-go\n```\n\n```go\nimport (\n  \"os\"\n\n  \"github.com/customerio/cdp-analytics-go\"\n)\n\nclient, err := analytics.NewWithConfig(os.Getenv(\"CIO_CDP_WRITE_KEY\"), analytics.Config{\n  // Endpoint: \"https://cdp-eu.customer.io\",  // for EU region\n})\nif err != nil {\n  return err\n}\ndefer client.Close()\n\nclient.Enqueue(analytics.Identify{\n  UserId: \"019mr8mf4r\",\n  Traits: analytics.NewTraits().\n    SetName(\"Cool Person\").\n    SetEmail(\"cool.person@example.com\").\n    Set(\"plan\", \"Enterprise\"),\n})\n\nclient.Enqueue(analytics.Track{\n  UserId: \"019mr8mf4r\",\n  Event:  \"Item Purchased\",\n  Properties: analytics.NewProperties().\n    Set(\"revenue\", 99.99).\n    Set(\"product\", \"Premium Plan\"),\n})\n```\n\n📚 https://docs.customer.io/cdp/sources/connections/go/\n\n## Common Setup Mistakes\n\n- **Wrong API key**: Use the **CDP source Write Key**, not the App API key\n- **Key as a literal**: read it from env/config; server-side keys are secrets\n- **Region mismatch**: Check if workspace is US or EU, set SDK region to match\n- **Not calling identify() first**: Always identify before tracking events\n- **Expo Go**: Not supported — use Development Build\n","sdk_troubleshooting.md":"---\nname: sdk_troubleshooting\ndescription: Troubleshooting workflow for Customer.io SDK push, event tracking, in-app, deep links, iOS AppDelegate issues.\ncli_only: true\n---\n\n# SDK Integration \u0026 Troubleshooting\n\nUse this skill when helping users with Customer.io SDK setup, push notification issues, event tracking problems, or any SDK integration question.\n\nFor Premium Live Notifications on Android or Live Activities on iOS, read\n[live_notifications.md](live_notifications.md) before applying the generic push\nchecks below. The feature has separate type registration, rendering, OS, and\nlifecycle requirements.\n\n## Troubleshooting Workflow\n\nAlways follow this order:\n\n1. **Start with the minimal fix** — check configuration before code changes\n2. **Verify the basics** — SDK initialization, API keys, region settings\n3. **Enable debug logging** — review SDK behavior in application logs\n4. **Apply one change at a time** — validate before proceeding\n5. **Consult Customer.io's setup documentation** for platform-specific setup instructions\n\n## Common Symptoms\n\n### Push Notifications Not Showing\n\n**Quick checks by platform:**\n- **iOS**: Must use real device (simulator can't receive push). Verify Notification Service Extension (NSE) is configured.\n- **Android**: Check notification channels exist. Verify `google-services.json` is in `app/` directory. Android 13+ requires POST_NOTIFICATIONS permission.\n- **React Native**: Run `cd ios \u0026\u0026 pod install` after package changes. Run `npx expo prebuild` for Expo.\n- **Flutter**: Uses nested AppDelegate wrapper pattern (see iOS Patterns below).\n\n**Diagnosing:**\n- Gradual delivery drop over time → token decay (normal)\n- Sudden drop → configuration or permission issue\n- Never delivered → setup problem\n\n### Events Not Showing in Workspace\n\n- Verify SDK initialized with correct region (US vs EU)\n- Call `identify()` with a stable user ID **before** tracking events\n- Verify you're using the **CDP source Write Key**, not the App API key\n- Check workspace Activity logs for the user ID\n\n### Geofence Transitions Missing\n\nGeofencing is gated on the account's `geofences` feature. For accounts that have\nit, read `@skills/recipes/mobile_geofencing.md` and follow its diagnostic chain. In\nparticular, verify the optional module is present at build time, Location and\nGeofence are both registered, the user was identified before the crossing,\nbackground location is granted, and iOS cold-wake bootstrap is installed. For\nExpo, verify SDK 53+, plugin 3.7.0+, top-level `geofence.enabled`, and a fresh\nprebuild.\n\n### Push Metrics Missing (delivered, opened, clicked)\n\n- **iOS**: Verify AppDelegate pattern — don't mix wrapper and manual (see below). NSE must initialize MessagingPush for \"delivered\" metrics.\n- **Android**: Enable push metrics capture in SDK config\n- **All platforms**: Verify metrics auto-tracking is enabled\n\n### In-App Messages Not Showing\n\n- Call `identify()` before testing\n- Initialize SDK with `siteId` (required for in-app)\n- App must be in foreground\n- Check page rules if targeting specific screens\n\n### Rich Push Images Not Loading (iOS)\n\n- Requires Notification Service Extension (NSE)\n- Set `mutable-content=1` in push payload\n- Validate image format (JPEG, PNG, GIF) and size\n\n### Deep Links Not Working\n\n- **iOS**: Verify `apple-app-site-association` file is accessible. Implement BOTH URL Schemes AND Universal Links.\n- **Android**: Add intent-filters in AndroidManifest (VIEW, DEFAULT, BROWSABLE)\n- **React Native**: Forward URLs to `RCTLinkingManager` in AppDelegate\n\n## iOS AppDelegate Patterns\n\n**Critical: Choose exactly ONE pattern. Mixing them causes conflicts.**\n\n### Pattern 1: Wrapper (Recommended)\n\nYour AppDelegate extends `CioProviderAgnosticAppDelegate` which automatically handles push metrics, notification lifecycle, and deep link routing.\n\n```swift\n@main\nclass AppDelegate: CioProviderAgnosticAppDelegate\u003cMyAppDelegate\u003e {\n    // Wrapper handles everything automatically\n    // Override deep link methods if you need custom routing\n}\n```\n\n**Do NOT** call `MessagingPush.shared.userNotificationCenter()` manually — the wrapper does this.\n\n**You CAN** optionally set `UNUserNotificationCenter.current().delegate = self` for custom foreground notification handling — the wrapper preserves your delegate.\n\n### Pattern 2: Manual\n\nYou implement `UIApplicationDelegate` and `UNUserNotificationCenterDelegate` yourself:\n\n```swift\n@main\nclass AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {\n    func application(_ application: UIApplication, didFinishLaunchingWithOptions ...) -\u003e Bool {\n        UNUserNotificationCenter.current().delegate = self\n        // Initialize SDK...\n        return true\n    }\n    \n    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: ...) {\n        MessagingPush.shared.userNotificationCenter(center, didReceive: response, withCompletionHandler: completionHandler)\n    }\n}\n```\n\n### Detecting Conflicts\n\nIf a user has issues, check which pattern they're using:\n- Found `CioProviderAgnosticAppDelegate` AND `MessagingPush.shared` calls → **conflict, choose one**\n- Found wrapper only → correct wrapper pattern\n- Found `MessagingPush.shared` calls only → correct manual pattern\n\n### Framework-Specific Patterns\n\n**React Native:**\n```swift\n@main\nclass AppDelegate: CioProviderAgnosticAppDelegate\u003cRCTAppDelegate\u003e {\n    override func application(_ app: UIApplication, open url: URL, options: ...) -\u003e Bool {\n        return RCTLinkingManager.application(app, open: url, options: options)\n    }\n    \n    override func application(_ application: UIApplication, continue userActivity: NSUserActivity, ...) -\u003e Bool {\n        return RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler)\n    }\n}\n```\n\n**Flutter (nested wrapper — different from other frameworks):**\n```swift\n@main\nclass AppDelegateWithCioIntegration: CioAppDelegateWrapper\u003cAppDelegate\u003e {}\n\nclass AppDelegate: FlutterAppDelegate {\n    override func application(...) -\u003e Bool {\n        GeneratedPluginRegistrant.register(with: self)\n        MessagingPushFCM.initialize(withConfig: MessagingPushConfigBuilder().build())\n        return super.application(application, didFinishLaunchingWithOptions: launchOptions)\n    }\n}\n```\n\n**Expo:** Plugin handles AppDelegate automatically. Configure deep links via `app.json`.\n\n## Cross-Platform Apps Have Two Layers\n\nFor React Native, Expo, or Flutter apps, issues can live in either the JS/Dart framework layer or the native (iOS/Android) layer — check both. The framework layer covers package install, SDK initialization, and JS/Dart routing; the native layer covers AppDelegate/Activity patterns, APNs/FCM config, and platform-specific implementation. See \"Framework vs SDK Issues\" below for isolating which layer is at fault.\n\n## Common Pitfalls\n\n| Platform | Pitfall | Fix |\n|----------|---------|-----|\n| iOS | Testing push on simulator | Use real device |\n| iOS | Missing NSE initialization | Initialize MessagingPush in NSE |\n| iOS | Mixing AppDelegate patterns | Choose wrapper OR manual, not both |\n| Android | Missing notification channels | Create channel with appropriate importance |\n| Android | `google-services.json` in wrong dir | Place in `app/` module directory |\n| Android 13+ | Missing POST_NOTIFICATIONS | Request runtime permission |\n| React Native | Forgetting `pod install` | Always run after package changes |\n| React Native | Not forwarding URLs | Implement both URL handling methods with RCTLinkingManager |\n| Expo | Using Expo Go | Use Dev Client (Development Build) |\n| Expo | Not running prebuild | Run `npx expo prebuild` |\n| Flutter | Wrong wrapper pattern | Use nested: `CioAppDelegateWrapper\u003cAppDelegate\u003e` |\n| Flutter | Wrong init location | Initialize in inner AppDelegate (FlutterAppDelegate), not wrapper |\n| Server SDKs | Wrong API key type | Use CDP source Write Key, not App API key |\n| Server SDKs | Region mismatch | Check account region, set SDK region to match |\n| All | Not calling identify() first | Always identify before tracking |\n\n## Framework vs SDK Issues\n\nWhen using cross-platform frameworks, the issue may be in the framework, not Customer.io:\n\n1. **Check crash logs** — does the stack trace mention Customer.io SDK code?\n2. **Search framework issue trackers:**\n   - React Native: https://github.com/facebook/react-native/issues\n   - Flutter: https://github.com/flutter/flutter/issues\n   - Expo: https://github.com/expo/expo/issues\n3. **Check changelogs** for known version-specific bugs\n4. If the error is in framework code, suggest upgrading the framework\n\n## When to Escalate\n\nContact Customer.io support if:\n- All checks pass but the issue persists\n- SDK initialized correctly but data isn't flowing\n- Authentication verified but API calls failing\n- Metrics appear in logs but not in workspace\n\nGather before escalating: user IDs, device tokens, debug logs, platform/version info, exact error messages.\n"},"hashes":{"auth.md":"335bb7855763c5372340e86f310b45947cf8488d93c42d6003fc433ce4dfebf3","billing.md":"40f4dc4b8c857d862a8c0a85332c146cfee0b8e78e3300d72bd077c34f637b4f","integration.md":"88759f3824912d7d364394833e589cefb9c40b78f88dcda549f13966374a3462","integration_backend.md":"d2db9caabd8b308aece1f60f799e718fbe3361058ee96b8b96dca11e25e99fed","integration_mobile.md":"8d1d70519049626b8bc0fefaba37256175033f7f65413d71abf5bb09707361ab","integration_web.md":"076e1297324f419fa57342860ebaf94a46656aacd056109ab2449d10103d9665","live_notifications.md":"5ecc99f4113e170749125cdccc883293b7e038e794f806cb584522271004978c","onboarding.md":"8e9ea789b29671d4abdc43651b674cdff8b4c47cbb18240ef941f360de15b783","sdk_setup.md":"2ef1e4dc8d9512d6a9048db045f6cdea6e0f37644865be4843cb59199142f4fa","sdk_troubleshooting.md":"ca5607f04000e908b927e1affd3c67ad5f2120c189732ff0123983b099d874c2"},"sizes":{"auth.md":8993,"billing.md":8357,"integration.md":8438,"integration_backend.md":4275,"integration_mobile.md":5188,"integration_web.md":11223,"live_notifications.md":17171,"onboarding.md":26442,"sdk_setup.md":20758,"sdk_troubleshooting.md":9015}},{"path":"fly-api","name":"Fly (UI) API Reference","description":"Endpoint reference for Customer.io's Fly API (ui_api) — automations, segments, customers, templates, deliveries, newsletters, A/B tests, goals, webhooks, collections, snippets, imports/exports, channels, billing and plan limits (how many profiles/people a plan includes, current usage against that allowance, overage), analytics, workspace settings, forms, tags, and audit logs.","content":"# Customer.io Fly (UI) API — Copilot Guidance\n\nBehavioral rules and API reference for the Fly API. Sub-files contain endpoint details, schemas, and parameters — see the reference table below.\n\n## Broadcast lookup — before the first API call\n\n“Broadcast” is ambiguous. Check every applicable resource before saying one does not exist:\n\n- One-time send: `messaging.md` (`/newsletters`).\n- API-triggered: read `api_triggered_broadcasts.md` first for a **trigger**, **trigger id**, or\n  `/newsletters` miss. A plain `/campaigns` list hides these; use its `triggered_broadcast` filter.\n  Use that guide to fire, schedule, inspect, or cancel one; use **automations.md** to create or edit it.\n- Anonymous, lead, or unidentified visitor: `anonymous_broadcasts.md` (`in_app_broadcast`). This is\n  not a profile-targeted one-time send with `channel: in_app`; ask which one they mean if unclear.\n\n## Using the `cio` CLI\n\n**Use cio api for Fly API, CDP API, and Design Studio calls** — never `curl` them; auth is automatic (capability grants too). File uploads too: `cio api --file @path` sends `multipart/form-data` (asset uploads in `assets.md`, knowledge-source uploads in `llm_actions.md`). The sole `curl` case is the App API send endpoints (`api.customer.io/v1/send/*`) — a separate API with its own key — and only in backend examples for the user. For your own test sends, `cio send email` and `cio transactional send \u003cchannel\u003e` wrap those endpoints on existing CLI auth; never create an App API key for this (`transactional_send.md`). Design Studio is no exception: its routes are ui_api routes — `/v1/environments/$ENV_ID/ds/...` (node-level), `/v1/environments/$ENV_ID/design_studio/...` (typed content) — reached with `cio api`. No `curl`, and never send `X-Workspace-Hydra-Id`; it's an internal ui_api → parcel detail. Read `@skills/design-studio` for workflows, `@skills/design-studio/nodes.md` for routes.\n\n```bash\n# GET request\ncio api /v1/environments/$ENV_ID/campaigns --jq '.campaigns[] | {id, name}'\n\n# GET with query params — they go in --params as JSON, never as ?k=v on the path\ncio api /v1/environments/$ENV_ID/reusable_conditions \\\n  --params '{\"type\": \"attribute\", \"attribute_field\": \"created_at\"}' --jq '.conditions[]'\n\n# POST/PUT — use --json for the request body (NOT --data or --body)\ncio api /v1/environments/$ENV_ID/campaigns -X POST --json '{\"campaign\": {\"name\": \"My Campaign\"}}'\n\n# PUT with path params\ncio api /v1/environments/{environment_id}/templates/{template_id} -X PUT \\\n  --params '{\"environment_id\": \"1\", \"template_id\": \"42\"}' \\\n  --json '{\"template\": {\"subject\": \"Hello\"}}'\n\n# Schema introspection (see system prompt for the \"schema before writes\" rule)\ncio schema campaigns                          # list endpoints for a resource\ncio schema newsletters.update --compact       # one line per field, the default for endpoint detail\ncio schema newsletters.update                 # full JSON Schema, when you need branch structure\n```\n\n`--compact` renders each field as `path  type  enum: a|b  description`, with `?` marking optional\nand `[]` marking an array element, at roughly half the tokens of the JSON Schema. Descriptions,\nenums and required markers all survive, so it is the right default for \"what does this body take\".\nIt flattens `oneOf` branches into one list of every field, so use the full schema when you need to\nknow which discriminator value admits a field, or need validation keywords like `minLength`.\n\n`cio schema` covers the Fly API, CDP, and the Design Studio `/ds/...` and `/design_studio/...` routes — all three are in the served OpenAPI specs: Journeys and Design Studio share `/v1/openapi.json`, CDP is a separate document at `/cdp/api/openapi.json`. Those specs are also the allowlist the MCP `cio_*` tools enforce, so from those a route missing from the spec is refused before the request is sent (`… is not an available endpoint`); `cio api` sends whatever path you give it and lets the server answer. The spec gives you request and response shapes, not workflow: read `@skills/design-studio` before building, editing, or publishing a Design Studio email.\n\n**Common mistakes:**\n- `--data` or `--body` → use `--json` instead. `-H \"X-CIO-Capability-Grant: …\"` or `--header` → `unknown shorthand flag: 'H' in -H`: `cio api` has no header flag and needs none — an approved grant is sent automatically from `$X_CIO_CAPABILITY_GRANT`. Drop the flag and re-run; never switch to raw `curl`.\n- `--params` takes a **JSON object** of path/query parameters — `--params '{\"id\": \"42\", \"region\": \"us\"}'`. Never pass a bare value (`--params latest`); it fails with `params is not valid JSON: invalid character ...`. A `key=value` query string (`--params id=42`) is accepted, but there you percent-encode the values yourself (`%20` for a space, `%2B` for a plus) — pass the JSON object and let the CLI encode.\n- Creating an automation? `type` on `POST /campaigns` is a strict enum — `[none, transactional, behavioral, seg_attr, triggered_broadcast]` (anything else fails `422 type must be one of [...]`). `webhook` is **not** creatable in one call (create as `none`, then `POST /campaigns/:id/webhooks`, then `PUT` with `type: webhook` + the returned `webhook_id`); `behavioral` is rejected for new agent-created automations. See `automations.md` before any campaign create.\n- `--arg name value` or `--argjson name value` (jq's space-separated form) → fails with `accepts 1 arg(s), received N`. The binding is a single `name=value` token: `--arg cid=\"$CUSTOMER_ID\"`, `--argjson start=1704067200`. These variables bind ONLY into the `--json` body program — they don't work in `--jq` read filters, so inline values into the filter string instead. Standalone `jq` in bash is the opposite: it takes the two-argument form (`jq --arg name \"$NAME\" ...`).\n- `cio skills read fly-api/automations` → must include `.md`: `cio skills read fly-api/automations.md`\n- `snowflake_query` + `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` → the CLI's 30s default timeout gave up, not the query. Every `snowflake_query` call needs `--timeout 120s`; resend the same SQL with it before narrowing anything.\n- `snowflake_query` refusals mean three different things and only one is the workspace's: **400** is your own malformed request (fix the payload and resend — not a sign warehouse access is missing), **403** is access rather than data, **503** is the warehouse being unreachable right now. Never collapse them into \"no warehouse access\". Read the error code before explaining a 403: `plan_restricted` means the workspace is on Builder and needs an upgrade, anything else means the user lacks the permission — don't report that one as a plan limit.\n- `endpoint did not return JSON (status 200 ... text/html)` → **that path doesn't exist** (a wrong URL never `404`s) — not missing data. Don't retry variants or report the resource as empty; run `cio schema \u003cresource\u003e` for the real routes. Never loop or `grep` an unproven path: the HTML body reads as \"no results\" rather than an error.\n- `cio schema /v1/environments/{id}/campaigns` → use just the resource name: `cio schema campaigns`\n- Guessing a method name from a skill file's \"Handler\" column (e.g. `imports.GenerateSignature`) → that's the Go handler, not necessarily the CLI's method name. If `cio schema \u003cresource\u003e.\u003cmethod\u003e` 404s, run `cio schema \u003cresource\u003e` alone (no method) to list the real method names instead of guessing again — don't burn a second guess.\n- Extracting a raw string (an id, a token, a body) → add the `-r`/`--raw-output` flag: `--jq '.foo' -r` prints the string unquoted, like `jq -r`. Without `-r`, `--jq` JSON-quotes string results. No external `jq` needed. This applies to any filter emitting one string per line, not just single fields — e.g. building CSV rows with `@csv`, `@tsv`, or `join(\",\")` needs `-r` too, or every row comes out wrapped in an extra pair of quotes.\n\n### Discovery Budget and Action Completion\n\nUse skills and schemas to act, not to stall.\n\n- Read the relevant skill entrypoint and sub-file once. Do not re-read the same skill or schema in the same session unless the user changes the task or an API result contradicts the guidance.\n- For non-GET calls or an endpoint you have not used in the session, run one `cio schema \u003cresource.method\u003e` check before the write. After that, make the call or ask for the one missing detail that blocks it.\n- Never use a write to find out what a resource accepts. Guessing field names and PUTing them to see which sticks is a real mutation on a real resource, and a question about whether something is configurable is not a licence to try it. `cio schema \u003cresource.method\u003e` answers that; if the field isn't there, say it isn't exposed by the API.\n- When the user asks you to create, build, update, delete, pull, or configure something, do not stop at a plan. Complete the action if the intent and required fields are clear.\n- Clear intent means the user asked for the change — not that you can see which change would help. A question (\"how do I…\"), a hypothetical (\"could we just…\"), or a bare \"yes\" to anything other than an offer to make that exact change is not a go-ahead: name the change in one line, ask, and wait. Reads, previews, drafts, and explanations never need this.\n- If a critical detail is ambiguous, ask one pointed blocker question. Do not spend multiple turns paging through broad lists trying to infer the user's business rule.\n- Prefer targeted list calls with `search`, `type`, `size`, or other documented filters. Avoid fetching whole workspaces and filtering locally unless the API has no suitable filter.\n- If a write command fails, check saved state before repeating it; `jq` errors can occur after a successful save. Fix other API errors before retrying.\n- **`no service account token configured` is an infrastructure fault, not an API limitation.** It means this sandbox didn't receive its credential — the endpoint is fine and the request was probably right. Retry the same call once; if it still fails, say the session lost its API credentials and the work couldn't be applied. Never read it as \"the API is inaccessible\", never conclude the resource can't be created this way, and never tell the user to go do it in the UI instead — that turns a transient fault into permanent wrong advice.\n- Keep user replies focused on the outcome, resource name, status, and UI link. Do not expose command strings, raw JSON, endpoint paths, internal system names, or internal troubleshooting unless the user asks for technical detail. Internal system names include the billing vendor in any form — the slugs `ordway` / `legacy_stripe` / `metronome` and the supplier names Ordway and Metronome as words — plus internal product ids, permission constants (`Account_Billing_View`), and feature/account-flag names or dumps (`new_pipelines_objects_pricing`, `feature_flags`) — reason from them, never name them. Route on them; describe the effect. \"You don't have billing access\", not \"you need `Account_Billing_View`\".\n- **Follow-ups stay on the skill already in play.** When a follow-up refines or extends the current analysis (a different metric, a narrower window, \"what about unique opens?\"), keep answering from the same skill and data source — don't re-route to a different skill or endpoint family mid-thread unless the question changes domain. Switching mid-analysis produces numbers computed differently that contradict what you already reported.\n- **Never narrate internal machinery.** No \"let me query the Snowflake semantic view\", no skill or tool names, no endpoint paths in progress updates — describe the action in product terms (\"checking your campaign's delivery data\", \"looking at the workflow structure\").\n\n### Saying a Fix Worked\n\nA write or read-back confirms **saved configuration**. A strict saved-template preview confirms **rendering with its sample**. A matching runtime record confirms **its recorded status**: attempted is not sent, and sent is not delivered. Use the recorded status word in progress updates and closing replies.\n\nFor repairs, read `automation_debugging.md` before editing. It covers reproducing failures, previewing saved content, and distinguishing a new test from an old retry.\n\n### Resolving a Named Resource\n\nWhen the user names a specific resource — an automation, segment, one-time send, template, object, attribute, or event — resolve it to exactly one match **before** you build anything on top of it. A wrong resolution silently corrupts everything downstream (the wrong automation baked into a segment condition, the wrong template in a send) and the user often can't tell until the result is wrong.\n\n- If a list/search returns **no exact match**, do not proceed with a similarly-named near-match. A different name is a different resource — `PAT_PhysIntroCallCanceledWnb` is not `PAT_PhysIntroCallNoShowWnb` — so don't report a near-match as if you found it.\n- Before concluding a named resource doesn't exist, broaden the search with a looser `search`/substring filter and check the next page of results. **For a message the user calls an \"automation\" or \"campaign\" that you don't find as one, you MUST run a Message Library search (`messaging.md`, `message_library_items`) across all message types — with no `parents[]` filter — before you give up or ask. It covers transactional messages, and an \"automation\"/\"campaign\" the user names is very often a transactional message or one-time send.** The resource may also be archived, or — if it is a Design Studio email — an unlinked node, which has no template and so never appears in the Library; `/ds/node_actions/search` covers those.\n- If you still can't find an exact match, **stop and ask**. List the closest candidates by name and let the user confirm or correct the name — never guess which one they meant. This is the \"ambiguous detail → one blocker question\" rule applied to identity.\n- Skip the confirmation only when there is exactly one unambiguous exact match.\n\n### Pagination\n\nFor lists with many pages (objects, profiles, logs), prefer `--page-all` over chaining `continuation`/`page` manually.\n\n**A bare list call returns only the first page.** Never conclude a named resource doesn't exist from one — say \"I searched all N\" only if you actually paged to the end. See § Resolving a Named Resource.\n\n**Three lists `--page-all` does NOT drive correctly — page these yourself:**\n\n- **`/campaigns`** (automations). `--page-all` starts at `page=1`, but this endpoint is **0-indexed** with a fixed page size of 20, so `page=1` returns records 21-40 and **the first 20 automations are never fetched**. Nothing corrects for it: the endpoint ignores `limit`, and its total sits at `meta.pagination.total`, which the walk doesn't read — it only stops on an empty `campaigns` array. Walk it explicitly instead, **starting at `page` 0**, bounded by that total — see the loop below; don't stop on an empty page, because an error body has no `campaigns` array and reads as one. Narrowing with `--params '{\"search\": \"...\"}'` is usually cheaper than paging at all.\n- **`/segments`**. Only paginates when you pass `size`, and returns no pagination meta otherwise, so `--page-all` refetches the same full list up to its 200-page cap. Use a plain `/segments` call (it is not truncated to a page) or pass `size` yourself.\n- **`/deliveries`** (message activity). It pages by the `continuation` cursor alone and ignores `page` and `limit`, so `--page-all` refetches the same first page until its page cap. Pass `size` (at most 500) and the previous response's `meta.continuation` yourself, and stop when `meta.continuation` comes back empty.\n\n**A page's length is not the collection's size.** `.campaigns | length` counts what came back; the size is `.meta.pagination.total` (not `.meta.total`), and on `/campaigns` `limit` / `per_page` / `size` never widen a page (other lists that take `size` cap it — `/deliveries` refuses above 500). A count therefore comes from the total in one call, while any per-record breakdown — \"how many in each state\", \"all of them\", \"none are Y\" — needs every page. One page reported as the whole set is a wrong answer, not an approximation.\n\n**When you walk pages, redirect each to a file — never `resp=$(cio api ...)`.** A page of JSON held in a shell variable is carried by every later command in the script. Write pages out, merge from disk, reconcile the count:\n\n```bash\nd=/tmp/campaigns; rm -rf \"$d\"; mkdir \"$d\"\ncio api \"/v1/environments/$ENV_ID/campaigns\" --params '{\"page\": 0}' \u003e \"$d/0000.json\"\ntotal=$(jq -r '.meta.pagination.total' \"$d/0000.json\")\npage=1\nwhile [ \"$((page * 20))\" -lt \"$total\" ]; do        # /campaigns: fixed size 20, 0-indexed\n  cio api \"/v1/environments/$ENV_ID/campaigns\" --params \"{\\\"page\\\": $page}\" \u003e \"$d/$(printf '%04d' $page).json\"\n  page=$((page + 1))\ndone\njq -s '[.[].campaigns[]?]' \"$d\"/*.json \u003e \"$d/all.json\"\ngot=$(jq length \"$d/all.json\")\n[ \"$got\" -eq \"$total\" ] || echo \"INCOMPLETE: $got of $total\"   # a page was lost; say so\n```\n\nSize and start index are per-endpoint (`/customers` is 50 and 1-indexed); check its section before reusing these numbers.\n\n**`--page-all` emits one line per *page*, not one line per record**, and `--jq` runs against each of those page objects. So the filter still has to reach into the collection — a filter written as if each line were a record silently yields `null` for every page and reads like \"no results\":\n\n```bash\n# right — index into the collection on each page object\ncio api /v1/environments/$ENV_ID/object_types/7/objects --page-all --jq '.objects[] | {id, name}'\n\n# wrong — {id, name} against a whole page object returns nulls, not an error\ncio api /v1/environments/$ENV_ID/object_types/7/objects --page-all --jq '{id, name}'\n```\n\n`--page-all` is a CLI flag, not an API parameter — it doesn't show up in `cio schema`. The schema's `query_params` describes what the server accepts; the CLI handles the loop.\n\n### Many-ID Lookups and Sandbox Parallelism\n\nBefore looping over hundreds of IDs, look for a list endpoint that returns the field for the whole set (`--page-all`, § Pagination) or an export that hands the user a file (`imports_exports.md` § Exports).\n\nWhen you do loop, `xargs -P` is the sandbox's one working form of parallelism:\n\n```bash\ncio api /v1/environments/$ENV_ID/customers --page-all --jq '.customers[].id' -r \\\n  | xargs -P 5 -I {} cio api /v1/environments/$ENV_ID/logs \\\n      --params '{\"internal_id\": \"{}\", \"type\": \"event\", \"limit\": \"1\"}' --jq '.logs[0].name // \"none\"' -r\n```\n\n- Separate each value from its flag — `-I{}`, `-P20` and `-n2` fail with `xargs: invalid option -- 'I'` — and keep concurrency low (`-P 5`).\n- One failed item exits non-zero, so a `set -eo pipefail` script stops after the loop; to finish the set, wrap it: `xargs -P 5 -I {} bash -c 'cio api ... {} || true'`.\n- `emscripten does not support processes` / `can't start new thread` (Pyodide `python3`) mean fall back to this loop, not another mechanism.\n\nOne bash call has a budget of a few minutes. Past a few hundred IDs even a parallel loop won't fit — tell the user what you can get and offer the export.\n\n### jq null-safety\n\nGuard scalar inputs to `test` / `contains` / `ascii_downcase` with `// \"\"` — fields are commonly null and an unguarded test errors out:\n\n```bash\n--jq '[.attributes[] | select((.id // \"\") | test(\"renew\"; \"i\"))]'\n```\n\n### Null identity fields mean a wrong jq path, not an empty resource\n\nSingle-resource GETs wrap the resource in a named envelope — `GET /templates/:id` returns `{\"template\": {...}, \"layouts\": [...]}`, a segment comes back under `.segment`, and so on. A `--jq` projection that misses the envelope (`{id, name}` read at the root instead of under `.template`) does **not** error — it returns `null` for every field, and any boolean or `test` computed over those nulls comes out `false`:\n\n```json\n{\"editor\": null, \"has_random_delta\": false, \"id\": null, \"name\": null}\n```\n\nThat is a broken query, not a result. If identity fields (`id`, `name`) come back null on a resource you just fetched by id, stop: rerun the call without `--jq` (or check `cio schema \u003cresource\u003e.\u003cmethod\u003e`) to see the real shape, fix the path, and rerun. Never read a `false`/empty computed over an all-null projection as a confirmed negative, and never build a \"doesn't exist\" / \"doesn't contain X\" answer on top of one. In a loop this compounds: one null-shaped response means every iteration used the wrong path — treat the whole pass as void, not as N confirmed negatives.\n\n### `--json` payloads\n\n**Build the JSON in the shell and pass it inline as `--json \"$BODY\"`** — you don't need to write the payload to a file. Pick by payload shape:\n\n- **Static, simple, no apostrophes** — inline single-quoted literal:\n  ```bash\n  cio api /v1/environments/$ENV_ID/campaigns -X POST \\\n    --json '{\"campaign\": {\"name\": \"My Campaign\", \"type\": \"transactional\"}}'\n  ```\n- **Any dynamic string value** — bind it with `--arg` and reference it in the `--json` program; `cio`'s gojq owns the escaping, so never interpolate raw data into inline JSON:\n  ```bash\n  cio api /v1/environments/$ENV_ID/segments -X POST \\\n    --arg name=\"Whatever, with spaces.dots\" --json '{segment:{name:$name}}'\n  ```\n- **Modify an existing resource** — GET it, change the field you need, and PUT. A segments PUT preserves fields you omit (`description`, `tag_ids`, …) but still requires `name`, and a dynamic segment's `conditions` — omitting either is a 422, so carry them over from the GET. For tag-only changes skip the PUT entirely: `POST .../segments/:id/tags` with `{\"tag_ids\": [...]}` appends tags without touching name/conditions (see segments.md). Other resources differ; check the resource's skill for which fields a PUT requires:\n  ```bash\n  CONDS=$(cio api /v1/environments/$ENV_ID/segments/$id --jq '.segment.conditions') || { echo \"fetch failed\"; exit 1; }\n  # the PUT 422s without conditions — confirm the fetch returned them before resending\n  [ -n \"$CONDS\" ] \u0026\u0026 [ \"$CONDS\" != \"null\" ] || { echo \"no conditions returned; refusing to PUT\"; exit 1; }\n  cio api /v1/environments/$ENV_ID/segments/$id -X PUT \\\n    --arg name=\"Renamed, again\" --argjson conds \"$CONDS\" \\\n    --json '{segment:{name:$name,conditions:$conds}}'\n  ```\n- **Payload with embedded quotes/Liquid (e.g. Carta markup)** — pass the value with `--arg name=value` (or `--argjson`) and write `--json` as a jq program that references `$name`; `cio`'s bundled gojq builds the body, so no shell escaping and no external `jq`. See `in_app.md` for the canonical Carta pattern.\n\n`--json \"$BODY\"` is safe: the shell carries the double-quoted variable verbatim without re-parsing the quotes inside it. Do **not** build a double-quoted inline payload (`--json \"{...$VAR...}\"`) — apostrophes and quotes in the data break it.\n\n**In a loop, rebuild the body inside each iteration** — don't build a payload once (in a file or an outer variable) and reuse it across resources. If a rebuild step fails, the shell stops the script; but a body carried over from a previous pass silently writes one resource's content onto every other one.\n\n## Referencing People\n\n**Displaying profiles:** Use their **email address** when available, fall back to `id`. Never show `cio_id` to users.\n\n**Linking to profiles — CRITICAL:** Never use `mailto:` links. Always link to the profile in the UI:\n- **cio_id known:** `https://fly.customer.io/workspaces/$ENV_ID/journeys/people/CIO_ID` (raw hex, no `cio_` prefix)\n- **Only have email:** `https://fly.customer.io/workspaces/$ENV_ID/journeys/people?email=EMAIL` (URL-encode `@` as `%40`)\n\n**Identifying people in API calls:** Endpoints accepting `customer_id` take either the external `id` or `cio_id` as raw hex without the `cio_` prefix. Prefer `cio_id` since every profile has one.\n\n**Workspace identifier type:** `cio api /v1/environments/$ENV_ID --jq '.environment.identifier_type' -r` — email workspaces use email as `id`, ID workspaces use a custom identifier. `cio_id` is workspace-scoped; use `email` or external `id` for cross-workspace matching.\n\n## Multi-Step Workflows\n\nAutomation creation, one-time send setup, and similar flows chain 3-5 API calls, each depending on IDs from the last. Validate each step's output before using it — confirm it returned what the next step needs (an id, a fetched value, a body you built).\n\n## Bulk Operations — CRITICAL\n\nWhen applying the same change to more than one resource (automations, templates, segments, etc.), never run the change against the full set in one pass. Side effects on other fields can be silent and only visible in audit logs after the fact.\n\n**Required pattern:**\n\n1. Pick one resource from the set.\n2. Fetch its full pre-change state and store it.\n3. Try the change with `cio api ... --dry-run` first to see what would happen without mutating anything. If the dry-run output looks wrong, stop here.\n4. Apply the change to just that one resource.\n5. Refetch its full post-change state.\n6. Diff the post-state against the pre-state. Confirm that only the fields you intended to change have changed. Any unintended change is a stop signal — surface it to the user and pause before continuing.\n7. Only then apply the change to the rest of the set.\n\nA 200 response confirms the request was accepted, not that it did what you expected. The verify step is non-negotiable.\n\nThis matters most for resources with live downstream effects (running automations, live segments), where a silent extra change can fire messages or alter targeting.\n\n## UI Refresh After API Changes\n\nThe UI doesn't always auto-refresh after API changes — if the user is viewing an affected page, they may need to refresh to see the update.\n\n## Linking to the UI\n\nWhen you create, modify, or look up a resource for the user, link them to it in the UI so they can verify your work — including each person you list (see § Referencing People). Do this only for resources actually available on this account: if a call returned a plan or permission error, or the capability isn't on the plan, follow § Feature Availability instead.\n\nOnly resources available on every plan are listed here. For anything else, the UI path lives in that resource's skill sub-file — so if the sub-file isn't on the account's plan, you won't have a path, and must not invent one.\n\nFull URL: `https://fly.customer.io/workspaces/$ENV_ID` + path from the table below.\n\nThe UI path is not the API path. Use the exact paths below — most workspace content sits under a `/journeys/` segment the API paths don't have.\n\n| Resource | UI Path |\n|----------|---------|\n| Profile | `/journeys/people/:id` |\n| People list (filtered) | `/journeys/people?filters=BASE64` (see below) |\n| Custom object | `/journeys/objects/:slug/:id` |\n| One-time send | `/journeys/broadcasts/newsletter/:newsletter_id` |\n| API-triggered broadcast | `/journeys/broadcasts/broadcast/:campaign_id` — a campaign id, never a newsletter id |\n| Transactional message | `/journeys/transactional/:transactional_message_id` |\n| Segment | `/journeys/segments/:segment_id` |\n| Layout | `/journeys/layouts/:layout_id` |\n| Sent | `/journeys/outbox/deliveries` |\n| Drafts | `/journeys/outbox/drafts` |\n\nSettings, Design Studio, and Pipelines sit directly under `/workspaces/$ENV_ID` (no `/journeys/`); their paths are in the relevant sub-files.\n\n### Filtered People List URLs\n\nWhen asked to find or link to a group of people matching criteria (e.g. \"find everyone from Minnesota\"), construct a filtered people list URL instead of inventing a URL path. See **customers.md § Filtered People List URLs** for the encoding format and examples.\n\n### Settings Navigation\n\nCustomer.io has three distinct settings areas. **There is no generic \"Settings\" page.** Always specify which one:\n\n- **Account Settings** — account-level config: team management, billing, security (SSO/2FA), privacy, API credentials. Accessed via the person menu → \"Account settings.\"\n- **Personal Settings** — per-user preferences: profile, personal security. Accessed via the person menu → \"Personal settings.\"\n- **Workspace Settings** — per-workspace config: email, SMS, push, in-app, webhooks, subscription center, sending domains, etc. Accessed via the person menu → \"Workspace settings.\"\n\n**Never say \"Go to Settings → ...\"** — always use the specific area name. Settings URL paths are in the relevant sub-files (**channels.md**, **subscriptions.md**, **webhooks.md**, **account.md**, **frequency_caps.md**).\n\n## Agent Permission Errors\n\nLive-data edits are controlled by an admin's chosen policy in [AI \u0026 MCP settings](/settings/ai), not a permanent platform limit. A denied edit was not made; user approval alone does not override the setting.\n\nFor the **built-in agent**, `403 agent_editing_disabled` or missing `write:live` means an account admin must enable **\"Edit live data\"**. Ignore generic OAuth \"re-authorize\" wording on that scope error. Once enabled, retry in a new bash call in this conversation: each call gets current scopes, even in a reused session. If it still fails, check that the setting saved. No new chat, reconnection, or OAuth re-authorization is needed. Live edits still require their normal approval; offer a manual UI change meanwhile.\n\nFor an **external MCP client**, `mcp_missing_write_live` or a missing `write:live` scope calls for checking the separate **\"Allow MCP to edit live data\"** toggle and the authorizing user's workspace send permission. An account admin can enable the toggle; the client then needs an OAuth grant with `write:live`, so reconnect or re-authorize it. If the error is `agent_editing_disabled`, follow the **\"Edit live data\"** setting above. Do not apply the built-in agent's next-bash advice to MCP.\n\n## Feature Availability\n\nSeparately from permissions, a capability can be **absent from this account's plan**. This is not a permission problem and not something the user can toggle.\n\n**Act on evidence, never on a guess.** The account's plan is stated authoritatively in your context, and the skills you were served are already scoped to it — so don't infer availability from doc text, don't compare tiers, and don't raise upgrades unless the user asks or a call is actually rejected. Only these count as evidence:\n\n- A sub-file read returns **\"Not available on your plan\"** instead of the documentation.\n- A sub-file's row in § Sub-File Reference carries a serve-time availability annotation.\n- An API call is **rejected for entitlement** with a `400` whose message reads `Upgrade your plan to use \u003cFeature\u003e`. This is the common shape — it comes from the per-feature route gate, and those routes stay in the `cio schema` you are served, so the endpoint looks available right up until you call it. Several sub-files call this out for their own feature (see `geofences.md`).\n- An API call is **rejected for entitlement** with a `403` whose detail names the plan. This is the plan-level route gate; those routes are absent from the schema you are served, so you usually won't see them to begin with.\n\nNote the two rejection shapes differ: a `400 Upgrade your plan …` and a `403 … not available on the \u003cplan\u003e plan` are both entitlement, not bad input on your part and not a bug. Neither is worth a retry.\n\nWhen you hit one of these:\n\n1. Say plainly that the feature isn't included, quoting what the response says. Don't invent a tier it needs — if the message names one, use that; if it names only the feature, name only the feature. Don't describe it as a bug, a permission error, or something you did wrong.\n2. **Never name the UI location, link to it, or give directions to it** — the path lives in the gated sub-file you can't read, so you don't have one, and inventing one sends the user hunting for a screen they don't have.\n3. Don't retry, and don't work around it by writing the same thing through a different endpoint.\n4. Offer an available alternative if there's a reasonable substitute. Point at \u003chttps://customer.io/pricing\u003e only if the user asks about upgrading.\n\n## Sub-File Reference\n\nRead the relevant sub-file BEFORE making API calls.\n\n\u003c!-- subfile-index: gates come from each sub-file's frontmatter; don't restate them. --\u003e\n| File | When to read |\n|------|-------------|\n| **search.md** | Find an entity via Universal Search — by a phrase in its content (snippets, templates, layouts, in-app), by name, or across types when the type is unknown; resolve to id + type, then act in that entity's skill. Also § Finding every mention of a term — \"which messages mention X\" exhaustively, workspace-wide or within one automation. **Automations/one-time sends/transactional match by name only here — for a message's subject or body text use the Message Library search in `messaging.md`. An *unlinked* Design Studio email matches neither: use `/ds/node_actions/search` (`@skills/design-studio/nodes.md § Search`); once linked and published its body lands on a template, so `kind=template` reaches it.** |\n| **automation_drafts.md** | **Automation drafts: read before any automation edit** |\n| **automations.md** | Create/update automations, set triggers, add actions (email, webhook, SMS, push) |\n| **flow_control.md** | Add branches (true/false, multi-split), delays, wait-until, time windows to automations; **branch convergence** rules and the shared-exit trap in chained reminder cascades. To wait for a *message's own send* to finish, use `await_completion.md` |\n| **data_operations.md** | Build data-operation actions in a workflow: attribute update, **create/send event**, batch update, static segment update, collection query. Includes the rule that a Send Event / `create_event` action never syncs to a Pipelines/Data-Out destination (use a **Webhook action** — see `automations.md` — to write a value to Salesforce/HubSpot/etc.) |\n| **query_objects.md** | Query Objects steps: save the objects a person matches to a journey or profile attribute |\n| **llm_actions.md** | Per-profile AI steps (`llm_action`, \"Run LLM\") — summarize, classify, score, or write a value, stored as an attribute. Also covers **knowledge sources** — uploading a reference file to the library and attaching it to an action. **Read before naming a model or credit multiplier; the allowed list changes and retired names are silently swapped.** Not for values Liquid can already substitute (`recipes/liquid_syntax.md`) |\n| **segments.md** | Segments — manual (static) creation and membership, plus shared mechanics: size/metrics, reading the profiles in a segment, listing/filtering, updating, delete/archive, membership over time. Start here for any segment task |\n| **data_driven_segments.md** | Rule-based segments — create/update with conditions, build state, condition mechanics |\n| **snapshot_segments.md** | Refresh-only segments |\n| **segment_conditions.md** | Data-driven segment condition format, grouping rules, operators, relationship conditions, geofence enter/exit/dwell and EXIT duration conditions |\n| **segment_examples.md** | Segment condition examples (incl. geofence), timestamp offset signs, common pitfalls |\n| **customers.md** | Look up/update people and attributes, filter profiles, people URLs, activity log (`type=geofence_entered`/`geofence_dwelled`/`geofence_exited`); **objects and relationships — when to use an object, the per-workspace object *type* cap, and the 200-with-`enabled: false` create** |\n| **messaging.md** | One-time sends (any audience — a segment expression, a subscription topic, or the whole workspace) and transactional message CRUD (create/list/update/cancel); **find/search a message by its subject or body content across all message types (Message Library) — the right tool when the user describes a message by what it says rather than its name. Not for *unlinked* Design Studio emails: the Library indexes message templates, so a node with no Journeys link is absent and a search there returns empty, not proof it doesn't exist. A linked, published one does match. Search those with `/ds/node_actions/search` — see `@skills/design-studio/nodes.md § Search`.** Also § One-time send states (`send_state`) before any all-clear. |\n| **api_triggered_broadcasts.md** | Find, fire, schedule, inspect, or cancel an API-triggered broadcast. Read for a trigger id or a `/newsletters` miss; covers the required campaign filter, composite trigger id, send state, and cancellation. |\n| **messaging_troubleshooting.md** | Why an SMS / WhatsApp / push message didn't send or get delivered — opt-out suppression, invalid/non-E.164 numbers, channel credentials, A2P/10DLC/toll-free registration, carrier filtering, MMS non-US/CA, an empty WhatsApp template `body`, WhatsApp template/24h-window. Its § Read the Message Status First (`deliveries` → `delivery_details/:id`, rendered body + failure reason) is the evidence step for **any** channel, email and webhook included (§ Saying a Fix Worked) |\n| **transactional_send.md** | Send transactional messages from a backend — HTTP shape for `/v1/send/*`, App API auth, Builder CLI-test exception, `auto_create` paradigm, channel-specific fields |\n| **templates.md** | Edit email subject/body/from, templates, layouts. Bee row **display conditions**: read before explaining `{% if %}` row wrappers or missing Bee rows |\n| **sms.md** | Authoring SMS copy — segments/cost, GSM-7 vs UCS-2, smart encoding, links, opt-out, body patterns. Read before writing or editing any SMS message, whichever channel it's on. Starts with § Which SMS channel is this workspace on? — the `twilio_action` vs `sms_action` check that decides where the body is stored |\n| **sms_design_studio.md** | Authoring an SMS on the `sms_action` (custom SMS) channel, where the body is a Design Studio node rather than a template field: `\u003cx-sms\u003e` markup, sender/recipient, create → link → publish, segment-count preview. Read after sms.md when `enabled_actions` has `sms` and not `twilio` |\n| **push.md** | Author push notification content — title/message, deep links, images, custom data, sound, priority, silent/custom payloads, platform differences, copy patterns |\n| **inbound_messaging.md** | Two-way SMS — handling inbound replies as events, building an inbound automation that branches on `{{event.keyword}}`, custom keywords and reserved-keyword collisions, and where to see inbound activity |\n| **content.md** | Snippets, collections, Design Studio nodes |\n| **channels.md** | Email/SMS/push/in-app/webhook channel configuration and settings |\n| **web_push_setup.md** | Web push setup: keys, plugin, service worker, Safari. Browser content, limits, tests: web_push.md. Read before search_docs |\n| **in_app.md** | In-app message content (Carta component markup), provisioning, surveys, route rules |\n| **sign_up_forms.md** | Find and update sign-up forms |\n| **in_app_troubleshooting.md** | Why an in-app message \"isn't showing up\" — blank template, not provisioned, SDK never connected, test-send/route-rule/targeting diagnosis |\n| **transactional_in_app.md** | Transactional in-app messages — create, configure (raw HTML + body_json), activate, send via App API |\n| **inbox.md** | Inbox messages — `body_json` schema (`topics`, `type`, `priority`, `expiration`, and the `properties` custom payload), create/configure, send via App API |\n| **translations.md** | Multi-language messages — `/translations` endpoint for one-time send/transactional, BCP-47 codes, send-time language matching. (Automation action translations are in `automations.md § Multi-Language Action Variants`.) |\n| **automation_triggers.md** | Event triggers, segment triggers, date triggers, API triggers, geofence enter/exit/dwell and EXIT duration triggers; **§ Webhook-triggered: what `{{trigger.*}}` / `{{customer.*}}` resolve to in a webhook journey (POST body top level, no `data` wrapper, no profile) — read before any Liquid on a webhook automation** |\n| **object_triggers.md** | Object trigger (`rel_seg_attr`): prefer it to legacy object/relationship triggers |\n| **geofences.md** | Geosets \u0026 geofences — geoset CRUD/duplicate/usage/emoji, fences within sets (search, bbox, bulk move/copy/delete), radius (100m–10,000m) and attribute validation, geocoding via the Places proxy, GeoJSONL export. Feature-gated. |\n| **automation_lifecycle.md** | Start/stop/sunset/**archive**/**unarchive**/**delete** automations and their states. Read before any \"archive\" or \"remove\" request: archive is a `PUT` state transition, not `DELETE` |\n| **automation_metrics.md** | Direct automation and action-level delivery stats and campaign link clicks |\n| **experiments.md** | First-class Experiments |\n| **automation_filters.md** | Entry/exit/goal filters on automations |\n| **automation_review.md** | Review or QA an automation before starting with pre-launch checklist covering structure, configuration, timing, and content |\n| **broadcast_review.md** | Review or QA a broadcast (one-time send) before sending — audience, sender, unsubscribe, tracking, and content checklist. Use this for broadcasts, not automation_review.md |\n| **ab_tests.md** | Automation A/B tests: convert a message into a split test, allocations, pick a winner, one split's raw variant metrics, active-tests dashboard. Cross-test trends: `deliverability.md` (`content_id`) |\n| **newsletter_ab_tests.md** | Broadcast A/B tests: variations, sampling, and winner |\n| **analytics.md** | Direct workspace-wide dashboard endpoints for delivery totals, inbox-provider/email-client breakdowns, and workspace link clicks; ad-hoc raw analytics for custom events or cross-automation funnels not covered by a governed view |\n| **deliverability.md** | Governed automation, broadcast, and transactional performance analysis — ranking, comparison, trends, derived rates, recipient-level details, attribution, \"best and worst performing campaigns\", \"average open rate across the emails\", A/B variant comparisons (`content_id`), \"campaigns tagged X\", best send time, inbound replies, webhook-triggered campaign inflow. Provider, device, link, and reason dimensions are NULL before 2026-07-22; when a direct dashboard endpoint answers the requested breakdown, prefer it over silently dropping earlier rows. For a subjective \"best/worst\" with no metric named, read `recipes/analysis.md` first |\n| **deliverability_examples.md** | Worked example queries (Ex 1–17) for `deliverability.md` — read `deliverability.md` first for the grain/rate/date rules, then start from the example matching the question's shape |\n| **query_spec.md** | Governed-view queries as a JSON spec, not SQL; adds an edit link |\n| **pipeline_health.md** | Failed/rejected data analytics — \"what's failing in my (data) pipeline\", \"how many events were rejected and why\", \"which attribute keeps failing to update\", skipped updates, GDPR suppression rejections, segment build runs (governed Snowflake view). Journeys ingest failures only — Pipelines (CDP) source/sync health is the cdp-api skill; one *specific* profile's update belongs in `automation_debugging.md`/`customers.md` |\n| **profile_changes.md** | Profile changes over time (Snowflake) — created/deleted in N days, net growth, \"where do new profiles come from\" / \"what is the source of most new profiles\" (ingress source — never activity-log pagination), \"how many profiles changed attribute X\", before/after values, device registrations. Current values: `profile_state.md` |\n| **site_activity.md** | Site \u0026 app activity analytics (governed Snowflake view) — Customer.io DOES hold page/screen view data (`page()`/`screen()` from the JS snippet and SDKs): \"how many people visited my site\", \"most visited pages\", \"how many opened page X\" (page VIEWS, not email opens), \"what time do people browse\", visit trends; geofence enter/exit events. Never tell the user to use a web-analytics tool without reading this first |\n| **object_state.md** | Current objects (e.g. companies) and members (Snowflake): counts, who is in one. Changes: `object_changes.md` |\n| **account.md** | Account settings, users, roles, audit logs, API credentials |\n| **billing.md** | Plan limits and usage — how many profiles/messages the plan includes, current usage, overage, invoices. **Read this for any \"how many X does our plan include / are we over our limit\" question:** the limit comes from the subscription and which endpoint serves it depends on the account's billing provider |\n| **workspace.md** | Workspace settings, timezone, subscription center |\n| **frequency_caps.md** | Message frequency limits (named caps), frequency caps API, legacy workspace message limit. Caps how often one *person* is messaged, across everything they receive — \"no more than 3 emails a week\", \"stop bombarding people\". For capping how fast a single automation sends, use `message_throttling.md` instead |\n| **message_throttling.md** | Rate limit an automation — cap how many messages per minute it sends on a channel; \"sending too fast\", \"slow it down\", \"throttle\", \"rate limit\", \"spread the send out\". Email, SMS, Push and Webhook only. For limiting how often one *person* is messaged, use `frequency_caps.md` instead; for messages arriving out of order after a rate limit, use `await_completion.md` |\n| **await_completion.md** | \"Wait before continuing journey\" — hold a person at a message step until that message actually sends; \"messages arrived out of order\", \"the next step ran before the email went out\", \"wait for the send to finish\". A boolean on an existing message action, **not** a delay and **not** a Wait Until — it adds no action to the workflow (for those see `delays.md` and `flow_control.md`) |\n| **subscriptions.md** | Subscription topics, preferences |\n| **webhooks.md** | Reporting webhooks (event callbacks) |\n| **goals.md** | Conversion goals |\n| **delays.md** | Delay actions (time delay, time window, specific date). To wait for a *message's own send* to finish, use `await_completion.md` |\n| **operations.md** | Tags, categories, object types, audit logs (config-change records, incl. geoset/geofence config changes — NOT geofence enter/exit activity, which is the activity log in `customers.md`), search, suppressions (bounces/blocks/spam_reports/invalid_emails), previews/test sends, backfills |\n| **imports_exports.md** | Bulk CSV/GeoJSON imports (people, events, objects, relationships, segments, geosets) and data exports (deliveries, metrics, profile/object data, etc.) |\n| **health.md** | Workspace health checks |\n| **automation_debugging.md** | Repairing automations and messages; verify renders and retests, skipped actions, and profile entry |\n| **test_sends.md** | Send test messages (email, SMS, push, webhook, Slack) |\n\u003c!-- /subfile-index --\u003e\n\n## Related Playbooks (recipes/)\n\nThese are workflow guides — read them when a question's shape matches, in addition to the API sub-file:\n\n- **`recipes/analysis.md`** — read FIRST whenever a user asks a subjective or evaluative question (rank, compare, \"which is best\", \"is X performing well\") without specifying the criteria.\n- **`recipes/analyzing_automations.md`** — multi-step workflow for diagnosing automation performance, delivery, bounce, or engagement issues. Use alongside `automation_metrics.md` and `automation_debugging.md`.\n- **`recipes/creating_a_goal.md`** — semantic-first flow for creating a new goal (existing-goal signal, intent, measure, impact, sources). This is what the \"Build with agent\" goals empty-state entry point uses. Pair with `goals.md` for the API mechanics.\n- **`recipes/goal_segments.md`** — strategy for building segments from goal completion data (completed, didn't complete, almost completed, repeat completers). Pair with `data_driven_segments.md` for the API mechanics.\n- **`recipes/mobile_geofencing.md`** — native and wrapper SDK setup, permissions, background delivery, transition payloads, and end-to-end troubleshooting. Read it for any mobile geofence integration question; pair with `geofences.md` for workspace configuration.\n- **`recipes/periodic_send_report.md`** — read when producing a recurring report of what was sent / how it performed over a time window (e.g. a weekly automation report routine). Prevents reporting on stale messages and missing recent sends.\n- **`recipes/liquid_syntax.md`** — Liquid templating reference. Read when authoring or debugging template body/subject content.\n\n## About the AI Assistant\n\n**You ARE the AI assistant (copilot).** Never call copilot session endpoints (/copilot/sessions, /copilot/messages, etc.) — they're the infrastructure running you; calls will fail or loop.\n\nWhen a user asks \"what can the AI do?\" or \"how do I use copilot?\", describe your capabilities in product terms:\n- Create and configure automations, broadcasts, and transactional messages\n- Add LLM actions to a workflow — call a model per profile to generate, classify, summarize, or score, and use the result in later messages\n- Build email content in Design Studio\n- Set up segments and manage audience targeting\n- Query automation metrics and troubleshoot delivery issues\n- Analyze warehouse data (governed views): message performance, site and app views, profiles and objects, and pipeline failures — check the sub-file index for the matching skill before declining these questions\n- Manage profiles, attributes, and events\n- Create and manage geosets of geofences, and use them as automation triggers or segment conditions\n- Bulk-import data (people, events, objects, geoset fences) from a CSV or GeoJSON file\n- Look up workspace settings and health status\n\nIf a user asks you to \"use copilot to generate content\" or similar, they're asking you to do it directly — you are the copilot. Proceed with the task using your available skills and API access.\n","files":{"ab_tests.md":"---\nminimum_plan: essentials\n---\n\n# Automation A/B Tests\n\nAutomation A/B tests work by converting an existing message action into a split test with multiple variants. Each variant gets its own template so you can test different subject lines, body content, or entire messages. (One-time send A/B tests work differently and are documented in `newsletter_ab_tests.md`.)\n\n\u003e **Check the channel first: A/B split is not available for every channel.** Automation A/B split is supported on the message channels the product offers it for: **Email, Push, SMS, WhatsApp, Line, in-app, Inbox, and Airship**. It is **not** available for **Slack** or **Webhook** actions (no comparable A/B metric), or for non-message actions (delay, branch, etc.). The `POST /actions/:id/test` endpoint does not fully enforce this, so a `200` is not by itself proof the channel is supported. Before creating a split, verify the target action's channel/`sub_type` is in the supported set; if it is not, do **not** create the split, tell the user A/B testing is not available for that channel, and offer an alternative (e.g. duplicate the message to compare manually).\n\n## Creating a split test\n\n### Convert the action to a split test\n\n```\nPOST /v1/environments/:environment_id/actions/:action_id/test\nBody: { \"campaign_id\": \"CAMPAIGN_ID\", \"type\": \"randomized\" }\n```\n\nThe response returns `meta.action_id` — this is the first sub-action (variant A). The original action becomes the parent split action with two variants.\n\n\u003e **Efficient A/B email sequence.** Create the automation + email action(s), split into A/B (this endpoint), then set each variant's content on its **own existing** message. Do **not** link Design Studio nodes to the A/B actions at all: linking to a **variant** sub-action returns **400** `\"campaign action has no default template to link\"`, and linking to the **parent split** action **silently succeeds** but writes to a template the split never sends from (see `@skills/design-studio/journeys.md § Constraints`). Set content per variant instead — if the pre-split action was already a Design Studio email (`editor: \"parcel\"`), revise the variant's existing node and republish; otherwise the variants are plain templates, so use `PUT /templates/:template_id`.\n\n### Get variant template IDs\n\nRead the automation to find the sub-action IDs under the parent split action. Each variant has its own `template_id` — get these from the action objects in the automation response.\n\n### Set variant content\n\nUpdate each variant's subject line, body, or other content by updating its template:\n\n```\nPUT /v1/environments/:environment_id/templates/:template_id\nBody: { \"template\": { \"subject\": \"Variant A subject line\" } }\n```\n\nThe `PUT /templates/:template_id` above applies when the variants are **plain templates** — i.e. the pre-split action was not a Design Studio email. If the pre-split action **was** a Design Studio email (`editor: \"parcel\"`), `CreateSplit` gives each variant its own DS node; revise that **existing** node and republish instead (`PUT /v1/.../design_studio/emails/\u003cnode_id\u003e` then publish — see `@skills/design-studio/journeys.md`), because editing the parcel template directly is overwritten on the next publish.\n\nEither way, do **not** link a Design Studio node to the A/B actions: linking to a **variant** sub-action returns 400, and linking to the **parent split** action silently succeeds but writes to a template the split never sends from. See `@skills/design-studio/journeys.md § Constraints`.\n\n### Set traffic allocations\n\nUse the automation update endpoint with `update_type: \"save_action\"` to configure the split percentages for the parent action.\n\n```\nPUT /v1/environments/:environment_id/campaigns/:campaign_id\n```\n\nThe parent action is a `split_randomized_action` with these key fields:\n- `allocations` — array of integers representing traffic percentage per variant. **Must sum to exactly 100.**\n- `split_state` — `\"inactive\"`, `\"running\"`, or `\"paused\"`\n\n**Important:** The `action` object is a **top-level field** (not nested inside `campaign`). Read the automation first to get the current `edges` and include them unchanged.\n\nExample:\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"save_action\",\n    \"edges\": [\"... existing edges from GET response ...\"]\n  },\n  \"action\": {\n    \"id\": \"PARENT_SPLIT_ACTION_ID\",\n    \"type\": \"split_randomized_action\",\n    \"name\": \"Subject line A/B test\",\n    \"allocations\": [50, 50],\n    \"split_state\": \"inactive\"\n  }\n}\n```\n\n\n## Monitoring results\n\n### Split metrics\n\n```\nGET /v1/environments/:environment_id/actions/:action_id/split_metrics\n```\n\nReturns per-variant delivery and engagement metrics (sent, delivered, opened, clicked, converted).\n\n\u003e **Only call this on a `split_randomized_action`.** Check the action's `type` in the automation response first. A non-split action (`email_action`, a delay, a branch…) returns **400** `\"action \u003cid\u003e has type \u003ctype\u003e, not split_randomized_action, so it has no split test metrics\"` — read that as \"wrong action type\", not as a transient failure, and don't retry it. For a regular action's numbers use `GET /v1/environments/:environment_id/actions/:action_id/metrics` instead (see `automation_metrics.md`). A split whose test has not started yet returns **200** with an empty `split_metrics` array.\n\n### Chance to Beat Original (CTBO)\n\n**CTBO** stands for **Chance to Beat Original** — the likelihood that a variation will outperform the original. It's how Customer.io reports statistical significance for an A/B test, and it's the metric the UI surfaces when picking a winner. It's available against three statistics: open rate, tracked link clicks, and conversion rate.\n\nCustomer.io uses a 95% significance level, so read it as:\n\n| CTBO | Interpretation |\n|---|---|\n| \u003e 95% | The variation is outperforming the original |\n| \u003c 5% | The original is outperforming the variation |\n| 5%–95% | Not statistically significant — no winner yet |\n\nA CTBO of 50% means the two are performing identically.\n\n\u003e **CTBO is not returned by `split_metrics`.** That endpoint returns raw per-variant counts only (`sent`, `delivered`, `opened`, `human_opened`, `clicked`, `human_clicked`, `converted`) as time-series arrays. CTBO is the significance calculation over those counts, computed for the UI. Don't claim to read a CTBO value out of the API response — if a user asks for the current CTBO, either point them at the test's results view in the UI or be explicit that you're reporting raw rates instead.\n\nWhen a user asks what CTBO means or how it's calculated, `search_docs` has the full write-up (\"Understanding your A/B test results\").\n\n### Pick a winner\n\n```\nPOST /v1/environments/:environment_id/actions/:action_id/winner\nBody: { \"winner_action_id\": \"WINNING_VARIANT_ID\" }\n```\n\nAfter picking a winner, all future traffic goes to the winning variant.\n\n---\n\n## Dashboard: Active A/B Tests\n\nView all active A/B tests across automations and broadcasts:\n\n```\nGET /v1/environments/:environment_id/metrics/dashboard_tests?version=2\u0026tz=America/New_York\n```\n\nReturns active A/B tests with per-variant metrics (sent, delivered, opened, clicked, converted) for automations, and test configuration (variation count, test metric, auto-winner) for broadcasts.\n\n**Query parameters:**\n- `version=2` (required)\n- `tz` (required) — timezone name (e.g., \"America/New_York\")\n- `res` — resolution (e.g., \"daily\", \"monthly\")\n- `start`, `end` — unix timestamps (optional)\n","account.md":"# Account — Users, Roles, Credentials \u0026 Settings\n\n## Account → Environment Hierarchy\n\nAn **account** is the top-level entity — it holds billing, users, and one or more **environments** (workspaces). Each environment is an isolated workspace with its own profiles, automations, and settings. Most API endpoints are scoped to an environment via `environment_id`.\n\nTo find which account an environment belongs to:\n\n```\nGET /v1/account_by_environment/:environment_id\n```\n\n## Accounts\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}` | `accounts.Get` | Full account details |\n| PUT | `/v1/accounts/{account_id}` | `accounts.Update` | Update account name. Body: `{\"name\": \"...\"}` |\n| GET | `/v1/accounts/{account_id}/environments` | `accounts.IndexEnvironments` | List all environments in the account |\n| GET | `/v1/accounts/{account_id}/usage` | `accounts.GetUsage` | Usage counts — profiles, messages, objects, per-workspace breakdown. On a Flex (`flex_2026`) account it also carries a `wallet` block, with `balance` and `total_spent` in dollars; the spend figure counts only what was drawn from the balance, not usage an allowance paid for. `has_funded` is true once the account has bought credits at least once, even if they have since been spent or expired; goodwill and grants do not set it, so read it to tell an account that never paid from one that paid and ran dry. Its `buckets` object lists what the wallet is made of, each row with the bucket `id`, `amount`, `remaining` and RFC3339 `starts_at` / `ends_at`, soonest to expire first. `buckets.deposits` is a list of the live purchase and goodwill buckets behind `balance`, with a `kind`, in dollars. `buckets.grants` is keyed by meter, lists only meters with a grant live right now, and its figures are the meter's own units (sends, not dollars). Never add figures across the two groups. Its `*_allotted` fields hold the **legacy Stripe** plan record's limits, so they describe the plan only for `legacy_stripe` accounts; for a plan limit read `billing.md` § Plan Entitlements and take it from the subscription for the account's `billing_vendor` |\n| GET | `/v1/accounts/{account_id}/usage_v2` | `accounts.GetUsageV2` | Usage counts plus MAU, and the only endpoint with a true objects watermark (`/usage` gates that on the legacy plan record). Without `start` it meters from the start of the account's current billing month; the overage comparison passes `/usage`'s window instead, see `billing.md` § Overage detection. Serves usage only; its plan fields stay at `0` |\n| GET | `/v1/accounts/{account_id}/audit_logs` | `audit_logs.GetAccountAuditLogs` | Account-level audit logs — account, user, and plan changes. Account-admin only, so not available to you; workspace content changes live on the per-workspace route in `operations.md` |\n| POST | `/v1/accounts/{account_id}/verify` | `accounts.Verify` | Submit account for verification. Body: `{\"website\": \"...\", \"description\": \"...\", \"technicalContact\": \"...\"}` |\n| POST | `/v1/accounts/{account_id}/address` | `accounts.UpdateAddress` | Update billing/physical address |\n| POST | `/v1/accounts/{account_id}/sign_logo` | `accounts.GenerateAccountLogoUploadSignature` | Generate signed URL for uploading account logo |\n| POST | `/v1/accounts/{account_id}/reactivate` | `accounts.ReactivateAccountPendingCancellation` | Cancel a pending account cancellation and reactivate |\n\n## Account Settings UI Paths\n\nAccount settings are **not workspace-scoped**. Use `https://fly.customer.io` directly; do not prefix these paths with `/workspaces/$ENV_ID`.\n\n| Setting | UI Path |\n|---------|---------|\n| Team Management | `/settings/team` |\n| Billing | `/settings/billing` |\n| Roles \u0026 Permissions | `/settings/roles` |\n| Security (SSO/2FA) | `/settings/security` |\n| AI \u0026 MCP | `/settings/ai` |\n| Audit Logs | `/settings/audit-logs` |\n| API Credentials | `/settings/api_credentials` |\n\nThe account-level audit log — team, role, credential, SSO and plan changes — is account-admin only (`Account_Edit`), so you can't read it; point the user at this page instead. Workspace audit logs are readable, and their shape and filters are in `operations.md`. For a specific account audit log, append `?audit_log_id=\u003cid\u003e` to `/settings/audit-logs`. Never use the singular UI path `/settings/audit-log`.\n\nRoles \u0026 Permissions is its own Account Settings page, not part of Team Management: link `/settings/roles`. Never use `/settings/team/roles`, which renders a blank page.\n\n## Account Settings\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/settings/public` | `accounts.GetPublicSettings` | Get account public settings |\n| PUT | `/v1/accounts/{account_id}/settings/public` | `accounts.UpdatePublicSettings` | Update account public settings |\n| PUT | `/v1/accounts/{account_id}/session_duration` | `accounts.SetSessionDuration` | Set session timeout for all users (1–14 days in seconds). Revokes sessions exceeding new limit |\n| POST | `/v1/accounts/{account_id}/2fa_required/{action}` | `accounts.TwoFactorAuthRequiredToggle` | Enforce or disable mandatory 2FA for all account users. `action`: `enable` or `disable` |\n| PUT | `/v1/accounts/{account_id}/set_support_access` | `accounts.UpdateSupportAccess` | Enable/disable Customer.io support team access |\n| PUT | `/v1/accounts/{account_id}/set_success_access` | `accounts.UpdateSuccessAccess` | Enable/disable Customer.io success team access |\n| PUT | `/v1/accounts/{account_id}/set_rd_access` | `accounts.UpdateRdAccess` | Enable/disable Customer.io R\u0026D team access |\n| POST | `/v1/accounts/{account_id}/activate_product` | `accounts.ActivateProduct` | Activate a product for the account |\n| POST | `/v1/accounts/{account_id}/enable-product-trial` | `accounts.EnableProductTrial` | Start a product trial |\n\n## AI Settings\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/billing/credits/balance/llm` | `billing.GetCreditBalance` | View remaining AI/LLM credits |\n| GET | `/v1/accounts/{account_id}/gemini_safety_settings` | `accounts.GetGeminiSafetySettings` | Get Gemini AI safety filter settings |\n| PUT | `/v1/accounts/{account_id}/gemini_safety_settings` | `accounts.UpdateGeminiSafetySettings` | Update Gemini AI safety filter settings |\n\n## Users\n\nUsers refer to the team members associated with an account. They are *not* the same as profiles.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/users` | `users.Index` | List users. Params: `page`, `search`, `sort`, `asc` |\n| GET | `/v1/accounts/{account_id}/all_users` | `users.IndexAll` | List all users (no pagination) |\n| GET | `/v1/accounts/{account_id}/users/{id}` | `users.Get` | Get a single user |\n| POST | `/v1/accounts/{account_id}/users` | `users.Create` | Invite a user. An optional hand-off `invite_type` with `invite_environment_id` makes that workspace the invitee's landing workspace and sends a hand-off email instead of the standard invite: `technical` links to the workspace's integrations overview, `set_up_domain` to its email sending-domain settings |\n| PUT | `/v1/accounts/{account_id}/users/{id}` | `users.Update` | Update user |\n| DELETE | `/v1/accounts/{account_id}/users/{id}` | `users.Delete` | Remove user |\n| POST | `/v1/accounts/{account_id}/users/{id}/set_email` | `users.SetEmail` | Change user email. Body: `{\"user\": {\"currentPassword\": \"...\", \"email\": \"...\"}}` |\n| POST | `/v1/accounts/{account_id}/users/{id}/reset_password` | `users.ResetPassword` | Send password reset email to user |\n| POST | `/v1/accounts/{account_id}/users/{id}/set_password` | `users.SetPassword` | Set or change the session user's password. Body: `{\"user\": {\"current_password\": \"...\", \"password\": \"...\"}}`; `current_password` is only required when `has_password` is true |\n| POST | `/v1/accounts/{account_id}/users/{id}/resend_confirmation_email` | `users.ResendConfirmationEmail` | Resend invite email to unconfirmed user |\n\n## User Permissions \u0026 Roles\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/users/{id}/permissions` | `users.UserPermissions` | Get user's account and workspace permissions |\n| GET | `/v1/accounts/{account_id}/users/{id}/roles` | `users.UserRoles` | Get user's roles across all workspaces |\n| GET | `/v1/accounts/{account_id}/user_roles` | `users.ListAccountUserRoles` | Get all user-role assignments across the account |\n\n## User 2FA\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/users/{id}/2fa_secret` | `users.TwoFactorAuthGenerateSecret` | Generate a TOTP secret for 2FA setup |\n| POST | `/v1/accounts/{account_id}/users/{id}/2fa_setup` | `users.TwoFactorAuthSetup` | Enable 2FA. Body: `{\"code\": \"...\", \"secret\": \"...\"}` (secret must be 16 chars) |\n| POST | `/v1/accounts/{account_id}/users/{id}/2fa_setup_destroy` | `users.TwoFactorAuthDestroySetup` | Disable 2FA. Body: `{\"code\": \"...\"}` (requires valid TOTP code) |\n| GET | `/v1/accounts/{account_id}/users/{id}/2fa_backup_codes` | `users.TwoFactorAuthGetBackupCodes` | Get current backup codes (requires 2FA enabled) |\n| POST | `/v1/accounts/{account_id}/users/{id}/2fa_generate_backup_codes` | `users.TwoFactorAuthGenerateNewBackupCodes` | Generate new backup codes (invalidates old ones) |\n\n## User External Identity (Google Login)\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| POST | `/v1/accounts/{account_id}/users/{id}/external_identity` | `users.SetExternalUserIdentity` | Link Google One-Click login. Body: `{\"current_password\": \"...\", \"code\": \"...\", \"redirect_uri\": \"...\"}` |\n| DELETE | `/v1/accounts/{account_id}/users/{id}/external_identity` | `users.DestroyExternalUserIdentity` | Unlink external identity |\n\n## Roles\n\nCustom roles define what permissions a user has within the account and its workspaces.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/role_definitions` | `roles.ListDefinitions` | List available permission definitions and template roles for building custom roles |\n| GET | `/v1/accounts/{account_id}/roles` | `roles.List` | List all roles |\n| GET | `/v1/accounts/{account_id}/roles/{role_id}` | `roles.Get` | Get a single role with permissions |\n| POST | `/v1/accounts/{account_id}/roles` | `roles.Create` | Create a custom role. Body: `{\"role\": {\"name\": \"...\", \"description\": \"...\", \"permissions\": [\"...\"]}}` |\n| POST | `/v1/accounts/{account_id}/roles/{role_id}/copy` | `roles.Copy` | Duplicate an existing role |\n| PUT | `/v1/accounts/{account_id}/roles/{role_id}` | `roles.Update` | Update role. Body: `{\"role\": {\"name\": \"...\", \"description\": \"...\", \"permissions\": [\"...\"]}}` |\n| DELETE | `/v1/accounts/{account_id}/roles/{role_id}` | `roles.Delete` | Delete role (fails if role is in use) |\n\n## SSO Configuration\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/sso_configuration` | `accounts.GetSSOConfiguration` | Get SSO config |\n| POST | `/v1/accounts/{account_id}/sso_configuration` | `accounts.CreateSSOConfiguration` | Set up SSO |\n| PUT | `/v1/accounts/{account_id}/sso_configuration` | `accounts.UpdateSSOConfiguration` | Update SSO config |\n| DELETE | `/v1/accounts/{account_id}/sso_configuration` | `accounts.DestroySSOConfiguration` | Remove SSO |\n| GET | `/v1/accounts/{account_id}/sso_configuration/validations/email_domain_mismatch` | `accounts.EmailDomainMismatch` | Check for users whose email domains don't match the SSO domain |\n\n## SCIM Provisioning\n\nSCIM (System for Cross-domain Identity Management) enables automatic user provisioning from an identity provider (e.g., Okta, Azure AD).\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/scim_configuration` | `accounts.GetSCIMConfiguration` | Get SCIM config including base URL and tokens |\n| POST | `/v1/accounts/{account_id}/scim_configuration` | `accounts.CreateSCIMConfiguration` | Enable SCIM. Body: `{\"scim_configuration\": {\"default_role_id\": ..., \"default_workspace_id\": ...}}` |\n| PUT | `/v1/accounts/{account_id}/scim_configuration` | `accounts.UpdateSCIMConfiguration` | Update SCIM defaults |\n| DELETE | `/v1/accounts/{account_id}/scim_configuration` | `accounts.DeleteSCIMConfiguration` | Disable SCIM provisioning |\n| POST | `/v1/accounts/{account_id}/scim_configuration/tokens` | `accounts.CreateSCIMToken` | Generate a new SCIM bearer token |\n| GET | `/v1/accounts/{account_id}/scim_configuration/tokens/{token_id}` | `accounts.GetSCIMToken` | Get a SCIM token |\n| DELETE | `/v1/accounts/{account_id}/scim_configuration/tokens/{token_id}` | `accounts.DeleteSCIMToken` | Revoke a SCIM token |\n\n## Credentials \u0026 API Keys\n\n### API Credentials (Track API Keys)\n\nAPI credentials are used to authenticate with the Customer.io Track API for sending profile data.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/api_credentials` | `api_credentials.Index` | List API keys. Params: `environment_id`, `page`, `size` |\n| GET | `/v1/accounts/{account_id}/api_credentials/{id}` | `api_credentials.Get` | Get a single API key |\n| POST | `/v1/environments/{environment_id}/api_credentials` | `api_credentials.Create` | Create an API key. Body: `{\"api_credential\": {\"name\": \"...\"}}` |\n| PUT | `/v1/environments/{environment_id}/api_credentials/{id}` | `api_credentials.Update` | Update API key name. Body: `{\"api_credential\": {\"name\": \"...\"}}` |\n| DELETE | `/v1/environments/{environment_id}/api_credentials/{id}` | `api_credentials.Delete` | Delete an API key |\n\n### External API Keys (App API Keys)\n\nExternal API keys are used to authenticate with the Customer.io App API for reading workspace data.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/ext_api_keys` | `ext_api_keys.Index` | List all external API keys with last-used timestamps |\n| POST | `/v1/environments/{environment_id}/ext_api_keys` | `ext_api_keys.Create` | Create an external API key. Body: `{\"ext_api_key\": {\"name\": \"...\"}}`. The key value is only returned on creation |\n| PUT | `/v1/environments/{environment_id}/ext_api_keys/{id}` | `ext_api_keys.Update` | Update key name. Body: `{\"ext_api_key\": {\"name\": \"...\"}}` |\n| DELETE | `/v1/environments/{environment_id}/ext_api_keys/{id}` | `ext_api_keys.Delete` | Delete an external API key |\n\n## IP Allowlist\n\nRestrict Track API access to specific IP addresses. When the allowlist is non-empty, only listed IPs can make Track API calls.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/accounts/{account_id}/whitelisted_ips` | `whitelisted_ips.Index` | List all allowed IPs with last-used timestamps |\n| POST | `/v1/accounts/{account_id}/whitelisted_ips` | `whitelisted_ips.Create` | Add an IP. Body: `{\"whitelisted_ip\": {\"ip\": \"...\", \"environment_id\": ...}}` |\n| DELETE | `/v1/accounts/{account_id}/whitelisted_ips/{id}` | `whitelisted_ips.Delete` | Remove an IP from the allowlist |\n\n## Common Pitfalls\n\n1. **Account vs environment scope**: Listing API keys and ext API keys is account-scoped, but creating/updating/deleting them is environment-scoped. Make sure you're using the right path.\n\n2. **2FA endpoints are current-user only**: The 2FA setup/destroy endpoints only work for the currently authenticated user — you cannot enable 2FA for another user. Use the account-level `2fa_required` toggle to enforce 2FA for everyone.\n\n3. **Role deletion**: Deleting a role fails if any users are currently assigned to it. Reassign users to a different role first.\n\n4. **SCIM tokens**: The actual token value is only returned when created. Store it securely — it cannot be retrieved later.\n\n5. **External API key value**: Like SCIM tokens, the `key` field is only returned on creation. Subsequent GET requests return only a `hint` (last few characters).\n","analytics.md":"---\nminimum_plan: essentials\n---\n\n# Analytics — Workspace Metrics\n\nWorkspace-wide performance tracking.\n\n## Conversion Goals (Campaigns \u0026 Broadcasts)\n\nCampaigns and broadcasts can have **conversion goals** — conditions that define when a customer has \"converted\" (e.g., opened an email, clicked a link, entered a segment). These are configured as part of the campaign or broadcast setup. For standalone goals tracking (a separate feature), see **goals.md**.\n\n## Workspace-Wide Metrics\n\n```\nGET /v1/environments/:environment_id/metrics/:name\n```\n\n| Name | Description |\n|------|-------------|\n| `analysis` | Aggregate delivery metrics with filtering by name, data type, tags, status |\n| `all_deliveries` | Delivery metrics by channel type |\n| `all_links` | Workspace-wide link click metrics |\n| `deliverability` | Inbox provider and email client metrics (feature-gated behind `deliverability_analytics`) |\n\n**Query parameters:**\n- `version=2` (required)\n- `tz` (required) — timezone name (e.g., \"America/New_York\")\n- `res` — resolution (e.g., \"daily\", \"monthly\")\n- `start`, `end` — unix timestamps (optional; defaults calculated from resolution)\n\n## Workspace-Wide Link Metrics\n\n```\nGET /v1/environments/:environment_id/tracked_link_metric\n```\n\nReturns per-link click data for all tracked links across the workspace. Uses the same query parameters as above.\n\nFor automation-scoped, template-scoped, or transactional message-scoped link metrics, see **automations.md**.\n\n**Gotcha:** Links whose underlying link record was deleted are silently dropped from results — no error, just missing data.\n\n## Direct Snowflake Queries\n\nFor ad-hoc analytics that the standard metrics endpoints can't answer — custom event aggregations, cross-campaign funnel analysis, raw event exploration, or anything requiring SQL flexibility.\n\n\u003e **For campaign/broadcast/transactional performance** (ranking, comparison, trends, rates), use `deliverability.md` — it queries governed views with pre-defined metric calculations. **Also use `deliverability.md` for any message-attribution question** — \"did messages influence/drive this event\", \"purchases after a campaign\", time-from-message-to-event — its `attributed_events` metrics (`people_did_event`, `people_attributed`, `avg_hours_since_message`) implement last-touch 30-day attribution correctly; never hand-roll attribution by joining raw event rows (a same-window join is NOT last-touch and produces wrong numbers). Use the raw queries below only for non-attribution work: custom event exploration, attribute changes, and segment analysis.\n\n```\nPOST /v1/environments/:environment_id/snowflake_query\n```\n\n**Request body:**\n```json\n{\"query\": \"SELECT name, count(*) as cnt FROM events WHERE event_type = 1 GROUP BY name ORDER BY cnt DESC LIMIT 20\"}\n```\n\n**Response:**\n```json\n{\n  \"columns\": [\"name\", \"cnt\"],\n  \"rows\": [{\"name\": \"purchase\", \"cnt\": \"4521\"}, ...],\n  \"truncated\": false\n}\n```\n\n### Query rules\n\n- Results capped at **10,000 rows** — check `\"truncated\": true` and add a `LIMIT` if you're hitting it\n- **Pass `--timeout 120s` on every `cio api ... snowflake_query` call.** The CLI's default client timeout is 30s, shorter than the warehouse's 90s statement limit — a slow-but-valid query dies with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)`. That error is the client giving up, not the query failing: resend the same SQL with `--timeout 120s` first, narrow the date range only if the warehouse itself times out at 90s\n- **Send it with `cio`, never curl:** `cio api /v1/environments/$ENV_ID/snowflake_query -X POST --arg query=\"$SQL\" --json '{query: $query}' --timeout 120s`. In a scheduled routine, curl and fetch may send only GET, so a curl POST fails with `HTTP method 'POST' not allowed` (or a bare exit code 3). That is the client, not the warehouse: resend with cio. It does not mean the query is a mutation or that routines can't reach the warehouse\n- **One statement per request** — submit only the `SELECT`, never a `SET`/`USE` statement ahead of it. A second statement is rejected with `Actual statement count 2 did not match the desired statement count 1`\n- All results are scoped to the current workspace automatically — **do not** add `WHERE workspace_id = ...`\n\n### The `events` table\n\nAll data lives in a single table: **`events`** (or `analytics.public.events`). Every activity in the workspace — track calls, attribute changes, delivery metrics, segment changes, page views — is a row in this table.\n\n#### Columns\n\n| Column | Type | Description |\n|--------|------|-------------|\n| `event_type` | int | Type of activity (see event type reference below) |\n| `name` | string | Event name, attribute name, metric name, or segment action depending on type |\n| `occurred_at` | timestamp | When the event happened, **UTC** — for a `sent` delivery metric, when the message actually went out (see \"Send timing\" below) |\n| `customer_bitmap_idx` | int | Internal person identifier — use for `COUNT(DISTINCT ...)` to count unique people |\n| `customer_internal_id` | string | Person's internal ID (hex) |\n| `json_data` | variant | Arbitrary JSON payload (custom event properties, error details, etc.) |\n| `campaign_id` | int | Associated campaign (0 if none) |\n| `action_id` | int | Associated action within a campaign (0 if none) |\n| `newsletter_id` | int | Associated broadcast (0 if none) |\n| `template_id` | int | Associated template (0 if none) |\n| `transactional_message_id` | int | Associated transactional message (0 if none) |\n| `segment_id` | int | Associated segment (0 if none) |\n| `content_id` | int | Associated content block (0 if none) |\n| `delivery_id` | string | Unique delivery identifier (delivery metrics only) |\n| `delivery_type` | int | Channel: 0=email, 2=SMS, 4=Slack, 5=push, 6=in-app, 9=WhatsApp |\n| `delivery_created_at` | timestamp | Delivery creation time (delivery metrics only), **UTC** — when the person reached the message step, NOT necessarily when it was sent (see \"Send timing\" below) |\n| `from_value` | string | Previous value (attribute changes only) |\n| `to_value` | string | New value (attribute changes only) |\n| `machine` | boolean | Whether an open/click was machine-generated (bots, prefetch) |\n| `synthetic` | boolean | Whether the metric is synthetic (system-generated) |\n\n#### Columns that do NOT exist (common mistakes)\n\nThe `events` table has exactly the columns above — do not borrow column names from the `ANALYTICS.CIO_AGENT` views (`fly-api/deliverability.md`):\n\n- There is NO `event_name`, `metric_name`, `attribute_name`, or `segment_name` column. All of these are the single `name` column, interpreted per `event_type` (see the event type reference below).\n- There is NO `channel` column — use the numeric `delivery_type` (0=email, 2=SMS, 4=Slack, 5=push, 6=in-app, 9=WhatsApp).\n- Delivery metric names are strings in `name` with `event_type = 6` (`'sent'`, `'opened'`, ...) — never numeric codes.\n\n#### Event types\n\n| event_type | What it represents | `name` contains | Key columns |\n|------------|-------------------|-----------------|-------------|\n| 1 | Custom track event | Event name (e.g. \"purchase\") | `json_data` has event properties |\n| 2 | Profile attribute change | Attribute name | `from_value`, `to_value` |\n| 3 | Segment membership change | \"entered\" or \"exited\" | `segment_id` |\n| 4 | Page view | Page URL | |\n| 5 | Screen view | Screen name | |\n| 6 | Delivery metric | \"sent\", \"delivered\", \"opened\", \"clicked\", \"bounced\", etc. | `campaign_id`, `action_id`, `newsletter_id`, `delivery_type`, `delivery_id`, `machine` |\n| 7 | Person created | | |\n| 8 | Person deleted | | |\n| 9 | Profile merge (identified) | | |\n| 10 | Profile merge (anonymous) | | |\n| 11 | Device added | \"add\" | `json_data` |\n| 12 | Device updated | Attribute name | `from_value`, `to_value` |\n| 13 | Device removed | \"remove\" | |\n| 14 | Relationship added (person side) | | |\n| 15 | Relationship removed (person side) | | |\n| 16 | Inbound message (e.g. SMS reply) | | |\n| 17 | Object created | | |\n| 18 | Object deleted | | |\n| 19 | Object attribute change | Attribute name | `from_value`, `to_value` |\n| 20 | Object attribute change rejected | Attribute name | |\n| 21 | Object relationship added | | |\n| 22 | Object relationship removed | | |\n| 23 | Relationship attribute change | Attribute name | `from_value`, `to_value` |\n| 24 | Relationship attribute change rejected | Attribute name | |\n| 25 | Track event rejected | Event name | |\n| 26 | Attribute change rejected | Attribute name | |\n| 27 | LLM action failed | | |\n| 28 | Data pipeline query failed | | |\n| 29 | Batch import row failed | | |\n| 30 | Object could not enter journeys | | |\n| 31 | Attribute change skipped (no-op) | Attribute name | |\n| 32 | Reporting webhook received | | |\n| 33 | Segment rebuild completed | The segment id | `json_data` (rebuild stats) |\n| 34 | Geofence transition | \"enter\" or \"exit\" | |\n\n#### Send timing, timezones, totals\n\n- A delivery has **two timestamps that answer different questions**. `delivery_created_at` = when the delivery was created — the person reached the message action (exited a delay or time window). The `sent` metric's `occurred_at` = when the message actually went out. They legitimately diverge by hours: message frequency limits, sending rate limits, and retries hold a created delivery back until it's allowed to send.\n- So for \"did my delay/window spread people out?\" bucket by `delivery_created_at`; for \"when did messages go out?\" bucket `sent` rows by `occurred_at`; for pacing questions run **both** and compare. Spread creations + converged sends is not a bug or a data artifact — something held the sends back. Check the workspace's message frequency limits (Settings → Actions → Message frequency limits) before reporting a send burst as an anomaly, and never present one timestamp as answering both questions.\n- Raw `events` timestamps are **UTC**; the `ANALYTICS.CIO_AGENT` views are **US Pacific**. Convert explicitly (`convert_timezone('UTC', ...)`) and state the timezone on reported times.\n- A bucketed distribution must sum to an unbucketed `count(*)` under the same filters — a mismatch means dropped rows; fix before presenting.\n\n### Example queries\n\n**Top custom events in the last 30 days:**\n```sql\nSELECT name, count(*) as total\nFROM events\nWHERE event_type = 1\n  AND occurred_at \u003e= dateadd('day', -30, current_date())\nGROUP BY name\nORDER BY total DESC\nLIMIT 20\n```\n\n**Daily email open rate for a campaign (excluding machine opens):**\n```sql\nWITH sends AS (\n  SELECT occurred_at::date AS day, count(*) AS cnt\n  FROM events\n  WHERE event_type = 6 AND name = 'sent' AND campaign_id = 42\n  GROUP BY day\n),\nopens AS (\n  SELECT occurred_at::date AS day, count(DISTINCT customer_bitmap_idx) AS cnt\n  FROM events\n  WHERE event_type = 6 AND name = 'opened' AND campaign_id = 42 AND NOT machine\n  GROUP BY day\n)\nSELECT s.day, s.cnt AS sends, COALESCE(o.cnt, 0) AS opens,\n       round(COALESCE(o.cnt, 0)::float / nullif(s.cnt, 0), 4) AS open_rate\nFROM sends s LEFT JOIN opens o ON s.day = o.day\nORDER BY s.day\n```\n\n**People who changed a specific attribute:**\n```sql\nSELECT customer_internal_id, from_value, to_value, occurred_at\nFROM events\nWHERE event_type = 2 AND name = 'plan'\n  AND occurred_at \u003e= '2025-01-01'\nORDER BY occurred_at DESC\nLIMIT 100\n```\n\n**Segment growth over time:**\n```sql\nSELECT occurred_at::date AS day,\n       count(DISTINCT iff(name = 'entered', customer_bitmap_idx, null)) AS entered,\n       count(DISTINCT iff(name = 'exited', customer_bitmap_idx, null)) AS exited\nFROM events\nWHERE event_type = 3 AND segment_id = 15\nGROUP BY day\nORDER BY day\n```\n\n### When to use this vs. standard metrics\n\n| Question | Use |\n|----------|-----|\n| \"How are my campaigns performing?\" | Standard metrics endpoints |\n| \"What's my email open rate?\" | Standard metrics endpoints |\n| \"Which custom events fire most often?\" | Snowflake query |\n| \"Show me the funnel from signup → purchase\" | Snowflake query |\n| \"How many people changed their plan attribute last month?\" | Snowflake query |\n| \"What % of people added each week have attribute X?\" / \"How has that changed?\" | `profile_changes.md` Ex 8 (attribute coverage by signup cohort) — never temporary segments |\n| \"Compare delivery rates across campaigns X, Y, Z\" | Snowflake query |\n| \"What events did person X trigger?\" | Activity log endpoints (see **customers.md**) |\n\n### Tips\n\n- Use `customer_bitmap_idx` for counting unique people — it's indexed and efficient for `COUNT(DISTINCT ...)`\n- Access JSON properties with Snowflake's semi-structured syntax: `json_data:\"property_name\"`\n- Always include a date range on `occurred_at` — the table has a lot of data\n- For delivery metrics, filter `machine = false` to exclude bot opens/clicks\n- `delivery_type` values: 0=email, 2=SMS, 4=Slack, 5=push, 6=in-app, 9=WhatsApp\n","anonymous_broadcasts.md":"---\nminimum_plan: premium\n---\n\n# Anonymous In-App Broadcasts\n\n\u003e **UI link:** `https://fly.customer.io/workspaces/$ENV_ID/journeys/anonymous/:in_app_broadcast_id` — link the user here when you create or reference an anonymous in-app broadcast.\n\nAnonymous in-app broadcasts require the `anonymous_in_app` gated feature, available only on **Premium and Enterprise plans**.\n\nAnonymous in-app broadcasts are a separate resource type delivered via the Gist SDK, distinct from one-time sends with `channel: \"in_app\"` and in-app actions in automations.\n\n\n## Broadcasts\n\nBroadcasts are one-time or scheduled in-app messages shown to a targeted audience. They have their own lifecycle separate from automations.\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET /v1/environments/:eid/in_app_broadcasts` | List broadcasts. Params: `states[]`, `types[]` (defaults to custom broadcasts), `search` (case-insensitive substring match on name), `page` (0-based, 20 per page), `sort` (`name`, `updated` or `state`; default `updated`) with `asc=true` for ascending (default descending, so most recently updated first). Without `page` every match is returned; with it, `meta.pagination` carries `page`, `size` and `total` |\n| `GET /v1/environments/:eid/in_app_broadcasts/:id` | Get a broadcast |\n| `POST /v1/environments/:eid/in_app_broadcasts` | Create a broadcast |\n| `PUT /v1/environments/:eid/in_app_broadcasts/:id` | Update a broadcast (metadata, state changes) |\n| `DELETE /v1/environments/:eid/in_app_broadcasts/:id` | Delete a broadcast. A live (scheduled, starting, running or stopping) one needs a `delete:in_app_broadcast:\u003cid\u003e` grant |\n| `POST /v1/environments/:eid/in_app_broadcasts/:id/copy` | Duplicate a broadcast (optionally to another environment) |\n| `POST /v1/environments/:eid/in_app_broadcasts/:id/tags` | Add tags to a broadcast |\n| `DELETE /v1/environments/:eid/in_app_broadcasts/:id/tags/:tag_id` | Remove a tag from a broadcast |\n| `GET /v1/environments/:eid/in_app_broadcasts/:id/tracked_responses` | Get tracked survey responses |\n\n### Audience Types\n\n| Value | Meaning |\n|-------|---------|\n| `anonymous` | Users not identified by the SDK |\n| `identified` | Users identified via the SDK (linked to a profile) |\n| `both` | All users regardless of identification status |\n\n### Update Types\n\nThe `update_type` field on PUT controls what kind of update is being made:\n\n| Value | Effect |\n|-------|--------|\n| `send` | Send the broadcast immediately |\n| `schedule` | Schedule the broadcast for `scheduled_at` time |\n| `deschedule` | Cancel a scheduled broadcast |\n| `stop` | Stop an active broadcast |\n\n`stop` and `deschedule` take a live (scheduled, starting, running or stopping) broadcast down for end users, so they need an `edit:in_app_broadcast:\u003cid\u003e:*` grant.\n\n### Scheduling\n\nSet `scheduled_at` (Unix timestamp) and `timezone` when scheduling. Broadcasts can also have an expiration via `expires_at` and `expire_timezone`.\n\n### Frequency \u0026 Conversion\n\nThe `frequency` object controls how often the message is shown (`count`, `delay` in seconds, `ignore_dismiss`).\n\nThe `conversion` object tracks goal completions. Its `type` is one of `identified` (the primary goal, a prerequisite for the others), `perform_event` (needs `event_name`), `enter`, or `exit` (a segment enter/exit, needs `segment_id`); `action` is `viewed` or `clicked`. A value outside these is rejected — `enter_segment`/`exit_segment` are not accepted.\n\n### Copy a Broadcast\n\n`POST /v1/environments/:eid/in_app_broadcasts/:id/copy` — Set `copy_to_env` to `0` to copy within the same environment, or to a destination environment ID.\n\n## Common Pitfalls\n\n1. **Provision first** — In-app messaging must be provisioned before creating broadcasts. Check `GET /in_app/status` first.\n2. **Audience is required** — Every broadcast needs an `audience` value (`anonymous`, `identified`, or `both`).\n3. **Templates are separate** — Creating a broadcast gives you an empty template. For a custom broadcast, update it via `PUT /templates/:template_id` (see `in_app.md` for content format). A broadcast whose message is built in Design Studio (its template's `editor` is `\"parcel\"`) takes no message or settings writes through the API: `PUT /templates/:template_id` answers 422 for any change to its `body_json`, so its page rules, expiry, targeting, persistence and attributes change only on the broadcast's setup page in the app. Tell the user that rather than retrying.\n4. **Name max length** — Broadcast names are limited to 100 characters, descriptions to 1000.\n5. **Platform/app targeting (multi-app workspaces)** — If `in_app.md` documents a `targeting` key for this workspace, set it in the **default template's** `body_json`; the default template alone defines where the whole broadcast displays, and translation templates never carry their own targeting. The `targeting` object's shape is in `in_app.md § Targeting`.\n","api_triggered_broadcasts.md":"---\nname: Fire and Schedule an API-Triggered Broadcast\ndescription: Find, fire, schedule, inspect, or cancel an API-triggered broadcast send. Use whenever the user names a broadcast trigger or trigger id, or a named broadcast is absent from newsletters; it is a triggered_broadcast campaign, and plain campaign lists hide it.\nminimum_plan: essentials\n---\n\n# Fire and Schedule an API-Triggered Broadcast\n\nAn **API-triggered broadcast** is an automation of `type: \"triggered_broadcast\"` that sends\nnothing on its own. Each send is a **trigger**: one POST naming an audience.\n\n**Creating or editing the broadcast itself is `automations.md`. This file is only about firing\nand scheduling one.** Go there for the automation's name, audience filter, workflow and\nactions; come back here for the trigger.\n\n\u003e **Three things it is not.** Not a **one-time send / broadcast** (`newsletters`, see\n\u003e `messaging.md`) — that is a different resource with a different schedule API. Not a\n\u003e **webhook automation** (`type: \"webhook\"`), which *receives* a POST rather than being fired\n\u003e by one. And a `filters` value on a `triggered_broadcast` is accepted and ignored — its saved\n\u003e audience lives in `recipients_filter` (`automation_filters.md`).\n\n## Endpoints\n\n| Method | Path | `cio schema` |\n|--------|------|--------------|\n| POST | `.../campaigns/:campaign_id/triggers` | `campaigns.create-triggers` |\n| GET | `.../campaigns/:campaign_id/triggers` | `campaigns.list-triggers` |\n| GET | `.../campaigns/:campaign_id/triggers/:trigger_id` | `campaigns.get-triggers` |\n| GET | `.../campaigns/:campaign_id/triggers/:trigger_id/download_errors` | `campaigns.list-triggers-download_errors` |\n\nThe verb is `create-triggers`, not `trigger` — the path segment is plural, so it does not\nresolve as an action verb. Read the body schema with\n`cio schema campaigns.create-triggers` before the first call.\n\n## Finding the broadcast — the default list hides it\n\n**`GET .../campaigns` with no type filter does not return API-triggered broadcasts.** The\nlist drops both `triggered_broadcast` and `transactional` campaigns unless a type filter is\ngiven, so a plain list against a workspace whose only automation is an APITB comes back\n**empty** — which reads as \"no such broadcast exists\". Always filter:\n\n```bash\ncio api /v1/environments/$ENV_ID/campaigns --params '{\"type\":\"triggered_broadcast\"}' \\\n  --jq '.campaigns[] | {id, name, state}'\n```\n\nConfirmed behaviour, not a quirk of one workspace: the same workspace returns `0` campaigns\nunfiltered and the broadcast with `type=triggered_broadcast`.\n\n\u003e **\"Broadcast\" is ambiguous — check both readings before saying nothing exists.** In the UI a\n\u003e *one-time send* is also called a broadcast (`newsletters`, `messaging.md`). If a user asks\n\u003e you to send or schedule \"the X broadcast\", an empty result from one resource is **not** an\n\u003e answer: look in the other before reporting that nothing matches. An empty `/newsletters`\n\u003e search plus an unfiltered `/campaigns` list is the specific pair that looks conclusive and\n\u003e is not.\n\n## The trigger id is composite — read this before any GET\n\nPOST returns a **bare integer**:\n\n```json\n{\"id\": 3}\n```\n\nEvery GET wants `\"\u003ccampaign_id\u003e-\u003ctrigger_id\u003e\"`. For campaign 144, that is `144-3`:\n\n```bash\ncio api '/v1/environments/{environment_id}/campaigns/{campaign_id}/triggers/{trigger_id}' \\\n  --params '{\"environment_id\":\"1\",\"campaign_id\":\"144\",\"trigger_id\":\"144-3\"}'\n```\n\nPassing the bare `3` is a 400 (`invalid 3`), not an empty result — the handler splits the\nvalue on `-` and rejects anything without two parts. Build the composite yourself from the\ncampaign id plus the integer POST returned. A trigger read back from a GET already carries\nthe composite form in its own `id` field, so pass that straight through.\n\n## Choosing the audience — exactly one field\n\n| Field | Audience | Resolved |\n|-------|----------|----------|\n| `recipients` | A segment-style filter | **when the send fires** |\n| `ids` | Person ids, at most 10000 | when the request is made |\n| `emails` | Email addresses, at most 10000 | when the request is made |\n| `per_user_data` | One object per recipient, each with that person's own merge data, at most 10000 | when the request is made |\n| `data_file_url` | A newline-delimited JSON file of `per_user_data` objects, for larger audiences | when the request is made |\n\n**Exactly one.** Two is\n`exactly one of \"recipients\", \"ids\", \"emails\", \"per_user_data\", or \"data_file_url\" must be provided`,\nand so is zero — unless the automation has a saved `recipients_filter`, which stands in for the\nfield when you omit all five.\n\n### Narrowing the audience — `filter`\n\n`filter` is a segment-style filter that narrows the trigger's audience to the people who match it,\nwhichever of the five fields above gives that audience. Use it when the user wants \"these people,\nbut only the ones who …\" — for example `{\"device_attribute\":{\"field\":\"id\",\"operator\":\"exists\"}}`\nskips people with no push device. It is checked when the send fires.\n\n`filter` needs `recipients` or a non-empty list with it. It cannot narrow the automation's saved\n`recipients_filter`: sending `filter` with no audience field, or with an empty list, is a 422, even\nwhen the automation has a saved filter. To narrow a saved audience, send the saved filter as `recipients` alongside `filter`.\n\nA filter does not make a large list cheaper: every identifier in the list is still looked up,\nhowever many people the filter then removes.\n\nThe **Resolved** column matters for a scheduled send: the four list-shaped fields freeze the\naudience when you book it, while a `recipients` filter is evaluated at send time and so picks\nup people who qualified during the wait. Say which one the user is getting when it affects the\nanswer. Either way an unsubscribe during the wait is honoured — that check runs when the\nmessages are generated, not when the audience is resolved.\n\nCompanion flags: `id_ignore_missing` and `email_ignore_missing` skip identifiers that match no\nperson instead of failing the trigger; `email_add_duplicates` sends to every person sharing an\naddress rather than one of them.\n\n`data_file_url` must be `http://` or `https://` with a host, and at most ~3056 characters — the\nURL is encrypted before storage and the 4096-character column limit applies to the encrypted\nform.\n\n## Merge data\n\n`data` is one JSON object shared by every recipient, up to **50KB**; a few workspaces are\nraised to 500KB. Per-person values go in `per_user_data` / `data_file_url` instead.\n\n\u003e `data` is interpolated when the send fires, but its **contents are whatever you posted**.\n\u003e For a scheduled send that is a value frozen at booking time, so do not put something in it\n\u003e that the user expects to be fresh hours later.\n\n## Scheduling\n\nSet `scheduled_at` to a **unix timestamp in seconds**. Omit it, or send `0`, to send now.\n\n```json\n{\"ids\": [\"42\", \"77\"], \"scheduled_at\": 1900000000}\n```\n\nThat epoch is illustrative. Compute the one the user asked for; never copy a\nliteral from this file into a real call.\n\n**There is no `timezone` field, and none is needed.** A one-time send's `schedule` action\nrequires one and 422s without it (`messaging.md`). This endpoint takes an absolute instant.\nDo not add `timezone`, and do not ask the user for one — ask for the wall-clock time and the\nzone, then compute the epoch yourself and state the time back to them for confirmation.\n\n**A past `scheduled_at` is refused, not sent immediately.** Anything more than five minutes\nold comes back as `scheduled_at is in the past` and nothing is created. A one-time send\napplies the same five-minute grace, so do not describe the two as behaving differently here.\nThe window exists for clock skew, not as a grace period to rely on: report the rejection and\nask for the intended date rather than retrying with a corrected one.\n\nOther rejections, all validation errors that create nothing:\n\n| Value | Error |\n|-------|-------|\n| Milliseconds (e.g. `1900000000000`) | `scheduled_at must be a unix timestamp in seconds, not milliseconds` |\n| Negative | `scheduled_at must be a unix timestamp in seconds` |\n| Not an integer | `is not an integer unix timestamp in seconds` |\n\n**A second trigger within 10 minutes of any earlier one is a 409.** The code is\n`recent_broadcast_trigger` and `meta.last_triggered_at` is the earlier trigger's unix time in\nseconds. It counts triggers from the API and the UI, scheduled or not, and nothing is created.\nTell the user the broadcast was triggered that recently, give the time, and ask whether to send\nanyway. Only after they say yes, resend the same body with `\"acknowledge_recent_trigger\": true`.\nNever set that field on a first attempt or without the user's answer, including when you book\nseveral dates for the same broadcast: ask once, then send the batch with the field set.\n\nThere is **no minimum lead time**. A send booked a minute out is accepted and works.\n\n## Expiry: one duration in, two fields back\n\n`expires_after` is a **duration in seconds**, not a timestamp. Past it, messages that have not\ngone out yet are discarded instead of delivered. Omit or `0` means no expiry; the maximum is\n2592000 (30 days).\n\nThe clock starts **when the send starts** — `scheduled_at` when the send is scheduled,\notherwise the moment the request arrives. So a late start shortens the window rather than\nextending it: the deadline stays the promise the user made. Messages already handed to a\ndelivery provider are not recalled.\n\n**The trigger comes back with both, and they are not the same number.**\n\n| Field | On the response | Means |\n|-------|-----------------|-------|\n| `expires_after` | the duration you sent, stored as-is | relative seconds, e.g. `7200` |\n| `expired_at` | resolved from it against the send's start | absolute unix seconds, or `0` for no expiry |\n\nQuote `expired_at` when telling a user *when* the send stops; `expires_after` is only the\nwindow they asked for, and reading it as a timestamp gives a date in 1970. `expired_at` is\nnot an accepted request key — send `expires_after` and let the API resolve it.\n\n## Reading a trigger's state back\n\n`GET .../triggers/:trigger_id` returns the trigger. **Read `send_state`** rather than inferring\nfrom the timestamps; `processed_at` alone cannot answer \"did it send?\"\n\n| `send_state` | Means |\n|--------------|-------|\n| `scheduled` | **Parked**, waiting for `scheduled_at` |\n| `sending` | In flight now, or overdue if `scheduled_at` is in the past |\n| `sent` | The send ran (`processed_at` is non-zero) |\n| `expired` | A rate-limited send hit its expiry first: it reached `recipients_count` of `audience_count` people, and the rest were not sent to |\n| `capped` | A rate-limited send stopped at the account's monthly message cap: it reached `recipients_count` of `audience_count` people, and the rest were not sent to |\n| `canceled` | Called off (`cancelled_at` is non-zero) |\n| `failed` | The per-user data file could not be processed and nothing was sent; `per_user_data_error_count` and `.../download_errors` explain why |\n\n`cancelled_at` / `send_state: canceled` is what identifies a cancelled trigger. `processed_at` is\nnot stamped on every cancel: a held trigger cancelled before its time keeps `processed_at: 0`, so\nnever read `processed_at: 0` as \"not cancelled\".\n\nWhen reporting a trigger as still scheduled or already sent, check `send_state` for `canceled`\nfirst. Do not leave that check implicit just because the field was present in the response.\n\nAlso on the trigger: `recipients_count` (exact for a list-shaped audience),\n`found_per_user_data`, `per_user_data_position`, `per_user_data_error_count`, and the\n`expires_after` / `expired_at` pair above.\n`audience_count` is how many people the trigger planned to reach (0 until known). `recipients_count` is final\nonly once the trigger is processed or cancelled. Before that it is 0, or the list size for a list trigger, and is not\nprogress. A cancelled paced trigger shows `recipients_count` below `audience_count`.\nA non-zero `per_user_data_error_count` means rows were skipped — read them with\n`.../triggers/:trigger_id/download_errors`, which returns a URL to the error file.\n\n**Re-read after your own write.** Report the outcome of a trigger you just created from a\nfresh GET, never from what you intended. A wrong-date schedule is refused rather than sent, so\nthe failure is visible — but only if you look.\n\n## Cancelling a scheduled trigger\n\n`POST /v1/environments/:environment_id/campaigns/:campaign_id/triggers/:trigger_id/cancel` cancels\na trigger that has not fired. Use the composite `\u003ccampaign_id\u003e-\u003ctrigger_id\u003e` id, as for the GET. It\nneeds the broadcast execute permission, and a user-approved capability for scope\n`cancel:campaign:\u003ccampaign_id\u003e` (request it before the call; the approved grant rides on `cio api` automatically).\n\n- **200**: `{\"trigger\": {...}, \"finishing_batch\": false}`. The trigger reads `send_state: canceled`.\n  `finishing_batch: true` means a running tick still sends its current batch before it stops, so\n  a few more messages can go out; say so.\n- **409**: the trigger is not waiting to send (it is resolving, already sending unpaced, or sent).\n  Nothing was cancelled. Report that, with the message returned; do not retry or claim it stopped.\n- Cancelling an already-cancelled trigger returns 200 again.\n\nDo not offer to delete or edit the automation instead, which does not stop a booked send. After the\ncall, re-read the trigger and report `send_state` from the fresh GET.\n\nCancelling only works while the trigger is still waiting to send, so confirm the time with the\nuser **before** booking a large send.\nWhen the user named the recipients and a definite time (a stated zone, or a millisecond value you converted\nto seconds), book it and state the time you used in your reply.\n","assets.md":"# Assets — Image and File Management\n\nAssets are images and files uploaded for use in email content. They support a hierarchical folder structure with search, folders, rename, move, delete, and restore.\n\n\n**Accepted file types:** BMP, JPEG, PNG, GIF, PDF\n**Max file size:** 2 MB\n\n## Images the User Attached in Chat\n\nYou can see a chat attachment, but you don't have its file: it is not in the sandbox, and `--file` can't reach it. A note after each attachment gives its **upload reference** (`gs://…/ephemeral/account_\u003ca\u003e/session_\u003cs\u003e/\u003cfile\u003e`). Import it server-side — never ask the user to upload it again:\n\n```\ncio api /v1/environments/{environment_id}/assets/from_upload -X POST \\\n  --params '{\"environment_id\":\"'$ENV_ID'\"}' \\\n  --json '{\"upload\":\"\u003cupload reference\u003e\",\"name\":\"fablebnb-logo\"}'\n```\n\n- Import only images the user wants in their content (a logo for the header, a product photo). Screenshots of something going wrong, examples, and reference images are context for you to look at, not assets: don't import them. If it's unclear, ask.\n- Pass the reference exactly as given. For an earlier attachment, the \"removed from history to save space\" placeholder carries the same reference.\n- The response's `asset.path` is the hosted URL — use it as the `src` (e.g. the header `x-image`). Importing the same image again returns the existing asset; the stored name gets a short content-hash suffix. `parent_folder_id` works as on upload.\n- Accepts PNG, JPEG, GIF, BMP, and WebP (stored as PNG), up to 2 MB and 4096px a side. Chat images can be up to 5 MB, so an import can fail with a 400 that says why: tell the user, ask for a smaller PNG or JPEG, and leave the image out — never save a placeholder `src`.\n- A 404 means the reference isn't one of this account's chat uploads, or it has expired: ask the user to attach the image again.\n\n## Upload Workflow\n\n**Preferred — one request (multipart upload).** POST the file as `multipart/form-data` to the upload endpoint; the server uploads it to storage AND registers the asset in a single call, returning the created `asset` with a hosted `path` URL (e.g. `https://userimg-assets.customeriomail.com/...`) you can drop straight into an `\u003cx-image src=\"…\"\u003e`.\n\n```\nPOST /v1/environments/:environment_id/assets/upload\nContent-Type: multipart/form-data\nForm fields: file=@local.png   (required)   [+ optional: name, parent_folder_id]\n```\n\nSend it with `cio api --file`, which switches the request to `multipart/form-data`:\n\n```\ncio api /v1/environments/{environment_id}/assets/upload -X POST \\\n  --params '{\"environment_id\":\"'$ENV_ID'\"}' --file @logo.png\n```\n\n`--json` supplies the form's *other* fields (`name`, `parent_folder_id`) rather than a JSON body, and takes scalars only — a nested object is rejected, because a form cannot carry one. `--file` cannot be combined with `--page-all`.\n\n**The filename needs a real extension.** The CLI deliberately declares no `Content-Type` on the part, so the server derives the type from the extension and gates on that *before* it inspects the bytes. An extensionless temp file therefore fails with `File type must be one of: …` even when the bytes are a valid PNG — name it `logo.png`, not `tmp4f2a`.\n\nThat extension fallback fires **only** when the part declares no type at all. A part explicitly labelled `application/octet-stream` is rejected — `File type must be one of: image/bmp, image/jpeg, image/jpg, image/png, image/gif, application/pdf` — even for a valid PNG with a correct filename, because the declared type is checked against that list first. `cio api --file` declares nothing, which is why it works; a hand-rolled uploader that labels the part generically (Go's `multipart.CreateFormFile` does) will fail here. Note this differs from `knowledge_source_library/upload`, which treats a generic type as carrying no claim and falls back to the extension.\n\n**MCP `cio_write_api` cannot call upload endpoints.** The MCP tools only read JSON request bodies out of the spec, so a `multipart/form-data` route shows up in `cio_schema` with no body schema and any call it builds returns `400 file is required`. Uploads go through the `cio` CLI's `--file`.\n\n**There is no sign→register path for *files* on this API — multipart `/assets/upload` is the only way to upload an image file here** (a chat attachment goes through `from_upload` above). `POST /v1/environments/:environment_id/assets/sign` does exist (and is in production, ungated) — it returns a presigned GCS POST (`gcs.url` + `gcs.fields`) — but the fly/ui_api `POST /assets` create is **folders-only**: it rejects file assets with `file assets must be created via the upload endpoint`. So after a presigned PUT there's no fly-API call to register the file. (The register-by-path completion — `POST /assets` with `{ name, path, size }` — exists only on the **public API** at `api.customer.io` (`ext_api`), not here.)\n\n**`POST /v1/environments/:environment_id/assets`** on this API is therefore for **creating folders**: `{ \"asset\": { \"name\": \"Campaign Images\", \"is_dir\": true, \"parent_id\": \u003cid|null\u003e } }`.\n\n## Other Asset Operations\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `.../assets` | Search assets. Params: `search` (base64-encoded), `sort`, `order`, `page`, `limit`, `folder_id`, `ignore_dirs` |\n| GET | `.../assets/list` | List all assets with pagination. Params: `size`, `page` |\n| PUT | `.../assets/:id` | Update a single asset's name, folder, or `alt_text` (`\"\"` means explicitly decorative) |\n| PUT | `.../assets` | Bulk move multiple assets to a new folder |\n| DELETE | `.../assets` | Delete assets. Body: `{ \"asset_ids\": [1, 2, 3] }` |\n| PUT | `.../assets/:id/restore` | Restore a previously deleted asset |\n\n**Pagination:** Assets use 1-indexed pagination (page=1 is first page).\n\n## Limitations\n\n- **Assets cannot be registered from external URLs.** You cannot point an asset at an arbitrary CDN URL — upload the bytes via multipart `/assets/upload`. `from_upload` takes only chat upload references.\n","automation_debugging.md":"---\nminimum_plan: essentials\n---\n\n# Automation Debugging\n\nStart here for a broken automation or message, including webhook-triggered emails with blank recipients or Liquid errors. For performance diagnosis, pair this with `recipes/analyzing_automations.md`.\n\n\u003e **Message reached the action but didn't deliver (SMS/WhatsApp/push)?** Once you've confirmed the person *entered* and *reached* the action, a send-time failure (opt-out, bad number, credentials, carrier filtering, registration, WhatsApp template/window) is a delivery problem — see `messaging_troubleshooting.md`.\n\n## Repair and Retest a Message\n\n1. **Before any edit**, list the matching deliveries and record their IDs, status and errors; read the relevant details in full. Then read the complete saved template, including recipient, subject and body, and the trigger's Liquid documentation; for webhooks, use `automation_triggers.md § Webhook-triggered`.\n2. Fix every occurrence of the bad Liquid reference in one template update. Use `templates.md`; preserve unrelated content and settings.\n3. Preview the saved template with representative data. Discover `POST /v1/environments/{environment_id}/previews/email_template`; provide `template_id` and `variables`. For webhooks, put the POST body under both `variables.trigger` and `variables.event`; omit `variables.customer`. Inspect every error and the resolved `to`, `subject` and `body`. A clean preview proves only that this sample renders from the saved template. Say **\"the edit is saved and the sample preview passed; sending is not verified\"**. Do not call the repair fixed or say the next send will or should work.\n4. When the user reports a retest, list matching deliveries and compare their IDs with the pre-edit list. A retry keeps its ID; creation time does not date its render. A matching new ID that appears only after the user reports a post-repair test is a post-edit attempt; never call it pre-edit by guessing the edit time from the conversation or wall clock. Without a new matching ID or identifying input, say **\"I found no new matching delivery, so I cannot verify that retest\"** and ask for the test's time, response and input. Do not present an old delivery as the user's test, and do not invoke the template cache when there is no new delivery to evaluate.\n5. Production delivery rendering caches a template for up to two minutes. A new delivery created after an edit can therefore record an error from the pre-edit template. Only identify this as cache-consistent when its recorded error names content that the saved template no longer contains. Do not say the delivery was inside or outside the cache window unless you read both the edit and delivery timestamps. Report it as a post-edit attempt that used cached content—not as a delivery rendered before the edit. Even after two minutes, only another new delivery can establish its recorded result. Never say the next attempt \"should work\", \"will succeed\", \"should render cleanly\", \"should go out clean\", or that the corrected template \"should now be in effect\". End a cache-consistent failure report with: **\"Send another webhook and give me its identifying value; I will inspect that new delivery. Its outcome is not yet known.\"**\n\nReport the observed result and the next check, without predicting its outcome:\n\n| Observation | Report |\n| --- | --- |\n| Saved template or successful write | Configuration saved |\n| Saved-template preview without errors | Sample rendering passed; sending is not observed. Check delivery details after a test. |\n| Delivery `attempted` | An attempt; sending unconfirmed |\n| Delivery `sent` | Report `sent`; reserve `delivered` for a delivered record |\n| Details `content_source: current_template` | Content rendered during this read, independent of the recorded attempt; it cannot show which cached version the attempt used |\n| Details `content_source: stored` | Stored delivery content |\n\nThe deliveries list and details can project recipient, subject, or content from the current saved template. Those current values do not prove what the recorded attempt rendered. When `content_source` is `current_template`, omit projected recipient, subject, and body from the attempt evidence; do not say they resolved, that the fix landed for that attempt, or that they are the delivery's rendered content. Identify the retest by stating its new ID and how it differs from the recorded pre-edit IDs. When a new delivery's recorded error names removed Liquid, report that error as the attempt evidence. No new matching delivery proves only that none was found; it does not prove whether a webhook request was sent or reached the automation.\n\nDeliveries use `recipient` and `created`; previews use `to`. Fetch an unprojected deliveries response before choosing jq paths. If a projection returns null for status, errors, or content source, fetch the full object and find the real fields before reporting; null from a guessed path is not evidence that no error exists.\n\n## Why People Skip Actions\n\nWhen action counts don't match expectations (e.g., 20 people reached action A but only 5 reached action B on the same branch), **check these causes in order before assuming an action failed:**\n\n1. **Unsubscribed** — the person is unsubscribed or opted out of the subscription topic. Email/SMS/push actions are skipped silently for unsubscribed users unless `send_to_unsubscribed` is true. In-app automation actions and one-time sends ignore the global `unsubscribed` attribute, but an assigned topic or an explicit `in_app` channel preference still restricts them, and transactional in-app still honors the global unsubscribe unless `send_to_unsubscribed` is enabled. When asked whether `unsubscribed` explains a missing in-app message, state all three, ask which kind of send it was, and name the causes that can explain it: SDK never connected, the person not entering the journey or segment, platform or app targeting, or page rules (`in_app.md`, `in_app_troubleshooting.md`).\n2. **Preconditions not met** — the action has a precondition filter that the person didn't match.\n3. **Holdout** — the action has a holdout percentage, and the person was in the holdout group.\n4. **Message limits** — workspace or automation message limits prevented sending.\n5. **Branch condition** — the person took a different branch (check branch action conditions).\n6. **Action actually failed** — the action errored (check delivery status for the specific person).\n\n**How to investigate:**\n- `GET .../campaigns/:id/action_status` — counts per action (spot where drop-offs happen)\n- `GET .../actions/:id/subjects` — list who is at a specific action\n- `GET .../subjects?customer_id=Y` — a specific person's journeys, matched to the campaign by `campaign_id` in the response (querying with `campaign_id=X` does not filter by profile — see § Why People Aren't Entering)\n- Link users to the automation's journeys tab: `https://fly.customer.io/workspaces/$ENV_ID/journeys/automations/:id/overview/journeys`\n\nWhen listing people, use email if available, otherwise their `id` (see SKILL.md § Referencing People).\n\n### Journey and Delivery Timing\n\nConvert Unix timestamps in a separate `node -e` call before stating calendar dates: `new Date(seconds * 1000).toISOString()`. Do not put the conversion after an API request that can fail and skip it. Check that the returned output contains each converted date, then copy it with the raw timestamp; never estimate a missing result. Use the observed entry and wake-up times, and compare delivery timestamps with the actual saved edit time before calling a delivery older or newer than a repair. Missing timestamps leave that ordering unconfirmed.\n\nA wake-up time schedules the next workflow step; it does not establish that a message will send. Read the workflow's next action before describing what follows a wait. Do not infer a reward or message action from the automation's name. If the next action is an exit, say that the journey ends after the wait.\n\n## Listing or Exporting the People at an Action\n\n\"Who is waiting at this delay\", \"export everyone currently at step X\", \"give me the people parked at this action for a control group\" — you can build this list yourself. Don't send the user to support for it.\n\n1. Find the action id in the automation's top-level `actions[]` (`GET /campaigns/:id`). `campaigns/:id/action_status` gives the count per action (`actions[].subject_count`), so you know how many rows to expect.\n2. Walk `GET .../actions/:action_id/subjects`. It returns at most 50 per page and ignores `limit`. Pass the previous response's `meta.continuation` as `continuation`, and stop when it comes back empty. Don't use `--page-all`.\n3. Each page has `subjects[]` (journey state, `customer_id`, `action_entry_time`, `next_wake_up`) and `customers[]` (the matching profiles). Join them on `subjects[].customer_id` = `customers[].id` (both are the cio_id). A profile's email and `id` are in `customers[].identifiers`.\n4. Write the rows to a CSV under /home/user/ and give it to the user as a download. If they want to reuse the list in Customer.io, offer a manual segment built from those ids.\n\nThis list is a point-in-time snapshot: people keep moving through the workflow after you read it. Say so, and compare your row count with `action_status` before you call the list complete.\n\n## Why People Aren't Entering an Automation\n\n### First confirm the person actually didn't enter\n\nFor a question about a **specific** person (\"why didn't she enter?\", \"this customer was skipped\"), verify the premise before diagnosing it — the authoritative record is the journey, not the activity log:\n\n- `GET .../subjects?customer_id=Y` — that person's journeys. A subject whose `campaign_id` is this automation means the person **did** enter; `subject_state` is where they are now and `match_time` is when they entered. Scope by the person, not the campaign: adding `campaign_id=X` to this call switches it to the campaign's subject list and drops the `customer_id` filter, so it returns other entrants' rows — a returned subject only counts as theirs when its `customer_id` matches. Once a subject for this campaign is confirmed, the real question is what happened *inside* the journey — a delay they're parked at, a branch they took, or a send that was skipped or failed — so move to § Why People Skip Actions and `messaging_troubleshooting.md`, not the non-entry causes below. Don't keep theorizing about non-entry, and don't send the user to support, once the journey shows they entered.\n- Read the whole result, and run this call on its own. An empty activity-log slice (e.g. `.../logs?type=campaign` → `[]`) is not proof of non-entry — the log can be filtered or aged out while the subject still exists. Neither is a truncated tool response: don't bundle the subjects check into a multi-command shell block where truncation can drop its output, and never conclude \"did not enter\" from an empty or cut-off read (SKILL.md § Null identity fields mean a wrong jq path).\n\nOnly when there is genuinely **no** subject for the person do the numbered causes below apply. That establishes non-entry, not its cause. For an event-triggered automation, read `customers.md § Profile activity` and inspect that profile's event history within the reported time window. If the history is unavailable, incomplete, or outside retention, report the cause as unconfirmed. Another profile's journey or a workspace event-name inventory cannot establish whether this profile sent the event.\n\n### Zero or fewer entries than expected\n\nWhen an automation has zero or fewer entries than expected, check these causes in order:\n\n1. **Read the live entry configuration first** — fetch the automation with its trigger, audience filters, and `updated`; use `automation_filters.md` to interpret its segment references. Fetch every referenced include/exclude segment and inspect its full condition tree and `updated_at` before querying raw events or individual profiles. If you use `jq`, include `updated` for the campaign and `updated_at` for each segment rather than dropping them. Compare the timestamps with the reported drop, but remember that they show when each current object was last updated; they do not reveal the previous condition or prove what changed.\n\n   For a reported drop after a change, report three things separately: **mechanism** (what the current conditions explain), **update times** (`campaign.updated` and each referenced `segment.updated_at`, exactly as returned, including `null`), and **timing conclusion** (confirmed or unknown). `null` means unavailable; do not substitute another timestamp. These timestamps do not reveal prior conditions or prove which change caused the drop. A condition that excludes new profiles explains that mechanism only; it does not prove the affected cohort changed today.\n\n2. **Automation state** — is the automation actually running? Check that `state` is `started` and `first_started` is not null. An automation in `draft` or `stopped` state won't accept new entries.\n\n3. **Trigger type** — is a trigger configured? If `type` is `\"none\"`, no one can enter. The trigger must be set via `update_type: \"recipients\"` (see automations.md Step 3).\n\n4. **Segment trigger** — does the segment have members?\n   - `GET .../segments/:id/count` — check `count` and `finished`\n   - `GET .../segments/:id` — a 404 means the segment was deleted, so the trigger can never match. `archived: true` on a 200 means the same thing: the segment is gone from the live config, so the trigger can never match. Check this regardless of the count: `/count` reads the membership bitmap, which outlives the segment config, so an archived segment keeps reporting its pre-archive count (and `finished: true`) until the background teardown runs\n   - If the segment exists and is not archived, `count` is 0, and `finished` is true, that establishes an empty membership result, not why it is empty. Interpret the saved condition tree against the reported cohort before naming a cause; the count alone does not explain the drop.\n   - For a negated event condition (`has not done X`) with `within \u003e 0`, membership also requires `_earliest_event` to be at least `within` seconds old. Customer.io initializes this value to the profile's creation time and lowers it when an earlier-timestamped event arrives, so age is effectively measured from the earlier of profile creation and the oldest event timestamp. A new profile fails unless an older backdated event lowers that value. Do not infer `_earliest_event` from retained activity: an older profile can still have an old value even when its older events are no longer visible. The public `GET .../customers/:id` response omits this private attribute, so don't claim to have read it there.\n   - If `finished` is false, membership is not final yet — a build is running, a rebuild is pending, or, on a manual segment, ingress is still processing a CSV import into it (`GET .../imports?type=segment\u0026segment_id=:id`). An import still unfinished 24h after it was queued stops counting\n\n5. **Event trigger** — does the event actually fire?\n   - `GET .../event_names` inventories event names across the workspace; its presence, absence, or `last_seen` does not establish a specific profile's event history.\n   - Use the profile activity workflow above to check the triggering event. Scope any absence to the profile and retained time window actually inspected.\n\n6. **Date trigger** — are dates set on profiles?\n   - `GET .../campaigns/:id/next` — shows next scheduled evaluation\n   - Check that the `date_triggered_attribute` exists on profiles\n\n7. **Filters too restrictive** — are entry filters filtering everyone out?\n   - Check `attribute_filters` (event attribute filter) and `filters` (audience filter) on the automation\n   - Try temporarily removing filters to see if entries start\n\n8. **Entry restrictions** — first verify the reported non-entry against that person’s journey subject, as described above; a missing subject confirms non-entry but does not establish its cause. Then check whether `restart_mode` prevents re-entry and inspect the trigger and filters. Message limits and frequency caps govern sends after entry; they do not block enrollment. Frequency caps count each person’s own qualifying message history, not a shared pool across people or journey entries. See `frequency_caps.md`; do not name a likely entry cause without checking the person and automation.\n\n9. **Workspace health** — is the workspace processing normally?\n   - `GET .../health` — check for processing delays or paused state\n\n10. **Specific person check** — verify a known person who should qualify:\n   - `GET .../customers/:id` — confirm they exist and have the expected attributes/events\n   - Check their profile activity for automation entry attempts or filter mismatches\n\n**How to investigate systematically:** Read the automation (`GET .../campaigns/:id`), then check each item above starting from the top. Most zero-entry issues are caused by items 1-4 (automation not started, no trigger, empty segment, or missing event).\n","automation_drafts.md":"---\nminimum_plan: essentials\nfeature_flag: campaign-draft-mode\n---\n\n# Automation Drafts (Draft Mode)\n\n**Every change to an automation's design goes through its draft, then a publish.** The live design does not change until you publish. A design change is anything in the workflow: actions and their settings, edges, the trigger and audience, exit conditions, goals and global settings.\n\nAction templates go through the draft too (§ Templates): message content, webhook requests, data action bodies and LLM prompts. Never `PUT /templates/:id` with a live template's id here: that saves live right away.\n\nThe live routes refuse design changes here with `cannot update live campaign design while campaign draft mode is enabled; save design changes to the campaign draft instead`. If you see that, switch to the steps below. Don't retry the live route.\n\n## What still saves live\n\n- **Name, description and tags:** `PUT /campaigns/:id` with `update_type: \"main\"`, as usual. The draft refuses `main`.\n- **Lifecycle:** start, stop, sunset, archive and the rest (`automation_lifecycle.md`). A never-started automation can't start while its draft has unpublished changes (`cannot start a campaign while its draft has unpublished changes; publish the draft first`): publish, then start.\n- **Creating an automation:** `POST /campaigns` creates it as today. The live `PUT /campaigns/:id` refuses its design after that too, so build it in a draft: create it, open the draft (`POST /campaigns/:id/draft`), set the trigger and audience (`recipients`) and the steps (`add_actions`, `edges`) there, publish, then start. A draft can't set the type back to `none`.\n- **Knowledge library entries:** an edit to a `.../knowledge_source_library` entry (file, name, description or table) goes live on every action it is attached to. Even when the user asked for the edit, first tell them it goes live now, outside the draft, and make it only after they confirm. Which sources an LLM action uses is part of the draft (§ Knowledge sources).\n\n## The flow\n\nThe draft routes are not in the API spec yet, so `cio api` refuses them unless you pass `--no-preflight`. Pass it on every call to a `/draft` route.\n\n1. **Open or resume the draft:** `POST /campaigns/:id/draft`. It returns the existing draft if one is open. Keep `draft_id` and `draft_revision` from the response.\n2. **Edit:** `PUT /campaigns/:id/draft` with the **same body** you would send to `PUT /campaigns/:id` for that change (same `update_type` and fields, `edges` inside `campaign`, top-level `action`/`actions`/`action_ids`), plus `draft_id` and `expected_draft_revision` at the top level:\n   ```json\n   { \"draft_id\": \"123\", \"expected_draft_revision\": 4, \"campaign\": { \"update_type\": \"...\", \"edges\": [...] }, \"actions\": [...] }\n   ```\n   Every other skill file's `PUT /campaigns/:id` example carries over this way. Each successful write returns the new `draft_revision`; send that one next.\n3. **Check before publishing:** `GET /campaigns/:id/draft` and read its `consequences` (below). An autosave (`PUT /campaigns/:id/draft`) returns them without `content`, so don't publish from that response. If the user already asked you to publish, go on, unless `may_exit_early`, `deleted_actions_exit_early` or `backfill_queued` is true, or a message in `content` or `sending_state_changes` has `live_changed`, or `live_added_actions` is not empty. Then stop, say who will leave or start, or which live edit or step publishing replaces or deletes, and publish only after they confirm. Either way, tell the user what publishing does (§ Consequences).\n4. **Publish:** `POST /campaigns/:id/draft/publish` with `{\"draft_id\": \"123\", \"expected_draft_revision\": 7}`. This is the step that changes the live automation, so it needs the same clear intent as any live change. The response may carry a `job_id` for the work on journeys already in the automation.\n\n**Reading the draft:** `GET /campaigns/:id/draft` gives the design. Templates are in `GET /campaigns/:id/draft/templates`: the draft's copy of each template it changed or added, matched by `action_id`. A `template_id` in `draft_actions` is the live template. To review a draft, follow `automation_review.md`.\n\nTo throw the draft away: `DELETE /campaigns/:id/draft` with the same two fields.\n\nDraft writes and publish need the same capability grant as a live edit of that automation.\n\n## Update types in a draft\n\nThese `update_type`s work on `PUT /campaigns/:id/draft`: `recipients`, `conversion`, `exit_and_conversion`, `exit_conditions`, `global_settings`, `add_actions`, `save_action`, `delete_actions`, `edges`, the sticky note ones, plus the draft-only ones below. `main` and lifecycle changes are refused; use the live route for them. `PUT /actions/:id` is a live route too: use `save_action` in the draft instead.\n\n- **New actions** get their permanent IDs when the draft accepts them. Read them from `draft_actions` in the response.\n- **`delete_actions`** keeps the action in the draft as deleted (`draft_state: deleted_in_draft`). A published action keeps its outgoing edge as the drain: people waiting in it move there at publish. With no outgoing edge, they exit early. An action the draft added (`added_in_draft: true`) holds nobody, so it has no drain, and publish drops it. A deleted exit, or a language branch or language the draft added, is removed outright and can't be restored.\n- **`restore_actions`** (`action_ids`) brings a deleted action back with the edits it had, with no incoming edge. Connect it with `edges`.\n- **`retarget_drains`** sets where a deleted published action's people go: `\"drain_retargets\": [{\"action_id\": \"12\", \"drain_target_action_id\": \"15\"}]`. An empty `drain_target_action_id` drains them out of the automation. It refuses an action the draft added, and a target that leads to the deleted action in the live automation, or a step the draft added whose edges lead to one, because its people already passed that step.\n\n### Sticky notes\n\nWrites to the live `.../campaigns/:id/notes` routes are refused too. A draft keeps its own copy of the notes in `draft_notes` on every draft response, so read them there, not from `GET .../notes`. Send the `note` object from `automations.md` (Canvas sticky notes), `relative_to` included. `note`, `note_id` and `note_ids` go at the top level, next to `draft_id`, not inside `campaign`:\n\n- **Create:** `update_type: \"create_note\"` with `\"note\": {...}`.\n- **Edit:** `update_type: \"update_note\"` with `\"note_id\": \"\u003cid\u003e\"` and the **whole** note: an omitted `content`, `position` or `shape` is cleared. Only an omitted `color` keeps the old one. Start from the note in `draft_notes`.\n- **Delete:** `update_type: \"delete_note\"` with `\"note_id\"`, or `\"delete_notes\"` with `\"note_ids\": [1, 2]`.\n\n### A/B tests\n\nThe live `POST /actions/:id/test` and `/actions/:id/winner` routes are refused. Use the draft:\n\n- **Convert a message into an A/B test:** `update_type: \"convert_to_split\"` with `\"action\": {\"id\": \"\u003caction id\u003e\"}`. In the response's `draft_actions`, the split lists its two new variants in `action_ids`.\n- **Pick the winner, or delete the test:** `update_type: \"choose_split_winner\"` with `\"action\": {\"id\": \"\u003csplit id\u003e\"}` and `\"winner_id\": \"\u003cvariant id\u003e\"`.\n- **Split percentages:** `save_action` on the split, as in `ab_tests.md`.\n\nOnly one of these per action per draft: a split whose winner you picked can't be converted again until you publish. On a Design Studio message, each variant gets its own Design Studio draft node (see Templates): getting the draft's copy of one variant copies every variant. Picking a winner moves the winner's draft node onto the split, so edit that node to change the message afterwards.\n\n### Test nodes (Experiments)\n\nA Test node (`experiment_action`) goes through the draft like any other step. The Experiment it points to does not: create and configure it with the `/experiments` routes in `experiments.md`, as usual.\n\n1. **Configure the experiment first,** variants included. Its variants and their order must not change after the draft saves the Test node, or publish refuses with `draft_experiment_drift`.\n2. **Add the Test node and its paths:** `add_actions` in the draft, with the same `actions` and `edges` as the attach example in `experiments.md`. Read the new ids from `draft_actions`.\n3. **Change a Test node's experiment or fallback:** `save_action` with `\"action\": {\"id\": \"\u003cnode id\u003e\", \"type\": \"experiment_action\", \"experiment_id\": \"\u003cid\u003e\", \"experiment_variant_ids\": [...], \"experiment_fallback_variant_id\": \"\u003cvariant id\u003e\"}`. `\"experiment_id\": \"0\"` takes the experiment off the node.\n4. **Change the steps on a path:** `add_actions`, `save_action`, `delete_actions` and `edges` in the draft, as for any step.\n\nThe draft only records the choice. Until publish, the experiment's `attachments` don't list this automation, and that is expected. Publish attaches the experiments the draft added and detaches the ones it took off. While a test runs, its Test node and the paths out of it can't change (`experiment_path_locked`), but the steps on those paths can.\n\nTwo experiment writes change the live Automation and have no draft step: applying a winner (`POST /experiments/:id/apply_winner`), and changing the variants of an experiment the live Automation already holds (`PUT /experiments/:id` with a different variant list). Both are refused here with the live-design message, and the app sends the same request, so it is refused there too. Don't call them. Tell the user it can't be done while Draft Mode is on, by you or in the app, and offer no other route: not the app, and not removing the Test node or a losing path by hand in the draft. Other edits to that experiment, such as its name, still save.\n\n## Templates\n\nEvery action's template saves to the draft's own copy and goes live at publish: a message's content (subject, body, sender, preheader), a webhook's request, a data action's body, an LLM action's prompt and result schema. The template ids on the live automation, and on a published action in `draft_actions`, are the live rows.\n\n1. **Get the draft's copy:** `POST /campaigns/:id/draft/actions/:action_id/templates` with the action's id. The first call copies the live templates into the draft; later calls return the same copies. For an action the draft added, it returns that action's own rows. The response's `templates` are the draft's rows, each with a `draft_id` and the `source_template_id` it copies. A message with language variations returns one row per `language`; an LLM action returns its prompt and schema rows; a variant of a Design Studio A/B test returns one row per variant, so edit the row whose `action_id` is the variant's.\n2. **Edit:** `PUT /templates/:template_id` with an `id` from that response, with the same body as any template edit.\n3. **Publish** the draft to make it live.\n\nAfter a publish or discard, the draft's row ids are no longer draft rows: a copy's id returns 404, and an added action's id becomes its live template, so a `PUT` on it saves live. Never reuse a draft row's id after a publish or discard: open a new draft and copy again. A 409 `this template was published, discarded or deleted since it was loaded` means a save raced a publish or discard: do the same.\n\nDesign Studio messages (`editor: parcel`) are edited in Design Studio, not with `PUT /templates`. Getting the draft's copy of one also gives it a Design Studio draft node, which the copied row's content names: edit that node, not the live one, as `design-studio/drafts.md` says, including the save that writes it onto the draft's copy. Publishing the draft makes its content live. If the copy is refused with `draft_edit_unsupported` (for example, the message is shared with another Automation), tell the user what the message says.\n\n## Knowledge sources\n\nAdding, attaching, editing or removing a source on an LLM action (`.../llm_actions/:action_id/knowledge_sources`) saves to the draft and goes live at publish.\n\n1. **Get the draft's copy:** `POST /campaigns/:id/draft/actions/:action_id/knowledge_sources` with the LLM action's id. The first call copies the live action's sources into the draft; later calls return the same copies. Each copy has a new `id`. For an action the draft added, it returns that action's own sources.\n2. **Edit:** send every call on the action's sources with `?draft_id=\u003cdraft_id\u003e`, using the ids from that response. A write with `draft_id` before the copy exists returns 409 `knowledge_source_draft_copy_missing`: get the draft's copy first. After the copy, a live source `id` with `draft_id` returns 404: use the copy's `id`.\n3. **Tell the user** the change goes live when the draft is published. Publish only when they ask for it, as for any draft change.\n\nWithout `draft_id`, a change to a published action's sources returns 409 `knowledge_source_draft_open` while its automation has a draft open. Test prompt (`verify/llm/start`) takes `draft_id` in the body too, so it runs with the draft's sources.\n\n## Errors\n\n| Status / code | Meaning | Do this |\n|---|---|---|\n| 409 `stale_draft_revision` | Someone else saved the draft since you read it | `GET /campaigns/:id/draft`, re-read what changed, then redo your edit on the new `draft_revision`. Don't resend the old body blindly. |\n| 409 `runtime_deleting_action_interaction` | The step is still being removed from the live automation, so the draft can't change it, point to it or delete it | Tell the user to wait until the removal finishes. |\n| 409 `campaign_state_not_publishable` | The automation's state can't take a publish | Tell the user its state. |\n| 409 `draft_template_source_gone` | A message the draft copied was deleted | Tell the user. The fix is to discard the draft and start again. |\n| 409 `draft_action_deleted_live` | A step the draft copied was deleted from the live automation, and publishing would add it back | Tell the user which step. The fix is to delete that step from the draft too, which keeps the other draft edits, or to discard the draft and start again. |\n| 422 `draft_edit_unsupported` | This change can't be made in a draft yet | Tell the user what the message says. Don't fall back to the live route. |\n| 422 `experiment_path_locked` | The edit changes the shape of a running test (its Test node or the paths out of it) | Tell the user. Edits to the steps on a test's branches are allowed. |\n| 422 `draft_experiment_drift` | A test the draft configured was stopped, deleted or changed since the draft saved it | Tell the user what `experiment_drift` lists, then reconfigure that Test node in the draft. Reason `live_changed` means the live Automation's experiment on that node moved after the draft opened and the draft changed it too: re-pick the experiment in the draft, or discard the draft. |\n| 422 `drain_to_earlier_step` | A drain sends a deleted step's people back to a step they already passed | Tell the user. Pick a later step for them, or exit them early. |\n| 422 `invalid_draft_graph` | The draft's workflow isn't valid to publish (often an action nothing leads to) | Fix the edges in the draft, then publish again. |\n| 422 `v3_draft_unsupported`, `draft_content_unavailable` | This automation or message can't be edited in a draft | Tell the user. Don't fall back to the live route. |\n| 404 on a draft route | No draft is open, or an A/B test write (`convert_to_split`, `choose_split_winner`) names an action the draft doesn't hold (a made-up id, or another Automation's) | Read the draft with `GET /campaigns/:id/draft`. If none is open, open one with `POST /campaigns/:id/draft`; otherwise use an id from its `draft_actions`. |\n| 403 on a draft route | Draft Mode is off for this workspace, or you lack edit access | Tell the user. Don't retry, and don't fall back to the live route. |\n\nThe draft refuses language variations of a Design Studio message in the workflow (`draft_edit_unsupported`), so tell the user rather than look for another way: an email's languages are added or removed on its draft node in Design Studio.\n\n## Consequences\n\n`GET /campaigns/:id/draft` (and every draft write) returns `consequences`:\n\n- `publishable` and `blockers` (`code`, `message`): if `publishable` is false, fix the blockers first. A `draft_experiment_drift` blocker means a Test node's experiment changed since the draft saved it; the top-level `experiment_drift` list names those nodes (`action_id`, `experiment_id`, `reason`), so reconfigure each one. A `draft_action_deleted_live` blocker names a step deleted from the live automation after the draft copied it: the draft can't publish. Tell the user which step, and that the fix is to delete that step from the draft too, which keeps the other draft edits, or to discard the draft and start again.\n- `empty`: true when publishing would change nothing.\n- `exit_scan_required`: publishing re-checks people already in the automation against the changed exit rules or trigger.\n- `may_exit_early`: that re-check can make some of them leave. When it is false, the re-check takes nobody out.\n- `deleted_actions_exit_early`: a deleted step has no drain, so its people exit early.\n- `trigger_changed`, `type_changed`, `backfill_queued`: the trigger or automation type changes; `backfill_queued` means people who already match will start.\n- `split_changes`: A/B tests that publishing starts (`convert`) or ends (`pick_winner`, with `winner_id`).\n- `sending_state_changes`: messages whose sending state publishing changes, `from` and `to` each `off`, `draft` (queue for review) or `automatic`, so they start, queue or stop sending. `live_changed: true` means the sending state changed live after the draft opened, and publishing reverts it to the draft's value.\n- `send_to_unsubscribed_changes`: messages that publishing starts (`from: false`, `to: true`) or stops sending to unsubscribed people, through their own setting, the automation's `send_to_unsubscribed` they inherit, or their A/B test's setting. A message the draft adds is listed as starting when it will send to unsubscribed people, unless its type always does, like a webhook.\n- `content`: messages to check, left empty by an autosave. A `live_changed` warning means the live message changed after the draft copied it, and publishing replaces that change. A `whatsapp_not_approved` warning means Meta hasn't approved a WhatsApp message, so its sends fail until Meta approves it. It doesn't stop a publish: tell the user.\n- `live_added_actions`: live steps the draft doesn't have, such as a language added in Design Studio while the draft was open. Publishing deletes them, and their people move on along each step's edge.\n\nSay these to the user in plain words. Also say where the people waiting in each deleted published step (`draft_state: deleted_in_draft` in `draft_actions`, without `added_in_draft`) go: to its drain, or out of the automation. A deleted step with `added_in_draft: true` holds nobody and never goes live.\n","automation_filters.md":"---\nminimum_plan: essentials\n---\n\n# Automation Filters \u0026 Conditions\n\nAutomation filter fields control who enters, which events qualify, and whether individual actions fire. These fields are used both during automation creation (Step 3 of the automation setup workflow) and when updating an existing automation's targeting. **These fields use different encodings** — see the `FilterCondition`, `AttributeCompareCondition`, and `AttributeFilters` schemas in the spec for the full filter DSL.\n\n| Field | Encoding | Where | Purpose |\n|-------|----------|-------|---------|\n| `attribute_filters` | Raw JSON object | Automation (`update_type: \"recipients\"`) | **Event** attribute filter — matched against the triggering event's payload, never the profile. Only for event-triggered automations. Send `{}` to clear. See `§ Profile attributes vs event attributes`. |\n| `trigger` | Raw JSON object | Automation (`update_type: \"recipients\"`) | Segment or attribute trigger conditions (who enters). Only for `seg_attr` automations. See `automation_triggers.md § Segment or attribute-triggered`. |\n| `filters` | Base64-encoded string | Automation (`update_type: \"recipients\"`) | **Profile** filter — who qualifies, written as **segment membership only** (`segments_negatable`). A profile attribute or message-history condition goes into a segment that this field references, including on event-triggered automations. See `§ Profile attributes vs event attributes` and `§ Segment membership is the only condition in filters`. |\n| `recipients_filter` | Stringified JSON | Automation (`update_type: \"recipients\"`) | Triggered broadcast recipients. Value is a JSON **string**, not object. An `or` array may only contain bare `segment` conditions — see the gotcha below. |\n| `preconditions` | Base64-encoded string | Action | Action-level gate. If not matched, action is skipped. |\n| `conditions` | Base64-encoded string | Action (branch actions) | Branching logic for `conditional_branch_action`. |\n| `multi_conditions` | Array of base64-encoded strings | Action (branch actions) | Multi-way branching for `multi_split_branch_action` and `conditional_wait_action`. |\n\n## Profile attributes vs event attributes\n\nWhich field a condition belongs in is decided by **where the data lives**, not by the automation's trigger type:\n\n- `attribute_filters` conditions are matched against **the triggering event's payload** (stored as `EtypeFilter`, evaluated at `campaigns/match.go`). They can only reference keys the event itself sends.\n- `filters` is matched against **the profile** (stored as `Filter`), and you write it as **segment membership only**. A condition on what the profile holds (its attributes, its message history) goes into a segment, and `filters` references that segment. See `§ Segment membership is the only condition in filters`.\n\nEvent-triggered automations (`transactional`, `form`) accept **both**, so an entry gate that spans both sources uses both fields together: event conditions in `attribute_filters`, profile conditions through a segment referenced in `filters`.\n\n**A profile attribute placed in `attribute_filters` never matches.** The API accepts it with a 200 and no warning, but the event payload has no such key, so every triggering event fails the filter and is discarded — no enrollment, no error, no log line, and nothing that distinguishes it from an automation receiving no events at all. If a condition names something the **profile** holds (`plan_name`, `account_deleted`, `country`, `lifecycle_stage`) rather than something the **event** sends, it belongs on the profile side: in a segment, referenced from `filters`.\n\nWhen you don't know which keys an event carries, check before choosing: `GET /v1/environments/:environment_id/event_names/:name` reports the attributes seen on that event.\n\n**Worked example** — on the `free_tier_monthly_cap_hit` event, enter free-plan profiles that aren't deleted, but only for the paywall surface. `surface` is on the event; `plan_name` and `account_deleted` are on the profile, so the conditions split:\n\n1. `attribute_filters` (raw JSON object) carries the event condition:\n\n   ```json\n   {\"and\": [{\"field\": \"surface\", \"operator\": \"eq\", \"value\": \"paywall\"}]}\n   ```\n\n2. The profile conditions become one data-driven segment, e.g. \"Free plan, not deleted\". Reuse an existing segment whose conditions are exactly these. Otherwise create it with `POST .../segments` (`data_driven_segments.md`), using these `conditions` (attribute-change conditions, per `segment_conditions.md` § Condition Types):\n\n   ```json\n   {\"and\": [\n     {\"event\": {\"type\": \"attribute_change\", \"name\": \"plan_name\", \"filters\": {\"and\": [\n       {\"field\": \"to\", \"operator\": \"eq\", \"value\": \"free\", \"inverse\": false},\n       {\"field\": \"from\", \"operator\": \"eq\", \"value\": \"free\", \"inverse\": true}]}},\n      \"times\": 1, \"within\": 0, \"inverse\": false},\n     {\"event\": {\"type\": \"attribute_change\", \"name\": \"account_deleted\", \"filters\": {\"and\": [\n       {\"field\": \"to\", \"operator\": \"eq\", \"value\": \"true\", \"inverse\": true},\n       {\"field\": \"from\", \"operator\": \"eq\", \"value\": \"true\", \"inverse\": false}]}},\n      \"times\": 1, \"within\": 0, \"inverse\": false}\n   ]}\n   ```\n\n3. `filters` references that segment, encoded per `§ Base64 Filter Encoding`:\n\n   ```json\n   [{\"type\":\"segments_negatable\",\"segments\":[\u003csegment_id\u003e],\"negate\":false}]\n   ```\n\n## Segment membership is the only condition in `filters`\n\nWrite an automation's `filters` as `segments_negatable` conditions and nothing else:\n\n```json\n[{\"type\":\"segments_negatable\",\"segments\":[\u003csegment_id\u003e],\"negate\":false}]\n```\n\n- `negate: false` = in any of the listed segments; `negate: true` = in none of them. Several conditions in the array are AND-ed.\n- Write `segments_negatable`, not the older `segments` type. The server reads both, but it always reads back `segments_negatable`, and that is the form the app edits.\n- **Never put a `type: \"attribute\"`, `type: \"message\"`, or any other non-segment condition in an automation's `filters`.** The API accepts it with a 200, but the app's Filters section is a segment picker, so the condition can't be seen or edited there. Saving correctly through the API does not mean the condition is right.\n- A profile attribute condition (`plan_name eq free`, `country eq US`) or a message-history condition (\"was sent the welcome email\") goes into a data-driven segment. Write it per `segment_conditions.md`: `attribute_change` for attributes, the message-delivery mapping for message history. Then reference that segment here. Put all of one entry gate's profile conditions in a single segment, not one segment per condition. Choose `negate: true` when the user's condition is the opposite of an existing segment (\"not in VIP customers\").\n- **Reuse before you create.** List segments first (`segments.md`) and reuse one whose conditions say exactly what the user asked for. A new data-driven segment counts against the workspace's allowance, so check the allowance before creating one (`data_driven_segments.md § Workspace allowance`). At the cap, relay the limit message. Never fall back to writing the attribute directly into `filters`.\n- A new segment starts in `\"build\"` state. Poll `GET .../segments/:id/status` (top-level `.state`) until it's `\"complete\"` before you start the automation. Until then its membership is partial, and the people who aren't counted yet don't qualify.\n- Tell the user which segment the automation now targets, and that you created it if you did. It appears in their segment list, and deleting or editing it changes who enters the automation.\n\nIf an automation you're reviewing or editing already stores non-segment conditions in `filters`, raise it with the user and offer to move them into a segment. Don't rewrite it silently. See `automation_review.md`.\n\n**Negation is written differently in `attribute_filters` and the base64 condition DSL — this is the trap in pairing an event filter with an action condition.** In `attribute_filters` (parsed by `core/segments`) an `inverse: true` key negates a condition. In the base64 `preconditions` / `conditions` / `multi_conditions` DSL (parsed by `ui_api/conditions`) an **`attribute` condition ignores `inverse`**. There, negation is a `!` prefix on the operator, e.g. `\"!eq\"`. Sending `inverse: true` on a base64 attribute condition is accepted with a 200 and silently stores the *un*-negated condition, i.e. exactly the opposite audience. Read-back confirms which form the server kept: it re-emits negated attribute conditions as `\"!eq\"`, never as an `inverse` key.\n\nThis applies to the whole attribute family in the base64 DSL (`attribute`, `event_attribute`, `device_attribute`, `collection_attribute`, object/relationship attribute conditions). It does **not** apply to `type: \"message\"` conditions, which do read `inverse` — see `§ Message Conditions`.\n\n## Base64 Filter Encoding\n\nThe `filters`, `preconditions`, `conditions`, and `multi_conditions` fields use **double encoding: JSON → URL-encode → base64**. You MUST URL-encode the JSON first, then base64 the result. Plain base64-encoded JSON is silently accepted but decoded incorrectly (fields come back empty).\n\n**Example:** an automation's `filters`, limited to members of segment 260:\n```json\n[{\"type\":\"segments_negatable\",\"segments\":[260],\"negate\":false}]\n```\n**Example:** an action precondition for `email_verified = true` (attribute conditions belong in `preconditions` / `conditions` / `multi_conditions`, never in an automation's `filters`):\n```json\n[{\"type\":\"attribute\",\"field\":\"email_verified\",\"operator\":\"eq\",\"value\":\"true\"}]\n```\nEncode: `encodeURIComponent(json)` → `base64(urlEncoded)`. See `FilterCondition` in the OpenAPI spec for all operators and fields. The key for the attribute name is `field` - NOT `attribute`.\n\n**Negate an attribute condition with a `!` operator prefix (`\"!eq\"`), not an `inverse` key** — attribute conditions in this DSL ignore `inverse`. (`type: \"message\"` conditions are the exception and do read it.) See `§ Profile attributes vs event attributes`.\n\nSegment membership (`type: \"segments_negatable\"`, as in the first example above) is the only condition type an automation's `filters` holds; see `§ Segment membership is the only condition in filters`. It goes in the audience `filters`, which is read by `behavioral`, `transactional`, `form`, `date`, `relationship` and `object` automations. `seg_attr` and `rel_seg_attr` target through `trigger` instead, and `triggered_broadcast` through `recipients_filter` — a `filters` value on any of those is accepted and ignored. Never fall back to `global_exit_conditions` or tell the user segment targeting is unsupported.\n\n## Message Conditions (`type: \"message\"`)\n\nThe base64-encoded condition DSL (`preconditions`, `conditions`, `multi_conditions`, and newsletter/broadcast audience `filters`, which use the same shape) supports a `message` condition for \"was sent / delivered / opened a message\" criteria. An **automation's** `filters` is the exception: its entry gate is segment membership only. For a message-history entry condition, build a segment with a message-delivery condition (`segment_conditions.md § Message delivery condition mapping`) and reference it from `filters` (`§ Segment membership is the only condition in filters`).\n\n```json\n[{\"type\": \"message\", \"event\": {\"type\": \"delivered_email\", \"name\": \"*\"}, \"inverse\": false}]\n```\n\n- `event.type` MUST be one of the exact values in the table below. **This is a closed list — never invent a type.** Values like `delivered_sms`, `delivered_push`, `opened_sms`, `delivered_twilio`, or `delivered_webhook` do NOT exist; the server rejects them with `422: unknown message-condition event type`, and historically they crash-looped backend workers when persisted (INC-4669). The channel is expressed in `event.name`, never in `event.type`.\n- Email uses `_email` suffix types. **Every non-email channel (SMS, push, in-app, Slack, webhook) uses the `_action` suffix types** with the channel as the `event.name` prefix.\n- `inverse: true` = has NOT had this message event.\n- `event.human: true` restricts opened/clicked to human (non-bot) activity.\n- In-app has no delivered metric — the SDK pulls the message, so the send-side event is `sent_action`. \"Received an in-app message\" is `sent_action` with `in_app_*`, never `delivered_action`.\n\n| Goal | `event.type` | `event.name` |\n|------|--------------|--------------|\n| Sent / delivered / opened / clicked / bounced any email | `sent_email` / `delivered_email` / `opened_email` / `clicked_email` / `bounced_email` | `*` |\n| Delivered any SMS (Twilio) | `delivered_action` | `twilio_*` |\n| Clicked any SMS link | `clicked_action` | `twilio_*` |\n| Delivered any push | `delivered_action` | `push_*` |\n| Opened / clicked any push | `opened_action` / `clicked_action` | `push_*` |\n| Sent / received any in-app message | `sent_action` | `in_app_*` |\n| Delivered any Slack message | `delivered_action` | `slack_*` |\n| Delivered any webhook | `delivered_action` | `webhook_*` |\n\nValid `event.type` values (exhaustive for message conditions):\n\n- Email: `attempted_email`, `drafted_email`, `sent_email`, `delivered_email`, `opened_email`, `clicked_email`, `converted_email`, `bounced_email`, `spammed_email`, `dropped_email`, `failed_email`, `undeliverable_email`, `deferred_email`, `unsubscribed_email`, `topic_unsubscribed_email`\n- Non-email channels: `attempted_action`, `drafted_action`, `sent_action`, `delivered_action`, `opened_action`, `clicked_action`, `converted_action`, `bounced_action`, `spammed_action`, `dropped_action`, `failed_action`, `undeliverable_action`, `deferred_action`, `unsubscribed_action`, `topic_unsubscribed_action`, `replied_action`\n\nTo scope to a specific automation, action, broadcast, or transactional message, use the `event.name` forms from `segment_conditions.md § Message delivery condition mapping` (`email-campaign_{id}`, `output_{action_id}`, `newsletter_{id}`, `transactional_{id}`, ...). The channel→type mapping there is identical to this one — only the wrapper differs (segment conditions wrap in `{\"event\": ..., \"times\": ..., \"within\": ...}`; the filter DSL wraps in `{\"type\": \"message\", \"event\": ..., \"inverse\": ...}`).\n\n## `attribute_filters` Shape (Raw JSON Object)\n\nUnlike the base64-encoded fields above, `attribute_filters` is a raw JSON object. It's parsed by `FiltersFromJsonValidated` (`core/segments/filter.go:337`, accepted shapes in `filtersFromJsonValidatedInternal` at `core/segments/filter.go:348-399`). The root MUST be a JSON **object** in one of these four shapes:\n\n| Root shape | Form | Use for |\n|------------|------|---------|\n| Tree wrapper | `{\"and\": [\u003cchild\u003e, ...]}` or `{\"or\": [\u003cchild\u003e, ...]}` | One or more conditions. Children use the same shape recursively. |\n| Single bare condition | `{\"field\": \"...\", \"operator\": \"...\", \"value\": \"...\", \"inverse\": false}` | A single condition — the parser accepts it at the root directly; wrapping under `and` is **not** required but is also fine. |\n| `attribute_compare` node | `{\"type\": \"attribute_compare\", \"operator\": \"...\", \"field_source\": \"...\", \"value_source\": \"...\", \"field\": \"...\", \"value\": \"...\", \"value2\": \"...\", \"inverse\": false}` | Comparing two attributes (or attribute-vs-event-attribute). |\n| Empty | `{}` | Clear the filter. |\n\n**A top-level JSON array is NOT a valid root** — e.g. `[{\"field\": \"doorway\", \"operator\": \"eq\", \"value\": \"habits\"}]` is invalid. Before [services#21277](https://github.com/customerio/services/pull/21277) this panicked the server and surfaced as a 500. As of #21277 it returns 422 with `invalid filter json: expected one of 'and', 'or', 'field', or attribute_compare 'type'` (see the error at `core/segments/filter.go:399`). If you have a list of conditions, wrap it: `{\"and\": [...]}` or `{\"or\": [...]}`.\n\n**Examples:**\n\nSingle condition (bare):\n```json\n{\"field\": \"doorway\", \"operator\": \"eq\", \"value\": \"habits-through-storytelling\"}\n```\n\nSingle condition wrapped (equivalent; preferred when the code may grow to multiple conditions):\n```json\n{\"and\": [{\"field\": \"doorway\", \"operator\": \"eq\", \"value\": \"habits-through-storytelling\"}]}\n```\n\nMultiple conditions, all must match:\n```json\n{\"and\": [\n  {\"field\": \"doorway\", \"operator\": \"eq\", \"value\": \"habits\"},\n  {\"field\": \"referrer\", \"operator\": \"eq\", \"value\": \"email\"}\n]}\n```\n\nEither-or with one branch nested:\n```json\n{\"or\": [\n  {\"field\": \"payment_method\", \"operator\": \"eq\", \"value\": \"card\"},\n  {\"and\": [\n    {\"field\": \"payment_method\", \"operator\": \"eq\", \"value\": \"invoice\"},\n    {\"field\": \"amount\", \"operator\": \"lt\", \"value\": \"50\"}\n  ]}\n]}\n```\n\nEvery `field` in these examples is a key the **event** sends. A profile attribute here is accepted and then never matches — see `§ Profile attributes vs event attributes`.\n\nOperators are validated by `ValidateFilterOperator` in `core/segments/filter.go`; two-value operators (e.g. `bw`) require `value2`. See the OpenAPI `AttributeFilters` / `FilterCondition` schemas for the full enum.\n\n## Exit Conditions\n\nExit conditions use `update_type: \"exit_conditions\"` and `global_exit_conditions`. Each item is a `Filter` object (NOT the same format as the condition DSL above) using nested type wrappers:\n\n```json\n{\"campaign\": {\"update_type\": \"exit_conditions\", \"exit_on_trigger_or_filter_not_matched\": true,\n  \"global_exit_conditions\": [{\"segment\": {\"id\": 10}}]}}\n```\n\nEvent exits use `{\"event_name\": {\"name\": \"purchased\"}}` — `name` is a required string. A malformed or unknown filter key is rejected with a 422 JSON:API error whose detail is `Invalid filter` and whose source pointer is `/data/attributes/global_exit_conditions`; it is not silently ignored.\n\n`global_exit_conditions` comes back `null` in the response when the field was never set, or when it was cleared by an `exit_and_conversion` update — see `automations.md § Conversion goals` for why that update_type owns this field.\n\n**A `foreign_event` condition must sit in the same filter as an `event_name` condition** (or the equivalent bitmap-event reference). It reads the arriving event's payload, which only exists when the filter is scoped to that event. A bare `foreign_event` exit condition is rejected with a 422 whose detail is `A foreign_event condition requires an event condition in the same filter`. It applies to every exit condition, not just the geofence shape below. The same rule covers action conditions: preconditions, True/False and Multi-Split branches, and Wait Until. See `flow_control.md § Automation Action Condition Types`.\n\n### Geofence exit conditions\n\nA geofence transition uses a separate, scoped `Filter` shape. Resolve the geoset name to its id first:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"exit_conditions\",\n    \"exit_on_trigger_or_filter_not_matched\": true,\n    \"global_exit_conditions\": [\n      {\n        \"and\": [\n          { \"event_name\": { \"name\": \"exit\", \"etype\": \"geofence\" } },\n          { \"foreign_event\": { \"attribute\": { \"field\": \"geoset_id\", \"operator\": \"eq\", \"value\": \"42\" } } }\n        ]\n      }\n    ]\n  }\n}\n```\n\nUse `enter`, `exit` or `dwell` as the event name. Keep the required `geoset_id` `eq` filter in the same `and` path as the event, then fetch the campaign and verify the stored `global_exit_conditions`. The one-hour DWELL freshness limit applies only to campaign starts; an older queued DWELL can satisfy an exit condition when it arrives.\n\n### Dynamic exit conditions: matching the trigger event with `attribute_compare`\n\nTo exit a journey when an event fires **for the same thing that triggered it** (the same loan, order, or application — not any occurrence of the event), compare the exit event's attribute against the trigger event's attribute. The key is `attribute_compare` nested inside `foreign_event` — **not** `foreign_event.attribute`, which accepts only a literal `value` and has no `value_source`:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"exit_conditions\",\n    \"exit_on_trigger_or_filter_not_matched\": true,\n    \"global_exit_conditions\": [\n      {\n        \"and\": [\n          { \"event_name\": { \"name\": \"loan_closed\" } },\n          { \"foreign_event\": { \"attribute_compare\": {\n              \"field\": \"loan_id\", \"field_source\": \"foreign_event_attribute\",\n              \"operator\": \"eq\",\n              \"value\": \"loan_id\", \"value_source\": \"event_attribute\" } } }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThis exits a person when `loan_closed` fires with the same `loan_id` as the event that started their journey. `field` names an attribute on the exit event (`foreign_event_attribute`); `value` names an attribute on the trigger event (`event_attribute`). Keep the `event_name` condition in the same `and` path, like the geofence example above.\n\nThree shape traps:\n\n- **No `type` key inside the block.** This is the nested-wrapper `Filter` format — the wrapper key already carries the type. Adding `\"type\": \"attribute_compare\"` (the base64 condition-DSL spelling from `delays.md § Wait Until: correlating two events`) is an unknown key: 422 `Invalid filter`.\n- `field` and `value` must both be non-blank: 422 `Invalid filter`.\n- **An omitted `field_source` or `value_source` silently defaults to `event_attribute`** (unlike the condition DSL, where omitting either is a 422). An `attribute_compare` with no `field_source` saves fine and then reads the trigger event on both sides — always set both.\n\nSince `value_source: \"event_attribute\"` reads the journey's trigger event, this only works on event-triggered automations — same caveat as the Wait Until version (see `delays.md`).\n\n## Key Gotchas\n\n- `attribute_filters` and `trigger` are raw JSON objects. `filters`/`preconditions`/`conditions`/`multi_conditions` are base64-encoded. `recipients_filter` is a JSON string (not an object).\n- **An `or` array inside `recipients_filter` can only contain bare `segment` conditions.** Mixing in an `attribute`, a `not`, or a nested `and`/`or` is rejected with a `422` naming the offending condition type (e.g. `'or' conditions can only contain segment references; found a \"not\" condition`) — the recipient condition editor in the app can't render anything else. Put non-segment conditions in a top-level `and`, or wrap the whole `or` in a `not` from the outside, instead of nesting them inside the `or` itself.\n- **`trigger` must be a JSON object, not a string.** When round-tripping (GET then PUT), embed the trigger value directly — do not re-serialize it into a string. A double-encoded trigger like `\"trigger\": \"{\\\"and\\\":[...]}\"` is rejected with `422: trigger must be a JSON object`.\n- **`multi_conditions` elements must not be empty strings.** Each element must be a valid base64-encoded condition. Sending `[\"\"]` or `[\"\", \"...\"]` is rejected with `422: condition filter cannot be empty`.\n- `attribute_filters` root must be a JSON object (`{\"and\":[...]}`, `{\"or\":[...]}`, a bare `{\"field\":...,\"operator\":...,\"value\":...}`, an `attribute_compare` node, or `{}`) — **not** a top-level array. See `§ attribute_filters Shape` above.\n- **A profile attribute in `attribute_filters` is accepted with a 200 and then silently discards 100% of trigger events.** `attribute_filters` reads the event payload; profile conditions belong in a segment referenced from `filters`. Both fields are available on `transactional` and `form`. See `§ Profile attributes vs event attributes`.\n- `attribute_filters` is silently ignored on segment/attribute-triggered (`seg_attr`) automations.\n- The condition DSL uses `field` for the attribute name — NOT `attribute`. Using `attribute` as the key silently fails (the server stores a blank field name).\n- **`inverse` negates a condition in `attribute_filters`, but an attribute condition in the base64 `preconditions` / `conditions` / `multi_conditions` DSL (and a newsletter's `filters`) ignores it** — negation there is a `!` operator prefix (`\"!eq\"`). An `inverse: true` on a base64 attribute condition is accepted with a 200 and stores the un-negated condition: the opposite audience, silently. `type: \"message\"` conditions do read `inverse`.\n- **A `related` condition in `preconditions` / `conditions` / `multi_conditions` must use `object_source: \"any\"` and `person_source: \"current\"` or `\"trigger\"`** — anything else is a `400`. To check an attribute of the object that triggered the journey, emit a bare `attribute_compare` with `field_source: \"trigger_object_attribute\"`, never a `related` wrapper around it. See `flow_control.md § Automation Action Condition Types`.\n- Preconditions are action-level (not automation-level). They gate individual steps, not automation entry.\n- `anchors` is only for existing legacy `behavioral` automations. Do not use it for new segment or attribute-triggered automations.\n\n## Saving Filter Changes\n\nAfter modifying `attribute_filters`, `trigger`, or `filters`, persist them with:\n```\nPUT /v1/environments/:environment_id/campaigns/:campaign_id\nBody: { \"campaign\": { \"update_type\": \"recipients\", \"type\": \"...\", \"event\": \"...\", \"event_type\": \"...\", \"anchors\": \"...\", \"attribute_filters\": {...}, \"trigger\": {...}, \"filters\": \"...\", \"restart_mode\": \"...\", \"restart_min_interval\": ... } }\n```\n`update_type` MUST be `\"recipients\"` or the filter fields are silently ignored (the API returns 200 but nothing changes).\n\n**A `recipients` write is a full replace, not a partial merge.** It rebuilds the automation's targeting from the request body: fetch the automation, change only what you mean to change, and pass everything else through exactly as the GET returned it. An omitted field is reset to empty/default with a 200 and no warning — and an explicit `null` or `\"\"` clears the stored value just as silently.\n\nWhich keys have to be present depends on the `type` — each type replaces only the fields it applies, and your requests are checked against that type, so a missing one comes back `400` naming it (e.g. `campaign.filters`); other API clients get the reset silently. Always start with `update_type` and `type`, then read the automation and resend the fields that type uses (see the per-`type` table below); pass anything else through as the GET returned it. The conversion/exit keys are *not* part of a recipients update — they belong to the `conversion`/`exit_conditions` update types; see `automations.md § update_type Field`.\n\nThe fields each `type` requires (a strict caller omitting one gets a `400`):\n\n| `type` | Required fields |\n|--------|-----------------|\n| `behavioral` | `anchors`, `filters`, `restart_min_interval`, `restart_mode` |\n| `seg_attr` | `trigger`, `restart_min_interval`, `restart_mode` |\n| `rel_seg_attr` | `rel_seg_attr_trigger`, `rel_seg_attr_object_type_id`, `restart_mode` |\n| `transactional`, `form` | `event`, `event_type`, `attribute_filters`, `filters`, `restart_min_interval` |\n| `triggered_broadcast` | — (omitting `recipients_filter` keeps it; sending `\"\"` clears it) |\n| `date` | `filters`, `date_triggered_attribute`, `date_triggered_frequency`, `date_triggered_lead_days`, `date_triggered_start_time`, `date_triggered_zone` |\n| `webhook` | `webhook_id` |\n| `relationship` | `relationship_campaign`, `relationship_attribute_triggers`, `trigger_person_filters`, `trigger_relationship_filters`, `audience`, `filters`, `relationship_filters`, `object_filters` |\n| `object` | `object_campaign`, `object_attribute_triggers`, `audience`, `filters`, `relationship_filters`, `object_filters`, and the `date_triggered_*` keys (`date_triggered_attribute`, `date_triggered_frequency`, `date_triggered_lead_days`, `date_triggered_start_time`, `date_triggered_zone`) |\n\n`object` requires the `date_triggered_*` keys because a date-triggered object automation replaces them and silently resets `date_triggered_lead_days`/`date_triggered_zone` if they are dropped; a non-date object automation ignores them, so send the empty GET values (`\"\"`, `0`) for those. `date_triggered_fallback_zone` is required only when `date_triggered_zone` is `customer_time_zone`. Fields a type doesn't list are accepted but ignored.\n\n- `seg_attr` targeting lives entirely in `trigger`; pass `filters` through as fetched rather than authoring one.\n- `restart_mode` is honored only for `behavioral`, `seg_attr`, and `rel_seg_attr`. For `transactional`, `form`, `relationship`, and `object` it's forced to `rematch` and the request value is ignored. Valid values are `none`, `interval`, and `rematch`; never use or recommend `restart_mode: \"finished\"`.\n- `attribute_filters` applies only to `transactional` and `form`. Those two types also accept `filters`. Being event-triggered is not a reason to put a profile condition in `attribute_filters`: it goes in a segment referenced from `filters`.\n\n**Required pattern for any `update_type: \"recipients\"` write:**\n\n1. Fetch the automation and read the current recipient fields for its `type` (the GET response carries all of them, including `restart_mode` / `restart_min_interval`).\n2. Modify only the field(s) you intend to change.\n3. Resend the automation's full set of recipient fields for its `type`, passing unchanged values through exactly as fetched — never send only the field you changed. The table above shows what's lost otherwise.\n4. Run the verify-one-before-bulk pattern (see `SKILL.md § Bulk Operations`) before applying to multiple automations.\n\n**On round-tripping `filters` and `anchors`:** both are base64-double-encoded strings, not JSON arrays. Pass the exact string you received from the GET — do not decode and re-encode it, since any change in encoding can corrupt the segment list.\n","automation_lifecycle.md":"---\nminimum_plan: essentials\n---\n\n# Automation Lifecycle\n\nThis file covers stopping, archiving, deleting, state transitions, exit conditions, and multi-language automations. For automation creation and workflow setup, see `automations.md`.\n\n## Stopping, Archiving, and Deleting\n\n| Action | `state` value | What happens |\n|--------|--------------|-------------|\n| Hard stop | `stop` | Everyone halted immediately. Pending in-app canceled. |\n| Graceful stop | `sunset` | No new entries, current profiles finish their journey. |\n| Archive | `archive` | Hidden from list. Cancels in-app. Reversible via `unarchive`. |\n| Delete | `DELETE /campaigns/:id` | Permanent. Async (`deleting` state). |\n| Schedule | `schedule` | Set `scheduled_start`/`scheduled_stop` timestamps. |\n\nAll state changes use `update_type: \"state\"`. Both stop types can be restarted with `\"state\": \"start\"`.\n\n### Queued deliveries after a stop\n\nA stop halts the journey. It does not touch messages already handed to egress. Those keep going out at the automation's message-throttle rate. A large backlog on a slow throttle can trickle for months. A stopped automation that still sends is this, not a broken stop.\n\nTo decide what happens to that backlog, send a `disposition`. It rides on the stop itself:\n\n```json\n{\"campaign\": {\"update_type\": \"state\", \"state\": \"stop\", \"disposition\": \"abort\"}}\n```\n\nor, for an automation that is already stopped, arrives on its own:\n\n```json\n{\"campaign\": {\"update_type\": \"queued_disposition\", \"disposition\": \"abort\"}}\n```\n\nThe standalone form 422s against a running automation — send it with the stop instead. That is deliberate: a running automation holding a raised egress rate never comes back down on its own, so the disposition can only be set at the moment of, or after, the stop.\n\n`disposition` takes `abort` or `send_now`. Both raise the automation's egress rate, so the queue clears in minutes instead of months.\n\n`abort` also stamps the automation. The send gate then discards every delivery queued at or before that moment, and each one records that the queued messages were cancelled.\n\n`send_now` does not stamp. The backlog drains to its normal outcome, so the messages still inside their 24h window go out. Older ones already expired and never send.\n\nNeither value bypasses a billing, compliance, or frequency cap. Both change pacing only.\n\nA message queued after an abort still sends. A restart therefore works normally.\n\nThe action also accepts an automation stopped long ago, so it clears an old backlog.\n\n`state` takes the **transition**, not the state you want to end up in: send `archive`, not `archived`. The resulting state is a different word (`archive` → `archiving` → `archived`), and the state names are rejected with 422 `invalid state`.\n\n**Archive is a `PUT`, never a `DELETE`.** There is no archive endpoint — `cio schema campaigns` lists a `DELETE /campaigns/:id` and no archive route, because archive is a `state` value on the update endpoint. `DELETE` permanently destroys the automation and its history; it is never how you fulfil an \"archive\" request, and its 200 looks identical to a successful archive.\n\n```bash\ncio api /v1/environments/{environment_id}/campaigns/{campaign_id} -X PUT \\\n  --params '{\"environment_id\": \"'$ENV_ID'\", \"campaign_id\": \"42\"}' \\\n  --json '{\"campaign\": {\"update_type\": \"state\", \"state\": \"archive\"}}'\n\n# Required second step. The PUT echoes back the state it intends, so its own body is\n# not evidence — only this fresh read is.\ncio api /v1/environments/$ENV_ID/campaigns/42 --jq '.campaign.state' -r\n```\n\nArchiving many automations is the same call per id. After a bulk loop, re-read one of the automations and confirm its `state` before reporting the outcome — a `DELETE` typo returns 200 for every id and reads as success. Archiving is async, so the read-back returns `archiving` or `archived`; both mean it worked. A `deleting` state, or a 404, means you deleted it.\n\n## State Transitions\n\n| `state` value | Valid from | Result | Notes |\n|---------------|-----------|--------|-------|\n| `start` / `restart` | draft, sunsetting, stopped | running | Add `backfill: true` for immediate evaluation |\n| `stop` | running, evaluating, sunsetting, paused | stopping → stopped | Async drain |\n| `archive` | draft, running, paused, sunsetting, stopped | archiving → archived | |\n| `unarchive` | archived | stopped | |\n| `sunset` | running, evaluating, paused | sunsetting → stopped | |\n| `schedule` | any | (no state change) | Sets timestamps only |\n\nA 422 means the request was rejected, not necessarily that the verb was wrong: an unrecognized `state` value returns 422, but so does `start`/`restart` on an automation with no actions, an unset `type`, or a missing subscription topic, and `schedule` with a past or inverted timestamp. So does `unarchive` when reviving the automation would restore its archived data-driven segments past the workspace's segment limit, in which case nothing is unarchived. On a plan with a conditions lookback limit, `unarchive` (or `stop` from archived) returns a 400 that names the segment (`segment N (\"name\"): invalid within value: ...`) when one of those segments has an event condition past the limit (`segment_lookback.md`); again nothing is unarchived. Read the message before changing verbs. A state value the automation cannot transition to from where it is (see the table above) returns **409** — read that as \"wrong starting state\", not as a broken request, and never as a reason to try `DELETE`. Automations in `stopping` state cannot be edited (422).\n\nA transition to the state an automation is already in (or past) is a **silent 200 no-op** — `unarchive` on an automation still `archiving`, `archive` on one already `archived`, `stop` on one already `stopped`. Nothing transitions and no error comes back, so a 200 here is not evidence anything changed: only a read-back tells you the state.\n\n## Deleting an automation\n\nDeleting an automation is permanent and takes its actions and reporting history with it, so it always requires a `delete:campaign:\u003cid\u003e` capability grant — including for drafts and stopped automations, which need no grant for ordinary edits. Request one scope per automation, naming the ones the user named, all in one `request_capability` call, and finish the deletes in the turn that approval opens — the grant ends when you reply to the user. The approved grant rides on every `cio api` call automatically — never pass it as a `-H` header; `cio api` has no such flag.\n\nArchive and delete both return 422 with code `draft_job_pending` while a send-drafts or delete-drafts job for the automation is queued or running. Nothing changes on the automation. Tell the user to retry after that task finishes.\n\nA request to identify which automations *could* be removed is advisory and read-only: report the candidates, don't delete them. And if you set out to archive and the API asks you for a `delete:campaign:\u003cid\u003e` scope, the call is wrong, not the permission — archive is the `PUT` above.\n\n## Exit Conditions\n\nExit conditions remove people from an automation when they meet specified criteria. Configured via `update_type: \"exit_conditions\"` or `update_type: \"exit_and_conversion\"`.\n\n### Segment-based exit\n\nPeople exit when they no longer match the automation's trigger segment:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"exit_conditions\",\n    \"exit_on_trigger_or_filter_not_matched\": true\n  }\n}\n```\n\n### Segment-based exit (global exit conditions)\n\nPeople exit when they match a specific segment:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"exit_conditions\",\n    \"global_exit_conditions\": [\n      {\n        \"segment\": { \"id\": 10 }\n      }\n    ]\n  }\n}\n```\n\nEach element in `global_exit_conditions` is a filter object — the same filter DSL used elsewhere (segment, event, attribute, and/or combinators). See `automation_filters.md` for filter encoding.\n\nYou can combine both: set `exit_on_trigger_or_filter_not_matched: true` and provide `global_exit_conditions` in the same request. To clear exit conditions, send an empty array `[]`.\n\n### Conversion goals, and combining them with exits\n\n`update_type: \"conversion\"` sets the goal in a single request and leaves `global_exit_conditions` untouched:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"conversion\",\n    \"conversion\": true,\n    \"conversion_type\": \"perform_event\",\n    \"conversion_event_name\": \"purchased\",\n    \"conversion_window\": 604800,\n    \"conversion_action\": \"receiving\"\n  }\n}\n```\n\nDon't use `update_type: \"exit_and_conversion\"` to enable `exit_on_conversion_matched` — that setting has no UI anywhere and is not how exit-on-goal is done. It takes the same conversion fields plus `exit_on_trigger_or_filter_not_matched` and `exit_on_conversion_matched`; derives `global_exit_conditions` from the goal; and discards any you send with it. For exit-on-goal, recreate the goal criteria in `global_exit_conditions` instead: send `conversion` first, then `exit_conditions`. Full field list, validation rules, and caveats: `automations.md § Conversion goals`.\n\n## Multi-Language Automations\n\nTranslations are covered in `translations.md` — that file routes by parent type (automation action, one-time send, transactional, subscription center). For automation actions specifically, see also `automations.md § Multi-Language Action Variants` for the workflow graph wiring, and `GET .../campaigns/:id/untranslated_actions` (in `automations.md § Other Endpoints`) for finding actions that still need translation content.\n\nFor simple cases — a few short strings inside otherwise identical content — **Liquid conditionals** in a single template can replace per-language variants entirely:\n\n```liquid\n{% if customer.language == \"es\" %}\n  ¡Hola {{ customer.first_name }}!\n{% else %}\n  Hello {{ customer.first_name }}!\n{% endif %}\n```\n","automation_metrics.md":"---\nminimum_plan: essentials\n---\n\n# Automation Metrics\n\nFor automation CRUD, triggers, and workflow operations, see `automations.md`.\n\n\u003e **Diagnosing performance, not just fetching numbers?** If the user is asking *why* an automation is doing well/poorly, *whether* it's performing, or to compare automations, read `recipes/analyzing_automations.md` for the multi-step diagnosis workflow before pulling metrics. If their question is subjective (\"which is best\", \"is X working\") read `recipes/analysis.md` first to clarify criteria.\n\n## Delivery \u0026 Engagement Stats\n\nActive automations are the ones with `state == \"running\"` in `GET .../campaigns`; there is no `active` field.\n\n```\nGET /v1/environments/:environment_id/campaigns/:campaign_id/metrics?version=2\u0026tz=America/New_York\u0026res=monthly\n```\n\nThe response contains several top-level keys:\n\n| Key | What it contains |\n|-----|-----------------|\n| `.metric` | **Email metrics only** — flat object with per-period arrays AND `total_*` summary fields (e.g., `total_sent`, `total_delivered`, `total_human_opened`, `total_human_clicked`). Always email regardless of automation channel. |\n| `.campaign_metrics` | **Per-channel breakdown** — keyed by channel name (`email`, `webhook`, `twilio`, `sms`, `push`, `in_app`, `slack`, `whatsapp`, `inbox`, `line`, `urban_airship`, `live_notification`) — one key per `channeltype.Types` entry. **SMS spans two keys**: `twilio` (the classic SMS action) and `sms` (the message-channel action). Read both or you under-count SMS. Each channel has per-period arrays only (no `total_*` fields). Use this for non-email channel stats. |\n| `.journey_metrics` | Journey-level data: `started`, `messaged`, `converted`, `exited_early`, `finished`, `never_activated`, `activated`. All per-period arrays. |\n| `.action_metrics` | Per-action ID breakdown with the same metric keys. |\n| `.link_metrics` | Per-link click arrays with `href` and `total_clicked`. |\n| `.subscription_topic_metrics` | Unsubscribes by topic ID. |\n\n\u003e **Common mistakes:**\n\u003e - `.channel_metrics` does **not exist**. Using it with jq returns `null`, and summing `null` silently returns `0` — making it look like the automation has no data.\n\u003e - There is no `pd_channel` query parameter. The `.metric` field is always email. For other channels, use `.campaign_metrics.\u003cchannel\u003e`.\n\n**Email totals** are available directly via `.metric.total_sent`, `.metric.total_delivered`, `.metric.total_human_opened`, `.metric.total_human_clicked`, `.metric.total_bounced`, `.metric.total_unsubscribed`.\n\n**Non-email channel totals** must be summed from the per-period arrays in `.campaign_metrics.\u003cchannel\u003e`.\n\n\u003e `total_*` defaults to the automation's lifetime. Pass `start`/`end` matching the user's date range and state the range used; never label a lifetime total \"YTD\".\n\n\u003e **\"Last 24 hours\" / \"yesterday\":** send `version=2` with explicit `start`/`end` epochs (`res=hourly` for a trailing window, or `res=daily` bounded to the previous full day in `tz`), and tell the user the exact window and timezone. The newest daily bucket is today so far, often only a few hours, so never read it as \"the last day\".\n\n\u003e UI open/click columns are *total* (human + machine); `total_human_*` excludes bots. When reconciling against the UI, report both.\n\n## Link Click Metrics\n\n```\nGET /v1/environments/:environment_id/campaigns/:campaign_id/tracked_link_metric\n```\n\nReturns per-link click data (`clicked` and `human_clicked` time series) for all tracked links in the automation, sorted by total clicks.\n\n## Response Metrics (In-App)\n\n```\nGET /v1/environments/:environment_id/campaigns/:campaign_id/tracked_response_metrics\n```\n\nReturns tracked response/engagement metrics (e.g., button clicks) for automation actions. Optional `delivery_type` query param (defaults to in-app). For automations with multi-language actions, metrics from all language variants are aggregated into the parent action.\n\n## A/B Test Metrics\n\nThe active-tests dashboard and per-split variant metrics belong to `ab_tests.md`.\nRead that file before using either endpoint; it includes the action-type guard\nthat prevents `split_metrics` calls against ordinary actions.\n\n## Metrics Query Parameters\n\nFor automation metrics, link metrics, and response metrics, always include:\n- `version=2` (required)\n- `tz` (required) — timezone name (e.g., \"America/New_York\")\n- `res` — resolution (e.g., \"daily\", \"monthly\")\n- `start`, `end` — unix timestamps (optional; defaults calculated from resolution)\n","automation_review.md":"---\nminimum_plan: essentials\n---\n\n# Automation Pre-Launch Review\n\n\u003e Use this skill when a user asks you to review, QA, check, or proofread an automation before starting it. This includes full automation reviews, single-element checks (\"is the sender right?\", \"check this condition\"), and content proofreads. For diagnosing issues with a running automation, see `automation_debugging.md`. For analyzing performance metrics after launch, see `recipes/analyzing_automations.md`. **To review a broadcast (a one-time send to a segment, not a workflow), use `broadcast_review.md` instead.**\n\n## Scoping the Review\n\nNot every request is a full review. Match the scope to what the user asked:\n\n**Focused check** — the user asks about a specific element (\"check the sender on all emails\", \"is this condition correct?\", \"proofread the subject lines\", \"is click tracking on?\"). Answer the specific question first using the relevant checklist items below. After answering, offer: *\"Would you like me to run a full pre-launch review of this automation?\"*\n\n**Full review** — the user asks to \"review\", \"QA\", \"sanity check\", or \"check for issues\" without narrowing to a specific element. Run the complete workflow below.\n\nWhen in doubt, run the full review.\n\n## Workflow\n\n\u003e The backend only blocks starting an automation for: no type, zero actions, or invalid subscription topics. Sender, content, unsubscribe, and tracking quality are never enforced — so a clean review here is genuinely additive.\n\nFollow these steps in order. Every step builds context for the next.\n\n### Step 1: Gather Automation Data\n\nRead the automation and all its message content:\n\n\u003e **Read the FULL template — do not project it with `--jq`.** A review judges what's present, so a self-written `--jq` filter that omits a field (e.g. `preheader_text`) makes that field look unset and produces false \"missing\" findings. Override the \"always `--jq`\" default here: pull the whole template object. Only report a field as a gap when the API returns it empty — never when your projection simply didn't include it.\n\n- `GET .../campaigns/:id` — full automation including workflow actions, edges, trigger config, filters, exit conditions, and `restart_mode`\n- For each message action in the workflow: `GET .../templates/:template_id` — the full template (subject, `body`, `from_identity_id`, `reply_to_identity_id`, `preheader_text`, links); do not narrow it with `--jq`\n- `GET .../campaigns/:id/untranslated_actions` — actions inside multi-language branches that are missing translated content\n- `GET .../campaigns/:id/validate` — backend warnings for Liquid refs invalid for the trigger type (`{{event.*}}` on non-event automations, wrong-object `{{trigger.}}`, relationship roots on non-relationship automations) plus contentless in-app/inbox bodies\n- `GET .../actions/:id/variables` — Liquid variables referenced in each action's template\n\n### Step 2: Summarize\n\nWrite one paragraph in plain English describing what this automation does and who it targets. Confirm or correct the user's mental model before getting into specifics. Include:\n- Automation type and trigger mechanism\n- Who enters (segment, event, date, API call)\n- What messages they receive and in what order\n- What the intended outcome appears to be\n\n### Step 3: Review — Structure\n\n**Will the automation behave as intended?**\n\nCheck each item and mark it `✓ Looks correct`, `⚠ Needs attention`, or `— Not applicable`.\n\n**Entry:**\n1. Will the trigger reach the intended audience? Are entry filters over- or under-inclusive?\n2. Could existing users enter unintentionally (e.g., segment-based trigger without restricting to future members only)?\n3. Will anyone enter who shouldn't — overlapping automations, missing suppression conditions, duplicate send risk?\n4. **Is every entry condition in the field that matches its data source?** On an event-triggered automation, each `attribute_filters` condition must name a key the triggering event actually sends (check `GET /v1/environments/:environment_id/event_names/:name`); conditions on profile attributes belong in a segment referenced from `filters`. A profile attribute in `attribute_filters` saves cleanly and then discards 100% of trigger events with no error. Decode `filters` on every type that reads it (`seg_attr`, `rel_seg_attr` and `triggered_broadcast` ignore it). Any condition other than `segments_negatable` / `segments` is `⚠ Needs attention`. This is a different problem from a profile attribute in `attribute_filters`: the condition does gate entry, but the app's Filters section is a segment picker, so it can't show or edit it. Say that, recommend moving it into a data-driven segment referenced from `filters` with `segments_negatable`, and ask \"Want me to move it into a segment?\" Don't change anything until the user says yes. See `automation_filters.md § Segment membership is the only condition in filters`.\n\n**Flow integrity:**\n\n\u003e Structural integrity (bad edges, cycles, unreachable actions) is enforced on every save — those states can't persist. Check logical emptiness instead.\n\n5. Does any branch converge immediately with no meaningful step between — a logically empty path that adds no value?\n6. Could a user get stuck indefinitely — a `conditional_wait_action` with `exit_type: \"never\"` and no escape condition? Also check multi-split and random cohort branches: a path that silently reconverges with no action is the same trap.\n\n**Conditions and logic:**\n7. Do True/False branch conditions match the stated intent?\n8. Is any date math correct (e.g., relative timestamp offsets in wait conditions)?\n9. Will conditions that reference Liquid variables resolve correctly? Check `GET .../campaigns/:id/validate` first — it surfaces Liquid refs invalid for the trigger type. Fall back to `GET .../actions/:id/variables` only for null/missing-attribute reasoning it doesn't cover.\n\n**Exit and goal:**\n10. Check whether exit conditions or a conversion goal are configured (`exit_on_trigger_or_filter_not_matched`, `global_exit_conditions`, `conversion` event). If none are set, note it — but this is only a concern if the automation has ongoing or looping paths. Many automations (e.g., linear drips) exit naturally at the end of the workflow and don't need early exit conditions.\n11. If a conversion goal is configured, check whether people who convert actually exit. Either mechanism counts:\n    - A `global_exit_conditions` entry matching the goal criteria — the supported pattern; it's what the UI's exit-conditions builder produces.\n    - `exit_on_conversion_matched: true` — seen on some existing automations; its mirrored condition shows in the UI's exit panel.\n    - **Neither present** → people who convert keep receiving messages. Flag that, and recommend recreating the goal criteria as an exit condition (`automations.md § Conversion goals`).\n    - The finding is always the *behavior*, never the field: `exit_on_conversion_matched: false` is not a defect on its own and must never be \"fixed\" by enabling the flag (it has no UI anywhere). A matching exit condition handles exit-on-goal completely without it.\n    - Conversion *counting* is independent of exit wiring in every case — a goal counts a profile as converted whether or not they exit.\n12. When converting doesn't cause profiles to exit (item 11's neither-present case) they continue through the remaining workflow steps even after conversion. Read each message after the expected conversion point with that in mind: is it still relevant to someone who already converted? A \"finish signing up\" nudge is not; deliberate post-conversion content (onboarding, an upsell) is fine. Irrelevant post-conversion messages strengthen the recommendation to add the goal criteria as an exit condition.\n\n**Re-entry:**\n13. Re-entry is configured as intended. Behavior depends on automation type:\n    - `seg_attr`: review both `restart_mode` and `restart_min_interval`.\n    - legacy `behavioral` (existing automations only — this type is deprecated and agents cannot create `behavioral` automations): review both `restart_mode` and `restart_min_interval`.\n    - transactional (event) and `form`: `restart_mode` is forced to `rematch`, but `restart_min_interval` still controls re-entry — negative = enter once, positive = rate-limited. Review the interval, not the mode.\n    - `relationship` and `object`: `restart_mode` is forced to `rematch`.\n    - `webhook`: the save path neither reads nor forces restart settings — don't advise setting `restart_mode`.\n    - `date`: controlled by date frequency/scheduling fields, not `restart_mode`.\n    - `triggered_broadcast`: `restart_mode` is forced to `rematch` and the update path explicitly refuses to modify it — don't advise setting it.\n\n### Step 4: Review — Technical\n\n**Is everything configured correctly?**\n\n14. `from_identity_id` and `reply_to_identity_id` are set on all email action templates — flag any that are missing or use a placeholder identity.\n15. `send_to_unsubscribed` is `false` at both the automation level (a top-level field on the automation) and on email/SMS message actions. Either level can allow sends to unsubscribed users — an action sends to unsubscribed users if its own flag is `true`, or if it inherits automation settings and the automation's flag is `true`. If `true` at either level, confirm a `subscription_topic_id` is set on the automation and that sending to unsubscribed users is intentional. Note: in-app, inbox, Slack, and webhook actions always have `send_to_unsubscribed: true` by design — do not flag those.\n16. `sending_state` is `\"automatic\"` on all message actions. If any action has `sending_state: \"draft\"`, messages will queue for manual review instead of sending. If any action has `sending_state: \"off\"`, messages won't send at all. Flag either so the user can confirm it's intentional.\n17. `subscription_topic_id` is set on the automation if the workspace uses subscription topics.\n18. All email template `body` content contains an unsubscribe link (look for `unsubscribe_url` or `{{unsubscribe_url}}`).\n19. No broken or placeholder links in template content — look for `http://example.com`, `#`, `TODO`, `FIXME`, or empty `href` attributes.\n20. UTM parameters are attached to links where expected — check for `utm_source`, `utm_medium`, `utm_campaign` in link URLs.\n21. Open and click tracking (`tracked` on each message action) is enabled. If tracking is off on any action, flag it — the user likely wants to measure engagement.\n22. Liquid variables used in message content (`subject`, `body`, `preheader_text`) will resolve for all users. Check `GET .../campaigns/:id/validate` first — it surfaces invalid Liquid refs for the trigger type. Fall back to `GET .../actions/:id/variables` for null/missing-attribute reasoning it doesn't cover; flag any `{{customer.*}}` or `{{event.*}}` references that could render blank or throw an error when the attribute is missing, null, or an unexpected type.\n\n### Step 5: Review — Timing\n\n**Will messages land when intended?**\n\n23. Time window actions (`delay_time_window_action`) use `zone: \"customer_time_zone\"` where relevant, not a hardcoded timezone. If `\"customer_time_zone\"` is used, confirm `fallback_zone` is set.\n24. No path exists where a user could receive two messages in rapid succession — check for consecutive message actions with no delay, wait, or time window between them.\n\n### Step 6: Review — Content\n\n**Is the content ready to send?**\n\n25. No typos or grammatical errors in subject lines, preheader text, or body copy.\n26. No leftover placeholder text — look for patterns like `[FIRST NAME]`, `[INSERT LINK]`, `TODO`, `FIXME`, `lorem ipsum`, `xxx`, or `placeholder`.\n27. Translated variants are complete — `GET .../campaigns/:id/untranslated_actions` returns no results. If it does, list the actions and languages still missing content.\n28. CTAs are present in every message that requires one, and all CTA links are set and resolve to real URLs (not `#` or empty).\n\n### Step 7: Deliver Findings\n\nPresent the review using the output format below. Link the user to the automation in the UI: `https://fly.customer.io/workspaces/$ENV_ID/journeys/automations/:id/overview`\n\n## Output Format\n\n**CRITICAL: The checklist items above reference API fields so you know what to inspect. The output to the user must NEVER contain API field names, JSON values, config keys, or action IDs.** Translate everything into product language. The user is a marketer, not a developer.\n\nBanned patterns in output (examples):\n- ~~`exit_on_conversion_matched` is `false`~~ → \"People who convert will keep receiving emails — add your goal criteria as an exit condition if they should stop\" (raise this only when nothing exits people who convert; never say \"turn on 'exit on conversion'\" — no such toggle exists in the UI)\n- ~~`sending_state: \"draft\"`~~ → \"emails are set to queue draft instead of send automatically\"\n- ~~`restart_mode: none`~~ → \"re-entry is disabled\"\n- ~~`zone: \"US/Eastern\"`~~ → \"uses a fixed US/Eastern timezone instead of the profile's local time\"\n- ~~no `fallback_zone` set~~ → \"no fallback timezone set for profiles whose timezone is unknown\"\n- ~~`from_identity_id: 1`~~ → \"sender identity is configured\"\n- ~~`send_to_unsubscribed: false`~~ → \"unsubscribed users will be skipped\"\n- ~~action 56~~ → \"the time window delay\" or \"the delay after the upgrade email\"\n- ~~`conditional_wait_action`~~ → \"wait step\"\n- ~~`filters` holds `{\"type\":\"attribute\",\"field\":\"plan\",...}`~~ → \"the entry filter 'plan is free' is saved in a form the app's Filters section can't show or edit; it belongs in a segment\"\n\n### Full review\n\nStart with the one-paragraph summary from Step 2.\n\nThen present findings in each section (Structure, Technical, Timing, Content) as a **markdown table** with columns `| Item | Analysis |`. You MUST use markdown table syntax — not numbered lists, not bullet points.\n\nExample of correct output:\n\n| Item | Analysis |\n|------|----------|\n| **Entry trigger scope** | ✓ Looks correct — only users created in the last 24 hours will enter. |\n| **Converters stop receiving messages** | ⚠ Needs attention — people who complete the goal will keep receiving emails instead of exiting early. |\n| **Date math** | — Not applicable — delays use fixed durations, no date calculations. |\n\nFor each item, mark it:\n- **✓** — Looks correct. Say why.\n- **⚠** — Needs attention. Say what the problem is.\n- **—** — Not applicable. Say why.\n\nDo not skip items. A confirmed ✓ is as valuable as a ⚠.\n\nDo not hedge or soften findings. If something looks correct, say so. If something will cause a problem, say so directly.\n\nAfter the tables, end with a **Fix Before Launch** section. List each ⚠ finding as a short action item in the same plain language. When an entry filter needs moving into a segment, end the section by asking whether the user wants you to make that move.\n\n### Focused check\n\nFor focused checks, only present the relevant checklist items — do not produce the full four-section table. Answer the specific question directly, then offer to run a full review.\n\n## Fixing Issues (internal reference)\n\nWhen applying fixes from the Fix Before Launch list, use these API patterns:\n\n**Sender / reply-to** — update `from_identity_id` or `reply_to_identity_id` on each email template via `PUT .../templates/:template_id`. To apply the same identity across all emails in the automation, loop through each email action's template.\n\n**Links** — update a URL across all emails by reading each template, replacing the old URL in `body`, and writing back via `PUT .../templates/:template_id`. Always confirm the old and new URLs with the user before bulk-replacing.\n\n**Sending state** — switch actions from queue draft to send automatically via `PUT .../actions/:id`. That PUT is a full replace, not a patch: GET the action, set `sending_state` on the fetched object, delete its read-only `recipient` and `subject`, and PUT the whole thing back — a minimal `{ \"action\": { \"sending_state\": \"automatic\" } }` body fails on the required `type` and resets every field it omits. See `automations.md § Updating Individual Actions` for the flow. Note: sending state syncs across language siblings — updating one variant flips all of them.\n\n**Subscription topic** — set `subscription_topic_id` on the automation via `PUT .../campaigns/:id` with `{ \"campaign\": { \"subscription_topic_id\": \"123\", \"update_type\": \"global_settings\" } }`.\n\n**Content fixes** — update `subject`, `body`, or `preheader_text` via `PUT .../templates/:template_id`. For typo fixes and copy changes, show the user the before/after diff before saving.\n\n**Exit on conversion** — recreate the goal's criteria as an exit condition: `PUT .../campaigns/:id` with `update_type: \"exit_conditions\"`, echoing `exit_on_trigger_or_filter_not_matched` and setting `global_exit_conditions` to the goal's event/segment criteria (see `automations.md § Conversion goals` for the fields and ordering; never enable `exit_on_conversion_matched` to do this). Confirm the conversion definition with the user first.\n\n**Entry filter into a segment** — after the user says yes, follow `automation_filters.md § Segment membership is the only condition in filters`: reuse or create the segment, then save `filters` as `segments_negatable` with `update_type: \"recipients\"` and the full recipient field set.\n\n**Enable click/open tracking** — tracking settings are read-only on existing actions and must be changed in the UI. (Defaults can be set at action creation, but cannot be toggled via API after the fact.) Direct the user to update each action in the UI.\n\nFor fixes that require multiple API calls (e.g., updating sender across 6 emails), confirm the change with the user once, then apply to all actions in one pass. For structural changes (rewiring edges, adding/removing actions), see `automations.md` and `flow_control.md`.\n","automation_triggers.md":"---\nminimum_plan: essentials\n---\n\n# Automation Triggers \u0026 Filters\n\nThis file covers trigger type configuration and filter encoding for automations. For the overall automation creation flow, see `automations.md`.\n\n\u003e **Not for API-triggered broadcast send records.** A user asking about a broadcast trigger or trigger\n\u003e id means `api_triggered_broadcasts.md`, including when the request is to read or cancel one. This file\n\u003e configures what starts ordinary automation journeys; it does not inspect `/campaigns/:id/triggers`.\n\n## Setting the Trigger Type\n\n\u003e **To look up known events in a workspace:**\n\u003e - `GET /v1/environments/:environment_id/event_names` — the event name is `.events[].id`. Narrow with `--params '{\"search\": \"\u003csubstring\u003e\"}'`; the response shape, its unfilled fields and its pagination are in `workspace.md § Data Index`.\n\u003e\n\u003e **Do NOT use `/data_index/events`** — it queues a background export job and returns `204 No Content` (no data). Each call creates an unwanted export record. There is no `/events` or `/exports/events` path. Hitting `/exports/{type}` with a POST will also create an unwanted export.\n\nAll trigger updates use the automation update endpoint:\n\n```\nPUT /v1/environments/:environment_id/campaigns/:campaign_id\n```\n\nSet `update_type: \"recipients\"` and the `type` field. The examples below are minimal — they show only the trigger. On an **existing** automation a `recipients` write is a full replace: missing core keys 400, and omitted shape-specific fields are reset without warning. Before changing the trigger on an existing automation, fetch it and resend its full recipient field set — see `automation_filters.md § Saving Filter Changes` for the required keys and per-`type` rules.\n\n\u003e **You can change trigger types, but only if the automation is in a draft or stopped state.** Reconfiguring a trigger *within* its type (a different segment, a different event name) works while running. But changing `type` on a running automation returns a 409. Stop the automation and send the new type's complete recipients payload in that one call, because the switch clears the previous type's trigger fields. See `automations.md § Automation Type`.\n\n### Segment or attribute-triggered (seg_attr)\n\n\u003e **Always use `seg_attr` for new segment-triggered or attribute-triggered automations. `behavioral` is deprecated.** Even though the OpenAPI spec lists `behavioral`, never present it as an option to users and never create or migrate automations with `type: \"behavioral\"`.\n\n**Attribute/current-value trigger** — use this when the user wants people to enter when an attribute/status currently equals a value:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"seg_attr\",\n    \"trigger\": {\n      \"and\": [\n        {\n          \"attribute\": {\n            \"field\": \"onboarding_status\",\n            \"operator\": \"eq\",\n            \"value\": \"verify_email\"\n          }\n        }\n      ]\n    }\n  }\n}\n```\n\n**Existing segment trigger** — use this when the user wants membership in a specific segment to trigger entry:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"seg_attr\",\n    \"trigger\": {\n      \"and\": [\n        {\n          \"or\": [\n            {\n              \"segment\": {\n                \"id\": SEGMENT_ID\n              }\n            }\n          ]\n        }\n      ]\n    }\n  }\n}\n```\n\nThe `trigger` field is a raw JSON **object**, not a string. To trigger for people *not* in a segment or *not* matching an attribute, add `\"inverse\": true` to the `segment` or `attribute` object. You can combine multiple conditions using the `and`/`or` structure.\n\n\u003e **Common mistake — double-encoded trigger (422):** When round-tripping an automation (GET then PUT), the `trigger` value from the GET response is already a JSON object. Embed it directly in the PUT body — do not serialize it into a string. The server rejects double-encoded triggers with `422: trigger must be a JSON object`.\n\u003e\n\u003e ```jsonc\n\u003e // WRONG — trigger is a JSON string (double-encoded):\n\u003e {\"campaign\": {\"trigger\": \"{\\\"and\\\":[{\\\"segment\\\":{\\\"id\\\":4}}]}\", ...}}\n\u003e\n\u003e // CORRECT — trigger is a JSON object:\n\u003e {\"campaign\": {\"trigger\": {\"and\":[{\"segment\":{\"id\":4}}]}, ...}}\n\u003e ```\n\n**For a `seg_attr` automation, put attribute conditions directly in `trigger`; don't create a standalone segment just to trigger on them.** `trigger` already accepts attribute conditions, so the extra segment adds nothing. This is about `seg_attr` triggers only: event-triggered and other automations that use `filters` do need a segment for profile conditions (see `automation_filters.md`).\n\n**System actions created:** `exit_action` only. See `automations.md` for edge wiring.\n\n### Event-triggered (transactional)\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"transactional\",\n    \"event\": \"purchase_completed\"\n  }\n}\n```\n\nNote: this is for event-triggered *automation workflows*, not standalone transactional messages (`transactional_messages` endpoint).\n\n**Narrowing who enters.** Event-triggered automations take two separate filters, and the choice between them is about where the data lives, not the trigger type:\n\n- Conditions on the **event's own payload** go in `attribute_filters` (raw JSON object).\n- Conditions on the **profile** — `plan_name`, `country`, past behavior — go into a segment that `filters` references. See `automation_filters.md § Segment membership is the only condition in filters`.\n\nA profile attribute in `attribute_filters` is accepted with a 200 and then silently drops every trigger event, because the event payload has no such key. Read `automation_filters.md § Profile attributes vs event attributes` before building an event-triggered automation with any entry condition.\n\n\u003e **Inbound SMS replies are events too.** **Before building any inbound or keyword-triggered automation, you MUST read `inbound_messaging.md` first** — keyword grouping, the `event: \"sms\"` trigger shape, `{{event.keyword}}` branching, and reserved-keyword collisions are all covered there and are easy to get wrong from this page alone.\n\n**System actions created:** `exit_action` only. See `automations.md` for edge wiring.\n\n### Geoset-triggered (geofence enter/dwell/exit)\n\nTrigger when a device enters, dwells in, or exits a **geoset** (see `geofences.md`; feature-gated — a 400 with \"Upgrade your plan\" on `/geosets` means the account's plan doesn't include geofencing). It's authored through the generic event-trigger fields:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"transactional\",\n    \"event_type\": \"geofence\",\n    \"event\": \"enter\",\n    \"attribute_filters\": { \"field\": \"geoset_id\", \"operator\": \"eq\", \"value\": \"42\", \"inverse\": false }\n  }\n}\n```\n\n- `event` is the transition — literally `\"enter\"`, `\"dwell\"`, or `\"exit\"`, never the geoset's name. A dwell trigger uses each fence's configured threshold; SDK confirmation is best-effort and can arrive late, and no event is emitted without fresh inside evidence.\n- A dwell trigger only starts for events whose timestamp is within the last hour. Older queued events still appear in activity and segments, but do not start the automation. Within that hour, a DWELL delivered after its EXIT can still trigger the automation; it does not prove the device is still inside.\n- `event_type` **must** be `\"geofence\"`. Omitting it defaults to `\"event\"`, which builds a trigger on a *custom event* literally named \"enter\" — a silent wrong-audience bug.\n- The geoset scope is the flat `attribute_filters` equality on `geoset_id`. Resolve the geoset's name to its id via `GET /v1/environments/:environment_id/geosets` first.\n- Multiple geosets: `\"attribute_filters\": {\"or\": [ {geoset_id eq A leaf}, {geoset_id eq B leaf} ]}`.\n- Both the single-geoset and multi-geoset (`or`) shapes appear in `GET /geosets/:id/usage`: usage extraction reads *every* `geoset_id` in the filter (it descends the `or`), so each referenced geoset lists this automation. A referenced geoset therefore can't be deleted — `DELETE` 422s until the trigger is dropped (see `geofences.md` § Deleting). *Emptying* a set (removing its fences) is not guarded, though, and silently leaves the trigger with nothing to match — check the set's fences before doing that.\n- Round-trip nuance (not a usage gap): only the single-geoset shape is retained as a first-class `GeosetTrigger`; a multi-geoset `or` stays on the generic event-trigger path. Both fire and both show in usage — this only changes how the trigger is represented, so don't tell users a multi-geoset trigger is untracked.\n- **Exit after a stay over 5 minutes**: use `event: \"exit\"` and an `and` of exactly the geoset equality plus a non-inverse `gt` on `visit_duration_seconds`, in whole seconds:\n\n  ```json\n  \"event_type\": \"geofence\", \"event\": \"exit\",\n  \"attribute_filters\": { \"and\": [\n    { \"field\": \"geoset_id\", \"operator\": \"eq\", \"value\": \"42\", \"inverse\": false },\n    { \"field\": \"visit_duration_seconds\", \"operator\": \"gt\", \"value\": \"300\", \"inverse\": false }\n  ] }\n  ```\n\n  Only this shape (one geoset, a `gt`) is kept as a first-class `GeosetTrigger`. Other duration comparisons are still matched per exit event, but they stay on the generic event-trigger path. The duration is the SDK's observed length of that one visit: from the fix that detected the entry to the one that detected the exit, rounded up to whole seconds. Both detections can lag the device's movement, so the real stay can start before the recorded entry and end before the recorded exit. It is absent when the SDK had no reliable start, and absent means **unknown, not zero**. A missing, non-numeric, or negative duration fails numeric comparisons and their operator-level `inverse` forms. An outer `not` still negates the whole filter normally; do not use it to select known short visits. Nothing totals visits across entries or tells you whether the device is inside now.\n\n**System actions created:** `exit_action` only. See `automations.md` for edge wiring.\n\n### Date-triggered\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"date\",\n    \"date_triggered_attribute\": \"birthday\",\n    \"date_triggered_frequency\": \"yearly\",\n    \"date_triggered_start_time\": \"9:00\",\n    \"date_triggered_zone\": \"customer_time_zone\",\n    \"date_triggered_fallback_zone\": \"US/Pacific\"\n  }\n}\n```\n\n| Field | Type | Description |\n|---|---|---|\n| `date_triggered_attribute` | string | Profile attribute name (e.g. `\"birthday\"`) |\n| `date_triggered_frequency` | string | `\"once\"`, `\"monthly\"`, or `\"yearly\"` |\n| `date_triggered_start_time` | string | Time in `\"H:MM\"` format (e.g. `\"9:00\"`) |\n| `date_triggered_zone` | string | IANA timezone or `\"customer_time_zone\"` |\n| `date_triggered_fallback_zone` | string | Required when zone is `\"customer_time_zone\"` (e.g. `\"US/Pacific\"`) |\n| `date_triggered_lead_days` | integer | Days before the date to trigger (0 = on the day) |\n\nThere are no `date_triggered_hour`, `date_triggered_minute`, or `date_triggered_use_customer_timezone` fields.\n\n\u003e **Date attribute value format.** The attribute value can be **Unix seconds** (e.g. `1786363200`) **or an ISO-8601 date/datetime string** (e.g. `\"2026-08-10\"` or `\"2026-08-10T09:00:00Z\"`) — both parse. Only genuinely non-date values (e.g. `\"next tuesday\"`) fail. So do **not** tell a user their date campaign isn't sending \"because the value is a string.\" When a date campaign isn't firing, the cause is almost always **activation timing** (date triggers evaluate on a forward scan; profiles/objects that already existed when the automation went live need a **backfill** — `state: \"start\"` with `backfill: true`) or **delivery being off**, not the value format.\n\n\u003c!-- Dev note (not for the served agent): \"is this date campaign going to match anything, and when\"\n     is exactly the question the agent flails on here — it can't observe scheduling without turning\n     on delivery and waiting. A pre-save preview that evaluated the object date attribute + lead +\n     frequency and returned a representative match (or a reason like unparseable/no-schedulable-date)\n     would give the agent that signal directly. Exploratory; deliberately not depended on yet. --\u003e\n\n**System actions created:** `exit_action` only. See `automations.md` for edge wiring.\n\n### Object-triggered\n\n\u003e **Check for the Object trigger first.** If `object_triggers.md` is in the fly-api sub-file index, read it before building an object- or relationship-triggered automation: its state-based Object trigger (`type: \"rel_seg_attr\"`) replaces `object` + `Updated` and `relationship` + `Enter`/`Updated`, and that file lists what still needs the triggers below. If it isn't listed, the Object trigger isn't enabled in this workspace — don't mention it, and don't send `type: \"rel_seg_attr\"` (the API refuses it with a bare `400 bad request`).\n\nFires on a change to a custom object of a given type. Set `object_campaign` and an **audience** (object automations have no single triggering profile, so the audience must be related — never Self; see `automations.md`).\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"object\",\n    \"object_campaign\": { \"type\": \"Updated\", \"object_type_id\": 1 },\n    \"audience\": { \"type\": 1 }\n  }\n}\n```\n\n| Field | Notes |\n|---|---|\n| `object_campaign.type` | `Updated`, `Deleted`, or `DateTriggered`. Never `Created` (numeric `0`, also the value an omitted `type` takes). With a non-empty `object_attribute_triggers` filter, must be `Updated` (see `automations.md`). |\n| `object_campaign.object_type_id` | Integer object type ID. |\n| `audience.type` | `1` = all related profiles, `2` = certain related profiles. `0` (Self) is rejected for object automations. |\n\n\u003e **Never use `Created`. It never enrols anyone.** Live ingress only emits object updates and deletes, and backfill refuses a `Created` automation. Starting one fails with `400 object trigger type Created is not supported`, and an existing running one silently sends to no one. For \"when an object is created\", use `Updated`: creating an object counts as an update. With no `object_attribute_triggers`, `Updated` fires on creation and on every later attribute change. With one, it fires only when the filter goes from not matching to matching, so a filter the object already matched before the change (`status != deleted`) never fires on creation. It reaches the related people linked at that moment, plus anyone linked within about 15 seconds. To message a person when they're linked to an object (\"email the owner when a listing is created\" works this way too), use the Object trigger where `object_triggers.md` is listed, otherwise a **relationship-triggered** automation (below) with `Enter`.\n\n\u003c!-- Dev note: relationship-trigger config below is grounded in campaigns/configuration.pb.go\n     (RelationshipTrigger_Type: Enter=0, Exit=1, Updated=2) and ui_api/campaigns/campaign_request.go\n     (json key `relationship_campaign`). An earlier draft fabricated a \"Profile added\" enum *value* —\n     the real enum is Enter/Exit/Updated; the UI's \"Profile added\" label maps to `Enter`. --\u003e\n\n### Relationship-triggered\n\n\u003e Where `object_triggers.md` is in the fly-api sub-file index, read it first — see § Object-triggered.\n\nFires when a profile's relationship to a custom object changes — the right trigger for \"message the profile the moment they're linked to an object\" (which `object` + `Created` never does). Set `relationship_campaign` and, for the just-linked profile, `audience.type: 0` (**Self** — allowed here, unlike object automations).\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"relationship\",\n    \"relationship_campaign\": { \"type\": \"Enter\", \"object_type_id\": 1 },\n    \"audience\": { \"type\": 0 }\n  }\n}\n```\n\n| Field | Notes |\n|---|---|\n| `relationship_campaign.type` | `Enter` (=`0`, profile added — UI \"Profile added\"), `Exit` (=`1`, profile removed), `Updated` (=`2`, relationship attribute changed). Numeric forms also accepted; the fabricated `Created`/`added`/`Profile added` values are rejected with `unrecognized enum RelationshipTrigger_Type value`. |\n| `relationship_campaign.object_type_id` | Integer object type ID. |\n| `audience.type` | `0` = Self (the profile whose relationship changed — the just-linked profile). `1` = all related profiles, `2` = certain related profiles. |\n\n\u003e **`cio schema` names this field `ln`, but the served API accepts `relationship_campaign`** — use `relationship_campaign` in the request body.\n\n\u003c!-- Dev note (not for the served agent): the empty-audience failure here is silent — the agent\n     can't tell \"sent to no one\" from \"sent\" without inspecting downstream. A pre-save preview that\n     resolved the trigger + audience and returned the would-be recipients (or a reason code for an\n     empty match) would let the agent catch this class before saving instead of after a dead send.\n     Exploratory only; no such endpoint is wired into this skill yet. --\u003e\n\n**Date-triggered object campaign.** Combine `object_campaign.type: \"DateTriggered\"` with the **top-level `date_triggered_*` fields** (same fields as the profile Date-triggered section above; the attribute is an *object* attribute):\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"object\",\n    \"object_campaign\": { \"type\": \"DateTriggered\", \"object_type_id\": 1 },\n    \"date_triggered_attribute\": \"check_in_date\",\n    \"date_triggered_frequency\": \"once\",\n    \"date_triggered_start_time\": \"9:00\",\n    \"date_triggered_zone\": \"customer_time_zone\",\n    \"date_triggered_fallback_zone\": \"US/Pacific\",\n    \"date_triggered_lead_days\": 5,\n    \"audience\": { \"type\": 1 }\n  }\n}\n```\n\n### Liquid in object automation content\n\nObject automations populate **both** namespaces, and they are not interchangeable:\n\n- ✅ `{{ trigger.\u003cobject_singular_slug\u003e.attribute_name }}` — **the object that actually triggered this journey**, e.g. `{{ trigger.reservation.property_name }}`. Note the **singular** slug (`reservation`), not the plural type slug. Prefer this: it is exact.\n- ✅ `{{ objects.\u003cobject_plural_slug\u003e[0].attribute_name }}` — the recipient's related objects as an array, e.g. `{{ objects.reservations[0].property_name }}`. The array is ordered by object id descending (`GetRelationshipsByPeopleID`), so `[0]` is whichever related object of that type has the highest id — **not** necessarily the triggering one. A guest with two reservations gets the wrong one.\n- ❌ `{{ trigger.relationship_attributes.* }}`, `{{ object.* }}` — neither namespace exists; both render blank with no error. (`{{ trigger.\u003cplural_slug\u003e }}` is also wrong, except on a legacy type with no singular slug, where the key falls back to the plural.)\n- Object date attributes are timestamps → pipe a date filter: `{{ trigger.reservation.check_in_date | date: \"%B %-d, %Y\" }}`.\n\n`{{ trigger.relationship }}` and `{{ trigger.customer }}` are populated on **relationship-triggered** automations under either audience setting. With a Related audience they name the profile/relationship that fired the journey, which is the useful case; with a Self audience they resolve to the same entity as `{{ customer }}` and `{{ customer._relationship }}`. An `object`-type automation — created, updated or date-triggered — has no triggering relationship or person at all, so both render blank there; use `{{ trigger.\u003csingular_slug\u003e }}` instead.\n\nSee `recipes/liquid_syntax.md` § Trigger Data vs Object Data.\n\n### Webhook-triggered\n\nCreating one is the 3-step flow in `automations.md § Creating a webhook automation`. This section is about what the journey **sees** once the external service POSTs to the automation's webhook URL — the part `cio schema` cannot tell you, and the one most often guessed wrong.\n\n- **`{{trigger.*}}` is the POST body's top level, bound directly.** A body of `{\"email\": \"ana@example.com\", \"route\": \"/checkout\", \"order\": {\"id\": 991}}` gives `{{trigger.email}}`, `{{trigger.route}}`, `{{trigger.order.id}}`. There is **no `data` wrapper**: unless the sender itself nested a `data` object, `{{trigger.data.email}}` does not render blank — reaching into the missing `trigger.data` fails the render, and the delivery fails with `undefined variable: trigger.data` in its failure message (a missing *top-level* key, `{{trigger.missing}}`, renders blank instead). Every key is whatever the sender named it — there is no fixed schema to look up. Confirm names against a sample payload from the user or a past delivery (below) before the automation starts; while drafting, use the obvious key and say it is an assumption, not the sender's verified shape.\n- **`{{event.*}}` is the same object.** `{{event.email}}` and `{{trigger.email}}` resolve identically; prefer `trigger`. `{{event_name}}` is the automation id, not anything from the body.\n- **An empty body is `{}`.** Every top-level `trigger.*` then renders blank with no error; anything nested fails as above.\n- **The journey never has a profile.** A webhook automation does not match, look up or create a person — there is no \"matched to a profile\" mode, whatever the payload contains. `{{customer.*}}` is therefore **always** undefined here, and a blank `{{customer.email}}` is not a data-quality problem on some profile; the value has to come from `{{trigger.*}}`. `| default:` cannot conjure a recipient the journey never had.\n- **Where the recipient goes:** on the template's `recipient` field as Liquid off the body (`{{trigger.email}}`), per `automations.md § Automation type restrictions`. The delivery is anonymous: it appears in the workspace deliveries log, not on any profile.\n\n**Debugging a failed message.** Read `automation_debugging.md § Repair and Retest a Message` before editing. List deliveries for the automation (`GET .../deliveries` with `campaign_id`) and read the matching `delivery_details/:id` for its rendered content and errors. A rendered body is not the original POST payload; get a representative payload from the user if the available evidence does not include it. After saving, use the strict saved-template preview to check all message fields with the sample payload. A template read-back confirms the write, a preview confirms rendering with its supplied variables, and a delivery's status confirms how far that delivery progressed.\n\n\u003c!-- Dev note: grounded in core/vars/vars.go AddEvent — for TypeWebhookEvent both \"event\" and\n     \"trigger\" are set to convert.ForceInterface(event.Data), and core/batch/batch.go AddWebhookEvents\n     copies the Ingress_WebhookEvent body into event.Data verbatim (nil → \"{}\"). No customer is added\n     for a webhook journey (ids.Internal{} at journey start), so \"customer\" is never set. --\u003e\n\n### Other types\n\n`form` — see `automations.md` and `CampaignUpdateRequest` in the spec.\n\n**Relationship and object automation re-entry:** These automations always run at the every re-match frequency. Do not tell users to set `restart_mode` for `relationship` or `object` automations; the request value is ignored and the backend forces `rematch`. Valid `restart_mode` values for the automation types that honor it are `none`, `interval`, and `rematch`; there is no `restart_mode: \"finished\"` value. If a user asks how to prevent duplicate active journeys for a relationship-triggered automation, explain that relationship automations have built-in re-entry protection for active journeys rather than inventing an API parameter.\n\n### What happens automatically\n\nSetting the trigger creates system actions. New `seg_attr`, event, date, API-triggered, relationship, object, and form automations create an `exit_action` only. Legacy `behavioral` automations have a `filter_match_delay_action` (entry gate) + `exit_action`. Their IDs appear in the response's `workflow_action_ids` — you'll need them for edges.\n\n## Filters \u0026 Conditions\n\nFor the full filter field reference — encoding types, base64 double-encoding, exit conditions, gotchas, and save patterns — see `automation_filters.md`. The `trigger` field for segment/attribute triggers is documented in § Segment or attribute-triggered above.\n","automations.md":"---\nminimum_plan: essentials\n---\n\n# Automations\n\nAutomations are automated workflows that send messages to profiles based on triggers.\n\n\u003e **UI links:** an automation is `https://fly.customer.io/workspaces/$ENV_ID/journeys/automations/:campaign_id`; the automations list is `https://fly.customer.io/workspaces/$ENV_ID/journeys/automations`. Link the user here when you create or reference an automation.\n\n\u003e **Reviewing an automation?** Read `automation_review.md` first and follow its checklist.\n\n**Work in fewer calls:**\n\n- `GET /campaigns/:id` returns top-level `actions[]`; `/campaigns/:id/actions` does not exist.\n- Include supported content when creating actions and batch independent changes in one `add_actions` request.\n- Verify configuration once at the end.\n- Read `automation_lifecycle.md` before starting, stopping, archiving, sunsetting, unarchiving or removing an automation. These requests can return 200 without changing anything; `DELETE` returns 200 for every ID. Confirm each transition by reading its state.\n- Fix structural errors before retrying. If only the shell pipeline failed, read before repeating the write; it may have succeeded.\n- Do not link Design Studio nodes to A/B split or variant actions. Edit each variant's own message using `ab_tests.md`.\n- When the request centres on an email that doesn't exist yet, draft it in Design Studio first (`design-studio` skill), then ask only what the automation still needs. Missing events, attributes or sender don't block the build: create it, flag the gaps.\n\n## Automation Type\n\nThe `type` field determines who enters the automation. It **is** changeable after creation — `update_type: \"recipients\"` with the new `type` and that type's full recipients payload — but only while the automation is **draft or stopped**.\n\n\u003e **Any other state refuses the change with a 409** with the generic body *\"There was a conflict when saving your changes.\"* It names no field; read it as wrong state, not bad payload: stop the automation and retry. Modifying a trigger *within* its type (different segment, different event name) works while running.\n\n\u003e **Switching type clears the old trigger and keeps the workflow.** It resets every type-specific recipients field — the per-`type` list in `automation_filters.md § Saving Filter Changes` — and deletes the backing segment behind a `seg_attr`/`rel_seg_attr` automation. Actions, content, ID and reporting survive. Send the new type's full payload in that **same** call — nothing carries over.\n\n| Type | Trigger | Description |\n|------|---------|-------------|\n| `none` | Not set | Default for new automations — set the type via `update_type: \"recipients\"` |\n| `seg_attr` | Attribute or segment | Supported path for all new segment/attribute-triggered automations. Requires raw JSON `trigger`. |\n| `behavioral` | Legacy segment | Deprecated. Do not create new automations with this type; only expect it on existing legacy automations. Uses `anchors`. |\n| `transactional` | Event | Requires `event` field — for automation workflows, not standalone transactional messages |\n| `date` | Date attribute | Requires `date_triggered_*` fields |\n| `webhook` | Incoming webhook | Webhook-triggered automation (enters when an external service POSTs to the automation's webhook URL). **Not** an API-triggered broadcast. Cannot be set at create; requires a `webhook_id` — see § Creating a webhook automation. |\n| `triggered_broadcast` | API-triggered broadcast | Set directly with `update_type: \"recipients\"`, `type: \"triggered_broadcast\"` (no `webhook_id`). Firing and scheduling one: `api_triggered_broadcasts.md`. |\n| `relationship` | Relationship event | |\n| `object` | Object event | |\n| `form` | Form submission | |\n\n\u003e **Never create new `behavioral` automations.** Agent-originated API requests that create or convert automations to `behavioral` are rejected. When a user asks for a segment-triggered automation, attribute-triggered automation, or migration to enter when an attribute/status currently equals a value, use `seg_attr` with a raw JSON `trigger`. Put current-value attribute entry rules in that trigger rather than creating a standalone `attribute_change` segment just for the automation; this keeps the rule with the automation and avoids a separate segment to maintain.\n\n\u003e **Object automations require a Related audience — never Self.** An object change has no single triggering person, so set `audience.type` to `1` (all related people) or `2` (certain related people); `0` (Self) is rejected with HTTP 400. `relationship` automations may still use Self.\n\n\u003e **A related-profiles audience runs *every* profile through the *whole* workflow — branch by role, don't chain per-role messages.** With `audience.type` `1`/`2` (all/certain related profiles), each related profile — guest, host, whoever — enters and traverses every action in sequence. So chaining \"guest email → host email\" sends **both** emails to **everyone**. To send different content per role, add a `conditional_branch_action` (or `multi_split_branch_action`) up front that tests the distinguishing profile attribute (e.g. `is_host eq true`) and routes each profile to its own message, converging at the exit — see `flow_control.md`. This applies to `object` and `relationship` automations alike.\n\n\u003e **Object automations with an attribute filter must use trigger type `Updated`.** `object_campaign.type` accepts `Deleted`, `Updated`, or `DateTriggered`. If `object_attribute_triggers` has any conditions set (e.g. \"status changed to X\"), `object_campaign.type` MUST be `Updated` — the backend can only detect an attribute-value transition on an update event, not on creation or deletion. `Created` is refused: it never enrols anyone (`automation_triggers.md § Object-triggered`). Before saving, check that `object_campaign.type` matches the intent described by `object_attribute_triggers` — \"catches X changing to Y\" implies `Updated`, never `Created`.\n\n### Creating a webhook automation\n\nA webhook-triggered automation (`type: \"webhook\"`) is a **3-step** sequence, not a single create call. `webhook` is **not** an accepted `type` on `POST /campaigns` (create rejects it with `422 type must be one of [none transactional behavioral seg_attr triggered_broadcast]`), and setting `type: \"webhook\"` requires a `webhook_id`:\n\n1. **Create a draft** with `type: \"none\"` — `POST /campaigns` `{\"campaign\": {\"name\": \"...\", \"type\": \"none\"}}`.\n2. **Create the webhook resource** — `POST /campaigns/:id/webhooks` `{\"webhook\": {\"name\": \"...\"}}` → returns `{\"webhook_id\": \"...\"}`.\n3. **Set the type** — `PUT /campaigns/:id` with `update_type: \"recipients\"`, `type: \"webhook\"`, and `webhook_id` from step 2, alongside the other required recipients keys (`automation_filters.md` § Saving Filter Changes). Omitting `webhook_id` fails with `422 webhook_id can't be blank for webhook campaigns`.\n\nBefore **starting** a workflow that targets a person, confirm its recipient mapping against a sample payload. Without one, draft it fully on the obvious key (`{{trigger.email}}`), call that an assumption, and ask for a sample. For Create Event actions, read `data_operations.md § Create Event`.\n\nThen add actions with `update_type: \"add_actions\"`. The webhook exists in draft; starting the automation lets it accept entries. The campaign response exposes `webhook_id`, not `webhook_url`; find the incoming URL in the UI. An absent `webhook_url` is not a reason to restart the automation.\n\n**Liquid inside the journey:** `{{trigger.*}}` is the POST body's **top level** (no `data` wrapper), `{{event.*}}` is the same object, and `{{customer.*}}` is undefined because the journey has no profile. Read `automation_triggers.md § Webhook-triggered` before writing or debugging any Liquid on a webhook automation — the field names come from the sender's payload, not from a schema.\n\nFor an existing broken message, read `automation_debugging.md` before editing: reproduce the render failure, render the saved correction across all fields before describing the result.\n\n\u003e **`webhook` is not `triggered_broadcast`.** When the user wants an automation that runs on an *incoming* webhook (Calendly, Stripe, a partner's POST), use `type: \"webhook\"` via the 3 steps above. Do **not** fall back to `triggered_broadcast` — that's an API-triggered broadcast you fire yourself, a different product, and it lives under Broadcasts, not Automations.\n\n## update_type Field\n\nCreate a `transactional` automation first (`POST /campaigns` with `type: \"transactional\"` requires `event`, or it fails `422 event name can't be blank`), then save `filters` (segment membership only; profile conditions go in a segment, see `automation_filters.md`) and `attribute_filters` (event-payload conditions) with `update_type: \"recipients\"`. Preserve the full recipient field set below.\n\nAll updates go through `PUT /campaigns/:id`. The `update_type` field controls which fields the server processes — **fields sent with the wrong `update_type` are silently ignored**. Always include `update_type`. The conversion/exit keys — `conversion_window`, `conversion_action`, `exit_on_trigger_or_filter_not_matched`, `exit_on_conversion_matched`, `global_exit_conditions` — are only read by the `conversion`, `exit_and_conversion`, and `exit_conditions` update types; read the automation first and echo the stored values, since an omitted one is reset. Other update types (`recipients`, `add_actions`, `main`, …) neither need nor accept them as changes.\n\n| `update_type` | What it does | Key fields |\n|---------------|-------------|------------|\n| `main` | Name/description | `name`, `description` |\n| `state` | Lifecycle state | `state`, `backfill`, `scheduled_*`, `disposition` (only with `state: \"stop\"`) |\n| `recipients` | Trigger type + filters | `type`, trigger fields, `attribute_filters`, `trigger`, `filters`; legacy `behavioral` only: `anchors` |\n| `add_actions` | Add workflow actions | `edges` + top-level `actions[]` |\n| `save_action` | Update one action | `edges` + top-level `action{}` with `id`. To change only an action's own fields (a delay, a name) use `PUT /actions/:id`: it leaves the graph alone and asks for the narrower approval |\n| `delete_actions` | Remove actions | `edges` + top-level `action_ids[]`, `exit_early` |\n| `edges` | Rewire connections | `edges` |\n| `global_settings` | Subscription/limits | `send_to_unsubscribed`, `use_message_limits`, `subscription_topic_id` (a string: `\"3\"`), `message_throttle` (per-minute send rate limit — see **message_throttling.md**) |\n| `conversion` | Conversion goal only | `conversion` (required), `conversion_type`, `conversion_event_name`, `conversion_event_filters`, `conversion_segment_id`, `conversion_window`, `conversion_action` |\n| `exit_and_conversion` | Goal + exit-on-conversion flag — never to *enable* the flag; needed only to clear a goal whose flag is set (see § Conversion goals) | everything `conversion` takes, plus `exit_on_trigger_or_filter_not_matched`, `exit_on_conversion_matched` |\n| `exit_conditions` | Exit rules only | `exit_on_trigger_or_filter_not_matched`, `global_exit_conditions` |\n| `queued_disposition` | Queued deliveries on an already-stopped automation | `disposition` (`abort`, `send_now`) |\n\n`global_exit_conditions` format: array of filter objects (same DSL as segment/event filters). E.g., `[{\"segment\": {\"id\": 10}}]`. Send `[]` to clear.\n\n\u003e **Several `update_type`s rewrite their fields from the request — they are not patches.** The required shared keys guard against omission, but an explicit fresh value clears just as effectively as a missing one — `[]`, `false`, `0`, and `\"\"` all overwrite silently. Echo stored values; don't author them. Specifics:\n\u003e\n\u003e | Where | Sending a zero value does this |\n\u003e |---|---|\n\u003e | `conversion`, `exit_and_conversion` | `conversion_window: 0` drops the window; `conversion_action: \"\"` resets to `receiving` |\n\u003e | `global_settings` | omitting `subscription_topic_id` clears the topic, and omitting `message_limits_retry_window_secs` while echoing `use_workspace_message_limits_retry` zeroes the window. The remaining fields, `message_throttle` included, are pointer-guarded and safe to omit |\n\u003e | `state: \"schedule\"` | all four `scheduled_*` fields are required on `state` updates (400 if missing); `0` clears that side of the schedule |\n\n### Conversion goals\n\nSetting a goal is **one** request — don't read other automations to infer the shape.\n\n```json\n{\"campaign\": {\"update_type\": \"conversion\", \"conversion\": true,\n  \"conversion_type\": \"perform_event\", \"conversion_event_name\": \"purchased\",\n  \"conversion_window\": 604800, \"conversion_action\": \"receiving\"}}\n```\n\n- `conversion` is **required** for `conversion` and `exit_and_conversion` (422 without it). Send `false` to clear the goal; if `exit_on_conversion_matched` is true, send it `false` in the same `exit_and_conversion` request to also null the `global_exit_conditions` mirrored from the goal (echoing `true` succeeds but keeps that exit). Don't reach for `exit_conditions` to do it: that update_type rewrites every exit field from the request, so it would also wipe any `global_exit_conditions` and turn off `exit_on_trigger_or_filter_not_matched` unless you re-send both.\n- `conversion_type`: `perform_event` (requires `conversion_event_name`; `conversion_segment_id` is *not* used), `enter` or `exit` (requires `conversion_segment_id`, a positive integer as a string). Any other value is treated as the segment form — `enter_segment`/`exit_segment` are **not** valid and silently produce an \"enters segment\" goal.\n- `conversion_action`: `\"\"`/`receiving`, `opening`, or `clicking` only.\n- `conversion_window` is in seconds.\n- `conversion_event_filters` is an optional filter AND-ed with the event name. The shape is the segment DSL wrapped in `foreign_event`: `{\"and\": [{\"foreign_event\": {\"attribute\": {\"field\": \"plan\", \"operator\": \"eq\", \"value\": \"pro\"}}}]}`. Other shapes are rejected with confusing 422s: a bare segment-DSL leaf (`{\"field\": …}`) gets `unsupported filter type (\"field\")`, and most other wrong shapes get `Value looks like object, but can't find closing '}' symbol` — both mean the filter shape is wrong, not that your JSON is malformed.\n\n\u003e **Exit-on-goal: goals measure; they never exit anyone.** To stop on conversion, recreate the goal criteria in `global_exit_conditions` with `update_type: \"exit_conditions\"`. Never enable `exit_on_conversion_matched` or send the user looking for an exit-on-conversion UI toggle; neither is the supported setup. Leave the legacy flag alone when an existing automation reads it back as true. If asked whether goal + matching exit needs more, say it's complete; don't name the flag.\n\u003e\n\u003e **Plans and diagrams:** show a global exit condition separately from the action graph and say it applies throughout the journey, including during delays. Keep the message path linear. Never draw `Converted?`/`Purchased?` between messages or propose, even as an optional extra, a branch, Wait Until, or branch exit to stop later sends: the exit condition already removes them at any step. Use those actions only for real alternate paths or timing.\n\u003e\n\u003e Two facts that prevent bad diagnoses here:\n\u003e - **Conversion counting never depends on exit wiring.** The goal counts a conversion when the person matches the criteria within the attribution window — whether they exit via a recreated condition, via `exit_on_conversion_matched`, or don't exit at all.\n\u003e - **Goals are not retroactive.** Only profiles that enter after the goal is set are measured, so a freshly configured goal on a long-running automation correctly shows 0 conversions at first.\n\n**Goal + global exits (including exit-on-goal) is two requests, in this order** — `exit_and_conversion` overwrites `global_exit_conditions` from the goal (it mirrors the conversion filter in when `exit_on_conversion_matched` is true, and nulls it when turning a stored true flag off), and any `global_exit_conditions` sent alongside it is discarded. `exit_conditions` symmetrically forces `exit_on_conversion_matched: false`.\n\n1. `{\"update_type\": \"conversion\", ...}` — sets the goal, leaves global exits alone.\n2. `{\"update_type\": \"exit_conditions\", \"exit_on_trigger_or_filter_not_matched\": true, \"global_exit_conditions\": [{\"segment\": {\"id\": 10}}]}` — for exit-on-goal, make the condition the goal's own event/segment criteria.\n\nSo `exit_on_conversion_matched: true` and hand-written `global_exit_conditions` are mutually exclusive by design. `exit_on_conversion_matched: true` without a goal is ignored and saved as false, not refused. `global_exit_conditions` is rejected outright for `triggered_broadcast` and `webhook` automations.\n\n## Edge Wiring\n\nEdges define the workflow DAG. When adding, deleting, or rewiring actions, you must provide ALL edges — the list is replaced entirely.\n\n\u003e **Two common `422 invalid graph` rejections — avoid both:**\n\u003e - **`action \u003cid\u003e has 0 edges`** — every action that will exist after the save must be connected. Each non-exit action needs at least one outgoing `continue` edge (to the next action or to an `exit_action`), and each newly-added action needs an incoming edge.\n\u003e - **`Edges don't match actions`** — the edge list must reference **exactly** the post-save action set, the system `exit_action` included. No edges to/from deleted or unknown ids, and none missing.\n\n### Edge format\n\n```json\n{ \"from\": \"ACTION_ID\", \"to\": \"ACTION_ID\", \"type\": \"continue\" }\n```\n\n### System actions (auto-created)\n\nSetting the trigger type creates system actions:\n- **Legacy behavioral automations**: `filter_match_delay_action` (entry gate) + `exit_action`\n- **Seg_attr and all other supported new trigger types**: `exit_action` only\n\nSystem action IDs appear in the response's `workflow_action_ids`.\n\n### Placeholder IDs for new actions\n\nWhen adding k actions, `-k` maps to `actions[0]`, `-k+1` to `actions[1]`, ..., `-1` to `actions[k-1]`.\n\n### Wiring patterns\n\n**Legacy behavioral automations** (type=`behavioral`, not created by the agent but may be encountered when editing existing automations) — entry gate → action → exit:\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"FILTER_ACTION_ID\", \"to\": \"-1\", \"type\": \"continue\" },\n      { \"from\": \"-1\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    { \"type\": \"email_action\", \"name\": \"Welcome Email\", \"sending_state\": \"draft\" }\n  ]\n}\n```\n\n**Seg_attr/date/event/API-triggered** — no entry gate, first action is implicit entry. Wire action → exit only:\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"-1\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    { \"type\": \"email_action\", \"name\": \"Birthday Email\", \"sending_state\": \"draft\" }\n  ]\n}\n```\n\n**Multiple actions** (e.g., delay → email → exit):\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"-2\", \"to\": \"-1\", \"type\": \"continue\" },\n      { \"from\": \"-1\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    { \"type\": \"delay_time_window_action\", \"name\": \"Wait for send window\" },\n    { \"type\": \"email_action\", \"name\": \"Birthday Email\", \"sending_state\": \"draft\" }\n  ]\n}\n```\n\n### Adding actions to an existing workflow\n\nWhen the workflow already has actions, you must **rewire** an existing edge through the new action — never just add edges to a placeholder without giving it an incoming edge. A new action with no incoming edge becomes a second root and the request fails with `\"graph has more than one root\"`.\n\n**Append after the current last action** — send the **full** edge list with `LAST → EXIT` swapped for `LAST → new → EXIT`:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"FIRST_ACTION_ID\", \"to\": \"LAST_ACTION_ID\", \"type\": \"continue\" },\n      { \"from\": \"LAST_ACTION_ID\", \"to\": \"-1\", \"type\": \"continue\" },\n      { \"from\": \"-1\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    { \"type\": \"email_action\", \"name\": \"Follow-up\", \"sending_state\": \"draft\" }\n  ]\n}\n```\n\n**Insert between two existing actions A and B** — send the workflow's **full** edge list with `A → B` swapped for `A → new → B`:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"A_ID\", \"to\": \"-1\", \"type\": \"continue\" },\n      { \"from\": \"-1\", \"to\": \"B_ID\", \"type\": \"continue\" },\n      { \"from\": \"B_ID\", \"to\": \"EXIT_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    { \"type\": \"delay_seconds_action\", \"name\": \"Wait\" }\n  ]\n}\n```\n\n**`edges` replaces the entire workflow graph, not just the edges you name.** Every action missing as a `from` has its outgoing edges cleared, and graph validation then rejects the result — an omitted *ordinary* action comes back `422 action \u003cid\u003e has 0 edges`, and an action left with no incoming edge comes back `graph has more than one root`. So a partial edge list fails loudly rather than corrupting the workflow; on either 422, re-read the automation and resend the complete list. Change only the one connection: drop the original `A → B` (or `LAST → EXIT`) and add the two new edges in its place.\n\nThe **exit action is the exception** — it must have exactly zero outgoing edges, so it appears only as a `to` and never as a `from`. Adding an `EXIT → …` edge to \"complete\" the list fails with `422 exit action has 1 edges`. Branch actions have their own counts (a true/false branch needs two, a multi-split one per condition plus a default).\n\n**Triggers never appear in `edges`.** They're stored apart from the actions, so the edge list starts at the root action; an edge whose `from` is a trigger id fails with `422 from edge points to missing action`.\n\n\u003e **A failed pipe is not a failed write — GET before you re-send `add_actions`.** When the API call is wrapped in shell post-processing (`| jq …`, `$(…)` capture) and the *pipeline* errors (`jq: cannot iterate over: null`, an empty capture), the PUT itself may still have succeeded — the action now exists, you just never saw the response. Re-sending the same `add_actions` then creates a **duplicate action**. Before any retry, `GET /campaigns/:id` and check `workflow_action_ids`: if the new action already landed, wire *it* into the edges instead of adding another `-1` placeholder. The telltale that this already happened: your retry fails with `422 invalid graph: action \u003cid\u003e has 0 edges` naming an id you never wired — that IS your first attempt's action, sitting orphaned. Wire that id; do not treat it as an existing workflow step to thread a second new action through.\n\n## Action Types\n\n**Message actions** (auto-create a template): `email_action`, `push_action`, `twilio_action`, `sms_action`, `webhook_action`, `slack_action`, `in_app_action`, `inbox_action`, `whatsapp_action`, `line_action`\n\n**SMS is two of these.** `twilio_action` is legacy direct-Twilio SMS; `sms_action` is the custom-SMS channel, whose content is authored as a Design Studio node rather than on the template. Both are labelled \"SMS\", only one applies per workspace, and the workflow builder offers only that one. Pick with the `enabled_actions` check in `sms.md § Which SMS channel is this workspace on?` before you add the action; `sms_action` then continues in `sms_design_studio.md`.\n\nEverything except `email_action` and `webhook_action` depends on a channel integration the workspace may not have. Check `WORKSPACE.md`'s Enabled Channels list before you build (`channels.md § Confirm the channel is set up before building on it`) and tell the user the channel is missing, rather than shipping an automation that can never send.\n\n**Delay actions**: See `delays.md`\n\n**Flow control**: See `flow_control.md`\n\n**Data operations**: See `data_operations.md` § Data Operations\n\n**AI**: `llm_action` — see `llm_actions.md`\n\n**System** (auto-created, never create directly): `filter_match_delay_action`, `exit_action`, `trigger`\n\n### Action field notes\n\n- `sending_state`: `\"draft\"` (queue for review) or `\"automatic\"` (send immediately). API defaults to `\"automatic\"`, UI defaults to `\"draft\"`.\n- `type` and `sub_type` are **immutable** after creation — attempting to change `type` causes a 500.\n- `tracked` (link tracking) is writable on **email, SMS and Twilio** actions only, on create and update alike. Omitting it leaves the stored value alone; on create, an omitted value defaults to on. Other action types ignore it.\n- Message actions auto-create a template. **Message content and sender fields live on that template.** The action response's `recipient` and `subject` are read-only copies; never include them in any action write (`add_actions`, `save_action`, `PUT /actions/:id`); it rejects read-only fields with a 400. Other template-only fields, such as `body` or `from_address`, are rejected as unknown action fields. That response already has `template_id` — skip the GET, use it with `PUT /templates/:template_id` to update recipient, subject, and content. See `templates.md` for the editor's write path and sender identity (`from_identity_id`, not `from`).\n- **Email steps default to Design Studio — even when the user never mentions it.** A new `email_action`'s template has no `editor` until content lands on it, and a `PUT /templates/:template_id` carrying `body` turns it into a code/rich-text email. Before writing any email content, check once: `cio api /v1/environments/$ENV_ID/templates --params '{\"channel\":\"email\",\"page_size\":1}' --jq '.meta.totals'`. `parcel` above 0 (the workspace already builds emails in Design Studio) or no emails at all → author in Design Studio: create the node, link it with `target.type: \"campaign_action\"` and the new `action_id`, publish (`@skills/design-studio/journeys.md`), and never write `body` on the template. Write `subject`/`body` directly only when the user explicitly asked for a code/rich-text email, or `parcel` is 0 and the workspace's existing emails are code/rich text (`html`/`wysiwyg`); a Bee-only workspace still gets Design Studio, since the API cannot author Bee content. Full rule: `@skills/design-studio` § When to Use Design Studio vs Templates.\n- **WhatsApp steps: read `whatsapp.md` before writing content.** It goes in the template's `body` as a JSON string; a `body_json` write leaves `body` empty, 422s, and if it lands every send fails.\n- **Don't link Design Studio nodes to A/B split or variant actions.** A variant sub-action link returns 400 (no linkable default template); a parent-split link silently succeeds but writes to a template the split never sends from. Set each variant's content on its own existing message instead — a Design Studio variant (pre-split action was `editor: \"parcel\"`) by revising its existing node and republishing, otherwise a plain template via `PUT /templates/:template_id`. See `ab_tests.md` and `@skills/design-studio/journeys.md § Constraints`.\n\n### Automation type restrictions\n\nThis is a **builder restriction, not an API rejection**. The API accepts the actions below with a `200`; the workflow builder just doesn't offer them. So a `200` is not evidence the restriction is fake — but neither is the restriction a reason to tear out an action you already created that works. If you added one deliberately and it delivers, leave it and tell the user it won't be editable in the builder.\n\n- **Webhook automations** — the builder omits every per-recipient channel plus a few data actions: `email_action`, `in_app_action`, `inbox_action`, `push_action`, `twilio_action`, `sms_action`, `whatsapp_action`, `line_action`, `urban_airship_action`, `static_seg_update_action`, `collection_query_action`, `conditional_wait_action`. `slack_action` and `webhook_action` are **not** restricted — their destination is static rather than per-recipient.\n\n**Why webhook automations omit the channels, and what it takes to use one anyway.** A webhook automation has no profile, so there is no `{{customer.email}}` / `{{customer.phone}}` to send to. A message action on one therefore has to supply its own recipient from the webhook payload — set the recipient on the **template** to Liquid off `{{trigger.*}}` (e.g. `{{trigger.email}}` — the body's top-level key, never `{{trigger.data.email}}`; see `automation_triggers.md § Webhook-triggered`), the same way the API itself defaults a Slack action's channel to `{{trigger.channel}}` on a webhook automation. Such a delivery is anonymous, which has three consequences. It skips frequency caps (`checkMessageLimits` returns early on a blank internal). It does not appear on any profile's delivery history or activity log — only in the workspace-wide deliveries log, since those per-profile views resolve a customer first. And **leave `preconditions` empty on the action**: preconditions can only express attribute and segment conditions, which cannot match with no profile, so a precondition on an anonymous journey makes the action skip rather than send. If the recipient Liquid renders empty, the send fails at delivery time, not at create time.\n\n**API-triggered broadcasts have no action-type restriction** — not in the API and not in the builder. Build whatever the user asks for.\n\n## Reading an Automation's Actions\n\nAn automation and its actions are **one stored document**, so there is no action sub-collection to list. `GET /v1/environments/:environment_id/campaigns/:campaign_id` returns every action, fully expanded, in a **top-level `actions[]`** beside `campaign`:\n\n```bash\ncio api /v1/environments/$ENV_ID/campaigns/$CID --jq '.actions[] | {id, type, name, template_id}'\n```\n\nThat one call is the complete list, sub-actions (A/B and language variants) included — which `workflow_action_ids` does not list. So don't fan out `GET /actions/:id` per id, and don't reach for `/campaigns/:id/actions`, which is not a route.\n\nTwo response-shape traps:\n\n- **`.campaign.actions` does not exist.** `campaign` carries only `workflow_action_ids`, so `--jq '.campaign.actions'` yields `null` — wrong path into the response, not an automation without actions. Read `.actions` at the root.\n- **`GET /actions/:id` nests its payload under `.action`.** `--jq '{id, type, name}'` returns all-nulls and looks like a broken endpoint; use `--jq '.action | {id, type, name}'`. That route is for a bare action id with no campaign context, or to fetch the exact object to PUT back.\n\n## Updating Individual Actions\n\n```\nPUT /v1/environments/:environment_id/actions/:action_id\n```\n\nMutable fields: `name`, `sending_state`, `send_to_unsubscribed`, `delay`, `min_delay`/`max_delay`, `preconditions`, `conditions`/`multi_conditions`, `holdout`, `position`, `await_completion` (hold the journey until this message sends — see **await_completion.md**).\n\n\u003e **This PUT is a full replace of the action, not a patch — GET the action, change the one field, PUT the whole object back.** Never send a minimal body like `{\"action\": {\"sending_state\": \"automatic\"}}`. Three traps:\n\u003e\n\u003e 1. **`type` is required.** Omit it and the request fails with a generic `400 bad request` (`unknown type: \"\"`), not a field error. `type` is immutable — send back the action's *current* type.\n\u003e 2. **Omitted fields reset to their zero value.** For delivery actions, `send_to_unsubscribed`, `use_message_limits`, `holdout`, `subscription_topic_id`, and `sub_type` are applied unconditionally from the body, so omitting them silently blanks them. (`await_completion` is an exception — it is preserved when omitted, so never send `false` defensively; see **await_completion.md**. `tracked` is pointer-guarded the same way.)\n\u003e\n\u003e    The attribute arrays on webhook and `llm_action` actions are pointer-guarded too: omitting `attributes_names` preserves the action's whole attribute-write set (names, values, destinations, fallbacks) rather than deleting it. Sending `attributes_names` replaces that set wholesale, so send the parallel arrays with it — in particular `attribute_destinations`, which otherwise defaults every entry to Profile and silently converts journey-attribute writes into customer-profile ones. An explicit `\"attributes_names\": []` clears the set, which is how the UI removes attributes.\n\u003e\n\u003e 3. **`recipient` and `subject` are read-only.** The GET returns them; a PUT that echoes them fails with `400 remove read-only fields from the request; action.recipient is read-only`. Delete them and any other field it names.\n\u003e\n\u003e The flow: `GET .../actions/:id` → take `.action` → delete `recipient`/`subject` → set only the field you're changing → `PUT` the full object back under `{\"action\": {...}}`.\n\u003e\n\u003e ```bash\n\u003e BODY=$(cio api /v1/environments/$ENV_ID/actions/$AID --jq '.action' \\\n\u003e   | node -e 'const a=JSON.parse(require(\"fs\").readFileSync(\"/dev/stdin\",\"utf8\"));delete a.recipient;delete a.subject;a.sending_state=\"automatic\";console.log(JSON.stringify({action:a}))')\n\u003e cio api /v1/environments/$ENV_ID/actions/$AID -X PUT --json \"$BODY\"\n\u003e ```\n\u003e\n\u003e Flipping a message action's `sending_state` from `draft` to `automatic` is what makes a firing automation *send*; drafts otherwise pile up unsent in Message Activity.\n\n## Deleting Actions\n\nDelete via automation update (not a separate DELETE endpoint). `action_ids` and `exit_early` are **top-level fields** (outside `campaign`), while `edges` goes inside `campaign`:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"delete_actions\",\n    \"edges\": [\n      { \"from\": \"100\", \"to\": \"102\", \"type\": \"continue\" }\n    ]\n  },\n  \"action_ids\": [\"101\"],\n  \"exit_early\": false\n}\n```\n\nRead the automation first, remove edges involving the deleted actions, and send the full remaining post-delete edge list. When `add_actions`, `save_action`, `delete_actions`, or `edges` carries an `edges` list, that list replaces the whole graph, and an explicit `[]` on a multi-action automation is rejected. `edges` is required on all four for agent requests, so leaving it out is a 400 for you; other API clients that leave it out keep the graph as it is.\n\n### Branch delete safety\n\nBranch actions (`conditional_branch_action`, `conditional_wait_action`, `multi_split_branch_action`, `multi_lang_branch_action`, `random_cohort_branch_action`) cannot be deleted while their branches still lead to multiple distinct non-deleted actions. The API returns 422 with `cannot delete branch action ... its branches lead to multiple distinct non-deleted next actions ...`.\n\nValid ways to delete a branch action:\n\n1. Include the branch action and all divergent downstream branch targets being removed in the same top-level `action_ids[]`, then wire the predecessor(s) to one surviving downstream action.\n2. First modify the graph so every branch output converges to the same non-deleted action, then delete the branch action.\n\nDo **not** delete only the branch action and arbitrarily pick one branch as the replacement path unless the user explicitly asked to discard the other branch outcomes. If intent is unclear, ask the user before sending `delete_actions`. `exit_early` only controls subjects currently parked at deleted actions; it does not bypass branch-delete validation.\n\n## Multi-Language Action Variants\n\nAutomations deliver localized content through a `multi_lang_branch_action` that routes profiles to sibling delivery actions, one per language. The branch and its variants act as **one logical step**: one message with translations underneath.\n\n\u003e **For one-time sends and transactional messages, don't use this flow — translations attach to the parent directly.** See `translations.md`. The branch model only applies to automation workflows, where each action is a graph node holding one template.\n\n```\n... → multi_lang_branch → [es_variant, fr_variant, default] → exit\n```\n\n**Constraints (enforced server-side):**\n- All variants under one branch must be the same action type (all `email_action`, all `twilio_action`, etc.).\n- All variants must converge to the same downstream action.\n- The **last branch index is always the default/fallback** — it has no `multi_conditions` entry and catches anyone who didn't match an earlier language.\n- Sending state is synced across siblings — flipping one variant from draft to automatic flips all of them.\n\n### Adding language variants to an existing action\n\nTo wrap an existing default email (`DEFAULT_EMAIL_ID`, with template `DEFAULT_TEMPLATE_ID`) in a multi-language branch with a Spanish variant, do it in **one** `add_actions` request that creates the branch and the new variant together, then rewires the edges so the branch sits in front of the existing email:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"ENTER_ID\", \"to\": \"-2\", \"type\": \"continue\" },\n      { \"from\": \"-2\", \"to\": \"-1\", \"type\": \"branch\", \"index\": 0 },\n      { \"from\": \"-2\", \"to\": \"DEFAULT_EMAIL_ID\", \"type\": \"branch\", \"index\": 1 },\n      { \"from\": \"-1\", \"to\": \"EXIT_ID\", \"type\": \"continue\" },\n      { \"from\": \"DEFAULT_EMAIL_ID\", \"to\": \"EXIT_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    {\n      \"type\": \"email_action\",\n      \"id\": \"-1\",\n      \"template_id\": \"DEFAULT_TEMPLATE_ID\",\n      \"template_language\": \"es\",\n      \"name\": \"es-email\"\n    },\n    {\n      \"type\": \"multi_lang_branch_action\",\n      \"id\": \"-2\",\n      \"name\": \"language-router\",\n      \"multi_conditions\": [\"BASE64(URL_ENCODE([{\\\"field\\\":\\\"language\\\",\\\"operator\\\":\\\"eq\\\",\\\"type\\\":\\\"attribute\\\",\\\"value\\\":\\\"es\\\"}]))\"]\n    }\n  ],\n  \"copy_from_env\": \"ENV_ID\",\n  \"default_language_action_id\": \"DEFAULT_EMAIL_ID\"\n}\n```\n\n**Top-level fields you must include:**\n\n| Field | What it does |\n|-------|-------------|\n| `copy_from_env` | Workspace ID to copy templates from. Set to your current env_id to copy from the existing default action's template. |\n| `default_language_action_id` | The existing action that becomes the fallback branch. Required so the API knows which template to clone for the new variants. |\n\n**Variant action fields:**\n\n| Field | What it does |\n|-------|-------------|\n| `template_id` | The default action's template ID. The API copies this template for the new variant. |\n| `template_language` | Locale string stored on the copied template (e.g. `\"es\"`, `\"fr\"`, `\"de-CH\"`). |\n\n**Branch action fields:**\n\n`multi_conditions` is an array of filter strings, one per **non-default** branch (so N variants → N-1 conditions). Each entry uses the standard double-encoding: JSON → URL-encode → base64. See `automation_filters.md` for encoding details. Filters typically match the profile's `language` attribute.\n\n### Updating content per variant\n\nEach variant is a regular delivery action: `PUT /actions/:id` for action-level fields (name, sending_state, etc.). Set content on the variant's **own existing** message — a standard template via `PUT /templates/:template_id` (subject/body/from), a Design Studio (parcel) variant by revising its node and republishing (see `@skills/design-studio/journeys.md`). As for A/B split variants (`ab_tests.md`), never link a **new** Design Studio email node to a variant action.\n\nA Design Studio in-app step takes its languages from Design Studio: add, edit and publish them as `@skills/design-studio/in_app.md` § Languages says. A variant added here sends a copy of the default's message until then. If you can't read that file, change nothing and tell the user.\n\n### Finding variants that need translation\n\n`GET /v1/environments/:environment_id/campaigns/:campaign_id/untranslated_actions` returns the variants whose templates are missing content, to finish before launching.\n\n### Removing a variant\n\nDelete the variant action with `update_type: \"delete_actions\"`. The API drops its branch from the parent multi_lang_branch_action's `multi_conditions` and edges. Removing the last non-default variant leaves a branch with only the fallback — then you may want to delete the branch action too and rewire directly to the default.\n\n### Metrics\n\nMetrics aggregate across all language variants into the parent multi_lang_branch_action (see `automation_metrics.md § Response Metrics`).\n\n## Other Endpoints\n\n| Endpoint | Description |\n|----------|-------------|\n| `GET .../campaigns/:id/metrics` | Delivery and engagement stats |\n| `GET .../campaigns/:id/action_status` | Profile counts per action |\n| `POST .../campaigns/:id/duplicate` | Clone an automation (draft copy) |\n| `GET .../campaigns/:id/versions` | Change history |\n| `.../campaigns/:id/triggers*` | Fire, schedule and inspect an API-triggered broadcast — see `api_triggered_broadcasts.md` |\n| `GET .../recent_triggers` | Automation trigger types most recently used in this workspace, for offering them again on a new automation. Returns campaign type plus object/relationship trigger details, **not broadcast triggers** |\n| `GET .../actions/:id/variables` | Liquid variables for a template |\n| `GET .../actions/:id/tracked_responses` | Tracked response data |\n| `POST .../actions/:id/optimal_time` | AI-powered optimal send time |\n| `GET .../campaigns/:id/forms` | Forms associated with automation |\n| `GET .../campaigns/:id/untranslated_actions` | Actions missing translations |\n| `GET/POST .../campaigns/:id/notes` | Canvas sticky notes |\n| `POST .../subjects/:subject_id/force_exit` | Force a person out of a journey |\n| `POST .../create_campaign_from_template` | Create from saved template |\n| `GET .../journey_attributes` | Journey attributes (pass `customer_id` + `campaign_id`) |\n| `GET .../subjects` | List people in journeys |\n\n## Canvas sticky notes\n\n`POST .../campaigns/:id/notes` creates sticky notes drawn **on the workflow canvas** — distinct from the automation's `description` field, which is plain-text settings metadata with no canvas presence. Sticky notes help annotate the workflow to help users across the team understand its intent and flow.\n\nWrap the note under a `note` key:\n\n```json\n{\"note\": {\"content\": \"\u003cb\u003eWelcome flow\u003c/b\u003e\", \"color\": \"#FFF2B3\",\n          \"position\": {\"relative_to\": {\"id\": \"-1\", \"x\": 0, \"y\": -260}}}}\n```\n\n- `content` — HTML rich text (bold, italic, links, bullet/numbered lists)\n- `color` — optional hex; omitted → default `#FFF2B3` (yellow). Palette also: teal `#86E3E8`, purple `#D3D0F5`, green `#B8FF99`, blue `#ABDAFF`, red `#FAC8C8`, pink `#FDC1DA`, orange `#FEC8BD`, grey `#CAD6D8`.\n- `position` — **always set `relative_to.id`.** You can't see the canvas and the API doesn't expose node pixels, so let the browser place the note against a node instead of inventing absolute `x/y` (which land unpredictably). Use `\"-1\"` for the **trigger** (default — clear space above it), or any action `id` from the automation GET to pin to that step (drawn with a dashed connector). The inner `x`/`y` just nudge the note off the node — reuse `x: 0, y: -260` to sit it above, and vary `x` so multiple notes don't overlap.\n\nSkip `relative_to.id` and the note falls at `(0,0)`, where notes stack invisibly.\n\n## Listing Automations\n\n```\nGET /v1/environments/:environment_id/campaigns\n```\n\nReturns automation metadata (id, name, type, state) plus metrics. The list response does **not** include `created_at` — sorting by that field returns nulls. For age ordering use numeric `id` as a proxy (lower = older); for exact creation timestamps use the audit log.\n\nAutomation list responses include each automation's `tag_ids`. Use those automation-level IDs when bulk adding, removing, or comparing automation tags. The `excludeTags=true` query parameter only omits the top-level `tags` model collection from the response; it does not remove `campaigns[*].tag_ids`.\n\nTo find automations by tag, pass `tagIds[]=TAG_ID` or `searchTagIds[]=TAG_ID` (both accepted; there is no unbracketed alias on the handler). Through `cio api` that is `--params '{\"searchTagIds[]\": \"42\"}'` — **one tag id per call**, because the CLI sets each key once and refuses a repeat with `param key \"searchTagIds[]\" appears more than once`. Automations must have *all* supplied tag IDs to match, so for several tags at once you have to page the unfiltered list and intersect on each automation's own `tag_ids` instead. The filter applies on its own (there is no `size` param). The list returns a fixed **20 automations per page** via `page` (0-based), so one call only sees the first 20. Pass `page` and any other query value through `--params`, not in the path — `cio api` rejects a path containing `?`.\n\nFrom the MCP `cio_*` tools the bracketed key cannot be sent at all (`param key \"searchTagIds[]\" contains invalid characters`), so there the unfiltered-list-plus-client-side-match below is the only route even for a single tag.\n\n### Selecting automations by theme or category — filter by tag, never by name\n\nWhen the user names a group of automations by theme, category, or tag (\"upselling automations\", \"the onboarding ones\", \"anything tagged win-back\"), the set is defined by the **tag**, not by words in the automation name. Name matching is wrong in both directions — tagged automations frequently don't contain the word, and automations containing the word frequently aren't tagged — and it won't match what the user sees under the UI's tag filter.\n\n1. **Resolve the tag:** list the workspace's tags with `GET /v1/environments/$ENV_ID/tags` (`operations.md`) and match the user's wording to a tag `name` (case-insensitive) to get its `id`. Don't resolve it from the automations list response's top-level `tags` — that collection holds the tags of a *single* automation on the page you fetched, not the workspace vocabulary, so a real tag will often look missing. If nothing matches, say so and ask before falling back to a name search.\n2. **Filter server-side by that tag id and page through every result — don't name-match.** Query the list with `searchTagIds[]` set to the one id (see above) and read each page until one returns fewer than 20; the server then returns only the tagged automations. **Fall back to the unfiltered list only when the filter cannot be sent** — several tags at once, or the MCP `cio_*` tools — and then page it to the very end, every page until one returns fewer than 20, keeping the automations whose own `tag_ids` contain the id. Compare it **as a string**: the tag's own `id` is a number in the `tags` collection, but each automation's `tag_ids` is an array of strings, so matching the raw number silently keeps nothing and reads as \"no automations carry that tag\". Paging to the end is the other half: the trap is stopping at the first page. Either way the complete set is every automation whose `tag_ids` contain the id, across every page — including names without the word, and excluding untagged look-alikes; report exactly that, never adding or dropping entries based on names.\n\n### No matching automation? Fall back to transactional messages\n\nUsers often say \"campaign\" or \"automation\" generically to mean \"a message we send.\" When a lookup for a named/described automation (or newsletter) turns up nothing here, **don't dead-end** — the message may be a transactional message. Fall back to the Message Library (`messaging.md` § Message Library — find a message by subject or body content): `GET .../message_library_items` with the user's phrase as the base64 `search` param and **no `parents[]` filter**, so automations, newsletters, and transactional messages are all searched. That endpoint also matches on **subject and body content**, so it finds the message even when the user only remembers what it said, not its name. It does not reach an **unlinked Design Studio node**, which has no template — if the Library also comes back empty, try `/ds/node_actions/search` before telling the user nothing exists (`@skills/design-studio/nodes.md § Search`).\n\n## Common Pitfalls\n\n1. **`recipients` is a full replace**: `update_type: \"recipients\"` rebuilds the automation's entire targeting from the request body. **Always GET the automation first, then send back ALL recipient fields for its `type`, changing only the field you need.** Missing core keys 400; omitted shape-specific fields (date fields, relationship/object config) are wiped with a 200 and no warning. See `automation_filters.md § Saving Filter Changes` for the required-key list and per-`type` table.\n2. **Wrong `update_type`**: Fields sent with the wrong `update_type` are silently ignored. API returns 200 but nothing changes.\n3. **Forgetting ALL edges**: the `edges` you send with `add_actions`, `save_action`, `delete_actions`, or `edges` replace the entire edge list — including on a `save_action` that only renames a step, so send the full list. When appending or inserting into an existing workflow, you must **drop** the old edge that pointed to the next action and route through the new placeholder — otherwise the new action has no incoming edge and the API returns `\"graph has more than one root\"`. See § Adding actions to an existing workflow.\n4. **Changing an action's type**: an *action's* `type` is immutable.\n5. **Date trigger on create**: `date_triggered_*` fields only processed with `update_type: \"recipients\"`.\n6. **State gates on edits**: editing during `stopping` returns 422 (wait for `stopped`); changing the trigger `type` while running returns a generic 409 (stop it first).\n7. **`add_actions` may return 500 but succeed**: Re-read the automation before retrying.\n8. **Branch convergence**: All branches from a split must reconverge at the same downstream action. A branch is exempt only when it connects to a **dedicated exit action with no other incoming edges** — create a new `exit_action` for each exiting branch rather than reusing the automation's original exit. If you get `\"branches from action \u003cid\u003e do not converge at a common action\"`, rewire continuing branches to the same action. See `flow_control.md § Workflow Graph Rules` for the full rule, examples, and error reference.\n9. **Deleting branch actions with divergent branches**: If `delete_actions` returns `\"cannot delete branch action \u003cid\u003e\"`, do not retry the same payload. Either include the divergent downstream actions in `action_ids[]` or first converge the branch outputs to one non-deleted action. Ask the user if discarding one branch path is not explicit.\n10. **Every action needs outgoing edges**: Non-exit, non-branch actions must have exactly 1 outgoing edge. If you get `\"action \u003cid\u003e has 0 edges\"`, add the missing `continue` edge.\n11. **Multi-language branches need a fallback**: The last branch index is always the default and has no entry in `multi_conditions` — `multi_conditions` should have N-1 entries for N branches. Profiles whose language attribute doesn't match any condition fall through to that last branch. See § Multi-Language Action Variants.\n12. **Sending state syncs across language siblings**: Updating `sending_state` on one variant flips all of them. There's no way to run individual variants in different states.\n13. **Adding language variants requires `default_language_action_id` and `copy_from_env`**: Without these top-level fields, the new variants have no template to clone and the request fails. See § Multi-Language Action Variants.\n","await_completion.md":"# Wait Before Continuing Journey (AwaitCompletion)\n\nA per-message setting that holds a person at that step until the message\nactually sends, instead of moving them straight to the next action. **Off by\ndefault**, set per action, on any message channel.\n\n**It does not make the message send any sooner.** The message is already queued\nbefore the wait begins; the setting only holds the rest of the journey. If a\nuser wants a message to go out faster, this is the wrong lever.\n\n**It is not a delay and not a Wait Until.** Those are actions you add to the\nworkflow — see **delays.md** and **flow_control.md**. This is a boolean on the\nmessage action you already have, and what it waits for is that message's own\nsend finishing.\n\n## Why it exists\n\nEach channel has its own send queue, so the order of actions in a workflow has\nnever guaranteed the order messages arrive. Rate limits make that visible: an\nautomation limited to 100 emails/min followed by an SMS step at 1,000/min will\nroutinely deliver the SMS first. Turning this on at the email step keeps the\njourney in order. See **message_throttling.md** for the rate limits themselves.\n\n## UI Location\n\nThe toggle lives in the message action's own settings panel, which has no URL of\nits own. Link the user to the automation\n(`https://fly.customer.io/workspaces/$ENV_ID/journeys/automations/:campaign_id`) and tell\nthem where to click:\n\n\u003e Open the automation → select the message action → **Settings** →\n\u003e **Wait before continuing journey**\n\n## The 24-Hour Cap — Say This Out Loud\n\nA wait you switch on is bounded. The clock starts when the message is created,\nand after **24 hours** the journey continues **whether or not the message was\nsent**. The cap comes with the setting: it does not apply to the implicit\nwebhook wait described under Two Traps, which has no timer at all.\n\nThat matters most in exactly the case the setting is used for. If an automation\nis rate limited, its queue may take longer than a day to drain, and only the\nmessages that get sent inside the first 24 hours are confirmed before their\njourney moves on — everyone else advances without their message having gone out.\nRoughly, `messages_per_minute × 1440` is what fits in the window; a queue larger\nthan that will have people fall past the cap.\n\nTell the user this when they enable it on a rate-limited automation, and work\nout the rough number with them. It is not a failure state and nothing is\ndropped, but the ordering guarantee they think they are buying only holds for\nthe people who make it through inside a day.\n\n## How the Wait Ends\n\nWith the setting switched on:\n\n| Outcome | What happens |\n|---------|--------------|\n| Message sends | Journey continues immediately |\n| Message definitively fails | Journey continues immediately — there is nothing left to wait for |\n| Neither, for 24 hours | Journey continues anyway (above) |\n\nAn implicit webhook wait ends on the first two rows only — there is no timer to\nfall back on.\n\n## Reading and Setting It\n\nThe field is `await_completion` on the action.\n\n```bash\ncio api /v1/environments/$ENV_ID/actions/$AID --jq '.action.await_completion'\n```\n\nSetting it uses the standard full-replace flow for actions — fetch, change the\none field, send the whole object back. Do **not** hand-write a body: an action\n`PUT` replaces the action, and omitted fields have their own rules. See\n**automations.md** § Updating Individual Actions for the full trap list.\n\n```bash\nBODY=$(cio api /v1/environments/$ENV_ID/actions/$AID --jq '.action' \\\n  | node -e 'const a=JSON.parse(require(\"fs\").readFileSync(\"/dev/stdin\",\"utf8\"));delete a.recipient;delete a.subject;a.await_completion=true;console.log(JSON.stringify({action:a}))')\ncio api /v1/environments/$ENV_ID/actions/$AID -X PUT --json \"$BODY\"\n```\n\n## Two Traps\n\n**Omitting `await_completion` preserves it — never send `false` defensively.**\nUnlike `send_to_unsubscribed`, `use_message_limits`, `holdout`,\n`subscription_topic_id` and `sub_type`, which reset to their zero value when left\nout of an action `PUT`, this field is left alone when absent. So an update that\ndoes not mention it keeps whatever was set. Sending `false` to be safe is the one\nway to silently switch a customer's ordering guarantee off.\n\n**On a webhook that writes attributes, the wait is already on and the field\nlies.** A webhook action with attribute mappings always waits, regardless of what\n`await_completion` says — the wait is implied by the attribute writes, and the UI\nshows the toggle checked and disabled to match. The stored field can still read\n`false`. Never tell a user \"this webhook is not waiting\" because the field is\n`false`; check whether it writes attributes first. Customers have reported this\nas a bug more than once, and the answer is that the behaviour is correct and the\nfield is not the source of truth for that case.\n\nThat implicit wait is **not** bounded by the 24-hour cap — the cap is scheduled\nonly for actions with `await_completion` set. An implicit webhook wait ends when\nthe webhook succeeds or definitively fails, and nothing else moves it on, so do\nnot quote a 24-hour worst case for it.\n\n## When to Suggest It\n\nRecommend it when:\n\n- two message actions sit back-to-back with no delay, branch or condition\n  between them, and the channels have different rate limits; or\n- a webhook has to write journey or customer attributes that a later message\n  renders — without the wait the message can render before the attribute lands,\n  which produces `Liquid error: undefined variable` in the sent message.\n\nLeave it off otherwise. It is off by default deliberately: only a small\nproportion of sends sit in that exposed shape, and turning it on means the two\nmessages can no longer go out at the same time, which some users want. Do not\nenable it across an automation as a precaution.\n","billing.md":"# Billing — Plans, Usage, Invoices\n\nRead-only billing and usage information.\n\n`ordway`, `legacy_stripe`, `metronome` and `modern_stripe` are internal names — route on them, and talk to the user about their plan, not the system that bills it.\n\n## Billing Provider Detection\n\nStart here — the provider determines which endpoint serves the account's plan data. **No endpoint's response tells you the provider**, and each one is wrong in a different way:\n\n- `billing/info` has no provider gate, so an Ordway or Metronome account gets a populated 200 whose plan has fallen back to the free trial.\n- `billing/plans` has no gate either and returns a **real list of Ordway plans for any account** — plans this account isn't on. Never read a limit off it.\n- The rest just fail quietly: the Ordway-only routes answer a non-Ordway account with 200 and a `null` body, and `billing/subscription_exists` 404s for Ordway.\n\nSettle the provider from the account record below, never from which endpoint answered or what it returned.\n\n```bash\n# the response nests everything under \"account\" — read the fields off that object\ncio api /v1/accounts/$ACCOUNT_ID --jq '.account | {billing_vendor, plan_slug}'\n```\n\n`account.billing_vendor` decides it, and every account has one:\n\n| `billing_vendor` | Plan data comes from |\n|------------------|----------------------|\n| `ordway` | `.../billing/profile` |\n| `legacy_stripe` | `.../billing/info` |\n| anything else, including `metronome`, `modern_stripe` or absent | Neither — see \"When the limit isn't available\" |\n\n`modern_stripe` is the Flex plan's vendor.  `billing/info` returns its Flex plan, and the add-ons it pays for in `add_ons`, each shaped like an entry in `plans`.  Flex is pay as you go with no profile or message allotment, so a limit question takes the same path as `metronome`.\n\nDon't substitute `ordway_id` for a missing `billing_vendor`: a Metronome account has no `ordway_id` either, so that guess sends you to `billing/info` and its free-trial plan — the wrong-limit outcome this routing exists to prevent.\n\n## Plan Entitlements (profile / message limits)\n\nPlan limits live on the **subscription**. Fetch the one for the account's provider:\n\n**Ordway** — `GET /v1/accounts/:account_id/billing/profile`\n\nThe array is keyed `ordway_subscriptions` (and the customer `ordway_customer`) — there is no top-level `subscriptions`. Entry `[0]` is the currently-effective subscription **when the account has one**; for a contract that is signed but hasn't started, `[0]` is that future subscription and its charge lines aren't filtered to effective ones. Check `[0].service_start_date` isn't in the future before trusting the number, and treat a future-dated contract as no current limit.\n\n```bash\ncio api /v1/accounts/$ACCOUNT_ID/billing/profile \\\n  --jq '.ordway_subscriptions[0].plans[] | select(.product_id==\"P-00105\") | .included_units'   # profiles\n# P-00031 → messages\n```\n\nWhen `[0]` is the effective subscription its charge lines are already filtered to the effective ones, so more than one `P-00105` line means genuinely concurrent charges rather than stale data. If the filter prints several identical numbers, that's one limit — use it. Only differing values are ambiguous: don't pick one, treat the limit as unavailable and point the user at Settings → Billing → Your Plan.\n\n**Stripe** — `GET /v1/accounts/:account_id/billing/info`\n\n`plans[]` holds the current plan *and* upgrade candidates, with no flag marking which is current. Select it by matching `subscription.plan_id` to `plans[].id`:\n\n```bash\ncio api /v1/accounts/$ACCOUNT_ID/billing/info \\\n  --jq '.subscription.plan_id as $p | .plans[] | select(.id==$p) | {customers, emails_sent}'\n```\n\n`customers` is the profile allotment, `emails_sent` the message allotment. The selector works even for an account with no stored subscription — the response synthesizes one carrying the current plan's id — so trust what it returns rather than substituting a plan you expect. A Builder-pinned account legitimately resolves to the Builder plan, not a trial.\n\nReading these:\n\n- **Price units differ by provider.** Ordway `list_price` / `effective_price` are decimal dollar strings (`\"333.33\"` is $333.33/month). Stripe `plans[].price` is an integer in **cents for that plan's own billing interval** — `33333` is $333.33 per interval, which is a year on an annual plan, so check `annual` / `interval_name` before calling it monthly.\n- Only profiles and messages have stable product ids. The MAU product id differs per environment, so don't guess one — for an MAU allotment send the user to Settings → Billing → Your Plan. Objects are different: they have no allotment of their own because they consume the profile allotment (see Overage detection).\n\nTake the plan **name** for an Ordway account from `billing/profile` — `ordway_subscriptions[0].plans[].plan_name`, in the response you already fetched for the limit. For a legacy Stripe account it's the `name` of the plan you selected from `billing/info`.\n\nDon't build a plan name out of `premium` / `premium_trial` on the account record: both are true on a paid Premium account, which composes to \"Premium Trial\" and tells a paying customer they're trialling. `plan_slug` there is an id on Ordway (`PLN-00012`) and a slug on Stripe (`essentials`) — neither is a display name. WORKSPACE.md's `Plan:` line has the same defect for the same reason, so confirm the name against the subscription before repeating it back.\n\nA `metronome` account's `plan_slug` / `premium` resolve through the legacy Stripe tables too, which a paid Metronome contract has no row in — so the name can read as a trial on a paying account. Treat it as unavailable rather than repeating \"trial\" back to them.\n\nThe `*_allotted` fields on `/usage` describe the **legacy Stripe plan record**, so they answer a limit question for `legacy_stripe` accounts only. An Ordway account has no such record, and the endpoint fills the gap with free-trial defaults — or with **Builder** limits if the account is Builder-pinned — and returns 200 either way. `/usage_v2` leaves its plan fields at `0`. For a limit, use the subscription endpoints above.\n\n### When the limit isn't available\n\nBoth `billing/profile` and `billing/info` need billing-view permission, so a user without it gets a 403 on whichever path their provider uses — tell them they don't have billing access on this account, don't name the permission. The other cases where no limit comes back:\n\n- `billing/profile` returns empty while the account's `ordway_id` is exactly `TEMP-\u003caccount id\u003e` (Ordway provisioning is incomplete). A `TEMP-` value that doesn't match that form is not recognized as a placeholder and takes the real Ordway fetch path instead.\n- `ordway_subscriptions` is an empty array whenever Ordway holds no subscription for the customer — suspended, cancelled and pending-deletion accounts, but also an account that went back to free and keeps its old `ordway_id`. The rest of the profile is still served. `.ordway_subscriptions[0].plans[]` errors rather than returning nothing; treat that error as \"no limit available\", not as a malformed command to retry.\n- A subscription may carry no charge for the product you filtered on, which yields no output at all.\n- A `metronome` or `modern_stripe` account, or any `billing_vendor` value the table above doesn't cover.\n- `usage_v2` answers **400** (`no active subscription found`) when an Ordway account has no effective row in the local billing-subscriptions mirror — a different source from the empty `ordway_subscriptions` above, so neither case implies the other. It fires before any usage is served, so the objects watermark goes with it.\n- `billing/overage_estimate` sits behind billing-*edit* permission, not view — the one read route here that does — so a read-only billing role gets a 403. Its `subscription_id` param is `ordway_subscriptions[0].id`.\n\nIn all of them: report the usage counts you do have, and send the user to **Settings → Billing → Your Plan**, which displays the contracted allotment. Their account team can confirm it as well.\n\n## Usage Counts\n\n```\nGET /v1/accounts/:account_id/usage\n```\n\nThe response contains `account_usage` with:\n\n| Field | Meaning |\n|-------|---------|\n| `customers_total` | Current customer profiles, summed across the account's billed workspaces |\n| `high_watermark_customers_total` | Peak profiles during the billing period (billing is based on this) |\n| `messages_sent` | Total messages sent (all channels — email, SMS, push, etc.) in the billing period |\n| `billable_messages_sent` | Messages that count toward billing (excludes test sends, internal, etc.) |\n| `objects_total` | Object (non-person entity) usage — object **instances**, not object types. The object *type* cap is a different limit and is not an `account_usage` field; it appears per workspace as `environment_usages[].object_types_allotted` (with `object_types_total` beside it). For a type-cap question read `environment.max_allowed_object_types` — see \"Object type limits\" in `customers.md`, which also covers why an allotment of `2` can be a fallback rather than a real limit |\n| `high_watermark_objects_total` | Only a real watermark when the legacy plan record says to watermark, which for Ordway it normally doesn't. Don't reason about which case you're in — take this one from `/usage_v2`, where it's unconditional |\n| `objects_allotted` | Deprecated and wrong for Ordway (it carries the free-trial plan's value). Ignore it — objects have no separate allotment |\n| `environment_usages` | Per-workspace breakdown of profiles, messages, and objects |\n| — | **Scope check.** These totals are account-wide, and on a parent account they span its children's workspaces too — so call the figure family-wide, since the response carries no account attribution to separate them. An Ordway allotment covers one subscription, so don't compare a family-wide total against it. For one workspace, read its `environment_usages[]` entry. Profile counts cover workspaces with `billed` set and object counts those with `objects_billing` set — separate flags — while message and CDP counts cover all workspaces |\n| `customers_allotted`, `messages_allotted`, `cdp_api_calls_allotted` | The legacy Stripe plan record's limits — meaningful for `legacy_stripe` accounts. For limits generally, see \"Plan Entitlements\" |\n\n**Overage detection.** Pair the limit from \"Plan Entitlements\" with the matching count:\n\n| Resource | Count to compare against the allotment |\n|----------|----------------------------------------|\n| Profiles (Ordway) | `high_watermark_customers_total` **+** `high_watermark_objects_total`, both from **`/usage_v2`** with `start` set (below) |\n| Profiles (Stripe, `new_pipelines_objects_pricing` in the account's `feature_flags`) | `customers_total` **+** `objects_total`, or both watermarks when `plan_include_churn` is true |\n| Profiles (Stripe, without that flag) | `customers_total`, or `high_watermark_customers_total` when `plan_include_churn` is true |\n| Messages | `billable_messages_sent` |\n\n**Objects consume the profile allotment, they don't have one of their own.** Ordway meters profiles and objects as one combined quantity against the profiles charge, and Stripe does the same under the new object pricing. Comparing profiles alone understates usage — an account at 140k profiles and 20k objects against a 150k allotment is in overage while the profile figure still looks like headroom.\n\nFor an Ordway account take **both halves from `/usage_v2`**, and pass `/usage`'s window as `start`:\n\n```bash\n# the window /usage meters over — NOT the Ordway billing period\nSTART=$(cio api /v1/accounts/$ACCOUNT_ID --jq '.account.plan_period_start')\ncio api /v1/accounts/$ACCOUNT_ID/usage_v2 --params \"{\\\"start\\\": $START}\" \\\n  --jq '.account_usage | {high_watermark_customers_total, high_watermark_objects_total}'\n```\n\nBoth halves from one call, because `/usage` doesn't watermark objects for Ordway. Pass `start` here even though it is optional: it also decides which deleted workspaces count as churn, and an omitted one means the current billing month, which need not match `/usage`'s window. Never pass `0` — a zero start counts every workspace the account ever deleted, inflating the watermark into a fake overage.\n\n`plan_period_start` is the window `/usage` meters, not the Ordway billing period — using `/usage`'s window is what keeps the two payloads comparable. Don't reuse it for genuinely start-scoped fields like `messages_sent` or `monthly_active_users`. For those, omit `start` to get the account's current billing month (the vendor's own period, cut to one month on annual plans), or pass the subscription's own dates for another window (`ordway_subscriptions[0].billing_period_start_date` / `usage_reset_date`, `YYYY-MM-DD`, so convert before passing as `start`).\n\n**MAU** is not on this endpoint — `/usage` never populates `monthly_active_users`. It's computed only by `usage_v2`, and only for Ordway accounts carrying an MAU charge. For the current billing period, omit `start`. Don't reuse the overage recipe's `plan_period_start`: that is `/usage`'s window, which need not line up with the billing month.\n\n```bash\n# MAU so far this billing month\ncio api /v1/accounts/$ACCOUNT_ID/usage_v2 --jq '.account_usage.monthly_active_users'\n```\n\nWhen you have a count but no trustworthy limit, report the count on its own — it's useful by itself, and pairing it with a limit from the wrong provider's record produces a misleading overage.\n\nFor Ordway accounts, there's also:\n```\nGET /v1/accounts/:account_id/billing/overage_estimate\n```\nReturns estimated overage charges for the current period. Requires `subscription_id` param.\n\n## Wallet (Flex)\n\nFlex accounts pay from a prepaid wallet. Both routes answer 400 for any other plan.\n\n| Endpoint | Returns |\n|----------|---------|\n| `GET .../billing/wallet/usage` | The balance in dollars, with `used` for the window and `remaining` for right now, and a `meters` entry for every priced meter splitting its use between the free grant and the balance, with an `environments` list breaking that use down by workspace, highest first. Always covers the current billing period and takes no window params; `start` and `end` in the reply name its days |\n| `GET .../billing/wallet/deposits` | Purchase and goodwill deposits, newest first, with `amount` and `remaining` in dollars. Grants never appear. Params `kind` (`purchase` or `goodwill`), `source_type`, `occurred_after`, `occurred_before` (RFC3339), `page`, `limit` |\n\nMeter figures are the meter's own units, except `balance_cost`, which is dollars. Never add units to dollars.\n\nThe wallet is not a plan limit. A limit question on a Flex account still follows \"When the limit isn't available\" and ends at Settings → Billing → Your Plan.\n\n## Plan \u0026 Subscription Info\n\n| Endpoint | Provider | Returns |\n|----------|----------|---------|\n| `GET .../billing/info` | Stripe | Full billing overview: subscription, plans, pricing tiers, coupons, tax rate, usage |\n| `GET .../billing/plans` | Ordway | Available plans (self-serve checkout plans only). Param: `product_type` |\n| `GET .../billing/subscription_exists` | Stripe | Whether an active subscription exists |\n| `GET .../billing/tax_rate` | All | Tax rate lookup. Params: `country`, `state`, `city`, `zip_code` |\n| `GET .../billing/profile` | Ordway | Customer billing profile, plus the subscription charge products carrying the plan's `included_units` — the source of truth for an Ordway account's profile/message allotment |\n| `GET .../billing/payment` | Ordway | Default payment method on file |\n\n## Invoices \u0026 Receipts\n\n| Endpoint | Provider | Returns |\n|----------|----------|---------|\n| `GET .../receipts` | Stripe | Invoice list. Each has `type`: `plan`, `overages`, or `plan_and_overages` |\n| `GET .../invoice/:invoice_id` | Stripe | Single invoice/receipt with its PDF URL. Use the `invoice_id` field from the receipts list, not the receipt's numeric `id` |\n| `GET .../billing/receipts` | Ordway | Invoice list. Status: `Paid`, `Overdue`, `Unpaid`, `Partially Paid` |\n\n## Common Gotchas\n\n1. **A successful GET proves nothing about the provider.** Each route fails differently — `null` body, ungated real data, or a 404. See § Billing Provider Detection for the per-endpoint list; don't infer the vendor from a response.\n2. **Plan limits come from the subscription, not the usage payload.** `/usage` always returns 200, filling its `*_allotted` fields from the legacy Stripe plan record — which exists for `legacy_stripe` accounts. Route limit questions through \"Plan Entitlements\" so the answer matches the account's provider.\n3. **High-watermark metering is conditional.** Overage is charged on the peak count only when the plan watermarks — always for Ordway, and for Stripe only when the account's `plan_include_churn` is true. When it does, deleting profiles doesn't reduce the current period's bill; when it doesn't, billing follows the current count and deletions do. Check the flag before telling a customer either way.\n4. **Per-workspace breakdown.** The `environment_usages` array in the usage response shows which workspaces are contributing the most profiles/messages.\n","broadcast_review.md":"---\nminimum_plan: essentials\n---\n\n# Broadcast Pre-Send Review\n\n\u003e Use this skill when a user asks you to review, QA, check, or proofread a **broadcast** (a one-time send — a one-off or recurring send to a segment) before sending it. This includes full reviews, single-element checks (\"is the sender right?\", \"is there a preheader?\", \"proofread the subject\"), and content proofreads. For reviewing an automation workflow instead, see `automation_review.md`. For one-time send mechanics (channels, recipients, scheduling), see `messaging.md`. For A/B test variations, see `newsletter_ab_tests.md`.\n\nA broadcast is a single send, not a workflow — there are no actions, edges, branches, re-entry rules, or exit conditions to review. Review the audience, the one message's configuration and content, and the schedule. Do not apply the automation workflow checklist here.\n\n## Scoping the Review\n\nMatch the scope to what the user asked:\n\n**Focused check** — the user asks about a specific element (\"check the sender\", \"is there a preheader?\", \"proofread the subject line\", \"who will this go to?\"). Answer the specific question first using the relevant checklist items below. After answering, offer: *\"Would you like me to run a full pre-send review of this broadcast?\"*\n\n**Full review** — the user asks to \"review\", \"QA\", \"sanity check\", or \"check for issues\" without narrowing to a specific element. Run the complete workflow below.\n\nWhen in doubt, run the full review.\n\n## Workflow\n\n\u003e The backend only blocks sending for: unset channel or no recipients. Sender, content, unsubscribe, and tracking quality are never enforced — so a clean review here is genuinely additive.\n\nFollow these steps in order.\n\n### Step 1: Gather Broadcast Data\n\n\u003e **Read the FULL template — do not project it with `--jq`.** A review judges what's present, so a self-written `--jq` filter that omits a field (e.g. `preheader_text`) makes that field look unset and produces false \"missing\" findings. Override the \"always `--jq`\" default here: pull the whole template object. Only report a field as a gap when the API returns it empty — never when your projection simply didn't include it.\n\n- `GET .../newsletters/:id` — the broadcast: `send_state` (**read this first** — a review of a broadcast that has already gone out is a different conversation, and `scheduled_at` alone cannot tell you; see `messaging.md § One-time send states`), `waiting_for_segments` (when true, nothing has gone out yet even if `send_state` is `sending`), `channel`, audience (`filters`, `send_to_everyone`, `send_percentage`, `deduped`), `send_to_unsubscribed`, `subscription_topic_id`, `tracked`, schedule (`scheduled_at`, `timezone`, `tz_match_enabled`), and any conversion config\n- `GET .../newsletters/:id/templates` — the message content. Read each returned template **in full**: `subject`, `preheader_text`, `from_identity_id`, `reply_to_identity_id`, `body`, `body_plain`, `links`\n- `GET .../newsletters/:id/segments` — the resolved target segments\n- `GET .../newsletters/:id/preview_recipient_counts` — how many people will receive it (sanity-check the audience size before send). `GET .../newsletters/:id/counts` also returns recipient/deduped counts\n- For multi-language broadcasts: the translations attached to the parent (see `translations.md`) — confirm every language has content\n\nBroadcasts have **no** `/validate` endpoint (that is automation-only) — reason about Liquid directly from the content, using `recipes/liquid_syntax.md`.\n\n### Step 2: Summarize\n\nWrite one paragraph in plain English describing what this broadcast is and who it goes to. Confirm or correct the user's mental model before specifics. Include:\n- Channel and what the message is\n- Who receives it (which segments, or everyone) and roughly how many people\n- When it sends (immediately, scheduled, timezone-aware)\n- What the intended outcome appears to be\n\n### Step 3: Review — Audience \u0026 Delivery\n\n**Will the right people receive it at the right time?**\n\nCheck each item and mark it `✓ Looks correct`, `⚠ Needs attention`, or `— Not applicable`.\n\n1. The target audience matches the stated intent — the right segments, and filters aren't over- or under-inclusive. Confirm against the resolved segments and the recipient count.\n2. The broadcast does not target the entire workspace unless the user explicitly wants that. Judge this from `filters`, which is the audience of record — `send_to_everyone` is only a label for it and can disagree on older broadcasts. A `filters` value that decodes to zero conditions (the canonical `\"JTVCJTVE\"`, an empty string, or a bare `\"[]\"`) means everyone, whatever the flag says. Flag `true` with a real segment filter in `filters` is also worth flagging as ambiguous rather than assumed.\n3. `send_percentage` is `100` unless the user is intentionally sampling (e.g. a partial send or manual A/B). A value below 100 silently sends to only part of the audience.\n4. `deduped` is on so someone in two target segments isn't sent twice.\n5. Schedule is as intended: sending now vs. at a future `scheduled_at`. If timezone-aware delivery (`tz_match_enabled`) is on, confirm a `timezone` fallback is set for people whose timezone is unknown.\n\n### Step 4: Review — Technical\n\n**Is the message configured correctly?**\n\n6. `from_identity_id` and `reply_to_identity_id` are set on the email template — flag any missing or placeholder identity.\n7. `send_to_unsubscribed` is `false`. If `true`, confirm a `subscription_topic_id` is set and that sending to unsubscribed people is intentional. Report `— Not applicable` on `channel: \"in_app\"` — see the note under item 8.\n8. `subscription_topic_id` is set if the workspace uses subscription topics. Applies to in-app too — see the note under this item.\n\n   \u003e **In-app broadcasts ignore the global `unsubscribed` attribute, and nothing else.** A profile who unsubscribed from everything still receives them, so item 7 is `— Not applicable` there. But an assigned `subscription_topic_id` **is** enforced on in-app, so review item 8 normally: a topic restricts an in-app broadcast exactly as it restricts an email one. Never describe an in-app broadcast as honoring a global unsubscribe, and never describe an assigned topic as inert. Webhook and inbox broadcasts still honor the global attribute too, unlike their automation-action counterparts, so flag those normally. Slack is not a broadcast channel at all.\n\n9. The email `body` contains an unsubscribe link (`unsubscribe_url` or `{{unsubscribe_url}}`).\n10. No broken or placeholder links in the content — `http://example.com`, `#`, `TODO`, `FIXME`, or empty `href`.\n11. UTM parameters are attached to links where expected (`utm_source`, `utm_medium`, `utm_campaign`).\n12. Open and click tracking (`tracked`) is enabled. If it's off, flag it — the user likely wants to measure engagement.\n13. Liquid variables in `subject`, `preheader_text`, and `body` will resolve for the recipients. Flag `{{customer.*}}` references that could render blank or error when the attribute is missing, null, or an unexpected type (there is no `/validate` endpoint for broadcasts — reason from the content). Note that a broadcast has no triggering event, so `{{event.*}}` references will never resolve.\n\n### Step 5: Review — Content\n\n**Is the content ready to send?**\n\n14. **Preheader text (`preheader_text`) is present.** The preheader is the preview line next to the subject in the inbox — read the full template (Step 1) to judge this. A blank preheader is a missed opportunity, not a blocker; flag it only when actually empty.\n15. No typos or grammatical errors in the subject line, preheader, or body copy.\n16. No leftover placeholder text — `[FIRST NAME]`, `[INSERT LINK]`, `TODO`, `FIXME`, `lorem ipsum`, `xxx`, `placeholder`.\n17. Translated variants are complete — every language attached to the broadcast has content. List any language still missing content.\n18. CTAs are present where the message needs one, and all CTA links resolve to real URLs (not `#` or empty).\n\n### Step 6: Deliver Findings\n\nPresent the review using the output format below. Link the user to the broadcast in the UI: `https://fly.customer.io/workspaces/$ENV_ID/journeys/broadcasts/newsletter/:newsletter_id` (this is the one-time-send route — `.../broadcasts/broadcast/:id` is a different resource, API-triggered broadcasts).\n\n## Output Format\n\n**CRITICAL: The checklist items above reference API fields so you know what to inspect. The output to the user must NEVER contain API field names, JSON values, config keys, or IDs.** Translate everything into product language. The user is a marketer, not a developer.\n\nBanned patterns in output (examples):\n- ~~`send_to_everyone: true`~~ → \"this will send to your entire workspace, not just the selected segment\"\n- ~~`send_percentage: 40`~~ → \"only 40% of the audience will receive this\"\n- ~~`send_to_unsubscribed: false`~~ → \"unsubscribed people will be skipped\" — but on `channel: \"in_app\"` say \"people who unsubscribed from all messages will still see this, because in-app isn't covered by a global unsubscribe\" (and mention the topic separately if one is assigned, since that restriction does apply)\n- ~~`from_identity_id: 1`~~ → \"sender identity is configured\"\n- ~~`preheader_text` is empty~~ → \"no preheader (inbox preview) text is set\"\n- ~~`tracked: false`~~ → \"open and click tracking is off\"\n\n### Full review\n\nStart with the one-paragraph summary from Step 2.\n\nThen present findings in each section (Audience \u0026 Delivery, Technical, Content) as a **markdown table** with columns `| Item | Analysis |`. You MUST use markdown table syntax — not numbered lists, not bullet points.\n\nExample of correct output:\n\n| Item | Analysis |\n|------|----------|\n| **Audience** | ✓ Looks correct — targets the \"Active trial\" segment, ~2,400 people. |\n| **Preheader** | ⚠ Needs attention — no preheader (inbox preview) text is set. |\n| **Send to everyone** | — Not applicable — this targets a segment, not the whole workspace. |\n\nFor each item, mark it:\n- **✓** — Looks correct. Say why.\n- **⚠** — Needs attention. Say what the problem is.\n- **—** — Not applicable. Say why.\n\nDo not skip items. A confirmed ✓ is as valuable as a ⚠. Do not hedge — if something looks correct, say so; if something will cause a problem, say so directly.\n\nAfter the tables, end with a **Fix Before Sending** section listing each ⚠ finding as a short action item in the same plain language.\n\n### Focused check\n\nFor focused checks, only present the relevant checklist items — do not produce the full table. Answer the specific question directly, then offer to run a full review.\n\n## Fixing Issues (internal reference)\n\nWhen applying fixes, use these API patterns (see `messaging.md` for the full one-time send update model — most updates go through `PUT .../newsletters/:id` with an `update_type` discriminator):\n\n**Content fixes** (`subject`, `body`, `preheader_text`, sender/reply-to) — update the template via `PUT .../templates/:template_id`. Get the template id from `GET .../newsletters/:id/templates`. For typos and copy changes, show the user the before/after diff before saving.\n\n**Audience** — `PUT .../newsletters/:id` with `update_type: \"recipients\"`. All four booleans (`send_percentage`, `send_to_unsubscribed`, `deduped`, `use_message_limits`) are required on every recipients update — fetch the one-time send first and merge, or the omitted ones error. Send back its `subscription_topic_id` and rate fields too: the update clears a topic or rate limits it isn't given. See `messaging.md § Recipients`.\n\n**Schedule** — `PUT .../newsletters/:id` with `update_type: \"schedule\"`. Note: `send` fires immediately and overwrites a pending schedule — see the warning in `messaging.md`.\n\nLive edits to a broadcast require the account's \"Edit live data\" setting — without it these return 403 (see SKILL.md § Agent Permission Errors).\n","channels.md":"# Channels — Email Infrastructure, SMS Settings, Credentials\n\nHow to configure sending infrastructure for each channel. For workspace-wide delivery metrics, see **analytics.md**. For subscription preference management, see **subscriptions.md**.\n\n\n## Channel Credentials (Twilio, Push, In-App, Slack)\n\nChannel credentials store authentication details for messaging providers (e.g., Twilio SID/token, push certificates). These must be configured before you can send messages on the corresponding channel. They are secrets, so the user enters or rotates them on the channel's page in Workspace Settings, not in the chat. Use these endpoints to read and check what is set.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/credentials/{action_type}` | `credentials.Get` | Get credentials for a channel type |\n| PUT | `/v1/environments/{environment_id}/credentials/{action_type}` | `credentials.Upsert` | Set channel credentials |\n| DELETE | `/v1/environments/{environment_id}/credentials/{action_type}` | `credentials.Delete` | Remove channel credentials and associated identities |\n| PUT | `/v1/environments/{environment_id}/credentials/{action_type}/prepare_switch` | `credentials.PrepareSwitchover` | Validate new credentials and preview available sender identities without saving |\n| PUT | `/v1/environments/{environment_id}/credentials/{action_type}/replace` | `credentials.MapIdentities` | Switch to new credentials and map old sender identities to new ones |\n| PUT | `/v1/environments/{environment_id}/credentials/{action_type}/config` | `credentials.UpdateConfig` | Update credential configuration (e.g., callback URLs) without replacing credentials |\n\n**`action_type` `sms` is not valid on any of these routes — read or write, they all\nreturn a 400.** The custom-SMS channel keeps one credential row per vendor\nconnection, and every handler here resolves an `action_type` to a single row, so\nit could only ever address one arbitrary vendor. Use these instead:\n\n| for | use |\n|---|---|\n| reading, adding, removing or rotating a vendor connection | `/sms_providers` (below) |\n| channel settings (link shortening, smart encoding) | `PUT .../message_channels/sms/settings` (see **SMS Settings** below) |\n\nLegacy direct-Twilio workspaces are unaffected — their credential is `action_type`\n`twilio` and these routes own it.\n\n### Confirm the channel is set up before building on it\n\nPush, SMS, in-app, WhatsApp and Slack each need their integration configured first, and the server refuses a message action on a channel that isn't set up (\"the push channel is not set up in this workspace\"). Email and webhook need no third-party credential and are available as channel types everywhere — but read the email note below before you call email ready.\n\nThis gate applies to creating a message action. A segment or condition on a channel's events (`sent_action`, `twilio_*`, …) needs no channel setup: build it and say the channel has no activity yet.\n\n**`WORKSPACE.md`'s Enabled Channels list is the answer for the channels it covers, and it's already in your context** — no API call is needed. It reports email, webhook, SMS, push, in-app and WhatsApp.\n\n**It does not cover Slack, LINE or Inbox at all**, so their absence from the list says nothing about whether they're set up — don't read it as \"not configured\" and refuse. For those three, go ahead on the user's say-so and let the server answer: it rejects a message action on a channel that isn't set up, and that error is the reliable signal.\n\nDon't try to derive this from the credentials endpoint. `GET .../credentials/:action_type` takes a specific credential type (`push_apns`, `push_fcm`, `twilio`, …), never a channel name like `push`, and an unrecognised value is a 400 rather than the \"not configured\" answer you wanted.\n\nWhen the channel is missing, say so plainly, offer a channel that is enabled or point at the setup steps, and create nothing. Do not build the automation anyway and report it as ready — a push automation in a workspace without push credentials never sends, and the user has no way to tell from the answer that it's inert.\n\n#### Email is always on the list; that is not the same as ready to send\n\n`email` and `webhook` are hardcoded into the Enabled Channels list, so they appear in every workspace — including one that has never sent anything. Email has no third-party integration to check, but it does have prerequisites of its own, and the list reports neither of them. Read both before you call email ready:\n\n| Prerequisite | Check | Ready when |\n|---|---|---|\n| Verified sending domain | `GET .../domains` | at least one row with `verified: true` (DKIM, SPF and domain all pass). A Customer.io-hosted starter domain (`starter: true`) counts for test sends, but only sandbox (Builder) accounts have one |\n| Sender identity on that domain | `GET .../identities?type=email` | at least one identity whose address sits on a verified domain |\n\nAn email step doesn't need a sender to be *built*: `add_actions` creates the step and its template, and the content saves without `from_identity_id`. The two prerequisites above are what it needs before it can *send*. The one path that does require a sender up front is creating a template directly as an email template through `POST .../templates`, which fails with \"Must choose a from address\" without one.\n\nEmail's failure mode is quieter than the other channels'. Nothing rejects the action and no send-time error says \"email is not set up\"; the message leaves the workflow and the ESP rejects it, because it will not deliver mail from an unverified domain. An unchecked \"email ✓\" therefore survives into production.\n\n`verified: \"undetermined\"` on an identity is not a failure. It means the address is not on a domain in this workspace's domains list — the normal state for a workspace sending through its own SMTP (`GET .../mail_servers`), where verification is the customer's responsibility and no Customer.io-verified domain is needed. Only `\"unverified\"` is a definite no.\n\nEndpoints and field detail: § Email Infrastructure below.\n\n## Email Infrastructure\n\n### Sending Domains\n\nDomains must be configured and DNS-verified before sending email. Each domain requires DKIM, SPF, and return-path DNS records.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/domains` | `domains.List` | List all domains with DNS verification status. `?include_in_use_by=true` adds `in_use_by: {transactional, marketing}` per domain |\n| POST | `/v1/environments/{environment_id}/domains` | `domains.Create` | Add a domain. Body: `{\"domain\": \"mail.example.com\"}` |\n| GET | `/v1/environments/{environment_id}/domains/{id}` | `domains.Get` | Get a single domain. Accepts `?include_in_use_by=true` like List |\n| PUT | `/v1/environments/{environment_id}/domains/{id}` | `domains.Update` | Update a domain |\n| DELETE | `/v1/environments/{environment_id}/domains/{id}` | `domains.Delete` | Delete a domain |\n| POST | `/v1/environments/{environment_id}/domains/{id}/ip_pool` | `domains.RequestIPPool` | Request IP pool migration (dedicated, transactional, shared). Body: `{\"ip_pool\": \"...\"}` plus optional `volume` (dedicated) and `comments` (transactional) |\n| POST | `/v1/environments/{environment_id}/domains/{id}/enable_auto_tls` | `domains.EnableAutoTLS` | Enable automatic TLS for a domain and create an auto TLS domain record |\n| GET | `/v1/environments/{environment_id}/domains/{id}/entri_token` | `domains.EntriToken` | Get JWT token for Entri DNS automation (auto-configure DNS records) |\n| PUT | `/v1/environments/{environment_id}/domains/{id}/mail_server` | `domains.SetMailServer` | Pin the domain to one custom SMTP server, which then handles its sends for every sender type. Body: `{\"mail_server_id\": \"...\"}` |\n| DELETE | `/v1/environments/{environment_id}/domains/{id}/mail_server` | `domains.ClearMailServer` | Unpin the domain, returning it to `default_for` routing |\n\nA domain's `mail_server_id` is optional. When set, that SMTP server handles the domain's sends for every sender type, overriding the `default_for` routing below. When unset, the workspace-wide `default_for` routing applies.\n\nA shared IP pool is the default for most plans and is not by itself evidence of any deliverability or bounce problem. Never name it as the likely cause of bounces, recommend a dedicated IP, or send the user to support or Account Management for one unless the actual bounce reasons show sender-reputation damage — rejections citing a blocklist or the sending IP's reputation, across multiple recipient domains. Recipient-side policy rejections (e.g. 5.7.1), invalid addresses, and auth failures are unaffected by which IP sends the mail. A dedicated IP is arranged through Account Management (typically a paid or higher-tier feature), requires sustained volume plus warm-up, and can worsen deliverability for low-volume senders. When diagnosing bounces, read the actual bounce reasons first (see `recipes/analyzing_automations.md` § Deliverability Issues).\n\nPlacement within the shared pool is not a customer-requestable knob. `RequestIPPool` requests a *sender type* — dedicated, transactional, shared — not a specific set of IPs, and which shared pool a domain sends from can reflect the kind of sender it is. Never tell a user to ask support, Account Management, or the deliverability team to move them to a \"healthier\" pool or rotate them onto different IPs, and never offer a blocklist delisting request on their behalf: neither is granted on request as a matter of course — a rotation happens only when Customer.io's own analysis says it benefits the sender — so offering it sets up a refusal. Don't name individual pool IPs to a user either — it invites exactly that request. See `recipes/analyzing_automations.md` § Deliverability Issues for what to offer instead.\n\n### Link Tracking Domains (Auto TLS)\n\nAuto TLS domains provide automatic TLS certificate provisioning for link tracking (click/open tracking with your own domain) and SMS URL shortening.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/auto_tls_domains` | `auto_tls_domains.List` | List auto TLS domains. Params: `purpose` (e.g., `short_links`) |\n| POST | `/v1/environments/{environment_id}/auto_tls_domains` | `auto_tls_domains.Create` | Create auto TLS domain. Body: `{\"auto_tls_domain\": {\"domain\": \"links.example.com\", \"purpose\": \"...\"}}` |\n| GET | `/v1/environments/{environment_id}/auto_tls_domains/{id}` | `auto_tls_domains.Get` | Get a single auto TLS domain |\n| POST | `/v1/environments/{environment_id}/auto_tls_domains/{id}/verify_dns` | `auto_tls_domains.VerifyDNS` | Trigger DNS verification and certificate provisioning |\n| DELETE | `/v1/environments/{environment_id}/auto_tls_domains/{id}` | `auto_tls_domains.Delete` | Delete auto TLS domain |\n| POST | `/v1/link_domain/test` | `verify.LinkDomain` | Verify a custom link tracking domain is correctly configured (DNS, TLS, HMAC). Body: `{\"domain\": \"links.example.com\"}`. Returns `{\"http\": bool, \"https\": bool, \"signature\": bool}` |\n\n### Custom SMTP Servers\n\nUse your own SMTP servers for email delivery instead of Customer.io's default infrastructure.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/mail_servers` | `mailservers.List` | List SMTP configurations |\n| GET | `/v1/environments/{environment_id}/mail_servers/{id}` | `mailservers.Get` | Get a single SMTP configuration |\n| POST | `/v1/environments/{environment_id}/mail_servers` | `mailservers.Create` | Add an SMTP configuration |\n| PUT | `/v1/environments/{environment_id}/mail_servers/{id}` | `mailservers.Update` | Update an SMTP configuration |\n| DELETE | `/v1/environments/{environment_id}/mail_servers/{id}` | `mailservers.Delete` | Remove an SMTP configuration |\n| POST | `/v1/environments/{environment_id}/mail_servers/{id}/default/{type}` | `mailservers.SetDefault` | Set as default for a delivery type. `type`: `all`, `broadcast`, `transactional`, or `none` (remove default). Tests connectivity before applying |\n| POST | `/v1/smtp/test` | `verify.SMTPTest` | Test SMTP connectivity before saving. Body: `{\"address\": \"smtp.example.com\", \"port\": 587, \"authentication\": \"plain\", \"username\": \"...\", \"password\": \"...\"}`. Also supports `\"use_api\": true` for SendGrid API key validation |\n\n### Sender Identities\n\nIdentities define the \"From\" name and address used when sending. Templates reference identities via `from_identity_id` and `reply_to_identity_id`.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/identities` | `identities.GetIdentities` | List identities. Params: `in_use`, `type`, `hidden` (omit = both, `true` = only hidden, `false` = only non-hidden) |\n| POST | `/v1/environments/{environment_id}/identities` | `identities.Create` | Create an identity |\n| PUT | `/v1/environments/{environment_id}/identities/{id}` | `identities.Update` | Update an identity |\n| DELETE | `/v1/environments/{environment_id}/identities/{id}` | `identities.Delete` | Delete an identity |\n| POST | `/v1/environments/{environment_id}/twilio/sync` | `identities.TwilioSync` | Sync sender identities from Twilio (fetches caller IDs and persists them) |\n\n### Design Studio Email Groups\n\nEmail groups are named lists of email addresses used in Design Studio for sharing and collaboration on email designs.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/ds/email_groups` | `ds_email_groups.GetEmailGroups` | List all email groups |\n| POST | `/v1/environments/{environment_id}/ds/email_groups` | `ds_email_groups.CreateEmailGroup` | Create email group. Body: `{\"name\": \"...\", \"email_addresses\": [\"...\"]}` |\n| PUT | `/v1/environments/{environment_id}/ds/email_groups/{id}` | `ds_email_groups.UpdateEmailGroup` | Update email group. Body: `{\"name\": \"...\", \"email_addresses\": [\"...\"]}` |\n| DELETE | `/v1/environments/{environment_id}/ds/email_groups/{id}` | `ds_email_groups.DeleteEmailGroup` | Delete email group |\n\nSuppression lists (types `bounces`, `blocks`, `spam_reports`, `invalid_emails`) are documented in `operations.md` § Suppressions.\n\n## SMS Settings\n\nSettings include **smart encoding** (on by default — normalizes typography to keep messages in cheaper GSM-7 encoding) and **link shortening** (rewrites links to short tracked URLs; requires an Auto TLS domain). For how these affect message authoring, see **sms.md**.\n\n**Which route depends on the channel, and picking the wrong one silently does nothing.** The two write different stores, and each send path reads only its own:\n\n| Method | Path | Handler | Channel |\n|--------|------|---------|---------|\n| GET/PUT | `/v1/environments/{environment_id}/sms/settings` | `sms.GetSMSSettings` / `UpdateSMSSettings` | **Legacy Twilio only.** Writes the `ClientSettings.SMS` block, read by the Twilio send path |\n| GET/PUT | `/v1/environments/{environment_id}/message_channels/{channel_name}/settings` | `message_channels.GetSettings` / `UpdateSettings` | **Custom SMS** (and WhatsApp). Writes `ClientSettings.ChannelSettings[channel]`, read by the custom-SMS send path |\n\n`channel_name` is `sms` or `whatsapp`; anything else is a 400. The `PUT` replaces the stored settings for that channel and needs workspace-admin permission. A `200` from the wrong one of these is not evidence the setting took effect — settle the channel first (below), then pick the matching route.\n\n**Reading the wrong one is just as wrong, and it looks like a real answer.** The\ntwo records disagree routinely — nothing syncs them, so a custom-SMS workspace\ntypically has an empty custom-SMS record next to stale values in the legacy one,\nand `/sms/settings` there returns settings its sends never use. Three things to\nget right when reporting them:\n\n- **Smart encoding is named oppositely in the two records.** The legacy one\n  carries `smart_encoding`, the channel one `disable_smart_encoding` — so\n  `smart_encoding: false` and `disable_smart_encoding: true` both mean *off*.\n  Answer in the user's terms (\"on\"/\"off\"), never by quoting the raw field.\n- **`disable_smart_encoding` is omitted when unset**, so an absent field means\n  smart encoding is **on** — not off, and not unknown.\n- **`PUT` is a full replace**, so GET it, change the one field, and PUT the whole\n  object back; a minimal body silently clears link shortening and the short-link\n  domain. (The legacy route alone preserves `smart_encoding` when omitted.)\n\n### Two SMS channels\n\nA workspace sends SMS over **one** of two channels, and `WORKSPACE.md`'s\nEnabled Channels list reports both as plain `sms` — it does not tell them\napart. `enabled_actions` on `GET /v1/environments/{environment_id}` does:\n`twilio` for the legacy direct-Twilio channel, `sms` for the custom-SMS one.\nThe settings and authoring differences follow from that, so read\n**sms.md § Which SMS channel is this workspace on?** before building anything\nSMS.\n\nLegacy Twilio credentials go through the channel-credentials endpoints above\nwith `action_type` `twilio`, and its senders sync via `POST .../twilio/sync`.\nCustom SMS does not — its credentials live only behind these vendor endpoints,\none row per connection:\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/v1/environments/{environment_id}/sms_providers` | List SMS vendor connections (Sinch, Vonage, Infobip, Bird, Twilio-as-a-vendor). An empty list means the custom-SMS channel isn't connected |\n| POST | `/v1/environments/{environment_id}/sms_providers` | Create a vendor connection. Body is a discriminated union: `provider` plus the matching per-vendor object |\n| DELETE | `/v1/environments/{environment_id}/sms_providers` | Remove a connection. Param: `credential_id` |\n| POST | `/v1/environments/{environment_id}/sms_providers/sync` | Pull sender identities from the vendor. Body: `{\"credential_id\": ...}` |\n\nSenders synced this way are identities of type `sms`; list them with\n`GET .../identities?type=sms`.\n\n**`type` is not optional here — it defaults to `email`.** A bare\n`GET .../identities` answers with the workspace's *email* senders, which reads\nas \"this workspace has no SMS senders\" and is the easiest way to give a\nconfidently wrong answer about SMS. Pass `type=sms` for custom SMS, `type=twilio`\nfor legacy Twilio.\n\n**A missing `twilio` credential is not evidence the workspace can't send SMS.**\nA custom-SMS workspace has no `twilio` row by design — its senders hang off the\nvendor connections instead. Before telling anyone SMS isn't set up, check\n`GET .../sms_providers` and `GET .../identities?type=sms`; the credentials list\nalone cannot answer the question, and \"connect a Twilio account first\" is wrong\nadvice for a workspace already sending over Sinch, Vonage or Infobip.\n\nIn the UI these connections live under **Workspace Settings → SMS**:\n`https://fly.customer.io/workspaces/$ENV_ID/settings/actions/sms-providers` — hyphenated,\nunlike its `/settings/actions/` neighbours. That page is the custom-SMS channel\nonly; legacy Twilio credentials are not managed there. So when someone asks where\nto change or rotate an SMS credential, read `enabled_actions` first and answer for\nthe channel **this** workspace is on — link a custom-SMS workspace to that page\nand list their actual connections with `GET .../sms_providers`. Don't hand over\nboth channels' instructions and ask which one they use. Rotating a vendor key is\nsomething the user does on that page; keys and secrets don't belong in the\nchat, so don't offer to take them there.\n\nThe same applies to open-ended questions like \"what can you do with my SMS\nsetup?\" — describe **this** workspace's channel. For custom SMS that is: list\nand manage vendor connections (`/sms_providers`), sync senders from a vendor\n(`/sms_providers/sync`), read and change delivery settings\n(`/message_channels/sms/settings`), and author messages as Design Studio SMS\nnodes (**sms_design_studio.md**). Framing it as \"your Twilio numbers\" or \"sync\nfrom Twilio\" is wrong there — and stays wrong when Twilio happens to be one of\nthe vendors behind the custom-SMS channel, because it is reached through\n`/sms_providers`, not the Twilio credential routes.\n\n## Push Notifications\n\nPush credentials (APNs key / FCM service account) are secrets: the user uploads them on the push channel's page in Workspace Settings. Read them back with the channel credentials endpoints above and the push `action_type`. For authoring push notification content — title, message, deep links, images, custom data, and platform differences — see **push.md**.\n\n### Web push (browsers)\n\nFor native web push setup and credentials, see **web_push_setup.md** when available. Otherwise, use webhooks to a third-party web push provider, or in-app messages for visitors while they are on the site.\n\n## WhatsApp\n\nFor full WhatsApp documentation, see `whatsapp.md`.\n\n## In-App Messages\n\nFor full in-app messaging documentation, see `in_app.md`. For anonymous/lead broadcasts, see `anonymous_broadcasts.md`.\n\n## Common Pitfalls\n\n1. **Domain DNS verification**: After adding a domain, DNS records must be configured and verified before sending. Use the `Get` endpoint to check verification status.\n\n2. **Mail server connectivity test**: `SetDefault` tests connectivity before applying — if the SMTP server is unreachable, the request fails.\n\n3. **Auto TLS lifecycle**: After creating an auto TLS domain, you must call `VerifyDNS` to trigger DNS verification. Once verified, certificate provisioning starts automatically.\n","content.md":"# Content — Layouts, Snippets, Collections\n\n## Layouts\n\nLayouts wrap email content — they define the outer HTML structure (header, footer, brand styling) that surrounds a template's body via a `{{ content }}` tag. Layouts only apply to email templates using the **code** or **rich text** editors. Design Studio (Parcel) and Bee editor templates do not use layouts.\n\n**Key rules:**\n- The layout `body` must contain `{{content}}` — this is where the template body gets inserted\n- The body must be valid Liquid syntax\n- Only one layout can be the default at a time — setting a new default unsets the old one\n- Layouts can be archived (hidden from the list but still viewable with `?archived=true`)\n- A Design-Studio-only workspace (past the classic editors sunset) has retired email layouts: creating, editing, restoring or setting a default layout returns 400 \"Email layouts have been retired in this workspace.\" Archiving and deleting still work. Don't offer to build a layout there; put branding in the Design Studio email.\n- Deleting a layout fails if it's used by an automation email, an unsent one-time send, a transactional message, or an automation draft, even one on an archived campaign. Check `in_use` on the layout first: the `/actions`, `/newsletters` and `/transactional_messages` lists show only live use, not drafts\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/layouts` | `layouts.GetLayouts` | List layouts. Params: `archived`, `starters` |\n| POST | `/v1/environments/{environment_id}/layouts` | `layouts.CreateLayout` | Create a layout. Body: `{\"layout\": {\"name\": \"...\", \"body\": \"\u003chtml\u003e{{content}}\u003c/html\u003e\"}}` |\n| GET | `/v1/environments/{environment_id}/layouts/{id}` | `layouts.Get` | Get a layout |\n| PUT | `/v1/environments/{environment_id}/layouts/{id}` | `layouts.UpdateLayout` | Update a layout. Body: `{\"layout\": {\"name\": \"...\", \"body\": \"...\"}}` |\n| DELETE | `/v1/environments/{environment_id}/layouts/{id}` | `layouts.DeleteLayout` | Delete a layout |\n| POST | `/v1/environments/{environment_id}/layouts/{id}/default` | `layouts.SetDefaultLayout` | Set this layout as the workspace default |\n| POST | `/v1/environments/{environment_id}/layouts/{id}/archived` | `layouts.ToggleArchived` | Archive or unarchive a layout |\n| GET | `/v1/environments/{environment_id}/layouts/{id}/newsletters` | `layouts.GetNewsletters` | List one-time sends using this layout |\n| GET | `/v1/environments/{environment_id}/layouts/{id}/actions` | `layouts.GetActions` | List automation actions using this layout |\n| GET | `/v1/environments/{environment_id}/layouts/{id}/transactional_messages` | `layouts.GetTransactionalMessages` | List transactional messages using this layout |\n\n\n## Snippets\n\nSnippets are reusable content blocks (e.g., a standard footer or legal disclaimer) that can be inserted into templates via Liquid tags.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/snippets` | `snippets.GetSnippets` | List snippets. Params: `page`, `limit`, `search`, `sort`, `asc` |\n| POST | `/v1/environments/{environment_id}/snippets` | `snippets.UpsertSnippet` | Create a snippet. Body: `{\"snippet\": {\"name\": \"...\", \"value\": \"...\"}}` |\n| GET | `/v1/environments/{environment_id}/snippets/{id}` | `snippets.GetSnippet` | Get a snippet |\n| PUT | `/v1/environments/{environment_id}/snippets/{id}` | `snippets.UpsertSnippet` | Update a snippet. Body: `{\"snippet\": {\"name\": \"...\", \"value\": \"...\"}}` |\n| DELETE | `/v1/environments/{environment_id}/snippets/{id}` | `snippets.DeleteSnippet` | Delete a snippet |\n| GET | `/v1/environments/{environment_id}/snippets/{id}/usage/{continuation}` | `snippets.GetSnippetUsage` | Get where this snippet is used (pagination via continuation token) |\n| GET | `/v1/environments/{environment_id}/object_type_snippets` | `snippets.GetSnippetsForObjectTypes` | List snippets associated with object types |\n\n\n## Collections\n\nCollections are structured data tables (like a product catalog or event schedule) stored independently from profiles. They're useful for shared, dynamic content that changes independently of the template — product catalogs, course lists, event schedules, etc.\n\n**CRITICAL — Collections are NOT directly accessible in Liquid.** There is no `collections[1].items` or `collections.\u003cname\u003e` Liquid object. You cannot use `{% for item in collections[1].items %}` or any similar pattern. To use collection data in a message, a **Collection Query action** (`collection_query_action`) must be added to the automation workflow *before* the message action. The query stores results in a profile or journey attribute, which the template then references via `{{ journey.\u003cattribute_name\u003e }}`. See `data_operations.md` § Collection Query for action configuration.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/collections` | `collections.Index` | List collections with schema |\n| POST | `/v1/environments/{environment_id}/collections` | `collections.Create` | Create a collection. Body: `{\"collection\": {\"name\": \"...\", \"schema\": {...}}}` |\n| GET | `/v1/environments/{environment_id}/collections/{id}` | `collections.Get` | Get a collection |\n| PUT | `/v1/environments/{environment_id}/collections/{id}` | `collections.Update` | Update a collection. Body MUST include `collection.update_type`; valid values are `name`, `description`, `s3_import`, `sheet`, `displayed_attributes`, `clear_error`. Example: `{\"collection\": {\"update_type\": \"name\", \"name\": \"...\"}}` |\n| DELETE | `/v1/environments/{environment_id}/collections/{id}` | `collections.Delete` | Delete a collection and all its data |\n| GET | `/v1/environments/{environment_id}/collection_items` | `collections.CollectionItems` | List rows. Params: `collection_id` (required), `order_by`, `asc`, `conditions` (base64), `page`, `limit` |\n| POST | `/v1/environments/{environment_id}/collections/query` | `collections.Query` | Query collection data |\n| POST | `/v1/environments/{environment_id}/collections/sign` | `collections.GenerateSignature` | Generate a signed URL for uploading collection data |\n| GET | `/v1/environments/{environment_id}/collections/{id}/usage` | `collections.GetCollectionUsage` | Campaigns whose actions query this collection (incl. archived) — see § Collection usage. 404s for an unknown id |\n| GET | `/v1/environments/{environment_id}/collections_usage?collection_ids=1,2` | `collections.GetCollectionsUsage` | Batch usage keyed by collection id; **omit the param for every collection in the workspace** |\n\n**Collection update_type quick reference:**\n- `name` — rename the collection\n- `description` — update the description\n- `s3_import` — start a new file-backed import using `s3_import`\n- `sheet` — start a new Google Sheet import using `sheet.spreadsheet_id` + `sheet.sheet_id`\n- `displayed_attributes` — save the visible columns preference\n- `clear_error` — clear an existing import error\n\nMissing or unknown `update_type` values return `422`.\n\n### Collection usage\n\nA collection is referenced in exactly one way: a **Collection Query action** naming it by id. Nothing else in a workspace references a collection — not newsletters, transactional messages, segments, or Design Studio, and not Liquid (see the CRITICAL note above). So \"where is this collection used?\" always answers with campaigns and their query actions.\n\nThe response groups by campaign, with the referencing actions nested:\n\n```json\n{ \"usage\": { \"campaigns\": [\n  { \"id\": \"412\", \"name\": \"Welcome series\", \"type\": \"behavioral\",\n    \"state\": \"running\", \"updated\": 1754870400, \"archived\": false,\n    \"referenced_in\": [\"query_collection_action\"],\n    \"actions\": [\n      { \"id\": \"9981\", \"name\": \"\", \"attribute_name\": \"plan_tier\", \"state\": \"running\" }\n    ] }\n]}}\n```\n\n- **`name` on an action is usually empty** — the campaign editor does not require a name for a Collection Query action. `attribute_name` (the attribute the query writes to) is always set, so describe an action as `name || attribute_name`.\n- The campaign's `state` and each action's `state` are different: actions have their own lifecycle (`running` / `paused` / `evaluating`), and a running action inside a stopped campaign is not executing.\n- Ids are strings, not numbers.\n- `referenced_in` is an array with one member today (`query_collection_action`); it exists so a future reference kind is additive.\n- **Archived campaigns are included** and sort after active ones. They still count as usage — an archived campaign can be unarchived, so deleting the collection underneath it would break it.\n- API-triggered broadcasts are campaigns, so they appear here too with `type: \"triggered_broadcast\"`.\n\nThe batch endpoint tolerates an unknown id (empty campaign list) rather than failing the whole request, since a list view may hold an id that was just deleted. The single-collection endpoint 404s instead.\n\n`DELETE` on a collection any campaign still queries returns `422` with one `collection id: used by campaign \u003cid\u003e` message per referencing campaign — a campaign is named once even if several of its actions query the collection. Check usage before offering to delete.\n\n### Collection Rows (Upload Workflow)\n\nAdding data to a collection is a multi-step process — signing a URL alone does not create rows. You MUST complete all steps:\n\n1. **Sign** — Get a signed upload URL:\n```\nPOST /v1/environments/:environment_id/collections/sign\nBody: { \"type\": \"application/json\", \"size\": \"1024\" }\n```\nThe `type` field must be `\"text/csv\"` or `\"application/json\"`. The `size` field is the file size in bytes as a string. Returns `s3` and/or `gcs` upload signature objects.\n\n2. **Upload** — Send the collection data to the signed URL. The data format depends on the collection schema (JSON or CSV).\n\n3. **Verify** — Confirm the rows were created:\n```\nGET /v1/environments/:environment_id/collection_items?collection_id=5\n```\n\nThe sign endpoint only generates a URL. The data is not stored until you upload to that URL.\n\n\n### Using Collection Data in Automations\n\nCollections are NOT directly accessible in Liquid. To use collection data in messages, a `collection_query_action` must be added to the automation workflow BEFORE the message action. It queries the collection and stores results in a journey attribute, which the template then references via `{{ journey.\u003cattribute_name\u003e }}`.\n\n`collections[1].items`, `collections.\u003cname\u003e`, and any direct Liquid reference to collections do not exist and will render as empty/error.\n\n## Assets\n\nAssets are images and files uploaded by the user for use in email templates and messages. They're stored in cloud storage and served via CDN.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/assets` | `assets.Search` | Search assets by name |\n| GET | `/v1/environments/{environment_id}/assets/list` | `assets.List` | List assets with pagination |\n| POST | `/v1/environments/{environment_id}/assets` | `assets.Create` | Create an asset record after upload |\n| POST | `/v1/environments/{environment_id}/assets/sign` | `assets.Sign` | Get a signed upload URL for uploading a file |\n| PUT | `/v1/environments/{environment_id}/assets/{id}` | `assets.Update` | Update an asset (rename, move folder, or set alt text) |\n| PUT | `/v1/environments/{environment_id}/assets` | `assets.UpdateMany` | Bulk update assets (move to folder, etc.) |\n| DELETE | `/v1/environments/{environment_id}/assets` | `assets.Delete` | Delete assets |\n| PUT | `/v1/environments/{environment_id}/assets/{id}/restore` | `assets.Restore` | Restore a deleted asset |\n\n**Upload workflow:** Call `POST .../assets/sign` to get a signed URL, upload the file directly to that URL, then call `POST .../assets` to create the asset record with the resulting URL.\n\n## Design Studio Nodes\n\n\u003e **Design Studio has its own skill — read `@skills/design-studio` first.** It owns the full route list\n\u003e (`nodes.md`), the component markup, and the publish flow. The rows below are the few node endpoints that\n\u003e come up while working on content; everything Design Studio is `cio api`, never `curl`.\n\n**Looking for an email by its content?** Use the Design Studio full-text search, not the Message Library:\n`GET /v1/environments/{environment_id}/ds/node_actions/search`, query param `search_query` passed as JSON\n(`--params '{\"search_query\": \"welcome\"}'`). The Message Library indexes message *templates* — the content\nattached to an automation, one-time send, transactional message, or in-app broadcast — so a Design Studio node\nthat is not linked to a Journeys destination has no template and never appears there. (Once it is linked and\npublished, its rendered body is written to the template and becomes findable — unless the Library's own default\nexclusions hide it anyway; see `messaging.md § Message Library`.) Searching the Library for an\nunlinked node returns an empty result that reads like \"no such email exists\". See\n`@skills/design-studio/nodes.md § Search`.\n\nDesign Studio nodes (email designs) also support feedback comments for collaboration. Comments can be threaded\nwith replies and resolved.\n\nThe endpoints below are the migrated ui_api surface.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/ds/node_actions/search` | `ds_nodes.SearchNodes` | Full-text search across Design Studio nodes. Params: `search_query`, `parent_id`, `allowed_types` |\n| GET | `/v1/environments/{environment_id}/ds/node_actions/linkable` | `ds_nodes.GetLinkableNodes` | Nodes that can be linked to a Journeys action. Params: `offset` (required), `limit` (required), `type`, `search_term`, `node_type` |\n| GET | `/v1/environments/{environment_id}/ds/recently_edited` | `ds_nodes.GetRecentlyEdited` | List recently edited Design Studio nodes |\n| GET | `/v1/environments/{environment_id}/ds/nodes/{node_id}/feedback/comments` | `ds_nodes.GetFeedbackComments` | List comments on a node |\n| POST | `/v1/environments/{environment_id}/ds/nodes/{node_id}/feedback/comments` | `ds_nodes.CreateFeedbackComment` | Add a comment to a node |\n| DELETE | `/v1/environments/{environment_id}/ds/nodes/{node_id}/feedback/comments/{comment_id}` | `ds_nodes.DeleteFeedbackComment` | Delete a comment |\n| GET | `/v1/environments/{environment_id}/ds/nodes/{node_id}/feedback/comments/{comment_id}/replies` | `ds_nodes.GetFeedbackCommentReplies` | Get replies to a comment |\n| POST | `/v1/environments/{environment_id}/ds/nodes/{node_id}/feedback/comments/{comment_id}/resolve` | `ds_nodes.ResolveFeedbackComment` | Resolve a comment thread |\n| POST | `/v1/environments/{environment_id}/ds/node_actions/bulk_move` | `ds_nodes.BulkMoveNodes` | Move multiple nodes to a different folder |\n| POST | `/v1/environments/{environment_id}/ds/node_actions/bulk_delete` | `ds_nodes.BulkDeleteNodes` | Delete multiple nodes |\n","customers.md":"# Profiles, Objects \u0026 Relationships\n\nFor segment management, see `segments.md`.\n\nA value that has to be produced per profile from their data (a summary, score, classification or written line) comes from an LLM action in an automation (`llm_actions.md`), which recomputes it for each person as they pass through. Writing it onto profiles yourself gives a one-off snapshot.\n\n## Creating a Profile\n\n```\nPOST /v1/environments/:environment_id/customers\n```\n\n```json\n{\n  \"customer\": {\n    \"attributes\": {\n      \"id\": \"user_123\",\n      \"email\": \"user@example.com\",\n      \"first_name\": \"Jane\",\n      \"plan\": \"premium\"\n    }\n  }\n}\n```\n\nThe `id` and `email` fields inside `attributes` are identifiers. Which are required depends on the workspace's identifier configuration. All other fields become profile attributes.\n\n### Updating a profile\n\n```\nPUT /v1/environments/:environment_id/customers/:customer_id\n```\n\nSame body format. The `customer_id` path parameter can be a `cio_id` as raw hex without the `cio_` prefix (e.g. `abc123`, not `cio_abc123`) — preferred since every profile has one — or an external ID (e.g. `user_123`).\n\n### Subscription management\n\n```\nPOST /v1/environments/:environment_id/customers/subscribe\nPOST /v1/environments/:environment_id/customers/unsubscribe\n```\n\n### Merging profiles\n\n```\nPOST /v1/environments/:environment_id/customers/merge/preview\n```\n\nPreview first to see what the merge will do, then:\n\n```\nPOST /v1/environments/:environment_id/customers/merge\n```\n\n**A merge needs a capability grant, and it destroys the secondary profile.** Request `merge:profile:\u003csecondary_internal_id\u003e` before the call — the same treatment segment deletion gets, for the same reason: it is asynchronous and cannot be undone. Once approved, the grant travels with your next `cio api` call by itself; don't add it as a `-H` header (`cio api` has none). In a workspace with agent live-data edits turned off it returns `403 agent_editing_disabled`, which is the admin's setting rather than a platform limit — say so and stop, don't route around it with raw curl.\n\n### Searching for profiles\n\n**First check the request is actually a search.** A rule that describes an audience — \"everyone who has a phone number on file\", \"customers who don't have a first name set\", \"people whose plan is gold or platinum\", \"people whose email contains gmail.com\", \"customers on the paid plan who aren't in the US\" — is a segment, even with no mention of the word. Build it (`segments.md`, `segment_conditions.md`) and report the segment.\n\nThe `email` param below is a strong pull in the wrong direction here: \"whose email contains gmail.com\" describes an ongoing audience, so it wants an `attribute_change` condition on `email` with `contains`, not a one-off `?email=%40gmail.com*` lookup. (The \"no `contains` on `email`\" warning further down is about the profile-search `filters` param only — segment conditions take `contains` on `email` fine.) An empty result is not a reason to switch to searching, either: a rule with no members today is still the rule they asked for.\n\nA profile search answers a question about *particular people* or *how many*: \"show me\", \"list\", \"how many\", \"find the profile for\", \"who is\". Those get the endpoints below.\n\nThe failure mode to avoid is answering an audience request with a profile table and then asking \"would you like me to create a segment from this?\" — the user asked for the thing you didn't build, the count is capped by the page size anyway, and it costs a round trip. When a rule is genuinely ambiguous, build the reading you'd defend and say which one you took.\n\n\u003e **Every `%40` on this page is what the finished URL looks like — do not type it yourself.** Pass the raw address inside `--params` (`'{\"email\": \"john@example.com\"}'`) and let `cio api` encode it. Pre-encoding sends a literal `%2540`, so the value never splits on `@`, the domain comes back empty, and the lookup returns nothing — indistinguishable from \"no such profile\".\n\n| Goal | How |\n|------|-----|\n| Find by exact email | `GET .../customers?email=john%40example.com` |\n| Find all at a domain | `GET .../customers?email=%40example.com*` |\n| Find by name | Discover the name attributes from the data index, then `GET .../customers?filters=...` with those attributes `eq` (base64-encoded — see § Filtered People List URLs below) |\n| Find by attribute | `GET .../customers?filters=...` (base64-encoded — see § Filtered People List URLs below) |\n\n\u003e **⚠️ There is no name/free-text search param — `email` does NOT match names, and there is no `query` param.** Name attributes are NOT reserved: a workspace stores names however it set them up (`first_name`/`last_name`, `name`, `full_name`, `fname`, etc.). Don't assume `first_name`/`last_name` exist. First list the workspace's attributes via the data index (`GET .../attributes` — see `workspace.md`), pick the name-like fields, then search with `filters` using `eq` on those (e.g. a first-name field `eq John` plus a last-name field `eq Smith`). `eq` is an exact match (case-insensitive) — no partial, prefix, or nickname matching, so `eq John` won't hit a profile stored as `Johnny`. If a combined name search comes back empty, retry with each part alone (last name is usually the most distinctive) before concluding the person doesn't exist. Do NOT fall back to `email=` guesses (e.g. `email=john@`); that only matches the email local-part, so a person whose email doesn't start with their name is unfindable that way.\n\n**`email` param behavior:**\n\nThe `email` value is split on `@` into a name part and a domain part. A trailing `*` on either part acts as a wildcard (SQL `LIKE` prefix match). Without `*`, matching is exact.\n\n- `?email=john%40example.com` — exact match on name and domain\n- `?email=%40example.com*` — all people at that domain (wildcard recommended — catches edge cases an exact match may miss)\n- `?email=john*%40example.com` — all people whose email name starts with \"john\" at example.com\n- `?email=john*` — all people whose email name starts with \"john\" at any domain\n- `?email=example` (no `@`, no `*`) — returns 0 results; does **not** do substring matching\n- **Do not use `filters` with a `contains` operator on `email`** — it returns a 500 error. Use `?email=%40domain.com*` for domain lookups instead.\n\n### Other profile endpoints\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET .../customers` | List/search. Params: `asc`, `page`, `email`, `filters` (base64-encoded filter — see § Filtered People List URLs below). 50 per page; no `per_page`; `sort` is accepted but never applied |\n| `DELETE .../customers/:id` | Delete a single profile |\n| `POST .../customers/delete` | Bulk delete |\n| `POST .../customers/unsuppress` | Remove suppression from profiles |\n| `GET .../customers_suppression_count` | Count of suppressed profiles |\n| `DELETE .../customers/:id/devices` | Remove specific device tokens from a profile. Body: `{\"devices\": [\"\u003ctoken\u003e\", ...]}`; only the listed tokens are removed. Without a body (or with an empty list) it returns 204 and removes nothing |\n| `GET .../customers/:id/optouts` | SMS opt-out status |\n| `PUT .../customers/:id/optouts` | Update SMS opt-outs |\n| `POST .../customers/exports` | Export profile data |\n\n### Filtered People List URLs\n\nThe `filters` query parameter is a base64-encoded, URL-encoded JSON string. Encoding pipeline: **JSON → URL-encode → base64-encode**.\n\nFilter JSON structure:\n```json\n{\"and\": [\n  {\"attribute\": {\"field\": \"ATTR_NAME\", \"value\": \"ATTR_VALUE\", \"operator\": \"OPERATOR\"}}\n]}\n```\n\nOperators — this endpoint supports ONLY these (and their `!`-prefixed inverses):\n\n| Operator | Meaning |\n|----------|---------|\n| `eq` / `!eq` | attribute equals / does not equal a value |\n| `exists` / `!exists` | attribute is set / is unset |\n| `email_valid` | email passes format validation |\n\n\u003e **⚠️ The profile list is bitmap-indexed, so only equality-style operators are supported.** Range/substring operators like `gt`, `lt`, `contains`, `bw`, `timestamp_gt`, `timestamp_lt`, `timestamp_gta`, `timestamp_lta` will 400 (legacy format) or 500 (modern format) — they don't exist in the bitmap index. If the user wants to filter by timestamp range, substring, or numeric comparison, **create a dynamic segment instead** (see `data_driven_segments.md`) and then link to that segment's member list. Do NOT try to express range queries in this URL.\n\u003e\n\u003e **That is for an audience or a list of people, not for analysis.** A share, coverage or trend by signup date — \"what % of people added last week have a timezone\", \"how has that changed over time\", \"last 7 days vs a week 60 days ago\" — is one warehouse query: `profile_changes.md` Ex 8 (attribute coverage by signup cohort). Never build temporary segments to measure cohorts; each costs a create plus build polling, and they stay in the workspace. The share across the whole base today is two profile counts (`meta.pagination.total` with and without the attribute filter). Fall back to segments only when the workspace has no warehouse access — that example isn't available to you, or the query returns 403 — and even then report the overall share first and ask before creating any; if the user agrees, create every cohort's segments in one bash call (a loop of creates), poll them together, and tell them the segments remain in the workspace. A 500 or 503 is a warehouse error, not missing access: report the overall share, say the cohort part could not be computed right now, and offer to retry — never switch to segments over it.\n\nMultiple conditions (AND):\n```json\n{\"and\": [\n  {\"attribute\": {\"field\": \"state\", \"value\": \"Minnesota\", \"operator\": \"eq\"}},\n  {\"attribute\": {\"field\": \"plan\", \"value\": \"premium\", \"operator\": \"eq\"}}\n]}\n```\n\n**Pagination:** 50 profiles per page, fixed. Only `page` moves through the list — `per_page` and `size` are ignored without an error. The count is `meta.pagination.total`, and you page until `page * 50 \u003e= total`: deleted and suppressed profiles are dropped after the page is cut, so a short page does not mean the last page. `filters` cannot be combined with `email` (400). An `email` search returns `total: -1` only when it matches more than 2,000 people. It retains the first 2,000 rows in the email index, then sorts them by profile ID. Pages 1–40 return those rows; later pages are empty. Narrow the email filter instead of requesting later pages.\n\n\u003e **⚠️ `sort` does nothing on this list.** The handler reads it and the query never applies it — rows come back in index order, with `asc` only reversing them. This holds for every request shape, filtered or not, so don't answer \"the newest/oldest N profiles\" from this endpoint; read the ids and order them yourself on an attribute. Deep paging in the default `desc` order also windows from the live total, so a shifting population re-serves or skips rows.\n\n**Filter by segment membership:** `{\"and\": [{\"segment\": {\"id\": 61, \"inverse\": false}}]}`, or the equivalent legacy array `[{\"type\": \"segments_negatable\", \"segments\": [61], \"negate\": false}]` — either one encoded the same way. This is how you read the profiles in a segment; there is no `/segments/:id/customers` endpoint. See `segments.md` § Reading Profiles in a Segment.\n\n`filters` is a query value, so it goes through `--params` as **JSON**. (`--params` does accept a `filters=$F` query string, but standard base64 emits `+`, and there a bare `+` is refused as ambiguous — you would have to write it `%2B` yourself. The JSON object encodes it for you.) A `--params` error is never a reason to reach for `curl`:\n\n```bash\nF=$(echo -n '[{\"type\":\"segments_negatable\",\"segments\":[61],\"negate\":false}]' | base64 -w 0)\ncio api /v1/environments/$ENV_ID/customers --params \"{\\\"filters\\\":\\\"$F\\\"}\" --jq '.meta.pagination.total'\n```\n\n---\n\n## Objects \u0026 Relationships\n\nObjects represent non-person entities (companies, accounts, orders) that can be related to profiles. They're organized by **object types**. When someone asks about an account, company, or order, they might be asking about an object.\n\nObjects are identified by their type and internal ID. The object type is an integer ID; the name is the common name for the object type (like \"Company\" or \"Order\").\n\n### Object vs event vs profile attribute\n\nWhen a user describes some *thing* in their business (\"orders\", \"subscriptions\", \"properties\", \"tickets\") and asks you to model it, pick deliberately. Getting this wrong is expensive to undo — objects and events cannot be converted into each other after the data lands.\n\n| Model it as | When |\n|---|---|\n| **Profile attribute** | One current value that belongs to the person and gets overwritten as it changes — `plan`, `city`, `lifecycle_stage`. No history, no payload. |\n| **Event** | Something that *happened* at a moment and never changes afterwards — `order_placed`, `page_viewed`, `trial_started`. Carries a payload, triggers automations the instant it arrives, and is the only one of the three whose payload the message can read directly — as `{{event.*}}` (note: `{{event.*}}`, not `{{trigger.*}}` — the trigger namespace carries a *webhook* or broadcast payload, not ordinary event data). |\n| **Object** | A separate entity with its own attributes and its own lifecycle, that **several people can share** or that **one person can have many of** — a company, a reservation, an order you need to track through states. Reached from a person through a relationship. |\n\nAsk these in order:\n\n1. **Does it need to change state after it's created?** An event can't be updated. If the user says \"when the order ships / when the reservation is cancelled\", it's an object (or an attribute), not just an event.\n2. **Can one person have several at once, or can several people share one?** A profile attribute holds exactly one value, so \"each guest can have multiple reservations\" or \"everyone at the company sees it\" rules attributes out — that's an object.\n3. **Does it just need to fire a journey and be referenced in the message once?** Then an event is enough, and cheaper. Don't create an object type for something the user only sends about once.\n\nCombinations are normal and usually correct: an `order_placed` **event** fires the journey while an `order` **object** holds the state the later messages read. Don't force one model to do both jobs.\n\nThree constraints that should shape the decision up front:\n\n- **An object has no recipient.** Messaging about an object requires a relationship to a profile — the object automation sends to the *related person*. An object with no relationships can never be messaged.\n- **Only 10 objects per type are addressable in Liquid** (`{{objects.\u003cplural_slug\u003e[0..9]}}`) — the 10 related objects with the highest ids, not the 10 most recently linked. If a person routinely has more than that and the message must reference a specific one, drive it from an object-triggered automation and use `{{trigger.\u003csingular_slug\u003e}}` — see `automation_triggers.md § Liquid in object automation content`.\n- **Object types are a capped, per-workspace resource.** Each one you add spends a slot from a cap that varies per account, and a type created past it is silently disabled — see § Object type limits below before creating one. Object *instances* also consume the account's profile allotment (see `billing.md`), so a concept modelled as an object where an attribute would do is billable as well as wasteful.\n\n### Object type limits\n\n\u003e ⚠️ **A workspace has a cap on active object types, and the number varies per account.** Plan defaults are 2 or 10 (a 10 here is unrelated to the 10-objects-per-type Liquid limit above), but per-account overrides exist and can be higher. **Read it, don't assume it** — `GET /v1/environments/:environment_id` returns `environment.max_allowed_object_types` alongside `object_types[]`, so one call gives you both the cap and the current count. **`max_allowed_object_types: 0` means unlimited, not zero** — never report it as \"no object types allowed\".\n\u003e\n\u003e **Past the cap, creating a type does not fail.** `POST .../object_types` returns **200 with `enabled: false`** — deliberate, not a bug. **Always check `enabled` on the create response** rather than treating 200 as success, and tell the user when a type came back disabled: it is not a working object type and the workspace won't behave as they expect. If you decide not to create at all, say what would actually happen rather than \"I can't create it\" — the create succeeds and hands back a half-live type that accepts writes but renders blank in Liquid and drops its columns from profile exports. That is the reason to free a slot first, and the user needs it to weigh the choice.\n\u003e\n\u003e **The cap is not the only cause of `enabled: false`.** An account with no effective subscription — suspended, cancelled, pending deletion, or a contract that hasn't started — is allotted no object types at all, so *every* create comes back disabled while `max_allowed_object_types` still reports a fallback `2`. A workspace can therefore show free slots and disable everything you create. If a create returns `enabled: false` while the enabled count is under the cap, that's this, not the cap: say the subscription is the blocker instead of hunting for a type to delete.\n\u003e\n\u003e What `enabled: false` actually costs — the type is half-live, which is why it's so easy to miss:\n\u003e\n\u003e | Still works | Does not work |\n\u003e |-------------|---------------|\n\u003e | Object and relationship writes are accepted and indexed | **Liquid personalization** — `{{objects.\u003cslug\u003e...}}` resolves to blank |\n\u003e | The type appears in `GET .../object_types` (with `enabled: false`) | **Profile exports** drop its object columns (a direct `objects/exports` for the type still works) |\n\u003e | | Its objects are left out of object usage counts, so `/usage` under-reports them |\n\u003e\n\u003e Don't tell a user a disabled type is safe to build on because data lands — messages personalized off it render blank.\n\u003e\n\u003e Only *enabled* types count toward the cap, so the way to make room is to disable (`PUT .../object_types/:id` with `enabled: false`) or delete an existing type. Both are blocked while the type is in use by a segment, campaign, newsletter, transactional message, snippet, or API-triggered broadcast — and **disable is the stricter of the two**: a type referenced only from a template blocks the disable but not the `DELETE`. If a disable 422s and you can't find a segment or campaign behind it, check templates. Re-enabling via `PUT` is rejected with a 422 when the workspace is already at the cap: the create path is the only one that degrades silently. **You do not have to probe for the blocker.** `GET .../object_types` carries a `usage` object on every type, so one call gives both the enabled list and what each type would cost to give up. Keep `usage` in the projection — the habitual `{id, name, enabled}` drops it and leaves you asking the user to choose blind:\n\u003e\n\u003e ```\n\u003e cio api /v1/environments/$ENV_ID/object_types \\\n\u003e   --jq '.object_types[] | select(.enabled) | {id, name, usage}'\n\u003e ```\n\u003e\n\u003e Zeros across `segment_count`, `campaign_count`, `newsletter_count`, `transactional_count`, `snippet_count` and `apitb_count` make a type the best candidate, not a safe one: a template-only Liquid reference blocks the disable without showing in any of those counts. Anything non-zero 422s for certain; all-zero can still 422, and templates are the thing to check when it does.\n\u003e\n\u003e **A 422 saying usage \"cannot be fully verified in this workspace\" is a dead end, not a retry.** A handful of workspaces have too many templates to scan within a request, so both disable and delete fail closed there rather than trust a truncated usage answer — meaning no slot can be freed through the API at all. Say so and stop; renames fail the same way. Don't re-issue the call or try the other verb.\n\nBefore creating a type, check the budget:\n\nWhen that check comes back full, the finding to report is not just \"no slots left\". Creating anyway is what a user would try next, and it looks like it worked — 200, the type in the list — while personalization renders blank and profile exports lose its columns. Lead with that, then the list of what could be freed.\n\n```bash\n# object_types is a TOP-LEVEL key, and it is null (not []) in a workspace with none — hence // []\ncio api /v1/environments/$ENV_ID \\\n  --jq '{max: .environment.max_allowed_object_types, enabled: [(.object_types // [])[] | select(.enabled)] | length}'\n```\n\nIf the workspace is at or near its cap, say so and ask which types matter before spending the remaining slots — don't create types until it silently starts disabling them.\n\nThe cap is set per account, so **don't tell the user a plan upgrade is how they raise it** — an account can already sit above or below its plan's default. Freeing a slot is the action they control; anything else is a conversation with their account team.\n\n### Creating an object type and objects\n\n1. **Create an object type:**\n\n```\nPOST /v1/environments/:environment_id/object_types\nBody: { \"object_type\": { \"name\": \"Companies\", \"slug\": \"companies\", \"singular_name\": \"Company\", \"singular_slug\": \"company\" } }\n```\n\n2. **Create objects of that type:**\n\n```\nPOST /v1/environments/:environment_id/object_types/:type_id/objects\n```\n\n3. **Upsert an object** (create or update by identifier):\n\n```\nPUT /v1/environments/:environment_id/object_types/:type_id/objects/:identifier\n```\n\n\u003e ⚠️ **Prefer the `PUT` upsert over `POST` for creating objects.** `POST` requires an `object_id` field in the request body and returns 422 if it's missing. `PUT /objects/:identifier` is simpler, idempotent, and works as both create and update.\n\u003e\n\u003e The upsert is **async**: it returns 202 with an empty body, which `cio api` prints as `null`. `null` with exit code 0 means **accepted, not failed** — don't parse the response for the created object. Verify with `GET .../object_types/:id/objects` (or a single-object GET) after a brief delay. The relationships `POST` below behaves the same way.\n\n### Managing relationships\n\nRelationships link profiles to objects, like a person who belongs to a company or a profile who has an order. The API has strict requirements — **getting this wrong is a common source of errors.**\n\n**Create a relationship:**\n\n```\nPOST /v1/environments/:environment_id/relationships\n```\n\n```json\n{\n  \"relationships\": [\n    {\n      \"entity1_type\": \"customer\",\n      \"entity1_id\": \"a9870d00ab03ac03\",\n      \"entity2_type\": \"object\",\n      \"entity2_id\": \"oba9870d0109\",\n      \"attributes\": { \"role\": \"admin\" }\n    }\n  ]\n}\n```\n\nKey constraints:\n- **Entity order matters**: `entity1` MUST be a profile, `entity2` MUST be an object. Reversing them returns 422.\n- **ID format is critical**: `entity1_id` must be the raw hex internal ID of the profile — e.g. `\"a9870d00ab03ac03\"`. Use the `internal_id` field (or `id`, both are the same raw hex value) from the profile list/get response. Do not use `cio_`-prefixed strings. `entity2_id` must be the `ob`-prefixed object ID — e.g. `\"oba9870d0109\"`. Get it from the `id` field in the object list/get response. External IDs are rejected with 422.\n- **Batch endpoint**: `relationships` is an array — you can create multiple in one call.\n- **Async**: Returns 202 Accepted. The relationship is queued for processing, not immediately visible.\n- **Attributes are optional**: You can set relationship-level attributes (e.g. `role`, `joined_at`) that are separate from the object's own attributes.\n\n**Delete a relationship:**\n\n```\nDELETE /v1/environments/:environment_id/relationships\n```\n\nSame body format — `entity1_type`, `entity1_id`, `entity2_type`, `entity2_id`. Same CIO ID requirements.\n\n### Querying relationships\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET .../relationships/objects/related_to/:cio_id` | Objects related to a profile |\n| `GET .../relationships/objects/related_to_all_types/:cio_id` | Same, grouped by object type |\n| `GET .../object_types/:type_id/relationships/objects/related_to/:cio_id` | Objects of a specific type related to a profile |\n| `GET .../relationships/customers/related_to/:cio_object_id` | Profiles related to an object |\n\n### Object CRUD\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET .../object_types` | List all object types |\n| `PUT .../object_types/:id` | Rename an object type |\n| `DELETE .../object_types/:id` | Delete type and all its objects |\n| `GET .../object_types/:id/objects` | List objects of a type |\n| `GET .../object_types/:id/objects/:identifier` | Get a single object |\n| `POST .../object_types/:id/objects/delete` | Bulk delete objects |\n| `POST .../object_types/:id/objects/exports` | Export objects |\n\n### Discovering Object Attributes\n\nDon't paginate through `object_types/:id/objects` to learn which attribute keys exist — these endpoints answer it directly:\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET .../attributes?objectType=:type_id\u0026size=50` | List object attribute names and metadata for a type. Use before `related_object_attribute` segment criteria. |\n| `GET .../attributes?objectType=:type_id\u0026relationship=true\u0026size=50` | List relationship attribute names and metadata for a type. Use before `relationship_attribute` segment criteria. |\n| `POST .../object_attributes/:type_id/object_count` | Returns `{attribute_object_counts: {\u003ckey\u003e: \u003cnum_objects\u003e}}` for every attribute on the type. Use this to gauge how broadly each object attribute is populated. Body: `{}`. |\n| `GET .../object_attributes_values/:type_id?attribute=\u003ckey\u003e` | Distinct values for one object attribute. Add `\u0026relationship=true` for relationship attributes. |\n| `GET .../object_attributes/:type_id/:attribute` | Metadata and usage for a single object attribute. |\n| `GET .../object_relationship_attributes/:type_id/:attribute` | Metadata and usage for a single relationship attribute. |\n\nThe `/data_index/object_attributes/:type_id` and `/data_index/relationship_attributes/:type_id` endpoints are **not** alternatives — they return 204 and queue background exports (see `workspace.md`).\n\n---\n\n## Profile activity\n\nProfile activity is the way to view a profile's events, sent messages, and other activity. There is no `/customers/:id/events` endpoint — use the logs endpoint with a `customer_id` filter instead.\n\n**Activity for many profiles at once.** The logs endpoint takes one profile per call, so \"what did each of these N profiles do\" is a genuine fan-out. Two rules keep it from eating a session:\n\n- **Look for a call that answers it in one pass first.** Workspace-wide `GET .../logs?type=event` (no profile filter) returns the recent event stream for the whole workspace — for \"who did something / who did nothing\" that is one call instead of N, and it stays correct as N grows.\n- **When you do fan out, `xargs -P N -I {} cio api ...` is the only thing in this sandbox that runs the calls concurrently**, and the flag values must be separated (`-I {}`, never `-I{}` — the glued form fails with `xargs: invalid option -- 'I'`, which is a parse error, not a missing feature). Nothing else parallelizes, so don't spend turns finding that out: python has no threads and no subprocesses, node's `child_process` cannot spawn, and `\u0026` + `wait` runs serially. Stay on `cio api` — dropping to `curl` or a python HTTP client against the API host costs turns and gains nothing.\n\n```bash\ncio api /v1/environments/$ENV_ID/customers --page-all --jq '.customers[].id' -r \\\n  | xargs -P 5 -I {} cio api /v1/environments/$ENV_ID/logs \\\n      --params '{\"internal_id\": \"{}\", \"type\": \"event\", \"limit\": \"1\"}' --jq '.logs[0].name // \"none\"' -r\n```\n\nTo send a test event for an existing profile, use `POST /v1/environments/:environment_id/events` (see `operations.md`) with the profile's internal CIO ID in `ids`. Do not use the external profile ID in that endpoint.\n\n```\nGET /v1/environments/:environment_id/logs?customer_id=user_123\u0026type=event\n```\n\n| Parameter | Description |\n|-----------|-------------|\n| `customer_id` | External profile ID (e.g. `user_123`) |\n| `internal_id` | Alternative: use the internal CIO ID instead of `customer_id` |\n| `type` | Event type filter (see below) |\n| `name` | Filter by event name (e.g. `purchase`) |\n| `limit` | Results per page (max 50, default 50) |\n| `continuation` | Pagination token from previous response's `meta.continuation` |\n| `from` / `to` | Date range filters (RFC3339, e.g. `2025-01-01T00:00:00Z`) |\n\n**`type` values:** `event` covers custom events sent via the Track API (the most common case). Other standalone types include `page`, `screen`, `attribute_change`, `device_change`. Delivery metrics for email follow the pattern `\u003cmetric\u003e_email` — e.g. `sent_email`, `delivered_email`, `opened_email`, `bounced_email`. Every other channel (push, SMS, webhook, in-app, etc.) shares the generic `\u003cmetric\u003e_action` family instead — e.g. `sent_action`, `delivered_action`, `opened_action`, `clicked_action`; there is no per-channel suffix like `opened_push` or `bounced_twilio`. Content interaction types are `viewed_content` and `clicked_content`.\n\n**Geofence enter/dwell/exit activity** is here too — the transitions are ordinary events (`type=geofence`, name `enter`/`dwell`/`exit`). This endpoint takes the friendly tokens **`type=geofence_entered`**, **`type=geofence_dwelled`**, and **`type=geofence_exited`** (they map to `type=geofence` + the matching transition name), so \"show me who dwelled\" is `GET .../logs?type=geofence_dwelled` (add `customer_id` to scope to one profile). In the UI this is **Journeys → Activity logs**, Activity Type = **Geofence** — *not* the account audit log. (The audit log tracks who *changed a geoset's configuration*, a different surface — see `operations.md`.)\n\nThere is no `geoset_id` or `geofence_id` query parameter. To answer \"who entered geoset X\" or \"show activity for fence Y,\" resolve the named set/fence to its id, page through the transition logs with `continuation`, and retain rows whose `logs[].attrs.geoset_id` or `logs[].attrs.geofence_id` matches. The `filters` query parameter is not a server-side row filter: it returns matching event ids in `meta.filter_matches` while leaving the `logs` array unfiltered.\n\nThe endpoint returns at most **50 rows per page** and only queries roughly the most recent **30 days** (the cutoff is week-aligned, so slightly more). An explicit `from`/`to` past that cutoff is rejected with 400; a request that merely pages past it returns 200 with the older rows silently dropped and pagination stopped, so a short result set is not proof of no activity. Never claim a filtered count until every page in the requested, retained window has been inspected, and never present that count as lifetime or older-than-30-day activity.\n\nWithout `customer_id` or `internal_id`, the endpoint returns workspace-wide activity logs.\n\n---\n\n## Attributes \u0026 Events (Data Index)\n\nTo list known attribute names, event names, and metadata for a workspace, see `workspace.md` (Data Index section). Those endpoints are workspace-scoped, not per-customer.\n","data_driven_segments.md":"---\nminimum_plan: essentials\nallow_free: true\n---\n\n# Data-Driven Segments\n\nData-driven segments are rule-based lists — you define `conditions` and membership updates automatically as profiles match or stop matching. Use them for \"everyone matching these rules\" audiences.\n\n**Build first, then report.** An event, attribute, page view, or channel the workspace hasn't recorded yet is not a reason to hold the segment. Create it from the names the user gave, then say which names matched no data so the user can correct them. The exception is a negated condition (\"has not done X\", \"is not Y\") on an event or attribute with no data: it matches every profile, so follow the check in `segment_conditions.md` or say it currently matches everyone.\n\n**Not for one-off analysis.** Don't create segments to measure something — \"what % of people added each week have X\", cohort comparisons, trends over time. That is one warehouse query (`profile_changes.md` Ex 8, attribute coverage by signup cohort); segments built only to read their counts each cost a create plus build polling, and stay in the workspace afterwards. Fall back to segments only when the workspace has no warehouse access — that example isn't available to you, or the query returns 403 — and even then report the overall share first and ask before creating any; if the user agrees, create every cohort's segments in one bash call (a loop of creates), poll them together, and tell them the segments remain in the workspace. A 500 or 503 is a warehouse error, not missing access: report the overall share, say the cohort part could not be computed right now, and offer to retry — never switch to segments over it.\n\nRead `segments.md` for the mechanics shared with manual segments: checking size, listing/filtering, updating, deleting/archiving, and UI links.\n\nThis guide is available on Flex, while automation guides may be plan-gated. If a requested segment condition is unsupported, offer only supported segment definitions. Do not mention or suggest an automation as a fallback for a segment request, including as a way to maintain an attribute used by the segment.\n\n\u003e **Building a segment based on goal completion?** (people who completed, didn't complete, almost completed, or repeatedly completed a goal) — read `recipes/goal_segments.md` first for the audience-building strategy, then come back here for the condition mechanics.\n\n## Creating a Data-Driven Segment\n\n### Workspace allowance\n\nBefore creating a temporary segment for a multi-step task, check that every required follow-up step, including deletion, is allowed in this workspace. If live edits are off, segment deletion is blocked even with a capability grant; explain this before doing dependent work and do not create the segment.\n\nFlex workspaces can create data-driven segments, including through the AI builder. They share the same allowance as segments created in the app: 10 by default on Flex, with account-specific limits possible. The allowance belongs to the account, so data-driven segments in its other workspaces count toward it too. Only data-driven segments count toward it, and sample segments do not.\n\nImmediately before every creation attempt, read `GET /v1/environments/:environment_id/settings/workspace`. Run it as its own call and read the result before sending any create request; never put the create in the same command. Quota values are a point-in-time check, so repeat this read before a retry in the same conversation and do not reuse an earlier response. A request such as \"try again\" is enough reason to refresh the quota; the user does not need to say that they deleted or archived a segment. The response fields are under **`settings`**:\n\n```bash\ncio api /v1/environments/$ENV_ID/settings/workspace \\\n  --jq '.settings | {used: .data_driven_segments_used, allotted: .data_driven_segments_allotted, limit_message: .data_driven_segments_limit_message}'\n```\n\nUse these values, not the length of the segment list:\n\n- `allotted: 0` means unlimited. `used` is then usually absent; that is expected, not an unknown allowance.\n- When both fields are present, `allotted \u003e 0` and `used \u003e= allotted` means there is no room. Do not attempt creation. Explain the limit by relaying `data_driven_segments_limit_message` as written; it is present at the cap.\n- Missing fields mean the allowance is unknown. Do not substitute zero or the default of 10; let the create API decide.\n\nThe create API can still reject a request after this check. A **422 whose error detail says the data-driven segment limit was reached** means no segment was created. The detail starts `You've reached the limit of N data-driven segments.`, says the limit is shared by all workspaces on the account, and then names this account's options; relay it as written, and do not retry until a fresh workspace-settings response shows that quota changed. Other 422s, such as an invalid name or conditions, are validation errors to correct, not evidence of quota exhaustion.\n\nAt the cap, the limit message lists every option this account has. Offer exactly those and nothing more; when it names only deleting a segment, that one option is the whole answer. Don't add an upgrade, plan, price, or add-on the message doesn't name, or a manual or snapshot segment: a manual segment has no conditions and its members are added by hand or through the API, so it can't build this audience.\n\nIf those options include the segments add-on, also say where to buy it and that you can't, for example: \"You can buy it under **Account settings → Plans \u0026 billing → Add-ons**. I can't buy it for you.\" Name no other place to buy it, and don't offer to help with the purchase.\n\nDon't delete, archive, or repurpose anything without the user's go-ahead. Don't bring up archiving or a manual segment yourself; create a manual segment only when the user asks for one. Don't say a refused segment was created.\n\nThis check is for adding a data-driven segment. Updating an existing data-driven segment's conditions does not add one and remains available at the cap. Converting a snapshot to data-driven does consume quota; respect a limit refusal on that update too.\n\n### Create request\n\n```\nPOST /v1/environments/:environment_id/segments\n```\n\n```json\n{\n  \"segment\": {\n    \"name\": \"Active US Users\",\n    \"description\": \"In the US and purchased recently\",\n    \"conditions\": {\n      \"and\": [\n        {\n          \"event\": {\n            \"type\": \"attribute_change\",\n            \"name\": \"country\",\n            \"filters\": {\n              \"and\": [\n                { \"field\": \"to\", \"operator\": \"eq\", \"value\": \"us\", \"inverse\": false },\n                { \"field\": \"from\", \"operator\": \"eq\", \"value\": \"us\", \"inverse\": true }\n              ]\n            }\n          },\n          \"times\": 1,\n          \"within\": 0,\n          \"inverse\": false\n        },\n        {\n          \"event\": { \"type\": \"event\", \"name\": \"purchase\", \"filters\": {} },\n          \"times\": 1,\n          \"within\": 2592000,\n          \"inverse\": false\n        }\n      ]\n    },\n    \"tag_ids\": [\"5\"]\n  }\n}\n```\n\nA data-driven segment is the default type — omit `type` (or set `type: \"dynamic\"`). It requires `conditions`.\n\nFor an audience that should *stop* updating — frozen at a point in time, rebuilt only on request — read `snapshot_segments.md` instead. Same conditions, same create route, `type: \"snapshot\"`.\n\nAfter creation, the segment enters `\"build\"` state while conditions are evaluated. Poll `GET .../segments/:id/status` to check progress. When `state` is `\"complete\"`, membership is current. `state` is top-level in that response (`--jq '.state'`); there is no `segment` wrapper, so `.segment.state` reads `null`.\n\nUpdating conditions (via PUT — see `segments.md`) triggers a rebuild: the segment goes back to `\"build\"` state and membership is recomputed immediately.\n\n## Conditions Reference\n\nRead `segment_conditions.md` before creating or updating conditions. It covers leaf types, operators, relationship structure, and UI-safe event windows.\n\nCritical reminders:\n\n- Before defining conditions, check for finished conditions you can reuse (`GET .../reusable_conditions?type=...`). Reusing one builds instantly and avoids a redundant backfill — see `segment_conditions.md § Reuse existing conditions`.\n- Segment conditions and automation action conditions are different formats. For automation action conditions, use `flow_control.md`.\n- Use the shallowest grouping that exactly represents the user's logic. For `A AND B AND C`, use `{ \"and\": [leafA, leafB, leafC] }`.\n- Do not wrap each criterion in singleton `{ \"or\": [leaf] }` or `{ \"and\": [leaf] }` groups.\n- `attribute_change` filters use an internal `\"and\"` for the `to`/`from` filter pair. That inner `\"and\"` is structural, not a segment-criteria joiner.\n- Do not use `{\"attribute\": {...}}` as a segment condition leaf. For profile attributes, use an `attribute_change` event with the `to`/`from` filter pair.\n- For examples, timestamp sign conventions, and common pitfalls, read `segment_examples.md`.\n","data_operations.md":"---\nminimum_plan: essentials\n---\n\n# Data Operations\n\nActions that modify profile data or trigger events. These use `type: \"continue\"` edges with a single outbound edge. In the UI these appear as **Data** actions in the workflow builder. For branching/routing actions see `flow_control.md`; for message actions (email, webhook, SMS, push) see `automations.md`.\n\n**Setting an attribute to something a model has to write or judge — a summary, a classification, a score, generated copy — is an LLM action, not an Attribute Update: see `llm_actions.md`.** Attribute Update computes its value from Liquid, static text, or another attribute; it cannot reason over the data. The two compose — an LLM action writes `journey.\u003cfield\u003e` or a profile attribute, and later actions read it exactly like any other attribute.\n\n## Attribute Update (`attribute_update_action`)\n\nSets one or more attributes when the journey reaches this action — ideal for computing a value once and reusing it across later messages.\n\n**The action carries only `type`, `sub_type`, and `name`. The attribute config lives in the action's linked TEMPLATE `body`** — set it with a separate template update after the action exists (this mirrors how every action stores its content; see `templates.md`). Do **not** put the values on the action itself: `attribute_name`/`attribute_value` set unused columns, and the action's `attributes` field is **read-only output** (on GET the API echoes the template body back into it). Setting either on the action is a silent no-op — `has_content` stays `false` and nothing is set.\n\n**Steps:**\n1. **Create the action** (automation update) with `type: \"attribute_update_action\"` and `sub_type`:\n   - `\"default\"` → a **profile** attribute, referenced later as `{{customer.\u003cname\u003e}}`\n   - `\"journey_attr_update\"` → a **journey** attribute scoped to this journey, referenced as `{{journey.\u003cname\u003e}}`\n\n   This creates a linked template with an empty body; take its `template_id` from the response.\n2. **Set the attribute(s) on that template's `body`** (`PUT /v1/environments/:eid/templates/:template_id` — see `templates.md`):\n   ```json\n   {\n     \"template\": {\n       \"body\": \"[{\\\"name\\\":\\\"event_title\\\",\\\"type\\\":\\\"liquid\\\",\\\"value\\\":\\\"{% assign v = 'now' | date: '%W' | plus: 0 | modulo: 4 %}{% if v == 0 %}Event Title 1{% else %}Event Title 2{% endif %}\\\"}]\"\n     }\n   }\n   ```\n   `body` is a JSON-encoded array of `{ \"name\", \"type\", \"value\" }`; `type` is `\"liquid\"` (Liquid template), `\"static\"` (literal), or `\"profile_attribute\"` (copy another attribute).\n\n   **Choose Liquid for a value containing Liquid syntax or requiring runtime evaluation** — for example, `{{ \"now\" | date: \"%s\" }}` to store the current Unix timestamp. **Static value is literal and does not render Liquid**; choosing it for that example stores the `{{ ... }}` text instead of a timestamp. Use Static only when the value itself should remain unchanged.\n\n   Then confirm `has_content: true` by reading the **action** — `GET /actions/:action_id`. `has_content` lives on the action, not the template, so projecting it from the template `PUT` response (`.template.has_content`) yields `null` for a write that actually succeeded. Don't read that `null` as a failed update, and don't retry the write against it.\n\n**Don't target reserved or identity attributes** — use a custom attribute name. Reserved names (`created_at`, `unsubscribed`, etc.) are rejected outright; identity names (`id`, `email`, `cio_id`) save but won't change the identity and can clobber a core field.\n\n## Static Segment Update (`static_seg_update_action`)\n\nAdds or removes a person from a static segment.\n\n**Fields:**\n- `name` — display name\n- `segment_id` — the static segment ID (as a string)\n- `add_to_seg` — `true` to add, `false` to remove\n\n**Example:**\n```json\n{\n  \"type\": \"static_seg_update_action\",\n  \"name\": \"Add to VIP list\",\n  \"segment_id\": \"123\",\n  \"add_to_seg\": true\n}\n```\n\n## Create Event (`create_event_action`)\n\nCreates a new event on the person. Useful for triggering other automations or tracking milestones.\n\n\u003e **⚠️ This does NOT sync data to a Pipelines / Data-Out destination (Salesforce, HubSpot, etc.).** An event created here lives in Journeys only — it lands on the person's activity timeline and can trigger other automations, but it is **not** emitted as a CDP source payload. Data-Out (Pipelines) destination actions fire on **source** payloads, so they will never see this event and nothing syncs out. This is a silent dead-end: the event fires, the timeline shows it, and the destination stays empty.\n\u003e\n\u003e If the goal is to push a value to an external tool from a workflow — e.g. stamp a Salesforce field, update a CampaignMember status, write back to HubSpot — do **not** use this action. Use one of:\n\u003e - **Webhook action** (`webhook_action`) — call the external tool's API (or a CDP source's HTTP endpoint) directly from the workflow. This is the right default, and the **only** option that can write to an external *object/record* (e.g. a Salesforce Contact field or CampaignMember status).\n\u003e - **Attribute Update action** (`attribute_update_action`) — what it writes depends on `sub_type`: `default` sets a **profile attribute**, `journey_attr_update` sets a **journey attribute**; there is no object-attribute option. Use it only when the target is a *profile* trait the destination's profile sync already maps; it cannot write an object/record field.\n\u003e\n\u003e Confirm the user's intent before building: \"trigger another automation / record a milestone\" → this action is fine; \"get this value into \u003cexternal tool\u003e\" → use a webhook action instead.\n\n**Fields at creation:**\n- `name` — display name (this is the action label, NOT the event name)\n\n**Setting the event name (required after creation):** The event name is stored in the action's template `name` field as a **JSON-encoded render parameter**, NOT a bare string. After creating the action, read the automation to get the action's `template_id`, then update the template:\n\n```\nPUT /v1/environments/:eid/templates/:template_id\n{ \"template\": { \"name\": \"{\\\"type\\\":\\\"static\\\",\\\"value\\\":\\\"onboarding_complete\\\"}\" } }\n```\n\nThe `name` value is a JSON string that itself contains a JSON object (`type`, `value`). Bare strings like `\"onboarding_complete\"` are rejected at the API with `422` and, prior to that validation, would fail at delivery time with `invalid character 'o' looking for beginning of value` for every recipient.\n\nUse `type: \"static\"` for a literal event name, or `type: \"liquid\"` with `value: \"{{customer.signup_event}}\"` for a dynamic name. The `body` (event data) and `recipient` fields, if set, are also JSON-encoded — `body` is a JSON array of render parameters, `recipient` is a JSON object `{\"field\":..., \"type\":..., \"value\":...}`.\n\n**Webhook journeys have no current person.** For an event on a matching profile, set the template's JSON-encoded `recipient` and `recipient_environment_id`. The recipient selects the profile identifier (`field`: `id`, `email`, or `cio_id`) and its source in the payload. An omitted recipient creates an anonymous event. If the payload is unknown, leave the recipient unset and ask for a sample. Then preview its resolved identifier and errors before starting.\n\n**Example (creation only — still needs template update above):**\n```json\n{ \"type\": \"create_event_action\", \"name\": \"Fire onboarding_complete event\" }\n```\n\n## Batch Update (`batch_update_action`)\n\nApplies bulk attribute updates. Like `create_event_action`, the specific update configuration is set on the action's template after creation.\n\n**Fields:**\n- `name` — display name\n\n**Example:**\n```json\n{ \"type\": \"batch_update_action\", \"name\": \"Batch tag users\" }\n```\n\n## Collection Query (`collection_query_action`)\n\n**This is the ONLY way to use collection data in messages.** Collections are NOT accessible directly in Liquid — there is no `collections[1].items` or similar syntax. You MUST add this action to the workflow before any message action that needs collection data. It queries a collection and stores the result in a profile or journey attribute, which the template then references (e.g., `{{ journey.recommended_products }}`).\n\nWhen building an automation that uses collection data, always wire: `collection_query_action` → message action. Without this step, no message (email, push, SMS, in-app, or webhook) has access to collection data.\n\n**Fields:**\n- `name` — display name\n- `collection_id` — the collection to query (as a string)\n- `attribute_name` — profile attribute to store results in (cannot be `cio_id`, `id`, `email`, or reserved attributes)\n- `conditions` — base64-encoded filter for query conditions (same encoding as branch conditions)\n- `limit` — max rows to return (integer)\n- `order_by` — column to sort by (string, optional)\n- `asc` — sort ascending if true (boolean, optional)\n- `randomize` — return random rows if true (boolean, optional)\n- `single_result` — return single object instead of array if true (boolean, optional)\n- `journey_attribute_name` — (optional) store results in a journey attribute instead\n\n**Validation rules:**\n- `attribute_name` cannot be `cio_id`, `id`, `email`, a reserved attribute, or contain invalid characters\n- `journey_attribute_name` (if provided) cannot contain invalid characters\n\n**Example:**\n```json\n{\n  \"type\": \"collection_query_action\",\n  \"name\": \"Get recommended products\",\n  \"collection_id\": \"42\",\n  \"attribute_name\": \"recommended_products\",\n  \"limit\": 3,\n  \"randomize\": true,\n  \"single_result\": false\n}\n```\n","database_syncs.md":"---\nminimum_plan: essentials\n---\n\n# Database Syncs — SQL Imports \u0026 Warehouse Sync\n\n## SQL Imports\n\nSQL imports pull data **into** Customer.io from external SQL databases on a recurring schedule.\n\nThe Pipelines side has its own feature called \"database syncs\" — those are CDP reverse ETLs, documented in the `cdp_api` skill's `reverse_etl.md`.\n\nThe setup has three layers:\n1. **Database connection** — credentials and connectivity info for an external database (MySQL, Postgres, Redshift, Snowflake, BigQuery, or SQL Server).\n2. **Import configuration** — a saved SQL query tied to a database connection, with a schedule (`interval`, in seconds) and mapping rules. Each import targets one of three data types: `people` (customer profiles), `object` (non-person entities), or `relationship` (links between entities). The query's `id_field_name` column maps rows to customer/object identifiers.\n3. **Sync rows** — execution records for each run of an import. Each row tracks status, timing, and row counts. You can list sync history by `sql_import_id` or by `segment_id` (if the import populates a segment).\n\n### Database Connections\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/sql_import_databases` | `sql_imports.ListDatabases` | List database connections |\n| POST | `/v1/environments/{environment_id}/sql_import_databases` | `sql_imports.CreateDatabase` | Create a database connection (verifies connectivity) |\n| PUT | `/v1/environments/{environment_id}/sql_import_databases/{id}` | `sql_imports.UpdateDatabase` | Update a database connection |\n| DELETE | `/v1/environments/{environment_id}/sql_import_databases/{id}` | `sql_imports.DeleteDatabase` | Delete a database connection |\n\n### Import Configurations\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/sql_imports` | `sql_imports.List` | List imports for one database type. Param: `database` (required; one of `mysql`, `postgres`, `snowflake`, `bigquery`, `redshift`, `sqlserver`). Sideloads the referenced `sql_import_databases`. |\n| POST | `/v1/environments/{environment_id}/sql_imports` | `sql_imports.Create` | Create an import configuration |\n| GET | `/v1/environments/{environment_id}/sql_imports/{id}` | `sql_imports.Get` | Get an import configuration |\n| PUT | `/v1/environments/{environment_id}/sql_imports/{id}` | `sql_imports.Update` | Update an import configuration |\n| DELETE | `/v1/environments/{environment_id}/sql_imports/{id}` | `sql_imports.Delete` | Delete an import configuration |\n| POST | `/v1/environments/{environment_id}/sql_imports/{id}/update_query` | `sql_imports.UpdateQuery` | Update just the SQL query (without changing schedule or mapping) |\n| POST | `/v1/environments/{environment_id}/sql_imports/{id}/start` | `sql_imports.Start` | Trigger an immediate sync |\n| POST | `/v1/environments/{environment_id}/sql_imports/{id}/stop` | `sql_imports.Stop` | Stop a running sync |\n\n\u003e **The import PUT is a full replace, not a patch.** `GET` the import first and resend the complete\n\u003e object, changing only what you intend — a partial body zeroes or rejects what you leave out. For a\n\u003e query-only edit use `POST /sql_imports/{id}/update_query`: on an already-active import the PUT still\n\u003e demands a `query` but discards its value, so that is the only way to change the query.\n\n### Sync History\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/sql_import_rows` | `sql_imports.ListRows` | List past sync executions. Param: `sql_import_id` (import config ID) or `segment_id` (one required) |\n| PUT | `/v1/environments/{environment_id}/sql_import_rows/{id}/canceled` | `sql_imports.Cancel` | Cancel a running sync execution |\n\n## Warehouse Sync\n\nWarehouse sync exports data **out of** Customer.io to cloud storage on a recurring basis. Only one warehouse sync config can exist per workspace.\n\n### Storage destinations\n\nExactly one storage destination must be configured:\n\n| Destination | Config key | Required fields |\n|-------------|-----------|-----------------|\n| AWS S3 | `s3_config` | `access_key`, `secret_key`, `bucket_name`, `bucket_path`, `region` |\n| Google Cloud Storage | `gcs_config` | `bucket_name`, `bucket_path`, `credentials` (JSON service account key) |\n| Azure Blob Storage | `azure_config` | `container_sas_url`, `bucket_path` |\n| Yandex Object Storage | `yandex_config` | `access_key`, `secret_key`, `bucket_name`, `bucket_path`, `region` |\n\n### Data types\n\nBoolean flags control which data types are exported. All are independently togglable:\n\n`attributes`, `deliveries`, `metrics`, `people`, `subjects`, `outputs`, `contents`, `objects`, `object_attributes`, `campaigns`, `events`, `broadcasts`, `inbound`\n\n### Endpoints\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/warehouse_sync` | `warehouse.Get` | Get the warehouse sync config (includes export file history) |\n| POST | `/v1/environments/{environment_id}/warehouse_sync` | `warehouse.Create` | Create a warehouse sync config |\n| PUT | `/v1/environments/{environment_id}/warehouse_sync/{id}` | `warehouse.Update` | Update a warehouse sync config |\n| DELETE | `/v1/environments/{environment_id}/warehouse_sync/{id}` | `warehouse.Delete` | Delete a warehouse sync config |\n\nCreate body wraps the storage config and data type flags:\n```json\n{\"warehouse_sync\": {\"people\": true, \"deliveries\": true, \"events\": true, \"s3_config\": {\"access_key\": \"...\", \"secret_key\": \"...\", \"bucket_name\": \"my-bucket\", \"bucket_path\": \"cio-exports/\", \"region\": \"us-east-1\"}}}\n```\n\nUpdate body requires an `update_type` to specify what's changing:\n- `\"settings\"` — toggle data type flags\n- `\"s3_config\"` / `\"gcs_config\"` / `\"azure_config\"` / `\"yandex_config\"` — update storage credentials\n- `\"schema_version\"` — upgrade the export schema version\n\n```json\n{\"warehouse_sync\": {\"update_type\": \"settings\", \"people\": true, \"events\": false}}\n```\n\n### Re-enabling `people` after it has been off a week\n\nTurning `people` back on is refused with a 400 — *\"People data has not been synced for over a week. Please contact win@customer.io to re-enable it.\"* — when it has been disabled for more than 7 days. The stored export position is too stale to resume from by then, so resuming would export an incomplete picture. Only support can clear it (Flightdeck: **Workspace → Workspace Data → Warehouse Export → Reset people state**, or the `reset_state` route), which resets the position and reopens self-service; the next people export is then a full re-export.\n\n### Re-exporting a type's full history\n\nTurning a data type off and back on resumes from its stored position; it does not re-send history. A full re-export of `attributes`, `broadcasts`, `campaigns`, `deliveries`, `metrics` or `people` (for example after the customer deleted their files) needs support to reset that type's export state while it is off. Turn the type off, ask support for the reset, then turn it back on: the next run writes `_backfill_` files until it has caught up. Toggling `people` off and back on inside the 7 days is unrestricted.\n","delays.md":"---\nminimum_plan: essentials\n---\n\n# Delays\n\nActions that pause a person's journey. In the UI, these appear under the **Delays** menu: Delay, Time Window, Randomized Delay, and Wait Until.\n\n\n## Delay (`delay_seconds_action`)\n\nWaits a fixed number of seconds before continuing.\n\n**Fields:**\n- `delay` — wait time in seconds (e.g., `86400` = 1 day, `604800` = 7 days)\n\n**Example:**\n```json\n{ \"type\": \"delay_seconds_action\", \"delay\": 86400 }\n```\n\nNo validation beyond standard integer parsing. A delay of `0` means no wait.\n\n## Time Window Delay (`delay_time_window_action`)\n\nWaits until the next occurrence of a specific time window (e.g., \"next weekday between 9am–5pm\").\n\n**Fields:**\n- `start_time` — start of window in `\"HH:MM\"` 24-hour format (e.g., `\"09:00\"`)\n- `end_time` — end of window in `\"HH:MM\"` 24-hour format (e.g., `\"17:00\"`)\n- `days` — colon-separated day names (e.g., `\"Mon:Tue:Wed:Thu:Fri\"`)\n- `zone` — IANA timezone (e.g., `\"America/New_York\"`) or `\"customer_time_zone\"` to use each person's timezone\n- `fallback_zone` — required when `zone` is `\"customer_time_zone\"`, used for people without a timezone attribute\n\n**Validation rules:**\n- `end_time` must be after `start_time` (within the same day)\n- `days` must contain valid day abbreviations: `Mon`, `Tue`, `Wed`, `Thu`, `Fri`, `Sat`, `Sun`\n- `zone` must be a valid IANA timezone or `\"customer_time_zone\"`\n- When `zone` is `\"customer_time_zone\"`, `fallback_zone` must be a valid IANA timezone\n\n**Example:**\n```json\n{\n  \"type\": \"delay_time_window_action\",\n  \"start_time\": \"09:00\",\n  \"end_time\": \"17:00\",\n  \"days\": \"Mon:Tue:Wed:Thu:Fri\",\n  \"zone\": \"customer_time_zone\",\n  \"fallback_zone\": \"America/New_York\"\n}\n```\n\n## Randomized Delay (`randomized_delay_action`)\n\nWaits a random duration between a minimum and maximum.\n\n**Fields:**\n- `min_delay` — minimum wait in seconds\n- `max_delay` — maximum wait in seconds\n- `name` — display name\n\n**Validation rules:**\n- `max_delay` must be greater than or equal to `min_delay`\n\n**Example:**\n```json\n{ \"type\": \"randomized_delay_action\", \"name\": \"Random 1-3 day wait\", \"min_delay\": 86400, \"max_delay\": 259200 }\n```\n\n## Wait Until (`conditional_wait_action`)\n\nHolds a person **until a condition is met** or a **timeout expires**. Unlike the flow control branches (True/False, Multi-Split) which check a person's current state instantly, Wait Until holds the person and checks over time — use this when **the condition hasn't happened yet** and you want to wait for it.\n\nWait Until produces branches (condition met vs. timed out), so it uses `type: \"branch\"` edges.\n\n**Prefer Wait Until over delay + branch when waiting for a user action.** Don't default to `delay → True/False \"did they do X?\"` — that checks once, after the full delay, so it can't react when the action happens early and mis-fires the \"no\" branch for anyone who acts late. For \"wait until the user does X\" (onboarding, activation, purchase), use Wait Until with a `foreign_event` condition + timeout: the condition branch fires the instant the event arrives, the timeout branch handles \"never did it\" (see the `foreign_event` example below). Use True/False only to check state the person *already* has; use a plain Delay only for a fixed pause with no condition.\n\n**Fields:**\n- `multi_conditions` — array of base64-encoded condition strings (what to wait for). See `flow_control.md § Condition Encoding` for the encoding formula.\n- `delay` — max wait time in seconds (0 = wait forever)\n- `exit_type` — only two valid values:\n  - `\"never\"` — wait indefinitely until a condition matches (`delay` must be `0`)\n  - `\"continue\"` — when timeout expires, continue to the timeout branch (`delay` must be \u003e `0`)\n- `delay_event_attr` — (optional) wait until a date stored under this key in the **triggering event's data**, or in a **journey attribute** set by an earlier action. **Profile attributes are never consulted** — a profile attribute name here silently never resolves. Value may be a unix timestamp or an ISO 8601 datetime; anything else is ignored.\n- `delay_event_attr_offset` — (optional) seconds offset from the attribute date\n\n**Only use `delay_event_attr` on an event-triggered automation, or where an earlier action wrote the journey attribute.** Object-, relationship-, segment- and date-triggered automations have no event data, so nothing resolves the key — the API accepts the field and the journey then waits on an arm that can never fire (with `exit_type: \"never\"`, forever). To act relative to a date that lives on a profile or an object, use a **date-triggered automation** (`date_triggered_attribute` + `date_triggered_lead_days`) — see `automation_triggers.md`.\n\n**To exit the automation on timeout:** Use `exit_type: \"continue\"` with `delay \u003e 0`, and wire the timeout branch edge (the last index) directly to the automation's exit action ID. There is no `exit_type` value that exits the automation automatically — you must route to the exit action explicitly.\n\n**Edge wiring depends on configuration:**\n\n| Configuration | Edge count | Index mapping |\n|---|---|---|\n| N conditions + timeout (`delay` \u003e 0) | N + 1 | `0..N-1` = condition branches, `N` = timeout |\n| N conditions, no timeout (`exit_type: \"never\"`) | N | `0..N-1` = condition branches only |\n| N conditions + `delay_event_attr` + timeout | N + 2 | `0..N-1` = conditions, `N` = event attr, `N+1` = timeout |\n\n**Validation rules:**\n- `exit_type: \"never\"` requires `delay: 0`\n- `exit_type: \"continue\"` requires `delay` \u003e 0\n- At least one edge is always required (minimum 1 condition or 1 timeout)\n\n**Placeholder IDs use negative indexing:** `-1` is the **last** action in the `actions` array, `-2` is second to last, etc. With 3 new actions, `-3` is the first.\n\n**Example — wait up to 3 days for a custom event, then continue:**\n\nTo build the `multi_conditions` value, encode a `foreign_event` condition using the formula from `flow_control.md § Condition Encoding`:\n```javascript\n// Raw condition: wait for the \"account_setup_complete\" event\nconst cond = [{\"type\": \"foreign_event\", \"event_name\": \"account_setup_complete\", \"event_type\": \"\"}];\nconst encoded = Buffer.from(encodeURIComponent(JSON.stringify(cond))).toString('base64');\n```\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"PREV_ACTION_ID\", \"to\": \"-3\", \"type\": \"continue\" },\n      { \"from\": \"-3\", \"to\": \"-2\", \"type\": \"branch\", \"index\": 0 },\n      { \"from\": \"-3\", \"to\": \"-1\", \"type\": \"branch\", \"index\": 1 },\n      { \"from\": \"-2\", \"to\": \"NEXT_ACTION_ID\", \"type\": \"continue\" },\n      { \"from\": \"-1\", \"to\": \"NEXT_ACTION_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    {\n      \"type\": \"conditional_wait_action\",\n      \"name\": \"Wait for account setup\",\n      \"exit_type\": \"continue\",\n      \"delay\": 259200,\n      \"multi_conditions\": [\n        \"ENCODED_VALUE_FROM_ABOVE\"\n      ]\n    },\n    { \"type\": \"email_action\", \"name\": \"Setup complete — next steps\", \"sending_state\": \"draft\" },\n    { \"type\": \"email_action\", \"name\": \"Reminder: finish setup\", \"sending_state\": \"draft\" }\n  ]\n}\n```\n\nEdge index mapping (`-3` = wait, `-2` = setup complete email, `-1` = reminder email):\n- `index: 0` → condition met (account setup completed) → action `-2`\n- `index: 1` → timeout (3 days passed without setup) → action `-1`\n- Both email actions have outgoing edges to `NEXT_ACTION_ID` (or `EXIT_ACTION_ID` if last step)\n\n**Branch convergence applies to Wait Until.** All continuing branches must reconverge at the same downstream action. If the timeout branch should exit the automation, create a new `exit_action` and route the branch to it — do not reuse the automation's original exit action. See `flow_control.md § Workflow Graph Rules`.\n\n### Geofence Wait Until\n\nUse the existing foreign-event Wait Until pattern with a geofence transition and a `geoset_id` equality in the same condition array:\n\n```json\n[\n  {\"type\": \"foreign_event\", \"event_name\": \"enter\", \"event_type\": \"geofence\"},\n  {\"type\": \"foreign_event_attribute\", \"field\": \"geoset_id\", \"operator\": \"eq\", \"value\": \"42\"}\n]\n```\n\nResolve the geoset name to its id before encoding. Use `enter`, `exit` or `dwell` as `event_name`, and keep the required scope as `geoset_id` with operator `eq`. For several geosets, encode one complete event-plus-scope array per geoset as separate `multi_conditions` entries, then use the normal edge-count and convergence rules above. The one-hour DWELL freshness limit applies only to campaign starts; an older queued DWELL can satisfy this wait when it arrives.\n\n**Example — wait forever for a purchase (no timeout):**\n\n```javascript\n// Raw condition: wait for \"purchase\" event\nconst cond = [{\"type\": \"foreign_event\", \"event_name\": \"purchase\", \"event_type\": \"\"}];\nconst encoded = Buffer.from(encodeURIComponent(JSON.stringify(cond))).toString('base64');\n```\n\n```json\n{\n  \"type\": \"conditional_wait_action\",\n  \"name\": \"Wait for purchase\",\n  \"exit_type\": \"never\",\n  \"delay\": 0,\n  \"multi_conditions\": [\n    \"ENCODED_VALUE_FROM_ABOVE\"\n  ]\n}\n```\n\nOnly 1 edge needed (the condition branch). No timeout branch since `exit_type` is `\"never\"`.\n\n**Example — wait for email open or click (2 conditions + timeout):**\n\n```javascript\n// Condition 1: opened any email\nconst cond1 = [{\"type\": \"message\", \"event\": {\"name\": \"*\", \"type\": \"opened_email\"}, \"inverse\": false}];\nconst encoded1 = Buffer.from(encodeURIComponent(JSON.stringify(cond1))).toString('base64');\n\n// Condition 2: clicked any email\nconst cond2 = [{\"type\": \"message\", \"event\": {\"name\": \"*\", \"type\": \"clicked_email\"}, \"inverse\": false}];\nconst encoded2 = Buffer.from(encodeURIComponent(JSON.stringify(cond2))).toString('base64');\n```\n\n```json\n{\n  \"type\": \"conditional_wait_action\",\n  \"name\": \"Wait for engagement\",\n  \"exit_type\": \"continue\",\n  \"delay\": 604800,\n  \"multi_conditions\": [encoded1, encoded2]\n}\n```\n\n3 edges: `index: 0` = opened, `index: 1` = clicked, `index: 2` = timeout (7 days).\n\n### Condition types and encoding\n\n\u003e **⚠️ CRITICAL: Automation action conditions are NOT segment conditions.**\n\u003e Wait Until conditions use the flat array format `[{type: \"foreign_event\", event_name: \"...\"}]`, NOT the segment-only condition tree documented in `segment_conditions.md`. Using segment-style conditions in `multi_conditions` causes 500 errors. See `flow_control.md § Automation Action Condition Types` for the complete reference.\n\nWait Until supports **all condition types** including foreign events (`type: \"foreign_event\"`), message events (`type: \"message\"`), and attribute comparisons (`type: \"attribute_compare\"`) — the first two are **not** supported by flow control branches (True/False, Multi-Split). See `flow_control.md § Automation Action Condition Types` for the full list and `§ Condition Encoding` for the encoding formula.\n\nConditions in the same array are ANDed, so a `foreign_event` plus a `foreign_event_attribute` or `attribute_compare` in one array means \"the event arrived **and** its data matches\".\n\n### Wait Until: correlating two events with `attribute_compare`\n\n**You can compare an attribute of the waited-for event against an attribute of the triggering event.** This is the way to wait for \"the *same* thing\" — the same application, order, or cart — rather than any occurrence of the event. Use an `attribute_compare` condition with `field_source: \"foreign_event_attribute\"` (the waited-for event) and `value_source: \"event_attribute\"` (the event that triggered the journey). `field` and `value` are the two **attribute names**, not values:\n\n```json\n{\n  \"type\": \"attribute_compare\",\n  \"field\": \"application_id\",\n  \"field_source\": \"foreign_event_attribute\",\n  \"value\": \"application_id\",\n  \"value_source\": \"event_attribute\",\n  \"operator\": \"eq\"\n}\n```\n\nPut it in the same condition array as the `foreign_event` it qualifies — on its own it has no waited-for event to read, and that mistake is **not** caught at save time (the automation saves, then fails when journeys are processed):\n\n```javascript\n// Wait for an application_submitted for the SAME application_id that triggered the journey.\nconst cond = [\n  {\"type\": \"foreign_event\", \"event_name\": \"application_submitted\", \"event_type\": \"\"},\n  {\"type\": \"attribute_compare\", \"field\": \"application_id\", \"field_source\": \"foreign_event_attribute\",\n   \"value\": \"application_id\", \"value_source\": \"event_attribute\", \"operator\": \"eq\"}\n];\nconst encoded = Buffer.from(encodeURIComponent(JSON.stringify(cond))).toString('base64');\n```\n\nAn arriving `application_submitted` whose `application_id` differs from the trigger event's — or that has no `application_id` at all — does not match, and the profile keeps waiting. Other useful source pairings: `foreign_event_attribute` vs `customer_attribute` (event data vs profile), and `foreign_event_attribute` vs `const` (a plain value filter, equivalent to a `foreign_event_attribute` condition). Attribute names are matched case-sensitively (the values they resolve to are compared case-insensitively, as everywhere else), and `exists` / `email_valid` / `timestamp` are rejected with a 422 unless `value_source` is `const`. The source list is in `flow_control.md § Automation Action Condition Types`.\n\n`event_attribute` reads the journey's trigger event, so this pattern needs an event-triggered automation. Any other entry — segment/attribute, date, object, or relationship — starts the journey with no trigger event, and the comparison then has nothing to resolve against (it doesn't error, it just never matches).\n\nThe same comparison works as a **global exit condition**, but in the nested-wrapper `Filter` format (`foreign_event.attribute_compare`, no `type` key) — not this base64 DSL. See `automation_filters.md § Dynamic exit conditions`.\n\n### Wait Until pitfall: `exit_type` values\n\n**`exit_type: \"leave\"` and `exit_type: \"exit\"` do not exist.** The API rejects both with a 422. The only valid values are `\"never\"` and `\"continue\"`. For other common pitfalls (encoding, `multi_conditions` format, 409 errors), see `flow_control.md § Common Pitfalls`.\n\n### Wait Until pitfall: chained reminder cascades and the shared-exit trap\n\nA common multi-stage pattern (NPS surveys, re-engagement nudges) chains several Wait Untils. Each stage sends a message, waits for a response, and on a **matched** condition funnels into one shared action (for example a Slack notification) that then continues to the automation exit. On **timeout** the stage falls through to the next stage's message. The final stage's timeout ends the journey.\n\nThe trap: routing that final timeout branch to the automation's existing exit action **fails convergence**, even though \"timeout, then exit\" reads as correct. The shared Slack action already continues into that exit via a `continue` edge, so the exit has a second incoming edge and is no longer a *dedicated* exit. The validator then sees the final wait's matched branches converging at the shared action while its timeout branch converges at the exit (two different points) and rejects the save:\n\n`\"branches from action \u003cWAIT_ID\u003e do not converge at a common action, got \u003cSHARED_ACTION_ID\u003e and \u003cEXIT_ID\u003e\"`\n\nIn the UI this shows as **\"Uh oh, your automation failed to start\"** with a `422 Unprocessable Content` on the automation `PUT`. Only the final timeout is wrong. Earlier stages' timeouts are fine because they flow to the next message, not to the exit.\n\n**Fix:** give the final timeout branch its **own new `exit_action`** (one incoming edge, so it is exempt), instead of reusing the exit that the shared action flows into.\n\n```json\n// Final wait stage. Matched branches share SLACK_ID; timeout gets its OWN new exit.\n// SLACK_ID already exists and continues to the original EXIT_ID.\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"LAST_SURVEY_ID\", \"to\": \"WAIT_ID\", \"type\": \"continue\" },\n      { \"from\": \"WAIT_ID\", \"to\": \"SLACK_ID\", \"type\": \"branch\", \"index\": 0 },\n      { \"from\": \"WAIT_ID\", \"to\": \"SLACK_ID\", \"type\": \"branch\", \"index\": 1 },\n      { \"from\": \"WAIT_ID\", \"to\": \"SLACK_ID\", \"type\": \"branch\", \"index\": 2 },\n      { \"from\": \"WAIT_ID\", \"to\": \"-1\",       \"type\": \"branch\", \"index\": 3 }\n    ]\n  },\n  \"actions\": [\n    { \"type\": \"exit_action\" }\n  ]\n}\n```\n\nMatched branches (index 0 to 2) all point at `SLACK_ID`: multiple branches to one target is allowed. The timeout branch (index 3, the default) points at a brand-new `exit_action` (`-1`) with no other incoming edge, so it is terminal and exempt. See `flow_control.md § Workflow Graph Rules` for the general rule.\n","deliverability.md":"---\nminimum_plan: essentials\n---\n\n# Deliverability Analytics\n\nAnalyze campaign, broadcast, and transactional message performance using the `sv_deliverability` semantic view in `ANALYTICS.CIO_AGENT`. All metric definitions are governed in the view — never hand-roll metric formulas in raw SQL.\n\nSibling Snowflake skills (same query mechanics): **pipeline_health.md**, **profile_changes.md**, **profile_state.md**, **object_state.md**, **object_changes.md**, **site_activity.md** — scopes in § What This Skill Cannot Answer. Message performance stays here.\n\n## The semantic view at a glance\n\n`sv_deliverability` exposes thirteen logical tables. Pick the table by the GRAIN of the question first — most wrong answers start at the wrong grain.\n\n| Logical table | Base table | Grain (one row per…) | Use for | Key caveat |\n|---|---|---|---|---|\n| `deliveries` | `fct_delivery_events` | metric **event** (sent, opened, …) | volumes, engagement rates, provider/link/device/reason breakdowns | event-count rates can exceed 100% (repeat events) |\n| `messages` | `fct_deliveries` | **message** (delivery) | absence (\"delivered but never opened\"), open/click latency, provider-level rates | provider is only known once delivered |\n| `attributed_events` | `fct_attributed_events` | **custom event** (last 90 days) | message→behavior funnels | last-touch attribution, 30-day cap — always state this |\n| `segment_events` | `fct_segment_events` | segment **membership change** | entries/exits/net change in a period | change counts, NOT current membership |\n| `inbound_messages` | `fct_inbound_messages` | **inbound** SMS/WhatsApp message | replies, STOP/HELP keyword volumes | bodies \u0026 phone numbers not exposed; ~70% of senders resolve to no profile |\n| `webhook_events` | `fct_webhook_events` | **incoming** webhook payload | webhook-triggered campaign inflow | trigger INFLOW — not the webhook delivery channel |\n| `campaigns` | `dim_campaigns` | automation (campaign) config | group/filter by automation name, type, or ID | UI calls these **automations** — users say either |\n| `actions` | `dim_campaign_actions` | automation step (email, delay, branch, …) | per-step / per-email breakdowns | check `action_tracked`/`action_holdout` before reading zero engagement |\n| `broadcasts` | `dim_broadcasts` | broadcast (one-time send) | group/filter by broadcast name or ID | a.k.a. **newsletters** — the ID dimension is `newsletter_id` |\n| `templates` | `dim_templates` | message template (latest state) | subject-line / template analysis | `subject_line` stores Liquid source, not the rendered subject |\n| `transactional_messages` | `dim_transactional_messages` | transactional message config | group/filter transactional sends | — |\n| `segments` | `dim_segments` | segment config | names/types for `segment_events` | — |\n| `tags` | `dim_tags` | tag definition | resolve tag NAME → `tag_id`, then `ARRAY_CONTAINS` on `campaigns`/`broadcasts` `tag_ids` | arrays can't be joined relationally |\n\n**Terminology:** users say *campaign/automation* and *newsletter/broadcast* interchangeably — never claim an entity doesn't exist over naming.\n\n## Choosing the right query\n\nDecide these four things BEFORE writing SQL — each is a common source of wrong (not just failed) answers:\n\n**1. Grain** — from the table above: provider rates and \"delivered but never opened\" → `messages`; engagement volumes/rates by campaign, subject, link → `deliveries`; behavior after a message → `attributed_events`; segment growth → `segment_events`; replies/STOP → `inbound_messages`; webhook-trigger inflow → `webhook_events`.\n\n**2. Date column** — `occurred_date` = \"what happened IN this period\" (volume/activity trends). `send_date` = \"how did what we SENT perform\" — late events attribute back to the send day. **Every windowed ranking or comparison of send performance uses `send_date`** — `occurred_date` mixes send cohorts and inflates long-running entities. For **sub-day timing** use the hour dimensions — `messages.send_hour` for \"best time to send\", `deliveries.occurred_hour` for \"when do people open\" — **all timestamps/hours are US Pacific wall-clock, NOT UTC or recipient-local — state this in the answer.**\n\n   **Send pacing** (\"did my delay/window/limit spread the sends?\") involves two different timestamps: `messages.created_at` = when the delivery was **created** (the person reached the message step), while the `sent` event's time (`deliveries.occurred_at` with `metric_name = 'sent'`) = when the message **actually went out**. A delay or time window shapes the CREATION distribution (a randomized delay spreads creations across its window); message frequency limits, rate limits, and retries then hold created deliveries back, so the two legitimately diverge by hours. A pacing answer therefore needs BOTH distributions — one query bucketing `messages.created_at`, one bucketing the `sent` events' `deliveries.occurred_at` — never one timestamp presented as answering both questions. Spread creations + converged sends does NOT mean the delay is broken — it means something held the sends back: check the workspace's message frequency limits (Settings → Actions → Message frequency limits) before reporting a send burst as an anomaly or a broken delay. When presenting either distribution, state the timezone (US Pacific) on the bucketed times and check the buckets sum to an unbucketed total under the same filters.\n\n**3. Rate family** —\n- **Rankings, \"best/worst\", cross-entity comparisons, \"% of recipients\"** → use `unique_*` rates. Denominator = `unique_sent` (distinct people), bounded at 100%. **Every ranking or comparison must filter `sent \u003e= 100`** — a rate computed over a handful of sends is noise and will otherwise top the ranking. Use `100`, not a lower ad-hoc threshold.\n- **Volume trends, workspace-wide totals** → event-count metrics are fine.\n- **Event-count rates** (`human_open_rate`, `click_rate`, etc.) **can exceed 100%** — one person opening five times counts five opens. If a rate exceeds 100%, report it as a repeat-event artifact, never as \"% of recipients.\"\n- **\"Average X across the emails in a flow\"** → volume-weighted (SUM of numerators ÷ SUM of denominators) in SQL, sub-100-send steps reported but excluded from the headline. State the rule; never mental-math an average of per-row rates.\n\n**4. Channel rules** —\n- **Email**: use `unique_human_*` and `human_*` metrics. `is_machine` filters bot/scanner activity.\n- **Push**: omit machine filter — no reliable human/machine distinction. Use `unique_opens` / `unique_clicks`.\n- **SMS**: no open tracking. Meaningful: `sent`, `delivered`, `clicked`, `failed`. Inbound replies live in `inbound_messages`, not `deliveries`.\n- **Webhook**: `sent` and `delivered` only. Incoming webhook payloads (campaign triggers) are `webhook_events` — a different thing; never mix the two in one number.\n- **In-app**: `sent`, `opened`, `clicked`. Never compare engagement rates across channels or score any channel against the email thresholds in § Interpretation guidance — in-app delivery requires an app open while the message is active, so rates far below email norms are structural.\n- **Payload detail (email):** `inbox_provider`, `device_type`, `email_client`, `link_url`/`link_id`, `reason` are NULL for events before **2026-07-22** — constrain those analyses to on/after it and state the go-forward caveat. Rates **by mailbox provider** go through `messages.inbox_provider` + `message_open_rate` (provider is only known at delivery), with the human open rate alongside (Ex 11).\n\n**Strategy questions need structure, not just rates.** For \"why do people drop off\" or advice about changing a flow, fetch the automation's workflow first (`GET /v1/environments/:environment_id/campaigns/:id` — `edges`, branch/delay actions, and trigger filters) alongside the SV numbers — recommending a branch the flow already has is an observed failure. A count difference matching a branch split or trigger filter is routing by design — check the structure before calling it a drop-off.\n\n## Composing queries\n\n**Precondition — read `deliverability_examples.md` BEFORE writing any `SEMANTIC_VIEW` SQL.** Do not submit a query you did not adapt from one of its worked examples (Ex 1–17): its routing table maps question shapes to examples, each already carrying the workspace/grain/date/rate choices from § Choosing. Every \"Ex N\" reference in this file resolves there.\n\n### Primary path: `sv_deliverability`\n```sql\nSELECT \u003ccolumns\u003e\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS deliveries.\u003cmetric\u003e, ...\n    DIMENSIONS \u003ctable\u003e.\u003cdimension_name\u003e AS \u003calias\u003e, ...\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND \u003cfilters\u003e\nORDER BY ...\n```\n\n**Workspace filter goes in the outer WHERE.** Reference the session variable exactly as `TO_NUMBER($WORKSPACE_ID)`, **unquoted** — `TO_NUMBER('$WORKSPACE_ID')` becomes a literal string and matches nothing. Never use a literal workspace ID. The explicit predicate is what prunes micro-partitions inside the warehouse's 90s statement limit.\n\n**Query `ANALYTICS.CIO_AGENT.*` objects only — never `ANALYTICS.PUBLIC.all_events` or raw base tables.** The Row Access Policy lives on the SV and the `fct_*`/`dim_*` views; bypassing them returns cross-workspace rows. The explicit `workspace_id` predicate is still required: the policy governs visibility, the predicate prunes.\n\n**Joins are automatic — but only along defined relationships.** Include a dimension from a *related* table alongside your metrics and Snowflake resolves the join; pair members from *unrelated* tables in one `SEMANTIC_VIEW()` call and it fails with `010959 (The entities '…' and '…' are not related)`. The complete relationship map — one fact table per query, dimensions only from its row:\n\n| Fact table (metrics from) | May combine with dimensions from |\n|---|---|\n| `deliveries` | `campaigns`, `actions`, `broadcasts`, `templates`, `transactional_messages` |\n| `messages` | `campaigns`, `actions`, `broadcasts`, `templates`, `transactional_messages` |\n| `attributed_events` | `campaigns`, `actions`, `broadcasts`, `transactional_messages` — NOT `templates` |\n| `segment_events` | `segments` only |\n| `webhook_events` | `campaigns` only |\n| `inbound_messages` | nothing — own dimensions only |\n\nFact tables never join each other — needing columns from two fact tables means two queries (or a CTE per fact, joined in the outer SQL). `tags` joins nothing: it is a standalone lookup, queried DIMENSIONS-only with **no METRICS clause at all** (Ex 15 step 1) — never add a throwaway metric from another table to \"satisfy\" METRICS.\n\n**Entity resolved from a URL or ID? Filter by ID, not a name pattern** — wildcard ILIKE silently merges near-duplicate campaigns. Request the ID dimension and filter it directly: `campaigns.campaign_id AS campaign_id` in DIMENSIONS, `campaign_id = 1339` in the outer WHERE. Same for steps (`actions.action_id`) and broadcasts (`broadcasts.newsletter_id`); select the ID alongside the name when the answer needs IDs. Use `ILIKE '%…%'` only when the user gave just a name fragment — and say which entities it matched.\n\n**Two dialects — `table.member` inside the parens, plain output names outside.** `SEMANTIC_VIEW(...)` outputs each member WITHOUT the table prefix (`segment_events.person_id` → `person_id`, metric `deliveries.sent` → `sent`); base-table columns like `customer_internal_id` are invisible to the outer query. The outer SELECT/WHERE/GROUP BY/ORDER BY may reference **only** those plain (or aliased) output columns — a `table.member` name outside the parens, or any column you didn't request in METRICS/DIMENSIONS, fails with `000904 (invalid identifier)`:\n\n```sql\n-- ✗ 000904 twice: qualified name outside the parens; campaign_name never requested\nSELECT messages.person_id\nFROM SEMANTIC_VIEW( ... DIMENSIONS messages.person_id, messages.workspace_id )\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID) AND campaign_name ILIKE '%promo%'\n\n-- ✓ plain output names outside; every outer column requested inside\nSELECT person_id\nFROM SEMANTIC_VIEW( ... DIMENSIONS messages.person_id, messages.workspace_id,\n        campaigns.campaign_name AS campaign_name )\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID) AND campaign_name ILIKE '%promo%'\n```\n\nSeveral tables expose same-named dimensions (`workspace_id`, dates, `channel`, `person_id`) — requested unaliased from two tables, any outer reference fails with `002028 (ambiguous column name)`: alias explicitly (`deliveries.person_id AS delivery_person_id`); aliasing every referenced dimension is always safe. (`person_id` from two FACT tables also breaks the one-fact-table rule — two queries.)\n\n**Members come only from the tables in this file — never guess a name.** id dims exist only where listed (`campaigns.campaign_id`, `actions.action_id`, `broadcasts.newsletter_id`, `tags.tag_id`, `deliveries.link_id`) — there is no `templates.template_id` or `segments.segment_id`, for example. A member not listed here does not exist; if a *documented* member errors as unknown, use § Schema Introspection — never invent a replacement. Dim tables queried directly use base column names (`dim_campaign_actions`: `action_id`, `name`, `type_name` — not the SV names), each with its own `workspace_id` predicate.\n\n**Aggregate filters: WHERE only sees columns, never aggregates.** SV metric columns arrive already aggregated to the DIMENSIONS grain — as plain outer columns they're filterable in WHERE (`sent \u003e= 100`, Ex 2/6). Prefer that: keep DIMENSIONS minimal so metrics come out at the grain you want and no re-aggregation is needed. When the outer query DOES re-aggregate (`SUM(...) … GROUP BY`), a filter on that aggregate — by alias or by expression — belongs in `HAVING` (Ex 3) or in a wrapping CTE, never in WHERE: Snowflake rejects it with `000990` (aggregate alias in WHERE) / `002035` (aggregate function in WHERE).\n\n```sql\n-- ✗ 000990: alias of an outer aggregate in WHERE\nSELECT reason, SUM(bounced) AS bounce_count\nFROM SEMANTIC_VIEW(...)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND bounce_count \u003e 0\nGROUP BY reason\n\n-- ✓ filter the aggregate in HAVING\nSELECT reason, SUM(bounced) AS bounce_count\nFROM SEMANTIC_VIEW(...)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nGROUP BY reason\nHAVING SUM(bounced) \u003e 0\n```\n\n**Re-aggregating? GROUP BY must cover every non-aggregated selected column.** The SV output is already one row per DIMENSIONS combination — re-aggregate only to collapse a dimension you included (daily → monthly, Ex 3). The moment one selected column is wrapped in an aggregate, the query is an aggregate query and EVERY other selected column — including derived expressions like `DATE_TRUNC('month', send_date) AS period` — must appear in GROUP BY (by name, alias, or repeated expression). Miss one, or omit GROUP BY entirely, and Snowflake fails with `000979 (… is not a valid group by expression)` naming the uncovered column; when no collapse is needed, skip re-aggregation entirely and let the SV grain do the work:\n\n```sql\n-- ✗ 000979: SUM() makes this an aggregate query, but no GROUP BY covers broadcast_name\nSELECT broadcast_name, SUM(sent) AS sent FROM SEMANTIC_VIEW(...)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID) ORDER BY sent DESC\n\n-- ✓ don't re-aggregate (SV grain is already one row per broadcast): SELECT broadcast_name, sent …\n-- ✓ or GROUP BY every non-aggregated selected column:\nSELECT broadcast_name, SUM(sent) AS sent FROM SEMANTIC_VIEW(...)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID) GROUP BY broadcast_name ORDER BY sent DESC\n```\n\n### Raw SQL is no longer needed\n\nCompose ONLY `SEMANTIC_VIEW(...)` queries — if a question seems to need raw `fct_*` access, re-check the Dimensions table.\n\n## Metrics\n\n### Volume metrics (event-count)\n\n`sent` (denominator for all event rates), `delivered`, `opened` / `human_opened`, `clicked` / `human_clicked`, `converted`, `bounced`, `unsubscribed`, `spammed`, `failed`. The `human_*` variants exclude machine/prefetched activity.\n\n### Unique metrics (distinct people)\n\n`unique_sent` (denominator for all unique rates), `unique_delivered`, `unique_opens` / `unique_human_opens`, `unique_clicks` / `unique_human_clicks`, `unique_converted`, `unique_bounced`, `unique_unsubscribed`, `unique_spammed`. There is **no `unique_failed`** — count failures with the volume metric `failed`.\n\n### Event-count rates (denominator = `sent`, can exceed 100%)\n\n| Metric | Definition |\n|--------|-----------|\n| `delivery_rate` | delivered / sent |\n| `open_rate` | opened / sent |\n| `human_open_rate` | human_opened / sent |\n| `click_rate` | clicked / sent |\n| `human_click_rate` | human_clicked / sent |\n| `ctor` | clicked / opened |\n| `human_ctor` | human_clicked / human_opened |\n| `bounce_rate` | bounced / sent |\n| `conversion_rate` | converted / sent |\n| `unsubscribe_rate` | unsubscribed / sent |\n| `spam_rate` | spammed / sent |\n\nConcern thresholds live in § Interpretation guidance (plus `human_click_rate` \u003c 3% for email).\n\nThere is **no `fail_rate` / `failure_rate` metric** — derive it in the outer query (`SUM(failed) / NULLIF(SUM(sent), 0)`).\n\n### Unique rates (denominator = `unique_sent`, bounded at 100%)\n\n`unique_delivery_rate`, `unique_open_rate`, `unique_human_open_rate`, `unique_click_rate`, `unique_human_click_rate`, `unique_bounce_rate`, `unique_conversion_rate`, `unique_unsubscribe_rate`, `unique_spam_rate` — each is `unique_\u003cx\u003e / unique_sent`. Plus `unique_ctor` (unique_clicks / unique_opens) and `unique_human_ctor` (unique_human_clicks / unique_human_opens).\n\n### Per-message funnel metrics (denominator = individual delivery, not event count)\n\nUse `messages.*` metrics for absence questions (\"delivered but never opened\") and engagement latency — delivery-grain, one row per message.\n\n| Metric | Description |\n|--------|-------------|\n| `messages.messages_sent` | Messages sent (one per delivery) |\n| `messages.people_messaged` | Distinct people sent at least one message |\n| `messages.messages_delivered` | Messages delivered — denominator for per-message funnel rates |\n| `messages.messages_opened` | Messages opened at least once (human or machine) |\n| `messages.messages_clicked` | Messages clicked at least once |\n| `messages.message_open_rate` | opened / delivered — bounded at 100%. Provider rates MUST use this grain, with human open rate alongside (Ex 11). |\n| `messages.message_click_rate` | clicked / delivered — bounded at 100% |\n| `messages.messages_delivered_not_opened` | Messages delivered but never opened |\n| `messages.messages_opened_not_clicked` | Messages opened but never clicked |\n| `messages.people_not_converted` | People sent a message who converted on none of their messages in the selected scope |\n| `messages.people_opened_not_clicked` | People who opened at least one message but clicked none |\n| `messages.avg_minutes_to_open` | Average minutes from send to first open (opened only) |\n| `messages.avg_minutes_to_click` | Average minutes from send to first click (clicked only) |\n| `messages.median_minutes_to_open` | Median minutes from send to first open (opened only) |\n\n### Custom event metrics (last 90 days, last-touch attribution)\n\nUse `attributed_events.*` metrics for message-to-behavior funnels. Attribution is last-touch, 30-day cap — always state this when reporting.\n\n`events`, `people_did_event`, `attributed_to_message` (events preceded by a message to that person within 30 days), `people_attributed` (distinct-people version of the same), `avg_hours_since_message` (attributed events only).\n\n**Ingress source ≠ attribution.** The `ingress_source_*` / `cdp_source_*` dimensions say HOW an event ENTERED Customer.io — distinct from both last-touch attribution (`was_attributed`) and the deliveries/messages `source_type` (what produced a MESSAGE). NULL on rows ingested before 2026-08-18 (backfill planned) — report the NULL bucket as unknown, never as a source. Profile creation sources → `profile_changes.md`.\n\n### Segment membership metrics (change events, not a roster)\n\nUse `segment_events.*` metrics for \"how many people entered/exited segment X in period Y\". These are **change counts**, not current membership.\n\n`entries`, `exits` (event counts), `people_entered`, `people_exited` (distinct people), `net_change` (entries − exits — membership growth in the period, NOT total current members).\n\n### Inbound message metrics (SMS/WhatsApp received FROM end users)\n\nUse `inbound_messages.*` for replies and keyword commands (STOP, HELP). Message bodies and phone numbers are not exposed. ~70% of senders resolve to no profile, so people counts cover resolved senders only — always say so.\n\n`inbound_received`, `people_replied` (resolved profiles only), `replies_to_deliveries` (inbound messages linked back to a specific outbound delivery).\n\n### Incoming webhook metrics (webhook-triggered campaign inflow)\n\nUse `webhook_events.*` for \"how many webhook payloads did my webhook-triggered campaign receive\" — the closest available proxy for that campaign type's entry volume. **NOT the webhook delivery channel** — outbound webhook sends are `deliveries.sent` with `channel = 'webhook'`. Never mix the two in one number.\n\n`webhooks_received`, `distinct_webhook_events` (distinct incoming event names).\n\n## Dimensions\n\nReference these as `\u003ctable\u003e.\u003cdimension_name\u003e` in the DIMENSIONS clause exactly as shown — the dimension name is not always the base-table column name; unaliased output drops the table prefix (`deliveries.metric_name` → `metric_name`; § Composing).\n\nEvery table also exposes `\u003ctable\u003e.workspace_id` (output `workspace_id`) — the REQUIRED filter on every query, subqueries included. **Legend: †** = populated only for events on/after **2026-07-22** (NULL before — constrain those analyses to that range). Always constrain every fact table's date dimension to a range (prunes the scan); `person_id` dimensions are high-cardinality — scope tightly.\n\n**Common mistakes that cause SQL compilation errors:**\n- `event_name` on delivery events ✗ — they carry `metric_name` (`'sent'`, `'opened'`, ...); `event_name` exists only on `attributed_events` (custom track events).\n- `broadcasts.broadcast_name` / `campaigns.campaign_name` ✓ — NOT `broadcasts.name` / `campaigns.name` (`name` is the base column, not the dimension name)\n- `deliveries.sent` ✓ — NOT `delivery_events.sent` (the table alias in this SV is `deliveries`, never `delivery_events`)\n- Sibling-SV members ✗ — `profile_changes.profiles_created` etc. do not exist in `sv_deliverability` (`000904`); profile/object/pipeline/page-view questions go to their own skill's SV; combine results in prose, never one query.\n- `deliveries.from_address` / `sender` / `reply_to` ✗ — no sender-address dimension exists; from-address breakdowns are unanswerable (§ Cannot Answer).\n\n| DIMENSIONS reference | Output column (unaliased) | Description |\n|---|---|---|\n| `deliveries.occurred_date` | `occurred_date` | Event date — **NOT** the send date; grouping rates by it mixes send cohorts. |\n| `deliveries.send_date` | `send_date` | Send-cohort date — a delivery's events group back to its send day. |\n| `deliveries.channel` | `channel` | `email`, `push`, `sms`, `in_app`, `webhook`, `slack`, `line`, `inbox`, `whatsapp`, `urban_airship` |\n| `deliveries.source_type` | `source_type` | `campaign`, `broadcast`, `transactional`, `other` |\n| `deliveries.metric_name` | `metric_name` | Raw metric name — prefer the named metrics |\n| `deliveries.is_machine` | `is_machine` | `TRUE` for bot/scanner opens and clicks (Apple MPP etc.) |\n| `deliveries.is_synthetic` | `is_synthetic` | `TRUE` for system-generated metrics |\n| `deliveries.person_id` | `person_id` | Internal person ID — group by to list WHO opened/clicked/bounced. |\n| `deliveries.inbox_provider` | `inbox_provider` | Mailbox provider on delivered/bounced/spammed email events †. For provider RATES use `messages.inbox_provider` (Ex 11). |\n| `deliveries.inbox_domain` | `inbox_domain` | Recipient email domain †. High cardinality — prefer `inbox_provider`. |\n| `deliveries.mx_host` | `mx_host` | MX host that accepted/rejected the message †. |\n| `deliveries.email_client` | `email_client` | Email client of an open †; `Apple Mail Protected` = MPP proxy, not a real open. |\n| `deliveries.device_type` | `device_type` | Device class of an open/click † (`Mobile`/`Desktop`/`Tablet`/`Bot`/`Unrecognized`); NULL for proxied opens — pair with `is_machine = FALSE`. |\n| `deliveries.link_url` | `link_url` | Destination URL of a clicked link † (Ex 12). |\n| `deliveries.link_id` | `link_id` | Stable numeric ID behind `link_url`; NULL for untracked links. |\n| `deliveries.reason` | `reason` | Cause paired with the metric †: SMTP reply for `bounced` (4.x.x soft / 5.x.x hard), suppression cause for dropped/undeliverable, render/send error for failed. |\n| `deliveries.frequency_cap_name` | `frequency_cap_name` | Frequency cap that suppressed the message † (undeliverable events only); NULL otherwise. |\n| `deliveries.occurred_at` | `occurred_at` | Full event timestamp (US Pacific) — sub-day analysis. |\n| `deliveries.occurred_hour` | `occurred_hour` | Hour (0-23, US Pacific) the event occurred. |\n| `deliveries.send_hour` | `send_hour` | Hour (0-23, US Pacific) the message was sent. |\n| `deliveries.content_id` | `content_id` | A/B variant ID; 0 = not in a test. |\n| `campaigns.campaign_id` | `campaign_id` | Campaign (automation) ID, as in app URLs and the API. |\n| `campaigns.campaign_name` | `campaign_name` | Campaign display name |\n| `campaigns.campaign_type` | `campaign_type` | Campaign type label (e.g. `SegAttr`, `Event`) |\n| `campaigns.campaign_tag_ids` | `campaign_tag_ids` | Array of tag IDs on the campaign — filter `ARRAY_CONTAINS(\u003ctag_id\u003e::VARIANT, …)`; resolve names via `tags`. Alias it (Ex 15). |\n| `actions.action_id` | `action_id` | Campaign action (step) ID. |\n| `actions.action_name` | `action_name` | Campaign action (step) display name |\n| `actions.action_type` | `action_type` | `email`, `sms`, `push`, `in_app`, `slack`, `webhook`, `urban_airship`, `line`, `whatsapp`, `inbox` are message-producing; `delay`, `time_window`, `ab_split`, `conditional_branch`, `exit`, … are flow-control. |\n| `actions.action_tracked` | `action_tracked` | Whether opens/clicks are tracked for this step. Steps with tracking disabled report no opens/clicks by design — check before interpreting zero counts. |\n| `actions.action_holdout` | `action_holdout` | `TRUE` if this step is a holdout (control) group — no message sent. |\n| `actions.action_enabled` | `action_enabled` | Whether this step is currently enabled. |\n| `broadcasts.newsletter_id` | `newsletter_id` | Broadcast ID. |\n| `broadcasts.broadcast_name` | `broadcast_name` | Broadcast display name |\n| `broadcasts.broadcast_tracked` | `broadcast_tracked` | Whether opens/clicks are tracked for this broadcast |\n| `broadcasts.broadcast_tag_ids` | `broadcast_tag_ids` | Array of tag IDs on the broadcast — same containment pattern as campaigns. |\n| `templates.template_name` | `template_name` | Template name |\n| `templates.subject_line` | `subject_line` | Email subject line |\n| `templates.template_language` | `template_language` | Language code |\n| `transactional_messages.transactional_name` | `transactional_name` | Transactional message name |\n| `messages.send_date` | `send_date` | Send-cohort date of the message — always constrain. |\n| `messages.channel` | `channel` | Delivery channel. |\n| `messages.source_type` | `source_type` | `campaign`, `broadcast`, `transactional`, `other`. |\n| `messages.person_id` | `person_id` | Person the message was sent to. |\n| `messages.created_at` | `created_at` | Delivery creation timestamp (US Pacific) — when the person reached the message step, NOT necessarily when the message went out (§ Send pacing). |\n| `messages.send_hour` | `send_hour` | Hour (0-23, US Pacific) — THE \"best send hour\" dimension (Ex 13). Fine for engagement-by-hour; do NOT use it to verify pacing — when frequency/rate limits hold a send back, creation and dispatch hours diverge and this single dimension cannot show which it reflects (§ Send pacing). |\n| `messages.content_id` | `content_id` | A/B variant ID at message grain; 0 = not in a test. |\n| `messages.inbox_provider` | `inbox_provider` | Mailbox provider † — THE dimension for provider rates (Ex 11); NULL if never delivered/bounced. |\n| `messages.bounce_reason` | `bounce_reason` | SMTP reply explaining the bounce (4.x.x soft / 5.x.x hard) †; NULL if not bounced. |\n| `messages.was_delivered` | `was_delivered` | `TRUE` if delivered. |\n| `messages.was_opened` | `was_opened` | `TRUE` if ever opened — `FALSE` = \"sent but never opened\". |\n| `messages.was_human_opened` | `was_human_opened` | `TRUE` if ever human-opened (excludes machine/prefetch). |\n| `messages.was_clicked` | `was_clicked` | `TRUE` if ever clicked. |\n| `messages.was_converted` | `was_converted` | `TRUE` if credited a conversion — `FALSE` = \"received but did not convert\". |\n| `messages.was_bounced` | `was_bounced` | `TRUE` if bounced. |\n| `attributed_events.occurred_date` | `occurred_date` | Custom-event date — only the last 90 days exist; always constrain. |\n| `attributed_events.event_name` | `event_name` | Customer-defined event name (e.g. `purchase`). |\n| `attributed_events.person_id` | `person_id` | Person who performed the event. |\n| `attributed_events.was_attributed` | `was_attributed` | `TRUE` if a message preceded the event within 30 days (last-touch). |\n| `attributed_events.attributed_channel` | `attributed_channel` | Channel of the attributed (most recent prior) message; NULL if unattributed. |\n| `attributed_events.hours_since_message` | `hours_since_message` | Hours from attributed send to event (0–720) — filter to tighten the window (e.g. `\u003c= 72`). |\n| `attributed_events.ingress_source_type` | `ingress_source_type` | Ingress source — HOW the event entered Customer.io: e.g. `track.customer.io`, `attribute_update_action`. NULL before 2026-08-18 (§ Custom event metrics). |\n| `attributed_events.ingress_source_subtype` | `ingress_source_subtype` | Subtype qualifier of the ingress source, when set. |\n| `attributed_events.ingress_source_environment_id` | `ingress_source_environment_id` | Workspace the ingress source acted FROM — can differ from `workspace_id`; scopes the ingress campaign/action ids. |\n| `attributed_events.ingress_source_campaign_id` | `ingress_source_campaign_id` | Campaign the ingress source acted for — NOT the attributed campaign; no `campaigns` relationship; cross-workspace names unresolvable. |\n| `attributed_events.ingress_source_action_id` | `ingress_source_action_id` | Campaign action (step) the ingress source acted for. |\n| `attributed_events.ingress_source_import_id` | `ingress_source_import_id` | CSV/SQL import behind the ingress source; NULL when not an import. |\n| `attributed_events.cdp_source_id` | `cdp_source_id` | Data Pipelines (CDP) source id; NULL unless via Data Pipelines. |\n| `attributed_events.cdp_source_type` | `cdp_source_type` | Data Pipelines source type (`javascript`, `snowflake`, `http`, …). |\n| `attributed_events.cdp_source_name` | `cdp_source_name` | Data Pipelines source display name. |\n| `segment_events.occurred_date` | `occurred_date` | Date of the membership change — always constrain; full history is very large. |\n| `segment_events.membership_change` | `membership_change` | `entered`/`exited` — prefer the `entries`/`exits`/`people_*` metrics. |\n| `segment_events.person_id` | `person_id` | Person ID of the membership change. |\n| `segments.segment_name` | `segment_name` | Segment display name. |\n| `segments.segment_type` | `segment_type` | `data_driven` (condition-based), `manual` (static), `temporary`, `hidden`. |\n| `segments.segment_archived` | `segment_archived` | Whether the segment is archived. |\n| `tags.tag_id` | `tag_id` | Numeric tag ID — the values inside the `tag_ids` arrays. |\n| `tags.tag_name` | `tag_name` | Tag display name — resolve name → `tag_id` here (step 1 of tag filtering). |\n| `tags.tag_deleted` | `tag_deleted` | `TRUE` if the tag was deleted. |\n| `inbound_messages.occurred_date` | `occurred_date` | Date received — always constrain. |\n| `inbound_messages.channel` | `channel` | `sms` or `whatsapp`. |\n| `inbound_messages.keyword` | `keyword` | Parsed keyword/command (`STOP`, `HELP`, …); NULL for free-form — group `inbound_received` by this. |\n| `inbound_messages.person_id` | `person_id` | Resolved profile; NULL for unresolved senders (~70%). |\n| `webhook_events.occurred_date` | `occurred_date` | Date received — always constrain. |\n| `webhook_events.webhook_event_name` | `webhook_event_name` | Customer-defined incoming webhook event name — group `webhooks_received` by this. |\n\n## Submitting queries — escaping rules\n\n**Pre-flight — check all six before every submit** (details in § Composing):\n\n0. The query is adapted from the matching `deliverability_examples.md` example — if you haven't read that file, read it now.\n1. Every member appears in this file's tables — no guessed names, no members from sibling SVs; ids only where listed (`000904`).\n2. `table.member` only inside `SEMANTIC_VIEW()`; plain output names outside; same-named dims aliased apart (`000904`/`002028`).\n3. Every outer-query column was requested in METRICS/DIMENSIONS (`000904`).\n4. Aggregating ⇒ GROUP BY every non-aggregate, or don't re-aggregate (`000979`); aggregate filters in HAVING, never WHERE (`000990`/`002035`).\n5. One fact table per call, related dimensions only (`010959`); `tags` lookups DIMENSIONS-only.\n\nEvery query in this skill runs through the Snowflake query endpoint:\n\n```\nPOST /v1/environments/:environment_id/snowflake_query\n```\n\n**Never inline SQL into the `--json` payload or hand-escape it** — the SQL's own single quotes, `$WORKSPACE_ID`, and newlines arrive mangled and fail with `SQL compilation error`. Build the SQL in a **quoted heredoc**, then let `--arg` own the JSON escaping:\n\n```bash\nSQL=$(cat \u003c\u003c'EOF'\nSELECT campaign_name, sent, unique_human_open_rate\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS deliveries.sent, deliveries.unique_human_open_rate\n    DIMENSIONS campaigns.campaign_name AS campaign_name, deliveries.workspace_id, deliveries.channel\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND channel = 'email'\nEOF\n)\ncio api /v1/environments/$ENV_ID/snowflake_query -X POST --timeout 120s \\\n  --arg query=\"$SQL\" --json '{query: $query}'\n```\n\n- **Always pass `--timeout 120s`.** The CLI aborts at its 30s default with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` while the server is still running the query (warehouse statement limit 90s, route budget 120s). That error is the client giving up, not the query failing — resend the same SQL with `--timeout 120s` before narrowing anything.\n\n- **Quote the heredoc delimiter (`\u003c\u003c'EOF'`).** `$WORKSPACE_ID` is a *Snowflake session variable* and must reach the server literally; an unquoted heredoc expands it to empty — the query errors or matches nothing.\n\n- **Zero escape sequences in the SQL** — none are needed with heredoc + `--arg`. A single quote inside a SQL literal is doubled (`ILIKE '%don''t%'`), never backslash-escaped.\n\n- **Build and submit in ONE bash invocation** — `/tmp` does not persist between tool calls; no `node`/`python` side-trips.\n\n- **Aggregate in SQL, never in bash/node afterwards.** Ranking, pivoting, rollups, averages belong in the query. Post-processing pipes fail on quoting/packet limits — never present numbers whose post-processing errored without saying so.\n\n- **On `SQL compilation error`: fix the SQL and retry this same endpoint** — never resubmit unchanged SQL (re-check the pre-flight list first). Do NOT fall back to the `metrics/analysis` endpoint — it computes metrics differently and contradicts mid-analysis. If two fixes still fail, tell the user instead of switching data sources.\n\n- **`unexpected 'X'` usually means an unbalanced paren or quote *earlier* in the statement** — Snowflake points at the next token. Rewrite the full statement cleanly and resubmit.\n\n### Query limits\n\n- Results are capped at **10,000 rows** — check `\"truncated\": true` and add filters or `LIMIT`\n- **Two different timeouts.** `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` is the CLI's 30s default — resend unchanged with `--timeout 120s`. A warehouse-side `Statement reached its statement or warehouse timeout` (90s) is the query itself being too slow — verify `workspace_id = TO_NUMBER($WORKSPACE_ID)` is in the outer WHERE; then narrow the date range or add filters\n\n### Schema Introspection\n\nRaw table columns: `DESCRIBE TABLE ANALYTICS.CIO_AGENT.fct_delivery_events;` (`TABLE` keyword required with dotted names).\n\nTo inspect the SV itself — ONLY when a documented member errors as unknown, never as a routine pre-step — submit `DESCRIBE SEMANTIC VIEW ANALYTICS.CIO_AGENT.sv_deliverability` through the same endpoint, appending (unfiltered output is huge):\n\n```\n--jq '[.rows[] | select(.property == \"COMMENT\" or .property == \"EXPRESSION\") | {kind: .object_kind, name: .object_name, value: .property_value}]'\n```\n\n## Examples\n\nEx 1–17 live in **deliverability_examples.md** — reading it before composing is the precondition at the top of § Composing queries, not optional reference material.\n\n## Answering\n\n### State your definitions\n\nEvery answer must state:\n\n- **Time window** used, and timezone assumption for \"today\" / \"yesterday\"\n- **Denominator and rate family** — unique rates use `unique_sent` (distinct people), event rates use `sent`; the product UI may use `delivered`. Say which was used and why.\n- **Scope narrowing** applied (e.g. email only, sent ≥ 100)\n- **Proxy substitution** — if the schema cannot answer directly, name the proxy and its limits; never present a proxy as a direct measurement\n\n### Interpretation guidance\n\nCompare against the workspace's own history — never industry benchmarks. Change from baseline: \u003c 5% normal fluctuation, 5–15% monitor, 15–30% investigate, \u003e 30% flag immediately. Flag entities with fewer than 100 sends. The signal table is **email only** — other channels have no fixed thresholds; compare within the same channel.\n\n| Email signal | Likely cause | Suggest |\n|---|---|---|\n| `delivery_rate` \u003c 95% | Reputation, list quality, auth | Check bounce breakdown |\n| `bounce_rate` \u003e 2% | Invalid addresses, domains | Split hard vs soft via `reason` (4.x.x/5.x.x) |\n| `spam_rate` \u003e 0.1% | Content, frequency, sourcing | Review content and cadence |\n| `unique_human_open_rate` \u003c 15% | Subject line, reputation | Compare to workspace baseline |\n| `human_ctor` \u003c 10% | CTA, content relevance | Review template content |\n| `unsubscribe_rate` \u003e 0.5% | Frequency fatigue | Check send cadence |\n\n## What This Skill Cannot Answer\n\n- **Current segment membership roster** (\"who is in segment X right now\") — `fct_segment_events` is a change-event log, not a snapshot; netting full history is too expensive. The current roster comes from the profiles list filtered by segment (`segments.md` § Reading Profiles in a Segment); offer period change counts (`entries`/`people_entered`) only when the user asked about change over time.\n- **Person-level lists with name/email** — `person_id` dimensions return internal IDs only; use activity log endpoints for exports (`inbox_domain` gives the domain, never the address).\n- **Journey entry counts** — proxy is `unique_sent` (undercounts; Ex 7); for webhook-triggered campaigns `webhooks_received` is a better proxy (Ex 17).\n- **Inbound message bodies or sender phone numbers** — `inbound_messages` exposes counts, channel, and keyword only.\n- **Suppression-list export** — `reason` answers \"why suppressed\" go-forward only; a full export or pre-2026-07-22 causes needs the suppression API endpoints.\n- **Sender reputation / authentication** — not in this data (per-provider delivery signal is not a reputation score).\n- **From-address / sender breakdowns** — sender identity never reaches the warehouse; closest scoping is campaign/broadcast/transactional.\n- **Real-time data** — ~1–5 min event-stream staleness.\n- **Anything provider / device / link / reason–specific before 2026-07-22** — † dimensions are go-forward only; older rows NULL.\n- **Pipeline failures, profile lifecycle/attribute changes, current profile attributes, objects and memberships, page views** — different SVs: see `pipeline_health.md`, `profile_changes.md`, `profile_state.md`, `object_state.md`, `object_changes.md`, `site_activity.md`. \"Opened page X\" is a page VIEW (site_activity), never an email open.\n\n### Previously \"cannot answer\" — now answerable\n\nProvider/link/device/reason breakdowns (†), sub-day timing, A/B variants, tags, inbound replies, webhook inflow (Ex 11-17) — do not refuse from stale memory.\n","deliverability_examples.md":"---\nminimum_plan: essentials\n---\n\n# Deliverability Analytics — Examples\n\nWorked `SEMANTIC_VIEW()` queries for **deliverability.md**. Read that file FIRST — the grain/date/rate doctrine (§ Choosing), composing and escaping rules, and the metric/dimension tables all live there, and every `§` reference below points into it. Adapt the matching example; never compose a query from scratch.\n\nStart from the example matching the question's shape — each carries the workspace/grain/date/rate choices from § Choosing:\n\n| Question shape | Start from |\n|---|---|\n| Single campaign / broadcast funnel | Ex 1 |\n| Windowed ranking (\"top N by … last 30 days\") | Ex 2 (campaigns, conversion), 4 (subject lines) |\n| A/B variant comparison | Ex 14 |\n| Tag-filtered campaigns/broadcasts | Ex 15 (two-step) |\n| Trend over months | Ex 3 |\n| Best send time / sub-day timing | Ex 13 |\n| Per-step / per-email breakdown | Ex 4 |\n| Workspace-wide aggregate or by channel | Ex 5 |\n| Health audit (bounce/spam thresholds) | Ex 6 |\n| Reach / distinct people | Ex 7 |\n| Absence (\"delivered, never opened\") | Ex 8 |\n| Message → behavior funnel | Ex 9 |\n| Segment growth | Ex 10 |\n| By mailbox provider | Ex 11 |\n| Link-level clicks | Ex 12 |\n| Replies / STOP keywords received | Ex 16 |\n| Webhook-triggered campaign inflow | Ex 17 |\n\n### 1. Single campaign performance — full funnel\n\n```sql\nSELECT campaign_name, sent, unique_sent, delivery_rate, unique_human_open_rate,\n        unique_human_click_rate, unique_conversion_rate, bounce_rate, unsubscribe_rate\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS deliveries.sent, deliveries.unique_sent, deliveries.delivery_rate,\n        deliveries.unique_human_open_rate, deliveries.unique_human_click_rate,\n        deliveries.unique_conversion_rate, deliveries.bounce_rate, deliveries.unsubscribe_rate\n    DIMENSIONS campaigns.campaign_name AS campaign_name, deliveries.workspace_id, deliveries.channel\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND channel = 'email'\n  AND campaign_name ILIKE '%welcome%'\n```\n\nCampaign resolved from a URL/ID? Add `campaigns.campaign_id AS campaign_id` to DIMENSIONS and swap the ILIKE for `campaign_id = \u003cid\u003e`. For a broadcast, filter `broadcasts.broadcast_name` (or `broadcasts.newsletter_id`) instead.\n\n### 2. Top 10 campaigns by unique human open rate (last 30 days)\n\nThe canonical windowed ranking: `unique_*` rate (bounded, distinct people), `send_date` window (§ Choosing #2 — the ranking is over what was SENT in the period), `sent \u003e= 100` floor.\n\n```sql\nSELECT campaign_name, sent, unique_sent, unique_human_opens, unique_human_open_rate\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS deliveries.sent, deliveries.unique_sent, deliveries.unique_human_opens,\n        deliveries.unique_human_open_rate\n    DIMENSIONS campaigns.campaign_name AS campaign_name, deliveries.workspace_id, deliveries.channel\n    WHERE deliveries.send_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND channel = 'email'\n  AND sent \u003e= 100\nORDER BY unique_human_open_rate DESC\nLIMIT 10\n```\n\nFor a conversion ranking, swap the ranked metric to `deliveries.unique_conversion_rate` — same window and floor.\n\n### 3. Period comparison — delivery rate by month\n\nGroup by month in the outer SELECT using volume counts (`sent`, `delivered`). Do not use rate metrics for this — rates cannot be re-averaged across daily rows. For unique rate month-over-month, group by month via the `send_date` dimension instead.\n\n```sql\nSELECT DATE_TRUNC('month', occurred_date) AS month, broadcast_name, SUM(sent) AS monthly_sent,\n        SUM(delivered) AS monthly_delivered, ROUND(SUM(delivered)::FLOAT / NULLIF(SUM(sent),\n        0) * 100, 1) AS delivery_rate_pct\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS deliveries.sent, deliveries.delivered\n    DIMENSIONS broadcasts.broadcast_name AS broadcast_name, deliveries.occurred_date,\n        deliveries.workspace_id, deliveries.channel\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND channel = 'email'\n  AND occurred_date \u003e= '2026-03-01'\n  AND occurred_date \u003c '2026-05-01'\nGROUP BY month, broadcast_name\nHAVING SUM(sent) \u003e= 100\nORDER BY broadcast_name, month\n```\n\n### 4. Per-step and subject-line breakdown for a campaign\n\nEx 1's funnel plus `actions.action_name AS action_name` in DIMENSIONS (add `deliveries.human_ctor` to METRICS), `ORDER BY sent DESC`. Check `action_tracked` / `action_holdout` before interpreting zero engagement on a step. For subject lines add `templates.subject_line` and rank by `unique_human_open_rate`; for \"last N days\" add a `deliveries.send_date` window in the inner WHERE (§ Choosing #2).\n\n### 5. Workspace-wide aggregate — by channel or source type\n\nSame funnel metrics as Ex 1 with NO name dimension — only `deliveries.workspace_id`, `source_type`, `channel` in DIMENSIONS and an inner `occurred_date` window; one row per channel × source_type. Pin `source_type = 'broadcast'` and `channel = 'email'` for a single aggregate row. Never compare rates across channels.\n\n### 6. Deliverability health audit — campaigns above bounce or spam threshold\n\nEx 1's shape with `delivery_rate`, `bounce_rate`, `spam_rate`, `unsubscribe_rate` in METRICS and the thresholds as outer filters — rate metrics are valid outer-WHERE predicates:\n\n```sql\n  AND sent \u003e= 100\n  AND (bounce_rate \u003e 0.02 OR spam_rate \u003e 0.001)\nORDER BY bounce_rate DESC\n```\n\n### 7. Campaign reach — distinct people messaged\n\n`deliveries.unique_sent` (alias it `people_messaged`) + `deliveries.sent` by `campaign_name`. No volume floor — floors guard rate comparisons; reach counts matter even when small. `unique_sent` is the best proxy for \"people who entered this campaign\" but undercounts people who exited before a message step — say so.\n\n### 8. Absence query — campaigns with high delivered-but-never-opened rate\n\nDelivery grain makes absence directly queryable — no raw SQL needed.\n\n```sql\nSELECT campaign_name, messages_sent, messages_delivered_not_opened,\n        ROUND(messages_delivered_not_opened::FLOAT / NULLIF(messages_sent, 0) * 100,\n        1) AS pct_not_opened, avg_minutes_to_open\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS messages.messages_sent, messages.messages_delivered_not_opened,\n        messages.avg_minutes_to_open\n    DIMENSIONS campaigns.campaign_name AS campaign_name, messages.workspace_id, messages.channel\n    WHERE messages.send_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND channel = 'email'\n  AND messages_sent \u003e= 100\nORDER BY pct_not_opened DESC\nLIMIT 10\n```\n\n### 9. Attribution funnel — people who did event X after receiving a campaign\n\nLast-touch, any-channel, 30-day-cap attribution — a person counted here received a message within 30 days before the event, not necessarily because of it; state this when reporting.\n\n```sql\nSELECT campaign_name, event_name, people_did_event, people_attributed,\n        ROUND(people_attributed::FLOAT / NULLIF(people_did_event, 0) * 100,\n        1) AS pct_message_influenced, avg_hours_since_message\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS attributed_events.people_did_event, attributed_events.people_attributed,\n        attributed_events.avg_hours_since_message\n    DIMENSIONS campaigns.campaign_name AS campaign_name, attributed_events.event_name,\n        attributed_events.workspace_id\n    WHERE attributed_events.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n      AND attributed_events.event_name = 'purchase'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY people_attributed DESC\nLIMIT 10\n```\n\n### 10. Segment growth — weekly entries, exits, and net change\n\n`entries`/`exits`/`net_change` are sum-safe (daily rows roll up to weeks). `people_entered`/`people_exited` are COUNT DISTINCT and cannot be re-summed — omit them here, or drop the outer GROUP BY for exact daily uniques.\n\n```sql\nSELECT segment_name, DATE_TRUNC('week', occurred_date) AS week, SUM(entries) AS weekly_entries,\n        SUM(exits) AS weekly_exits, SUM(net_change) AS weekly_net_change\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS segment_events.entries, segment_events.exits, segment_events.net_change\n    DIMENSIONS segments.segment_name AS segment_name, segment_events.occurred_date,\n        segment_events.workspace_id\n    WHERE segment_events.occurred_date \u003e= DATEADD('day', -90, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND segment_name ILIKE '%high value%'\nGROUP BY segment_name, week\nORDER BY segment_name, week\n```\n\n### 11. Open rate by mailbox provider (Gmail vs non-Gmail)\n\nProvider is only known once a message is **delivered**: provider rates go through the **message grain** (`messages.inbox_provider`, NULL before 2026-07-22 — constrain the range), never event-grain opens.\n\n**Always report the human open rate alongside the raw one.** Machine opens (Apple MPP prefetch) inflate `message_open_rate` *differentially by provider*, so raw-rate-only comparisons rank providers misleadingly. There is no governed human message-grain rate: pull `messages.was_human_opened` as a DIMENSION and derive it in the outer query:\n\n```sql\nSELECT inbox_provider, SUM(messages_delivered) AS delivered,\n        ROUND(SUM(messages_opened)::FLOAT / NULLIF(SUM(messages_delivered), 0) * 100, 1) AS open_rate_pct,\n        ROUND(SUM(IFF(was_human_opened, messages_delivered, 0))::FLOAT\n             / NULLIF(SUM(messages_delivered), 0) * 100, 1) AS human_open_rate_pct\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS messages.messages_delivered, messages.messages_opened\n    DIMENSIONS messages.inbox_provider, messages.was_human_opened, messages.workspace_id,\n        messages.channel\n    WHERE messages.send_date \u003e= '2026-07-22'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND channel = 'email'\nGROUP BY inbox_provider\nHAVING SUM(messages_delivered) \u003e= 100\nORDER BY delivered DESC\n```\n\n\u003e `messages.was_human_opened` MUST be in DIMENSIONS — it splits each provider's delivered volume, so the rates are derived in the outer query (per-bucket governed rates cannot be re-averaged). No human split needed? Select the governed metrics directly by `messages.inbox_provider`, no GROUP BY.\n\nA wide human-vs-raw gap on one provider is MPP/proxy inflation there, not better engagement. Clicks carry the same caveat — `messages_clicked` counts machine/scanner clicks too; note that when click comparisons drive a decision.\n\n### 12. Most clicked links in a campaign\n\nGroup `deliveries.human_clicked` by `deliveries.link_url AS href` (plus `campaign_name`, workspace, channel dims), inner `deliveries.occurred_date \u003e= '2026-07-22'` (NULL before), outer `href IS NOT NULL`, `ORDER BY human_clicked DESC LIMIT 20`.\n\n### 13. Best time to send — open rate by send hour and day of week\n\nMessage grain, reusing Ex 11's IFF mechanic: METRICS `messages.messages_delivered`; DIMENSIONS `messages.send_hour`, `messages.send_date`, `messages.was_human_opened` (+ workspace, channel), inner 90-day `send_date` window; outer `GROUP BY send_hour, DAYOFWEEK(send_date)` (0 = Sunday) deriving `SUM(IFF(was_human_opened, messages_delivered, 0)) / NULLIF(SUM(messages_delivered), 0)`, with `HAVING SUM(messages_delivered) \u003e= 100`. Hours are US Pacific — say so.\n\n### 14. A/B test variant comparison\n\nEx 1's funnel plus `deliveries.content_id` in DIMENSIONS and outer `content_id != 0` (0 = not in a test), `ORDER BY content_id`. Compare variants on `unique_*` rates.\n\n### 15. Campaigns tagged 'Lifecycle' — two-step tag filter\n\nTag arrays can't be joined relationally. **Step 1** — resolve the name to `tag_id`: DIMENSIONS `tags.tag_id`, `tags.tag_name AS name`, `tags.tag_deleted AS is_deleted`, `tags.workspace_id` (no METRICS), outer `name ILIKE '%lifecycle%' AND NOT is_deleted`. **Step 2** — filter by containment:\n\n```sql\nSELECT campaign_name, sent, unique_sent, unique_human_open_rate, unique_human_click_rate\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_deliverability\n    METRICS deliveries.sent, deliveries.unique_sent, deliveries.unique_human_open_rate,\n        deliveries.unique_human_click_rate\n    DIMENSIONS campaigns.campaign_name AS campaign_name, campaigns.campaign_tag_ids AS tag_ids,\n        deliveries.workspace_id, deliveries.channel\n    WHERE deliveries.send_date \u003e= DATEADD('day', -365, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND channel = 'email'\n  AND ARRAY_CONTAINS(42::VARIANT, tag_ids) -- 42 = tag_id from step 1\n  AND sent \u003e= 100\nORDER BY sent DESC\n```\n\n### 16. Inbound replies and STOP keywords (SMS/WhatsApp)\n\nGroup `inbound_messages.inbound_received`, `people_replied`, `replies_to_deliveries` by `inbound_messages.channel` and `keyword` with an inner `occurred_date` window. `keyword IS NULL` rows are free-form replies; `keyword = 'STOP'` volume is the opt-out signal. `people_replied` covers resolved profiles only (~30% of senders) — always state that. For a reply rate, get the denominator from `deliveries.sent` (`channel = 'sms'`) in a SEPARATE query — the grains differ.\n\n### 17. Incoming webhook inflow for webhook-triggered campaigns\n\nGroup `webhook_events.webhooks_received` by `campaigns.campaign_name` and `webhook_events.webhook_event_name` with an inner `occurred_date` window. Trigger INFLOW — the closest proxy for a webhook-triggered campaign's entry volume — NOT the outbound webhook channel (`deliveries.sent`, `channel = 'webhook'`).\n","experiment_exposure_segment_condition.md":"---\nminimum_plan: essentials\nfeature_flag: experiment-action\n---\n\n# Experiment Exposure Segment Condition\n\nA segment condition (`segment_conditions.md`) that matches on whether a profile has been\nexposed to an Experiment (`experiments.md`), optionally scoped to one variant. Read\n`segment_conditions.md` first for the general condition/filter format this fits into.\n\n## Wire shape\n\n```json\n{\n  \"event\": {\n    \"type\": \"experiment_exposure\",\n    \"name\": \"exposed\",\n    \"filters\": {\n      \"and\": [\n        {\"field\": \"experiment_id\", \"operator\": \"eq\", \"value\": \"42\"},\n        {\"field\": \"variant_id\", \"operator\": \"eq\", \"value\": \"\u003cVARIANT_ID\u003e\"}\n      ]\n    }\n  },\n  \"times\": 1,\n  \"within\": 0,\n  \"inverse\": false\n}\n```\n\n`experiment_id` is the numeric Experiment `id` sent as a string. `variant_id` is the\nAPI-returned variant ID (see `experiments.md`); never invent one.\n\nFour variants, all built from the same shape:\n\n- **Any experiment**: omit `filters` entirely.\n- **One experiment, any variant**: `filters` has only the `experiment_id` leaf.\n- **One experiment, one variant**: both leaves, either order.\n- **Has NOT been exposed**: any of the above with `inverse: true`.\n\nNo other filter shape is accepted. A filter naming a field other than `experiment_id`/\n`variant_id`, a `variant_id` leaf with no `experiment_id` leaf, or an `or`/`not`/compare\nshape is rejected when the segment is saved.\n\n## Constraints\n\n- `times` must be `1` and `within` must be `0`. This condition answers only \"has been\n  exposed\", not \"exposed N times\" or \"exposed within a window\"; a segment builder request\n  for a count or time window on this condition type is refused.\n- The Experiment must exist, and if `variant_id` is given it must be one of that\n  Experiment's variant IDs. The Experiment must not be a draft: a draft has no exposures yet.\n- Gated on the same `experiment-action` flag as the rest of the Experiment feature.\n- Not supported across a workspace clone.\n\nThe event `name` must be `exposed`. Any other name is rejected.\n","experiments.md":"---\nminimum_plan: essentials\nfeature_flag: experiment-action\n---\n\n# Experiments\n\nExperiments are first-class objects that measure conversion after a profile is exposed to\nvariant paths in an automation. They are attached to an automation through an\n`experiment_action` (via a new 'Test' action in the UI). The experiment object owns the variants,\nenrollment and observation windows, conversion criterion, results, and winner. The automation\nowns the graph and the actions on each variant path.\n\nFirst-class Experiments support three decision modes: `manual`, where a person picks the winner,\n`automatic_at_horizon`, where the engine completes the test at its horizon, and\n`automatic_on_significance`, where the engine completes the test on the first pass that names a\nvariant. `automatic_winner` is false.\n\nThis is a separate product from both kinds of A/B test that remain available:\n\n| Product | Identify it by | Use this for |\n|---|---|---|\n| Experiment | `/experiments` or an action with `type: \"experiment_action\"` | Variant allocation, exposure-based conversion measurement, enrollment/observation, results, and applying a winner |\n| Automation A/B test | An action with `type: \"split_randomized_action\"` | Splitting an existing message action and reading message delivery metrics; read `ab_tests.md` |\n| Broadcast A/B test | A one-time send with variations | Sampling broadcast content and sending the winning variation; read `newsletter_ab_tests.md` |\n\nWhen the user says “A/B test”, inspect the resource before choosing a workflow. When the user asks\nhow these products differ rather than asking to act on one, name every identifier the “Identify it\nby” column lists for each product being compared — for an Experiment that is both the `/experiments`\nresource and an `experiment_action` (Test node), against a `split_randomized_action` for an\nautomation A/B test — so the answer tells them what to look for in the API, not only how the\nfeatures differ. Do not use `split_metrics`, the A/B-test winner endpoint, or CTBO for an\n`experiment_action`. Conversely, do not turn a `split_randomized_action` into an Experiment just\nbecause both have variants. An\n`experiment` suggestion in outcome optimization is also not this product Experiment.\n\n## Create and configure an experiment\n\nRead the experiment first when editing or transitioning it. Experiment writes use optimistic\nconcurrency, so send the current top-level `version` and re-read after a transition or a rejected\nversion update.\n\nCreate a draft:\n\n```bash\ncio api /v1/environments/$ENV_ID/experiments -X POST \\\n  --json '{\"name\":\"Checkout subject line\"}'\n```\n\nThe response contains the numeric experiment `id`, a draft `status`, generated variant IDs, and\n`version`. Replace the draft configuration with `PUT /v1/environments/$ENV_ID/experiments/:experiment_id`\nand the complete body, not a patch.\n\nIn examples and explanations, represent those generated IDs as `\u003cVARIANT_A_ID\u003e`,\n`\u003cVARIANT_B_ID\u003e`, and so on. Never invent realistic-looking variant IDs or substitute labels such\nas `control` or `treatment` for an ID:\n\n```json\n{\n  \"name\": \"Checkout subject line\",\n  \"version\": 1,\n  \"config\": {\n    \"mode\": \"test\",\n    \"duration_days\": 14,\n    \"conversion_window_days\": 7,\n    \"conversion\": {\n      \"type\": \"event\",\n      \"event\": {\n        \"name\": \"purchase\",\n        \"event_type\": \"event\"\n      }\n    },\n    \"variants\": [\n      {\"id\": \"\u003cVARIANT_A_ID\u003e\", \"name\": \"Variant A\", \"role\": \"treatment\", \"allocation\": 50, \"version\": 1},\n      {\"id\": \"\u003cVARIANT_B_ID\u003e\", \"name\": \"Variant B\", \"role\": \"treatment\", \"allocation\": 50, \"version\": 1}\n    ],\n    \"test\": {\n      \"decision_mode\": \"manual\",\n      \"confidence_level\": 0.9,\n      \"power\": 0.8,\n      \"mde\": {\"type\": \"relative\", \"value\": 0.1},\n      \"automatic_winner\": false\n    }\n  }\n}\n```\n\nTo let the engine decide, set `decision_mode` to `automatic_at_horizon`. Add `on_inconclusive` to\nsay what happens when the evidence never names a winner: `defer_to_person` (the default),\n`reference` (the first variant), or `best_rate` (the highest observed rate).\n\nTo stop a test early, set `decision_mode` to `automatic_on_significance` and `scoring_method` to\n`sequential`. The sequential interval stays valid at every pass, and it can end a test early only\nfor a large effect, about 1.8 times the MDE. Do not promise a short test. An inconclusive test still\nends at the horizon, with `on_inconclusive` applied. Choose `scoring_method` before the experiment\nstarts, because an edit cannot change it later.\n\nConfiguration rules:\n\n- `mode` is `test`.\n- There are 2–4 variants. Variant IDs and names are non-empty and unique. IDs are stable once\n  the experiment is attached or running.\n- A new draft comes back with two variants. Preserve the IDs the API returned when editing an\n  existing experiment. Each variant has a `role`: `treatment`, `control`, or\n  `holdout`. A new draft comes back with `treatment` on both variants. Always send `role` on\n  every variant: the API rejects a variant with no role (`422 variant \"\u003cid\u003e\" has unsupported\n  role \"\"`). Send back the role you read, and give a variant you add `treatment`. At least one\n  variant must be a treatment, and at most one can be a holdout. A variant cannot become\n  `control` unless it already is one.\n- A holdout is an intentional no-message path. Use one only when the user asks for a group that\n  gets no message. Its path on the campaign graph must hold no actions, the fallback cannot be a\n  holdout, and an automatic `on_inconclusive` policy never picks it. To add a holdout, set\n  `\"role\": \"holdout\"` on one variant and keep that role on every later edit.\n- Allocations are non-negative integers totaling exactly 100. A variant can have 0 allocation,\n  but do not invent traffic for it.\n- `duration_days` is 1–90 and `conversion_window_days` is 1–30. For a span shorter than a\n  day, set `duration_hours` (1–2160) or `conversion_window_hours` (1–720): hours take\n  precedence, and the API sets the matching `_days` field to the hours rounded up to whole\n  days. To switch a span back to days, send its `_hours` field as 0 with the new `_days` value.\n  Leaving `conversion_window_hours` out also switches the window to days, but a config that\n  leaves `duration_hours` out keeps the stored hours while `duration_days` is unchanged.\n  The conversion criterion is optional. An experiment with no criterion must use\n  `decision_mode: \"manual\"`: starting it with any other decision mode fails. Without a\n  criterion, the results compare the variants on each delivery metric and a person picks the\n  winner.\n- A conversion criterion is one of two kinds. An event criterion uses `type: \"event\"`, an event\n  `name`, and `event_type: \"event\"`. Its optional conditions must inspect event properties only.\n  A delivery-metric criterion uses `type: \"delivery_metric\"` and a `delivery_metric.metric` that\n  names one delivery metric: `sent`, `delivered`, `opened`, `human_opened`, `prefetch_opened`,\n  `clicked`, `human_clicked`, `machine_clicked`, `converted`, `bounced`, `spammed`,\n  `unsubscribed`, `dropped`, `failed`, `undeliverable`, or `replied`.\n- A delivery-metric test, and a test with no criterion, need at least one message in the test.\n  Starting one with no message attached fails.\n- `decision_mode` is `manual`, `automatic_at_horizon`, or `automatic_on_significance`, and\n  `automatic_winner` must be false. `automatic_on_significance` requires `scoring_method: sequential`.\n  `on_inconclusive` accepts `defer_to_person`, `reference`, or `best_rate`. `confidence_level`,\n  `power`, and relative MDE value must be positive and within their accepted ranges.\n  `scoring_method` is `fixed_horizon` (the default) or `sequential`. An edit cannot change it after\n  the experiment starts.\n- Optional `test` fields: `exposures_per_variant` is an exposure target for each variant (at least 100).\n  Enrollment ends when every variant with allocation has reached it. `planned_exposures` and `planned_exposures_source`\n  (`target`, `suggestion`, `audience_estimate`, or `default`) hold the per-variant horizon that\n  the sequential scorer uses. Start sets them once, so a later edit of `exposures_per_variant`\n  does not move them. `baseline_rate` is the expected conversion rate as a percentage from 0 to\n  100.\n- `config.description` is optional text of at most 140 characters. The winner note is\n  the top-level `summary` field of the experiment, not part of `config`. It has at most 140\n  characters. Write it with `summary` on `choose_winner`.\n- A variant role cannot change after the experiment starts.\n- `version` on each variant starts at 1. Use the IDs returned by the API instead of making up\n  IDs when updating a draft created by the API.\n\nDeleting is only for an unattached draft. A draft can be detached from a campaign only through\nthe campaign graph update; do not use `DELETE` as a way to stop a live experiment.\n\n## Attach an experiment to an automation\n\nRead the complete automation once with `GET /v1/environments/$ENV_ID/campaigns/:campaign_id`.\nIts actions are in top-level `actions[]`; there is no `/campaigns/:id/actions` endpoint. Find the\n`experiment_action`, or add one through the campaign update. The experiment configuration stays\non the Experiment resource. The action references it and defines the path order:\n\n```bash\ncio api /v1/environments/$ENV_ID/campaigns/$CAMPAIGN_ID \\\n  --jq '.actions[] | select(.type == \"experiment_action\")'\n```\n\nNever filter `.campaign.actions[]`; actions are not nested under `campaign`.\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      {\"from\": \"-3\", \"to\": \"-2\", \"type\": \"branch\", \"index\": 0},\n      {\"from\": \"-3\", \"to\": \"-1\", \"type\": \"branch\", \"index\": 1},\n      {\"from\": \"-2\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"continue\"},\n      {\"from\": \"-1\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"continue\"}\n    ]\n  },\n  \"actions\": [\n    {\n      \"type\": \"experiment_action\",\n      \"name\": \"Checkout subject line test\",\n      \"experiment_id\": \"123\",\n      \"experiment_variant_ids\": [\"\u003cVARIANT_A_ID\u003e\", \"\u003cVARIANT_B_ID\u003e\"],\n      \"experiment_fallback_variant_id\": \"\u003cVARIANT_A_ID\u003e\"\n    },\n    {\n      \"type\": \"email_action\",\n      \"name\": \"Variant A message\",\n      \"sending_state\": \"draft\"\n    },\n    {\n      \"type\": \"email_action\",\n      \"name\": \"Variant B message\",\n      \"sending_state\": \"draft\"\n    }\n  ]\n}\n```\n\nThe example is illustrative: derive the full edge list and placeholder mapping from the actual\npost-save action set. Branch edge indexes preserve the Experiment variant order. `edges` replaces\nthe whole graph, so include every existing edge and the system `exit_action`. When configuring\nthe paths, add ordinary message/delay actions after the Test node and connect each variant path to\nthe merge point or exit. Do not put message content on the `experiment_action` itself.\n\nThe variant arms must not join at a shared action before the merge point. Each arm goes to the same\nmerge point as the holdout edge, or the holdout ends in its own new `exit_action`. Publish rejects\narms that do not converge. See \"Experiment holdout\" in `flow_control.md`.\n\nAttachment rules:\n\n- Experiment actions are supported on profile campaigns, not broadcasts. If the campaign type is\n  unsupported, explain that instead of falling back to a broadcast A/B test.\n- `experiment_variant_ids` must contain every experiment variant exactly once, in the same order\n  as the Experiment configuration. Re-read the experiment after changing its variant set before\n  saving the campaign graph.\n- `experiment_fallback_variant_id` must use the API-returned ID of a non-holdout variant. The\n  fallback is the path used when the experiment is paused, disabled, or otherwise not enrolling.\n  A holdout is an intentional no-message path, not a safe fallback. `stop` and `end_enrollment`\n  can change it with `fallback_variant_id` (see Lifecycle).\n- A live experiment cannot be replaced on a Test node. Replacing is allowed only while the old\n  experiment is a draft, and the new experiment must be draft or paused as required by the API.\n- A newly added, unconfigured Test node does not create a draft automatically. Create the\n  Experiment first, then attach its returned ID.\n- Campaign updates use the normal automation graph rules in `automations.md`: send all edges,\n  keep every action connected, and preserve existing fields required by the selected\n  `update_type`.\n\nTo find relationships, list experiments with `attached` and `status` filters, use\n`GET /v1/environments/$ENV_ID/campaigns/:campaign_id/experiments` for a campaign's history, or\ninspect the campaign's top-level `actions[]`. The UI detail route is\n`https://fly.customer.io/workspaces/$ENV_ID/experiments/:experiment_id`.\n\nWhen you explain an existing `experiment_action`, show `experiment_variant_ids` and\n`experiment_fallback_variant_id` in a short example with the returned-ID placeholders, for\nexample `\"experiment_variant_ids\": [\"\u003cVARIANT_A_ID\u003e\", \"\u003cVARIANT_B_ID\u003e\"]`.\n\n## Lifecycle\n\nThe API exposes `status` values `draft`, `running`, `paused`, `observing`, `ready_to_decide`,\n`completed`, `stopped`, and `dropped`. `observing` and `ready_to_decide` can be projected from\nthe enrollment and conversion-window deadlines, so compare the returned status and timestamps\nrather than calculating them from a stale response.\n\nTransitions use:\n\n```json\n{\"command\":\"pause\",\"version\":3}\n```\n\nPOST it to `/v1/environments/$ENV_ID/experiments/:experiment_id/transitions`. The supported\ncommands are `start`, `pause`, `resume`, `end_enrollment`, `extend_enrollment`, `stop`,\n`choose_winner`, and `drop`.\n\n- `start` validates the conversion (if any) and attachments, then begins enrollment for\n  `duration_days`. Starting the automation starts its attached draft experiment as part of the\n  campaign lifecycle.\n- Attached experiments support direct lifecycle transitions through this endpoint. If a command is\n  rejected, surface the API's exact validation error and stop. Do not stop or pause the automation\n  first, and do not change its state as a fallback or prerequisite for retrying the Experiment\n  transition. Changing the automation affects all campaign traffic and only pauses its experiment;\n  it never substitutes for terminally stopping the Experiment. Give the user that reason alongside\n  the error, so the answer says why a campaign change is not the resolution rather than only ruling\n  it out.\n- In particular, do not interpret a validation error that says the campaign owns the lifecycle as\n  an instruction to stop or pause the automation first. Report that exact error as the outcome of\n  the direct transition. Do not propose a campaign change, a different Experiment command, or a\n  retry as its resolution.\n- `pause` stops new enrollment. `resume` continues a paused experiment if its enrollment deadline\n  has not elapsed. Re-read after either operation.\n- `end_enrollment` closes enrollment early but leaves the conversion observation window. Use\n  `extend_enrollment` with `enrollment_ends_at` to move the deadline later.\n- After enrollment and the conversion window, plus the final scoring grace period, the experiment\n  becomes `ready_to_decide`. With manual decision mode, select a variant with `choose_winner` and\n  `variant_id`. `early: true` is available only when deliberately deciding before the normal\n  observation window ends.\n- `stop` ends the experiment without selecting a winner. `drop` abandons it. These are not\n  substitutes for deleting a draft or for applying a winner.\n- `stop` and `end_enrollment` accept an optional `fallback_variant_id` (a non-holdout variant ID),\n  saved as the Test node's `experiment_fallback_variant_id`. Other commands reject it.\n\nTransition example with a variant:\n\n```json\n{\n  \"command\": \"choose_winner\",\n  \"variant_id\": \"\u003cVARIANT_A_ID\u003e\",\n  \"version\": 7\n}\n```\n\nIf a write returns a version conflict, GET the experiment again and retry only with the new\nversion after checking that the requested transition is still appropriate.\n\n## Inspect exposures and results\n\n`GET /v1/environments/$ENV_ID/experiments/:experiment_id` includes the latest results when\navailable, attachments, and exposure counts: `exposures_by_variant` (`variant_id`, `exposed`),\n`exposed_total`, and `exposures_by_node`. For row-level data use\n`GET /v1/environments/$ENV_ID/experiments/:experiment_id/exposures`. Its filters include\n`variant_id`, `outcome` (`converted`, `pending`, or `expired`), and `since`; use the endpoint's\npagination fields rather than assuming the first page is complete. Pass filters through `--params`,\nnot a query string in the path:\n\n```bash\ncio api /v1/environments/$ENV_ID/experiments/$EXPERIMENT_ID/exposures \\\n  --params '{\"variant_id\":\"\u003cVARIANT_A_ID\u003e\",\"outcome\":\"converted\"}'\n```\n\nExposure records include the assigned variant, first exposure time, conversion/window timestamps,\noutcome, and the action through which the profile was exposed.\n\nExperiment results are not CTBO — Chance to Beat Original, the A/B-test significance metric\ndescribed in `ab_tests.md`. Results contain per-variant `exposed`, `converted`, `rate`,\n`relative_lift`, confidence interval bounds, `p_value`, adjusted p-value, `significant`, and\n`probability_to_beat_control`, plus pairwise comparisons and a recommendation. The API also\nidentifies a reference variant in `reference_variant_id` (the first variant), the\n`recommended_variant_ids` the engine names, and the `scoring_method` used. `decision_basis` is\nnull until the engine decides. After that it is `automatic_on_significance`,\n`automatic_at_horizon`, or `policy_on_inconclusive` (the `on_inconclusive` policy picked the\nvariant because the evidence did not). Each exposure is converted, pending (its window is open), or\nexpired (its window closed without a conversion). `rate` is `resolved_converted / resolved`, and it\ncounts only exposures whose window has closed, so `rate` is not `converted / exposed` while a test\nruns.\n`absolute_lift` with `abs_ci_low` and `abs_ci_high` is the rate difference and its interval.\n`ci_low` and `ci_high` are both 0 when the reference never converts, which means no relative\ninterval. Report the fields returned by the API and label any rate as a\nrate.\n\nResults also carry `metrics`: one entry for each delivery metric the experiment's channels\nrecord, with `metric`, per-variant `variants`, and `comparisons`. Each metric counts the people\nin a variant who had that metric on at least one of the test's messages, so `rate` there is a\nshare of exposed people, not of messages. Each metric is its own comparison family. An\nexperiment with no criterion has `recommendation: \"manual\"` and no recommended variant: read the\nmetrics to the user and let them choose the winner with `choose_winner`. Do not calculate a significance value and call it CTBO, and do not call the A/B-test\n`split_metrics` endpoint for these results.\n\nTo build a segment of profiles exposed (or not exposed) to an experiment or one of its variants,\nread `experiment_exposure_segment_condition.md`.\n\nFor an audit of what the campaign looked like at a lifecycle point, use the experiment's\n`/snapshots` endpoint. For a campaign-specific view, use\n`GET /v1/environments/$ENV_ID/campaigns/:campaign_id/experiments/:experiment_id/shape`.\n\n## Reading results\n\nWhen the user asks how an experiment is doing, read it at once, whatever its status: `running`,\n`paused`, `observing`, `ready_to_decide`, `completed`, or `stopped`. Do not wait for a winner.\n\n1. Find the experiment. If the user gives no ID, list with\n   `GET /v1/environments/$ENV_ID/experiments` and `--params` with `status`, `attached`,\n   `page`, and `per_page` (at most 100). Match on `name`. If more than one matches,\n   ask which one.\n2. `GET /v1/environments/$ENV_ID/experiments/:experiment_id`. Read `status`, `winner_variant_id`,\n   `latest_results`, `exposures_by_variant`, and `config`. `latest_results` is null before the\n   first scoring pass. Say that the results are not ready yet.\n3. For a trend, call `/exposures` and read `series` with `series_bucket_seconds` (3600 for a test\n   of 3 days or less, else 86400). Use `outcome` and `since` to count conversions in a period.\n\nPresent one row for each variant, and use the variant `name` from `config.variants`, not the ID:\n\n- Exposures: `exposed`, and the planned number (`config.test.exposures_per_variant` or\n  `planned_exposures`) when one is set.\n- Conversions and `rate`. `rate` is a fraction, so 0.042 is 4.2 percent. It counts only\n  exposures with a closed window, so use `resolved` and `resolved_converted` as the base, and say\n  that pending exposures can still convert.\n- `relative_lift` against the reference variant (`reference_variant_id`). The reference row shows 0.\n  When the reference rate is 0, `relative_lift` is 0 and means no lift can be computed.\n- `significant` and `adjusted_p_value`. They compare the variant with the reference.\n\nThen state the `recommendation` and `recommended_variant_ids` in plain words, and `decision_basis`\nwhen it is set. If `winner_variant_id` is set, name that variant as the chosen winner. A set `decision_basis`\nmeans the engine or the policy chose it. Without one, a person chose it. When the\n`recommendation` is one of the no-winner values below and a variant is chosen, add that the\nevidence itself did not name a winner.\n\n`winner` and `control_recommended` both name a variant. For `winner`, one variant beats all others\nwith significance. For `control_recommended`, the reference variant (the first variant, often the\noriginal) beats every other variant with significance, and it is the recommended variant. This\nresult is decisive. Do not call it inconclusive, and do not say that the evidence named no\nvariant. Say that the other variants did not improve on the reference.\n\nBe direct when the data does not support a winner:\n\n- `inconclusive`, `no_meaningful_difference`, `leading_variants`, `insufficient_data`, or `manual`:\n  say that the results do not name a winner. If `winner_variant_id` is null, do not name a winner,\n  and do not call the variant\n  with the highest `rate` the winner. You can say which variant leads on `rate`, and that the\n  lead is not significant.\n- `insufficient_data`, or `exposed` below the plan: say how far the test is from the plan and\n  that a variant needs at least 100 resolved exposures to be scored. Each compared pair also needs\n  at least 25 resolved conversions in total, so a test can pass 100 exposures and still read\n  `insufficient_data`. Give the enrollment and\n  observation deadlines (`enrollment_ends_at`, `observation_ends_at`) when the response has them.\n- A `policy_on_inconclusive` basis means the policy chose the variant, not the evidence. Say so.\n- Do not choose, apply, or stop anything unless the user asks. If they want to decide, use\n  `choose_winner`.\n\n## Choose and apply a winner\n\nChoosing a winner records the selected variant on the Experiment. Applying it is a separate\ncampaign graph operation and requires a completed Experiment with a winner:\n\n```bash\ncio api /v1/environments/$ENV_ID/experiments/$EXPERIMENT_ID/apply_winner -X POST \\\n  --json '{\"campaign_id\":123,\"version\":7}'\n```\n\nThe operation collapses the Test node to the winning path, removes losing paths, and detaches\nthe Experiment from the campaign while retaining its history. A holdout winner has an empty\nmessage path and reconnects at the merge point; it is still never a valid experiment fallback.\nIf the campaign has unsaved graph changes, save or resolve those changes before applying the\nwinner. Re-read the campaign after applying it and verify the resulting graph. This is different\nfrom the automation A/B-test winner operation, which selects a winning message action.\n\nIf the user asks for a conventional message A/B test, route to `ab_tests.md`; if they ask for a\nbroadcast A/B test, route to `newsletter_ab_tests.md`. Ask which product they mean only when the\nresource type cannot be determined from context or a read.\n","flow_control.md":"---\nminimum_plan: essentials\n---\n\n# Flow Control\n\nActions that route people down different paths based on conditions. In the UI, these appear under the **Flow Control** menu: True/False Branch, Multi-Split Branch, Random Cohort Branch, and Exit.\n\nFor delay and wait-until actions, see `delays.md`. For general automation setup and edges, see `automations.md`.\n\n---\n\n## Workflow Graph Rules\n\nThe backend validates the workflow graph on every save. Violations return a **409** with an `ErrInvalidModification` message.\n\n### Branch Convergence Rule\n\nAll branches from a split action (True/False, Multi-Split, Wait Until, Random Cohort) that continue in the workflow **must reconverge at the same downstream action**. A branch is terminal and exempt from convergence only when it connects to a **dedicated exit action that has no other incoming edges**. To make a branch exit, create a new `exit_action` for it — do not reuse the automation's original exit action, because sharing an exit with other workflow paths creates multiple incoming edges and triggers convergence checks.\n\n**Valid patterns:**\n- All continuing branches → same next action ✓\n- Some branches → same next action, others → dedicated new `exit_action` (terminal) ✓\n- All branches → same shared `exit_action` (all converge there) ✓\n- Each branch → its own dedicated `exit_action` ✓\n\n**Invalid:** Branch A continues to action X, Branch B continues to action Y (X ≠ Y, and neither is a dedicated exit) ✗\n\n**Common trap, the \"free\" exit is not free:** Routing a branch to the automation's existing exit looks like a clean way to leave on that path, but if any sibling path already flows into that exit (for example a message action whose `continue` edge lands there), the exit now has a second incoming edge and stops being a dedicated, exempt exit. The split's branches then diverge: some converge at the message action, the other at the exit, and the save is rejected. Always create a **new** `exit_action` for each exiting branch. See `delays.md § Wait Until pitfall: chained reminder cascades and the shared-exit trap` for a worked NPS example.\n\n**Error:** `\"branches from action \u003cid\u003e do not converge at a common action, got \u003cX\u003e and \u003cY\u003e\"`\n\n**Fix:** Rewire all continuing branches to reach the same downstream action. For branches that should leave the workflow, create a new `exit_action` and route the branch to it.\n\n**Experiment holdout:** The holdout arm of an `experiment_action` has no actions. If the holdout continues in the workflow, its edge goes to the same merge point as the edge of every variant arm. The variant arms must not join each other before that merge point. If they do, the holdout edge and the joined arms reach different actions, and the save is rejected or the canvas cannot draw one merge. The holdout can instead end in its own new `exit_action`.\n\n**Complete example — T/F branch with both paths continuing and converging:**\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"PREV_ACTION_ID\", \"to\": \"-3\", \"type\": \"continue\" },\n      { \"from\": \"-3\", \"to\": \"-2\", \"type\": \"branch\", \"index\": 0 },\n      { \"from\": \"-3\", \"to\": \"-1\", \"type\": \"branch\", \"index\": 1 },\n      { \"from\": \"-2\", \"to\": \"NEXT_ACTION_ID\", \"type\": \"continue\" },\n      { \"from\": \"-1\", \"to\": \"NEXT_ACTION_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    { \"type\": \"conditional_branch_action\", \"name\": \"Is VIP?\", \"conditions\": \"\u003cbase64\u003e\" },\n    { \"type\": \"email_action\", \"name\": \"VIP email\", \"sending_state\": \"draft\" },\n    { \"type\": \"email_action\", \"name\": \"Standard email\", \"sending_state\": \"draft\" }\n  ]\n}\n```\nBoth paths reconverge at `NEXT_ACTION_ID`. Replace with `EXIT_ACTION_ID` if this is the last step.\n\n**Example — one path continues, the other exits:**\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"PREV_ACTION_ID\", \"to\": \"-3\", \"type\": \"continue\" },\n      { \"from\": \"-3\", \"to\": \"-2\", \"type\": \"branch\", \"index\": 0 },\n      { \"from\": \"-3\", \"to\": \"-1\", \"type\": \"branch\", \"index\": 1 },\n      { \"from\": \"-2\", \"to\": \"NEXT_ACTION_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    { \"type\": \"conditional_branch_action\", \"name\": \"Is VIP?\", \"conditions\": \"\u003cbase64\u003e\" },\n    { \"type\": \"email_action\", \"name\": \"VIP email\", \"sending_state\": \"draft\" },\n    { \"type\": \"exit_action\" }\n  ]\n}\n```\nTrue path → email → continues. False path → dedicated new exit action (`-1`, terminal, exempt from convergence). The new exit action has only one incoming edge (the branch), so it is treated as a terminal path.\n\n### Graph Validation Error Reference\n\n| Error message | Cause | Fix |\n|---|---|---|\n| `\"branches from action \u003cid\u003e do not converge at a common action, got \u003cX\u003e and \u003cY\u003e\"` | Branch children rejoin at different downstream actions | Rewire continuing branches to the same action; for exiting branches, create a new `exit_action` and route the branch to it |\n| `\"cannot delete branch action \u003cid\u003e: its branches lead to multiple distinct non-deleted next actions [...]\"` | `delete_actions` tried to remove a branch action while its outputs still point at multiple surviving actions | Include the divergent downstream actions in `action_ids[]`, or first rewire the branch outputs to converge to one non-deleted action. `exit_early` does not bypass this validation |\n| `\"action \u003cid\u003e has multiple incoming edges outside of a branch\"` | An action receives edges from unrelated workflow paths | Ensure each merge point is the convergence of exactly one branch |\n| `\"graph is cyclic\"` | A loop — an action points back to an ancestor | Remove the backward edge |\n| `\"workflow has unreachable actions\"` | Actions not connected to the root | Wire all actions into the workflow |\n| `\"graph has more than one root\"` | Two or more actions have no incoming edge — usually a newly added action that wasn't spliced into an existing path | Give every new action an incoming edge by rewiring an existing edge through it. See `automations.md § Adding actions to an existing workflow` |\n| `\"action \u003cid\u003e has \u003cn\u003e edges\"` | Non-branch, non-exit action doesn't have exactly 1 outgoing edge | Ensure exactly 1 outgoing `continue` edge |\n| `\"conditional branch has \u003cn\u003e edges\"` | T/F branch doesn't have exactly 2 edges | Provide exactly 2 branch edges (index 0 and 1) |\n| `\"multi-split branch has \u003cn\u003e edges, not \u003cexpected\u003e\"` | Edge count ≠ `len(multi_conditions) + 1` | Match edge count to conditions + 1 |\n| `\"conditional wait has \u003cn\u003e edges, not \u003cexpected\u003e\"` | Edge count doesn't match condition/timeout config | See `delays.md` for Wait Until edge count rules |\n| `\"exit action has \u003cn\u003e edges\"` | Exit action has outgoing edges | Remove all edges from exit — nothing flows after it |\n| `\"action \u003cid\u003e links to action \u003cid\u003e which is not in the campaign\"` | Edge target doesn't exist | Fix the edge target to a valid action ID |\n\n---\n\n## True/False Branch (`conditional_branch_action`)\n\nSimple true/false branch — exactly 2 edges. `index: 0` = condition matched, `index: 1` = not matched.\n\n**Fields:**\n- `conditions` — base64-encoded condition string (singular, not an array). See § Condition Encoding below.\n- `name` — display name\n\n**Example:**\n```json\n{\n  \"type\": \"conditional_branch_action\",\n  \"name\": \"Is VIP?\",\n  \"conditions\": \"\u003cbase64: plan eq premium\u003e\"\n}\n```\n\nEdges: `{ \"from\": \"ID\", \"to\": \"TRUE_PATH\", \"type\": \"branch\", \"index\": 0 }` and `{ \"from\": \"ID\", \"to\": \"FALSE_PATH\", \"type\": \"branch\", \"index\": 1 }`.\n\n## Multi-Split Branch (`multi_split_branch_action`)\n\nRoutes a person down **one of several paths** based on conditions, evaluated **instantly** (no waiting). First matching condition wins; unmatched people take the default branch.\n\n**Fields:**\n- `multi_conditions` **(required)** — array of base64-encoded condition strings, one per branch. **Must be provided at creation time** — you cannot create a multi-split branch without conditions and add them later.\n- `name` — display name for the action\n\n**Edge wiring:** N conditions require **exactly N + 1** edges (indices `0` through `N-1` for each condition, index `N` for the default \"everyone else\" branch). All edges use `type: \"branch\"`. The edge count and condition count **must** satisfy `edges = conditions + 1` — a mismatch causes a 409 error.\n\n**Evaluation order:** Conditions are tested in array order. The person takes the **first** branch whose condition matches. If no conditions match, the person takes the default branch (last index).\n\n**Placeholder IDs use negative indexing:** `-1` is the **last** action in the `actions` array, `-2` is second to last, etc. With 3 new actions, `-3` is the first.\n\n**Example — branch on `plan` attribute (2 conditions + default):**\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"edges\": [\n      { \"from\": \"PREV_ACTION_ID\", \"to\": \"-3\", \"type\": \"continue\" },\n      { \"from\": \"-3\", \"to\": \"-2\", \"type\": \"branch\", \"index\": 0 },\n      { \"from\": \"-3\", \"to\": \"-1\", \"type\": \"branch\", \"index\": 1 },\n      { \"from\": \"-3\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"branch\", \"index\": 2 },\n      { \"from\": \"-2\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"continue\" },\n      { \"from\": \"-1\", \"to\": \"EXIT_ACTION_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    {\n      \"type\": \"multi_split_branch_action\",\n      \"name\": \"Which plan?\",\n      \"multi_conditions\": [\n        \"\u003cbase64: plan eq premium\u003e\",\n        \"\u003cbase64: plan eq enterprise\u003e\"\n      ]\n    },\n    { \"type\": \"email_action\", \"name\": \"Premium path email\" },\n    { \"type\": \"email_action\", \"name\": \"Enterprise path email\" }\n  ]\n}\n```\n\nEdge index mapping (`-3` = branch, `-2` = first email, `-1` = second email):\n- `index: 0` → first condition matched (premium) → action `-2`\n- `index: 1` → second condition matched (enterprise) → action `-1`\n- `index: 2` → no condition matched (default/everyone else) → exit\n\nEach condition in `multi_conditions` is a base64(urlEncoded(JSON)) string — see § Condition Encoding below for the exact formula. The rule for which condition types work here: anything that resolves against the profile's **current state** — `attribute`, `segments_negatable`, `event_attribute` (the triggering event), `device_attribute`, `object_attribute`, `relationship_attribute`, `related`, and `attribute_compare` between those sources (plus `journey_attribute`, which is reachable only as an `attribute_compare` source). `foreign_event` and `message` conditions — and an `attribute_compare` reading `foreign_event_attribute` — need a waited-for event, so they belong in a Wait Until. See § Automation Action Condition Types.\n\n## Random Cohort Branch (`random_cohort_branch_action`)\n\nRandomly assigns people to cohorts based on relative weights. For permanent audience segmentation within a workflow — not for A/B testing messages (use `split_randomized_action` for that, see § A/B Test Split below).\n\n**Fields:**\n- `name` — display name\n- `cohorts` — array of integers on a **0–1000 scale**, where the UI displays each value divided by 10 as a percentage. For example, `500` displays as `50.0%`, `300` as `30.0%`. Values **must sum to 1000** to display correctly in the UI. While the API technically treats them as relative weights, using values that don't sum to 1000 causes the UI to display incorrect percentages.\n- `cohort_names` — (optional) array of display names, one per cohort. If provided, must match length of `cohorts`. Max 100 characters each.\n\n**Edge wiring:** N cohorts require N edges with `type: \"branch\"` and indices `0` through `N-1`. No default branch.\n\n**Validation rules:**\n- Sum of `cohorts` must be greater than zero\n- `cohort_names` length must match `cohorts` length (if provided)\n\n**Example:**\n```json\n{\n  \"type\": \"random_cohort_branch_action\",\n  \"name\": \"Split audience\",\n  \"cohorts\": [500, 300, 200],\n  \"cohort_names\": [\"Group A\", \"Group B\", \"Group C\"]\n}\n```\n\n---\n\n## Condition Encoding\n\nAll condition fields (`conditions`, `multi_conditions`) use a two-step encoding: **URL-encode the JSON first, then base64-encode the result.** Skipping the URL-encoding step causes conditions to be stored but display as empty in the UI.\n\n**Encoding formula:** `base64(urlEncode(JSON.stringify(conditionArray)))`\n\n```javascript\n// JavaScript\nconst raw = JSON.stringify([{\"field\": \"plan\", \"operator\": \"eq\", \"type\": \"attribute\", \"value\": \"premium\"}]);\nconst encoded = Buffer.from(encodeURIComponent(raw)).toString('base64');\n```\n\n\u003e **⚠️ CRITICAL: Automation action conditions are NOT the same as segment conditions.**\n\u003e Segment conditions are a separate segment-only condition tree documented in `segment_conditions.md`.\n\u003e Automation action conditions use a **flat array** format: `[{type: \"attribute\", field: \"...\", operator: \"...\", value: \"...\"}]`.\n\u003e **Never use segment-style conditions in automation actions** — they will cause 500 errors.\n\n### Automation Action Condition Types\n\nEach condition is a JSON object with a `type` field. The raw format (before encoding) is always a **JSON array** of these objects.\n\n**`attribute`** — check a profile attribute:\n```json\n[{\"type\": \"attribute\", \"field\": \"plan\", \"operator\": \"eq\", \"value\": \"premium\"}]\n[{\"type\": \"attribute\", \"field\": \"age\", \"operator\": \"gt\", \"value\": \"30\"}]\n[{\"type\": \"attribute\", \"field\": \"email\", \"operator\": \"exists\"}]\n[{\"type\": \"attribute\", \"field\": \"created_at\", \"operator\": \"timestamp_gt\", \"value\": \"-2592000\"}]\n```\n\n**`segments_negatable`** — check segment membership:\n```json\n[{\"type\": \"segments_negatable\", \"segments\": [123], \"negate\": false}]\n[{\"type\": \"segments_negatable\", \"segments\": [123], \"negate\": true}]\n```\n\n**`event_attribute`** — check the triggering event's data (event-triggered automations only):\n```json\n[{\"type\": \"event_attribute\", \"field\": \"amount\", \"operator\": \"gt\", \"value\": \"100\"}]\n```\n\n**`foreign_event`** — wait for a specific event to fire (**Wait Until only — causes 500 in branches**):\n```json\n[{\"type\": \"foreign_event\", \"event_name\": \"purchase_completed\", \"event_type\": \"\"}]\n```\nTo also filter on the event's data properties, add a `foreign_event_attribute` object to the same array:\n```json\n[\n  {\"type\": \"foreign_event\", \"event_name\": \"purchase_completed\", \"event_type\": \"\"},\n  {\"type\": \"foreign_event_attribute\", \"field\": \"amount\", \"operator\": \"gt\", \"value\": \"50\"}\n]\n```\n\n**`attribute_compare`** — compare two attributes **against each other** instead of against a literal. Both `field` and `value` hold *attribute names*, and each carries a `_source` companion saying where to read that name from. **Both `field_source` and `value_source` are required** — omitting either (including on the `const` side) is a 422, there is no default:\n```json\n[{\"type\": \"attribute_compare\", \"field\": \"plan\", \"field_source\": \"customer_attribute\", \"value\": \"plan\", \"value_source\": \"event_attribute\", \"operator\": \"eq\"}]\n```\n\n| `_source` value | Reads from |\n|---|---|\n| `customer_attribute` | the profile's attributes |\n| `event_attribute` | the **triggering** event's data — resolves only on automations entered by an event; any other entry (segment/attribute, date, object, relationship) carries none, so the compare never matches |\n| `foreign_event_attribute` | the **waited-for** event's data — Wait Until and exit conditions only, and only alongside a `foreign_event` condition in the same array (see the warning below); the `global_exit_conditions` wire shape is `foreign_event.attribute_compare`, see `automation_filters.md § Dynamic exit conditions` |\n| `journey_attribute` | journey attributes set earlier in the workflow |\n| `object_attribute` / `trigger_object_attribute` | the related object's attributes |\n| `relationship_attribute` | the relationship's attributes |\n| `trigger_person_attribute` / `trigger_relationship_attribute` | the person / relationship that triggered a relationship-triggered journey — nothing else carries them (see the warning below) |\n| `collection_attribute` | a collection row — **not available in automation actions at all**; journeys carry no collection row, so this source only works where a collection is being iterated (see the warning below) |\n| `const` | the paired side is a literal value, not an attribute name |\n\n**Attribute names are case-sensitive; the values they resolve to are not.** `field` and `value` are looked up exactly as written (`Plan` and `plan` are different attributes), but the comparison of the two resolved values lowercases both sides, same as every other condition type. Unary operators (`exists`, `email_valid`, `timestamp`) are rejected with a 422 unless `value_source` is `const` — there is nothing to compare them against. Pairing `foreign_event_attribute` with `event_attribute` correlates the waited-for event to the triggering event by a shared ID — see `delays.md § Wait Until: correlating two events with attribute_compare`.\n\n**A `foreign_event_attribute` condition needs a `foreign_event` condition in the same array.** Alone it reads a waited-for event that no evaluation path supplies. Preconditions, True/False and Multi-Split branches, Wait Until, and global exit conditions all reject the bare shape with a 422. The detail reads `A foreign_event condition requires an event condition in the same filter`. In a branch the paired shape saves, but it never matches: a branch evaluates once and carries no waited-for event.\n\n\u003e **⚠️ Picking a source the journey doesn't carry saves cleanly and breaks later.** The save path validates that each `_source` is a *known* name, not that this journey can supply it, so a bad choice returns success and then fails when journeys are processed — no 422 tells you the shape is wrong. Two cases to avoid:\n\u003e - `collection_attribute` anywhere in an automation action — journeys carry no collection row.\n\u003e - `trigger_person_attribute` / `trigger_relationship_attribute` outside a relationship-triggered journey.\n\u003e\n\u003e A source that is merely *empty* is safe and simply doesn't match — for example `event_attribute` on a journey with no trigger event. It's the three above that break.\n\n**`message`** — wait for a message delivery event like open/click (**Wait Until only — causes 500 in branches**):\n```json\n[{\"type\": \"message\", \"event\": {\"name\": \"*\", \"type\": \"opened_email\"}, \"inverse\": false}]\n[{\"type\": \"message\", \"event\": {\"name\": \"*\", \"type\": \"clicked_email\"}, \"inverse\": false}]\n[{\"type\": \"message\", \"event\": {\"name\": \"twilio_*\", \"type\": \"delivered_action\"}, \"inverse\": false}]\n```\n\n**`device_attribute`** — check a device attribute:\n```json\n[{\"type\": \"device_attribute\", \"field\": \"platform\", \"operator\": \"eq\", \"value\": \"ios\"}]\n```\n\n**`object_attribute`** — check an attribute of the object the journey is running for, scoped to `type_id` (a journey carries one object, so this reads that object, not a scan across the person's objects):\n```json\n[{\"type\": \"object_attribute\", \"field\": \"status\", \"operator\": \"eq\", \"value\": \"active\", \"type_id\": 1}]\n```\n\n**`relationship_attribute`** — check a relationship attribute:\n```json\n[{\"type\": \"relationship_attribute\", \"field\": \"role\", \"operator\": \"eq\", \"value\": \"admin\", \"type_id\": 1}]\n```\n\n**`related`** — check that the person is related to an object of a given type, optionally refined by that object's attributes. A relationship **existence** test, not a way to read an attribute:\n```json\n[{\"type\": \"related\", \"object_type_id\": 1, \"object_source\": \"any\", \"person_source\": \"current\", \"inverse\": false,\n  \"refinement\": {\"and\": [{\"type\": \"attribute_compare\", \"field\": \"status\", \"field_source\": \"object_attribute\",\n                          \"value\": \"active\", \"value_source\": \"const\", \"operator\": \"eq\"}]}}]\n```\n\n- **`object_source` must be `\"any\"`, and `person_source` must be `\"current\"` or `\"trigger\"`.** Any other value, an omitted key included, is rejected with a `400` naming the offending source. `\"trigger\"` as an *object* source is the trap: it is a legal value in segment conditions, so it reads as legitimate here, and before [services#25712](https://github.com/customerio/services/pull/25712) it saved with a `200` and then errored on every evaluation of the automation — each ingress batch retried and then dropped, no delivery and nothing surfaced to the user.\n- A `refinement` scans every related object the person has, so it is served from bitmap indexes: only `eq` and `exists` work there (other operators are a `400`), and an `attribute_compare` child must read `object_attribute` or `relationship_attribute`. `trigger_object_attribute` inside a refinement is accepted at save and then errors at evaluation time.\n- Omit `refinement` for a bare \"is related to any object of this type\" check. `inverse: true` negates the whole condition.\n\n\u003e **⚠️ Never use `related` to read an attribute of the object that triggered the journey.** Use a bare `attribute_compare` with `field_source: \"trigger_object_attribute\"`. The journey already knows its trigger object, so there is nothing to scan across and no `related` wrapper to add — and outside a refinement `trigger_object_attribute` and `object_attribute` are aliases, both reading that object.\n\u003e\n\u003e ```json\n\u003e [{\"type\": \"attribute_compare\", \"field\": \"listing_health_acceptable\", \"field_source\": \"trigger_object_attribute\",\n\u003e   \"value\": \"false\", \"value_source\": \"const\", \"operator\": \"eq\"}]\n\u003e ```\n\u003e\n\u003e The shape below is what that mistake looked like in a real automation, as the server stores it — the wire form is the same condition with `\"type\": \"related\"`. No UI flow emits it; the editor flattens a trigger-object check to the bare compare above. One automation saved in this shape took down every ingress batch that evaluated it:\n\u003e\n\u003e ```json\n\u003e {\"related\": {\"and\": true, \"inverse\": false, \"object_source\": \"trigger\", \"object_type_id\": 1,\n\u003e   \"person_source\": \"current\", \"refinement\": {\"and\": [{\"map\": {\"attribute_compare\": {\n\u003e     \"field\": \"listing_health_acceptable\", \"field_source\": \"object_attribute\",\n\u003e     \"operator\": \"eq\", \"value\": \"false\", \"value_source\": \"const\"}}}]}}}\n\u003e ```\n\n### Operators\n\nCommon operators (same across condition types): `eq`, `!eq`, `exists`, `!exists`, `gt`, `lt`, `bw` (between, needs `value` + `value2`), `contains`, `!contains`.\n\nDate operators: `timestamp_gt`/`timestamp_lt` (relative seconds offset, negative = past, e.g. `\"-2592000\"` = 30 days ago), `timestamp_gta`/`timestamp_lta` (absolute unix timestamp), `timestamp_bw` (between two timestamps, needs `value` + `value2`).\n\n## Common Pitfalls\n\n1. **409 \"conflict\" almost always means edge/condition count mismatch — not a lock.** If the number of branch edges doesn't match the expected count (N+1 for multi-split, variable for conditional wait), the API returns a **409** with the message *\"There was a conflict when saving your changes.\"* This is **not** a browser lock or concurrency issue. Fix: ensure `multi_conditions` has exactly one fewer entry than the number of branch edges (the extra edge is the default/timeout branch).\n\n2. **`multi_conditions` must be present at creation.** You cannot create a `multi_split_branch_action` without `multi_conditions` and add conditions later via `save_action`. The conditions and edges are validated together — omitting `multi_conditions` while providing branch edges triggers the 409 above.\n\n3. **`multi_conditions` is an array of strings, not a single value.** Each element must be a **separate base64-encoded string**. Common mistakes:\n   - Passing a raw JSON array of condition objects → unmarshal error\n   - Passing a single base64 string instead of an array → unmarshal error\n   - Correct: `\"multi_conditions\": [\"\u003cbase64 string 1\u003e\", \"\u003cbase64 string 2\u003e\"]`\n\n4. **`conditional_branch_action` uses `conditions` (singular string), not `multi_conditions`.** `conditional_branch_action` takes a single base64-encoded `conditions` string and always has exactly 2 edges. `multi_split_branch_action` and `conditional_wait_action` use `multi_conditions` (array).\n\n5. **Branch convergence errors mean branches rejoin at different actions.** If you get `\"branches from action \u003cid\u003e do not converge at a common action\"`, your branch paths lead to different downstream actions. Fix: rewire all continuing branches to the same action, or create a new `exit_action` for branches that should leave the workflow. See § Workflow Graph Rules above.\n\n6. **Every non-exit, non-branch action needs exactly 1 outgoing edge.** If you get `\"action \u003cid\u003e has \u003cn\u003e edges\"`, a message, delay, or data operation action is missing its outgoing `continue` edge or has too many. Every action in a branch path must have an edge to the next action or to an exit action.\n\n---\n\nFor **data-operation actions** — attribute update, create/send event, batch update, static segment update, collection query — see `data_operations.md`. This file covers branching and flow-control actions only.\n\n---\n\n# Other Workflow Actions\n\n## A/B Test Split (`split_randomized_action`)\n\nCreated by converting an existing message action into an A/B test. See `ab_tests.md` for details.\n","forms.md":"# Forms — Custom Form Tracking \u0026 Submissions\n\n## Custom Forms\n\nCustom forms let users track HTML form submissions on their website and map form fields to profile attributes. When a visitor submits a tracked form, Customer.io creates or updates their profile with the mapped attributes and fires a `form_submit` event for that form — which can be used to trigger automations.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/custom_forms` | `custom_forms.Index` | List all custom forms |\n| GET | `/v1/environments/{environment_id}/custom_forms/{id}` | `custom_forms.Get` | Get a custom form |\n| POST | `/v1/environments/{environment_id}/custom_forms` | `custom_forms.Create` | Create a custom form |\n| PUT | `/v1/environments/{environment_id}/custom_forms/{id}` | `custom_forms.Update` | Update a custom form |\n| GET | `/v1/environments/{environment_id}/scan_for_forms` | `custom_forms.Scan` | Scan a URL for HTML forms. Param: `url` (required). Returns `{scanned_forms: [...]}` |\n\nCreate/update body:\n```json\n{\"custom_form\": {\"html_form_id\": \"signup\", \"html_form_name\": \"Signup Form\", \"html_form_url\": \"https://example.com/signup\", \"html_form_inputs\": [\"email\", \"name\"], \"name\": \"My Form\", \"attr_maps\": [{\"form_field_key\": \"email\", \"form_field_type\": \"email\", \"cio_attr\": \"email\"}]}}\n```\n\n## Segmenting on Form Submissions\n\nEvery tracked form fires the same `form_submit` event, so a segment of \"people who submitted form X\" is one `form_submit` event condition refined by a `form_id` filter carrying the form's `form_type` — never an event named after the form. The exact shape is in `segment_conditions.md § 4b`; the `form_id` and `form_type` to use come from the `custom_forms` list above.\n\n## Form Submissions\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/form_submissions` | `form_submissions.List` | List form submissions. Params: `newsletter_id`, `campaign_id`, `action_id`, `template_id`, `form_id`, `start_date`, `end_date` (unix timestamps), `page` (1-indexed), `size` (max 1000) |\n\nAt least one of `newsletter_id` or `campaign_id` is required; if both are supplied, `newsletter_id` takes precedence. `action_id` filters actions within `campaign_id`. Omit `form_id` or leave it empty for no form filter; literal `\"null\"` filters for that string.\n\nIn each submission's `customer`, `email` and `internal_id` are omitted when absent.\n","frequency_caps.md":"# Message Frequency Limits (Frequency Caps)\n\nNamed caps that limit how many messages a person can receive in a time window.\nA cap is created once in Workspace Settings, then applied to individual messages. It is checked when that person’s message is sent against that person’s qualifying send history. It does not limit enrollment, count journey entries or re-entries, or create a shared pool across people. An automation or action can reference a workspace cap, but does not have its own shared per-automation frequency quota. A person with no qualifying sends in the rule window has no prior usage against the cap.\nMessage frequency limits supersede the older single workspace-wide message limit\n(both APIs are documented below).\n\nTo slow one automation's send rate rather than cap what a person receives, read\n**message_throttling.md** instead.\n\nThe settings page is gated by the **`frequency_caps` feature flag**. With the flag\noff the route redirects to Workspace Settings, so the page does not exist for that\nworkspace — check the flag first when a user says they cannot find it. The API\nendpoints below are gated by permissions (`Journeys_WorkspaceSettings_*` or\n`Journeys_MessageSettings_*`), not by the flag.\n\n## UI Paths\n\nCreate and manage named caps:\n\n`https://fly.customer.io/workspaces/$ENV_ID/settings/actions/message_frequency_limits`\n\nThe page is titled **Message frequency limits** and lives under Workspace\nSettings. The path is nested under `actions` — **never use\n`/settings/message-limits` or `/settings/message_frequency_limits`**. Neither is\na valid route and both render a 404.\n\n## Applying a Cap to a Message\n\nCaps are created on the settings page above, then selected per message. These\nthree surfaces have no URL of their own, so tell the user where to click — do not\nconstruct a link for them:\n\n| Surface | Where |\n|---------|-------|\n| Automation message action | Open the automation → select the message action → **Message Settings** → Message frequency limit |\n| One-time send / broadcast | Open the newsletter → **Sending options** → Message frequency limit |\n| Transactional message | Open the transactional message → **Message Settings** → Message frequency limit |\n\n## Frequency Caps API\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/v1/environments/{environment_id}/frequency_caps` | List caps |\n| POST | `/v1/environments/{environment_id}/frequency_caps` | Create a cap |\n| PUT | `/v1/environments/{environment_id}/frequency_caps/{frequency_cap_id}` | Update a cap |\n| DELETE | `/v1/environments/{environment_id}/frequency_caps/{frequency_cap_id}` | Delete a cap |\n| GET | `/v1/environments/{environment_id}/frequency_caps/{frequency_cap_id}/usage` | Messages currently using this cap |\n| GET | `/v1/environments/{environment_id}/frequency_cap_supported_channels` | Channels that support caps |\n\nA cap is a name plus one or more rules. Each rule caps a set of channels:\n\n```bash\ncio api /v1/environments/$ENV_ID/frequency_caps -X POST --json '{\n  \"frequency_cap\": {\n    \"name\": \"Marketing weekly\",\n    \"rules\": [\n      {\"channels\": [\"email\"], \"cap_limit\": 3, \"window_secs\": 604800, \"retry_window_secs\": 86400}\n    ]\n  }\n}'\n```\n\n- `channels` — channel names, validated server-side; call\n  `frequency_cap_supported_channels` rather than guessing which are allowed.\n- `cap_limit` — messages permitted per `window_secs`.\n- `window_secs` — the rolling window, in seconds.\n- `retry_window_secs` — how long a suppressed message stays eligible for retry.\n\nDeleting a cap that messages still reference is refused with a `409` whose body\nlists the referencing messages (`errors[0].meta.references`) — it is not deleted.\nCall `usage` to see what references it, then detach the cap from those messages\nbefore deleting. Report a `409` as \"still in use\", never as a completed deletion.\n\nThe refusal can be overridden with `?force=true` on the same DELETE, which removes\nthe cap and lets the referencing messages send without that limit. Only use it\nwhen the user has seen the list of references and explicitly asked to delete\nanyway; never add it to get past a `409` on your own.\n\n## Legacy Workspace Message Limit\n\n**Deprecated — being removed.** Never set a limit through this surface and never\npoint a user at it. It is documented here only so you can read the state of a\nworkspace that still has one, and explain what you find. Use named frequency caps\nfor anything you change.\n\nThe older single limit for the whole workspace. A `PUT` here also writes a\nfrequency cap named **`Default`** covering all supported channels, and sending is\nenforced from that cap — not from the legacy values. Setting `count` or\n`window_secs` below `1` soft-deletes the `Default` cap: references from messages\nare preserved, and evaluation skips it.\n\n**The sync is one-way, so these two can disagree.** `GET message_limits` returns\nthe stored legacy values, while enforcement uses the `Default` cap. Editing that\ncap through the frequency caps API or the Message frequency limits page does not\nwrite the legacy values back, so a `GET` here can report a limit that is no longer\nwhat applies. When a user asks what limit is actually in force, read the `Default`\ncap, not this endpoint. Renaming or deleting the `Default` cap breaks the link\nentirely — the workspace default is resolved by the name `Default`, and a later\nlegacy `PUT` will create a second cap under that name.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/message_limits` | `message_limits.Get` | Get the workspace message limit and the automations/newsletters using it |\n| PUT | `/v1/environments/{environment_id}/message_limits` | `message_limits.Update` | Set it. Body: `{\"message_limit\": {\"count\": 1000, \"window_secs\": 3600, \"retry_window_secs\": 7200}}` |\n\n### Which One to Use\n\nDefault to named frequency caps — the API above and the Message frequency limits\npage — for everything: creating a cap, applying it, changing it. Named caps are the\nonly way to express per-channel rules or more than one cap, and the UI presents\nmessage frequency limits as the upgraded replacement for the older message limit.\n\n`message_limits` is read-only as far as you are concerned — it is being removed, so\ndo not write through it and do not offer it as an option. Its `GET` remains useful\nfor one thing named caps don't cover: it lists the automations, one-time sends, and\nAPI-triggered broadcasts that have limits enabled.\n\nDo not migrate a workspace off the legacy limit on your own initiative. An existing\n`Default` cap is a working configuration and shows up in the Message frequency\nlimits page like any other cap, so a user who wants to change it can be pointed\nthere instead.\n\nApplying a cap to a single message action does **not** create a cap for that\naction. Enabling limits on an action points it at the existing workspace `Default`\ncap; if there is no `Default`, the action falls back to \"same as campaign\" rather\nthan becoming uncapped. Opting an action out sets it to no cap. So an action\ncarries a *reference* to a cap, not a cap of its own.\n\n## Reporting\n\n`frequency_cap_id` and `frequency_cap_name` appear on undeliverable events, which\nis how you answer \"which cap suppressed this message\". See **deliverability.md**\nfor the queries.\n","geofences.md":"---\nminimum_plan: premium\n---\n\n# Geosets \u0026 Geofences\n\nGeofences (\"fences\") are geographic boundaries used to trigger automations when a device enters, dwells in, or exits a location. Every fence lives inside one or more **geosets** — named collections of fences. The geoset is the unit automations reference and imports/exports target; the fence is the individual boundary. This file covers direct CRUD access. Geoset enter/dwell/exit **automation triggers** are in `automation_triggers.md` § Geoset-triggered; **segment conditions** are in `segment_conditions.md` (the Geofence condition type); bulk imports and exports are in `imports_exports.md` § Geoset Imports.\n\n\u003e **Gated per account.** Geofencing is a capability flag (`geofences`) computed for the account — usually granted by the plan, and grantable to an individual account. It applies to every caller, including this agent. Don't pre-judge availability — attempt the call, then read the failure, because two different ones are easy to conflate:\n\u003e\n\u003e - **400 `\"Upgrade your plan to use Geofences\"`** — the account's plan doesn't include geofencing. The gate is account-wide, not per workspace, so switching workspaces won't help. Say so plainly and don't retry.\n\u003e - **403** (`\"forbidden\"` from the API, surfaced by `cio api` as `AUTHZ_ERROR \"Insufficient permissions.\"`) — the *user's role* lacks the geofence permission the route needs. Nothing to do with the plan: never report a permissions failure as a plan limit. Every built-in role can view geosets, so a 403 on a read means a custom role omits it, while a 403 on a create/edit/delete usually means a view-only role (viewer, support, content creator). Either way an admin has to change their role.\n\u003e\n\u003e Either way, don't assume you made a mistake and don't retry the same call.\n\n## The Model — fences are shared, sets are membership\n\n- A **geoset** is `{id, name, emoji, fence_count, created_at, updated_at}`. The list is unpaginated and has no search — filter client-side. `name` is required, at most **255 characters**, and unique per workspace **case-insensitively**: creating or renaming to a name that differs only in case 422s with \"A geoset with this name already exists\". On this 422, list the geosets and find the case-insensitive match before replying; the error alone does not reveal the existing set's exact name. Name that set, explain the case-insensitive conflict, leave it untouched, and ask whether to use it or choose a different name for the new set. `emoji` is an optional icon string — the server only caps it at **16 characters** and does **not** check that it's actually an emoji (`\"shop\"` is accepted and stored verbatim), so send a real single emoji (ZWJ sequences like 👨‍👩‍👧 are fine) rather than relying on the API to reject a bad value.\n- A **fence** is `{id, name, latitude, longitude, radius, shape, dwell_threshold_seconds, external_id, enabled, attributes, geoset_ids, created_at, updated_at}`; polygons add `geometry` and `base_radius_m`. Radius is integer meters.\n- `dwell_threshold_seconds` is how long a device must stay inside that fence before the SDK reports a `dwell` transition. It is an integer number of **seconds**, per fence rather than per set, and always present in fence responses. `0` means dwell is off: once a device's SDK refreshes its fences, it stops generating new dwell transitions for that fence (enter and exit continue). Setting `0` does not cancel dwells already observed. A queued DWELL can still arrive, and matching never looks up the fence's current threshold. It can start a dwell automation while its occurrence timestamp is under one hour old, and it counts in activity and dwell segments like any past dwell.\n- `external_id` is the fence's identity, unique per **workspace** (not per set). A fence can belong to several sets at once — `geoset_ids` lists all of them. Editing, disabling, or re-importing a fence through any one set changes what **every** set sees.\n- A set holds at most **10,000 fences**; the workspace's total fence count is unbounded (a fence in multiple sets counts against each set's cap).\n\n## Endpoints\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/v1/environments/:environment_id/geosets` | List geosets (unpaginated, no search) |\n| POST | `/v1/environments/:environment_id/geosets` | Create a geoset — `{\"geoset\": {\"name\": \"...\", \"emoji\": \"🏬\"}}`, name required, emoji optional |\n| GET | `/v1/environments/:environment_id/geosets/:id` | Get one geoset |\n| PUT | `/v1/environments/:environment_id/geosets/:id` | Update name and/or emoji — omitted key preserved, `\"emoji\": \"\"` clears the icon |\n| POST | `/v1/environments/:environment_id/geosets/:id/duplicate` | Duplicate a geoset — see § Duplicating |\n| DELETE | `/v1/environments/:environment_id/geosets/:id` | Delete — 422s if the set is referenced by any campaign or segment, see § Deleting |\n| GET | `/v1/environments/:environment_id/geosets/:id/usage` | Campaigns and segments referencing this geoset (triggers, exit conditions, conditional waits; incl. archived) |\n| GET | `/v1/environments/:environment_id/geosets_usage?geoset_ids=1,2` | Batch usage, keyed by geoset id |\n| GET | `/v1/environments/:environment_id/geosets/:id/fences` | List a set's fences. Params: `page` (1-indexed, default 1), `page_size` (default 25, max 1000), `sort` (`id`/`name`/`radius`/`created_at`), `order` (`asc`/`desc`), `search`, `bbox` — see § Finding Fences |\n| GET | `/v1/environments/:environment_id/geosets/:id/fence_points` | Every fence's geometry in one response — see § Finding Fences |\n| POST | `/v1/environments/:environment_id/geosets/:id/fences` | Create a fence in this set |\n| GET | `/v1/environments/:environment_id/geosets/:id/fences/:fence_id` | Get one fence |\n| PUT | `/v1/environments/:environment_id/geosets/:id/fences/:fence_id` | Partial update of a fence |\n| DELETE | `/v1/environments/:environment_id/geosets/:id/fences/:fence_id` | Remove the fence from this set — see § Deleting |\n| POST | `/v1/environments/:environment_id/geosets/:id/fences/move` | Bulk move or copy fences to another set — see § Bulk Move, Copy \u0026 Delete |\n| POST | `/v1/environments/:environment_id/geosets/:id/fences/delete` | Bulk delete fences from this set — see § Bulk Move, Copy \u0026 Delete |\n| POST | `/v1/environments/:environment_id/geosets/:id/exports` | Async GeoJSONL export — see § Exporting |\n| POST | `/v1/environments/:environment_id/places/geocode` | Address ↔ coordinates — see § Geocoding |\n| POST | `/v1/environments/:environment_id/places/autocomplete` | Place-name search (Google Places proxy) |\n| POST | `/v1/environments/:environment_id/places/details` | Place details (name, address, coordinates) by `place_id` |\n\n## Finding Fences — search, bbox, fence_points\n\nTo answer \"what's the radius on the Oak St fence?\" or \"do we have a fence for the airport?\", don't page through the whole set — pass `search` to the fence list: a case-insensitive substring match on fence **name and external_id** (`?search=oak`). Combine with `sort`/`order`/paging as usual.\n\n`bbox` filters to a map area: `?bbox=min_lng,min_lat,max_lng,max_lat` (WGS84 degrees, **longitude first**). It matches every fence whose *circle intersects* the box, not just centers inside it; `min_lng \u003e max_lng` spans the antimeridian.\n\n`GET .../fence_points` returns the entire set's geometry in one unpaginated response. Circle points carry `id`, `latitude`, `longitude`, `radius`, and `shape: \"circle\"`. Polygon points additionally carry their GeoJSON `geometry` and `base_radius_m`; their latitude, longitude, and radius describe the enclosing circle. It carries no names or attributes; fetch details per fence via the single-fence GET.\n\n## Creating a Fence\n\n**If the user handed you rows, this is an import, not a loop of creates.** A CSV block with a header line, a GeoJSON document, a pasted table — that is a file, however few rows it has, and it belongs in a geoset import with inline `content` (`imports_exports.md` § Geoset Imports). One validated call upserts by `external_id`, so a row that already exists updates instead of 422-ing, and the whole thing leaves one auditable import record. A loop of `POST /fences` gives up all three and dies partway through on the first duplicate. Use the direct create below when the user describes a fence in prose (\"add one at the Ferry Building, 300m\").\n\n```\nPOST /v1/environments/:environment_id/geosets/:id/fences\n```\n\n```json\n{\n  \"fence\": {\n    \"name\": \"Downtown Store\",\n    \"latitude\": 37.7749,\n    \"longitude\": -122.4194,\n    \"radius\": 200,\n    \"external_id\": \"downtown-store\",\n    \"enabled\": true,\n    \"dwell_threshold_seconds\": 300,\n    \"attributes\": { \"store_name\": \"Downtown\", \"tier\": \"flagship\" }\n  }\n}\n```\n\n`name`, `latitude`, `longitude`, and `radius` are required. `name` and `external_id` are each capped at **255 characters** (over-cap values 422 with \"Name must be at most 255 characters\" / \"External ID must be at most 255 characters\"). `enabled` defaults to `true`, so the fence goes live immediately. `dwell_threshold_seconds` is optional and defaults to `0` (dwell off). Convert whatever unit the user gives into whole seconds (\"5 minutes\" is `300`). The server accepts `0` through `2147483`; a negative or larger value 422s under `dwell_threshold_seconds`. A fractional or string value fails JSON decoding. No narrower product range is enforced. Put the threshold in the top-level `dwell_threshold_seconds` field. An `attributes` key with that name is stored as an ordinary custom attribute and never sets the threshold. `external_id` is optional — the server generates a UUID when omitted — but it is **create-only and immutable**: it's the fence's identity across imports, and updates that include it 422. If the `external_id` already exists in the workspace, create fails with a 422 — that fence already exists (possibly in another set); to link an existing fence into this set, use an import with that `external_id` (§ `imports_exports.md`), don't invent a new id for the same real-world location.\n\nLatitude/longitude of exactly `(0, 0)` is rejected — treat that as a sign the coordinates weren't actually resolved, not a valid location. If the user names a place instead of giving coordinates, resolve it via § Geocoding.\n\nFor a polygon, send `shape: \"polygon\"` and one GeoJSON Polygon outer ring instead of latitude, longitude, or radius:\n\n```json\n{\n  \"fence\": {\n    \"name\": \"Waterfront district\",\n    \"shape\": \"polygon\",\n    \"geometry\": {\n      \"type\": \"Polygon\",\n      \"coordinates\": [[[-122.40, 37.79], [-122.39, 37.79], [-122.39, 37.80], [-122.40, 37.80], [-122.40, 37.79]]]\n    }\n  }\n}\n```\n\nCoordinates are `[longitude, latitude]`; the ring must be closed and contain 3–500 unique vertices. V1 accepts one convex outer ring without holes or antimeridian crossing, within ±85° latitude and a backend-derived enclosing radius of 100–10,000 meters. The server derives the enclosing center and `base_radius_m`; never estimate or send them. Polygon geometry can be replaced through the normal partial-update endpoint, but a saved fence cannot change between circle and polygon.\n\n### Radius — validate before sending\n\nThe supported range is **100–10,000 meters (0.1–10 km, roughly 0.06–6.2 miles, or 328–32,808 feet)**. The API only accepts an integer number of meters and does not do unit conversion. Whatever unit the user gives — kilometers, miles, feet, or meters — convert to meters yourself and validate against the range **before** calling the API:\n\n- `radius_m = km * 1000`\n- `radius_m = mi * 1609.34`\n- `radius_m = ft * 0.3048`\n\nThis applies in both directions: a value that's fine in the user's stated unit can still be out of range once converted (e.g. \"10 miles\" is ~16,093m, well past the 10,000m maximum), and a value that sounds small can still be too small (e.g. \"50 feet\" is ~15m, under the 100m minimum). If the converted value is out of range, tell the user it's too large or too small (state the actual range) and ask them to adjust — don't send the request and relay a raw validation error back.\n\n### Attributes — bounded, flat, scalar\n\n`attributes` is a flat JSON object that rides along on every geofence event (`data.attributes`) — automation context like `store_name`, `promo_code`, `tier`. Limits, enforced on create/update and imports alike:\n\n- at most **10** key/value pairs per fence\n- keys at most **64** characters; string values at most **254** characters\n- whole payload at most **5 KB** encoded\n- values must be scalars (string/number/boolean) — nested objects, arrays, and `null` are rejected\n\n## Editing a Fence\n\n```\nPUT /v1/environments/:environment_id/geosets/:id/fences/:fence_id\n```\n\n```json\n{ \"fence\": { \"radius\": 500 } }\n```\n\nGenuine partial update: an omitted key preserves the current value; a present key writes through. Omitting `dwell_threshold_seconds` keeps the current threshold, and so does `\"dwell_threshold_seconds\": null` (unlike a GeoJSON import, where `null` turns dwell off). Send `{\"fence\": {\"dwell_threshold_seconds\": 600}}` to set 10 minutes, or `0` to turn dwell off. `attributes` is replaced wholesale (`{}` clears it), not merged per key. `external_id` cannot be changed — including it 422s. The merged fence is re-validated, and a 422 names the failing field (`radius`, `latitude`, `longitude`, `name`, `geometry`, `dwell_threshold_seconds`, or `attributes`). Relay that field and message. The stored attributes are re-validated on every update, even when `attributes` is omitted, so an attribute that is already invalid can fail an unrelated edit; explain the field the 422 names. An `attributes` key named `dwell_threshold_seconds`, in any casing, is an ordinary custom attribute: it is stored as sent and never changes the fence's threshold.\n\n**The edit hits the shared fence record**: if `geoset_ids` shows more than one set, the change (including `enabled: false`) applies everywhere the fence appears. Surface that to the user before editing a multi-set fence.\n\nTo take a fence out of rotation without deleting it, set `enabled: false` — this unregisters the region from the mobile SDK's nearby-geofence query but keeps the record and its references intact. That's the right answer to \"turn off\" or \"pause\"; delete is for \"remove permanently.\"\n\n## Duplicating\n\n`POST /geosets/:id/duplicate` duplicates a **geoset**. Body `{\"geoset\": {\"name\": \"...\"}}` is optional — omitted or blank defaults to \"Copy of \u003csource name\u003e\". This is a *membership* copy: the new set contains the **same shared fences**, not clones, so a later edit to any fence shows up in both sets. If the user wants to tweak the copy independently, **do not call duplicate or copy-mode first**. Explain the sharing and either create independent fences with fresh `external_id`s (for example through export/import) or ask which path they want before changing anything.\n\nThere is no duplicate endpoint for a single fence: `GET` the source fence, then `POST` a create with the same fields, a new name, and a **new `external_id`** (or omit it) — reusing the source's `external_id` 422s because it *is* the source fence.\n\n## Bulk Move, Copy \u0026 Delete\n\nPrefer these endpoints over looping single-fence calls — one request, one audit record, all-or-nothing.\n\n**Move / copy to another set:**\n\n```\nPOST /v1/environments/:environment_id/geosets/:id/fences/move\n```\n\n```json\n{ \"fence_ids\": [12, 34, 56], \"target_geoset_id\": 7, \"mode\": \"move\" }\n```\n\n`mode` is `\"move\"` (default — removes membership from the source set) or `\"copy\"` (fences end up in both). The target must be a different set. All-or-nothing: any `fence_ids` entry that isn't a member of the *source* set 422s with the offending ids listed, and nothing changes. Up to 10,000 ids per request; the target's 10,000-fence cap counts only fences not already members. The response is `{\"fences\": [...]}` with each fence's post-move `geoset_ids`.\n\nLike § Duplicating, **copy shares the fence records** — an edit after the copy shows up in both sets. And because a *move* changes what the source set contains, an automation triggering on the source set stops firing for the moved locations — if the source set shows usage (`GET /geosets/:id/usage`), say so before moving.\n\n**Bulk delete from this set:**\n\n**This is destructive with no undo.** Before calling it, list the matching fences by name, say which ones this set is the last home for (check `geoset_ids`), and wait for the user to confirm. A request like \"delete the closed ones\" names a pattern, not the fences, so it still needs that confirmation. Same posture as § Deleting.\n\n```\nPOST /v1/environments/:environment_id/geosets/:id/fences/delete\n```\n\n```json\n{ \"fence_ids\": [12, 34, 56] }\n```\n\nSame membership semantics as single-fence delete, batched: each fence is removed from *this* set, and permanently deleted only if this was its last set. The response's `deleted_fence_rows` is the count of fences that were permanently deleted (vs. merely unlinked). All-or-nothing with the same 422-on-non-member behavior as move.\n\n## Deleting — the API blocks in-use sets; check usage to name them\n\nA delete confirmation must state **both** of these, every time:\n\n1. **What blocks the delete** — the campaigns and segments from the usage endpoint, named.\n2. **What the delete would destroy** — the fences that belong *only* to this set are permanently deleted with it. Count them and say the number.\n\nStopping after (1) is the common miss, and it is the dangerous one: it reads as \"clear these references and you're safe\", so the user clears them and loses fences they never meant to touch. Say it even when the delete is currently blocked and you expect to go no further.\n\n```\nGET /v1/environments/:environment_id/geosets/:id/usage\n```\n\n```json\n{ \"usage\": {\n  \"campaigns\": [ { \"id\": \"42\", \"name\": \"Store Visit Follow-up\", \"type\": \"triggered\", \"transition\": \"enter\", \"exit_condition\": false, \"conditional_wait\": false, \"updated\": 1720000000, \"archived\": false, \"state\": \"running\" } ],\n  \"segments\": [ { \"id\": \"11\", \"name\": \"Currently near a store\", \"updated\": 1720000000, \"archived\": false } ]\n} }\n```\n\nUsage returns two lists. A **campaign** can reference a geoset as an enter/dwell/exit **trigger** (`transition`), an **exit condition** (`exit_condition: true`), and/or a **conditional wait** (`conditional_wait: true`) — any combination; a campaign that only waits/exits on the set has an empty `transition`. A **segment** references it through a geofence condition. Both lists include archived records (newest first, each appearing once); an unused geoset returns empty (non-null) lists.\n\n**`DELETE /geosets/:id` 422s if the set is in use** — the server-side guard rejects deletion with `geoset id: N is used by at least one campaign or segment` when any campaign (trigger, exit condition, or conditional wait) or segment references it, **including archived ones**. It does *not* tell you *which* references block it. So call the usage endpoint first — not because the API won't stop you, but so you can name the specific campaigns and segments to the user, get explicit confirmation, and then either delete or back off. If you skip the pre-check and the delete 422s, read it as \"still referenced,\" surface the referencing records, and don't treat it as a mistake of your own.\n\nDeleting a set removes its memberships: a fence that belonged only to this set is **permanently deleted**; a fence shared with other sets survives in those. Compare each fence's `geoset_ids` against this set to get the count for point (2) above. Deleting a single fence (`DELETE .../fences/:fence_id`) works the same way — it removes the fence *from this set*, and only deletes the record outright when this was its last set. Check `geoset_ids` to know which of the two you're about to do. To delete several fences at once, use the bulk endpoint (§ Bulk Move, Copy \u0026 Delete) rather than a loop of single deletes.\n\n## Geocoding — resolve places through the API, don't estimate\n\nWhen the user names a place instead of giving coordinates (a store, an address, \"the office\"), resolve it with the Places proxy — do not estimate coordinates from general knowledge and do not fetch external URLs (store locators etc. aren't reachable from here):\n\n```\nPOST /v1/environments/:environment_id/places/geocode\n```\n\n- Forward: `{\"address\": \"1 Ferry Building, San Francisco\"}` → `{\"results\": [{\"place_id\", \"formatted_address\", \"latitude\", \"longitude\"}]}`\n- Reverse: `{\"latitude\": 37.7955, \"longitude\": -122.3937}` → same shape. Exactly one mode per call — mixing address with coordinates 422s.\n\nFor ambiguous names (\"the Starbucks in Springfield\"), `POST /places/autocomplete` with `{\"input\": \"...\"}` returns candidate places, and `POST /places/details` with a `place_id` returns the resolved address and coordinates. Multiple or ambiguous results mean you show the `formatted_address` candidates and let the user pick — never silently take the first hit for a chain or a common street name. Confirm the resolved address with the user before creating the fence.\n\n**If the Places proxy is down or misconfigured, that is the answer.** A 400/422/500 from geocode or autocomplete means you cannot resolve the place — report the failure and ask the user for coordinates. Do not fill the gap with coordinates you recall for the landmark, not even as a suggestion for them to approve: a plausible-looking latitude/longitude is exactly the kind of thing that gets waved through, and a fence in the wrong place fails silently for months.\n\n## Exporting a Geoset\n\n```\nPOST /v1/environments/:environment_id/geosets/:id/exports   →   {\"export_id\": \"123\"}\n```\n\nThe export runs asynchronously: poll `GET /v1/environments/:environment_id/exports/:id` until ready and download via `.../exports/:id/download` (it also appears in the workspace's Exports UI). The file is **GeoJSONL** with one GeoJSON Feature per line. `id` is the fence's `external_id`; circles export as `Point` geometry with `[longitude, latitude]` coordinates and `properties.radius`, while polygons export their exact `Polygon` geometry without a radius property. `properties` also carries the fence name and non-reserved attributes, plus `dwell_threshold_seconds` on every fence, including `0` when dwell is off. The explicit zero lets an exported file turn dwell off again on re-import, so thresholds round-trip exactly.\n\nThe exported `.geojsonl` file can be imported directly, without wrapping its lines in a FeatureCollection. It is the lossless round-trip format, except for the reserved-name attributes below. Re-import still applies the usual polygon validation; exporting or converting the file does not bypass it. A CSV rebuilt from the fence list is self-contained when it carries the `dwell_threshold_seconds` column; without it, matching fences keep their current thresholds and new fences default to off (`imports_exports.md` § Exporting a Geoset). CSV cannot express polygons. `enabled` is not exported: existing fences keep their enabled/disabled state when re-imported, while newly created fences are enabled.\n\nA fence created before dwell support may still store an attribute named `dwell_threshold_seconds` (in any letter case). An export made after dwell support omits that attribute and writes the fence's real threshold in its place, so re-importing that new export cannot turn the old value into a threshold. Older files are different: an export made before dwell support, or the original CSV/GeoJSON that created the attribute, now sets a real threshold in **seconds** from a numeric `dwell_threshold_seconds` column or property, and a text value fails the row. Warn the user before they re-upload such a file, and ask them to rename that column or property first if it meant something else (for example minutes). Import replaces `attributes` wholesale, so re-importing a new export removes that attribute from the matching fence. Polygon attributes named `radius`, `latitude`, or `longitude` are dropped the same way. To keep such a value, rename the attribute with a fence update (`PUT`, which replaces `attributes`) before exporting.\n\nBulk **imports** (CSV or GeoJSON) are documented in `imports_exports.md` § Geoset Imports — read that before creating one. Imports **merge by default** (upsert rows, remove nothing); `\"replace\"` mode makes the file the set's entire membership, so a partial file removes fences: name the fences it will remove and wait for the user to confirm before creating a replace import. Mode choice and its other warnings live there.\n\n## Viewing Geofence Activity\n\nThe activity log returns **50 rows per page** and retains only about **30 days**. Two consequences that belong in every answer built on it:\n\n- Page through the retained window before quoting a number, and present it as \"in the last ~30 days\" — never as lifetime history.\n- **An empty result means \"nothing in the retained window\", not \"nothing ever.\"** Do not write \"no transitions have been recorded at all\", \"there is no geofence activity in this workspace\", or any wording that generalises past the window — older transitions have aged out and are simply not visible here. Say what the window showed, then offer the SDK-side explanations for why it might be empty.\n\nTo see who **entered, dwelled in, or exited** a geoset/fence (the geofence events over time), use the **activity log**, not the audit log: `GET /v1/environments/:environment_id/logs?type=geofence_entered`, `geofence_dwelled`, or `geofence_exited`, optionally with `customer_id` to scope to one profile — see `customers.md` § Profile activity. For a trailing window (\"in the last 7 days\"), send only `from` (RFC3339, the older bound) and read newest first; add `to` only for a window that ends in the past. The bounds apply to the first page only: a `continuation` request drops them, so keep only rows inside the window and stop paging once rows fall older than `from`. In the UI this is **Journeys → Activity logs** with Activity Type = **Geofence**. The account audit log (`operations.md`) is a *different* surface — it records who *changed a geoset's configuration* (created/renamed/deleted), not transition activity. Match the surface to the question: \"who dwelled at the store?\" → activity log; \"who deleted this geoset?\" → audit log.\n\n\n## Common Pitfalls\n\n1. **Radius is meters-only server-side.** Always convert and validate client-side — the API won't tell you the user meant kilometers.\n2. **Fences are shared records.** Check `geoset_ids` before editing, disabling, or deleting — a multi-set fence changes everywhere at once, and a fence delete only removes membership until the last set.\n3. **Geoset delete 422s if the set is in use.** The server blocks deletion when any campaign or segment (incl. archived) references it. Call `/geosets/:id/usage` first anyway — the 422 won't name the references, so use usage to tell the user exactly which campaigns and segments are in the way before confirming.\n4. **An explicit dwell value in an import updates matching fences.** A `0` in a CSV `dwell_threshold_seconds` cell, or a GeoJSON `null` or `0`, turns dwell off even in merge mode. Omitting the column or property, or leaving a CSV cell blank, preserves existing thresholds; new fences default to off. Details are in `imports_exports.md` § Geoset Imports.\n5. **`external_id` is immutable and workspace-unique.** A create that 422s on `external_id` means the fence already exists — link it via import, don't rename the id.\n6. **Duplicate and copy-mode both share fences.** `POST /geosets/:id/duplicate` and `fences/move` with `\"mode\": \"copy\"` reproduce *membership*, not fence records — later edits show up in every set.\n7. **Bulk move/delete are all-or-nothing.** A 422 listing fence ids means those ids aren't members of the source set; nothing was changed. Fix the list, don't fall back to per-fence calls.\n8. **400 with \"Upgrade your plan\" on any geoset or places call means the account's plan doesn't include geofencing** — not a bug in your request. Say so plainly rather than retrying.\n9. **Geocode through `/places/geocode`** — don't estimate coordinates from your own knowledge or fetch external URLs, and confirm the resolved address with the user.\n\n## Linking to the UI\n\nGeosets have their own top-level nav section. When you create or reference one, provide the UI link:\n- Geosets list: `https://fly.customer.io/workspaces/$ENV_ID/geosets`\n- A set's fences: `https://fly.customer.io/workspaces/$ENV_ID/geosets/:id/geofences`\n- New fence in a set: `https://fly.customer.io/workspaces/$ENV_ID/geosets/:id/geofences/new`\n- CSV/GeoJSON import into a set: `https://fly.customer.io/workspaces/$ENV_ID/geosets/:id/geofences/import`\n- Single fence: `https://fly.customer.io/workspaces/$ENV_ID/geosets/:id/geofences/:fence_id`\n\nThe nav item and these routes only appear for workspaces with geofencing enabled (§ above) — if the user can't see the page, that's the same gating, not a bug.\n","goals.md":"---\nminimum_plan: essentials\nfeature_flag: show-goals\n---\n\n# Goals Tracking\n\n\u003e **UI link:** `https://fly.customer.io/workspaces/$ENV_ID/journeys/goals/:goal_id` — link the user here when you create or reference a goal.\n\n## Overview\n\nStandalone goals measure whether profiles complete a desired action (the \"goal event\") and optionally track a funnel from a start event to the goal event. Unlike automation conversion goals, standalone goals can attribute goal completions across multiple automations and broadcasts.\n\n## Key Enum Values\n\n**`value_type`:** `0` = none, `1` = fixed value (set `value` + `currency`), `2` = from event attribute (set `value_attribute` + `currency`)\n\n**`attribution_metrics` channel keys:** `0`=Email, `2`=SMS, `4`=Slack, `5`=Push, `6`=In-app, `9`=WhatsApp. Values: `sent`, `delivered`, `opened`, `human_opened`, `clicked`, `human_clicked`.\n\n**`goal_event_type`** (also `funnel_start_event_type`) selects what kind of thing `goal_event`/`goal_event_filter` (or the funnel equivalents) refer to. This is a bare integer with no server-side cross-check against the name/filter you send — a wrong type is accepted and silently never matches:\n\n| `goal_event_type` | Shape | `goal_event` | `goal_event_filter` |\n|---|---|---|---|\n| `1` | Event | The event name (e.g. `purchase`) | Attribute conditions on the event payload |\n| `2` | Attribute change | The attribute name | Conditions comparing before/after value |\n| `3` | Segment membership | Literally `entered` or `exited` | A condition on `segment_id` for the segment in question |\n| `6` | Delivery metric | The metric name (e.g. `clicked`) | Conditions on delivery columns — see Common Pitfalls #6 for the exact field set |\n\nThere is no \"message-based\" `goal_event_type` distinct from delivery metric (`6`) — a goal that completes when a profile engages with a specific message is a delivery-metric condition filtered to that message's `campaign_id`/`action_id`/`newsletter_id`.\n\nConfirm the exact `goal_event_filter` shape for type `2` or `3` against a real create call before relying on it for anything beyond a simple example — the nested-field convention for attribute changes is not fully documented here.\n\n## Refreshing Goal Data\n\nThree endpoints return time-series data. All require `start` and `end` query params (unix timestamps) and have a 2-minute timeout.\n\n| Endpoint | Returns |\n|----------|---------|\n| `GET .../goals/:id/data` | Overall goal + funnel time series |\n| `GET .../goals/:id/goal_refresh` | Per-campaign/action attribution breakdown with revenue |\n| `GET .../goals/:id/funnel_refresh` | Funnel starts / completions / conversion rate (errors if no funnel configured) |\n\n## Common Pitfalls\n\n1. **Tag IDs are strings on update, not integers.** Create doesn't accept tags — add them via update after creation.\n2. **Funnel requires all three fields.** If you set any of `funnel_start_event`, `funnel_start_event_type`, or `funnel_time_window_seconds`, you must set all three.\n3. **Attribution metrics keys are strings in JSON.** JSON object keys must be strings: `{\"0\": \"sent\"}`, not `{0: \"sent\"}`.\n4. **Refresh endpoints are slow.** 2-minute timeout. Large date ranges may take significant time. `funnel_refresh` runs its Snowflake query synchronously on a budget just under that timeout; a range too wide to compute in one request returns a 408 with code `context_timeout` asking for a shorter date range, so retrying the same range won't help — shrink it.\n5. **A mismatched `goal_event_type` fails silently, not with an error.** Create validation only checks that `goal_event_type` is non-zero — it never confirms the type agrees with `goal_event`/`goal_event_filter` (see the table above). A segment goal built with the wrong type is accepted and persisted but never records a completion.\n6. **The valid delivery-metric filter fields:** `campaign_id`, `action_id`, `newsletter_id`, `transactional_message_id`, `segment_id`, `machine`. Do not guess a field name from another part of the API. `tracked_response` is a real field, but it belongs only to the separate `tracked_response_metrics` endpoints. Use `action_id` to target one specific in-app CTA. Any other field fails goal validation with a 400 error (FLO-4729).\n","health.md":"# Workspace Health\n\nReal-time workspace health metrics: queue processing status, alerts, tasks, and daily statistics.\n\n## Endpoints\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/health` | `env_health.Get` | Overall workspace health: score, processing queues, egress queues, tasks, alerts, daily stats |\n| GET | `/v1/environments/{environment_id}/health/top_events` | `env_health.TopEvents` | Top event names by volume today |\n| GET | `/v1/environments/{environment_id}/health/segments` | `env_health.Segments` | Segment usage stats (total, unused, newsletter-only counts) |\n| GET | `/v1/environments/{environment_id}/health/campaigns` | `env_health.Campaigns` | Active/stale automation counts and stale automation details |\n| GET | `/v1/environments/{environment_id}/health/api_triggered_broadcasts` | `env_health.APITriggeredBroadcasts` | API-triggered broadcast stats (pending, errors, processed today) |\n| GET | `/v1/environments/{environment_id}/health/objects` | `env_health.Objects` | Custom object type stats and processing counts |\n\n## How to read the main health response\n\nThe main `/health` endpoint returns three top-level indicators:\n\n- **`health`** — overall status: `\"healthy\"`, `\"busy\"`, or `\"degraded\"`.\n- **`score`** — numerical health score (0–100).\n- **`alerts`** — an array of user-facing alerts, each with a `severity` (`\"info\"`, `\"caution\"`, or `\"error\"`), title, message, and `action_type` for linking to the relevant detail view. Delay alerts also carry `category` and `surface` (`processing` or `egress`), which locate the category the alert was raised for — that category holds the `delay_cause` and `delay_attribution` behind it.\n\nThe `health` status is derived from the alerts: if any alert is `\"error\"` severity the workspace is `\"degraded\"`, if any is `\"caution\"` it's `\"busy\"`, otherwise it's `\"healthy\"`. To understand *why* a workspace is degraded, look at the alerts and the processing/egress categories they reference.\n\nAdditional fields:\n\n- **`processing`** — ingestion queue metrics per category\n- **`egress`** — delivery/outbound queue metrics per category\n- **`tasks`** — currently running backfill task, queued count, and recently failed tasks\n- **`stats`** — daily throughput rates, processed counts vs. previous day, and failed event counts\n- **`disabled_queues`** — list of manually disabled queue category names (omitted when empty)\n\n## Understanding processing delays\n\nEach processing and egress category reports:\n\n- **`pending`** — the number of items waiting to be processed in that category's queue.\n- **`delay_seconds`** — how far behind the category is. The two sides measure this differently: **processing (ingress)** categories report the true age of the oldest queued message (now − its enqueue time), so the number is an actual wait time; **egress** categories report `pending ÷ processing rate`, smoothed over time, because egress queues don't expose a head timestamp. Treat an egress figure as an effective backlog duration rather than the wall-clock wait of any single message.\n- **`delay_tier`** — the severity tier for `delay_seconds`: `normal` (\u003c 30s), `slight_delay` (\u003c 60s), `delayed` (\u003c 300s), `major_delay` (≥ 300s). Use the reported tier rather than re-deriving one from the raw seconds.\n- **`delay_cause`** — why the category is behind. Present only when the tier isn't `normal`, and omitted when there was no throughput baseline to compare against.\n- **`secondary_cause`** — only alongside `delay_cause: \"campaign_throttle\"`, and only when the messages the throttles aren't pacing are behind on their own account: `volume` if that remainder is working through its own backlog at the workspace's normal rate, `platform` if it is going out well below it. A `campaign_throttle` verdict answers for the throttled subqueues and nothing else, so read this before telling a customer the backlog is just their throttle.\n- **`throttled_sends`** — on the delivery categories, the campaigns pacing their own sending through a message throttle configured on the campaign itself, most-queued first and capped at a few. Each entry carries `id`, `name`, `kind` (`campaign` or `api_triggered_broadcast`), `channel`, `queued`, `messages_per_minute` and `clear_seconds` (how long the queued messages take to go out at that rate). Present whenever such a campaign is holding work, so it does not on its own mean the category is behind — read `delay_cause` for that. This is the answer to \"which automation is holding up sending\", and `clear_seconds` is the answer to \"when will it finish\".\n- **`delay_attribution`** — which import, send or throttled campaign is responsible, on the `csv_imports`, `integrations` and `broadcasts` categories and on a delivery category with `throttled_sends`. `label` is the sync, import or broadcast name, `source_kind` and `source_id` identify it for linking, and `share` is its fraction of the sampled backlog. The alert message deliberately does not contain the label: an alert whose `category` and `surface` point here is how the dashboard finds it. Present when one source accounts for more than half the sample, so a mixed backlog reports nothing rather than naming the wrong integration. It does not imply the category is behind: a lane holding work is named while it is still keeping up, so read `delay_tier` for that. When it's present, name it instead of describing the backlog generically — that's the answer to \"which import or broadcast is holding this up\".\n- **`unthrottled_pending`** and **`unthrottled_delay_seconds`** — on a delivery category with `throttled_sends`, the backlog with everything those throttled subqueues hold taken out of it, and how long that remainder takes at the drain rate the throttles leave behind. `delay_seconds` is `pending ÷ rate`, so a campaign paced at a couple of messages a minute dominates it completely and says nothing about the messages queued beside it. These two are how you tell \"the throttle is the whole story\" from \"the throttle plus something else\". `unthrottled_pending` of zero means the throttled campaigns are the entire backlog.\n- **`drain_seconds`** — how long it would take to fully drain the current backlog at the current processing rate, assuming no new items arrive. This is useful for estimating recovery time after a spike.\n\n`delay_cause` is the field that tells you whether there is a problem at all:\n\n| Cause | Meaning | How to treat it |\n|---|---|---|\n| `volume` | The category's queues are processing at a healthy share of the peak rate this workspace itself sustained over the last 24h. The backlog is the workspace's own send volume working through the queue. | Not a fault. Explain the expected wait and use `drain_seconds` to set expectations; don't recommend escalation. |\n| `platform` | Work is queued while throughput has collapsed relative to what this workspace normally sustains. Nothing about its own volume explains the delay. | Treat as platform-side. Recommend contacting Customer.io support rather than proposing customer-side workarounds. |\n| `rate_limited` | The backlog is deliberate pacing from a workspace-level configured rate limit. | Working as configured. Only relevant if the customer wants to change the limit. |\n| `campaign_throttle` | The campaigns named in `throttled_sends` account for most of the category's *projected delay* — not most of its messages: an egress delay is `pending ÷ rate`, so a subqueue paced at two messages a minute can hold a minority of the backlog and still be nearly all of the wait. Also requires every queue holding that throttled work to be moving enough messages to be those throttles pacing themselves, with no other backlogged queue in the category stalled. A throttle-dominated category that has stopped draining reports `platform` instead, so this cause never hides an outage — not on another channel in the same category, and not in another egress category, where a workspace-wide \"Delivery stalled\" alert is kept whenever any other category is holding work with no readable delay. | Not a fault in itself, and never escalate the throttled part. Name the campaigns from `throttled_sends`, quote their `messages_per_minute` and `clear_seconds`, and point at the campaign's own message settings as the only place the rate can change. Then check `secondary_cause`: it answers for everything the throttles are *not* holding, and `platform` there is worth escalating even though the primary cause isn't. The alert's own action stays on the campaign's message settings — the throttle is the half the customer can act on — so the escalation is yours to make from `support_payload`, which carries the secondary cause and the unthrottled figures. A remainder large enough to be a stall on its own also keeps the workspace-wide \"Delivery stalled\" alert and its support route. This cause outranks a collapsed-throughput reading, because a throttled queue is held below its rate on purpose; a workspace-level `rate_limited` verdict outranks it in turn. |\n\nA high `pending` count with a low `delay_seconds` means the system is processing quickly and keeping up despite volume. A high `delay_seconds` with `delay_cause: \"volume\"` means a large send is in flight and draining normally — this is the case that looks alarming but isn't. A high `delay_seconds` with `delay_cause: \"campaign_throttle\"` can read as hours behind and still be exactly what the customer configured; answer it from `throttled_sends`, but check `secondary_cause` and `unthrottled_delay_seconds` before saying so — the throttle explains the delay figure, and those two say whether it explains the whole backlog. A high `delay_seconds` with `delay_cause: \"platform\"` means the processing rate has dropped, and is the one worth escalating.\n\n## Processing categories explained\n\nProcessing categories represent the ingestion side — data coming *into* the workspace and being acted on.\n\n| Category | What it covers |\n|----------|----------------|\n| `identify` | Profile and identity data |\n| `events` | Custom events received |\n| `campaigns` | Automation workflow progression |\n| `broadcasts` | One-time segment/list sends |\n| `transactional` | Transactional messages processing |\n| `delivery_metrics` | Open, click, bounce tracking (not delivery itself) |\n| `csv_imports` | CSV file imports |\n| `integrations` | Data from connected integrations and Reverse ETL |\n| `objects` | Custom objects, relationships, object-based segments |\n| `segments` | Static segment changes and scheduled re-evaluation |\n\nCategories are split into two priority tiers. **High-priority** categories — `identify`, `events`, `broadcasts`, `transactional`, `objects` — handle data that directly affects user-facing messaging and must be processed promptly. **Low-priority** categories — `delivery_metrics`, `csv_imports`, `integrations` — handle data that is important but can tolerate delay without impacting active messaging.\n\nWhen a high-priority category is under stress (i.e. its delay exceeds its threshold), the system intentionally pauses low-priority processing to free capacity for the high-priority work. This is why users may see `delivery_metrics`, `csv_imports`, or `integrations` back up even though those categories themselves have no issues — they're being held back to let critical work catch up first. Message sending and campaign triggers for data already in the workspace run on the high-priority lanes, so an import backlog doesn't delay them.\n\n## Egress categories explained\n\nEgress categories represent the outbound side — messages being delivered and internal operations triggered by processing.\n\n| Category | What it covers |\n|----------|----------------|\n| `message_delivery` | Email, push, SMS delivery |\n| `transactional_delivery` | Transactional message delivery |\n| `webhooks` | Outbound webhook calls from journeys |\n| `journey_actions` | Data lookup and profile update steps in journeys |\n| `alias` | Profile merge and anonymous event resolution |\n| `related_updates` | Cascading updates from object/relationship changes |\n\nEgress includes both outbound delivery to external systems (email providers, push services, webhook endpoints) and internal system operations that fan out from ingestion (alias resolution, relationship cascades). Delays in egress categories often indicate downstream pressure — a slow email provider, webhook timeouts, or complex cascading updates from object changes.\n\n## How alerts escalate\n\nAlerts use three severity tiers with concrete thresholds:\n\n- **`info`** — delay is ≥ 30 seconds but still within the category's caution threshold. The workspace is slightly behind but catching up. These are informational and don't change the overall health status.\n- **`caution`** — delay exceeds the category's threshold (see table below). The workspace is meaningfully behind and users may notice delayed processing.\n- **`error`** — delay is ≥ 600 seconds on a high-impact category *and* volume doesn't explain it (`delay_cause` is not `volume`), OR there are failed tasks, OR queues have been manually disabled. A `campaign_throttle` category never reaches `error`: it is `info` while the throttle is the whole story and `caution` once `secondary_cause` is set, because the remainder's rate is the queue's measured rate less a configured one — an estimate, not a measurement. This indicates a significant issue that likely needs attention. A very large send can push a high-impact category well past 600s on its own; that case stays at `caution` (overall health `busy`) rather than escalating to `degraded`.\n\nLow-priority categories never reach `error`, however far behind they are: their queues are paused whenever high-priority work needs the capacity, and nothing being sent or triggered waits on them. They read `info` when the backlog is the workspace's own volume or deliberate pacing, and `caution` only when throughput has collapsed (`delay_cause` is `platform`, or there was no baseline to judge against). `delivery_metrics` is `info` in every case. So a multi-hour import is not a reason to describe a workspace as degraded — check the high-priority categories before calling anything a fault.\n\nWhen a high-priority category is over its threshold, alerts for low-priority categories are suppressed into a single summary alert (e.g. \"Low-priority processing paused\") rather than showing individual alerts for each backed-up low-priority queue. This avoids alert noise — the root cause is the high-priority backlog, not the low-priority queues themselves.\n\n## Category thresholds\n\nMost categories share a **120s** caution threshold. The exceptions are `transactional` and `transactional_delivery` at **60s** — users expect near-real-time processing for transactional messages like password resets and order confirmations.\n\nThresholds — and the `delay_tier` boundaries they're built from — scale with the workspace's profile count, because routine bulk work leaves bigger workspaces further behind: **1x** under 5M profiles, **2x** at 5–25M, **3x** at 25–100M, **4x** at 100M and above. `transactional` and `transactional_delivery` are exempt (a late password reset is late at any size), as are the stalled-processing and disabled-queue alerts, which don't key off a delay threshold at all. `delay_seconds` is the same measurement everywhere — only the grading moves — so don't compare a raw delay against the unscaled numbers above on a large workspace; read `delay_tier` instead.\n\n## Tasks and backfills\n\nThe `tasks` field reports on long-running background operations (imports, re-segmentation, data backfills):\n\n- **Running task** — the currently executing task, if any, with its name and start time. A task running for more than **30 minutes** triggers a `caution` alert, as most tasks should complete faster.\n- **Queued count** — how many tasks are waiting to run. **5 or more** queued tasks trigger an `error` alert, as this suggests a bottleneck or stuck running task.\n- **Failed tasks** — recently failed tasks with their error details. Any failed task triggers an `error` alert.\n\n## Daily stats and spike detection\n\nThe `stats` field provides daily throughput context:\n\n- **`throughput`** — current processing rate (items per second)\n- **`processed_today`** / **`processed_yesterday`** — total items processed so far today vs. the full previous day, useful for spotting trends\n- **`failed_today`** — count of items that failed processing today\n\nThe system detects **volume spikes** by comparing today's processed count to yesterday's count at the same elapsed time of day. A spike alert fires when today's volume is **50% or more above yesterday's** at the equivalent point in the day, and only when the baseline exceeds **1 million items** (to avoid false alerts on low-volume workspaces). This helps identify unexpected surges — a misconfigured integration, a runaway script, or a legitimate traffic spike that may need capacity attention.\n\n","imports_exports.md":"# Imports \u0026 Exports — CSV Data Import and Data Export\n\n\n## CSV Imports\n\nImports bring data into a workspace from CSV files or Google Sheets. The `type` field determines what the import does:\n\n| Import type | What it does |\n|-------------|-------------|\n| `people` | Create and update profiles with attributes. Upserts — creates new people or updates existing ones. Optionally adds them to a segment via `segment_id`. |\n| `event` | Create events for existing people. Requires `_cio_customer_id` and `_cio_name` columns. Does not create or update people. |\n| `object` | Create and update objects in a custom object type. Requires `object_type_id`. |\n| `relationship` | Create relationships between people and objects. Does not create the people or objects themselves. |\n| `segment` | Add existing people to a static segment. Requires `segment_id`. Does not create or update people. |\n| `geoset` | Merge (default) or replace a geoset's fences from a CSV or GeoJSON file. Requires `geoset_id`. See § Geoset Imports. |\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/imports` | `imports.Index` | List imports. Params: `type`, `segment_id`, `page` (default 1), `limit` (default 50, max 1000). Always paged |\n| POST | `/v1/environments/{environment_id}/imports` | `imports.Create` | Create an import |\n| GET | `/v1/environments/{environment_id}/imports/{id}` | `imports.Get` | Get an import |\n| PUT | `/v1/environments/{environment_id}/imports/{id}` | `imports.Update` | Update an import |\n| DELETE | `/v1/environments/{environment_id}/imports/{id}` | `imports.Delete` | Delete an import |\n| POST | `/v1/environments/{environment_id}/imports/sign` | `imports.GenerateSignature` | Generate a signed URL for uploading import files |\n| POST | `/v1/environments/{environment_id}/imports/validate` | `imports.Validate` | Validate import configuration before starting |\n| POST | `/v1/environments/{environment_id}/imports/download/import/{id}` | `imports.DownloadImport` | Download the processed import file |\n| POST | `/v1/environments/{environment_id}/imports/download/original/{id}` | `imports.DownloadOriginal` | Download the original uploaded file |\n| GET | `/v1/environments/{environment_id}/spreadsheets/{spreadsheet_id}/sheets` | `imports.GetSheets` | List sheets in a Google Spreadsheet. Returns `{sheets: [{id, name}]}` |\n\n### You Cannot Upload the File Yourself\n\nCreating a file-based import is a three-step flow: `POST /imports/sign` for a signed upload URL, then PUT/POST the file bytes directly to that URL (an S3 host), then `POST /imports` referencing the uploaded path. **You can only do the first and third steps.** The signed URL always points to an S3 host, and your bash sandbox's network allowlist only reaches Customer.io endpoints (the app API, CDP, design-studio, a couple of asset/image hosts) — S3 is never on it, by design, because a signed upload URL is per-request and can't be added to a static allowlist without defeating its purpose. Attempting the upload yourself (`curl` to the S3 URL, `node`, `python`) will not work — do not spend multiple turns on it. If you have not already told the user about this limitation, do so now rather than retrying.\n\nThis applies to every import type in the table above **except `geoset`, which accepts the file content inline in the create request** (see § Geoset Imports) — never tell a user a geoset import needs the UI. For the other types, your actual role for a file-based import is:\n1. Help the user get their data into the correct column/property format (validate against the type's required columns before they upload — see the type-specific sections below).\n2. Tell them to complete the upload themselves via the workspace's Imports UI (or the type-specific import page, e.g. the geoset's Import page under Geosets), since only a real browser can complete the signed-URL upload.\n3. If asked to check on an import already created (by the user, via the UI), `GET /imports/{id}` and `GET /imports` work fine — reading import status/results isn't blocked, only pushing the file bytes is.\n\nFor a small, well-defined dataset a user describes conversationally (not literally attached as a file), you can usually skip the import flow entirely and create records directly via the resource's own CRUD `POST` endpoint in a loop — that only requires JSON bodies over the already-allowlisted app API, no file upload involved. For geofences that's `POST /geosets/{id}/fences` (see `geofences.md`) — though past a handful of rows a merge-mode import with inline `content` is the better tool: one validated call instead of N.\n\nWhat decides this is the shape of what the user handed you, not how many rows it has. Rows pasted in a file format — a CSV block with a header line, a GeoJSON document, a tabular paste — are a file, so they go through the import even if there are only three of them: the import validates every row together, upserts by `external_id` instead of 422-ing on a row that already exists, and leaves one auditable record. Reach for direct CRUD when the user describes the records in prose (\"add a fence at the Ferry Building, 300m\"). A full-membership sync (\"make the set exactly match this list\") is a **replace**-mode import — the only option that removes fences (§ Geoset Imports).\n\n### Geoset Imports\n\n`type: \"geoset\"` merges or replaces the fences of one geoset from a CSV, GeoJSON `FeatureCollection` (RFC 7946), or exported GeoJSONL file (one Feature per line). The body must carry `geoset_id` (as a string), referencing an existing geoset — create the set first if needed. File-only: Google Sheets are rejected for this type. The format is auto-detected from the filename extension (`.geojson`/`.geojsonl`/`.json` → GeoJSON) or, failing that, the content (first non-whitespace byte `{` → GeoJSON, else CSV).\n\n**You can run a geoset import yourself — no signed-URL upload.** `geoset` is the one import type whose create accepts the file content inline: put the raw CSV or GeoJSON text in the `content` field of the create body instead of `path`. `content` is mutually exclusive with `path` and `sheet`; `filename` is still required (its extension is the primary format signal). The inline cap is 8 MiB — a maximal 10,000-fence file fits comfortably — anything larger must go through the UI's signed-URL flow. Bind the file through `--arg` so `cio`'s gojq owns the JSON escaping; never interpolate raw file text into inline JSON:\n\n```bash\ncio api /v1/environments/$ENV_ID/imports -X POST \\\n  --arg content=\"$(cat /tmp/fences.csv)\" \\\n  --json '{import: {type: \"geoset\", geoset_id: \"42\", geoset_mode: \"merge\", filename: \"fences.csv\", description: \"Store fences\", content: $content}}'\n```\n\nThe response is the same import record the signed-URL flow returns; drive and poll it exactly as the lifecycle note below describes.\n\n**Feature-gated**, same as direct geoset CRUD (see `geofences.md`): requires the `geofences` account feature. If the import create call returns 400 with an \"Upgrade your plan\" message, the account's plan doesn't include geofences — say so plainly rather than retrying or assuming a malformed request.\n\n**Merge vs replace — `geoset_mode`.** The create body's `geoset_mode` is `\"merge\"` (the default) or `\"replace\"`:\n\n- **Merge** upserts the file's rows and **removes nothing**. A row whose `external_id` already exists in the workspace updates that fence in place (same fence `id`, same event history) — and if it lived in another set, the shared fence is linked into this set too, with its name/coordinates/radius/attributes rewritten for **every** set that contains it. New `external_id`s create new fences. Fences already in the set but absent from the file are untouched. This is the right mode for \"add these stores\" / \"update these rows\".\n- **Replace** makes the file the set's *entire* membership: everything merge does, plus a fence currently in the set but absent from the file is removed from the set — and permanently deleted if this was its last set. This is the right mode for \"make the set exactly match this list\".\n- In both modes, attributes are replaced wholesale per imported fence; a row with no attribute cells clears whatever a previous import set.\n- In both modes, an imported fence keeps its existing **dwell threshold** when the CSV has no `dwell_threshold_seconds` column or the GeoJSON feature omits that property. New fences default to dwell off (`0`). A `0` in a CSV `dwell_threshold_seconds` cell, or a GeoJSON property set to `null` or `0`, explicitly turns dwell off for the matching fence. A blank CSV cell counts as omitted for that row: it keeps the existing threshold, and a new fence defaults to off. A positive value replaces its threshold. Merge mode protects set membership, not these explicitly supplied field values.\n- Files containing no geofences fail validation in both modes, even with `geoset_skip_invalid_rows`. An empty import cannot clear a set.\n\n**Never choose `replace` on the user's behalf.** Only send `\"geoset_mode\": \"replace\"` when the user explicitly wants a full sync — and before creating it, name what it will remove (fences in the set but not in the file, permanently if this is their last set) and wait for the user to confirm. Importing \"the 10 new stores\" as a replace into a set that has 200 removes the other 190; as a merge it just adds 10.\n\n**Row validation — `geoset_skip_invalid_rows`.** By default validation is all-or-nothing: any row error rejects the whole file — fix the reported rows and re-upload. Setting `\"geoset_skip_invalid_rows\": true` skips invalid rows and imports the rest (a file with *zero* valid rows still fails); the skipped count and a capped error list appear in the import record's `validate_meta` — relay them to the user. **Do not combine `replace` with `skip_invalid_rows` without explicit confirmation**: under replace, a skipped row's fence is *removed from the set* — a typo silently deletes that fence. Prefer fixing the rows.\n\n**CSV format** — required header columns: `external_id`, `name`, `latitude`, `longitude`, `radius` (integer meters, 100–10,000 — see § Radius in `geofences.md`). `external_id` is **required per row** (it's the fence's identity across imports) and must be unique within the file. `dwell_threshold_seconds` is an optional reserved column: a whole number of seconds from `0` to `2147483`, where `0` means dwell is off. A missing column or a blank cell preserves an existing threshold; write `0` to turn dwell off. A new fence defaults to `0` when the column is missing. A non-integer (`5m`, `30.5`) or out-of-range cell is a row error. Reserved names match exactly in lowercase, and a header that differs only by case (`Dwell_Threshold_Seconds`) is rejected. Every column outside the reserved set (`external_id`, `name`, `latitude`, `longitude`, `radius`, `dwell_threshold_seconds`) is an attribute column: each row's non-empty cells become that fence's `attributes` (as strings; an empty cell means the attribute is absent for that fence). So a `dwell_minutes` or `dwell` column is stored as an attribute and sets no threshold. There is no `enabled` column — new fences are created enabled, and re-imported fences keep their current enabled state.\n\n**GeoJSON format** — a `FeatureCollection` or one Feature per line, containing `Point` circle features or supported `Polygon` features. Exported `.geojsonl` files import directly without conversion. Coordinates are `[longitude, latitude]` (GeoJSON's axis order — easy to get backwards). Points require an integer `properties.radius` in meters. Polygons use one closed convex outer ring with no holes and must not supply `radius`, `latitude`, or `longitude` properties; the backend derives their enclosing circle, and export omits attributes with those reserved names. A missing `properties.name` is a row error. The `external_id` comes from, in order: the feature's top-level `id`, then `properties.id`, then `properties.external_id` — one of them must be present. `properties.dwell_threshold_seconds` is the optional threshold in whole seconds (`0`–`2147483`). An absent property preserves an existing threshold; `null` or `0` disables dwell, like an explicit `0` in a CSV cell; a blank CSV cell instead keeps the existing threshold. A new fence defaults to `0` when the property is absent. A fractional or string value is a row error, and so is a property whose name differs only by case (`Dwell_Threshold_Seconds`); it does not become an attribute. All other properties become the fence's `attributes`, keeping their JSON types (numbers/booleans survive, unlike CSV).\n\n**Ask the user which format they're providing and confirm the column/property names before creating the import** — don't guess at a CSV header or GeoJSON property shape from a partial description.\n\nOther constraints, shared with direct CRUD (`geofences.md`):\n- Circle coordinate and radius validation (including the \"no all-zero coordinates\" rule) applies per row. Polygon geometry uses the V1 admission rules in `geofences.md`.\n- Attribute limits apply per fence: at most 10 pairs, keys ≤ 64 chars, string values ≤ 254 chars, ≤ 5 KB encoded, scalar values only (no nested objects/arrays/null).\n- A geoset holds at most **10,000 fences**. The cap applies to the *resulting* membership: under replace that's simply the file's row count; under merge it's the set's existing fences plus the file's net-new ones (the import fails over the cap). The workspace's total fence count is unbounded.\n- **No address-based geocoding in the importer.** It only accepts already-resolved `latitude`/`longitude` (or GeoJSON coordinates) — there is no `address` column that gets geocoded. If the user only has addresses, resolve them first via `POST /places/geocode` (see `geofences.md` § Geocoding); don't invent an address column and expect it to work.\n\nLifecycle note: geoset imports run on a dedicated async pipeline (not the shard backfill), but the API flow is the standard one — create the import, then drive it with `PUT /imports/{id}` update types and poll `GET /imports/{id}` for state, exactly as the UI does. A geoset import can't be pointed at a segment, and an existing import of another type can't be converted to `geoset` via reset.\n\n### Exporting a Geoset\n\nGeosets have a dedicated export, but it's created from the geoset — `POST /v1/environments/{environment_id}/geosets/{id}/exports` → `{\"export_id\": \"...\"}` — not via the `POST /exports/{type}` table below. Poll `GET /exports/{id}` and download as usual. The output format (GeoJSONL) and its round-trip behavior are documented in `geofences.md` § Exporting a Geoset.\n\nTo rebuild an importable **CSV** instead (e.g. as a real-data template for a spreadsheet edit), page through the set's fences and build it in one call:\n\n```bash\ncio api /v1/environments/$ENV_ID/geosets/$GEOSET_ID/fences --params '{\"page_size\": 1000}' \\\n  --jq '.fences[] | [.external_id, .name, .latitude, .longitude, .radius, .dwell_threshold_seconds] | @csv' -r \u003e /tmp/geoset.csv\n```\n\nPrepend the header line `external_id,name,latitude,longitude,radius,dwell_threshold_seconds` and the file re-imports with each fence's threshold intact. Keep the dwell column to make the CSV self-contained: omitting it preserves current thresholds on matching fences but gives new fences no dwell, and a blank cell does the same for its row, so write `0` to turn dwell off. **This core-field CSV does not carry existing custom fence attributes; re-importing it clears them on matching fences.** If those attributes matter, add a CSV column for each attribute key before re-importing — they are a JSON object on each fence, not fixed columns. A set with more than 1,000 fences needs one call per `page`. CSV has no polygon form: a polygon's `latitude`/`longitude`/`radius` in the list are its enclosing circle, so re-importing them turns it into a circle. For a set with polygons, use the GeoJSONL export instead; it preserves both geometry and thresholds.\n\n**The `-r` flag is required.** Without it, `--jq` JSON-quotes each row string, wrapping every line in an extra pair of quotes — this isn't geofence-specific, it's the same behavior documented in `SKILL.md` § Using the `cio` CLI, and it applies to any filter that emits one string per row (`@csv`, `@tsv`, `join(\",\")`, string interpolation), not just single-field extraction.\n\nDon't reach for `node -e` or `python3` to reshape this into CSV — `cio api --jq -r` already does it in one call, and both are a detour here: `node -e` means hand-parsing JSON you already have a filter for, and `python3` runs under Pyodide, where neither subprocesses nor threads start at all (`SKILL.md` § Many-ID Lookups and Sandbox Parallelism). Neither matters if the `--jq` filter does the whole job.\n\n## Exports\n\nExports let users download data from their workspace. Create an export by POSTing to the appropriate sub-path, then poll `GET /exports/{id}` until ready and download via the `download_url`.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/exports` | `exports.Index` | List exports |\n| GET | `/v1/environments/{environment_id}/exports/{id}` | `exports.Get` | Get an export (includes `download_url` when ready) |\n| GET | `/v1/environments/{environment_id}/exports/{id}/download` | `exports.GetURL` | Get the download URL for a completed export |\n\n### Creating Exports\n\nPOST to `/v1/environments/{environment_id}/exports/{type}`. All return `{\"export_id\": \"...\"}`.\n\n| Export Type | Description | Request Body Fields |\n|-------------|-------------|---------------------|\n| `deliveries` | Individual delivery records | `campaign_id`, `action_id`, `newsletter_id`, `transactional_message_id`, `template_id`, `attributes[]`, `delivery_type` (one **exact** channel name; SMS is two of them, `twilio` and `sms`, so either value excludes the other — use `all` or omit to keep both), `metric`, `start_ts`, `end_ts` |\n| `delivery_metrics` | Automation action delivery metrics over time | `campaign_id`, `action_id`, `delivery_type` (same exact-channel rule as above), `period`, `start`, `steps` |\n| `delivery_totals` | Aggregate delivery totals | `campaign_id`, `action_id`, `newsletter_id`, `transactional_message_id`, `template_id` |\n| `newsletter_delivery_totals` | Broadcast delivery totals | `search`, `start_time`, `end_time`, `tag_ids[]`, `channel` |\n| `profile_data` | A single profile data | `customer_id` or `internal_id` |\n| `object_data` | A single object's data | `internal_id` |\n| `journey_metrics` | Automation journey-level metrics | `campaign_id`, `start`, `end`, `resolution` |\n| `segment_memberships` | Profiles in a segment | `segment_id`, `start`, `end`, `resolution` |\n| `content_metrics` | Content performance metrics, one row per template and time bucket (`viewed`, `clicked`, `converted`, `dismissed`, `submitted`, `profile_created`) | `in_app_broadcast_id`, `start`, `end`, `resolution` |\n| `analysis` | Analysis/reporting data | `name`, `start`, `end`, `data_type[]`, `tag_ids[]`, `status[]`, `resolution`, `report_id` |\n| `form_submissions` | Form submission data | `form_id` (must be non-empty), exactly one of `campaign_id` / `newsletter_id` (must be nonzero) |\n| `devices` | Device data | `attributes[]` |\n| `email_suppressions` | Email suppression list | _(no body required)_ |\n\nFor `deliveries` exports, the `metric` field accepts values like `sent`, `opened`, `clicked`, etc.\n\nFor `delivery_totals`, omit unused selectors. All request fields, including\n`exported_from`, are optional, and the creation handler does not enforce exactly\none parent. The worker reads `action_id` first, then `campaign_id`, then\n`template_id`, so `template_id` narrows the export only when neither of the other\ntwo is set. If no selector resolves to a template, the export is created and then\nfails. `action_id` may be an action ID or an `n`-prefixed newsletter template ID.\n","in_app.md":"# In-App Messages — Content, Provisioning \u0026 Surveys\n\nUse this file when creating or editing in-app message content for automations, one-time sends, transactional messages, or anonymous broadcasts. Content is set **directly on the template** — there is no separate node creation, linking, or publishing step.\n\nFor anonymous/lead broadcast CRUD, see `anonymous_broadcasts.md`.\n\n## A global unsubscribe does not apply\n\nIn-app delivery ignores the global `unsubscribed` attribute for automation actions, one-time sends, and anonymous broadcasts. A profile who unsubscribed from everything still receives those in-app messages.\n\n**Topics and channel preferences still apply.** Scoped to that one attribute on purpose: an assigned `subscription_topic_id` and an explicit `in_app` channel preference still narrow who receives an in-app broadcast, matching the rule campaign actions follow. So a topic assignment *can* explain a missing in-app message on those sends, while a global unsubscribe cannot. On them, `send_to_unsubscribed` is stored but tells you nothing, since the attribute it governs is already ignored.\n\n**Transactional in-app is the exception.** The transactional pipeline honors the global `unsubscribed` attribute on every channel: an unsubscribed profile's delivery is recorded as undeliverable with reason \"Customer is unsubscribed.\" unless `send_to_unsubscribed` is enabled in the message settings or overridden on the request. For a transactional in-app message a global unsubscribe *can* explain a missing message, and `send_to_unsubscribed` is the setting that decides it. See `transactional_in_app.md`.\n\n## Provisioning\n\nBefore sending any in-app messages (automations, one-time sends, or anonymous broadcasts), the workspace must be provisioned. Provisioning creates a site ID and credentials for the in-app SDK.\n\n| Endpoint | Use for |\n|----------|---------|\n| `POST /v1/environments/:eid/in_app/provision` | Enable in-app messaging for the workspace |\n| `GET /v1/environments/:eid/in_app/status` | Check provisioning status and last SDK connection time |\n\n## Setting In-App Content\n\n\nAfter creating an automation with an `in_app_action` (or a one-time send/transactional message with in-app channel), set the content on the action's template.\n\n**Author in-app content as Carta components** (the flow below) — storing `body_json.carta_content` is what keeps the message editable in the UI composer. Reserve raw HTML for fully code-owned messages no one will open in the UI: it delivers, but the Carta-based composer shows it as an empty canvas. For the **transactional** in-app channel specifically (create → configure → activate → send), see `transactional_in_app.md`.\n\n### Step 0: Resolve global styles before writing markup\n\nBefore writing any markup, fetch the workspace's global styles and map the tokens you'll reference. **Always use token references instead of hardcoding hex values, font families, or pixel sizes** — this keeps messages consistent with the design system and makes brand changes a recompile, not a rewrite.\n\n```bash\ncio api /v1/environments/$ENV_ID/design_studio/global_styles \\\n  --jq '{colors: [.default.color | to_entries[] | {name: .key, id: .value.\"$extensions\".\"io.parcel.global-styles\".id, value: .value.\"$value\"}], fonts: [.default.font | to_entries[] | {name: .key, id: .value.\"$extensions\".\"io.parcel.global-styles\".id}], spacing: [.default.spacing | to_entries[] | {name: .key, id: .value.\"$extensions\".\"io.parcel.global-styles\".id, value: .value.\"$value\"}]}'\n```\n\nUse the returned IDs to build `globalStyles.*` references in your markup (see § Global Styles below). Apply button styles with `#variant=\"primary\"` on `x-cta` instead of setting `background` and `color` manually. Set `has_global_styles: true` in `body_json` whenever your markup references any token.\n\n### Saving the template (two-step workflow)\n\n**Saving an in-app template is a two-step workflow** — the API will accept `body_json` alone with a 200 response, but the message will render blank in the UI and fail to deliver until you also set the compiled `body`:\n\n1. **Compile** the markup. `POST /v1/environments/:eid/previews/carta_content` with `{ \"content\": \"\u003cx-base\u003e...\u003c/x-base\u003e\", \"has_global_styles\": false }`, sending the `has_global_styles` you write in `body_json`. Response: `{ \"html\": \"...\" }`. The compile resolves token references with the workspace's global styles either way, and applies their component defaults, `#variant` styles included, only when `has_global_styles` is `true`, as the Journeys editor does.\n2. **PUT** the template with **all three** of `body` (the compiled HTML from step 1), `body_json` (the full structure below — `carta_content` is the same markup string you compiled), and `editor: \"visual\"`.\n\nThis whole-message write is for the **first save of a new message**. To change a message that already has content, follow § Updating an existing message instead.\n\n```\nPUT /v1/environments/:eid/templates/:template_id\n```\n\n```json\n{\n  \"template\": {\n    \"editor\": \"visual\",\n    \"body\": \"\u003ccompiled HTML from /previews/carta_content\u003e\",\n    \"body_json\": \"{ \\\"message_id\\\": \\\"\\\", \\\"priority\\\": 10, \\\"display\\\": {...}, \\\"attributes\\\": {}, \\\"persistent\\\": false, \\\"carta_content\\\": \\\"\u003cx-base\u003e...\u003c/x-base\u003e\\\", \\\"has_global_styles\\\": false }\"\n  }\n}\n```\n\nSkipping step 1 (or omitting `body` / `editor` on the PUT) is the most common cause of \"empty editor\" / blank in-app messages — services stores `body` and `body_json` independently and does not auto-compile.\n\n### Shell pattern for the compile step\n\nCarta markup has nested quotes, Liquid tags, and unicode. Pass it as a `--arg` value and let `cio`'s `--json` build the body — the bundled gojq encodes it, so there's no external `jq` and no shell escaping. (`--arg name=value` binds a string variable for the `--json` program; `--argjson` binds a JSON value.)\n\nPut the markup in a quoted heredoc rather than a single-quoted string: copy often contains apostrophes, which end a single-quoted string, and the quoted `'EOF'` keeps `$` and Liquid exactly as written.\n\nCompile the markup to HTML, capturing the result with `--jq '.html' -r` (the `-r` flag prints the string unquoted):\n\n```bash\nMARKUP=$(cat \u003c\u003c'EOF'\n\u003cx-base\u003e\u003cx-message display-type=\"modal\" :display-max-width=\"480\" padding=\"32px\" background=\"#ffffff\" :is-start-section=\"true\"\u003e\u003cx-heading-2 text-align=\"center\"\u003eHeadline\u003c/x-heading-2\u003e\u003cx-cta behavior=\"dismiss\" background=\"#007bff\" color=\"#ffffff\"\u003eOK\u003c/x-cta\u003e\u003c/x-message\u003e\u003c/x-base\u003e\nEOF\n)\nHTML=$(cio api /v1/environments/$ENV_ID/previews/carta_content -X POST \\\n  --arg c=\"$MARKUP\" --json '{content:$c,has_global_styles:false}' --jq '.html' -r) || { echo \"compile failed\"; exit 1; }\n# a blank body is the top cause of broken in-app messages — confirm the compile returned HTML before the PUT\n[ -n \"$HTML\" ] || { echo \"compile produced no HTML; refusing to write a blank body\"; exit 1; }\n```\n\nFor a new message, PUT the template with `body` (the compiled HTML), `editor: \"visual\"`, and `body_json` — a JSON **string** holding the display config plus the same markup as `carta_content` and the same `has_global_styles` the compile sent (`false` here, since this markup references no token). Bind both values with `--arg` and let the `--json` program stringify `body_json` with `(… | tostring)`, all in one call:\n\n```bash\ncio api /v1/environments/$ENV_ID/templates/$TEMPLATE_ID -X PUT \\\n  --arg html=\"$HTML\" --arg markup=\"$MARKUP\" \\\n  --json '{template:{editor:\"visual\",body:$html,body_json:({message_id:\"\",priority:10,display:{mode:\"modal\",value:\"center\",message_width:480,exit_click:false},attributes:{},persistent:false,carta_content:$markup,has_global_styles:false}|tostring)}}'\n```\n\nThe `template` wrapper is required, and `body_json` must be a stringified JSON value, not a nested object. Never send this example's `body_json` over a message that already has content: it resets the message's display settings, priority, persistence and attributes, and drops its page rules and expiry.\n\n**CRITICAL: `body_json` must include the full structure — not just `carta_content`.** The UI requires `display`, `priority`, `attributes`, `persistent`, and `has_global_styles` alongside the markup. Omitting these fields causes the message to appear blank or uninitialized in the UI.\n\nIn-app markup uses component syntax. The `body_json` field must be a JSON string containing the full in-app structure:\n\n### `body_json` structure\n\n`body_json` is a **JSON string** (not a nested object). **All fields are required** — omitting any causes the UI to treat the message as uninitialized. The values below are for a new message; an existing message keeps its own:\n\n| Field | Type | Description |\n|---|---|---|\n| `message_id` | string | Message identifier. Use `\"\"` for new messages |\n| `priority` | number | Display priority. `10` (high) for modals, `1` for low-priority |\n| `display` | object | Display configuration (see below) |\n| `attributes` | object | Custom attributes. Use `{}` for none |\n| `persistent` | boolean | Whether message persists after dismiss. Use `false` |\n| `carta_content` | string | Component markup (`\u003cx-base\u003e\u003cx-message\u003e...\u003c/x-message\u003e\u003c/x-base\u003e`) |\n| `has_global_styles` | boolean | `true` if markup uses `globalStyles.*` tokens, `false` otherwise |\n\n### `display` object\n\nMust match the `x-message` display props in the markup:\n\n| Field | Type | Description |\n|---|---|---|\n| `mode` | string | `\"modal\"`, `\"overlay\"`, `\"inline\"`, or `\"tooltip\"` |\n| `value` | string | Modal: `\"top\"`, `\"center\"`, `\"bottom\"`. Overlay: the Gist position id, not the markup's name: `topLeft`, `topCenter`, `topRight`, `bottomLeft`, `bottomCenter` and `bottomRight` are stored as `\"x-gist-floating-top-left\"`, `\"x-gist-floating-top\"`, `\"x-gist-floating-top-right\"`, `\"x-gist-floating-bottom-left\"`, `\"x-gist-floating-bottom\"` and `\"x-gist-floating-bottom-right\"` |\n| `tooltip_target` | string | CSS selector (tooltip only). `\"\"` otherwise |\n| `tooltip_position` | string | The markup's `top`, `bottom`, `left` or `right` stored as `\"x-gist-tooltip-top\"`, `\"x-gist-tooltip-bottom\"`, `\"x-gist-tooltip-left\"` or `\"x-gist-tooltip-right\"` (tooltip only). `\"\"` otherwise |\n| `tooltip_arrow_color` | string | Tooltip arrow color. `\"\"` if not used |\n| `overlay_color` | string | Background overlay color (modal). `\"\"` if not used |\n| `message_width` | number | Max width in px. `0` for auto |\n| `exit_click` | boolean | Dismiss on outside click |\n\n### Updating an existing message\n\nThis flow is for messages whose `editor` is `visual` and whose `body_json` holds `carta_content`. A code-owned raw-HTML message (no `carta_content`) is updated as `transactional_in_app.md § Saving and updating the template` describes. For any other in-app message, don't write it with this flow: tell the person what you found and ask before changing anything.\n\n**Step 1: make sure the message isn't open in the editor.** The Journeys editor keeps its own copy of the whole message and never picks up a change made anywhere else. If the person has the message open (they say so, or the current URL is that message's editor page), don't write yet: ask them to save their changes and close it, then wait for them to confirm. Writing while it's open loses work either way: their next save from the editor undoes your change, and reloading to see yours discards any edits they haven't saved. The `updated` check in step 2 doesn't cover this. It catches a save that has already happened, not one they make later from the open editor.\n\n**Step 2: write from a fresh read.** Your write replaces the whole message, so one based on an old read undoes whatever the person saved since.\n\n- **Base your change on a fresh read.** Read the template, then edit the `carta_content` that read returned. Never write markup you built from an earlier read.\n- **Keep every other `body_json` key as stored.** Replace `carta_content`, plus `has_global_styles` when your change adds or removes tokens. `display`, `priority`, `persistent`, `attributes`, `expiration` and the page rules (`route_rules`, `excluded_route_rules`) are the person's delivery settings: change one only when they ask.\n- **Mirror a display change into `display`.** Delivery takes the display from `body_json.display`, not from the markup, so when your change edits an `x-message` display prop, set the matching key too: `display-type` → `mode`, a modal or overlay position → `value`, a tooltip position → `tooltip_position`, `display-tooltip-target` → `tooltip_target`, `:display-max-width` → `message_width` and `:display-dismiss-outside-click` → `exit_click`. Overlay and tooltip positions are stored as Gist ids rather than the markup's names, while modal positions keep theirs (§ `display` object): moving an overlay to `bottomCenter` stores `\"x-gist-floating-bottom\"`, and a plain `\"bottomCenter\"` saves without error but doesn't display as an overlay. A `display-type` change sets the new mode's position too, using the markup's default when it sets none: an overlay without `display-overlay-position` sits at `topCenter`, stored as `\"x-gist-floating-top\"`, and a modal without `display-modal-position` at `\"center\"`.\n- **Check nobody saved in between.** Re-read `updated` in the same command as the write, print the result either way, and stop if it moved since your read.\n\nRead the template, and note its `updated` value:\n\n```bash\ncio api /v1/environments/$ENV_ID/templates/$TEMPLATE_ID \\\n  --jq '.template | {updated, editor, body_json: (.body_json | fromjson)}'\n```\n\nThen compile your edited markup and write it, all in one command. The markup goes in a quoted heredoc because the person's copy often contains apostrophes, and you must keep them as written:\n\n```bash\nREAD_UPDATED=1790592258  # `updated` from the read your change is based on\nGS=false  # the has_global_styles body_json keeps after your change\nMARKUP=$(cat \u003c\u003c'EOF'\n\u003cx-base\u003e...that read's carta_content, with your change applied...\u003c/x-base\u003e\nEOF\n)\nHTML=$(cio api /v1/environments/$ENV_ID/previews/carta_content -X POST \\\n  --argjson gs=\"$GS\" --arg c=\"$MARKUP\" --json '{content:$c,has_global_styles:$gs}' --jq '.html' -r) || { echo \"compile failed\"; exit 1; }\n[ -n \"$HTML\" ] || { echo \"compile produced no HTML; refusing to write a blank body\"; exit 1; }\nCURRENT=$(cio api /v1/environments/$ENV_ID/templates/$TEMPLATE_ID --jq '.template | {updated, body_json}') || exit 1\nNOW=$(echo \"$CURRENT\" | jq -r '.updated')\n[ \"$NOW\" = \"$READ_UPDATED\" ] \u0026\u0026 echo \"unchanged since your read (updated $NOW); writing\" || { echo \"saved after your read (updated $NOW, read $READ_UPDATED): read it again and re-apply your change\"; exit 1; }\ncio api /v1/environments/$ENV_ID/templates/$TEMPLATE_ID -X PUT \\\n  --argjson cur=\"$CURRENT\" --argjson gs=\"$GS\" --arg html=\"$HTML\" --arg markup=\"$MARKUP\" \\\n  --json '{template:{editor:\"visual\",body:$html,body_json:($cur.body_json | fromjson | .carta_content = $markup | .has_global_styles = $gs | tostring)}}'\n```\n\nChain any other key you change onto the same merge, such as `.carta_content = $markup | .has_global_styles = $gs | .display.exit_click = true`. Set `GS` to the `has_global_styles` the read returned, or to `true` when your change introduces `globalStyles.*` tokens, and `false` when it removes the last one. The compile and the write take the same value, so the body gets the global-style defaults the Journeys editor gives that message. Always send the recompiled `body` with the merged `body_json`: updating `body_json` alone leaves the rendered HTML stale.\n\nIf the check stops the write, someone saved the message after your read. Read it again, apply your change to what that read returns, and write again.\n\n**Step 3: tell them to reload.** After writing, tell the person to reload or reopen the message in the editor before they edit it again. Say it even when they've closed it: another tab can still hold the old copy.\n\n**When the person says they've saved or closed the editor, read the message again before you write or report anything.** Their save may have undone a change you already made. If that read shows your change is gone, don't write it straight back: tell them their editor saved over it, and ask them to close or reload the editor first. Then apply your change to the latest read, so their newer edits survive.\n\n---\n\n## Component Markup\n\nIn-app messages use `\u003cx-base\u003e\u003cx-message\u003e...\u003c/x-message\u003e\u003c/x-base\u003e` as the root structure. **Never use `\u003cx-section\u003e`** — that produces an email layout, not an in-app message.\n\n### Critical Markup Rules\n\n1. **All `x-message` attributes must be on a single line.** The parser stops at the first newline inside an opening tag and treats the rest as text.\n\n2. **Keep the person's text as written.** Accented letters, em dashes, arrows and emoji all compile and render, and so do HTML entities (`\u0026#8212;`). Don't strip or re-encode characters you weren't asked to change.\n\n3. **Boolean props take the `:` form.** Write `:display-dismiss-outside-click=\"true\"`. A plain `display-dismiss-outside-click=\"false\"` passes the string `\"false\"` where a boolean belongs, so the compile fails with \"expects boolean but received string\" and the editor can't render or save the message. To turn a boolean off, omit it or write `:display-dismiss-outside-click=\"false\"`.\n\n### Property Syntax\n\n**Static** (default) — `property=\"value\"` with units: `padding=\"20px\"`, `color=\"#ff6b6b\"`.\n\n**Dynamic** (`:` prefix) — for numeric-only props, booleans, global style tokens, and responsive/dark mode:\n\n| Property type | Syntax | Example |\n|---|---|---|\n| Most properties | Static with units | `padding=\"20px\"`, `color=\"#ff6b6b\"` |\n| `font-size`, `line-height` | Dynamic, number only | `:font-size=\"16\"`, `:line-height=\"1.6\"` |\n| `display-max-width`, `size`, `height`, `rows` | Dynamic, number only | `:display-max-width=\"460\"`, `:size=\"24\"`, `:rows=\"3\"` |\n| `is-start-section`, `hidden` | Dynamic boolean | `:is-start-section=\"true\"` |\n| Global style tokens | Dynamic with full path | `:color=\"globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e\"` |\n| Responsive/dark mode | Dynamic object | ``:padding=\"{ small: `20px`, medium: `40px` }\"`` on content components. Every component takes a plain string (`padding=\"0px\"`) or a token (`:padding=\"globalStyles.spacing.\u003csafeName\u003e_\u003cid\u003e\"`); on `x-message` that is **all** `padding`, `width` and `margin` take, so a `{ small, medium }` object fails the render there (`expects string but received object`) |\n\n### When to use the `:` prefix\n\nWithout `:`, the attribute value is a **string literal**. With `:`, it's evaluated as a **JavaScript expression**. Prop schemas use strict validation (no coercion), so a type mismatch fails at compile time. Use `:` when:\n\n- **The prop expects a number:** `font-size`, `line-height`, `letter-spacing`, `display-max-width`, `size`, `height`, `rows`. Without `:`, the string `\"16\"` fails number validation. Example: `:font-size=\"16\"` works, `font-size=\"16\"` fails.\n- **The prop expects a boolean:** `full-width`, `hidden`, `is-start-section`. Without `:`, the string `\"true\"` fails boolean validation. Example: `:full-width=\"true\"` works, `full-width=\"true\"` fails.\n- **The value is a JS expression:** `globalStyles.*` token references, responsive objects (`{ small: '20px', medium: '40px' }`), dark mode objects.\n\nString-typed props (`color`, `padding`, `background`, `margin`, `border-radius`, `text-align`, etc.) do **not** need `:` — the string literal is what the schema expects.\n\n### Common attribute errors\n\n- **`letter-spacing` is in em:** `:letter-spacing=\"0.15\"` = subtle tracking. `:letter-spacing=\"2\"` = 2em, which is extreme. The unit is implicit — don't append `px`.\n- **`x-row` may conflict with global styles:** If the workspace has global styles with a layout property set on the row component group, `x-row` can error at compile. Use `x-box` with flex layout as a workaround.\n\n### Global Styles\n\n**Always reference global style tokens instead of hardcoding values.** Token references keep messages consistent with the workspace's design system — when a brand color changes, recompile and republish the message to pick up the new value. (The references search in Design Studio finds all messages using a given token, so you can identify what needs republishing.)\n\nWhen your markup uses any `globalStyles.*` reference, you **must** set `has_global_styles: true` in `body_json`. This flag is how the references search identifies messages that use tokens. Tokens are resolved at compile time — the compiler substitutes the token's current value into the CSS. Send the compile the same `has_global_styles`: with `true` it also applies the workspace's global-style component defaults, `#variant` styles included, as the Journeys editor does for a message synced to global styles.\n\nReference tokens as `:color=\"globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e\"`. All three parts are required — the renderer is a JS expression evaluator and needs `\u003csafeName\u003e_` to recognize the path. Dropping it (just `globalStyles.colors.\u003cid\u003e`) doesn't resolve.\n\n- `\u003cgroup\u003e`: variable token groups are `colors`, `fonts`, `radius`, `spacing` — `color` and `font` are pluralized, but `radius` and `spacing` are **not**. Component token groups are singular (`button`, `textStyle`, `link`, `divider`, `image`, `message`).\n- `\u003csafeName\u003e`: the token's JSON key with non-word chars (hyphens, spaces, etc.) stripped, then lowercased. `brand-cream` → `brandcream`, `neutral-100` → `neutral100`. If the original key already had internal capitals or underscores, those are treated as word boundaries and camelCased (`heading_color` → `headingColor`); but typical UI-created keys use hyphens, which get flattened.\n- `\u003cid\u003e`: the value of `$extensions[\"io.parcel.global-styles\"].id`.\n\nExample: a token `{ \"brand-cream\": { \"$extensions\": { \"io.parcel.global-styles\": { \"id\": \"abc123def456\" } } } }` is `:background=\"globalStyles.colors.brandcream_abc123def456\"`.\n\n---\n\n## In-App Action System\n\nControls click behavior on `x-cta` and `x-image`.\n\n| Behavior | Description | Props |\n|---|---|---|\n| `dismiss` | Close the message. Does not navigate — `href` is ignored. | — |\n| `openUrl` | Navigate to a URL. Same-tab navigation closes the message (the page unloads). With `new-tab`, the URL opens in a new tab and the message **stays visible**. | `href` (required), `new-tab` |\n| `openDeeplink` | Mobile deep link. On web, behaves like `openUrl`. | `deep-link`, `new-tab` |\n| `performAction` | Fire a custom named action to the SDK. | `action` |\n| `showMessage` | Show a different step in a multi-step message. `show-message-target` is the target step's `name` attribute (set on `x-message`). Step names must be unique within the message. | `show-message-target` |\n\nSet behavior with `behavior=\"dismiss\"` (static syntax). Add `tracked-response-name` for analytics.\n\n**IMPORTANT — behavior syntax and patterns:**\n- **Use static syntax:** `behavior=\"dismiss\"`, NOT `:behavior=\"'dismiss'\"`. Both work, but static is simpler and consistent with how the UI saves it.\n- **CTA that navigates to a page:** Use `behavior=\"openUrl\"` with `href=\"/path\"`. The page navigation unloads the in-app message naturally. Root-relative paths (`/pricing`) and absolute URLs both work. **Do not use `behavior=\"dismiss\"` with `href`** — `dismiss` ignores `href` entirely and just closes the message.\n- **CTA that opens a new tab:** Use `behavior=\"openUrl\"` with `href=\"url\"` and `new-tab`. The URL opens in a new tab and the message **stays visible** — add a separate dismiss CTA if the message should also close.\n- **Do NOT use `dismiss-on-click`** on `x-cta` — it breaks the preview renderer. The `display` object's `exit_click` field (in `body_json`) handles tap-outside-to-dismiss at the container level.\n- **Dismiss only (no navigation):** Use `behavior=\"dismiss\"` with no `href`. This is for \"Close\", \"No thanks\", and other pure-dismiss CTAs.\n\n---\n\n## Components\n\n### x-message\nRoot container. Must be inside `x-base`. All attributes on a single line.\n\n| Prop | Default | Description |\n|---|---|---|\n| `display-type` | `modal` | `modal`, `overlay`, `inline`, `tooltip` |\n| `display-modal-position` | `center` | `top`, `center`, `bottom` |\n| `display-overlay-position` | `topCenter` | `topLeft`, `topCenter`, `topRight`, `bottomLeft`, `bottomCenter`, `bottomRight` |\n| `:display-max-width` | — | Dynamic, number only: `:display-max-width=\"460\"` |\n| `:display-dismiss-outside-click` | off | Dynamic boolean: `:display-dismiss-outside-click=\"true\"`. Omit for off; never the plain `=\"false\"` form |\n| `display-tooltip-target` | — | CSS selector (tooltip only) |\n| `display-tooltip-position` | `top` | `top`, `bottom`, `left`, `right` |\n\nLayout: `padding`, `margin`, `border-radius`, `align`. Style: `background`, `opacity`, borders, `box-shadow`. Control: `name` (step name: letters, numbers, spaces, `-` and `_` only; `name=\"step.one\"` or `name=\"Step 1: Offer\"` fails the render), `:hidden=\"true\"`, `:is-start-section=\"true\"`. `padding`, `width` and `margin` take plain strings only, never a `{ small, medium }` object.\n\n### Standard components\n\nAll standard `x-*` components work: `x-heading-1`–`x-heading-6`, `x-paragraph`, `x-cta`, `x-image`, `x-row`, `x-column`, `x-box`, `x-spacer`, `x-hr`.\n\n### Form components\n\nIn-app messages support form-style templates for collecting user input.\n\n**x-form** — Form container. Props: `name` (form name). Slots: `x-input`, `x-textarea`, and `x-cta` (with `type=\"submit\"`). POSTs form data using the `form_name` query param.\n\n**x-lead-form** — Pre-built lead capture (name + email). Props: `enable-name` (on/off), `name-label`, `name-placeholder`, `email-label`, `email-placeholder`, `gap` (default `12px`). Input styling: `placeholder-color`, `input-padding`, borders, `background`, focus outline. Submits `lead_capture_name`, `lead_capture_email`, `lead_capture_anonymous_id`.\n\n**x-input** — Single-line input inside `x-form`. Slot: label text. Props: `name` (field name, required), `type` (text/email/number/tel), `required`, `placeholder`, `placeholder-color`, `label-gap` (4px). Styling: `input-padding`, `background`, borders, focus outline.\n\n**x-textarea** — Multi-line input inside `x-form`. Same props as `x-input` plus: `rows` (default 4), `maxlength` (max 500).\n\n### Other\n\n`x-video` (props: `src` — YouTube, Vimeo, Wistia, Loom URLs), `x-watermark` (CIO branding footer, no props).\n\n### Close button preset\n\nThe editor's **Close button** is a preset, not a component — it is not returned by any API. For a close (X) icon in the corner of a modal, insert it as the first child of `x-message`:\n\n```html\n\u003cx-image align=\"right\" behavior=\"dismiss\" width=\"26px\" src=\"https://storage.googleapis.com/cio-in-app-templates/assets/close-padding.603304e2.png\" /\u003e\n```\n\nUse that `src` exactly — don't invent an icon URL or fake the X with a text `x-cta`. Keep `behavior=\"dismiss\"` with no `href`; add `tracked-response-name` to track closes.\n\n---\n\n## Route Rules\n\nRoute rules control which pages/screens display in-app messages.\n\n| Endpoint | Use for |\n|----------|---------|\n| `POST /v1/environments/:eid/in_app/route_rules/regex` | Generate regex from route rules |\n\nRequest provides rules per platform (`web`, `ios`, `android`) with `included_rules` and `excluded_rules` arrays containing URL/path patterns.\n\n## Surveys\n\nIn-app surveys are a special type of in-app message with structured response collection.\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET /v1/environments/:eid/in_app/survey_templates` | Get pre-built survey templates (NPS, rating, feedback) |\n| `POST /v1/environments/:eid/in_app/analyze_survey/start` | Start async AI analysis of survey responses (returns 202 with `workflow_id` + `run_id`) |\n| `POST /v1/environments/:eid/in_app/analyze_survey/status` | Poll analysis status — query params `workflow_id` + `run_id`; returns `{\"status\":\"pending\"}` or `{\"status\":\"done\",\"result\":{...}}` |\n\n### Survey Types\n\n| Type | Description |\n|------|-------------|\n| `nps` | Net Promoter Score (0-10 rating) |\n| `rating` | Star or numeric rating |\n| `feedback` | Free-text feedback collection |\n\n## Testing\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET /v1/environments/:eid/in_app/check_send_test` | Check if a test message was delivered |\n\nReturns `delivered` (boolean) and `expired` (boolean — true if test message is older than 24 hours).\n\n---\n\n## Patterns\n\n### Modal with Global Styles (recommended starting point)\nReferences workspace tokens instead of hardcoding values. Fetch token IDs from `GET /design_studio/global_styles` first (see § Step 0).\n```html\n\u003cx-base\u003e\u003cx-message display-type=\"modal\" display-modal-position=\"center\" :display-max-width=\"440\" padding=\"24px\" :background=\"globalStyles.colors.background_\u003cid\u003e\" border-radius=\"8px\" :is-start-section=\"true\"\u003e\n    \u003cx-heading-2 text-align=\"center\" :color=\"globalStyles.colors.primarytext_\u003cid\u003e\" :font-family=\"globalStyles.fonts.primary_\u003cid\u003e\"\u003eNew Feature!\u003c/x-heading-2\u003e\n    \u003cx-paragraph text-align=\"center\" :color=\"globalStyles.colors.secondarytext_\u003cid\u003e\"\u003eWe just launched dark mode.\u003c/x-paragraph\u003e\n    \u003cx-cta #variant=\"primary\" behavior=\"dismiss\" :full-width=\"true\"\u003eAwesome\u003c/x-cta\u003e\n\u003c/x-message\u003e\u003c/x-base\u003e\n```\nReplace each `\u003cid\u003e` with the actual token ID from your workspace. Set `has_global_styles: true` in `body_json`.\n\n### Modal Announcement\n```html\n\u003cx-base\u003e\u003cx-message display-type=\"modal\" display-modal-position=\"center\" :display-max-width=\"460\" padding=\"24px\" background=\"#ffffff\" border-radius=\"12px\" :is-start-section=\"true\"\u003e\n    \u003cx-heading-2 text-align=\"center\"\u003eNew Feature!\u003c/x-heading-2\u003e\n    \u003cx-paragraph text-align=\"center\"\u003eWe just launched dark mode.\u003c/x-paragraph\u003e\n    \u003cx-cta behavior=\"dismiss\" background=\"#007bff\" color=\"#ffffff\" align=\"center\"\u003eAwesome\u003c/x-cta\u003e\n\u003c/x-message\u003e\u003c/x-base\u003e\n```\n\n### Lead Capture\n```html\n\u003cx-base\u003e\u003cx-message display-type=\"modal\" :display-max-width=\"460\" padding=\"32px\" background=\"#ffffff\" border-radius=\"16px\" :is-start-section=\"true\"\u003e\n    \u003cx-heading-2 text-align=\"center\"\u003eStay in the loop\u003c/x-heading-2\u003e\n    \u003cx-form name=\"newsletter-signup\"\u003e\n      \u003cx-lead-form enable-name=\"off\" :font-size=\"14\" border-radius=\"8px\" /\u003e\n      \u003cx-cta type=\"submit\" behavior=\"dismiss\" background=\"#000000\" color=\"#ffffff\" :full-width=\"true\"\u003eSubscribe\u003c/x-cta\u003e\n    \u003c/x-form\u003e\n\u003c/x-message\u003e\u003c/x-base\u003e\n```\n\n### Custom Form\n```html\n\u003cx-base\u003e\u003cx-message display-type=\"modal\" :display-max-width=\"460\" padding=\"28px\" background=\"#ffffff\" border-radius=\"16px\" :is-start-section=\"true\"\u003e\n    \u003cx-heading-2 text-align=\"center\"\u003eWe'd love your feedback\u003c/x-heading-2\u003e\n    \u003cx-paragraph text-align=\"center\" color=\"#666666\"\u003eHelp us improve your experience.\u003c/x-paragraph\u003e\n    \u003cx-spacer :size=\"12\" /\u003e\n    \u003cx-form name=\"feedback-form\"\u003e\n      \u003cx-input name=\"favorite_feature\" type=\"text\" placeholder=\"What feature do you use most?\" label-gap=\"6px\"\u003eFavorite feature\u003c/x-input\u003e\n      \u003cx-spacer :size=\"8\" /\u003e\n      \u003cx-textarea name=\"suggestions\" placeholder=\"Any suggestions for us?\" :rows=\"3\" label-gap=\"6px\"\u003eHow can we improve?\u003c/x-textarea\u003e\n      \u003cx-spacer :size=\"12\" /\u003e\n      \u003cx-cta type=\"submit\" behavior=\"dismiss\" background=\"#007bff\" color=\"#ffffff\" :full-width=\"true\"\u003eSubmit\u003c/x-cta\u003e\n    \u003c/x-form\u003e\n\u003c/x-message\u003e\u003c/x-base\u003e\n```\n\n### Multi-Step Survey\n```html\n\u003cx-base\u003e\u003cx-message name=\"question\" display-type=\"modal\" padding=\"24px\" background=\"#ffffff\" border-radius=\"12px\" :is-start-section=\"true\"\u003e\n    \u003cx-heading-2 text-align=\"center\"\u003eHow are we doing?\u003c/x-heading-2\u003e\n    \u003cx-cta behavior=\"showMessage\" show-message-target=\"thanks\" tracked-response-name=\"positive\" background=\"#22c55e\" color=\"#ffffff\"\u003eGreat!\u003c/x-cta\u003e\n    \u003cx-cta behavior=\"showMessage\" show-message-target=\"thanks\" tracked-response-name=\"negative\" background=\"#ef4444\" color=\"#ffffff\"\u003eNot great\u003c/x-cta\u003e\n\u003c/x-message\u003e\u003cx-message name=\"thanks\" :hidden=\"true\" display-type=\"modal\" padding=\"24px\" background=\"#ffffff\" border-radius=\"12px\"\u003e\n    \u003cx-heading-2 text-align=\"center\"\u003eThank you!\u003c/x-heading-2\u003e\n    \u003cx-cta behavior=\"dismiss\" background=\"#007bff\" color=\"#ffffff\"\u003eClose\u003c/x-cta\u003e\n\u003c/x-message\u003e\u003c/x-base\u003e\n```\n\n### Overlay Banner\n```html\n\u003cx-base\u003e\u003cx-message display-type=\"overlay\" display-overlay-position=\"bottomCenter\" padding=\"16px\" background=\"#1a1a2e\" border-radius=\"8px\" :is-start-section=\"true\"\u003e\n    \u003cx-paragraph color=\"#ffffff\" text-align=\"center\"\u003e{{customer.first_name}}, your trial ends in {{customer.days_remaining}} days.\u003c/x-paragraph\u003e\n    \u003cx-cta behavior=\"openUrl\" href=\"{{upgrade_url}}\" background=\"#e94560\" color=\"#ffffff\" align=\"center\"\u003eUpgrade Now\u003c/x-cta\u003e\n\u003c/x-message\u003e\u003c/x-base\u003e\n```\n\n### Tooltip\nTooltips anchor to a specific element on the page via CSS selector. Note `display-tooltip-target` and `display-tooltip-position` are tooltip-specific props.\n```html\n\u003cx-base\u003e\u003cx-message display-type=\"tooltip\" display-tooltip-target=\"#new-feature-btn\" display-tooltip-position=\"bottom\" padding=\"16px\" background=\"#ffffff\" border-radius=\"8px\" box-shadow=\"0 4px 12px rgba(0,0,0,0.15)\" :is-start-section=\"true\"\u003e\n    \u003cx-paragraph :font-size=\"14\"\u003eClick here to try our new feature!\u003c/x-paragraph\u003e\n    \u003cx-cta behavior=\"dismiss\" background=\"#007bff\" color=\"#ffffff\" :font-size=\"13\"\u003eGot it\u003c/x-cta\u003e\n\u003c/x-message\u003e\u003c/x-base\u003e\n```\n\n## Common Pitfalls\n\nThe top three failure modes — everything else in this file (markup rules, behavior syntax, font-family placement) is stated in its own section above.\n\n1. **Always compile `carta_content` and PUT `body` alongside `body_json`** — The template API stores `body` (rendered HTML) and `body_json` (config + markup) as independent fields and does not auto-compile. A PUT with `body_json` only returns 200, but the message renders blank in the UI and fails to deliver. Call `POST /previews/carta_content` first and include both `body` and `body_json` (plus `editor: \"visual\"`) on every create/update. **If the user says \"I don't see the message,\" `GET /templates/:id` and check `editor`, `body`, and `body_json` field-by-field before theorizing about markup** — most \"blank page\" reports trace back to a missing field, not bad markup.\n2. **Provision first** — In-app messaging must be provisioned before creating automation in-app actions or anonymous broadcasts. Check `GET /in_app/status` first.\n3. **`body_json` requires all fields** — Omitting `display`, `priority`, `attributes`, `persistent`, or `has_global_styles` causes the UI to treat the message as uninitialized.\n","in_app_troubleshooting.md":"# In-App Troubleshooting — \"my in-app message isn't showing up\"\n\n\u003e For authoring in-app content (Carta markup, the compile-then-PUT workflow, components, surveys), see `in_app.md`. This file is the diagnostic decision tree for **delivery** failures: the message was built but the end user never sees it.\n\nThe in-app path has more moving parts than email/SMS: the message must be built correctly **and** delivered **and** the SDK must be running on the user's device/page **and** the page must match the route rules. \"Nothing shows up\" can break at any of those. Check the causes **in order** — they are sorted most-common first, and the cheap checks come before the expensive ones.\n\n## Decision Tree\n\n1. **Blank template** (the #1 cause). The template API stores `body` (compiled HTML) and `body_json` (config + markup) as independent fields and does **not** auto-compile. A PUT with `body_json` only returns 200 but renders blank and fails to deliver.\n   - `GET /v1/environments/$ENV_ID/templates/$TEMPLATE_ID` — check `editor`, `body`, and `body_json` **field-by-field** before theorizing about markup.\n   - Missing or empty `body`, or `editor` ≠ `\"visual\"` → the message was saved without compiling. Fix per `in_app.md § Setting In-App Content` (compile via `/previews/carta_content`, then PUT `body` + `body_json` + `editor` together).\n   - Most \"blank page\" reports end here. Do this check first.\n\n2. **Not provisioned.** In-app messaging needs a site ID + SDK credentials before anything delivers.\n   - `GET /v1/environments/$ENV_ID/in_app/status` → `{ \"provisioned\": bool, \"connected\": bool, \"last_connected\": unix_ts }`. The status is environment-level, aggregated across all of the environment's site IDs and public (`pk_`) API keys — `connected: true` means at least one SDK has connected with either, which is sufficient for delivery (every site ID and key maps to the same in-app org).\n   - `\"provisioned\": false` → provision it: `POST /v1/environments/$ENV_ID/in_app/provision --json '{}'`.\n\n3. **SDK never connected** (the silent killer — config is perfect, app isn't wired). `last_connected` is the Unix timestamp of the most recent SDK connection. **Only read it when `provisioned: true`** — the API reports `last_connected: 0` while unprovisioned, so a `0` there just means \"not provisioned yet\" (cause 2), not an SDK problem.\n   - `provisioned: true` **and** `connected: false` → the SDK has never connected: a **customer integration problem, not a CIO config problem.** The SDK isn't initialized in their app/site. No amount of message editing will fix it. Point them at SDK setup (`@cio/sdk_setup.md` for the integration, web/iOS/Android).\n   - `provisioned: true` and a stale `last_connected` (days/weeks old) → SDK was working but isn't now (app not opened, init removed, build regressed).\n\n4. **Test send didn't land.** Confirms the delivery pipe end-to-end for one device.\n   - `GET /v1/environments/$ENV_ID/in_app/check_send_test` → `{ \"delivered\": bool, \"expired\": bool }`\n   - `expired: true` → the last test is \u003e24h old; send a fresh test from the UI and re-check.\n   - `delivered: false` + `expired: false` → **ambiguous, and the most common state.** It means *either* no in-app test has ever been sent from this workspace, *or* a recent test is sitting in the queue unconsumed (SDK isn't pulling — loops back to cause 3). Send a fresh test from the UI to disambiguate: if it flips to `delivered: true` the pipe works; if it stays `false`, the SDK isn't running on that device.\n   - `delivered: true` → the pipe works; the problem is targeting/route rules (causes 5–7), not delivery.\n\n5. **Route rules don't match the page.** In-app messages only display on pages/screens matching the message's route rules — a correct, delivered message stays hidden on a non-matching URL.\n   - `POST /v1/environments/$ENV_ID/in_app/route_rules/regex` with the message's `included_rules` / `excluded_rules` per platform (`web`, `ios`, `android`) → returns the compiled regex.\n   - Compare that regex against the **actual** URL/screen the user is on. Mismatched path, wrong platform bucket, or an over-broad `excluded_rules` are the usual culprits.\n\n6. **Platform/app targeting doesn't match the device** (multi-app workspaces). If the message's `body_json` has a `targeting` key, it only displays on the targeted `platforms` — and for `ios`/`android`, only in the app referenced by `targeting.app_id`.\n   - `GET /v1/environments/$ENV_ID/templates/$TEMPLATE_ID` → parse `body_json.targeting`. A message targeted `{\"platforms\": [\"web\"]}` never shows in a mobile app, and vice versa.\n   - For mobile: `GET /v1/environments/$ENV_ID/apps/$APP_ID` and compare each platform entry's `identifier` against the bundle ID / package name the SDK initializes with. The wrong app selected on the message — or an identifier that was since edited — means the SDK's app never matches.\n   - No `targeting` key stored → legacy untargeted message; it displays everywhere and this cause is ruled out.\n\n7. **Person never received it** (automation/one-time send targeting). If the message is built, the SDK is live, and route rules match, the failure is upstream — the person didn't enter the automation or the in-app action was skipped.\n   - For an in-app **automation action**: see `automation_debugging.md § Why People Skip Actions` and `§ Why People Aren't Entering`. Cause 1 (Unsubscribed) applies only in its topic form — a global unsubscribe never suppresses in-app.\n   - For an in-app **one-time send** (`channel: \"in_app\"`): confirm segment membership and send status via `messaging.md`.\n   - **A global unsubscribe is not the answer for automation actions or one-time sends.** Those paths ignore the `unsubscribed` attribute, so never attribute a missing in-app message on them to it, and never suggest resubscribing the profile or setting `send_to_unsubscribed`.\n   - **Transactional in-app is the exception.** The transactional pipeline honors the global unsubscribe on every channel. If the missing message is a transactional in-app send and the profile is unsubscribed, expect a delivery marked undeliverable with reason \"Customer is unsubscribed.\" The fix is `send_to_unsubscribed` in the message settings (or the per-request override), not resubscribing the profile. Confirm which kind of send it is before ruling the attribute out. See `transactional_in_app.md`.\n   - **A subscription topic can be.** If the broadcast has a `subscription_topic_id`, a profile who opted out of that topic *is* excluded, as is one who never opted in to an opt-in topic. An explicit `in_app` channel preference excludes too. Check the profile's `cio_subscription_preferences` against the message's topic before moving on to segment membership and journey entry.\n\n8. **Display/eligibility logic** (it delivered but doesn't appear, or the wrong one appears).\n   - **Priority** — when multiple messages qualify, higher `priority` wins. A low-priority message can be perpetually preempted.\n   - **`persistent: false`** — message clears after one dismiss and won't return; expected behavior, not a bug.\n   - **`display.mode` mismatch** — a `tooltip` with a `tooltip_target` selector that doesn't exist on the page renders nothing. Verify the selector.\n   - **Expiration** — a `body_json.expiration` in the past suppresses display.\n\n## Quick Triage\n\nThe first three checks resolve most cases and are one call each:\n\n```bash\n# 1. Is the content actually compiled?\ncio api /v1/environments/$ENV_ID/templates/$TEMPLATE_ID --jq '.template | {editor, has_body: (.body != null and .body != \"\"), has_body_json: (.body_json != null and .body_json != \"\")}'\n\n# 2 + 3. Provisioned, and has the SDK ever connected?\ncio api /v1/environments/$ENV_ID/in_app/status --jq '{provisioned, connected, last_connected, sdk_never_connected: (.provisioned and (.connected | not))}'\n\n# 4. Did the last test send actually land?\ncio api /v1/environments/$ENV_ID/in_app/check_send_test\n```\n\nIf 1 shows missing `body`/`editor` → blank-template fix (`in_app.md`). If 2 shows `provisioned: false` → provision (and ignore `last_connected`, which is always `0` until provisioned). If **provisioned** but `connected: false` → SDK isn't running in the customer's app (integration problem). Only after all three pass do route rules and targeting (causes 5–7) become worth investigating.\n","inbound_messaging.md":"# Inbound Messaging\n\nHow to receive SMS replies and act on them. When a person texts back, Customer.io\nrecords the reply as an **inbound event** on their profile — and an event can\ntrigger an automation. You build an **inbound automation** that enters people when they reply, branch on what they said, and message them back.\n\nCustomer.io does not support all forms of two-way messaging — only simple automations powered by inbound SMS activities\n\n\u003e **Scope.** This file is about *handling* replies — automation setup, keyword\n\u003e branching, custom keywords, and where to see inbound activity. For *writing* the\n\u003e outbound SMS copy (segments/cost, encoding, opt-out lines), see **sms.md**. For\n\u003e the keyword list itself, see **sms.md § Opt-Out, Keywords \u0026 Compliance** — it is\n\u003e not duplicated here.\n\n## Inbound replies are events\n\nAn inbound SMS becomes an event named **`sms`** on the replying person's profile\n(WhatsApp replies become a **`whatsapp`** event). The event carries the reply\ndetails as attributes:\n\n| Attribute | Meaning |\n|---|---|\n| `body` | The full text the person sent |\n| `keyword` | The recognized keyword/intent, if any (see below) |\n| `from` | The person's phone number (E.164) |\n| `to` | Your sending number / sender |\n| `messaging_service_sid` | Twilio messaging service SID, when applicable |\n| `sid` | Twilio message SID |\n| `mms` | `true` when the reply included media |\n| `in_reply_to` | The delivery ID this reply was attributed to, when matched |\n\nWhen a reply is matched to a recent outbound message, that message also gets a\n**replied** metric, and the inbound event records which delivery it answered\n(`in_reply_to`). Matching is best-effort against the person's recent deliveries —\nreplies arriving long after the original send may not be attributed to it.\n\nBecause it is an ordinary event, reference its attributes in Liquid the usual way:\n`{{event.keyword}}`, `{{event.body}}`, `{{event.from}}`. `{{event.keyword}}` is the\none to branch on.\n\n## Recognized keywords (STOP / START / HELP)\n\nCustomer.io recognizes a fixed set of opt-out / opt-in / help keywords\ncase-insensitively and collapses every variant into one intent, and it acts on the\nopt-out keywords automatically:\n\n- **STOP-group** keywords suppress the person from future SMS **immediately**, with\n  no automation required.\n- **START-group** keywords opt them back in.\n- **HELP-group** keywords are recognized as the help intent.\n\nThe STOP confirmation reply is sent by the carrier/Twilio — you do not build it.\nFor the exact keyword-to-intent table (every recognized word, plus the\naffirmative/negative `YES`/`NO` replies), see **sms.md § Opt-Out, Keywords \u0026\nCompliance**. Do not re-derive or guess the list — it is authoritative there and in\n`sms/keywords/parse.go`.\n\n\u003e The automatic STOP/START suppression happens whether or not you build an inbound\n\u003e automation. An inbound automation is for the *additional* behavior you want on top of\n\u003e it (a HELP auto-reply, a custom-keyword follow-up, routing on `YES`/`NO`).\n\n## Building an inbound automation\n\nAn inbound automation is just an **event-triggered automation** whose event is the\ninbound message. In the UI: on the automation **Trigger** step, select the\n**inbound message** card as the trigger. Via the\nAPI this is a `transactional` (event) trigger on the `sms` event:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"transactional\",\n    \"event_type\": \"inbound\",\n    \"event\": \"sms\"\n  }\n}\n```\n\nThe trigger mechanics (`update_type: \"recipients\"`, the full-replace behavior of a\n`recipients` write, system actions created) are the same as any event automation —\nsee **automation_triggers.md § Event-triggered (transactional)**. The only difference\nis the event name and that the entering event carries the reply attributes above.\n\n### Branching on the keyword\n\nUse the keyword to route people. Add a **multi-split branch** keyed on\n`{{event.keyword}}` (see **flow_control.md** for branch encoding) with one path per\nkeyword you care about — for example a `HELP` path that texts back your support\ninfo, and a fallback path for anything unrecognized. Treat the model like an\nautomated phone menu: each recognized keyword advances the person down a different\nbranch.\n\nYou can also narrow *entry* with a trigger **filter** — for example, only enter\nwhen the reply came to a specific `to` number, or only on a specific `body`. Filter\nencoding is in **automation_filters.md**.\n\n## Custom keywords\n\nAnything that isn't a recognized STOP/START/HELP/YES/NO word is passed through as a\ncustom keyword: a **single-word** reply with no spaces becomes the `keyword` value\nverbatim (uppercased), so a person texting `DEALS` produces `{{event.keyword}} ==\n\"DEALS\"`. **Multi-word replies do not set a keyword** (`keyword` is empty) — branch\non `{{event.body}}` if you need to inspect free-form text.\n\nThis lets you run \"text DEALS for offers\" style flows: prompt people to reply with\nyour keyword, then branch the inbound automation on `{{event.keyword}}` to enrol or\nfollow up.\n\n\u003e **Collision hazard with reserved keywords.** Never pick a custom keyword that\n\u003e overlaps the reserved STOP/START/HELP/YES/NO sets (or their variants — `END`,\n\u003e `CANCEL`, `OK`, `N`, etc.). A custom keyword that collides is consumed by the\n\u003e built-in handling first: `END` opts the person out, `OK`/`Y` is read as an\n\u003e affirmative, and your branch never sees it. The same applies to *prompts* in your\n\u003e outbound copy — telling people to \"reply YES to continue\" can silently opt them in\n\u003e or fire automation you didn't intend. Pick distinctive words (`DEALS`, `MENU`,\n\u003e `RENEW`) and confirm against the reserved list in **sms.md**.\n\n## Where to see inbound activity\n\n- **Profile activity** — inbound replies appear as a **Replied to\n  SMS** activity on the person's timeline; the activity shows the conversation and\n  the full reply text. Sort/filter the Profile activity by that activity to find replies.\n  Use it to confirm whether a reply contained the keyword you expected before\n  blaming the automation.\n- **Replied metric** — the original outbound message records a **replied** status\n  when a reply is attributed to it, so you can see reply rates alongside delivered /\n  clicked.\n- **Inbound data export** — the `inbound` export contains one row per inbound SMS\n  received between syncs; each row includes an internal profile id you can resolve\n  to a person.\n\nSee the product docs on inbound statuses and activities for the full status list.\n","inbox.md":"# Inbox Messages — Content \u0026 `body_json` Payload\n\nUse this file when creating or editing **inbox** message content (the persistent\nnotification-inbox channel rendered by the Customer.io SDK). Inbox content lives\nin the template's **`body_json`** and is delivered **as-is** — inbox is *not*\nrendered server-side, so `body_json` is the contract. This is a different schema\nfrom in-app (`transactional_in_app.md`): in-app `body_json` controls display\nmode / route rules, inbox `body_json` carries the message fields and a free-form\ncustom payload.\n\nInbox messages are valid on transactional messages (`type: \"inbox\"`),\none-time sends, and automation actions.\n\n## Two ways to author inbox content\n\nPick one per message — they produce the same delivered `body_json`, but only one\nis editable in the Design Studio UI:\n\n- **Custom (code-owned) format** — write `body_json` directly, using the schema\n  below. Generated and owned by your code; delivers as-is. It is **not editable\n  in the Design Studio inbox composer** — a hand-authored payload has no Carta\n  source or DS node behind it, so the composer can't open it (analogous to a\n  raw-HTML in-app body, see `transactional_in_app.md`). Use this for fully\n  programmatic messages.\n- **Design Studio** — author an `\u003cx-inbox-message\u003e` Carta node and link/publish\n  it; on publish the DS serializer converts the markup into the same `body_json`\n  shape (the `properties` keys below) and embeds node metadata so the message\n  stays editable in the composer. See `design_studio/inbox.md`.\n\nThe rest of this file documents the **custom-format** `body_json`. It also tells\nyou what a published Design Studio message contains, since both paths land on the\nsame shape.\n\n## `body_json` schema\n\n`body_json` is a JSON **string** on the template. Parsed shape:\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `topics` | `string[]` | no | Inbox topics this message belongs to (used for SDK-side filtering/grouping). Each topic **must not** start with the reserved prefix `_cio`. Default `[]`. |\n| `type` | string | no | Caller-defined message type/category (free string, your own taxonomy). **Must not** start with `_cio`. |\n| `priority` | int | yes | Display ordering; **must be \u003e 0** (higher = shown first). Default `10`. |\n| `expiration` | object | no | When the message expires from the inbox. See below. Defaults to relative 60 days (`5184000` seconds). |\n| `properties` | object (`map`) | yes | **The custom payload** — arbitrary key/values the SDK reads to render and act on the message. Free-form; see conventions below. Serialized payloads always carry a `properties` object (its presence is how the backend tells a published payload from a raw node snapshot). |\n\nThe whole rendered `body_json` cannot exceed **500KB**.\n\n### `expiration`\n\n```json\n{ \"type\": \"relative\", \"value\": \"5184000\", \"unit\": \"seconds\" }\n```\n\n- `type`: `\"relative\"` (default) or `\"absolute\"`. `\"unset\"` (or empty) resolves to the relative 60-day default.\n- **relative** — `value` is a count (string) and `unit` is `seconds` | `minutes` | `hours` | `days`. A missing unit falls back to `days`.\n- **absolute** — `value` is an ISO-8601 timestamp, or a Liquid expression that renders one.\n- Max expiration is **60 days** in the future.\n\n### `properties` — the custom payload\n\n`properties` is a free-form object; the SDK reads whatever keys you put there.\nThe keys below are the **conventions** the Design Studio inbox serializer emits,\nso SDK rendering and the Journeys UI agree on them — follow them when authoring\n`body_json` by hand:\n\n| Key | Type | Meaning |\n|---|---|---|\n| `title` | string | Headline text (Liquid allowed). |\n| `body` | string | Body text (Liquid allowed). |\n| `timestamp` | string | Usually `{{ 'now' \\| date: '%Y-%m-%dT%H:%M:%SZ' }}`. |\n| `media` | string | Image URL. |\n| `cta` | object | Primary action: `label` + action fields (`behavior`, `action`, `newTab`, `dismiss`, `name`). |\n| `messageAction` | object | Whole-message tap action: same action fields as `cta` (no `label`). |\n\nAction-field shape (used by `cta` and `messageAction`):\n\n| Key | Meaning |\n|---|---|\n| `behavior` | `openUrl` (action = the URL), `openDeeplink` (action = the deep link), `dismiss` (no action), or a custom behavior (action = its value). |\n| `action` | The URL / deep link / value, per `behavior`. |\n| `newTab` | `true` to open in a new tab (web). |\n| `dismiss` | `true` to dismiss the message on click. |\n| `name` | Tracked-response name for analytics. |\n\nAny other keys you add to `properties` are passed through to the SDK untouched —\nthis is where app-specific custom data goes.\n\n### Example `body_json`\n\n```json\n{\n  \"topics\": [\"promotions\"],\n  \"type\": \"announcement\",\n  \"priority\": 10,\n  \"expiration\": { \"type\": \"relative\", \"value\": \"30\", \"unit\": \"days\" },\n  \"properties\": {\n    \"title\": \"Welcome aboard, {{customer.first_name | default: 'there'}}!\",\n    \"body\": \"Your account is ready.\",\n    \"timestamp\": \"{{ 'now' | date: '%Y-%m-%dT%H:%M:%SZ' }}\",\n    \"media\": \"https://example.com/banner.png\",\n    \"cta\": { \"label\": \"Get started\", \"behavior\": \"openUrl\", \"action\": \"https://example.com/start\", \"name\": \"welcome_cta\" },\n    \"orderId\": \"12345\"\n  }\n}\n```\n\n## Create \u0026 configure\n\n1. Create the inbox transactional message (`type: \"inbox\"`) — see\n   `messaging.md § Transactional Messages`. The auto-created template has an\n   **empty `body_json`**, so it renders empty until you set it.\n2. Update the template's `body_json` with the schema above (see `templates.md`).\n\n## Sending\n\nBackend send: `POST https://api.customer.io/v1/send/inbox_message` (App API\nauth) — see `transactional_send.md`. The send request supplies the recipient\n(`identifiers` / `to`) and `message_data` for Liquid; the template supplies\n`body_json`.\n\n- **Do not rely on `auto_create`** for inbox — an auto-created template has no\n  `body_json` and delivers empty. Create and configure the message explicitly\n  first.\n- A send needs a recipient (`userToken`/identity); a payload with neither is\n  rejected.\n\n## Testing\n\nInbox test sends can't be triggered by the assistant — they require the\nCustomer.io SDK running in the user's app to display the message. Guide the user\nto test from the dashboard. See `test_sends.md`.\n","llm_actions.md":"---\nminimum_plan: essentials\n---\n\n# LLM Actions\n\nLLM actions call an AI model mid-journey to classify, enrich, or generate data for each person. They're added to automations like any other action (`type: \"llm_action\"`).\n\nIn the workflow builder the action is named **Run LLM**, and the palette offers it under three preset entry points — **Generate Content…**, **Qualify Customers…**, and **Make Recommendation…** (all the same `llm_action` type, grouped under AI). Users may call it any of those names, or \"the AI step\" / \"the LLM block\". The workflow lives at `https://fly.customer.io/workspaces/$ENV_ID/journeys/automations/:campaign_id/overview/workflow/actions`.\n\n## When to Use One — CHECK FIRST\n\nAn LLM action is right when the value has to be **produced** per profile, not looked up:\n\n- **summarize / condense** many events or fields into prose (\"summarize each account's recent activity\")\n- **write / generate** copy per profile (\"write a subject line from what they browsed\")\n- **classify / score / judge** (\"is this reply a complaint?\", \"score this lead\", \"qualify this customer\")\n- **extract** structure out of free text (a support message, a survey response)\n- **recommend** with a reason, where a ranked collection query isn't enough\n\nIt is the **wrong** tool when the value already exists and a template can substitute it. Inserting a first name, formatting a date, echoing a browsed product name, or branching on a stored tier is Liquid's job — see `@skills/recipes/liquid_syntax.md`. Reaching for a model there is slower, spends credits, and adds a send-time failure mode for no gain. Ranked/filtered rows out of a collection are `collection_query_action` (`data_operations.md`), not an LLM action.\n\nThe tell is the verb. *Summarize, write, classify, score, rewrite, recommend, judge* cannot be done in Liquid at any level of cleverness; *interpolate, format, branch, loop, default* all can. If you're unsure whether the source data exists, ask which attribute or event holds it before proposing either tool.\n\n**Proposing one unprompted:** only when the user has described a task in the first list and the workflow doesn't already do it. Say what it would write, where it would store it, and which later action would consume it — a suggestion the user can't picture is noise. Don't propose one for work an existing action in the same workflow already covers.\n\n## How They Work\n\n1. A Liquid **prompt template** renders with the person's attributes, event data, and journey attributes\n2. **Output fields** (a JSON schema) define what the LLM should return — each field has a name and type\n3. The LLM processes the prompt and returns structured JSON matching the schema\n4. Each output field is stored as either a **journey attribute** or a **profile attribute** (configured per action)\n5. The journey continues — downstream actions can branch on the LLM's output\n\n### Output Fields \u0026 Storage\n\nOutput fields define the structured data you want back from the LLM (e.g., `classification: string`, `sentiment_score: number`, `is_urgent: boolean`). The result schema is a plain JSON schema (not Liquid — it's used as-is, unlike the prompt template which is Liquid-rendered). Each key in the LLM's JSON response becomes a separate attribute.\n\nWhere results are stored depends on `llm_result_action.type`:\n\n- **`\"set_journey_attributes\"`** — results are stored as journey attributes, scoped to this automation journey only. They disappear when the journey ends. Use for data that only matters within this workflow (e.g., routing decisions, temporary classifications).\n- **`\"set_customer_variables\"`** — results are stored as profile attributes that persist permanently. Use for durable enrichment (e.g., computed segments, lifetime classifications, derived traits).\n\n## Configuration\n\nFields on the action:\n\n- `llm_calls[0].model` — model name, see § Available Models\n- `llm_calls[0].llm_configuration_model_id` — for BYOK (Bring Your Own Key) models; `0` for built-in\n- `llm_calls[0].prompt_template_id` / `llm_calls[0].result_schema_template_id` — **outputs, not inputs.** The API creates both templates for you and writes their IDs back. Sending them on create is pointless rather than dangerous: outside a cross-workspace copy the create path ignores request-supplied template IDs entirely, so a value you pass is silently dropped and the templates are provisioned fresh. Read the real IDs from the create response.\n- `llm_result_action.type` — `\"set_customer_variables\"` or `\"set_journey_attributes\"`\n\nOnly 1 LLM call per action is supported, and `type` / `sub_type` are immutable after creation. Change the prompt or schema by editing the existing templates' bodies, not by repointing the action: draft→live materialization rejects an action whose template IDs moved.\n\n## Building One — Two Steps, Both Required\n\nCreating the action does **not** give it a prompt. Like every other templated action, the content lives on linked templates that start **empty**; you must set their bodies in a second call. An action created and left at step 1 fails at run time with `prompt template has empty body` (non-retryable — the journey continues to the next step and the profile gets nothing).\n\n**Step 1 — create the action** with `PUT /v1/environments/:eid/campaigns/:campaign_id` and `update_type: \"add_actions\"`. Read the automation first: `edges` must be the workflow's **complete** graph with the new action wired in — both an incoming and an outgoing edge. (The conversion/exit keys are not needed on `add_actions` — they belong to the conversion/exit update types; see `automations.md § update_type Field`.) A partial list 422s; see `automations.md § Adding actions to an existing workflow`.\n\n`llm_calls` must hold exactly one entry — an empty array is rejected with `422 at least one LLM call is required`, and more than one with `422 only one LLM call is supported`. You don't have template IDs yet and must not send them; the API fills them in.\n\nAppending to a workflow whose only action is `A`, with `A → EXIT` becoming `A → new → EXIT`:\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"add_actions\",\n    \"conversion_window\": 0,\n    \"conversion_action\": \"\",\n    \"exit_on_trigger_or_filter_not_matched\": false,\n    \"exit_on_conversion_matched\": false,\n    \"global_exit_conditions\": null,\n    \"edges\": [\n      { \"from\": \"A_ID\", \"to\": \"-1\", \"type\": \"continue\" },\n      { \"from\": \"-1\", \"to\": \"EXIT_ID\", \"type\": \"continue\" }\n    ]\n  },\n  \"actions\": [\n    {\n      \"type\": \"llm_action\",\n      \"name\": \"Summarize account activity\",\n      \"llm_calls\": [{ \"model\": \"gemini-3-flash-lite\", \"llm_configuration_model_id\": 0 }],\n      \"llm_result_action\": { \"type\": \"set_journey_attributes\" }\n    }\n  ]\n}\n```\n\nEcho the five conversion/exit keys with the values you just read, not the placeholders above. On a workflow with more actions, carry every other edge over too.\n\n**A `--jq` error on this call does not mean the write failed.** The response nests the automation under `campaign`, so a filter written against a flat shape fails with `jq eval: cannot iterate over: null` *after* the action was created. Re-issuing the identical PUT then adds a **second** action, and the edge list only wires one of them — which fails the next update with `422 invalid graph: action \u003cid\u003e has 0 edges`. On any error from this call, re-read the automation and check whether the action already exists before retrying.\n\n**`set_customer_variables` can be rejected on create.** Without profile-attribute write permission, only `set_journey_attributes` is allowed at creation time — `set_customer_variables` comes back `422 you do not have permission to write customer attributes`. If the user wants durable profile enrichment and you hit that, say it's a permissions limit rather than retrying; a journey attribute is the working alternative for in-workflow use.\n\nTake `prompt_template_id` and `result_schema_template_id` from the created action in the response (or re-read the automation).\n\n**Step 2 — set both template bodies** (`PUT /v1/environments/:eid/templates/:template_id`, see `templates.md`). Two separate calls:\n\n```bash\n# prompt template (template_type: llm_action_prompt) — Liquid, rendered per profile\ncio api /v1/environments/$ENV_ID/templates/$PROMPT_TEMPLATE_ID -X PUT \\\n  --arg body=\"Summarize this account's recent activity in two sentences: {{customer | to_json}}\" \\\n  --json '{template:{body:$body}}'\n\n# result schema template (template_type: llm_action_result_schema) — plain JSON Schema, NOT Liquid\ncio api /v1/environments/$ENV_ID/templates/$SCHEMA_TEMPLATE_ID -X PUT \\\n  --arg body='{\"type\":\"object\",\"properties\":{\"activity_summary\":{\"type\":\"string\"}}}' \\\n  --json '{template:{body:$body}}'\n```\n\n`--arg` takes a single `name=value` token — `--arg body 'text'` (space-separated) fails with `accepts 1 arg(s), received N`. See `SKILL.md § Common mistakes`.\n\nBoth bodies must be non-empty — an empty result schema fails the same non-retryable way as an empty prompt. Then test it (§ Testing) before telling the user it's ready; that is the only way to know the prompt renders and the response matches the schema.\n\nEach key in the result schema becomes one attribute, readable downstream as `{{journey.\u003ckey\u003e}}` or `{{customer.\u003ckey\u003e}}` depending on `llm_result_action.type`.\n\n## Inserting Into an Existing Workflow\n\nThe edge mechanics are not specific to LLM actions — full edge list, exit and trigger carve-outs,\nnegative placeholders — see `automations.md § Adding actions to an existing workflow` and follow it.\n\nTwo things are specific to this action type:\n\n- **Order.** The LLM action must sit **before** whatever consumes its output. A journey attribute\n  written after the email has already sent is invisible to it.\n- **Personalizing an existing message.** Don't rewrite the message as an LLM action — insert one\n  before it and reference the result from the message (`{{journey.\u003ckey\u003e}}`). Keep the message's existing content and add the reference alongside it. Check that template's\n  `editor` first: `parcel` means Design Studio owns the content and a direct `PUT /templates/:id`\n  clobbers the compiled body; anything else takes the plain template update in `templates.md`.\n\n## Knowledge Sources\n\nReference material attached to an action, either injected into its prompt or looked up by the model through a tool (see **Attach modes** below). Library entries live per environment and are attached to an action by ID, so one uploaded file can back several actions.\n\n**Upload a file in one call** — `--file` sends the request as `multipart/form-data`; `--json` supplies the form's *other* fields (`name`, `description`), not a JSON body:\n\n```\ncio api /v1/environments/{environment_id}/knowledge_source_library/upload -X POST \\\n  --params '{\"environment_id\":\"'$ENV_ID'\"}' \\\n  --file @runbook.md --json '{\"name\":\"Ops runbook\"}' --timeout 120s\n```\n\n`--json` takes scalars only here — a nested object is rejected, because a form cannot carry one. `--file` cannot be combined with `--page-all`. The CLI's default 30s timeout can be shorter than a large upload takes; the server's budget is 60s and the size cap is 25 MB.\n\n**Accepted types are text-only:** `text/plain`, `text/markdown`, `text/csv`, `text/tab-separated-values`, `application/json`, and the three YAML spellings (`application/yaml`, `text/x-yaml`, `application/x-yaml`). No PDF or DOCX — converting one to text before upload is the workaround.\n\n**The filename extension is the only type signal.** The CLI declares no `Content-Type` on the part, so the server resolves the type from the extension alone. An agent writing to a randomly named temp file gets `400 unsupported file type` — give the temp file a real extension (`.md`, `.csv`, `.json`).\n\n**MCP `cio_write_api` cannot call this route.** The MCP tools read only JSON request bodies out of the spec, so a multipart route appears in `cio_schema` with no body schema and any call built for it returns `400 file is required`. Uploads go through the `cio` CLI's `--file`.\n\n**Google Drive sources do not use the upload route.** They are created with `POST .../knowledge_source_library` (JSON) using `{\"type\":\"google_drive\",\"credential_id\":\"…\",\"file_id\":\"…\"}`. The credential itself cannot be created from the CLI — `POST .../oauth_credentials` needs a Google consent `authorization_code`, and the per-user credential routes are excluded from the API surface. A human connects Drive once in the UI; after that an agent can attach files freely.\n\nAttach an existing library entry to an action with `POST .../llm_actions/:action_id/knowledge_sources/attach`; list what an action already has with `GET .../llm_actions/:action_id/knowledge_sources`.\n\n### Attach modes\n\nThe UI's \"Include it in full\" omits the attach body's `mode`, so the whole file goes into every prompt. \"Look it up as needed\" gives the model a tool instead: `mode: \"queryable_table\"` (SQL over a CSV, TSV or Sheet, max 50,000 rows) or `\"searchable_text\"` (keyword search over other text). Try `queryable_table` first; on `400 knowledge_source_not_tabular`, use `searchable_text`.\n\n- Included sources on one action share a **100,000-token** budget (sum of `estimated_tokens`); lookup sources don't count.\n- Lookup has no per-call fee, but each lookup is an extra model turn that re-sends the prompt and bills its tokens.\n- Include small files (the UI suggests ≤ ~2,000 tokens) and files the task needs whole, like comparing every row. Look up large files where each run needs a few rows or passages.\n\n## Available Models\n\n`GET /v1/environments/:eid/llm_models` returns the live list for this workspace's region — `value` (the name to set), `label`, `category`, `provider` and `multiplier`. That endpoint is the authority; the table below is a snapshot for when you don't need a call. If they disagree, believe the endpoint and say so.\n\nBuilt-in (US): `gemini-3-flash-lite` (default), `claude-haiku-5.5`, `gemini-3-flash`, `gemini-3-pro`, `claude-sonnet-5.5`, `claude-opus-5.5`.\n\nCredits are token usage × a per-model multiplier, and the multipliers are not ordered the way the names suggest — `claude-haiku-5.5` is the cheapest allowed model at **1×**, the default `gemini-3-flash-lite` 2.5×, `gemini-3-flash` 5×, `gemini-3-pro` and `claude-sonnet-5.5` 20×, and `claude-opus-5.5` 40×. For high-volume classification or short generation, use `claude-haiku-5.5` or keep the default. Reach for `gemini-3-pro` or Claude Sonnet/Opus only for long-form reasoning, and mention the cost step when you do.\n\nGemini preview models use **family names without a minor version** (`gemini-3-pro`, not `gemini-3.1-pro`) so campaigns survive Google's version bumps; Claude and GA Gemini models carry explicit versions. Use the names above verbatim.\n\nTwo names that look valid and aren't: retired names (`gemini-2.0-flash`, the 2.5 Gemini names, `gemini-3.0-pro`, and the 4.x Claude names such as `claude-sonnet-4.6` or `claude-opus-4.7`) are **silently normalized** to a current model at run time rather than rejected — so the campaign keeps working but runs a different model than the one you named. Don't set them, and if you find one on an existing action, say which model actually runs.\n\nEU workspaces match US (the default is `gemini-3-flash-lite`), except that `gemini-3-pro` isn't served in the EU and runs as `claude-sonnet-5.5`.\n\nBYOK (Bring Your Own Key): customer configures their own provider credentials and models.\n\n## Testing\n\n| Method | Path | Description |\n|--------|------|-------------|\n| POST | `/v1/environments/:eid/verify/llm/start` | Start a real LLM call. Returns `status: \"pending\"` with `workflow_id` + `run_id` (and the `rendered_prompt` immediately), or `status: \"done\"` with the result if it resolved without a model call |\n| POST | `/v1/environments/:eid/verify/llm/status` | Poll with the returned `workflow_id` / `run_id` until `status` is `done` — response, token usage, errors |\n| POST | `/v1/environments/:eid/previews/llm` | Render the prompt template with sample variables (no LLM call, no credits) |\n\nVerify is **async — two calls.** `start` returns `202 Accepted`, not the result; the result only comes from polling `status`. There is no synchronous `verify/llm_action` endpoint.\n\n**Both test endpoints take the prompt and schema inline — not an action ID.** Send `prompt`, `result_schema`, `model` and `variables` in the body, passing the template bodies you just wrote; neither endpoint loads them from the action for you. On `verify/llm/start`, `action_id` is an optional extra that only injects the action's knowledge sources, so posting `{\"action_id\": N}` alone returns `400 Prompt can't be blank`; `previews/llm` has no `action_id` at all and will happily render an empty prompt. On `status`, `workflow_id` and `run_id` are **query parameters**, not body fields — in the body they come back `400 workflow_id is required`.\n\nA real verify call spends credits; `previews/llm` doesn't. Use the preview when you only need to check that the Liquid renders — it answers most \"why is my prompt wrong\" questions (an `undefined variable: event.data` error is a Liquid/data-source problem, see `@skills/recipes/liquid_syntax.md`, not a model problem).\n\n## Failure Modes\n\n- **Credits exhausted** — workspace ran out of LLM credits (built-in models only, not BYOK)\n- **Safety block** — model refused the prompt due to content safety filters\n- **Timeout** — LLM call exceeded 5-minute timeout (retries 3 times with backoff)\n\nWhen an LLM action truly fails, the error is stored on the output and the journey **continues** to the next step — it does not halt.\n\n## Adding One to a Running Automation\n\nInserting an action into a live automation is a mutation on live data, so it goes through the normal capability flow. The create answers `428 capability_required` before anything is created: request exactly its `required_scopes`, then rerun it. The prompt write that follows answers its own 428 — request those scopes too and finish the write in the same turn. Don't reply to the user between the create and the prompt write: the grant ends when you reply, and a created action whose prompt never lands sits in a running workflow with an empty prompt, which is worse than not starting.\n\nIf the account has \"Edit live data\" off, the create returns `403 agent_editing_disabled`. Don't retry — offer to draft the prompt and result schema so the user can paste them in, or walk them through the palette (**Add → AI → Generate Content… / Qualify Customers… / Make Recommendation…**).\n\n## Limits\n\n- 1 LLM call per action\n- Journey attributes: max 100 per person, max 100KB per value, max 128 bytes per key\n- Built-in models consume credits based on token usage × model multiplier; BYOK costs 1 flat credit\n","message_throttling.md":"# Rate Limit an Automation (Message Throttling)\n\nA ceiling on **how fast one automation sends on one channel**, in messages per\nminute. Set it when a user says an automation sends too fast — to protect a\ndownstream endpoint, pace a provider, warm a domain, or spread load.\n\nThe product calls this a **rate limit**; use that phrase with users. \"Message\nthrottling\" is the internal name and survives in the API field\n(`message_throttle`), so recognise it when a user says \"throttle\" but don't\nintroduce the word yourself.\n\n**Not a frequency cap.** A rate limit caps how fast *the automation* sends; a\nfrequency cap limits how often *one person* can receive messages across\neverything (see **frequency_caps.md**). They sit in the same panel and users\nmix them up, so read what the user is protecting: the receiving *system* is a\nrate limit, the receiving *person* is a cap. \"Send no more than 500 emails a\nminute\" is a rate limit. \"No one gets more than 3 emails a week\" is a cap.\n\n## UI Location\n\nThe setting is a section inside the automation's own settings panel and has\n**no URL of its own**. Link the user to the automation\n(`https://fly.customer.io/workspaces/$ENV_ID/journeys/automations/:campaign_id`) and tell\nthem where to click — do not construct a deeper link:\n\n\u003e Open the automation → **Settings** → **Message Settings** → **Rate limit automation**\n\nOff by default. The panel shows the hourly and daily equivalents of whatever\nper-minute rate is entered.\n\n## Channels\n\nExactly four channels can be rate limited, and the wire value is not always what\nthe user says:\n\n| User says | `channel_type` | Notes |\n|-----------|----------------|-------|\n| Email | `email` | |\n| SMS, text | `twilio` | **Never `sms`** — see below |\n| Push | `push` | Covers UrbanAirship too |\n| Webhook | `webhook` | |\n\n**`sms` and `urban_airship` are rejected.** Both name real channels elsewhere in\nthe API, so they look right and fail: `sms` and `urban_airship` return\n`channel_type is not supported`. SMS is `twilio` and push is `push` here. On\nthat error, retry with the canonical value — do not tell the user the channel\ncan't be rate limited.\n\nEach of the four has its own independent limit, but a limit covers everything\nthat sends on that channel: the `twilio` rate applies to legacy Twilio and\ngeneric SMS sends together, and the `push` rate applies to push and UrbanAirship\ntogether. Within a pair there is no way to pace one and not the other.\n\nIn-App, Slack, Line, Inbox and WhatsApp **cannot** be rate limited. That is a\nproduct decision, not pending work — say so plainly rather than implying it is\ncoming.\n\nWhich of the four a given workspace can actually use depends on the channels it\nhas set up. Read them, don't assume:\n\n```bash\ncio api /v1/environments/$ENV_ID/campaign_message_throttle_supported_channels --jq '.channels'\n```\n\n## Reading the Current Config\n\n```bash\ncio api /v1/environments/$ENV_ID/campaigns/$CAMPAIGN_ID --jq '.campaign.message_throttle'\n```\n\n`null` means no rate limit is configured — the automation sends as fast as the\nworkspace allows.\n\n## Setting a Rate Limit\n\n**Read the automation first — a rate limit change is never a lone field.** Six\nkeys have to ride along, echoed from the `GET` rather than authored: the five\nshared conversion/exit keys every `update_type` carries (`conversion_window`,\n`conversion_action`, `exit_on_trigger_or_filter_not_matched`,\n`exit_on_conversion_matched`, `global_exit_conditions`) plus\n`subscription_topic_id`, which `global_settings` rewrites from the request and\ntherefore **clears if you leave it out**. See **automations.md** § update_type\nField.\n\n```bash\n# Every key after message_throttle is ECHOED from GET .../campaigns/$CAMPAIGN_ID.\n# Send the values that automation actually has — never the ones shown here.\ncio api /v1/environments/$ENV_ID/campaigns/$CAMPAIGN_ID -X PUT --json '{\n  \"campaign\": {\n    \"update_type\": \"global_settings\",\n    \"message_throttle\": {\n      \"enabled\": true,\n      \"channel_rates\": [\n        {\"channel_type\": \"email\", \"messages_per_minute\": 500},\n        {\"channel_type\": \"twilio\", \"messages_per_minute\": 60}\n      ]\n    },\n    \"conversion_window\": 604800,\n    \"conversion_action\": \"receiving\",\n    \"exit_on_trigger_or_filter_not_matched\": false,\n    \"exit_on_conversion_matched\": false,\n    \"global_exit_conditions\": [],\n    \"subscription_topic_id\": \"42\"\n  }\n}'\n```\n\n\u003e **Setting a rate limit must not detach a subscription topic.** Send back the\n\u003e `subscription_topic_id` the `GET` returned. If the automation has none the\n\u003e field comes back empty and there is nothing to preserve, but when it has one\n\u003e and you omit it, the topic is silently removed and the automation starts\n\u003e sending outside it.\n\n- **`enabled: true` is required.** With `enabled: false` the rates save cleanly\n  and limit nothing — a silent no-op, and a common way to \"set\" a limit that\n  never applies. To turn rate limiting off, send `enabled: false`.\n- **A channel you omit is unlimited.** Leave a channel out of `channel_rates`\n  rather than sending `0`; `0` is invalid.\n- **1 to 60,000 messages per minute**, per channel.\n- **Each channel appears at most once** per request.\n- Rates are independent. Unused SMS capacity never flows to email, and there is\n  no automation-wide total.\n- **One rate covers the whole automation, not each step.** Every message step on\n  the same channel draws from that channel's single limit, so an automation with\n  three email steps at 100/min sends 100 emails a minute in total, not 300.\n\nApplies within seconds, to a running automation, with no publish step.\n\n**`message_throttle` itself is safe to omit.** A later `global_settings` update\nthat leaves it out preserves the configured rates — unlike\n`subscription_topic_id`, which clears when omitted, and unlike the five shared\nkeys above, which are required on every call. That safety does not extend to\nauthoring: when you change one channel and leave another alone, read the current\n`message_throttle` and echo the untouched channel back, because `channel_rates`\nreplaces the whole list.\n\n## What a Rate Does and Doesn't Do\n\n- **It is a ceiling, not a promise.** Nothing is dropped. Messages over the rate\n  wait in the queue and drain at the configured pace.\n- **Delivery is bursty, not evenly spaced.** At 2/min with 5 queued: 2 go now, 2\n  at +60s, 1 at +120s. Never describe it as \"one every 30 seconds\".\n- **Raising a rate releases the backlog as a burst** on the next check. Lowering\n  one does not recall messages already released in the current minute.\n- **Retries and manual sends count.** A retry consumes budget each time it is\n  released, and drafts released from the Drafts tab, \"Retry all\", and \"Resend\"\n  are all rate limited like normal sends.\n- **Automations only.** `message_throttle` does nothing on one-time sends,\n  broadcasts, or transactional messages. A one-time send paces itself through a\n  different mechanism — `update_type: \"rate_limits\"` with `rate_limit_*` /\n  `daily_rate_limits` (see **messaging.md**) — so \"rate limit this broadcast\" is\n  a real request, just not this field. Note that an automation created with\n  `type: \"transactional\"` (an event-triggered automation) **is** rate limited by\n  this field; that type is unrelated to the Transactional Messages product.\n- **It paces sends, not journey progression.** A person moves to the next action\n  as soon as this message is queued, so a faster channel later in the workflow\n  can overtake a slower one that is still waiting for a slot — messages arrive\n  out of order. The fix is the \"Wait before continuing journey\" setting on the\n  slower message; see **await_completion.md**, including its 24-hour cap, which\n  a slow rate limit can outlast.\n- **It can only slow sending down, never speed it up.** Whatever sending\n  capacity the workspace already has still applies first; a rate limit tightens\n  the pace within that and can never lift it. Setting a high rate on a slow\n  automation changes nothing.\n\n## Errors\n\n| Message | Cause | Fix |\n|---------|-------|-----|\n| `channel_rates[N].channel_type is invalid` | Not a channel name at all | Use `email`, `twilio`, `push`, or `webhook` |\n| `channel_rates[N].channel_type is not supported` | A real channel that can't be rate limited — usually `sms` or `urban_airship` | Retry as `twilio` / `push`; for In-App, Slack, Line, Inbox, WhatsApp it is unsupported by design |\n| `channel_rates[N].channel_type is duplicated` | Same channel twice in one request | Merge into a single entry |\n| `channel_rates[N].messages_per_minute must be between 1 and 60000` | `0`, negative, or over the cap | Omit the channel to leave it unlimited; otherwise pick a value in range |\n| `message throttling is not enabled for this account` | Rate limiting is switched off for the whole account | Not a value to retry — no payload will succeed. Tell the user it needs enabling for their account and stop. Rare: it is on for every account by default |\n\nEditing a **live or scheduled** automation additionally needs the user to\napprove the change when prompted; on a draft it applies directly. See\n**SKILL.md** § Agent Permission Errors.\n\n## Interactions\n\n- **Frequency caps are evaluated when a message is finally sent**, not when it\n  is queued. A message held by a rate limit can still be suppressed later by a cap\n  that a different automation filled while it waited. Both are working as\n  intended — check the cap before concluding the rate limit \"lost\" messages.\n- **There is no live view** of a rate limit's queue depth or drain time. The\n  config is readable; how many messages are waiting is not. Don't estimate one.\n","messaging.md":"# Messaging — One-time sends, Transactional Messages, Message activity\n\n\u003e **Liquid in one-time send/transactional content?** Subject lines and bodies support Liquid — see `recipes/liquid_syntax.md` for syntax, filters, and pitfalls.\n\n## One-time sends\n\nWhen automations aren't available on the account's plan, a one-time send reaches the same audience on any channel, not just email.\n\nOne-time sends deliver a message to an audience on a one-off or recurring schedule — a segment expression, a subscription topic, or the whole workspace. They support all messaging channels — not just email.\n\n\u003e **No one-time send matches the name the user gave? Read `api_triggered_broadcasts.md` before the next lookup.** The UI calls both a \"broadcast\", so \"send/schedule the X broadcast\" may mean either. An empty `/newsletters` result is not an answer on its own. API-triggered broadcasts are campaigns, and a plain `/campaigns` list **hides** them; follow that file's `type: triggered_broadcast` lookup before saying the broadcast does not exist.\n\n\u003e **UI links:** a one-time send is `https://fly.customer.io/workspaces/$ENV_ID/journeys/broadcasts/newsletter/:newsletter_id` — `/setup/review` for a draft you just built, `/overview` once it has sent. Link the user here when you create or reference one. Never link a one-time send under `.../broadcasts/broadcast/:id`: that is the API-triggered broadcast route, and it reads the id as a campaign id and redirects to an unrelated automation.\n\n### Supported Channels\n\nSet the `channel` field when configuring a one-time send:\n\n| `channel` value | Channel |\n|---|---|\n| `email` | Email |\n| `twilio` | SMS on a legacy-Twilio workspace (writing the body? see `sms.md`) |\n| `sms` | SMS on a custom-SMS workspace — same channel to the user, different storage. Pick between this and `twilio` with `sms.md § Which SMS channel is this workspace on?`; the content is a Design Studio node (`sms_design_studio.md`) |\n| `push` | Push notification |\n| `in_app` | In-app message |\n| `whatsapp` | WhatsApp (requires external template — see `whatsapp.md`) |\n| `slack` | Slack |\n| `line` | LINE |\n\n### Sending a one-time send\n\n**Create the one-time send:**\n\n```\nPOST /v1/environments/:environment_id/newsletters\nBody: { \"newsletter\": { \"name\": \"Weekly Update\", \"send_percentage\": 100 } }\n```\n\nThe channel is `\"unset\"` at creation. Save the `id` from the response.\n\n**Configure the one-time send** (set channel, segments, schedule):\n\n```\nPUT /v1/environments/:environment_id/newsletters/:id\n```\n\nThe update endpoint uses an **`update_type` discriminator** — fields sent under the wrong `update_type` are silently ignored (or, for channel, return a 400 telling you to set `update_type` correctly). Always set `update_type` to match the fields you're changing:\n\n\u003e `recipients` and `test` require `send_percentage` between 1 and 100; omitting it there is rejected. `recipients_file` accepts omission or 0 without changing the stored percentage, stores values from 1 through 100, and rejects values below 0 or above 100. Other update types ignore it.\n\n| `update_type` | Fields it accepts |\n|---|---|\n| `main` (default) | `name`, `description` |\n| `channel` | `channel` |\n| `recipients` | **required:** `send_percentage`, `send_to_unsubscribed`, `deduped`, `use_message_limits`. **target:** `filters` (encoded segment expression — see below); for the whole workspace also send `send_to_everyone: true`. **replaced, not kept:** `subscription_topic_id` (a string, `\"3\"`; a number is a 400) and the rate fields (`rate_limit_email_rate`, `rate_limit_time_period`, `rate_limit_spread`, `daily_rate_limits`) — leaving the topic out clears it, and unless the body has a non-empty `daily_rate_limits` or both `rate_limit_email_rate` and `rate_limit_time_period`, every rate limit is cleared, so echo the stored values (see below) |\n| `recipients_file` | `recipient_file_path`, `send_percentage`, `send_to_unsubscribed`. **replaced, not kept:** `subscription_topic_id` — leaving it out clears it |\n| `tracking` | `conversion`, `conversion_type`, `conversion_segment_id`, `conversion_event_name`, `conversion_window`, `conversion_action`. `conversion_window` is in seconds; when the user gives none, use `604800` (7 days) and say so rather than asking |\n| `schedule` | `scheduled_at`, `timezone`, `tz_match_enabled`. `scheduled_at` must be in the future — a timestamp more than five minutes in the past is rejected with a 422 (`scheduled_at cannot be set in past`) and changes nothing; one within those five minutes is accepted and sends right away. Never fabricate a placeholder date to satisfy the field: compute the epoch from the user's stated date/time (double-check the year), or ask the user for the send time |\n| `rate_limits` | `rate_limit_*`, `daily_rate_limits`. Replaces every rate limit: limits survive only when the body has a non-empty `daily_rate_limits` or both `rate_limit_email_rate` and `rate_limit_time_period`; anything else (none of them, a rate without a period or the reverse, or `rate_limit_spread` alone) clears them all. The same holds for the rate fields on `recipients` |\n| `draft_schedule` | `draft_scheduled_at`, `draft_send_mode` (`now` or `scheduled`), plus `timezone`, `tz_match_enabled`, `sto_enabled`, `expires_after`, rate fields. The UI's staged, uncommitted schedule: the `draft_*` pair is replaced (leaving either out clears it), and nothing sends until a `schedule` or `send` update commits it |\n| `send` / `pause` / `resume` / `cancel` | (no additional fields — these are actions) |\n| `test` / `end_test` | A/B test fields — see `newsletter_ab_tests.md` |\n\nRun `cio schema newsletters.update` for the canonical, always-fresh list.\n\n\u003e **`send` fires immediately — it is not a \"commit\" for a pending schedule.** `schedule` and `send` are independent actions, not a two-step ritual. Once you `schedule` a one-time send for a future time, you are done: it moves to `scheduled` and the scheduler delivers it at `scheduled_at` on its own. Do **not** follow `schedule` with `send`. Calling `send` always sends *right now*: it overwrites the pending `scheduled_at` with the current time and clears `original_scheduled_at`, so a broadcast staged for hours/days out goes out instantly with no error or warning. To verify a future schedule took, use `GET .../newsletters/:id/schedule` — not `send`. (That endpoint, not `send_state`: scheduling a split test's winner or remainder leaves the broadcast `sending`, because it has already sent once, so it never reads back as `scheduled`.) To call off a scheduled send, use `cancel` (or `schedule` again to move the time). These live actions also require the account's \"Edit live data\" setting (Settings → AI \u0026 MCP) — without it they return 403.\n\n```json\n// Set the channel — REQUIRES update_type: \"channel\"\n{ \"newsletter\": { \"channel\": \"email\", \"update_type\": \"channel\", \"send_percentage\": 100 } }\n```\n\n**Recipients (`update_type: \"recipients\"`).** No `segment_ids` field — silently ignored. Targeting always goes through `filters` (base64+urlencoded JSON, same shape as automation segment filters — see `automation_filters.md`). For the whole workspace, send the encoded everyone-filter `\"JTVCJTVE\"` (base64 of `%5B%5D`, i.e. `[]`) **together with** `send_to_everyone: true`. If the audience involves \"was/wasn't sent or delivered a message\" criteria, use a `type: \"message\"` condition with an `event.type` from the closed list in `automation_filters.md § Message Conditions` — never invent channel-suffixed types like `delivered_sms` (SMS/push/etc. use `delivered_action` with the channel in `event.name`).\n\n**`filters` is the audience; `send_to_everyone` only labels it.** The two are not independent fields, so read this before sending either:\n\n| You send | Result |\n|---|---|\n| both | `filters` wins — `send_to_everyone` is recomputed from it and the value you sent is **discarded**, even if it contradicts |\n| `filters` only | the flag is derived from it |\n| `send_to_everyone` only | it is stored, and an unconstrained stored filter is added/removed to match; a stored **segment** filter is left alone |\n| neither | the stored audience is untouched |\n\nConsequences worth knowing:\n\n- Setting `send_to_everyone: false` to \"clear\" a whole-workspace audience only clears it when the stored flag was actually `true`. Restating a flag that is already `false` is not a retraction and leaves the audience as-is.\n- More than one encoding means \"everyone\": the canonical `\"JTVCJTVE\"`, an empty string, and a bare `\"[]\"` all decode to zero conditions. All three set the flag to `true`. Prefer the canonical constant.\n- Reading back `send_to_everyone` tells you the label, not necessarily what the send resolves. When it matters, inspect `filters`.\n- **\"Everyone\" is not every profile.** An unconstrained filter selects the whole workspace, and the send then ANDs subscription state on top unless `send_to_unsubscribed: true` — a plain subscribed check on a legacy workspace, or the profile's topic and channel preferences where the subscription center is on. `send_percentage`, a `subscription_topic_id`, and message limits / frequency caps narrow it further. So report a whole-workspace audience as \"everyone who is still subscribed\", never as a profile count, and get the real number from `GET .../newsletters/:id/preview_recipient_counts` rather than estimating one.\n\n\u003e **Echo the topic and rate limits on every `recipients` PUT.** The examples below are for a fresh one-time send with neither. On an existing one, `GET .../newsletters/:id` first and add its `subscription_topic_id`, `rate_limit_email_rate`, `rate_limit_time_period`, `rate_limit_spread` and `daily_rate_limits` to the body. Leaving the topic out clears it, which widens the audience to people who opted out of that topic; leaving the rate fields out removes the send's rate limits.\n\nFor the whole workspace, inline JSON is enough:\n\n```bash\ncio api /v1/environments/$ENV_ID/newsletters/$NL_ID -X PUT \\\n  --json '{\"newsletter\":{\"update_type\":\"recipients\",\"send_percentage\":100,\"send_to_unsubscribed\":false,\"deduped\":true,\"use_message_limits\":true,\"send_to_everyone\":true,\"filters\":\"JTVCJTVE\"}}'\n```\n\nFor specific segments, compute `filters` and the full body in one `node -e`, capture it, confirm it built, then send — no temp file:\n\n```bash\nBODY=\"$(node -e '\nconst filters = Buffer.from(encodeURIComponent(JSON.stringify([{type:\"segments\",segments:[1,2]}]))).toString(\"base64\");\nconsole.log(JSON.stringify({\n  newsletter: {\n    update_type: \"recipients\",\n    send_percentage: 100, send_to_unsubscribed: false, deduped: true, use_message_limits: true,\n    filters,\n  },\n}));\n')\" || { echo \"failed to build recipients body\"; exit 1; }\necho \"$BODY\" | jq -e '.newsletter.filters' \u003e/dev/null || { echo \"no filters in built body; refusing to PUT\"; exit 1; }\ncio api /v1/environments/$ENV_ID/newsletters/$NL_ID -X PUT --json \"$BODY\"\n```\n\nThe outer filters array is OR groups; each inner `segments` list is ANDed. `[{segments:[1]},{segments:[2]}]` = \"in segment 1 OR 2\"; `[{segments:[1,2]}]` = \"in segment 1 AND 2\".\n\nAll four booleans (`send_percentage`, `send_to_unsubscribed`, `deduped`, `use_message_limits`) are required on every `recipients` PUT — omitting any returns `\"\u003cfield\u003e cannot be nil\"`. Fetch the one-time send first if you only want to change one, and send its stored topic and rate fields back with them (see above). After updating, verify with `GET .../newsletters/:id/segments`.\n\n\u003e **A sent broadcast's audience cannot be changed.** `update_type: \"recipients\"` is rejected with a 400 once a broadcast has been sent (including one still mid-ramp or with a send-to-remaining pending), and the account's \"Edit live data\" setting does not exempt you. The send resolves its audience once, at send time, so there is nothing an edit could recalculate. If someone asks you to narrow or exclude an audience on a broadcast that is already out — for example, to stop the rest of a ramp reaching a segment — the answer is `cancel` and then a new broadcast with the corrected audience; do not report the edit as applied. A **cancelled** broadcast does accept a `recipients` edit, since nothing further will ship from it.\n\nFor an SMS one-time send, the channel is `\"twilio\"` or `\"sms\"` depending on the workspace — check `enabled_actions` per `sms.md § Which SMS channel is this workspace on?` rather than defaulting to `\"twilio\"`. For push, use `\"channel\": \"push\"`. See the supported channels table above.\n\n**Set the content** — one-time sends have templates, just like automation actions and transactional messages. **This shared infrastructure means you'll see `/templates/:id` endpoints when editing one-time send content even though no automation is involved** — if the user gets nervous about \"is this an automation?\", reassure them that templates are the generic content container used by one-time sends, automation actions, and transactional messages alike. Get the template from `GET .../newsletters/:id/templates`, then update it via the templates API. For email one-time sends, a `layout_id` can be set on the template to wrap the body in a layout.\n\n**Forward to one person:**\n\n```\nPOST /v1/environments/:environment_id/newsletters/:id/forward\n```\n\n```json\n{ \"customer_id\": \"person-id\", \"template_id\": \"123\" }\n```\n\nRequires a non-empty `customer_id` or `internal_id`; `internal_id` takes precedence when both are supplied. The optional `template_id` selects a newsletter template (defaulting to the first when omitted or not found). This endpoint forwards the newsletter to that person; it does not send or schedule the broadcast audience.\n\n\n### One-time send states — read `send_state`, never infer\n\n`GET .../newsletters/:id` returns **`send_state`** — the state the app itself shows for that broadcast, already derived for you. Read it instead of reassembling one from the timestamps, which is where this goes wrong. For the narrower question \"has anything gone out\", read it *together with* `sent_at`, per the table.\n\n| `send_state` | Meaning | Has anything shipped? |\n|---|---|---|\n| `drafted` | Never sent or scheduled | No |\n| `scheduled` | Committed to a future time, nothing sent yet | No |\n| `sending` | Dispatching now, or mid-ramp with deliveries in flight | **Yes**, unless `waiting_for_segments` is true |\n| `sent` | Finished delivering | **Yes** |\n| `awaiting_winner` | Split test sent to its sample, holding the remainder for a manual winner | **Yes**, to the sample, unless `waiting_for_segments` is true |\n| `paused` | Paused; the rest is held. Reachable **before** a send starts, so this is not by itself evidence anything went out | Check `sent_at` |\n| `stopped` | Cancelled — nothing further will ship, but whatever already went out stayed out | Check `sent_at` |\n| `failed` | Terminal failure (cap exceeded, no recipients, no templates) | Check `sent_at` |\n\n`send_state` tells you the **situation**, not a shipped/not-shipped bit: `paused`, `stopped` and `failed` each occur both before and after a send starts. Pair it with `sent_at`. One more asymmetry worth knowing: `awaiting_winner` outranks `sending`, so a manual split test reports it both while still dispatching its sample and while parked waiting for someone to pick a winner. `send_state` can't separate those — check `scheduled_at` or `GET .../newsletters/:id/send_status` if you need to know whether anything is actually moving.\n\n**`waiting_for_segments: true` means nothing has shipped yet**, even when `send_state` reads `sending` or `awaiting_winner` and `sent_at` is set. The broadcast is waiting for its audience segments to finish building and goes out on its own if they finish in time; its `scheduled_at` is not a send time. Tell the customer it is waiting on the segment build.\n\n\u003e **`scheduled_at: null` is not evidence that nothing was sent.** It is null in *both* the never-scheduled and the already-delivered case: a send sets `scheduled_at` to the current time and then clears it once dispatch completes. So a broadcast that went to its entire list minutes ago reads `scheduled_at: null` — identical to a draft that was never touched. **A newsletter has no `sending_state` field either** (that is an automation *action* field); asking for it in a `--jq` projection returns `null` and reads as reassuring.\n\n**A non-null `sent_at` means a send was *initiated*.** It is set when the send starts resolving its audience — before the first delivery exists — and cancelling or completing never clears it, so it does not decay into a null the way `scheduled_at` does. The one thing that clears it is a pre-flight failure that shipped nothing, which correctly returns the broadcast to a draft. So `sent_at` answers \"did a send start\", and **`total_sent` plus `GET .../newsletters/:id/metrics` answer \"how many actually went out\"** — report the volume from those, never from the presence of `sent_at` alone.\n\n### Never report an all-clear you did not verify\n\n\"Nothing was sent\", \"no emails went out\", \"it is not scheduled\", \"all clear\" — these are the highest-stakes sentences you can say about a broadcast, because a customer who believes them stops investigating. Before writing any of them:\n\n1. `GET .../newsletters/:id` and read `send_state` and `sent_at` — on the broadcast itself, fetched fresh, not from anything you read earlier in the conversation.\n2. **`sent_at` is the test.** Non-null means a send started — say so plainly, give the volume from `total_sent` (a send can start and ship nothing), and link the overview page. A `send_state` of `drafted` or `scheduled` is the only pair that means nothing has started; `paused`, `stopped` and `failed` need `sent_at` read before you characterise them either way. If `waiting_for_segments` is true, nothing has gone out yet, but it may still send on its own, so it is not an all-clear.\n3. If a value you need came back `null`, treat it as an unanswered question, not a negative — confirm the field exists on this resource (`cio schema newsletters.get --compact`) before building any conclusion on it. See `SKILL.md § Null identity fields mean a wrong jq path`.\n\n**This applies most strictly right after your own write.** A `send`, `schedule`, `cancel`, `pause` or `resume` you just issued has changed the state; re-read the broadcast and report what it now says. Never describe the outcome of your own action from what you intended it to do — if you scheduled something and the customer asks whether it went out, the answer comes from a fresh `send_state`, because a `scheduled_at` up to five minutes in the past is accepted and sends right away. A wrong date further back is refused with a 422 and changes nothing: report that the broadcast was neither scheduled nor sent, never that it already went out. Telling a customer their broadcast is safe while it is in flight costs them the window in which they could still have cancelled it.\n\n### Other one-time send endpoints\n\n| Endpoint | Use for |\n|----------|---------|\n| `POST .../newsletters/:id/copy` | Duplicate a one-time send |\n| `GET .../newsletters/:id/metrics` | Delivery stats (sent, opened, clicked, etc.) |\n| `GET .../newsletters/:id/tracked_link_metric` | Per-link click metrics |\n| `GET .../newsletters/:id/tracked_response_metrics` | Conversion/goal metrics |\n| `GET .../newsletters/:id/tracked_responses` | Raw tracked response data |\n| `GET .../newsletters/:id/counts` | Recipient count for target segments |\n| `GET .../newsletters/:id/preview_recipient_counts` | Preview recipient counts before sending |\n| `GET .../newsletters/:id/send_status` | Send progress while delivering |\n| `GET .../newsletters/:id/segments` | Target segments |\n| `GET .../newsletters/:id/schedule` | Schedule details |\n| `GET .../newsletters/:id/forms` | Forms associated with the one-time send |\n| `POST .../newsletters/:id/optimal_time` | Enable AI-powered optimal send time |\n| `POST .../newsletters/:id/analyze_survey` | AI-powered survey response analysis |\n| `POST/DELETE .../newsletters/:id/tags` | Tag management |\n| `POST .../newsletters/:id/tests` | Create A/B test variations — see `newsletter_ab_tests.md` |\n| `DELETE .../newsletters/:id/test` | Delete a single test variation — see `newsletter_ab_tests.md` |\n| `DELETE .../newsletters/:id/tests/:test_group_id` | Delete a test group |\n| `DELETE .../newsletters/:id/tests` | Delete all test variations |\n\nDuplicating a one-time send unarchives every segment its audience references, so it can be\nrefused: if that would take the workspace over its data-driven segment limit, the copy returns\n422, no one-time send is created, and nothing is unarchived. The 422 text calls it a broadcast.\nWhen this 422 occurs, explicitly tell the user: \"No broadcast was created, and no segments were unarchived.\"\nOn a plan with a conditions lookback limit, the copy is also refused, with a 400 naming the segment\n(`segment N (\"name\"): invalid within value: ...`), when a segment it would unarchive has an event condition past the limit\n(`segment_lookback.md`). Again nothing is created or unarchived.\nFor a normal duplicate, send `{\"copy_to_env\": 0}` (or `{}`) as the JSON body. Never set\n`copy_to_env` to the current workspace ID: any nonzero value selects the workspace-copy\npath, which drops the audience filter and does not unarchive segments, even for the current\nworkspace. That path is uncapped. The remedy for a refused duplicate is to delete a\ndata-driven segment, not to archive one.\n\n---\n\n## Transactional Messages\n\nStandalone transactional messages are purpose-built for one-off, API-triggered sends. Automations with `type: \"transactional\"` are a different thing (event-triggered *automation workflows*).\n\nTransactional messages are triggered by the customer's backend via the App API (using a bearer token). The UI API manages the message definitions and templates.\n\n### Creating a Transactional Message\n\nAvailable types:\n\n| `type` value | Channel |\n|---|---|\n| `email` | Email |\n| `push` | Push notification |\n| `twilio` or `sms` | SMS |\n| `in_app` | In-app message |\n| `inbox` | Inbox message |\n\n**Create the transactional message:**\n\n```\nPOST /v1/environments/:environment_id/transactional_messages\nBody: { \"transactional_message\": { \"name\": \"Order Confirmation\", \"type\": \"email\" } }\n```\n\nThe response includes the message AND a `templates` array with the auto-created template.\n\nThe response includes the message AND a `templates` array with the auto-created template. Edit the template content via `PUT /templates/:id` — for email, pick the editor first: Design Studio by default when the workspace uses it (`templates.md § Setting Message Content`), linking with `target.type: \"transactional_message\"`. The customer's backend triggers sends via the App API using the transactional message ID.\n\n\n**Channel is changeable** — you can update the channel via `PUT` with `update_type: \"channel\"`, but only before the first delivery is sent. After that, the channel is locked.\n\n### Sending Transactional Messages from a Backend\n\nProduction sends (HTTP shape, auth, `auto_create` paradigm, channel-specific fields, code examples) live in **`transactional_send.md`**. Read that file when the user asks how to send a transactional message from their app code, or when generating backend integration snippets.\n\n### Other Transactional Endpoints\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET .../transactional_messages` | List all transactional messages |\n| `GET .../transactional_messages/:id` | Get a transactional message with templates |\n| `PUT .../transactional_messages/:id` | Update name, description, tags |\n| `DELETE .../transactional_messages/:id` | Delete a transactional message |\n| `POST .../transactional_messages/:id/copy` | Duplicate |\n| `GET .../transactional_messages/:id/metrics` | Delivery metrics |\n| `POST .../transactional_messages/:id/cancel` | Cancel all pending deliveries |\n| `POST/DELETE .../transactional_messages/:id/tags` | Tag management |\n| `POST .../transactional_messages_sample/:type` | Create a sample transactional message with pre-filled content. Type is `email`, `push`, `twilio`, or `inbox` (defaults to `email` if omitted). `in_app` is a valid transactional type but has no sample content — requesting it returns an error |\n\n**Multi-language content?** One-time sends and transactional messages attach translations directly to the parent — see `translations.md`.\n\n---\n\n## Message Library — find a message by subject or body content\n\nThe **one endpoint that searches message subject and body content**, across every message type (automations, one-time sends, broadcasts, and transactional messages) in a single call. Use it whenever the user describes a message by what it *says* — a subject line, a phrase in the body — rather than by name or ID. Universal Search (`search.md`) matches automations/one-time-sends/transactional on **name and metadata only — never the email subject or body**, so it won't find a message by its subject/content; this endpoint is the right tool for that.\n\n```\nGET /v1/environments/:environment_id/message_library_items\n```\n\n| Param | Notes |\n|-------|-------|\n| `search` | The content query. **Base64-encoded** (plain single-encode — *not* the double-encoding that automation/segment `filters` use). Decoded term must be ≥ 3 chars. Matches **subject, body, name, and preheader** across all message types. |\n| `parents[]` | Restrict to parent types: `campaign`, `newsletter`, `transactional_message`, `api_triggered_broadcast`, `in_app_broadcast`. The route reads it repeated, but `cio api` carries one value per call (see the callout below). **Omit to search all types** (see fallback note below). |\n| `channel[]` | Filter by channel/action type, matched against the template's type — exactly, with one exception: `in_app` also pulls in `in_app_broadcast`. (e.g. `email`). **SMS is two of them** — `twilio` for the classic SMS action and `sms` for the message-channel action — so either value hides the other. Omit `channel[]` and split on each row's `delivery_type`, which carries both spellings. |\n| `status[]` | Parent-type + state combos. The full set, and nothing else is recognized: `campaign_automatic`, `campaign_draft`, `campaign_off`, `apitb_automatic`, `apitb_draft`, `apitb_off`, `transactional_automatic`, `transactional_draft`, `transactional_drafted`, `anonymous_active`, `anonymous_drafted`, `anonymous_off`, `newsletter_draft`, `newsletter_sent`. |\n| `page` / `limit` / `order` | `limit` default 50, max 200; `order` is `asc`/`desc` on `updated_at`. |\n| `from_id` / `reply_to_id` | Filter by sender / reply-to identity ID. |\n| `updated_before` / `updated_after` | Unix-timestamp bounds on `updated_at`. |\n| `include_archived_campaigns` / `include_stopped_campaigns` | Both default **false**, so a plain `search` call silently drops every template on an archived or stopped automation. Set `\"true\"` when a message the user insists exists comes back empty. |\n\n\u003e **Three exclusions apply even with no filters set.** A `search`-only call is not \"everything\": templates on\n\u003e archived automations and on stopped automations are dropped (the two flags above), and so are templates on a\n\u003e newsletter that has **already sent**. Each looks identical to \"no such message exists\".\n\u003e\n\u003e Two different ways through, because the third exclusion has no `include_*` flag. For archived/stopped, re-run\n\u003e with both flags `\"true\"`. For an already-sent one-time send the only value that works is\n\u003e `status[]=newsletter_sent`:\n\u003e\n\u003e ```bash\n\u003e Q=$(printf '%s' 'your phrase' | base64 | tr -d '\\n')\n\u003e cio api /v1/environments/$ENV_ID/message_library_items \\\n\u003e   --params \"{\\\"search\\\": \\\"$Q\\\", \\\"status[]\\\": \\\"newsletter_sent\\\"}\"\n\u003e ```\n\u003e\n\u003e Any status value clears the sent-newsletter exclusion internally, but that is not enough on its own: the status\n\u003e filter then replaces it with a clause covering only the parent types its own values name. So\n\u003e `status[]=campaign_automatic` drops newsletters right back out, and the result is indistinguishable from the\n\u003e exclusion never having lifted. Only `newsletter_sent` names them.\n\u003e\n\u003e Two consequences. `status[]` **narrows** the call to newsletters rather than widening it — run it as a second\n\u003e search, not a replacement. And an unrecognized value — or one no row is currently in — compiles to `AND FALSE`:\n\u003e `200` with zero rows, which reads exactly like \"no such message\". There is no server-side validation and no\n\u003e error, so check the spelling against the full `status[]` list in the table above before believing an empty\n\u003e result. The same holds for an unrecognized `parents[]` value.\n\u003e\n\u003e Don't fall back to the newsletters list for this — it matches on **name** only, and body text is the whole\n\u003e reason you are on this endpoint.\n\n\u003e **`parents[]`, `channel[]` and `status[]` carry one value each.** Pass a bracketed key like any other — `--params\n\u003e '{\"parents[]\": \"transactional_message\"}'` — but only once: `cio api` sets each key once, and repeating it is refused with `param\n\u003e key \"channel[]\" appears more than once`, so a multi-value filter cannot be sent. For several values, omit the\n\u003e filter and narrow with `--jq` — but raise `limit` first (it defaults to 50, max 200): narrowing a capped page\n\u003e client-side under-reports, and an empty `--jq` result off a capped page is not proof. Omitting `parents[]` is\n\u003e what the fallback below wants anyway.\n\u003e\n\u003e From the MCP `cio_*` tools these three are unreachable at all — their param validator rejects any key outside\n\u003e `[a-zA-Z0-9_]` (`param key \"channel[]\" contains invalid characters`), so the omit-and-`--jq` route is the only\n\u003e one there. It covers `parents[]` and `channel[]`, whose rows are in the page either way. It does **not** cover\n\u003e the sent-newsletter case: omitting `status[]` re-arms that exclusion, so those templates are never in the page\n\u003e to filter. From the MCP tools an already-sent one-time send cannot be reached through this endpoint at all —\n\u003e say so rather than reporting it as missing.\n\nEncode the search term as plain standard base64 (keep the `=` padding — the server decodes with standard, *not* URL-safe, base64), and pass query values as a JSON object via `--params` (not in the path — `cio api` rejects a path with `?`):\n\n```bash\nQ=$(printf '%s' 'free shipping' | base64 | tr -d '\\n')\ncio api \"/v1/environments/$ENV_ID/message_library_items\" --params \"{\\\"search\\\": \\\"$Q\\\"}\"\n```\n\nThe agent sandbox has no `openssl` (`openssl base64 -A` fails with `command not found`); `base64` is present, and\n`tr -d '\\n'` undoes the 76-column wrapping GNU `base64` applies to longer terms.\n\nEach result carries the parent linkage (`campaign_id`, `newsletter_id`, `transactional_message_id`, `in_app_broadcast_id`), `subject`, `name`, `used_in`, `delivery_type`, `status`, plus a `fields_matched` array (which of subject/body/name/preheader hit) and a `highlight` snippet. Surface the `highlight` so the user sees why it matched, and use the parent ID + type to continue in the relevant skill.\n\n\u003e **Unlinked Design Studio emails are not in here.** The Library indexes message *templates* — the content attached\n\u003e to an automation, one-time send, transactional message, or broadcast. A Design Studio node that is not linked to a\n\u003e Journeys destination has no template, so it never appears, and a search for its text comes back empty. Once linked\n\u003e and published, the rendered body lands on that template, so a published node matches here — subject to the three\n\u003e exclusions above, which hide it by default if its automation is archived or stopped or its one-time send has\n\u003e already gone out. Clear those first (the flags, or `status[]`) before you conclude a *published* node is missing.\n\u003e Read an empty result as \"out of scope\", never as \"no such email exists\": search Design Studio with\n\u003e `GET /v1/environments/$ENV_ID/ds/node_actions/search` (`--params '{\"search_query\": \"…\"}'`), documented in\n\u003e `@skills/design-studio/nodes.md § Search`.\n\n\u003e **\"Automation\" (or \"campaign\") that isn't one — fall back here.** Users often say \"campaign\"/\"automation\" generically for \"a message we send.\" When an automation/newsletter lookup (Universal Search or the automations list) turns up nothing, **re-run the search here with no `parents[]` filter** so transactional messages are included, before telling the user nothing was found.\n\n\u003e **Note:** `parents[]`, `status[]`, and response IDs still use the API's `campaign` naming (e.g. `campaign_id`, `campaign_automatic`) — that's the raw API field name, not the display term.\n\n---\n\n## Message activity\n\nA message is a single instance sent to a profile — the personalized version of an automation action, one-time send, or transactional message.\n\n\u003e **A test send is not in message activity.** To check whether a test push was recorded as delivered or opened, read it back from `test_deliveries` (see `test_sends.md`). The routes below never show it.\n\n\n### Viewing messages\n\n```\nGET /v1/environments/:environment_id/deliveries\n```\n\nKey query parameters: `campaign_id`, `newsletter_id`, `customer_id`, `recipient`, `metric` (status filter), `type` (a single **exact** channel name — note SMS is two of them, `twilio` for the classic SMS action and `sms` for the message-channel action, so either value hides the other; omit it and split on each row's `action_type` instead — those carry the `_action` suffix, so SMS is `twilio_action` **plus** `sms_action`), `start_ts`/`end_ts`, `size` (default 50, **at most 500** — a larger value is rejected with 400), `continuation` (pagination token). To read more than 500, pass each response's `meta.continuation` back until it is empty — `--page-all` does not drive this endpoint (SKILL.md § Pagination); for a bulk export of message activity, point the user at a data warehouse sync rather than paging thousands of rows.\n\n### Message details\n\n```\nGET /v1/environments/:environment_id/delivery_details/:id\n```\n\nReturns the rendered content and profile activity for a specific message. Use `GET .../delivery_details/:id/edit` for the editable version of draft messages.\n\n### Message actions\n\nManage unsent or failed messages. The path suffix determines the action:\n\n```\nPOST /v1/environments/:environment_id/deliveries/send          # Send drafts\nPOST /v1/environments/:environment_id/deliveries/retry         # Retry failed/undeliverable\nPOST /v1/environments/:environment_id/deliveries/:id/send      # Send a single draft\nPOST /v1/environments/:environment_id/deliveries/:id/retry     # Retry a single delivery\nPOST /v1/environments/:environment_id/deliveries/redeliver     # Resend selected deliveries\nPOST /v1/environments/:environment_id/deliveries/:id/redeliver # Resend a single delivery\n```\n\nSend and retry accept the same selectors as delete — exactly one of `delivery_ids`, `campaign_id`, `action_id`, `newsletter_id`, `template_id`, or `transactional_message_id`. The `:id` URL variant is a shortcut for a single delivery. For send and retry, `type` is required and nonempty with a wide selector; it defaults to `\"all\"` with `delivery_ids` when omitted, null, or empty. Selected ID lists are limited to 5000 parsable IDs. Deprecated `campaign_ids` accepts exactly one numeric string or `newsletter_\u003cid\u003e` and is ignored when `campaign_id` is nonzero. Send and retry accept nullable `override_message_limit` (omitted or null defaults to false); send ignores it when using nonempty overrides.\n\n- **send** — sends draft messages. Supports `overrides` (map of field overrides) for a single draft — this is the \"edit then send\" flow from the draft detail view\n- **retry** — re-attempts failed or undeliverable messages. Messages older than 6 months can't be retried. Optionally filter by `metric` (`\"failed\"` or `\"undeliverable\"`). Omitted, null, or empty selects both for wide selectors and no metric filter for specific IDs. Nonempty `overrides` are rejected\n- **redeliver** — creates a brand new message by cloning an already-sent one (drafts are rejected). Requires `delivery_ids` (not bulk filters). Primary use case: re-sending a bounced transactional message (e.g., an order confirmation that bounced). Preserves the original recipient address from the first attempt. Fails if the profile is suppressed or the original message used `hide_body`. Nonempty `overrides` are rejected. Nullable `tracked` supplies no tracking override when omitted or null. Nullable `override_message_limit` preserves the original message-limit setting when omitted or null, but a frequency-cap bypass must be explicitly selected again.\n\n### Delete draft messages\n\n```\nDELETE /v1/environments/:environment_id/deliveries\nDELETE /v1/environments/:environment_id/deliveries/:id\n```\n\nDeletes unsent messages (drafts and failed). Deleted messages are not recoverable — always confirm with the user before calling, especially for drafts which may contain editable content.\n\nSpecify exactly **one** filter in the request body: `delivery_ids` (array), `campaign_id`, `action_id`, `newsletter_id`, `template_id`, or `transactional_message_id`. Set `type` to scope the channel. It is **required** whenever you are not passing `delivery_ids` — a wide selector without it is rejected with `type is required when not targeting specific delivery_ids`, precisely so a single call cannot blast every channel. With `delivery_ids` it defaults to `\"all\"`, never to `\"email\"`. The single-ID route (`/deliveries/:id`) is a shortcut for deleting one draft by URL param. Deprecated `campaign_ids` accepts exactly one numeric string (not `newsletter_\u003cid\u003e`) and is ignored when `campaign_id` is nonzero. Metric/state, time range, and recipient filters are not supported; an unscoped delete-all request is rejected.\n\n## Broadcast Metrics\n\n### Delivery Metrics\n\n```\nGET /v1/environments/:environment_id/in_app_broadcasts/:id/metrics\n```\n\nReturns delivery and engagement metrics (sent, delivered, opened, clicked, etc.) for an in-app broadcast.\n\n### Response Metrics\n\n```\nGET /v1/environments/:environment_id/in_app_broadcasts/:id/tracked_response_metrics\n```\n\nReturns tracked responses (e.g., button clicks) with time series and totals per response.\n\n### Query Parameters\n\nFor both endpoints, always include:\n- `version=2` (required)\n- `tz` (required) — timezone name (e.g., \"America/New_York\")\n- `res` — resolution (e.g., \"daily\", \"monthly\")\n- `start`, `end` — unix timestamps (optional)\n\n## Common Pitfalls\n\n1. **One-time send channel is `\"unset\"` at creation**: You must explicitly set the channel via update before sending. Attempting to send with an unset channel will fail.\n\n2. **`send_percentage`**: Must be between 1 and 100. This controls what percentage of the target segment receives the one-time send — useful for A/B testing sends.\n\n3. **Messages use continuation-based pagination**, not page numbers. Pass the `continuation` token from the response to get the next page.\n","messaging_troubleshooting.md":"# Messaging Delivery Troubleshooting — \"why didn't my SMS / WhatsApp / push / email send?\"\n\n\u003e This is the diagnostic decision tree for **outbound messaging delivery** failures on SMS (Twilio), WhatsApp, push, and email — the message was authored and the person was targeted, but it never sent or never arrived. For *authoring* SMS copy see `sms.md`; for *sending* WhatsApp templates see `whatsapp.md`. For \"the person never entered the automation / skipped the action\" (an upstream targeting problem, not a delivery problem) see `automation_debugging.md`. For in-app, see `in_app_troubleshooting.md`.\n\u003e\n\u003e Parts of a drag-and-drop (Bee) email missing from what arrived is a content question, not delivery: rows wrapped in `{% if %}` are row display conditions. The user views, edits or removes one in the editor (select the row, then row properties, then Display condition, whose Before/After fields hold the tags), and the API cannot edit Bee content, so give them those steps rather than offering to change it. More in `templates.md` § Bee row display conditions.\n\nMost \"it didn't send\" reports are one of a handful of causes. Check them **in order** — they are sorted most-common first, and the cheap one-call checks come before the deeper ones. Confirm with the message record before theorizing: a single message's status and failure reason usually names the cause directly (see § Read the Message Status First).\n\n## Read the Message Status First\n\nBefore walking causes, pull the actual message. A message is one instance to one person, and its status + failure reason almost always points at the cause.\n\n```bash\n# List recent deliveries for this person. Don't filter by\n# `metric` while triaging — the status itself is the clue, and the most common\n# \"didn't send\" case (opt-out) records as `dropped`, not `failed`. Don't filter\n# by `type` either: SMS is two distinct channel types, `twilio` (the classic SMS\n# action) and `sms` (the message-channel action), so `type: \"sms\"` silently\n# hides legacy Twilio sends and reads as \"never attempted\".\ncio api /v1/environments/$ENV_ID/deliveries \\\n  --params \"{\\\"customer_id\\\": \\\"$CUSTOMER_ID\\\"}\" \\\n  --jq '.deliveries[] | {id, state, recipient, created}'\n\n# Then read one delivery's rendered content + activity log (has the failure reason).\ncio api /v1/environments/$ENV_ID/delivery_details/$DELIVERY_ID\n```\n\n- `GET .../deliveries` — list deliveries. Key params: `customer_id`, `recipient`, `campaign_id`, `newsletter_id`, `metric` (status — note the *filter* is `metric` but each row reports its status in `state`), `type` (`sms`/`push`/etc. — but don't filter on it while triaging, per the twilio/sms split noted above), `start_ts`/`end_ts`, `continuation` (pagination). See `messaging.md § Message activity`. **When triaging, don't narrow `metric` to `failed`** — opt-out suppression appears as `dropped` (the first cause below), provider errors as `failed`, and a never-attempted send may have no row at all. Pull all recent deliveries and read the `state` on each.\n- `GET .../delivery_details/:id` — the single delivery's rendered body **and profile activity**. The profile activity carries the failure **reason** string (for SMS this is the provider/Twilio reason; for a webhook it includes the HTTP status code).\n- The status you see on a failed SMS maps to a Twilio reason. The common ones and what they mean:\n\n  | What you see | Meaning | Goes to which cause below |\n  |---|---|---|\n  | `dropped`, reason \"opted out\" | recipient is on the opt-out list (Twilio 21610 / internal opt-out) | § Opt-Out Suppression |\n  | `failed`, \"invalid To number\" | recipient phone isn't valid/E.164 (Twilio 21211) | § Invalid / Non-E.164 Numbers |\n  | `failed`, \"authenticate\" / no credentials | Twilio credentials missing or wrong (Twilio 20003) | § Channel Credentials |\n  | `failed`, \"can't route to region\" / \"not a mobile number\" / \"landline\" | number can't receive SMS (Twilio 21214 / 21408 / 21614 / 30006) | § Invalid / Non-E.164 Numbers |\n  | `failed`, \"unreachable\" / transient | destination temporarily unreachable (Twilio 30003 / 30008) — these **retry** | usually carrier/transient — see § Carrier Filtering |\n  | `undeliverable` after sending | accepted by Twilio, rejected downstream by the carrier | § Carrier Filtering \u0026 Registration |\n\n- If the failure reason is blank or the message was never attempted, check the content first — a broken template (§10) also produces zero deliveries, and is cheaper to rule out than an upstream targeting problem: `GET /campaigns/:id` → root `.actions[]` (see `automations.md § Reading an Automation's Actions`) → `template_id` → `GET /templates/:template_id`. Only then switch to `automation_debugging.md § Why People Skip Actions`. Unsubscribed/opted-out people have their SMS/push actions **skipped silently** unless the action sets `send_to_unsubscribed`.\n\nFor workspace-wide patterns (many failures at once, a sudden drop), pull channel-level metrics: `GET .../metrics/all_deliveries` with `--params '{\"version\": \"2\", \"tz\": \"UTC\", \"res\": \"hourly\", \"start\": \u003cepoch\u003e, \"end\": \u003cepoch\u003e}'` (delivery metrics by channel type — see `analytics.md`; **both** bounds are required once you send `start`, or the call 400s), and per-channel automation totals via `.campaign_metrics.twilio` **and** `.campaign_metrics.sms` (same split as above — read both, or you under-count SMS) / `.push` / `.whatsapp` on `GET .../campaigns/:id/metrics` (see `automation_metrics.md` — note `.metric` is **email-only**; non-email channels live under `.campaign_metrics.\u003cchannel\u003e`).\n\n## 1. Opt-Out Suppression\n\n**Symptom:** SMS shows `dropped` (not `failed`); the person stopped receiving messages after a certain date; no provider error.\n\n**Likely cause:** the recipient texted an opt-out keyword (e.g. `STOP`) and is on the workspace opt-out list, or the person is unsubscribed at the profile/topic level. Customer.io recognizes inbound keywords automatically and collapses every variant into one intent — `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`, `OPTOUT`/`OPT-OUT`, `REVOKE` all opt the number out; `START`, `UNSTOP`, `OPTIN` opt back in (see `sms.md § Opt-Out, Keywords \u0026 Compliance` for the full table). An opted-out number is suppressed from future sends with no message delivered.\n\n**How to confirm:**\n- The delivery's reason in `delivery_details` will indicate the recipient opted out / is suppressed.\n- Check the profile activity and subscription/opt-out state on their profile (`GET .../customers/:id`, see `customers.md`).\n- A `dropped` metric (as opposed to `failed`) is the signal that the message was intentionally suppressed, not that the provider rejected it.\n\n**Fix:**\n- This is working as intended — do **not** route around it. A person who opted out must opt back in themselves (texting `START`/`UNSTOP`/`OPTIN`); you cannot re-subscribe them on their behalf for compliance reasons.\n- If they opted out by mistake and want back in, tell them to text `START`.\n- If the person is unsubscribed at the topic/profile level rather than via SMS keyword, that's a subscription-center concern — see `subscriptions.md`. Sending to them anyway requires the action's `send_to_unsubscribed` (see `automation_debugging.md`), which should only be used for genuinely transactional messages.\n\n## 2. Invalid / Non-E.164 Numbers\n\n**Symptom:** SMS shows `failed` with \"invalid To number\", \"not a valid mobile number\", \"landline\", or \"can't route to region\"; sends to one person fail while others on the same automation succeed.\n\n**Likely cause:** the recipient phone value isn't a usable mobile number. Customer.io normalizes profile phone values to E.164 (`+15551234567`) before sending and drops values it can't normalize — e.g. a missing country code or letters in the number. Customer.io does **not** check line type, so a number that normalizes cleanly but can't actually receive SMS — a landline, another non-mobile line, or an unroutable region — is still sent and rejected by Twilio downstream. Recipients must be E.164: a bare `5551234567` or a value without `+\u003ccountry code\u003e` may fail.\n\n**How to confirm:**\n- Check the `phone`/recipient attribute on the failing profile (`GET .../customers/:id`). Look for missing `+`, missing country code, extensions, or obviously non-mobile numbers.\n- The Twilio reason on the delivery distinguishes *malformed* (21211 invalid To) from *valid-but-can't-receive-SMS* (21408 region, 21614 not mobile, 30006 landline). Malformed → fix the data; landline/non-mobile → the number genuinely can't get SMS.\n\n**Fix:**\n- Correct the stored phone number to full E.164 (`+` + country code + number). The cleanest fix is at the source that writes the attribute (the integration / import), not per-delivery.\n- If many numbers are malformed, the import or upstream source is the culprit — normalize on the way in. Consider an entry filter or segment condition that excludes profiles whose `phone` isn't well-formed so they don't pile up as failed deliveries.\n- Landline / non-mobile numbers can't receive SMS at all; there's no fix beyond getting a mobile number for the person.\n\n## 3. Channel Credentials (Missing, Wrong, or Just Switched Over)\n\n**Symptom:** **all** sends on a channel fail at once (not just one person); reason is an auth/\"no credentials\" error; failures started right after someone touched channel settings.\n\n**Likely cause:** the workspace's provider credentials (Twilio SID/token for SMS) are missing, invalid, or were just switched and the sender identities weren't re-mapped. Channel credentials must be configured before any send on that channel works. A recent credential switchover is a classic cause of a sudden workspace-wide failure: the new credentials are in place but the old \"From\" sender identities weren't mapped to the new account, so sends have no valid sender.\n\n**How to confirm:**\n- `GET /v1/environments/$ENV_ID/credentials/{action_type}` — confirm credentials exist for the channel (see `channels.md § Channel Credentials`). For SMS this is the legacy direct-Twilio channel (`action_type` `twilio`); `action_type` `sms` is rejected there, so read a custom-SMS workspace's connections with `GET /v1/environments/$ENV_ID/sms_providers` instead (`channels.md § Two SMS channels` tells the two apart).\n- An auth failure (Twilio 20003) on the delivery means the credentials are present but rejected by the provider — wrong SID/token, or the Twilio account was suspended.\n- Ask whether anyone recently changed SMS settings or rotated the Twilio account.\n\n**Fix:**\nNew or replacement credentials are secrets, so the user enters them on the channel's page in Workspace Settings, not in the chat. Tell them what to change and why; the notes below are what to check.\n- Missing → the user sets them (the API is `PUT /v1/environments/$ENV_ID/credentials/{action_type}`, see `channels.md`). Custom SMS is the exception: the credentials routes reject `action_type` `sms` entirely, and the connection is created with `POST /v1/environments/$ENV_ID/sms_providers`.\n- Switching providers/accounts → use the **prepare → replace** flow, not a blind overwrite:\n  - `PUT .../credentials/{action_type}/prepare_switch` — validate the new credentials and preview the sender identities they expose **without** saving.\n  - `PUT .../credentials/{action_type}/replace` — switch to the new credentials **and map old sender identities to new ones**. Skipping the identity mapping is what leaves sends with no valid \"From.\"\n  - To change only callback URLs without replacing creds: `PUT .../credentials/{action_type}/config`. Custom-SMS channel settings live elsewhere: `PUT .../message_channels/sms/settings` (see `channels.md § SMS Settings`).\n- After any credential change, re-sync sender identities (`POST .../twilio/sync`) and send a test before resuming live traffic — see `test_sends.md`.\n\n## 4. SMS Sender Registration Still Pending (A2P 10DLC / Toll-Free)\n\n**Symptom:** US SMS sends are accepted but never arrive, or arrive only intermittently; failures look like silent carrier drops rather than a clean rejection; the sending number is new.\n\n**Likely cause:** US carriers require the sending number to be **registered** before they'll reliably deliver application-to-person SMS — A2P 10DLC (brand + campaign registration) for 10-digit long codes, or toll-free verification for toll-free numbers. While registration is **pending** (or was never completed), carriers filter or block the traffic. This is a carrier/registry requirement enforced on the provider side, not a Customer.io send-time block — so the message can be \"sent\" from Customer.io's perspective and still be filtered by the carrier.\n\n**How to confirm:**\n- Confirm the sending number's type and registration status with the Twilio account / number owner. 10DLC registers at least one **brand** and one **campaign**; toll-free numbers go through a separate verification with `pending` / `approved` / `rejected` / `expired` states.\n- A pattern of accepted-then-undeliverable US sends on a new number, with no per-recipient data problem, points here.\n\n**Fix:**\n- Complete A2P 10DLC brand + campaign registration (or toll-free verification) for the sending number before sending volume. Registration is handled with the number provider, not in this API.\n- Respect the throughput caps for the sender type — 10DLC, toll-free, and short codes each have strict per-second/per-day limits; exceeding them causes queueing or drops independent of registration.\n- This is a setup/Workspace-Settings concern, not an authoring one — see the SMS deliverability guidance at docs.customer.io.\n\n## 5. Carrier Filtering / Spam Blocking\n\n**Symptom:** messages are accepted by the provider (status leaves Customer.io as sent) but never arrive, or arrive for some recipients and not others; reasons are vague (\"unreachable\", transient) or there's no failure record at all because the carrier dropped it silently downstream.\n\n**Likely cause:** the destination carrier filtered the message as spam. Common triggers: unregistered or shared sending numbers (see § 4), public URL shorteners (`bit.ly` and friends are heavily filtered — use the workspace's own URL shortening instead, see `sms.md § Links`), spammy copy, no opt-out affordance, or an anonymous first-contact message that doesn't identify the sender. Some transient provider errors (Twilio 30003 \"unreachable destination\", 30008 \"unknown error\") are **retried** automatically; a clean carrier *block* is not retried.\n\n**How to confirm:**\n- Look at the spread: if failures cluster on one carrier or one number, suspect filtering on that path. If a single number suddenly degrades, its reputation or registration likely changed.\n- Check the SMS copy against `sms.md § Opt-Out, Keywords \u0026 Compliance` and `§ Links` — missing opt-out, public shortener links, or an anonymous first message all increase filtering.\n- A `failed` with a transient reason that keeps retrying vs. a hard block tells you whether it's worth retrying (`POST .../deliveries/retry`) or whether the path itself is the problem.\n\n**Fix:**\n- Complete sender registration (§ 4) and stay within throughput caps.\n- Use `https://` links and the workspace's own URL shortening, never a public shortener (`sms.md`).\n- Lead with the brand name, include `Reply STOP to opt out` on marketing messages, and keep copy specific and single-purpose.\n- For one-off transient failures, retry: `POST /v1/environments/$ENV_ID/deliveries/retry` (or `/deliveries/:id/retry`) — deliveries older than 6 months can't be retried (see `messaging.md`).\n\n## 6. MMS Image Dropped or Not Delivered\n\n**Symptom:** a message with an image attached fails or arrives without the image for some recipients — often, but not only, those outside the US and Canada.\n\n**Likely cause:** **MMS isn't available on every destination carrier.** Support varies by country and carrier; where the recipient's carrier doesn't support MMS, the send fails or the image is stripped (this is most common outside the US and Canada, but it's a carrier-level limitation, not a hard country rule). (Image size also matters: keep images under ~1.5MB, and under ~600KB on short-code/toll-free senders.)\n\n**How to confirm:**\n- Check whether the failures cluster by country/carrier — if recipients on MMS-capable carriers (e.g. most US/CA numbers) got the image fine while others didn't, this is it.\n\n**Fix:**\n- Don't rely on the image to carry essential information. Put everything the recipient needs in the **text** so recipients whose carrier can't receive MMS still get the message as plain SMS. See `sms.md § Media (MMS)`.\n- If the audience is global or mixed-carrier, send a plain SMS rather than an MMS, or branch so only recipients likely to support MMS (e.g. US/CA) get the MMS variant.\n\n## 7. WhatsApp: Empty Template Body\n\n**Symptom:** every send on one message fails, permanently rather than retrying. `delivery_details` answers 200 carrying \"The template for this message is not configured or has an issue.\", and the send log reads `whatsapp template body is empty`. The template looks fine otherwise — it has a name, a type, often a populated `body_json`.\n\n**Likely cause:** the content went to `body_json` and `body` was left empty. Delivery reads `body` alone. `PUT /templates` now rejects that write, so a template in this state predates the check. Campaign start checks nothing, so the first symptom is a failed delivery to a real person.\n\n**How to confirm:**\n```\ncio api /v1/environments/$ENV_ID/templates/:template_id --jq '{body: .template.body, editor: .template.editor}'\n```\nAn empty `body` confirms it. A populated `body_json` is not content — on a WhatsApp template it can hold an old Design Studio snapshot.\n\n**Fix:** write the content to `body` (`whatsapp.md`), or for a Design Studio node link and publish it, which writes `body` for you. Re-read `body` before saying it's fixed.\n\n## 8. WhatsApp: Unapproved Template or Outside the 24-Hour Window\n\n**Symptom:** a WhatsApp send fails or is rejected; the template is new; or the message is a free-form (non-template) reply.\n\n**Likely cause:** WhatsApp has two platform rules, both enforced by Meta/Twilio (not by a Customer.io send-time check):\n- **Templates must be approved before use.** Outside the 24-hour window every send uses a pre-approved template (see `whatsapp.md`). Meta reviews a submitted template (usually minutes, up to ~24h) and sets it to approved/active or rejected. Sending with a template that isn't approved fails.\n- **Free-form body shape.** A free-form reply is sent from a `{\"body\":\"…\"}` body; a body in any other shape, such as Meta's `{\"text\":{\"body\":…}}`, reaches Meta as empty text and fails with `The parameter text.body is required`.\n- **The 24-hour customer-service window.** Outside of an open 24-hour window (started by the customer messaging the business), WhatsApp only permits **template** messages — free-form session messages aren't allowed. For template sends this mostly surfaces as: a template in the wrong **category** (`MARKETING` vs `UTILITY`/authentication) being restricted, or a send failing for a recipient with whom no session is open and a category that requires one.\n\n**How to confirm:**\n- Detect the integration and check template status: `GET /v1/environments/:eid/whatsapp/check`, then search templates (`GET .../whatsapp/templates?search=...` for Meta direct, `GET .../twilio_content_templates?search=...` for Twilio legacy) and confirm the template is approved/active and every placeholder (`{{1}}`, `{{2}}`, …) is mapped to a value. Empty parameters cause send failures. See `whatsapp.md`.\n- Check the sender phone number's `status` and `quality_rating` (`GET .../whatsapp/phone_number_details`) — a `status` other than `CONNECTED` means the number isn't usable, and a `RED` rating throttles or blocks sends. **Those are the only two fields on that response that gate a send.** In particular `code_verification_status: EXPIRED` is *not* a cause: it's a Meta passthrough that a healthy sending number reports routinely next to `status: CONNECTED`, nothing in the send path reads it, and it only matters when the number is already disconnected (`whatsapp.md § Sender Phone Numbers`). Don't open with it, and don't route the user to re-verification or \"Sync from Meta\" on the strength of it — on a connected number that path fixes nothing and the real cause is usually §7.\n\n**Fix:**\n- Wait for (or resubmit) template approval in Meta Business Manager / Twilio before sending; the template only appears in Customer.io once approved.\n- Pick the correct template **category** for the use case. Map every parameter to a value before sending.\n- For setup, point the user to Workspace Settings → WhatsApp (see `whatsapp.md`). Template authoring and resubmission happen in Design Studio / the provider, not via this API.\n\n## 9. Push: Invalid Device Tokens, Platform Config, or Revoked Permissions\n\n**Symptom:** a push send fails or never arrives, but SMS/email to the same person works. Pull the person's push deliveries (`type:\"push\"`) and read the failure reason first, same as the triage below. Push failures cluster into three causes, most common first:\n\n- **Invalid / expired device tokens (most common).** The app was uninstalled, the user switched devices, or the token wasn't refreshed. These fail per-recipient and show up as a gradual decline as tokens age — that decay is normal and self-heals when the app next opens and re-registers. Nothing to fix workspace-wide.\n- **Platform configuration.** APNs certificate/key expired (iOS), FCM credentials wrong (Android), or an app-identifier mismatch. These fail *workspace-wide* and start *suddenly* — check the push credentials exist and are current (§3).\n- **Revoked permissions.** The user disabled notifications at the OS level. Per-recipient, not fixable from Customer.io.\n\n**Read the time-shape:** a *gradual* drop = token decay (normal); a *sudden* workspace-wide drop = a credential/config or permission change; *never delivered at all* = a setup problem (push credentials never configured, or the SDK never registered a device token). Send a test push to a known-good device to isolate setup from data.\n\n## 10. Email: Blank Subject, Body, or Recipient\n\n**Symptom:** an email campaign shows no deliveries, or every send fails immediately (`subject can't be blank`, `from address can't be blank`); the template otherwise looks fine and the campaign is running.\n\n**Likely cause:** `render_email` requires `subject`, `body`, and a resolvable from-address, or it fails at render time on every attempt. No delivery needs to exist first to confirm this — the template's fields are readable directly.\n\n**How to confirm:**\n```\ncio api /v1/environments/$ENV_ID/campaigns/:id --jq '.actions[] | {id, type, template_id}'\ncio api /v1/environments/$ENV_ID/templates/:template_id --jq '{subject: .template.subject, body: .template.body, from_identity_id: .template.from_identity_id, recipient: .template.recipient}'\n```\nAn empty `subject`, `body`, or `recipient`, or a missing `from_identity_id`, confirms it — check this before concluding \"no deliveries\" means an upstream targeting problem.\n\n**Fix:** set the missing field on the template (`PUT /templates/:template_id`) — content and sender live there, not on the action. Re-read it before saying it's fixed.\n\n## Quick Triage\n\n```bash\n# 1. Pull recent deliveries for this person — don't filter by\n#    metric; the status is the clue (dropped = opt-out, failed = provider error).\ncio api /v1/environments/$ENV_ID/deliveries \\\n  --params \"{\\\"customer_id\\\": \\\"$CUSTOMER_ID\\\"}\" \\\n  --jq '.deliveries[] | {id, state, recipient, created}'\n\n# 2. Read one delivery's activity log for the exact failure reason.\ncio api /v1/environments/$ENV_ID/delivery_details/$DELIVERY_ID\n\n# 3. Channel-wide? Check credentials exist for the channel.\ncio api /v1/environments/$ENV_ID/credentials/{action_type}\n\n# 4. Workspace-wide pattern? Channel-level delivery metrics.\nEND=$(date -u +%s); START=$((END - 86400))\ncio api /v1/environments/$ENV_ID/metrics/all_deliveries \\\n  --params \"{\\\"version\\\": \\\"2\\\", \\\"tz\\\": \\\"UTC\\\", \\\"res\\\": \\\"hourly\\\", \\\"start\\\": $START, \\\"end\\\": $END}\"\n```\n\nRead the reason first (1–2). `dropped` → opt-out (§1). `invalid To` → bad number (§3 of the reason table → § Invalid Numbers). `authenticate`/no creds → credentials (§3). Accepted-but-never-arrived on a new US number → registration/carrier (§4–5). Image-only failures (often outside US/CA) → MMS carrier support (§6). A WhatsApp send that fails on every attempt, whose `delivery_details` carries \"not configured or has an issue\" → empty template body (§7). WhatsApp template/category errors → §8. If the delivery was never attempted, check the email template first (§10) before going to `automation_debugging.md` for upstream targeting.\n","newsletter_ab_tests.md":"# One-time send A/B Tests\n\nOne-time send A/B tests create separate test variations of the one-time send, each with its own template. Variations are managed through dedicated test endpoints on the one-time send itself. (Automation A/B tests work differently — converting a message action into a split test — see `ab_tests.md`.)\n\n## Creating a test\n\n### Step 1: Create variations\n\n```\nPOST /v1/environments/:environment_id/newsletters/:id/tests\n```\n\nTakes **no request body**. Each call creates **one** new test variation alongside the one-time send's default (control) template. To set up an A+B test, call once. For multi-variant tests (up to 8 total variations), call up to 7 times.\n\nThe response returns the one-time send's full templates list. The default (control) template has `test_group_id: 0`; test variation templates have a non-zero `test_group_id` (1, 2, 3, ...).\n\n### Step 2: Get variation template IDs\n\n```\nGET /v1/environments/:environment_id/newsletters/:id/templates\n```\n\nReturns all templates for the one-time send. Match templates to variations by their `test_group_id` — that's how you know which template ID belongs to which variation.\n\n### Step 3: Set variation content\n\nUpdate each variation's subject line, preview text, body, etc., by updating its template:\n\n```\nPUT /v1/environments/:environment_id/templates/:template_id\n```\n\n```json\n{\n  \"template\": {\n    \"subject\": \"Variant A subject line\",\n    \"preheader_text\": \"Variant A preview text\",\n    \"body\": \"\u003chtml\u003e...\u003c/html\u003e\"\n  }\n}\n```\n\n**Field naming:** what the Customer.io UI calls \"preview text\" is the `preheader_text` field on the API — always use `preheader_text` in the request body. Full email template field reference is in `templates.md`.\n\nRepeat for each variation. For Design Studio emails, update the DS node and re-publish instead.\n\n### Step 4: Set sample percentage\n\nThe one-time send's `send_percentage` controls what percentage of the audience receives the A/B test (the rest can receive the winning variation later via `end_test`). Use the one-time send update endpoint with `update_type: \"recipients\"` and `send_percentage` between 1 and 100.\n\nA `recipients` update replaces more than the percentage, so don't send it on its own. `GET .../newsletters/:id` first, then send back its `send_to_unsubscribed`, `deduped` and `use_message_limits` (required), its `subscription_topic_id`, and its rate fields (`rate_limit_email_rate`, `rate_limit_time_period`, `rate_limit_spread`, `daily_rate_limits`) with the new `send_percentage`. Leaving the topic out clears it, which widens the audience to people who opted out of that topic, and leaving the rate fields out removes the send's rate limits. See `messaging.md` § Recipients.\n\n## Monitoring results\n\n### Step 1: Get variation metrics\n\n```\nGET /v1/environments/:environment_id/newsletters/:id/template_metrics\n```\n\nReturns per-template/variation metrics for the A/B test — delivery and engagement stats broken down by variation.\n\nFor **Chance to Beat Original (CTBO)** — how Customer.io reports statistical significance, and the 5%/95% thresholds for reading it — see `ab_tests.md` § Chance to Beat Original (CTBO). It applies the same way here, and it is likewise not returned by this endpoint.\n\n### Step 2: Pick a winner and send to remaining\n\n`update_type: end_test` ends the A/B test and sends the winning variation to the remainder of the audience.\n\n**Before calling this endpoint**, ask the user whether they want the winning variation sent **now** or **on a schedule**. If now, omit `scheduled_at`. If on a schedule, get the desired send time from them and include it as `scheduled_at` (unix timestamp) **together with `timezone`** — a `scheduled_at` sent without a timezone is rejected with a 422 (`timezone cannot be nil`), and the time must be in the future (`scheduled_at cannot be set in past`). Both rules exist because a non-zero `scheduled_at` that the server cannot honor as a schedule would otherwise mean \"send to the whole remaining audience right now\".\n\n```\nPUT /v1/environments/:environment_id/newsletters/:id\n```\n\n```json\n{\n  \"newsletter\": {\n    \"update_type\": \"end_test\",\n    \"winner_template_id\": \"42\",\n    \"scheduled_at\": 1747843200,\n    \"timezone\": \"America/New_York\",\n    \"send_percentage\": 100\n  }\n}\n```\n\n`end_test` requires only `winner_template_id`; `scheduled_at` and `timezone` may be omitted or null to send immediately. A non-zero `scheduled_at` requires a non-empty `timezone`. `send_percentage` is ignored by this update.\n\n**Field types:** `winner_template_id` is a **string-encoded integer** (e.g. `\"42\"`, not `42`). `scheduled_at` is a plain integer (unix timestamp), and `timezone` an IANA name (e.g. `\"America/New_York\"`).\n\n## Managing variations\n\n| Endpoint | Use for |\n|----------|---------|\n| `DELETE .../newsletters/:id/test` | Delete a single test variation |\n| `DELETE .../newsletters/:id/tests/:test_group_id` | Delete a specific test group |\n| `DELETE .../newsletters/:id/tests` | Delete all test variations |\n","object_changes.md":"---\nminimum_plan: essentials\n---\n\n# Object and Relationship Changes Analytics\n\nAnswer questions about changes to custom objects and relationships over a period using the `sv_object_changes` semantic view in `ANALYTICS.CIO_AGENT`: objects created or deleted, people added to or removed from objects, which objects gained the most members, and which object attributes change most.\n\nEach workspace names its own object types and attributes. Filter `object_type_name` by the name the user gives. The examples in this file use a workspace that stores companies as objects.\n\n**This is a CHANGE LOG, never a roster.** Every metric counts change events over a period. \"How many objects exist\" and \"who is in \u003cobject\u003e right now\" go to `object_state.md`.\n\nRouting: current objects, memberships, and values → `object_state.md`. Profile changes → `profile_changes.md`. Segment entries and exits → `deliverability.md` (`segment_events`) — relationship changes are not segment membership. FAILED object or relationship updates → `pipeline_health.md`.\n\n## The semantic view at a glance\n\n| Logical table | Base table | Grain (one row per…) | Use for | Key caveat |\n|---|---|---|---|---|\n| `object_changes` | `fct_object_changes` | object create or delete, or one changed attribute | objects created/deleted, object attribute churn | attribute changes dominate volume — tight date range, filter `change_type` |\n| `relationship_changes` | `fct_object_relationships` | relationship add or remove, or one changed relationship attribute | people added to or removed from objects, role changes | already deduplicated to one row per change |\n| `object_types` | `dim_object_types` | object **type** | the name a customer sees (Companies) | joined by both facts |\n\n**Joins.** Both facts join `object_types`, and never each other: one fact per `SEMANTIC_VIEW` call (`010959` \"entities are not related\" otherwise). When the answer needs both, give each fact its own call in a CTE and join them in the outer SQL.\n\n**PRODUCT SURFACE:** the workspace Objects pages, each object's member list, and a person's Relationships panel. Changes arrive through the objects and relationships APIs, Data Pipelines object calls, imports, and CRM syncs (Salesforce, HubSpot).\n\n## Choosing the right query\n\n1. **Creates and deletes** → `objects_created` / `objects_deleted`, filtered by `object_type_name` when the user names a type (Ex 1).\n2. **\"How many people were added to \u003cobject\u003e in \u003cperiod\u003e\"** → `people_with_relationship_changes` with `relationship_changes.change_type = 'added'` — distinct people (Ex 3). `relationships_added` counts add events, so a person removed and re-added counts twice.\n3. **Filter `change_type`** (`created`/`deleted`/`attribute_changed` on objects, `added`/`removed`/`attribute_changed` on relationships) whenever the question is about one kind of change. Attribute changes are tens of millions of rows a day across all workspaces.\n4. **Identify objects by `object_id`.** `cio_object_id` is populated on creates, deletes, adds, and removes only, and NULL on `attribute_changed` rows.\n5. **Attribute churn** → `object_attribute_changes` / `relationship_attribute_changes` count changes, not objects; `objects_changed` counts distinct objects. Before and after values are not exposed in this view.\n6. **Dates:** `occurred_date` is a US Pacific date. History starts 2026-03-05, so earlier periods return nothing: say the data starts then, not that nothing happened.\n7. Trends: group metrics by `occurred_date` and compare periods in SQL — there are no delta metrics.\n\n## Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `object_changes.objects_created` | Object created events in the period |\n| `object_changes.objects_deleted` | Object deleted events in the period |\n| `object_changes.object_attribute_changes` | Object attribute change events — a count of changes, not objects |\n| `object_changes.objects_changed` | Distinct objects with any change event — activity, not how many exist |\n| `object_changes.object_attributes_changed` | Distinct object attribute names changed |\n| `relationship_changes.relationships_added` | Relationship added events in the period |\n| `relationship_changes.relationships_removed` | Relationship removed events in the period |\n| `relationship_changes.relationship_attribute_changes` | Relationship attribute change events — a count of changes, not relationships |\n| `relationship_changes.people_with_relationship_changes` | Distinct people with any relationship change — THE \"how many people were added\" metric with `change_type = 'added'` |\n| `relationship_changes.objects_with_relationship_changes` | Distinct objects whose relationships changed |\n| `relationship_changes.relationship_attributes_changed` | Distinct relationship attribute names changed |\n\n## Dimensions\n\n| DIMENSIONS reference | Output column (unaliased) | Description |\n|---|---|---|\n| `object_changes.workspace_id` | `workspace_id` | **Required filter on every query** |\n| `object_changes.occurred_date` | `occurred_date` | Date of the change (US Pacific). Always constrain to a range. |\n| `object_changes.change_type` | `change_type` | `created`, `deleted`, `attribute_changed`. |\n| `object_changes.object_type_id` | `object_type_id` | Numeric object type id within the workspace. |\n| `object_changes.object_id` | `object_id` | Customer-supplied object id. Group by it to list which objects. |\n| `object_changes.cio_object_id` | `cio_object_id` | Internal object key. NULL on `attribute_changed` rows. |\n| `object_changes.object_attribute` | `object_attribute` | Object attribute that changed (`attribute_changed` rows only). |\n| `relationship_changes.workspace_id` | `workspace_id` | Required filter on every relationship query. |\n| `relationship_changes.occurred_date` | `occurred_date` | Date of the change (US Pacific). Always constrain to a range. |\n| `relationship_changes.change_type` | `change_type` | `added`, `removed`, `attribute_changed`. |\n| `relationship_changes.object_type_id` | `object_type_id` | Type of the object side of the edge. |\n| `relationship_changes.object_id` | `object_id` | Customer-supplied id of the object side. Filter it for \"who was added to \u003cobject\u003e\". |\n| `relationship_changes.cio_object_id` | `cio_object_id` | Internal object key of the object side. NULL on `attribute_changed` rows. |\n| `relationship_changes.relationship_attribute` | `relationship_attribute` | Attribute on the person-object link that changed (`attribute_changed` rows only). |\n| `relationship_changes.person_id` | `person_id` | Person side of the edge — an internal profile id. |\n| `object_types.object_type_name` | `object_type_name` | Plural display name of the type (e.g. Companies). |\n| `object_types.object_type_singular_name` | `object_type_singular_name` | Singular display name (e.g. Company). |\n| `object_types.object_type_is_deleted` | `object_type_is_deleted` | True when the type itself has been deleted. |\n\nOutput columns are always named after the dimension, so outer SELECT/WHERE/GROUP BY clauses say `person_id` — never `customer_internal_id`, which is the underlying fact-table column and invisible to the outer query (`invalid identifier` on compile).\n\n## Submitting queries\n\nSame endpoint and rules as `deliverability.md` § Submitting queries, including its five-point pre-flight — heredoc + `--arg`, never hand-escape, aggregate in SQL; members only from this file's tables, never guessed names (`000904`); `table.member` only inside the SEMANTIC_VIEW parens, plain output names outside, and the outer query touches only requested columns (`000904`); aggregate filters in `HAVING` or a wrapping CTE (never the outer WHERE — `000990`/`002035`); GROUP BY covering every non-aggregated selected column when re-aggregating (`000979`); one fact table per call, related dimensions only (`010959`); fix-and-retry on compilation errors — never resubmit unchanged SQL, never fall back to another data source:\n\n```bash\nSQL=$(cat \u003c\u003c'EOF'\nSELECT object_type_name, objects_created, objects_deleted\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_changes\n    METRICS    object_changes.objects_created,\n               object_changes.objects_deleted\n    DIMENSIONS object_types.object_type_name,\n               object_changes.workspace_id\n    WHERE object_changes.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n      AND object_changes.change_type IN ('created', 'deleted')\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY objects_created DESC\nEOF\n)\ncio api /v1/environments/$ENV_ID/snowflake_query -X POST --timeout 120s \\\n  --arg query=\"$SQL\" --json '{query: $query}'\n```\n\nThe heredoc delimiter must be quoted (`\u003c\u003c'EOF'`) so `$WORKSPACE_ID` reaches Snowflake literally. Results cap at 10,000 rows (`\"truncated\": true`). `--timeout 120s` is required: the CLI's 30s default dies with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` while the server is still working — resend the same SQL with the flag, don't narrow. Only a warehouse-side 90s statement timeout means the query is too slow — then narrow the date range and filter `change_type`.\n\n## Examples\n\n### 1. Objects created and deleted in the past 30 days, by type\n\nThe query above.\n\n### 2. People added to and removed from objects this week, by type\n\n```sql\nSELECT object_type_name, relationships_added, relationships_removed\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_changes\n    METRICS    relationship_changes.relationships_added,\n               relationship_changes.relationships_removed\n    DIMENSIONS object_types.object_type_name,\n               relationship_changes.workspace_id\n    WHERE relationship_changes.occurred_date \u003e= DATEADD('day', -7, CURRENT_DATE())\n      AND relationship_changes.change_type IN ('added', 'removed')\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY relationships_added DESC\n```\n\n### 3. Accounts that gained the most people in the past 30 days\n\n```sql\nSELECT object_id, people_with_relationship_changes AS people_added\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_changes\n    METRICS    relationship_changes.people_with_relationship_changes\n    DIMENSIONS relationship_changes.object_id,\n               relationship_changes.workspace_id\n    WHERE relationship_changes.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n      AND relationship_changes.change_type = 'added'\n      AND object_types.object_type_name = 'Companies'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY people_added DESC\nLIMIT 10\n```\n\n### 4. Most-changed object attributes this week\n\n```sql\nSELECT object_attribute, object_attribute_changes, objects_changed\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_changes\n    METRICS    object_changes.object_attribute_changes,\n               object_changes.objects_changed\n    DIMENSIONS object_changes.object_attribute,\n               object_changes.workspace_id\n    WHERE object_changes.occurred_date \u003e= DATEADD('day', -7, CURRENT_DATE())\n      AND object_changes.change_type = 'attribute_changed'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY object_attribute_changes DESC\nLIMIT 20\n```\n\n### 5. Who was added to one account in the past 30 days\n\n```sql\nSELECT person_id, occurred_date\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_changes\n    DIMENSIONS relationship_changes.person_id,\n               relationship_changes.occurred_date,\n               relationship_changes.workspace_id\n    WHERE relationship_changes.object_id = 'acme-01'\n      AND relationship_changes.change_type = 'added'\n      AND relationship_changes.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY occurred_date DESC\nLIMIT 100\n```\n\nSomeone added and later removed still appears here. For who is in the account now, use `object_state.md` Ex 4.\n\n## Answering\n\nState the time window and, on every count, that it is CHANGE events in the period — not a current total. Dates are US Pacific.\n\n## What This Skill Cannot Answer\n\n- **Current totals and current members** — `object_state.md` (`sv_object_state`).\n- **Before and after values** of an object or relationship attribute — not exposed in this view.\n- **Changes before 2026-03-05** — history starts then.\n- **Segment entries and exits** — `deliverability.md` (`segment_events`).\n- **Names and emails of people** — `person_id` is an internal id; resolve it through `customers.md`.\n","object_state.md":"---\nminimum_plan: essentials\n---\n\n# Object and Relationship State Analytics\n\nAnswer questions about the current state of custom objects using the `sv_object_state` semantic view in `ANALYTICS.CIO_AGENT`: how many objects of each type exist, what an object's attributes hold, who is related to an object, how many objects a person belongs to, and what an attribute on a membership holds.\n\nEach workspace names its own object types and attributes. Filter `object_type_name` and attribute names by the names the user gives, and list the workspace's types first (Ex 1) when no type matches. The examples in this file use a workspace that stores companies as objects, with a `plan` object attribute and a `role` relationship attribute.\n\n**This is CURRENT STATE, never a change log.** Each table holds one row per object, attribute, or relationship as it stood at the last daily build (early morning, US Pacific). There is no date range: questions about a period (\"created last week\", \"added this month\") go to `object_changes.md`.\n\nRouting: object and relationship changes over a period → `object_changes.md`. One person's own attributes → `customers.md` (live); attribute breakdowns across profiles (\"profiles per \u003cattribute\u003e value\") → `profile_state.md`. Creating or editing objects, object types, or relationships, the object type cap, and one object's live record → `customers.md` (Objects API).\n\n## The semantic view at a glance\n\n| Logical table | Base table | Grain (one row per…) | Use for | Key caveat |\n|---|---|---|---|---|\n| `objects` | `dim_objects` | **object**, live or deleted | how many objects exist, by type | deleted objects keep their row and can outnumber live ones — use `live_objects` |\n| `object_attributes` | `dim_object_attributes` | **(object, attribute)** | an object's current values, how many objects hold a value | cleared values and attributes of deleted objects keep their row with a NULL value |\n| `relationships` | `dim_profile_object_relationships` | **(object, person)** edge | who is in an object now, how many objects a person is in | removed edges keep their row with `is_related = false`; edges of deleted profiles stay |\n| `relationship_attributes` | `dim_profile_object_relationship_attributes` | **(object, person, attribute)** | a person's role, seat, or flag within one object | a NULL value means cleared, including by removing the edge |\n| `object_types` | `dim_object_types` | object **type** | the name a customer sees (Companies) | joined by every other table |\n\n**Joins.** `objects`, `object_attributes`, and `relationship_attributes` each join `object_types`; `relationships` joins `objects` (and through it `object_types`). The two attribute tables join **nothing but `object_types`**: their `object_id` is the customer-supplied ID, which a deleted object and a later one can share. So never mix `object_attributes.*` or `relationship_attributes.*` members with `objects.*` or `relationships.*` members in one `SEMANTIC_VIEW` call. **Snowflake does not reject the mix**: it repeats each metric for every value of the other table's dimension (a cross join through `object_types`), so the numbers look plausible and are wrong. When a question needs both, give each its own call in a CTE and join them on `object_type_id` + `object_id` in the outer SQL (Ex 7).\n\n**PRODUCT SURFACE:** the workspace Objects pages and each object's attributes, a person's Relationships panel, and each object's member list; object types are defined under Data \u0026 Integrations \u003e Objects. Data arrives through the objects and relationships APIs, Data Pipelines object calls, imports, and CRM syncs (Salesforce, HubSpot).\n\n## Choosing the right query\n\n1. **Use the named metrics.** Every table keeps rows that stopped being current, and the metrics apply the current-state filter for you: `live_objects` (not deleted), `related_people` / `current_relationships` / `objects_with_members` (edge exists now), `objects_with_value` / `people_with_relationship_value` (value set now). Never `COUNT(*)` a dimension list or use `deleted_objects` as a total.\n2. **\"How many \u003ctype\u003e exist\"** → `objects.live_objects` grouped or filtered by `object_types.object_type_name` (Ex 1). Object types are workspace-specific numeric ids; filter by `object_type_name` when the user names a type.\n3. **\"How many \u003cobjects\u003e have \u003cattribute\u003e = \u003cvalue\u003e\" / \"\u003cobjects\u003e per \u003cattribute\u003e value\"** → `object_attributes.objects_with_value` filtered on `object_attribute` and grouped by `object_attribute_value` (Ex 2).\n4. **One object** → filter its `object_id`, and on the `objects` table pair it with `objects.is_deleted = FALSE`: a deleted object and a newer one can share the id. Add `object_type_name` when two types could share an id.\n5. **\"Who is in \u003cobject\u003e\" / \"how many people are in \u003cobject\u003e\"** → `relationships.related_people` with `objects.object_id` and `objects.is_deleted = FALSE` (Ex 4). To list the people, select `relationships.person_id` with `relationships.is_related = TRUE`.\n6. **\"How many \u003cobjects\u003e is this person in\"** → `relationships.objects_with_members` filtered on `relationships.person_id` (Ex 5).\n7. **Attributes on a membership** (\"who has \u003cattribute\u003e = \u003cvalue\u003e in \u003cobject\u003e\", such as a role) → `relationship_attributes` filtered on `object_id`, `relationship_attribute`, and `relationship_attribute_value` (Ex 6). A non-NULL value means the edge carries it now. Attribute names like `removed` or `inactive` are customer labels, not edge state.\n8. **Values are text.** Compare `'40'`, not `40`, and use `TRY_TO_NUMBER(...)` in the outer SQL for numeric ranges. Values are case-sensitive as stored.\n9. **Count before you list.** Membership lists can be large and results cap at 10,000 rows — return a count first, and list only when asked, with a `LIMIT`.\n\n## Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `objects.live_objects` | Objects that exist now — THE \"how many \u003cobjects\u003e exist\" metric |\n| `objects.deleted_objects` | Deleted objects that still have a row — not deletes in a period |\n| `object_attributes.objects_with_value` | Distinct objects whose attribute holds a value now — THE \"how many \u003cobjects\u003e have \u003cattribute\u003e = \u003cvalue\u003e\" metric |\n| `object_attributes.current_object_attribute_values` | Object attribute values set now, one per (object, attribute) — a count of values, not objects |\n| `relationships.current_relationships` | Person-to-object edges that exist now — a person in two objects counts twice |\n| `relationships.related_people` | Distinct people related to at least one object now — THE \"how many people are in \u003cobject\u003e\" metric |\n| `relationships.objects_with_members` | Distinct objects with at least one related person now — THE \"how many \u003cobjects\u003e is this person in\" metric |\n| `relationship_attributes.people_with_relationship_value` | Distinct people whose relationship attribute holds a value now — THE \"how many people have \u003cattribute\u003e = \u003cvalue\u003e in \u003cobject\u003e\" metric |\n| `relationship_attributes.current_relationship_attribute_values` | Relationship attribute values set now, one per (edge, attribute) |\n| `object_types.live_object_types` | Object types that exist now |\n\n## Dimensions\n\n| DIMENSIONS reference | Output column (unaliased) | Description |\n|---|---|---|\n| `objects.workspace_id` | `workspace_id` | **Required filter on every query** |\n| `objects.object_type_id` | `object_type_id` | Numeric object type id within the workspace. `object_type_name` gives its name. |\n| `objects.object_id` | `object_id` | Customer-supplied object id. Pair with `is_deleted = FALSE` to reach the live object. |\n| `objects.cio_object_id` | `cio_object_id` | Customer.io internal object key — stable and unique per workspace. |\n| `objects.is_deleted` | `is_deleted` | True when the object has been deleted. |\n| `object_attributes.workspace_id` | `workspace_id` | Required filter on every object attribute query. |\n| `object_attributes.object_type_id` | `object_type_id` | Type of the object holding the attribute. |\n| `object_attributes.object_id` | `object_id` | Customer-supplied id of the object holding the attribute. Filter it to read one object. |\n| `object_attributes.object_attribute` | `object_attribute` | Attribute of the object itself (e.g. `plan`, `industry`). Workspace vocabulary. |\n| `object_attributes.object_attribute_value` | `object_attribute_value` | Current value as text. NULL when cleared or when the object was deleted. Group by it only within one `object_attribute`. |\n| `object_attributes.is_object_deleted` | `is_object_deleted` | True when the object's deletion cleared this attribute. |\n| `relationships.workspace_id` | `workspace_id` | Required filter on every relationship query. |\n| `relationships.person_id` | `person_id` | Person side of the edge — an internal profile id. Includes deleted profiles. |\n| `relationships.is_related` | `is_related` | True when the edge exists now; false when removed, its object deleted, or the profile merged away. |\n| `relationship_attributes.workspace_id` | `workspace_id` | Required filter on every relationship attribute query. |\n| `relationship_attributes.object_type_id` | `object_type_id` | Type of the object side of the edge. |\n| `relationship_attributes.object_id` | `object_id` | Customer-supplied id of the object side of the edge. |\n| `relationship_attributes.person_id` | `person_id` | Person side of the edge. |\n| `relationship_attributes.relationship_attribute` | `relationship_attribute` | Attribute stored on the person-object link itself (e.g. `role`, `seat_type`). |\n| `relationship_attributes.relationship_attribute_value` | `relationship_attribute_value` | Current value as text. NULL when cleared. |\n| `object_types.object_type_name` | `object_type_name` | Plural display name of the type (e.g. Companies). Filter or group by it when the user names a type. |\n| `object_types.object_type_singular_name` | `object_type_singular_name` | Singular display name (e.g. Company). |\n| `object_types.object_type_is_deleted` | `object_type_is_deleted` | True when the type itself has been deleted. |\n\nOutput columns are always named after the dimension, so outer SELECT/WHERE/GROUP BY clauses say `object_attribute`, `object_attribute_value`, `relationship_attribute`, `relationship_attribute_value`, and `person_id` — never `attribute_name`, `attribute_value`, or `customer_internal_id`, which are underlying columns and invisible to the outer query (`invalid identifier` on compile). `person_id` is the same internal profile id as in `profile_state.md` and `profile_changes.md`; it returns ids only, so look a person up through `customers.md` when the user needs a name or email.\n\n## Submitting queries\n\nSame endpoint and rules as `deliverability.md` § Submitting queries, including its five-point pre-flight — heredoc + `--arg`, never hand-escape, aggregate in SQL; members only from this file's tables, never guessed names (`000904`); `table.member` only inside the SEMANTIC_VIEW parens, plain output names outside, and the outer query touches only requested columns (`000904`); aggregate filters in `HAVING` or a wrapping CTE (never the outer WHERE — `000990`/`002035`); GROUP BY covering every non-aggregated selected column when re-aggregating (`000979`); members from related tables only — this view returns wrong numbers rather than an error for unrelated ones (§ The semantic view at a glance); fix-and-retry on compilation errors — never resubmit unchanged SQL, never fall back to another data source:\n\n```bash\nSQL=$(cat \u003c\u003c'EOF'\nSELECT object_type_name, live_objects\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_state\n    METRICS    objects.live_objects\n    DIMENSIONS object_types.object_type_name,\n               objects.workspace_id\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY live_objects DESC\nEOF\n)\ncio api /v1/environments/$ENV_ID/snowflake_query -X POST --timeout 120s \\\n  --arg query=\"$SQL\" --json '{query: $query}'\n```\n\nThe heredoc delimiter must be quoted (`\u003c\u003c'EOF'`) so `$WORKSPACE_ID` reaches Snowflake literally. Results cap at 10,000 rows (`\"truncated\": true`). `--timeout 120s` is required: the CLI's 30s default dies with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` while the server is still working — resend the same SQL with the flag, don't narrow.\n\n## Examples\n\n### 1. How many objects of each type exist\n\nThe query above. Filter `object_types.object_type_name = 'Companies'` inside the parens for one type.\n\n### 2. How many accounts are on each plan\n\n```sql\nSELECT object_attribute_value AS plan, objects_with_value\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_state\n    METRICS    object_attributes.objects_with_value\n    DIMENSIONS object_attributes.object_attribute_value,\n               object_attributes.workspace_id\n    WHERE object_attributes.object_attribute = 'plan'\n      AND object_types.object_type_name = 'Companies'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY objects_with_value DESC\nLIMIT 20\n```\n\nFilter `object_attribute_value = 'enterprise'` inside the parens instead of grouping for one value's count.\n\n### 3. One account's current attributes\n\n```sql\nSELECT object_attribute, object_attribute_value\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_state\n    DIMENSIONS object_attributes.object_attribute,\n               object_attributes.object_attribute_value,\n               object_attributes.workspace_id\n    WHERE object_attributes.object_id = 'acme-01'\n      AND object_types.object_type_name = 'Companies'\n      AND object_attributes.object_attribute_value IS NOT NULL\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY object_attribute\n```\n\n### 4. How many people are in one account\n\n```sql\nSELECT related_people\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_state\n    METRICS    relationships.related_people\n    DIMENSIONS relationships.workspace_id\n    WHERE objects.object_id = 'acme-01'\n      AND objects.is_deleted = FALSE\n      AND object_types.object_type_name = 'Companies'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n```\n\nTo list them, replace the metric with `DIMENSIONS relationships.person_id, relationships.workspace_id`, add `AND relationships.is_related = TRUE`, and end with `LIMIT 100`.\n\n### 5. How many accounts one person is in\n\n```sql\nSELECT object_type_name, objects_with_members\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_state\n    METRICS    relationships.objects_with_members\n    DIMENSIONS object_types.object_type_name,\n               relationships.workspace_id\n    WHERE relationships.person_id = 'a1b2c3d4e5f6a7b8c9'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n```\n\nTo name the accounts, use `DIMENSIONS object_types.object_type_name, objects.object_id, relationships.workspace_id` with `AND relationships.is_related = TRUE`.\n\n### 6. Who holds a role in one account\n\n```sql\nSELECT person_id\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_object_state\n    DIMENSIONS relationship_attributes.person_id,\n               relationship_attributes.workspace_id\n    WHERE relationship_attributes.object_id = 'acme-01'\n      AND relationship_attributes.relationship_attribute = 'role'\n      AND relationship_attributes.relationship_attribute_value = 'admin'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nLIMIT 100\n```\n\nFor the count, use `METRICS relationship_attributes.people_with_relationship_value` instead of the `person_id` dimension.\n\n### 7. People in accounts that hold an attribute value\n\n\"How many people belong to accounts on the enterprise plan\". Object attributes and memberships never share a call, so each gets its own CTE and the outer SQL joins them on `object_type_id` + `object_id`:\n\n```sql\nWITH on_plan AS (\n  SELECT object_type_id, object_id\n  FROM SEMANTIC_VIEW(\n      ANALYTICS.CIO_AGENT.sv_object_state\n      DIMENSIONS object_attributes.object_type_id, object_attributes.object_id,\n                 object_attributes.workspace_id\n      WHERE object_attributes.object_attribute = 'plan'\n        AND object_attributes.object_attribute_value = 'enterprise'\n  )\n  WHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n),\nmembers AS (\n  SELECT object_type_id, object_id, person_id\n  FROM SEMANTIC_VIEW(\n      ANALYTICS.CIO_AGENT.sv_object_state\n      DIMENSIONS objects.object_type_id, objects.object_id,\n                 relationships.person_id, relationships.workspace_id\n      WHERE relationships.is_related = TRUE\n        AND objects.is_deleted = FALSE\n  )\n  WHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n)\nSELECT COUNT(DISTINCT m.person_id) AS people\nFROM members AS m\nJOIN on_plan AS p ON p.object_type_id = m.object_type_id AND p.object_id = m.object_id\n```\n\n## Answering\n\nSay every count is as of the last daily build, not live. People counts include profiles that were deleted without their memberships being removed; say so when a membership count is the headline. Object counts already exclude deleted objects when they come from `live_objects`.\n\n## What This Skill Cannot Answer\n\n- **Changes over a period** — \"\u003cobjects\u003e created last week\", \"people added to \u003cobject\u003e this month\": `object_changes.md` (`sv_object_changes`).\n- **Earlier values** — what an attribute held before its current value. This view keeps only the current one.\n- **Live state** — the view is up to a day old. For one object's live record, or to confirm a change made today, use the Objects API in `customers.md`.\n- **A person's own attributes** — one person's live record is in `customers.md`; breakdowns across profiles are in `profile_state.md`. Relationship attributes describe the link, not the person.\n- **Whether a related person still exists** — deleted profiles keep their edges here; check the profile through `customers.md`.\n- **Names and emails of related people** — `person_id` is an internal id; resolve it through `customers.md`.\n","object_triggers.md":"---\nminimum_plan: essentials\nfeature_flag: state-based-object-campaign-trigger\n---\n\n# Object Trigger (`rel_seg_attr`)\n\nThe **Object** trigger (`type: \"rel_seg_attr\"`) starts a journey for each person–object relationship whose profile, object and relationship conditions are all true at once. It is state-based: a relationship enters when its last missing condition becomes true, whatever order the profile, object and relationship updates arrive in. Call it the **Object** trigger when talking to users — that's its name in the trigger picker; `rel_seg_attr` is only the API name.\n\n## Prefer it over the legacy object and relationship triggers\n\nThe `object` (`Updated`) and `relationship` (`Enter`, `Updated`) triggers fire at the moment of one change and reach only the relationships that exist at that instant. Updates aren't processed in a guaranteed order, so those triggers silently miss people: an account switched to `active` before its admin is linked never messages that admin. Build these requests with the Object trigger:\n\n| The user wants | Trigger conditions | Replaces |\n|---|---|---|\n| Message the people on an object when it reaches a state (\"when an account becomes `active`, email its members\") | `object_attribute` | `object` + `Updated`, all related people |\n| …only some of those people (\"its admins\", \"members with an email\") | add `relationship_attribute` and/or profile `attribute` leaves | `object` + `Updated`, certain related people |\n| Message a person when they're linked to an object in some state, or their relationship reaches one (\"when someone becomes a workspace admin\") | `relationship_attribute`, plus any object or profile leaves | `relationship` + `Enter` / `Updated`, Self audience |\n\nKeep another type only for what this trigger can't express, and tell the user why:\n\n- A date on an object (\"5 days before check-in\") → `object` + `DateTriggered` (the date-triggered object campaign in `automation_triggers.md`). That one isn't legacy.\n- A removal or deletion (a person removed from an object, an object deleted) → `relationship` + `Exit` or `object` + `Deleted`. The Object trigger fires only when a relationship starts matching.\n- A bare link or new object (\"when someone is added to an account\") with no qualifying condition → the trigger can't be blank, so ask which profile, object or relationship state should qualify. Only if the user confirms there is none, use `relationship` + `Enter` with a Self audience.\n- Messaging an object's *other* people when one person's relationship changes → not possible here; the recipient is always the person on the matching relationship. That takes a legacy relationship trigger with an all/certain-related-people audience, so confirm the user needs it before building one.\n\nThe product still lists the legacy triggers under **View legacy object triggers**; don't send users there for anything the table covers.\n\n## Create one\n\n1. Resolve the object type and attribute names as for segment conditions (`segment_conditions.md` § Object Attribute Preflight): `GET /v1/environments/:environment_id/object_types` for the type's `id`, then `GET .../attributes?objectType=:type_id\u0026size=50` for object attributes and `GET .../attributes?objectType=:type_id\u0026relationship=true\u0026size=50` for relationship attributes. Profile attributes come from `GET .../attributes`.\n2. Create the automation with `POST /campaigns` and `type: \"none\"` — create doesn't accept `rel_seg_attr` (`422 type must be one of [...]`).\n3. Set the trigger with a `recipients` update:\n\n```\nPUT /v1/environments/:environment_id/campaigns/:campaign_id\n```\n\n```json\n{\n  \"campaign\": {\n    \"update_type\": \"recipients\",\n    \"type\": \"rel_seg_attr\",\n    \"rel_seg_attr_object_type_id\": 3,\n    \"rel_seg_attr_trigger\": {\n      \"and\": [\n        { \"object_attribute\": { \"type_id\": 3, \"field\": \"plan\", \"operator\": \"eq\", \"value\": \"enterprise\" } },\n        { \"relationship_attribute\": { \"type_id\": 3, \"field\": \"role\", \"operator\": \"eq\", \"value\": \"admin\" } },\n        { \"attribute\": { \"field\": \"email\", \"operator\": \"exists\" } }\n      ]\n    },\n    \"restart_mode\": \"none\"\n  }\n}\n```\n\nThat enters every person who is an `admin` of an account on the `enterprise` plan and has an email — once per such account.\n\n| Field | Notes |\n|---|---|\n| `rel_seg_attr_object_type_id` | The object type's integer id. Required (`Object type ID must be set`). One object type per automation. |\n| `rel_seg_attr_trigger` | A raw JSON object, never a string — a stringified value is rejected (`trigger must be a JSON object, not a JSON string`). Can't be blank (`Trigger can't be blank`). See § Trigger conditions. |\n| `restart_mode` | `none` or `rematch` — see § Who enters. `interval` is rejected (`Relationship campaigns do not support the at-intervals frequency`). |\n\nA `recipients` write is a full replace: on an existing one, fetch it and resend `type` with all three fields, changing only what you mean to. `restart_min_interval`, `filters` and `audience` aren't read for this type. Switching to or from it needs the automation draft or stopped (`automations.md` § Automation Type).\n\n4. Add actions (`automations.md`).\n5. Before starting, poll the count (§ Checking who matches) until it reports `finished: true`. Saving the trigger builds a segment of the matching relationships, and nothing the build adds ever enters on its own — a start with `backfill: true` enters only the members it finds at that moment, and one without backfill only relationships that begin matching after the build.\n6. Start it (`automation_lifecycle.md`), with `backfill: true` if relationships that already match should enter.\n\n**Editing the trigger on a running one** saves, but rebuilds the segment: nothing enters until the count reads `finished: true` again, and relationships that already match the new rules at that point never enter — only ones that start matching afterwards. To bring those in, stop it and start it again with `backfill: true` once the count is finished; with `restart_mode: \"rematch\"` that also enters relationships that already had a journey.\n\n## Trigger conditions\n\nAn `and`/`or` tree — groups nest freely — over four leaf types:\n\n| Leaf | Reads | Example |\n|---|---|---|\n| `attribute` | the person's profile | `{\"attribute\": {\"field\": \"email\", \"operator\": \"exists\"}}` |\n| `object_attribute` | the related object | `{\"object_attribute\": {\"type_id\": 3, \"field\": \"plan\", \"operator\": \"eq\", \"value\": \"enterprise\"}}` |\n| `relationship_attribute` | the person–object relationship | `{\"relationship_attribute\": {\"type_id\": 3, \"field\": \"role\", \"operator\": \"eq\", \"value\": \"admin\"}}` |\n| `segment` | the person's membership in a people segment | `{\"segment\": {\"id\": 12}}` |\n\n- Every `object_attribute` and `relationship_attribute` leaf needs `type_id` equal to `rel_seg_attr_object_type_id`; a mismatch is a `400`.\n- A `segment` leaf can name a data-driven, manual or snapshot segment; any other segment, or one that doesn't exist or is archived, is a `400`. `\"inverse\": true` matches people not in it. Membership changes enter relationships like any other condition, except a segment rebuild — editing its conditions, refreshing a snapshot, or clearing a manual segment — which updates who matches without entering anyone. Until that rebuild finishes the trigger enters no one at all, through any leaf, so tell the user an edit, refresh or clear of the segment pauses the automation's entries for a while.\n- Only `and`, `or` and these four leaves are accepted. A `not` wrapper, `related` and event conditions fail with `400 unsupported filter type for relationship segment: …`. A key this format doesn't have — such as the segment-condition shapes `related_object_attribute` and `relationship` from `segment_conditions.md` — fails with a `422` on `rel_seg_attr_trigger`: `This filter includes an unsupported filter type (\"related_object_attribute\")…`.\n- Operators are the attribute operators (`eq`, `exists`, `contains`, `gt`, `lt`, …). An unknown operator is silently read as `eq`: there is no `neq`, and the `!eq` / `!exists` prefix form from action conditions doesn't exist here, so either one quietly becomes `eq`. Negate a leaf with `\"inverse\": true`.\n- A trigger of profile `attribute` leaves alone starts a journey for each of the person's relationships to the object type. If the object doesn't matter to the user, use `seg_attr` instead (`automation_triggers.md`).\n\n## Who enters, and how often\n\n- **The recipient is the person on the matching relationship.** There's no `audience` field and no all/certain-related-people choice: an object condition on its own enters every related person whose relationship matches.\n- **One journey per relationship.** A person related to three matching accounts runs three journeys at once, each with its own trigger object and relationship.\n- **At start**, only relationships that begin matching afterwards enter, unless you start with `backfill: true` — then every relationship already in the segment enters too, one journey each (§ Create one, step 5).\n- **`restart_mode`**: `none` (the UI's \"One time\"; `\"\"` means the same) gives each relationship one journey, ever — leaving the state and matching again doesn't restart it. A person removed from the object and added back gets a new relationship, though, which can enter again. `rematch` (\"Every re-match\") starts a new journey each time the relationship begins matching again. When reviewing one (`automation_review.md`), the re-entry check is `restart_mode` alone: `restart_min_interval` isn't read.\n- **Exiting when it stops matching**: `update_type: \"exit_conditions\"` with `exit_on_trigger_or_filter_not_matched: true` exits a journey whose relationship no longer matches the trigger — an account that downgrades mid-journey stops getting its upgrade series. That update type rewrites every exit field, so resend existing `global_exit_conditions` (`automations.md` § update_type Field). Profile and event exit conditions work as on any automation.\n\n## Checking who matches\n\n`GET /campaigns/:id` returns `rel_backing_segment_id` (a string) once the trigger is saved. Count it with:\n\n```\nGET /v1/environments/:environment_id/relsegments/:rel_backing_segment_id/count\n```\n\nIt returns `{\"count\": N, \"finished\": bool}`. `count` is **matching relationships, not people** — a person on two matching accounts counts twice, so say so when you report it. `finished: false` means the segment is still building and the count isn't final. Relationship-segment ids are their own id space: never pass one to `/segments/:id/count`, which reads people segments, where the same number can belong to an unrelated one.\n\nThe automation owns this segment — never create, edit or delete it yourself. Editing the trigger rebuilds it under the same id (§ Create one). Changing `rel_seg_attr_object_type_id` replaces it with a new segment, so move every leaf's `type_id` in the same call. Switching type, archiving or deleting the automation removes it; unarchiving or duplicating creates a new one. Re-read `rel_backing_segment_id` after any of these: an id whose segment is gone still reports `{\"count\": 0, \"finished\": true}`, which is not an empty audience.\n\n## Liquid and conditions in the workflow\n\nThe matching relationship is both the trigger and the audience:\n\n| Liquid | Resolves to |\n|---|---|\n| `{{customer.*}}`, `{{trigger.customer.*}}` | the person on the relationship — the recipient |\n| `{{trigger.\u003csingular_slug\u003e.*}}` | the relationship's object, e.g. `{{trigger.account.plan}}` |\n| `{{trigger.relationship.*}}`, `{{customer._relationship.*}}` | the relationship's own attributes, e.g. `{{trigger.relationship.role}}` |\n\nPrefer `trigger.\u003csingular_slug\u003e` to `objects.\u003cplural_slug\u003e[0]`: a person on several accounts has several, and `[0]` may not be the one that triggered. There is no `{{event.*}}`. `GET .../campaigns/:id/validate` flags roots this journey can't resolve. Slugs and date filters: `recipes/liquid_syntax.md` § Trigger Data vs Object Data.\n\nBranches, Wait Until and batch updates can read the same object and relationship: `object_attribute` and `relationship_attribute` conditions, and the `trigger_object_attribute` and `trigger_relationship_attribute` sources (`flow_control.md` § Automation Action Condition Types). A Wait Until on them re-evaluates when the object's or relationship's attributes change.\n\n## Why didn't someone enter?\n\nCheck these before naming a cause:\n\n1. The automation is running, and was started with `backfill: true` if the relationship already matched at start.\n2. The person is related to an object of this type, and the profile, the object and the relationship each satisfy their leaves — read all three; one unmet leaf keeps the relationship out.\n3. With `restart_mode: \"none\"`, that relationship may already have had its one journey.\n4. The segment wasn't still building when the relationship began matching or when the automation started: a relationship the build takes in is skipped for good. It enters only by leaving and matching again, or through a stop and a fresh start with `backfill: true` once the count is finished.\n","operations.md":"# Operations — Tags, Categories, Audit, Search, Suppressions, Previews\n\n## Tags\n\nTags are labels applied to automations, one-time sends, and other resources for organization.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/tags` | `tags.GetAll` | List all tags with usage counts |\n| POST | `/v1/environments/{environment_id}/tags` | `tags.Create` | Create a tag. Body: `{\"tag\": {\"name\": \"...\", \"color\": \"...\"}}` (`color` optional; a duplicate name is a 409) |\n| GET | `/v1/environments/{environment_id}/tags/{tag_id}` | `tags.Get` | Get a tag |\n| PUT | `/v1/environments/{environment_id}/tags/{tag_id}` | `tags.Update` | Rename/recolor a tag. Body: `{\"tag\": {\"name\": \"...\", \"color\": \"...\"}}` |\n| DELETE | `/v1/environments/{environment_id}/tags/{tag_id}` | `tags.Delete` | Delete a tag |\n\n### Applying Tags to Resources\n\nCreating a tag does not attach it to anything. After creating (or finding) a tag, you MUST apply it to the target resource using the resource-specific tag endpoint. The tag is not useful until it's applied.\n\n| Method | Path | Description |\n|--------|------|-------------|\n| POST | `/v1/environments/{environment_id}/campaigns/{campaign_id}/tags` | Apply one or more tags to an automation. Body: `{\"tags\":[{\"name\":\"Lifecycle\",\"color\":\"#7c3aed\"}]}` |\n| POST | `/v1/environments/{environment_id}/newsletters/{newsletter_id}/tags` | Apply one or more tags to a newsletter/broadcast. Body: `{\"tags\":[{\"name\":\"Lifecycle\",\"color\":\"#7c3aed\"}]}` |\n| POST | `/v1/environments/{environment_id}/transactional_messages/{transactional_message_id}/tags` | Apply one or more tags to a transactional message. Body: `{\"tags\":[{\"name\":\"Lifecycle\",\"color\":\"#7c3aed\"}]}` |\n| DELETE | `/v1/environments/{environment_id}/campaigns/{campaign_id}/tags/{tag_id}` | Remove a tag from an automation |\n| DELETE | `/v1/environments/{environment_id}/newsletters/{newsletter_id}/tags/{tag_id}` | Remove a tag from a one-time send |\n| DELETE | `/v1/environments/{environment_id}/transactional_messages/{transactional_message_id}/tags/{tag_id}` | Remove a tag from a transactional message |\n\n## Categories\n\nCategories group automations and broadcasts for organization in the UI. They differ from tags — categories are hierarchical groupings while tags are flat labels.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/categories` | `categories.GetAll` | List all categories. Returns `{categories: [...]}` |\n| GET | `/v1/environments/{environment_id}/categories/{category_id}` | `categories.Get` | Get a category. Returns `{category: {...}}` |\n| POST | `/v1/environments/{environment_id}/categories` | `categories.Create` | Create a category. Body: `{\"category\": {\"name\": \"...\"}}` |\n| PUT | `/v1/environments/{environment_id}/categories/{category_id}` | `categories.Update` | Rename a category. Body: `{\"category\": {\"name\": \"...\"}}` |\n| DELETE | `/v1/environments/{environment_id}/categories/{category_id}` | `categories.Delete` | Delete a category |\n\n## Audit \u0026 Profile activity\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/audit_logs` | `audit_logs.GetWorkspaceAuditLogs` | Environment audit logs. Params: `limit`, `cursor`, `sort_order`, `user_id`, `ip_addr`, `object_type`, `object_id`, `action`, `attribute_name`, `start_time`, `end_time`, `campaign_id` |\n| GET | `/v1/environments/{environment_id}/audit_log/{id}` | `audit_logs.GetWorkspaceAuditLog` | Get a single audit log entry |\n| POST | `/v1/environments/{environment_id}/audit_logs/export` | `audit_logs.ExportWorkspaceAuditLogs` | Export audit logs. Account-admin only, so not available to you — read the log above and summarize it, or send the user to the UI to export |\n\nThe list endpoint returns the entries under `data` (not `audit_logs`), plus `pagination` and the `users` / `service_accounts` lookup arrays — join `user_id` / `service_account_id` against those to name the actor. Each entry carries `changed_from` and `changed_to` holding only the fields that changed, so \"what changed\" comes from those two rather than from diffing `data` yourself. Times are unix seconds; `start_time` / `end_time` accept RFC3339.\n\n`attribute_name` narrows to records where that field changed — `attribute_name=name` for renames, `attribute_name=fsm` for start/stop. It requires `object_type` or `object_id`. The match is against `changed_from` / `changed_to`, so an Updated record whose previous version falls outside the fetched window is excluded: it cannot prove the attribute changed at that row.\n\n\u003e **Performance:** Always pass `object_type` — unfiltered queries scan the full table and time out on large workspaces. Keep `limit` at 20 unless you specifically need more. Use `start_time`/`end_time` to narrow the window; avoid ranges wider than 30 days. Paginate with `cursor` rather than increasing `limit`.\n\n\u003e **Paging: read `pagination.next_cursor`, never the row count.** A page can come back with fewer rows than `limit` — even zero — and still have more behind it. The server stops a filtered scan once it has read a bounded amount of data, which on workspaces with large campaign snapshots happens well before the window is exhausted, and `attribute_name` values that match nothing return empty pages the whole way. Keep going while `next_cursor` is non-zero, passing it as `cursor`; stop only when it is `0`. Treating a short or empty page as the end silently under-reports the audit history.\n\u003e\n\u003e **`page_all` does not work on this route.** It paginates by incrementing a `page` param this endpoint ignores, and looks for `has_more` / `total_pages` at the top level rather than `pagination.next_cursor` — so it re-reads the first page until it caps out, or stops on the first empty page. Page it yourself with `cursor`.\n\nThese are per-workspace: to cover several workspaces, call the route once per workspace. Account-level changes (team, roles, credentials, SSO, plan) are a separate account-admin-only log you can't read — see `account.md`.\n\n**Geofence *configuration* changes** (who created/edited/deleted a geoset or fence) are audit-log records: `object_type` accepts `Geoset` (set create/rename/delete, imports, bulk fence operations) and `Geofence` (single-fence create/edit/delete). Bulk operations — geoset imports, bulk move/copy, bulk delete — write **one summary record on the geoset** carrying the fence counts, not an entry per fence; so \"who deleted those 50 fences\" is a `Geoset` record, and filtering `object_type=Geofence` surfaces only individual fence edits.\n\nFor a geoset or fence configuration query ending now (for example, \"last week\"), pass `start_time` and omit `end_time`. Do not invent today's current hour; an end earlier than the actual current time silently excludes today's changes.\n\n\u003e **Geofence enter/dwell/exit *activity* is NOT in the audit log.** When a user asks to see geofence activity — who entered, dwelled in, or exited a geoset or fence — that lives in the **activity log**, not here. Use `GET /v1/environments/:environment_id/logs?type=geofence_entered` (or `geofence_dwelled` or `geofence_exited`) — see `customers.md` § Profile activity — and in the UI it's **Journeys → Activity logs**, Activity Type = **Geofence**. Only route to the account audit log when they specifically ask *who changed a geoset's configuration*.\n\n### Workspace Audit Logs UI Path\n\nFull workspace audit logs URL: `https://fly.customer.io/workspaces/$ENV_ID/settings/audit-logs`. For a specific workspace audit log, append `?audit_log_id=\u003cid\u003e`.\n\nReading workspace audit logs requires `Account_View`; exporting them requires `Account_Edit`. If the user can't open the page, explain that audit logs live in Workspace Settings or Account Settings and that they need someone with account access to review them. Never use the singular UI path `/workspaces/$ENV_ID/settings/audit-log`; singular `/audit_log/{id}` paths are API endpoints only.\n\n## Search\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/search` | `search.Search` | Universal search across automations, one-time sends, segments, etc. Params: `q` (required), `limit`, `filter` (resource type) |\n\n## Suppressions\n\nSuppressions prevent delivery to specific email addresses. Types: `bounces`, `blocks`, `spam_reports`, `invalid_emails`. These are **email only** suppressions reported by the mail provider.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/suppression/{type}` | `suppressions.Index` | List suppressions. Params: `limit` (max 50), `offset`, `email` (min 3 chars) |\n| POST | `/v1/environments/{environment_id}/delete_suppression` | `suppressions.Delete` | Remove suppressions. Body: `{\"category\": \"bounces\", \"emails\": [\"user@example.com\"]}` |\n\n## Events\n\nYou might use the `/events` endpoint to send a test event to one or more existing People, usually to test an event-triggered automation.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| POST | `/v1/environments/{environment_id}/events` | `events.SendEvent` | Send a test event. Body: `{\"ids\":[\"\u003cinternal_cio_id\u003e\"],\"name\":\"event_name\",\"data\":\"{\\\"key\\\":\\\"value\\\"}\",\"timestamp\":\"1717358400\"}` |\n\n**Important:** `ids` must be internal CIO IDs (`customer.internal_id` or `customer.identifiers.cio_id` from `GET .../customers/:id`), not external profile IDs or email addresses. `data` is a JSON-encoded string, not a nested object. `timestamp` is optional; when provided, it must be Unix seconds as a string. Omit `timestamp` to use the current time.\n\n## Previews \u0026 Test Sends\n\nPreview endpoints render content without sending. Verify endpoints send a real test message.\n\n**Previews** (all `POST`):\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| POST | `/v1/environments/{environment_id}/previews/email` | `previews.PreviewEmail` | Preview email |\n| POST | `/v1/environments/{environment_id}/previews/push` | `previews.PreviewPush` | Preview push |\n| POST | `/v1/environments/{environment_id}/previews/twilio` | `previews.PreviewTwilio` | Preview SMS |\n| POST | `/v1/environments/{environment_id}/previews/webhook` | `previews.PreviewWebhook` | Preview webhook |\n| POST | `/v1/environments/{environment_id}/previews/slack` | `previews.PreviewSlack` | Preview Slack |\n| POST | `/v1/environments/{environment_id}/previews/in_app` | `previews.PreviewInApp` | Preview in-app |\n| POST | `/v1/environments/{environment_id}/previews/in_app/{template_id}/live` | `previews.LivePreviewInApp` | Live preview for in-app (real-time editing) |\n| POST | `/v1/environments/{environment_id}/previews/inbox` | `previews.PreviewInboxChannel` | Preview inbox message |\n| POST | `/v1/environments/{environment_id}/previews/line` | `previews.PreviewLine` | Preview LINE message |\n| POST | `/v1/environments/{environment_id}/previews/urban_airship` | `previews.PreviewUrbanAirship` | Preview Urban Airship push |\n| POST | `/v1/environments/{environment_id}/previews/attribute` | `previews.PreviewAttribute` | Preview attribute update action |\n| POST | `/v1/environments/{environment_id}/previews/create_or_update_person` | `previews.PreviewCreateUpdatePerson` | Preview create/update person action |\n| POST | `/v1/environments/{environment_id}/previews/create_event` | `previews.PreviewCreateEvent` | Preview create event action |\n| POST | `/v1/environments/{environment_id}/previews/link_statuses` | `previews.CheckLinks` | Check link statuses in template content |\n| POST | `/v1/environments/{environment_id}/previews/llm` | `previews.PreviewLLMAction` | Preview AI/LLM action output |\n| POST | `/v1/environments/{environment_id}/previews/carta_content` | `previews.PreviewCartaContent` | Preview Design Studio (Carta) content |\n| POST | `/v1/environments/{environment_id}/previews/message_channel` | `previews.PreviewMessageChannel` | Preview message for a specific channel |\n| POST | `/v1/environments/{environment_id}/previews/twilio_content_template` | `previews.PreviewTwilioContentTemplate` | Preview Twilio content template (WhatsApp/MMS) |\n\n**Test sends** (all `POST`):\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| POST | `/v1/environments/{environment_id}/verify/email_template` | `verify.EmailTestFromTemplate` | Send test email of an existing template (rendered server-side; see test_sends.md) |\n| POST | `/v1/environments/{environment_id}/verify/slack_template` | `verify.SlackTestFromTemplate` | Send test Slack message of an existing template (rendered server-side; see test_sends.md) |\n| POST | `/v1/environments/{environment_id}/verify/line_template` | `verify.LineTestFromTemplate` | Send test LINE message of an existing template (rendered server-side; see test_sends.md) |\n| POST | `/v1/environments/{environment_id}/verify/push_template` | `verify.PushTestFromTemplate` | Send test push of an existing template (rendered server-side; see test_sends.md) |\n| POST | `/v1/environments/{environment_id}/verify/twilio_template` | `verify.TwilioTestFromTemplate` | Send test SMS of an existing template (rendered server-side; see test_sends.md) |\n| POST | `/v1/environments/{environment_id}/verify/message_channel_template` | `verify.MessageChannelTestFromTemplate` | Send test WhatsApp/SMS message of an existing template (rendered server-side; see test_sends.md) |\n| POST | `/v1/environments/{environment_id}/verify/webhook_template` | `verify.WebhookTestFromTemplate` | Send test webhook from an existing template |\n| POST | `/v1/environments/{environment_id}/verify/in_app_template` | `verify.InAppTestFromTemplate` | Send test in-app message of an existing template to a named profile (rendered server-side; see test_sends.md) |\n| POST | `/v1/environments/{environment_id}/verify/inbox_template` | `verify.InboxTestFromTemplate` | Send test inbox message of an existing template to a named profile (rendered server-side; see test_sends.md) |\n\nA push test send's delivery is read back with `GET /v1/environments/{environment_id}/test_deliveries/{delivery_id}` (`verify.GetTestDelivery`), using the `delivery_id` the send returned; see test_sends.md.\n\n## Backfills\n\nBackfills re-process data for segments, automations, or other resources. Users can view backfill status, retry failed backfills, or deprioritize long-running ones.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/backfills` | `backfills.List` | List backfills. Params: `page`, `continuation`, `type`, `state` |\n| POST | `/v1/environments/{environment_id}/backfills/{backfill_id}/requeue` | `backfills.Requeue` | Retry a failed backfill |\n| POST | `/v1/environments/{environment_id}/backfills/{backfill_id}/deprioritize` | `backfills.Deprioritize` | Deprioritize a backfill. Body: `{\"mode\": \"next\"|\"last\"}` (default: `\"last\"`) |\n","pipeline_health.md":"---\nminimum_plan: essentials\n---\n\n# Data Pipeline Health Analytics\n\nAnswer \"what is failing in my data pipeline\", \"how many events were rejected and why\", \"which attribute keeps failing to update\", and \"when did segment X last rebuild\" using the `sv_pipeline_health` semantic view in `ANALYTICS.CIO_AGENT`.\n\nRouting: message performance → `deliverability.md`. Person-grain segment membership changes (entries/exits) → `deliverability.md` (`segment_events`). Incoming webhook payloads → `deliverability.md` (`webhook_events`). Successful attribute changes → `profile_changes.md` — this view holds the FAILED ones.\n\n## The semantic view at a glance\n\n| Logical table | Base table | Grain (one row per…) | Use for | Key caveat |\n|---|---|---|---|---|\n| `pipeline_events` | `fct_data_pipeline_events` | failure/skip **item** | rejected events, failed attribute changes, failed LLM actions / query collections / batch updates / object journeys, skipped updates | a failure touching N attributes yields N rows sharing one `event_id` |\n| `segment_builds` | `fct_segment_builds` | segment **(re)build run** | build cadence, per-build entered/exited counts | low volume; membership changes live in sv_deliverability |\n| `segments` | `dim_segments` | segment config | names/types for `segment_builds` | segments never edited since audit tailing began may be absent |\n\n**PRODUCT SURFACE:** the failure/skip entries in profile Activity Logs and workspace data-health views (incl. Data Operations batch updates); segment builds are the data-driven segment (re)build runs shown in the Segments UI.\n\n## Choosing the right query\n\n1. **Start failure investigations by grouping `failures` by `event_category`** — then drill into `reason` / `failure_subject` within ONE category. `reason` is free-form and high-cardinality; never group by it across categories or long periods.\n2. **Item grain, not event grain.** A failure touching N attributes/collections counts N in `failures`. Say \"failure items\" when reporting, or note the inflation when one identify call fans out.\n3. **`people_affected` undercounts** — it covers person-level failures only; object- and workspace-level failure rows carry no person. Always say so when reporting it.\n4. **GDPR suppression rejections** show up as `failed_event` with `failure_detail` carrying the suppression label — they are expected behavior for suppressed profiles, not a pipeline defect. Check `failure_detail` before declaring an incident.\n5. Every query: `workspace_id = TO_NUMBER($WORKSPACE_ID)` in the outer WHERE + a constrained `occurred_date` range. Trends and period-over-period: group metrics by `occurred_date` and compare periods — there are no delta metrics.\n\n`event_category` values: `failed_event`, `failed_attribute_change`, `object_failed_attribute_change`, `relationship_failed_attribute_change`, `failed_llm_action`, `failed_query_collection`, `failed_batch_update`, `failed_object_journeys`, `skipped_update`.\n\n## Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `pipeline_events.failures` | Failed pipeline items in the selected scope (everything except `skipped_update`) |\n| `pipeline_events.skips` | Skipped updates (e.g. batch updates matching no profiles) |\n| `pipeline_events.total_events` | Failed + skipped combined |\n| `pipeline_events.people_affected` | Distinct profiles involved in person-level failures (undercounts object/workspace-level categories) |\n| `segment_builds.builds` | Segment build events in the selected scope |\n| `segment_builds.segments_built` | Distinct segments (re)built |\n| `segment_builds.build_entries` | People added to segments by builds |\n| `segment_builds.build_exits` | People removed from segments by builds |\n\n## Dimensions\n\n| DIMENSIONS reference | Output column (unaliased) | Description |\n|---|---|---|\n| `pipeline_events.workspace_id` | `workspace_id` | **Required filter on every query** |\n| `pipeline_events.occurred_date` | `occurred_date` | Date of the failure/skip. Always constrain to a range; group by it for trends. |\n| `pipeline_events.event_category` | `event_category` | Failure category (values above). Group failures by this FIRST. |\n| `pipeline_events.outcome` | `outcome` | `failed` or `skipped`. Prefer the `failures`/`skips` metrics over filtering this. |\n| `pipeline_events.failure_detail` | `failure_detail` | Short label where present (e.g. GDPR suppression, Batch Update, the rejected event's name). NULL when absent. |\n| `pipeline_events.reason` | `reason` | Free-text explanation. High cardinality — group only within one `event_category` and a short period. |\n| `pipeline_events.failure_subject` | `failure_subject` | The attribute/relationship field/collection the failure relates to (e.g. `email`). NULL for events with a top-level reason. THE dimension for \"which attribute keeps failing\". |\n| `pipeline_events.person_id` | `person_id` | Profile involved (person-level failures only; NULL for object/workspace-level). High cardinality — always combine with workspace_id, an `event_category` filter, and a date range. |\n| `segment_builds.workspace_id` | `workspace_id` | Required filter on every segment-build query. |\n| `segment_builds.occurred_date` | `occurred_date` | Date the segment build completed. |\n| `segment_builds.segment_id` | `segment_id` | Segment that was (re)built — joins to `segments` for the name. |\n| `segments.segment_name` | `segment_name` | Segment display name. |\n| `segments.segment_type` | `segment_type` | `data_driven`, `manual`, `temporary`, or `hidden`. |\n\nOutput columns are always named after the dimension, so outer SELECT/WHERE/GROUP BY clauses say `person_id` — never `customer_internal_id`, which is the underlying fact-table column and invisible to the outer query (`invalid identifier` on compile). Alias explicitly when combining same-named dimensions from multiple tables.\n\n## Submitting queries\n\nSame endpoint and rules as `deliverability.md` § Submitting queries, including its five-point pre-flight — heredoc + `--arg`, never hand-escape, aggregate in SQL; members only from this file's tables, never guessed names (`000904`); `table.member` only inside the SEMANTIC_VIEW parens, plain output names outside, and the outer query touches only requested columns (`000904`); aggregate filters in `HAVING` or a wrapping CTE (never the outer WHERE — `000990`/`002035`); GROUP BY covering every non-aggregated selected column when re-aggregating (`000979`); one fact table per call, related dimensions only (`010959`); fix-and-retry on compilation errors — never resubmit unchanged SQL, never fall back to another data source:\n\n```bash\nSQL=$(cat \u003c\u003c'EOF'\nSELECT event_category, failures\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_pipeline_health\n    METRICS    pipeline_events.failures\n    DIMENSIONS pipeline_events.event_category,\n               pipeline_events.workspace_id,\n               pipeline_events.occurred_date\n    WHERE pipeline_events.occurred_date \u003e= DATEADD('day', -7, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY failures DESC\nEOF\n)\ncio api /v1/environments/$ENV_ID/snowflake_query -X POST --timeout 120s \\\n  --arg query=\"$SQL\" --json '{query: $query}'\n```\n\nThe heredoc delimiter must be quoted (`\u003c\u003c'EOF'`) so `$WORKSPACE_ID` reaches Snowflake literally. Results cap at 10,000 rows (`\"truncated\": true`). `--timeout 120s` is required: the CLI's 30s default dies with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` while the server is still working — resend the same SQL with the flag, don't narrow. Only a warehouse-side 90s statement timeout means the query is too slow — then narrow the date range.\n\n## Examples\n\n### 1. What failed in the last 7 days — category overview\n\nThe query above. Present categories in plain language (e.g. `failed_attribute_change` → \"profile attribute updates that failed\").\n\n### 2. Which attribute keeps failing to update\n\n```sql\nSELECT failure_subject, reason, failures\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_pipeline_health\n    METRICS    pipeline_events.failures\n    DIMENSIONS pipeline_events.failure_subject,\n               pipeline_events.reason,\n               pipeline_events.event_category,\n               pipeline_events.workspace_id\n    WHERE pipeline_events.occurred_date \u003e= DATEADD('day', -14, CURRENT_DATE())\n      AND pipeline_events.event_category = 'failed_attribute_change'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND failure_subject IS NOT NULL\nORDER BY failures DESC\nLIMIT 20\n```\n\n### 3. Rejected incoming events and why (incl. GDPR suppression)\n\n```sql\nSELECT failure_detail, reason, failures, people_affected\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_pipeline_health\n    METRICS    pipeline_events.failures,\n               pipeline_events.people_affected\n    DIMENSIONS pipeline_events.failure_detail,\n               pipeline_events.reason,\n               pipeline_events.event_category,\n               pipeline_events.workspace_id\n    WHERE pipeline_events.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n      AND pipeline_events.event_category = 'failed_event'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY failures DESC\nLIMIT 20\n```\n\n### 4. Failure trend — is the pipeline getting worse?\n\nGroup `failures` by `occurred_date` (and `event_category`) over the window; compare periods in SQL, not by eyeballing.\n\n### 5. Segment build activity\n\n```sql\nSELECT name, builds, build_entries, build_exits\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_pipeline_health\n    METRICS    segment_builds.builds,\n               segment_builds.build_entries,\n               segment_builds.build_exits\n    DIMENSIONS segments.segment_name AS name,\n               segment_builds.workspace_id\n    WHERE segment_builds.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY builds DESC\n```\n\n## Answering\n\nState the time window, that counts are failure ITEMS (not source events), and the `people_affected` undercount caveat when used. Distinguish expected rejections (GDPR suppression) from genuine defects. `occurred_date` reflects US Pacific calendar days (no timestamp dimension is exposed).\n\n## What This Skill Cannot Answer\n\n- **Successful pipeline volume** — this view holds failures and skips only; there is no success denominator, so no \"failure rate\". Report absolute counts and trends.\n- **Successful attribute changes** — `profile_changes.md` (`sv_profile_changes`).\n- **Successful object and relationship changes** — `object_changes.md` (`sv_object_changes`).\n- **Segment membership entries/exits per person** — `deliverability.md` (`segment_events`).\n- **Incoming webhook payloads** — `deliverability.md` (`webhook_events`).\n- **The failing payload itself** — reasons and labels only; original payloads never reach the warehouse.\n- **Real-time data** — ~1–5 minute staleness from the event stream.\n","profile_changes.md":"---\nminimum_plan: essentials\n---\n\n# Profile Changes Analytics\n\nAnswer \"how many profiles were created/deleted in the past N days\", \"what is the source of most new profiles\", \"how many profiles changed attribute X\", and \"how many devices were registered this month\" using the `sv_profile_changes` semantic view in `ANALYTICS.CIO_AGENT`.\n\n**This is a CHANGE LOG, never a roster.** Every metric counts change events over a period. \"How many profiles exist\" / \"how many devices exist\" cannot be answered here (netting full history is unsupported) — total-count questions go to the People page APIs, not Snowflake. Current attribute values (\"how many profiles are on each plan\") are in `profile_state.md`.\n\nRouting: message performance → `deliverability.md`. FAILED attribute changes → `pipeline_health.md` — this view holds the successful ones. Page/screen views → `site_activity.md`.\n\n## The semantic view at a glance\n\nThree independent facts — no relationships between them; one fact per `SEMANTIC_VIEW` call. When a question needs two (Ex 8), give each its own call in a CTE and join them on `person_id` in the outer SQL.\n\n| Logical table | Base table | Grain (one row per…) | Use for | Key caveat |\n|---|---|---|---|---|\n| `profile_lifecycle` | `fct_profile_lifecycle` | lifecycle **event** | created / deleted / merged / anonymous-merged counts, net growth, creation-source splits (`source_type`) | merges reduce totals without a `deleted` event |\n| `attribute_changes` | `fct_attribute_changes` | **(event, attribute)** change | \"changed attribute X\", before/after values | **highest-volume fact in the warehouse** — tight date range + `attribute_name` filter, always |\n| `device_changes` | `fct_device_changes` | device change **item** | registrations, removals, platform at registration | `updated` fires per device attribute and dominates volume — not a device count |\n\n**PRODUCT SURFACE:** the People page and each person's Attributes panel, Devices list, and Activity Log — creates via identify/imports/forms, deletes via UI/API/GDPR flows, merges via the profile-merge tool, attribute updates via identify calls, imports, UI edits, and journey \"update attribute\" actions, devices registered by the mobile SDKs (push tokens).\n\n## Choosing the right query\n\n1. **\"How many profiles changed attribute X in \u003cperiod\u003e\"** → `attribute_changes.people_changed` with an `attribute_name` filter — THE metric for this question shape. `changes` counts events (one identify call updating N attributes = N rows sharing an `event_id`).\n2. **Attribute-change queries MUST be scoped**: workspace + tight `occurred_date` range + `attribute_name` whenever the question names one. Unscoped queries hit the warehouse's 90s statement limit on this fact before any other.\n3. **Net profile growth** = `net_profile_change` (created − deleted). It is growth, NOT total profiles — and merges additionally reduce totals without a deleted event; say so when reporting growth.\n4. **\"Where do new profiles come from\" / \"what created these profiles\"** → group `profiles_created` by `source_type` — THE dimension for this question. Never fall back to paginating per-profile activity logs for source questions; the answer is one warehouse query. Values name the ingress, e.g. `track.customer.io` (track API/SDKs/CDP ingest), `attribute_update_action` (journey/campaign action), `import` (CSV/SQL import). Sources exist on `created`/`deleted` events only and only for rows ingested since the source-attribution release (2026-08-18, backfill planned) — report the NULL bucket as \"unknown (older data or no source recorded)\", never as a named source. Drill down with `source_subtype`, `source_import_id` (which import), or `cdp_source_name`/`cdp_source_type` (which Data Pipelines source).\n5. **`source_campaign_id`/`source_action_id` are scoped to `source_environment_id`**, which can differ from `workspace_id` — journey actions routinely create profiles across workspaces. There is no campaigns table in this view, and cross-workspace campaign names are not resolvable at all; report the ids (and the source workspace when it differs) rather than guessing names.\n6. **Device registrations** → `devices_added` / `devices_removed`. Never use `device_updates` as a proxy for devices (one device yields many update rows). `device_platform` is known on `added` events only — platform splits are registration splits.\n7. **`from_value`/`to_value`** are free-text and high-cardinality — group by them only for a single attribute over a short period. Attribute names beginning with `!` are reserved/system attributes.\n8. Trends and period-over-period: group metrics by `occurred_date` and compare periods in SQL — there are no delta metrics.\n9. **\"What % of people added each week have attribute X\" / \"how has that changed\"** → Ex 8: one `SEMANTIC_VIEW` call per fact in its own CTE, joined on `person_id` in the outer SQL. Never build temporary segments to measure cohorts.\n\n## Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `profile_lifecycle.profiles_created` | Profile created events in the period |\n| `profile_lifecycle.profiles_deleted` | Profile deleted events in the period |\n| `profile_lifecycle.profiles_merged` | Merge events (two identified profiles combined) |\n| `profile_lifecycle.anonymous_merges` | Anonymous-activity merge events |\n| `profile_lifecycle.net_profile_change` | Created − deleted — growth, NOT total profiles |\n| `attribute_changes.changes` | Attribute change events in the selected scope |\n| `attribute_changes.people_changed` | Distinct profiles with ≥1 attribute change — THE \"how many profiles changed attribute X\" metric |\n| `attribute_changes.attributes_changed` | Distinct attribute names changed |\n| `device_changes.devices_added` | Device registration events |\n| `device_changes.devices_removed` | Device removal events |\n| `device_changes.device_updates` | Device attribute update events (per-attribute — high volume, not a device count) |\n| `device_changes.unique_devices` | Distinct devices with any change event — activity, not a current-device roster |\n| `device_changes.people_with_device_changes` | Distinct profiles with any device change |\n\n## Dimensions\n\n| DIMENSIONS reference | Output column (unaliased) | Description |\n|---|---|---|\n| `profile_lifecycle.workspace_id` | `workspace_id` | **Required filter on every query** |\n| `profile_lifecycle.occurred_date` | `occurred_date` | Date of the lifecycle event. Always constrain to a range; group by it for trends. |\n| `profile_lifecycle.lifecycle_event` | `lifecycle_event` | `created`, `deleted`, `merged`, `anonymous_merged`. Prefer the named metrics over filtering this. |\n| `profile_lifecycle.person_id` | `person_id` | Profile the event relates to (surviving profile for merges). High cardinality — scope tightly. |\n| `profile_lifecycle.source_type` | `source_type` | Primary ingress source that created/deleted the profile — e.g. `track.customer.io` (track API/SDKs/CDP ingest), `attribute_update_action` (journey action), `import` (CSV/SQL import). THE dimension for \"where do new profiles come from\". `created`/`deleted` only — NULL on merges and on rows from before the source-attribution release. |\n| `profile_lifecycle.source_subtype` | `source_subtype` | Subtype qualifier of the primary source, when the ingress set one. NULL otherwise. |\n| `profile_lifecycle.source_environment_id` | `source_environment_id` | Workspace the source acted FROM — can differ from `workspace_id` (cross-workspace journey actions). `source_campaign_id`/`source_action_id` are scoped to THIS id. |\n| `profile_lifecycle.source_campaign_id` | `source_campaign_id` | Campaign the source acted for, scoped to `source_environment_id` (NOT necessarily this workspace). Cross-workspace campaign names are not resolvable. |\n| `profile_lifecycle.source_action_id` | `source_action_id` | Campaign action (step) the source acted for, scoped to `source_environment_id`. |\n| `profile_lifecycle.source_import_id` | `source_import_id` | CSV/SQL import that created or deleted the profile. NULL when the source was not an import. |\n| `profile_lifecycle.cdp_source_id` | `cdp_source_id` | Data Pipelines (CDP) source id. NULL unless the event arrived via a Data Pipelines source. |\n| `profile_lifecycle.cdp_source_type` | `cdp_source_type` | Data Pipelines source type, e.g. `javascript`, `snowflake`, `http`. NULL when not via Data Pipelines. |\n| `profile_lifecycle.cdp_source_name` | `cdp_source_name` | Data Pipelines source display name as configured in the product. NULL when not via Data Pipelines. |\n| `attribute_changes.workspace_id` | `workspace_id` | Required filter on every attribute-change query. |\n| `attribute_changes.occurred_date` | `occurred_date` | Date of the change. ALWAYS a tight range — highest-volume fact in the warehouse. |\n| `attribute_changes.attribute_name` | `attribute_name` | Attribute that changed (e.g. `email`, `plan`). Workspace vocabulary; `!`-prefixed = reserved/system. Filter by this for \"changed attribute X\". |\n| `attribute_changes.from_value` | `from_value` | Value before the change (empty when previously unset). High cardinality. |\n| `attribute_changes.to_value` | `to_value` | Value after the change (empty when cleared). High cardinality. |\n| `attribute_changes.person_id` | `person_id` | Profile whose attribute changed. Group by to list WHO — combine with `attribute_name` + date range. |\n| `device_changes.workspace_id` | `workspace_id` | Required filter on every device query. |\n| `device_changes.occurred_date` | `occurred_date` | Date of the device change. Always constrain to a range. |\n| `device_changes.change_type` | `change_type` | `added`, `updated`, `removed`. Prefer the metrics over filtering this. |\n| `device_changes.device_attribute` | `device_attribute` | Device attribute that changed (`updated` events only), e.g. `app_version`, `push_enabled`. |\n| `device_changes.device_platform` | `device_platform` | Platform (e.g. `ios`, `android`). Known on `added` events only — NULL on updates/removes. |\n| `device_changes.person_id` | `person_id` | Profile that owns the device. High cardinality — scope tightly. |\n\nOutput columns are always named after the dimension, so outer SELECT/WHERE/GROUP BY clauses say `person_id` — never `customer_internal_id`, which is the underlying fact-table column and invisible to the outer query (`invalid identifier` on compile).\n\n## Submitting queries\n\nSame endpoint and rules as `deliverability.md` § Submitting queries, including its five-point pre-flight — heredoc + `--arg`, never hand-escape, aggregate in SQL; members only from this file's tables, never guessed names (`000904`); `table.member` only inside the SEMANTIC_VIEW parens, plain output names outside, and the outer query touches only requested columns (`000904`); aggregate filters in `HAVING` or a wrapping CTE (never the outer WHERE — `000990`/`002035`); GROUP BY covering every non-aggregated selected column when re-aggregating (`000979`); one fact table per call, related dimensions only (`010959`); fix-and-retry on compilation errors — never resubmit unchanged SQL, never fall back to another data source:\n\n```bash\nSQL=$(cat \u003c\u003c'EOF'\nSELECT profiles_created, profiles_deleted, net_profile_change\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_changes\n    METRICS    profile_lifecycle.profiles_created,\n               profile_lifecycle.profiles_deleted,\n               profile_lifecycle.net_profile_change\n    DIMENSIONS profile_lifecycle.workspace_id\n    WHERE profile_lifecycle.occurred_date \u003e= DATEADD('day', -20, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nEOF\n)\ncio api /v1/environments/$ENV_ID/snowflake_query -X POST --timeout 120s \\\n  --arg query=\"$SQL\" --json '{query: $query}'\n```\n\nThe heredoc delimiter must be quoted (`\u003c\u003c'EOF'`) so `$WORKSPACE_ID` reaches Snowflake literally. Results cap at 10,000 rows (`\"truncated\": true`). `--timeout 120s` is required: the CLI's 30s default dies with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` while the server is still working — resend the same SQL with the flag, don't narrow. Only a warehouse-side 90s statement timeout means the query is too slow — then narrow the date range (and add `attribute_name`).\n\n## Examples\n\n### 1. Profiles created / deleted in the past 20 days\n\nThe query above. Report growth with the merge caveat (merges also reduce totals, with no `deleted` event).\n\n### 2. How many profiles changed attribute \"plan\" in the past 15 days\n\n```sql\nSELECT people_changed, changes\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_changes\n    METRICS    attribute_changes.people_changed,\n               attribute_changes.changes\n    DIMENSIONS attribute_changes.workspace_id,\n               attribute_changes.attribute_name\n    WHERE attribute_changes.occurred_date \u003e= DATEADD('day', -15, CURRENT_DATE())\n      AND attribute_changes.attribute_name = 'plan'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n```\n\n`people_changed` is the headline (\"N profiles changed plan\"); `changes` shows repeat churn on the same profiles.\n\n### 3. Most-changed attributes this week\n\n```sql\nSELECT attribute_name, changes, people_changed\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_changes\n    METRICS    attribute_changes.changes,\n               attribute_changes.people_changed\n    DIMENSIONS attribute_changes.attribute_name,\n               attribute_changes.workspace_id\n    WHERE attribute_changes.occurred_date \u003e= DATEADD('day', -7, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY changes DESC\nLIMIT 20\n```\n\n### 4. What did attribute \"plan\" change FROM and TO (single attribute, short window)\n\nAdd `attribute_changes.from_value` and `attribute_changes.to_value` to DIMENSIONS of Ex 2 and group `changes` by them. Only ever for one attribute over a short period.\n\n### 5. Device registrations by platform this month\n\n```sql\nSELECT device_platform, devices_added\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_changes\n    METRICS    device_changes.devices_added\n    DIMENSIONS device_changes.device_platform,\n               device_changes.workspace_id\n    WHERE device_changes.occurred_date \u003e= DATE_TRUNC('month', CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY devices_added DESC\n```\n\n### 6. Profile growth trend\n\nGroup `profiles_created` / `profiles_deleted` / `net_profile_change` by `occurred_date` (or DATE_TRUNC to weeks in the outer query — these metrics are sum-safe) over the window.\n\n### 7. What is the source of most new profiles (past 7 days)\n\n```sql\nSELECT source_type, profiles_created\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_changes\n    METRICS    profile_lifecycle.profiles_created\n    DIMENSIONS profile_lifecycle.source_type,\n               profile_lifecycle.workspace_id\n    WHERE profile_lifecycle.occurred_date \u003e= DATEADD('day', -7, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY profiles_created DESC\n```\n\nReport the NULL row as \"unknown (older data or no source recorded)\" — never as a source. To name which Data Pipelines source, add `profile_lifecycle.cdp_source_name`; to name which import, add `profile_lifecycle.source_import_id`. Same shape with `profiles_deleted` answers \"what is deleting profiles\".\n\n### 8. Attribute coverage by signup cohort\n\n\"What % of people added each week have a `timezone`\", \"how has that changed\", \"people added in the last 7 days vs a week 60 days ago\". The facts never join inside one `SEMANTIC_VIEW` call, so each gets its own CTE and the outer SQL joins them on `person_id`:\n\n```sql\nWITH created AS (\n  SELECT person_id, DATE_TRUNC('week', MIN(occurred_date)) AS signup_week\n  FROM SEMANTIC_VIEW(\n      ANALYTICS.CIO_AGENT.sv_profile_changes\n      DIMENSIONS profile_lifecycle.person_id, profile_lifecycle.occurred_date,\n                 profile_lifecycle.workspace_id\n      WHERE profile_lifecycle.lifecycle_event = 'created'\n        AND profile_lifecycle.occurred_date \u003e= DATEADD('day', -70, CURRENT_DATE())\n  )\n  WHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  GROUP BY person_id\n),\nhas_tz AS (\n  SELECT DISTINCT person_id\n  FROM SEMANTIC_VIEW(\n      ANALYTICS.CIO_AGENT.sv_profile_changes\n      DIMENSIONS attribute_changes.person_id, attribute_changes.workspace_id\n      WHERE attribute_changes.attribute_name = 'timezone'\n        AND attribute_changes.to_value \u003c\u003e ''\n        AND attribute_changes.occurred_date \u003e= DATEADD('day', -70, CURRENT_DATE())\n  )\n  WHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n)\nSELECT c.signup_week, COUNT(*) AS people_added, COUNT(h.person_id) AS with_timezone,\n       ROUND(COUNT(h.person_id) * 100.0 / COUNT(*), 1) AS pct\nFROM created c LEFT JOIN has_tz h ON h.person_id = c.person_id\nGROUP BY c.signup_week ORDER BY c.signup_week\n```\n\n- A new profile starts with no attributes, so the first value it receives — including one sent on the creating identify call — is an attribute change with an empty `from_value`. A matching change row means **the attribute was set at some point**, not that it is set now: say so, and note a value later cleared still counts. Deleted profiles stay in their cohort, and merges can undercount: `person_id` is the surviving profile, so an attribute set on a profile later merged into another stays under the old id.\n- `DATE_TRUNC('week', …)` gives calendar weeks, and the current one is partial. For rolling windows (\"last 7 days\", \"the week 60 days ago\"), keep `MIN(occurred_date) AS signup_date` in the `created` CTE and label cohorts with `CASE WHEN signup_date \u003e= DATEADD('day', -7, CURRENT_DATE()) … END` instead, so each cohort covers exactly the days asked for.\n- Start the attribute window on the same date as the cohort window — never later, or the earliest cohort loses the values set at signup — and always filter `attribute_name`; attribute changes are the highest-volume fact.\n- The share across the whole base *today* is current state: the attribute count from `profile_state.md` over the People total from `customers.md`, not this query.\n- **Why not segments:** each cohort needs two segments, a create call each, and polling until they build — about ten tool calls per cohort, enough to run the turn out before the answer is written. They stay in the workspace afterwards, and with live-data edits off you cannot delete them.\n\n## Answering\n\nState the time window and, on every count, that it is CHANGE events in the period — not a current total. Growth answers carry the merge caveat. All timestamps are US Pacific wall-clock.\n\n## What This Skill Cannot Answer\n\n- **Current totals** — \"how many profiles exist\", \"how many devices are registered right now\": netting full history is unsupported. Use the People page / profile APIs.\n- **Failed attribute changes** — `pipeline_health.md` (`sv_pipeline_health`).\n- **Attribute values across the whole base** (\"how many profiles have \u003cattribute\u003e = \u003cvalue\u003e\", \"profiles per \u003cattribute\u003e value\") — that is current state, not changes: `profile_state.md` (`sv_profile_state`).\n- **Who a profile merged WITH** — only the surviving profile is carried.\n- **Sources of merges** — the ingress source dimensions are populated on `created`/`deleted` events only, never merges.\n- **Sources of profiles created before the source-attribution release (2026-08-18)** — NULL until the planned backfill; the NULL bucket also covers events that carried no source. Say \"unknown\", don't paginate activity logs to fill the gap.\n- **Cross-workspace campaign names** — `source_campaign_id` is scoped to `source_environment_id`; when that differs from `workspace_id` the campaign name cannot be resolved.\n- **Real-time data** — ~1–5 minute staleness from the event stream.\n","profile_state.md":"---\nminimum_plan: essentials\n---\n\n# Profile Attribute State Analytics\n\nAnswer questions about current profile attribute values using the `sv_profile_state` semantic view in `ANALYTICS.CIO_AGENT`: how many profiles hold each value of an attribute, how many have an attribute set, how many meet a numeric threshold, and which attributes are most populated.\n\nEach workspace names its own attributes. Filter `attribute_name` by the name the user gives. The examples in this file use attributes such as `plan`, `country`, and `lifetime_value`.\n\n**This is CURRENT STATE, never a change log.** One row per (profile, attribute) holding the value as it stood at the last daily build (early morning, US Pacific). There is no date range: \"changed attribute X in \u003cperiod\u003e\" goes to `profile_changes.md`.\n\n**Rows of deleted and merged-away profiles stay, unflagged.** Every profile count here can exceed the People page total, and \"how many profiles exist\" is not answerable from this view.\n\nRouting: attribute changes over a period → `profile_changes.md`. One person's attributes, or one exact live count with a single condition → `customers.md` (live, and it excludes deleted profiles). A person's role within an account → `object_state.md` (relationship attributes). Building an audience from a condition → `segments.md`.\n\n## The semantic view at a glance\n\n| Logical table | Base table | Grain (one row per…) | Use for | Key caveat |\n|---|---|---|---|---|\n| `profile_attributes` | `dim_profile_attributes` | **(profile, attribute)** | value distributions, attribute coverage, multi-attribute counts | over 40 billion rows; laid out by workspace then attribute name — **filter `attribute_name` on every question that names one** |\n\nA cleared attribute keeps its row with `attribute_value` NULL and `is_cleared` true; an attribute never set has no row.\n\n**PRODUCT SURFACE:** the Attributes panel on a person's profile. Values arrive from identify calls, imports, UI edits, journey \"update attribute\" actions, and merges.\n\n## When to use this view, and when not\n\n- **Use it** for breakdowns across values or attributes — \"profiles per plan\", \"top 20 countries\", \"how many have both plan = pro and country = DE\", numeric ranges — which the profile APIs can only answer by paging through every profile.\n- **Use `customers.md` instead** for one person's attributes, and for a single exact count with one condition when the user needs today's number: the filtered search total is live and leaves out deleted profiles. A lookup of one person here reads the whole workspace, because `person_id` is not part of the layout.\n\n## Choosing the right query\n\n1. **Always filter `attribute_name`** when the question names an attribute. A query without it reads every attribute of every profile in the workspace — about 20 seconds on the largest workspaces.\n2. **\"How many profiles have \u003cattribute\u003e = \u003cvalue\u003e\" / \"profiles per \u003cattribute\u003e value\"** → `profiles_with_value` grouped by `attribute_value` with an `attribute_name` filter (Ex 1). `profiles_with_value` skips cleared values for you.\n3. **\"How many profiles have \u003cattribute\u003e set at all\"** → `profiles_with_value` with only the `attribute_name` filter (Ex 2). `profiles_cleared` counts those explicitly blanked.\n4. **Values are text.** Numbers, booleans, and dates are stored as strings: compare `'true'`, not `TRUE`, and use `TRY_TO_NUMBER(attribute_value)` in the outer SQL for numeric ranges (Ex 3). Values are case-sensitive as stored.\n5. **Several conditions on one profile** (\"plan = pro AND country = DE\") → one call filtered to the attribute names involved, grouped by `person_id` in the outer SQL with `HAVING` (Ex 4). Never build temporary segments to count combinations.\n6. **High-cardinality attributes** (`email`, ids, free text) — count them, never group by their value.\n7. **Attribute names beginning with `!`** are reserved or system attributes.\n8. **Share of the base** (\"what % of profiles have a timezone\") → the numerator from this view and the denominator from `customers.md` (the People total). Say the two sources differ: this view includes deleted profiles, the People total does not.\n\n## Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `profile_attributes.profiles_with_value` | Distinct profiles whose attribute holds a value now — THE \"how many profiles have \u003cattribute\u003e = \u003cvalue\u003e\" metric |\n| `profile_attributes.current_values` | Attribute values set now, one per (profile, attribute) — a count of values, not profiles |\n| `profile_attributes.attributes_in_use` | Distinct attribute names at least one profile holds a value for |\n| `profile_attributes.profiles_cleared` | Distinct profiles whose attribute was explicitly blanked and not set again |\n\n## Dimensions\n\n| DIMENSIONS reference | Output column (unaliased) | Description |\n|---|---|---|\n| `profile_attributes.workspace_id` | `workspace_id` | **Required filter on every query** |\n| `profile_attributes.attribute_name` | `attribute_name` | Profile attribute name (e.g. `email`, `plan`). Workspace vocabulary. Filter it on every question that names one. |\n| `profile_attributes.attribute_value` | `attribute_value` | Current value as text. NULL when cleared. Group by it only within one `attribute_name`. |\n| `profile_attributes.person_id` | `person_id` | Profile holding the attribute — the same internal id as in `profile_changes.md`. Includes deleted and merged-away profiles. |\n| `profile_attributes.is_cleared` | `is_cleared` | True when the attribute was explicitly set to empty. |\n\nOutput columns are always named after the dimension, so outer SELECT/WHERE/GROUP BY clauses say `person_id` — never `customer_internal_id`, which is the underlying column and invisible to the outer query (`invalid identifier` on compile). `person_id` returns ids only; resolve one through `customers.md` when the user needs a name or email.\n\n## Submitting queries\n\nSame endpoint and rules as `deliverability.md` § Submitting queries, including its five-point pre-flight — heredoc + `--arg`, never hand-escape, aggregate in SQL; members only from this file's table, never guessed names (`000904`); `table.member` only inside the SEMANTIC_VIEW parens, plain output names outside, and the outer query touches only requested columns (`000904`); aggregate filters in `HAVING` or a wrapping CTE (never the outer WHERE — `000990`/`002035`); GROUP BY covering every non-aggregated selected column when re-aggregating (`000979`); fix-and-retry on compilation errors — never resubmit unchanged SQL, never fall back to another data source:\n\n```bash\nSQL=$(cat \u003c\u003c'EOF'\nSELECT attribute_value AS plan, profiles_with_value\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_state\n    METRICS    profile_attributes.profiles_with_value\n    DIMENSIONS profile_attributes.attribute_value,\n               profile_attributes.workspace_id\n    WHERE profile_attributes.attribute_name = 'plan'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY profiles_with_value DESC\nLIMIT 20\nEOF\n)\ncio api /v1/environments/$ENV_ID/snowflake_query -X POST --timeout 120s \\\n  --arg query=\"$SQL\" --json '{query: $query}'\n```\n\nThe heredoc delimiter must be quoted (`\u003c\u003c'EOF'`) so `$WORKSPACE_ID` reaches Snowflake literally. Results cap at 10,000 rows (`\"truncated\": true`). `--timeout 120s` is required: the CLI's 30s default dies with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` while the server is still working — resend the same SQL with the flag, don't narrow. Only a warehouse-side 90s statement timeout means the query is too slow — then add the `attribute_name` filter it is missing.\n\n## Examples\n\n### 1. How many profiles are on each plan\n\nThe query above. Filter `profile_attributes.attribute_value = 'pro'` inside the parens instead of grouping for one value's count.\n\n### 2. How many profiles have an attribute set\n\n```sql\nSELECT profiles_with_value, profiles_cleared\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_state\n    METRICS    profile_attributes.profiles_with_value,\n               profile_attributes.profiles_cleared\n    DIMENSIONS profile_attributes.workspace_id\n    WHERE profile_attributes.attribute_name = 'timezone'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n```\n\n### 3. How many profiles have a numeric attribute over a threshold\n\n```sql\nSELECT COUNT(DISTINCT person_id) AS profiles\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_state\n    DIMENSIONS profile_attributes.person_id,\n               profile_attributes.attribute_value,\n               profile_attributes.workspace_id\n    WHERE profile_attributes.attribute_name = 'lifetime_value'\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND TRY_TO_NUMBER(attribute_value) \u003e 1000\n```\n\nValues that don't parse as numbers drop out; say so if the attribute mixes text and numbers.\n\n### 4. How many profiles match two conditions at once\n\n```sql\nSELECT COUNT(*) AS profiles\nFROM (\n  SELECT person_id\n  FROM SEMANTIC_VIEW(\n      ANALYTICS.CIO_AGENT.sv_profile_state\n      DIMENSIONS profile_attributes.person_id,\n                 profile_attributes.attribute_name,\n                 profile_attributes.attribute_value,\n                 profile_attributes.workspace_id\n      WHERE profile_attributes.attribute_name IN ('plan', 'country')\n  )\n  WHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  GROUP BY person_id\n  HAVING COUNT_IF(attribute_name = 'plan' AND attribute_value = 'pro') \u003e 0\n     AND COUNT_IF(attribute_name = 'country' AND attribute_value = 'DE') \u003e 0\n)\n```\n\n### 5. Which attributes are most populated\n\n```sql\nSELECT attribute_name, profiles_with_value\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_profile_state\n    METRICS    profile_attributes.profiles_with_value\n    DIMENSIONS profile_attributes.attribute_name,\n               profile_attributes.workspace_id\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY profiles_with_value DESC\nLIMIT 25\n```\n\nThe one shape that has no `attribute_name` filter, so it reads the whole workspace: expect it to be the slowest query in this file.\n\n## Answering\n\nSay every count is as of the last daily build, not live, and that it counts profiles with this value on record, deleted and merged-away profiles included. When the user needs an exact current number for one condition, run the filtered profile search in `customers.md`.\n\n## What This Skill Cannot Answer\n\n- **How many profiles exist** — this view has no roster; use the People total in `customers.md`.\n- **One person's attributes** — `customers.md` returns them live; this view would scan the whole workspace for one person.\n- **Changes over a period** and earlier values — `profile_changes.md` (`sv_profile_changes`).\n- **Segment membership** — `segments.md`.\n- **Relationship attributes** (a person's role in an account) — `object_state.md`.\n- **Live state** — the view is up to a day old.\n","push.md":"# Push Notifications — Authoring Playbook\n\nHow to write the content of a push notification. Push is **not** a Design Studio node — its content lives on a `push` template, the same way SMS content lives on a `twilio` template. You set it by updating the template attached to a `push_action` (automation), a broadcast, or a transactional message. For template CRUD and the endpoint shape, see **templates.md**. For configuring push credentials (APNs key / FCM service account), see **channels.md**.\n\n\u003e **Push uses Liquid.** The title, message, link, image URL, and custom data all support Liquid templating. Read `recipes/liquid_syntax.md` for the full syntax reference, filters, control flow, and pitfalls.\n\n## The push template fields\n\nA `push` template stores its content across a few flat template fields plus a `body_json` blob — it is **not** one big JSON object. Set them on the template:\n\n```\nPUT /v1/environments/:eid/templates/:template_id\n```\n\n| Field | What it is | Notes |\n|-------|------------|-------|\n| `subject` | Push **title** (the bold heading) | Optional. Supports Liquid. See title fallback behavior below. |\n| `body` | Push **message** (the body text) | The main line(s) of text. Supports Liquid. |\n| `image_url` | **Rich push image** | An `https://` URL (including file extension). Shows as a large image on supported devices. Supports Liquid. |\n| `recipient` | Which device(s) to target | Defaults to `all` (every device the person has on the message's platform). `last_used` sends only to the most recently used of those devices. Rarely set by hand. |\n| `body_json` | **Rich-push extras**: `link`, `sound`, `custom_data`, and per-platform `android` / `ios` priority blocks | See the `body_json` section. Supports Liquid inside its string values. |\n| `editor` | `\"rich\"` or `\"json\"` | `rich` = the structured fields above (the normal authoring mode). `json` = the **custom payload** escape hatch (see below). |\n\n\u003e **`subject` is the title, `body` is the message.** This trips people up because for email `subject` means the inbox subject line. For push there is no separate \"title\" field — the title is the template's `subject`.\n\n### Minimal example (rich editor)\n\n```json\n{\n  \"template\": {\n    \"subject\": \"Your order shipped\",\n    \"body\": \"{{customer.first_name}}, your package is on its way!\",\n    \"editor\": \"rich\"\n  }\n}\n```\n\n### Full rich-push example\n\n```json\n{\n  \"template\": {\n    \"subject\": \"Your order shipped 📦\",\n    \"body\": \"{{customer.first_name}}, track your package now.\",\n    \"image_url\": \"https://cdn.example.com/shipped.png\",\n    \"editor\": \"rich\",\n    \"body_json\": \"{\\\"link\\\": \\\"myapp://orders/{{event.order_id}}\\\", \\\"sound\\\": \\\"default\\\", \\\"custom_data\\\": \\\"{\\\\\\\"order_id\\\\\\\": \\\\\\\"{{event.order_id}}\\\\\\\", \\\\\\\"screen\\\\\\\": \\\\\\\"order_detail\\\\\\\"}\\\", \\\"android\\\": {\\\"priority\\\": \\\"high\\\"}, \\\"ios\\\": {\\\"apns-priority\\\": \\\"10\\\"}}\"\n  }\n}\n```\n\n## The `body_json` shape (rich editor)\n\nIn the rich editor, `body_json` holds the structured extras. **`body_json` is sent as a stringified JSON value (a JSON string), not a nested object** — the templates API types this field as a string and rejects a raw object, so JSON-encode the whole blob when you set it (see the examples; with the `cio` CLI, build the object and pipe it through `| tostring`). The fields that are read are exactly:\n\n| `body_json` field | Type | Purpose |\n|-------------------|------|---------|\n| `link` | string | **Deep link or web URL.** Where the app goes when the notification is tapped. Supports Liquid. |\n| `sound` | string | Notification sound. When authoring a template only `\"default\"` (play the default sound) or `\"\"` / omitting the field (no sound) are accepted — **`\"none\"` is rejected** by template validation. |\n| `custom_data` | string | A **stringified JSON object** of your own key/value data, delivered alongside the notification for your app to read. It must be a JSON string, not a nested object (see pitfalls). Supports Liquid inside the string. |\n| `android` | object | Android/FCM-specific block. Only `priority` is read — `\"normal\"` (default) or `\"high\"`. |\n| `ios` | object | iOS/APNs-specific block. Only `apns-priority` is read — `\"10\"` (immediate, default), `\"5\"` (considerate), or `\"1\"` (power-saving). |\n\nAnything else you put in the rich `body_json` (other than these keys) is ignored. The `android` / `ios` blocks here are **only** used to extract delivery priority — they do not become the payload. To author a raw platform payload, use the custom payload (`json`) editor instead.\n\n### Where the fields end up on-device\n\nWhen Customer.io's SDK delivers the notification, the title (`subject`) and message (`body`) become the visible alert. The `image_url` and `link` are delivered inside a `CIO.push` object in the payload (`CIO.push.image`, `CIO.push.link`), and `custom_data` is merged into the payload **outside** the `CIO` and `aps` objects so your app can read it independently. You do not assemble that structure yourself in the rich editor — you set the flat fields and the platform builds the APNs/FCM payload.\n\n## Title and message length / truncation\n\n- **Title (`subject`) is optional.** If you omit it: **Android** shows your app name in its place; **iOS** omits the title line entirely. If a title matters on iOS, set one.\n- There is no per-field character cap enforced by Customer.io, but device OSes **truncate** long titles and bodies with an ellipsis in the collapsed notification. Keep titles short (a few words) and front-load the important part of the body — the first ~3–4 words of each are what reliably show on a locked screen.\n- **Hard payload limit: 4KB (4096 bytes) total**, enforced by both APNs and FCM. This is the whole payload — title, body, image URL, link, **and** `custom_data` all count against it. Large `custom_data` is the usual culprit when a push fails to send.\n\n## Deep links vs web links\n\nThe `link` field accepts either:\n\n- **Deep link** — an app-scheme URL like `myapp://orders/123` that routes to a screen inside the app. Format and scheme are defined by the customer's app; Customer.io just passes the string through.\n- **Web/universal link** — an `https://` URL. Opens in the browser, or in-app if the app is configured for universal/app links.\n\nTapping the notification triggers the link. Handling the destination (routing to the right screen, opening the browser) requires the Customer.io SDK or the customer's own handling of the payload — that is app-side work, not something you configure in the template.\n\n## Custom data payloads\n\n`custom_data` is a **stringified JSON object** of arbitrary key/value pairs your app reads directly. Use it to pass IDs, screen names, or feature flags that drive in-app behavior on tap:\n\n```json\n\"custom_data\": \"{\\\"campaign\\\": \\\"winback\\\", \\\"product_id\\\": \\\"{{event.product_id}}\\\"}\"\n```\n\nIt is delivered outside the `CIO`/`aps` objects, so it does not collide with Customer.io's own keys. Remember it counts toward the 4KB limit.\n\n## Rich push: images and action buttons\n\n- **Images** are first-class: set `image_url` (rich editor). Rendering the image on-device requires Customer.io's rich-push SDK setup (a notification service extension on iOS).\n- **Action buttons are NOT a built-in field for apps.** Customer.io's SDKs support deep links and images out of the box, but not action buttons, badges, or grouping. To send those to iOS or Android you must use the **custom payload** editor and write the raw platform payload, plus add your own app-side handling code.\n\n## Platform differences (iOS / APNs vs Android / FCM)\n\n| Behavior | iOS (APNs) | Android (FCM) |\n|----------|-----------|----------------|\n| Missing title | Title line omitted | Falls back to app name |\n| Priority field | `ios.apns-priority`: `10` / `5` / `1` | `android.priority`: `high` / `normal` |\n| Default sound | `sound: \"default\"` (vibrates if ringer off / sound permission denied) | `sound: \"default\"` |\n| Raw payload editor tab | iOS tab in custom payload | Android tab in custom payload |\n\nThe custom payload (`json`) editor has separate iOS and Android tabs precisely because the raw payloads differ.\n\n## Web push (browsers)\n\nFor native web push (browser notifications), see **web_push_setup.md** for setup and **web_push.md** for browser content, targeting and test sends, when available. Otherwise, use webhooks to a third-party web push provider, or in-app messages for visitors while they are on the site.\n\n## Silent / data-only and custom payloads (`editor: \"json\"`)\n\nFor anything beyond title + body + image + link + sound + priority — silent/background (data-only) pushes, badges, notification grouping, action buttons — switch the template to the **custom payload** editor (`editor: \"json\"`) and provide the raw platform payloads. In this mode `body_json` is a **two-key object of stringified payloads**, one per platform:\n\n```json\n{\n  \"template\": {\n    \"editor\": \"json\",\n    \"body_json\": \"{\\\"ios\\\": \\\"{\\\\\\\"aps\\\\\\\": {\\\\\\\"content-available\\\\\\\": 1}, \\\\\\\"sync_now\\\\\\\": true}\\\", \\\"android\\\": \\\"{\\\\\\\"data\\\\\\\": {\\\\\\\"sync_now\\\\\\\": \\\\\\\"true\\\\\\\"}}\\\"}\"\n  }\n}\n```\n\n- `body_json` itself is sent as a JSON **string** (as in the rich editor); inside it, `ios` and `android` are each *also* a **stringified JSON payload** (a string, not a nested object) — hence the doubled escaping above. Liquid inside them is rendered before sending. With the `cio` CLI, build the object and `| tostring` it rather than escaping by hand.\n- A **silent / data-only** push omits the visible alert: on iOS use `aps.content-available: 1` with no `alert`; on Android send only a `data` block (no `notification`). The app wakes in the background to process it.\n- A **silent / data-only** push omits the visible alert: on iOS use `aps.content-available: 1` with no `alert`; on Android send only a `data` block (no `notification`). The app wakes in the background to process it.\n- A custom payload **replaces** the structured fields — you own the entire APNs/FCM body, so you are responsible for keeping it valid and under 4KB.\n\n## Personalization with Liquid\n\nEvery content field supports Liquid: `subject`, `body`, `image_url`, and the string values inside `body_json` (`link`, `custom_data`, and the platform payloads in `json` mode). Always guard attributes that may be missing so the push still renders:\n\n```json\n{\n  \"template\": {\n    \"subject\": \"{{customer.first_name | default: 'there'}}, you left something behind\",\n    \"body\": \"Your {{event.product_name | default: 'cart'}} is waiting.\",\n    \"editor\": \"rich\",\n    \"body_json\": \"{\\\"link\\\": \\\"myapp://cart\\\"}\"\n  }\n}\n```\n\nA Liquid render error on a push field fails the send — use `default` filters for anything not guaranteed to be set.\n\n## Copy patterns\n\n- **Lead with value, not the brand.** \"Your refund of $42 is on its way\" beats \"Acme Bank: Notification.\"\n- **One idea per push.** The body shows a line or two on a lock screen — don't pack multiple asks.\n- **Title = hook, body = detail.** Keep the title to a few words; let the body carry specifics.\n- **Make the tap obvious.** If you set a `link`, the copy should imply tapping does something (\"Tap to track\", \"See your match\").\n- **Personalize early.** A name or order detail in the first few words survives truncation; one buried at the end won't show.\n- **Mind quiet hours and priority.** Reserve `high` / `apns-priority: 10` for time-sensitive sends; routine marketing can use normal priority.\n\n## Common mistakes checklist\n\n1. **Passing `body_json` as a nested object.** The whole `body_json` field is sent as a **JSON string**; the templates API types it as a string and rejects a raw object. JSON-encode it (or `| tostring` with the `cio` CLI).\n2. **Putting the title/message inside `body_json`.** The title is the template's `subject` and the message is its `body` — flat fields, not keys in `body_json`. Only `link`, `sound`, `custom_data`, and the `android`/`ios` priority blocks live in the rich `body_json`.\n3. **`custom_data` as a nested object instead of a string.** It must be a **stringified** JSON object (`\"custom_data\": \"{\\\"k\\\":\\\"v\\\"}\"`), not `\"custom_data\": {\"k\":\"v\"}`. A raw object is ignored.\n3. **Expecting action buttons / badges from the rich editor.** Those aren't built-in fields. Use the custom payload (`json`) editor and your own app handling.\n4. **Exceeding 4KB.** Title + body + link + image + `custom_data` share one 4KB budget across APNs and FCM. Trim `custom_data` first.\n5. **Forgetting a title and assuming it shows.** iOS omits a missing title entirely; Android substitutes the app name. Set `subject` when the title matters.\n6. **Unguarded Liquid.** A missing attribute throws a render error and fails the send. Use `| default: ...`.\n7. **Confusing the two editor modes.** `editor: \"rich\"` reads the structured fields; `editor: \"json\"` expects `body_json` to be `{\"ios\": \"...\", \"android\": \"...\"}` stringified payloads. Mixing them (rich fields under `json`, or raw payloads under `rich`) silently does the wrong thing.\n8. **Hand-setting `recipient`.** It defaults to `all` (every device on the message's platform). Only override it for a deliberate single-device target.\n","query_objects.md":"---\nfeature_flag: query-objects-action\n---\n\n# Query Objects (`query_objects_action`)\n\nA **Query Objects** step finds objects of one custom object type for the person in the journey and saves them, as JSON, to a journey attribute or a profile attribute. Later steps read them in Liquid. It's a data action with one outgoing `continue` edge, wired like any other action (`automations.md`).\n\n## When to use it\n\nUse Query Objects when the data already lives in Customer.io objects: courses, studies, stores, any object type. **Query Collection** (`collection_query_action`, `data_operations.md`) is only for data uploaded as a collection. Liquid's `objects.\u003cslug\u003e` already lists up to 10 of the person's related objects, unfiltered; this step filters them, picks objects the person isn't related to, or keeps up to 50.\n\nIt picks objects by any mix of:\n\n- **fixed values** on object attributes: `level` equals `advanced`;\n- **comparisons** with a value the journey has, from the person's profile, the trigger event or a journey attribute: `level` equals the profile's `preferred_level`;\n- **is in / is not in** a list held in one of those values: `object_id` is in the profile's `recommended_study_ids`;\n- **a list attribute's elements** against those values: studies whose `discipline_ids` hold any of the profile's `followed_discipline_ids`;\n- **relationship scope**: every object of the type, only objects related to the person, or only objects the person isn't related to.\n\nIt can also sort the matches by one of their attributes and keep the first ones, such as the 5 highest-rated courses (see Sorting).\n\n## Create and update\n\nAdd the step with `add_actions` (`automations.md` § Wiring patterns). Its settings are fields on the action:\n\n| Field | Value |\n|---|---|\n| `type` | `\"query_objects_action\"` |\n| `object_type_id` | The object type's numeric ID (`GET .../object_types`, see `customers.md`) |\n| `relationship_scope` | `\"any\"` (the default), `\"related\"` or `\"not_related\"` |\n| `conditions` | Base64 condition array, encoded like branch conditions (`flow_control.md` § Condition Encoding). Omit it to match every object of the type |\n| `limit` | **Required**, 1–50 |\n| `order_by` | Sorts the matches by this attribute, named exactly, before keeping `limit` of them (see Sorting). Omit it for no sort. Never set it with `randomize` |\n| `asc` | `false` puts the highest values first. Ascending is the default (`true` or omitted). Ignored without `order_by` |\n| `randomize` | `true` returns a random sample of the matches. A large type allows it only with `related` scope (see Which objects come back) |\n| `single_result` | `true` saves one object instead of an array |\n| `fields` | Object attribute names each result keeps, up to 100. Omit it to keep every attribute; `object_id` is always kept. For a profile attribute, list only what the message uses (see What the step saves) |\n| `journey_attribute_name` | Saves the results to this journey attribute |\n| `attribute_name` | Saves the results to this profile attribute |\n\n- **Set exactly one destination.** Sending both `journey_attribute_name` and `attribute_name` is a `422`. A step saved with neither, or with no `object_type_id`, saves fine and then fails for every journey that reaches it.\n- `fields` names are exact top-level attribute keys: a dotted name is a literal key, not a path. List a type's attribute names with `GET .../attributes?objectType=:type_id` (`customers.md`).\n- Saving to a profile attribute needs permission to edit people; without it, `attribute_name` has to stay as stored.\n- **`PUT /actions/:id` replaces the whole step** (`automations.md` § Updating Individual Actions), so send every field back. An omitted `conditions` drops the conditions, an omitted `fields` keeps every attribute, an omitted `relationship_scope` becomes `any`, an omitted destination is cleared, an omitted `order_by` drops the sort, an omitted `asc` sorts ascending, and an omitted `limit` is a `422`.\n\n```json\n{\n  \"type\": \"query_objects_action\",\n  \"name\": \"Top 10 recommended studies\",\n  \"object_type_id\": 3,\n  \"relationship_scope\": \"any\",\n  \"conditions\": \"\u003cbase64 of the array below\u003e\",\n  \"limit\": 10,\n  \"randomize\": false,\n  \"single_result\": false,\n  \"fields\": [\"name\", \"university\", \"url\"],\n  \"journey_attribute_name\": \"top_studies\"\n}\n```\n\nIts conditions, before encoding: `[{\"type\": \"attribute_compare\", \"field\": \"object_id\", \"field_source\": \"object_attribute\", \"operator\": \"contained_in_array\", \"value\": \"recommended_study_ids\", \"value_source\": \"customer_attribute\"}]`.\n\n## Conditions\n\n**A named subset is a condition, not another type.** \"EBP studies\", \"open programmes\" and \"premium courses\" each name an attribute value of their type. Before you build the step, read a few of the type's objects to find the attribute that carries the qualifier: `GET .../object_types/:type_id/objects?size=10` returns their attributes, and `GET .../object_attributes_values/:type_id?attribute=\u003ckey\u003e` lists one attribute's values (`customers.md`). Add a condition that keeps every value carrying it: `contained_in_array` with the values comma-separated (`\"value\": \"open,rolling\"`), or `contains` when the shared text appears in no other value. If no attribute clearly carries the qualifier, or several could, ask the user which one does. Never drop a constraint the request states.\n\nThe array is a flat list of leaves, and an object has to match every leaf. Two leaf types work here; any other type is a `422`.\n\n**Fixed value: `object_attribute`**, with `type_id` set to the step's `object_type_id`.\n\n```json\n[{\"type\": \"object_attribute\", \"type_id\": 3, \"field\": \"level\", \"operator\": \"eq\", \"value\": \"advanced\"},\n {\"type\": \"object_attribute\", \"type_id\": 3, \"field\": \"discipline_ids[]\", \"operator\": \"eq\", \"value\": \"7\"}]\n```\n\nThe branch operators apply (`eq`, `exists`, `contains`, `gt`, `lt`, `bw`, `contained_in_array`, the timestamp operators), and a `!` prefix negates one (`!eq` is not equals). `field` can be a nested path (`location.city`) or a list's elements (`discipline_ids[]`).\n\n- `eq` and `exists` on a top-level attribute are answered from the object index right away.\n- Every other fixed-value condition is kept by a segment of objects the step maintains. It builds once the automation starts (a draft builds nothing). A journey that reaches the step while it builds waits and retries, and fails if it's still building after several attempts. One object type allows 100 of these conditions across all its Query Objects steps.\n- Refused: equals on `object_id` or `cio_object_id` (use a comparison or `exists`), an `eq` value over 767 bytes, and attributes that start with `_`, apart from `_created_in_customerio_at` with an operator other than `eq` or `exists`.\n\n**Comparison: `attribute_compare`**, with the object attribute in `field` and the journey's value in `value`.\n\n```json\n[{\"type\": \"attribute_compare\", \"field\": \"level\", \"field_source\": \"object_attribute\",\n  \"operator\": \"eq\", \"value\": \"preferred_level\", \"value_source\": \"customer_attribute\"}]\n```\n\n- `field_source` is always `object_attribute`. `field` is a top-level attribute, `object_id`, `cio_object_id` or a list's elements (`discipline_ids[]`, below): not another nested path, and not a name that starts with `_`.\n- `value_source` is `customer_attribute` (the profile), `event_attribute` (the triggering event's data, which only automations triggered by an event, a form or a webhook have) or `journey_attribute`. `value` names the attribute and can be a path (`address.city`, `picked[].level`).\n- **Before comparing against a profile attribute, read one real person's value of it.** The attribute list shows no values. `GET .../customers?filters=...` with `exists` on the attribute (`customers.md` § Filtered People List URLs) lists people who have it, with their `attributes`.\n- **When the value holds a JSON object, or a list of objects, compare against a path into it**: `current_study.object_id` for `{\"name\":\"Data Science\",\"object_id\":100002}`, or `top_studies[].object_id` with is in for a list of objects. The whole JSON text equals no object's value. Results a Query Objects step saved are JSON text, in a profile or a journey attribute alike.\n- Operators: `eq`, `!eq`, `contained_in_array` (is in) and `!contained_in_array` (is not in). Nothing else.\n- The step reads the value each time it runs. A missing or blank value matches nothing, so the step saves no matches (see below). A missing or empty list makes is not in exclude nothing.\n- A list is a JSON array (`[399701, 399702]`) or a comma-separated string, with up to 100 distinct values. A longer list, or a value over 767 bytes, fails the step.\n- Not equals and is not in also match objects that don't have the attribute.\n\n**A list attribute** on the objects matches element by element when `field` is its name followed by `[]`. The API shows a list as its JSON text, such as `\"discipline_ids\": \"[7,12]\"`. Without the `[]`, the whole list is compared as one value, so no element matches.\n\n```json\n[{\"type\": \"attribute_compare\", \"field\": \"discipline_ids[]\", \"field_source\": \"object_attribute\",\n  \"operator\": \"contained_in_array\", \"value\": \"followed_discipline_ids\", \"value_source\": \"customer_attribute\"}]\n```\n\n- `contained_in_array` against a profile, event or journey list keeps objects whose list holds any of its values: here, studies in any discipline the person follows. Results follow the person's list, as for any is in.\n- `eq` against a single value keeps objects whose list holds it: `discipline_ids[]` equals the event's `discipline_id`.\n- `!contained_in_array` and `!eq` keep objects whose list holds none of the values, objects without the list included.\n- As for any comparison, a missing or blank value matches nothing, so the step saves `\"\"`, and a missing or empty list makes `!contained_in_array` exclude nothing.\n- `7` and `\"7\"` are the same element, and case doesn't matter. A comma-separated string such as `\"7,12\"` isn't a list and has no elements. A field inside each element, such as `disciplines[].id`, is a `422`.\n- Like the conditions a segment keeps, each list's index builds once the automation starts, and counts as one of the type's 100 conditions.\n- **An object whose list has more than 500 elements fails the step** when the step reads it, since lists that long aren't indexed. If the type's lists can grow that long, tell the user.\n\nMatching ignores case, except on `object_id`, which has to match exactly.\n\n**Relationship scope** is the `relationship_scope` field, not a condition:\n\n- `related`: only objects related to the person. Each result also carries the person's relationship attributes under `relationship`.\n- `not_related`: only objects the person isn't related to yet, such as courses they haven't taken.\n- Either one fails the step for a person with more than 10,000 relationships to the type.\n\n## Which objects come back\n\n- **Without `order_by` or `randomize`**, the objects created first come first. With an is in comparison, results follow its list instead: every match for the list's first value, then its second, and so on. So a ranked list of `object_id`s returns its top N in rank order. With two lists, the first one in the conditions sets the order.\n- **With `order_by`**, the step sorts every match by the attribute and keeps the first `limit`, or the first one with `single_result` (see Sorting). The sort sets the order even when an is in comparison is there, so the list only filters.\n- **With `randomize`**, the step returns a uniform random sample of every match, in random order, and ignores the list's order.\n- One query searches up to 3 million objects of the type, or 1.5 million for a random pick. A query that needs more fails rather than returning part of the matches. A random pick with `related` scope searches only the person's related objects.\n\n**A random pick on a large type can't be saved.** When the step picks from every object (`any`) or every unrelated one (`not_related`) of a type that has held 1.5 million objects or more, deleted ones included, the save is a `422` on `randomize`: \"Random results are available on object types with up to 1.5M objects.\" Conditions don't change it, so don't retry. Tell the user what the message says, and ask whether to pick from the person's related objects instead (`related` saves on any type) or to save without `randomize`, saying which order the step then returns.\n\n## Sorting\n\nSort when the request ranks the objects by a value they carry: the top-rated, highest, lowest, cheapest, newest, oldest, most popular or soonest. Set `order_by` to the attribute that holds the value, and `asc` to the direction:\n\n| The request asks for | `asc` |\n|---|---|\n| The highest, most, best, newest, latest or most recent | `false` |\n| The lowest, least, cheapest, oldest, earliest or soonest | `true` |\n\nFor the 5 highest-rated courses: `\"order_by\": \"rating\", \"asc\": false, \"limit\": 5`.\n\n- **Find the attribute before you sort.** Read a few of the type's objects, as for a named subset (see Conditions), to see which attribute holds the value and how it's written, and use its exact name. A name no object has still saves, and the results then come back in the order the objects were created. If no attribute holds the value, or several could, ask the user which one to sort by.\n- `order_by` is one top-level attribute. A nested path (`location.city`), a list's elements (`discipline_ids[]`), `object_id`, `cio_object_id` and names that start with `_`, `_created_in_customerio_at` included, are a `422`. For the newest or oldest objects, sort by a date attribute the objects carry, and ask the user when they have none.\n- **Never set `randomize` with `order_by`.** The pair is a `422`, so leave `randomize` `false`.\n- **A ranked list keeps its own order.** When an is in comparison's list ranks the objects, as in \"the top 10 of their recommended_study_ids\", leave `order_by` out: a sort would replace the list's order.\n- Plain numbers (`1200`, `4.5`) sort numerically, and versions such as `1.10.0` as versions. Any other value sorts as text, alphabetically and ignoring case, so `$1,200` comes before `$950`. Dates sort in time order as Unix timestamps, or as ISO 8601 text (`2027-03-01`) written in one format and time zone. Every number sorts below any text, so on an attribute that mixes them, a descending sort puts the text first. If the values won't sort the way the request means, explain how they'll sort and ask whether to keep the sort.\n- Objects without a value (missing, empty or `null`) come last in either direction. Ties keep the order the objects were created in.\n- A Liquid sort of the saved results reorders only the objects the step already picked, so it can't stand in for `order_by`.\n- Like a condition the step's segment keeps (see Conditions), each attribute a step sorts by counts as one of the type's 100 conditions, and its index builds once the automation starts.\n\n**Two refusals mean the sort can't be saved here.** Both are a `422` on `order_by`:\n\n- \"Sorting is in beta and isn't enabled for this account. Contact support to request access.\" The account doesn't have sorting.\n- \"Sorting is available on object types with up to 1M objects.\" The object type has held more objects than that, deleted ones included.\n\nDon't retry either one or substitute another pick (`randomize`, extra conditions, a Liquid sort). Tell the user what the message says, and ask whether to save the step without a sort, saying which order it then returns (see Which objects come back).\n\n## What the step saves\n\n- An array of objects, or one object with `single_result`. Each has `object_id`, the picked `fields` (or every attribute) and, with `related` scope, `relationship`.\n- Values are typed the way Liquid types profile attributes. Digit strings become numbers (`\"309985\"` becomes `309985`, while `\"02138\"` stays a string), `\"true\"` and `\"false\"` become booleans, and JSON arrays and objects become structures. Decimals stay strings (`\"4.5\"`).\n- **No matches saves `\"\"`, not `[]`.** A later branch's \"is set\" check on the attribute is false, so branch on it to handle an empty result.\n- Results are capped at 100 KiB.\n- **When saving to a profile attribute, set `fields` to only what the message uses.** If the request doesn't say what the message shows, keep what identifies each object, such as its name, and say which fields you kept. The saved value has to fit the workspace's profile attribute limit, 1,000 characters by default. Some workspaces allow more or are exempt, but the API doesn't show which, so plan for 1,000: over the limit, the step fails and keeps the previous value. A list runs to about `limit` × one object's length (`object_id` plus the picked fields), so when a few objects show that it can pass 1,000, ask the user whether to lower `limit`, save to a journey attribute, or keep it because their workspace allows more.\n- `attribute_name` can't be an identifier (`id`, `email`, `cio_id` or `phone`, in any case) or a reserved attribute, and can't start or end with a space.\n- **A failure** (a deleted object type, results too large, a query past the search limits, conditions still building, a list too long) sends the person a `failed_query_objects` event whose `reason` says why. It clears a journey attribute (saves `\"\"`) and leaves a profile attribute as it was. Either way, the journey moves on to the next step.\n\n## Reading the results in Liquid\n\nA journey attribute is `journey.\u003cname\u003e`. A profile attribute is `customer.\u003cname\u003e`, and other automations and segments can read it too.\n\n```liquid\n{% for study in journey.top_studies %}\n  {{ study.name }} at {{ study.university }}\n{% endfor %}\n```\n\nWith `single_result`, read the fields directly: `{{ journey.best_course.title }}`. A `related` result keeps the relationship's attributes under `relationship`: `{{ study.relationship.status }}`. Guard against an empty result with `{% if journey.top_studies != blank %}` or a branch.\n\n**To put results in a JSON body, such as a webhook's, use `to_json` and leave the tag unquoted:** `{\"studies\": {{ journey.top_studies | to_json }}}`. It writes the array or object as JSON, or `\"\"` when nothing matched. `json` and `jsonify` don't serialize on the legacy Liquid engine (`template_engine` 0), so the results render as Ruby hash text like `{\"name\"=\u003e\"Law\"}`. Quotes around the tag break the JSON whenever there's a result.\n","query_spec.md":"---\nminimum_plan: essentials\nfeature_flag: agent_query_builder\n---\n\n# Query by spec\n\nRead this before any `snowflake_query` call against a semantic view. When a question fits the shape below, send a **spec** instead of SQL: the server compiles it to the same `SEMANTIC_VIEW` SQL the sibling skills teach, runs it, and returns the rows together with the SQL it ran. Send raw SQL only when the question cannot be expressed as a spec.\n\nWhy: a spec is the one input the visual query builder in Fly can show and edit. Raw SQL cannot be shown there. So a spec-path answer gets a \"View and edit this query\" link and a raw-SQL answer does not.\n\n## The spec\n\nOne key per SQL clause. Names come from the semantic-view skill for the view (`deliverability.md`, `site_activity.md`, `profile_changes.md`, `pipeline_health.md`) — the same metrics, dimensions and `table.member` spelling.\n\n```json\n{\n  \"version\": 1,\n  \"from\": {\n    \"view\": \"sv_deliverability\",\n    \"metrics\": [\"deliveries.sent\", \"deliveries.unique_human_open_rate\"],\n    \"dimensions\": [\"campaigns.campaign_name\", \"deliveries.channel\"]\n  },\n  \"where\": {\n    \"and\": [\n      { \"field\": \"deliveries.send_date\", \"op\": \"\u003e=\", \"value\": { \"relative\": { \"n\": 30, \"unit\": \"day\" } } },\n      { \"field\": \"channel\", \"op\": \"=\", \"value\": \"email\" },\n      { \"field\": \"sent\", \"op\": \"\u003e=\", \"value\": 100 }\n    ]\n  },\n  \"order_by\": [{ \"field\": \"unique_human_open_rate\", \"dir\": \"desc\" }],\n  \"limit\": 10\n}\n```\n\n| Key | Meaning |\n|---|---|\n| `from.view` | The semantic view name, without the `ANALYTICS.CIO_AGENT.` prefix |\n| `from.metrics`, `from.dimensions` | `table.member` as the view's skill lists them; at least one metric; dimensions may be empty for totals |\n| `select` | Optional. Bare output names to return, in order. Default: every dimension, then every metric; when grouped, the kept columns, then the bucket column, then the metrics. A dimension dropped by `group_by` cannot be selected |\n| `where` | A leaf `{field, op, value}` or a group `{\"and\": [...]}` / `{\"or\": [...]}`; groups nest |\n| `group_by` | Optional. `{\"columns\": [...dimension names to keep], \"time_bucket\": {\"field\": \"send_date\", \"unit\": \"month\"}}` — the view is read with all its dimensions, then every metric is **summed** over the kept ones. `time_bucket.field` is the bare name of a dimension already in `from.dimensions`; the bucket comes back as a column named after its unit (`day`, `week`, `month`). Only count-like metrics belong in a grouped spec; never sum a rate. A bare metric filter in a grouped spec applies to each row *before* the sum, not to the total — a threshold on the total (\"months with at least 100 sent\") needs raw SQL with HAVING |\n| `order_by` | Optional. `[{\"field\": \u003coutput name\u003e, \"dir\": \"asc\" or \"desc\"}]` |\n| `limit` | Optional, 1 to 10,000 |\n\n**Where a condition applies.** A dotted `table.member` field filters inside the semantic view. A bare output name filters the outer query, and is the only way to filter on a metric (`sent \u003e= 100`). Only the top-level `and` may mix both; every other group, `and` or `or`, must stay on one side.\n\n**Operators:** `=`, `!=`, `\u003e`, `\u003e=`, `\u003c`, `\u003c=`, `in` (array value), `not_in`, `contains` (substring, case-insensitive), `is_null`, `is_not_null`. **Values:** string, number, true/false, an array for `in`, or a rolling window `{\"relative\": {\"n\": 7, \"unit\": \"day\"}}` (`day`, `week`, `month`) which compiles to `DATEADD('day', -7, CURRENT_DATE())`. Exact dates are plain strings: `{\"field\": \"deliveries.send_date\", \"op\": \"\u003e=\", \"value\": \"2026-09-01\"}`.\n\nThe workspace filter is added by the server. Never put `workspace_id` in a spec.\n\n## Three rules\n\n1. The spec expresses the question, not the presentation. No rounding, percent scaling or renaming in the spec — do that in the answer.\n2. If the question fits, use the spec. If it needs re-aggregation beyond `group_by`, a ratio of two metrics, a window function, a join between views, or raw-table columns, use the raw SQL path from the view's skill and say in one line why.\n3. The spec you send is the spec you show (see § Answering). Never describe a query differently from what ran.\n\n## Submitting a spec\n\nBuild the JSON in a quoted heredoc and bind it with `--argjson`, so the CLI owns the escaping:\n\n```bash\nSPEC=$(cat \u003c\u003c'EOF'\n{\"version\": 1,\n \"from\": {\"view\": \"sv_site_activity\", \"metrics\": [\"activity.unique_page_visitors\", \"activity.page_views\"], \"dimensions\": []},\n \"where\": {\"field\": \"activity.occurred_date\", \"op\": \"\u003e=\", \"value\": {\"relative\": {\"n\": 1, \"unit\": \"week\"}}}}\nEOF\n)\ncio api /v1/environments/$ENV_ID/snowflake_query -X POST --timeout 120s \\\n  --argjson spec=\"$SPEC\" --json '{spec: $spec}'\n```\n\nThe response carries `columns`, `rows`, `truncated`, and `sql` (the statement that ran) with `source: \"spec\"`. Add `dry_run: true` to the body to get `sql` back without running anything — useful once, never as a routine pre-step.\n\n- **Always pass `--timeout 120s`.** The CLI's 30s default gives up before the warehouse does; `context deadline exceeded` is the client, not the query.\n- **A 400 starting `invalid query spec: \u003cpath\u003e: \u003cdetail\u003e`** names the JSON path that is wrong (for example `where.and[1].value`); a path of `$` means the JSON itself is malformed or has a misspelt key. Fix that and resend. A 400 with `SQL compilation error` means the view rejected the compiled SQL: usually a member name the view does not have (including a dotted `where` field), sometimes a value of the wrong type. Check the view's skill, never guess.\n- **After a compile error, change the spec before resending.** The server rejects the identical statement for 60 seconds. A client timeout or a 5xx is different: resend unchanged, with `--timeout 120s`.\n\n## Answering\n\nWhen the client's component library offers `AnalyticsQuery` (it is listed in the UI components section of your instructions), end an answer whose numbers came from a spec-path call with:\n\n```\nq = AnalyticsQuery(\"\u003cthe customer's question, in their words\u003e\", \"\u003cthe exact spec JSON you sent, as one string\u003e\")\n```\n\ninside the usual `openui` fence, after any stat cards or charts. The `spec` argument is the JSON text byte for byte; do not reformat it. When the library does not offer `AnalyticsQuery`, or the numbers came from raw SQL, emit nothing about the query and do not mention a link in prose.\n","search.md":"# Universal Search — Find Entities by Name or Content\n\nFree-text, relevance-ranked search across a workspace's entities. Two things set it apart from the per-resource list endpoints: it searches **every entity type at once** (ranked by relevance), and for content-bearing kinds it matches the **full body content**, not just the name — so you can find a snippet, template, email layout, or in-app message by a phrase *inside* it. Use it for \"find the \u003cthing\u003e called \u003cname\u003e\" and \"which \u003cthing\u003e mentions \u003cphrase\u003e\" requests.\n\n\u003e **Looking for a message (automation / one-time send / transactional) by its subject line or body text? Don't use this — use the Message Library search in `messaging.md`.** Universal Search matches those message kinds by **name only** (see the kinds table), so a subject/body phrase won't hit here. The Message Library (`GET .../message_library_items?search=…`) matches subject and body across all message types and is the correct tool for \"find the message whose subject/body says X\" and for \"automation/campaign\" lookups that turn up no automation. **An *unlinked* Design Studio email is a third case: it is not indexed as its own kind here, and with no template the Message Library misses it too — search it with `/ds/node_actions/search` (`@skills/design-studio/nodes.md § Search`). Once linked and published its body lands in a template, so `kind=template` does reach it.**\n\n## When to use this vs. per-resource search\n\n- **Universal Search** when the match is about *content or identity*: a phrase inside an entity's body (content-bearing kinds only — see the table; per-resource search can't do this), a by-name or fuzzy / typo-tolerant lookup, or cross-type discovery (\"anything called Black Friday\") when you don't yet know the type. Returns ranked hits (id, type, title, link) plus matched `snippets`.\n- **The per-resource list endpoint's `search`/filter params** (see `automations.md`, `segments.md`, `messaging.md`, …) when you already know the exact type and want to enumerate or filter the full set by name/metadata and get back the complete resource object.\n\n## Endpoint\n\n```\nGET /v1/environments/:environment_id/search?q=\u003cquery\u003e\n```\n\nThe `?a=b` above names the parameters, not the command line: `cio api` rejects a\npath containing `?`, so these go in `--params` as a JSON object.\n\n| Param | Required | Notes |\n|-------|----------|-------|\n| `q` | yes | Free-text query. Pass it raw inside `--params` — `cio api` URL-encodes the value. Pre-encoding a space as `%20` sends the literal `%2520`, so the server searches for a term you did not mean. |\n| `kind` | no | Restrict to one or more entity kinds. The route takes it repeated, but `cio api` sets each key once, so through the CLI it carries **one** kind — use `filters` for several (see § Narrowing by type). |\n| `limit` | no | Default 10, max **49**. The bound is exclusive and there is no clamp: `50`, `100`, or anything higher is discarded and you silently get **10**. Raising the limit with a round number therefore returns *fewer* results — use `49`. |\n| `filters` | no | Advanced: URL-safe base64 of a JSON object for entity-specific fields — see § Narrowing by type. |\n| `order_by` | no | Sort expression. Omit to sort by relevance — the default, and usually what you want. |\n\n```bash\ncio api /v1/environments/$ENV_ID/search --params '{\"q\": \"onboarding\", \"limit\": 5}'\n```\n\n## Searchable entity kinds\n\nA query with no filter searches all of these, blended by relevance. The **Matches** column is the key distinction: content-bearing kinds are full-text indexed on their body, so a query hits text *inside* them; the rest match only on name and a few metadata fields.\n\n| `kind` | Entity | Matches | Resolve / act in |\n|--------|--------|---------|------------------|\n| `snippet` | Content snippets | name + **body content** | `content.md` |\n| `template` | Templates | name + **body content** | `templates.md` |\n| `email_layout` | Email layouts | name + **body content** | `content.md` |\n| `anonymous_in_app` | Anonymous in-app messages | name + **body content** | `anonymous_broadcasts.md` |\n| `segment` | Segments | name + metadata | `segments.md` |\n| `campaign` | Automations | name + metadata | `automations.md` |\n| `action` | Automation actions (a message/step inside an automation) | name + metadata | `automations.md` |\n| `newsletter` | One-time sends / broadcasts | name + metadata | `messaging.md` |\n| `transactional_message` | Transactional messages | name + metadata | `messaging.md` |\n| `goal` | Standalone goals | name + metadata | `goals.md` |\n| `collection` | Data collections | name + metadata | `content.md` |\n\nOnly entities the workspace has indexed are returned. A `404 search is not yet available for this environment` means the workspace isn't indexed yet — fall back to the per-resource `search` param for the type the user named.\n\n## Response\n\n```json\n{\n  \"results\": [\n    {\n      \"doc_id\": \"env123-snippet456\",\n      \"kind\": \"snippet\",\n      \"title\": \"Holiday footer\",\n      \"url\": \"https://fly.customer.io/workspaces/123/content/snippets/456\",\n      \"snippets\": [\"…free shipping through \u003cem\u003eDecember\u003c/em\u003e…\"],\n      \"other\": { \"id\": \"456\", \"env_id\": \"123\" },\n      \"relevance_score\": 0.87\n    }\n  ],\n  \"query\": \"free shipping\",\n  \"limit\": 5\n}\n```\n\n- Results are **already ranked** by relevance — preserve that order.\n- `url` is a ready-to-use UI link with the workspace id already filled in. Surface it.\n- The entity's numeric **id is `other.id`** and its type is the top-level **`kind`**. Use both to continue in the resolve/act skill above.\n- `snippets` are matched excerpts from the entity's **body content** (content-bearing kinds) — they show why a content match hit. Surface one so the user sees the hit in context.\n- `other` carries entity-specific fields (e.g. `segment_type`, `campaign_type`, `archived`). `action` hits are enriched with `campaign_name` and `campaign_url`.\n- `relevance_score` is not present on every result — treat a missing score as \"ranked, score unknown\", not as zero.\n\n## Surfacing results and following up\n\n- Present the top few as `**title** · \u003ctype\u003e · [link](url)`, one per line. When a result has `snippets` (a content match), include one so the user sees why it matched. Don't expose `doc_id`, the `other` map, scores, or raw JSON.\n- **Resolve to exactly one match before acting on a named entity** — the SKILL.md § Resolving a Named Resource rule applies directly here. When there's no exact-name hit, broaden first (drop the `kind` filter, raise `limit` to 49), then confirm with the candidates; don't act on a near-name.\n- To do something with a result — edit it, add it to an automation, pull its metrics — take `other.id` + `kind` and continue in that entity's skill. Universal Search locates the entity; it does not read or modify it.\n\n## Narrowing by type\n\nPass `kind` to restrict results to an entity kind. Narrowing to a single kind also scopes the search to that type's datastore, which improves recall (handy for a content search scoped to, say, snippets only):\n\n```bash\n# one kind\ncio api /v1/environments/$ENV_ID/search --params '{\"q\": \"free shipping\", \"kind\": \"snippet\"}'\n```\n\n**Several kinds go through `filters`, not a repeated `kind`.** The route reads `kind` as a repeatable parameter, but `cio api` sets each key once: an array is rejected (`has non-scalar value`) and a repeated key is refused outright (`param key \"kind\" appears more than once`). Put the list in `filters` instead — it takes `kind` like any other field:\n\n```bash\nFILTERS=$(printf '%s' '{\"kind\":[\"snippet\",\"template\"]}' | base64 | tr -d '\\n' | tr '+/' '-_' | tr -d '=')\ncio api /v1/environments/$ENV_ID/search --params \"{\\\"q\\\": \\\"welcome\\\", \\\"filters\\\": \\\"$FILTERS\\\"}\"\n```\n\n**Spell each kind exactly as the kinds table has it.** A bad kind in the `kind` param is rejected with `400 invalid kind: \"...\"`, but kinds inside `filters` are not validated — a typo there returns `200` with zero results, which reads as \"nothing matches\" rather than \"you misspelled it\". If a `filters` search comes back empty, check the spelling before concluding the term is absent.\n\n### Advanced: entity-specific filters\n\nFor fields beyond `kind` (e.g. `segment_type`, `archived`), use `filters` — a URL-safe base64 (no padding) encoding of a JSON object mapping each field to allowed values. Entity-specific fields are accepted when the matching kind is selected.\n\n```bash\nFILTERS=$(printf '%s' '{\"segment_type\":[\"dynamic\"]}' | base64 | tr -d '\\n' | tr '+/' '-_' | tr -d '=')\ncio api /v1/environments/$ENV_ID/search --params \"{\\\"q\\\": \\\"onboarding\\\", \\\"kind\\\": \\\"segment\\\", \\\"filters\\\": \\\"$FILTERS\\\"}\"\n```\n\n\u003e **⚠️ This encoding differs from campaign/segment `filters`.** Universal Search expects **plain JSON, URL-safe-base64'd with no padding**. Do **not** apply the `JSON → URL-encode → base64` double-encoding that automation and segment filters use (`automation_filters.md`) — it decodes incorrectly here and silently returns the wrong results.\n\n## Finding every mention of a term\n\n\"Which messages mention X?\", \"did we remove X everywhere?\" — these ask about a whole set, so a \"clean\" answer is wrong unless every item's real content was covered. Two different tools depending on the scope:\n\n**Across the workspace** — Universal Search with `kind=template`. It indexes the compiled template body, so it matches the text of the rendered email, including templates attached to **automation actions** and *published* Design Studio nodes — publishing writes the rendered body onto the linked template. The `GET /templates` list endpoint is **not** this tool: it's paginated and its `search` param matches names only (`templates.md`), so a workspace-wide content claim built on it is wrong by construction. An **unlinked** Design Studio node has no template and is invisible here, so for an exhaustive Design Studio audit run `/ds/node_actions/search` as well (`@skills/design-studio/nodes.md § Search`). But results are relevance-ranked and capped by `limit` (default 10, max 49), so a returned page is never proof the set is complete: raise `limit` to **49** — not 50 or 100, which fall back to 10 — and never report \"none\" off a capped page. Terms living only in an automation / one-time send / transactional **subject or body** won't surface here at all (see § Common pitfalls) — that's the Message Library in `messaging.md`.\n\n**Within one automation** — don't scope Universal Search down to it. A ranked, capped, workspace-wide query can drop matching action templates before you ever intersect, turning a miss into a false \"clean\". Instead enumerate the automation's message actions, take each one's `template_id`, and `GET /v1/environments/:eid/templates/:id` per action (`templates.md`) — coverage is then exhaustive by construction.\n\nScan the fields that actually carry content **for that template's channel** (`templates.md` § Key Content Fields) — a `.template.body`-only loop silently skips whole channels:\n\n- **Email** — `.template.body` (compiled), `.template.subject`, `.template.preheader_text`. Don't substitute `body_json` for the compiled body: on Design Studio / `parcel` templates it is empty or holds an old snapshot, so they read \"clean\" while the rendered email still contains the term.\n- **In-app, inbox** — content lives entirely in `.template.body_json`; these read clean on every `.template.body` scan.\n- **Push** — `.template.subject` (title), `.template.body` (message), `.template.body_json` (extras).\n- **SMS, WhatsApp, Slack, webhook, LINE** — `.template.body`.\n\nBefore reporting \"none\", check that you fetched one template per message action and that every body you scanned was non-empty — and that no response came back null-shaped (null `id`/`name` means the jq path missed the `.template` envelope; see SKILL.md § Null identity fields). When a surface genuinely can't be covered, say so and scope the answer (\"none among the automation's email templates; I couldn't search X\") rather than asserting an unqualified negative. If you scan in a shell loop, guard each fallible step with `|| true` — under `set -eo pipefail` the first no-match `grep` exits non-zero and kills the loop, so the remaining templates are never scanned at all.\n\n## Common pitfalls\n\n1. **Expecting content search on a name-only kind.** Only the content-bearing kinds in the table match on body text; for the rest, a phrase that appears only inside the entity (not in its name) won't match — fall back to that type's own skill. In particular, `campaign` (automations), `newsletter`, and `transactional_message` match on **name and metadata only (never the email subject or body)** here, so you cannot find a message by its subject line or body through Universal Search — use the Message Library (`messaging.md` § Message Library) `message_library_items` endpoint, whose `search` param matches subject and body across all message types. Neither surface finds an unlinked **Design Studio** node — use `/ds/node_actions/search` for those (`@skills/design-studio/nodes.md § Search`).\n2. **Double-encoding `filters`.** See the warning above — this endpoint is single base64, not the automation-style double encoding.\n3. **Assuming every workspace is searchable.** Search depends on the workspace being indexed. An unindexed one returns `404 search is not yet available for this environment` — fall back to per-resource search rather than retrying.\n4. **Re-implementing reads.** A result gives you id + type + link (+ a content snippet) only. For full details or any change, switch to the entity's own skill.\n","segment_conditions.md":"---\nminimum_plan: essentials\nallow_free: true\n---\n\n# Segment Conditions\n\nUse this file before creating or updating dynamic segment conditions, and before explaining or recommending changes to existing conditions, including conditions visible in page context. Before creating a data-driven segment, also read `data_driven_segments.md`. It covers the workspace allowance check and what to tell the user at the cap.\n\nBefore recommending a named segment as a replacement, read its definition and compare the full condition tree with the current segment. If they are identical, say that switching will not change membership.\n\n\u003e **Keep segment alternatives within segment capabilities.** When a requested condition is unsupported, explain the limitation and offer only supported segment definitions. Do not suggest automation as a fallback for a segment request. If the user separately asks for an automation, read `automations.md`, `delays.md`, and `flow_control.md` before discussing or building it; a plan notice means the feature is unavailable.\n\n\u003e **⚠️ Segment conditions vs automation action conditions — these are DIFFERENT formats.**\n\u003e This file documents **segment conditions** (`and`/`or` groups, leaf criteria, `event`, `times`, `within`).\n\u003e Automation action conditions (`conditions` and `multi_conditions` on workflow actions like branches and Wait Until) use a **different flat array format** — see `flow_control.md § Automation Action Condition Types`.\n\u003e **Never use segment-style conditions in automation actions or vice versa.**\n\nConditions define who belongs to a dynamic segment. Use the shallowest grouping that exactly represents the user's logic.\n\n- Single criterion: use the leaf object directly.\n- All criteria must match: use one `\"and\"` array with the leaf objects directly inside it.\n- Any criterion can match: use one `\"or\"` array with the leaf objects directly inside it.\n- Mixed joiners: nest only the block that needs a different joiner, for example `{ \"or\": [{ \"and\": [leafA, leafB] }, leafC] }` means `(A AND B) OR C`.\n- A requirement that holds for the whole audience stays in the top-level `and`, next to the `or` of alternatives — `{ \"and\": [exclusion, { \"or\": [altA, altB] }] }` — never inside one alternative.\n\nDo **not** wrap each criterion in a one-item `{ \"or\": [...] }` or `{ \"and\": [...] }` group. Those singleton groups are valid JSON but make the segment awkward to edit in the UI. The expected flat shape for `A AND B AND C` is:\n\n```text\n{ \"and\": [leafA, leafB, leafC] }\n```\n\nThe main exceptions are structural internals: `attribute_change` filters use an internal `\"and\"` for the `to`/`from` filter pair. That inner `\"and\"` is not a segment-criteria joiner and does not violate the flat parent grouping rule. Relationship criteria are also structured blocks: use a relationship group to bind a `relationship` leaf to any related-object or relationship-attribute refinement, and always wrap the refinement in an inner `\"and\"`/`\"or\"` group — even a single one. A refinement placed as a bare sibling of the `relationship` is rejected by the API (see the relationship structure section below).\n\n## Resolve Names Before Building Conditions\n\nEvent names and customer attribute names in a segment must match what the workspace actually stores. User phrasing is rarely the stored name (\"placed an order\" may be `placed_order`, `order_completed`, or `purchase`), so resolve every referenced name against the workspace before you build the condition — never guess from the prompt.\n\n- **Event condition** (`type: \"event\"`): list known events with `GET /v1/environments/:environment_id/event_names` and pick the entry that matches the user's intent. Use that exact name.\n- **Customer attribute** (`type: \"attribute_change\"`): list known attributes with `GET /v1/environments/:environment_id/attributes` and use the exact stored key.\n- Both responses put the name under **`id`**, so the filter is `--jq '.events[].id'` / `--jq '.attributes[].id'`. Full shape and the fields these lists leave empty: `workspace.md § Data Index`.\n- **Tracked page names come from `GET /v1/environments/:environment_id/page_views`**, not from the event list — the `pages` key on the `event_names` response is always empty. It returns `{\"pages\": [{\"id\": \"\u003curl\u003e\"}]}` for recently-seen unique page views and takes an optional `search` substring. That is where the `name` for a page condition comes from.\n- **Geofence condition** (`type: \"geofence\"`): resolve the geoset's id with `GET /v1/environments/:environment_id/geosets` — the geoset id goes in the condition's filter, not its name (type 11 below).\n\nIf no listed name plausibly matches and you inferred the name from loose wording, tell the user which name was missing and confirm before building on it. When the user spelled out the field itself (\"where favorite_food exists\", \"deposits_count=1\"), build on that exact name and note if the workspace hasn't recorded it yet. A detail that qualifies a behaviour (\"deposited once (deposits_count=1)\") is a property filter on that behaviour's event — check `GET .../event_names/:name` for it before treating it as an attribute.\n\n**A behaviour clause can live in either namespace — check both, and follow the data.** \"Has not completed a consultation\" is an event condition only if the workspace actually records that event. Many workspaces model the same fact as *state* instead: a `consultation_status` attribute whose value becomes `completed`. So look in both lists, and when both offer a candidate, pick the one that is **populated** — `POST .../event_names/profile_count` and `POST .../attributes/profile_count` settle it in one call each.\n\nAn event name existing in the schema is not evidence it fires. A `completed_consultation` that has never fired is worse than a missing one: the positive form matches nobody, and `inverse: true` on it matches **everybody**, so \"has not completed a consultation\" silently becomes \"everyone\". Neither failure raises an error — the segment just builds with the wrong audience. When the event has no occurrences and a status attribute does, build the `attribute_change` condition (negated via its `inverse` pair) and say which you chose and why.\n\n**When several stored names are close, pick the one with data.** Workspaces accumulate near-duplicates — `bought_product` next to `purchased_product`, `consultation_status` next to `consultation_status_1`, `favorite food` next to `favorite_food`. The prompt can't disambiguate these, so the workspace has to. Both namespaces expose a population count, and one batched call covers every candidate at once:\n\n```\nPOST .../event_names/profile_count   {\"events\": [\"bought_product\", \"purchased_product\"]}\nPOST .../attributes/profile_count    {\"attributes\": [\"consultation_status\", \"consultation_status_1\"]}\n```\n\nThey return `event_profile_counts` / `attribute_profile_counts` keyed by name — the number of people who triggered the event or have the attribute set. Build on the populated one and name your choice in the answer (\"used `bought_product` — `purchased_product` has no events\"). When several carry data and are clearly one fact under different names (`zero_bounce.status` and `zerobounce` holding the same results), match each of them in one `or` so people stored under any variant qualify, and say so; when that isn't clear, ask the user.\n\n## Object Attribute Preflight\n\nBefore creating or updating a segment condition that mentions accounts, companies, workspaces, orders, subscriptions, stores, or another object-like entity:\n\n1. List object types with `GET /v1/environments/:environment_id/object_types` and choose the matching `type_id`. The list includes types with `enabled: false` — over the workspace's type cap, or auto-created on ingest and never turned on. A condition on one saves and validates, but the type is disabled in the product and its data doesn't personalize, so surface the disabled state to the user rather than building on it silently (see `customers.md § Object type limits`).\n2. List object attributes with `GET /v1/environments/:environment_id/attributes?objectType=:type_id\u0026size=50`.\n3. List relationship attributes with `GET /v1/environments/:environment_id/attributes?objectType=:type_id\u0026relationship=true\u0026size=50`.\n4. Use the attribute only in its matching namespace:\n   - Object field → `related_object_attribute`\n   - Person-object link field → `relationship_attribute`\n   - Person/customer field → `attribute_change`\n\nDo not invent a profile attribute when the requested field belongs to an object. Do not fall back to a bare `relationship` condition when the user asked for a specific object or relationship attribute. If the requested attribute is not present in either list, tell the user exactly which object type and attribute were missing before creating a broader segment.\n\n## Reuse existing conditions when one already fits\n\nBefore defining a condition — especially for a vaguely-specified request like \"recent buyers\" or \"engaged users\" — check whether the workspace already has a **finished** condition you can reuse:\n\n```\nGET /v1/environments/:environment_id/reusable_conditions?type=event\u0026event_type=\u003cevent_name\u003e\nGET /v1/environments/:environment_id/reusable_conditions?type=attribute\u0026attribute_field=\u003cfield\u003e\n```\n\nEach result is `{ \"id\": 45, \"type\": \"event\", \"definition\": { ... } }`, where `definition` is a ready-to-use condition leaf in exactly the shape documented below. Filters: `type` (`event`, `attribute`, `device_attribute`, ...), `event_type`, `attribute_field`; paginated with `page`/`size`.\n\nReusing one is effectively free: a finished condition has already been evaluated against existing data, so the segment builds **instantly** for that part and the backend dedups an identical `definition` on save (no extra backfill). When an existing condition matches the user's intent, drop its `definition` straight into your `conditions` rather than writing a slightly different one, and tell the user you reused it (e.g. \"reused existing condition #45 — builds instantly\"). Don't bend a near-match against an explicit user parameter — if they asked for \"last 7 days\" but the existing condition is 30 days, build the new one; correctness comes first.\n\n**Reuse is a shortcut for building, never for deciding.** A stored condition only records what someone once built — it is not evidence that its event name is the right one, that the name still fires, or that its type suits this request. Work out what the condition should be first (resolve the name, check it is populated, pick event vs attribute), and only then look for a stored condition matching *that*. A reusable condition on a never-firing event, or one whose `type` is a custom event named `page` where the request wants a real `page` view, is a wrong answer that arrives faster. If the only close match disagrees with what you resolved, build the correct one.\n\n## UI-Safe Event Windows\n\nAgent-created or agent-updated dynamic segment conditions must stay within the segment UI's supported surface. Event `within` values are stored as seconds by the API, but the segment UI represents event refinement windows in whole days. For normal user-facing segment edits:\n\n- Use `within: 0` for all time, or a whole-day value such as `86400`, `259200`, or `604800`. Some plans refuse `0` and long windows; see `segment_lookback.md`.\n- Do NOT create or update segment event conditions with sub-day windows like `18000` for 5 hours, even though the backend can evaluate them. Do not present this as an option. Creating the segment and warning the user not to edit it in the UI is not an acceptable workaround.\n- If a fetched segment already has a sub-day `within`, report the API value accurately and warn that the UI may display or save it as a whole-day window. Do not make a condition update that requires resending that UI-unsafe value.\n- If the user needs hour-level logic such as \"has not performed `food_logged` in the last 5 hours\", stop without creating anything and explain the segment limitation. Ask whether a whole-day window would work; do not silently change it or suggest a different product feature.\n\n## Plan Lookback Limit\n\nSome plans, such as Flex, refuse `within: 0` and event windows longer than 30 days. **Before writing an event condition with `within: 0` or a window longer than 30 days, read `segment_lookback.md`.** It covers where to read the account's limit, which conditions it applies to, and what to tell a user who asks for \"ever\".\n\n## `within` Is Measured From Now — Segments Can't Relate Two Events\n\nEach condition's `within` is a rolling window measured backward from the moment membership is evaluated (\"now\"), never from another event. `within: 86400` means \"in the last 24 hours,\" and the window keeps sliding as time passes.\n\nA trailing window after behaviours joined by \"and\" (\"completed a consultation and had a prescription approved in the last 14 days\") applies to every one of them; put the same `within` on each event condition unless the user separates their timing.\n\nConditions in an `and`/`or` group are evaluated **independently**, each against its own window. There is no segment construct for *relative timing between two events* — \"performed B within 24h of performing A\", \"logged in within an hour of signing up\", \"was sent the automation within a day of the call\". Two windowed conditions in an `and` only mean \"did A in its window AND did B in its window\", with the windows unrelated to each other.\n\nWhen the user asks for relative inter-event timing:\n\n- Do **not** silently approximate it with two independent rolling-window conditions and caveat it afterward. Surface the limitation **before** writing, then confirm the approach.\n- Offer only supported segment alternatives, such as a clearly labeled independent-window approximation or a profile attribute maintained by the customer's backend or integration. Do not mention automation as a way to maintain that attribute, and do not suggest a different product feature.\n- If, after you've explained the limitation, the user still wants a plain segment of \"did A (in window) and also did B (in window)\", build it — but say plainly that the two windows are independent, so they're choosing that approximation knowingly.\n\n## Condition Types\n\n\u003e **⚠️ CRITICAL: Do NOT use `{\"attribute\": {...}}` as a segment condition leaf.**\n\u003e That shape is the campaign/newsletter **`filters.Filter`** format (used in `RecipientsFilter`, automation triggers, goals) — it is NOT a valid segment condition and the server returns 500 on `POST /segments`. For a profile attribute match in a segment, always use the `attribute_change` event pattern (type 1 below) with a `to`/`from` filter pair, never a bare `attribute` leaf.\n\u003e\n\u003e Wrong — this is the automation `trigger` / goal shape (automation `filters` takes segment references only — see `automation_filters.md`) and will 500 on segment create:\n\u003e\n\u003e ```json\n\u003e { \"attribute\": { \"field\": \"last_login_at\", \"operator\": \"timestamp_lt\", \"value\": \"-2592000\" } }\n\u003e ```\n\u003e\n\u003e Right — segment-condition shape:\n\u003e\n\u003e ```json\n\u003e {\n\u003e   \"event\": {\n\u003e     \"type\": \"attribute_change\",\n\u003e     \"name\": \"last_login_at\",\n\u003e     \"filters\": { \"and\": [\n\u003e       { \"field\": \"to\",   \"operator\": \"timestamp_lt\", \"value\": \"-2592000\", \"inverse\": false },\n\u003e       { \"field\": \"from\", \"operator\": \"timestamp_lt\", \"value\": \"-2592000\", \"inverse\": true }\n\u003e     ]}\n\u003e   },\n\u003e   \"times\": 1, \"within\": 0, \"inverse\": false\n\u003e }\n\u003e ```\n\u003e Same rule for `{\"device_attribute\": ...}`, `{\"object_attribute\": ...}`, `{\"map\": ...}`, `{\"not\": ...}` — these are filter-shape leaves and do not belong in segment conditions.\n\nEach leaf condition has exactly ONE of these properties set:\n\n### 1. Attribute change\n\nProfile attribute currently matches a value:\n```json\n{\n  \"event\": {\n    \"type\": \"attribute_change\",\n    \"name\": \"plan\",\n    \"filters\": {\n      \"and\": [\n        { \"field\": \"to\", \"operator\": \"eq\", \"value\": \"premium\", \"inverse\": false },\n        { \"field\": \"from\", \"operator\": \"eq\", \"value\": \"premium\", \"inverse\": true }\n      ]\n    }\n  },\n  \"times\": 1, \"within\": 0, \"inverse\": false\n}\n```\n\n`attribute_change` is the legacy segment API wrapper for profile attribute checks. It evaluates the **current** attribute value. For ordinary \"attribute is X\" or \"attribute is not X\" criteria, use the `to`/`from` pair as an internal filter expression:\n\n- `to` with `inverse: false` = current value must match\n- `to` with `inverse: true` = current value must not match\n- `from` repeats the same operator/value with the opposite `inverse` only to complete the legacy pair. It is not the previous value; never interpret it as an attribute transition.\n\nDo not describe the `from` filter as an additional user criterion or as proof that a change happened. For current-attribute segment membership, the `to` filter carries the user's intent; the complementary `from` filter is structural.\n\n**`attribute_change` is a wire label, not a behavior — it reads the same in both directions.** When you read a segment back or explain its membership, a `type: \"attribute_change\"` leaf still means \"the attribute **currently** equals X\", never \"the attribute ever changed to X\" or \"a change event fired\". A profile matches on its current value regardless of whether or when that value last changed — someone whose `country` has been `United States` for a year and never touched since is a member of a `country eq United States` segment. Explaining a non-member as \"their attribute never changed to X\" is wrong: it describes an event-history check the segment doesn't do, and it sends the user chasing a change that has nothing to do with why the profile is out. Evaluate the current value.\n\nCommon patterns:\n\n| Goal | Operator | Notes |\n|------|----------|-------|\n| Attribute equals X | `eq` | `value: \"X\"` |\n| Attribute equals any of X, Y, Z | `one_of` | One condition, never an `or` of `eq` conditions — see below. Match the stored form: `GET .../attributes_values?attribute=\u003cname\u003e` samples recent values per attribute, so use it to learn their format and add stored variants of a requested value (`valid (alternate)` for \"valid\"). Keep every value the user asked for — a value missing from that sample may still be stored or arrive later |\n| Attribute exists | `exists` | No value needed |\n| Attribute contains X | `contains` | Substring match |\n| Attribute \u003e N | `gt` | String comparison — use numbers as strings |\n| Attribute \u003c N | `lt` | |\n| Attribute between A and B | `bw` | Uses `value` and `value2` |\n| Is valid email format | `email_valid` | Parses the address format; does not verify that the mailbox can receive mail or that delivery will succeed |\n| Date is set | `timestamp` | `value: \"0\"` — the default for any date attribute. Switch to `exists` only if `attributes_values` shows the dates stored as ISO strings or milliseconds, which `timestamp` never matches |\n| Date after N seconds ago | `timestamp_gt` | `value` = negative seconds offset (e.g. `\"-2592000\"` = 30 days ago) |\n| Date is in the future — rolling (e.g. subscription not yet expired) | `timestamp_gt` | `value` = `\"0\"`. Evaluates as `attribute \u003e now + offset`, so it stays correct as time passes. Do **NOT** use `timestamp_gta` with the current unix time for this — that freezes the cutoff at the moment the segment is created. |\n| Date before N seconds ago | `timestamp_lt` | `value` = negative seconds offset (e.g. `\"-2592000\"` = 30 days ago) |\n| Date after absolute time | `timestamp_gta` | `value` = unix timestamp |\n| Date before absolute time | `timestamp_lta` | |\n| Date between two times | `timestamp_bw` | `value` and `value2` = unix timestamps |\n| Value in array attribute | `contained_in_array` | `value: \"X\"`. Use for \"includes\", \"has X in\" on list attributes; `eq`/`contains` do not match array elements |\n\n**Email format is not deliverability.** `email_valid` checks whether the address parses as an email address; it does not verify that a mailbox can receive mail or predict whether a send will succeed. When a user connects this condition to delivery counts, explain that limit and do not attribute the delivery gap to `email_valid` alone.\n\n\"Active\" recency is the workspace's activity attribute (`lastActive`, `last_seen`) when one is populated, not page views.\n\nFor signup, account creation or \"created\" wording, use `created_at` when the workspace has it, without asking: customers send it as the signup date, while Customer.io itself only sets `_created_in_customerio_at`.\n\n**Negate with the `inverse` pair, never by swapping the operator.** \"Has not logged in in the last 60 days\" is the *negation* of \"logged in in the last 60 days\": keep `timestamp_gt` with `-5184000` and flip the pair to `to` inverse `true` / `from` inverse `false`. Rewriting it as a positive `timestamp_lt` looks equivalent but isn't — it requires the attribute to be set, so every profile that has never logged in at all drops out of the segment, which is exactly the audience the user asked for. The same holds for `exists`, `eq`, and `one_of`: the operator states the fact, the `inverse` pair states whether you want it or its opposite.\n\nFor an inverted `eq`, a profile with no stored attribute value also matches: equality is false when the value is absent, then `inverse` flips it. When explaining who matches, explicitly mention profiles with no stored value too; don't narrow the audience to profiles whose value is `false`. If the user wants the attribute to be set as well as not equal to a value, combine the inverted equality with `exists`.\n\n**Calendar periods are `timestamp_bw`, not rolling offsets.** \"Added last month\", \"signed up in Q3\", \"created this month\" name a fixed span with a start and an end, so build one `timestamp_bw` leg with both unix bounds — not `timestamp_gt \"-2592000\"`, and not a pair of `timestamp_gta`/`timestamp_lta` legs. A rolling offset answers a different question (\"in the last 30 days\") and keeps sliding, so a \"last month\" segment built that way stops meaning last month tomorrow. Note that an absolute range does freeze the segment to that period, which is what the user asked for; if their intent was really \"recently\", say so and offer the rolling form.\n\n**Both bounds are inclusive.** `timestamp_bw` matches `start \u003c= t \u003c= end`, so a calendar month ends at 23:59:59 on its last day — August 2026 is `1785542400`..`1788220799`. Using the next period's midnight (`1788220800`) as the upper bound pulls in the first second of September, which nothing in the result will show you: the segment looks right, builds fine, and quietly carries members who don't belong to the month you named.\n\n**Matching a list of values — `one_of`.** Whenever a request names two or more values for the same attribute (\"plan is gold, silver, or bronze\", \"country is CA, CY, MT, or HU\"), build one `one_of` condition, never an `or` group of `eq` conditions. Membership is identical either way, but `one_of` is one condition instead of N and rides the same attribute value indexes as `eq`, so it builds faster and stays readable.\n\n```json\n{ \"field\": \"to\",   \"operator\": \"one_of\", \"value\": \"[\\\"bronze\\\",\\\"gold\\\",\\\"silver\\\"]\", \"inverse\": false },\n{ \"field\": \"from\", \"operator\": \"one_of\", \"value\": \"[\\\"bronze\\\",\\\"gold\\\",\\\"silver\\\"]\", \"inverse\": true }\n```\n\n- `value` is a **JSON array serialized into the string field**, holding scalars only (strings, numbers, booleans). A bare array, an empty array, or a nested array/object is rejected.\n- \"Is not one of\" flips the pair's `inverse` flags (`to` → `true`, `from` → `false`), exactly like a negated `eq`. The operator stays `one_of`; there is no `!one_of`.\n- The server canonicalizes the list on save — entries trimmed, lowercased, deduplicated, sorted — so a read-back is normally not byte-identical to what you sent. Report what the read-back says.\n- **At most 50 distinct values** per condition; a longer list is rejected. Split it into chunks of 50 or fewer, and mind the joiner: chunks of an \"is one of\" list are alternatives joined with `or`, while chunks of an \"is not one of\" list are all required and must be joined with **`and`** — `or`-ing negated chunks matches almost every profile, including the values the user asked to exclude.\n- The same operator, value format, and 50-value cap apply to an **event property refinement** (the `filters` on a custom event condition, type 2 below), so \"performed `purchase` where `plan` is one of gold or silver\" is one refinement filter rather than an `or` of `eq` refinements. There `inverse: true` on the filter carries the negation directly — a refinement is a single filter node, not a `to`/`from` pair:\n\n```json\n{ \"event\": { \"type\": \"event\", \"name\": \"purchase\",\n             \"filters\": { \"field\": \"plan\", \"operator\": \"one_of\", \"value\": \"[\\\"gold\\\",\\\"silver\\\"]\", \"inverse\": false } },\n  \"times\": 1, \"within\": 0, \"inverse\": false }\n```\n\n- Alternatives on *different* fields (\"did `X` with `a \u003e 0` or `b` set\") are an `or` of two event conditions, one refinement each; a condition's filters are ANDed.\n- Not for a fixed list of people: `one_of` on `cio_id` is rejected at create, and on `id` it has no value indexes, so the segment builds by scanning every profile — use a manual segment (`segments.md`). Automation/campaign filters don't support it; use `eq` there.\n- **Relationship refinements** (`related_object_attribute` / `relationship_attribute`, types 8 and 9 below) do take it, with the same value format, canonicalization and 50-value cap — but the index trade-off inverts here, so pick by the shape of the refinement:\n  - Refinement is **only** a list match on one attribute → use an `or` refinement of `eq` children. On this path `eq` rides the shared object attribute value indexes while `one_of` does not (it needs a per-condition bitmap that has to be backfilled, like `gt`/`lt`/`contains` here), and the two encodings are otherwise identical.\n  - Refinement must **AND** the list match with another filter → `one_of` is the only encoding. A relationship block's joiner is a single boolean covering every child and the children must be flat, so `plan_type = startup AND (account_state = active OR trialing)` cannot be written with `eq` alone.\n- **Never split a relationship refinement across two relationship blocks** to emulate the mixed case. Each block is its own existential quantifier over the profile's related objects, so two blocks let the two halves be satisfied by *different* objects, where one block requires a single object to satisfy both. The member counts legitimately differ, and the two-block form is the weaker condition — it is not an equivalent rewrite (AUD-4353).\n- Don't rewrite an existing `or` of `eq` conditions into a `one_of` unless the user asked you to change that part of the segment — every conditions change triggers a full rebuild.\n\n### 2. Custom event\n\nProfile performed an event:\n```json\n{\n  \"event\": { \"type\": \"event\", \"name\": \"purchase\", \"filters\": {} },\n  \"times\": 1, \"within\": 2592000, \"inverse\": false\n}\n```\n- `type` must be one of the valid event types listed below. Email channels use `_email` suffix types (`opened_email`, `clicked_email`, `sent_email`, `delivered_email`, `bounced_email`, etc.) while non-email channels use `_action` suffix types (`delivered_action`, `clicked_action`, `sent_action`, etc.) with the channel prefix in the name. See the full mapping table below.\n- **⚠️ Never use bare types like `opened`, `clicked`, `bounced`, `unsubscribed`, or `converted` without a suffix.** The backend rejects them (`invalid condition event type`) and the UI cannot render them. Always use the `_email` suffix for email conditions (e.g., `opened_email` not `opened`) and `_action` for other channels.\n- `within`: seconds (`0` = all time, `2592000` = 30 days). For agent writes, use only `0` or whole-day values; see \"UI-Safe Event Windows\" above, and `segment_lookback.md` for plans that refuse `0` and long windows.\n- `inverse: true` = has NOT performed this event\n- **Before setting `inverse: true` on an event, check the event has ever fired** — `POST .../event_names/profile_count {\"events\": [\"\u003cname\u003e\"]}`. A negated condition on an event with zero occurrences matches *every* profile in the workspace, silently and without error, which is the worst possible outcome for a \"has not done X\" audience. If the count is zero, the workspace is recording that fact somewhere else — usually a status attribute — so build the `attribute_change` form instead, and say why you switched. This check is only needed for negated event conditions; a positive one that matches nobody is obvious to the user immediately.\n- Wildcards work in event names (e.g. `\"twilio_*\"`)\n- Add `filters` to match on event properties (same operator syntax as attribute filters)\n\n**Event property filters use the bare property name.** The filter is evaluated against the event's `data` object, so a `purchase` sent as `{\"data\": {\"amount\": 1200}}` is matched by `{\"field\": \"amount\", \"operator\": \"gt\", \"value\": \"1000\"}`. A `data.` prefix matches nothing. Dotted paths address *nesting inside* a property (`items.0.sku`), never the `data` wrapper itself.\n\n**Confirm the property exists before you filter on it.** `GET /v1/environments/:environment_id/event_names/:event_name` returns the event's recorded attributes alongside its usage counts. If the request needs a property the event has never carried — \"who did not buy socks\" when `purchase` records no product field — the segment you'd build silently matches everyone or no one. Say which property was missing and ask how they identify it, rather than guessing a plausible key.\n\n#### Message delivery condition mapping\n\nTo match users who were sent/delivered a message on a specific channel:\n\n| Goal | `type` | `name` pattern | Example |\n|------|--------|---------------|---------|\n| Sent any email | `sent_email` | `*` | `{\"type\": \"sent_email\", \"name\": \"*\"}` |\n| Delivered any email | `delivered_email` | `*` | `{\"type\": \"delivered_email\", \"name\": \"*\"}` |\n| Opened any email | `opened_email` | `*` | `{\"type\": \"opened_email\", \"name\": \"*\"}` |\n| Clicked any email | `clicked_email` | `*` | `{\"type\": \"clicked_email\", \"name\": \"*\"}` |\n| Bounced any email | `bounced_email` | `*` | `{\"type\": \"bounced_email\", \"name\": \"*\"}` |\n| Delivered any SMS (Twilio) | `delivered_action` | `twilio_*` | `{\"type\": \"delivered_action\", \"name\": \"twilio_*\"}` |\n| Clicked any SMS link | `clicked_action` | `twilio_*` | `{\"type\": \"clicked_action\", \"name\": \"twilio_*\"}` |\n| Sent any SMS (Twilio) | `sent_action` | `twilio_*` | `{\"type\": \"sent_action\", \"name\": \"twilio_*\"}` |\n| Sent any SMS (custom SMS provider) | `sent_action` | `sms_*` | `{\"type\": \"sent_action\", \"name\": \"sms_*\"}` |\n| Dropped any SMS | `dropped_action` | `twilio_*` | `{\"type\": \"dropped_action\", \"name\": \"twilio_*\"}` |\n| Bounced any SMS (Twilio) | `bounced_action` | `twilio_*` | `{\"type\": \"bounced_action\", \"name\": \"twilio_*\"}` |\n| Bounced any SMS (custom SMS provider: Infobip, Vonage, Sinch, Bird, or Twilio connected as one) | `bounced_action` | `sms_*` | `{\"type\": \"bounced_action\", \"name\": \"sms_*\"}` |\n| Failed to send any SMS (Twilio) | `failed_action` | `twilio_*` | `{\"type\": \"failed_action\", \"name\": \"twilio_*\"}` |\n| Failed to send any SMS (custom SMS provider) | `failed_action` | `sms_*` | `{\"type\": \"failed_action\", \"name\": \"sms_*\"}` |\n| Delivered any push notification | `delivered_action` | `push_*` | `{\"type\": \"delivered_action\", \"name\": \"push_*\"}` |\n| Clicked any push notification | `clicked_action` | `push_*` | `{\"type\": \"clicked_action\", \"name\": \"push_*\"}` |\n| Dropped any push | `dropped_action` | `push_*` | `{\"type\": \"dropped_action\", \"name\": \"push_*\"}` |\n| Delivered any Slack message | `delivered_action` | `slack_*` | `{\"type\": \"delivered_action\", \"name\": \"slack_*\"}` |\n| Clicked any Slack action | `clicked_action` | `slack_*` | `{\"type\": \"clicked_action\", \"name\": \"slack_*\"}` |\n| Delivered any webhook | `delivered_action` | `webhook_*` | `{\"type\": \"delivered_action\", \"name\": \"webhook_*\"}` |\n| Sent / received any in-app message | `sent_action` | `in_app_*` | `{\"type\": \"sent_action\", \"name\": \"in_app_*\"}` |\n| Clicked any in-app action | `clicked_action` | `in_app_*` | `{\"type\": \"clicked_action\", \"name\": \"in_app_*\"}` |\n\nNon-email channels use `_action` suffix types (e.g. `delivered_action`, `sent_action`, `clicked_action`) with the channel prefix in the name (e.g. `twilio_*`, `push_*`, `in_app_*`).\n\nIn-app messages never produce a `delivered_action` — the SDK pulls the message from a queue, so the only send-side metric is `sent`. \"Received an in-app message\", \"got an in-app\", and \"was shown an in-app\" all map to `sent_action` with `in_app_*`.\n\n**Suppressed is `dropped_email`, not `bounced_email`.** A message the platform chose not to send — suppression list, frequency cap, unsubscribed, invalid address held back before handoff — is *dropped*. A bounce is the receiving mail server rejecting a message we did send. \"Suppressed\", \"blocked\", \"held back\", and \"not sent because of a cap\" are `dropped_email` / `dropped_action`; only an actual delivery failure at the recipient's server is `bounced_email`. `spammed_email` is narrower still — a recipient's spam complaint.\n\n\u003e **⚠️ IMPORTANT**: The `name` field is the **action name prefix**, NOT the channel name. For SMS, use `twilio_*` (legacy Twilio) or `sms_*` (custom provider), not `sms`; \"any SMS\" is an `or` of both. For push, use `push_*` not `push`. Always use the wildcard patterns from the table above.\n\n**Scoping to a specific automation or message** — replace the `name` wildcard with one of these forms:\n\n| Goal | `name` | Example |\n|------|--------|---------|\n| Any email in automation N | `email-campaign_{id}` | `{\"type\": \"opened_email\", \"name\": \"email-campaign_38\"}` |\n| Any message of channel C in automation N | `{channel}-campaign_{id}` | `{\"type\": \"delivered_action\", \"name\": \"twilio-campaign_38\"}` |\n| A specific email action | `output_{action_id}` | `{\"type\": \"opened_email\", \"name\": \"output_1476\"}` |\n| A specific non-email action | `{channel}_{action_id}` | `{\"type\": \"delivered_action\", \"name\": \"twilio_1234\"}` |\n| A specific broadcast (one-time send) | `newsletter_{id}` | `{\"type\": \"opened_email\", \"name\": \"newsletter_673\"}` |\n| A specific transactional message | `transactional_{id}` | `{\"type\": \"delivered_email\", \"name\": \"transactional_42\"}` |\n\nTo match *all* emails belonging to an automation with one condition (rather than one segment condition per email action), use `{actionType}-campaign_{campaign_id}`. Do not put `campaign_id` in `filters` (the backend accepts it but the UI silently ignores it). Do not enumerate every action's `output_{id}` (works but is brittle and bloats the segment). The `_h` suffix for human-only opens/clicks applies to all of these (e.g. `email-campaign_38_h`).\n\n**Scoping a click to a specific link** — keep `name` as the message scope (`*` for any email) and refine on the `href` property with `contains`:\n\n```json\n{\n  \"event\": { \"type\": \"clicked_email\", \"name\": \"*\",\n             \"filters\": { \"field\": \"href\", \"operator\": \"contains\", \"value\": \"https://customer.io/solutions/activation\", \"inverse\": false } },\n  \"times\": 1, \"within\": 0, \"inverse\": false\n}\n```\n\nUse `contains`, not `eq`: the recorded `href` is the link as it appeared in the rendered message, so it usually carries UTM parameters, a click-id, or a Liquid-substituted suffix that an exact match would miss. The same filter works on `clicked_action` for non-email channels.\n\n**Scoping an SMS bounce, failure, or drop to a provider error code** — refine `bounced_action`, `failed_action`, or `dropped_action` on the `error_code` property. Name the message the way that channel's events are named: `twilio_*` for the legacy Twilio integration, `sms_*` for the custom SMS channel (Infobip, Vonage, Sinch, Bird, or Twilio connected as a custom provider); for \"any SMS\" across both, `or` two conditions. The value is the provider's own code, verbatim, and matching ignores case:\n\n- **Bounces** (the carrier's later report, `bounced_action`): Twilio and Vonage report numbers (`30003`, `1170`), Sinch its numeric SMPP code, Infobip a name (`EC_UNKNOWN_SUBSCRIBER`).\n- **Failures at send** (`failed_action`): Twilio and Vonage the same numbers, Infobip a status name (`REJECTED_DND`), Sinch its API status (`INVALID_ARGUMENT`).\n\nUse `one_of` for exact codes and `contains` for a fragment. Count and time window stack on top as usual — \"Twilio SMS bounced with an unreachable-handset code at least 5 times in 30 days\":\n\n```json\n{\n  \"event\": { \"type\": \"bounced_action\", \"name\": \"twilio_*\",\n             \"filters\": { \"field\": \"error_code\", \"operator\": \"one_of\", \"value\": \"[\\\"30003\\\",\\\"30006\\\"]\", \"inverse\": false } },\n  \"times\": 5, \"within\": 2592000, \"inverse\": false\n}\n```\n\nPrefer `one_of` over `contains` for numeric codes: `contains` is a substring match, so `300` also matches `63003`. Messages sent before error codes were recorded carry no `error_code` and never match.\n\n### 3. Page view\n\nProfile viewed a page (built-in page tracking, NOT a custom event):\n```json\n{\n  \"event\": { \"type\": \"page\", \"name\": \"*\", \"filters\": {} },\n  \"times\": 1, \"within\": 2592000, \"inverse\": false\n}\n```\n\n\u003e **⚠️ IMPORTANT: \"Page view\" is NOT a custom event.**\n\u003e When users mention \"page view\", \"page views\", \"visited a page\", \"viewed a page\", or \"pages visited\", use `\"type\": \"page\"` — NOT `\"type\": \"event\"` with `\"name\": \"Page View\"`. Page views are tracked by Customer.io's JavaScript snippet via `analytics.page()` and have their own dedicated condition type. Using `\"type\": \"event\"` would match only a custom event literally named \"Page View\", which is almost never what the user means.\n\u003e\n\u003e The exception is a user naming an event key rather than describing behaviour — `page_view` listed alongside `signed_up` and `purchase`, or any name that comes back from `GET .../event_names`. Then they mean that custom event, and it takes `\"type\": \"event\"` like any other.\n\n- `name`: the page itself — the URL or URL pattern. Use `\"*\"` only when the user means *any* page.\n- Built-in filter properties (do NOT prefix with `data.`): `url`, `referrer`, `title`, `path`, `keywords`, `name`, `search`\n- Same `times`, `within`, `inverse` semantics as custom events\n\n**When the user names a page, that page goes in `name`.** \"Viewed https://customer.io/docs\" is `name: \"https://customer.io/docs\"` with `filters: {}` — not `name: \"*\"` plus a `url`/`path` filter, and not the URL with a `*` bolted on. Use the address exactly as the user gave it; a wildcard widens their audience to pages they didn't ask about, which is invisible in the answer and shows up later as a segment that's too big. Add `*` only where the user described a family of pages (\"anything under /pricing\" → `name: \"*/pricing*\"`). Reach for a `url`/`path` filter only to refine *beyond* the page identity — a query parameter, a referrer, a title — where the value being matched isn't the page's own address.\n\nWhen the user names a page by description rather than URL (\"the purchase page\", \"the pricing page\"), resolve it against the workspace's tracked pages — `GET .../page_views?search=purchase` — and use the URL you find. Don't fall back to a custom event that sounds related (`viewed_product` is not the purchase page); if no tracked page matches, say which pages exist and ask.\n\nExample — users who viewed any page where the URL contains \"gclid=\":\n```json\n{\n  \"event\": {\n    \"type\": \"page\",\n    \"name\": \"*\",\n    \"filters\": {\n      \"field\": \"url\", \"operator\": \"contains\", \"value\": \"gclid=\", \"inverse\": false\n    }\n  },\n  \"times\": 1, \"within\": 0, \"inverse\": false\n}\n```\n\n### 4. Screen view\n\nProfile viewed a screen (mobile app screen tracking):\n```json\n{\n  \"event\": { \"type\": \"screen\", \"name\": \"*\", \"filters\": {} },\n  \"times\": 1, \"within\": 2592000, \"inverse\": false\n}\n```\nSame structure as page view but for mobile screen views tracked via `analytics.screen()`. Use `\"type\": \"screen\"` — not a custom event named \"Screen View\". As with pages, the screen the user names is the `name`, spelled exactly as they gave it — \"viewed the homescreen\" is `name: \"homescreen\"` with empty `filters`, not `\"*homescreen*\"`.\n\nThe word \"screen\" in the request is what decides the type. There is no endpoint listing a workspace's screen names — `page_views` covers pages only — so not finding the screen anywhere is expected and is **not** a reason to fall back to `\"type\": \"page\"` or to a custom event. Take the name from the user and build the `screen` condition. A page condition silently matches nothing on a mobile-only workspace. A screen condition carries no property refinement at all, so a filter is never the right place for the screen's identity.\n\n### 4b. Form submission\n\nProfile submitted a tracked form. Every form submission arrives as the same event, `form_submit`; the form's identity lives in a `form_id` filter, never in the event name:\n\n```json\n{\n  \"event\": {\n    \"type\": \"event\", \"ui_type\": \"form\", \"name\": \"form_submit\",\n    \"filters\": { \"field\": \"form_id\", \"form_type\": \"typeform\", \"operator\": \"eq\", \"value\": \"form_3\", \"inverse\": false }\n  },\n  \"times\": 1, \"within\": 0, \"inverse\": false\n}\n```\n\n- `form_type` sits on the filter next to `field`, not in the event name or the condition body. It comes from the form's own record, along with the id — `GET /v1/environments/:environment_id/custom_forms` lists both. Resolve the form the user names rather than guessing an id.\n- `ui_type: \"form\"` is what makes the segment UI render this as a form condition instead of a raw event; the condition still matches without it, but the user can no longer edit it in the builder.\n- Refine on the submitted fields by adding an `and` alongside the `form_id` filter — \"submitted the feedback form with positive sentiment\" is the `form_id` filter and a `{\"field\": \"sentiment\", \"operator\": \"eq\", \"value\": \"positive\"}` filter together.\n- \"Submitted the form more than once\" is `times`, exactly as for any other event: `times: 2` with a `within` window.\n- Do **not** substitute a custom event (`signup`, `contact_form`) for a form the user described, and do not decline because no event named after the form exists — the event is always `form_submit`.\n\n### 5. Device change\n\nDevice attribute condition:\n```json\n{\n  \"event\": {\n    \"type\": \"device_change\",\n    \"name\": \"platform\",\n    \"filters\": {\n      \"and\": [\n        { \"field\": \"to\", \"operator\": \"eq\", \"value\": \"ios\", \"inverse\": false },\n        { \"field\": \"from\", \"operator\": \"eq\", \"value\": \"ios\", \"inverse\": true }\n      ]\n    }\n  },\n  \"times\": 1, \"within\": 0, \"inverse\": false\n}\n```\nSame structure as attribute change but for device attributes.\n\n\"Has a device\" or \"added a device\" (any platform) is `device_change` on `name: \"id\"`, with `to` existing and `from` not existing (`inverse: true`). Use `platform` only when the user names one.\n\n### 6. Segment membership\n\nIs/isn't in another segment:\n```json\n{ \"segment\": { \"id\": 10, \"inverse\": false } }\n```\nNesting restrictions: max depth of 1 (can only reference leaf segments), cannot self-reference.\n\n### 7. Relationship\n\nHas/doesn't have relationship with object type:\n```json\n{ \"relationship\": { \"type_id\": 1, \"inverse\": false } }\n```\n\n### 8. Related object attribute\n\nAttribute on a related object:\n```json\n{ \"related_object_attribute\": { \"type_id\": 1, \"field\": \"status\", \"operator\": \"eq\", \"value\": \"active\", \"inverse\": false, \"value2\": \"\" } }\n```\nUse this for fields on the object itself, such as an account's `plan_type`, company's `industry`, workspace's `ARR`, order's `total`, or store's `region`.\n\n### 9. Relationship attribute\n\nAttribute on the relationship itself:\n```json\n{ \"relationship_attribute\": { \"type_id\": 1, \"field\": \"role\", \"operator\": \"eq\", \"value\": \"admin\", \"inverse\": false, \"value2\": \"\" } }\n```\nUse this for fields on the person-object link, such as `role`, `owner`, `membership_status`, `joined_at`, or relationship-specific status.\n\n\u003e **⚠️ IMPORTANT: Relationship condition structure**\n\u003e `related_object_attribute` and `relationship_attribute` MUST be nested inside a relationship group — never at the top level. The relationship group is a single `\"and\"` array where index 0 is the `relationship` and index 1 is an `\"and\"`/`\"or\"` group holding the refinement conditions. The refinement is **always** wrapped in that inner group — even for a single refinement. The segment editor reads refinements only from that wrapper, so a refinement placed as a bare sibling of the `relationship` (index 1 not an `\"and\"`/`\"or\"`) is rejected by the API. An inverse relationship (`\"inverse\": true`, \"has no related object\") takes no refinement at all.\n\u003e\n\u003e Single refinement — wrap the one leaf in an inner `\"and\"`:\n\u003e ```json\n\u003e {\n\u003e   \"and\": [\n\u003e     { \"relationship\": { \"type_id\": 1, \"inverse\": false } },\n\u003e     { \"and\": [\n\u003e       { \"related_object_attribute\": { \"type_id\": 1, \"field\": \"status\", \"operator\": \"eq\", \"value\": \"active\", \"inverse\": false, \"value2\": \"\" } }\n\u003e     ]}\n\u003e   ]\n\u003e }\n\u003e ```\n\u003e\n\u003e Multiple refinements — same shape, more leaves in the inner group:\n\u003e ```json\n\u003e {\n\u003e   \"and\": [\n\u003e     { \"relationship\": { \"type_id\": 1, \"inverse\": false } },\n\u003e     { \"and\": [\n\u003e       { \"related_object_attribute\": { \"type_id\": 1, \"field\": \"status\", \"operator\": \"eq\", \"value\": \"active\", \"inverse\": false, \"value2\": \"\" } },\n\u003e       { \"relationship_attribute\": { \"type_id\": 1, \"field\": \"role\", \"operator\": \"eq\", \"value\": \"admin\", \"inverse\": false, \"value2\": \"\" } }\n\u003e     ]}\n\u003e   ]\n\u003e }\n\u003e ```\n\u003e The refinement leaves inside the inner group must be flat `related_object_attribute` / `relationship_attribute` objects — do NOT wrap each one in its own singleton `{\"or\": [...]}` or `{\"and\": [...]}`. Because the inner group's `\"and\"`/`\"or\"` is therefore the joiner for *every* leaf, a refinement that needs to AND one filter with a choice of values for another has no `eq`-only encoding — use `one_of` for the choice, and do not reach for a second relationship block (see `one_of` above). Put this entire relationship block directly in the parent group. Use a parent `\"and\"` only when combining it with other required criteria, or a parent `\"or\"` only when it is one alternative among others. Do NOT put `related_object_attribute` or `relationship_attribute` in top-level parent groups outside the relationship group. Do NOT nest `\"conditions\"` inside the `\"relationship\"` object.\n\n### 10. Optout\n\nProfile opted out of a channel:\n```json\n{ \"optout\": { \"type\": \"sms\", \"from\": \"18005551234\", \"inverse\": false } }\n```\nCurrently only `\"sms\"` is supported for `type`.\n\n### 11. Geofence\n\nProfile's device entered, dwelled in, or exited a geoset (see `geofences.md`; feature-gated — a 400 with \"Upgrade your plan\" when listing geosets means geofencing isn't enabled, say so and don't build the condition):\n```json\n{\n  \"event\": {\n    \"type\": \"geofence\",\n    \"name\": \"enter\",\n    \"filters\": { \"field\": \"geoset_id\", \"operator\": \"eq\", \"value\": \"42\", \"inverse\": false }\n  },\n  \"times\": 1, \"within\": 0, \"inverse\": false\n}\n```\n\n- `name` is the **transition** — literally `\"enter\"`, `\"dwell\"`, or `\"exit\"`, never the geoset's name. The geoset scope lives in the filter: resolve the user's geoset name to its id via `GET /v1/environments/:environment_id/geosets` first (unpaginated — filter client-side; confirm ambiguous names).\n- The six user-facing statuses map to the three transitions ± condition-level `inverse`:\n  - *has entered* → `name: \"enter\"`, `inverse: false`\n  - *has not entered* → `name: \"enter\"`, `inverse: true`\n  - *has dwelled* → `name: \"dwell\"`, `inverse: false`\n  - *has not dwelled* → `name: \"dwell\"`, `inverse: true`\n  - *has exited* → `name: \"exit\"`, `inverse: false`\n  - *has not exited* → `name: \"exit\"`, `inverse: true`\n- **Current occupancy cannot be represented by these segment conditions.** The enter and exit indexes are historical, monotonic signals: once a profile has exited a geoset, `has not exited` remains false even if the device later re-enters. Therefore `has entered` AND `has not exited` means \"ever entered and never exited,\" not \"currently inside.\" Do not create that approximation for a current-state request. Explain the limitation and suggest segmenting on a customer-maintained current-location attribute/state instead. A bounded \"entered recently and did not exit recently\" audience is still only an approximation and must be labeled as such.\n- Keep an all-time (`times: 1`, `within: 0`) filter to a single `geoset_id` equality when possible — that exact shape is served by a dedicated precomputed index and evaluates fast at any scale. Changing `times`, adding a `within` window, or adding filter clauses drops off the shared geoset bitmap onto a per-condition index that has to be backfilled before it is useful. Filtering on `geofence_id` instead pins one specific fence (also indexed for the same all-time shape), but geoset-level is what users almost always mean.\n- `times`/`within` work as for any event condition (`within: 604800` = entered in the last 7 days; UI-safe whole-day windows apply).\n- *Exited after staying over 5 minutes*: `name: \"exit\"` with the same filter as the exit-duration trigger (`automation_triggers.md` § Geoset-triggered), `{\"and\": [{geoset_id eq leaf}, {\"field\": \"visit_duration_seconds\", \"operator\": \"gt\", \"value\": \"300\", \"inverse\": false}]}`. The same rules apply: one visit per event, and an unknown duration matches neither the numeric comparison nor its operator-level `inverse`. The added clause puts it on a per-condition index (above).\n- A fence in multiple geosets emits one enter/dwell/exit event per set, so a geoset-scoped condition sees exactly its own set's transitions.\n","segment_examples.md":"---\nminimum_plan: essentials\nallow_free: true\n---\n\n# Segment Examples\n\nRead `segment_conditions.md` before adapting these examples.\n\n## Common Examples\n\n### \"Signed up in the last 30 days AND is in the US\"\n\nOne AND group with two flat conditions:\n\n\u003e **⚠️ `timestamp_gt` / `timestamp_lt` values are seconds offsets from now.** Negative = past, positive = future. For \"in the last N days\", always use a **negative** value: `\"-2592000\"` = 30 days ago. A positive value like `\"2592000\"` means 30 days *from now* (the future), which is impossible for `created_at` since it records when a person was first identified and is always in the past.\n\n```json\n{\n  \"conditions\": {\n    \"and\": [\n      { \"event\": { \"type\": \"attribute_change\", \"name\": \"created_at\", \"filters\": { \"and\": [\n        { \"field\": \"to\", \"operator\": \"timestamp_gt\", \"value\": \"-2592000\", \"inverse\": false },\n        { \"field\": \"from\", \"operator\": \"timestamp_gt\", \"value\": \"-2592000\", \"inverse\": true }\n      ]}}, \"times\": 1, \"within\": 0, \"inverse\": false },\n      { \"event\": { \"type\": \"attribute_change\", \"name\": \"country\", \"filters\": { \"and\": [\n        { \"field\": \"to\", \"operator\": \"eq\", \"value\": \"us\", \"inverse\": false },\n        { \"field\": \"from\", \"operator\": \"eq\", \"value\": \"us\", \"inverse\": true }\n      ]}}, \"times\": 1, \"within\": 0, \"inverse\": false }\n    ]\n  }\n}\n```\n\n### \"Purchased 3+ times in 90 days\"\n\n```json\n{\n  \"conditions\": {\n    \"event\": { \"type\": \"event\", \"name\": \"purchase\", \"filters\": {} },\n    \"times\": 3,\n    \"within\": 7776000,\n    \"inverse\": false\n  }\n}\n```\n\n### \"Received at least one SMS message\"\n\n```json\n{\n  \"conditions\": {\n    \"event\": { \"type\": \"delivered_action\", \"name\": \"twilio_*\", \"filters\": {} },\n    \"times\": 1,\n    \"within\": 0,\n    \"inverse\": false\n  }\n}\n```\n\n### \"Has NOT opened any email in 30 days\"\n\n```json\n{\n  \"conditions\": {\n    \"event\": { \"type\": \"opened_email\", \"name\": \"*\", \"filters\": {} },\n    \"times\": 1,\n    \"within\": 2592000,\n    \"inverse\": true\n  }\n}\n```\n\n### \"Is in segment 10 OR has email attribute\"\n\nOne OR group with two alternatives:\n\n```json\n{\n  \"conditions\": {\n    \"or\": [\n      { \"segment\": { \"id\": 10, \"inverse\": false } },\n      { \"event\": { \"type\": \"attribute_change\", \"name\": \"email\", \"filters\": { \"and\": [\n        { \"field\": \"to\", \"operator\": \"exists\", \"value\": \"\", \"inverse\": false },\n        { \"field\": \"from\", \"operator\": \"exists\", \"value\": \"\", \"inverse\": true }\n      ]}}, \"times\": 1, \"within\": 0, \"inverse\": false }\n    ]\n  }\n}\n```\n\n### \"Related to object type 1 where status is active\"\n\n```json\n{\n  \"conditions\": {\n    \"and\": [\n      { \"relationship\": { \"type_id\": 1, \"inverse\": false } },\n      { \"and\": [\n        { \"related_object_attribute\": { \"type_id\": 1, \"field\": \"status\", \"operator\": \"eq\", \"value\": \"active\", \"inverse\": false, \"value2\": \"\" } }\n      ]}\n    ]\n  }\n}\n```\n\n### \"Related to object type 1 where relationship role is admin\"\n\n```json\n{\n  \"conditions\": {\n    \"and\": [\n      { \"relationship\": { \"type_id\": 1, \"inverse\": false } },\n      { \"and\": [\n        { \"relationship_attribute\": { \"type_id\": 1, \"field\": \"role\", \"operator\": \"eq\", \"value\": \"admin\", \"inverse\": false, \"value2\": \"\" } }\n      ]}\n    ]\n  }\n}\n```\n\nUse `related_object_attribute` for fields on the object itself. Use `relationship_attribute` for fields on the person-object link. If the user asks for a specific object or relationship attribute, do not simplify the segment to `{ \"relationship\": { \"type_id\": 1 } }`; that changes the segment to \"object exists\" and ignores the requested criterion.\n\n### \"Entered the Downtown Stores geoset in the last 7 days\"\n\nResolve the geoset name to its id first (`GET .../geosets`). See `segment_conditions.md` type 11.\n\n```json\n{\n  \"conditions\": {\n    \"and\": [\n      {\n        \"event\": {\n          \"type\": \"geofence\",\n          \"name\": \"enter\",\n          \"filters\": { \"field\": \"geoset_id\", \"operator\": \"eq\", \"value\": \"42\", \"inverse\": false }\n        },\n        \"times\": 1, \"within\": 604800, \"inverse\": false\n      }\n    ]\n  }\n}\n```\n\n### \"Currently inside the Downtown Stores geoset\"\n\nDo not build this from `has entered` AND `has not exited`. Those conditions record whether the transition has **ever** happened; they do not fold the ordered event stream into current state. After enter → exit → re-enter, the profile still has a historical exit and the proposed condition excludes them even though they are inside again.\n\nTell the user that exact current occupancy is unavailable as a native geofence segment. The reliable alternative is a profile/object attribute or other state that their system updates as transitions arrive, then segment on that state. If they accept a time-bounded approximation such as \"entered within the last day and did not exit within the last day,\" label it as approximate and warn that exit → re-enter sequences remain wrong.\n\n## Timestamp Offset Sign Convention\n\nThe `timestamp_gt` and `timestamp_lt` operators use a **seconds offset from now**:\n- **Negative** values point to the **past**: `\"-86400\"` = 1 day ago, `\"-2592000\"` = 30 days ago\n- **Positive** values point to the **future**: `\"86400\"` = 1 day from now\n\nFor `created_at` (which records when a person was first identified), the value is **always in the past**. Using a positive offset with `created_at` creates an impossible condition that will never match anyone.\n\n| User request | Operator | Value | Meaning |\n|---|---|---|---|\n| \"signed up in the last day\" | `timestamp_gt` | `\"-86400\"` | created_at \u003e 1 day ago ✅ |\n| \"signed up in the last 7 days\" | `timestamp_gt` | `\"-604800\"` | created_at \u003e 7 days ago ✅ |\n| \"signed up in the last 30 days\" | `timestamp_gt` | `\"-2592000\"` | created_at \u003e 30 days ago ✅ |\n| \"signed up more than 30 days ago\" | `timestamp_lt` | `\"-2592000\"` | created_at \u003c 30 days ago ✅ |\n| ~~\"signed up after tomorrow\"~~ | `timestamp_gt` | `\"86400\"` | created_at \u003e tomorrow ❌ IMPOSSIBLE |\n\n## Common Pitfalls\n\n1. **Forgetting the `to`/`from` pair**: Attribute conditions need both a `to` filter and a matching `from` filter with `inverse: true`. Without the `from` filter, the condition may not match as expected.\n\n2. **Segment nesting depth**: You can only reference segments that don't themselves reference other segments (leaf segments). Attempting deeper nesting returns 400.\n\n3. **Editing archived segments**: Returns 400. Unarchive first.\n\n4. **Deleting in-use segments**: Segments used by active automations or unsent one-time sends can't be deleted. Check usage first with `GET .../segments/:id/usage`.\n\n5. **Wrong sign on timestamp offsets**: `timestamp_gt` with `\"86400\"` means \"after tomorrow\" (future), not \"in the last day\". For \"in the last N\", always use negative values: `\"-86400\"` for 1 day, `\"-604800\"` for 7 days, `\"-2592000\"` for 30 days. This is especially critical for `created_at` which is always in the past.\n\n6. **`times_operator` does not exist.** There is no `times_operator` field. The `times` field is always \"at least N times\" — there is no way to change this to \"exactly\" or \"at most\". Do not add `times_operator` to conditions; it is silently ignored and the condition may not work as expected.\n\n7. **Using `{\"attribute\": {...}}` as a segment leaf.** This is the `filters.Filter` shape used by `RecipientsFilter` and automation triggers (not automation `filters`, which takes segment references only) and returns 500 on segment create. For segments, wrap attribute matches in `{\"event\": {\"type\": \"attribute_change\", \"name\": \"\u003cattr\u003e\", \"filters\": {\"and\": [to, from]}}, \"times\": 1, \"within\": 0, \"inverse\": false}`. The `{\"attribute\": {...}}` leaf is parseable by the filters library but the segments JSON parser does not accept it.\n\n8. **Replacing object attribute criteria with relationship existence.** A condition like \"Fly Accounts with plan_type enterprise\" must include a `related_object_attribute` refinement. A bare `relationship` condition means \"has any Fly Account\" and will match the wrong audience.\n\n9. **Approximating \"B within N of A\" with two conditions.** A segment's `within` is a rolling window measured from now, and conditions are evaluated independently. There is no way to require that one event happened within N of *another* event (\"sent the automation within 24h of the call\"). Do not ship two independent windowed conditions as if they enforced that relationship. Flag the limitation before building and offer only supported segment alternatives. See `segment_conditions.md` § \"within Is Measured From Now\".\n\n10. **Substituting a near-named campaign/message into a delivery condition.** Scoped delivery conditions hinge on an exact `email-campaign_{id}` / `{channel}-campaign_{id}` id. If the automation the user named doesn't return an exact match, don't quietly use a similarly-named one — confirm which automation they mean first (see `SKILL.md § Resolving a Named Resource`). A wrong id silently targets the wrong audience.\n\n11. **Putting the geoset in the geofence event's `name`.** A geofence condition's `name` is the transition (`\"enter\"`/`\"exit\"`); the geoset belongs in `filters` as a `geoset_id` equality. And never model geofencing as a custom event (`\"type\": \"event\"` with a name like \"geofence_entered\") — that matches a literal custom event, not the geofence transitions.\n","segment_lookback.md":"---\nminimum_plan: essentials\nallow_free: true\n---\n\n# Plan Lookback Limit\n\nSome plans cap how far back an event condition can look: Flex allows 30 days, or 365 with the Extended data retention add-on, and every other plan is unlimited. Before writing an event condition with `within: 0` or a window longer than 30 days, read the account's limit: `GET /v1/accounts/{account_id}` returns it as `account.gated_features.conditions_lookback_days.limit`, in days (the response wraps everything in `account`, so read `.account.gated_features`, not `.gated_features`). When `account.gated_features` has no `conditions_lookback_days` entry, the entry is empty (`{}`, so `limit` reads `null`), or the `limit` is `0`, the account is unlimited and nothing below applies. A `null` for `.account.gated_features` itself means you read the wrong path: re-read before writing the condition.\n\nOn an account with a limit:\n\n- The limit covers every `event` leaf except the `attribute_change`, `device_change`, `experiment_exposure` and `consent` types, which keep `within: 0`. Custom events, message events (`opened_email`, `delivered_action`, ...), page and screen views, form submissions and geofences are all capped.\n- `within: 0` (all time) is refused, and so is any window longer than the limit (`limit × 86400` seconds).\n- If the user asks for \"ever\", \"all time\" or a longer window, create nothing and don't shorten the window yourself: a shorter window changes who is in the segment. Explain the plan's limit, offer the longest window it allows, and wait for the user to agree. When the limit is 30 days, add that the Extended data retention add-on raises it to 365 days and is bought under **Account settings → Plans \u0026 billing → Add-ons**, and that you can't buy it for them. The add-on never allows \"ever\": a Flex account is capped at 365 days even with it, so don't present it as a way to get an all-time condition.\n- When updating a segment, a condition it already has may stay past the limit if you resend it unchanged. Changing its window or event, or copying it into another segment, makes it a new condition, which is refused.\n- A 400 whose message contains `invalid within value` is this limit. Nothing was saved; explain the limit as above rather than retrying.\n","segments.md":"# Segments\n\nSegments are groups of profiles used as automation triggers, one-time send audiences, and filters. This file covers manual segments and the mechanics shared by every type. For rule-based (data-driven) segment creation — conditions, build state — read `data_driven_segments.md`.\n\nBefore explaining, critiquing, or recommending changes to an existing segment's conditions, read `segment_conditions.md`, including when the conditions are already visible in page context.\n\nBefore recommending a named segment as a replacement, read its definition and compare its full condition tree with the current segment. If they are identical, say that switching will not change membership.\n\n## Segment types\n\n| Type | When to use | Has conditions? |\n|------|------------|----------------|\n| **Data-driven** | \"Everyone matching these rules\" — membership updates automatically | Yes — `conditions` required |\n| **Manual** | \"I want to manually control who's in this list\" — members added/removed via API | No |\n| **Snapshot** | \"Who matched these rules as of now\" — membership holds until refreshed | Yes — `conditions` required |\n\nA manual segment can never change type. Data-driven and snapshot are interchangeable after creation — see `snapshot_segments.md`.\n\n\u003e **Flex supports data-driven segments within a workspace allowance.** Read `data_driven_segments.md` before offering or creating a data-driven segment. If it returns a plan notice instead of content, this workspace's plan does not support data-driven segments. A quota limit is different: follow that file's allowance guidance, and never offer a manual segment as a substitute for a live, rule-based audience. If the user asks for a manual segment, create it even at the allowance.\n\n\u003e **Snapshot segments are gated too.** Only offer one if `snapshot_segments.md` is readable — if that file doesn't exist for this workspace, the type isn't enabled: don't mention it, and don't send `type: \"snapshot\"`.\n\n## Segment Request Workflow\n\nWhen the user asks to create, build, pull, or inspect a segment, move from discovery to an outcome quickly.\n\n1. Decide between a **data-driven segment** (\"everyone matching these rules\") and a **manual segment** (\"a manually controlled list\"). If the wording is rule-based or audience-based, prefer data-driven — subject to the availability check above. Only reach for a snapshot segment when the user says the membership should *not* keep updating (\"as of today\", \"freeze this list\", \"don't let it change\"); see `snapshot_segments.md`.\n2. If the request uses vague business terms like \"active members\", \"programs\", \"VIPs\", or \"engaged\", do at most two targeted discovery calls before asking a blocker question:\n   - Search existing segments by name with `size` and `search`.\n   - Check workspace data/index or a small profile sample only to identify likely attribute/event names.\n3. If you find a clear matching field or existing segment, either create/update the requested segment or explain the exact option you found and ask one confirmation question.\n4. Signup or \"created\" wording means `created_at` when the workspace has it: customers send it as the signup date, while Customer.io only sets `_created_in_customerio_at`. Build on it without asking.\n5. Before sending an event condition with `within: 0` (\"ever\", \"all time\") or a window longer than 30 days, read the account's lookback limit first (`segment_lookback.md`). Some plans refuse those windows, and the check has to come before the create, not after a 400.\n6. Do not page through many customer-list pages to infer a business rule. If the definition still is not clear after targeted discovery, ask the user to define the rule in one sentence.\n7. If the user asks to see the people in a segment, give the segment people UI link plus the count/metrics result — and if you need the member data yourself, filter the profiles list by segment (see § Reading Profiles in a Segment). There is no `/segments/:id/customers` endpoint to guess at.\n\nIn user-facing replies, refer to segments by **name**, not only numeric ID. Include the ID only when the user explicitly asks for it or when it helps disambiguate duplicate names.\n\n**A described audience is a segment request, even without the word \"segment\".** \"Everyone who has a phone number on file\", \"customers who don't have a first name set\", \"people on the premium plan who aren't in the US\" — these name a rule, so build the segment and report it. A profile search answers a different shape of question: one about *these particular people* or *how many*, like \"show me\", \"how many\", \"find the profile for\", \"list the people who\". Don't run a `/customers` search, report a count, and stop to ask whether they wanted a segment — that leaves the user with nothing and costs a round trip. If the rule is genuinely ambiguous, build the reading you'd defend and say which one you took.\n\n## UI Links\n\nWhen you create or reference a segment, provide the UI links:\n- Segment settings: `https://fly.customer.io/workspaces/$ENV_ID/journeys/segments/:id`\n- People in segment: `https://fly.customer.io/workspaces/$ENV_ID/journeys/segments/:id/people`\n\n## Creating a Manual Segment\n\n```\nPOST /v1/environments/:environment_id/segments\n```\n\n```json\n{\n  \"segment\": {\n    \"type\": \"static\",\n    \"name\": \"VIP Customers\",\n    \"description\": \"Manually curated VIP list\"\n  }\n}\n```\n\nThe API type value for a manual segment is `\"static\"`. A manual segment has no `conditions`.\n\n### Managing Members\n\n```\nPUT /v1/environments/:environment_id/segments/:segment_id/members\n```\n\n| `type` value | What it does | Needs `customer_id`? |\n|-------------|-------------|---------------------|\n| `add` | Add a profile | Yes — `cio_id` as raw hex without the `cio_` prefix (preferred), or external `id` |\n| `remove` | Remove a profile | Yes |\n| `clear` | Remove all members | No |\n| `clear_with_changes_notification` | Clear all + notify downstream (automations watching this segment) | No |\n\n## Checking Segment Size\n\nUse the count endpoint to get the number of people in a segment:\n\n```\nGET /v1/environments/:environment_id/segments/:segment_id/count\n→ { \"count\": 1500, \"finished\": true }\n```\n\n`finished` is false while a build is running or a rebuild is pending — see `automation_debugging.md § Why People Aren't Entering an Automation`. On a manual segment it is also false while ingress is still processing a CSV import into it: there is no build, and the segment's `state` reads `build_queued` (no rows processed yet) or `build` with `progress`. An import still unfinished 24h after it was queued stops counting.\n\nFor count plus engagement breakdown (opened, clicked, active this week):\n\n```\nGET /v1/environments/:environment_id/segments/:segment_id/metrics\n→ { \"customer_count\": 1500, \"subscribed_count\": 1200, ... }\n```\n\n## Reading Profiles in a Segment\n\n**There is no segment sub-resource for members — no route, and no method on the one that exists.** All four of these fail, and none of them fails cleanly:\n\n| Attempt | What you get |\n|---|---|\n| `GET .../segments/:id/customers` | `200` + `text/html` (the SPA shell), never `404` |\n| `GET .../segments/:id/membership` | same |\n| `GET .../segments/:id/people` | same |\n| `GET .../segments/:id/members` | same — the path is real but **`PUT`-only** (add/remove/clear); a GET has no counterpart and falls through to the shell |\n\nA wrong path or wrong method never `404`s here: the shell body reads as \"no results\" rather than an error, so a retry loop looks like an empty segment. Don't spend a call finding out — the member list comes from the profiles list below.\n\nRead members through the **profiles list, filtered by segment**:\n\n```bash\nFILTER=$(echo -n '[{\"type\":\"segments_negatable\",\"segments\":[\u003csegment_id\u003e],\"negate\":false}]' | base64 -w 0)\ncio api /v1/environments/$ENV_ID/customers --params \"{\\\"filters\\\":\\\"$FILTER\\\"}\" \\\n  --jq '{total: .meta.pagination.total, ids: [.customers[].id]}'\n```\n\n`meta.pagination.total` equals the segment's count. The modern filter shape works too — `{\"and\":[{\"segment\":{\"id\":\u003csegment_id\u003e,\"inverse\":false}}]}`, base64'd the same way — and `negate: true` / `\"inverse\": true` gives you everyone *not* in the segment.\n\nAdd conditions to narrow the members (AND-ed, and `attribute` here is limited to `eq` / `exists` — see `customers.md` § Filtered People List URLs). Once a filter carries an attribute *value*, URL-encode the JSON before base64 as `customers.md` describes: a raw `+` in a value decodes to a space (wrong audience, still a 200) and a raw `%` fails decoding with a 400.\n\n```json\n[{\"type\":\"segments_negatable\",\"segments\":[61],\"negate\":false},\n {\"type\":\"attribute\",\"field\":\"plan\",\"operator\":\"eq\",\"value\":\"premium\"}]\n```\n\nSix things that will otherwise cost you turns:\n\n- **Page size is fixed at 50 and only `page` paginates.** `per_page` and `size` are read by nothing on this endpoint — pass them and you still get 50 rows, with no error to tell you.\n- **Page until `page * 50 \u003e= meta.pagination.total`, not until a short page.** Deleted and suppressed profiles are dropped *after* the page is cut, so a middle page can hand back fewer than 50 rows while more members remain. The usual \"stop when a page is short\" rule under-reports the segment here.\n- **`sort` does nothing on this endpoint** — not just under `filters`. Rows come out in index order (`asc` merely reverses it), so never present a page as \"the newest\" or \"the first\" members. To rank members, pull the ids and order them yourself on an attribute you read back.\n- **Deep paging in the default `desc` order is anchored to the live total**, so a segment gaining or losing members mid-walk re-serves or skips rows. For a moving segment, prefer `asc` or an export.\n- **`filters` and `email` are mutually exclusive** (400). Filter by an email attribute inside `filters` instead.\n- **An empty page next to a non-zero total is never \"the segment is empty.\"** Either a sensitive attribute in the filter concealed the rows from your grant, or every row on that page was deleted/suppressed. Say which you can't tell rather than reporting zero members.\n\nAlso watch the encode step itself: an empty `filters` value is a valid filter meaning *no filter*, so a `$FILTER` that silently came back empty lists the whole workspace and reports the workspace size as the segment's. Check the variable before trusting the total.\n\nFor a full member dump, prefer `POST .../customers/exports` with the same base64 `filters` (see `customers.md`) over paging hundreds of pages — it needs export-create permission, so on a 403 fall back to paging rather than reporting the export as broken.\n\nStill use the count/metrics endpoints for sizing, and when the user just wants to *look* at the people, give them the UI link — `https://fly.customer.io/workspaces/$ENV_ID/journeys/segments/:id/people` — rather than pasting a list of ids.\n\nOne more dead-end: `GET .../customers?segment_id=:id` — the `segment_id` query param is **silently ignored** by the profiles list handler (it reads only `asc`, `page`, `email`, `filters` — plus a `sort` it never applies). You get unfiltered workspace profiles back and won't notice unless you check membership.\n\n### Why a profile is or isn't a member\n\nMembership is decided by whether the profile satisfies each condition **now**, not by the history of how it got there. When you explain why someone is or isn't in a segment, read their **current** attribute values (and event history for event conditions) and check them against the leaves.\n\nThe trap is the `attribute_change` leaf. It is a **current-value** check — `country eq \"United States\"` means \"country currently equals United States\" — even though the read-back JSON says `type: \"attribute_change\"` with a `to`/`from` pair (see `segment_conditions.md` § attribute change). Never tell a user a profile is excluded because an attribute \"never changed to\" a value, or that they must re-set the attribute to trigger a change: a profile whose `country` has read `United States` for months, untouched, is a member. Diagnose against the current value, not against a change event.\n\n## Updating a Segment\n\n```\nPUT /v1/environments/:environment_id/segments/:segment_id\n```\n\nSend the same shape as create. For data-driven segments, updating conditions triggers a rebuild (segment goes back to `\"build\"` state) — see `data_driven_segments.md`.\n\n**A PUT replaces the live definition.** Before overwriting:\n\n- **GET the segment first.** You need its current `name` (a PUT without it returns `Name can't be blank`), and the existing definition shows you what you're replacing.\n- **Disambiguate create vs. edit.** If the user said \"build a segment\" while a specific segment was open (you have its ID/URL from context), confirm whether they want to **modify that existing segment** or **create a new one** before overwriting a definition someone else may own.\n\nCannot update archived segments (returns 400). If a segment is used as an automation trigger, it cannot be updated to reference other segments (returns 422).\n\n**Tag-only change? Don't PUT.** A PUT requires `name` (and `conditions` for data-driven segments) even if you only want to touch tags. Use the dedicated tags endpoints below instead — they never touch the segment definition.\n\n## Tags\n\nTags are organizational labels shared workspace-wide (segments, broadcasts, etc.). They never affect membership or delivery; their only job is filtering lists — see `searchTagIds` below.\n\n### Adding tags\n\n```\nPOST /v1/environments/:environment_id/segments/:segment_id/tags\n```\n\nTwo body shapes, **mutually exclusive** (sending both is a 400):\n\n```json\n{ \"tag_ids\": [28, 30] }\n```\n\nAttaches **existing** tags by ID. An unknown ID is a 400 and nothing is attached. Idempotent — re-sending the same IDs is safe. **Prefer this shape whenever the tag already exists.**\n\n```json\n{ \"tags\": [{ \"name\": \"VIP\" }] }\n```\n\nMatches by tag **name**; a name with no existing match **silently creates a new tag** (so a typo mints a duplicate tag instead of failing). Only use this shape to create-and-attach a tag that doesn't exist yet. The `id` field on entries is ignored.\n\nBoth shapes **append** — tags already on the segment are kept. To bulk-tag many segments, loop this endpoint (one call per segment); never GET+PUT the full segment payload just to add a tag.\n\n### Removing a tag\n\n```\nPOST /v1/environments/:environment_id/segments/:segment_id/tags_delete/:tag_id\n```\n\nRemoves one tag from the segment (the tag itself survives). To replace the entire tag set in one shot, send `tag_ids` on the segment PUT — but that requires the full payload (see Updating above).\n\n## Listing and Filtering\n\n```\nGET /v1/environments/:environment_id/segments\n```\n\n\u003e **⚠️ IMPORTANT**: Filters like `usage`, `search`, `type`, `archived`, `searchTagIds`, etc. are **only applied when pagination is enabled** (`size` \u003e 0). Without `size`, the endpoint returns ALL segments unfiltered and silently ignores any filter parameters. Always include `size` (e.g. `size=50`) when filtering.\n\nCommon filters (require `size` \u003e 0):\n\n| Parameter | Example | Description |\n|-----------|---------|-------------|\n| `search` | `\"VIP\"` | Substring match on name, description, or ID |\n| `type` | `\"dynamic\"` | Filter by segment type — `dynamic` (data-driven), `static` (manual), or `snapshot`; `manual` is not a valid filter value |\n| `usage` | `\"not_in_use\"` | `in_use`, `not_in_use`, `in_sent_newsletters`, or `all` |\n| `archived` | `true` | Return only archived segments |\n| `short` | `\"1\"` | Lightweight response (id, name, type only) — works without `size` |\n\n## Deleting and Archiving\n\n- **Delete**: `DELETE .../segments/:id` or batch via `POST .../segments/delete` with `{ \"segment_ids\": [1, 2, 3] }`\n- **Archive**: `POST .../segments/archive` with `{ \"segment_ids\": [1, 2, 3] }`\n- **Unarchive**: `POST .../segments/unarchive` with `{ \"segment_ids\": [1, 2, 3] }`\n\nSegment deletion is irreversible and always requires explicit, unambiguous user intent naming the exact segment or segments to delete. A request to identify which segments *could* be deleted is advisory and read-only: inspect usage and report candidates, but do not issue a DELETE as a test or delete any candidate. When live edits are off, deletion is blocked for every segment even with a capability grant; state this before doing dependent work. When live edits are on, before deleting, request one `delete:segment:\u003cid\u003e` scope for each named segment, all in one `request_capability` call, and finish the deletes in the turn that approval opens. Once approved, issue the plain `cio api .../segments/\u003cid\u003e -X DELETE` — the CLI attaches the grant itself, and a `-H \"X-CIO-Capability-Grant: ...\"` flag only fails (`unknown shorthand flag: 'H'`).\n\nCannot delete segments in use by active automations, unsent one-time sends, or SQL imports (returns 422). Cannot delete segments referenced by other segments (returns 400). Cannot archive segments used by active automations or unsent one-time sends (returns 422). A form's segment can't be deleted while its form exists, or archived while the form is connected (returns 422; the segment's usage reports the form under `linked_form`): disconnect or delete the form instead. Unarchiving is also capped: if restoring the requested segments would take the workspace over its data-driven segment limit, the whole batch is refused with a 422 and nothing is restored. Unarchiving isn't gated by the live-data setting, even when a draft uses the segment, so make the call and relay what it returns. When it's refused, say the limit counts active data-driven segments and archived ones don't count until restored. Manual segments never count toward that cap. On a plan with a conditions lookback limit (`segment_lookback.md`), unarchiving is also refused, with a 400 that names the segment (`segment N (\"name\"): invalid within value: ...`), when a requested segment or one it references has an event condition past the limit. Archiving ends grandfathering: the segment keeps its conditions, but restoring checks them against the limit as if they were new. An \"ever\" condition can only be restored on a plan without a limit; the Extended data retention add-on's 365 days isn't enough. A form's segment is exempt and always restores as it was. Nothing is restored, so name that segment and explain the limit rather than retrying. Offer deleting an unneeded data-driven segment with the user's explicit agreement to free space. Archived segments are excluded from current usage, but restoring counted data-driven segments consumes allowance again. Duplicating a one-time send whose audience references archived segments unarchives them too, so it is capped the same way and refused whole (see `messaging.md`).\n\n## Segment Membership Over Time\n\nTrack how a segment's size changes over time:\n\n```\nGET /v1/environments/:environment_id/metrics/segment_membership?segment_id=:id\n```\n\nReturns time series of `total`, `entered`, and `left` counts. Time range capped at 2 years.\n\n**Query parameters (v1 only — do NOT pass `version=2`):**\n- `segment_id` (required) — the segment to track\n- `start`, `end` — unix timestamps\n- `resolution` — one of `days`, `weeks`, `months`\n\nFor a point-in-time engagement snapshot (current size, opened/clicked this week, etc.), use the segment metrics endpoint instead:\n\n```\nGET /v1/environments/:environment_id/segments/:segment_id/metrics\n```\n","sign_up_forms.md":"---\nfeature_flag: sign-up-forms\n---\n\n# Sign-up Forms\n\nSign-up forms are in-app broadcasts with `type: \"sign_up_form\"`. Their templates represent multiple outputs of one form, so their content must stay identical.\n\nThis file covers finding and saving existing forms. To build a new form end to end — create, author the fields and success state, enable outputs, launch, hand back the links — read `skills/recipes/creating_a_sign_up_form.md` first; it carries the workflow and the gotchas this file assumes.\n\n## Find a form\n\nList forms through `GET /v1/environments/:eid/design_studio/sign_up_forms`: it returns every form node as a stub (`id` is the node id; no broadcast id yet) and takes only scalar params (`parent_folder_id`, `sort_by`, `page`, `limit`). Then `GET .../design_studio/sign_up_forms/:id` gives the full form with `in_app_broadcast_id` and state, and `GET .../in_app_broadcasts/:iab_id` its broadcast with templates and URLs.\n\n```bash\ncio api /v1/environments/$ENV_ID/design_studio/sign_up_forms --jq '.sign_up_forms[] | {name, id}'\n```\n\n`GET /v1/environments/:eid/in_app_broadcasts` can also list form broadcasts, but only with `types[]=sign_up_form` (without it the list omits forms, and `types=sign_up_form` returns nothing) — and `cio api --params` rejects bracketed keys and array values, so this filter is unusable from the agent CLI. Don't burn attempts re-encoding it; use the stub list above.\n\n## Links\n\nA live form's URLs are on its broadcast (`GET .../in_app_broadcasts/:iab_id`): `hosted_page_url` when the `hosted` output is on, `embed_script_url` when `embedded` is on. Hand the embed back as a paste-ready snippet — `\u003cscript src=\"EMBED_SCRIPT_URL\" async\u003e\u003c/script\u003e`, placed in the page's `\u003cbody\u003e` where the form should render (the script inserts the form at the tag's position, falling back to the end of the body). That wrapper is the documented embed form, not a guess; give it, don't hand back the bare URL.\n\n## Update form content\n\nCompile Carta content as described in `in_app.md`, then save it with:\n\n```\nPOST /v1/environments/:eid/design_studio/sign_up_forms/:id/publish\n```\n\n`:id` is the form's Design Studio node id, the `ds_node_id` on the broadcast's linked template in `GET /in_app_broadcasts/:id`; the numeric in-app broadcast id is still accepted but deprecated. Send every output's template for the language in one request; discover the current request shape with `cio schema`. Read the current content from each output's `GET /templates/:template_id` (the broadcast lists its templates). The other `.../design_studio/sign_up_forms/:id` routes take only the node id, so a 404 there with a broadcast id is expected and is no reason to save another way.\n\nYour save is explicit, so always use this `publish` route. Don't use `PUT .../sign_up_forms/:id/templates`: it is the editor's save while typing, takes the same body, and a scheduled or live form refuses it with 409 `form_live_explicit_save_required`. On a draft form the publish route only writes. On a scheduled or running form it first runs launch's content checks, then writes; a running form also republishes the hosted page and embed script, while a scheduled form publishes at go-live without checking again. A failed check returns 422 `form_content_invalid` and saves nothing: `errors[0].meta.problems` lists each failing `template_id`, its `output` and its `errors`, and can name a stored template you did not send (another language), which needs fixing too. While the form is starting, stopping or deleting, every save returns 422 `form_locked`; wait for it to settle rather than saving another way.\n\nIn a multi-app workspace, a form's platform/app `targeting` lives in its `in_app` output's template in the default template's language, which needn't be the broadcast's default template. Set it there; `targeting` in the embedded and hosted templates is ignored.\n\nDo not save form content through `PUT /templates/:template_id`. That route rejects `body` and `body_json` changes with a 422. Metadata fields such as `name` can still be updated there.\n","site_activity.md":"---\nminimum_plan: essentials\n---\n\n# Site \u0026 App Activity Analytics\n\nAnswer \"how many people visited my site\", \"how many people opened page X\", per-page/per-screen breakdowns, and visit trends using the `sv_site_activity` semantic view in `ANALYTICS.CIO_AGENT`. Also carries geofence enter/exit events (plan-gated: Premium/Enterprise with geofencing enabled).\n\n**\"Opened page X\" means VIEWED page X — never an email open.** Email opens live in `deliverability.md` (`sv_deliverability`). When a question says \"opened\" and could mean either, the presence of a page/screen/site/app noun routes here; a message/campaign/subject noun routes to deliverability.\n\n**Identified profiles only.** Anonymous pre-identification traffic never reaches the warehouse, so every count here undercounts what a web-analytics tool would show. Say so whenever reporting site totals.\n\nRouting: message performance → `deliverability.md`. Profile lifecycle/attributes/devices → `profile_changes.md`. Profile attribute breakdowns → `profile_state.md`. Objects and memberships → `object_state.md` / `object_changes.md`. Pipeline failures → `pipeline_health.md`.\n\n## The semantic view at a glance\n\n| Logical table | Base table | Grain (one row per…) | Use for | Key caveat |\n|---|---|---|---|---|\n| `activity` | `fct_site_activity_events` | page/screen **view** | visit volumes, unique visitors, per-page/per-screen splits, hour-of-day | identified profiles only |\n| `geofence` | `fct_geofence_events` | geofence **enter/exit** | physical location visits | plan-gated (Premium/Enterprise + geofencing enabled) — most workspaces have no rows; don't surface geofence data to workspaces without the feature |\n\n**PRODUCT SURFACE:** the `page()`/`screen()` activities from the Customer.io JavaScript snippet and mobile SDKs that appear on a person's Activity Log and power \"Page view\" segment/trigger conditions; geofence events come from the Geofences/Geosets feature used for location-based triggers.\n\n## Choosing the right query\n\n1. **\"How many people visited my site\"** → `unique_page_visitors`. With a `view_name` filter, the same metric answers \"how many people opened page X\". App equivalents: `unique_screen_visitors`. Combined: `unique_visitors`.\n2. **`view_name` is a URL or a page title depending on the sender's SDK** — try both a URL match and a title match when a page filter finds nothing. `page_host`/`page_path` are parsed only from full URLs; NULL there does NOT mean \"not a website\" (it may be a title-based page view).\n3. **Compare pages of one site via `page_path`** — it strips the query string, so campaign-parameter variants of the same page group together. `view_name` keeps them separate.\n4. **Trends / \"change in the past N days\"**: group metrics by `occurred_date` and compare periods in SQL — there are no delta metrics. Weekly/monthly buckets need the person-grain rollup in Example 4. Hour-of-day questions use `occurred_hour` (US Pacific wall-clock — always state the timezone).\n5. Every query: `workspace_id = TO_NUMBER($WORKSPACE_ID)` in the outer WHERE + a constrained `occurred_date` range.\n\n## Metrics\n\n| Metric | Description |\n|--------|-------------|\n| `activity.page_views` | Page view events (websites). \"Opened page X\" = viewed page X. |\n| `activity.screen_views` | Screen view events (mobile apps) |\n| `activity.total_views` | Page + screen views combined |\n| `activity.unique_visitors` | Distinct identified people with ≥1 page or screen view |\n| `activity.unique_page_visitors` | Distinct identified people with ≥1 page view — THE \"how many people visited my site\" metric |\n| `activity.unique_screen_visitors` | Distinct identified people with ≥1 screen view |\n| `activity.distinct_pages_viewed` | Distinct page URLs/titles viewed |\n| `activity.views_per_visitor` | Average views (page + screen) per distinct identified visitor |\n| `geofence.geofence_entries` | Geofence entry events |\n| `geofence.geofence_exits` | Geofence exit events |\n| `geofence.people_in_geofences` | Distinct people with any geofence event |\n\n## Dimensions\n\n| DIMENSIONS reference | Output column (unaliased) | Description |\n|---|---|---|\n| `activity.workspace_id` | `workspace_id` | **Required filter on every query** |\n| `activity.occurred_date` | `occurred_date` | Date of the view. Always constrain to a range; group by it for trends. |\n| `activity.occurred_at` | `occurred_at` | View timestamp. **US Pacific wall-clock, NOT UTC.** |\n| `activity.occurred_hour` | `occurred_hour` | Hour of day (0–23, US Pacific) the view occurred. |\n| `activity.view_type` | `view_type` | `page` (website) or `screen` (mobile app). Prefer the `page_*`/`screen_*` metrics over filtering this. |\n| `activity.view_name` | `view_name` | What was viewed: page URL or page title (sender's SDK chooses), or screen name. Filter/group by this for \"page X\" — try URL and title matches. |\n| `activity.page_host` | `page_host` | Host parsed from `view_name` when it is a full URL. NULL for titles, scheme-less URLs (no `https://`), and screens — NULL ≠ \"not a website\". |\n| `activity.page_path` | `page_path` | URL path parsed from `view_name` (same NULL cases as `page_host`), no leading slash or query string. Groups query-param variants of one page. |\n| `activity.person_id` | `person_id` | Person who viewed. Group by to list WHO visited, or COUNT DISTINCT it for exact uniques per bucket — combine with a date range and a page filter. |\n| `geofence.workspace_id` | `workspace_id` | Required filter on every geofence query. |\n| `geofence.occurred_date` | `occurred_date` | Date of the enter/exit. |\n| `geofence.movement` | `movement` | `enter` or `exit`. Prefer the metrics over filtering this. |\n| `geofence.geofence_name` | `geofence_name` | Display name of the geofence at event time. |\n| `geofence.geoset_id` | `geoset_id` | Geoset (geofence collection) the fence belongs to. |\n| `geofence.person_id` | `person_id` | Person who entered/exited. High cardinality — scope tightly. |\n\nOutput columns are always named after the dimension, so outer SELECT/WHERE/GROUP BY clauses say `person_id` — never `customer_internal_id`, which is the underlying fact-table column and invisible to the outer query (`invalid identifier` on compile).\n\n## Submitting queries\n\nSame endpoint and rules as `deliverability.md` § Submitting queries, including its five-point pre-flight — heredoc + `--arg`, never hand-escape, aggregate in SQL; members only from this file's tables, never guessed names (`000904`); `table.member` only inside the SEMANTIC_VIEW parens, plain output names outside, and the outer query touches only requested columns (`000904`); aggregate filters in `HAVING` or a wrapping CTE (never the outer WHERE — `000990`/`002035`); GROUP BY covering every non-aggregated selected column when re-aggregating (`000979`); one fact table per call, related dimensions only (`010959`); fix-and-retry on compilation errors — never resubmit unchanged SQL, never fall back to another data source:\n\n```bash\nSQL=$(cat \u003c\u003c'EOF'\nSELECT unique_page_visitors, page_views\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_site_activity\n    METRICS    activity.unique_page_visitors,\n               activity.page_views\n    DIMENSIONS activity.workspace_id\n    WHERE activity.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nEOF\n)\ncio api /v1/environments/$ENV_ID/snowflake_query -X POST --timeout 120s \\\n  --arg query=\"$SQL\" --json '{query: $query}'\n```\n\nThe heredoc delimiter must be quoted (`\u003c\u003c'EOF'`) so `$WORKSPACE_ID` reaches Snowflake literally. Results cap at 10,000 rows (`\"truncated\": true`). `--timeout 120s` is required: the CLI's 30s default dies with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` while the server is still working — resend the same SQL with the flag, don't narrow. Only a warehouse-side 90s statement timeout means the query is too slow — then narrow the date range.\n\n## Examples\n\n### 1. How many people visited my site in the past 30 days\n\nThe query above. Report as \"identified visitors\" with the anonymous-traffic caveat.\n\n### 2. How many people opened page X\n\n```sql\nSELECT view_name, unique_page_visitors, page_views\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_site_activity\n    METRICS    activity.unique_page_visitors,\n               activity.page_views\n    DIMENSIONS activity.view_name,\n               activity.workspace_id\n    WHERE activity.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND (view_name ILIKE '%/pricing%' OR view_name ILIKE '%pricing%')\nORDER BY page_views DESC\n```\n\nThe double match covers URL-based and title-based `view_name` senders. If it matches several distinct pages, show them — don't silently sum.\n\n### 3. Top pages of one site (query params collapsed)\n\n```sql\nSELECT page_path, page_views, unique_page_visitors\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_site_activity\n    METRICS    activity.page_views,\n               activity.unique_page_visitors\n    DIMENSIONS activity.page_path,\n               activity.page_host,\n               activity.workspace_id\n    WHERE activity.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND page_host = 'app.example.com'\nORDER BY page_views DESC\nLIMIT 20\n```\n\n### 4. Visit trend / weekly \u0026 monthly rollups\n\nDaily grain: group `page_views` + `unique_page_visitors` by `occurred_date`. For weekly or monthly buckets, do NOT select the pre-aggregated unique metrics — period uniques cannot be derived from daily uniques (SUM overcounts, MAX undercounts). Pull `person_id` as a dimension instead and aggregate in the outer query:\n\n```sql\nSELECT\n  DATE_TRUNC('week', occurred_date) AS week_start,\n  SUM(page_views)                   AS page_views,\n  COUNT(DISTINCT person_id)         AS unique_page_visitors\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_site_activity\n    METRICS    activity.page_views\n    DIMENSIONS activity.occurred_date,\n               activity.person_id,\n               activity.view_type,\n               activity.workspace_id\n    WHERE activity.occurred_date \u003e= DATEADD('day', -91, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\n  AND view_type = 'page'\nGROUP BY week_start\nORDER BY week_start\n```\n\nSwap `'week'` for `'month'` (with e.g. `\u003e= DATE_TRUNC('month', DATEADD('month', -3, CURRENT_DATE()))`) for monthly buckets; flag the current partial week/month when reporting. Rules that make this correct:\n\n- The `view_type = 'page'` filter is what makes `COUNT(DISTINCT person_id)` mean *page* visitors — the person-grain rows cover page AND screen views, so without it the count is combined `unique_visitors` (over-counting on any workspace with a mobile app). Use `= 'screen'` with `SUM(screen_views)` for app-only; drop the filter (and rename the alias) for combined.\n- Once the outer query has a GROUP BY, every selected metric needs an aggregate — `SUM(page_views)` is safe because page views are additive. The converse also holds: every non-aggregated selected column (including derived expressions like the week bucket) must be in the GROUP BY, or Snowflake fails with `000979`. Filters on those aggregates go in `HAVING` (or a wrapping CTE), never the outer WHERE.\n- SELECT every column you bucket or order by. Never return dateless rows and reconstruct dates client-side by row position — days with zero activity have no row, so positional dating silently shifts buckets.\n- If the person-grain query hits the warehouse's 90s statement timeout on a large workspace (with `--timeout 120s` already set), fall back to one query per bucket window with only `activity.workspace_id` as a dimension — the `unique_page_visitors` metric is then exact for that window.\n\n### 5. What time of day do people browse\n\nGroup `total_views` by `occurred_hour` (state: US Pacific wall-clock, not visitor-local).\n\n### 6. Geofence visits (plan-gated)\n\n```sql\nSELECT geofence_name, geofence_entries, people_in_geofences\nFROM SEMANTIC_VIEW(\n    ANALYTICS.CIO_AGENT.sv_site_activity\n    METRICS    geofence.geofence_entries,\n               geofence.people_in_geofences\n    DIMENSIONS geofence.geofence_name,\n               geofence.workspace_id\n    WHERE geofence.occurred_date \u003e= DATEADD('day', -30, CURRENT_DATE())\n)\nWHERE workspace_id = TO_NUMBER($WORKSPACE_ID)\nORDER BY geofence_entries DESC\n```\n\nZero rows on a workspace without the Geofences feature is expected — say the feature isn't in use rather than \"no location visits happened\".\n\n## Answering\n\nState the time window, that counts cover identified profiles only (anonymous traffic absent), and the US Pacific timezone on any hour/timestamp answer. When a page filter matched multiple `view_name` values, list them.\n\n## What This Skill Cannot Answer\n\n- **True total site traffic** — anonymous pre-identification views never reach the warehouse; a web-analytics tool will always show more.\n- **Email/message opens** — `deliverability.md`. \"Opened page X\" here is a page view.\n- **Referrers, UTM attribution, session duration, bounce rate** — not captured; only the view events and their names.\n- **Current geofence occupancy** — enter/exit events only, not who is inside right now.\n- **Real-time data** — ~1–5 minute staleness from the event stream.\n","sms.md":"# SMS Authoring — Writing the Message Body\n\nHow to write good SMS copy for Customer.io: segmentation and cost, smart\nencoding, links, opt-out compliance, and copy patterns. All of it applies to\nevery SMS the workspace sends. **Where** that copy is stored depends on which\nSMS channel the workspace is on — settle that first.\n\n## Which SMS channel is this workspace on?\n\nCustomer.io has two SMS channels, both labelled \"SMS\" in the UI, and they store\ncontent in completely different places. Check before creating or editing any\nSMS message:\n\n```bash\ncio api /v1/environments/$ENV_ID --jq '.environment.enabled_actions'\n```\n\n| `enabled_actions` contains | Channel | Action type | Where the body lives |\n|---|---|---|---|\n| `\"twilio\"` | Legacy direct-Twilio SMS | `twilio_action` | Plain-text `body` on a `twilio` template — see **templates.md § SMS example** |\n| `\"sms\"` (and not `\"twilio\"`) | Custom SMS (Sinch, Vonage, Infobip, Bird, Twilio-as-a-vendor) | `sms_action` | `\u003cx-sms\u003e` markup on a **Design Studio SMS node**, published to an `sms` template — see **sms_design_studio.md** |\n| neither | SMS isn't set up | — | Don't build an SMS message; say the channel is missing and link where to connect an SMS provider: `https://fly.customer.io/workspaces/$ENV_ID/settings/actions/sms-providers` (**channels.md**) |\n\nSettings, sender sync and vendor connections are per channel too\n(**channels.md**). On custom SMS the delivery settings (link shortening, smart\nencoding) are `GET`/`PUT .../message_channels/sms/settings` — the legacy\n`.../sms/settings` record is separate and custom-SMS sends never read it, so\nanswer from the channel record alone. When asked what you can do with a\ncustom-SMS setup: manage vendor connections (`/sms_providers`), sync senders\n(`/sms_providers/sync`), read and change those delivery settings, and author\nmessages as Design Studio SMS nodes (**sms_design_studio.md**).\n\n`\"twilio\"` wins when both are present: a workspace with legacy Twilio already\nprovisioned keeps using `twilio_action`, which is also what the dashboard's\naction picker offers. (An internal `legacy-twilio` flag can pin a workspace to\nTwilio as well; it isn't readable over the API, so if the user says the\ndashboard shows something other than what `enabled_actions` implies, believe\nthe user.)\n\n**When editing an existing message, don't infer — read it.** The campaign\naction's own `type` (`twilio_action` vs `sms_action`), or the template's `type`\n(`twilio` vs `sms`), is the answer for that message. Writing a plain body onto\nan `sms` template, or trying to link a Design Studio SMS node to a `twilio`\none, fails.\n\nThe copy guidance below is identical for both, and applies whether the message\nis an automation action, a one-time send (**messaging.md**), or a transactional\nsend (`/v1/send/sms` — **transactional_send.md**). Recipients are E.164 phone\nnumbers (`+15551234567`).\n\n\u003e **Liquid works in the body.** Personalization, defaults, filters, conditionals\n\u003e all apply — see `recipes/liquid_syntax.md`. The SMS-specific risk is that\n\u003e interpolated values change the message length at send time; see § Segments \u0026 Cost.\n\n## The One Thing to Get Right: Segments \u0026 Cost\n\nSMS is billed **per segment**, not per message. A \"segment\" is one 160-or-fewer\ncharacter chunk. Long messages are split and you pay for each part. The character\nbudget depends on the encoding, which is decided automatically by the characters\nin the body:\n\n| Encoding | When it's used | Single-segment limit | Per-segment when split |\n|---|---|---|---|\n| **GSM-7** | Body uses only standard GSM-7 characters (Latin letters, digits, common punctuation) | **160** | **153** |\n| **UCS-2** | Body contains **any** character outside GSM-7 — most commonly an emoji or non-Latin script | **70** | **67** |\n\nTwo consequences worth internalizing:\n\n1. **One emoji collapses the budget from 160 to 70.** Adding a single 🎉 to an\n   otherwise-plain 150-character message turns a 1-segment GSM-7 message into a\n   3-segment UCS-2 message — roughly **3× the cost** for the same copy. Before adding\n   emoji to SMS, tell the user what it does to the segment count and cost and\n   let them choose between that and a GSM-7 alternative; change nothing until\n   they do.\n2. **Multi-segment messages lose characters to headers.** Once a message splits,\n   each part holds only 153 (GSM-7) or 67 (UCS-2) characters because segment\n   headers consume the rest. A 161-character GSM-7 message is 2 segments totaling\n   306 characters of capacity, not one big one.\n\n**GSM-7 extension characters count as two.** These are technically GSM-7 but each\neats two character slots: `\\` `|` `^` `€` `{` `}` `[` `]` `~`. A body full of curly\nbraces or the euro sign hits the segment boundary sooner than its visible length\nsuggests.\n\n\u003e **Budget for the rendered length, not the source length.** `Hi {{customer.first_name}}`\n\u003e is ~20 source characters but renders to `Hi Bartholomew` (14) or `Hi Jo` (5) —\n\u003e and a long value can push the message over a segment boundary you didn't expect.\n\u003e When a body is near 160 (or 70 with emoji), assume the longest realistic value\n\u003e for each Liquid variable and check whether it still fits one segment. Use\n\u003e `| truncate:` on unbounded fields (product names, addresses) to cap the worst case.\n\n**Don't count characters by hand — ask the server.** Both preview endpoints\nrender the Liquid, apply the workspace's smart-encoding setting, and return\n`num_segments` and `encoding`: `POST .../previews/twilio` for a `twilio` body,\n`POST .../previews/message_channel` with `\"template_type\": \"sms\"` for an\n`\u003cx-sms\u003e` one (**sms_design_studio.md § Checking segments before you publish**).\nRun it with the longest realistic variable values rather than eyeballing the\nresult.\n\n## Smart Encoding\n\n**Smart encoding is on by default** and silently rewrites common \"smart\"\ntypography into its GSM-7 equivalent so the message stays in the cheaper encoding.\nFor example it maps curly quotes `\"` `\"` → straight `\"`, em/en dashes and figure\ndashes → `-`, ellipsis `…` → `...`, and many accented or symbol look-alikes to\nASCII. The user pastes text from a word processor and smart encoding keeps it from\naccidentally falling into UCS-2.\n\n| Smart encoding **fixes** (stays GSM-7) | Smart encoding does **NOT** fix (forces UCS-2) |\n|---|---|\n| Curly quotes, smart apostrophes | Emoji (🎉 ❤️ 👍) |\n| Em/en dashes, fancy hyphens, ellipsis | Non-Latin scripts (中文, العربية, हिंदी) |\n| Some accented \u0026 symbol look-alikes | Characters with no GSM-7 equivalent |\n\n- The toggle is a channel setting: `.../message_channels/sms/settings`\n  (`disable_smart_encoding`; absent means on) for custom SMS, `.../sms/settings`\n  for legacy Twilio (**channels.md**). Leave it on unless the user has a\n  specific reason.\n- It is **not a license to use emoji** — emoji and non-Latin scripts have no GSM-7\n  equivalent, so they still force UCS-2 and the 70-character budget regardless.\n\n## Links\n\n- **Customer.io tracks link clicks by default** by appending tracking code to your\n  URLs, which makes them *longer*. **URL shortening** (a workspace SMS setting —\n  **channels.md § SMS Settings**) offsets that: links are rewritten to a short\n  tracked URL, preserving click tracking while saving characters.\n- **Short links expire after 90 days** (paths are reused only within your own\n  workspace, never across workspaces).\n- Shortening uses your workspace's short-link domain; up to **10 custom short-link\n  domains** can be configured per workspace.\n- **Links still cost characters.** Even a shortened link is ~20-25 characters —\n  budget for it against the segment limit. A link in a UCS-2 (emoji) message is\n  especially expensive.\n- **Always use `https://`.** Carriers and link scanners treat bare or `http://`\n  links as more spammy.\n- Don't hand-shorten URLs with a third-party shortener — public shorteners\n  (bit.ly etc.) are heavily filtered by carriers and hurt deliverability.\n\n## Opt-Out, Keywords \u0026 Compliance\n\nCustomer.io recognizes inbound keywords automatically (case-insensitive) and\ncollapses every variant into one intent — you do **not** write logic for each\nword, and a person who texts any opt-out variant is suppressed from future sends\nwithout you building anything:\n\n| Intent | Recognized keywords |\n|---|---|\n| **Opt out** → STOP | `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`, `OPTOUT` / `OPT-OUT` / `OPT OUT`, `REVOKE` |\n| **Opt out** → STOP (French) | `ARRET`/`ARRÊT`, `ARRETE`, `ARRETT`, `DESABONNEMENT`/`DÉSABONNEMENT`, `DÉSABONNER`, `CESSER`, `ANNULER` |\n| **Opt back in** → START | `START`, `UNSTOP`, `OPTIN` |\n| **Help / info** → HELP | `HELP`, `INFO`, `AIDE` |\n\n(`YES`/`Y`/`OK`/`OUI`/`👍` and `NO`/`N`/`NON`/`👎` are also recognized as affirmative/negative replies.)\n\nThe French terms are recognized in **every** workspace, not just Canadian ones —\nthere is no locale or country gate. Matching ignores case, surrounding whitespace,\ntrailing punctuation (`Stop.` and `STOP!` both work) and accents (`arret` ==\n`arrêt`), but it matches the **whole message only**: \"I want to cancel my order\" is\nan ordinary reply, not an opt-out.\n\n`AIDE` is the French **help** keyword, not an opt-out one — it answers with the help\nintent, same as `HELP`/`INFO`. CWTA's Canadian Common Short Code guidelines require\n`STOP`, `ARRET`, `HELP`, `AIDE` and `INFO` on every program whatever language the\nprogram itself is in. There is no French `START` equivalent yet.\n\n- **Opt-outs are honored immediately**, and the STOP confirmation reply is sent by\n  the carrier/Twilio — you don't write it. Customer.io captures opt-in/opt-out\n  state from Twilio and auto-suppresses opted-out numbers.\n- **Include an opt-out affordance**, especially on the first/marketing message:\n  `Reply STOP to unsubscribe` (or `Txt STOP to opt out`). This is a compliance\n  expectation, not just good manners.\n- **Add a HELP/info line** where appropriate (e.g. `Reply HELP for help`) — `HELP`\n  and `INFO` are handled as the help intent.\n- **Identify the sender** in the first message a person receives, e.g. lead with\n  the brand name. Anonymous \"Hi, your code is 1234\" first-contact messages get\n  flagged.\n- **Don't use any of the keywords above as casual words or as your own reply\n  prompts** — they collide with keyword handling. Inbound replies can also trigger\n  automations (inbound automations), so a stray \"reply YES\" can fire automation you\n  didn't intend or quietly opt someone in/out. To handle replies, build an inbound\n  automation — see **inbound_messaging.md**.\n- Marketing SMS requires prior opt-in. If the user is drafting a promotional blast,\n  confirm the audience has consented; don't help send cold SMS.\n\n\u003e Sender registration (toll-free / 10DLC verification, brand vetting) is an\n\u003e infrastructure concern handled in Settings, not part of authoring — out of scope\n\u003e here.\n\n## Sender \u0026 Recipient\n\n- **Recipient** must be E.164 (`+15551234567`). A bare `5551234567` or a value\n  without `+country` may fail to send. When the phone comes from a profile\n  attribute, the workspace normalizes it, but malformed numbers are dropped.\n- **Sender** is a sender identity on the workspace, synced from the provider —\n  `/twilio/sync` for legacy Twilio, `/sms_providers/sync` for custom SMS (see\n  **channels.md**). List them with `GET .../identities?type=twilio` or\n  `?type=sms` respectively. On `twilio_action` the template's\n  `from_identity_id` carries it; on `sms_action` the Design Studio node's\n  `sender_id` does, and publishing is refused without one\n  (**sms_design_studio.md**). Either way, be aware that a single message body\n  may be sent from a long code, toll-free, or short code, and very long\n  multi-segment bodies behave differently across them.\n\n## Media (MMS)\n\nYou can attach an image to a message, turning it into an MMS. **MMS isn't available\non every destination carrier** — support varies by country and carrier, and where\nthe recipient's carrier doesn't support it the media is dropped or the send fails\n(most commonly outside the US and Canada). MMS also costs more than a plain SMS\nsegment. Don't rely on the image to carry the message — keep the essential\ninformation in the **text** so recipients whose carrier can't receive MMS still get it.\n\n## Copy Patterns\n\nKeep SMS short, specific, and single-purpose. Lead with the brand or context, put\nthe one useful thing up front, and include an opt-out on marketing.\n\n### Shipping / status update (transactional, GSM-7)\n```\nAcme: Hi {{customer.first_name | default: \"there\"}}, your order #{{event.order_id}} has shipped. Track it: {{event.tracking_url}}\n```\nPlain text, one link, stays in GSM-7. ~95 chars + link → 1 segment.\n\n### OTP / verification code (transactional, GSM-7)\n```\n{{event.otp_code}} is your Acme verification code. It expires in {{event.expires_in_minutes}} minutes. Don't share it.\n```\nCode first so it's visible in the notification preview. No link (OTP links invite phishing).\n\n### Appointment reminder (transactional, GSM-7)\n```\nAcme: Reminder — your appointment with {{event.provider_name}} is {{event.appt_time | date: \"%a %b %-d, %-I:%M %p\"}}. Reply C to confirm or call {{event.office_phone}} to reschedule.\n```\nWatch the length — this is near one segment; a long `provider_name` can push it to two.\n\n### Abandoned cart (marketing, GSM-7, with opt-out)\n```\nAcme: {{customer.first_name | default: \"Hi\"}}, you left {{event.product_name | truncate: 30}} in your cart. Complete your order: {{event.cart_url}}\nReply STOP to opt out.\n```\n`truncate` caps the worst-case product name; opt-out line is required for marketing.\n\n### Promo (marketing — flag the emoji cost)\n```\nAcme: Our biggest sale is on — {{event.discount_percent}}% off everything through {{event.sale_end | date: \"%b %-d\"}}. Shop: {{event.shop_url}}\nReply STOP to opt out.\n```\n\u003e If the user wants an emoji here, tell them it switches the message to UCS-2 (70-char\n\u003e segments) and will likely cost 2-3 segments. Offer to keep it GSM-7 instead.\n\n## Common Mistakes (Authoring Checklist)\n\n- **Writing the body in the wrong place** — assuming every workspace is\n  `twilio_action`. Check `enabled_actions` first (§ Which SMS channel is this\n  workspace on?); an `sms_action` message is authored as a Design Studio node.\n- **Emoji / non-Latin text added without asking** — triples cost by forcing\n  UCS-2 (70-character single segment). Confirm with the user first.\n- **Body near the limit with unbounded Liquid** — a long rendered value tips it\n  into an extra segment. Truncate or budget for the longest realistic value.\n- **No opt-out on a marketing message** — compliance gap; add `Reply STOP to opt out.`\n- **Anonymous first message** — lead with the brand name.\n- **`http://` or bare-domain links** — use `https://`; rely on workspace URL\n  shortening rather than a public shortener.\n- **Counting source characters instead of rendered characters** — Liquid expands\n  at send time; `{{...}}` is not its final length.\n- **Assuming smart encoding rescues emoji** — it doesn't; only typography look-alikes.\n- **Long URLs eating the budget** — even shortened links cost ~20-25 chars.\n","sms_design_studio.md":"---\nname: SMS in Design Studio\ndescription: Author an sms_action message as a Design Studio node — create with \u003cx-sms\u003e markup, set sender and recipient, link to a campaign action / one-time send / transactional message, publish.\n---\n\n# SMS in Design Studio (`sms_action` workspaces)\n\n**Read `sms.md § Which SMS channel is this workspace on?` first.** This file\napplies only when the workspace's SMS action is `sms_action`. On a\n`twilio_action` workspace the body is a plain string you `PUT` onto the\ntemplate — there is no Design Studio node — and this whole flow does not apply.\n\nThe reason the flow differs: an `sms` template accepts only the Design Studio\n(`parcel`) editor. There is no simple-editor body field to write to, so the\ncontent lives on a Design Studio SMS **node** and reaches the template by\npublishing.\n\n\u003e **Body copy is authored the same way on both channels.** Segments and cost,\n\u003e GSM-7 vs UCS-2, links, opt-out — all of `sms.md` applies. This file is only\n\u003e about *where* the body lives and how it gets to the message.\n\n## Flow: create → link → publish\n\nThe three steps mirror the Design Studio email flow (`@skills/design-studio/journeys.md`),\non the `sms` sibling paths.\n\n### 1. Create the node\n\n```\nPOST /v1/environments/$ENV_ID/design_studio/sms\n```\n\n```json\n{\n  \"name\": \"Order shipped\",\n  \"content\": \"\u003cx-sms\u003e\u003cx-sms-body\u003eAcme: your order has shipped. Track it: {{event.tracking_url}}\u003c/x-sms-body\u003e\u003c/x-sms\u003e\",\n  \"recipient\": \"{{customer.phone}}\",\n  \"sender_id\": 4821\n}\n```\n\n| Field | Notes |\n|---|---|\n| `name` | Required. |\n| `content` | The `\u003cx-sms\u003e` markup — see § Content markup below. |\n| `recipient` | Liquid resolving to an E.164 number. Use `{{customer.phone}}`, which is what the dashboard seeds. |\n| `sender_id` | Sender **identity** id (an integer), not a phone number. Optional at create, **required before publish**. An id that doesn't resolve to an existing identity is a 400. |\n| `parent_folder_id` | Optional folder UUID; `null` for the root. Omitted means the root, so decide it first — `@skills/design-studio/SKILL.md` step 1g (Where it lives). |\n\nThe server assigns the node id — do **not** generate one. The response wraps the\nnode under `sms`: `{ \"sms\": { \"id\": \"\u003cuuid\u003e\", \"content\": …, \"recipient\": …, \"sender_id\": …, \"is_linked\": false } }`.\n\nPick `sender_id` from the workspace's SMS sender identities:\n\n```\nGET /v1/environments/$ENV_ID/identities --params '{\"type\": \"sms\"}'\n```\n\n(`type=sms` lists the custom-SMS senders. A workspace migrated from legacy\nTwilio can still hold `type=twilio` identities, and the SMS editor accepts\neither, so check both when `type=sms` comes back empty.)\n\nMost sender identities are synced from the vendor — if there are none, the user\nsyncs them from **Workspace settings → Custom SMS**\n(`POST /v1/environments/$ENV_ID/sms_providers/sync`, see `channels.md`).\nDon't invent an id, and **when the workspace has no sender, stop and tell the\nuser** — a missing sender is theirs to resolve, never something to manufacture a\nway around.\n\nCreating a sender is a separate task the user asks for by name, not a step in\nauthoring a message. When they do ask — for a number the vendor sync doesn't\nreturn, including a `phone` written as liquid that resolves to a different number\nper profile — it's `POST /v1/environments/$ENV_ID/identities` with\n`template_type: \"sms\"` and the number they gave you. Such a sender needs\n`actions_credential_id` — the `id` of a connection from\n`GET /v1/environments/$ENV_ID/sms_providers` — unless its `phone` contains\nliquid, in which case the provider is optional and the rendered number is matched\nagainst the synced senders at send time.\n\n### 2. Link it to a destination\n\n```\nPOST /v1/environments/$ENV_ID/design_studio/sms/\u003cnode_id\u003e/link\n```\n\n```json\n{ \"target\": { \"type\": \"campaign_action\", \"action_id\": 91234 } }\n```\n\n`target.type` is `campaign_action` (`action_id`), `newsletter` (`newsletter_id`),\nor `transactional_message` (`transactional_message_id`) — the destination\n**entity** id, never a template id. The endpoint resolves that destination's\ndefault template and points it at the node. The response is\n`{ \"node_id\", \"template_id\", \"target\" }`.\n\nFor a campaign, create the `sms_action` first (`automations.md`) and link to the\n`action_id` the automation returns.\n\nLink also seeds the template body immediately, so a linked-but-never-published\nSMS is already deliverable — but publish is still what promotes later edits.\n\n### 3. Publish\n\n```\nPOST /v1/environments/$ENV_ID/design_studio/sms/\u003cnode_id\u003e/publish\n```\n\nBody may be omitted. This writes the node's current `content` (plus its\nrecipient and sender) onto the linked template and records a version:\n`{ \"template_ids\": [...], \"version_id\": \"...\" }`. **Nothing the user receives\nchanges until this returns 2xx** — say \"saved, not published\" otherwise, per\n`@skills/design-studio/journeys.md § Reporting results to the user`.\n\nTo revise after publishing, `PUT /v1/environments/$ENV_ID/design_studio/sms/\u003cnode_id\u003e`\nwith any of `name`, `content`, `recipient`, `sender_id`, `parent_folder_id`\n(at least one; returns `204`), then publish again.\n\n## Content markup\n\nThe node's `content` is `\u003cx-sms\u003e` markup, not bare text. The SMS channel parses\nit at delivery — the publish step does not render it.\n\n```html\n\u003cx-sms\u003e\n  \u003cx-sms-body\u003eAcme: hi {{customer.first_name | default: \"there\"}}, your order has shipped.\u003cbr\u003eTrack it: {{event.tracking_url}}\u003c/x-sms-body\u003e\n  \u003cx-sms-media type=\"image\" href=\"{{event.product_image_url}}\" /\u003e\n\u003c/x-sms\u003e\n```\n\n| Tag | Required | Notes |\n|---|---|---|\n| `\u003cx-sms\u003e` | yes | Root. |\n| `\u003cx-sms-body\u003e` | yes | The message text, with Liquid. An empty or missing body fails to parse at send time. |\n| `\u003cx-sms-media\u003e` | no | Attaches an image, making the message an MMS. `type=\"image\"` and `href` (a URL or Liquid resolving to one). At most one. |\n\n- **Line breaks are `\u003cbr\u003e`**, not literal newlines — the parser rewrites `\u003cbr\u003e`\n  to a newline and `\u0026nbsp;` to a space. Any form of the tag works: attributes,\n  self-closing, any casing.\n- **No other HTML goes in the body.** An SMS is plain text, and the tags above\n  are the whole grammar. A tag wrapping text — `\u003cb\u003e`, `\u003cspan\u003e` — fails the parse\n  with `template_body is not in the expected format` naming the tag, rather than\n  quietly dropping what it wrapped. A tag holding no text is dropped. Convert\n  formatting to plain text before you save.\n- The markup is XML-parsed leniently: a bare `\u0026` is accepted, but a bare `\u003c`\n  is not — write it `\u0026lt;` (`5 \u0026lt; 6`), or the body fails to parse. A stray\n  `\u003c` is the usual cause of `template_body is not in the expected format`.\n- Read `sms.md § Media (MMS)` before adding media: MMS isn't supported by every\n  destination carrier and costs more, so the essential information belongs in\n  the body text.\n\n## Checking segments before you publish\n\nDon't count characters yourself — the server renders the markup, applies the\nworkspace's smart-encoding setting, and reports the real numbers:\n\n```\nPOST /v1/environments/$ENV_ID/previews/message_channel\n```\n\n```json\n{\n  \"template_type\": \"sms\",\n  \"template_body\": \"\u003cx-sms\u003e\u003cx-sms-body\u003eHello {{customer.first_name}}\u003c/x-sms-body\u003e\u003c/x-sms\u003e\",\n  \"variables\": { \"customer\": { \"first_name\": \"Bartholomew\" } }\n}\n```\n\nThe response carries `num_segments`, `encoding` (`GSM-7` / `UCS-2`),\n`segment_size` and `body_size` alongside the rendered body. Pass a realistic\nlong value for each Liquid variable — that is the check `sms.md § Segments \u0026\nCost` asks for, done against the send path rather than by eye.\n\nTo send a real test message to a handset, use\n`POST /v1/environments/$ENV_ID/verify/message_channel_template` with the\n**template** id (from the link response) or `action_id` — see `test_sends.md`.\n\n## Linking to the UI\n\nSMS nodes have their own editor route:\n\n```\nhttps://fly.customer.io/workspaces/$ENV_ID/design-studio/editor/sms/$NODE_UUID\n```\n\nUse the node's UUID `id`, never its `short_id` — see\n`@skills/design-studio/SKILL.md § Linking to the UI`.\n\n## Constraints and gotchas\n\n- **Publish requires a sender.** A node with no `sender_id` in its envelope is\n  refused: `sms node has no sender selected`. So is an empty `content`. When the\n  workspace has no sender to pick, that refusal is the answer — report the\n  message as saved but not live and hand the sender problem back to the user.\n  Creating an identity to satisfy the check invents a number they never chose and\n  bills their sends to it.\n- **Adding an `sms_action` is not proof the channel exists.** Unlike most\n  channels, the API accepts an `sms_action` in an automation even when no SMS\n  vendor is connected — you get a 200 and an action that can never send.\n  Confirm the channel per `channels.md § Confirm the channel is set up before\n  building on it` rather than treating the 200 as the check.\n- **Don't `PUT` the linked template's `body`.** Once linked its editor is\n  `parcel`, and an agent write to `body` is rejected 422. Edit the node and\n  publish instead (`templates.md`).\n- **The destination must be an SMS message.** Linking an SMS node to an email,\n  push, in-app or WhatsApp destination returns 400 `destination is not an sms`.\n  On a `twilio_action` workspace the destination is a `twilio` template, which\n  is also refused — that is the loudest symptom of getting § Which SMS channel\n  wrong.\n- **One destination per node, and `link` never re-points it.** Linking a node\n  that already links a *different* destination is a 400; re-linking the same one\n  is idempotent. There is no plain unlink, and `bulk_duplicate` doesn't accept\n  SMS nodes. To serve another destination, create a new SMS with the same\n  content, then either link the new one there (the original stays put — say so),\n  or, when the *original* must move, transfer its link onto the new one with\n  `POST /ds/node_actions/relink` `{ \"from_node_id\", \"to_node_id\" }` and link the\n  original to the new destination (`@skills/design-studio/journeys.md`\n  § Re-pointing an email). Either way the old destination keeps an SMS on it.\n  If the *destination* is already linked to a different node, link returns 409 —\n  pass `\"force\": true` only with the user's intent, since it steals the\n  destination from the other node.\n- **Translations don't publish.** Link refuses a translation node (`cannot link\n  a translation; link the default-language sms`), and publish writes only the\n  default node's content into the one linked template. It still answers 200 —\n  don't report translated variants as live.\n- **No `unpublished_changes` route for SMS.** That endpoint exists for emails\n  only; compare the node's `content` with the linked template's `body` yourself\n  if you need to know whether a publish is pending.\n- **A linked node can't be deleted.** `DELETE /design_studio/sms/{id}` returns\n  409 `cannot delete sms because it is linked`, and there is no plain unlink to\n  clear that. Deleting it means first transferring its link to another unlinked\n  SMS (`POST /ds/node_actions/relink`) or removing the destination (the campaign\n  action, one-time send, or transactional message) — the user's call, not yours.\n- **Node search can't see SMS.** `/ds/node_actions/search` drops SMS nodes even\n  unfiltered. List them with `GET /v1/environments/$ENV_ID/design_studio/sms`,\n  fetch one by id, or use `node_actions/linkable` with `\"node_type\": \"SMS\"` —\n  see `@skills/design-studio/nodes.md`.\n- **Live-data gate.** Edits to a linked node's content, recipient, or sender,\n  plus link and publish, enforce the workspace's \"Allow agent to edit live\n  data\" setting when the destination is an automation whose `state` is running,\n  evaluating, or sunsetting, or whose `scheduled_start` is greater than zero; a\n  non-cancelled scheduled or sent one-time send; or an active or previously-sent\n  transactional message. A transactional message is non-live only when it is\n  inactive and has never sent. These operations return 403\n  `agent_editing_disabled` when the setting is off. Handle it per `SKILL.md §\n  Agent Permission Errors`; don't retry.\n","snapshot_segments.md":"---\nfeature_flag: snapshot_segments\nminimum_plan: essentials\n---\n\n# Snapshot Segments\n\nA snapshot segment is defined with `conditions` like a data-driven segment, but its\nmembership **does not update as data changes**. It holds whoever matched at the last\nrefresh until someone refreshes it again.\n\nUse one when the audience has to stay still: a campaign cohort you want to report on,\nan experiment arm, an \"everyone who qualified on the 1st\" list. Use a data-driven\nsegment whenever the user wants membership to keep up.\n\nRead `segments.md` for the mechanics shared with the other types, and\n`segment_conditions.md` for the condition format — it is the same format.\n\n## Creating one\n\n```\nPOST /v1/environments/:environment_id/segments\n```\n\n```json\n{\n  \"segment\": {\n    \"type\": \"snapshot\",\n    \"name\": \"Q3 qualified buyers\",\n    \"conditions\": { \"and\": [ ... ] }\n  }\n}\n```\n\n`type: \"snapshot\"` is the only difference from a data-driven create — `name` and\n`conditions` are required exactly as they are there.\n\nThe first build runs on create, so a new snapshot segment populates itself. It enters\n`\"build\"` state; poll `GET .../segments/:id/status` as usual.\n\n## Refreshing\n\n```\nPOST /v1/environments/:environment_id/segments/:segment_id/refresh\n```\n\nReturns `204` and starts a rebuild in the background. Poll\n`GET .../segments/:id/status` for progress — the same endpoint and the same states as\na data-driven build — and `POST .../segments/:id/cancel` aborts it.\n\nPressing refresh while a refresh is already running returns `204` and does nothing.\nThere is no separate \"already refreshing\" error to handle; poll `status` either way.\n\n\u003e **⚠️ A refresh can trigger automations.** Profiles entering or leaving the segment\n\u003e during a refresh emit the same entered/exited events an ordinary segment does, so an\n\u003e automation triggered on segment entry **will fire** for everyone the refresh adds.\n\u003e Say so before refreshing a segment that has an entry-triggered automation attached —\n\u003e check with `GET .../segments/:id/usage`.\n\nA refresh rebuilds from current data, so it costs about what creating the segment\ncosts. It is not a cheap operation on a large workspace; don't poll-and-refresh in a\nloop.\n\n## How old is the snapshot\n\n`last_built_at` on the segment (unix seconds) is when its membership last finished\nbuilding — for a snapshot segment, when the snapshot was taken. It is `null` for a\nsegment that has never been built. Report the age from this field, not `updated_at`:\n`updated_at` moves whenever the definition is edited, so it drifts away from the\nbuild as soon as anyone renames the segment.\n\n## Changing type\n\nUnlike the other segment types, **snapshot and data-driven are interchangeable after\ncreation** — they hold the same conditions and differ only in whether membership\nkeeps updating. PUT the segment (see `segments.md`) with `type: \"snapshot\"` or\n`type: \"dynamic\"`.\n\n- **data-driven → snapshot** freezes the membership it already has. Nothing rebuilds.\n- **snapshot → data-driven** rebuilds, because the segment has to catch up on\n  everything it ignored while it was frozen. Expect a `\"build\"` state afterwards.\n\nConverting to or from **manual** is still impossible, for any type.\n\n## Listing\n\n`type=snapshot` filters the segment list to these (needs `size` \u003e 0, like every other\nfilter — see `segments.md`). They report `\"type\": \"snapshot\"`, not `\"dynamic\"`.\n\n## Limits\n\nSnapshot segments do **not** count against the data-driven segment limit, because\ningress skips them. A workspace at its data-driven cap can still create them.\n\nOne caveat on cost: dormancy silences people ingress. A snapshot segment built on\nobject or relationship conditions still pays to keep those indexes current, so it is\nfrozen but not cheap.\n\n## Not supported\n\nA **manual** segment cannot become a snapshot segment, and a snapshot segment cannot\nbecome manual — its membership *is* its conditions. The type change returns `400`.\nOnly `dynamic` and `snapshot` are interchangeable.\n\nRelationship conditions are fine: a snapshot segment freezes whatever its conditions\nmatch, object and relationship conditions included.\n","subscriptions.md":"# Subscriptions — Preference Center Configuration\n\nThe subscription center lets profiles manage their messaging preferences. Automations and one-time sends can be tagged with a **topic** so unsubscribed profiles are automatically excluded. The preference page supports multiple **channels** (email, SMS, push), **localization**, and custom **page layouts**.\n\n\n## Topics\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/subscription_topics` | `subscription_center.GetAllTopics` | List all topics with usage info (which campaigns/broadcasts use them) |\n| GET | `/v1/environments/{environment_id}/subscription_topics/{id}` | `subscription_center.GetTopic` | Get a single topic with usage data |\n| POST | `/v1/environments/{environment_id}/subscription_topics` | `subscription_center.CreateTopic` | Create a topic |\n| PUT | `/v1/environments/{environment_id}/subscription_topics/{id}` | `subscription_center.UpdateTopic` | Update a topic |\n| DELETE | `/v1/environments/{environment_id}/subscription_topics/{id}` | `subscription_center.DeleteTopic` | Delete a topic |\n| PUT | `/v1/environments/{environment_id}/subscription_topic_order` | `subscription_center.SaveTopicOrder` | Set display order of topics. Body: `{\"topic_order\": [1, 3, 2]}` (all topic IDs must be included) |\n\n## Topic Messages (Automation/One-time send Assignments)\n\nAssign subscription topics to automations and one-time sends so profiles who unsubscribe from a topic stop receiving those messages.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/subscription_topic_messages/list` | `subscription_center.MessageList` | List campaigns/newsletters with their topic assignments. Params: `search`, `tagIds[]`, `type`, `state`, `globalPreference`, `topicId`, `page` |\n| PUT | `/v1/environments/{environment_id}/subscription_topic_messages/update` | `subscription_center.MessageUpdate` | Assign a topic to campaigns/newsletters. Body: `{\"updates\": {\"campaign_ids\": [...], \"newsletter_ids\": [...], \"action_ids\": [...]}, \"subscription_topic_id\": ..., \"send_to_unsubscribed\": false}` |\n\n## Channels\n\nSubscription channels define which delivery types (email, SMS, push, etc.) profiles can manage preferences for on the preference page.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/subscription_channels` | `subscription_center.GetAllChannels` | List all subscription channels |\n| PUT | `/v1/environments/{environment_id}/subscription_channels/{channel_type}` | `subscription_center.UpsertChannel` | Create or update a subscription channel. Body: `{\"subscription_channel\": {\"name\": \"...\", \"description\": \"...\", \"opt_in\": false}}` |\n| DELETE | `/v1/environments/{environment_id}/subscription_channels/{channel_type}` | `subscription_center.DeleteChannel` | Delete a subscription channel |\n\n## Languages (Localization)\n\nMulti-language support for the subscription center preference page. Each language can have translated topic names, channel names, and page titles.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/subscription_languages` | `subscription_center.GetLanguages` | List all available languages |\n| POST | `/v1/environments/{environment_id}/subscription_languages` | `subscription_center.CreateLanguages` | Enable languages. Body: `{\"subscription_languages\": [{\"id\": \"es\", \"enabled\": true}]}` |\n| PUT | `/v1/environments/{environment_id}/subscription_languages` | `subscription_center.EnableDisableLanguages` | Toggle enabled state for languages. Body: `{\"subscription_languages\": [{\"id\": \"es\", \"enabled\": false}]}` |\n| DELETE | `/v1/environments/{environment_id}/subscription_languages` | `subscription_center.DeleteLanguages` | Delete multiple languages. Body: `{\"languages\": [\"es\", \"fr\"]}` |\n| GET | `/v1/environments/{environment_id}/subscription_languages/{language_id}` | `subscription_center.GetLanguage` | Get a single language with translated topic/channel names |\n| PUT | `/v1/environments/{environment_id}/subscription_languages/{language_id}` | `subscription_center.UpdateLanguage` | Update translations. Body: `{\"subscription_language\": {\"title\": \"...\", \"subtitle\": \"...\", \"topics\": [{\"id\": ..., \"name\": \"...\", \"description\": \"...\"}], \"channels\": [{\"type\": \"...\", \"name\": \"...\", \"description\": \"...\"}]}}` |\n| DELETE | `/v1/environments/{environment_id}/subscription_languages/{language_id}` | `subscription_center.DeleteLanguage` | Delete a single language |\n\n## Pages\n\nCustom subscription center page layouts and header content.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/subscription_pages` | `subscription_center.GetPages` | List all subscription center pages |\n| POST | `/v1/environments/{environment_id}/subscription_pages` | `subscription_center.CreatePage` | Create a page. Body: `{\"subscription_page\": {\"id\": \"...\", \"header\": {\"title\": \"...\", \"subtitle\": \"...\"}}}` |\n| PUT | `/v1/environments/{environment_id}/subscription_pages/{id}` | `subscription_center.UpdatePage` | Update page header |\n\n## Settings\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/subscription_center_settings` | `subscription_center.GetSettings` | Subscription center settings |\n| PUT | `/v1/environments/{environment_id}/subscription_center_settings` | `subscription_center.UpdateSettings` | Update subscription center settings |\n\n## Common Pitfalls\n\n1. **Topic ordering**: `SaveTopicOrder` requires all topic IDs in the array — omitting one removes it from display ordering.\n\n2. **send_to_unsubscribed**: When assigning topics via `MessageUpdate`, the `send_to_unsubscribed` flag controls whether messages still reach profiles who opted out of that topic. Default is `false`.\n","templates.md":"# Templates — Content for Actions \u0026 Broadcasts\n\nTemplates hold the actual content for delivery actions and broadcasts. Every message action (`email_action`, `push_action`, `twilio_action`, etc.) has an associated template. The same template system is used across all channels — the fields you set depend on the channel.\n\n\u003e **Authoring or debugging Liquid?** `subject`, `body`, webhook bodies, and most other content fields support Liquid templating. Read `recipes/liquid_syntax.md` for the full syntax reference, filters, control flow, and common pitfalls.\n\n## Template Types\n\nTemplates are channel-specific. The `template_type` determines which fields are relevant:\n\n| Template Type | Channel | Key Content Fields |\n|---|---|---|\n| `email` | Email | `subject`, `body` (HTML), `body_plain`, `preheader_text`, `from_identity_id` |\n| `twilio` | SMS (legacy Twilio) | `body` (plain text message) — **author via `sms.md`** |\n| `sms` | SMS (custom SMS) | `body` (`\u003cx-sms\u003e` markup) — **don't write it here**; author it as a Design Studio node, see `sms_design_studio.md` |\n| `push` | Push notification | `subject` (title), `body` (message), `body_json` (extras: link/sound/custom_data) — **author via `push.md`** |\n| `in_app` | In-app message | `body_json` (in-app payload) |\n| `whatsapp` | WhatsApp | `body` (WhatsApp content-template payload as a JSON string — see below) |\n| `slack` | Slack | `body` (Slack message payload) |\n| `webhook` | Webhook | `body` (raw request body string), `url`, `request_method`, `headers` |\n| `inbox` | Inbox message | `body_json` (inbox payload — schema in `inbox.md`) |\n| `line` | LINE | `body` (LINE message payload as a JSON string) |\n| `attribute_update` | Attribute Update action | `body` (JSON-encoded array `[{name,type,value}]` — set on the action's linked template; see `data_operations.md § Attribute Update`) |\n\n## Endpoints\n\n| Endpoint | Use for |\n|----------|---------|\n| `GET /v1/environments/:eid/templates` | List templates — **paginated** (default 25/page, max 100). Params: `page`, `page_size`, `channel`, `editor`, `search`. `search` matches **names only** (the owning action/one-time send name), never body content |\n| `GET /v1/environments/:eid/templates/:id` | Get a template |\n| `POST /v1/environments/:eid/templates` | Create a template (for broadcast A/B tests — requires `newsletter_id`) |\n| `PUT /v1/environments/:eid/templates/:id` | Update a template |\n\n\u003e **Response envelope.** `GET`/`POST`/`PUT` on a single template return `{\"template\": {...}, \"layouts\": [...]}` — every field lives under `.template.*` (`.template.id`, `.template.body`, `.template.editor`). A `--jq` projection at the root returns `null` for every field without erroring; treat that as a broken query, not a result (SKILL.md § Null identity fields mean a wrong jq path). The list returns collections side by side: `{\"templates\": [...], \"actions\": [...], \"newsletters\": [...], \"transactional_messages\": [...], \"in_app_broadcasts\": [...], \"layouts\": [...], \"meta\": {...}}`.\n\u003e When repairing Liquid, read `.template` without projecting fields, replace every bad reference in `recipient`, `subject`, `body`, `body_plain`, `preheader_text`, and headers in one update, then preview the saved template.\n\u003e\n\u003e **\"Which template contains X?\" is not a job for these endpoints.** The list's `search` matches names only, one page is never the whole workspace, and templates attached to automation actions don't all surface on a single page. For content questions (\"find the template using `{% random %}`\", \"which emails mention X\") use Universal Search with `kind=template` (`search.md` § Finding every mention of a term) — it indexes template **bodies** workspace-wide, including templates attached to automation actions. If some surface can't be covered (unindexed workspace, unlinked Design Studio nodes), scope the answer to what was actually checked (\"none among the templates I could search; I couldn't check …\") instead of asserting a workspace-wide negative.\n\n## Setting Message Content\n\n\u003e **STOP — check the editor before writing an email `body`.** `GET` the template first and read its `editor`. Two editor values make the body off-limits to this endpoint, each with different failure behavior:\n\u003e\n\u003e - **`\"parcel\"` (Design Studio):** `body` is *compiled output* owned by Design Studio — **do NOT `PUT` `body` (or `body_plain`/`body_amp`) to this endpoint.** Doing so overwrites the compiled body (an empty/partial `body` wipes the whole email), and it does **not** touch the real source, so the change is lost on the next publish. The write is **not blocked** — it silently clobbers. Instead, edit the source with `PUT /v1/environments/:eid/design_studio/emails/{email_uuid}` then publish — see `@skills/design_studio` and its `journeys.md`. The `{email_uuid}` is the Design Studio node UUID, **not** the numeric `template_id` in the legacy template response. Get it with `GET /v1/environments/:eid/ds/journeys/{template_id}` → `{\"node_id\",\"environment_id\"}` (404 = no linked email). `ds/node_actions/linkable` cannot do this lookup: its results do not say which template a node is linked to. Envelope-only fields (`subject`, `preheader_text`, `from_identity_id`, `reply_to_identity_id`) may still be set here. (Agent service accounts are rejected outright if they change `body` on a `parcel` template.)\n\u003e\n\u003e - **`\"bee\"` (drag-and-drop):** the body lives in the Bee visual editor's design (`body_json`) and is **not directly editable through this endpoint** — agent and CLI writes that touch content fields (`body`, `body_json`, `body_plain`, `body_amp`) are **rejected with a 422**. Do not attempt the write, do not retry with different fields, and **never tell the user the content was updated** — it wasn't. Declare the limitation up front: *\"This email uses the Bee drag-and-drop editor, whose content can only be edited in the visual editor — I can't change its body directly.\"* For an explicitly requested migration, read `@skills/design_studio` `journeys.md` § **Convert an existing Bee email**: the API can carry the Bee design into Design Studio. Otherwise use the **HTML handoff** below. Envelope fields from the allowlist (`name`, `subject`, `preheader_text`, `recipient`, `bcc`, `cc`, `from_identity_id`, `reply_to_identity_id`, `headers`) may still be set here.\n\u003e\n\u003e **Bee HTML handoff — do this instead of a body write.** You can still do the authoring work; only the final paste is manual:\n\u003e 1. `GET /v1/environments/:eid/templates/:id` — the template's `body` holds the last HTML compiled from the Bee design; use it as the starting point.\n\u003e 2. `GET /v1/environments/:eid/templates/:id/variables` — the Liquid variables available to this template, so any personalization you add uses real fields.\n\u003e 3. Produce the updated HTML (and call out the Liquid variables you used) and hand it to the user to apply in the editor themselves.\n\u003e 4. If the user wants to migrate, use `@skills/design_studio` `journeys.md` § **Convert an existing Bee email**, which preserves the design through the Bee conversion routes. Get their agreement before changing editors; keep Bee when its visual editor already supports the requested change (like display conditions below).\n\n\u003e Liquid `{% if %}` wrappers around whole rows in a Bee `body` are **row display conditions** — an editable UI feature, not baked-in HTML. See § Bee row display conditions below before explaining them to a user.\n\nTo set content, update the template associated with an action or broadcast:\n\n```\nPUT /v1/environments/:eid/templates/:template_id\n```\n\n### Email example\n\n\u003e **New email with no `editor` yet — a just-created `email_action`, one-time send, or transactional message? Don't reach for `body` here by default.** The first `body` write fixes the email as a code/rich-text template. New email content defaults to Design Studio whenever the workspace uses it (`GET /templates?channel=email\u0026page_size=1` → `meta.totals.parcel` above 0) or has no emails yet — see `automations.md § Action field notes` and `@skills/design-studio` § When to Use Design Studio vs Templates for the create → link → publish flow. Use this endpoint's `subject`/`body` for a new email only when the user explicitly asked for a code/rich-text email, or the workspace has no Design Studio emails and its existing emails are code/rich text (`html`/`wysiwyg`); a Bee-only workspace still gets Design Studio, since Bee content cannot be written via the API.\n\n\u003e **A 400 \"This workspace can only create Design Studio emails.\" means the workspace is Design Studio only** (past the classic editors sunset): no new code/rich-text/drag-and-drop email and no switch onto those editors. Build the email in Design Studio instead (create → link → publish). Existing code/rich-text emails there still take `body` edits.\n\n\u003e **An `html`/`wysiwyg` (or editor-less) email `body` is plain email HTML — never Design Studio component markup.** These editors send the body as written, so `\u003cx-base\u003e`, `\u003cx-section\u003e`, `\u003cx-paragraph\u003e` or any other `\u003cx-*\u003e` tag renders broken. The API rejects a body containing `\u003cx-base\u003e` with a 422; a fragment without it is accepted and still broken, so a 200 does not mean the markup is valid. Write table-based email HTML here instead. If you want component markup, build the email in Design Studio (create → link → publish, `@skills/design_studio` `journeys.md`). Linking moves the message to Design Studio, so ask the user first when the email already has content.\n\n```json\n{\n  \"template\": {\n    \"subject\": \"Welcome, {{customer.first_name}}!\",\n    \"body\": \"\u003chtml\u003e\u003cbody\u003e\u003ch1\u003eHello!\u003c/h1\u003e\u003c/body\u003e\u003c/html\u003e\",\n    \"preheader_text\": \"Thanks for signing up\",\n    \"from_identity_id\": 1,\n    \"reply_to_identity_id\": 1\n  }\n}\n```\n\n\u003e **The sender is `from_identity_id` (a numeric ID), NOT a `from`/`from_address`/`from_name` field.** Those field names are silently dropped — the template saves with `from: null` and the email drafts error at send with a missing-sender complaint. `from_identity_id` references a **sender identity** that must already exist. A brand-new workspace has **none** (`GET /v1/environments/:eid/identities` returns `[]`), so create one first, then use its `id`:\n\u003e\n\u003e ```bash\n\u003e cio api /v1/environments/$ENV_ID/identities -X POST \\\n\u003e   --json '{\"identity\": {\"template_type\": \"email\", \"name\": \"Airbnb\", \"email\": \"[redacted-email]\"}}'\n\u003e # → {\"identity\": {\"id\": \"1\", ...}}  → then PUT from_identity_id: 1 on each template\n\u003e ```\n\u003e\n\u003e See `channels.md § Sender Identities`. (In `send_to_one`/test delivery the address need not be on a verified domain — the identity just has to exist.)\n\n### SMS example\n\n```json\n{\n  \"template\": {\n    \"body\": \"Hi {{customer.first_name}}, your order has shipped! Track it here: {{event.tracking_url}}\"\n  }\n}\n```\n\n\u003e **Before writing or editing an SMS `body`, you MUST read `sms.md` first.** The\n\u003e segment/cost rules (one emoji can triple the cost), smart encoding, link, and\n\u003e opt-out compliance guidance there change the content and are not in this file.\n\u003e This `body` field is the only SMS-specific thing `templates.md` covers — the\n\u003e authoring rules live in **sms.md**.\n\n\u003e **This example is the `twilio` template type only.** An `sms` template (the\n\u003e custom-SMS channel, `sms_action`) is Design-Studio-authored: its `body` holds\n\u003e `\u003cx-sms\u003e` markup owned by a Design Studio node, and you change it by editing\n\u003e and publishing that node — see **sms_design_studio.md**. Once linked, the\n\u003e template's editor is `parcel`, so an agent `body` write here is rejected 422\n\u003e by the guard above. On a **freshly created, never-linked** `sms` action the\n\u003e template has no editor yet and a raw `body` write is *accepted* — don't take\n\u003e that as permission: the SMS editor in the UI is Design Studio only, so a\n\u003e template written that way has no node behind it and the user cannot edit it.\n\u003e `GET` the template and read its `type` if you aren't sure which one you have.\n\n### Push notification example\n\n**Before writing or editing push content, you MUST read `push.md` first.** For push, the **title** is the template's `subject` and the **message** is its `body` — they are flat fields, not keys inside `body_json`. The rich `body_json` holds only extras: `link`, `sound`, `custom_data`, and per-platform priority. **push.md** has the full authoring playbook (title fallback, deep links, images, custom/silent payloads, platform differences, copy patterns) — this section only covers the field shape.\n\n```json\n{\n  \"template\": {\n    \"subject\": \"You have a new message\",\n    \"body\": \"{{customer.first_name}}, check out what's new\",\n    \"editor\": \"rich\",\n    \"body_json\": \"{\\\"link\\\": \\\"https://example.com/inbox\\\"}\"\n  }\n}\n```\n\n### Webhook example\n\n`body` is stored and rendered exactly as sent: Liquid runs over the raw text first, and only the rendered result has to be valid JSON. So write the body as plain text, with Liquid string literals in ordinary quotes, and never backslash-escape a quote inside a `{{ }}` or `{% %}` tag — Liquid cannot parse `{{ \\\"now\\\" }}`, and every send fails.\n\n```\n{\n  \"customer_id\": \"{{ customer.id }}\",\n  \"sent_at\": \"{{ \"now\" | date: \"%Y-%m-%dT%H:%M:%SZ\" }}\",\n  \"tier\": \"{% if customer.plan == \"pro\" %}gold{% else %}standard{% endif %}\"\n}\n```\n\nBind that text with `--arg` so `cio` does the one layer of JSON encoding for the request:\n\n```bash\ncio api /v1/environments/$ENV_ID/templates/$TEMPLATE_ID -X PUT \\\n  --arg body=\"$(cat \u003c\u003c'EOF'\n{\n  \"customer_id\": \"{{ customer.id }}\",\n  \"sent_at\": \"{{ \"now\" | date: \"%Y-%m-%dT%H:%M:%SZ\" }}\"\n}\nEOF\n)\" \\\n  --json '{template: {url: \"https://example.com/webhook\", request_method: \"POST\", body: $body, headers: [{name: \"Content-Type\", value: \"application/json\"}]}}'\n```\n\nWhen you show a webhook body to the user, show this plain text too, not a JSON-escaped string they would paste back in.\n\n**Webhook-specific fields:** `url` (string), `request_method` (string: `\"POST\"`, `\"GET\"`, etc. — NOT `method`), `body` (string — the raw request body, supports Liquid), `headers` (array of `{\"name\": \"...\", \"value\": \"...\"}` objects — NOT a plain key-value dict like `{\"Content-Type\": \"...\"}`)\n\n### Key Fields\n\n| Field | Used by | Purpose |\n|-------|---------|---------|\n| `subject` | Email, Push | Subject line or push title (supports Liquid) |\n| `body` | Email, SMS, Push, Slack, WhatsApp, LINE, Webhook | Primary message content: HTML (email), plain text (SMS/push message), or a channel-specific payload string (Slack, WhatsApp, LINE, webhook). Supports Liquid |\n| `body_plain` | Email | Plain text fallback for email |\n| `body_amp` | Email | AMP HTML version for email |\n| `body_json` | In-app, Inbox, Push (extras only) | Structured JSON payload (channel-specific format) |\n| `preheader_text` | Email | Email preheader text |\n| `from_identity_id` | Email | Sender identity ID |\n| `reply_to_identity_id` | Email | Reply-to identity ID |\n| `layout_id` | Email | Layout to wrap the body |\n| `editor` | Email, In-app, Push | Field value, not the UI label. Email: `\"html\"` (UI \"Code\"), `\"wysiwyg\"` (UI \"Rich text\"), `\"bee\"` (drag-and-drop), `\"parcel\"` (Design Studio); in-app: `\"visual\"`/`\"json\"`; push: `\"rich\"`/`\"simple\"`/`\"json\"`. There is no `\"code\"`/`\"richtext\"` value |\n\n## Bee row display conditions — `{% if %}` wrappers around rows\n\nLiquid tags that wrap an **entire row** in a Bee template's compiled `body` (`{% if ... %}` before the row's table markup, `{% endif %}` after) are almost always **row display conditions** — a first-class, fully editable editor feature, not hand-written HTML buried in the compiled output. Never tell a user these conditions are invisible in the UI or that Bee \"can't show raw Liquid\": they live on the row itself. To view, change, or remove one in the UI: **select the row → open the row properties panel → Display condition (Add condition / Edit condition)** — the condition is stored as *Before* (opening tag, e.g. `{% if plan == \"pro\" %}`) and *After* (closing tag, e.g. `{% endif %}`) fields. Removing the condition there restores the row unconditionally. Docs: https://docs.customer.io/journeys/drag-and-drop/#display-conditions\n\nDebugging \"some rows don't render\" on a Bee email:\n\n1. `GET` the template and scan `body` for `{% if %}`/`{% endif %}` pairs wrapping row markup — those rows are conditioned; rows without wrappers (e.g. dividers) render regardless, which is the classic tell.\n2. Check what the condition references. A common failure: the condition tests a variable that some *other* message sets (e.g. `{% capture random_delta %}{% random 2 %}{% endcapture %}` in the original campaign email), and the rows were copied into a template that never sets it — the variable is nil, every comparison is false, and all conditioned rows silently drop from the render.\n3. The fix is in the editor: edit or remove the condition on the row, or add the missing `{% capture %}`/`{% assign %}` so the variable exists. You cannot fix it via the API — Bee content writes are rejected (422, above).\n\nRules of engagement when the user asks about these conditions:\n\n- **\"How do I change/remove this myself?\" gets UI steps** (row → row properties → Display condition), not an offer to do it on their behalf — the API can't edit Bee content anyway.\n- **Never present another editor's docs as Bee steps.** Bee's toolbar has no `\u003c\u003e` / \"Edit HTML\" control — that's the Design Studio / code editor. If a docs search for a Bee question returns only Design-Studio- or code-editor-scoped results, that's a search miss, not an answer; fall back to this section and the display-conditions doc above.\n- **Don't propose converting the template to another editor** when the reversible in-UI path above solves it.\n\n## In-app messages\n\nIn-app content is set directly on the template via `PUT /templates/:template_id` with `editor: \"visual\"`. See `in_app.md` for the `body_json` format and component markup reference.\n\n## WhatsApp Templates\n\nWhatsApp messages require a **pre-approved external template before sending**, except a free-form reply inside the 24-hour window. **Read `whatsapp.md` before writing any WhatsApp content**: it picks between filling an already-approved template and drafting a new one in Design Studio, and the two write different fields. The filled template goes in **`body`** as a JSON string; `body_json` never reaches the sender, so a `body_json`-only write is rejected with a 422 naming `body`.\n\n## Common Pitfalls\n\n1. **All message and data actions start empty** — Creating any message action (`email_action`, `webhook_action`, `push_action`, etc.) or data operation (`create_event_action`, `batch_update_action`) via `add_actions` auto-creates a template, but the template has no content. You **must** update the template via `PUT /templates/:template_id` to give the action actual content. Without this, the action appears as a blank item in the UI and does nothing useful.\n\n2. **Design Studio templates** — Templates with `editor: \"parcel\"` are managed by Design Studio; `body` is compiled output, not the source. A `PUT` of `body` here is **not blocked** — it silently overwrites the compiled body (an empty/partial `body` wipes the email) without changing the Design Studio source, so it's lost on the next publish. Edit `body` only via Design Studio (`PUT /design_studio/emails/{email_uuid}` + publish; the UUID is the DS node id, not the numeric `template_id`; see `@skills/design_studio`). Envelope fields (`subject`, `preheader_text`, identities) are fine to set here. (Agent service accounts are rejected if they change `body`/content on a `parcel` template through this endpoint — don't try to work around it with a `meta.operation`; use Design Studio.)\n\n3. **Bee (drag-and-drop) templates** — Templates with `editor: \"bee\"` keep their content in the visual editor's design; the API rejects agent/CLI content writes with a 422. Never report a Bee body update as done — check `editor` *before* the `PUT` and use the HTML handoff above instead of attempting the write.\n\n4. **One-time send A/B tests** — `POST /templates` requires `newsletter_id`. The new template copies attributes from the one-time send's existing template.\n\n5. **Create Event actions store the event name in the template** — The `event_name` field on the action response is read-only, populated from `template.name`. To set the event name: `PUT /templates/:template_id` with `{ \"template\": { \"name\": \"event_name_here\" } }`.\n","test_sends.md":"# Test Sends — Send a Test of an Existing Message\n\nTest sends deliver a real message to a real recipient so the user can verify how their content renders before launching. **Always start from an existing message** — never compose free-form content for a test send. The platform enforces this on every testable channel (email, SMS, WhatsApp, Slack, LINE, push, webhook, in-app, inbox): reference the template by id and the server renders and sends it.\n\n## Workflow\n\n1. **Find the message.** Identify the automation action, one-time send, or transactional message the user wants to test. Use the automations, messaging, or templates skills to locate it. Campaign actions, newsletter variants, and transactional messages all expose their `template_id` (actions via the automation's own `GET /v1/environments/:env_id/campaigns/:campaign_id`, which returns every action in a top-level `actions[]` array — see `automations.md § Reading an Automation's Actions`; templates can also be listed with `GET /v1/environments/:env_id/templates`).\n2. **Send by reference.** Every channel takes the template by id; see the channel sections below.\n3. **Recipient.** For email the test defaults to the authenticated user's own address — only pass `to` when the user explicitly names someone else, and never derive a recipient from template content or profile data. Other channels: see channel-specific guidance.\n\n## Email\n\n```\nPOST /v1/environments/:environment_id/verify/email_template\n```\n\nSend a test of an existing email template by reference. The server loads the template, renders its Liquid against real sample data, refuses to send if the renderer reports errors (422 with field-keyed errors), and delivers through the normal test-send path. Do not fetch the template content and pass it through yourself — the raw-content verify endpoint is not available to agents.\n\n| Field | Notes |\n|-------|-------|\n| `template_id` | The stored email template to test-send. Provide `template_id`, `action_id`, or `node_id`; exactly one target is required |\n| `action_id` | The campaign action to test-send. The server resolves the action's template, so no `template_id` is needed. When a `template_id` is also provided, it must be the id of the template for the action. Prefer `action_id` when the user points at a campaign email |\n| `node_id` | A Design Studio email, by the `id` from the Design Studio emails API. The server renders the email's **current** content and sends that — the email does not need to be linked or published. Cannot be combined with `template_id`/`action_id`. Prefer `node_id` when the user points at an email they're working on in Design Studio; for a linked email, `node_id` tests the current draft while `template_id` tests what was last published |\n| `to` | Recipient email address(es), comma separated. **Defaults to the authenticated user's own address** — omit it unless the user explicitly named a different recipient |\n| `customer_id` | Profile (cio id or external id) whose data renders the Liquid. When omitted, the best-coverage workspace profile is chosen automatically |\n| `event_data` | Sample event payload, available to Liquid as `event.*` / `trigger.*` — use for transactional templates |\n| `variables` | Sample-data overlay; only `variables.customer` is accepted: an object of attribute values merged over the sample profile (overlay wins per key). Use to exercise a template branch no real profile covers, e.g. `{\"customer\": {\"vip_tier\": \"gold\"}}`. Requires the People View permission |\n| `campaign_type` | SMTP routing hint, e.g. `transactional`, `newsletter`, `transactional_message`. Defaults from the template's owning campaign context |\n| `prepend_test` | Set `true` to prepend \"[TEST]\" to the rendered subject |\n| `tracked` | Inject an open-tracking pixel |\n\nThe campaign and message context (`campaign.*`, `message.*`, send-time scalars like `{{unsubscribe_address}}`) is resolved server-side from the template's owning action, newsletter, or transactional message — you never supply it. A `node_id` test resolves the same context through the email's linked destination when one exists; a standalone (unlinked) Design Studio email has no such context, so its `campaign.*`/`message.*` references render as empty strings with a warning. `journey.*` and `objects.*` data exist only on live deliveries and render as empty strings (with a warning). A `node_id` test also requires a sender on the email (`envelope.from_id`) and the Design Studio emails view permission.\n\nThe response reports `accepted` (accepted for delivery — not proof of delivery), the rendered `subject`, the `profile_id` used for sample data, and `warnings` for template branches the sample profile couldn't exercise. A `lax_render: true` means some referenced variables had no data source and missing variables rendered as empty strings.\n\n**Rate limits (per account per day):** Trial: 50, max 3 recipients per send. Essentials: 250, max 25 recipients. Other plans: 500 (750 with the high-volume flag). Recipient counts include CC.\n\n**App API twin for Design Studio emails.** When the user wants the test send from CI or their own backend rather than from you, point them at `POST https://api.customer.io/v1/design_studio/emails/{id}/test_send` (App API key, `Authorization: Bearer`). It uses the shared server-side render, caps and quota, and its sample-data fields take the same shapes as the App API's `preview` endpoint, so one payload serves both. Differences from the `node_id` path above: `to` is a required **array** of addresses (an API key has no user to default to, and email groups do not exist there); customer data is `customer` (an object of scalar attributes, the equivalent of `variables.customer`) and/or `customer_id` (an unknown id is a 422 keyed `customer_id`); the sample event is `event`, and a separate `trigger` fills `trigger.*` on its own, which matters for broadcasts and transactional messages where real sends set `trigger.*` and not `event.*`; `lax: true` mirrors preview and renders missing variables empty instead of refusing; `tracked` defaults to the linked destination's setting (on when unlinked). No workspace profile is selected automatically: without `customer_id` or a nonempty `customer`, rendering is lax with a warning even if the workspace has profiles. The response is `node_id`, `node_type`, `language`, `accepted`, `to`, `subject`, `from` and `warnings`; it has no `profile_id` or `lax_render`. `warnings` reports the escalation branches (no profile or overlay, unavailable template context, ignored routing keys), not every variable that rendered empty: with `lax: true` and resolved context, a missing attribute such as `{{customer.plan}}` renders blank with no warning, so a caller who needs to know about missing customer data should leave `lax` off and handle the 422.\n\n**Strict rendering with an overlay.** A nonempty `customer` overlay (or a named `customer_id`) makes the render strict without a full profile: a missing customer attribute returns a field-keyed **422 and nothing is sent**, e.g. a subject `Hi {{customer.first_name}} {{customer.plan}}` with only `first_name` in `customer` returns 422 on `subject` for `customer.plan`. Two fallbacks still apply, and both should be explained together: with no profile and no nonempty overlay the render is lax with a warning; and unavailable template context (such as `campaign.name` on an unlinked email) forces the whole render lax **even with a profile or overlay**, so a missing customer attribute is only guaranteed to 422 when no such warning is present. Do not describe an overlay as unconditionally strict; advise callers to inspect `warnings` on accepted responses as well as handling 422s.\n\n## SMS\n\n**Which route depends on the template type.** A `twilio` template (legacy\nTwilio SMS) uses `verify/twilio_template`, documented here. An `sms` template\n(the custom-SMS channel, `sms_action`) uses\n`verify/message_channel_template` — the same route as WhatsApp below, with\nthe same fields; posting an `sms` template to `verify/twilio_template` fails.\n`GET` the template and read its `type`, or settle it with\n`sms.md § Which SMS channel is this workspace on?`.\n\n```\nPOST /v1/environments/:environment_id/verify/twilio_template\n```\n\nSend a test of an existing SMS template by reference — the same reference-based model as email, Slack and LINE. The server loads the template, renders its Liquid against real sample data, refuses to send if the renderer reports errors (422 with field-keyed errors), and sends through the normal test-send path. Do not fetch the template body and pass it through yourself — the raw-content SMS verify endpoint is not available to agents.\n\n| Field | Notes |\n|-------|-------|\n| `template_id` | The stored SMS template to test-send. Provide `template_id` or `action_id`; one target is required |\n| `action_id` | The campaign action to test-send. The server resolves the action's template, so no `template_id` is needed. When a `template_id` is also provided, it must be the id of the template for the action |\n| `to` | **Required** — the phone number to send to, in E.164 form, e.g. `+15551234567`. Ask the user for it; there is no default recipient, and the template's own recipient field is never used |\n| `customer_id` | Profile (cio id or external id) whose data renders the Liquid. When omitted, the best-coverage workspace profile is chosen automatically |\n| `event_data` | Sample event payload, available to Liquid as `event.*` / `trigger.*` |\n| `variables` | Sample-data overlay; only `variables.customer` is accepted: an object of attribute values merged over the sample profile (overlay wins per key). Requires the People View permission |\n\n**Sender:** resolved server-side from the template's sender identity — do **not** look up identities and do not pass a `from`. The response echoes the number it sent from as `from`. A template with no sender identity, or whose identity no longer exists, returns a 422 naming `template_id`.\n\nThe template's body **and image** are both sent — a template whose `image_url` Liquid fails to render is refused (422 on `image_url`), and an image Twilio can't accept comes back as a 422 on `image_url` after the message was sent, the same as the dashboard test send. A recipient that is not valid E.164 also comes back as a 422 on `recipient` after the send was attempted.\n\nLinks are resolved as they are on a real send, with the workspace URL params applied, but click tracking is off: a test send has no delivery to attribute clicks to. Link shortening follows the workspace SMS setting, so a shortened body looks like the real one. A template whose URL params fail to render is refused (422 on `url_params`) only when its body actually contains a link.\n\nThe campaign and message context (`campaign.*`, `message.*`) is resolved server-side from the template's owning action — you never supply it. `journey.*` and `objects.*` data exist only on live deliveries and render as empty strings (with a warning).\n\nThe response reports `accepted`, the `from` used, the `profile_id` used for sample data, `lax_render`, and `warnings`. Requires Twilio credentials configured in the workspace (404 when missing) and the SMS channel enabled (400 when disabled).\n\nA template flagged for the **legacy Twilio** WhatsApp path sends over that path automatically — it is the template's own setting, not a request field. Templates built as Twilio **content templates** (WhatsApp) cannot be test-sent here; they return a 422 naming `template_id`. Use the WhatsApp endpoint below for those, and for WhatsApp generally.\n\n## WhatsApp (and custom-SMS `sms` templates)\n\n```\nPOST /v1/environments/:environment_id/verify/message_channel_template\n```\n\nSend a test of an existing WhatsApp or custom-SMS template by reference — the same reference-based model as email, SMS, Slack and LINE. The server loads the template, renders its Liquid against real sample data, refuses to send if the renderer reports errors (422 with field-keyed errors), and sends through the normal test-send path. Do not fetch the template body and pass it through yourself — the raw-content message-channel verify endpoint is not available to agents.\n\nOne endpoint covers **every** WhatsApp integration, so you do not have to detect which one a workspace uses (whatsapp.md explains the difference if you need it). The server routes on the stored template itself:\n\n- **Native (Meta direct)** — a `whatsapp` template. Also accepts `sms` templates, which go through the same message-channel pipeline.\n- **Twilio content templates** — a `twilio` template flagged for WhatsApp whose body names a Twilio content SID. The message text lives server-side at Twilio; only the variables filled into it are rendered here.\n\n| Field | Notes |\n|-------|-------|\n| `template_id` | The stored WhatsApp or SMS template to test-send. Provide `template_id` or `action_id`; one target is required |\n| `action_id` | The campaign action to test-send. The server resolves the action's template, so no `template_id` is needed. When a `template_id` is also provided, it must be the id of the template for the action |\n| `to` | **Required** — the phone number to send to, in E.164 form, e.g. `+15551234567`. Ask the user for it; there is no default recipient, and the template's own recipient field is never used |\n| `customer_id` | Profile (cio id or external id) whose data renders the Liquid. When omitted, the best-coverage workspace profile is chosen automatically |\n| `event_data` | Sample event payload, available to Liquid as `event.*` / `trigger.*` |\n| `variables` | Sample-data overlay; only `variables.customer` is accepted: an object of attribute values merged over the sample profile (overlay wins per key). Requires the People View permission |\n\n**Sender:** resolved server-side from the template's sender identity — do **not** look up identities, and do not pass a `sender_identity_id` or a `from`. The response echoes the sender as `from`. A template with no sender identity, or whose identity no longer exists, returns a 422 naming `template_id`.\n\n**Channel:** also resolved from the template — do not pass `template_type`. The response echoes the stored type as `template_type` (`whatsapp`, `sms`, or `twilio` for a content template).\n\nLinks are resolved as they are on a real send, with the workspace URL params applied, but click tracking is off: a test send has no delivery to attribute clicks to. Link shortening follows the workspace's setting for that channel, so a shortened message looks like the real one. A template whose URL params fail to render is refused (422 on `url_params`) only when its content actually contains a link.\n\nThe campaign and message context (`campaign.*`, `message.*`) is resolved server-side from the template's owning action — you never supply it. `journey.*` and `objects.*` data exist only on live deliveries and render as empty strings (with a warning).\n\nThe response reports `accepted`, the `from` and `template_type` used, the `profile_id` used for sample data, `lax_render`, and `warnings`. Requires the channel's credentials configured in the workspace (400 when missing, or 404 for a content template with no Twilio credentials).\n\nA recipient that is not valid E.164 is rejected before the send on a native WhatsApp template (`400 \"Please enter a valid phone number.\"`); on a Twilio content template it comes back as a 422 on `recipient` after the send was attempted, the same as the dashboard test send.\n\nTwo shapes are refused here on purpose: a plain `twilio` template is legacy SMS-over-Twilio and returns a 422 pointing at `verify/twilio_template`; any other channel type returns a 400.\n\n## Push\n\n```\nPOST /v1/environments/:environment_id/verify/push_template\n```\n\nSend a test of an existing push template by reference — the same reference-based model as email, SMS, Slack and LINE. The server loads the template, renders its Liquid against real sample data, refuses to send if the renderer reports errors (422 with field-keyed errors), and sends through the normal test-send path. Do not fetch the template content and pass it through yourself — the raw-content push verify endpoint is not available to agents.\n\n**Recipient:** Users won't have a device token handy. Instead:\n\n1. Ask for a **profile** (by email, ID, or cio_id).\n2. Fetch the profile: `GET /v1/environments/:env_id/customers/:customer_id` — the response includes a `devices` array with device tokens, platform, and provider info.\n3. Present the list of devices and ask the user which one to send to.\n4. Use the selected device's token and platform (and provider, when it differs from the platform's default) in the test send.\n\nFor a browser (`web`) device, see **web_push.md § Test-sending to a browser** when available; otherwise test-send to iOS and Android devices only.\n\n| Field | Notes |\n|-------|-------|\n| `template_id` | The stored push template to test-send. Provide `template_id` or `action_id`; one target is required |\n| `action_id` | The campaign action to test-send. The server resolves the action's template, so no `template_id` is needed. When a `template_id` is also provided, it must be the id of the template for the action |\n| `to` | **Required** — the device token to send to, from the profile's `devices` array; for `web`, see **web_push.md**. There is no default recipient, and the template's own recipient field is never used |\n| `platform` | **Required** — `ios`, `android` or `web`, from the selected device. Selects the payload half of a JSON-editor template and the default provider |\n| `provider` | `fcm`, `apns` or `webpush`. Defaults from the platform (`ios`: `apns`, `android`: `fcm`, `web`: `webpush`). Pass it only when an iOS or Android device's provider differs, e.g. an iOS device registered through Firebase. Ignored when the template names an app: the app's stored credential decides the provider |\n| `customer_id` | Profile (cio id or external id) whose data renders the Liquid. When omitted, the best-coverage workspace profile is chosen automatically |\n| `event_data` | Sample event payload, available to Liquid as `event.*` / `trigger.*` |\n| `variables` | Sample-data overlay; only `variables.customer` is accepted: an object of attribute values merged over the sample profile (overlay wins per key). Requires the People View permission |\n\nThe template's editor decides what is sent, exactly as on a real send: a simple template sends its title, body and image; a rich template also carries its deep link, sound, custom data and priorities; a JSON-editor template sends the stored payload for the requested platform. All of it renders server-side — a payload whose Liquid renders into invalid JSON is refused (422 on `body` or `custom_data`).\n\n**Credentials:** resolved server-side — do **not** pass credentials. A template that names an app sends through that app's own platform credential; otherwise the workspace's push credentials for the provider are used (400 when not configured).\n\nLinks in the body are resolved as they are on a real send, with the workspace URL params applied, but click tracking is off: a test send has no delivery to attribute clicks to. A template whose URL params fail to render is refused (422 on `url_params`) only when its body actually contains a link.\n\nThe campaign and message context (`campaign.*`, `message.*`) is resolved server-side from the template's owning action — you never supply it. `journey.*` and `objects.*` data exist only on live deliveries and render as empty strings (with a warning).\n\nThe response reports `accepted`, the provider's `status`, the device `to`, the `platform` used, the `profile_id` used for sample data, `lax_render`, and `warnings`, plus `delivery_id` when the workspace records test deliveries (see below). A provider-rejected token (expired, wrong platform) comes back as a 400 with the provider's error message, the same as the dashboard test send.\n\n**Reading a test push back.** When the workspace records test deliveries, the response also has `delivery_id`: the same id the push itself carries, which the device's SDK reports delivered and opened against. Read it with `GET /v1/environments/:environment_id/test_deliveries/:delivery_id`, which works for a day (24 hours) after the send and is a 404 after that. It has the provider's answer in `provider_status`, with `failure_message` and `failure_detail` when the provider refused the push, and `delivered` and `opened` timestamps once the SDK reports them; `state` sums it up. For a viewer who can't see recipients, `token` is masked and the failure text is null, but `provider_status` still shows whether the provider accepted the push. Test deliveries are kept apart from real ones, so the delivery routes, lists, metrics and exports never show them. Without `delivery_id`, the test send kept no delivery and there is nothing to read back.\n\n## Webhook\n\n```\nPOST /v1/environments/:environment_id/verify/webhook_template\n```\n\nSend a test of an existing webhook template by reference — the same reference-based model as email. The server loads the template, renders its Liquid (URL, headers and body) against real sample data, refuses to fire if the renderer reports errors (422 with field-keyed errors), signs the request, and sends through the normal test-send path. Do not fetch the template's url/body/headers and pass them through yourself — the raw webhook verify endpoint that accepts a caller-named URL and payload is not available to agents.\n\n**Recipient:** None needed — the request fires at the URL stored in the template. Warn the user that this makes a real, signed HTTP request to that URL. A destination that resolves to an internal or private address is refused (422 on `url`).\n\n| Field | Notes |\n|-------|-------|\n| `template_id` | The stored webhook template to test-send. Provide `template_id` or `action_id`; one target is required |\n| `action_id` | The campaign action to test-send. The server resolves the action's template, so no `template_id` is needed. When a `template_id` is also provided, it must be the id of the template for the action |\n| `customer_id` | Profile (cio id or external id) whose data renders the Liquid. When omitted, the best-coverage workspace profile is chosen automatically |\n| `event_data` | Sample event payload, available to Liquid as `event.*` / `trigger.*` |\n| `variables` | Sample-data overlay; only `variables.customer` is accepted: an object of attribute values merged over the sample profile (overlay wins per key). Requires the People View permission |\n\nThe URL, HTTP method, headers and body all come from the template — none of them can be supplied on the request. A template backed by a **webhook configuration** works too: the destination, method and headers resolve from the configuration, and only the body comes from the template.\n\nLinks in the body are resolved as they are on a real send, with the workspace URL params applied, but click tracking is off: a test send has no delivery to attribute clicks to. A template whose URL params fail to render is refused (422 on `url_params`) only when its body actually contains a link.\n\nThe campaign and message context (`campaign.*`, `message.*`) is resolved server-side from the template's owning action — you never supply it. `journey.*` and `objects.*` data exist only on live deliveries and render as empty strings (with a warning).\n\nThe response reports `accepted`, the destination's HTTP `status` and response `body` (truncated to the workspace limit), delivery `errors` when the destination could not be reached, the `profile_id` used for sample data, `lax_render`, and `warnings`. The rendered URL and request headers are not echoed back — they can carry credentials.\n\n## Slack\n\n```\nPOST /v1/environments/:environment_id/verify/slack_template\n```\n\nSend a test of an existing Slack template by reference — the same reference-based model as email. The server loads the template, renders its Liquid against real sample data, refuses to send if the renderer reports errors (422 with field-keyed errors), and posts through the normal test-send path. Do not fetch the template body and pass it through yourself — the raw-content Slack verify endpoint is not available to agents.\n\n| Field | Notes |\n|-------|-------|\n| `template_id` | The stored Slack template to test-send. Provide `template_id` or `action_id`; one target is required |\n| `action_id` | The campaign action to test-send. The server resolves the action's template, so no `template_id` is needed. When a `template_id` is also provided, it must be the id of the template for the action |\n| `to` | **Required** — the Slack channel or user to post to, e.g. `#alerts` or `U01234ABCD`. Ask the user which channel to use; Slack has no default recipient, and the template's own recipient field is never used |\n| `customer_id` | Profile (cio id or external id) whose data renders the Liquid. When omitted, the best-coverage workspace profile is chosen automatically |\n| `event_data` | Sample event payload, available to Liquid as `event.*` / `trigger.*` |\n| `variables` | Sample-data overlay; only `variables.customer` is accepted: an object of attribute values merged over the sample profile (overlay wins per key). Requires the People View permission |\n\nThe campaign and message context (`campaign.*`, `message.*`) is resolved server-side from the template's owning action — you never supply it. `journey.*` and `objects.*` data exist only on live deliveries and render as empty strings (with a warning).\n\nLinks are resolved as they are on a real send, with the workspace URL params applied, but click tracking is off: a test send has no delivery to attribute clicks to. A template whose URL params fail to render is refused (422 on `url_params`) only when its body actually contains a link.\n\nThe response reports `accepted`, the Slack API `status`, the `profile_id` used for sample data, `lax_render`, and `warnings`. Requires Slack credentials configured in the workspace (400 when missing).\n\n## LINE\n\n```\nPOST /v1/environments/:environment_id/verify/line_template\n```\n\nSend a test of an existing LINE template by reference — the same reference-based model as email and Slack. The server loads the template, renders its Liquid against real sample data, refuses to send if the renderer reports errors (422 with field-keyed errors), and sends through the normal test-send path. Do not fetch the template body and pass it through yourself — the raw-content LINE verify endpoint is not available to agents.\n\n| Field | Notes |\n|-------|-------|\n| `template_id` | The stored LINE template to test-send. Provide `template_id` or `action_id`; one target is required |\n| `action_id` | The campaign action to test-send. The server resolves the action's template, so no `template_id` is needed. When a `template_id` is also provided, it must be the id of the template for the action |\n| `to` | **Required** — the LINE user id to send to, e.g. `U01234abcdef`. Ask the user for it; LINE has no default recipient, and the template's own recipient field is never used |\n| `customer_id` | Profile (cio id or external id) whose data renders the Liquid. When omitted, the best-coverage workspace profile is chosen automatically |\n| `event_data` | Sample event payload, available to Liquid as `event.*` / `trigger.*` |\n| `variables` | Sample-data overlay; only `variables.customer` is accepted: an object of attribute values merged over the sample profile (overlay wins per key). Requires the People View permission |\n\nThe template's body **and image** are both sent — a template whose `image_url` Liquid fails to render is refused (422 on `image_url`), and an image that renders but is too large for LINE comes back as a 422 on `image_url` after the message was sent, the same as the dashboard test send.\n\nLinks are resolved as they are on a real send, with the workspace URL params applied, but click tracking is off: a test send has no delivery to attribute clicks to. A template whose URL params fail to render is refused (422 on `url_params`) only when its body actually contains a link.\n\nThe campaign and message context (`campaign.*`, `message.*`) is resolved server-side from the template's owning action — you never supply it. `journey.*` and `objects.*` data exist only on live deliveries and render as empty strings (with a warning).\n\nThe response reports `accepted`, the LINE API `status`, the `profile_id` used for sample data, `lax_render`, and `warnings`. Requires LINE credentials configured in the workspace (400 when missing).\n\n## In-App\n\n```\nPOST /v1/environments/:environment_id/verify/in_app_template\n```\n\nSend a test of an existing in-app template by reference — the same reference-based model as email. The server loads the template, renders its Liquid against the recipient profile's data, refuses to send if the renderer reports errors (422 with field-keyed errors), and queues through the normal test-send path. Do not fetch the template's markup and pass it through yourself — the raw-content in-app verify endpoint is not available to agents.\n\n**Recipient:** an in-app message can only appear on an **identified profile's SDK session**, so `customer_id` is required and doubles as the sample-data source. The message shows up the next time that profile's device or browser session (running the Customer.io SDK) checks in — queuing alone doesn't display anything. Ask the user which profile they're logged in as in their app or website.\n\n| Field | Notes |\n|-------|-------|\n| `template_id` | The stored in-app template to test-send. Provide `template_id` or `action_id`; one target is required |\n| `action_id` | The campaign action to test-send. The server resolves the action's template, so no `template_id` is needed. When a `template_id` is also provided, it must be the id of the template for the action. An action also carries its subtype: a product tour tested via its `action_id` keeps its persistence, like the dashboard's test |\n| `customer_id` | **Required** — the profile (cio id or external id) that receives the test AND whose data renders the Liquid. Requires the People View permission |\n| `event_data` | Sample event payload, available to Liquid as `event.*` / `trigger.*` |\n| `variables` | Sample-data overlay; only `variables.customer` is accepted: an object of attribute values merged over the recipient profile's attributes (overlay wins per key). Affects the render only — the recipient is always the `customer_id` profile |\n\nThe template's editor decides what is sent, exactly as on a real send: a visual-editor template sends its rendered message HTML; a JSON-editor template sends its rendered message definition (position, attributes, page rules). Display targeting resolves from the rendered message the same way the send path resolves it.\n\nLinks in the body are resolved as they are on a real send, with the workspace URL params applied, but click tracking is off: a test send has no delivery to attribute clicks to. A template whose URL params fail to render is refused (422 on `url_params`) only when its body actually contains a link.\n\nThe campaign and message context (`campaign.*`, `message.*`) is resolved server-side from the template's owning action — you never supply it. `journey.*` and `objects.*` data exist only on live deliveries and render as empty strings (with a warning).\n\nThe response reports `accepted` (queued — not proof of display), the queue `status` and `queue_id`, the `profile_id` used, `lax_render`, and `warnings`. Test messages expire a few minutes after queuing, so tell the user to open their app promptly. Requires in-app credentials configured in the workspace (400 when missing).\n\n## Inbox\n\n```\nPOST /v1/environments/:environment_id/verify/inbox_template\n```\n\nSend a test of an existing inbox template by reference. Works exactly like the in-app endpoint above — same fields (`template_id`/`action_id`, required `customer_id`, `event_data`, `variables`), same render gate, same response shape — but delivers into the profile's persistent notification inbox rendered by the Customer.io SDK. Do not fetch the template body and pass it through yourself — the raw-content inbox verify endpoint is not available to agents.\n\nThe message's static fields (topics, type, priority) come from the stored template; its properties (title, body, CTA) render against the recipient profile's data. A Design Studio inbox template whose content was never published returns a 422 naming `template_id` — have the user publish it first.\n\nLike in-app, the test appears when that profile's SDK session checks in, and test messages expire a few minutes after queuing.\n","transactional_in_app.md":"# Transactional In-App Messages: Create, configure, and send\n\nFollow these steps in order — each depends on the previous.\nAssumes `cio` is installed, authenticated, and you know the environment ID.\n\n## Step 0 — Provision in-app (once per workspace)\n\nIn-app messaging must be provisioned before in-app messages can be delivered.\nProvisioning creates the site ID and SDK credentials the delivery worker and the\nin-app SDK require; without them, in-app deliveries fail. Check first, and\nprovision if needed:\n\n```bash\ncio api /v1/environments/{environment_id}/in_app/status   # { provisioned, connected, last_connected }\ncio api /v1/environments/{environment_id}/in_app/provision -X POST --json '{}'\n```\n\nSee `in_app.md § Provisioning` for details.\n\n## Step 1 — Create the transactional message\n\n```bash\ncio api /v1/environments/{environment_id}/transactional_messages -X POST \\\n  --json '{\"transactional_message\":{\"name\":\"\u003cmessage-name\u003e\",\"type\":\"in_app\"}}'\n```\n\n**The create payload is `name` + `type` only.** Older examples also passed\n`send_to_unsubscribed` here — the API now rejects it with\n`json: unknown field \"send_to_unsubscribed\"` (400). Unsubscribe behavior for\ntransactional messages is managed via the update endpoint / message settings,\nnot the create call.\n\nNote the returned `id` (transactional message ID) and `template_id` from the\n`templates` array. Both are needed in later steps.\n\n**Important:** The newly created template has no editor, no body, and no\nbody_json. It will fail silently at render time if you skip Step 2.\n\n## Step 2 — Configure the template\n\nThe template has two content fields that serve different purposes:\n\n- **`body`** — the HTML content the user sees (e.g. `\u003cp\u003eHello\u003c/p\u003e`)\n- **`body_json`** — controls *how and where* the message displays. The server\n  reads this during delivery and maps it to both the Gist payload (native SDKs)\n  and HTML data attributes (web renderer). This is the source of truth for all\n  display settings.\n\n**Both are required.** Without `body_json`, the render service fails with\n`unexpected end of JSON input` in worker logs — the delivery is queued\nsuccessfully but never rendered.\n\n### body_json examples\n\n**Centered modal** (most common — card with dimmed backdrop):\n```json\n{\n  \"message_id\": \"welcome\",\n  \"display\": {\"mode\": \"modal\", \"value\": \"center\", \"overlay_color\": \"#00000033\", \"message_width\": 414, \"exit_click\": true},\n  \"expiration\": {\"type\": \"relative\", \"value\": \"604800\", \"unit\": \"seconds\"}\n}\n```\n\n**Top banner** (full-width, slides in from top):\n```json\n{\n  \"message_id\": \"alert\",\n  \"display\": {\"mode\": \"overlay\", \"value\": \"x-gist-top\"},\n  \"expiration\": {\"type\": \"relative\", \"value\": \"604800\", \"unit\": \"seconds\"}\n}\n```\n\n**Bottom banner**:\n```json\n{\n  \"message_id\": \"cookie-notice\",\n  \"display\": {\"mode\": \"overlay\", \"value\": \"x-gist-bottom\"},\n  \"expiration\": {\"type\": \"relative\", \"value\": \"604800\", \"unit\": \"seconds\"},\n  \"persistent\": true\n}\n```\n\n**Tooltip anchored to an element** (for onboarding tours):\n```json\n{\n  \"message_id\": \"feature-tip\",\n  \"display\": {\"mode\": \"tooltip\", \"tooltip_target\": \"#settings-btn\", \"tooltip_position\": \"bottom\", \"tooltip_arrow_color\": \"#333333FF\"},\n  \"expiration\": {\"type\": \"relative\", \"value\": \"604800\", \"unit\": \"seconds\"}\n}\n```\n\n**High-priority persistent modal** (survives app restart, shown first):\n```json\n{\n  \"message_id\": \"critical-update\",\n  \"priority\": 10,\n  \"display\": {\"mode\": \"modal\", \"value\": \"center\", \"overlay_color\": \"#000000AA\", \"message_width\": 414, \"exit_click\": false},\n  \"expiration\": {\"type\": \"relative\", \"value\": \"604800\", \"unit\": \"seconds\"},\n  \"persistent\": true\n}\n```\n\n**With route rules** (only show on specific screens):\n```json\n{\n  \"message_id\": \"checkout-promo\",\n  \"display\": {\"mode\": \"modal\", \"value\": \"center\", \"message_width\": 414},\n  \"expiration\": {\"type\": \"relative\", \"value\": \"604800\", \"unit\": \"seconds\"},\n  \"route_rules\": {\n    \"web\": [{\"order\": 1, \"operator\": \"contains\", \"value\": \"/checkout\"}],\n    \"ios\": [{\"order\": 1, \"operator\": \"contains\", \"value\": \"checkout\"}]\n  },\n  \"excluded_route_rules\": {\n    \"web\": [{\"order\": 1, \"operator\": \"equals\", \"value\": \"/checkout/confirm\"}]\n  }\n}\n```\n\nRoute rules use `operator` (`contains` or `equals`) with wildcard `*` support\nin the `value` field (e.g. `/products/*/details`). Rules within a platform are\nOR'd; include and exclude rules are combined as: must match at least one include\nAND zero excludes. Page rules are case sensitive.\n\n### Editor selection\n\nSet the editor to `visual` — the only in-app editor, and the correct choice for\nboth authoring approaches below. Set it on the first PUT and keep it: the editor\nis **write-once**, so changing it later means creating a new template. (Reserve\n`html`, `wysiwyg`, and `parcel` for email.)\n\n### Authoring the body: prefer Carta components\n\n**Author the body as Carta component markup** (`\u003cx-base\u003e\u003cx-message\u003e...`).\nIn-app messages are authored in the **in-app editor** and\nuse their own component set rooted at `\u003cx-message\u003e` (never the email\n`\u003cx-section\u003e`, which produces an email layout). Compile the markup, then store\n**both** the compiled HTML in `body` and the Carta source in\n`body_json.carta_content` (plus `has_global_styles`). This keeps the message\neditable in the UI composer, which loads from `body_json.carta_content`. Follow\nthe compile-and-PUT flow in `in_app.md § Setting In-App Content`, and see\n`in_app.md § Component Markup` for the `\u003cx-base\u003e\u003cx-message\u003e...` component syntax\nand the available components.\n\n**Reach for raw HTML only when the message is fully code-owned** — generated and\nsent entirely from app code, with no one ever opening it in the UI. Put\nfully-rendered HTML in `body`, set `editor: \"visual\"`, omit `carta_content`, and\ndrive behavior through the `message.*` action bridge (see *Available actions in\nHTML* below). The reference template and `body_json` schema in this file\ndocument this raw-HTML path.\n\nAuthor with Carta whenever a human will view or edit the message. A raw-HTML body\n(no `carta_content`) delivers correctly, but the composer loads from\n`body_json.carta_content`; opening such a message in the UI editor shows an empty\ncanvas and **replaces the stored `body` with a blank scaffold**, discarding the\nraw HTML. So author any message that may be opened in the editor as Carta from the\nstart — recompiling to add `carta_content` keeps the editor `visual` (no new\ntemplate), but only before the body has been overwritten.\n\n### Designing the HTML body\n\nThese are **in-app messages** — not emails or web pages. When the user gives a\nsimple instruction like \"say hello\" without specifying HTML, generate a message\nthat looks and feels like an in-app notification.\n\nThe SDK handles all display chrome — overlays, positioning, backdrop dimming,\nand dismiss-on-outside-click. The HTML body is **only the message content\nitself**. Do not add overlay backgrounds, fixed positioning, or backdrop\nelements in the HTML.\n\nUse the following as the reference template. Adapt the content, colors, and\nimages to match the user's request, but preserve the structure:\n\n- Full HTML document with `\u003c!DOCTYPE html\u003e`, `\u003chead\u003e`, viewport meta\n- CSS variables in `:root` for easy theming\n- `\u003cstyle\u003e` block (not inline styles)\n- Close button with icon using `message.dismiss({})`\n- Hero image (swap or remove as needed)\n- Heading + body text\n- CTA button using `message.openUrl()` with `name` for tracking\n\n```html\n\u003c!DOCTYPE html\u003e\n\u003chtml lang=\"en\" dir=\"ltr\"\u003e\n\u003chead\u003e\n  \u003cmeta charset=\"utf-8\"\u003e\n  \u003cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0\"\u003e\n  \u003cmeta name=\"color-scheme\" content=\"normal\"\u003e\n  \u003ctitle\u003eMessage Title\u003c/title\u003e\n  \u003cstyle\u003e\n    :root {\n      color-scheme: normal;\n      --max-width: 414px;\n      --primary-color: #0057c4;\n      --primary-hover: #0046a0;\n      --text-color: #666666;\n      --heading-color: #333333;\n    }\n\n    html {\n      overscroll-behavior: none;\n      cursor: default;\n    }\n\n    body {\n      font-family: Helvetica, Arial, sans-serif;\n      margin: 0;\n      -webkit-font-smoothing: antialiased;\n      -moz-osx-font-smoothing: grayscale;\n      color: #000;\n      line-height: 1.5;\n    }\n\n    .page-wrapper {\n      max-width: var(--max-width);\n      margin: 0 auto;\n      padding: 24px 16px 0;\n    }\n\n    .modal {\n      padding: 8px;\n    }\n\n    .card {\n      background: #fefefe;\n      border-radius: 8px;\n      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n      color: var(--text-color);\n      font-size: 18px;\n      line-height: 1.5;\n      padding: 24px;\n      overflow: auto;\n      box-sizing: border-box;\n    }\n\n    .close-bar {\n      margin: 0 0 26px;\n      text-align: right;\n    }\n\n    .close-btn {\n      all: unset;\n      cursor: pointer;\n    }\n\n    .close-icon {\n      box-sizing: border-box;\n      vertical-align: middle;\n      max-width: 100%;\n      width: 28px;\n    }\n\n    .hero-image-wrapper {\n      margin: 0 0 32px;\n      text-align: left;\n    }\n\n    .hero-image {\n      box-sizing: border-box;\n      vertical-align: middle;\n      max-width: 100%;\n      width: 100%;\n    }\n\n    .card h1 {\n      color: var(--heading-color);\n      font-size: 28px;\n      line-height: 1.25;\n      margin: 0 0 18px;\n    }\n\n    .card p {\n      margin: 16px 0;\n    }\n\n    .cta-wrapper {\n      text-align: center;\n      margin: 8px 0;\n    }\n\n    .cta-btn {\n      all: unset;\n      cursor: pointer;\n      display: inline-block;\n      width: 100%;\n      box-sizing: border-box;\n      max-width: 100%;\n      color: #fff;\n      font-size: 16px;\n      font-weight: 700;\n      text-align: center;\n      text-decoration: none;\n      line-height: inherit;\n      padding: 18px 0;\n      background: var(--primary-color);\n      border-radius: 8px;\n    }\n\n    .cta-btn:hover {\n      background: var(--primary-hover) !important;\n    }\n  \u003c/style\u003e\n\u003c/head\u003e\n\u003cbody\u003e\n  \u003cdiv class=\"page-wrapper\"\u003e\n    \u003cdiv class=\"modal\"\u003e\n      \u003cdiv class=\"card\"\u003e\n\n        \u003cdiv class=\"close-bar\"\u003e\n          \u003cbutton class=\"close-btn\" onclick=\"message.dismiss({})\"\u003e\n            \u003cimg src=\"https://storage.googleapis.com/cio-in-app-templates/assets/close-padding.603304e2.png\"\n                 alt=\"Close\" class=\"close-icon\"\u003e\n          \u003c/button\u003e\n        \u003c/div\u003e\n\n        \u003cdiv class=\"hero-image-wrapper\"\u003e\n          \u003cimg src=\"https://storage.googleapis.com/cio-in-app-templates/assets/placeholder-wide.40b5e387.png\"\n               alt=\"Announcement image\" class=\"hero-image\"\u003e\n        \u003c/div\u003e\n\n        \u003ch1\u003eHeading here\u003c/h1\u003e\n\n        \u003cp\u003eBody text here.\u003c/p\u003e\n\n        \u003cdiv class=\"cta-wrapper\"\u003e\n          \u003cbutton type=\"button\" class=\"cta-btn\"\n                  onclick=\"message.openUrl('https://example.com', {name: 'cta-click', target: '_blank'})\"\u003e\n            Call to action\n          \u003c/button\u003e\n        \u003c/div\u003e\n\n      \u003c/div\u003e\n    \u003c/div\u003e\n  \u003c/div\u003e\n\u003c/body\u003e\n\u003c/html\u003e\n```\n\nAdapt the structure to the content — add an image, icon, or secondary button as\nneeded — but always keep it compact and mobile-friendly. Include a dismiss\naction on at least one element.\n\n### Available actions in HTML\n\nIn-app messages render inside an iframe with a JavaScript bridge. Actions are\ntriggered via `onclick` handlers calling `message.*` methods — there are **no\nspecial `data-gist-*` attributes** for actions.\n\n| Method | Purpose | Example |\n|---|---|---|\n| `message.dismiss({})` | Close the message | `onclick=\"message.dismiss({})\"` |\n| `message.openUrl(url, {target: \"_blank\"})` | Open URL in new tab | `onclick=\"message.openUrl('https://example.com', {target: '_blank'})\"` |\n| `message.openDeeplink(url)` | Trigger a deep link (native apps) | `onclick=\"message.openDeeplink('myapp://settings')\"` |\n| `message.performAction(action)` | Fire a custom action string | `onclick=\"message.performAction('upgrade-clicked')\"` |\n| `message.showMessage(event, stepName)` | Navigate to a message step (multi-step) | `onclick=\"message.showMessage(event, 'step-2')\"` |\n| `analytics.track(event, properties)` | Track a custom event | `onclick=\"analytics.track('cta_clicked', {source: 'promo'})\"` |\n\nUse `\u003cbutton\u003e` elements for actions (not `\u003ca\u003e` links) to avoid needing\n`return false` to prevent default behavior.\n\n### Multi-step messages\n\nMulti-step messages combine multiple display types (e.g. a modal that\ntransitions to an overlay banner) in a single template. Each step is a `\u003cdiv\u003e`\nwith data attributes that control its identity, display type, and visibility.\n\n#### Required structure\n\nEvery step must have:\n\n- **`data-message-name=\"stepId\"`** — unique identifier for the step, used by\n  `message.showMessage()` to navigate. This is required — without it the SDK\n  can't find or transition to the step.\n- **`class=\"cio-message-visible\"`** or **`class=\"cio-message-hidden\"`** — controls\n  initial visibility. The first step should be `cio-message-visible`, all others\n  `cio-message-hidden`. The SDK toggles these classes when transitioning.\n\nAdd this CSS to the template:\n\n```css\n.cio-message-hidden {\n  display: none !important;\n}\n```\n\n#### Per-step display attributes\n\nEach step can override the display type and positioning independently:\n\n| Attribute | Values | Description |\n|---|---|---|\n| `data-message-display-type` | `modal`, `overlay`, `tooltip` | Display mode for this step |\n| `data-message-display-modal-position` | `center`, `top`, `bottom` | Position for modal steps |\n| `data-message-display-overlay-position` | `topCenter`, `bottomCenter`, `topLeft`, `topRight`, `bottomLeft`, `bottomRight` | Position for overlay steps |\n| `data-message-display-modal-overlay-color` | `#RRGGBBAA` | Backdrop color for modal steps |\n| `data-message-display-max-width` | integer (pixels) | Max width of the message |\n| `data-message-display-dismiss-outside-click` | `true` / `false` | Dismiss when clicking outside |\n| `data-message-display-element-selector` | CSS selector | Container element for inline steps |\n| `data-message-display-tooltip-target` | CSS selector | Anchor element for tooltip steps |\n| `data-message-display-tooltip-position` | `top`, `bottom`, `left`, `right` | Arrow position for tooltip steps |\n| `data-message-display-tooltip-arrow-color` | `#RRGGBBAA` | Tooltip arrow color |\n\n#### Example: Modal → Overlay transition\n\n```html\n\u003c!-- Step 1: Modal --\u003e\n\u003cdiv data-message-name=\"announcement\"\n     data-message-display-type=\"modal\"\n     data-message-display-modal-position=\"center\"\n     data-message-display-modal-overlay-color=\"#00000044\"\n     data-message-display-max-width=\"414\"\n     data-message-display-dismiss-outside-click=\"true\"\n     class=\"cio-message-visible\"\u003e\n  \u003c!-- modal content --\u003e\n  \u003cbutton onclick=\"message.showMessage(event, 'banner')\"\u003eContinue\u003c/button\u003e\n\u003c/div\u003e\n\n\u003c!-- Step 2: Bottom overlay --\u003e\n\u003cdiv data-message-name=\"banner\"\n     data-message-display-type=\"overlay\"\n     data-message-display-overlay-position=\"bottomCenter\"\n     data-message-display-max-width=\"500\"\n     class=\"cio-message-hidden\"\u003e\n  \u003c!-- banner content --\u003e\n  \u003cbutton onclick=\"message.dismiss({})\"\u003eClose\u003c/button\u003e\n\u003c/div\u003e\n```\n\nThe SDK handles the display type transition automatically — it removes the modal\noverlay and repositions the message as a bottom banner when\n`message.showMessage(event, 'banner')` is called.\n\n### Saving and updating the template\n\nThis PUT writes the whole message, so use it only for the **first save of a new message**:\n\n```bash\ncio api /v1/environments/{environment_id}/templates/{template_id} -X PUT \\\n  --json '{\"template\":{\"editor\":\"visual\",\"template_type\":\"in_app\",\"body\":\"\u003cdiv\u003e...\u003c/div\u003e\",\"body_json\":\"{\\\"message_id\\\":\\\"\u003cunique-id\u003e\\\",\\\"priority\\\":5,\\\"display\\\":{\\\"mode\\\":\\\"modal\\\",\\\"value\\\":\\\"center\\\",\\\"overlay_color\\\":\\\"#00000033\\\",\\\"message_width\\\":414,\\\"exit_click\\\":true},\\\"expiration\\\":{\\\"type\\\":\\\"relative\\\",\\\"value\\\":\\\"604800\\\",\\\"unit\\\":\\\"seconds\\\"},\\\"persistent\\\":false}\"}}'\n```\n\n**To update a message that already has content, never resend a whole `body_json`.** Doing so resets the display settings, priority, persistence and expiry, and drops the page rules, attributes, targeting and `carta_content`.\n\n- **Authored as Carta** (`body_json` holds `carta_content`): follow `in_app.md § Updating an existing message`.\n- **Code-owned raw HTML** (no `carta_content`): send only what changes, after the same fresh read and `updated` check. New HTML goes in `body`, with `body_json` left out of the PUT:\n\n```bash\nREAD_UPDATED=1790592258  # `updated` from the read your change is based on\nBODY=$(cat \u003c\u003c'EOF'\n\u003cdiv\u003e...the new HTML...\u003c/div\u003e\nEOF\n)\nCURRENT=$(cio api /v1/environments/{environment_id}/templates/{template_id} --jq '.template | {updated, body_json}') || exit 1\n[ \"$(echo \"$CURRENT\" | jq -r '.updated')\" = \"$READ_UPDATED\" ] || { echo \"saved after your read: read it again and re-apply your change\"; exit 1; }\necho \"unchanged since your read (updated $READ_UPDATED); writing\"\ncio api /v1/environments/{environment_id}/templates/{template_id} -X PUT \\\n  --arg body=\"$BODY\" --json '{template:{body:$body}}'\n```\n\nTo change a setting as well, add `--argjson cur=\"$CURRENT\"` and merge it into the stored `body_json` in the same `--json`, for example `body_json:($cur.body_json | fromjson | .priority = 10 | tostring)`.\n\n### body_json schema\n\n`body_json` is a JSON **string** (escaped JSON inside JSON). Structure:\n\n| Field | Required | Description |\n|---|---|---|\n| `message_id` | Yes | Unique string identifier for the message |\n| `priority` | No | 1 (low, default), 5 (medium), 10 (high) |\n| `display.mode` | Yes | `modal`, `overlay`, `inline`, or `tooltip` |\n| `display.value` | Yes | Position string — depends on mode (see below) |\n| `display.overlay_color` | No | `#RRGGBBAA` hex color for overlay background |\n| `display.message_width` | No | Integer, width in pixels |\n| `display.exit_click` | No | Boolean, dismiss on background click |\n| `display.tooltip_target` | Tooltip only | CSS selector for the anchor element (e.g. `#settings-btn`) |\n| `display.tooltip_position` | Tooltip only | `x-gist-tooltip-top`, `x-gist-tooltip-bottom`, `x-gist-tooltip-left` or `x-gist-tooltip-right`. The plain words are rejected |\n| `expiration.type` | Yes | `relative` or `absolute` |\n| `expiration.value` | Yes | String: seconds for relative (1-2592000), unix timestamp for absolute |\n| `expiration.unit` | No | `seconds`, `minutes`, `hours`, `days` (relative only) |\n| `persistent` | No | Boolean, survives app restart (default: false) |\n| `attributes` | No | Object of custom key-value string pairs |\n| `route_rules` | No | Object keyed by platform (`ios`, `android`, `web`) with arrays of `{order, operator, value}`. Operators: `contains` or `equals`. Wildcard `*` supported. |\n| `excluded_route_rules` | No | Same structure as `route_rules`, for exclusions |\n| `carta_content` | Preferred | Carta component markup (`\u003cx-base\u003e\u003cx-message\u003e...`). Set this so the message renders and stays editable in the UI composer — the preferred path (see *Authoring the body: prefer Carta components* above). Omit only for fully code-owned raw-HTML messages. |\n| `has_global_styles` | With `carta_content` | Set `true` when `carta_content` uses `globalStyles.*` tokens; pair it with `carta_content`. |\n\n### Minimal body_json example\n\n```json\n{\n  \"message_id\": \"hello\",\n  \"display\": {\"mode\": \"modal\", \"value\": \"center\", \"message_width\": 414},\n  \"expiration\": {\"type\": \"relative\", \"value\": \"604800\", \"unit\": \"seconds\"}\n}\n```\n\n### display.value by mode\n\n**Modal** — `top`, `center`, `bottom`\n\n**Overlay** — uses `x-gist-*` position identifiers:\n- `x-gist-top` — full-width top banner\n- `x-gist-bottom` — full-width bottom banner\n- `x-gist-floating-top` — floating top center\n- `x-gist-floating-bottom` — floating bottom center\n- `x-gist-floating-top-left` — floating top left\n- `x-gist-floating-top-right` — floating top right\n- `x-gist-floating-bottom-left` — floating bottom left\n- `x-gist-floating-bottom-right` — floating bottom right\n\n**Inline** — a CSS selector for the container element (e.g. `#promo-slot`)\n\n**Tooltip** — not used (positioning is controlled by `tooltip_target` and\n`tooltip_position` instead)\n\n## Step 3 — Activate the message\n\nNew transactional messages default to `draft`. Activate with `update_type: activate`:\n\n```bash\ncio api /v1/environments/{environment_id}/transactional_messages/{id} -X PUT \\\n  --json '{\"transactional_message\":{\"name\":\"\u003cmessage-name\u003e\",\"type\":\"in_app\",\"content_source\":\"\",\"sample_data\":\"\",\"tags\":[],\"trigger_name\":\"\",\"update_type\":\"activate\"}}'\n```\n\nAll fields shown are required by the update endpoint. Confirm `\"state\": \"active\"`\nin the response.\n\n## Step 4 — Send the message\n\nSending uses the **App API**, not the UI API. In production, customers POST\nto `/v1/send/in_app` from their backend. For testing, `cio transactional send\nin_app` wraps the same endpoint. See `messaging.md § Sending from a Backend`\nfor the full common payload shape and channel-specific fields across all\nfive send endpoints.\n\n- **App API host:** `https://api.customer.io` (production), `https://api-eu.customer.io` (EU)\n- **App API key:** found in Customer.io UI under Settings \u003e API \u0026 Webhook Credentials\n- **Auth:** Bearer token (not Basic auth — this is a common mistake)\n\n```bash\ncurl -s -X POST https://api.customer.io/v1/send/in_app \\\n  -H 'Content-Type: application/json' \\\n  -H 'Authorization: Bearer \u003cAPP_API_KEY\u003e' \\\n  -d '{\"transactional_message_id\": \"\u003cid\u003e\", \"identifiers\": {\"id\": \"\u003cuser-id\u003e\"}}'\n```\n\nThe endpoint is `/v1/send/in_app` — not `/send/push` or `/send/email`.\n\nIn-app messages are delivered client-side: the SDK polls for pending messages\nwhen the user opens the app. They are not pushed server-side.\n\n### Common errors\n\n| Error | Cause | Fix |\n|---|---|---|\n| `Unauthorized request` | Wrong API key, wrong host, or using Basic auth | Verify App API key and use `Authorization: Bearer \u003ckey\u003e` |\n| `Account owner's email address is not confirmed` | Account email unverified | Confirm the account owner's email in the UI |\n| `unexpected end of JSON input` (in worker logs) | `body_json` is empty on the template | Update the template with a valid `body_json` (Step 2) |\n| `invalid editor` | Using an editor not valid for in-app | Set editor to `visual` |\n| Message blank/uneditable in the UI composer (but delivery works) | Raw HTML stored in `body` with no `carta_content` | Author the body as Carta and set `carta_content` to keep it UI-editable — see *Authoring the body: prefer Carta components*. (Empty composer is expected for fully code-owned messages.) |\n| In-app deliveries fail / never arrive | Workspace not provisioned (no in-app credentials) | Provision it — see Step 0 / `in_app.md § Provisioning` |\n| 422 `Select at least one platform` on the template PUT | The error message explains the fix: `body_json` omitted a required `targeting` key | The `targeting` object's shape is in `in_app.md § Targeting` |\n| Old content still showing after template update | Template cache hasn't cleared yet | Wait ~120 seconds after saving the template before sending — the delivery worker caches templates and it takes up to 2 minutes for changes to take effect |\n","transactional_send.md":"# Sending Transactional Messages from a Backend\n\nCustomers send transactional messages (order confirmations, password resets, receipts, push notifications) by POSTing directly to the Transactional API from their backend code. **No Customer.io SDK is required** — the request shape is the API contract, and an HTTP client in any language works.\n\nThe CDP analytics SDKs (`@customerio/cdp-analytics-*`) are still the right choice for `identify` / `track` ingestion (they have batching/retry). The Track + App API SDKs are *not* preferred here — they're thin HTTP wrappers and add nothing for sends.\n\nFor the management/CRUD side of transactional messages (creating, listing, updating templates, getting metrics), see `messaging.md § Transactional Messages`. For the in-app workflow specifically (template body_json, route rules, etc.), see `transactional_in_app.md`.\n\n## Endpoints\n\nOne per channel, all share a common base shape:\n\n| Channel | Path |\n|---|---|\n| Email | `POST https://api.customer.io/v1/send/email` |\n| Push | `POST https://api.customer.io/v1/send/push` |\n| SMS | `POST https://api.customer.io/v1/send/sms` |\n| In-app | `POST https://api.customer.io/v1/send/in_app` |\n| Inbox | `POST https://api.customer.io/v1/send/inbox_message` |\n\nEU region: replace `api.customer.io` with `api-eu.customer.io`.\n\n## Auth\n\nFor normal customer backend code, use:\n\n`Authorization: Bearer \u003cAPP_API_KEY\u003e`\n\nUse a workspace-scoped App API key for backend app secrets. It is narrower\nthan a service account token, which can carry account-management rights.\n\nFor CLI-only test sends, do **not** create an external API key just to send\nthe message. `cio send email` and `cio transactional send \u003cchannel\u003e` use the\ncurrent CLI authentication.\n\nDo not embed CLI signup/bootstrap tokens, admin automation tokens, or anything\ncopied from `cio auth token` into customer code.\n\nCreate an App API key for normal backend application code, either via the UI\n(Settings \u003e API \u0026 Webhook Credentials) or via the CLI:\n\n```bash\ncio api /v1/environments/{environment_id}/ext_api_keys -X POST \\\n  --json '{\"ext_api_key\":{\"name\":\"backend-prod\"}}'\n```\n\nThe response includes the full Bearer value on creation — copy it into the backend's env var. Subsequent `GET .../ext_api_keys` calls return only a hint, not the full key. It is the application's secret: store it wherever the app keeps backend secrets, under whatever name fits the codebase — Customer.io does not define a canonical env var (`CIO_APP_API_KEY` is a fine example). Never put it in browser or mobile code.\n\n**Bearer**, not Basic — Basic auth is the Track API and is a common mistake here.\n\n## Common body fields (all channels)\n\n| Field | Type | Notes |\n|---|---|---|\n| `transactional_message_id` | int or string | Numeric ID, or string name. A string name requires the message to exist or `auto_create: true`. |\n| `identifiers` | object | Recipient: `{\"id\": \"...\"}`, `{\"email\": \"...\"}`, or `{\"cio_id\": \"...\"}` |\n| `message_data` | object | Variables for the template (`{{trigger.X}}`) |\n| `send_at` | int | Unix timestamp for scheduled send (max 90 days out) |\n| `language` | string | Locale matched against the message's translation templates (case-insensitive); falls back to the default if no match. Set up translations via `POST .../transactional_messages/:id/translations` — see `translations.md`. |\n| `disable_message_retention` | bool | Hide rendered body from delivery logs (sensitive content) |\n| `send_to_unsubscribed` | bool | Bypass unsubscribe check |\n| `auto_create` | bool | Auto-create the transactional_message on send when given a string name |\n| `queue_draft` | bool | Queue as draft, don't send immediately |\n\n## Channel-specific fields\n\n- **Email:** `to`, `from`, `bcc`, `reply_to`, `subject`, `preheader`, `body` (HTML), `body_plain`, `body_amp`, `headers`, `attachments`, `tracked` (link tracking), `fake_bcc`, `disable_css_preprocessing`. Email can send *without* a `transactional_message_id` if all content is provided inline — useful for fully dynamic mail.\n- **Push:** `to` (recipient), `title`, `message`, `image_url`, `link`, `sound`, `custom_data`, `custom_payload` (`{ios: {...}, android: {...}}` for native overrides), `custom_device`.\n- **SMS:** `from`, `to` (E.164 phone), `tracked`. Body comes from `message_data` rendered through the template.\n- **In-app:** see `transactional_in_app.md` for the in-app workflow and full body_json shape.\n- **Inbox:** see `inbox.md` for the inbox `body_json` schema (`topics`, `type`, `priority`, `expiration`, and the `properties` custom payload).\n\n## Example request\n\nThe HTTP shape is the contract; produce the equivalent in whatever language the customer is using.\n\n```http\nPOST https://api.customer.io/v1/send/email\nAuthorization: Bearer \u003cAPP_API_KEY\u003e\nContent-Type: application/json\n\n{\n  \"transactional_message_id\": \"order_confirmation\",\n  \"auto_create\": true,\n  \"identifiers\": { \"id\": \"user-123\" },\n  \"to\": \"user@example.com\",\n  \"message_data\": { \"order_number\": \"12345\", \"total\": \"99.99\" }\n}\n```\n\n## Choosing where the email content lives\n\nWhere the email content lives is the user's choice — ask before authoring,\ndon't default silently:\n\n\u003e Do you want to create your email in Customer.io (recommended), or store the\n\u003e message in your code?\n\nCreating it in Customer.io lets the user manage styles, reusable components,\nand layouts in their workspace and edit the message without touching code.\nKeep the question short; expand only if the user asks. Default to Customer.io\n(Design Studio) unless the user picks code.\n\n- **Customer.io-authored (recommended):** content lives on the message's\n  template, editable in Design Studio; the app sends only\n  `transactional_message_id` + `to` + `identifiers` + `message_data`. See\n  *Authoring stored content* below.\n- **Code-owned (`auto_create` or inline):** content is the `subject`/`body`\n  the app sends on every call; the message in the UI stays an empty,\n  non-editable shell. Fine for fully dynamic sends (password resets, receipts)\n  where that is expected. Do not use it for in-app/inbox messages or for\n  anything designed in Design Studio.\n\nQuote display names containing commas in `from`: `\"Bogus, Inc\" \u003chello@example.com\u003e` —\nunquoted 400s with \"invalid email address in field from\".\n\n### Authoring stored content\n\nUse the node-centric Design Studio endpoints (env-scoped under\n`/v1/environments/:environment_id/design_studio`). You work from an email\n**node id** — never a template id and never a pre-rendered body:\n\n1. Create the email node with its content. `content.html` is Design Studio\n   **component markup** (`\u003cx-base\u003e...\u003c/x-base\u003e`), not raw HTML/MJML — read\n   `cio skills read design-studio/SKILL.md` (and `design-studio/components_reference.md`)\n   for the `x-` component syntax:\n   `POST /v1/environments/:env/design_studio/emails`\n   `{\"name\":\"Welcome\",\"content\":{\"html\":\"\u003cx-base\u003e...\u003c/x-base\u003e\",\"text\":\"...\"}}` —\n   the response is `{\"email\":{\"id\":\"\u003cnode_id\u003e\", ...}}`.\n2. Link the node to the transactional message (pass the message id, not a\n   template id):\n   `POST /v1/environments/:env/design_studio/emails/\u003cnode_id\u003e/link`\n   `{\"target\":{\"type\":\"transactional_message\",\"transactional_message_id\":\u003cid\u003e}}`.\n3. Publish — renders the node server-side and promotes it into the linked\n   template's `body`:\n   `POST /v1/environments/:env/design_studio/emails/\u003cnode_id\u003e/publish` `{}`.\n\nTo revise content later, `PUT .../design_studio/emails/\u003cnode_id\u003e` then publish\nagain. Read `cio skills read design-studio/nodes.md` for the node content shape\nand multi-language publishes.\n\nThen send with `transactional_message_id` + `to` + `identifiers` +\n`message_data` only. Subject and sender live on the message.\n\n## Mobile apps\n\nMobile apps don't call the App API directly: the app sends the business action\nto the backend, the backend validates it, and the backend calls the\nTransactional API (purchase → receipt, booking → confirmation, reset request →\nreset email).\n\n## The `auto_create` paradigm — simplest pattern for backend sends\n\nPick a stable string identifier (`\"order_confirmation\"`, `\"password_reset\"`, `\"shipping_update\"`) and pass it as `transactional_message_id` along with `auto_create: true`. The first call creates a transactional message in the workspace with that name and the channel matching the endpoint you hit (`/v1/send/email` → email-typed message, `/v1/send/push` → push, etc.). Subsequent calls find and reuse the existing message. Keep `auto_create: true` on every send — it's idempotent.\n\nName constraints: must be a non-numeric string, non-empty, ≤ 191 unicode characters. A name that parses as a number (e.g. `\"42\"`) is rejected; `auto_create` is text-only.\n\n**When NOT to use `auto_create`:**\n\n- **In-app and inbox messages.** Auto-created templates have no `body_json`, so deliveries queue successfully but render empty. Create the message explicitly via `messaging.md § Transactional Messages` (or the UI), then configure the template — see `transactional_in_app.md` for the in-app workflow and `inbox.md` for the inbox `body_json` schema.\n- **When the template content needs to be authored ahead of time** (e.g. the email body is built in Design Studio or the UI). Create the message explicitly and call with the resulting numeric ID or string name; omit `auto_create`.\n\n## Testing and one-off sends\n\nFor testing or scripting (not production code): `cio send email` (inline-content email, no template) and `cio transactional send \u003cchannel\u003e` (templated send via `transactional_message_id`) wrap these endpoints with flag-based or `--json` payloads. See `cio send --help` and `cio transactional send --help`. Note: `cio transactional send in_app` is not yet wired — use raw HTTP for in-app testing.\n\n```bash\ncio transactional send email --environment-id \u003cenvironment_id\u003e \\\n  --transactional-message-id \u003cid-or-name\u003e \\\n  --to \u003crecipient-email\u003e \\\n  --message-data '{\"first_name\":\"Ada\"}'\n```\n\nPrefer the app's real backend path for a proof: the browser or app triggers\nthe action, the backend sends, then verify receipt.\n\n## Verify delivery — a 200 is not delivered\n\n`/v1/send/*` returning 200 means accepted. Confirm the delivery reached a\nterminal state:\n\n```bash\ncio api /v1/environments/{environment_id}/deliveries \\\n  --params '{\"environment_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --jq '.deliveries[] | {id, state, reason: (.meta.reason // null)}'\n```\n\nRead `meta.reason` before changing anything. On a new account, delivery stays\ndisabled until the account's verification review approves it, and real email\nneeds a verified sending domain — a failed or held delivery there is not a\ncontent problem (`cio skills read cio/onboarding.md`, *Verification and\nsending*). A first send from a freshly added domain can transiently fail with\nthe generic \"error in Customer.io\" reason while the domain provisions; wait\nbriefly, then retry only deliveries confirmed `failed`:\n\n```bash\ncio api /v1/environments/{environment_id}/deliveries/retry -X POST \\\n  --params '{\"environment_id\":\"\u003cenvironment_id\u003e\"}' \\\n  --json '{\"delivery_ids\":[\"\u003cfailed-delivery-id\u003e\"]}'\n```\n\nNever blind-resend or auto-retry failed POSTs; retries duplicate deliveries. If\nthe backend queues sends, make idempotency explicit at the application layer.\n\n## Note on `cio schema`\n\n`cio schema` does NOT currently cover the `/v1/send/*` endpoints — only the `transactional_messages` management endpoints (create/list/update/cancel — see `messaging.md § Other Transactional Endpoints`). Use the shape documented in this file directly. (Adding these to `cio schema` is tracked as a follow-up.)\n","translations.md":"# Translations (Multi-Language Messages)\n\nCustomer.io ships content in multiple languages by attaching translation templates to a parent message. At send time, the recipient's `language` profile attribute is matched (case-insensitively) against the translations; a missed match falls back to the default template.\n\nThe mechanism differs by parent type:\n\n| Parent | How translations attach | Where to look |\n|---|---|---|\n| One-time send | `POST .../newsletters/:id/translations` — translations hang directly off the parent | This file |\n| Transactional message | `POST .../transactional_messages/:id/translations` — same model | This file |\n| Automation delivery action | `multi_lang_branch_action` + sibling per-language actions wired into the workflow graph | `automations.md § Multi-Language Action Variants` |\n| Subscription center pages | Per-locale strings on the topic | `subscriptions.md` |\n\nThe rest of this file covers the parent-attached flow (one-time sends and transactional messages). Automation action translations are graph-wiring-heavy and live with the workflow docs.\n\n**A Design Studio in-app message is the exception.** When the parent's in-app template has `editor: \"parcel\"`, its languages come from Design Studio, not from this file's routes: add, edit and publish them as `@skills/design-studio/in_app.md` § Languages says. Its templates refuse content writes, and a language added with the routes below sends a copy of the default's message until Design Studio publishes it. If you can't read that file, the account doesn't build in-app messages in Design Studio: change nothing, and tell the user the message's languages are managed there.\n\n## Add translations\n\n```\nPOST /v1/environments/:environment_id/newsletters/:id/translations\nPOST /v1/environments/:environment_id/transactional_messages/:transactional_message_id/translations\n```\n\nOne-time send body — `reference_template_id` picks which A/B test group's template to copy from (use the default group's template ID for non-A/B one-time sends):\n\n```json\n{\n  \"reference_template_id\": \"12345\",\n  \"languages\": [\"es\", \"fr\", \"pt-br\"]\n}\n```\n\nTransactional body — single template per message, no reference needed:\n\n```json\n{ \"languages\": [\"es\", \"fr\"] }\n```\n\nThe endpoint copies the source template (subject, body, etc.) into a new translation template per language. The response returns the parent with all templates (default + translations).\n\n**Constraints:**\n- Codes must be non-blank; one-time sends validate against a BCP-47 whitelist (e.g. `\"es\"`, `\"fr-CA\"`, `\"pt-br\"`). Transactional only checks non-blank, but follow the same convention.\n- Duplicates within the request body, and languages already attached to the parent, are rejected (case-insensitive for one-time sends).\n- One-time send A/B test groups each carry their own translation set — `reference_template_id` selects the group.\n\n## Update translation content\n\nEach translation is a regular template — edit it via `PUT /v1/environments/:environment_id/templates/:template_id`. Get the IDs from the parent's `templates` array (e.g. `GET .../newsletters/:id/templates`).\n\nA translation of a Design Studio in-app message is the exception above: its template refuses content writes.\n\n## Send-time language selection\n\nFor transactional sends, pass `language: \"es\"` in the `/v1/send/*` body — see `transactional_send.md`. For one-time sends, the recipient's `language` profile attribute drives the match automatically.\n","web_push.md":"---\nfeature_flag: web-push\n---\n\n# Web push (browsers) — content, targeting and test sends\n\nBrowser-specific push for workspaces with native web push. Read **push.md** first for the shared `push` template fields; this file covers what differs for browsers. For keys, the site plugin and the service worker, see **web_push_setup.md**.\n\n## Web push (browsers)\n\nBrowsers are the Push channel's third platform, `web`. The same `push` template reaches them once the workspace has a `push_webpush` (VAPID) credential (**web_push_setup.md § Web push credentials**, when available, which also covers the alpha enrollment it needs). There is no separate web template.\n\n**Content.** A browser receives the title (`subject`), message (`body`), image (`image_url`), `link` and `custom_data`. The customer's service worker decrypts the message and decides how to show it. `sound` and the `android` / `ios` priority blocks do not apply to browsers. The 4KB limit applies here too, and a little less of it is left for content.\n\n**Rich editor only. Custom payloads do not reach web devices.** A custom payload (`editor: \"json\"`) has only `ios` and `android` halves, so a send skips every web device (`WEBPUSH_CUSTOM_PAYLOAD_UNSUPPORTED`) and a message that targets only browsers sends nothing. If the user needs web delivery, keep the template on the rich editor.\n\n**Targeting a platform.** Browsers get push only from a `push_action` whose `send_to_platform` is `\"web\"` (or a broadcast whose platform is web). `\"all\"` (the default, labelled **iOS \u0026 Android** in the UI, formerly All platforms) means iOS and Android only and never reaches a browser. `\"ios\"` and `\"android\"` narrow it to one platform. To reach both phones and browsers, use two push actions, one on `\"all\"` and one on `\"web\"`. The template's `recipient` chooses among the devices on that platform, so `last_used` on an `\"all\"` action picks the person's most recently used phone even when a browser was used more recently, and on a `\"web\"` action picks their most recently used browser.\n\n**Transactional push to browsers.** A transactional push message has a `platform` with the same meaning: `all` (or unset, the default, **iOS \u0026 Android**) never reaches a browser, `ios` and `android` narrow it, `web` sends to browsers only and `all_and_web` to phones and browsers. Set it on the message (`platform` when creating it, or an update with `update_type` `\"platform\"`; see **messaging.md § Transactional Messages**), or per send with `platform` in the `POST /v1/send/push` body, which overrides the message's platform for that send only. With `auto_create: true`, the request's `platform` is how a new message reaches browsers, and the message it creates keeps that platform. `web` and `all_and_web` need web push enabled: without it the message update answers 422, and a send with any `platform` answers 400. `to` chooses within the platform: `all`, `last_used` (the most recently used device on it) or one browser's endpoint id. The browser gets the request's `title`, `message`, `link`, `image_url` and `custom_data` when the template has no Web fields, and `disable_message_retention` works as for phones. A message that sends to browsers gets no App by default; one scoped to an App other than the workspace's default leaves browsers out, as a Web Push action does.\n\n**Devices.** A browser is a device with `platform` `web`. Its id is the subscription's `endpoint` URL. Track accepts subscription keys as `webpush_p256dh` and `webpush_auth` attributes; services stores them as transport state rather than device attributes, so they are not indexed, cannot be used in segments, and do not appear in profile views or device-change activity. The customer's site registers browsers with the Pipelines JavaScript SDK's web push plugin: after `identify`, `analytics.webPush.subscribe()` asks for permission, subscribes with the workspace's public key and sends the device. With SDK **0.7.0 or later**, the plugin also sends the device attribute `origin`, the site the browser subscribed on (such as `https://shop.example.com`), shown as **Site** when expanded in the profile's Devices tab (API `attributes.origin`); a browser registered by an older SDK gets it on its next identified visit to the site with the updated SDK. It cannot be backfilled without a browser visit. `origin` is a record of where the subscription came from, not a send filter: a send picks a person's browsers by `recipient` whatever their origin, so a segment on it selects people, not which of their browsers receives the message. Never put the Track API key in a browser. A backend that already holds the subscription can register it through the Track API instead (Basic auth with the site ID and Track API key):\n\n```\nPUT /api/v1/customers/{identifier}/devices\n{\"device\": {\"id\": \"\u003csubscription.endpoint\u003e\", \"platform\": \"web\", \"last_used\": 1791205319,\n            \"attributes\": {\"webpush_p256dh\": \"\u003csubscription.keys.p256dh\u003e\", \"webpush_auth\": \"\u003csubscription.keys.auth\u003e\"}}}\n```\n\nThe Track API answers 400 when the endpoint is not an absolute `https://` URL or either key is missing or the wrong size. Profiles list web devices alongside app devices (`GET .../customers/:id` → `devices`) without returning subscription keys. When a push service reports the subscription gone (404/410), the device is removed. A locally invalid subscription, or a recognized VAPID key-mismatch response, is marked bounced until the browser registers again. The recognized key-mismatch responses are:\n\n- a 403 with JSON `reason` `VapidPkHashMismatch`;\n- FCM's exact 403 key-mismatch sentence from endpoint host `fcm.googleapis.com`;\n- Mozilla's 401 with numeric `errno` 109 and message `VAPID public key mismatch` from endpoint host `updates.push.services.mozilla.com`.\n\nA push service may refuse an oversized message with 400 or 413. Provider 400 and 413 responses fail this message without bouncing the device. Other 401 and 403 responses are workspace authentication failures and preserve the subscription.\n\n### Web notification fields\n\nWhen adding Web fields to an existing template, read its current `body_json` and merge the `web` object into it. Preserve `link`, `custom_data`, and any `android` or `ios` blocks. For an explain-only API answer, show this merge rather than inventing replacement values for existing keys. `body_json` is still sent as a JSON string.\n\niOS and Android ignore the `web` block, so adding it to a shared rich template leaves their content unchanged. A rich template's `body_json` can carry a `web` object that only browsers read: the small `icon` beside the title (a square image, 192×192 to 512×512 px works well), a monochrome `badge` some browsers show in the status bar, and up to two action `buttons`. The large picture is still `image_url`, and `link` stays where a click on the notification itself goes. Every string supports Liquid, except the Advanced options `lang` and `dir` (below).\n\n```\nPUT /v1/environments/:eid/templates/:template_id\n```\n\n```json\n{\n  \"template\": {\n    \"subject\": \"Your order shipped\",\n    \"body\": \"{{customer.first_name}}, it arrives Friday.\",\n    \"image_url\": \"https://cdn.example.com/shipped.png\",\n    \"editor\": \"rich\",\n    \"body_json\": \"{\\\"link\\\": \\\"https://example.com/orders/{{event.order_id}}\\\", \\\"custom_data\\\": \\\"{}\\\", \\\"web\\\": {\\\"icon\\\": \\\"https://cdn.example.com/icon-192.png\\\", \\\"badge\\\": \\\"https://cdn.example.com/badge-72.png\\\", \\\"actions\\\": [{\\\"id\\\": \\\"track\\\", \\\"title\\\": \\\"Track package\\\", \\\"url\\\": \\\"https://example.com/track/{{event.order_id}}\\\"}, {\\\"id\\\": \\\"help\\\", \\\"title\\\": \\\"Get help\\\", \\\"url\\\": \\\"https://example.com/help\\\"}]}}\"\n  }\n}\n```\n\nRules, checked when the template is saved for a workspace in the web push alpha (a 422 points at the field, e.g. `body_json.web.actions[1].id`):\n\n- `icon`, `badge` and each action `url` are `https://` URLs. Liquid is fine (`https://cdn.example.com/{{customer.plan}}.png`, `{{customer.icon_url}}`), but a field that names `http:` or any other scheme than https anywhere, inside a tag or out of it, is refused (`{{ \"http://…\" }}`, `{% if … %}http://…{% endif %}`, `replace: 'https:', 'http:'`). What a variable holds is checked when the message is sent: an icon, badge or button whose rendered URL isn't https is left out of that notification.\n- `actions`: at most 2. Each has an `id` of 1–32 characters from `a-z`, `0-9`, `_` and `-`, unique within the message; a `title` of at most 32 characters once trimmed (an emoji counts as one character; a title with Liquid is checked once it renders, and a button whose title renders blank or too long is left out of that message); and an optional `url`. A button without a `url` opens `link`.\n- The Advanced options are all optional; leave a key out for the browser's default. They are the composer's **Advanced options** section:\n\n  | Key | Type and rule | What the browser does |\n  |---|---|---|\n  | `require_interaction` | boolean | Keeps the notification on screen until the person dismisses or clicks it (desktop Chrome and Edge; others ignore it) |\n  | `silent` | boolean | No sound or vibration. Can't be combined with `vibrate` (422 on `body_json.web.vibrate`) |\n  | `tag` | 1–64 characters of `A-Z a-z 0-9 _ . : -`; Liquid allowed, checked once rendered | A new notification with the same tag replaces the earlier one instead of stacking |\n  | `renotify` | boolean; needs a `tag` (422 on `body_json.web.renotify` otherwise) | Alerts again (sound, vibration) when it replaces a notification with the same tag |\n  | `vibrate` | array of 1–10 whole numbers, each 0–10000 ms, alternating vibrate/pause | Vibration pattern, Android only |\n  | `lang` | BCP 47 language tag, at most 35 characters (`en`, `pt-BR`, `zh-Hant-TW`); no Liquid | Language of the title and body |\n  | `dir` | `auto`, `ltr` or `rtl`; no Liquid | Text direction |\n  | `timestamp` | epoch seconds or milliseconds (a whole number below 100000000000 is seconds, so `{{event_timestamp}}` works as is), or an ISO-8601 time with a zone (`2026-10-13T09:30:00Z`) or a date (`2026-10-13`); number or string, not negative; Liquid allowed, checked once rendered; always sent to the browser in milliseconds | Shows that time (e.g. when the event happened) instead of the arrival time |\n  | `app_badge` | whole number 0–99999; number or string; Liquid allowed (`{{customer.unread_count}}`), checked once rendered | Sets the app icon badge in Safari and installed Home Screen apps; `0` clears it |\n\n  A `tag`, `timestamp` or `app_badge` whose Liquid renders to something outside its rule is left out of that notification (a `renotify` goes with its `tag`), and the rest of the message is still sent.\n\n  ```json\n  \"web\": {\"tag\": \"order-{{event.order_id}}\", \"renotify\": true, \"require_interaction\": true, \"timestamp\": \"{{event_timestamp}}\", \"app_badge\": \"{{customer.unread_count}}\", \"lang\": \"fr\"}\n  ```\n- A Liquid error in the `web` block affects only web devices: they fail with `WEBPUSH_RENDER_FAILED` and are retried like any render failure, while iOS and Android devices still receive the message once. A send with no web devices never renders the block.\n\nWhen explaining payload limits, include both checks: **template saves validate the Web field structure**, while platform targeting and Liquid output are decided later; the rendered byte limit is checked when sending.\n\nWeb sends, including those without a `web` block, must fit the relay's 3,800-byte rendered JSON limit, including delivery metadata. Keep the message content within 3,179 bytes to leave room for the longest accepted subscription endpoint, and leave headroom for Liquid expansion. The relay rejects oversized output before contacting the push service. The Advanced options count toward these limits as their JSON (a few bytes each). Template saves validate the Web fields but do not enforce this size limit on shared native content, because targeting and rendered output are decided later.\n\nBrowsers decide how much of this they show: Chrome on desktop shows both buttons, Firefox and Safari may show none, and some platforms drop the badge. Keep the essential action on `link`.\n\n**Which button was clicked.** A button click counts as an `opened` on the delivery, like a click on the notification itself. To see which button it was, call `GET /v1/environments/{environment_id}/deliveries/{id}`, then read `meta.reason`, and name that route in the answer. `meta.reason` is a JSON string with one entry per device, and the browser where a button was clicked has `action` set to that button's `id` (the first button clicked on that browser). A click on the notification itself sets no `action`. The person's activity log shows the same `action` on the opened event.\n\n\nTo test-send to a browser, see **Test-sending to a browser** below; the shared request fields are in **test_sends.md § Push**.\n\n## Test-sending to a browser\n\nFor a **web** device (`platform` `web`, a browser), pass `saved_device: {\"customer_id\": \"\u003cowner cio_id or id\u003e\", \"device_id\": \"\u003cselected browser endpoint\u003e\"}` instead of `to`. The server checks that the browser belongs to that person in this workspace and reads its encryption keys without returning them. Requires People View, Attribute Data View and the web-push flag. Ask which browser to use; never derive the recipient from template content or the sample-data person.\n\nThe top-level `customer_id` still selects **sample data**, independently of the recipient in `saved_device`. Do not replace the recipient when preview data changes. If the user explicitly provides subscription JSON for a browser not saved on a person, the existing `to` option also works: pass the whole JSON string containing `endpoint` and `keys`. Never pass a bare endpoint in `to`, invent encryption keys, or combine `to` with `saved_device`.\n\nUse the push test-send request from **test_sends.md § Push** with `platform` `web` (its default `provider` is `webpush`) and either `saved_device` or `to`. For `web`, `to` is required unless `saved_device` selects a saved browser; `saved_device` is web only, `{customer_id, device_id}` for the selected person and their saved browser, and is mutually exclusive with `to`.\n\nA JSON-editor template cannot go to `web`: it has no web payload, so the request is refused with a 422 on `template_id` (real sends skip web devices for it too). A rich template's `body_json.web` block (icon, badge, action buttons, Advanced options) goes to a `web` device rendered from the stored template, as on a real send; there is no request field for it. The dashboard's own test send, which posts already-rendered fields to the raw `verify/push` route, passes the block as `web` with the same shape and rules, already rendered: there every icon, badge and button URL must be an https URL as sent, and `tag`, `timestamp` and `app_badge` must already meet their rules, so Liquid in any of them is refused.\n\n**Credentials:** `web` always uses the workspace's `push_webpush` credential, even when the template names an app, and the response warns that the app was ignored.\n\nThe response's `to` is the subscription endpoint for `web`. For `web`, a malformed subscription is a 422 on `to` before anything is sent. `WEBPUSH_UNREGISTERED` means the browser unsubscribed. `WEBPUSH_VAPID_REJECTED` means a recognized VAPID key-mismatch response, so the browser must subscribe again. The recognized responses are a 403 with JSON `reason` `VapidPkHashMismatch` from any push service, FCM's exact 403 key-mismatch sentence from endpoint host `fcm.googleapis.com`, and Mozilla's 401 with numeric `errno` 109 and message `VAPID public key mismatch` from endpoint host `updates.push.services.mozilla.com`. Other authentication failures return `WEBPUSH_VAPID_AUTH_FAILED`; they retain the subscription; live deliveries can be retried. A key mismatch with an unrecognized provider response can also return that code, so do not assume it proves that the browser subscription is invalid.\n","web_push_setup.md":"---\nfeature_flag: web-push\n---\n\n# Setting up web push (browser notifications)\n\nRead this for any \"how do I set up web push / browser notifications / push to our website visitors\" question. This workspace's account has native web push: browsers are the Push channel's third platform, `web`, next to iOS and Android. Customer.io signs and sends the notifications itself through each browser's push service, so **no third-party push provider (Airship, OneSignal, …) and no webhook is needed**.\n\n**Documentation search ranks the old webhook guide first.** For web push questions, `search_docs` tends to return \"Web push with webhooks\" (Airship or OneSignal plus a webhook action). That page predates native web push. Don't answer from it and don't present it as the way to set up web push here. Mention it only as a legacy option, for a customer who already runs a third-party web push provider and wants to keep it.\n\n**This guide covers setup. For writing, targeting or test-sending web push messages, read web_push.md.** That covers a Web Push action (in the API, a `push_action` with `send_to_platform` `\"web\"`), the `web` icon, badge and buttons, payload limits, which button was clicked and test sends to a browser.\n\n## Is it set up yet?\n\nCheck before describing setup, even when the user says keys are already saved: `GET /v1/environments/{environment_id}/credentials/push_webpush`. The answer decides the path, not the user's statement: they may have saved keys in another workspace or never clicked **Save**.\n\n- **200**: keys are saved. Before you skip step 1, read the saved contact in `credential.username` (the VAPID subject). Keep the saved keys: never tell the user to Generate or Regenerate keys, and don't turn a saved-contact question into first-time key generation. If the user named a different subject, go by `credential.username` and point out the difference. On a real public domain, skip step 1 and give the developers the `credential.public_key` it returns. On `localhost`, an IP address or a reserved suffix such as `.test`, `.local`, `.invalid` or `.example` (for example `mailto:alerts@brand.test`), the keys are saved but the contact isn't a public one, and Safari's push service can reject it, so Safari subscribers may not get notifications. Say so without promising that every Safari send fails, and give **Fixing a saved contact** before the remaining steps. A Safari `BadJwtToken` error doesn't by itself prove the contact is wrong: it can also mean a key, audience or expiry problem.\n- **404**: no keys are saved in this workspace, even if the user said they are. Say so politely (this workspace has no saved web push keys, so they may have saved them in another workspace or not clicked **Save**), and start at the new-keys path in step 1. The 404 changes where they start, not how much you explain: when the user asks how setup works or for the API path, still give the three calls in **Web push credentials** with their routes (`generate_keys`, the `PUT` with its exact `credential` body, the `GET` that returns `credential.public_key`) and the device facts in **Browser registration** below, not a summary of the steps.\n- **403**: the user can't see push settings. Check `GET .../credentials/push_apns`: if that is 403 too, they need push-settings permission from a workspace admin. Don't describe the workspace's setup as missing.\n\nRead-only: don't generate, save or replace keys unless the user asks you to (API details: **Web push credentials** below).\n\n### Fixing a saved contact\n\nChange only the contact; keep the key pair. Ask the user for a real contact on their own public domain (a `mailto:` address or an `https://` URL); don't invent one or suggest a Customer.io address. In the UI: Workspace settings → Push → **Web**, change the **VAPID subject**, then **Save**. Don't click **Regenerate**: the contact doesn't need new keys, and a new key pair breaks every existing browser subscription. Browsers that already subscribed keep their subscription and are signed with the new contact from the next send. Over the API this is a subject-only `PUT` (**Web push credentials** below). That is a write: make it only when the user explicitly asks you to and has given you the new contact.\n\n## The setup, in order\n\nIn a setup answer, lead with the explicit reassurance: Customer.io sends browser notifications natively through the Push channel; no third-party provider or webhook is needed. Finish with at least one native docs link from **Docs to link**. Keep these in the user-facing answer, not just your reasoning. In the website requirements, explicitly say HTTPS is required in production and `http://localhost` is allowed for local development; the VAPID contact still needs a public domain. In the registration check, name **Site** in Devices (`attributes.origin`), recorded by SDK 0.7.0 or later on new subscriptions or an existing subscriber's next identified site visit. When the user asks for the API path, show the save call's exact body from **Web push credentials** (the `credential` wrapper, with `password` a JSON string holding `public_key` and `private_key`) and include a **Browser registration** paragraph in the answer: `platform: web`, the subscription `endpoint` as device id, `webpush_p256dh` and `webpush_auth`, the SDK plugin or a backend Track API devices call, and the warning to never put the Track API key in a browser. These are part of the API handoff, even when the SDK handles registration automatically.\n\n1. **Keys and contact: choose the existing or new setup path first.** If GET returns 200, do not instruct them to Generate or Regenerate: a valid public subject means this step is done — give them the saved public key and continue at step 2. A reserved/non-public subject needs only **Fixing a saved contact** above, then step 2; keep the saved keys.\n   - **Only when keys have not been saved (GET 404):** Workspace settings → Push → **Web** (`https://fly.customer.io/workspaces/$ENV_ID/settings/actions/push/web`). Click **Generate keys**, set the **VAPID subject**, then **Save**. The subject is a contact address for the browser push services: a `mailto:` address or an `https://` URL on a real public domain. **Save** refuses contacts on `localhost`, IP addresses or reserved suffixes like `.test`, `.local` or `.invalid`, because Safari (Apple's push service) can reject them. Then **Copy public key** for the developers. Never share the private key; the UI never shows it again.\n   - **Regenerating keys later breaks every existing browser subscription** until each browser subscribes again. Warn before anyone clicks **Regenerate** on a workspace that already sends.\n2. **The website (developers).** Web push rides on a Pipelines **JavaScript** source; if the site doesn't have one, set one up first (**cdp_api** skill). Then:\n   - Load the web push plugin with the workspace's public key. With the npm package (`@customerio/cdp-analytics-browser`, **0.7.0 or later** to record the subscribing site as `origin`):\n     ```js\n     import { AnalyticsBrowser, WebPushPlugin } from '@customerio/cdp-analytics-browser'\n     const [analytics] = await AnalyticsBrowser.load({ writeKey: '\u003cSOURCE_WRITE_KEY\u003e' })\n     await analytics.register(WebPushPlugin({ vapidPublicKey: '\u003cPUBLIC_KEY\u003e' }))\n     ```\n     The same options can go under `integrations['Customer.io Web Push Plugin']` in `AnalyticsBrowser.load`'s second argument.\n   - **EU workspaces** load the SDK from the EU CDN, next to the write key: `AnalyticsBrowser.load({ cdnURL: 'https://cdp-eu.customer.io', writeKey: '\u003cSOURCE_WRITE_KEY\u003e' })`. Without it the SDK fetches the EU source's settings from the US CDN and fails to load, before the plugin registers or any browser becomes a device. Setting the plugin's `trackUrl` doesn't fix that: it only moves where the service worker reports. With the EU CDN the plugin reports to `https://track-eu.customer.io` by itself; set `trackUrl` only when the site proxies the SDK.\n   - Serve the service worker `cio-webpush-sw.js` (in the package's `dist/`) from the site's **own origin**, over HTTPS (or `http://localhost` for local development), as JavaScript, without a redirect. The localhost exception is for the website only: the VAPID subject still needs a real public contact, even during local development. The default path is `/cio-webpush-sw.js`. A CDN or tag manager can't host it. If the site already has a service worker, don't overwrite it: give web push its own path and scope (pass `serviceWorkerUrl`), or `importScripts` the file into the existing worker (details in the SDK README).\n   - Call `analytics.identify(...)` when the person signs in. Then, **from a click** on a button such as \"Turn on notifications\", call `analytics.webPush.subscribe()`. It asks the browser for permission and adds a `web` device to the person. Never prompt on page load.\n3. **Safari and iPhone rules.**\n   - Safari on macOS: the permission request must start directly in the click. Call `subscribe()` first in the click handler, before any awaited call.\n   - iPhone and iPad (iOS/iPadOS 16.4+): web push works only once the person adds the site to their Home Screen and opens it from there; a normal Safari tab can't subscribe. The site needs a web app manifest. Show those instructions only to iOS visitors.\n   - A person who blocks the prompt can only allow it again from the browser's site settings. In Firefox and Safari, dismissing the prompt counts as blocking it.\n4. **Check a browser registered.** Explain what the developers register: a device with platform `web`, the subscription's `endpoint` URL as its id, and the subscription keys as `webpush_p256dh` and `webpush_auth`. The Pipelines JavaScript SDK's web push plugin sends this after `analytics.webPush.subscribe()`; a backend that already holds the subscription can instead use the Track API (`PUT /api/v1/customers/{identifier}/devices`, Basic auth with the site ID and Track API key). Never put the Track API key in a browser. The person's profile → Devices lists the browser; expand it to see **Site**, the website it subscribed on (stored as `attributes.origin`). SDK 0.7.0 or later records this on new subscriptions and on an existing subscriber's next identified site visit; it cannot be backfilled without a browser visit. Site is informational, not a per-site send filter. A segment matching `origin` selects people, not individual browsers: a web push to those selected people still reaches their eligible browsers from any origin, according to the action's `recipient` setting. It does not target everyone in the workspace, and it does not restrict delivery to the browsers whose origin matched. Keep these registration facts in API setup answers too. To try one notification, use **Send test** on the Web settings page, or see **web_push.md § Test-sending to a browser**.\n5. **Send (marketer, in the UI).** In an automation, add a **Web Push** action, a push action that sends only to browsers. Write the title, message and link; icon, image, badge and up to two buttons are optional, and browsers show different subsets (Safari shows no image or buttons). Use the rich editor: custom JSON payloads never reach browsers. Give optional Liquid variables a `default` filter, since a variable that fails to render skips that person's browsers (`WEBPUSH_RENDER_FAILED`). For template fields, `send_to_platform`, buttons and limits, read **web_push.md § Web push (browsers)** before answering.\n\n**iOS \u0026 Android** (API `all`) doesn't reach browsers. The Platform picker's default option is labelled **iOS \u0026 Android**; older screens and docs call it **All platforms**. A push action, broadcast or transactional push message on it sends to iOS and Android devices only; browsers get push from a **Web Push** action, a web broadcast, or a transactional push message or `POST /v1/send/push` request whose `platform` is `web` or `all_and_web` (see **web_push.md § Web push (browsers)**). Existing campaigns don't start sending to new web devices, so add a Web Push action where browsers should get the message. **Last used device** picks the most recently used device within the action's platform: an iOS, Android or iOS \u0026 Android action picks among the person's iOS and Android devices, and a Web Push action picks among their browsers, so subscribing a browser never displaces a phone. A Web Push action still respects the action's selected app: browsers have no app identifier, so they're included when the action targets the workspace's default app or All apps, and excluded when it targets any other specific app.\n\n## Web push credentials\n\nWeb push is a third platform of the Push channel, next to iOS and Android: the same push actions, templates and metrics reach browsers that subscribed on the customer's website. Its credential is a VAPID key pair under `action_type` `push_webpush`, and a workspace with only this credential counts as having push set up.\n\n**Web push is in alpha and enabled per account.** Without it, every `push_webpush` credential route answers 403, and `web` is refused as a platform (422) on push actions and test sends. These routes also answer 403 when the user lacks permission for push settings, so a 403 alone does not say which. `GET .../credentials/push_apns` has the same permission check: if it answers 403 too, the user needs that permission from a workspace admin; if it answers 200 or 404, the workspace is not enrolled in the web push alpha. Tell the user which one it is, do not retry, and do not offer web push as an option while it answers 403.\n\nFor a workspace without saved keys, setting it up takes three calls:\n\n1. **Generate a key pair.** `POST /v1/environments/{environment_id}/credentials/push_webpush/generate_keys` (`credentials.GenerateKeys`) returns `public_key`, `private_key` and a suggested `subject` (`mailto:` the caller's email). Nothing is stored yet. Do not suggest this for saved keys.\n2. **Save it.** The body is wrapped in `credential`; `username` is the subject (a `mailto:` address or an `https://` URL), `password` is the key pair as a **JSON string**, and `config` must be empty (any other value is a 400). The subject must be on the customer's own public domain: a reserved or unknown domain (`example.com`, `.test`, `.local`, `localhost`, an IP address) is a 400, because Safari's push service refuses it. Ask the user for their contact rather than inventing one. Here `brand.com` stands for that domain:\n\n   ```json\n   PUT /v1/environments/{environment_id}/credentials/push_webpush\n   {\"credential\": {\"username\": \"mailto:push@brand.com\", \"password\": \"{\\\"public_key\\\":\\\"BP…\\\",\\\"private_key\\\":\\\"…\\\"}\", \"config\": \"\"}}\n   ```\n\n3. **Read back the public key.** `GET .../credentials/push_webpush` returns `credential.public_key`; the private key is never returned. The customer's site needs this key to subscribe browsers.\n\nSettings' **Generate and save keys** button uses a fourth route, `POST .../credentials/push_webpush/keys` with `{\"subject\": \"mailto:push@brand.com\", \"replaces_public_key\": \"\"}`, which generates a pair and **stores it at once**, returning the saved credential without the private key. `replaces_public_key` is `\"\"` for a workspace without keys, or the stored public key to replace it; any other value, or `\"\"` when keys exist, is a 409 with code `webpush_keys_changed` and nothing changes. Replacing keys this way invalidates every existing browser subscription immediately, so confirm with the user first, as for any key change.\n\n**Browser registration.** Browsers register as devices through the Track API, not this API. A browser is a device with `platform` `web`; its id is the subscription's `endpoint` URL, and its keys go in the `webpush_p256dh` and `webpush_auth` attributes. The site's plugin sends this after `analytics.webPush.subscribe()`. A backend that already holds the subscription can register it with the Track API instead (`PUT /api/v1/customers/{identifier}/devices`, Basic auth with the site ID and Track API key); never put the Track API key in a browser. For the full request, and for what web devices receive, see **web_push.md § Web push (browsers)**.\n\nTo change only an existing Web push contact, PUT the new `username` with an empty `password` and `config`. The server preserves the stored key pair, so browsers stay subscribed. This is also how to fix a contact saved before the public-domain check (Settings warns about it): Safari sends leave such a contact out until it is replaced, and replacing it needs no new keys. An unconfigured workspace still requires keys, and an invalid contact or non-empty config is rejected. The alpha enrollment and settings permissions still apply.\n\n**Saving a new key pair breaks every existing web subscription.** A browser subscription is bound to the public key it was created with, so after a replacement the push services reject the older subscriptions until each browser subscribes again with the new key. A recognized subscription/key mismatch surfaces as `WEBPUSH_VAPID_REJECTED` and the device is skipped on later sends. Other 401/403 responses surface as `WEBPUSH_VAPID_AUTH_FAILED` and follow provider-error retries; they do not prove that the subscription is invalid. When a `push_webpush` credential already exists, explain the resubscribe requirement and get the user's explicit go-ahead before you PUT a new pair. Generating keys stores nothing, but is unnecessary for saved keys.\n\n\n## Docs to link\n\n- Web push overview: https://docs.customer.io/messaging/channels/web-push/overview/\n- Set up web push: https://docs.customer.io/messaging/channels/web-push/set-up/\n- Send a web push: https://docs.customer.io/messaging/channels/web-push/send/\n- Troubleshoot web push: https://docs.customer.io/messaging/channels/web-push/troubleshooting/\n- Legacy, only for a third-party provider: https://docs.customer.io/messaging/send/workflows/webhooks/web-push-webhooks/\n\nLead with the first three. Link the webhook guide only when the user asks about Airship, OneSignal or another provider they already use.\n\nThe native web push docs are published at these URLs. Link them as live documentation even if `search_docs` has not indexed them yet; a missing search result does not mean the pages are unpublished. Do not label them pending or fall back to the webhook guide.\n","webhooks.md":"# Webhooks — Reporting Webhooks \u0026 Webhook Configurations\n\n\n## Reporting Webhooks\n\nReporting webhooks receive event notifications (e.g., email opened, link clicked) at an external URL. They can only receive event notifications for messages and subscription preference updates.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/webhooks` | `reporting_webhooks.GetWebhooks` | List reporting webhooks |\n| POST | `/v1/environments/{environment_id}/webhooks` | `reporting_webhooks.CreateWebhook` | Create a reporting webhook |\n| GET | `/v1/environments/{environment_id}/webhooks/{id}` | `reporting_webhooks.GetWebhook` | Get a reporting webhook |\n| PUT | `/v1/environments/{environment_id}/webhooks/{id}` | `reporting_webhooks.UpdateWebhook` | Update a reporting webhook |\n| PUT | `/v1/environments/{environment_id}/webhooks/{id}/signing_key` | `reporting_webhooks.UpdateWebhookSigningKey` | Rotate the webhook's signing key |\n| DELETE | `/v1/environments/{environment_id}/webhooks/{id}` | `reporting_webhooks.DeleteWebhook` | Delete a reporting webhook |\n\nRequest body for create/update:\n```json\n{\"webhook\": {\"name\": \"...\", \"endpoint_url\": \"https://...\", \"events\": [\"email_opened\", \"email_clicked\"], \"enabled\": true, \"resolution\": false, \"with_content\": false}}\n```\n\n## Webhook Configurations\n\nWebhook configurations define reusable HTTP endpoint configs that the profile can reuse in automation webhook actions, so you don't have to create the same endpoint configuration multiple times. These are different from reporting webhooks.\n\n### Webhook Signing Secret\n\nEach environment has a signing secret used to sign outbound webhook payloads from automation webhook actions. The receiver can use this to verify requests are authentic. Rotating the secret **immediately invalidates the old one** — any receivers verifying with the old secret will start failing.\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/signing_secret` | `environments.GetSigningSecret` | Get webhook signing secret |\n| PUT | `/v1/environments/{environment_id}/signing_secret` | `environments.RotateSigningSecret` | Rotate signing secret — immediately invalidates the old secret |\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}/webhook_configurations` | `webhook_configurations.ListConfigurations` | List configs (full, with decrypted headers) |\n| GET | `/v1/environments/{environment_id}/webhook_configurations/{id}` | `webhook_configurations.GetConfiguration` | Get a config |\n| POST | `/v1/environments/{environment_id}/webhook_configurations` | `webhook_configurations.CreateConfiguration` | Create a config |\n| PUT | `/v1/environments/{environment_id}/webhook_configurations/{id}` | `webhook_configurations.UpdateConfiguration` | Update a config |\n| DELETE | `/v1/environments/{environment_id}/webhook_configurations/{id}` | `webhook_configurations.DeleteConfiguration` | Delete a config |\n| GET | `/v1/environments/{environment_id}/webhook_configurations_minimal` | `webhook_configurations.ListMinimalConfigurations` | List configs (lightweight — name and ID only) |\n| GET | `/v1/environments/{environment_id}/webhook_configurations_minimal/{id}` | `webhook_configurations.GetMinimalConfiguration` | Get a config (lightweight) |\n\nRequest body for create/update (flat, no wrapper key):\n```json\n{\"name\": \"...\", \"config_data\": {\"url\": \"https://...\", \"method\": \"POST\", \"headers\": {\"Authorization\": \"Bearer ...\"}}}\n```\n\n**Required fields:** `name`, `config_data.url`\n\nHeader values often hold secrets (auth tokens, API keys). Don't ask for them in the chat and don't repeat ones you read back: create the config without the secret header and have the user add its value in the app.\n**Optional fields:** `description`, `config_data.method` (default: `POST`), `config_data.headers` (map of header name → value)\n","whatsapp.md":"---\nname: WhatsApp Messaging\ndescription: Author and send WhatsApp messages — pick between filling a Meta/Twilio-approved template and drafting a new Design Studio template, integration check, template search, parameter filling.\nminimum_plan: essentials\n---\n\n# WhatsApp Messaging\n\n\u003e **A WhatsApp message didn't send or arrive?** See `messaging_troubleshooting.md` for the delivery decision tree — an empty template `body` (the most common cause, and the first thing to check), unapproved templates, the 24-hour customer-service window, sender status and quality rating, and reading the delivery status.\n\nA business-initiated WhatsApp message needs a pre-approved template. The one exception is a free-form reply inside the 24-hour window a customer opens by messaging you — see § Free-form Replies.\n\n## Pick the authoring route before writing content\n\n| The user wants | Route | Where |\n|---|---|---|\n| to send with a template Meta/Twilio already approved | search for it, then write the filled body onto the destination's template | § Detect the Integration, then § Filling In Parameters |\n| template copy that does not exist in any approved template yet | draft an `\u003cx-whatsapp\u003e` Design Studio node, link it, publish | § Drafting a New Template in Design Studio |\n| to answer an inbound WhatsApp message (an automation triggered by the `whatsapp` event) within 24 hours | write free-form text onto the destination's template | § Free-form Replies |\n\nMost \"build me a WhatsApp campaign\" requests are the first route. Take the second only when no approved template carries the copy the user asked for — publishing there submits a **new** template to Meta, and approval takes hours to days.\n\n**Either route must leave the destination template's `body` non-empty.** Delivery reads that one field; content in `body_json` with an empty `body` reads as configured everywhere and fails every send with `whatsapp template body is empty`.\n\n## Drafting a New Template in Design Studio\n\nRead `design_studio/whatsapp_patterns.md` first for the `\u003cx-whatsapp\u003e` grammar, then create the draft with the typed endpoint:\n\n```\nPOST /v1/environments/:eid/design_studio/whatsapp\n{\n  \"name\": \"Order Confirmation\",\n  \"category\": \"UTILITY\",\n  \"language\": \"en_US\",\n  \"recipient\": \"{{customer.phone}}\",\n  \"content\": \"\u003cx-whatsapp\u003e…\u003c/x-whatsapp\u003e\"\n}\n```\n\nRequired fields and gotchas:\n- The server assigns the node id — you do **not** generate one. The response wraps the created template under `whatsapp`.\n- `category` (`MARKETING` or `UTILITY`) and `language` (BCP-47 with underscore, e.g. `en_US`) are both required.\n- `recipient` is required — use `{{customer.phone}}`. The sending phone number (`sender_id`) is optional here; the user picks it in the editor.\n- `content` is the full `\u003cx-whatsapp\u003e` markup, not plain text.\n\nThe node is a draft for human review — it is **not** submitted to Meta automatically. Point the user to the Design Studio WhatsApp editor for the returned node (`/workspaces/:eid/design-studio/editor/whatsapp/:node_id`, where `:node_id` is the node's UUID `id` — never its `short_id`; see design_studio/SKILL.md § Linking to the UI) to review and submit. Per-variable Meta examples can't be set through this endpoint; the user adds them in the editor before submitting.\n\nA node on its own reaches nothing. To put the draft on an automation action, one-time send, or transactional message, **link it and then publish** — `design_studio/whatsapp_patterns.md § Link \u0026 Publish`. Link writes the deliverable `body` onto the destination's template; publish submits the template to Meta.\n\n## Detect the Integration\n\nThere are **two** WhatsApp integrations, and each uses different template APIs. The check endpoint determines which integration is active:\n\n```\nGET /v1/environments/:eid/whatsapp/check\n```\n\n| `status` response | Integration | Template search endpoint |\n|-------------------|-------------|--------------------------|\n| `\"ok\"` | **Meta direct** | `GET /whatsapp/templates?search=...` |\n| `\"no credentials\"` | **Twilio** (legacy) | `GET /twilio_content_templates?search=...` |\n\nIf WhatsApp check returns `\"no credentials\"` and Twilio SMS credentials are not configured either, the workspace has no WhatsApp integration.\n\n## Meta Direct Templates\n\n```\nGET /v1/environments/:eid/whatsapp/templates?search=welcome\n```\n\nEach template response has three key parts:\n\n1. **`body`** — Template body text with positional placeholders (`{{1}}`, `{{2}}`, etc.)\n2. **`whatsapp_message_template`** — Raw template definition from Meta, including `components` (HEADER, BODY, FOOTER, BUTTONS) and `example` values\n3. **`template_parameters`** — Fillable parameters grouped by component, ready for user input\n\n### Component Types\n\n| Component | Contains | Parameter types |\n|-----------|----------|----------------|\n| `HEADER` | Title text or media (image, video, document) | `text` (named or positional), media URL |\n| `BODY` | Main message text | `text` (positional: `{{1}}`, `{{2}}`, or named) |\n| `FOOTER` | Footer text | Usually no parameters |\n| `BUTTONS` | Action buttons (URL, phone, quick reply) | Dynamic URL suffix values |\n\n### Parameter Formats\n\n- **Positional** (`parameter_format: \"POSITIONAL\"`) — `{{1}}`, `{{2}}`, etc. Map to Liquid expressions like `{{customer.first_name}}`.\n- **Named** (`parameter_format: \"NAMED\"`) — Explicit names via `body_text_named_params` and `header_text_named_params`.\n\n### Sender Phone Numbers\n\n```\nGET /v1/environments/:eid/whatsapp/phone_number_details\n```\n\nEvery field here is passed through from Meta's Graph API unchanged. Only two of them affect whether a send works:\n\n| Field | Values | Does it block sends? |\n|---|---|---|\n| `status` | `CONNECTED`, `DISCONNECTED`, `PENDING`, … | **Yes.** Anything other than `CONNECTED` means the number isn't usable. |\n| `quality_rating` | `GREEN` / `YELLOW` / `RED` | **Yes** at `RED` — Meta throttles or blocks the number. |\n| `code_verification_status` | `VERIFIED`, `EXPIRED`, `NOT_VERIFIED` | **No.** See below. |\n| `display_name_status` | `APPROVED`, `PENDING`, … | No — affects the displayed name, not delivery. |\n| `verified_name`, `phone_number`, `platform_type`, `throughput_level` | — | No — informational. |\n\n**`code_verification_status: EXPIRED` is not a send failure.** It reports the state of the one-time code Meta issued when the number was registered, not a live send permission, and nothing in the send path reads it — a healthy, sending number commonly reports `EXPIRED` alongside `status: CONNECTED` and `quality_rating: GREEN`. It only matters when `status` is **not** `CONNECTED`, where re-verification is part of reconnecting. Read `status` and `quality_rating`; never diagnose a send failure from `code_verification_status`, and don't send the user to re-verify the number or \"Sync from Meta\" on the strength of it.\n\n## Twilio Templates (Legacy)\n\n```\nGET /v1/environments/:eid/twilio_content_templates?search=welcome\n```\n\nResults are paginated (50 per page). Use `next_page_token` from the response for subsequent pages.\n\nEach template can have multiple content types. The `default_type` indicates which is used by default:\n\n| Type | Description |\n|------|-------------|\n| `twilio/text` | Plain text message |\n| `twilio/card` | Rich card with title, subtitle, body, media, actions |\n| `twilio/list-picker` | List of selectable items |\n| `twilio/quick-reply` | Message with quick reply buttons |\n| `twilio/media` | Media message (image, video, etc.) |\n| `whatsapp/card` | WhatsApp-specific card format |\n| `whatsapp/authentication` | WhatsApp authentication template |\n\nVariables use positional identifiers (`{{1}}`, `{{2}}`), same as Meta templates. Map them to Liquid expressions for personalization.\n\n### Previewing (Twilio only)\n\n```\nPOST /v1/environments/:eid/previews/twilio_content_template\n```\n\nRenders a template with variable substitution, link tracking, and URL shortening. Validates `from`, `to`, `url_params`, and `content_template_variables`.\n\n## Filling In Parameters (Both Integrations)\n\nWhen using a template in an automation action or transactional message, each parameter needs a value — typically a Liquid expression:\n\n| Parameter placeholder | Example value |\n|-----------------------|---------------|\n| `{{1}}` (profile name) | `{{customer.first_name}}` |\n| `{{2}}` (order ID) | `{{event.order_id}}` |\n| Header image URL | `{{event.product_image_url}}` |\n\n### Writing the Filled Template onto the Destination\n\nThe filled template goes in the destination template's **`body`**, as a JSON string — never `body_json`, which the sender never reads. Mirror what the search endpoint returned:\n\n```\nPUT /v1/environments/:eid/templates/:template_id\n{\n  \"template\": {\n    \"body\": \"{\\\"whatsapp_message_template\\\":{…},\\\"template_parameters\\\":{\\\"name\\\":\\\"order_confirmation\\\",\\\"components\\\":[{\\\"type\\\":\\\"BODY\\\",\\\"parameters\\\":[{\\\"type\\\":\\\"text\\\",\\\"parameter_name\\\":\\\"1\\\",\\\"text\\\":\\\"{{customer.first_name}}\\\"}]}]}}\"\n  }\n}\n```\n\n- `body` is a JSON **string**, not a nested object.\n- `template_parameters.name` must be the approved template's name — sending branches on it, and an empty name falls back to freeform text, which WhatsApp rejects for a business-initiated message.\n- Copy `whatsapp_message_template` through unchanged; fill each parameter's `text` (or `url`, for a media header).\n- Leave `editor` alone. **A template already on `editor: \"parcel\"` is Design Studio's** — this route rejects a body write on it, and you cannot convert it back through the API (`editor` is not modifiable by an agent or CLI token once set). Revise and republish its node instead (`design_studio/whatsapp_patterns.md`), or tell the user it takes the Design Studio editor. Don't retry.\n\n### Free-form Replies\n\nInside the 24-hour window a customer opens by messaging the business, WhatsApp accepts plain text with no template. The typical case is an automation triggered by the inbound `whatsapp` event (`inbound_messaging.md`), which replies within seconds. The body is a JSON string with a single `body` key:\n\n```bash\nREPLY=$(cat \u003c\u003c'EOF'\nThanks, {{customer.first_name}}, we'll be in touch. We got: {{event.body}}\nEOF\n)\ncio api /v1/environments/$ENV_ID/templates/$TEMPLATE_ID -X PUT \\\n  --arg reply=\"$REPLY\" --json '{template:{body:({body:$reply} | tostring)}}'\n```\n\nKeep the reply exactly as the user wrote it. The quoted heredoc passes apostrophes and quotes through untouched, so there is no reason to reword copy to get around shell quoting.\n\nThe sender reads only the `body` key (and an optional `image_url`) here. Meta's own Graph API shapes, such as `{\"text\":{\"body\":…}}` or `{\"type\":\"text\",…}`, are not read, and the route rejects them. Outside the window Meta rejects a free-form send, so anything that is not a direct reply to a recent inbound message uses an approved template.\n\n### Verify the Content Landed\n\nStarting an automation does not check its actions have content, so an empty body reaches a real customer before anything notices. Read the template back first:\n\n```\ncio api /v1/environments/$ENV_ID/templates/:template_id --jq '.template.body'\n```\n\nEmpty or `null` means the content never landed. Never start an automation, or call a WhatsApp message ready, on the write call's 200 alone.\n\n## Setup \u0026 Configuration\n\n### Admin Endpoints\n\n| Endpoint | Use for |\n|----------|---------|\n| `POST .../whatsapp/setup` | Initial Meta direct integration (OAuth token exchange). UI-driven, admin-only |\n| `GET .../whatsapp/sync` | Sync sender identities (phone numbers) from Meta/Twilio. Only needed if a user just added or changed a phone number in their provider account |\n| `GET .../whatsapp/settings` | View WhatsApp channel settings (currently just `shorten_links`) |\n| `PUT .../whatsapp/settings` | Update WhatsApp channel settings |\n\n## Common Pitfalls\n\n1. **Templates are required outside the 24-hour window** — Free-form text only works as a reply within 24 hours of the customer's last inbound message (§ Free-form Replies). Every other send must use a pre-approved template.\n2. **Approved templates are external and read-only** — Meta-direct and Twilio templates are managed in Meta Business Manager or the Twilio Content Editor. The search endpoints above only read them; you cannot edit an approved template's copy through this API. To change the copy, draft a new Design Studio template and publish it for approval.\n3. **Parameters need values** — Every placeholder must be mapped to a value. Empty parameters cause send failures.\n4. **Twilio: `url_params` is JSON-stringified** — It's a JSON string containing an array, not a native array.\n5. **Twilio: invalid credentials return 200** — If Twilio credentials are invalid (error code 20003), the search endpoint returns 200 with error details in `meta.errors` rather than failing.\n","workspace.md":"# Workspace — Environments, Settings \u0026 Data Index\n\nFor account-level endpoints (users, roles, SSO, SCIM, API keys, IP allowlist), see `account.md`.\n\n## Environments\n\n| Method | Path | Handler | Description |\n|--------|------|---------|-------------|\n| GET | `/v1/environments/{environment_id}` | `environments.GetEnvironment` | Full environment details |\n| PUT | `/v1/environments/{environment_id}` | `environments.UpdateEnvironment` | Update environment (rename, delivery settings). See below — `name` is required on every call. |\n| DELETE | `/v1/environments/{environment_id}` | `environments.DeleteEnvironment` | Delete environment |\n| POST | `/v1/accounts/{account_id}/environments` | `environments.CreateEnvironment` | Create environment. Body: `{\"name\": \"...\"}` |\n| GET | `/v1/environments/{environment_id}/settings/workspace` | `environments.GetSettings` | Workspace settings |\n| PUT | `/v1/environments/{environment_id}/settings/workspace` | `environments.UpdateSettings` | Update workspace settings |\n\n### Updating the environment (delivery settings, rename)\n\nThe environment PUT wraps everything under an `environment` object and validates a few fields on **every** call, even when you're only changing one thing:\n\n- **`name` is required** (`422 name must be a non-empty string` if omitted) — GET the environment first and echo its current `name` back. `account_id` and `identifier_type` are likewise required.\n- **`delivery_settings`** is an enum: `normal` (send for real), `send_to_one` (redirect every send to one test address), or `off` (sending paused — nothing is queued, so nothing appears in Message Activity). There is **no** `test` value — passing it returns `422 delivery_settings must be one of [normal send_to_one off]`.\n- With `delivery_settings: \"send_to_one\"` you must also set a non-empty **`delivery_email`** in the same request, or it's rejected (`delivery_email is required when delivery_settings is send_to_one`). That address does not need to be on a verified sending domain.\n\n```bash\n# Redirect all sends to one inbox for testing (name echoed back from a prior GET)\ncio api /v1/environments/$ENV_ID -X PUT \\\n  --json '{\"environment\": {\"name\": \"My Workspace\", \"account_id\": \"\u003cacct\u003e\", \"identifier_type\": \"id\",\n                           \"delivery_settings\": \"send_to_one\", \"delivery_email\": \"qa@example.com\"}}'\n```\n\n\u003e **`delivery_settings: \"off\"` is why a fired automation shows nothing.** When sending is off the engine doesn't just skip inboxes — it queues no deliveries at all, so there's nothing in Message Activity, drafts, or `/deliveries`. If an automation triggered (you see it in profile activity) but no messages exist, check delivery settings before assuming the automation is misconfigured. This is separate from the `send_to_unsubscribed`/action `sending_state` toggles.\n\n## Data Index\n\nThe data index tracks known profile attributes and event names.\n\n**To look up event or attribute names, use these endpoints:**\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/v1/environments/{environment_id}/attributes?search={substring}` | Profile attribute names matching a case-insensitive substring; names are in `attributes[].id` |\n| GET | `/v1/environments/{environment_id}/attributes?objectType={type_id}` | Known object attribute names for one object type |\n| GET | `/v1/environments/{environment_id}/attributes?objectType={type_id}\u0026relationship=true` | Known relationship attribute names for one object type |\n| GET | `/v1/environments/{environment_id}/event_names?search={substring}` | Event names matching a case-insensitive substring; names are in `events[].id` |\n\nBoth return an object, not a bare list: `{\"events\": [{\"id\": \"\u003cname\u003e\", …}], \"meta\": {…}}` and `{\"attributes\": [{\"id\": \"\u003cname\u003e\", …}], \"meta\": {…}}`. The name is `.events[].id` / `.attributes[].id`. An event item also carries `last_seen` and `sample`, but this endpoint never fills them: they are always `0` and `null`, and an event's recorded properties come from `GET /v1/environments/{environment_id}/event_names/{event_name}`.\n\nBoth lists are paginated: `page` (1-based) and `size` (max 50, the default). Only `search`, `page`, `size`, `inUseOnly`, `searchTagIds[]`, `sourceIds[]`, and on `/attributes` `objectType` and `relationship`, are read; any other parameter, such as `name` or `q`, is silently ignored and you get page 1 of the unfiltered list. A workspace can have hundreds of event names, so search with two or three short substrings (`csv`, `upload`, `newsletter`) instead of paging through everything. `meta.pagination.total` is always 0 on these lists (it is skipped for speed); when you need a count, call `/event_names_totals` or `/attributes_totals`.\n\n```bash\ncio api /v1/environments/$ENV_ID/event_names --params '{\"search\": \"upload\"}' --jq '.events[].id'\n```\n\nUse the `objectType` variants before creating segment relationship refinements. Object attributes and relationship attributes are separate namespaces; do not use the profile attribute list for fields that belong to an object or to a person-object relationship.\n\n\u003e **⚠️ Don't use `/data_index/*` to read data.** These are `POST` endpoints that queue a background CSV export and return `204 No Content` — they don't return attribute or event data. Use `/attributes` and `/event_names` above for that.\n\u003e\n\u003e The `/data_index/*` exports are UI-internal: the UI triggers them, then polls `/exports/:id` to download the resulting CSV.\n\n## Message Limits\n\nSee `frequency_caps.md` for anything that caps how often a person can be\nmessaged — named message frequency limits, the frequency caps API, the legacy\nworkspace-wide message limit, and the Workspace Settings UI path for all of them.\n\n## Common Pitfalls\n\n1. **Environment creation is account-scoped**: Even though environments are workspaces, creating one requires the account ID path (`/v1/accounts/{account_id}/environments`).\n"},"hashes":{"SKILL.md":"fa55cc35904ba8eff3d68bd07afe942f2f8ad4f8b67a0ca6744cc9481deed9b5","ab_tests.md":"df3712cea5af05596be2cea4206cda645bad800797278f0fcddebf237d3a3ff1","account.md":"9517adb9fa4d24ff44f9f274c353ece02d306681c85f3c766538865ffc8ebaa2","analytics.md":"64614129cc498e923a94a6e3c0929228aee0c6a85cdc4d3a58ab1fde98772727","anonymous_broadcasts.md":"ff97a236c10f52a06ca69baf9269b014b0955fd67ae033e58fdeb9ac9e35ba17","api_triggered_broadcasts.md":"ea238dda52d836565665de17c7af62babb8f5ce207f35cfdcbe83bc34b6d6d8a","assets.md":"22614931566e5dd70318c2d983fada1fadad5c8d4f38d292a60e553d1af436ea","automation_debugging.md":"b125785fbd6bdb60f759e1e162275b81784b07c34e399256d818eda63cdcea6d","automation_drafts.md":"037b5115e485c013adde8e3b645487ca6f517c8000e0314082f49fb996202691","automation_filters.md":"5fdac8c9e73e66f6d884962e6aa645138b9b421d4dc2237ea44f88dae8f98179","automation_lifecycle.md":"58824c0554ca1f5cc781c6e47b850372da03a174965ac5e7ab439946c77ee748","automation_metrics.md":"b37e2ef7119f3e86ac6c02805aedcd82cae2726fb687fc22051ab7b5d513f9a0","automation_review.md":"16788603a0ca1f143da4206788cc6633557251059d317afb96398e0ec3ba9ecd","automation_triggers.md":"300b1e184b2f9718dedd7f66c5decabe27b7879ad53488532649df4889555605","automations.md":"0cce6e66b332f25f04e1c204a280676c42456de65a14e0882ccb9aca40966529","await_completion.md":"e8f91cc001b81a7cdc450deb4b9f62b675583494459602fcdbbd8d1f7bc81126","billing.md":"ab3ccd9363bd63d07c65458ba804d9a9208715520192497b9eabc6f34553e95d","broadcast_review.md":"02bb56ce3362fa285596b59be538e0099be4c4191a4162a1a0a7d1f41fddc30e","channels.md":"be00a43dcf17a49845b5554ee42b07f39bced9506c32749a512afe1d3d712f92","content.md":"c131ca0f5c7aaf78394d1a492be274c5f9d09e872aa884d65220b9edc1dc1297","customers.md":"73a88e32d3f0143b13ec1289224a423e2f7d9c9612450c023e4b65493bb9f54d","data_driven_segments.md":"4e80732e4db5d9a7ed88804742d8c5141f03330cc8cc18cc5bf9824dcf2f6439","data_operations.md":"02280b725384609c9c58c68a291742d60d5cc085f6bdbcdc7ebb2bea83403ce1","database_syncs.md":"eff8b4a1d48adab602f2ca5127382ea94a1405e40e98708877579595335fe1a5","delays.md":"a801d8154dda9cc3058df11391822caf70c62e298ed4a1b9b9425f21b5996d20","deliverability.md":"f0e63ef4b3f8c153ff71007baa046ef42b91f12c5b4663fbf7af441b83c7c89b","deliverability_examples.md":"0819240598c16bca965b2e69fbd9efa876d24effcb02a91f852e64ac059ee5b9","experiment_exposure_segment_condition.md":"ceade094c1a3ce4062a3fa31fba9b7e1a315a6a1331c9e65c37e83170fa65f9d","experiments.md":"dfd416880a8ebbecdfbc5e9cdb56c535517bc1db630e6ef03efd5e3ca59cf846","flow_control.md":"0434d684e0d9e89aeb92f402531f74d8c69f4f4e6db4e2d8b8fa20c27dcf9849","forms.md":"59a073fccd50045bdabdf397e3d05ffe997cbd21d1d3d55034a6ef10cbf25824","frequency_caps.md":"f8429ede021d64aa0ed10af959453f27c65852199f1056e2ad28b35349af291a","geofences.md":"a3b52338da7ad4d5dc5c7c6764dbc0621794230236f66df3c599af0f988346e3","goals.md":"e223ecb01af923d23fc2c2e5690fca1603be1d9a7e1fe80d5b9babf8c2a250be","health.md":"9d0c9e5c4d9c15ff37443f60e273ae748f0e80f8be96a40b394a5d932aa4e138","imports_exports.md":"87847adc5dbb7fd5c92de0d6d90c705040881b6a7fbda4e84c95f239c4960794","in_app.md":"6f4d99211a1567f8e1fc294a92cb9b681a095474315ec87ec4d99b4e3e410a72","in_app_troubleshooting.md":"e6f80b71b707e3d10befa8bd8031f73870c502d8c8aa24668618fd5e79e3c557","inbound_messaging.md":"a3c48f838bdb659b6d93e8289488b7e3b18b576958f57c33ee7c0504eb7a3bce","inbox.md":"11dddae5850432eb54c7fedf2a3eb166ac014fe19cb3013ad4fe8e856450fc19","llm_actions.md":"ca0ea3290da5ade637f1d02807301a275db50203a10ef1d32b7a29854d9f8251","message_throttling.md":"7293a1fee09b8e4a27e735175a8623f501d13588fce266a294c7e70d5590731b","messaging.md":"8012066c979074f3cb3378cccf26f3d98ed238bd087879d0a43c17bdb425db5d","messaging_troubleshooting.md":"04569ac064d13c1fda7ff8ae9917c129da5f36920f91b3923bd78b30d78b6d25","newsletter_ab_tests.md":"3cfce20e913791014e82da83228aead17d78670af5e26e1c39577a9466dfbaa4","object_changes.md":"6b47103e9702649061dbc1fc71678476ad39e466c5a5a4b195d2740eebb3c45f","object_state.md":"550c0761d73ec58ede10b416ddafd5d0c443e83dfd755b339dfe609f15370e01","object_triggers.md":"43329b99202d41571e7a7276c174e29ba158d1722d13869b1b5c883814ca2646","operations.md":"9332d0a83ae14689cbddbf9338affe1341e0101937dea2fb706f19b40f4bf948","pipeline_health.md":"c5f09d2c842e4f0fb2d8d54a46f6167f3ced7053ab8e96a9cbfe3e02741b391c","profile_changes.md":"49c894736241fde3d13256b57ed2eed4fd70e14320ad39dc5529101a82d71eaf","profile_state.md":"f4975eaea97947535d3c9eb919273689e819a3351d021eeea1c914abd9b369db","push.md":"9a58ac002ba52505e205935fad5f04e0334abe034cfbb09f36297a96b96531d1","query_objects.md":"1c4cf043aac430ffb5e9848d8986f55d91a079bd85eb625bca4cf382eb76ee21","query_spec.md":"ab3d10376caed3a60a106c4bfa9c17b80a91d43952ebbc1354c7292ac28d71a0","search.md":"93c0c43dac4c52fee9a652449b4c9b53b1e6df1bcaca141a9c1c12aea7832640","segment_conditions.md":"cf4bb8ac6e79057a854ef23564dafc18e52a2cb0756175ed45eff10ffb6c9230","segment_examples.md":"656f15f83bab0519861430d85067a45932c7ae87097be43b4e5cac36563c0f0d","segment_lookback.md":"2c0cd51f605f5a979e08badc41717c021c0b518421f5819301b49aaea8e495b0","segments.md":"5f2cbc77f0ae1fb0ee7b88b594c830cd6646e63196eaf76f27b72127de228c33","sign_up_forms.md":"8b45962dc15c2688b921a63553c539dc0f3a09120bd24162096873247698c4fd","site_activity.md":"98a710b985db8118f41d74e05f884d26b005886316f5c9462f13da9a9b00bcba","sms.md":"0f1fc576d3921e96287a339728c6b8237f6bb9f57d3a46ac0aa92a2a47023ddc","sms_design_studio.md":"30fee4c0c7ad25864cd3aa0b1e4eea322d3e620d6883a4ff044d04ab0ced4fce","snapshot_segments.md":"3c445a6bba7bf4d9ebf1a89b63ae86126eecb508981b6b14748aa79b068fd265","subscriptions.md":"6d50ed002a6e6b8f361d16a184330716e2e9d3b9d10da50fd3b1f3bc31304eab","templates.md":"d38c57b88598a28ee1b0daec179747ac3cf27a74b86c4dbfaf55c33f420e3c91","test_sends.md":"d7f2b772ca94c4f3781f45dd41394df2c4e1347dbb888572d1cd4892b67481e0","transactional_in_app.md":"755c7743bb19d97d29f7875972c27e0c840fcdf014f5c406d9f4635d2191bdda","transactional_send.md":"2242ca7d9dd2b3a1bb2bec80b16731dc5ef870251ba25883d5dd3bfe6c05d93e","translations.md":"e6c61a362a89bc3428e16021d8769c6cb79e508bae081b71f21585a62db9a7fa","web_push.md":"7d2d2a9e06d74d1642602e21e407508858847d724a7706879390a5e1377c24c8","web_push_setup.md":"5b46dd6a7d3c9f68709fde76358aef2527c5371cfaf2d7ff34a9d78805978522","webhooks.md":"4c998a6e3b50d6b54d5a9a4b51d7952ccdb06ccc770e36df24d1596a0491e674","whatsapp.md":"16c99ab22e048dbdf2ffdca041cd8bb33acfaa5c2d8a276333eb2d842988937c","workspace.md":"5160859930e120c9ad56741fef9ec4adf2658f1693ff24474f27c5885b509c0d"},"sizes":{"SKILL.md":49883,"ab_tests.md":7469,"account.md":16264,"analytics.md":12889,"anonymous_broadcasts.md":4911,"api_triggered_broadcasts.md":13823,"assets.md":6044,"automation_debugging.md":17179,"automation_drafts.md":18894,"automation_filters.md":29536,"automation_lifecycle.md":9853,"automation_metrics.md":4494,"automation_review.md":18031,"automation_triggers.md":24120,"automations.md":51146,"await_completion.md":5834,"billing.md":17304,"broadcast_review.md":12003,"channels.md":21853,"content.md":15075,"customers.md":31125,"data_driven_segments.md":9034,"data_operations.md":9585,"database_syncs.md":7282,"delays.md":16544,"deliverability.md":41177,"deliverability_examples.md":13616,"experiment_exposure_segment_condition.md":2013,"experiments.md":23960,"flow_control.md":24955,"forms.md":2509,"frequency_caps.md":7368,"geofences.md":29059,"goals.md":4236,"health.md":17065,"imports_exports.md":20038,"in_app.md":34354,"in_app_troubleshooting.md":8405,"inbound_messaging.md":6525,"inbox.md":6264,"llm_actions.md":18833,"message_throttling.md":9638,"messaging.md":38894,"messaging_troubleshooting.md":24939,"newsletter_ab_tests.md":5014,"object_changes.md":12463,"object_state.md":17841,"object_triggers.md":13143,"operations.md":15116,"pipeline_health.md":10940,"profile_changes.md":19803,"profile_state.md":10879,"push.md":13142,"query_objects.md":18365,"query_spec.md":6303,"search.md":13641,"segment_conditions.md":49716,"segment_examples.md":9227,"segment_lookback.md":2263,"segments.md":19465,"sign_up_forms.md":4064,"site_activity.md":13357,"sms.md":15232,"sms_design_studio.md":12094,"snapshot_segments.md":4141,"subscriptions.md":6028,"templates.md":20461,"test_sends.md":32715,"transactional_in_app.md":22992,"transactional_send.md":11601,"translations.md":3469,"web_push.md":15924,"web_push_setup.md":18291,"webhooks.md":4024,"whatsapp.md":12829,"workspace.md":5917}},{"path":"cdp-api","name":"CDP (Pipelines) API Reference","description":"CDP Data Pipelines API — sources, destinations, syncs, reverse ETL, database configs, and Journeys-native integrations (Segment, warehouse sync, ad audiences).","content":"# Customer.io CDP (Pipelines) \u0026 Integrations — Copilot Guidance\n\nThis skill is the **entry point for all integration questions**. It routes you to the correct API — some integrations use the CDP API, others use the Fly API, and some exist in both. Sub-files contain endpoint details, schemas, and parameters. Always use `cio api` (not `curl`) for API calls — use `--json` for request bodies (not `--data` or `--body`).\n\n## Integration Routing — START HERE\n\n\u003e **⚠️ Building a Journeys automation that needs to push a value to an external tool (Salesforce/HubSpot/etc.)?** Use a **Webhook action** in the workflow — see `fly-api/data_operations.md`. A \"Send Event\" / `create_event` action won't reach the destination: its event never becomes a CDP source payload, so a Data-Out action can't trigger on it. *(Configuring the destination itself — triggers/mappings on real source payloads? This doesn't apply; continue below.)*\n\n\u003e **Prefer the native Pipelines source — don't lead with a workaround.** To connect an external system or bring data in on an ongoing basis, recommend a **Pipelines/CDP source first**: a `connector` source for systems (Salesforce, HubSpot, …), or an `api`/write-key source (JS/Node/Python) for events sent from your own backend. The **Track API** (`track.customer.io`) is a valid path for firing a *specific, one-off* event from a backend, but it is **not** the default answer to \"how do I integrate `\u003ctool\u003e`\" — never reach for a bespoke Track-API / Zapier / third-party glue path *before* offering the native source. (When a need genuinely has no source — e.g. detecting email replies — an external event fire is correct; still frame the native source as the recommended path for the broader integration.)\n\nA **connection** is a source feeding one or more destinations: data enters through a source (Salesforce, JavaScript, a database sync, Journeys Message Metrics, …) and every destination connected to it runs its actions on those payloads. `GET /destinations` returns each destination's `source_ids`.\n\n**What does the user want?**\n\n1. **List/check existing integrations** → Query both APIs and present a unified view:\n   - `GET /v1/environments/:eid/integrations` — Journeys integrations\n   - `GET /v1/environments/:eid/integrations/pipelines` — Pipelines connections\n   - `GET /cdp/api/workspaces/:workspace_id/sources` and `/destinations` — CDP sources \u0026 destinations\n\n2. **Set up something new** → Ask: \"Are you looking to **bring data into** Customer.io, or **send data out** to another tool?\" Then:\n   - **Data in** → `cio skills read cdp-api/sources.md` for CDP sources (JavaScript, Node, Python, Salesforce, etc.)\n     - *From a database or warehouse* (Postgres, MySQL, Snowflake, BigQuery, Redshift, SQL Server, Databricks — \"sync our DB\", \"reverse ETL\", \"database sync\" into people/objects/relationships) → `cio skills read cdp-api/reverse_etl_setup.md`\n   - **Data out (message activity)** → `cio skills read cdp-api/journeys_integrations.md` for simple integrations (Segment, Mixpanel, Amplitude via write key / API key)\n   - **Data out (full pipeline)** → `cio skills read cdp-api/destinations.md` — a CDP **Destination**. This is the default meaning of \"**Data Out Integration**\", \"send data out\", \"export to \u003ctool\u003e\", or \"sync to \u003ctool\u003e\" → configuring actions (triggers + mappings).\n     - *Other ways to send data out — disambiguate first:* **Journeys Data Warehouse** = scheduled bulk export to S3/GCS/Azure → `cio skills read cdp-api/journeys_integrations.md`; **Reporting Webhooks** = per-event message-activity callbacks to a URL → Fly API `webhooks.md`.\n   - **Filter/control incoming data** (unwanted profiles, data quality, field mapping) → `cio skills read cdp-api/cio_destination.md`\n   - **Data export to storage** → `cio skills read cdp-api/journeys_integrations.md` for warehouse sync (S3, GCS, Azure)\n   - **Ad audiences** → `cio skills read cdp-api/journeys_integrations.md` for Facebook/Google Ads\n\n3. **Change a destination that already exists — add, edit, or fix one of its actions** (\"add an action\", \"edit the action\", \"create a new action on \u003ctool\u003e\", \"the action never fires\", \"change what we send to \u003ctool\u003e\") → `cio skills read cdp-api/destinations.md`. This is a different job from setting a destination up, and it is easy to reach part-way through another task — troubleshooting a database sync, a delivery, or a missing relationship — so route here the moment the work turns into writing an action, whatever the session started as.\n   - The `action` key must come from the destination's manifest — read it before you write, never infer the key.\n   - **Journeys Workspace** — the native Customer.io destination (`option_id: customerio`), the one data flows *into* — is covered by `cio skills read cdp-api/cio_destination.md`, which carries its action keys. Read `destinations.md` alongside it only for trigger and mapping mechanics.\n\n4. **Named integration** → Use this quick-reference, then read the sub-file:\n\n| Integration | Sub-file |\n|---|---|\n| Warehouse sync, ad audiences, ad credentials, mParticle | `cio skills read cdp-api/journeys_integrations.md` |\n| Segment, Mixpanel, Amplitude | `cio skills read cdp-api/journeys_integrations.md` (multiple options exist — see disambiguation in that file) |\n| CDP sources (JS, Python, Salesforce, HubSpot, etc.) | `cio skills read cdp-api/sources.md` |\n| CDP destinations (webhooks, custom) | `cio skills read cdp-api/destinations.md` |\n| A destination action needs a value the source payload lacks — pull a **Journeys profile attribute** into the mapping (`$.customer.\u003cattr\u003e`), e.g. the Salesforce Contact/Lead ID (the `who_id` field) for tasks fired from Journeys Message Metrics | `cio skills read cdp-api/destinations.md` § Pulling profile attributes into an action |\n| Add, edit, or fix an **action** on a destination that already exists | `cio skills read cdp-api/destinations.md` — the `action` key must come from the destination manifest |\n| **Journeys Workspace** / the native Customer.io destination (`option_id: customerio`) — its action keys and what each one writes | `cio skills read cdp-api/cio_destination.md` |\n| Unwanted profiles, data quality, filtering incoming data | `cio skills read cdp-api/cio_destination.md` |\n| Reverse ETL / database sync — set up a new one, or questions about query authoring, `{{last_sync_time}}`, preview cost, sync intervals/scheduling | `cio skills read cdp-api/reverse_etl_setup.md` |\n| Reverse ETL, database configs — endpoint reference, edits | `cio skills read cdp-api/reverse_etl.md` |\n\n**Important:** Segment, Mixpanel, and Amplitude each have multiple integration types (simple message-activity forwarding vs full Pipelines destination). When a user asks about these by name, read **journeys-integrations.md** first — it has disambiguation guidance.\n\n---\n\n## Linking to the UI\n\nAfter creating or modifying a CDP resource, link the user to it. **UI links render on the app host `https://fly.customer.io` — no region prefix** (the `eu.`/region prefix is API-only; a UI link like `https://eu.fly.customer.io/workspaces/.../pipelines/...` 404s). Build them as `https://fly.customer.io/workspaces/$WORKSPACE_ID/pipelines/...` (do **not** reuse `https://fly.customer.io`, which is the region-prefixed API host).\n\n| Resource | UI Path |\n|----------|---------|\n| Source | `/pipelines/sources/:id` |\n| Source syncs | `/pipelines/sources/:id/syncs` |\n| Source settings | `/pipelines/sources/:id/settings` |\n| Source database connections | `/pipelines/sources/:id/databases` |\n| Destination | `/pipelines/destinations/:id` |\n| Destination actions | `/pipelines/destinations/:id/subscriptions` |\n| Connections overview | `/pipelines/connections` |\n| Add a source (OAuth/connector/database) | `/pipelines/sources/add?source=:option_id` (e.g. `?source=salesforce`, `?source=databricks`) |\n\n## CDP vs Fly API\n\n| Task | Use |\n|------|-----|\n| Sources, destinations, syncs, reverse ETL, database configs | **CDP API** (this skill) |\n| Automations, one-time sends, transactional messages, segments, profiles | **Fly API** (`@skills/fly-api`) |\n| Email message content | **Design Studio** (`@skills/design-studio`) |\n| In-app message content | **Fly API** (`@skills/fly-api/in_app.md`) |\n","files":{"cio_destination.md":"# Incoming Data Filtering \u0026 Mapping\n\nData from external sources (Salesforce, JavaScript, databases, etc.) flows through the CDP pipeline before reaching Customer.io. You can filter and transform this data to control what creates profiles, which attributes are set, and what events are recorded.\n\nUnder the hood, Customer.io is a \"destination\" in the CDP pipeline with its own subscriptions and mappings — but customers don't need to know this. Focus on their intent: controlling what data comes in and how it's handled.\n\n## When to use this file\n\n- User has **unwanted profiles** (e.g., \"why do I have so many profiles?\", \"I'm getting junk records from Salesforce\")\n- User wants to **filter incoming data** (e.g., \"only import leads with an email address\", \"don't create profiles for internal test accounts\")\n- User wants to **control how source fields map to Customer.io attributes** (e.g., \"map MailingCity to city\")\n- User wants to **connect Salesforce or another source** and needs to configure what data flows in\n\n## How incoming data is processed\n\nData flowing into Customer.io goes through **actions** — the same concept as any other CDP destination (see `destinations.md`). Each action determines **when** data is brought into Customer.io and **what** happens with it. Every action has a subscription with a mapping that controls which incoming events trigger it and how source fields translate to Customer.io fields.\n\nThe Customer.io destination ships these actions. The **action key** is the value of a subscription's `action` field, and it is not the same as the event type it listens for — `subscribe` holds `type = \"identify\"` while `action` holds `createUpdatePerson`. Always take the key from this column or from the manifest. These actions are all manifest-backed, so they edit normally in the UI — but save a subscription whose `action` the manifest does not define and *that* action becomes uneditable, offering only enable/disable, because the UI looks the stored `action` up in the manifest to decide.\n\n| Action key | UI title | Default trigger | Example filter use case |\n|---|---|---|---|\n| `createUpdatePerson` | Create or Update Person | `type = \"identify\"` | \"Only create profiles for leads with an email address\" |\n| `trackEvent` | Track Event | `type = \"track\"`, excluding the reserved event names below | \"Don't track internal test events\" |\n| `trackPageView` | Track Page View | `type = \"page\"` | \"Only track pages from production domains\" |\n| `trackScreenView` | Track Screen View | `type = \"screen\"` | — |\n| `createUpdateObject` | Create or Update Object | `type = \"group\"` | \"Only sync Accounts with status 'Active'\" |\n| `mergePeople` | Merge People | `type = \"alias\"` | — |\n| `createUpdateDevice` | Create or Update Device | `Application Installed`, `Device Created or Updated`, `Application Opened` | — |\n| `deleteDevice` | Delete Device | `Application Uninstalled`, `Device Deleted` | — |\n| `deletePerson` | Delete Person | `User Deleted` | — |\n| `deleteObject` | Delete Object | `Object Deleted` | — |\n| `deleteRelationship` | Delete Relationship | `Relationship Deleted` | — |\n| `suppressPerson` | Suppress Person | `User Suppressed` | — |\n| `unsuppressPerson` | Unsuppress Person | `User Unsuppressed` | — |\n| `reportDeliveryEvent` | Report Delivery Event | `Report Delivery Event` | — |\n| `reportContentEvent` | Report Content Event | `Report Content Event` | — |\n| `trackGeofenceTransition` | Track Geofence Transition | `Geofence Transition` | — |\n| `trackLiveNotificationEvent` | Track Live Notification Event | `Live Notification Event` | — |\n| `registerLiveNotificationToken` | Register Live Notification Token | `Live Notification Token` | — |\n\n`createUpdateObject` is also what relates a person to an object: its `user_id` field (default `$.userId`) carries the person, `id` (default `$.groupId`) the object, and `relationship_attributes` (default `$.traits.relationshipAttributes`) any attributes on the relationship itself. A `group` payload with no `userId` upserts the object and creates no relationship.\n\nTwelve event names are reserved: `Device Created or Updated`, `Device Deleted`, `Relationship Deleted`, `User Deleted`, `Object Deleted`, `User Suppressed`, `User Unsuppressed`, `Report Delivery Event`, `Report Content Event`, `Geofence Transition`, `Live Notification Event`, and `Live Notification Token`. Each drives the matching action above, and `trackEvent`'s default trigger excludes them all so a reserved name never lands on a timeline as an ordinary event. The device actions also accept `Application Installed`, `Application Opened` and `Application Uninstalled`, which `trackEvent` does not exclude.\n\nThese actions are created with the destination itself, not when a source is connected to it — a new source adds no actions. To control what data enters Customer.io, you **modify these subscriptions** — updating their mappings to add filters, change field mappings, or disable actions entirely. You can also create additional subscriptions for the same action with different filters (e.g., a stricter `createUpdatePerson` for Salesforce data vs. the default for other sources).\n\n\u003e **One destination usually serves every source.** Workspaces commonly route many sources through the single default Journeys destination, so editing or disabling one of its actions changes what arrives from *all* of them. Check the destination's `source_ids` before you change an action, and prefer an additional action with a narrower trigger over editing the shared default. For trigger and mapping mechanics, and the full-replace PUT rules, see `cio skills read cdp-api/destinations.md`.\n\n## Inspecting the CIO destination\n\n1. **Find the CIO destination:**\n   ```\n   GET /cdp/api/workspaces/:workspace_id/destinations\n   ```\n   Look for the destination with the Customer.io name/type. Note its `id`.\n\n2. **List its subscriptions:**\n   ```\n   GET /cdp/api/workspaces/:workspace_id/destinations/:destination_id/subscriptions\n   ```\n   Each subscription has:\n   - `subscribe` — the FQL trigger, not a bare event type (e.g. `type = \"identify\"`, `event = \"Object Deleted\"`)\n   - `action` — CIO action to trigger\n   - `mapping` — JSON defining how source fields transform to CIO fields\n   - `enabled` — whether it's active\n\n3. **Inspect the manifest** to see all available actions and field definitions:\n   ```\n   GET /cdp/api/workspaces/:workspace_id/action_destinations/customerio/manifest\n   ```\n\n## Person↔Object relationships\n\nThe Customer.io destination has **no \"Create or Update Relationship\" action** — do not create one. Person↔Object relationships are created by the `createUpdateObject` action (UI title: **Create or Update Object**, default trigger `type = \"group\"`) when the event carries a `userId`. The authoritative reference and troubleshooting steps for a missing relationship live in `destinations.md` → **\"Customer.io destination: Person↔Object relationships\"** (that is where the Agent routes \"data out\" / action-configuration questions). Read it before diagnosing a missing relationship.\n\n## Filtering incoming data\n\nWhen a user wants to prevent certain records from reaching Customer.io (e.g., \"only import leads with an email\"), the approach is to **modify subscription mappings**.\n\n### Workflow: \"Only identify people who have an email\"\n\n\u003e **Note:** \"Subscriptions\" here means CDP destination subscriptions (event-to-action mappings), NOT the Fly API subscription center/topics. All paths below are under the CDP API.\n\n1. List the CIO destination's subscriptions to find the `createUpdatePerson` subscription:\n   `GET /cdp/api/workspaces/:workspace_id/destinations/:destination_id/subscriptions`\n2. Get the subscription details:\n   `GET /cdp/api/workspaces/:workspace_id/destinations/:destination_id/subscriptions/:subscription_id`\n3. Inspect the current `mapping` to understand how fields are mapped\n4. Update the subscription's mapping to add a filter condition, or disable the default identify subscription and create a new one with stricter requirements\n5. Preview the change:\n   `POST /cdp/api/workspaces/:workspace_id/preview` with the updated mapping and a sample event to verify the filter works\n6. Apply:\n   `PUT /cdp/api/workspaces/:workspace_id/destinations/:destination_id/subscriptions/:subscription_id` with the updated mapping\n\n### Workflow: \"Create a Salesforce-specific identify action\"\n\nFor complex filtering (e.g., default identify for all sources, but a stricter one for Salesforce):\n\n1. **Keep the default** identify subscription enabled for other sources\n2. **Create a new subscription** specifically for the Salesforce use case:\n   ```\n   POST /cdp/api/workspaces/:workspace_id/destinations/:destination_id/subscriptions\n   Body: {\n     \"name\": \"Salesforce Identify (email required)\",\n     \"subscribe\": \"type = \\\"identify\\\"\",\n     \"action\": \"createUpdatePerson\",\n     \"mapping\": { ...mapping with email requirement... },\n     \"enabled\": true\n   }\n   ```\n3. **Disable or modify** the default identify subscription to exclude Salesforce events if needed\n\n**Important:** To understand the mapping structure and available fields, always fetch the manifest first. The mapping format is defined by the manifest's action field definitions — don't guess the structure.\n\n## Connecting Salesforce to Customer.io\n\nWhen connecting a Salesforce source to the CIO destination, the connection requires object type mappings:\n\n```\nPOST /cdp/api/workspaces/:workspace_id/source_destination\nBody: {\n  \"source_id\": \u003csalesforce_source_id\u003e,\n  \"destination_id\": \u003ccio_destination_id\u003e,\n  \"connect\": true,\n  \"settings\": {\n    \"objects\": [\n      { \"name\": \"Account\", \"object_type_id\": \"\u003ccio_object_type_id\u003e\" },\n      { \"name\": \"Contact\", \"object_type_id\": \"\u003ccio_object_type_id\u003e\" }\n    ]\n  }\n}\n```\n\nTo get the Customer.io object type IDs: `GET /cdp/api/workspaces/:workspace_id/object_types`\n\n**Gotchas:**\n- `settings.objects` is required when the source has syncs with `cdp_type: \"group\"`\n- Each Salesforce object must map to a Customer.io object type\n- Auto-track sources cannot connect to Customer.io destinations\n- Create the necessary object types in Customer.io first if they don't exist\n\n## Customizing field mappings\n\nWhen a user wants to rename or transform fields (e.g., \"map Salesforce MailingCity to city\"):\n\n1. Get the current subscription mapping\n2. Use `POST /cdp/api/workspaces/:workspace_id/preview` to test your mapping changes against a sample event payload\n3. Update the subscription with the new mapping\n\nThe preview endpoint is essential — always test mapping changes before applying them.\n","destinations.md":"---\nminimum_plan: essentials\n---\n\n# CDP API: Destinations \u0026 Action Configuration\n\nDestinations send data from Customer.io out to external tools (Mixpanel, Amplitude, HubSpot, webhooks, etc.). In the product, a destination is also called a **\"Data Out Integration\"** — treat that phrase (and \"send data out\", \"export to \u003ctool\u003e\", \"sync to \u003ctool\u003e\") as a synonym for a destination. (Two other export paths exist — **Journeys Data Warehouse** and **Reporting Webhooks** — see SKILL.md routing to disambiguate.) Every destination holds a set of **actions**. Each action decides **which** incoming requests it handles (its **trigger**) and **how** the source payload is reshaped into what the external tool expects (its **mapping**).\n\n\u003e **Terminology — say \"action\", not \"subscription\".** The product UI calls these **actions**; that's the only term to use with the user. The CDP API names them `subscriptions` (the `/subscriptions` endpoints, the `subscribe` field) — treat that as an internal wire detail and **never surface \"subscription\" or `subscribe` in user-facing text** (summaries, questions, confirmations).\nThis skill helps you do the two things that take real skill to get right:\n\n1. **Configure the trigger** — filter which source requests fire the action (FQL).\n2. **Configure the mapping** — transform the source payload into the action's required shape (the mapping kit).\n\nBase path: `/cdp/api/workspaces/:workspace_id/destinations`. Use `cio api` (not `curl`) for all calls; pass request bodies with `--json`. Pass **query/filter params with `--params '{...}'`** (JSON) — `cio api` rejects a `?query` string in the path, so `?limit=N` won't work; use `--params '{\"limit\":N}'`.\n\n---\n\n## How actions work\n\nEach action carries a **schema** that defines its trigger and its fields, and it processes either a **single** request or a **batch** of requests. Many destinations ship **default actions** whose trigger and field mapping are pre-built from the common event spec (e.g. a \"Track Event\" action that fires on `type = \"track\"` and maps the standard event fields). Defaults are a starting point — they can be edited or filtered further.\n\nWhen a source payload is dispatched to a connection, the action-destinations service (ADS) receives it and runs it through **every enabled action** on that destination:\n\n```\nsource payload ──▶ ADS ──▶ for each enabled action:\n                              1. does the payload match the action's TRIGGER?  (FQL)\n                              2. if yes, transform payload ──▶ action's shape   (mapping kit)\n                              3. execute the action against the external tool\n```\n\nSo an action only runs when its trigger matches, and what it sends is whatever the mapping produces. Getting both right is the whole job.\n\n\u003e **The payload always originates from a CDP source.** A destination action can only fire on data that entered the pipeline through a **source** (JavaScript/SDK, Salesforce, a database/reverse-ETL, an HTTP source, etc.) — see `sources.md`. A **Journeys workflow \"Send Event\" action** (`create_event_action`) does **not** produce a source payload; its event stays inside Journeys and never reaches this destination. So if a user built an automation that fires a custom event expecting it to sync out here, that will never trigger — the fix is on the Journeys side (a webhook action or an attribute update, see `fly_api/data_operations.md`), not a new subscription here.\n\n---\n\n## Recommended workflow (start from a real payload)\n\nThe single biggest accuracy win is to **configure against a real source payload** instead of guessing the shape. The UI does not surface sample source payloads during action setup, but the API does — use it.\n\n**Check the API before asking the user.** Resolve the destination, its connected sources, and the available field names from the API and from real payloads — don't ask the user to confirm state you can look up. Reserve questions for genuine business decisions (e.g. \"how do you define a premium profile?\") that the data can't answer on its own.\n\n1. **Resolve the destination — discover it, don't ask.**\n   - If you already have a destination id: `GET /destinations/:destination_id` — details incl. `option_id`, `source_ids`, `oauth_connected`/`enabled`.\n   - If the user only named a tool (e.g. \"HubSpot\") and you don't have an id: `GET /destinations` and match by `option_id`/`name` to find it and confirm it's connected. **Do not ask the user whether the destination exists — look it up.** Only ask if it's genuinely absent (offer to create it) or the match is ambiguous.\n   - `GET /sources` — locate the source(s) feeding this destination (its `source_ids`).\n\n2. **Pull recent real payloads from the source.**\n   - `GET /sources/:source_id/events` with `--params '{\"limit\":N}'` — recent events. Each entry in `events[]` is the raw payload the source emitted. Filter with `eventTypes`, `recordTypes`, `search`, or `messageId` via `--params` (see reference below). Pick a representative payload to design against.\n   - If no real payloads exist yet, fall back to the best known shape for that source type (standard event spec: `type`, `event`, `userId`, `anonymousId`, `properties`, `traits`, `context`, `timestamp`).\n\n3. **Inspect the destination's available actions and fields.**\n   - `GET /cdp/api/workspaces/:workspace_id/action_destinations/:option_id/manifest` (workspace-scoped — `/cdp/api/action_destinations/...` without the workspace segment is a 401) — auth scheme, the actions this connector supports, and each action's field definitions. The mapping you build must satisfy these fields. **Manifest shape:** `actions` is an **object keyed by action slug** — that key (e.g. `upsertContact`) is what you pass as the subscription's `action`; there is no `.slug` property. Each action has `title`, `description`, `defaultSubscription` (its default FQL trigger), and `fields`. `fields` is an **object keyed by field key** — that key is what you put on the left of the mapping; each field has `label`, `type`, `required`, and `default` (a default mapping directive — there is no `.fieldKey`/`.defaultValue`). Useful jq: `.actions | keys`, then `.actions.upsertContact.fields | to_entries | map({key, required: .value.required, default: .value.default})`.\n\n4. **Configure the trigger (FQL).** Start from the action's default trigger; narrow it only if the user wants to filter (see § Triggers). Example: fire only for metrics from one automation.\n\n5. **Configure the mapping (mapping kit).** Map each required action field from the real payload using directives (see § Mapping kit).\n\n6. **Validate the mapping with `/preview` — do not skip this.**\n   - `POST /workspaces/:workspace_id/preview` with `{ \"mapping\": {...}, \"event\": {...} }` → returns the transformed payload. `event` must be a JSON object. Iterate here until the output matches what the destination expects. This is the safe inner loop — it never contacts the external tool.\n   - Preview against a **real event of the type your trigger matches** (from step 2), not an invented one. Paths that don't exist resolve to **nothing** and the field is silently dropped — so a wrong path looks identical to a correct one until you preview. Common trap: mapping `$.traits.*` on a **track** event — traits live on `identify`, so on a track payload those fields come back empty. Inspect the preview output and confirm every field you expect is actually populated before saving.\n\n7. **Save** the action (`PUT /subscriptions/:subscription_id`, or `POST` for a new one).\n\n8. **Live test — only with explicit user consent.**\n   - `POST /destinations/:destination_id/send_test` with a sample payload as the body (e.g. `{ \"type\": \"identify\", ... }`). This runs the **real** trigger + transformation and **sends a real request to the external destination**, returning the request/response exchange. Because it hits the live external tool, always confirm with the user before calling it.\n\n---\n\n## Triggers (FQL)\n\nA trigger is a boolean filter written in **FQL** (Filter Query Language). If it evaluates true for an incoming payload, the action runs. Default triggers usually match an event type (e.g. `type = \"track\"`); you refine them to filter further.\n\n### Fields you can reference\n\n| Field | Source payload location |\n|-------|-------------------------|\n| `type` | event type — `\"track\"`, `\"identify\"`, `\"page\"`, `\"screen\"`, `\"group\"`, `\"alias\"` |\n| `event` | event name (track events) |\n| `name` | name (page/screen) |\n| `userId` | the user id |\n| `properties.X` | event properties (dot-path, nested ok: `properties.items.count`) |\n| `traits.X` | identify/group traits |\n| `context.X` | context fields |\n| `customer.X` | a **Journeys profile attribute** pulled in by enrichment — only present when `X` is listed in the action's `settings.enrich` (see § Pulling profile attributes into an action) |\n\n### Operators \u0026 functions\n\n| Form | Meaning |\n|------|---------|\n| `field = \"value\"` / `field != \"value\"` | equals / not equals (string) |\n| `field = 100` / `field != 0` | numeric equals / not equals |\n| `field \u003c n`, `\u003c=`, `\u003e`, `\u003e=` | numeric comparison |\n| `field = true` / `field = false` | boolean |\n| `field != null` | exists |\n| `field = null` | does not exist |\n| `contains(field, \"x\")` / `!contains(field, \"x\")` | substring match |\n| `match(field, \"x*\")` | starts with `x` (wildcard `*`) |\n| `match(field, \"*x\")` | ends with `x` |\n| `!match(field, \"x*\")` | does not start with |\n| `a and b`, `a or b`, `( ... )` | combine / group |\n\n### Examples\n\n```text\ntype = \"track\"\ntype = \"track\" and event = \"Order Completed\"\nevent = \"Product Added\" and contains(properties.name, \"Nike\")\ntype = \"identify\" and traits.plan = \"premium\"\nmatch(event, \"Email *\") and properties.campaign_id = \"12345\"\n```\n\n\u003e Filtering Journeys Message Metrics to a single automation: trigger on the metric event plus the automation identifier present in the payload, e.g. `properties.campaign_id = \"12345\"` (confirm the exact property name from a real source payload via step 2).\n\n---\n\n## Mapping kit\n\nThe mapping defines how the source payload becomes the action's payload. A mapping is JSON that mixes **raw values** (copied through as-is) with **directives** (objects with a single `@`-prefixed key that fetch or transform data from the input).\n\n```json\n{\n  \"name\": \"Mr. Rogers\",\n  \"neighborhood\": { \"@path\": \"$.properties.neighborhood\" },\n  \"greeting\": { \"@template\": \"Won't you be my {{properties.noun}}?\" }\n}\n```\n\nHere `name` is a raw value (copied through as-is); `neighborhood` and `greeting` are directives that pull from the input.\n\nRules:\n- A directive object may contain **only one** `@`-key, and a directive cannot sit at the same level as raw keys *within the same object* — wrap it: `{ \"x\": { \"@path\": \"$.a\" } }`, not `{ \"x\": \"y\", \"@path\": \"$.a\" }`.\n- `undefined` results are **omitted** from the output; `null` is kept.\n\n### Directives\n\n| Directive | Purpose | Example |\n|-----------|---------|---------|\n| `@path` | Read a value by dot-path (leading `$.` optional). | `{ \"@path\": \"$.properties.email\" }` |\n| `@template` | String with `{{...}}` placeholders. Missing values render empty. | `{ \"@template\": \"Hello {{traits.name}}\" }` |\n| `@literal` | Emit a value untouched (escape hatch when a value would be misread as a template). | `{ \"@literal\": true }` |\n| `@if` | Branch on a conditional. Conditional: `exists`. Branches: `then` / `else` (omit a branch to drop the field when that branch is taken). | see below |\n| `@arrayPath` | Read an array at a path and optionally reshape each item. The item shape can be an object **or a single directive** — the latter projects a flat list of scalars. | objects: `{ \"@arrayPath\": [\"$.properties.products\", { \"id\": { \"@path\": \"$.productId\" } }] }` · scalars: `{ \"@arrayPath\": [\"$.properties.products\", { \"@path\": \"$.product_id\" }] }` → `[\"SKU-1001\", …]` |\n| `@case` | Lower/upper-case a string value. `operator`: `\"lower\"` \\| `\"upper\"`. | `{ \"@case\": { \"operator\": \"lower\", \"value\": { \"@path\": \"$.event\" } } }` |\n| `@replace` | Replace `pattern` with `replacement` (both required; `replacement` may be `\"\"`). Optional `ignorecase`, `global`. | `{ \"@replace\": { \"pattern\": \"-\", \"replacement\": \"_\" } }` |\n\n`@if` with a fallback (common for email coming from either properties or context traits):\n\n```json\n{\n  \"@if\": {\n    \"exists\": { \"@path\": \"$.properties.email\" },\n    \"then\":   { \"@path\": \"$.properties.email\" },\n    \"else\":   { \"@path\": \"$.context.traits.email\" }\n  }\n}\n```\n\n### Validate before you trust it\n\nAlways run the mapping through `POST /workspaces/:workspace_id/preview` with a real payload (step 6) and compare the transformed output to the action's required fields from the manifest. Tweak and re-run until correct.\n\n---\n\n## The mapping kit can do more than the UI\n\nThe mapping kit supports compositions our configuration UI cannot express. When a user needs something the form won't let them build, configure the action's `mapping` directly via the API.\n\n\u003e **Real case (HubSpot custom event).** The action's `eventName` had to be a static prefix joined to a lowercased, space-stripped attribute from the payload — not expressible in the UI, but valid mapping-kit. Directives compose: `@replace` wraps `@case` wraps `@template`.\n\n```json\n{\n  \"eventName\": {\n    \"@replace\": {\n      \"pattern\": \" \",\n      \"replacement\": \"_\",\n      \"ignorecase\": true,\n      \"value\": {\n        \"@case\": {\n          \"operator\": \"lower\",\n          \"value\": { \"@template\": \"pe49209507_{{event}}\" }\n        }\n      }\n    }\n  },\n  \"occurredAt\": { \"@path\": \"$.timestamp\" },\n  \"email\": {\n    \"@if\": {\n      \"exists\": { \"@path\": \"$.properties.email\" },\n      \"then\":   { \"@path\": \"$.properties.email\" },\n      \"else\":   { \"@path\": \"$.context.traits.email\" }\n    }\n  },\n  \"objectId\": \"\",\n  \"properties\": { \"@path\": \"$.properties\" }\n}\n```\n\n---\n\n## Pulling profile attributes into an action (data enrichment)\n\nSometimes the action needs a value the **source payload doesn't carry**. The classic case: a **Journeys Message Metrics** source feeds a **Salesforce** destination whose \"Task for Message Delivery\" action requires the person's Salesforce Contact/Lead ID (its `who_id` field). A message event carries `userId`, the message, the metric — but not the Salesforce ID. That value lives on the **Journeys profile**, and an action can pull it from there.\n\n**How it works.** Before the trigger and mapping run, the pipeline looks up each event's `userId` in Journeys and copies the requested profile attributes onto the payload under a `customer` object. The mapping then reads them as `$.customer.\u003cattribute\u003e` (`{{customer.\u003cattribute\u003e}}` in a template) and the trigger as `customer.\u003cattribute\u003e`. In the destination's *Data out* log this shows up as an extra internal request named **\"Attribute Enrichment\"** before the real request to the external tool; it costs no external API calls.\n\n```json\n{\n  \"who_id\":  { \"@path\": \"$.customer.salesforce_contact_id\" },\n  \"subject\": { \"@template\": \"{{event}}: {{properties.subject}}\" }\n}\n```\n\n\u003e **Manifest defaults that reference `$.customer.\u003cSomething\u003e` are placeholders, not facts about the workspace.** Salesforce's \"Task for Message Delivery\" ships with `who_id` defaulting to `$.customer.WhoId` and a trigger of `… and customer.WhoId != null`. Nothing guarantees the workspace stores the Contact/Lead ID under `WhoId` — it is whatever the data-in sync named it. Replace the attribute in **both** the mapping and the trigger with the real one (rule 1), and list that name in `settings.enrich`. Left as-is, the trigger never matches and the action silently does nothing.\n\n**The profile is the source of truth.** Enrichment reads whatever the profile already holds, so the value has to be there first — however it landed: a Salesforce *source* syncing contacts/leads, a reverse ETL query, a CSV import, the Track API, or a workflow attribute update. The typical full loop is a Salesforce source writing the Contact/Lead ID onto the profile and the message metrics flowing back out through the destination, but it is not the only one. When the attribute isn't on the profile yet, the fix is upstream of the profile, in whichever of those feeds the workspace uses — not a different mapping. Tell the user both halves: where to add the field upstream, and that once it lands on profiles you can map it as `$.customer.\u003cattribute\u003e` and list it in `settings.enrich`.\n\n### Rules\n\n1. **Never guess the attribute name.** Attribute names are workspace-specific (`salesforce_contact_id`, `sf_lead_id`, `WhoId`, `contact_id`, … all exist in the wild) and are looked up **exactly as written, case-sensitively** — a wrong name resolves to nothing and the field is silently dropped. Discover the name and the value shape from the workspace — these two settle it regardless of how the data lands:\n   - the **data index** — `GET /v1/environments/:environment_id/attributes` (Fly API, see `fly-api/workspace.md`) lists every known profile attribute name; pick the candidates that look like the external ID;\n   - a **real profile** — `GET /v1/environments/:environment_id/customers/:id` (see `fly-api/customers.md`; find one via `GET .../customers?email=…`) and read the attribute off `customer.attributes` to confirm it's populated and see the actual value (a Salesforce Contact ID is an 18-char string starting `003`, a Lead ID starts `00Q`). Do this even when the user names the attribute — confirm it exists before wiring it.\n   Then, **only if `GET /sources` lists a connector source for that tool** (Salesforce, HubSpot, …), confirm the name against `GET /sources/:source_id/syncs`: each sync's `mapping` has `fields` (what's pulled) and `renames` (what those fields are stored as on the profile — e.g. `\"renames\": {\"Id\": \"contact_id\"}` on a Contact sync means the Salesforce Contact ID lives in `contact_id`). One listing call bounds this; when there is no such source the value arrived another way (reverse ETL, import, Track API, a workflow), and there is nothing to chase in Pipelines.\n   If the data index and profiles don't have it, stop: the profile isn't carrying the value yet. Say so and point upstream (add or rename the field wherever the workspace feeds that data from) rather than mapping a name that doesn't exist.\n\n2. **Declare every attribute you reference in `settings.enrich`.** Enrichment only fetches the attributes listed there — `{\"enrich\": [\"salesforce_contact_id\"]}`. The **UI derives this list automatically** from the mapping and trigger; the **API does not**, so when you write the action via `POST`/`PUT /subscriptions` you must set `settings` yourself. A `$.customer.X` path with no matching `enrich` entry is not an error: the lookup is skipped, `customer` never appears on the payload, and the field is dropped. Include the attributes used by the **trigger** too (a `customer.plan = \"enterprise\"` condition needs `\"plan\"` in the list), and keep `settings` on `PUT` — full replace means omitting it removes the enrichment.\n\n3. **Enrichment is keyed on the payload's `userId`.** It resolves against the workspace's identifiers (`cio_…` id, email, or external id — whichever the payload's `userId` is), so it works out of the box for Journeys Message Metrics events. Events with no `userId`, or for a profile that doesn't have the attribute set, get **no** `customer` object; the mapped field drops, and when that field is required by the action the event is skipped rather than sent. That is the expected behavior for people who aren't in the external tool — not a bug to work around.\n\n4. **Values arrive as strings.** Every enriched attribute is a string, whatever type the profile stores. A JSON-valued attribute comes through as JSON text, so map scalar attributes.\n\n### Configuring it\n\n- **Preview doesn't enrich.** `POST /workspaces/:workspace_id/preview` applies the mapping to exactly the `event` you send — it never calls Journeys. To preview a `$.customer.*` mapping, take a real source payload (step 2 of the workflow) and add a `customer` object to it — `\"customer\": {\"\u003cattribute\u003e\": \"\u003cvalue read off a real profile\u003e\"}` — using the value you saw on the profile in rule 1, not an invented one, so the preview also proves the value's shape (ID format, string vs JSON) is what the destination expects. Confirm the field is populated in the output.\n- **Verify the real thing on the Data out log.** `GET /destinations/:destination_id/events` — each delivered event that used enrichment shows the \"Attribute Enrichment\" exchange (request: `userIds` + `attributes`; response: the matched profiles and their attribute values) ahead of the request to the external tool. An empty `[]` response there means the attribute wasn't found on those profiles under that exact name. `send_test` runs the real lookup too (send a payload whose `userId` is a real, synced person), but it also hits the external tool — user consent first.\n\nWorked example — Journeys Message Metrics → Salesforce \"Task for Message Delivery\", where the workspace's Salesforce Contact sync stores the Contact ID as `contact_id` (illustrative — take yours from the sync mapping / data index):\n\n```json\n{\n  \"subscription\": {\n    \"name\": \"Salesforce task for email sent\",\n    \"action\": \"task\",\n    \"subscribe\": \"type = \\\"track\\\" and event = \\\"Email Sent\\\" and customer.contact_id != null\",\n    \"enabled\": true,\n    \"mapping\": \"{\\\"operation\\\":\\\"create\\\",\\\"who_id\\\":{\\\"@path\\\":\\\"$.customer.contact_id\\\"},\\\"subject\\\":{\\\"@template\\\":\\\"{{event}}: {{properties.subject}}\\\"},\\\"priority\\\":\\\"Normal\\\",\\\"status\\\":\\\"Completed\\\",\\\"task_subtype\\\":\\\"Email\\\",\\\"activity_date\\\":{\\\"@path\\\":\\\"$.timestamp\\\"}}\",\n    \"settings\": { \"enrich\": [\"contact_id\"] }\n  }\n}\n```\n\nThe `customer.\u003cattr\u003e != null` trigger clause is what keeps people who aren't in Salesforce from producing failed requests. `action` and the field keys come from the destination's manifest — check them, don't copy these.\n\n---\n\n## Customer.io destination: Person↔Object relationships (no separate \"create relationship\" action)\n\nThe native **Customer.io** destination has **no \"Create or Update Relationship\" action** — do not create one, and do not tell the user one is missing. A Person↔Object relationship is formed *inside* an existing action, most commonly `createUpdateObject` (UI title: **Create or Update Object**, default trigger `type = \"group\"`): a `group` call that carries a `userId` both upserts the object AND relates the person to it. Relationship-specific attributes ride along in the `relationship_attributes` field (mapped from the `relationshipAttributes` trait by default). A relationship can also be attached from the person side through `createUpdatePerson` (UI title: **Create or Update Person**), by supplying `objectId` + `object_type_id` traits. The only relationship-specific action is **Delete Relationship** (`event = \"Relationship Deleted\"`).\n\n**Troubleshooting a missing Person↔Object relationship** (e.g. the profile has the right `object_id` attribute but no relationship shows up):\n\n1. **Do NOT create a new action.** Show the user the destination's real action list from its manifest (`GET /cdp/api/workspaces/:workspace_id/action_destinations/customerio/manifest`, `.actions | keys`) — there is no relationship-creating action to add, and creating a \"Create or Update Relationship\" subscription writes an action the manifest doesn't back, and it lands in a broken, unrenderable state.\n2. **Check the `createUpdateObject` (\"Create or Update Object\") subscription's mapping** — that is where the relationship is (or isn't) being formed:\n   - `user_id` must resolve to the person's id (default `$.userId`). **No `userId` on the `group` event = the object is upserted but no relationship is created.** This is the most common cause.\n   - `id` (Object ID, default `$.groupId`) must resolve to the intended object.\n   - `object_type_id` must match the object's type — a wrong type relates the person to the wrong object type (or nothing).\n3. **Preview the `group` event** through that subscription (`POST /workspaces/:workspace_id/preview`) and confirm `user_id`, `id`, and `object_type_id` all resolve *before* changing anything. A path that doesn't exist resolves to nothing and is silently dropped, so a missing `userId` looks identical to a correct mapping until you preview.\n\n---\n\n## Actions — endpoint reference (API path: `/subscriptions`)\n\nBase: `/destinations/:destination_id/subscriptions`\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/subscriptions` | List actions for the destination |\n| POST | `/subscriptions` | Create an action |\n| GET | `/subscriptions/:subscription_id` | Get one action |\n| PUT | `/subscriptions/:subscription_id` | Update (trigger, `mapping`, `settings`, `enabled`) — full replace, GET first and resend the complete object or you blank the `mapping` (and drop `settings.enrich`, silently breaking every `$.customer.*` reference) |\n| DELETE | `/subscriptions/:subscription_id` | Delete an action |\n| POST | `/subscriptions/toggle_all` | Enable/disable every action at once |\n\n### Action fields (API request body)\n\nThe request body wraps the fields in a `subscription` object — `{ \"subscription\": { … } }` — that's the API's wire name, not a term to show the user.\n\n- `name` — display name.\n- `subscribe` — the **trigger** (FQL string; see § Triggers).\n- `action` — which destination action to run. **It must be a key of the destination's manifest** (`GET /cdp/api/workspaces/:workspace_id/action_destinations/:option_id/manifest`, step 3 above) — never a key inferred from a sibling action already on the destination, from the action's display name in the UI, or from what the tool \"should\" support. A key the manifest doesn't define leaves an action the UI can only enable or disable, never edit, and the API may reject it outright.\n- `mapping` — the transformation, **as a JSON-encoded string** (see the gotcha below).\n- `settings` — optional action-specific settings, as a **raw JSON object** (unlike `mapping`, which is a string). The one you will set is `{\"enrich\": [...]}` — see § Pulling profile attributes into an action.\n- `enabled` — boolean.\n\nActions are usually **auto-created** from the destination's manifest when the destination is first connected, so you typically **edit** an existing one (`PUT`) rather than create from scratch.\n\n### ⚠️ Creating is not idempotent — list first, and verify before retrying\n\n`POST /subscriptions` creates a **new** action every time; there is no upsert. Before creating, `GET /subscriptions` and, if a matching one already exists, `PUT` it instead. And if a `POST`/`PUT` response looks empty or unexpected, **re-list to confirm** what actually happened — do **not** blindly re-POST, or you'll end up with duplicate actions.\n\n### Response shape (flat — not wrapped)\n\nResponses are **flat**: the create/update/get response is the resource itself, **not** wrapped in a `subscription` (or `destination`) key — even though the *request* body is wrapped in `{ \"subscription\": { … } }`. Read `.id`, not `.subscription.id`. A create/update returns:\n\n```json\n{\n  \"id\": 462,\n  \"action\": \"sendCustomBehavioralEvent\",\n  \"destination_id\": 13,\n  \"enabled\": true,\n  \"mapping\": \"{\\\"eventName\\\":\\\"pe123_order_completed\\\", …}\",\n  \"settings\": null,\n  \"name\": \"Order Completed over 100\",\n  \"subscribe\": \"type = \\\"track\\\" and event = \\\"Order Completed\\\" and properties.total \u003e 100\",\n  \"created_at\": 1782348462,\n  \"updated_at\": 1782348462\n}\n```\n\n(`GET /destinations/:destination_id` is flat too — `.id`, `.name`, `.source_ids`, …, not `.destination.*`.)\n\nThe one exception is the **list** endpoint: `GET /subscriptions` returns `{ \"subscriptions\": [ … ] }` (wrapped) — iterate `.subscriptions[]`, not `.[]`. Single-resource GET/POST/PUT are flat as shown above.\n\n### ⚠️ `mapping` is a stringified JSON here (but a raw object in `/preview`)\n\nOn `POST`/`PUT /subscriptions`, `mapping` is **not** a nested object — it is a **string containing JSON**. Sending an object returns `400 … expected=string, got=object`. The mapping JSON must be serialized and embedded as a string value:\n\n```json\n{\n  \"subscription\": {\n    \"name\": \"Order Completed over 100\",\n    \"action\": \"sendCustomBehavioralEvent\",\n    \"subscribe\": \"type = \\\"track\\\" and event = \\\"Order Completed\\\" and properties.total \u003e 100\",\n    \"enabled\": true,\n    \"mapping\": \"{\\\"eventName\\\":\\\"pe123_order_completed\\\",\\\"occurredAt\\\":{\\\"@path\\\":\\\"$.timestamp\\\"}}\"\n  }\n}\n```\n\nBuild the body programmatically so you don't hand-escape — e.g. `jq -n --arg m \"$MAPPING_JSON\" '{subscription:{name:\"…\",action:\"…\",subscribe:\"…\",enabled:true,mapping:$m}}'` — rather than nesting `\"\\\"\"` by hand.\n\nNote the asymmetry: `POST /preview` (below) takes `mapping` as a **raw JSON object**, while the subscription resource stores it as a **string**. Don't copy one shape into the other.\n\n---\n\n## Configure \u0026 test endpoints\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/sources/:source_id/events` | Recent **source** payloads to design against. Pass query via `--params` (JSON), e.g. `--params '{\"limit\":20,\"search\":\"Order Completed\"}'`: `eventTypes`, `recordTypes`, `syncNames`, `search`, `messageId`, `limit` (max 100), `after`/`before` (pagination tokens from `meta.continuation`). Returns `{ \"events\": [...], \"meta\": {...} }`. |\n| GET | `/sources/:source_id/events/:event_id` | One source event. |\n| GET | `/cdp/api/workspaces/:workspace_id/action_destinations/:option_id/manifest` | Connector manifest — auth scheme, actions, field definitions. Workspace-scoped — without `/workspaces/:workspace_id` it 401s. |\n| POST | `/workspaces/:workspace_id/preview` | Apply a mapping to a sample event. Body: `{ \"mapping\": {...}, \"event\": {...} }` — here `mapping` is a **raw object** (not a string), and `event` must be an object. Returns the transformed payload. **Does not** contact the external tool — safe to iterate. |\n| POST | `/destinations/:destination_id/send_test` | Send a sample payload (request body = the event, e.g. `{ \"type\": \"identify\", ... }`) through the real trigger + transform to the **live external destination**. Returns the request/response exchange. **Hits the external tool — only call with explicit user consent.** |\n| POST | `/cdp/api/workspaces/:workspace_id/action_destinations/:option_id/validate` | Validate settings against the connector spec. |\n\n---\n\n## Destination CRUD \u0026 operations\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/destinations` | List destinations |\n| POST | `/destinations` | Create. Body: `{ \"destination\": { \"name\", \"option_id\", \"settings\" } }` |\n| GET | `/destinations/:destination_id` | Get details |\n| PUT | `/destinations/:destination_id` | Update. `settings` merge into the stored ones, but `name` and `enabled` are replaced — send both |\n| DELETE | `/destinations/:destination_id` | Delete |\n| POST | `/destinations/clone-auto-track` | Clone an auto-track destination (duplicates all subscriptions + settings) |\n| GET | `/available_destinations` | Available connectors with their `option_id` |\n\nAuto-track destinations are a Customer.io → Journeys integration that tracks website interactions automatically without explicit event calls.\n\n### Status, metrics, events \u0026 errors\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/destinations/statuses` | Status for all destinations |\n| GET | `/destinations/:destination_id/status` | Single destination status |\n| GET | `/destinations/:destination_id/metrics` | Event metrics |\n| GET | `/destinations/:destination_id/events` | Events **delivered** to the destination (paginated via `--params '{\"limit\":N,\"after\":\"TOKEN\"}'`) |\n| GET | `/destinations/:destination_id/events/:event_id` | Single delivered event |\n| GET | `/destinations/:destination_id/errors_summary` | Error counts by type |\n| GET | `/destinations/:destination_id/errors/:error_type` | Errors of a type |\n| GET | `/destinations/:destination_id/errors/:error_type/:event_id` | One error with full payload |\n| GET | `/destinations/:destination_id/exports` | Download error data as a file export |\n| POST | `/destinations/:destination_id/request_data_replay` | Ask support to replay historical data from sources |\n\n### OAuth connections\n\nSame pattern as sources — see `sources.md` § OAuth Connections.\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/destinations/connect` | OAuth provider config (`--params '{\"option_id\":\"X\"}'`) |\n| GET | `/destinations/:destination_id/connect` | Connection status |\n| POST | `/destinations/:destination_id/connect` | Establish. Body: `{ \"authorizationCode\", \"redirectUri\" }` |\n| DELETE | `/destinations/:destination_id/connect` | Revoke |\n| POST | `/destinations/:destination_id/force_token_refresh` | Force token refresh |\n\n---\n\n## Link the user to the UI\n\nAfter configuring, link the destination's actions page:\n`https://fly.customer.io/workspaces/$WORKSPACE_ID/pipelines/destinations/:destination_id/subscriptions`\n","journeys_integrations.md":"---\nminimum_plan: essentials\n---\n\n# Journeys-Native Integrations\n\n## Segment Integration Types\n\nCustomer.io has **four** Segment integrations:\n\n| Name | Direction | API | What it does |\n|---|---|---|---|\n| **Segment (Data In)** | Inbound | Fly API (`segment_in`, read-only) or CDP source | Brings data FROM Segment INTO Customer.io |\n| **Segment (Message Activity)** | Outbound | Fly API (`segment_out`) | Sends message events (opens, clicks, bounces) to Segment via write key |\n| **Segment (Advanced)** | Outbound | Fly API (`cdp_out`) | Sends message events through CDP pipeline with event filtering, content forwarding, identity resolution, EU endpoint |\n| **Segment destination** | Outbound | CDP destination | Full Pipelines destination with subscriptions, action mappings, per-event-type filtering |\n\n## Mixpanel / Amplitude Integration Types\n\nEach has **two** integration paths:\n\n| Name | API | What it does |\n|---|---|---|\n| **Message Activity** | Fly API (`data_out/:identifier`) | Forwards message events (opens, clicks, etc.) |\n| **Pipelines destination** | CDP destination | Full destination with subscriptions, field mappings, per-event-type control |\n\n\u003e **A journey/automation step cannot sync a profile *into* a Pipelines destination.** A destination (Segment destination, Mixpanel/Amplitude Pipelines destination) is fed by the data pipeline from your CDP *sources* — it has no inbound endpoint a workflow writes to, and the **Message Activity** / **Advanced** integrations only forward *message events* (opens, clicks, bounces), not profile syncs. So when someone asks to \"sync a profile to our Segment/Pipelines destination\" from a journey, explain it's pipeline-fed and build nothing. (A webhook action posting to a CDP *source's* HTTP endpoint can emit a source event the pipeline routes onward — offer that only if they actually want to emit a source event, not as a stand-in for the sync.)\n\n## What this API manages\n\nThese integrations use Fly API endpoints (`/v1/environments/:eid/integrations/...`), not the CDP API base URL.\n\n- **Segment inbound** — read and disconnect via `/segment_in`: check whether a Segment connection exists, and delete it. The connection itself is established from Segment's dashboard, so there is no create or update\n- **Segment (Message Activity)** — full CRUD via `/segment_out`: sends message events (opens, clicks, bounces) to Segment via write key\n- **Segment (Advanced)** — full CRUD via `/cdp_out`: sends message events through the CDP pipeline with event filtering, content forwarding, identity resolution, and EU endpoint support\n- **Data out** — Mixpanel (Message Activity), Amplitude (Message Activity), and other analytics (keyed by string `:identifier`, not numeric ID)\n- **mParticle** — full CRUD via `/mparticle`: connect with an mParticle key and secret (the user enters these in the app, not the chat), read the connection, update its event list and settings, and disconnect\n- **Warehouse sync** — data export to S3, GCS, Azure, Yandex (feature-gated)\n- **Ad audiences** — sync segments to Facebook and Google Ads custom audiences\n- **Ad platform credentials** — OAuth tokens for ad platforms (Facebook, Google Ads), which the user connects in the app; full CRUD: create (with OAuth token data), read, update (to refresh expired tokens), and delete\n\n## What this API does NOT manage\n\n- **Pipelines sources/destinations** — can be listed but cannot be created, updated, or deleted via this API.\n- **Partner-built integrations** (Zapier, Census, Hull, Stitch) — catalog entries link out to the partner.\n- The `/segdotcom` endpoints configure the older generation of this connector and are still live — a record with `legacy: true` can only be written through them, and they refuse a current-generation one with a 409. `/segment_out` is where the current generation is created and updated; it always creates the current generation.\n\n## Gotchas\n\n- **Data out `:identifier`** is a string key (e.g. `\"mixpanel\"`), not a numeric ID.\n- **mParticle update response** wraps in `\"webhook\"`, not `\"mparticle\"` — known API quirk. Create and read both use `\"mparticle\"`; only the update reply differs.\n- **Segment inbound** cannot be created or updated from Journeys — it's connected from the Segment dashboard. Journeys can only read the connection and delete it.\n- **Warehouse sync `update_type`** — when updating, set this field to indicate what changed: `\"settings\"`, `\"s3_config\"`, `\"gcs_config\"`, `\"yandex_config\"`, `\"azure_config\"`, or `\"schema_version\"`.\n- **Ad audiences require credentials first** — a valid `audience_credential` must exist before creating an audience.\n- **One audience per segment + ad account** — duplicates are rejected.\n- **Audience consent** — `has_consent` is the customer's confirmation that a Google Ads (`adwords`) audience complies with Google's consent requirements where they apply. Customer.io requires it to create or enable such an audience; a request without `has_consent: true` is rejected with a validation error on `has_consent`. It can only be set to `true` (cannot revoke). Use `bulk_consent` for batch updates; it also moves audiences parked in `error` for missing confirmation back to `pending`.\n- **Credential cascading delete** — deleting an audience credential also deletes all associated audiences of that type.\n- **Credential expiry** — check the `expired` field; the user re-authenticates by reconnecting the ad platform in the app.\n\n","reverse_etl.md":"---\nminimum_plan: essentials\n---\n\n# CDP API: Reverse ETL \u0026 Database Configs\n\nReverse ETL runs a SQL query against an external database on a schedule and sends the resulting rows back into CDP as customer data. Database configs store the shared connection credentials that reverse ETL jobs (and database-type sources) use.\n\n## Reverse ETL\n\nReverse ETLs are what the Pipelines UI calls \"database syncs\". The Journeys feature also named \"database syncs\" (SQL imports) is a different product surface — see the `fly_api` skill's `database_syncs.md`.\n\n\u003e **Setting one up?** The guided workflow — use-case → query authoring (reserved columns, object types, relationship attributes, `{{last_sync_time}}`) → preview → interval → run watching → Journeys verification — is in `cio skills read cdp-api/reverse_etl_setup.md`. This file is the endpoint reference.\n\n\u003e **Work turned into changing a destination action?** Troubleshooting a sync often ends at the destination that consumes it — a missing profile, object, or relationship traced to the action that writes it. Stop and read `cio skills read cdp-api/destinations.md` (and `cio skills read cdp-api/cio_destination.md` for the native Customer.io destination) before adding or editing any action. The `action` key must be a key of the destination manifest, and nothing in this file tells you what those keys are.\n\nA reverse ETL config defines: a SQL query, a payload `type` (`identify`, `group`, `track`, `page`, `screen`, or `alias` — selects what the rows become), a schedule (`query_interval` in seconds, min 60), and a reference to a database config for the connection. Supported database types: `mysql`, `postgres`, `redshift`, `snowflake`, `bigquery`, `sqlserver`, `databricks`.\n\nBase: `/cdp/api/workspaces/:workspace_id/reverse_etls`\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/reverse_etls` | List all reverse ETL configs |\n| POST | `/reverse_etls` | Create reverse ETL. Key fields: `query`, `query_interval` (seconds, min 60), `database_config_id`, `type` |\n| GET | `/reverse_etls/:id` | Get reverse ETL details |\n| PUT | `/reverse_etls/:id` | Replace reverse ETL — full object required, see warning below |\n| DELETE | `/reverse_etls/:id` | Delete reverse ETL |\n| POST | `/reverse_etls/preview` | Preview — run the query and return sample rows without ingesting |\n| GET | `/reverse_etls/meta/:type` | Get metadata for a database type (supported column types, features) |\n\n\u003e **PUT is a full replace, not a patch.** `GET /reverse_etls/:id` first and resend the complete object,\n\u003e changing only what you intend — a partial body zeroes everything you left out, disabling the sync or\n\u003e blanking its name (`active` omitted = sync silently disabled). `source_id` is create-only and ignored here.\n\u003e When exactly one sync matches the user's description, proceed with the edit; ask only when several match.\n\n**Request bodies are wrapped, responses are flat.** POST/PUT take `{\"reverse_etl\": { ...fields... }}`; an\nunwrapped body fails with misleading validation errors (e.g. \"query_interval must be at least 60 seconds\"\neven though you sent it). Responses return the object **without** the wrapper — `.id`, not `.reverse_etl.id`.\nThe same convention applies to `database_configs` (`{\"database_config\": {...}}`) and `sources` (`{\"source\": {...}}`).\nIf a response looks empty, your jq path is wrong — **re-check with a GET; never re-send a create**, it isn't\nidempotent and will make a duplicate sync. Worked update:\n\n```\nGET /reverse_etls/42                          → flat object {id, name, query, query_interval, active, ...}\nPUT /reverse_etls/42  {\"reverse_etl\": {\u003cevery field from the GET, with only your change applied\u003e}}\n```\n\n### Runs\n\nEach scheduled execution produces a run with status, row counts, and error details. Statuses: `scheduled` → `querying` → `running` → `done`, or `failed` / `canceled` / `skipped` (a sync whose previous run is still active is skipped, not stacked).\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/reverse_etls/:id/runs` | List runs (paginated) |\n| GET | `/reverse_etls/:id/runs/:run_id` | Get run details (status, processed/failed/suppressed row counts) |\n| GET | `/reverse_etls/:id/runs/:run_id/errors` | Download run errors as a file |\n\nReverse ETL runs also appear under sources: `GET /sources/:source_id/reverse_etl_runs`.\n\n## Database Configs\n\nStored connection credentials for external databases. Each config specifies the database type, host/port, authentication method, and optional SSH tunnel (bastion) for secure connections.\n\nBase: `/cdp/api/workspaces/:workspace_id/database_configs`\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/database_configs` | List all database configs |\n| POST | `/database_configs` | Create config (see structure below) |\n| GET | `/database_configs/:id` | Get database config — credentials are never returned (no `password` / `pem_private_key` in the response) |\n| PUT | `/database_configs/:id` | Replace database config — also a full replace; the stored `config` is rebuilt from the body (never merged), and an omitted `database_config_ssh_key_id` detaches the SSH key |\n| DELETE | `/database_configs/:id` | Delete database config |\n\n### Create/Update Structure\n\n```json\n{\n  \"database_config\": {\n    \"name\": \"Production Postgres\",\n    \"type\": \"postgres\",\n    \"config\": {\n      \"host\": \"db.example.com\",\n      \"port\": 5432,\n      \"username\": \"readonly\",\n      \"password\": \"...\",\n      \"database\": \"analytics\",\n      \"ssl\": true\n    }\n  }\n}\n```\n\nType-specific `config` fields:\n- **BigQuery**: `project_id` instead of host/port\n- **Snowflake**: add `role`, `warehouse`\n- **SSH tunnel**: add `bastion: { \"host\", \"port\", \"user\" }` and set `database_config_ssh_key_id` (create key first via SSH Keys endpoint)\n- **PEM auth**: use `pem_private_key` instead of `password`\n\n\u003e **Credentials can't be round-tripped, and they don't go through the chat.** The GET response omits\n\u003e `password` and `pem_private_key`, so a GET body is not a usable PUT body. Don't ask the user for the\n\u003e secret. They create or update the connection in the app: a new one through\n\u003e `https://fly.customer.io/workspaces/$WORKSPACE_ID/pipelines/sources/add?source=\u003coption_id\u003e`, an\n\u003e existing source's connections at `https://fly.customer.io/workspaces/$WORKSPACE_ID/pipelines/sources/\u003csource_id\u003e/databases`.\n\n### SSH Keys\n\nGenerate and manage SSH key pairs for bastion/tunnel authentication.\n\nBase: `/cdp/api/workspaces/:workspace_id/database_config_ssh_keys`\n\n| Method | Path | Description |\n|--------|------|-------------|\n| POST | `/database_config_ssh_keys` | Generate a new SSH key pair (returns public key to add to bastion) |\n| GET | `/database_config_ssh_keys/:id` | Get the public key for an existing key pair |\n","reverse_etl_setup.md":"---\nminimum_plan: essentials\n---\n\n# Setting Up a Reverse ETL Source (Pipelines Database Sync) — Guided Workflow\n\nEnd-to-end workflow for connecting a customer's database as a Pipelines source and syncing rows into Journeys as **people, objects, relationships, or events**. Endpoint reference (request/response shapes, PUT semantics, credentials): `cio skills read cdp-api/reverse_etl.md`.\n\n\u003e **Not Journeys \"SQL Import\".** Journeys has an older data-in feature also called database syncs (SQL import) — that's `fly-api/database_syncs.md`. This file is the Pipelines Reverse ETL product.\n\n**The pieces**: a **source** (`option_id` = database type; holds one or more syncs — its syncs all flow to the source's connected destinations) + a **database config** (connection credentials, reusable across syncs) + the **reverse ETL** itself (SQL query, payload `type`, `query_interval`). Each scheduled execution is a **run**. Rows become CDP messages that flow to the source's destinations — for Journeys data, through the auto-created **Customer.io (Journeys) destination** (`option_id: \"customerio\"`).\n\n## Step 1 — Understand the use case, pick the sync `type`\n\nEach sync has exactly one payload `type` — one sync per data shape (a users sync, a companies sync, a memberships sync are three syncs):\n\n| Customer goal | `type` | Required columns | Key optional columns |\n|---|---|---|---|\n| People / profiles in Journeys | `identify` | `userId` (or `anonymousId`) | any other column → profile attribute |\n| Objects (companies, accounts, …) | `group` | `groupId` + `object_type_id`\\* | any other column → object attribute |\n| Person↔object relationships | `group` | `groupId` + `userId` + `object_type_id`\\* | `relationshipAttributes` (JSON) |\n| Events, device management, deletions | `track` | `userId` (or `anonymousId`) + `event` | `properties.*`, `context.*` |\n\n\\* The API won't reject a `group` sync without `object_type_id` — it **silently defaults to `\"1\"`**, pouring every row into whatever object type happens to have id 1. Treat it as required (Step 2): mis-typed objects pollute the workspace and cleaning them up is a manual chore for the customer. Never enable an object or relationship sync without an explicit, verified `object_type_id`.\n\n(`page`, `screen`, `alias` also exist but are rare for database syncs.)\n\n## Step 2 — Know the Journeys data shape before writing SQL\n\n- **Object type ID**: a `group` sync targets one Journeys object type by numeric ID. List them with the Fly API — `GET /v1/environments/:environment_id/object_types` — and use the matching `id`. **Never guess and never rely on the default**: an omitted or wrong `object_type_id` silently lands every row in the wrong type (the default is `\"1\"`), and undoing that means manually deleting the mis-typed objects.\n- **No suitable object type yet?** Create it before writing the sync — `POST /v1/environments/:environment_id/object_types` with `{\"object_type\": {\"name\": \"Accounts\", \"slug\": \"accounts\", \"singular_name\": \"Account\", \"singular_slug\": \"account\"}}` — and use the `id` from the response. Confirm the intended name with the customer first (the type's name is what their team sees all over Journeys), and mind the workspace cap (`max_allowed_object_types` on `GET /v1/environments/:environment_id`); details in `fly-api/customers.md`.\n- **Attribute naming**: match the workspace's existing profile attribute names (check a profile or the attributes list via the Fly API) — `plan` vs `plan_name` creates duplicate attributes.\n\n## Step 3 — Database config (connection)\n\nAsk the customer to create a **read-only database user** granted SELECT on just the synced schema — the sync never needs writes, and query validation is not a substitute for least privilege. Customer.io's IPs must be allowlisted on the customer's firewall (docs list them; or use the SSH bastion option).\n\n**The customer enters the connection in the app, not in the chat.** A config holds a password, access token, or private key, and those don't belong in the conversation. Check `GET /database_configs` for an existing config first. If there is none, give them the link in the same reply: `https://fly.customer.io/workspaces/$WORKSPACE_ID/pipelines/sources/add?source=\u003coption_id\u003e` (e.g. `?source=databricks`); that flow creates both the source and the database config, and they can finish it without a query. Don't create the source yourself first, or they end up with two. If they already pasted a password or token, don't use it or repeat it: tell them it is now in the chat history, so they should rotate it and enter the new one. Then pick up the new source (`GET /sources`) and config (`GET /database_configs`) and continue from Step 5. Creating the connection live-tests it (`SELECT 1`, 10s timeout), so a failure there means host/port/credentials/allowlisting, not query problems.\n\n## Step 4 — Reuse or create the source\n\nA source holds many syncs, so **adding another sync to a database that already has a source means reusing that source** — check `GET /sources` for one with the matching database `option_id` before creating. **When reusing, verify the source is enabled** — the source object carries an `enabled` boolean, while `GET /sources/:source_id/status` returns a status string (`enabled`, `disabled`, or `no_recent_data`; only `disabled` means the scheduler skips its syncs — `no_recent_data` is an enabled source that simply hasn't seen events). The scheduler only picks up syncs whose source is enabled, so a disabled source silently freezes every sync on it — no runs, no errors, nothing to watch. Re-enable it (with the customer's OK; per `sources.md`, PUT replaces `name`/`enabled` outright and clears `settings` if omitted — resend them) or attach to an enabled source. Create a separate source only for a reason: different destination routing (a source's syncs all feed its connected destinations) or separate per-source monitoring. When the connection is new, the add-source flow in Step 3 creates the source, so skip this. Otherwise, to create one: `POST /cdp/api/workspaces/:workspace_id/sources` with `{\"source\": {\"name\": \"...\", \"option_id\": \"\u003cdb type\u003e\"}}`. Database `option_id`s: `mysql`, `postgres`, `redshift`, `snowflake`, `bigquery`, `sqlserver`, `databricks`. On creation the Journeys destination is connected automatically and enabled by a background loop within ~10 seconds — no manual step, but verify it's enabled (Step 9) before expecting data in Journeys.\n\n## Step 5 — Discover the source schema\n\nCheapest first: **ask the customer** for the table/column layout. To probe yourself, run schema queries through the preview endpoint (Step 7) — e.g. `SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = '\u003cschema\u003e'` (works on MySQL/Postgres/Snowflake/SQL Server; Redshift also has `SVV_COLUMNS`, BigQuery uses `\u003cdataset\u003e.INFORMATION_SCHEMA.COLUMNS`).\n\n\u003e **⚠️ Previews cost real money on metered warehouses.** Every preview executes the query against the customer's database. BigQuery bills by bytes scanned (a `LIMIT` does **not** reduce scan cost), Snowflake resumes/bills the warehouse, Databricks bills the SQL warehouse. Before probing a metered warehouse, tell the customer previews run billed queries and get their OK; keep probes few and targeted. Only SELECT-shaped statements are safe to assume supported — DDL/DML is rejected (`forbidden_query`), and utility commands (`SHOW`, `DESCRIBE`, `EXPLAIN`) aren't guaranteed across connectors — prefer `information_schema`.\n\n## Step 6 — Write the query\n\n**Column names select behavior — matching is case-insensitive.** Reserved names: `userId`, `anonymousId`, `messageId`, `timestamp`, `event` (track), `groupId` (group/track), `objectTypeId` (track), `relationshipAttributes` (group), `name` (page/screen), `previousId` (alias). Prefixes `traits.`, `properties.`, `context.`, `integrations.` nest explicitly (quote dotted aliases: backticks in MySQL/Databricks, `\"double quotes\"` in Postgres/Snowflake, `[brackets]` in SQL Server). Every non-reserved column becomes a trait (`identify`/`group`) or property (`track`/`page`/`screen`).\n\n\u003e **The column aliases ARE the mapping — there is no other mapping surface.** Do not invent one: there is no `mapping` field on a reverse ETL (unknown fields can 500), no `relationship` sync type (relationships are `group` syncs with `userId` + `groupId`), and no `groupType`/slug-based routing (object routing is the numeric `object_type_id` column only).\n\n\u003e **Don't alias data onto a reserved name by accident.** A column aliased `timestamp` becomes the message timestamp and is **dropped from the traits** — e.g. `signed_up_at AS timestamp` silently loses the signup date as a profile attribute. Use `timestamp` only when the row time should override processing time (event-history imports); keep dates the customer wants stored (`signed_up_at`, `created_at`) as plain trait columns.\n\n**People** (`type: identify`):\n\n```sql\nSELECT user_id AS userId, email, first_name, plan, lifecycle\nFROM users\nWHERE updated_at \u003e FROM_UNIXTIME({{last_sync_time}})\n```\n\n**Objects** (`type: group`, no `userId` → object-only upsert):\n\n```sql\nSELECT company_id AS groupId, '2' AS object_type_id, name, industry, employee_count\nFROM companies\nWHERE updated_at \u003e FROM_UNIXTIME({{last_sync_time}})\n```\n\n**Relationships with attributes** (`type: group`, `userId` links the person; `relationshipAttributes` must arrive as a JSON **object**, so build it with the database's JSON constructor — `JSON_OBJECT` (MySQL/SQL Server), `json_build_object` (Postgres), `OBJECT_CONSTRUCT` (Snowflake), `TO_JSON(STRUCT(...))` (BigQuery), `named_struct` (Databricks)):\n\n```sql\nSELECT m.user_id AS userId, m.company_id AS groupId, '2' AS object_type_id,\n       JSON_OBJECT('role', m.role, 'title', m.title, 'level', m.level) AS relationshipAttributes\nFROM memberships m\nWHERE m.updated_at \u003e FROM_UNIXTIME({{last_sync_time}})\n```\n\nA relationship-focused sync may repeat object traits harmlessly, but object attributes belong in the objects sync. (An `identify` sync can also attach a relationship from the person side via `objectId` + `object_type_id` trait columns.)\n\n**Semantic events** (`type: track` with a reserved event name — the Journeys destination routes these to special actions instead of tracking a plain event):\n\n| `event` value | Effect | Required columns beyond `userId` + `event` |\n|---|---|---|\n| `Device Created or Updated` | upsert device | `` `context.device.token` ``, `` `context.device.type` `` (`ios`/`android`) |\n| `Device Deleted` | remove device | `` `context.device.token` `` |\n| `User Deleted` | delete person | — |\n| `User Suppressed` / `User Unsuppressed` | (un)suppress person | — |\n| `Object Deleted` | delete object | `` `properties.objectId` `` (+ `` `properties.object_type_id` ``) |\n| `Relationship Deleted` | remove relationship | `` `properties.objectId` `` (+ `` `properties.object_type_id` ``) |\n\n```sql\nSELECT user_id AS userId, 'User Deleted' AS event, requested_at AS timestamp\nFROM deletion_requests\nWHERE updated_at \u003e FROM_UNIXTIME({{last_sync_time}})\n```\n\n**Incremental syncs — always prefer `{{last_sync_time}}`.** It substitutes as a bare **unix-epoch integer (seconds, UTC)**; on the first run it's `0`, so the same query does the initial backfill. It's set from the start time of the last run that ended `done`. Convert it to the column's type with an index-friendly comparison:\n\n| Database | Pattern |\n|---|---|\n| MySQL | `updated_at \u003e FROM_UNIXTIME({{last_sync_time}})` |\n| Postgres | `updated_at \u003e TO_TIMESTAMP({{last_sync_time}})` |\n| Redshift | `updated_at \u003e TIMESTAMP 'epoch' + {{last_sync_time}} * interval '1 second'` |\n| Snowflake | `updated_at \u003e TO_TIMESTAMP_NTZ({{last_sync_time}})` |\n| BigQuery | `updated_at \u003e TIMESTAMP_SECONDS({{last_sync_time}})` |\n| SQL Server | `updated_at \u003e DATEADD(SECOND, {{last_sync_time}}, '1970-01-01')` |\n| Databricks | `updated_at \u003e= timestamp_seconds({{last_sync_time}})` |\n\nWithout it, every run re-reads the full table and relies on **deduplication as a last resort**: rows without a `messageId` column get a content-hash message ID (SHA-256 of sync ID + type + the sync's `updated_at` + all sorted column name/value pairs), and repeats are dropped downstream. That still executes the full query each run (cost!) — and because the hash includes the sync's `updated_at`, **any edit to the sync re-imports every row on the next run**. Provide a `messageId` column only for a stable natural key; otherwise omit it.\n\n**Validation warnings are advisory** (`not_a_select_query`, `select_star_query`, `limit_not_present`, `last_sync_time_without_unix_timestamp`, `sensitive_fields_found`) — they don't block saving or preview. `limit_not_present` is case-insensitive and is skipped when the query uses `{{last_sync_time}}`. The `last_sync_time` hint only recognizes `UNIX_TIMESTAMP`, `EXTRACT(EPOCH FROM …)`, `DATE_PART('epoch', …)`, `TIMESTAMP_SECONDS(…)`, and `FROM_UNIXTIME(…)`, so the Postgres, Redshift, Snowflake, and SQL Server patterns above still trip it even though they're correct. Fix real issues (`SELECT *`, password/credit-card columns), don't churn the query to silence cosmetic ones. Only `forbidden_query` (DDL/DML) is a hard error.\n\n## Step 7 — Preview before enabling\n\n`POST /cdp/api/workspaces/:workspace_id/reverse_etls/preview`:\n\n```json\n{\"query\": \"…\", \"type\": \"identify\", \"database_config_id\": 12, \"last_sync_time\": 0, \"limit\": 10}\n```\n\n- `limit` (1–100000) appends `LIMIT N` — omit it for SQL Server (T-SQL has no LIMIT; the call errors). **Don't also write `LIMIT` inside the query** — the appended clause lands after yours and the SQL breaks. If that happens, drop the in-query `LIMIT` and keep the param (not the reverse — a param-less preview executes the full table, the costly choice on metered warehouses).\n- `last_sync_time: 0` shows the first-run backfill; a recent epoch simulates a later incremental run.\n- **Always send `type`.** Without it the response carries `error: \"unknown type\"` and validation runs generically — demanding `userId` even for a group query. Fix that by adding `\"type\"`, never by padding the query with `NULL AS userId` (which risks a bogus \"null\" person relationship at run time).\n- 20s timeout; response shows ≤100 rows plus `validations` (warnings/errors). Type-required columns are checked against the actual result (e.g. `id_not_present`).\n- Confirm in the returned columns/rows: reserved columns present and populated, `relationshipAttributes` is a JSON object (not a quoted string), `object_type_id` is the intended ID.\n- Cost warning from Step 5 applies — this executes the real query.\n\n## Step 8 — Create the sync and choose the interval\n\n`POST /cdp/api/workspaces/:workspace_id/reverse_etls` with `{\"reverse_etl\": {\"source_id\", \"name\", \"database_config_id\", \"query\", \"type\", \"query_interval\", \"start_at\", \"active\": true}}`. Leave `stringify_fields` unset — enabling it turns JSON columns (e.g. `relationshipAttributes`) into strings. The response is the **flat** created object (`.id`, no `reverse_etl` wrapper) — if your parse comes back empty, fix the parse and confirm with `GET /reverse_etls`; **never re-send the POST**, creates aren't idempotent and a retry leaves a duplicate active sync hammering the customer's database.\n\n\u003e **Destructive syncs arm on create.** An `active: true` sync with no future `start_at` runs almost immediately — including its first-run full backfill. For syncs that delete or suppress (semantic events like `User Deleted`), build what they asked for but leave arming to them: create it with `active: false`, state the consequences (who gets deleted or suppressed, including the backfill), and tell them how to enable it. Create it active only after they have explicitly OK'd those consequences.\n\n**Interval (`query_interval`, seconds, min 60):** syncs across workspaces are processed by a fair scheduler that gives each workspace a round-robin turn, and syncs within one workspace queue up FIFO for that turn — so **a workspace's syncs compete with each other**. A sync whose previous run is still going is skipped, not stacked. When configuring multiple syncs:\n\n- Pick intervals comfortably longer than a typical run (watch a run first, then tune down).\n- Prefer 15m–24h; per-minute intervals on multiple syncs starve each other and hammer the source DB.\n- Stagger `start_at` (unix epoch) so sibling syncs don't all become due at the same instant. **Compute it from `date +%s`** — never estimate the current epoch from memory (a guessed \"now\" that's in the past makes every sync due immediately, silently defeating the stagger), and note a past `start_at` means the first run begins right away.\n- Non-incremental (full-table) syncs deserve the longest intervals — every run re-reads and re-hashes everything.\n\n## Step 9 — Watch the next run\n\nPoll `GET /reverse_etls/:id/runs` (newest first). Statuses: `scheduled` → `querying` → `running` → `done`, or `failed` / `canceled` / `skipped`. On the finished run check:\n\n- `total_rows` matches expectation (first run ≈ full table; incremental runs ≈ recent changes),\n- `failed_rows` = 0 — failures are per-row transform errors (missing required column values); download details via `GET /reverse_etls/:id/runs/:run_id/errors`,\n- `skipped_rows` — rows dropped by suppression,\n- `error` — whole-run failures; \"import timed out\" means the query exceeded the workspace timeout (default 6h).\n\nZero-row runs go straight to `done` — with `{{last_sync_time}}` that's the normal quiet state, not a failure.\n\n**No run appearing at all** is a different signal: check that the sync is `active` **and its source is `enabled`** — a disabled source means the scheduler never queues the sync, with no error anywhere. Also note the runs list can lag a completed run; the sync's own `last_successful_run_at` is the authoritative \"it ran\" check.\n\n## Step 10 — Verify the data reached Journeys\n\n1. **Data-out (CDP side)**: find the Journeys destination — `GET /destinations` (`option_id: \"customerio\"`) — confirm `enabled: true`, then inspect `GET /destinations/:destination_id/events?source_id=:source_id` (request/response log against the Journeys Track API), `/metrics`, and `/errors_summary`. Group calls arrive as \"Create or Update Object\" actions, identify as \"Create or Update Person\", semantic events under their action names.\n2. **Landed data (Journeys side, Fly API — see `fly-api/customers.md`)**: search profiles and check attributes; activity logs via `GET /v1/environments/:eid/logs?customer_id=\u003cexternal id\u003e\u0026type=event` (there is no per-profile `/activities` sub-resource; omit `customer_id` for the workspace-wide stream); objects via `GET /v1/environments/:eid/object_types/:type_id/objects` and the object's relationships/attributes.\n\n## Pitfalls\n\n- **Any sync edit ⇒ full re-import** next run (content-hash includes the sync's `updated_at`). Expected once after setup tuning. Mention it with the edit; ask first only when the user hasn't already told you to apply it.\n- **PUT is a full replace** — read, modify, resend the whole object (`reverse_etl.md`).\n- **Group without `userId`** upserts the object only — no relationship is created.\n- **Missing `object_type_id` defaults to `\"1\"`** — the rows import \"successfully\" into the wrong object type, and recovery is manual (delete the mis-typed objects, e.g. via an `Object Deleted` track sync over the same ids, then re-import). Catch it at preview time, not after the first run.\n- **`timestamp` column** is optional (defaults to processing time); provide it when historical event times matter (accepts epoch int, ISO-8601 string, or native datetime).\n- **`ignore_empty_values: true`** skips NULL/empty columns instead of overwriting existing attribute values with blanks — usually what customers want for sparse tables.\n","sources.md":"# CDP API: Sources\n\nSources bring data into Customer.io from external systems (Salesforce, HubSpot, Segment, etc.) or via API.\n\nBase: `/cdp/api/workspaces/:workspace_id/sources`\n\n## Core CRUD\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/sources` | List all sources in workspace |\n| POST | `/sources` | Create source. Body: `{ \"source\": { \"name\", \"option_id\", \"settings\" } }` |\n| GET | `/sources/:source_id` | Get source details |\n| PUT | `/sources/:source_id` | Update source. Body: `{ \"source\": { \"name\", \"enabled\", \"settings\", \"geolocation_enabled\" } }` |\n| DELETE | `/sources/:source_id` | Delete source |\n\n`option_id` is the connector identifier — get options from `GET /cdp/api/workspaces/:workspace_id/available_sources`.\n\n**Gotchas:**\n- Salesforce sources require `settings.sync_type` (`\"pull\"` or `\"push\"`) at creation — immutable after\n- Premium sources require an account feature flag — but a missing entitlement returns a **specific premium/plan error**, never a bare `401` (see the auth callout below)\n- Settings are **merged** on update, not replaced — but only when you send them; omitting `settings` clears the stored ones. `name` and `enabled` are replaced outright, so GET first and resend the complete object\n- Cannot disable a source with Journeys sources connected\n- Passwords/secrets are auto-encrypted; API returns placeholders\n\n\u003e **OAuth connector sources (Salesforce, HubSpot, …) are set up interactively — not via `POST /sources`.** Connecting them is an OAuth flow the customer completes in the Pipelines UI (**Connections → Add source → Connect**). Don't `POST /sources` to create an OAuth connector on the user's behalf; walk them to the add-source page instead: `https://fly.customer.io/workspaces/$WORKSPACE_ID/pipelines/sources/add?source=\u003coption_id\u003e` (e.g. `?source=salesforce`). The app host has **no** region prefix — don't build the link from `https://fly.customer.io` (that's the region-prefixed API host and 404s as a UI link).\n\u003e\n\u003e **A `401` / `AUTH_ERROR \"Not authenticated\" / \"permission denied\"` on a CDP write is an AUTH/access problem — NOT a customer plan or entitlement gate.** (API-type sources *are* created via `POST /sources`; a 401 there means the write wasn't authorized, not that the workspace lacks the feature. OAuth connectors like Salesforce aren't created by API at all — see above — so a 401 attempting to `POST`-create one is expected; use the UI/OAuth flow.) A genuine plan/premium-integration restriction returns a **distinct, specific** error message (e.g. a premium-integration/entitlement error), not a generic 401. **Never tell a customer their plan lacks Pipelines, or send them to support to \"enable\" it, based on a 401** — and a Pipelines UI link that 404s is not proof the feature is off (check the region host and that Pipelines is provisioned first). When a write is blocked, link the customer to the Pipelines connections UI to complete it themselves.\n\n## Available Sources\n\n`GET /cdp/api/workspaces/:workspace_id/available_sources` — workspace-scoped (the unscoped `/cdp/api/available_sources` returns 401). Returns connectors with `option_id`, `name`, `category`, `type` (`\"api\"` | `\"connector\"` | `\"database\"`), `content`, `learn_more_url`, and `code` — a per-source setup snippet template with `${...}` placeholders (e.g. the `javascript` source's `code` is the full browser snippet; placeholders `${apiKey}` = write key, `${analyticsKey}` = global var name, `${edgeUrl}` = region edge URL).\n\n## Status \u0026 Metrics\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/sources/statuses` | Status for all sources |\n| GET | `/sources/:source_id/status` | Single source status (`enabled`, `disabled`, `no_recent_data`) |\n| GET | `/sources/:source_id/metrics` | Event time series (`--params '{\"duration\":\"1h\",\"event\":\"all\"}'`). Duration: `1h`, `1d`, `7d`, `30d` |\n| GET | `/sources/:source_id/schema` | Data schema (traits/events) (`--params '{\"type\":\"identify\"}'`). Type: `identify`, `track` |\n| GET | `/sources/:source_id/events` | Sample events (`--params '{\"limit\":100,\"after\":\"TOKEN\"}'`; paginated via `after`/`before` tokens) |\n| GET | `/sources/:source_id/events/:event_id` | Single event details |\n| GET | `/sources/:source_id/settings` | Metrics settings (customerio source type only) |\n| PUT | `/sources/:source_id/settings` | Update metrics settings (`events` array, `send_duplicates` bool) |\n\n## Source-Destination Mappings\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/source_destination` | Query mappings (`--params '{\"source_id\":X,\"destination_id\":Y}'`) |\n| POST | `/source_destination` | Connect/disconnect. Body: `{ \"source_id\", \"destination_id\", \"connect\": bool }` |\n| PUT | `/source_destination` | Update connection settings |\n\n**Gotchas:**\n- Salesforce → Journeys connections require `settings.objects` with object type mappings\n- Cannot connect auto-track sources to Customer.io destinations\n\n## Source Syncs\n\nSyncs define how data flows from a connector source (e.g., Salesforce objects → Customer.io profiles).\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/sources/syncs` | List all syncs across all sources |\n| GET | `/sources/:source_id/syncs` | List syncs for a source |\n| POST | `/sources/:source_id/syncs` | Create sync. Body: `{ \"source_sync\": { \"name\", \"type\", \"enabled\", \"sync_interval\", \"mapping\", \"config\", \"backfill_type\" } }` |\n| GET | `/sources/:source_id/syncs/:sync_id` | Get sync details |\n| PUT | `/sources/:source_id/syncs/:sync_id` | Update sync. Outer field `confirm_restart: true` restarts immediately. |\n| PUT | `/sources/:source_id/syncs/:sync_id/toggle` | Toggle enabled. Body: `{ \"enabled\": bool }` |\n| DELETE | `/sources/:source_id/syncs/:sync_id` | Delete sync |\n| POST | `/sources/:source_id/syncs/preview` | Preview sync (dry run for Salesforce) |\n| POST | `/sources/:source_id/syncs/:sync_id/runs` | Trigger sync run. Body: `{ \"restart\": bool }` |\n\n**Key fields:** `type` (`\"pull\"` | `\"push\"`) must match source config. `sync_interval` in seconds (1–31536000, default 3600). `backfill_type`: `\"none\"` | `\"required\"`.\n\n### Sync Mapping Structure\n\nThe `mapping` field defines how source records map to CDP data. Discover available fields first via `GET /sources/:source_id/record_types`.\n\nKey mapping fields:\n- `record_type` — source object (e.g., `\"Contact\"`) from record_types response\n- `cdp_type` — transform type: `\"identify\"`, `\"group\"`, `\"track\"`, or `\"relationship\"`. A sync using `\"user\"` or `\"object\"` cannot be enabled, even if an incomplete mapping saved successfully.\n- `identifiers` — which source fields map to CDP identifiers (`user_id`, `group_id`, `object_name`, `event`)\n- People records (e.g. Contacts) use `identify`, object records (e.g. Accounts) use `group`, and join records linking the two (e.g. AccountContactRelation) use `relationship` — not `group`.\n- `identify` and `track` require `identifiers.user_id`; `group` requires `identifiers.group_id`; `relationship` requires both. For relationships, the values must match the identifiers used by the linked person and object syncs.\n- `fields` — array of source field names to sync\n- `renames` — object mapping source field names to CDP attribute names (`{\"MailingCity\": \"city\"}`)\n- `filters` — optional filters by lifecycle: `{\"created\": [{\"field\": \"Status\", \"operator\": \"eq\", \"value\": \"Active\"}]}`\n- `where_clause` — optional SOQL WHERE clause (Salesforce only)\n\n**Gotchas:**\n- Sync type must match source's configured type\n- Salesforce push syncs: cannot change record type after creation\n- Rename fields max 255 chars, no duplicate targets\n\n### Sync Runs\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/sources/:source_id/sync_runs` | List runs (`--params '{\"limit\":N,\"page\":N}'`; paginated). Optional: `show_only_errors`, `source_sync_id`, `exclude_zero_total_rows` |\n| GET | `/sources/:source_id/sync_runs/:run_id/errors/export` | Export errors as file |\n\n## OAuth Connections\n\nFor OAuth sources (Salesforce, HubSpot, etc.).\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/sources/connect` | Get OAuth provider config — authorize URL, client ID, scope (`--params '{\"option_id\":\"X\"}'`) |\n| GET | `/sources/:source_id/connect` | Get connection status for a source |\n| POST | `/sources/:source_id/connect` | Establish connection. Body: `{ \"authorizationCode\", \"redirectUri\" }` |\n| DELETE | `/sources/:source_id/connect` | Revoke connection |\n| POST | `/sources/:source_id/force_token_refresh` | Force token refresh. Body: `{ \"expires_at\": epoch_seconds }` |\n\n## API Keys\n\nFor API-type sources — authenticate data sent to the source.\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/sources/:source_id/api_keys` | List API keys |\n| POST | `/sources/:source_id/api_keys` | Create API key (no body) |\n| DELETE | `/sources/:source_id/api_keys/:api_key_id` | Delete API key |\n\n## Salesforce-Specific\n\n| Method | Path | Description |\n|--------|------|-------------|\n| GET | `/sources/:source_id/record_types` | Available record types (`--params '{\"object_type\":\"X\"}'`). Optional: `include_relationships` |\n| POST | `/sources/:source_id/validate_salesforce_filter` | Validate SOQL WHERE clause. Body: `{ \"where_clause\", \"sobject_name\", \"identifiers\" }` |\n"},"hashes":{"SKILL.md":"02dc3d5c0c839db3e1545445c524a97fc9acd766105eb1f232691f0049105ff8","cio_destination.md":"a8c5059d877ee98c03317ec0f2e9d4415fb32cd82c601490c82018337baadbe8","destinations.md":"eebff6370173632c6a6fbfa51e83b479d91ec990585e2ccd982683408bb96bee","journeys_integrations.md":"9e7348baaf6503b1ba6df08b10ad20bc93082c17261f6167402bbc464dc60c80","reverse_etl.md":"4e563fe5e9b08af330679d8cd0a896dfa6612d6ce29f3a7e06531f774feb6c77","reverse_etl_setup.md":"c3035db6766c0c43fb050599a3fba0255e7eca11d32a7834ed9a23a00d9f8a71","sources.md":"73917682bc7202bd9695f7f63a6c44431279a1f554e64ca02e2990af21806ee8"},"sizes":{"SKILL.md":8338,"cio_destination.md":10640,"destinations.md":33180,"journeys_integrations.md":5485,"reverse_etl.md":6832,"reverse_etl_setup.md":19962,"sources.md":9391}},{"path":"design-studio","name":"Design Studio API Reference","description":"Design Studio API — node CRUD, component properties, global styles, Journeys template linking, publishing, email review (QA findings + readiness score: is this email ready to send?), and accessibility checking.","content":"# Design Studio — Copilot Guidance\n\nDesign Studio workflows; sub-files hold endpoint details.\n\n\u003e **Liquid in email content?** Component text fields (and email subject/preheader at publish time) support Liquid — see `recipes/liquid_syntax.md` for syntax, filters, and pitfalls.\n\n\u003e **One surface: `cio api`.** All Design Studio operations are `cio api` calls under `/v1/environments/$ENV_ID/...`, split by what you address. **`/design_studio/...` — typed content resources:** full CRUD per type (`emails`, `folders`, `components`, `sms`, `whatsapp`, `in_app`, `inbox`), each with its own typed body; plus per-message **link** and **publish** for email, SMS, WhatsApp and inbox (see `journeys.md`), email **translations**, `unpublished_changes`, **render**, and global styles. **`/ds/...` — node-level operations** addressed by node id, type-agnostic: search, linkable, bulk move/delete, feedback, component definitions and references, versions, global styles. Bodies are **snake_case**. See `nodes.md` for the full route list.\n\u003e\n\u003e **Query values travel in `--params`, as a JSON object** — never as `?k=v` on the path, which `cio api` rejects. A route written in the sub-files as `path?a=b` names its parameters, not the command line. Full rule and per-route traps: **nodes.md**.\n\u003e\n\u003e \"Parcel\" is the backend service behind Design Studio. You'll see it in literal API values (`editor: \"parcel\"`, `$extensions[\"io.parcel.global-styles\"]`) — keep those strings exactly. Outside those values, refer to the surface as \"Design Studio.\"\n\n\u003e **Design Studio email IDs are UUIDs.** Use the UUID in a node's `id` field from `GET /design_studio/emails` or `GET /ds/node_actions/linkable`; never substitute a numeric Journeys or legacy template ID. Numeric IDs belong to `/templates/{template_id}`, not `/design_studio/emails/{id}`. To turn a template ID into its email's UUID, call `GET /ds/journeys/{template_id}` and read `node_id`.\n\n\u003e **Bee migration?** Read **journeys.md § Convert an existing Bee email**. Agents may migrate only non-live messages; live conversions go to the user in the UI. For Bee edits, read `@skills/fly-api/templates.md`.\n\n\u003e **Color, token, or button-style request?** Read **global_styles.md** and fetch the workspace styles before proposing exact markup or making an edit, even when the user asks for a preview only. Match generic colors by token value; resolve named tokens by name. If names collide after removing case and punctuation, ask which one the user means before writing.\n\n## Routing\n\n\u003c!-- subfile-index: rows are auto-annotated at serve time with the plan/feature\n     each linked file requires; the gate lives in each sub-file's frontmatter\n     (minimum_plan / feature_flag), not here. Don't restate it in a row. --\u003e\n| File | When to read |\n|------|-------------|\n| **this file** | Workflow and behavioral rules — routing, editor choice, live-automation safety, the pre-flight steps, custom components, render-failing prop mistakes, validation, UI links |\n| **component_syntax.md** | **Component syntax** — property syntax (static vs `:` dynamic, numeric-only props), common mistakes, HTML text tags, Liquid \u0026 interpolation, responsive `{ small, medium }` / dark-mode `{ light, dark }` objects, `globalStyles.*` token paths and `Component.utils`, image alt/layer labels. Read before writing any `\u003cx-*\u003e` markup |\n| **components_reference.md** | Per-component property reference (layout, text, media, utility); also § Troubleshooting for rendering errors, deprecated components (`x-columns`), debugging |\n| **custom_components.md** | **Create / edit a reusable custom component** (header, footer, button, card) — guided build, required `\u003cscript\u003e`/`\u003ctemplate\u003e` file structure, property types, slots, presets, best practices |\n| **email_patterns.md** | **Email patterns index** — read it before writing any email, whatever the type. It maps the email onto its per-type pattern file (band anatomy, structural variants, subject/preheader and CTA patterns, a converted example), gives that file's exact path, and says what to do when no type fits |\n| **whatsapp_patterns.md** | WhatsApp templates — create / link / publish, categories, language codes, body patterns, Meta approval pitfalls |\n| **nodes.md** | **API** — create, get, update, delete nodes, folders (`/ds/folders` breadcrumbs), custom components, search |\n| **nodes_advanced.md** | API — versions and restore, render, bulk ops, translations, feedback, validation, recently edited |\n| **journeys.md** | API — migrate an existing Bee email, link DS email node to a Journeys destination, publish |\n| **drafts.md** | A message in an Automation draft |\n| **global_styles.md** | API — global styles (design tokens), email groups |\n| **design_system_audit.md** | **Design-system audit** — full pass for raw values + repeated patterns to standardise (read-only) |\n| **org_patterns.md** | **Org patterns (ORG_PATTERNS.md)** — this workspace's own band anatomy, house style and subject/CTA conventions: layering with BRAND.md and the type files, and how regeneration works |\n| **promote_design_tokens.md** | Audit raw values (colors/spacing/radii/fonts) → token candidates (read-only) |\n| **promote_component_variants.md** | Audit repeated component prop combos → variant candidates (read-only) |\n| **mass_replace_value.md** | Replace a hardcoded value with a global-styles token across all nodes (write, dry-run + confirm); read **global_styles.md** too |\n| **bulk_apply_variant.md** | Apply an existing variant to all instances of a component type in a scope (write, dry-run + confirm) |\n| **email_craft.md** | **Design Quality Bar + Email Craft** — mandatory read before writing any email markup |\n| **email_review.md** | **Review an email**: full QA report + 0-100 readiness score via one API call (ready to send? / pre-publish gate) |\n| **email_accessibility.md** | **Accessibility**: judgment-based AI audit (alt-text quality, semantics, touch targets); automated rules run inside the review |\n| **visual_verification.md** | **Screenshot check** — render to an image and inspect before reporting done; render budget, checklist |\n| **inbox_previews.md** | **Real-client screenshots (paid)** — only on explicit user ask |\n| **brand_document.md** | **Brand design system (BRAND.md)** — when to read it, how to apply its rules, when writes are allowed; read it before authoring any email content |\n| **inbox.md** | **Inbox messages** — `\u003cx-inbox-message\u003e` markup, variants (basic/image/cta), notification inbox setup, link \u0026 publish |\n| **in_app.md** | **In-app messages** — create, link, edit and publish one, and what stays on the template |\n| **sign_up_forms.md** | **Sign-up forms** — anything involving a sign-up form node or the in-app broadcast behind it |\n\u003c!-- /subfile-index --\u003e\n\n### Transient Failures — the `dependency_timeout` code\n\n**A `503` whose error code is `dependency_timeout` means a dependency stalled before anything was written** — the request itself was fine. Honour its `Retry-After` and retry once.\n\nKey the decision on the code, not on the verb. The code is only ever attached where nothing was saved, so it is itself the signal that retrying is safe — including on `PATCH /ds/nodes/{node_id}`, which can stall on a pre-write read and says so in the message (\"Your change was not saved\").\n\nThe calls that carry the code are the get and list routes for emails, folders, components, SMS, WhatsApp, in-app and inbox, plus `GET /ds/nodes/{node_id}`, `GET /ds/nodes/{node_id}/versions` and `.../versions/{version_id}`, `check_unpublished_changes`, `review`, and `PATCH /ds/nodes/{node_id}`. Other Design Studio calls, such as global styles, the `/render` routes and the `/ds/node_actions/...` reads, still answer a stall with a plain 500 — so the absence of the code there means nothing either way.\n\n**A 503 without that code tells you nothing about which kind it is.** Some are transient (a database failover, an auth-store blip) and some will not clear (a stopped workspace), and the response does not say which — so retry at most once, then report it. Be more careful on a write: a create, link or publish that fails *without* the code may still have landed, so re-read the state before sending it again.\n\n### Common Mistakes\n\n**A create without `parent_folder_id` lands at the root.** Every node create takes it, and a workspace that files its emails in folders does not want new ones piling up at the top level. Decide the folder before the create — step 1g below.\n\n**Translations use the native language variant system** (`POST .../design_studio/emails/{id}/languages` — see `nodes_advanced.md § Language Translations`), which drives automatic send-time language routing. Use it whenever a user asks to translate an email or add a language variant.\n\n### When to Use Design Studio vs Templates\n\n**Design Studio is the default editor for new emails — including an email step in a workflow, one-time send, or transactional message you are building from a free-form request.** The user does not have to say \"Design Studio\" for this to apply. Use it unless:\n- The message already exists in a different editor (check template `editor` field — anything other than `\"parcel\"`)\n- The user explicitly asks for a different editor (code, rich text, drag-and-drop) or has set an editor preference\n- The workspace has never used Design Studio and builds its emails in another editor. One call tells you: `cio api /v1/environments/$ENV_ID/templates --params '{\"channel\":\"email\",\"page_size\":1}' --jq '.meta.totals'` counts the workspace's email templates by editor. `parcel` at 0 with `wysiwyg` or `html` above 0 → write the new email as a code/rich-text template (`@skills/fly-api/templates.md`). A Bee-only workspace (`parcel` 0, only `bee` above 0) still gets Design Studio: the API cannot author Bee content. Any `parcel` above 0 means Design Studio is in active use here, so new emails go there; a workspace with no emails yet also gets Design Studio.\n\n**Decide before the first content write.** A new `email_action` (or one-time send / transactional message) starts with an empty template and no `editor`; the first `PUT /templates/:id` carrying `body` makes it a code or rich-text email. Run the one-call check above whenever you are about to write email content, and when the answer is Design Studio, don't write `subject`/`body` on the template at all — create the node, link it to the destination, publish (**journeys.md**).\n\n**New email → Design Studio.** Create the email (`POST /design_studio/emails` — **nodes.md**), write component markup (**component_syntax.md** + **components_reference.md**), link + publish (**journeys.md**). If your system prompt told you to read a brand design system first, do that before anything else — it decides the colours, type sizes, and spacing you'll write in the markup.\n\n**New WhatsApp template draft → see **whatsapp_patterns.md**.** Only for copy no approved template carries yet; when the workspace already has the one the user wants, fill it onto the destination instead (`@skills/fly-api/whatsapp.md`). It covers the full draft flow — create (`POST /design_studio/whatsapp`), the `\u003cx-whatsapp\u003e` grammar, language codes, category guidance, link, and publish (which submits to Meta for approval). First confirm credentials with `GET /whatsapp/check` (`success: true`); if not, send the user to Settings → WhatsApp.\n\n**New inbox message → see **inbox.md**.** Create an `\u003cx-inbox-message\u003e` node (`POST /design_studio/inbox`), write markup (three layout variants: `basic`, `image`, `cta`), link + publish. The workspace also needs a published notification inbox — query or create one via `/notification_inboxes` (see **inbox.md § Determining the `topic` value**). For the code-owned alternative (writing `body_json` directly without Design Studio), see `@skills/fly-api/inbox.md`.\n\n**New in-app → see **in_app.md** if the index above lists it.** Otherwise in-app isn't built in Design Studio here: use `@skills/fly-api/in_app.md`, which sets content on the template.\n\n**Existing messages → use the editor that created them.** Check the template's `editor` field — `\"parcel\"` means Design Studio. Otherwise use `@skills/fly-api/templates.md`. Editors cannot be switched after creation.\n\n### Working with Automations \u0026 One-time sends\n\nWhen creating emails for an automation, you can write email content first or build the automation first — **but the link step needs an `action_id` from an automation action** (link takes the action id, not a template id). Plan your work so the automation and actions exist before you reach the linking step.\n\n**Two valid paths:**\n\n**Content first:** Create and validate DS nodes with email markup (steps 1–4 below — the token check and render + review apply on this path too), then create the automation and add actions via `@skills/fly-api/automations.md` Steps 1–4, then link and publish (steps 5–6 below). Take it whenever the automation's details (trigger event, timing, audience) are unknown: build the email now, then ask only what the automation needs.\n\n**Automation first:** Complete automation setup via `@skills/fly-api/automations.md` Steps 1–4 to get the action ids, then create and link email content here. Good when the user has a clear automation structure in mind.\n\n**Either path: complete the full automation setup** (trigger type, actions, branches, exit conditions) via `@skills/fly-api/automations.md` before linking.\n\n### CRITICAL: Live Automation Safety\n\n**Never link or publish to a live automation without explicit user approval** — the content can reach people mid-journey. For this check, an automation is live when its `state` is `running`, `evaluating`, or `sunsetting`, **or when `scheduled_start` is greater than zero**, even if it is not running yet. A request like \"create an email for [automation]\" is not approval.\n\n**Before linking/publishing, read the automation** (`GET /v1/environments/:environment_id/campaigns/:campaign_id`) and check both `state` and `scheduled_start`, plus the action's `sending_state` for how urgent the risk is. The actions are the response's top-level `actions[]`; `campaign` has no `actions` key, so `.campaign.actions[]?` returns an empty list for an automation that has actions:\n- **`automatic`** — high risk: the change sends automatically, with no review step.\n- **`draft`** — lower risk: queued for manual approval before sending.\n- **`off`** — the action is disabled, which lowers immediate delivery risk, but it does not make its parent automation non-live or bypass approval and the live-data gate.\n\nWhen it's live: build the email independently first (create the node, write content, share the editor link — don't link/publish yet), then ask for the go-ahead with `request_capability`, not with a question in prose. Request the one scope the link and publish need, `publish:campaign:\u003cid\u003e:messages`, on its own turn, with a reason that names the automation, its state, the action's `sending_state`, and what approving does: the email links to the action and publishes into the live automation. The card is the user's approval; a question in prose is not, and the surface they asked from (the automation's email card, a broadcast or transactional content step) only changes once the link lands, so a prose question leaves them looking at a card that never updates.\n- **Approved** — link and publish, then confirm the link. Editing the linked node's content afterwards is a separate write that asks for `edit:campaign:\u003cid\u003e:messages` (a 428 names it); don't request it up front.\n- **Declined** — don't retry. Offer to **stop first** (`state: \"stop\"` to halt, or `sunset` to let current journeys finish; publish while stopped, restart when ready) or, if supported, to draft a **new version** rather than overwriting the live one.\n\n**403 `agent_editing_disabled` on linked content edits, `link`, or `publish`** — the toggle is off and the destination is live: an automation whose `state` is `running`, `evaluating`, or `sunsetting`, or whose `scheduled_start` is greater than zero; a non-cancelled scheduled or sent one-time send; or an active or previously-sent transactional message. A cancelled one-time send is non-live, and a transactional message is non-live only when it is inactive **and** has never sent. For SMS, edits to a linked node's content, recipient, or sender are all gated content edits. The draft may be saved only when the rejected operation was link or publish; a rejected linked content, recipient, or sender edit is not saved. Handle per **fly_api/SKILL.md § Agent Permission Errors**: frame it as the admin's chosen policy, link to [AI \u0026 MCP settings](/settings/ai) (the response itself has no settings URL), note only an admin can flip it, and share the editor URL so the user can review the current draft. Do not claim the existing linked node can still be edited while its destination is live; offer the resource-specific non-live path or a separate unlinked node for further drafting, but don't suggest bypassing the setting.\n\n**For every live destination above, the gate covers all three operations: linked content edits, link, and publish.** When \"Edit live data\" is off, **each** returns 403 `agent_editing_disabled` — including publish to an active transactional message that has never sent or an automation that has not started but has `scheduled_start \u003e 0`. User approval does not override the account setting. The pre-flight above is still required when the setting is on: the gate is a backstop, not a substitute for checking state and obtaining explicit approval.\n\n### Vocabulary — what to call things\n\nSay \"template\" only for (a) a *saved Design Studio template* (a node returned by `ds/node_actions/linkable` with `type: template`), or (b) a Journeys automation action / one-time send / transactional message you're linking to. For an email you are creating or editing for the user, say **\"email\"** or **\"message\"** — never \"template.\" Applies to replies and thinking. Examples: ✅ \"I'll create the email in Design Studio\" / ✅ \"I'll start from your saved 'Welcome' template\" / ❌ \"I'll build a template for you.\"\n\n### User-Facing Updates\n\nAfter validation, report the email status, what changed, and the Design Studio link — in product terms, not CLI commands, payloads, endpoint paths, or IDs (unless the user asks for technical detail). A token is its display name (\"the accent colour\"), never its `globalStyles…` path, even where a document you quote spells one out. If blocked, state the blocker and the decision needed. When you picked the folder yourself, the last line is *\"Saved in **\u003cfolder\u003e** — say if you want it somewhere else.\"* (step 7).\n\n**A question with nameable choices is a menu, not a paragraph.** When the user has to pick among things you can list — a folder, a custom component, a button variant — and `ask_user_question` is in your tool list, call it with 2–4 concrete options (label plus a one-line description); the UI appends its own free-text \"Something else\" row, so never add a catch-all option yourself. If it is not in your tool list, that surface has no menu: ask in prose, listing the same options as a short numbered list, and never call a tool you were not given. Approval to touch a live resource is a different thing and goes through `request_capability` (§ Live Automation Safety).\n\n**Steps:**\n\n**Missing facts never block the build.** Per-recipient values (amounts, dates, names) are Liquid; anything else unsupplied (a URL, an address) is a visible placeholder you list in the reply (**email_craft.md** § Keep the slots). Ask afterwards, not instead.\n\n**STOP before writing any markup.** Steps 1a–1f and the **email_craft.md** read (checklist item 6) are mandatory and cannot be skipped, even if you think you know what the email needs. If fetching global styles or the node list returns an error, tell the user and do not proceed until the issue is resolved.\n\n1. **Learn the workspace's design language** — you MUST do all of these before writing any markup, in this order. (If your system prompt mandates a brand design system, `read(\"BRAND.md\")` comes first — see **brand_document.md**. If it also mandates org patterns, `read(\"ORG_PATTERNS.md\")` comes next.)\n   a. **Saved templates — check first.** Call `GET /v1/environments/$ENV_ID/ds/node_actions/linkable` with one JSON params object: `--params '{\"type\":\"template\",\"offset\":0,\"limit\":50}'`. If a result's name/description matches what the user asked for (e.g. \"text-based\" → \"09 - Text-based\", \"welcome\" → \"Welcome\"), **start from it**: `GET /design_studio/emails/{template_uuid}` (the result's UUID `id`), then pass its `content` (and `envelope`/`transformers`) into a new `POST /design_studio/emails` (see **nodes.md § Start from a Template**) — this inherits the template's markup, fonts, components, and styles. If one nearly fits, start from it and update only the parts that differ (**nodes.md § Update**). Build from scratch only when the user says so or no template matches; if none match, say so and continue to 1b–1d.\n   b. **Global styles** — fetch design tokens (`global_styles.md`) for colors, fonts, spacing via `cio api /v1/environments/$ENV_ID/ds/global_styles`. The raw response is large — don't pipe it through `head`/truncation (you'll silently lose whole groups like `radius`). Project it to a compact list instead:\n      ```\n      cio api /v1/environments/$ENV_ID/ds/global_styles --jq '(.styles // .).default | {color, font, radius, spacing} | to_entries[] | .key as $g | .value | to_entries[] | \"\\($g).\\(.key) id=\\(.value.\"$extensions\".\"io.parcel.global-styles\".id) value=\\(.value.\"$value\")\"'\n      ```\n      That projection lists **variable tokens only**. The workspace's **named component variants** — the button, link, divider, and section styles a user calls \"primary\", \"secondary\", \"ghost\", \"outline\" — live in the component groups it drops. List them too, with the colors that define them:\n      ```\n      cio api /v1/environments/$ENV_ID/ds/global_styles --jq '(.styles // .).default | {button, link, divider, section} | to_entries[] | .key as $g | .value | to_entries[] | select(.key | startswith(\"$\") | not) | \"\\($g).\\(.key) default=\\(.value.\"$extensions\".\"io.parcel.global-styles\".default // false) background=\\(.value.background.\"$value\" // \"-\") color=\\(.value.color.\"$value\" // \"-\") border=\\(.value.\"border-color\".\"$value\" // \"-\")\"'\n      ```\n      Variants are applied by name (`#variant=\"secondary\"` on `x-cta`, see **global_styles.md § Applying Component Styles**), never through a `globalStyles.*` path.\n   c. **Custom components** — fetch `cio api /v1/environments/$ENV_ID/ds/component_definitions` (parsed props, slots, presets). Use a matching component (header, footer, logo, …) instead of rebuilding it; confirm ambiguous matches with the user. Only use a tag that appeared in the response — a custom (non-`x-`) tag no component answers to renders the email empty. Full guidance in **§ Custom Components** below.\n   d. **Pattern file** — read **email_patterns.md**, then the type file it names for this email, and follow its anatomy, subject/preheader and CTA patterns. Copy the path from the index exactly; guessing a filename fails the read.\n   e. **Existing assets** — an image the user attached in chat to use in the content (not a screenshot shared as feedback): import it (`@skills/fly-api/assets.md` § Images the User Attached in Chat) and use the returned `path`. Unless the user supplied or requested a specific image, select needed imagery before writing markup. Keep imagery the chosen template or custom component supplies. Otherwise prefer a suitable entry from `BRAND.md`'s Assets table; those were curated for a role and imported into the workspace. If none fits, search the workspace asset library via `@skills/fly-api/assets.md`. Use the exact hosted URL. Do not reach for a placeholder, hotlink, or new upload until you have checked both sources; if nothing suitable exists, keep the layout text-only, or give an image the brief asked for a named stand-in you list in the reply (**email_craft.md**).\n   f. **Sender → recipient frame** — before copy or markup, state in visible text — a `# Frame: …` comment at the top of the first command that writes markup (a heredoc to a file counts) — who is speaking, who receives the message, their relationship or moment, and the message's purpose. Use the brief, not an automation/folder/campaign name: an automation's name says little about who is writing to whom. If the brief leaves the frame genuinely ambiguous and the alternatives change the copy, ask the user; otherwise proceed with the explicit frame and preserve it throughout.\n   g. **Where it lives** — decide the folder before the create. `parent_folder_id` omitted means the root, and the people who organise Design Studio into folders do not want new emails piling up there. Work down this ladder and stop at the first rung that answers:\n      1. **The user named a folder.** `cio api /v1/environments/$ENV_ID/ds/folders --jq '.[] | \"\\(.breadcrumb)  id=\\(.id)\"'` lists every folder with its path — `Newsletters` for a root folder, `Lifecycle / Onboarding` for a nested one (**nodes.md § Folders** — the response is a bare array, so `.folders[]` matches nothing and reads as \"no folders\"). Match the name against the breadcrumbs. One match: use its `id`. Several (two folders called \"Onboarding\" under different parents): ask, one option per match labelled by breadcrumb (menu rules under § User-Facing Updates). None: say so and ask before creating a folder — never invent one unprompted, and never on a later turn either: \"put it in X\" when no X exists is a question back to the user, not a create.\n      2. **A node is open in the editor.** The `\u003ccontext\u003e` block carries \"The current URL\"; when it matches `/design-studio/editor/\u003cuuid\u003e` (or `/editor/whatsapp/\u003cuuid\u003e`, `/editor/in-app/\u003cuuid\u003e`), `GET /ds/nodes/\u003cuuid\u003e` and take its `parent_id`. Create beside it and say so in one line: *\"Created next to the open email in Lifecycle / Onboarding — tell me if you want it moved.\"*\n      3. **The email is for a named automation, one-time send, or transactional message.** A folder whose breadcrumb contains that campaign's name is its home. Create there and say so, as in rung 2.\n      4. **The workspace has no folders.** Create at the root and don't mention it.\n      5. **Folders exist and nothing above decided.** Match the email's pattern type from step 1d and its name against the breadcrumbs — `Onboarding` or `Lifecycle` for a welcome or activation email, `Transactional` or `Receipts` for an order confirmation, `Newsletters` for a digest. **Exactly one plausible folder:** create there and close the reply with the checklist-9 sentence — folder by name, offer to move, one sentence. **Zero or several:** create at the root, then ask — `ask_user_question` when the surface has it, a numbered list in prose otherwise — as a numbered question (\"Where should it live? 1. Misc 2. Q3 2025 3. Keep at root\"), not a passing \"tell me if you want it moved\" — with at most three candidate folders (the plausible ones, or three top-level ones when nothing fits — never every folder) plus a \"Keep at root\" option, four options at most, and move on the answer with `PUT /design_studio/emails/{id}` and `{ \"parent_folder_id\": \"\u003cid\u003e\" }`. The email is already built when you ask, so the question costs the user one click, not a wait.\n\n      Two rules hold on every rung. **Never guess into a folder whose name is not about purpose** — a person's name, a date or year, `Archive`, `Old`, `Drafts`, `Test`, `WIP`, `Scratch`. Those are where a wrong guess annoys most, so they are chosen only when the user names them. **Say where the node landed whenever a guess decided it** (rungs 2, 3, 5), in product terms, with the offer to move. A move is one PUT, so a wrong guess costs the user a sentence; a silent misplacement costs them a hunt. This step places nodes you create — never move an existing node the user did not ask about, even one at the root that clearly belongs elsewhere. The ladder applies to components, SMS, WhatsApp and inbox nodes too: every create takes `parent_folder_id`.\n\n   **Pre-flight checklist before writing markup:**\n   1. I called `ds/node_actions/linkable` with `--params '{\"type\":\"template\",\"offset\":0,\"limit\":50}'` as one JSON object.\n   2. I know whether any template matches what the user asked for.\n   3. If a template matches: I'm fetching its content by the node UUID (`GET /design_studio/emails/{template_uuid}`) and passing that into a new `POST /design_studio/emails`.\n   4. If no template matches: I'm preferring `globalStyles.*` tokens for any value the workspace has a token for — by value, or by name when the user names one — and only falling back to hardcoded fonts or hex colors when no token's value is close.\n   5. I called `component_definitions` and I know what custom components are available, including their props, slots, and how to use them.\n   6. I read **email_craft.md** and this email's pattern file, and declared the mini design system I will use.\n   7. If the email uses imagery, I selected it from `BRAND.md` or the workspace asset library, or confirmed that neither has a suitable asset.\n   8. I stated the sender → recipient frame and checked that the copy's point of view, greeting, and actions match it.\n   9. I know where the node lives (step 1g) — a folder id or the root — and which rung decided it. If a guess decided it (rungs 2, 3, 5), I have written the sentence my reply will end with, verbatim: *\"Saved in **\u003cfolder\u003e** — say if you want it somewhere else.\"* The offer is the point of the sentence: it is how the user learns a guess was made. Naming the folder without it does not tick this box. Rung 5 with zero or several candidates ends instead with the numbered folder question.\n\n   If you can't tick (3) or (4) — whichever applies — (6), (8), or (9), stop and fix it before writing markup. If the email uses imagery and you can't tick (7), stop too.\n2. **Create or edit message content** — create nodes (**nodes.md**), write markup using workspace tokens and custom components where available. Meet the Design Quality Bar and craft rules in **email_craft.md**, sender and unsubscribe footer included. A create that returned an id exists even when a later call in the same command failed — fix it with a PUT, never a second create.\n3. **Quick token check** — after the create, verify tokens mechanically. Counting `globalStyles.` refs is not the test: one binding plus thirty literals passes it and still fails **email_craft.md** rule 1. Run both checks in **email_craft.md § The token check** — (a) literals a token covers, (b) `globalStyles.` paths on a bare attribute, which ship as dead strings. Fix every hit in one PUT; do not loop.\n   Note: inline `\u003ca\u003e` tags don't support `:color`. On a themed surface, set `:color=\"{ light, dark }\"` on the parent `\u003cx-paragraph\u003e` — and give the anchor `style=\"color: inherit\"` — a bare `\u003ca\u003e` renders the client's default blue.\n4. **Render + review** — render (`GET /design_studio/emails/{id}/render`) to catch explicit errors, then fetch the server review report (**§ Email Readiness Checks**). Fix `error` findings and contrast warnings (**email_craft.md** rule 7) in one pass; surface other warnings and tips rather than looping — and never by deleting the content they name. To check the email in a real inbox, offer a test send (`@skills/fly-api/test_sends.md § Email`).\n   **Then screenshot it** — an email built from scratch, or any change to layout, images or styles, is not done until you have looked at it: after your last edit, mint a screenshot and `fetch` each url in the very next calls, before anything else — as below. An email you built is not ready until you have seen it at 700 **and** 375 (mobile is where columns stack); an edit needs 700 only. Copy and subject edits skip it. Cap and checklist: **visual_verification.md**.\n   ```bash\n   W=\"700 375\"   # you built this email in this task\n   W=\"700\"       # you edited an existing email — keep only the line that applies\n   for w in $W; do cio api /v1/environments/$ENV_ID/ds/exports/screenshot -X POST --arg id=\"$EMAIL_ID\" --json \"{node_id:\\$id,width:$w}\" \u003e /tmp/shot_$w.json \u0026\u0026 jq -r .url /tmp/shot_$w.json; done\n   ```\n5. **Link** — connect the email node to its destination by entity id (**journeys.md**). For an automation this **needs the `action_id`** — if you don't have one yet, create the automation and add actions first (`@skills/fly-api/automations.md` Steps 1–4); the `action_id` comes from the action in the automation response. (One-time sends use `newsletter_id`, transactional messages `transactional_message_id`.)\n6. **Publish** — render the email server-side and push it to the linked destination (**journeys.md**). A 2xx publish is not proof of success: re-confirm the published `content` is non-empty (**§ Content Validation**) before sharing.\n7. **Share** — share the Design Studio editor link. When a guess placed the email (step 1g rungs 2, 3, 5), the reply ends with the sentence you wrote at checklist item 9 — *\"Saved in **Lifecycle / Onboarding** — say if you want it somewhere else.\"* — as its last line, after the review findings and links. Both halves are required. \"Saved in Lifecycle / Onboarding.\" on its own is the failure this line exists to stop. The user may need to refresh Design Studio for a newly created node to appear.\n\n### Editing an Existing Email\n\nThe mandatory reads above are not only for new emails. **Before proposing or changing colors, buttons, typography, spacing, or any other styling on an existing email, read global_styles.md, fetch the global styles (step 1b — both projections), and read the email's current source.** Then:\n\n- **Resolve style names against the workspace before touching markup.** \"Primary\", \"secondary\", \"brand blue\", \"the outline button\", \"our accent\" are usually the names of the workspace's **tokens or component variants**. Look them up in the global styles output and apply them by reference — `#variant=\"secondary\"` on an `x-cta`, `:background=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"` for a token — rather than reading them as a description of the attributes already on the element. In particular, \"primary and secondary button colors\" refers to the two **button variants**, not to a button's `background` and `color` attributes; swapping those two attributes inverts the button (white fill, dark text) and is never what the user meant.\n- **When the request has more than one plausible reading and the edit changes what the email looks like, state your interpretation and confirm before writing.** When the email itself settles the reading — each named style already matches exactly one element, so the request is a swap of two things you can see — make the edit. When it does not, say what you found and ask which element should change. Do not pick a reading, apply it, and explain afterwards — a wrong guess is a destructive edit the user has to notice and unwind. A request that resolves cleanly (each named style maps to exactly one element or variant) needs no confirmation: just make the change.\n- **A visual complaint (\"looks broken\", \"fix the spacing\") starts with a screenshot** (step 4): look before you edit, and once more after the fix.\n- **Change only what was asked.** Preserve every other attribute, component, and Liquid tag verbatim; read the email back after the PUT and report the change in product terms (which button is now which color), not as a diff.\n\n### Design Quality Bar \u0026 Email Craft\n\n**Read `@skills/design-studio/email_craft.md` in full before writing any markup — mandatory.** It owns the Design Quality Bar and the craft rules — do not work from a summary. Re-scan your markup against it before the create/update call.\n\n### Prop Mistakes That Fail the Render\n\nEach of these recurs in generated markup and fails the whole render (verified against the renderer). A **misspelled component** (`\u003cx-heading\u003e`, `\u003cx-button\u003e`) does not: `x-*` tags no component answers to are emitted verbatim into the output, with no error anywhere — check every tag name against **components_reference.md**, because nothing else will. A *slot* violation renders an **empty document silently** — treat empty rendered `content` as a structural error (§ Diagnosing Empty Renders).\n\n| Mistake | Fix |\n|---|---|\n| `\u003cx-row\u003e` without `:layout` | `:layout` is required — always `:layout='[50, 50]'`-style array |\n| `letter-spacing=\"2px\"` or `\"0.05em\"` (string) | Number expression only: `:letter-spacing=\"0.15\"` (unit is em) |\n| String `height` on `\u003cx-hr\u003e` (`height=\"2px\"`, `height=\"2\"`) | `:height=\"2\"` — the dynamic number form. Omit it entirely for the default 1px hairline |\n| Bare boolean attribute (`full-width`) | Expression form: `:full-width=\"true\"` |\n| `{ light, dark }` / `{ small, medium }` object on a prop that isn't themable/responsive (e.g. a boolean) | Objects belong on color props (`color`, `background`, `outer-background`, `border-color`, x-hr `background-color`, x-image `src`) and size props (`padding`, `margin`, `font-size`) |\n| Literal string where a number is expected (`font-size=\"18\"`) | `:font-size=\"18\"` — numbers, arrays, objects, booleans always take the `:` prefix |\n| px strings inside a responsive `font-size` object — ``:font-size=\"{ small: `26px`, medium: `32px` }\"`` | Bare numbers inside the object: `:font-size=\"{ small: 26, medium: 32 }\"`. Numeric-only props stay numeric inside `{ small, medium }` objects too |\n| Number where a string-with-units prop expects a string (`:border-radius=\"8\"`) | `border-radius=\"8px\"` — dimensional props (`padding`, `margin`, `width`, `border-radius`) are plain string attributes with units |\n| String opacity (`hover-opacity=\"0.9\"`) | `:hover-opacity=\"0.9\"` — `opacity`/`hover-opacity` are numeric-only |\n| Expression referencing an undefined identifier (`:color=\"isPremium ? 'a' : 'b'\"`) | Only `globalStyles.*` and `Component.utils.*` exist at render time; anything else fails the render |\n\n**Creating or editing a custom component? STOP — read `@skills/design-studio/custom_components.md` first** — the guided build workflow, property types and best-practice defaults, slots, the spec/validation rules, and creating via the API. A component's `content` is a complete source file (`\u003cscript\u003e` with `config`/`presets` + `\u003ctemplate\u003e` with one `#root` element), **never raw `\u003cx-*\u003e` markup**. Raw markup saves without error but the component is blank and uneditable in the visual editor.\n\n### Content Validation\n\nValidate before linking or publishing, so broken markup never reaches a live template. Render the email first: `GET /design_studio/emails/{id}/render` (returns `.html`, no publish, Liquid intact — see **nodes_advanced.md § Render**). A 400 with a message (e.g. `\"color is not defined\"` — a missing `globalStyles.*` token) flags explicit errors to fix.\n\nA **2xx publish is not proof of success**: if the resulting `content` is empty (`\"\"` or `null`) the email silently failed to render — treat it as not ready and work through **§ Diagnosing Empty Renders** before sharing. One common silent failure: an unresolvable `globalStyles.*` token (wrong id, typo, missing `\u003csafeName\u003e_` prefix) renders empty while the publish still succeeds — cross-check every token reference against the actual ids from the API response before creating the node (see **component_syntax.md § Global Styles \u0026 Component.utils**).\n\n**A from address (sender) is required to send.** A message with a blank sender is rejected at send/publish with `from address can't be blank` — the `/render` check does **not** catch this, so verify it before publishing. Set the email's `envelope.from_id` to a valid sender identity: list them with `cio api /v1/environments/$ENV_ID/identities` (params `in_use`, `type` — see `@skills/fly-api/channels.md § Sender Identities`), pick one, and set it on the node's `envelope` (also set `reply_to_id` if replies should go elsewhere). If the workspace has no sender identity, tell the user to add one in Settings rather than leaving it blank.\n\n**Accessibility check** — the review report (**§ Email Readiness Checks**) includes the accessibility findings (automated checker + contrast in light, dark, and forced-dark modes); **email_accessibility.md** is the authoring guidance for getting them right up front. If you built or edited the email this conversation, auto-fix issues by updating the node and re-publishing, and flag judgment calls (alt-text content, link text, decorative images) — critical/serious issues that can't be auto-fixed go to the user before link/publish. If you're validating an existing email, report findings and let the user decide.\n\n### Diagnosing Empty Renders\n\nAn empty `content` (`\"\"`/`null`) means the email failed to render — usually a broken custom component, an unknown custom (non-`x-`) tag, invalid syntax, or a broken `globalStyles.*` reference. A misspelled `x-*` tag is **not** a cause: it is emitted verbatim instead of rendering empty (§ Prop Mistakes That Fail the Render). Work inside-out:\n\n1. **Custom components first** — for each non-`x-` tag, inspect its source (`GET .../design_studio/components/{component_id}` → `content`) against **components_reference.md § Troubleshooting** (missing `:break-point`, deprecated `x-columns`, malformed `\u003cscript\u003e`). Fix via `PUT .../design_studio/components/{id}` and re-publish.\n2. **Then the email markup** — check for unknown custom (non-`x-`) component tags, invalid property syntax, or broken `globalStyles.*` references; fix via `PUT .../design_studio/emails/{id}` and re-publish until `content` is non-empty.\n\n### Email Readiness Checks\n\nAfter the render succeeds (step 4), fetch the review before linking or publishing:\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/nodes/$EMAIL_ID/review -X POST\n```\n\n**The report fields, fix loop, `fix` kinds and pitfalls live in **email_review.md** — read it rather than working from this summary.** Three rules belong here because they get broken:\n\n- **Your final message must state the score and label exactly as the editor shows them.** Never describe the email as ready, clean, or live while the report is red or locked — not even alongside a \"before you send\" list. If findings remain that you cannot fix (a broken external link, a spam-wording judgment), hand them to the user *with the score* instead of rounding up to \"done\".\n- **Act only on `state: \"fail\"` rows**, and fix `error` severity before linking or publishing. One review → one fix pass → one re-review; do not loop.\n- **Never delete content to raise the score.** Warnings and tips are advisory, so a yellow report you've explained is a fine place to stop. Removing the image, section, or link a warning names is not a fix: leave it in place and let the user decide.\n\n### Linking to the UI\n\nAfter creating or referencing a Design Studio node, always include a clickable link.\n\n**Editor links use the node UUID `id`, never `short_id`, for every node type and editor route.** A short-id link will not open the file; take `id` from the node response.\n\n- Emails, components, and other nodes: `https://fly.customer.io/workspaces/$ENV_ID/design-studio/editor/$NODE_UUID`\n- WhatsApp nodes (type-segmented route): `https://fly.customer.io/workspaces/$ENV_ID/design-studio/editor/whatsapp/$NODE_UUID` (see fly_api/whatsapp.md)\n- In-app nodes: `https://fly.customer.io/workspaces/$ENV_ID/design-studio/editor/in-app/$NODE_UUID`\n\nPrint the finished URL with a command (`echo \"https://fly.customer.io/workspaces/$ENV_ID/design-studio/editor/$EMAIL_ID\"`) and paste what it printed, so the link is never typed from memory.\n\n---\n\n## Core Concepts\n\nComponent-based templating with `\u003cx-*\u003e` standard components for structure, **custom components** for reusable blocks (headers, footers, logos, etc.), and HTML tags for text formatting.\n\n### Custom Components\n\nWorkspaces often have **custom reusable components** for common elements like headers, footers, logos, social links, and CTAs. These use kebab-case tag names that do **NOT** start with `x-` (e.g., `\u003ccompany-header\u003e`, `\u003cemail-footer\u003e`, `\u003csocial-links\u003e`).\n\n**Before building an email, always check for available custom components** by fetching component definitions (`GET /v1/environments/$ENV_ID/ds/component_definitions`). This returns every component with parsed props (name, type, required, default, options), slots, and preset usage examples — no need to manually fetch and parse component source. If a component exists that matches what you need, **use it** instead of manually constructing the same element with standard components. Custom components ensure brand consistency and respect the workspace's established patterns.\n\n**How to identify what to use:**\n- Component names with words like `header`, `footer`, `logo`, `nav`, `social`, `banner` are likely reusable blocks — use them in the corresponding email section\n- If you need the raw source markup for debugging, fetch it via `GET .../design_studio/components/{component_id}` → `content`\n- If no matching custom component exists, build the section with standard `\u003cx-*\u003e` components (syntax in **component_syntax.md**, per-component props in **components_reference.md**)\n\n**When the user asks for specific components by name or prefix:** List all available custom components first and confirm which ones the user means before building the email. Component naming conventions vary across workspaces (e.g., `eds-header`, `ai-header`, `cio-header` could all be \"the header component\"). Don't assume — show the user the available options and ask if the match is ambiguous.\n\n**When a custom component causes a rendering error:** Fix the component itself (`PUT .../design_studio/components/{id}` — see **nodes.md § Update**) rather than removing it from the email. The user chose those components for a reason — removing them silently or replacing them with inline markup breaks the workspace's design system. See **nodes.md § Inspecting Custom Component Source** to read the component's markup and diagnose the issue.\n\n**Custom component boolean props** require dynamic syntax: `:prop=\"true\"`, never `prop=\"true\"` (which passes the string `\"true\"`, not the boolean). If you see the error `\"Did you forget #set:?\"`, it means a prop expected a dynamic value — use the `:` prefix. This is the same rule as standard components but the error message is misleading.\n\n### Component Syntax\n\nProperty syntax (static vs `:` dynamic, numeric-only props), common mistakes and correct examples, HTML text tags, Liquid \u0026 interpolation, responsive and dark-mode objects, `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e` token paths with `Component.utils`, and image alt/layer labels live in **component_syntax.md**. Read it before writing or editing any `\u003cx-*\u003e` markup — § Prop Mistakes below is the short list of render failures, not the syntax reference.\n\n## WhatsApp Templates\n\nThe full WhatsApp draft flow — create (`POST /design_studio/whatsapp`), the `\u003cx-whatsapp\u003e` `x-wa-*` grammar (header / header-media / body / footer / actions), language codes, category guidance (`MARKETING` vs `UTILITY`), link, publish, and Meta approval pitfalls — lives in **whatsapp_patterns.md**. WhatsApp uses its own `x-wa-*` grammar, not the `x-*` email grammar in **component_syntax.md**.\n\n## Best Practices\n\n1. **Start from a saved template when one matches** — list saved templates first (step 1a) and, when a name/description matches, copy its content into a new email (`GET /design_studio/emails/{template_uuid}` → `POST /design_studio/emails`; the value is the saved node's UUID, not a numeric Journeys template ID; see **nodes.md § Start from a Template**). The new email inherits the template's markup, fonts, and components. Do not overwrite them with hardcoded styles or inline replacements unless the user asks.\n2. **You MUST use global styles when they exist** — fetch workspace tokens first (`global_styles.md`) and use `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"` instead of `color=\"#007bff\"` (see **component_syntax.md § Global Styles \u0026 Component.utils** for how `\u003csafeName\u003e` and `\u003cid\u003e` are derived). Only fall back to hardcoded hex values when no matching global style token exists. This applies even when you're making design choices (like a dark background) — use the workspace's dark/secondary tokens rather than hardcoding colors. Match by **value, not name** — see **email_craft.md** rule 1.\n3. **Use custom components when available** (§ Custom Components).\n4. **One `\u003cx-heading-1\u003e` per email** — accessibility requirement\n5. **Sequential heading levels** — h1 → h2 → h3, never skip levels. Style headings with properties, not by changing heading levels.\n6. **Text styles high in hierarchy** — set `font-family`, `:font-size`, `:line-height` on `\u003cx-base\u003e` so all children inherit. When the design changes for a section, apply overrides to the relevant container (`x-section`, `x-box`, `x-row`, `x-column`, `x-wrap`). Avoid repeating font styles on every element.\n7. **Override inherited styles** with empty string: `font-family=\"\"` — use empty values where supported (`padding=\"\"`) rather than zero (`padding=\"0\"`). Exception: headings and paragraphs have default margin, so use `margin=\"0\"` to reset. Headings also have default `font-size` and `font-weight`.\n8. **Always include `border-style`** when adding borders (e.g., `border-style=\"solid\"`)\n9. **Use `\u003cx-section\u003e`** for full-width backgrounds (color extends to viewport edge), **`\u003cx-box\u003e`** to group content within a section. An `x-section` is essentially an `x-box` with a full-width background behind it — `width`, `background`, `border`, `padding` apply to the inner box; `outer-background` fills the viewport.\n10. **Most emails need just one section** — only add multiple sections for distinct full-width color regions (e.g., header, body, footer). Don't add extra sections for spacing or grouping.\n11. **Use boxes for varied padding within a section** — rather than splitting into multiple sections, use `x-box` inside a single section.\n12. **Prefer margin/padding over spacers** — on the element, or an `x-box` around a group; a spacer only when neither works.\n13. **Alt text on all images** — always provide descriptive `alt` attribute. Only use PNG, JPG, and GIF formats — other formats have limited email client support.\n14. **Use `data-layer-label`** on key components for editor clarity\n15. **Use Liquid for personalization** — `{{customer.*}}`, `{{event.*}}` for per-recipient data; use `${}` only for build-time static data\n","files":{"brand_document.md":"---\nfeature_flag: improve-ds-agent\n---\n\n# Brand Document\n\nThe workspace's brand design system. Where global styles are the *tokens* (a colour\nnamed `primary`, a 16px body size), this is the *rules*: which role each colour\nplays, which pairings are illegal, how much space goes between a headline and its\nbody, what the brand never does, and how its copy sounds.\n\n**Read it before writing or editing any email content.** Not only when the user\nmentions branding — the document exists so that on-brand output doesn't depend on\nthem remembering to ask.\n\nReading it is not enough: **every save is bound by it, including small edits.**\nBefore saving any markup — a new email or a one-line change — check what you are\nabout to save against the document's Prohibitions list and its type floor. The most\ncommon miss is a mid-edit addition (a link colour, a caption size) that violates a\n\"never\" rule the original build respected. When the email review returns a contrast\nfinding or a token-literal clash that involves the document's colours, that is a\nbrand violation to fix before you finish, not a tip to dismiss.\n\n## Reading and writing it\n\nOne document, two surfaces — use whichever your session has:\n\n- **Copilot filesystem** — the document is mounted as `BRAND.md`: `read(\"BRAND.md\")`\n  to read, `write(\"BRAND.md\")` to replace. Prefer this path when you have it.\n- **API** — `cio api` (or the MCP `cio_read_api` / `cio_write_api` tools). The\n  document has no endpoint of its own; it rides the composite global-styles\n  envelope under `brand`:\n\n| Method | Path | Notes |\n|---|---|---|\n| GET | `/v1/environments/{environment_id}/ds/global_styles` | Returns `{ \"styles\": …, \"brand\": { markdown, exists, generated_at, sources, max_bytes, max_lines } }`. Read the document from `.brand`. `.brand` is present only when the workspace is opted in (AI + the feature); if it's absent the workspace doesn't have the feature. `brand.exists: false` means the feature is on but no document is saved yet — not an error. |\n| PUT | `/v1/environments/{environment_id}/ds/global_styles` | Brand-only write: body `{ \"brand\": { \"markdown\": \"...\" } }` — omit `global_styles` (a null or absent `global_styles` skips the styles pipeline). Replaces the whole document. `markdown` must be non-empty, at most `brand.max_bytes` bytes and `brand.max_lines` lines (both from the GET — a document full of short table rows can hit the line cap while well under the byte cap). Omit `sources`/`generated_at` on an edit — the stored provenance is preserved. |\n\nNot every workspace has one. If `.brand` is absent, the workspace doesn't have the\nfeature — carry on without it and don't mention it. A 403 on the **write** is\ndifferent: the caller lacks the brand edit permission (`Parcel_Emails_Edit`) —\ntell the user what you wanted to change instead of dropping it silently.\n\n## When there is no document yet\n\n`brand.exists: false` — or the copilot placeholder in `BRAND.md` — means the\nfeature is on and nobody has generated a document. **When the user has asked you\nto create email content, ask how to proceed before you build.**\n\nGenerating the document runs on Design Studio's **Style** tab\n(**Design Studio → Style**), which you cannot drive yourself (see **Regenerating**\nbelow), so only the user can start it. Call it the Style tab — that is its label in\nthe product, even though its URL says `global-styles`; there is no tab called\n\"Global Styles\" for the user to find. The link is\n`https://fly.customer.io/workspaces/$ENV_ID/design-studio/global-styles`.\n\nAsk with `ask_user_question`: one question, `multiSelect: false`, the question text\n\"Would you like to generate a brand document first so that your email will match\nyour brand's colors and styles?\", and exactly these two options. Use the question, labels and\ndescriptions verbatim, even when the request is not a whole email — the labels are\nwhat the answer comes back as:\n\n| label | description |\n|---|---|\n| Yes, I want to generate the brand document | Generate it on the Style tab from your website and brand files, then come back and tell me to continue. |\n| Create the email without a brand document | I'll build it now from your workspace's global styles. |\n\nAdd no third option — the UI appends its own free-text \"Something else\" row and a\nSkip button. Keep links out of the question and options, and send none before the\nquestion: they render as plain text, so the Style tab link goes in your reply once\nthey have answered. If `ask_user_question` is not in your tool list, ask the same\nquestion in prose, with the two labels as a numbered list, and stop for the reply.\nThe prose question carries no link either: list just the two labels, without their\ndescriptions, and put no URL anywhere in that message. The Style tab link waits\nfor the answer.\n\nThen act on the answer:\n\n- **Yes, I want to generate the brand document** — link them to the Style tab and\n  say they can generate the document there from their website plus any brand files\n  they have (a logo, a PDF brand book, a CSS file); the same flow sets up global\n  styles and shows the document for review before saving. Tell them the chat stays\n  open, so they can come back and say continue. **Stop** — don't create, update,\n  render, or link any node in that turn. When they say continue, re-read the\n  document and build from it, or build as below if there still isn't one.\n- **Create the email without a brand document**, Skip (an empty `answers` list),\n  or \"just build it\" in their own words — build now, conservatively from global\n  styles, and add to the same message: \"You can create a brand document later on\n  the [Style tab](https://fly.customer.io/workspaces/$ENV_ID/design-studio/global-styles).\"\n- Any other free-text answer — read it for what they meant and act on that.\n\nIf the question comes back as not completed because the user wrote a message\ninstead of answering it, that message is their answer: act on it as above, and\ndon't re-ask or offer to try the question again.\n\nThree limits keep this from becoming a nag:\n\n- **Ask once per conversation.** After they've answered — any way, Skip included —\n  don't raise it again in later turns; just keep the disclosure below.\n- **Never ask for an edit.** Changing content that already exists, answering a\n  question, or any read-only request proceeds normally.\n- **Never ask twice for one request.** The question is a fork, not a checkpoint you\n  return to.\n\n**The disclosure is separate from the question and always applies.** Whenever you\nproduce content with no document — including after the user chose to build\nwithout one — **tell the user, in the same message as the result, that no brand\ndocument exists**. Close that message with these two sentences, in this order —\nthe second does not replace the first: \"This workspace doesn't have a brand\ndocument yet, so I built this from your global styles rather than brand rules. You\ncan create a brand document later on the\n[Style tab](https://fly.customer.io/workspaces/$ENV_ID/design-studio/global-styles).\" When the request says \"match our brand\" and there is no document, an answer that\npresents the result as on-brand without the caveat is wrong even if the email\nitself is fine.\n\n## How to apply it\n\nThe document is prose, not a schema, so read it and follow it. In practice:\n\n- **Assets** — an Assets table lists images already imported into the workspace\n  asset library. Prefer a suitable entry for its documented role and use its\n  hosted URL exactly; use the supplied alt text as the starting point. The table\n  is the proof the asset exists — there is no need to fetch it, and a fetch that\n  fails from your sandbox says nothing about whether recipients can load it. If no\n  listed asset fits, search the customer's broader workspace asset library before\n  using a placeholder, hotlink, or new upload (see `@skills/fly-api/assets.md`).\n- **Colours** — use the roles, not your own judgement about what looks good. If it\n  says the accent is only ever a CTA fill inside dark bands, never use it as a\n  section background. Prefer the `globalStyles.colors.\u003cname\u003e_\u003cid\u003e` token reference\n  over a literal hex so a later token change propagates (see `global_styles.md`).\n  An empty or errored probe of the styles is not evidence the workspace has no\n  tokens — a workspace with a brand document almost always has them. Re-check with\n  the plain `GET /ds/global_styles` before falling back to literals, and treat a\n  review's token-literal-clash finding as proof the tokens are there.\n- **Token columns** — newer documents carry a Token column in their Color System\n  and Typography tables holding `globalStyles.*` references next to the literal\n  value that was current at generation time. The literal is a snapshot, nothing\n  more; the reference is canonical. **Never assume the reference still resolves to\n  the baked literal.** Any question about what a brand colour currently is — \"which\n  hex do I use\", \"the token looks different now\" — requires fetching the live\n  global styles (`GET /ds/global_styles`) before answering; the document alone\n  cannot answer it. When the live value differs from the baked literal, the token\n  wins, and the difference has one explanation: the token was edited after the\n  document was generated. It is never a rendering artifact or a display/monitor\n  issue — do not offer those. When editing these tables, keep refs verbatim —\n  never invent or alter one. **When you quote a token to a user, say its display\n  name and value, not its ref** — \"Primary action: primary, #004D3F\", never\n  \"Primary action: globalStyles.colors.primary_954sdn4fzr1i | #004D3F\". The ref is\n  an internal id: it belongs in markup and in the document's Token cells, not in a\n  sentence addressed to a person.\n- **Contrast** — the document's ratios were computed, not estimated. If it prohibits\n  a pairing, that pairing fails WCAG AA and you must not author it, even if the user\n  asks for \"the green text\". Offer the substitute the document names.\n- **Type** — take sizes, weights, and line-heights from its table. Where it says a\n  value was inferred rather than observed, you may deviate with reason; where it\n  states a measured value, don't.\n- **Spacing and layout** — its numbers are the defaults for `x-section` padding,\n  gaps between blocks, and container width.\n- **Signature moves** — the highest-value section. These are the gestures that make\n  output recognisably this brand. Use them.\n- **Prohibitions** — the \"never\" list is binding. It exists because someone would\n  otherwise plausibly do that thing.\n- **Voice** — pull subject lines, preheaders, and CTA labels from its Say list;\n  avoid its Avoid list. Respect its stated sentence length and reading level.\n\nWhen the brand document and your own design instinct disagree, the document wins.\nWhen the brand document and global styles disagree, prefer the token values for\n*values* (they're what actually renders) and the document for *rules* about which\nvalue to use where — and mention the discrepancy to the user, because it usually\nmeans the tokens were edited after the document was written. Call the token by its display name (\"the accent token\"), never the `globalStyles…` path — even when the document itself quotes one.\n\n## Updating it\n\nA write replaces the whole document — there is no partial update.\n\n**Only write when the user asks you to change a brand rule.** It is workspace-wide:\nevery future email, from every teammate and every session, is built against it.\nDon't rewrite it because you disagree with it, and don't \"tidy\" it as a side effect\nof another task.\n\nWhen you do write:\n\n1. Read the current document first and edit from that text — a blind write loses\n   everything you didn't reproduce.\n2. Change only what was asked. Preserve the section structure and everything else\n   verbatim. Never add facts the user didn't supply — no dates, names, or reasons\n   invented to make the change read better. When the asked change makes another\n   sentence inconsistent (a retired colour still named elsewhere), make the\n   smallest edit that restores consistency — don't delete alternatives that still\n   hold on their own.\n\n   The commonest miss is an either/or rule that names the retired value as one\n   option: retire that option, keep the alternatives. If a rule says a button\n   inside a dark band \"uses white text on an accent fill, or white text on a\n   bordered transparent fill\", and the accent is retired in favour of the band's\n   own colour, the right edit keeps only the bordered-transparent option — that\n   alternative exists precisely because a fill matching its band is invisible.\n   Collapsing the rule to the new colour deletes a live rule and prescribes an\n   invisible button. After editing, re-read every sentence you touched and check\n   it still makes sense against the rest of the document.\n3. Keep the discipline the document is written to: numbers, hexes, named\n   constraints, prohibitions. Never introduce a contrast ratio you haven't been\n   given — you cannot compute one reliably, and every prohibition downstream rests\n   on those figures being real. If a change would need a new ratio, say so and ask\n   the user to regenerate the document instead.\n4. Tell the user what you changed — every edit you made, including consistency\n   edits in other sections and any sentence or option you removed (a deletion is\n   an edit) — and that it affects future email for everyone in the workspace.\n\nDesign Studio shows who last changed the document — copilot writes are attributed\nto the agent, API writes to the authenticated user. Refuse to write an empty\ndocument.\n\n## Regenerating\n\nGenerating a document from sources (a domain, a logo, a PDF brand book) runs from\nthe **Design Studio → Style** tab in the product UI (the brand document folded\ninto it; the standalone Brand page was retired). It crawls and\nscreenshots the sources, extracts design tokens, computes the contrast matrix, and\nauthors the document, then shows it for review before saving. Don't run that\npipeline yourself or assemble a document from guesswork — the review-before-save\nstep exists so a human approves what every future email is built against. Point the\nuser at the Style tab instead —\n`https://fly.customer.io/workspaces/$ENV_ID/design-studio/global-styles`. This is why a\nworkspace with no document gets asked whether to generate one there or build\nwithout it, rather than an email presented as on-brand from guesswork\n(**§ When there is no document yet**).\n","bulk_apply_variant.md":"# Bulk-Apply a Component Variant\n\nSets a chosen variant on all instances of a component type within a scope — e.g. \"switch every CTA to the secondary variant across this folder.\" **Dry-runs and requires explicit confirmation before writing anything.**\n\nThis is the write-op counterpart to the variant audit: **promote_component_variants.md** surfaces *which* variants are worth creating; this applies an existing variant across many instances at once.\n\n## When to use\n\n- \"Make all my CTAs use the secondary variant\"\n- \"Apply the hero section variant everywhere in this folder\"\n- \"Switch every link to the cta link style\"\n- Any request to roll a variant choice across many component instances at once.\n\n## Background: variant-keyed groups and directives\n\nOnly these groups are variant-keyed. All others (typography tags, `x-paragraph`, `x-heading-*`, `x-base`, `x-subtitle`) are not — setting a variant on them has no effect, skip them entirely.\n\n| Group     | Tag          | Directive      |\n| --------- | ------------ | -------------- |\n| `button`  | `x-cta`      | `#variant`     |\n| `divider` | `x-hr`       | `#variant`     |\n| `image`   | `x-image`    | `#variant`     |\n| `section` | `x-section`  | `#variant`     |\n| `inbox`   | `x-inbox`    | `#variant`     |\n| `link`    | inline `\u003ca\u003e` | `data-variant` |\n\n## Inputs (resolve all before starting)\n\n| Input                 | Required | Notes                                                                                 |\n| --------------------- | -------- | ------------------------------------------------------------------------------------- |\n| Target group or tag   | ✅       | e.g. `button` / `x-cta`, `link` / `\u003ca\u003e`                                               |\n| Variant name to apply | ✅       | Bare name, verbatim — e.g. `secondary`, `Roamly Blue Button`                          |\n| Scope                 | ✅       | A specific node id, folder id, node type (`emails`\\|`components`), or whole workspace |\n\nIf any required input is missing, ask before proceeding.\n\n## Procedure\n\n### Step 1 — Validate the variant\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/global_styles\n```\n\nFor the target group:\n\n- Confirm the requested variant name exists. If it doesn't, stop and list the available variant names for that group.\n- Identify the **group default**: the variant where `$extensions[\"io.parcel.global-styles\"].default === true`. If none is flagged, the variant with the lowest `index` is the default.\n- Note whether the requested variant **is** the group default — this determines the rewrite behaviour in Step 3.\n\nSee **global_styles.md** for the response structure and `$extensions` format.\n\n### Step 2 — Resolve scope to nodes\n\nDepending on the scope the user specified (see **nodes.md § List** — paginate with `page` / `limit`):\n\n- **Whole workspace:** paginate `cio api /v1/environments/$ENV_ID/design_studio/emails` and `.../components`.\n- **By folder:** pass `parent_folder_id=\u003cid\u003e` to the list endpoints.\n- **By node type:** paginate only `.../design_studio/emails` or only `.../design_studio/components`.\n- **Specific node:** fetch that node directly.\n\nFor each node in scope, fetch its source markup:\n\n- Email: `cio api .../design_studio/emails/{id} --jq '.email.content.html' -r`\n- Component: `cio api .../design_studio/components/{id} --jq '.component.content' -r`\n\n\u003e **Note:** full-text search (`GET /ds/node_actions/search`, `--params '{\"search_query\": \"\u003ctag\u003e\"}'`) indexes html content but returns line/preview matches, not structured tag instances — it won't reliably enumerate every occurrence of a tag for rewriting. Always enumerate nodes and scan markup directly.\n\n### Step 3 — Rewrite each node\n\nScan each node's markup for every instance of the target tag. For each instance:\n\n**Determine the new directive value:**\n\n- If the requested variant **is the group default** → **remove** the directive entirely. This mirrors the editor's behaviour: an instance with no directive follows the workspace default automatically, so it will update if the default ever changes.\n- Otherwise → add or replace the directive with the bare variant name, verbatim.\n\n**How to apply the directive:**\n\n- For `x-cta`, `x-hr`, `x-image`, `x-section`, `x-inbox`:\n  - If the tag already has `#variant=\"...\"`: replace the value, or remove the attribute entirely if applying the default.\n  - If the tag has no `#variant` attribute: add `#variant=\"\u003cname\u003e\"` (skip this step if applying the default, since the absence of the attribute is already correct).\n- For inline `\u003ca\u003e`:\n  - Same logic using `data-variant` instead of `#variant`.\n\n**What not to touch:**\n\n- Do **not** alter any other attribute on the instance.\n- Do **not** alter slot content (inner HTML / child elements).\n- Do **not** modify attributes inside Liquid expressions (`{{ }}`, `{% %}`).\n\nRecord for each changed instance: node id, tag, the old directive value (or \"none\"), the new directive value (or \"removed\").\n\n### Step 4 — Dry-run (MANDATORY — stop here for confirmation)\n\nPresent a clear summary for every node that would change:\n\n```\nNode: \"Example Visual Editor Email\" (email)\n  x-cta #1:  #variant=\"primary\"  →  #variant=\"secondary\"\n  x-cta #2:  (none)              →  #variant=\"secondary\"\n\nNode: \"Cher's First Email\" (email)\n  x-cta #1:  #variant=\"Roamly Blue Button\"  →  #variant=\"secondary\"\n\nTotal: 3 instances across 2 nodes.\nVariant to apply: \"secondary\" (button group)\n```\n\nIf the requested variant is the group default, note it:\n\n```\nNote: \"Roamly Green Button\" is the group default. Applying it will remove the\n#variant directive from each instance so it tracks the workspace default.\n```\n\n**Then stop and ask:**\n\n\u003e \"Ready to apply this change to N instances across M nodes? Reply 'yes' to proceed or 'no' to cancel.\"\n\nDo **not** write anything until the user confirms.\n\n### Step 5 — Apply\n\nOn explicit confirmation, for each node with changes:\n\n1. Fetch fresh markup directly from the API (do not reuse the Step 2 copy — it may be stale).\n2. Apply all directive substitutions for that node.\n3. Write the updated markup back. Pass it as an `--arg` value and let `cio`'s `--json` build the body (multi-line markup is fine — see **nodes.md § Shell Safety**); for long markup, write it to a file and bind with `--arg h=@file`:\n   - Email:\n     ```bash\n     cio api /v1/environments/$ENV_ID/design_studio/emails/{id} -X PUT \\\n       --arg h=@/tmp/node_markup.html --json '{content:{html:$h}}'\n     ```\n   - Component:\n     ```bash\n     cio api /v1/environments/$ENV_ID/design_studio/components/{id} -X PUT \\\n       --arg h=@/tmp/node_markup.html --json '{content:$h}'\n     ```\n\nEditing a node does **not** publish — a linked Journeys destination keeps the previously published content until you publish again (**journeys.md**).\n\n### Step 6 — Report\n\n```\n✅ Done. Variant applied: \"secondary\" (button group)\n\nModified nodes (2):\n- \"Example Visual Editor Email\" (email) — 2 instances updated\n- \"Cher's First Email\" (email) — 1 instance updated\n```\n\nIf any node PUT failed, list it with the error and note it was skipped.\n\n## Guardrails\n\n- **Never write node content without explicit user confirmation.** The dry-run in Step 4 is mandatory on every invocation.\n- **Only act on variant-keyed tags.** Ignore all typography tags and anything not in the six-group table above.\n- **Default → strip the directive.** Never write the literal default variant name as a `#variant` value — remove the attribute instead.\n- **Never alter slot content or other attributes.** Only the `#variant` / `data-variant` directive changes.\n- **Never modify values inside Liquid expressions.**\n- If the variant name does not exist for the target group, stop at Step 1 and show available options.\n- If no instances of the target tag are found in scope, say so and stop. Do not proceed to dry-run with an empty list.\n\n## API reference\n\nSee **global_styles.md** for the full global styles API reference — endpoint paths, variant structure, `$extensions` format, and how to read the group default. Node enumeration and markup fetching (**nodes.md**):\n\n- `GET /v1/environments/$ENV_ID/design_studio/emails[?parent_folder_id=X]` — paginated; `.emails[]`\n- `GET /v1/environments/$ENV_ID/design_studio/components[?parent_folder_id=X]` — paginated; `.components[]`\n- `GET /v1/environments/$ENV_ID/design_studio/emails/{id}` → `.email.content.html`\n- `PUT /v1/environments/$ENV_ID/design_studio/emails/{id}` → body `{ \"content\": { \"html\": \"…\" } }`\n- `GET /v1/environments/$ENV_ID/design_studio/components/{id}` → `.component.content`\n- `PUT /v1/environments/$ENV_ID/design_studio/components/{id}` → body `{ \"content\": \"…\" }`\n\nAll calls go through `cio api`, which handles authentication and builds the JSON body via its bundled encoder (`--arg`/`--json`) — no manual escaping, no separate HTTP client. See **nodes.md § Shell Safety for Design Studio Payloads** for the patterns.\n","component_syntax.md":"---\ndescription: \u003e\n  The Design Studio \u003cx-*\u003e email grammar — property syntax (static vs dynamic,\n  numeric-only props), common mistakes, inline HTML, Liquid and interpolation,\n  responsive and dark-mode objects, globalStyles token paths and Component.utils,\n  image alt text and layer labels.\n---\n\n# Design Studio — Component Syntax\n\nThe `\u003cx-*\u003e` email grammar: how properties are written, which props are numeric-only, when to use `{ small, medium }` / `{ light, dark }` objects, how `globalStyles.*` token paths are built, and how Liquid fits in. Read this before writing or editing any Design Studio email markup. Per-component property lists live in **components_reference.md**; the short table of render-failing mistakes stays in **SKILL.md § Prop Mistakes**; custom (non-`x-`) components are covered in **SKILL.md § Custom Components** and **custom_components.md**.\n\n## Property Syntax\n\n**DEFAULT APPROACH:** Use static syntax with units for most properties: `padding=\"20px\"`, `color=\"#ff6b6b\"`, `border-radius=\"15px\"`. **However, prefer a global style — by value for a plain color, by name if the user names one** — use `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"` instead of `color=\"#007bff\"` (see **§ Global Styles \u0026 Component.utils**).\n\n**Dynamic** — `:property=\"expression\"` for JS expressions. This is Vue's `v-bind`\nshorthand and follows Vue's rule exactly: `property=\"16\"` passes the **string** `\"16\"`,\n`:property=\"16\"` passes the **number** `16`. (It is not React — the sigil goes on the\nattribute *name*, never in the value, there is no `{...}` value form, and attributes are\nkebab-case: a camelCase prop like `fontSize` is silently dropped.) Use ONLY when needed:\n1. **Numeric-only properties** — `:font-size=\"16\"`, `:line-height=\"1.6\"` (ALWAYS require dynamic syntax)\n2. **Global style tokens** — `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"`, `:padding=\"globalStyles.spacing.\u003csafeName\u003e_\u003cid\u003e\"` (ALWAYS use dynamic syntax)\n3. **Component.utils combinators** — `:padding=\"Component.utils.spacing(...)\"` (see § Global Styles \u0026 Component.utils). Never reference other identifiers in an expression — `:color=\"isPremium ? ... \"` fails the render, because only `globalStyles` and `Component.utils` exist at render time\n4. **Responsive/dark mode** — ``:padding=\"{ small: `20px`, medium: `40px` }\"`` (string values inside the object take **backticks**, not quotes)\n\n## Rules\n\n| Property type | Syntax | Example |\n|---|---|---|\n| Most properties | Static with units | `padding=\"20px\"`, `color=\"#ff6b6b\"` (or a value-matched token: `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"`) |\n| `font-size` | Always dynamic, number only* | `:font-size=\"16\"` |\n| `line-height` | Always dynamic, number only* | `:line-height=\"1.6\"` |\n| `gap` (x-row) | Dynamic, number only* | `:gap=\"16\"` |\n| `break-point` | Always dynamic, number only* | `:break-point=\"480\"` |\n| `size` (x-spacer) | Always dynamic, number only* | `:size=\"24\"` |\n| `height` (x-hr) | Always dynamic, number only* | `:height=\"2\"` (default `1`; `height=\"2px\"` and `height=\"2\"` both fail) |\n| `letter-spacing` | Always dynamic, number only* — unit is **em** | `:letter-spacing=\"0.05\"` (editor range **−0.1 to 0.5**, negative tightens; `1` = `1em` = huge) |\n| `display-max-width` | Always dynamic, number only* | `:display-max-width=\"460\"` |\n| Boolean props (`track-link`, `full-width` on x-cta, `reverse` on x-row, `looping` on x-countdown, `required`; in-app/inbox: `new-tab`, `hidden`, `is-start-section`) | Always dynamic | `:track-link=\"false\"`, `:new-tab=\"true\"`. `new-tab=\"true\"` passes the **string** `\"true\"` and fails the render (`expects boolean but received string`) |\n| `opacity` / `hover-opacity` | Always dynamic, number only* | `:hover-opacity=\"0.9\"` |\n| Responsive/dark mode | Dynamic object, backtick-wrapped string values | ``:padding=\"{ small: `20px`, medium: `40px` }\"`` |\n\n**Which props take which object** (and how the wrong one silently drops the value): **components_reference.md § Dark Mode**. The `{ ... }` row above applies only to those props.\n\n\\* **Numeric props stay numeric inside responsive objects:** `:font-size=\"{ small: 14, medium: 16 }\"` — bare numbers. Strings with units inside the object (``{ small: `14px` }``) fail the render.\n\n**Quick decision guide:**\n1. Is there a global style token for it — by value for a plain color, by name when the user names one? → `:property=\"globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e\"` (full path, both safeName and id required)\n2. Is it `font-size`, `line-height`, or `letter-spacing`? → `:property=\"number\"`\n3. Is it `gap`, `break-point`, `size`, or x-hr `height`? → `:property=\"number\"`\n4. Is it a boolean (`track-link`, `full-width`, `reverse`, `required`, in-app `new-tab`)? → `:property=\"true\"` / `:property=\"false\"`\n5. Combining several token/literal values? → `Component.utils.*` (never other variables — undefined identifiers fail the render)\n6. Is it responsive or dark mode? → `:property=\"{ ... }\"`\n7. Otherwise → `property=\"value\"` (static syntax with units)\n\n## Common Mistakes\n\n```html\n\u003c!-- WRONG → RIGHT --\u003e\n\u003cx-paragraph font-size=\"16px\"\u003e        \u003c!-- :font-size=\"16\" — numeric-only, always dynamic --\u003e\n\u003cx-paragraph :font-size=\"'16px'\"\u003e     \u003c!-- :font-size=\"16\" — expects number, not string --\u003e\n\u003cx-row gap=\"16px\"\u003e                    \u003c!-- :gap=\"16\" — numeric-only on x-row --\u003e\n\u003cx-paragraph :font-weight=\"700\"\u003e      \u003c!-- font-weight=\"700\" — font-weight is a STRING; a bare number fails the render --\u003e\n\u003cx-wrap :gap=\"16\"\u003e                    \u003c!-- gap=\"12px\" — x-wrap gap takes STRING with units! --\u003e\n\u003cx-spacer size=\"24px\"\u003e                \u003c!-- :size=\"24\" — numeric-only --\u003e\n\u003cx-paragraph letter-spacing=\"1px\"\u003e     \u003c!-- :letter-spacing=\"0.05\" — numeric-only; unit is em (range −0.1 to 0.5), so \"1\" = 1em (huge) --\u003e\n\u003cx-cta background-color=\"#007bff\"\u003e     \u003c!-- background=\"#007bff\" — x-cta uses background; background-color is a deprecated fallback --\u003e\n\u003cx-base background-color=\"#f5f5f5\"\u003e    \u003c!-- background-color is deprecated; use background=\"#f5f5f5\" instead --\u003e\n\u003cx-paragraph :color=\"#ff6b6b\"\u003e         \u003c!-- color=\"#ff6b6b\" — # is JS private field syntax; use static --\u003e\n\u003cx-section :padding=\"20\"\u003e             \u003c!-- padding=\"20px\" for a literal — but prefer :padding=\"globalStyles.spacing.\u003csafeName\u003e_\u003cid\u003e\" when a spacing token matches --\u003e\n\u003cx-section :padding=\"'20px'\"\u003e         \u003c!-- padding=\"20px\" — don't wrap static values in dynamic syntax --\u003e\n\u003cx-section :background-color=\"'#f5f5f5'\"\u003e  \u003c!-- background=\"#f5f5f5\" — background-color is deprecated; use static `background` for literal colors --\u003e\n\u003cx-box :border-radius=\"15\"\u003e           \u003c!-- border-radius=\"15px\" for a literal — but prefer :border-radius=\"globalStyles.radius.\u003csafeName\u003e_\u003cid\u003e\" when a radius token exists --\u003e\n\u003cx-box :border-width=\"1\"\u003e             \u003c!-- border-width=\"1px\" — a unit string; a bare number fails the render --\u003e\n\u003cx-box :height=\"200\"\u003e                 \u003c!-- height=\"200px\" — only x-hr's height is a number; everywhere else it is a unit string --\u003e\n\u003cx-section :margin=\"20\"\u003e              \u003c!-- margin=\"20px 0\" — string-based, use static with units --\u003e\n\u003cx-section :background-color=\"globalStyles.colors.abc123def456\"\u003e  \u003c!-- missing \u003csafeName\u003e_ prefix — won't resolve. Use :background=\"globalStyles.colors.brand_abc123def456\" (also use `background`, not `background-color`) --\u003e\n\u003cx-section :background-color=\"{ light: `#fff`, dark: `#1a1a1a` }\"\u003e  \u003c!-- :background=\"{ light: `#fff`, dark: `#1a1a1a` }\" — background-color is deprecated and takes no object; use `background`. Only x-hr's background-color takes { light, dark }; x-image's is a plain string --\u003e\n\u003cx-heading-1 align=\"center\"\u003e          \u003c!-- text-align=\"center\" — `align` positions blocks (x-image, x-cta, x-section, x-box, x-row, x-wrap, x-hr); text components have no `align`, so it does nothing --\u003e\n\u003cx-paragraph align=\"center\"\u003e          \u003c!-- text-align=\"center\" — same: text alignment is `text-align`, not `align` --\u003e\n```\n\n## Correct Examples\n\n```html\n\u003c!-- Global style tokens — PREFERRED. Replace \u003cid\u003e placeholders with actual token IDs from the API --\u003e\n\u003cx-paragraph :color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"\u003eBody text\u003c/x-paragraph\u003e\n\u003cx-section :background=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\" :padding=\"globalStyles.spacing.\u003csafeName\u003e_\u003cid\u003e\"\u003eContent\u003c/x-section\u003e\n\u003cx-cta href=\"https://example.com\" :background=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\" :color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"\u003eClick\u003c/x-cta\u003e\n\u003cx-heading-1 :color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\" :margin=\"globalStyles.spacing.\u003csafeName\u003e_\u003cid\u003e\"\u003eTitle\u003c/x-heading-1\u003e\n\n\u003c!-- Static string values — use when no global style exists --\u003e\n\u003cx-paragraph text-align=\"center\" color=\"#333333\"\u003eBody text\u003c/x-paragraph\u003e\n\u003cx-section background=\"#f5f5f5\" padding=\"20px\"\u003eContent\u003c/x-section\u003e\n\u003cx-box border-radius=\"15px\" border-style=\"solid\" border-width=\"1px\" padding=\"20px\"\u003eCard\u003c/x-box\u003e\n\n\u003c!-- Numeric-only properties — MUST use colon with bare numbers --\u003e\n\u003cx-paragraph :font-size=\"16\" :line-height=\"1.6\"\u003eStyled text\u003c/x-paragraph\u003e\n\u003cx-row :layout=\"[60, 40]\" :gap=\"16\" :break-point=\"480\"\u003e...\u003c/x-row\u003e\n\u003cx-spacer :size=\"24\" /\u003e\n\u003cx-hr background-color=\"#D9D9D9\" /\u003e\n\n\u003c!-- Dynamic values — colon prefix for responsive/dark mode objects --\u003e\n\u003cx-section :padding=\"{ small: `20px`, medium: `40px` }\"\u003eResponsive\u003c/x-section\u003e\n```\n\n## HTML Tags for Text Formatting\n\n**Inline** HTML goes in a component's text: `\u003ca\u003e`, `\u003cstrong\u003e`, `\u003cem\u003e`, `\u003cspan\u003e`, `\u003cu\u003e`, `\u003cbr\u003e`.\n\n**Block-level HTML does not** — a raw `\u003cul\u003e`/`\u003col\u003e`, `\u003cdiv\u003e`, `\u003cp\u003e` or `\u003ctable\u003e` renders but no component owns it, so the editor cannot select or delete it. Use the component (**components_reference.md**); a custom component's own `\u003ctemplate\u003e` is raw HTML and exempt.\n\n### Lists\n\n**A user request for bullets, a bulleted list, a numbered list, or numbered steps always means a semantic `x-list`.** Read **components_reference.md § x-list**, then use sibling list markup with one `\u003cli\u003e` per item:\n\n```html\n\u003cx-paragraph\u003eWhat shipped this month:\u003c/x-paragraph\u003e\n\u003cx-list list-style=\"disc\"\u003e\n  \u003cli\u003eShared views\u003c/li\u003e\n  \u003cli\u003eBulk export\u003c/li\u003e\n\u003c/x-list\u003e\n\n\u003cx-list list-style=\"decimal\"\u003e\n  \u003cli\u003eChoose a workspace\u003c/li\u003e\n  \u003cli\u003eInvite your team\u003c/li\u003e\n\u003c/x-list\u003e\n```\n\nNever imitate a list with bullet characters, manual numeric prefixes, `\u003cbr\u003e`-separated lines inside an `x-paragraph`, or separate cards/boxes unless the user explicitly asks for cards instead of a list. Never place an `x-list` inside an `x-paragraph`. When editing an existing `x-list`, preserve its `x-list`/`li` structure and change only the requested items.\n\n## Liquid \u0026 Interpolation\n\n**Template lifecycle:** Write (DS + Liquid) → Save (DS executes, Liquid untouched) → Send (Liquid executes with profile data)\n\n\u003e **`\u003cx-base\u003e` must be the root — put ALL Liquid inside it.** Any content before the opening `\u003cx-base ...\u003e` tag — including `{% assign %}` / `{% if %}` setup blocks — breaks the visual editor with `Invalid HTML — your code is probably missing a closing x-base tag`. Place setup/logic Liquid immediately **after** the opening `\u003cx-base ...\u003e` tag, never before it. The backend `/render` tolerates content before the root (so a passing render does **not** catch this), but the editor cannot mount a root that isn't the first node.\n\n- `${}` — **build-time** content interpolation: `\u003cx-paragraph\u003eHello ${user.name}!\u003c/x-paragraph\u003e`\n- `{{ }}` — **send-time** Liquid with customer/event data: `{{customer.first_name | default: \"there\"}}`\n- `{% %}` — **send-time** Liquid logic: `{% if customer.is_premium %}...{% endif %}`\n- **Don't** use `${}` for profile data — it doesn't exist at build time\n- For event data, use top-level event attributes such as `{{event.order_id}}`; do **not** use `{{event.data.order_id}}` unless the event payload itself has a nested `data` attribute.\n- **Subject and preheader render in a separate Liquid scope from the body.** A variable created with `{% assign %}` in the body markup does **not** exist when the subject or preheader is rendered — referencing it there sends blank (`Missing variable in Subject field`). For a value needed in more than one field, or computed from non-trivial logic, compute it **once before the message** with an `attribute_update_action` (`sub_type: \"journey_attr_update\"` — the value supports Liquid; see `@skills/fly-api/data_operations.md § Attribute Update`) and reference `{{ journey.\u003cname\u003e }}`, which is available in every field's scope at send time (subject, preheader, body). Otherwise put only send-time data (`{{customer.*}}`, `{{event.*}}`) directly in the subject. Don't compute a value in the body and expect it in the subject.\n\n```html\n\u003c!-- Combining both: DS for layout, Liquid for personalization --\u003e\n\u003cx-section :padding=\"{ small: `20px`, medium: `40px` }\"\u003e\n  \u003cx-heading-1\u003eHi {{customer.first_name}}!\u003c/x-heading-1\u003e\n  {% if event.order_total \u003e 100 %}\n  \u003cx-paragraph color=\"#007700\"\u003eYou qualify for free shipping!\u003c/x-paragraph\u003e\n  {% endif %}\n  \u003cx-paragraph\u003eOrder total: {{event.order_total | round: 2}}\u003c/x-paragraph\u003e\n\u003c/x-section\u003e\n```\n\n## Responsive \u0026 Dark Mode\n\nDefining explicit `{ light, dark }` values fixes dark mode contrast issues — set them in pairs (a dark background needs matching light text, or dark mode ends up light-on-light). A request to \"add dark mode colors\" to a component means adding `{ light, dark }` pairs — keep the current value as the `light` half and add a `dark` value; it does NOT mean replacing the value with a darker color. On a button that is both `background` and `color`, each its own pair, even when one half repeats. **Forced dark mode** (Outlook/Gmail) is different: the email client overrides colors, which is largely out of our control. Fixing regular dark mode contrast issues often resolves forced-dark warnings too. If forced-dark warnings appear *without* regular contrast issues, suggest the user either increases contrast further or reviews the forced-dark preview to assess the issue.\n\nOnly use objects for properties marked as **Responsive** or **Dark Mode** in component definitions. The **theme-able** props — the ones that accept a `{ light, dark }` object — are **`color`**, **`background`** and **`outer-background`**, **`border-color`**, and **`x-hr`'s `background-color`**. `padding`/`font-size`/`width`/`text-align` are **responsive-only** (`{ small, medium }`, no dark). `font-family` takes a **plain string only**, and `line-height` / `letter-spacing` take a **bare number only** (`:line-height=\"1.6\"`, `:letter-spacing=\"0.05\"`) — passing any of them an object fails, and letter-spacing rejects string values entirely. `background-color` is deprecated on most components (use `background` instead) — the exceptions are `x-hr` (supports dark mode objects) and `x-image` (plain string only).\n\n```html\n\u003c!-- Responsive: different values for mobile (small) and desktop (medium) --\u003e\n\u003cx-section :padding=\"{ small: `20px`, medium: `40px` }\"\u003e\n\n\u003c!-- Dark mode: different values for light and dark themes --\u003e\n\u003cx-paragraph :color=\"{ light: `#000000`, dark: `#ffffff` }\"\u003e\n\n\u003c!-- Backgrounds: use `background` (NOT `background-color`) for dark/responsive fills.\n     Always give an explicit light value — an empty/omitted light makes the\n     readiness/contrast checker read the wrong background and mis-flag \"hard to read\". --\u003e\n\u003cx-section :background=\"{ light: `#ffffff`, dark: `#1a1a1a` }\"\u003e\n\n\u003c!-- Both responsive AND dark mode — only x-image `src` takes the four-way form.\n     Compound keys are double-quoted, so the attribute itself switches to single\n     quotes; this is exactly what the visual editor serializes. Every other prop is\n     one or the other ({ small, medium } or { light, dark }), never both. --\u003e\n\u003cx-image :src='{ \"small:light\": `https://cdn.example.com/hero-mobile.png`, \"small:dark\": `https://cdn.example.com/hero-mobile-dark.png`, \"medium:light\": `https://cdn.example.com/hero.png`, \"medium:dark\": `https://cdn.example.com/hero-dark.png` }' /\u003e\n\n\u003c!-- Responsive numeric values — bare numbers inside the object, never px strings --\u003e\n\u003cx-paragraph :font-size=\"{ small: 14, medium: 16 }\"\u003e\n```\n\n\u003e **Use `background`, not `background-color`.** `background-color` is deprecated — it accepts only a plain string, so an object value fails with `expects string but received object`. Use **`background`** (content-area fill) or **`outer-background`** (full-viewport) on `x-section` / `x-base` / `x-box` for both general backgrounds and dark-mode variants (via `{ light, dark }` objects). **Exceptions:** `x-hr` and `x-image` use `background-color` (they have no `background` prop). `x-hr`'s `background-color` supports dark mode (`{ light, dark }` objects work); `x-image`'s does not. Wrap the string values inside an object in **backticks** — ``{ light: `#8ccbf2`, dark: `#0b353b` }``. All three delimiters render, but only backticks survive the visual editor: its property panel strips backticks and keeps quote characters as part of the value, so a quoted value shows up broken in the color/spacing controls and gets written back corrupted on the next panel edit. Backticks are also what the editor serializes, and they let a value embed Liquid without clashing with the attribute's quotes. Bare numbers and `globalStyles.*` refs are not strings — leave them unwrapped.\n\n## Global Styles \u0026 Component.utils\n\n**Prefer global styles over hardcoded values — by value for plain colors, by name when the user names one** (`global_styles.md` § Choosing a color token). Use the full `:` path.\n\n**Token reference format:** `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`. **Both parts are required** — the renderer is a JS expression evaluator and needs the full `\u003csafeName\u003e_\u003cid\u003e` path to resolve a token. Dropping the safeName (just `globalStyles.colors.\u003cid\u003e`) silently fails to resolve and renders as empty.\n\n- `\u003cgroup\u003e`: only variable token groups are available in expressions: `colors`, `fonts`, `radius`, `spacing` — `color` and `font` are pluralized, but `radius` and `spacing` are **not**. Component style groups (`button`, `textStyle`, `link`, `divider`, `image`, `section`, `inbox`) are applied as defaults or variants, not referenced through `globalStyles.*`; see **global_styles.md § Applying Component Styles**.\n- `\u003csafeName\u003e`: the token's JSON key with non-word chars stripped, then lowercased. `brand-cream` → `brandcream`, `neutral-100` → `neutral100`. Non-word chars (hyphens, spaces, dots) are stripped *before* camelCasing, so they are never boundaries. Underscores survive and are (`heading_color` → `headingColor`), as are internal capitals.\n- `\u003cid\u003e`: the value of `$extensions[\"io.parcel.global-styles\"].id` for the token.\n\nExample: a color token with key `\"brand-primary\"` and `$extensions.id` = `\"xgwx2rlcwva5\"` is referenced as `globalStyles.colors.brandprimary_xgwx2rlcwva5`.\n\n```html\n\u003c!-- Token references combine the safe name and the id, joined by underscore --\u003e\n\u003cx-heading-1 :color=\"globalStyles.colors.heading_a8f3k2d\"\u003e\n\u003cx-paragraph :color=\"globalStyles.colors.body_q7m2p9x\"\u003e\n\u003cx-section :background=\"globalStyles.colors.surface_r4k8w2n\"\u003e\n\u003cx-section :padding=\"globalStyles.spacing.medium_xzw5d19i9r2i\"\u003e\n\u003cx-box :border-radius=\"globalStyles.radius.card_m3k7r2p\"\u003e\n\u003cx-cta :background=\"globalStyles.colors.brand_a8f3k2d\" :color=\"globalStyles.colors.onBrand_j5n1v8b\"\u003e\n\n\u003c!-- Component.utils — ONLY for combining MULTIPLE values (2+) --\u003e\n\u003cx-section :padding=\"Component.utils.spacing(globalStyles.spacing.small_abc123, globalStyles.spacing.large_def456)\"\u003e\n\u003cx-section :padding=\"Component.utils.spacing(globalStyles.spacing.small_abc123, '0px', '0px', globalStyles.spacing.large_def456)\"\u003e\n\u003cx-paragraph :color=\"Component.utils.opacity(globalStyles.colors.body_q7m2p9x, 0.5)\"\u003e\n\u003cx-section :background=\"Component.utils.background('linear-gradient(90deg, #000 0%, #fff 100%)', globalStyles.colors.brand_a8f3k2d)\"\u003e\n\u003cx-box :border-radius=\"Component.utils.radius(globalStyles.radius.card_m3k7r2p, '0px')\"\u003e\n```\n\n**Never** use string concatenation (e.g., `:padding=\"globalStyles.spacing.small_abc123 + ' 10px'\"`) — it breaks the visual editor. **Never** use `Component.utils` for single values — just use the value directly.\n\n**When no global style exists** for a value, fall back to static syntax with hardcoded values: `color=\"#333333\"`, `padding=\"20px\"`.\n\n**Validation note:** Publishing does NOT validate that global style tokens resolved correctly. Unresolvable tokens like `globalStyles.colors.nonexistent` silently render as empty — the publish still succeeds. **Before writing markup, cross-check every `globalStyles.*` reference against the actual token IDs from the API response.** If a token ID doesn't match, the color/spacing will be silently missing.\n\n**Design-system operations.** To audit a workspace for tokens/variants worth promoting, or to roll a value/variant out across every node: run the read-only audits (**promote_design_tokens.md**, **promote_component_variants.md**, or **design_system_audit.md** for both at once) to surface candidates, then apply with **mass_replace_value.md** (create a variable and rewrite all references) or **bulk_apply_variant.md** (set an existing variant on every instance of a component type). Both write ops dry-run and require explicit confirmation before any write.\n\n## Images \u0026 Labels\n\n- Placeholder images: `https://placehold.co/600x300`\n- Always include `alt` text for accessibility\n- Layer labels for editor identification:\n\n```html\n\u003cx-section data-layer-label=\"Header\"\u003e\n  \u003cx-image data-layer-label=\"Company Logo\" src=\"logo.png\" alt=\"Company Logo\" width=\"200px\" align=\"center\" /\u003e\n  \u003cx-heading-1 data-layer-label=\"Main Title\"\u003eWelcome!\u003c/x-heading-1\u003e\n\u003c/x-section\u003e\n```\n","components_reference.md":"# Design Studio: Component Property Reference\n\n**Global style token references** in this file (e.g. `globalStyles.colors.primary_id`) are **placeholders** — replace them with the actual opaque token ID from the workspace's API response. The ID comes from `$extensions[\"io.parcel.global-styles\"].id`.\n\n## Component Hierarchy\n\n```\nx-base (root, one per message)\n  └─ x-section (full-width row, min 1)\n       ├─ content: x-heading-*, x-paragraph, x-cta, x-image, x-list, x-html, x-spacer, x-hr, x-countdown, x-hidden-text\n       ├─ x-box (grouping container, nestable)\n       ├─ x-wrap (horizontal button/image group)\n       └─ x-row \u003e x-column (multi-column layout)\n```\n\nIn-app: `x-message` replaces `x-section`.\n\n## Custom Components\n\nWorkspaces may have **custom reusable components** for headers, footers, logos, social links, CTAs, etc. These use kebab-case tag names that do **NOT** start with `x-` (e.g., `\u003ccompany-header\u003e`, `\u003cemail-footer\u003e`, `\u003csocial-links\u003e`).\n\nCustom components accept props and/or slots defined by their creator. Check `GET .../ds/component_definitions` for available custom components and their parsed props, slots, and presets.\n\n```html\n\u003c!-- Custom components sit inside x-section or directly in x-base, depending on their type --\u003e\n\u003cx-base font-family=\"Arial, sans-serif\" :font-size=\"16\" :line-height=\"1.6\"\u003e\n  \u003ccompany-header /\u003e                \u003c!-- reusable header with logo --\u003e\n  \u003cx-section :padding=\"globalStyles.spacing.large_id\"\u003e\n    \u003cx-heading-1 :color=\"globalStyles.colors.heading_id\"\u003eWelcome!\u003c/x-heading-1\u003e\n    \u003cx-paragraph\u003eEmail body content...\u003c/x-paragraph\u003e\n  \u003c/x-section\u003e\n  \u003cemail-footer unsubscribe-url=\"{% unsubscribe_url %}\" /\u003e  \u003c!-- reusable footer --\u003e\n\u003c/x-base\u003e\n```\n\nCustom components with `componentType: 'layout'` or `'structure'` act as structural wrappers. Custom components with `componentType: 'content'` go inside sections like any other content.\n\n## Editor Presets (Not in the API)\n\nThe editor's component panel also lists **presets** — pre-filled markup built from standard `x-*` components. They are not components, so `component_definitions` never returns them. When the user asks for one by its panel name, insert the markup below.\n\n**Social links** — hosted icons, 50px each:\n\n```html\n\u003cx-wrap\u003e\n  \u003cx-image alt=\"facebook\" href=\"https://facebook.com/example\" src=\"https://userimg-assets.customeriomail.com/images/client-env-138234/1743424590192_facebook_01JQP384W9XTHH4P79BKXRGBBK.png\" width=\"50px\"/\u003e\n  \u003cx-image alt=\"x\" href=\"https://x.com/example\" src=\"https://userimg-assets.customeriomail.com/images/client-env-138234/1743424591861_twitter_01JQP3864FRW78C0D2XRFBQT10.png\" width=\"50px\"/\u003e\n  \u003cx-image alt=\"instagram\" href=\"https://instagram.com/example\" src=\"https://userimg-assets.customeriomail.com/images/client-env-138234/1743424592414_instagram_01JQP386NHM1ACZXKD9NZVSRHE.png\" width=\"50px\"/\u003e\n  \u003cx-image alt=\"linkedIn\" href=\"https://linkedin.com/company/example\" src=\"https://userimg-assets.customeriomail.com/images/client-env-138234/1743424591320_linkedin_01JQP385KBK48HV4YDB9WBJ1PC.png\" width=\"50px\"/\u003e\n\u003c/x-wrap\u003e\n```\n\n- Use these `src` URLs exactly. **Never invent or guess icon URLs** — a made-up path renders as a broken image. For a network not listed here, use an icon from the brand's own assets or leave it out.\n- Replace each `href` with the brand's real profile URL, and drop icons for networks the brand doesn't use. Resize with `width` (footers usually use 24–32px).\n- Prefer a workspace custom component (e.g. `\u003csocial-links\u003e`) when `component_definitions` has one.\n\n**Navigation** — `\u003cx-wrap\u003e\u003cx-cta\u003eHome\u003c/x-cta\u003e\u003cx-cta\u003eAbout\u003c/x-cta\u003e\u003cx-cta\u003eContact\u003c/x-cta\u003e\u003c/x-wrap\u003e`.\n\n## Property Syntax Summary\n\nThere are two property syntaxes. Choose based on the property:\n\n| Syntax | When to use | Example |\n|---|---|---|\n| `property=\"value\"` (Static) | **Default for most properties.** Strings with units. | `padding=\"20px\"`, `color=\"#ff6b6b\"` |\n| `:property=\"expression\"` (Dynamic) | Numeric-only properties, booleans, global style tokens, variables, responsive/dark mode objects | `:font-size=\"16\"`, `:track-link=\"false\"`, `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"`, ``:padding=\"{ small: `20px`, medium: `40px` }\"`` |\n\nGlobal style tokens can be referenced using `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"` syntax.\n\n**Static vs dynamic is chosen by the value, not by the property.** The left column is the default for a literal, not a restriction on the property: any property holding a token takes the `:` form, `font-family` and `padding` included. This applies to custom component props exactly as it does to built-ins — a prop declared `Component.props.string()` accepts `:background=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"`, because the path evaluates to the string it stores. If you have written `:font-size` on a component, that component takes `:background` and `:padding` too.\n\n## Dark Mode\n\nThe **theme-able** props — the ones that accept a `{ light, dark }` object — are `color`, `background`, `outer-background`, `border-color`, `x-hr`'s `background-color`, and `x-image`'s `src`. That includes `x-cta`: its `background`, `color`, and `hover-background` all take dark-mode objects.\n\n**A prop takes only the object kind it supports, and the wrong kind fails in one of two ways:**\n\n| Object | Props that take it |\n|---|---|\n| `{ light, dark }` | the theme-able props above, plus `x-cta`'s `hover-color` |\n| `{ small, medium }` | `font-size` (text components), `width`, `height`, `padding`, `margin` (except on `x-message`, where `width`, `padding` and `margin` are plain strings), `align`, `text-align`, `x-hr`'s `height` |\n| both, as `\"small:light\"` keys | `x-image`'s `src` only |\n| neither | everything else: `hide`, `line-height`, `letter-spacing`, `opacity`, `x-spacer`'s `size`, `x-row`'s `gap`, `border-radius`, `border-width`, `box-shadow`, `font-weight`, and `background-color` on `x-image`, `x-box` and `x-section` |\n\n- On a prop that takes **neither**, any object fails the render (`expects string but received object`).\n- On a prop that takes the **other** kind, the object passes validation but its keys are discarded, so the value **silently disappears** with no error: `:padding=\"{ light: … }\"` or `:color=\"{ small: … }\"` renders as if the prop were unset. A passing render does not prove a dark-mode or responsive value took effect.\n- `hide` is a plain value, never an object: `hide=\"small\"` hides on mobile. ``:hide=\"{ small: `none` }\"`` fails.\n\n**Wrap string values inside a `{ ... }` object in backticks, not quotes** — ``{ light: `#0F172A`, dark: `#E2E8F0` }``, not `{ light: '#0F172A', … }`. Bare numbers and `globalStyles.*` refs stay unwrapped. Quoted strings render, but the visual editor's property panel treats the quote characters as part of the value, so the panel shows a broken value and corrupts it on the next edit. This applies to responsive `{ small, medium }` objects too.\n\nA request to \"add dark mode colors\" or \"apply dark mode\" to a component means **adding a `{ light, dark }` pair**: keep the component's current value as the `light` half and add a suitable `dark` value. It does NOT mean replacing the value with a darker color.\n\n```html\n\u003c!-- \"Add dark mode colors to this button\" — current background/color become the light halves --\u003e\n\u003cx-cta href=\"{{action_url}}\" :background=\"{ light: `#0F172A`, dark: `#E2E8F0` }\"\n  :color=\"{ light: `#FFFFFF`, dark: `#0F172A` }\"\u003eGet Started\u003c/x-cta\u003e\n```\n\n## Numeric-Only Properties (No Units)\n\nThese take **bare numbers** with `:` prefix — NOT strings with `px`. Most common validation error source.\n\n| Property | Component(s) | Wrong | Right |\n|---|---|---|---|\n| `font-size` | all text | `font-size=\"16px\"` | `:font-size=\"16\"` |\n| `line-height` | all text | `line-height=\"1.6\"` | `:line-height=\"1.6\"` |\n| `gap` | `x-row` only | `gap=\"16px\"` | `:gap=\"16\"` |\n| `break-point` | `x-row` | `break-point=\"480\"` | `:break-point=\"480\"` |\n| `size` | `x-spacer` | `size=\"24px\"` | `:size=\"24\"` |\n| `height` | `x-hr` | `height=\"2px\"` or `height=\"2\"` — **both fail the render** (`height invalid input`) | `:height=\"2\"` (number, default `1`, min `1`, responsive) |\n| `letter-spacing` | all text | **string forms fail the render** — never `letter-spacing=\"0.05em\"` or `\"2px\"` | `:letter-spacing=\"0.05\"` (number ONLY; unit is **em**; range **−0.1 to 0.5**, negative tightens — `1` = `1em` = huge) |\n| `opacity` / `hover-opacity` | most components (incl. `x-image`, `x-cta`) | `opacity=\"0.5\"` — **string fails the whole render** (`expects number but received string`), so the component vanishes from the preview | `:opacity=\"0.5\"` (number, scale **0–1** — `1` = fully opaque, never a percentage like `50`) |\n\n**`font-weight` is a string, not a number:** `font-weight=\"700\"`, never `:font-weight=\"700\"` — a bare number fails the whole render (`expects string but received number`). The `:` form is only for a token (`:font-weight=\"globalStyles.…\"`).\n\nAll other dimensional properties (`padding`, `margin`, `width`, `height`, `border-radius`, `border-width`, etc.) use **static syntax with string units**: `padding=\"20px\"`, `margin=\"20px 0\"`, `border-radius=\"8px\"`, `border-width=\"1px\"`. A bare number fails the render (`expects string but received number`): never `:border-radius=\"8\"` or `:border-width=\"1\"`. `height` is a unit string everywhere except `x-hr` (`x-box`, `x-wrap`, `x-cta`, `x-image` take `height=\"200px\"`).\n\n## Text Style Inheritance\n\nText styles (color, font-family, font-size, font-weight, line-height, letter-spacing, text-align) cascade from parent to child. Set on `x-base`/`x-section` to style all children. Override inherited styles with empty string: `font-family=\"\"`.\n\n**System fonts** (email-safe, no web font loading required): `Arial, sans-serif` · `Helvetica, sans-serif` · `Times New Roman, serif` · `Arial Black, sans-serif` · `Courier New, monospace` · `Georgia, serif` · `Tahoma, sans-serif` · `Verdana, sans-serif`. Use `font-family=\"\"` (inherit) when global styles define the font. Workspaces may also have web fonts configured via global styles — prefer those when available.\n\n## Shared Property Groups\n\n- **prose** — All text styling + margin, text-transform, text-decoration, hide, lang, dir, class, style\n- **simpleText** — Text styling only (color, font-family, font-size, font-weight, line-height, letter-spacing, text-align). Applied as \"Paragraph Styles\" on containers\n- **media** — Image layout (alt, width, margin, align) + `:opacity` (number, 0–1) + borders + box-shadow + hover effects + alt-text styling\n- **action** — In email: `href`, `track-link`. In in-app: adds `behavior`, `deep-link`, `tracked-response-name`, `new-tab`, `show-message-target`. Do NOT use `dismiss-on-click` — it breaks the in-app preview renderer.\n\n## Layout Components\n\n### x-base\nRoot. One per message. Slots: `x-section`/`x-message` (min 1). Props: `title`, `background` (sets the email body background; supports dark mode via `{ light, dark }` object), `color-scheme`. Inherits: simpleText. Defaults: color `#000000`, background `#fefefe`, font-family `Helvetica, sans-serif`, line-height `1.5`. **`background-color` is deprecated** — use `background` instead. **`\u003cx-base\u003e` must be the first/root node** — all content, including any Liquid `{% assign %}`/`{% if %}` setup, goes *inside* it; nothing (not even Liquid) may appear before the opening tag, or the visual editor fails with `missing a closing x-base tag`.\n\n```html\n\u003c!-- Set base text styles here so all children inherit; use global styles when available --\u003e\n\u003cx-base font-family=\"Arial, sans-serif\" :font-size=\"16\" :line-height=\"1.6\" :color=\"globalStyles.colors.bodyText_id\"\u003e\n  \u003cx-section\u003e...\u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### x-section\nFull-width section. Must be inside `x-base`. Key props: `width` (default `640px`, responsive), `padding` (default `20px`, responsive), `outer-background` (full-viewport fill; also accepts `{ light, dark }` for dark mode), `background` (content-area fill; also accepts `{ light, dark }` for dark mode), `align` (default `center`). Also: margin, opacity, borders, box-shadow, hide. Inherits: simpleText.\n\n```html\n\u003c!-- Section with global style padding and dark mode background.\n     Dark/responsive fills go on `background` (or `outer-background`), NOT `background-color`\n     (that prop is string-only and rejects objects). --\u003e\n\u003cx-section\n  :padding=\"globalStyles.spacing.large_id\"\n  :background=\"{ light: globalStyles.colors.surface_id, dark: globalStyles.colors.surfaceDark_id }\"\n  data-layer-label=\"Main Content\"\u003e\n  ...\n\u003c/x-section\u003e\n```\n\n### x-row (Columns)\nMulti-column layout. Slots: `x-column` only. Key props: `layout` (required, array: `:layout=\"[50, 50]\"`), `gap` (bare number: `:gap=\"16\"`), `break-point` (bare number: `:break-point=\"480\"`), `fallback` (`single`/`multi`), `reverse`. Also: width, padding, margin, align, background, opacity, borders, box-shadow, hide. Inherits: simpleText.\n\n```html\n\u003c!-- Two-column layout that stacks on mobile --\u003e\n\u003cx-row :layout=\"[60, 40]\" :gap=\"16\" :break-point=\"480\"\u003e\n  \u003cx-column padding=\"10px\"\u003eLeft content (60%)\u003c/x-column\u003e\n  \u003cx-column padding=\"10px\"\u003eRight content (40%)\u003c/x-column\u003e\n\u003c/x-row\u003e\n\n\u003c!-- Three equal columns --\u003e\n\u003cx-row :layout=\"[33, 34, 33]\" :gap=\"16\" :break-point=\"480\"\u003e\n  \u003cx-column\u003eCol 1\u003c/x-column\u003e\n  \u003cx-column\u003eCol 2\u003c/x-column\u003e\n  \u003cx-column\u003eCol 3\u003c/x-column\u003e\n\u003c/x-row\u003e\n```\n\n### x-column\nInside `x-row`. Width set by parent's `layout`. Key props: `vertical-align` (`top`/`middle`/`bottom`), `padding` (responsive). Also: background, opacity, borders, box-shadow.\n\n### x-box\nEmail-safe `\u003cdiv\u003e`. Grouping, backgrounds, borders, spacing. Key props: `width` (default `auto`, responsive), `height`, `padding`, `margin`, `align`, `background` (also accepts `{ light, dark }` for dark mode). Also: opacity, borders, box-shadow, hide. Inherits: simpleText.\n\n```html\n\u003c!-- Card-style box with border — use global styles for colors/radii --\u003e\n\u003cx-box :background=\"globalStyles.colors.surface_id\" padding=\"24px\" margin=\"16px 0\"\n  border-style=\"solid\" border-width=\"1px\" :border-color=\"globalStyles.colors.border_id\" :border-radius=\"globalStyles.radius.medium_id\"\u003e\n  \u003cx-heading-2\u003eCard Title\u003c/x-heading-2\u003e\n  \u003cx-paragraph\u003eCard content goes here.\u003c/x-paragraph\u003e\n\u003c/x-box\u003e\n```\n\n### x-wrap (Navigation)\nHorizontal flex-wrap for buttons/images. Slots: `x-image` and `x-cta` only. Key props: `gap` (**string with units**: `gap=\"8px\"`, default `8px` — unlike x-row's bare number!), `align-content` (`left`/`center`/`right`), `vertical-align` (`top`/`middle`/`bottom`), `align`. Also: width, height, padding, margin, background, opacity, borders, box-shadow, hide.\n\n```html\n\u003c!-- Horizontal button group (note: gap uses STRING with units here) --\u003e\n\u003cx-wrap gap=\"12px\" align-content=\"center\"\u003e\n  \u003cx-cta href=\"/pricing\" :background=\"globalStyles.colors.primary_id\" color=\"#ffffff\"\u003eView Plans\u003c/x-cta\u003e\n  \u003cx-cta href=\"/demo\" :background=\"globalStyles.colors.secondary_id\" color=\"#ffffff\"\u003eBook Demo\u003c/x-cta\u003e\n\u003c/x-wrap\u003e\n```\n\n**CAUTION:** `x-wrap` uses `gap=\"12px\"` (string with units). `x-row` uses `:gap=\"16\"` (bare number). Mixing these up is a common mistake.\n\n## Text \u0026 Content Components\n\n### x-heading-1 through x-heading-6\nUses **prose** group. Default font-sizes: h1=`32`, h2=`24`, h3=`19`, h4=`16`, h5=`13`, h6=`11`. Default margins: h1=`21px 0`, h2=`20px 0`, h3=`19px 0`, h4=`21px 0`, h5=`22px 0`, h6=`25px 0`. Default font-weight is `bold` (inherited from the HTML heading element). Both font-size and margin support responsive. One h1 per email, sequential levels (h1 → h2 → h3; never skip).\n\n```html\n\u003cx-heading-1 :font-size=\"28\" :color=\"globalStyles.colors.heading_id\" margin=\"0 0 16px 0\"\u003eMain Title\u003c/x-heading-1\u003e\n\u003cx-heading-2 :font-size=\"22\" :color=\"globalStyles.colors.heading_id\"\u003eSection Title\u003c/x-heading-2\u003e\n```\n\n### x-paragraph\nBody text. **prose** group. Default margin `16px 0` (responsive). Supports inline HTML tags for formatting.\n\n```html\n\u003cx-paragraph :color=\"globalStyles.colors.bodyText_id\" :font-size=\"16\" :line-height=\"1.6\"\u003e\n  Welcome to our platform! Check out our \u003ca href=\"https://example.com\"\u003elatest features\u003c/a\u003e\n  and enjoy \u003cstrong\u003eexclusive benefits\u003c/strong\u003e.\n\u003c/x-paragraph\u003e\n```\n\n### x-list\nBulleted/numbered list. Uses `\u003cli\u003e` tags inside. Key prop: `list-style` — bulleted: `disc`, `circle`, `square`; numbered: `decimal`, `decimal-leading-zero`, `lower-alpha`, `upper-alpha`, `lower-roman`, `upper-roman`. Nestable. The `element` prop is deprecated — list type is determined by `list-style` instead.\n\n```html\n\u003cx-list list-style=\"disc\" :color=\"globalStyles.colors.bodyText_id\"\u003e\n  \u003cli\u003eFirst item with \u003cstrong\u003ebold\u003c/strong\u003e text\u003c/li\u003e\n  \u003cli\u003eSecond item with \u003ca href=\"https://example.com\"\u003ea link\u003c/a\u003e\u003c/li\u003e\n  \u003cli\u003eThird item\u003c/li\u003e\n\u003c/x-list\u003e\n```\n\n**Never emit a raw `\u003cul\u003e` or `\u003col\u003e` in message markup.** Every list is an `x-list`; a numbered list is `x-list` with a numbered `list-style`, not an `\u003col\u003e`. A raw list still renders, so nothing warns you — but it has no component behind it, and the visual editor cannot select, restyle or delete it. (Inside a custom component's `\u003ctemplate\u003e` raw `\u003cul\u003e` is correct — that file *is* raw HTML; see **custom_components.md**.)\n\nA nested list is another `x-list`, and it goes *beside* the items rather than inside an `\u003cli\u003e` — it renders its own list item, so nesting it within one produces a stray bullet:\n\n```html\n\u003cx-list list-style=\"disc\"\u003e\n  \u003cli\u003eFirst\u003c/li\u003e\n  \u003cx-list list-style=\"disc\"\u003e\u003cli\u003eNested under the first\u003c/li\u003e\u003c/x-list\u003e\n\u003c/x-list\u003e\n```\n\n### x-html\nRaw HTML injection. Only prop: `hide`. Slot: raw HTML. The one place a message may carry raw block tags (`\u003cul\u003e`, `\u003ctable\u003e`, `\u003cdiv\u003e`) — everything inside is opaque to the editor, so reach for it only when no component can express the markup.\n\n### x-hidden-text\nPreheader/preview text. No props. Place inside `x-base` before first `x-section`.\n\n```html\n\u003cx-base\u003e\n  \u003cx-hidden-text\u003eThis text appears as email preview in inbox but is hidden in the body.\u003c/x-hidden-text\u003e\n  \u003cx-section\u003e...\u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n## Interactive, Media \u0026 Utility\n\n### x-cta (Button)\n**Uses `background`, NOT `background-color`.** `background-color` is a deprecated fallback that applies only while `background` is left at its default, so set `background`. Defaults: background `#000000`, color `#FFFFFF`, font-size `16`, padding `10px 20px`, margin `8px 0`, align `center`, border-radius `4px`. Hover: `hover-color`, `hover-background`, `hover-opacity` (numeric-only: `:hover-opacity=\"0.9\"`), `hover-box-shadow`, `hover-border-radius`. Also: width, height, opacity (numeric-only: `:opacity=\"0.9\"`), borders, box-shadow, hide. `background`, `color`, `border-color`, and `hover-background` accept `{ light, dark }` dark-mode objects (see § Dark Mode). Inside x-wrap: renders inline. When used without an `href`, renders as a styled label (no link behavior).\n\n```html\n\u003c!-- Standard centered button — use global style for brand color --\u003e\n\u003cx-cta href=\"https://example.com\" :background=\"globalStyles.colors.primary_id\" color=\"#ffffff\"\n  padding=\"12px 32px\" border-radius=\"6px\" align=\"center\"\u003eGet Started\u003c/x-cta\u003e\n\n\u003c!-- Button with hover effects --\u003e\n\u003cx-cta href=\"{{action_url}}\" :background=\"globalStyles.colors.accent_id\" color=\"#ffffff\"\n  hover-background=\"#e05555\" :hover-opacity=\"0.9\"\u003eShop Now\u003c/x-cta\u003e\n```\n\n### x-image\nImage with responsive/dark mode variants. Key props: `src` (the one prop that takes responsive and dark mode together — compound keys are double-quoted, so the attribute itself is single-quoted: ``:src='{ \"small:light\": `...`, \"small:dark\": `...`, \"medium:light\": `...`, \"medium:dark\": `...` }'``), `srcset`, `sizes`. Also: action group (linking), media group (layout, opacity, borders, hover), hide. To fade an image, use `:opacity=\"0.5\"` — a number from 0 to 1. The string form (`opacity=\"0.5\"`) fails the whole render, so the image disappears from the preview. **Only supports PNG, JPG, and GIF** — other formats have limited email client support. Keep file sizes as small as possible. Image dimensions should not be smaller than the display size and no more than 3x the display size. **Note:** `x-image` uses `background-color` (not `background`) and does not support dark mode for its background.\n\n```html\n\u003c!-- Basic image with alt text --\u003e\n\u003cx-image src=\"https://placehold.co/600x300\" alt=\"Hero banner\" width=\"100%\" align=\"center\" /\u003e\n\n\u003c!-- Responsive image with dark mode variant --\u003e\n\u003cx-image :src=\"{ light: `logo-dark.png`, dark: `logo-light.png` }\" alt=\"Logo\" width=\"200px\" /\u003e\n\n\u003c!-- Faded image — opacity is numeric-only, 0–1 --\u003e\n\u003cx-image src=\"https://placehold.co/600x300\" alt=\"Background texture\" :opacity=\"0.4\" width=\"100%\" /\u003e\n```\n\n### x-spacer\nEmpty space. `:size=\"24\"` (bare number, default `8`, range 0–100). Also: hide. Prefer `margin` or `padding` on elements or containers instead of spacers — spacers should only be used when margin/padding cannot achieve the desired spacing.\n\n```html\n\u003cx-spacer :size=\"24\" /\u003e\n```\n\n### x-hr (Divider)\nHorizontal line. Key props: `background-color` (line color, default `#D9D9D9`, supports dark mode via `{ light, dark }` objects), `:height=\"2\"` (bare number, default `1`, min `1`), `border-style` (`solid`/`dashed`/`dotted`/`double`), `width` (default `auto`), `align`, `margin` (default `10px 0`). Also: border-radius, hide. **`height` takes the dynamic number form only** — `height=\"1px\"` and `height=\"1\"` both fail the render, and the default 1px hairline needs no `height` at all. **Note:** `x-hr` uses `background-color` (not `background`) — unlike most components, but its `background-color` does support dark mode.\n\n```html\n\u003cx-hr :height=\"2\" :background-color=\"globalStyles.colors.border_id\" margin=\"24px 0\" /\u003e\n```\n\n### x-countdown\nAnimated countdown GIF, drawn by a server, so its text and fill are baked into the image. Key props: `time` (ISO datetime string, e.g. `time=\"2026-12-31T12:00:00Z\"`), `resolution` (`D`/`H`/`M`/`S`, default `S`), `font-family` (`roboto`/`inter` only; anything else falls back to `roboto`), `font-weight` (string, `\"100\"`–`\"900\"`, default `\"400\"`), `text-align` (default `center`; not `align`). Also: width, padding, margin (default `16px 0`), opacity, borders, box-shadow, hide.\n\n- **Numbers:** `:font-size=\"48\"` (default `48`, range 10–100, **not** responsive) and `:frames=\"5\"` (default `5`, range 1–60). `font-size=\"48px\"` or `frames=\"5\"` fails the render.\n- **Colors are plain hex strings, no dark mode:** `color=\"#111111\"`, `background=\"#ffffff\"`. The GIF can't switch with the color scheme, so a `{ light, dark }` object on either fails the render. A non-hex `background` (a gradient, `rgba(...)`) makes the GIF transparent and paints the fill behind it instead.\n- **Advanced:** `:looping=\"true\"` (boolean, default `true`), `variant` (`stacked` with labels, default; or `compact`, `00d 00h 00m 00s`), `expired` (`zero`, default, shows all zeros; or `message`, shows \"Expired\").\n\n### x-empty-state\nEditor-only placeholder: shows a dashed \"add block\" drop target in the visual editor and renders **nothing** in the sent message. No props. Its main use is as default slot content in custom components — `\u003cslot\u003e\u003cx-empty-state/\u003e\u003c/slot\u003e` — so an unfilled slot looks intentional (see **custom_components.md § Slots**). Only for **component** slots (`any`/`children`); `text` and `html` slots take plain default content instead.\n\n## Rendering Notes\n\n- `x-columns` is not a component. Always write `x-row`: inside an `\u003cx-columns\u003e` the `x-column` children find no row and the render fails. `x-row` needs only `:layout`; `:break-point` is optional.\n- Empty `content` in a render response indicates a silent rendering failure.\n- `x-row` can be inside `x-section`, `x-box`, or `x-column`.\n\n## Complete Example: Properly Structured Email\n\nReplace `globalStyles.*` references below with the actual token paths from the workspace's global styles.\n\n```html\n\u003cx-base font-family=\"Arial, sans-serif\" :font-size=\"16\" :line-height=\"1.6\" :color=\"globalStyles.colors.bodyText_id\"\u003e\n  \u003c!-- Preheader text --\u003e\n  \u003cx-hidden-text\u003eYour weekly update is here!\u003c/x-hidden-text\u003e\n\n  \u003c!-- Use \u003ccompany-header /\u003e if available, otherwise build manually: --\u003e\n  \u003cx-section :background=\"globalStyles.colors.surfaceAlt_id\" padding=\"30px 20px\" data-layer-label=\"Header\"\u003e\n    \u003cx-image src=\"logo.png\" alt=\"Company Logo\" width=\"150px\" align=\"center\" /\u003e\n    \u003cx-heading-1 :font-size=\"28\" text-align=\"center\" :color=\"globalStyles.colors.heading_id\" margin=\"16px 0 0\"\u003eYour Weekly Update\u003c/x-heading-1\u003e\n  \u003c/x-section\u003e\n\n  \u003c!-- Main content section --\u003e\n  \u003cx-section :padding=\"globalStyles.spacing.large_id\" data-layer-label=\"Body\"\u003e\n    \u003cx-paragraph\u003eHi {{customer.first_name | default: \"there\"}}, here's what's new this week.\u003c/x-paragraph\u003e\n\n    \u003c!-- Feature cards in two columns --\u003e\n    \u003cx-row :layout=\"[50, 50]\" :gap=\"16\" :break-point=\"480\"\u003e\n      \u003cx-column padding=\"10px\"\u003e\n        \u003cx-box :background=\"globalStyles.colors.surface_id\" padding=\"20px\" :border-radius=\"globalStyles.radius.medium_id\"\u003e\n          \u003cx-heading-2 :font-size=\"18\" :color=\"globalStyles.colors.primary_id\"\u003eNew Feature\u003c/x-heading-2\u003e\n          \u003cx-paragraph :font-size=\"14\"\u003eCheck out our latest release with powerful new capabilities.\u003c/x-paragraph\u003e\n        \u003c/x-box\u003e\n      \u003c/x-column\u003e\n      \u003cx-column padding=\"10px\"\u003e\n        \u003cx-box :background=\"globalStyles.colors.surface_id\" padding=\"20px\" :border-radius=\"globalStyles.radius.medium_id\"\u003e\n          \u003cx-heading-2 :font-size=\"18\" :color=\"globalStyles.colors.secondary_id\"\u003eTip of the Week\u003c/x-heading-2\u003e\n          \u003cx-paragraph :font-size=\"14\"\u003eBoost your productivity with this simple workflow trick.\u003c/x-paragraph\u003e\n        \u003c/x-box\u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-cta href=\"{{dashboard_url}}\" :background=\"globalStyles.colors.primary_id\" color=\"#ffffff\"\n      padding=\"12px 32px\" :border-radius=\"globalStyles.radius.medium_id\" align=\"center\" margin=\"16px 0 0\"\u003eView Dashboard\u003c/x-cta\u003e\n  \u003c/x-section\u003e\n\n  \u003c!-- Use \u003cemail-footer /\u003e or \u003csocial-links /\u003e if available, otherwise build manually (social row: § Editor Presets): --\u003e\n  \u003cx-section :background=\"globalStyles.colors.surfaceAlt_id\" padding=\"20px\" data-layer-label=\"Footer\"\u003e\n    \u003cx-hr :background-color=\"globalStyles.colors.border_id\" margin=\"0 0 16px 0\" /\u003e\n    \u003cx-paragraph :font-size=\"12\" :color=\"globalStyles.colors.muted_id\" text-align=\"center\"\u003e\n      © 2025 Company Name. \u003ca href=\"{% unsubscribe_url %}\"\u003eUnsubscribe\u003c/a\u003e\n    \u003c/x-paragraph\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n","custom_components.md":"---\ndescription: \u003e\n  Create and edit custom (reusable) Design Studio components — the guided\n  build workflow, component syntax, property types and best-practice defaults,\n  slots, the spec/validation rules, and how to create the component via the API.\n---\n\n# Custom Components for Design Studio\n\nUse this file when **creating or editing a custom (reusable) component** for Design Studio — a header, footer, logo, social-links block, card, button, and so on. For using components that already exist in the workspace, and for the standard `\u003cx-*\u003e` component property reference, see `@skills/design-studio/components_reference.md` and `SKILL.md § Custom Components`.\n\n**You have the Design Studio API — use it; don't make the user do manual editor steps.** This guidance is adapted from a self-serve user walkthrough: wherever the original told the user to click around the editor and copy/paste, you instead do the work through `cio api` calls and report back. Discover what exists, build the component, confirm with the user before writing, then create it via `POST /design_studio/components` (**§ Creating it in the workspace**). Use `cio skills read design-studio/\u003cfile\u003e` for the endpoints referenced below (`nodes.md`, `nodes_advanced.md`, `global_styles.md`, `journeys.md`) and `cio schema \u003cresource.method\u003e` for exact request/response shapes. Always show the final markup in your reply for transparency, and share the editor link so the user can test it.\n\n**Never claim a component will definitely render.** Components only fully render in the context of an email — verify (see § Creating it in the workspace) and tell the user that if the editor shows an error, they should paste it back so you can fix it.\n\n**CRITICAL: a component's `content` is the complete source file — never raw `\u003cx-*\u003e` markup.** The `content` string in `POST /design_studio/components` and `PUT /design_studio/components/{id}` (**nodes.md § Component**) is the whole file described below (`\u003cscript\u003e` + `\u003ctemplate\u003e`), exactly as you'd write it in the code editor. The API doesn't validate structure, so raw markup (e.g. just an `\u003cx-section\u003e…\u003c/x-section\u003e` block) saves without error and even looks right in Code View — but the component is **blank and uneditable in the visual editor** (\"No base component found\") and never appears in the insert menu. If you have markup for a reusable block (footer, header, …), wrap it: put it inside `\u003ctemplate\u003e` with `#root` on the outermost element, and add a `\u003cscript\u003e` with `config` (label + at least one preset).\n\n---\n\n## Creating a custom component — the workflow\n\nAdaptive, not a rigid script: gather the essentials, use judgment, and don't interrogate the user one question at a time when you can infer the answer.\n\n**1. Reuse and learn the workspace first.** Fetch existing components with `cio api` `GET /v1/environments/$ENV_ID/ds/component_definitions` (parsed props, slots, presets). If one already matches what the user wants, use or edit it instead of creating a duplicate — custom-component **tags are unique per workspace**. Otherwise use the results to match house style (naming, insert-menu sections) and read a similar component's source (`GET /design_studio/components/{id}` — **nodes.md § Inspecting Custom Component Source**) as a worked reference — your equivalent of the design-system example components. Pull global styles and reusable blocks too (**SKILL.md** step 1) so the component is on-brand.\n\n**2. Name it.** A custom-component name/tag (see § Naming rules): kebab-case, at least two words joined by `-`, starts with a letter, and must not equal an existing HTML element name (`header` ✗, `my-header` ✓). If the user gives a one-word or invalid name, suggest a compliant one.\n\n**3. Insert-menu placement (optional).** A `section` groups the component in the insert menu. There are **no built-in section names** — any string creates or joins a group. Map it to an existing section in the workspace (reuse one from the presets you saw in step 1), name a new one, or omit `section` entirely. Maps to `presets[].section`.\n\n**4. Get the starting markup — retrieve it yourself where you can.** Don't ask the user to copy/paste from the editor if you can pull the source via the API:\n- **It already lives in an email they point to** → `GET /v1/environments/$ENV_ID/design_studio/emails/{id}` and lift the relevant block out of `content.html` (find the email with `node_actions/search` or `linkable` — **nodes.md** / **nodes_advanced.md**).\n- **There's an existing component to adapt** → read its source with `GET /design_studio/components/{id}` (**nodes.md § Inspecting Custom Component Source**) and start from it.\n- **Nothing built yet** → draft the markup from standard `\u003cx-*\u003e` components (**components_reference.md**), reusing the workspace's global styles and custom components so it matches house style.\n- **Only unsaved in the visual editor** (the one place the API can't reach) → ask the user to open the `…` menu → **Switch to code** and paste the block (e.g. `\u003cx-cta …\u003eButton Text\u003c/x-cta\u003e`).\n\n**5. Turn the markup into a reusable component.** Put the markup inside `\u003ctemplate\u003e` with exactly one `#root` element, add a `\u003cscript\u003e` with `config`, and decide **what to expose as editable props** and **which property `type`** each should use (see § Choosing what to expose). Use judgment grounded in the examples:\n- **Button** → expose `href` (type `url`) and editable text (a text slot).\n- **Image** → expose `src` (type `media`) and `alt` (type `text`).\n- **Box / card / container** → add a component slot (`Component.slots.any()`) so users can drop content inside; default it to `\u003cslot\u003e\u003cx-empty-state/\u003e\u003c/slot\u003e` for a clean empty state.\n- **Text** → make it an editable slot, unless it's a fixed label in a header/footer.\n- **Only expose what the user actually wants to edit.** Don't add slots or props speculatively — if a block has no child content, it doesn't need a slot.\n\n**6. Validate against the best-practice rules** (§ Best-practice checklist) *before* you present anything.\n\n**7. Confirm, then create** via the API, verify the render, and share the editor link (§ Creating it in the workspace). The user may need to refresh Design Studio for the new component to appear in the insert menu.\n\n**8. Offer next steps:** add more slots/placeholder content, expose more styling props, and ask the follow-ups that matter for email — mobile responsiveness and dark mode (`component_syntax.md § Responsive \u0026 Dark Mode`), color, layout, and inbox/preview complexities (`@skills/design-studio/inbox.md`).\n\nAt every step, link the most relevant documentation:\n- Standard components — https://docs.customer.io/journeys/standard-components/\n- Creating components — https://docs.customer.io/journeys/create-custom-component/\n- Slots — https://docs.customer.io/journeys/component-slots/\n- Properties — https://docs.customer.io/journeys/component-properties/\n- Syntax — https://docs.customer.io/journeys/custom-comp-syntax/\n- Directives — https://docs.customer.io/journeys/directives/\n- Loops — https://docs.customer.io/journeys/component-loops/\n\nPrefer what the live API tells you (`component_definitions`, render results) over the docs when they disagree.\n\n---\n\n## Component structure\n\nA component file has up to **6 top-level blocks**: `\u003cscript\u003e`, `\u003cstyle\u003e`, `\u003ctemplate\u003e`, `\u003clink\u003e`, `\u003cmeta\u003e`, `\u003ctitle\u003e`. **`\u003cscript\u003e` and `\u003ctemplate\u003e` are required and appear once each**; the rest are optional and rarely needed (the base layout already supplies `\u003ctitle\u003e`/`\u003cmeta\u003e`/`\u003clink\u003e`). `\u003cstyle\u003e` goes **after `\u003cscript\u003e` and before `\u003ctemplate\u003e` — never inside `\u003ctemplate\u003e`.**\n\n```html\n\u003cscript\u003e\n  export const config = {\n    componentType: 'content', // see § config for the allowed values\n    label: 'My Button',\n    presets: [{\n      icon: 'button',\n      label: 'My Button',\n      content: '\u003cmy-button\u003e\u003c/my-button\u003e', // preset content must INCLUDE the component's tag\n      description: 'A reusable CTA button' // helps users — and the agent — know when to use it\n    }]\n  };\n\n  export const props = Component.defineProps({ /* ... */ });\n  export const slots = Component.defineSlots({ /* ... */ });\n\u003c/script\u003e\n\n\u003cstyle\u003e\n  /* optional, scoped to this component */\n\u003c/style\u003e\n\n\u003ctemplate\u003e\n  \u003cdiv #root\u003e\n    \u003c!-- Exactly ONE element must have #root --\u003e\n  \u003c/div\u003e\n\u003c/template\u003e\n```\n\nBoth blocks are load-bearing in the visual editor, beyond just rendering: the editor extracts the literal `\u003ctemplate\u003e` tag to open the component (no `\u003ctemplate\u003e` = blank canvas, even though send-time rendering tolerates its absence), and `#root` is how the editor maps the component's markup for selection and decides which slots it can be dropped into (no `#root` = can't be placed inside `\u003cx-base\u003e`).\n\n### Naming rules\n- **At least two words** joined by `-`, e.g. `primary-button`, `email-footer`.\n- **Starts with a letter**, never a number or symbol.\n- **kebab-case** (`my-button`, not `MyButton`).\n- **Cannot start with `x-`** (reserved for standard components).\n- **Cannot equal an existing HTML element name** (`button`, `header`, `div`) — but may *contain* one (`my-button`, `site-header`).\n- The **preset `content` must include the component's own tag.** It may contain more than that tag — e.g. a list seeds items with `content: '\u003cmy-list\u003e\u003cli\u003e\u003c/li\u003e\u003cli\u003e\u003c/li\u003e\u003c/my-list\u003e'`. If a dragged-in component doesn't appear, a missing tag in the preset `content` is the usual cause.\n\n---\n\n## config\n\n`export const config` is statically extracted — keep it a plain object literal.\n\n```javascript\nexport const config = {\n  componentType: 'content',\n  label: 'My Button',\n  presets: [{\n    icon: 'button',                       // a Material Symbols icon name\n    label: 'My Button',\n    content: '\u003cmy-button\u003e\u003c/my-button\u003e',\n    section: 'Basics',                    // optional insert-menu group\n    description: 'A reusable CTA button', // optional hover help\n  }],\n};\n```\n\n- **`componentType`** — one of `content` (text, images, leaf content), `structure` (box/row/column shaping), or `layout` (section-level). Most custom components are `content` or `structure`. (`root` and `logic` exist but are reserved for built-in components like `x-base` — don't use them.) For a component that accepts styled rich-text, set `prose: true` alongside `componentType: 'content'` — `'prose'` is **not** a valid `componentType`.\n- **`label`** — required; the name shown in the layers menu.\n- **`presets`** — what shows in the insert menu. Each needs both `label` + `content` **or it is silently dropped** — a component with no valid preset can't be inserted from the menu; `icon`, `section`, `description` are optional. Set `description` anyway: it tells users (and future agent conversations reading `component_definitions`) how and when to use the component. For a **list** component, put the repeating `\u003cli\u003e` in the preset `content` (not the `\u003ctemplate\u003e`) so pressing Enter adds items: `content: '\u003ccustom-list\u003e\u003cli\u003eList item\u003c/li\u003e\u003c/custom-list\u003e'`.\n- **`allowedParents`** — optional array restricting where it can be dropped (`['x-column']`). An empty array `[]` means it can't be nested in anything; omitting it (the default — prefer this) allows it anywhere.\n\n---\n\n## Choosing what to expose \u0026 which property type\n\nPick the property `type` from the use case. **Never invent a type** — confirm it against https://docs.customer.io/journeys/component-properties/#types-of-properties. The `type` must agree with the `schema`.\n\n| You want to expose… | `type` | `schema` | Notes |\n|---|---|---|---|\n| Image / file `src` | `media` | `string()` | Links to the asset library. `accept: 'image/png, image/jpeg'` to restrict. |\n| A single color | `color` | `string()` | Color picker. |\n| A full background (image/gradient/position) | `background` | `string()` | Returns a CSS shorthand string. Use `color` for a plain background color. |\n| `padding` / `margin` | `box` | `string()` | Per-side control; value is a CSS shorthand string like `'16px 24px'`. |\n| `text-align` | `toggle` | `enum(['left','center','right'])` | Toggle buttons with icons (see below). |\n| A short string | `text` | `string()` | `placeholder` optional. |\n| Longer text | `textarea` | `string()` | `rows`, `multiLine`, `resize: 'vertical'`. |\n| A URL | `url` | `string()` | |\n| A number | `number` | `number()` | `min`, `max`, `step`. |\n| A number on a range | `slider` | `number()` | `min`, `max`, `step`. |\n| A size with units | `size` | `string()` | `units: ['auto','px','percent']`. |\n| Border radius | `radius` | `string()` | |\n| Box shadow | `shadow` | `string()` | |\n| Font | `font-family` | `string()` | Standard DS font selector. |\n| A choice (\u003e3 options) | `select` | `enum([...])` | `options: [{label, value}]` matching the enum. |\n| A choice (≤3 options) | `toggle` | `enum([...])` | `options` may carry `icon`. |\n| On/off | `switch` | `boolean()` | |\n| Code-only / not editable in UI | `hidden` | any | Functional but set only in code. |\n\n**`text-align` as an icon toggle:**\n```javascript\n'text-align': {\n  label: 'Text Align',\n  section: 'Text Styles',\n  schema: Component.props.enum(['left', 'center', 'right']).optional(),\n  type: 'toggle',\n  options: [\n    { label: 'Left',   value: 'left',   icon: 'format_align_left' },\n    { label: 'Center', value: 'center', icon: 'format_align_center' },\n    { label: 'Right',  value: 'right',  icon: 'format_align_right' },\n  ],\n},\n```\n\n**`border`:** split into `border-width`, `border-style`, `border-color` and regroup them in one popup with `set: 'Border'` on each. Always set `border-style` when you render a border — give it `onSetValue: 'solid'` so opening the Border set applies a style automatically.\n\n**Restricting options.** It's fine to depart from these defaults to constrain choice — e.g. a `select`/`toggle` of three approved fonts instead of the full `font-family` picker. If that value is then passed to a **standard component's** prop, make sure every option is valid for that prop and matches its schema.\n\n---\n\n## Defining properties\n\n```javascript\nexport const props = Component.defineProps({\n  href: {\n    label: 'Link URL', section: 'Action',\n    schema: Component.props.string().optional(),\n    type: 'url',\n  },\n  background: {\n    label: 'Background', section: 'Styles',\n    schema: Component.props.string().optional().default('#0066cc'),\n    type: 'background',\n  },\n  padding: {\n    label: 'Padding', section: 'Styles',\n    schema: Component.props.string().optional().default('12px 24px'),\n    type: 'box',\n  },\n  variant: {\n    label: 'Style', section: 'Styles',\n    options: [\n      { label: 'Primary', value: 'primary' },\n      { label: 'Secondary', value: 'secondary' },\n    ],\n    schema: Component.props.enum(['primary', 'secondary']).optional().default('primary'),\n    type: 'select',\n  },\n});\n```\n\n- **`schema` is required.** It builds on Zod (https://zod.dev/api). Common: `string()`, `number()`, `boolean()`, `enum([...])`, chained with `.optional()` and `.default(value)`. A missing or malformed schema causes the runtime error **`keyValidator._parse is not a function`**.\n- Schema → input mapping: `string()` → text/color/url/media/box/select/etc.; `number()` → number/slider; `boolean()` → switch; `enum([...])` → select/toggle.\n- **Configuration keys:** `label` (always set it), `section` (optional — group props once there are more than a few), `helpText` (hover), `type`, `defaultValue` (initial value shown in the visual editor — distinct from the schema's `.default()`), `placeholder` (text/textarea), `min`/`max`/`step` (number/slider), `options` (select/toggle), `accept` (media), `hidden: (props) =\u003e boolean` (conditional visibility), `advanced: true` (tuck into the advanced section), `set` (group a few related props into one popup, e.g. border), `onSetValue` (default applied once the `set` popup is opened).\n\n---\n\n## Slots\n\nSlots are where users drop content. Define them with `Component.defineSlots`.\n\n- **One slot → name it `default`.** Multiple slots → unique names, referenced in the template as `\u003cslot name=\"...\"\u003e`.\n- A slot holds **either text or components, never both.**\n- `Component.slots.any()` — accepts any component.\n- `Component.slots.children(['x-column'])` — accepts only the listed components.\n- `Component.slots.text()` — typed text; add `marks` to limit formatting.\n- `Component.slots.html()` — raw HTML (edge case, e.g. an embed block).\n\n```javascript\nexport const slots = Component.defineSlots({\n  default: {\n    schema: Component.slots.text(),\n    // omit marks to allow all; [] for none; or list a subset:\n    marks: ['bold', 'italic', 'underline', 'strikethrough', 'link'],\n  },\n});\n```\n\nMark options: `bold`, `italic`, `underline`, `strikethrough`, `link`, `code`, `color`, `linebreak`, `superscript`, `subscript`.\n\nA slot definition takes two equivalent forms: the bare slot type (`default: Component.slots.text()`) or an object with a `schema` key plus metadata (`default: { schema: Component.slots.text(), marks: [...] }`). Both are valid — use the object form whenever you need metadata like `marks` or `placeholder`.\n\n```html\n\u003ctemplate\u003e\n  \u003cdiv #root\u003e\n    \u003ch2\u003e\u003cslot name=\"header\"\u003eDefault Header\u003c/slot\u003e\u003c/h2\u003e\n    \u003cdiv\u003e\u003cslot\u003eDefault body content\u003c/slot\u003e\u003c/div\u003e\n  \u003c/div\u003e\n\u003c/template\u003e\n```\n\nDefault content inside a `\u003cslot\u003e` is replaced when the user adds their own; default content set via the preset `content` is *editable* rather than replaced. For component slots (`any`/`children`) with no natural default, use `\u003cslot\u003e\u003cx-empty-state/\u003e\u003c/slot\u003e` so an unfilled slot still looks intentional in the editor — `x-empty-state` shows a dashed drop target in the visual editor and renders nothing in the sent message (**components_reference.md § x-empty-state**).\n\n---\n\n## Template syntax\n\nDefines the rendered HTML; can also use other components.\n\n### Dynamic values\n```html\n\u003c!-- Text content uses ${} --\u003e\n\u003cp\u003e${props.message}\u003c/p\u003e\n\u003ch1\u003e${props.greeting || 'Hello'}\u003c/h1\u003e\n\n\u003c!-- Attributes use :attribute (never ${} inside an attribute) --\u003e\n\u003ca :href=\"props.url\" :style=\"styleObject\"\u003e\n\u003cimg :src=\"props.image\" :alt=\"props.altText\"\u003e\n```\n\n### Directives\n`#if` / `#else-if` / `#else` (conditionals), `#each` (loops), `#set` (set an attribute), `#set:html` (raw HTML), `#class:*` / `#style:*` (toggle a class/style), `#is` (dynamic component), `#is:raw` (skip compilation), `#slot` (assign to a named slot).\n\n```html\n\u003cdiv #if=\"props.showHeader\"\u003eHeader\u003c/div\u003e\n\u003cdiv #else-if=\"props.showAlt\"\u003eAlt\u003c/div\u003e\n\u003cdiv #else\u003eFallback\u003c/div\u003e\n\n\u003c!-- Loops --\u003e\n\u003cli #each=\"item in props.items\"\u003e${item}\u003c/li\u003e\n\u003cli #each=\"(item, index) in props.items\"\u003e${index + 1}. ${item}\u003c/li\u003e\n\u003cdiv #each=\"n in 10\"\u003eItem ${n}\u003c/div\u003e\n\n\u003c!-- Dynamic element type --\u003e\n\u003cfragment #set:is=\"props.href ? 'a' : 'span'\" :href=\"props.href\" #root\u003e\n  \u003cslot\u003eClick here\u003c/slot\u003e\n\u003c/fragment\u003e\n```\n\n### Native template elements\n- `\u003cfragment\u003e` — group elements / apply logic without adding a wrapper tag.\n- `\u003cslot\u003e` — placeholder + fallback for child content.\n\n---\n\n## Styling\n\n**Prefer standard components, then inline styles, then a scoped `\u003cstyle\u003e` block.**\n\n### Prefer standard components\nReach for standard `\u003cx-*\u003e` components first — they carry the email-client fallbacks and dark-mode handling that raw HTML doesn't. Bind your props straight to their attributes:\n```html\n\u003cx-box :background=\"props.background\" :text-align=\"props['text-align']\" #root\u003e\n  \u003cslot\u003e\u003cx-empty-state /\u003e\u003c/slot\u003e\n\u003c/x-box\u003e\n```\nDrop to plain HTML elements with inline styles only when no standard component fits.\n\n### Inline via a style object\nBuild the object in `\u003cscript\u003e`, then bind it:\n```javascript\nconst styleObject = {\n  color: props.color,\n  'font-size': props['font-size'] + 'px',\n  'line-height': props['line-height'],\n  'text-align': props['text-align'],\n  margin: props.margin,\n  background: props.background,\n};\n```\n```html\n\u003cdiv :style=\"styleObject\" #root\u003e\u003c/div\u003e\n```\n(Static inline styles are also fine: `\u003cdiv style=\"padding: 20px;\" #root\u003e`.)\n\n### Scoped `\u003cstyle\u003e` block\nPlaced after `\u003cscript\u003e`, before `\u003ctemplate\u003e`. Scoped to the component; supports `#if`/`#else` for conditional rules, and `set()` to inject JS values:\n```html\n\u003cstyle\u003e\n  .container { background: set('props.background'); padding: 20px; }\n  @media (max-width: 600px) {\n    .container { padding: 12px; }\n  }\n\u003c/style\u003e\n```\n\n**Isolated styles.** Component styles are de-duped and merged into one shared `\u003cstyle\u003e` block. To keep a rule in its own block use `\u003cstyle #isolated\u003e`, or add it to a *named* shared isolated block with `\u003cstyle #isolated=\"interactive\"\u003e`. Use this for advanced styles that strict client sanitisers (e.g. Gmail) might mangle when combined.\n\n---\n\n## Two-phase rendering \u0026 Liquid\n\n1. **Build time** — component syntax runs: `${}`, `:attribute`, `#if`, `#each`.\n2. **Send time** — Liquid runs with recipient data: `{{ }}`, `{% %}`.\n\nUse component syntax for editor-controlled design/layout; use Liquid for per-recipient data. Don't use `${}` for customer data (it doesn't exist at build time).\n\n```html\n\u003ctemplate\u003e\n  \u003cdiv #root\u003e\n    \u003cdiv #if=\"props.showGreeting\"\u003e\n      \u003ch1\u003eHello {{ customer.first_name | default: \"there\" }}!\u003c/h1\u003e\n    \u003c/div\u003e\n    \u003ca href=\"{{ product.url }}\" :style=\"buttonStyle\"\u003eView {{ product.name }}\u003c/a\u003e\n    \u003cul\u003e\n      {% for item in cart.items %}\n        \u003cli\u003e{{ item.name }} - {{ item.price | money }}\u003c/li\u003e\n      {% endfor %}\n    \u003c/ul\u003e\n  \u003c/div\u003e\n\u003c/template\u003e\n```\n\n## Context (passing data down the tree)\n\nSet on a parent, read from any descendant — no prop drilling.\n```javascript\nComponent.context.set('theme', { primary: 'blue' });   // in an ancestor\nconst theme = Component.context.get('theme');          // in a descendant → theme.primary === 'blue'\n```\n\n---\n\n## Complete examples\n\n### Button — props, text slot, dynamic tag\n\n```html\n\u003cscript\u003e\n  export const config = {\n    componentType: 'content',\n    label: 'Call to Action',\n    presets: [{ icon: 'button', label: 'CTA Button', content: '\u003ccta-button\u003e\u003c/cta-button\u003e' }],\n  };\n\n  export const props = Component.defineProps({\n    href:       { label: 'Link URL',  section: 'Action', schema: Component.props.string().optional(), type: 'url' },\n    background: { label: 'Background color', section: 'Styles', schema: Component.props.string().optional().default('#0066cc'), type: 'color' },\n    color:      { label: 'Text Color', section: 'Styles', schema: Component.props.string().optional().default('#ffffff'), type: 'color' },\n    padding:    { label: 'Padding',   section: 'Styles', schema: Component.props.string().optional().default('12px 24px'), type: 'box' },\n    radius:     { label: 'Radius',    section: 'Styles', schema: Component.props.string().optional().default('4px'), type: 'radius' },\n  });\n\n  export const slots = Component.defineSlots({\n    default: { schema: Component.slots.text(), marks: ['bold', 'italic', 'underline'] },\n  });\n\n  const style = {\n    display: 'inline-block',\n    padding: props.padding,\n    background: props.background,\n    color: props.color,\n    'text-decoration': 'none',\n    'border-radius': props.radius,\n    'font-weight': '600',\n  };\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003cfragment #set:is=\"props.href ? 'a' : 'span'\" :href=\"props.href\" :style=\"style\" #root\u003e\n    \u003cslot\u003eClick Here\u003c/slot\u003e\n  \u003c/fragment\u003e\n\u003c/template\u003e\n```\n\n### Card — a slot that accepts child components\n\nA container users can drop other components into. Prefer a standard `\u003cx-box\u003e` as the shell — it brings email-safe fallbacks — and default the slot to `\u003cx-empty-state/\u003e` so an empty card looks intentional.\n\n```html\n\u003cscript\u003e\n  export const config = {\n    componentType: 'structure',\n    label: 'Card',\n    presets: [{ icon: 'crop_square', label: 'Card', section: 'Layout', content: '\u003cinfo-card\u003e\u003c/info-card\u003e' }],\n  };\n\n  export const props = Component.defineProps({\n    background: { label: 'Background',    section: 'Styles', schema: Component.props.string().optional().default('#ffffff'), type: 'background' },\n    padding:    { label: 'Padding',       section: 'Styles', schema: Component.props.string().optional().default('24px'), type: 'box' },\n    radius:     { label: 'Corner radius', section: 'Styles', schema: Component.props.string().optional().default('8px'), type: 'radius' },\n  });\n\n  export const slots = Component.defineSlots({\n    default: { schema: Component.slots.any() }, // drop any components inside\n  });\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003cx-box :background=\"props.background\" :padding=\"props.padding\" :border-radius=\"props.radius\" #root\u003e\n    \u003cslot\u003e\u003cx-empty-state /\u003e\u003c/slot\u003e\n  \u003c/x-box\u003e\n\u003c/template\u003e\n```\n\n### List — repeatable items (`\u003cli\u003e` in the preset)\n\nThe `\u003cli\u003e` goes in the **preset `content`**, never the `\u003ctemplate\u003e`, so pressing Enter in the editor adds another item. The items land in a **text** slot. (Modeled on the standard `x-list`.)\n\n```html\n\u003cscript\u003e\n  export const config = {\n    componentType: 'content',\n    label: 'Checklist',\n    presets: [{\n      icon: 'checklist',\n      label: 'Checklist',\n      content: '\u003cfeature-list\u003e\u003cli\u003eList item\u003c/li\u003e\u003c/feature-list\u003e', // \u003cli\u003e lives here\n    }],\n  };\n\n  export const slots = Component.defineSlots({\n    default: Component.slots.text(), // the \u003cli\u003e items — bare shorthand for { schema: Component.slots.text() }\n  });\n\n  export const props = Component.defineProps({\n    'list-style': {\n      label: 'List style', section: 'Styles',\n      schema: Component.props.enum(['disc', 'circle', 'square', 'decimal']).optional().default('disc'),\n      type: 'select',\n      options: [\n        { label: 'Disc (•)',     value: 'disc' },\n        { label: 'Circle (◦)',   value: 'circle' },\n        { label: 'Square (▪)',   value: 'square' },\n        { label: 'Numbered (1.)', value: 'decimal' },\n      ],\n    },\n  });\n\n  const style = { 'list-style': props['list-style'], margin: '0', 'padding-left': '20px' };\n\u003c/script\u003e\n\n\u003ctemplate\u003e\n  \u003cul :style=\"style\" #root\u003e\n    \u003cslot /\u003e\n  \u003c/ul\u003e\n\u003c/template\u003e\n```\n\n---\n\n## Creating it in the workspace (API)\n\nOnce the user confirms, create the component node — **confirm before writing**, since this adds a resource to their workspace.\n\n1. **Check the tag is free** — it must be unique per workspace. You already fetched `component_definitions` in step 1; for an existing node you can also validate via `/ds/nodes/{node_id}/validate_component_name` (see **nodes_advanced.md § Validation**).\n2. **Create** — `cio api` `POST /design_studio/components` with `{ \"name\", \"tag\", \"parent_folder_id\": \u003cuuid|null\u003e, \"content\": \"\u003cmarkup\u003e\" }` (run `cio schema` for the exact shape). `tag` is the custom-element name (e.g. `cta-button`) and **must appear in the preset `content`**; `content` is the **entire component source file** as a string — never raw markup (see the CRITICAL note at the top). Write the file to disk and bind it with `--arg src=@file` — `cio`'s `--json` builds the body with its bundled gojq (see **nodes.md § Shell Safety**; no external `jq`, no shell escaping):\n\n   ```bash\n   COMPONENT_ID=$(cio api /v1/environments/$ENV_ID/design_studio/components -X POST \\\n     --arg src=@/tmp/cta-button.html --json '{name:\"CTA Button\",tag:\"cta-button\",content:$src}' \\\n     --jq '.component.id' -r)\n   ```\n\n   A 2xx means it exists — never re-POST on a parse failure (that duplicates); list components to recover the id.\n3. **Confirm the definition parsed** — fetch `GET /ds/component_definitions` and check the tag is listed with the expected label, props, and presets, **and that the entry's `error` and `warnings` fields are empty** (a broken component can still appear in the list with `error` set). If the entry is missing, incomplete, or carries an error/warnings, the source file is malformed — fix it and `PUT` the corrected file before using the tag in an email.\n4. **Verify it renders** — a component only renders in context. Drop it into a draft email (or an existing one) and render via `GET /design_studio/emails/{id}/render`; empty/`null` content is a silent failure — work through **SKILL.md § Diagnosing Empty Renders** (unknown custom tag, missing `#root`, malformed `\u003cscript\u003e`, broken `globalStyles.*`). Don't promise it renders without this check.\n5. **Share the editor link** — `https://fly.customer.io/workspaces/$ENV_ID/design-studio/editor/$COMPONENT_UUID`, using the component's UUID `id` from the create response. Editor links ALWAYS use the UUID and NEVER the 12-character `short_id` (**SKILL.md § Linking to the UI**) — short-id links don't open the file. Tell the user to refresh Design Studio if the component isn't yet in the insert menu, and to paste any editor error back for a fix.\n\nTo edit an existing component, use `PUT /design_studio/components/{id}` (**nodes.md § Update**) — editing does **not** publish; re-publish any linked email to push changes. Before editing or deleting one the workspace already uses, check its impact with `POST /ds/components/references` (**nodes.md § Components \u0026 Discovery**) so you know which emails are affected — and fix the component rather than removing it (**SKILL.md § Custom Components**).\n\n---\n\n## Best-practice checklist (validate before presenting)\n\n- [ ] Name: ≥2 words, kebab-case, starts with a letter, not an HTML element name; tag appears in the preset `content`.\n- [ ] `\u003cscript\u003e` and `\u003ctemplate\u003e` present; `\u003cstyle\u003e` (if any) sits between them; exactly one `#root`.\n- [ ] Every prop has a `schema`; `type` matches the schema and is a real type from the docs.\n- [ ] `label` set on every prop; group with `section` when there are more than a few (sections are optional).\n- [ ] `padding`/`margin` use `type: 'box'` (string shorthand); `background` uses `type: 'background'` (or `color` for a plain color); `text-align` uses an icon `toggle`; `border` is split and grouped with `set: 'Border'`.\n- [ ] Slots are text **or** components, not both; single slot named `default`.\n- [ ] Liquid for per-recipient data, `${}`/`:attr` for build-time design.\n- [ ] Inline styles via a style object for plain HTML elements.\n\n## Troubleshooting\n\n- **Blank in the visual editor / \"No base component found\"?** The saved `content` is raw markup without the `\u003cscript\u003e`/`\u003ctemplate\u003e` wrapper (see the CRITICAL note at the top). Fetch the source (`GET /design_studio/components/{id}`), wrap it in the required structure, and `PUT` the full file back.\n- **Doesn't appear when dragged in?** The component's tag must appear in the preset `content` (and match the file's tag).\n- **`keyValidator._parse is not a function`?** A prop is missing or has a malformed `schema`.\n- **Not rendering?** Confirm one `#root`, both `\u003cscript\u003e` and `\u003ctemplate\u003e` exist, no unknown custom tags. Verify via a render (above).\n- **Attributes not applying?** Use `:attribute=\"expr\"` for dynamic values; never `${}` inside an attribute.\n- **Props undefined?** Use `props.name` (not `properties.name`); include `.optional().default()`.\n- **Slot not editable?** Define it with `Component.slots.text()` or `.any()` and give it default content.\n- **`\"Did you forget #set:?\"`** on a boolean prop? Pass it dynamically: `:my-flag=\"true\"`, not `my-flag=\"true\"`.\n","design_system_audit.md":"# Design System Audit (Coordinator)\n\nFull design-system health check. Runs both the raw-values audit (**promote_design_tokens.md** — hardcoded literals → token candidates) and the component-variants audit (**promote_component_variants.md** — repeated prop combos → variant candidates) in a single pass over the workspace's Design Studio nodes, sharing the node-fetching work so markup is only retrieved once. **Read-only — never writes anything.**\n\n## When to use\n\n- \"Audit my design system\"\n- \"Run a full Design Studio audit\"\n- \"What should I clean up / standardise in my emails?\"\n- \"Check my workspace for missing tokens and variants\"\n- Any request that implies both token hygiene and component pattern analysis in one go.\n\nFor narrower requests — \"just find repeated colors\" or \"just find button variants\" — run the individual audit directly (**promote_design_tokens.md** or **promote_component_variants.md**) instead of this coordinator.\n\n## What this does\n\nIt runs two audits back-to-back over the same set of nodes, sharing the node-fetching work so markup is only retrieved once. It then presents both reports together with a combined summary.\n\n- **Audit 1:** **promote_design_tokens.md** — finds hardcoded literal values (colors, spacing, radii, font families) that appear across multiple nodes and aren't already global-styles variables.\n- **Audit 2:** **promote_component_variants.md** — finds repeated explicit prop combinations on variant-keyed components (`x-cta`, `x-hr`, `x-image`, `x-section`, `x-inbox`, `\u003ca data-variant\u003e`) that aren't already named variants.\n\n## Inputs (resolve before starting)\n\n| Input                  | Default                               |\n| ---------------------- | ------------------------------------- |\n| Scope                  | Whole workspace (emails + components) |\n| Folder ID              | None                                  |\n| Node type filter       | `emails` and `components`             |\n| Min reach — tokens     | ≥ 2 distinct nodes                    |\n| Min cluster — variants | ≥ 3 instances across ≥ 2 nodes        |\n\nAccept overrides for any of the above from the user before starting.\n\n## Procedure\n\n### Phase 0 — Shared setup (run once, feed both audits)\n\n1. **Global styles inventory.** `cio api /v1/environments/$ENV_ID/ds/global_styles`. Extract:\n   - All existing variable `$value`s by group (color, font, radius, spacing) — for the token audit's exclusion list.\n   - All existing variant prop bags per group (button, divider, image, section, inbox, link) including which is the default — for the variant audit's baseline and exclusion list.\n\n2. **Component definitions.** `cio api /v1/environments/$ENV_ID/ds/component_definitions`. Record prop schemas for the six variant-keyed tags — for the variant audit's Step 2.\n\n3. **Node enumeration.** Page through `cio api /v1/environments/$ENV_ID/design_studio/emails` and `.../components` (see **nodes.md § List**). **Honour the scope inputs:** pass `parent_folder_id=\u003cid\u003e` to the list calls when a Folder ID is set, and enumerate only the selected type when the Node type filter names one. For each node in scope, fetch its source markup once:\n   - Email: `.../emails/{id} --jq '.email.content.html' -r`\n   - Component: `.../components/{id} --jq '.component.content' -r`\n\n   Store all markup in memory. Both audits consume from this shared set — do **not** fetch nodes twice.\n\n### Phase 1 — Token audit\n\nFollow the full procedure in **promote_design_tokens.md**:\n\n- Steps 3–7: extract literals, normalise, drop already-tokenised values, cluster by value+role, rank, propose names.\n- Use the global styles data already fetched in Phase 0.\n\n### Phase 2 — Variant audit\n\nFollow the full procedure in **promote_component_variants.md**:\n\n- Steps 3–9: collect instances of variant-keyed tags, extract explicit props, compute delta vs default, cluster, drop already-covered patterns, rank, propose variant names.\n- Use the global styles data and component definitions already fetched in Phase 0.\n\n### Phase 3 — Combined output\n\nPresent both reports in sequence, clearly separated. Then append a **combined action plan** at the end (see Output format below).\n\n## Output format\n\n```\n## Part 1 — Token Candidates\n[full ranked table from the token audit]\n\n## Part 2 — Variant Candidates\n[group-by-group ranked tables from the variant audit]\n\n## Combined summary\n[see below]\n```\n\n### Combined summary paragraph\n\nInclude:\n\n- Total nodes scanned\n- Total existing tokens found (excluded from Part 1)\n- Total existing variants found (excluded from Part 2)\n- Count of token candidates surfaced (Part 1)\n- Count of variant candidates surfaced (Part 2)\n\n### Relationship callout (when applicable)\n\nIf any token candidate value (e.g. `#0b353b`) also appears as a prop value inside a variant candidate's delta, call it out explicitly:\n\n\u003e \"`#0b353b` appears in both the token candidates (color) and the `teal-outline-pill` button variant candidate. Promoting it as a token first would let the variant reference it as `globalStyles.colors.color0b353b_\u003cid\u003e` rather than a hardcoded hex.\"\n\nThis is the highest-value insight the combined audit can surface — dependencies between the two layers.\n\n### Closing offer\n\nEnd with:\n\n\u003e \"Would you like me to:\n\u003e\n\u003e - Hand token candidates to **mass_replace_value.md** to create variables and rewrite references?\n\u003e - Create variant candidates in global styles (read-modify-write) and roll them out with **bulk_apply_variant.md**?\n\u003e - Or both, in order (tokens first, then variants using the new token references)?\n\u003e\n\u003e Just tell me which rows or groups to act on — I'll dry-run any writes first.\"\n\n## Guardrails\n\n- **Strictly read-only.** Never call `PUT /ds/global_styles` or modify any node.\n- Fetch each node's markup exactly once — do not re-fetch between phases.\n- If individual node fetches fail, skip them, count the errors, and note the count in the summary. Do not abort.\n- If the workspace has no Design Studio nodes with markup, say so and stop.\n- Apply all normalisation rules from both audits consistently — use the same normalisation in both phases so that a value like `#fff` (token candidate) and `#ffffff` (in a variant delta) are recognised as the same value in the relationship callout.\n- State both thresholds used at the top of the report.\n\n## Sub-procedure references\n\nRead these before executing — they contain the full field-level procedures this coordinator delegates to:\n\n- **promote_design_tokens.md**\n- **promote_component_variants.md**\n\n## API reference\n\nSee **global_styles.md** for the full global styles API reference — endpoint paths, token and variant structure, `$extensions` format, validation rules, and the read-modify-write pattern. Node enumeration and markup fetching (**nodes.md**):\n\n- `GET /v1/environments/$ENV_ID/design_studio/emails[?parent_folder_id=X]` — paginated; `.emails[]`\n- `GET /v1/environments/$ENV_ID/design_studio/components[?parent_folder_id=X]` — paginated; `.components[]`\n- `GET /v1/environments/$ENV_ID/design_studio/emails/{id}` → `.email.content.html`\n- `GET /v1/environments/$ENV_ID/design_studio/components/{id}` → `.component.content`\n- `GET /v1/environments/$ENV_ID/ds/component_definitions` → prop schemas for variant-keyed tags\n\nAll calls go through `cio api`, which handles authentication.\n","drafts.md":"---\nfeature_flag: campaign-draft-mode\n---\n\n# Design Studio messages in an Automation draft\n\nA Design Studio message that an Automation draft changes has its own **draft node**: a hidden copy of the live node that the draft edits. Its content reaches customers only when the user publishes the Automation's draft. Until then, the live node and the live message stay as they are.\n\n## Tell a draft node from a live one\n\nRead the node with `GET /ds/nodes/{node_id}` before you edit it. Three fields tell you where it is edited:\n\n- `effective_draft_id`: the Automation draft the node is edited in. When it is set, the node is in a draft: edit and save it as § Edit and save says. When it is unset, the node is live, and nothing in this file applies.\n- `draft_id`: set on a draft node, the draft's own copy of a message or a message the draft added. Only a draft node is hidden from lists and refused the live actions in § What a draft node refuses.\n- `source_node_id`: the live node a draft node was copied from. It is unset on a message the draft added.\n\nA node with `effective_draft_id` but no `draft_id` is a library email linked into the draft. Its edits also reach the draft's message only, but it is an ordinary node: it shows in lists, and nothing refuses a live action on it. Don't live-publish (`POST .../publish`), link, relink or delete it either.\n\nThe typed routes (`GET /design_studio/emails/{id}` and the other types) don't return these fields, so read the node by id once even when you know its type.\n\nWhen the `\u003ccontext\u003e` block's current URL is a Design Studio editor (`/design-studio/editor/\u003cuuid\u003e`, or `/editor/whatsapp/\u003cuuid\u003e`, `/editor/sms/\u003cuuid\u003e`, `/editor/in-app/\u003cuuid\u003e`), the uuid is the node the user has open. A message opened from an Automation draft opens its draft node. Edit that node, and never its `source_node_id`: an edit to the live node is a live edit, and the next publish of that node sends it to customers.\n\nTo find the draft node for a message in an Automation, get the draft's copy of the action's templates as `fly-api/automation_drafts.md` § Templates says, then turn a copied template's `id` into its node with `GET /ds/journeys/{template_id}`.\n\nDraft nodes are left out of every list and search: `GET /design_studio/emails`, `GET /ds/nodes`, `ds/node_actions/search`, `ds/node_actions/linkable` and the folder tree. Not finding one there is expected. Reach it by its id. A live node that an open draft edits names its draft node in `open_draft.node_id` on its `GET /ds/nodes` row. When the user has such a live node open, ask which one they mean: the live email changes now, the draft's copy when the Automation's draft is published.\n\n## Edit and save\n\nEdit a draft node with the same routes as any node: `PUT /design_studio/emails/{id}` or `PATCH /ds/nodes/{node_id}`. A draft node's edits need no capability grant, and the \"Edit live data\" setting does not apply to them, because the draft is not live.\n\n**A save alone does not reach the draft's message.** Publishing the Automation makes the draft's template live, and a save does not write the node onto that template. So after your last edit, write it there:\n\n```\ncio api /v1/environments/$ENV_ID/ds/nodes/\u003cnode_id\u003e/save_and_publish -X PATCH --json '{}'\n```\n\nEach language of an email is its own node with its own draft template, so call it on every node you edited. Send `{}`: make the edit itself with the routes above first. Read `publish.status` in the response, and `publish.message` for any status but `published` and `unchanged`:\n\n| `publish.status` | Meaning | Do this |\n|---|---|---|\n| `published` | The draft's message now holds the node's content | Done |\n| `unchanged` | It already did | Done |\n| `invalid` | The node can't be published as it is; `publish.message` says why | Fix the content, then call it again. When the message says the node is no longer in an Automation draft, the draft was published or discarded: tell the user |\n| `error` | Publishing failed for a reason a retry can clear | Call it again once, then tell the user |\n| `skipped` | Nothing was published; `publish.message` says why | See below |\n\n`skipped` with `the node isn't in an Automation draft` means the node is live: stop, and follow `SKILL.md` § CRITICAL: Live Automation Safety for it. `skipped` with `publishing requires the \"Publish content\" permission` means the edit was saved but the user can't publish content: tell them. `the node was deleted` means someone deleted it: tell the user. `only emails and inbox messages publish on save` means this message type has no draft publish yet: tell the user its edits are saved on the node and don't reach the Automation.\n\nThen tell the user the change is in the Automation's draft and goes live when they publish the draft.\n\n**\"Publish this email\" on a draft node means `save_and_publish`, and nothing more.** Publishing the Automation's draft (`fly-api/automation_drafts.md` § The flow) makes every change in it live, not only this email. Do it only when the user asks to publish the Automation or its draft by name. Otherwise, offer it as their next step.\n\n## What a draft node refuses\n\nThese return 409 with `reason: draft_node` and the message `This message is a draft in an Automation, so it can't be ... here`:\n\n- Live publish: `POST /design_studio/{emails|inbox|sms|whatsapp|in_app}/{id}/publish` and `POST /ds/nodes/{node_id}/publish`.\n- Link and relink: `POST /design_studio/.../{id}/link` and `POST /ds/node_actions/relink`, with the draft node on either side.\n- Moving it to another folder, duplicating it, discarding its unpublished changes, and deleting it (except a language of a draft email, below).\n- The typed language routes, `POST /design_studio/emails/{id}/languages` and `DELETE /design_studio/emails/{id}/languages/{language}`.\n\n**Languages of a draft email** change in the draft through the generic routes: add one with `POST /ds/nodes/{node_id}/language_translations` and `{\"languages\": [\"es\"]}` on the default-language draft node (the response's `translations` list the new nodes), and remove one with `DELETE /ds/nodes/{node_id}` on that language's node. Both need edit access to the Automation. The default-language node can't be deleted: a 409 there, or a 403, goes to the user as the next paragraph says.\n\nDon't retry these, and don't do the same thing on the live node instead. Tell the user what the 403 or 409 says. A 409 `draft_node` also says the change goes live when they publish the Automation's draft.\n","email_accessibility.md":"# Accessibility Checker\n\nAnalyze the rendered email HTML for accessibility issues. Combines mechanical rule-based checks with judgment-based analysis to catch issues that rules alone miss.\n\n\u003e **The automated rules below also run server-side** as the review's `accessibility` check (**email_review.md**): one `POST .../ds/nodes/{id}/review` returns those findings plus the raw audit under `details.accessibility`. Prefer starting from that report (it's what the editor shows) and spend this file's effort on the judgment-based analysis the automated rules can't do.\n\n**Token efficiency:** Only report issues found. Do not list passing checks or clean elements. If the email is long, prioritize critical and serious issues first.\n\n**Severity reflects impact on assistive-tech users, not fix complexity.** If you describe a finding as \"invisible\", \"unreadable\", or \"critically low contrast\", label it critical or serious — keep the severity consistent with your own language.\n\n## Workflow\n\n### Step 1: Get the rendered HTML\n\nRender the email server-side **without publishing** via the email render endpoint (see **nodes_advanced.md § Render**) and read the `html` field:\n\n```bash\nHTML=$(cio api /v1/environments/{environment_id}/design_studio/emails/{id}/render \\\n  --params \"{\\\"environment_id\\\":\\\"$ENV_ID\\\",\\\"id\\\":\\\"$NODE_ID\\\"}\" \\\n  --jq '.html' -r)\n```\n\n`render` returns the compiled markup publishing would write, and works whether or not the email is linked/published. It's email-only and leaves Liquid intact — this is template markup, so checks on Liquid-driven text (alt, link text) see the raw tag; for a Liquid-rendered, sample-data version use `POST /v1/environments/{environment_id}/previews/email`. Empty `html` means the email didn't render — see **SKILL.md § Diagnosing Empty Renders**.\n\n**Before scanning:** Rendered HTML often splits element attributes across multiple lines. Before running any checks, flatten the HTML so each opening tag is a single logical line — attributes on continuation lines will be silently missed by line-by-line grep patterns. When using shell tools, collapse newlines within tags before grepping (e.g. `tr '\\n' ' '` then `sed 's/  */ /g'`). When analyzing directly, always read the complete opening tag and its full text content before evaluating.\n\n### Step 2: Rule-based checks\n\nScan the rendered HTML for these mechanical pass/fail issues. Severities: **critical** \u003e **serious** \u003e **moderate** \u003e **mild**.\n\n#### Document structure\n\n| Severity | Rule | Details |\n|----------|------|---------|\n| serious | `\u003chtml\u003e` must have a valid `lang` attribute | e.g. `lang=\"en\"`. Check the value is a valid BCP 47 language tag. `lang=\"und\"` (undetermined) is valid and not a failure — but recommend setting a specific language for better screen reader pronunciation. |\n| serious | Body content wrappers must have `lang` and `dir` | Email clients often strip `\u003chtml\u003e`, so direct children of `\u003cbody\u003e` (excluding empty elements, `\u003cstyle\u003e`, `\u003cscript\u003e`, and `\u003cimg alt=\"\"\u003e`) need their own `lang` and `dir` attributes. If `lang` and `dir` are present but set to generic values (`lang=\"und\"`, `dir=\"auto\"`), do not flag as a failure — instead note as a **recommendation** (mild) to set specific values for better assistive technology support. |\n| serious | `dir` must be `ltr`, `rtl`, or `auto` | Flag any other value. `dir=\"auto\"` is valid — recommend setting an explicit direction only as a mild improvement. |\n| serious | Document should have a `\u003ctitle\u003e` element if the email has a view in browser link | title is used for navigation on hosted version of the email |\n| critical | `\u003cmeta name=\"viewport\"\u003e` must not disable zoom | Flag `maximum-scale=1`, `user-scalable=no`, or `user-scalable=0`. |\n\n#### Headings\n\n| Severity | Rule |\n|----------|------|\n| mild | Email should contain at least one `\u003ch1\u003e` (if it has structured content) |\n| moderate | Email should have no more than one `\u003ch1\u003e` |\n| moderate | Heading levels must be sequential — no skipping (e.g. `\u003ch1\u003e` then `\u003ch3\u003e`) |\n| mild | Headings must have visible text content |\n\n#### Images\n\n| Severity | Rule |\n|----------|------|\n| critical | `\u003cimg\u003e` elements must have an `alt` attribute (can be empty for decorative images) |\n| mild | `alt` text should not repeat adjacent visible text |\n| serious | Elements with `role=\"img\"` and `\u003csvg\u003e` with img/graphics-document role must have accessible text |\n\n#### Links\n\n| Severity | Rule |\n|----------|------|\n| serious | Links must have discernible text (not empty or whitespace-only), this is a common issue for linked images without alt text |\n| moderate | Link text should be descriptive — flag \"here\", \"click here\" and similar |\n| critical | Links must not use `role=\"button\"` — breaks assistive technology link menus |\n\n#### Tables\n\n| Severity | Rule |\n|----------|------|\n| serious | Layout tables must have `role=\"none\"` or `role=\"presentation\"` — without it, screen readers announce every cell. Only data tables (with `\u003cth\u003e`, `\u003cthead\u003e`, etc.) should omit the role. |\n\n#### HTML5 elements and landmarks\n\nEmail clients already contain landmark elements, so using them in emails creates conflicts.\n\n| Severity | Rule |\n|----------|------|\n| mild | Use `\u003cdiv role=\"article\"\u003e` instead of `\u003carticle\u003e` |\n| mild | Use `\u003cdiv role=\"region\"\u003e` instead of `\u003csection\u003e` |\n| mild | Use `\u003cdiv role=\"navigation\"\u003e` instead of `\u003cnav\u003e` |\n| moderate | Avoid `\u003cheader\u003e` or `[role=banner]` — email client already has one |\n| moderate | Avoid `\u003cfooter\u003e` or `[role=contentinfo]` — email client already has one |\n| moderate | Avoid `\u003cmain\u003e` or `[role=main]` — email client already has one |\n| moderate | Avoid `\u003caside\u003e` or `[role=complementary]` — email client already has one |\n\n#### Lists and IDs\n\n| Severity | Rule |\n|----------|------|\n| serious | `\u003cli\u003e` must be inside `\u003cul\u003e` or `\u003col\u003e`; `\u003cdt\u003e`/`\u003cdd\u003e` inside `\u003cdl\u003e` |\n| mild | `id` attribute values must be unique across the document |\n\n#### VML\n\n| Severity | Rule |\n|----------|------|\n| critical | VML shapes (`v:rect`, `v:roundrect`, `v:oval`, `v:image`, `v:shape`, `v:group`, `v:line`, `v:polyline`, `v:curve`, `v:arc`) must have an `alt` attribute |\n| critical | VML must not contain links — they are not keyboard-accessible |\n| moderate | VML must not contain headings — they are not usable in some screen readers |\n\n#### Color contrast (inline styles only)\n\nWhen both text color and background color are explicitly set in inline styles on an element or its direct parent, check the WCAG AA contrast ratio: 4.5:1 for normal text, 3:1 for large text (18px+ bold or 24px+). Report the foreground color, background color, computed ratio, and required ratio.\n\n**Contrast failure severity** (based on how far the ratio falls below the required threshold):\n- **Critical** — ratio below 2:1 regardless of text size (effectively invisible to low-vision users)\n- **Serious** — ratio is 2:1 or above but more than 1.5 points below the required threshold (e.g. required 4.5:1, actual below 3:1)\n- **Moderate** — ratio fails but is within 1.5 points of the required threshold (e.g. required 4.5:1, actual 3:1–4.4:1; required 3:1, actual 2:1–2.9:1)\n\n**Limitation:** This only catches contrast issues where colors are explicitly set in inline styles. Inherited, cascading, or computed styles cannot be evaluated. Note this limitation in the report. The automated accessibility checker can run deeper tests\n\n### Step 3: Judgment-based analysis\n\nThese checks require understanding content and context — they can't be reduced to mechanical rules.\n\n**Use the DS source markup as a supplement.** The rendered HTML compiles components into verbose table-based markup that obscures intent. For judgment checks, also read the Design Studio source (`GET /v1/environments/{environment_id}/design_studio/emails/{id}` → `content.html`) — see 3b for details on cross-referencing source vs. rendered HTML.\n\n#### 3a. Image alt text quality\n\nYou cannot see images — perform text-based checks only. Do not guess what images depict based on URLs or filenames.\n\nFor each `\u003cimg\u003e` in the rendered email:\n\n1. **Empty alt (`alt=\"\"`) is correct for decorative images** — don't flag it. Note that the user should verify the image is truly decorative.\n2. **Read alt in context** — for each image, read the `alt` together with surrounding copy (headings, body text, captions, link labels). Does the alt make sense alongside that content? Flag when it contradicts nearby text, repeats what is already stated without describing what the image *shows*, or would sound confusing when a screen reader reads the image description and adjacent content in sequence.\n3. **Flag low-quality alt text patterns:**\n   - Filename-as-alt: `alt=\"IMG_2847.jpg\"`, `alt=\"hero-banner-final-v2.png\"`\n   - Generic: `alt=\"image\"`, `alt=\"logo\"`, `alt=\"icon\"`, `alt=\"banner\"`\n   - Redundant with surrounding text: if adjacent text says \"Nike Air Max 90\" and the alt is identical, the alt should describe what the image *shows*\n4. **Dynamic image URLs** — if `src` contains Liquid variables (e.g. `{{event.product_image}}`), note these can't be checked and advise ensuring alt text is also dynamic or provides a meaningful generic description.\n\n#### 3b. Semantic structure\n\nLook at the content and identify elements marked up incorrectly for their purpose.\n\n- **Text that should be a heading but isn't** — visually styled as a heading (large, bold, introducing a section) but is a `\u003cp\u003e` or `\u003cspan\u003e`. In the DS source, look for `\u003cx-paragraph\u003e` or `\u003cx-text\u003e` elements with heading-like styles (`:font-size` ≥ 20, `font-weight=\"700\"` or `font-weight=\"bold\"`, or both). In the rendered HTML, check computed `font-size` and `font-weight` in inline styles. Report the element, its text content, current markup, and suggest the appropriate heading level.\n- **Content that should be a list but isn't** — series of items as separate paragraphs or `\u003cbr\u003e`-separated lines. Suggest converting to an `\u003cx-list\u003e` component (the Design Studio list component). Raw `\u003cul\u003e` and `\u003col\u003e` tags are not the correct fix in Design Studio emails — always use `\u003cx-list\u003e` instead. Use `list-style` to set the marker: `disc`, `circle`, `square` for bulleted lists; `decimal`, `lower-alpha`, `upper-alpha`, `lower-roman`, `upper-roman` for numbered lists.\n- **Data that should be in a table** — structured data (order summaries, pricing) in layout hacks. Suggest a proper `\u003ctable\u003e` with `\u003cth\u003e` headers, inside an `\u003cx-html\u003e` — Design Studio has no table component, so that is the one place a raw `\u003ctable\u003e` belongs. Layout tables should have `role=\"presentation\"` — only data tables use default semantics.\n- **Headings used for styling** — heading elements used for bold/large text when the content isn't actually a heading. Suggest styled paragraphs instead.\n\n#### 3c. Link text quality\n\nEvaluate whether link text describes the destination or action. The rule-based checks (Step 2) catch exact matches like \"click here\" — your job is to apply broader judgment.\n\n- **Non-descriptive text:** Flag links whose text wouldn't make sense read out of context (screen reader users navigate by listing all links). This includes \"read more\", \"learn more\", \"find out more\", \"see details\", \"continue\", \"tap here\", and similar. Suggest descriptive replacements.\n- **Duplicate text, different destinations:** Group links by visible text. For any group where links share text but point to different URLs, report all of them — screen reader users can't distinguish them. E.g., three \"Shop now\" links going to different categories should say \"Shop men's\", \"Shop women's\", \"Shop accessories.\"\n\n#### 3d. Visual and interaction concerns\n\nExamine styles and layout for issues that pass mechanical checks but create real usability problems.\n\n**Font size:** Body text below 14px is a concern; secondary text (footers, disclaimers) below 10px is critical. Check for responsive overrides — if it scales up on mobile, note that.\n\n**Touch targets:** Links and buttons should have at least 44x44px tap area (WCAG 2.5.5). Flag short text links, closely spaced links (e.g. footer nav with `|` separators), and small icon links. Consider padding in your assessment.\n\n**Center-aligned long text:** Flag center-aligned blocks likely to exceed 3 lines at mobile widths (~320px) — the ragged left edge hurts readability, especially for people with dyslexia. Check for responsive overrides that switch to left-align on small screens.\n\n**Borderline contrast:** Flag color combinations that are hard to read in practice even if they technically pass AA: light grey on white at body text size, similar-hue colored text on colored backgrounds, text over images or gradients. This supplements the inline-style contrast check in Step 2 — focus here on cases where contrast can't be computed mechanically (inherited colors, gradients, background images).\n\n**Line height:** Body text with line-height below 1.5x its font-size is harder to read (WCAG 1.4.12). Headings can go down to 1.2x.\n\n**Other checks — flag if present:**\n- `text-align: justify` — irregular word spacing hurts readability\n- Color as the only way to convey meaning (e.g. red for error, green for success) without a text/icon indicator\n- Repeated emoji (3+ same) or dense emoji strings (5+) — report how a screen reader would announce them\n- Full sentences/paragraphs in all-caps (short labels like \"SALE\" are fine, but should be done with CSS unless it's an acronym/initialism)\n- Content in a different language than the email's `lang` without its own `lang` attribute\n- Hidden preheader text that is visually hidden but still exposed to screen readers (e.g. via `font-size:0`, `max-height:0`, or `overflow:hidden`) — suggest using `display:none` instead, which hides it from both visual users and screen readers. The preheader has already served its purpose in the email client preview pane.\n- CSS animations without `@media (prefers-reduced-motion: reduce)` fallback\n\n### Step 4: Present results\n\nCombine all findings into a single report. Write for a non-technical audience — avoid raw HTML tags, CSS property names, and code snippets in your explanations. Instead, describe issues in plain language referencing the visible content (e.g. \"the 'Summer Sale' heading\" not \"`\u003ch2\u003e` element\", \"the hero image\" not \"`\u003cimg src='...'\u003e`\"). Only include code when showing a specific fix the user needs to apply.\n\n1. **Issues grouped by severity** (critical → serious → moderate → mild) — each with: what the issue is, where in the email it occurs (describe the content/location), why it matters for people using assistive technology, and how to fix it\n2. **Summary** — brief overall assessment: how accessible is this email, and what are the highest-priority fixes?\n\nDo not list passing checks. Do not duplicate findings — if the same element was caught by both a rule-based check and a judgment-based check, report it once with the combined context.\n","email_craft.md":"# Email Craft \u0026 Design Quality Bar\n\nMandatory rules for every email the agent writes in Design Studio. Read this file completely before\nwriting any markup; the pre-flight checklist in SKILL.md § User-Facing Updates requires it. The\nrender-failing prop mistakes table stays in SKILL.md § Prop Mistakes.\n\n## Design Quality Bar\n\nBefore writing any markup, declare the mini design system you will use. For every value that matches a workspace token, write the `globalStyles.*` path — not the hex/px value:\n\n```\npalette: globalStyles.colors.brand_risapxu0nl8n (#0b353b), globalStyles.colors.bodytext_izbt7ck8bjqi (#08272b), globalStyles.colors.neutral100_k1xxa9v20pgy (#ffffff), globalStyles.colors.accent_qf5n9mw1i4gn (#138563)\nradius: globalStyles.radius.small_5fe2jckjr9j2 (4px) for buttons, globalStyles.radius.medium_kt9yq935y5yf (8px) for cards\nspacing: globalStyles.spacing.large_1x5sc1rox8yb (20px) for sections, globalStyles.spacing.medium_byw3969uair3 (10px) for gaps\ntype scale: 32 / 22 / 16 / 13\n```\n\n**Build each path from the step-1b response — this is the whole job.** The ids above are one real workspace's. Yours differ, so derive them:\n\n```\nresponse (step 1b projection)                          the path you write\ncolor.brand        value=#0b353b  id=risapxu0nl8n   →  globalStyles.colors.brand_risapxu0nl8n\ncolor.body-text    value=#08272b  id=izbt7ck8bjqi   →  globalStyles.colors.bodytext_izbt7ck8bjqi\ncolor.neutral-100  value=#ffffff  id=k1xxa9v20pgy   →  globalStyles.colors.neutral100_k1xxa9v20pgy\nradius.small       value=4px      id=5fe2jckjr9j2   →  globalStyles.radius.small_5fe2jckjr9j2\nspacing.large      value=20px     id=1x5sc1rox8yb   →  globalStyles.spacing.large_1x5sc1rox8yb\n```\n\nThree rules the table shows: `color` and `font` pluralize in the path (`colors`, `fonts`) while `radius` and `spacing` do not. The safeName strips every non-word character *before* camelCasing, so `body-text` becomes `bodytext` — **not** `bodyText`: the hyphen is gone by the time words are split. Underscores survive that strip and do act as boundaries, so `heading_color` → `headingColor`. Same for spaces and dots: removed, not treated as boundaries.\n\nNever ship a `\u003cid\u003e` placeholder. It is not valid expression syntax and it fails the render (verified against the renderer). A path with a wrong or missing id is worse: it is valid syntax, so it resolves to nothing, renders empty, and the publish still succeeds.\n\nThe hex/px in parentheses is for your reference — in the markup, use the `globalStyles.*` path. Write markup that matches this declaration:\n\n1. **Tokens are used, not just fetched.** Every color, spacing, and radius value with a matching workspace token references `globalStyles.*` — never copy a token's hex/px into a literal. Hardcode only values that genuinely have no token. Dark mode is not an excuse to drop tokens: use the token as the light half of the pair — ``:background=\"{ light: globalStyles.colors.brand_risapxu0nl8n, dark: `#3730A3` }\"`` — and a matching dark token (e.g. a `brand-dark` token) as the dark half when one exists. With no dark-valued token, reuse a palette token that reads on the dark surface (the light neutral for text) rather than inventing a new hex.\n\n   **Match tokens by role and value, not by name.** Workspaces name tokens whatever they like. Almost none of them use the role words in this file or in the pattern files, so searching for a token *called* `surface` or `bodyText` and concluding \"this workspace has no matching tokens\" is wrong nearly every time. Instead, take the token list from step 1b and assign each design role to the closest token by its value:\n\n   - page and card surfaces → the lightest neutral (`neutral-100`, `white`, `paper`, `bg`)\n   - body copy → the darkest neutral, or whatever token holds a near-black\n   - headings → a heading token if one exists, otherwise the same darkest neutral\n   - the primary CTA fill → the brand or accent token\n   - hairlines and borders → a mid neutral (`neutral-500`, `grey-200`)\n\n   A workspace whose only colors are `neutral-100`, `neutral-500` and `neutral-900` still has a token for white, for a hairline and for body text. Saying otherwise and shipping `#ffffff` fails this rule. The same applies to `4px` when `radius.small` is 4px, and to a font stack when `font.primary` holds it.\n2. **Radii from tokens, by role.** Every rounded element uses a radius token; distinct tokens may serve distinct roles (e.g. `radius.small` for buttons, `radius.card` for cards), applied consistently — every button the same, every card the same. With no radius token, use one literal everywhere. Arbitrary mixed literals (6px buttons here, 12px cards there) are the most common \"looks off\" complaint.\n3. **A deliberate type scale.** `font-family` / `:font-size` / `:line-height` once on `\u003cx-base\u003e`; beyond that a small scale you chose up front — heading, sub-heading, body, small print — at most ~5 distinct steps in the whole email, a responsive heading pair counting as one step. Never re-declare the base size or line-height on individual elements, and never invent a new size for one element.\n4. **Small palette, accent reserved.** Brand accent + a neutral ramp, ~4 colors total. The accent belongs to the primary CTA — exactly one per email, with any other buttons or links visibly subordinate (outline, plain text) — not section backgrounds, icon text, and links all at once. No one-off grays that appear a single time. Leave inline `\u003ca\u003e` tags without a `style`: the workspace link style applies (`data-variant` picks a named one), and a `style` string can hold no token. Where a link must follow its text colour, set the token pair on the parent `\u003cx-paragraph\u003e`.\n5. **One spacing scale.** Vertical rhythm from the spacing tokens (or consistent multiples of one base); identical horizontal padding across sections.\n6. **No emoji as icons or decoration.** Use images, or plain text without ornamentation.\n7. **Contrast holds everywhere, both themes.** Body and fine-print text ≥ 4.5:1 against its actual background in light *and* dark mode — footers and headers on tinted backgrounds are where this usually breaks. If an element's background is a `{ light, dark }` pair, every piece of text on it (including `\u003ca\u003e` links, which otherwise keep the client's default blue) needs its own `{ light, dark }` color pair — a single mid-gray hex cannot be readable on both. Check the pair you wrote, not the pair you intended. Links take that pair from their parent `\u003cx-paragraph\u003e` through `style=\"color: inherit\"` — a bare `\u003ca\u003e` renders the client's default blue, and a fixed `color: #…` holds for one theme only. A review contrast warning is a defect to fix, not one to report; re-run the review once after the fix to confirm it cleared.\n8. **Links have descriptive text.** `\u003ca href=\"...\"\u003eVisit the help center\u003c/a\u003e` — the visible text says where the link goes in words. Never use the URL or its hostname as the text (`help.example.com` as link text fails this bar as much as the full URL does).\n\n## Email Craft\n\nCraft rules for producing professionally designed markup. Apply to every email.\n\n**Sender → recipient frame.** Before writing copy or markup, make one working comment at the top of the first command that writes markup (a heredoc to a file counts): `# Frame: [sender] → [recipient]; relationship/moment: [...]; purpose: [...]`. Derive it from the user's brief, not an automation, folder, or campaign name. Keep point of view, greeting, pronouns, and calls to action consistent with that line. If the brief leaves the frame genuinely unresolved and the alternatives would produce different copy, ask before authoring.\n\n**Content boundaries.** Treat supplied facts as a closed set. Do not invent names, role titles, employers, biographies, addresses, support contacts, offers, product capabilities, setup times, or destinations. If the user asks you to create now and requests a section such as “background,” “family,” or “interests” without supplying its facts, use clearly editable copy such as `[Add professional background]` or `[Add family and interests]`; otherwise ask for the missing details. Do not turn the category words into plausible specifics or generic claims. A visible placeholder is better than plausible fiction. Facts are verifiable specifics — URLs, prices, dates, names, addresses; the steps, benefits and wording a brief implies (a setup nudge's \"connect a data source, invite your team\") are copy you write from it.\n\n**Keep the slots, mark the gaps.** When the brief asks for a button, image or link, or the type file's anatomy marks a band as always present (a welcome's logo band and CTA, a receipt's address and support line), build it even when the brief supplies no destination, asset or fact: an `href` like `https://example.com/REPLACE-WITH-SETUP-GUIDE-URL`, an `[Add postal address]` line, an `x-image` with a clearly named stand-in `src` and `alt` that describes the picture meant for the slot (\"Analysts reviewing the new dashboard\"), never a bracketed instruction when no workspace asset fits. Placeholders stand in for facts only — a URL, an address, a price or name the brief withholds. The copy itself is yours: write headlines, body and feature blurbs from the brief, and never ship `[Add …]` prose. List every placeholder in your reply. A personal note, or an action nothing in the brief or type calls for, still gets no CTA. With no logo asset, the logo band carries the brand name as a text wordmark.\n\n**Voice fallback.** When workspace tone guidance is missing, incomplete, or unusable, use conversational professionalism: clear, warm, restrained, and low-hype. Avoid invented brand personality, jokes, slang, emoji, repeated exclamation marks, and unsupported enthusiasm. The sender → recipient frame still decides whether the voice is first-person singular, company plural, or neutral service voice.\n\n**Ready to review from the first save.** Render it (`GET /design_studio/emails/{id}/render`) whatever else fails — a screenshot error does not replace the render check. Create the email with a sender — `envelope.from_id` from `GET /identities` — and close any customer-facing email with a footer band carrying the unsubscribe link (`{% unsubscribe_url %}`), transactional types included — for a receipt or password reset, tell the user they can drop it once the email is linked as a transactional message (a personal note to colleagues stays without one): until the draft is linked to a transactional message, the review scores a missing sender or unsubscribe link as an error and locks the score. Save once; if a create seems to fail, list the emails before posting again. When SKILL.md step 1g left the folder open (zero or several candidates), the reply ends with the numbered folder question, \"Keep at root\" included; when a guess placed it, with the move offer.\n\n**Subject and preheader.** Set both `content.subject` and `content.preheader_text` on the create, and repeat the preheader in an `\u003cx-hidden-text\u003e` at the top. The preheader adds what the subject does not carry — the consequence, the deadline, the next step, a concrete detail — so reading them together says more than either alone. \"Your cart is waiting\" / \"Your gear is still in your cart\" fails; \"Your cart is waiting\" / \"The trail pack you picked is selling fast\" works.\n\n**Anatomy.** Start with the preheader, then use only the bands the frame and message need: optional header/brand lockup → body sections → primary CTA when the brief supplies a real action and destination → compliance footer when the message requires one. A personal note to existing colleagues can be greeting → concise body → sign-off, with no marketing CTA, postal address, or unsubscribe link. Sketch the `data-layer-label`s before writing content.\n\n**Band structure.** One `\u003cx-section\u003e` carries the email width; each visible band — logo, hero, body, offer, CTA, footer — is its own `\u003cx-box\u003e` inside it, and so is each divider (`\u003cx-box\u003e` padding spaces an `\u003cx-hr\u003e`; margin on the rule does not). Add a second section only where the full-bleed `outer-background` changes. The section and every band box get all three:\n- Responsive padding: ``:padding=\"{ small: `24px 16px`, medium: `32px 24px` }\"``\n- Explicit background in both themes, from tokens on both halves: `:background=\"{ light: globalStyles.colors.neutral100_k1xxa9v20pgy, dark: globalStyles.colors.brand_risapxu0nl8n }\"` (a literal dark half only when the workspace has no dark-valued token)\n- A `data-layer-label` for editor navigation\n\n**Backtick the string values inside an object.** ``:padding=\"{ small: `24px 16px`, medium: `32px 24px` }\"`` and ``:background=\"{ light: `#F7F3EC`, dark: `#1C1917` }\"`` — never `'24px 16px'` or `\"#F7F3EC\"`. Bare numbers and `globalStyles.*` refs are not strings and stay unwrapped; a compound key such as `\"small:dark\"` (only `x-image` `src` takes the four-way form) is double-quoted, which puts the attribute itself in single quotes: ``:src='{ \"small:dark\": `…` }'``. A quoted value renders correctly, so nothing errors — but the visual editor's property panel keeps the quote characters as part of the value, so the colour or spacing control shows a broken value and writes it back corrupted the first time someone touches it. This applies to every object value in the email, not just the ones copied from the examples here.\n\n**Dark mode is not optional.** Set `color-scheme=\"light dark\"` on `\u003cx-base\u003e`. Leaving it off is not neutral: `x-base` defaults to `normal`, and the render then ships `\u003cmeta name=\"color-scheme\" content=\"normal\"\u003e` while still emitting the `prefers-color-scheme: dark` rules — so every `{ light, dark }` pair in the email is declared to a client that has been told the email has no dark support (verified against the renderer). Every surface with a `{ light, dark }` background needs matching `{ light, dark }` text colors. Pair warm with warm — a cream light theme (`#F7F3EC`) pairs with a warm dark (`#1C1917`), not a cold gray (`#1a1a1a`). The CTA fill also needs a `{ light, dark }` pair.\n\n**Responsive type.** Every heading level, `x-heading-2` and below included, gets `{small, medium}` font sizes (e.g. `:font-size=\"{ small: 24, medium: 30 }\"`). Body text stays fixed at the `\u003cx-base\u003e` size — it's already readable on mobile.\n\n**One primary CTA when there is an action.** When the brief supplies an action and a real destination, use exactly one filled `\u003cx-cta\u003e` per email — it gets the accent color. Secondary actions are outline buttons (`border-style=\"solid\"` with transparent background) or inline `\u003ca\u003e` links. The CTA's touch target comes from padding, never height. When the brief supplies no action or destination, do not invent either; omit the CTA.\n\n**Spacing comes from the spacing tokens.** Take every gap, margin and CTA padding from the workspace's spacing scale and bind it: a single value is `:margin=\"globalStyles.spacing.large_1x5sc1rox8yb\"`; a per-side value combines tokens with `Component.utils.spacing` — ``:margin=\"Component.utils.spacing(`0px`, `0px`, globalStyles.spacing.large_1x5sc1rox8yb, `0px`)\"`` for a 20px bottom gap, `:padding=\"Component.utils.spacing(globalStyles.spacing.medium_byw3969uair3, globalStyles.spacing.large_1x5sc1rox8yb)\"` for a 10px × 20px button. Zero sides stay a backticked `` `0px` ``; a plain `margin=\"0\"` resets a default margin. Only the responsive `{ small, medium }` padding objects stay literal. A literal px (`margin=\"0 0 16px 0\"`, `padding=\"14px 28px\"`) is the off-scale value rule 5 forbids; pick the nearest token instead.\n\n**Composition moves.** If the workspace has a brand document (`read(\"BRAND.md\")` — see **brand_document.md**), its Signature moves override this generic list. Correct-but-flat is the most common failure mode: every section same background, headline straight into body text, lists as bare paragraphs. These moves are what make an email read as designed — use the ones the content calls for:\n\n- **Eyebrow line** — a short all-caps label in the accent color above the `\u003cx-heading-1\u003e`: ``\u003cx-paragraph :font-size=\"12\" text-transform=\"uppercase\" :letter-spacing=\"0.15\" :color=\"{ light: globalStyles.colors.accent_qf5n9mw1i4gn, dark: `#D97A4E` }\" :margin=\"Component.utils.spacing(`0px`, `0px`, globalStyles.spacing.medium_byw3969uair3, `0px`)\"\u003eShipment confirmed\u003c/x-paragraph\u003e`` (the accent token carries the light half; the dark half is literal only because this workspace has no lighter accent token). Letter-spacing MUST be the number form (`:letter-spacing=\"0.15\"`, unit is em) — string values fail the render.\n- **Banded layout** — give the content column a different fill from the full-bleed band (`background` vs `outer-background`) so the email reads as a card on a page instead of a flat sheet; alternate one or two section fills for rhythm.\n- **Accent band** — at most one section on the inverted palette (dark fill in light mode, e.g. a brand note, tip, or pull-quote). It breaks the scroll and carries the brand voice. Skip it on short transactional emails.\n- **Item rows** — anything list-like (order items, articles, features) is an `\u003cx-row :layout='[17, 55, 28]' :break-point=\"480\"\u003e`: small thumbnail (`width=\"48px\"` to `\"72px\"`, `border-radius` from the radius token), name plus a one-line muted caption, right-aligned value. Hairline `\u003cx-hr\u003e` between rows, a stronger one before the total row.\n- **Numbers right-align** — prices, totals, and quantities get `text-align=\"right\"` in their column; the label column stays left. Bold the total row only.\n- **Existing assets before placeholders** — An image the user attached in chat to use in the content is imported into the asset library (`@skills/fly-api/assets.md` § Images the User Attached in Chat) and used by its returned `path`; if the import fails, tell the user why and leave the image out. A screenshot of a problem or a reference image shared as feedback is context, not an asset: don't import it. Unless the user supplied or requested a specific image, keep imagery from the chosen template or custom component, then use a suitable entry from `BRAND.md`'s Assets table; it was already curated for that role and imported into the workspace. If none fits, search the customer's broader workspace asset library via `@skills/fly-api/assets.md` and use the returned hosted `path`. Do not guess from an ambiguous filename. When nothing suitable exists, keep the layout text-only, or — for an image the brief asked for — a named stand-in listed in your reply (§ Keep the slots); never silently ship a placeholder or hotlink an arbitrary image.\n- **Logo lockup** — render the logo at half its source width for retina (320px-wide source → `width=\"160px\"`). A matching custom logo component remains preferable because it preserves the workspace's established lockup.\n- **Marketing footer belongs on the quiet band** — for customer-facing marketing, use muted small text (13px) with only the physical address, support link, preferences, or unsubscribe details the workspace or brief actually supplies. Do not invent them. Internal colleague notes and other personal correspondence do not get a marketing footer unless the user asks for one. Social links are a row of icon images, not text links: copy the hosted-icon preset in **components_reference.md § Editor Presets**.\n\n**Craft pattern** — a well-built section uses token paths for every value that has a matching workspace token (the `globalStyles.*` paths come from step 1b):\n```html\n\u003c!-- Every ref below is a complete path built from the step-1b table above. Both halves of each\n     { light, dark } pair are tokens: on the dark half the light-valued token becomes the text\n     colour and the dark-valued one becomes the fill. --\u003e\n\u003cx-section data-layer-label=\"Hero\"\n           :padding=\"{ small: `32px 16px`, medium: `48px 32px` }\"\n           :background=\"{ light: globalStyles.colors.neutral100_k1xxa9v20pgy, dark: globalStyles.colors.brand_risapxu0nl8n }\"\n           :outer-background=\"{ light: globalStyles.colors.surface_x1xalnao0t1w, dark: globalStyles.colors.brand_risapxu0nl8n }\"\u003e\n  \u003cx-heading-1 :color=\"{ light: globalStyles.colors.heading_57jkqn2y52oo, dark: globalStyles.colors.surface_x1xalnao0t1w }\"\n               :font-size=\"{ small: 26, medium: 32 }\"\u003e\n    Your Order Has Shipped\n  \u003c/x-heading-1\u003e\n  \u003cx-paragraph :color=\"{ light: globalStyles.colors.bodytext_izbt7ck8bjqi, dark: globalStyles.colors.surface_x1xalnao0t1w }\"\u003e\n    It's on the way — here are the details.\n  \u003c/x-paragraph\u003e\n  \u003cx-cta href=\"https://example.com/track\"\n         :background=\"{ light: globalStyles.colors.brand_risapxu0nl8n, dark: globalStyles.colors.accent_qf5n9mw1i4gn }\"\n         :color=\"globalStyles.colors.neutral100_k1xxa9v20pgy\"\n         :border-radius=\"globalStyles.radius.small_5fe2jckjr9j2\"\n         :padding=\"Component.utils.spacing(globalStyles.spacing.medium_byw3969uair3, globalStyles.spacing.large_1x5sc1rox8yb)\"\u003e\n    Track Your Shipment\n  \u003c/x-cta\u003e\n\u003c/x-section\u003e\n```\n\nOne literal survives in that block, and it is the justified case: the responsive `:padding` values sit inside a `{ small, medium }` object, which takes no token. Everything else, the CTA's touch-target padding included, is bound to a token. `:color` on the CTA is a single token rather than a pair because white reads on both fills — a pair with identical halves is noise.\n## The token check (SKILL.md step 3)\n\nAfter the create, verify token usage mechanically. Counting `globalStyles.` refs is not the test: an email with one binding and thirty literals passes that count and still fails rule 1 above. Run all three checks. Two ways a rebuilt version of (a) passes an email it should fail, both common: feeding it the `/render` output instead of the source markup, and narrowing its token loop to `color.*`. Rendering resolves every `globalStyles` path to a hex, so the rendered HTML cannot show you which values were bound; and a colour-only loop silently exempts radius, spacing and font, which are the three that survive most often. Check (a) printing nothing is what clears the email — each line it prints is a value to go back and bind, not a caveat to mention in the summary.\n\n```bash\n# The token table from step 1b, saved once. Font tokens carry an object $value,\n# so unwrap fontFamily.\ncio api /v1/environments/$ENV_ID/ds/global_styles --jq '(.styles // .).default | {color, font, radius, spacing}\n  | to_entries[] | .key as $g | .value | to_entries[]\n  | \"\\($g).\\(.key)\\t\\(.value.\"$extensions\".\"io.parcel.global-styles\".id)\\t\\(\n      .value.\"$value\" | if type == \"object\" then .fontFamily else . end)\\tglobalStyles.\\({color:\"colors\",font:\"fonts\"}[$g] // $g).\\(\n      .key | gsub(\"[^A-Za-z0-9_$]\";\"\") | gsub(\"(?\u003ca\u003e[^A-Z_])(?\u003cb\u003e[A-Z])\";\"\\\\(.a)_\\\\(.b)\") | split(\"_\")\n      | map(select(. != \"\") | ascii_downcase) | .[0] + (.[1:] | map((.[:1] | ascii_upcase) + .[1:]) | join(\"\"))\n      | if test(\"^[0-9]\") then \"_\" + . else . end)_\\(.value.\"$extensions\".\"io.parcel.global-styles\".id)\"' -r \u003e /tmp/tokens.tsv\n\n# M is the source markup, the only form that still shows globalStyles paths.\nM=$(cio api /v1/environments/$ENV_ID/design_studio/emails/$EMAIL_ID --jq '.email.content.html' -r) \\\n  || { echo \"email fetch failed; check EMAIL_ID\" \u003e\u00262; exit 1; }\n# a 200 can still carry no html, and empty markup makes every check below print\n# nothing, which reads as a clean email\n[ -n \"$M\" ] \u0026\u0026 [ \"$M\" != \"null\" ] || { echo \"email returned no markup\" \u003e\u00262; exit 1; }\n\n# New markup should backtick its object values (see Section craft above), but\n# this check reads whatever is already stored, and older emails carry single or\n# double quotes, so match all three delimiters rather than double quotes alone.\nQ=$'[\"\\'`]'; NQ=$'[^\"\\'`]'; E=$'[ \"\\'`]'\n\n# The sandbox runs every command under `set -eo pipefail`, and a clean email is\n# exactly the case where a grep matches nothing. Without the `|| true` guards\n# below, the first no-match aborts the script and the rest of the checks never\n# run — printing nothing, which reads as a pass.\n\n# (a) literals a token covers. Colours count on a component colour prop, in a\n#     pair or standalone. Spacing/radius count in a bare padding/margin/radius\n#     attribute, single or per-side (\"0 0 20px 0\" -\u003e Component.utils.spacing);\n#     a value inside a responsive { small, medium } object has no token form.\nCP='color|background|outer-background|border-color|background-color|hover-color|hover-background'\nCOLORS=$(grep -oE \"(:?)($CP)=${Q}${NQ}*${Q}\" \u003c\u003c\u003c\"$M\" || true)\nSINGLES=$(grep -oE \"(^|[^:a-z-])(padding|margin|border-radius)=${Q}${NQ}+${Q}\" \u003c\u003c\u003c\"$M\" || true)\nFONTS=$(grep -oE \"(:?)font-family=${Q}${NQ}*${Q}\" \u003c\u003c\u003c\"$M\" || true)\n# An empty table silences (a), so say so rather than letting it read as a pass.\n# (b) and (d) need no tokens and still run.\n[ -s /tmp/tokens.tsv ] || echo \"no tokens in this workspace; (a) cannot run\" \u003e\u00262\nPATHS=$(cut -f4 /tmp/tokens.tsv || true)\nwhile IFS=$'\\t' read -r tok id value path; do\n  case \"$tok\" in\n    color.*)            grep -qiF \"$value\" \u003c\u003c\u003c\"$COLORS\"         \u0026\u0026 echo \"LITERAL $value -\u003e use $tok ($id)\" ;;\n    radius.*|spacing.*) grep -qE \"${E}$value${E}\" \u003c\u003c\u003c\"$SINGLES\" \u0026\u0026 echo \"LITERAL $value -\u003e use $tok ($id)\" ;;\n    font.*)             grep -qF \"$value\" \u003c\u003c\u003c\"$FONTS\"           \u0026\u0026 echo \"LITERAL $value -\u003e use $tok ($id)\" ;;\n  esac\ndone \u003c /tmp/tokens.tsv || true\n\n# (b) dead bindings — a globalStyles path on an attribute with no ':' prefix\n#     ships as a literal string and nothing errors.\ngrep -oE \"(^|[^:a-zA-Z0-9-])[a-z-]+=${Q}${NQ}*globalStyles\\.${NQ}*${Q}\" \u003c\u003c\u003c\"$M\" || true\n\n# (c) paths that do not exist in this workspace. This is the quiet one: a wrong id\n#     is valid expression syntax, so the renderer drops the reference, the value\n#     comes out empty, and the publish still succeeds. Nothing else catches it.\n#     A wrong name with a real id is just as dead (x-large and x_large are both\n#     xlarge), so the whole path has to match one the workspace holds.\ngrep -oE 'globalStyles\\.[a-zA-Z]+\\.[A-Za-z0-9_$-]+' \u003c\u003c\u003c\"$M\" | sort -u |\n  while read -r ref; do\n    grep -qxF \"$ref\" \u003c\u003c\u003c\"$PATHS\" \u0026\u0026 continue\n    echo \"UNKNOWN TOKEN PATH: $ref -\u003e use $(grep -F \"_${ref##*_}\" \u003c\u003c\u003c\"$PATHS\" || echo 'a path from step 1b')\"\n  done || true\n\n# (d) literal corner radii that disagree with each other. A literal no token\n#     covers slips past (a), so a 12px card beside a 6px button beside a 10px\n#     image is clean by every check above and still the \"looks off\" complaint\n#     from rule 2. Token-bound radii are excluded: rule 2 allows distinct\n#     tokens for distinct roles.\ngrep -oE '(:?)border-radius=(\"|'\"'\"'|`)[^\"'\"'\"'`]+' \u003c\u003c\u003c\"$M\" | sed 's/.*=.//' |\n  grep -v globalStyles | sort -u |\n  { mapfile -t radii; [ \"${#radii[@]}\" -le 1 ] || printf 'MIXED RADII: %s\\n' \"${radii[*]}\"; } || true\n```\n\nAny line from (a), (b), (c) or (d) is a defect. Fix them all in **one** PUT, then move on — one fix pass, no loop.\n\n- **(d) counts token references and literals alike**, because the rule is one corner radius across the email (or one per role, applied consistently — every button alike, every card alike). Two lines for two roles is fine; four values across six elements is the defect.\n- **(a) prints two lines for one literal** when two tokens share a value (`radius.medium` and `spacing.small` both 8px). Pick the one whose role fits the attribute — radius on a corner, spacing on a gap.\n- **(a) deliberately ignores hexes inside an inline `style=\"…\"` string**, because there is no token form for one: `style` is a plain string and concatenation breaks the visual editor. It is still worth avoiding — for an `\u003ca\u003e`, set `:color=\"globalStyles…\"` (or a `{ light, dark }` token pair) on the parent `\u003cx-paragraph\u003e` and let the anchor inherit, per rule 4. Judgement, not a check.\n- **(c) is the check to trust least of your own judgement on.** Never copy an id out of this file or a pattern file — every id here belongs to one example workspace. Rebuild each path from your own step-1b table.\n\n**Self-check before the create call — one pass, no re-check loop.**\n\n- Substitute every hex that matches a token value with its `globalStyles.*` path as you write — don't write hex first and fix later. **The bar is not \"some tokens are referenced\".** One binding does not clear it. Walk the step-1b list against your markup and bind every value the markup *can* express as a token: each hex on a colour prop (both halves of a `{ light, dark }` pair included), the `font-family`, each `border-radius`, and each side of a bare `padding`/`margin` (per-side values through `Component.utils.spacing(...)`). Only a value inside a responsive `{ small, medium }` object has no token form — leave those literal.\n- Every `globalStyles.` path must sit on a `:`-prefixed attribute. `background-color=\"{ light: globalStyles… }\"` without the colon ships the whole thing as a dead literal string — it renders as text, not a colour, and nothing errors.\n- Confirm ≤5 type steps, one `border-radius` value (or role-based token pair), and no emoji in headings or decoration.\n- Every `{ light, dark }` background surface needs matching `{ light, dark }` text colors on its children.\n- No hex remains on a colour prop, dark halves included: where no token holds the dark value, reuse the palette token that reads on that surface. Every box with a background sets it as a `{ light, dark }` pair.\n","email_patterns.md":"---\nname: Email Patterns Index\ndescription: Index of the per-email-type pattern files — anatomy, subject and preheader patterns, and a converted Carta example for each — plus the generic layouts whose email type has not been distilled yet.\n---\n\n# Email Patterns\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1f preflight: fetch global styles, check for custom components, look for a matching saved template, read this email's type file below, resolve imagery, and state the sender → recipient frame.\n\nThe frame is part of the pattern, not a detail to infer later. Use the default below only when the brief agrees with it. A workflow or folder name is not audience evidence: a “welcome” automation can carry a new employee's introduction to existing colleagues. When no type fits, state the frame from the brief before choosing a generic layout.\n\nEach email type has its own file. A type file documents the band-by-band anatomy that recurs across real emails of that type, its structural variants and roughly how common each one is, real subject-line and preheader patterns, CTA phrasing, tone, and one Carta example converted from a real email and graded against it. Read the file for the type you are writing before you write anything.\n\nRead the **File** column path exactly as written — the pattern files keep underscores (`email_patterns/account_security.md`). A hyphenated guess (`account-security.md`) is not a file and the read fails.\n\n| Email type | Default sender → recipient frame | File |\n|---|---|---|\n| Welcome / signup confirmation for a list, an account or a trial start | Company or service → new subscriber or account holder | **email_patterns/welcome.md** |\n| Individual introduction / colleague announcement | Named individual or incoming teammate → existing colleagues | **email_patterns/colleague_announcement.md** |\n| Order confirmation, receipt, booking confirmation | Merchant or service → purchaser or booker | **email_patterns/order_confirmation.md** |\n| Abandoned cart and browse abandonment | Merchant → shopper who left an item or session | **email_patterns/abandoned_cart.md** |\n| The messages after the welcome — finish setup, product tours and \"you haven't done X yet\" nudges | Product or customer team → existing user who has not completed activation | **email_patterns/onboarding_activation.md** |\n| Winning back a lapsed or inactive contact — we-miss-you notes, incentive win-backs, sunset emails | Company → inactive customer or subscriber | **email_patterns/re_engagement.md** |\n| Points balances and rewards, tier upgrades, bonus-point promotions, refer-a-friend give-get invites | Loyalty program or company → member | **email_patterns/loyalty_referral.md** |\n| A trial is ending, a subscription renews or just charged, a payment failed, a plan changed or was cancelled | Billing service or company → account holder | **email_patterns/billing_subscription.md** |\n| Inviting to a webinar or event, confirming a registration, pre-event reminders, replay/recap | Host or company → invitee or registrant | **email_patterns/event_webinar.md** |\n| Promotion, sale, discount, limited-time offer | Brand or merchant → prospect or customer | **email_patterns/promotion_sale.md** |\n| Product launch, new arrival, drop, feature release | Company or product team → customer or subscriber | **email_patterns/product_launch.md** |\n| Newsletter, digest, roundup, recurring editorial send | Publisher or company → subscriber | **email_patterns/newsletter_digest.md** |\n| Personalized recommendations, picks for you, curated items | Brand or service → known customer | **email_patterns/recommendations.md** |\n| Educational content, reports, how-tos, recipes, guides | Expert, publisher, or company → learner or subscriber | **email_patterns/content_education.md** |\n| Year in review, recap, milestone, impact or progress report | Organization or service → customer, member, or supporter | **email_patterns/milestones_recap.md** |\n| Password reset, email verification, one-time passcode, new sign-in or password-changed alert | Security service → account holder | **email_patterns/account_security.md** |\n| Account created or activated, profile completion, status change, deletion warning, preference opt-out | Service or account team → account holder | **email_patterns/account_lifecycle.md** |\n| System alert, usage limit, policy or price-change notice, deadline reminder, office closure or schedule notice | Service or operations team → affected user or account owner | **email_patterns/alerts_reminders.md** |\n| Apology, correction (\"please disregard\"), service disruption, customer-support emails | Responsible company or support team → affected customer | **email_patterns/apology_support.md** |\n| Review request, survey invite, NPS or rating ask, testimonial emails | Company or researcher → customer or participant | **email_patterns/review_feedback.md** |\n| Shipment notification (shipped, out for delivery, delivered) and shipping-offer promos | Merchant or carrier → recipient of the shipment | **email_patterns/shipping_delivery.md** |\n\nRead the closest row's file even for a short email. When nothing fits, say so in one line before the markup and follow the generic layouts below. When the workspace's own emails (ORG_PATTERNS.md) override the type file, the reply names the contrast in one line — *\"Most cart emails open with a hero image; yours open with the headline, so this one does too.\"* When its provenance line says it was distilled more than 30 days ago, the reply also offers once to refresh it (**org_patterns.md** § Regenerating it).\n\nEvery distilled type above has its own file. For an email that fits none of them, use the generic multi-column layout at the end of this file as a starting shape, and follow the same discipline the type files use: one `x-section` carrying the width, one `x-box` per visible band, one `x-heading-1`, and one primary CTA.\n\n**If your system prompt mandates `ORG_PATTERNS.md`, read it before the type file.** That\ndocument is this workspace's own distilled patterns, and where it and a type file\ndisagree about structure — band order, hero contents, subject length — it wins; the\ntype file still supplies the Carta syntax and the graded example (**org_patterns.md**).\nWithout that mandate, ignore this paragraph: the workspace has no such document.\n\n**Read email_craft.md before the type file, not after.** It carries the rules every email you write must meet — responsive `{ small, medium }` padding, explicit `{ light, dark }` backgrounds and text with `color-scheme=\"light dark\"` on `\u003cx-base\u003e`, `data-layer-label` on every section, one primary CTA, consistent spacing.\n\n**Where the two differ, and which wins.** email_craft.md is the bar for an email you are *designing*. A type file's example is a *conversion* of one real email, graded against the source render, so it shows what that source did: often a single theme, and per-element `:font-size` / `:line-height` matching the source's metrics rather than one scale set on `\u003cx-base\u003e`. Take anatomy, band order, copy patterns and Carta syntax from the type file. Take the design rules from email_craft.md — including the dark-mode pairs a single-theme conversion does not show. The one thing a conversion cannot follow is the type scale: `:line-height` accepts no `{ small, medium }` object, so a source that steps line-height per breakpoint can only be matched statically.\n\n**Token substitution — read this before copying anything out of a type file.** A type file's markup is a *conversion of one real email*, graded against that source's render. Its hex values are that sender's brand colors and its `globalStyles.*` paths carry placeholder ids. **Neither is copyable as written.** Every value has to be re-mapped onto the workspace you are building in:\n\n- Every `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e` path must be rebuilt from the real ids in your step-1b response. A placeholder id is valid syntax that resolves to nothing — it renders empty and the publish still succeeds, so nothing tells you it broke.\n- Every hex and px literal is a candidate for substitution. Do this during the design-system declaration, not after. Copying a type file's palette verbatim ships another company's brand colors.\n- **Match tokens by role and value, not by name.** The example paths use role words (`brand`, `surface`, `bodyText`, `pageBackground`, `mutedText`, `pill`) that almost no real workspace uses as token names. Do not search for a token *called* `surface`, find none, and conclude the workspace has no tokens — that conclusion is wrong nearly every time. Map each role onto the closest token by value: lightest neutral for surfaces, darkest for body copy, brand or accent for the CTA fill, a mid neutral for hairlines. See **email_craft.md** § Design Quality Bar rule 1.\n- Image URLs in converted examples are source-document stand-ins, not usable assets. Never persist `placehold.co`, `example.com`, or an invented image URL from an example. Use an existing asset selected during step 1e. When none fits, drop an image only the example had; an image the brief asked for keeps its slot with a clearly named stand-in `src`, listed in your reply (**email_craft.md** § Keep the slots).\n\n## Authoring notes\n\nSKILL.md § Best Practices covers global styles, custom components, heading levels, and section-versus-box structure. These are the points it does not:\n\n- Limit font families to 1–2 max; apply `font-family` on `\u003cx-base\u003e` for inheritance\n- Always include `alt` text on `\u003cx-image\u003e` — a linked image with no `alt` reads as an empty link\n- When the brief supplies a real action and destination, exactly one filled `\u003cx-cta\u003e` carries it, per email — not per section (**email_craft.md** § One primary CTA). A second button belongs after a value-prop block or in the footer, and must be visibly weaker: outline, or a plain link. Where a corpus type really does repeat a button of equal weight, its type file says so. If there is no supplied action or destination, omit the CTA rather than inventing one\n- Use `\u003cx-row :break-point=\"480\"\u003e` for columns that must stack, and `\u003cx-wrap\u003e` for icon or button runs that must not\n- Set an explicit `background` on every band, even when it matches the page — a transparent band is what breaks dark mode\n\n### Pre-Send Checklist\n\n- [ ] Mobile rendering looks correct\n- [ ] Heading hierarchy is sequential (h1 → h2 → h3)\n- [ ] All images have alt text\n- [ ] Sufficient color contrast\n- [ ] All links work\n- [ ] Tested with sample customer data\n- [ ] Spacing and alignment are consistent\n- [ ] Liquid syntax is valid (no unclosed tags, correct filter syntax)\n- [ ] Dark mode: check if background colors are explicit (not transparent)\n\n## Generic layouts\n\nThese are shapes, not distillations. Replace them with the type file as soon as one exists.\n\n### Multi-column feature grid\n\n```html\n\u003c!-- Token paths below are one real workspace's ids. Rebuild every one from YOUR step-1b\n     response before writing markup — a wrong id is valid syntax that resolves to nothing,\n     renders empty, and still publishes. --\u003e\n\u003cx-base :font-family=\"globalStyles.fonts.primary_fzwqtmxf3cv0\" :font-size=\"16\" :line-height=\"1.5\"\n        :background=\"{ light: globalStyles.colors.surface_x1xalnao0t1w, dark: globalStyles.colors.brand_risapxu0nl8n }\"\n        color-scheme=\"light dark\"\u003e\n  \u003cx-section data-layer-label=\"Features\"\n             :padding=\"{ small: `32px 16px`, medium: `40px 32px` }\"\n             :background=\"{ light: globalStyles.colors.neutral100_k1xxa9v20pgy, dark: globalStyles.colors.brand_risapxu0nl8n }\"\u003e\n    \u003cx-heading-1 :color=\"{ light: globalStyles.colors.heading_57jkqn2y52oo, dark: globalStyles.colors.surface_x1xalnao0t1w }\"\n                 :font-size=\"{ small: 24, medium: 28 }\" text-align=\"center\"\u003e\n      What's New\n    \u003c/x-heading-1\u003e\n    \u003cx-row :layout=\"[50, 50]\" :gap=\"16\" :break-point=\"480\" margin=\"24px 0 0 0\"\u003e\n      \u003cx-column\u003e\n        \u003cx-box :background=\"{ light: globalStyles.colors.surface_x1xalnao0t1w, dark: globalStyles.colors.brand_risapxu0nl8n }\"\n               :border-radius=\"globalStyles.radius.medium_kt9yq935y5yf\"\n               :padding=\"globalStyles.spacing.large_1x5sc1rox8yb\"\u003e\n          \u003cx-image src=\"https://placehold.co/280x180\" alt=\"Smart segmentation dashboard\" width=\"100%\" /\u003e\n          \u003cx-heading-2 :color=\"{ light: globalStyles.colors.heading_57jkqn2y52oo, dark: globalStyles.colors.surface_x1xalnao0t1w }\" :font-size=\"18\" margin=\"12px 0 4px 0\"\u003e\n            Smart Segments\n          \u003c/x-heading-2\u003e\n          \u003cx-paragraph :color=\"{ light: globalStyles.colors.bodytext_izbt7ck8bjqi, dark: globalStyles.colors.surface_x1xalnao0t1w }\" :font-size=\"14\"\u003e\n            Build behavioral segments that update in real time as your customers act.\n          \u003c/x-paragraph\u003e\n        \u003c/x-box\u003e\n      \u003c/x-column\u003e\n      \u003cx-column\u003e\n        \u003cx-box :background=\"{ light: globalStyles.colors.surface_x1xalnao0t1w, dark: globalStyles.colors.brand_risapxu0nl8n }\"\n               :border-radius=\"globalStyles.radius.medium_kt9yq935y5yf\"\n               :padding=\"globalStyles.spacing.large_1x5sc1rox8yb\"\u003e\n          \u003cx-image src=\"https://placehold.co/280x180\" alt=\"A/B testing results chart\" width=\"100%\" /\u003e\n          \u003cx-heading-2 :color=\"{ light: globalStyles.colors.heading_57jkqn2y52oo, dark: globalStyles.colors.surface_x1xalnao0t1w }\" :font-size=\"18\" margin=\"12px 0 4px 0\"\u003e\n            A/B Testing\n          \u003c/x-heading-2\u003e\n          \u003cx-paragraph :color=\"{ light: globalStyles.colors.bodytext_izbt7ck8bjqi, dark: globalStyles.colors.surface_x1xalnao0t1w }\" :font-size=\"14\"\u003e\n            Test subject lines, content, and send times — let the data pick the winner.\n          \u003c/x-paragraph\u003e\n        \u003c/x-box\u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n","email_patterns/abandoned_cart.md":"---\nname: Abandoned Cart Email Patterns\ndescription: What a cart-abandonment or browse-abandonment email looks like in practice — the band-by-band anatomy, the recovered-item block and its Liquid loop, structural variants, real subject and preheader patterns, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Abandoned Cart Emails\n\nAn abandoned-cart email fires hours or days after a shopper leaves items in a\ncart, or after a shopper views a product and never adds it. It reminds the\nreader what they left, removes one objection, and sends them back to checkout\nwith a single button. The reader is expected to click one CTA, not to browse.\n\nThe patterns below come from a 141-email set — the smallest group in this\ncollection. I read subject, category, and tag metadata for all 141, ranked\ndescription metadata for the top 30, rendered screenshots for 20 diverse ones,\nand full HTML for 5. Be aware that description metadata is thin: only 9 of the\n141 records carry a description at all, so every prevalence figure here comes\nfrom the subjects, the categories, and the screenshots I opened, not from\neditorial notes.\n\nThe group mixes two jobs. 127 of 141 are categorized **Abandoned Cart** (items\nleft in a cart) and 16 are **Browse Abandonment** (a product viewed, never\nadded), with 2 carrying both. The two share one anatomy and differ in a single\nband — see § Structural variants.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- Per-item photos link with `x-image href=\"…\"`, never inside an `x-cta`.\n- The primary button says `Continue checkout` or `Checkout` and points at the\n  cart's checkout URL.\n- The preheader names something the subject does not (an item, how long the\n  cart is held), never the same reminder in other words.\n\n## Anatomy\n\n```text\n 1 preheader           ~always   content.preheader_text, 25-90 chars, names the hook\n 2 utility strip       ~25%      thin bar, 11-13px uppercase, brand or dark bg, offer or nav\n 3 logo band           ~always   centered wordmark 120-210px wide, 20-40px padding\n 4 nav link row        ~30%      3-5 uppercase text links under the logo, in an x-wrap\n 5 hero image          ~50%      full-width photo or GIF at content width, sometimes text over it\n 6 headline + subcopy  ~always   the nudge, usually a question; one h1, 24-40px\n 7 incentive line      ~30%      percent off, free shipping, or a code\n 8 primary CTA         ~85%      solid button, 12-16px vertical padding, 4-30px radius\n 9 recovered items     ~75%      THE defining band — product image + name + price\n10 CTA repeat          ~60%      same label again, directly under the item block\n11 reassurance row     ~35%      3-up icons: shipping, returns, payment\n12 recommendations     ~30%      2-up or 3-up \"you may also like\" grid\n13 social proof        ~20%      review quotes, star rating, or a customer photo\n14 footer              ~always   logo, nav, social icons, address, unsubscribe\n```\n\n**Content width.** 109 of the 141 emails put their fixed container at **600px**\nand 13 at 640px. Write `x-section width=\"600px\"` unless the source says\notherwise — `x-section` defaults to 640px, which is 40px too wide on every band.\n\n**Utility strip.** A single line of 11-13px uppercase text, often the incentive\n(\"UNLOCK 60% OFF\", \"Free delivery on orders over $49\"), sometimes a nav run. One\n`x-box` with a brand background and a centered `x-paragraph`. Put the tracking on\nit: `:letter-spacing` is em, so 1.2px on 12px text is `:letter-spacing=\"0.1\"`.\n\n**Headline.** The most consistent band in the group. It is a short question or a\nshort statement in second person, 3-8 words, and it is the email's only\n`x-heading-1`. Roughly a quarter of the emails set it 32-40px and let it wrap to\ntwo or three lines on mobile.\n\n**Recovered-item block.** See the section below. This is what makes the email an\nabandoned-cart email rather than a promotion.\n\n**Reassurance row.** Three icon-plus-caption cells: free shipping, free returns,\na payment option. Build it as an `x-row` with `:layout=\"[33, 34, 33]\"` (layout\npercentages must total 100), not as an `x-wrap`, because the captions wrap.\n\n**Footer.** Longer than in most email types, because these emails carry offer\nfine print. Expect a social run (build it with `x-wrap`, whose `gap` is a\n**string**), an address, and an unsubscribe link.\n\n## The recovered-item block\n\nThis is the band an agent is most often asked to generate, and the one to get\nright. Three shapes cover almost everything I opened.\n\n**A. Image-left, details-right (most common).** A product photo on the left at\nroughly half the content width, and name, price, and quantity on the right. In\nCarta this is one `x-row :layout=\"[50, 50]\" :gap=\"0\"` with two `x-column`s. The\nright column carries a 20-30px left padding and two or three `x-paragraph`s with\n`margin=\"0\"`. Let the name inherit the base font size and color. Set a colour\nonly on the price and quantity line, in a muted gray (`#a1a1a1` in the converted\nexample).\n\n**B. Single centered hero product.** One large product photo at the full content\nwidth, with the name and price centered beneath it and the CTA under that. This\nis **not** an `x-row`. A source table whose outer cells hold filler is a centered\nelement, not a three-column layout.\n\n**C. 2-up or 3-up grid.** Used for recommendations, for multi-item carts, and for\n\"other people also bought\". One `x-row` per grid row, `:layout=\"[50, 50]\"` or\n`:layout=\"[33, 34, 33]\"`, `:gap=\"16\"` to `:gap=\"24\"`, `:break-point=\"480\"` so the\ncells stack.\n\nPrices appear in about half of the item blocks I opened. Quantity appears in\nabout a third. A per-item link or a per-item button appears in about a quarter,\nand it is `x-image href=\"…\"` on the photo, never an image inside an `x-cta`.\n\n**Tell the user the loop is their part.** None of the corpus emails ship a loop.\nEvery one was captured after its ESP expanded the cart data into flat markup, so\nthis file cannot tell you the real field names. Wrap variant A in a Liquid\n`{% for %}`, then say plainly in your reply which cart fields you assumed and\nthat the user must confirm them against their own data. Give every row the same\nbottom margin and take those 30px off the top of the band that follows the loop:\n\n```html\n{% for item in event.cart.items %}\n\u003cx-row :layout=\"[50, 50]\" :gap=\"0\" :break-point=\"481\" margin=\"0 0 30px\"\u003e\n  \u003cx-column\u003e\n    \u003cx-image src=\"{{item.image_url}}\" alt=\"{{item.name}}\" width=\"100%\" /\u003e\n  \u003c/x-column\u003e\n  \u003cx-column :padding=\"{ small: `15px 15px 0`, medium: `0 0 0 30px` }\"\u003e\n    \u003cx-paragraph margin=\"0\"\u003e{{item.name}}\u003c/x-paragraph\u003e\n    \u003cx-paragraph color=\"#a1a1a1\" margin=\"0\"\n      \u003eQuantity: {{item.quantity}} — {{item.price}}\u003c/x-paragraph\n    \u003e\n  \u003c/x-column\u003e\n\u003c/x-row\u003e\n{% endfor %}\n```\n\nDo **not** try to drop the last row's margin with\n`margin=\"{% unless forloop.last %}…{% endunless %}\"`. `forloop` is not in scope\nwhen the renderer resolves an attribute inside the loop body, and\n`POST /emails/:id/render` fails with\n`400 forloop is not defined in \u003cfragment /\u003e`. Interpolation of a loop *variable*\nis fine (`src=\"{{item.image_url}}\"`), a loop *control tag* inside an attribute is\nnot.\n\nKeep the Liquid inside `x-base`. Content before the opening `\u003cx-base\u003e` tag breaks\nthe visual editor (component_syntax.md § Liquid \u0026 Interpolation). Liquid in a plain string\nattribute (`src=\"{{item.image_url}}\"`) is correct — do not switch to `:src`.\n\n## Structural variants\n\n**cart-recap** — the default, and the majority of what I opened. Headline names\nthe cart, the item block reproduces the real line items with quantity and price,\none checkout CTA repeats above and below the block. Pick this whenever the brief\nsays \"cart\" and event data carries line items.\n\n**browse-nudge** — for the browse-abandonment half of the group and for any\nbrief with no cart data. The item block holds the one viewed product, or a 3-up\nof related products, and drops quantity and price. The headline is a question\nabout looking rather than about buying (\"Did you see something you liked?\"). The\nCTA is \"Shop now\" or \"View item\", not \"Checkout\".\n\n**offer-led** — roughly a third of the group. The incentive is the headline and\noften the subject line too: a percent, a dollar amount, free shipping, or a code.\nThe item block moves below the offer and shrinks. Add a fine-print\n`x-paragraph` at 11-12px in the footer, because every offer email in the corpus\ncarries one.\n\n**urgency / low-stock** — smaller, maybe one in eight, but distinctive. The\nheadline is \"almost gone\", \"just a few left\", or \"sold out\", and a stock count\nsits directly under the product photo as a small centered caption. When the item\nsold out, the block becomes a replacement grid: one line of apology, then a 2-up\nor 4-up of alternatives.\n\n## Subject lines\n\nAnonymized, with the shape and punctuation kept exactly.\n\n| Move           | Real patterns                                                                                                                           |\n| -------------- | --------------------------------------------------------------------------------------------------------------------------------------- |\n| Forgetting     | `Forgot something?` · `Did you forget something?` · `Forget something?` · `Forgotten something?`                                        |\n| You left it    | `Hey! You left this behind` · `You left something behind 🛒` · `PSST: You left items in your cart`                                      |\n| We saved it    | `We saved your bag!` · `We saved this cart for you.` · `{{company.name}}. We saved your spot.`                                          |\n| Still deciding | `Still thinking it over?` · `Still deciding?` · `Still not sure?` · `Need more time? Your order is still here (for now).`               |\n| We noticed you | `Did you see something you liked?` · `We noticed you checking us out… 👀` · `We caught you peeping! 👀` · `fyi: you've got good taste`  |\n| Urgency        | `ALMOST GONE: {{product_name}}` · `Just a few left...` · `Last call ⏰` · `The {{product_name}} in your cart sold out`                  |\n| Offer          | `Get 20% OFF Your Order!` · `Free Shipping, Just For You 🕊` · `$5 to pick up where you left off.` · `Complete your order with $15 off` |\n| Absence        | `Where'd you go?` · `Are you still there, {{customer.first_name}}?` · `Circling back`                                                   |\n\nObserved length is short: median 29 characters, mean 31. Roughly one in six\ncarries an emoji, and it sits at the end (🛒 👀 ⏰ 💕) except in the offer\nsubjects, where it leads. About a quarter end in a question mark — the highest\nshare of any email type in this collection. About one in twelve opens with a\nfirst-name token, and every one of those needs the guard:\n`{{customer.first_name | default: \"there\"}}`. Naming the product in the subject\nis common in the urgency variant and rare elsewhere.\n\n## Preheaders\n\n```text\nYou left something in your cart.\nYou left some great stuff behind.\nComplete your purchase for {{company.name}} Pro.\nUse code {{event.discount_code}} at checkout.\nYour cart is waiting for you...\nPick up where you left off. It isn't going anywhere.\nYou looked, you lingered…now, are you ready to claim what's yours?\nFree shipping on orders over $150.\n```\n\nRules the corpus follows. Keep it to 40-90 characters — the short ones pair with\na long subject, the long ones with a two-word subject. Do **not** repeat the\nsubject: only one of the preheaders I extracted restated its subject, and the\nrest either name the incentive, name the code, or extend the sentence the subject\nstarted. When the subject asks a question, the preheader answers it.\n\nFour of the twenty-two preheaders I extracted were not preheaders at all — they\nwere the nav row (\"SHOP LEARN GALLERY SUPPORT\") or \"View In Browser\" leaking into\nthe preview because the email declared none. Always write one.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n```text\nCheckout      Continue checkout · CHECKOUT NOW · Return to Cart · SEE MY CART · VIEW BAG\nFinish        FINISH UP · Finish shopping · Complete your purchase · Complete your order\nShop          Shop now · SHOP · Continue shopping · Take another look · View item\nOffer         UNLOCK 60% OFF · Claim your discount · Get 20% off\nSecondary     Give feedback · Contact us · Shop all\n```\n\nOne to three buttons. The primary appears once above the item block and repeats\ndirectly under it in roughly two thirds of the emails, with the same label both\ntimes — use one label, not two. Labels are 1-3 words. Uppercase labels are about\nas common as sentence case. A secondary button is styled as an outline (border,\ntransparent fill) and never competes: it is help, feedback, or a category link.\n\n## Tone\n\n- Second person, present tense, and the reader's cart is \"yours\" not \"the cart\".\n- Short. One to three sentences of body copy between the headline and the CTA.\n- Interrogative: about a quarter of the subjects and many of the headlines are\n  questions. The question is the whole hook.\n- Blame-free. \"No worries\", \"we saved it for you\", \"maybe it was not the right\n  moment\" — the corpus never scolds the reader.\n- Urgency is stated as fact, not as a countdown: \"going fast\", \"6 LEFT\",\n  \"for now\". Timers are rare in this group.\n- Exclamation marks are less common here than in welcome emails. Questions carry\n  the energy instead.\n- A minority of brands go absurd or jokey in the subject. Match that only when\n  the brand voice in the brief already does.\n- Reassurance copy (returns, shipping, a reply-to invitation) appears in about a\n  third and sits between the item block and the footer.\n\n## Responsive and dark-mode idioms\n\n- One 600px column, boxes carry the band padding. Side padding drops from\n  50-60px to 15-20px on mobile: ``:padding=\"{ small: `40px 15px`, medium: `40px 60px` }\"``.\n- The item row stacks. Give every `x-row` a `:break-point` and set it from the\n  source's own media query, not from a habit.\n- Buttons go full width on mobile. Almost every ESP export in this group carries\n  a `.button-full-width` rule. Express it as\n  ``:width=\"{ small: `100%`, medium: `auto` }\"`` with `text-align=\"center\"`.\n- When a stacked column needs breathing room that the desktop row does not,\n  put it in the second column's responsive padding:\n  ``:padding=\"{ small: `30px 2px 0`, medium: `0 2px` }\"``.\n- Product photos scale to the full viewport width on mobile — `width=\"100%\"` on\n  `x-image`, not a fixed px width.\n- Only 15 of the 141 emails declare a `prefers-color-scheme` rule. The group is\n  effectively light-mode only, so set an explicit `background` on `x-base` and\n  on `x-section`. Do not repeat it on every band — a band only needs its own\n  `background` when the value differs from the one it inherits. A product photo\n  on an implicit white background is the classic dark-mode failure here.\n- Roughly a third of the emails use an animated GIF, usually as the hero.\n  `x-image` carries a GIF unchanged.\n\n## Example: letter-style cart recap\n\nSubject: `How can we help?`\nPreheader: `You left something in your cart.`\n\nThis is the **cart-recap** variant with variant A of the item block, from a\nfurniture brand. It leads with a plain-text letter instead of a hero image,\nwhich suits a considered, high-price purchase. To adapt it: replace the letter\nwith a headline plus two sentences when the price point is lower, wrap the item\nrow in the Liquid loop above, and delete the secondary button row first when the\nbrief wants a single action.\n\n\u003c!-- Converted from a real abandoned-cart exemplar. The conversion was pixel-graded\n     against the source render: desktop 0.912, mobile 0.888. The markup below was\n     then simplified for idiom and accessibility per review, so it no longer\n     reproduces the source pixel for pixel. The score describes the original\n     conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#ffffff\"\n  font-family=\"sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  color=\"#333333\"\n  :mobile-breakpoint=\"481\"\n\u003e\n  \u003cx-section\n    width=\"600px\"\n    padding=\"\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-image\n      src=\"https://placehold.co/960x121\"\n      alt=\"{{company.name}}\"\n      href=\"https://example.com/\"\n      width=\"206px\"\n      align=\"center\"\n      margin=\"0 0 33px\"\n      data-layer-label=\"Logo\"\n    /\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `40px 15px`, medium: `40px 60px` }\"\n      data-layer-label=\"Letter\"\n    \u003e\n      \u003cx-paragraph margin=\"0 0 20px\"\n        \u003eHi {{customer.first_name | default: \"there\"}},\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0 0 20px\"\n        \u003eDo you have any questions about the pieces below?\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0 0 20px\"\n        \u003eWe are happy to help with shipping, sizing or anything else. Feel free\n        to reply directly to this email (we know this is a big\n        decision).\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0 0 20px\"\u003eWarm regards,\u003c/x-paragraph\u003e\n      \u003cx-paragraph margin=\"0 0 20px\"\n        \u003e\u003cstrong\u003eThe design team \u003c/strong\u003e— {{company.name}}\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0 0 20px\"\n        \u003e\u003cstrong\u003eThe support team \u003c/strong\u003e— reply any time\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0\"\u003eCo-Founders at {{company.name}}\u003c/x-paragraph\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#000000\"\n      color=\"#ffffff\"\n      :padding=\"{ small: `40px 15px`, medium: `40px 60px` }\"\n      data-layer-label=\"Cart headline\"\n    \u003e\n      \u003cx-heading-1 :font-size=\"38\" font-weight=\"700\" margin=\"0 0 20px\"\n        \u003eYour cart is ready for checkout.\u003c/x-heading-1\n      \u003e\n      \u003cx-paragraph :font-size=\"20\" margin=\"0\"\n        \u003eItems left in shopping cart:\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-row\n      :layout=\"[50, 50]\"\n      :gap=\"0\"\n      :break-point=\"481\"\n      :padding=\"{ small: `30px 0 0`, medium: `30px 60px 0` }\"\n      data-layer-label=\"Cart item\"\n    \u003e\n      \u003cx-column\u003e\n        \u003cx-image\n          src=\"https://placehold.co/600x600\"\n          href=\"{{event.cart.items.first.url}}\"\n          alt=\"{{event.cart.items.first.name}}\"\n          width=\"100%\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column :padding=\"{ small: `15px 15px 0`, medium: `0 0 0 30px` }\"\u003e\n        \u003cx-paragraph margin=\"0\"\u003e{{event.cart.items.first.name}}\u003c/x-paragraph\u003e\n        \u003cx-paragraph color=\"#a1a1a1\" margin=\"0\"\n          \u003eQuantity: {{event.cart.items.first.quantity}}\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-cta\n      href=\"{{event.cart.checkout_url}}\"\n      background=\"#000000\"\n      color=\"#ffffff\"\n      padding=\"14px 28px\"\n      border-style=\"solid\"\n      border-width=\"3px\"\n      border-color=\"#000000\"\n      border-radius=\"12px\"\n      :width=\"{ small: `100%`, medium: `auto` }\"\n      text-align=\"center\"\n      align=\"left\"\n      :margin=\"{ small: `30px 15px 0`, medium: `30px 60px 0` }\"\n      data-layer-label=\"Checkout CTA\"\n      \u003eContinue checkout\u003c/x-cta\n    \u003e\n\n    \u003cx-row\n      :layout=\"[50, 50]\"\n      :gap=\"0\"\n      :break-point=\"481\"\n      :padding=\"{ small: `30px 15px 0`, medium: `30px 60px 0` }\"\n      data-layer-label=\"Secondary CTAs\"\n    \u003e\n      \u003cx-column padding=\"0 2px\"\u003e\n        \u003cx-cta\n          href=\"https://example.com/\"\n          background=\"transparent\"\n          color=\"#000000\"\n          padding=\"14px 28px\"\n          border-style=\"solid\"\n          border-width=\"3px\"\n          border-color=\"#000000\"\n          border-radius=\"12px\"\n          :font-size=\"14\"\n          :width=\"{ small: `100%`, medium: `auto` }\"\n          text-align=\"center\"\n          align=\"left\"\n          margin=\"0\"\n          \u003eShop everything\u003c/x-cta\n        \u003e\n      \u003c/x-column\u003e\n      \u003cx-column :padding=\"{ small: `30px 2px 0`, medium: `0 2px` }\"\u003e\n        \u003cx-cta\n          href=\"https://example.com/feedback\"\n          background=\"transparent\"\n          color=\"#000000\"\n          padding=\"14px 28px\"\n          border-style=\"solid\"\n          border-width=\"3px\"\n          border-color=\"#b3b3b3\"\n          border-radius=\"12px\"\n          :font-size=\"14\"\n          :width=\"{ small: `100%`, medium: `auto` }\"\n          text-align=\"center\"\n          align=\"left\"\n          margin=\"0\"\n          \u003eGive feedback\u003c/x-cta\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `40px 15px 24px`, medium: `40px 60px 24px` }\"\n      data-layer-label=\"Footer\"\n    \u003e\n      \u003cx-paragraph :font-size=\"14\" margin=\"0 0 20px\"\n        \u003e\u003cstrong\u003e{{company.name}}\u003c/strong\u003e\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph :font-size=\"13\" margin=\"0\"\n        \u003e100 Example Street, Portland, OR 97201\u003c/x-paragraph\n      \u003e\n      \u003cx-wrap gap=\"16px\" align-content=\"left\" margin=\"24px 0 0\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Facebook\"\n          href=\"https://example.com/facebook\"\n          width=\"20px\"\n        /\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Instagram\"\n          href=\"https://example.com/instagram\"\n          width=\"20px\"\n        /\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Pinterest\"\n          href=\"https://example.com/pinterest\"\n          width=\"20px\"\n        /\u003e\n      \u003c/x-wrap\u003e\n      \u003cx-paragraph :font-size=\"14\" margin=\"30px 0 0\"\u003e\n        \u003ca\n          href=\"{% unsubscribe_url %}\"\n          style=\"color: #000000; text-decoration: underline\"\n          \u003eUnsubscribe\u003c/a\n        \u003e\u003cbr /\u003e© {{ \"now\" | date: \"%Y\" }} {{company.name}}\n      \u003c/x-paragraph\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Why the padding numbers are round\n\nAn earlier version of this example carried `39px` and `41px` paddings, a\n`:line-height=\"1\"` on the logo box, and an `x-hidden-text` preheader. The odd\nvalues were one-pixel corrections for the source render. They only existed to\noffset the line box that `x-hidden-text` added and the leading that the inline\nlogo carried. All three are gone, so the paddings are round `40px` values again.\n\nKeep them round when you adapt this example. A padding value that is not a\nmultiple of the band rhythm is almost always a correction for something the\nmarkup no longer contains.\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                                            | Move it to                                                                    |\n| ----------------------------------------------------------------- | ----------------------------------------------------------------------------- |\n| `background=\"#000000\"` on the headline box and the primary `x-cta` | `:background=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"`                           |\n| `color=\"#333333\"` on `x-base`                                     | `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"`                                |\n| `color=\"#ffffff\"` on the headline box                             | `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"` (an on-dark text token)        |\n| `color=\"#a1a1a1\"` on the quantity line                            | `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"` (a muted/secondary text token) |\n| `border-color=\"#b3b3b3\"` on the secondary button                  | `:border-color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"` (a border token)        |\n| `border-radius=\"12px\"` on all three buttons                       | `:border-radius=\"globalStyles.radius.\u003csafeName\u003e_\u003cid\u003e\"`                        |\n| `padding=\"14px 28px\"` on all three buttons                        | the workspace's `button` component token, when one exists                     |\n| `font-family=\"sans-serif\"`                                        | the workspace's brand font, only if the workspace actually loads it           |\n\nWhat to delete first when the brief is simpler:\n\n1. The secondary button row. It is the least common band in the group, and one\n   action converts better.\n2. The letter. Replace the seven paragraphs with a headline and two sentences,\n   and move the `x-heading-1` above the item block.\n3. The `Quantity:` line, when the cart data has no quantity worth showing.\n4. The social `x-wrap`, when a workspace footer custom component already carries\n   one — check `component_definitions` before hand-building any footer.\n","email_patterns/account_lifecycle.md":"---\nname: Account Lifecycle Email Patterns\ndescription: What an account-created, activation-nudge, status-change, deletion-warning, or email-preference message looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, the keep-or-lose choice, and one converted Carta example to adapt.\n---\n\n# Account Lifecycle Emails\n\nAn account lifecycle email marks a change in the state of an account rather than\na change in an order. The account exists now. The account is half set up. The\nplan or the tier moved. The account will close in 30 days unless you sign in.\nYou can stop hearing from us about one topic. In all of those the email's job is\nto state the new state, say what it means, and give one way to act.\n\nThe patterns below come from the account-lifecycle set: **146 emails, and this\nfile reads all 146.** Subject, category and screenshot-dimension metadata for\nall 146, rendered screenshots for 24, full HTML for 1. The crawl that produced\nthe set is complete, so this is the whole account-lifecycle group rather than a\nsample of it. The prevalence figures are still proportions read off metadata and\nscreenshots rather than a parse of every source, so treat them as close, not\nexact.\n\n**The set overlaps its neighbours much more than the first sample showed.** This\nis the largest and loosest of the transactional groups, and completing the crawl\nmade that worse rather than better. 20 of the 146 also carry the corpus's\n`welcome` category, 11 carry `verification`, 7 carry `password-reset` and 7\ncarry `security-phishing` — and **32 of the 146 are literally also members of\nthe account-security group**. A first-message-after-signup belongs in\n**email_patterns/welcome.md**; a verification code or a password reset belongs\nin **email_patterns/account_security.md**. Use this file for everything in\nbetween: the account is already real, and something about it changed.\n\n**Two blocks the first sample was too small to see.** About one in ten of the\nset is an **activity notification** — somebody followed you, somebody sent you a\nthing, a shared folder changed, a token was issued. Another one in ten is a\n**subscription state change** — the plan renewed, the trial ended, the card\nfailed, the store closed, the membership was cancelled. Both are now named\nvariants below, and the second one leans hard on\n**email_patterns/billing_subscription.md** for the money side.\n\n**One block that does not belong here at all.** Four members are ordinary\nnewsletters that the corpus filed under `opt-in`. If the brief is a monthly\nround-up, use **email_patterns/newsletter_digest.md** and ignore this file.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n 1 logo band        ~always  left or centered logo, 20-30px padding; a second slot in ~30%\n 2 store or app nav ~15%     3-5 category links under the logo, on retail and marketplace senders\n 3 hero             ~45%     full-column illustration, or a photo with the word set in type over it\n 4 spot image       ~25%     a single icon or small illustration instead of a hero, 60-200px\n 5 headline         ~70%     one h1, 24-32px, naming the new state\n 6 greeting         ~55%     \"Hi {{customer.first_name}},\" on its own line\n 7 status copy      ~always  2-4 short paragraphs: what changed, what it means, what happens next\n 8 consequence line ~40%     the deadline or the loss: \"scheduled for deletion in 30 days\"\n 9 primary CTA      ~85%     one button carrying the account action\n10 opposite CTA     ~20%     a second button offering the other outcome — see § The keep-or-lose choice\n11 benefit list     ~35%     2-4 icon+text rows, a bulleted x-list, or a category grid\n12 not-you line     ~20%     \"NOT YOU? If this account was not registered by you, follow this link\"\n13 help line        ~45%     \"Questions? We're here to help\", \"contact support\"\n14 social row       ~40%     3-5 linked icons, 20-32px\n15 footer           ~always  address, preferences, unsubscribe — always present in this type\n```\n\n**Logo band.** Always there. Roughly a third carry a second slot on the right —\n\"My account\", \"Privacy\", \"Member login\", \"View in browser\" — which makes the\nband a real two- or three-column `x-row` rather than a centered image. Build the\nrow only when that second slot exists.\n\n**Hero.** A little under half. Illustration still leads, because the subject is\nan abstraction rather than a product, but the larger set narrows the margin:\nproduct-led brands run a full-bleed photograph with a single word — \"Welcome\" —\nset in large type over it, and that is a legitimate reading of the type rather\nthan a category error. Full-column width, no rounding in most cases, and often\nit is the only color in the email.\n\nWhere there is no hero there is frequently a **spot image** instead: one icon or\na small illustration, 60–200px, centered above the headline. A sad monitor for a\ncancellation, a padlock for a security change, a party popper for an activation.\nThe set treats hero and spot as alternatives, so about seven emails in ten carry\none or the other and the rest carry no art at all.\n\n**Headline.** Names the new state in the reader's terms: \"Your account is\nready\", \"Congrats, you're in!\", \"You'll be unsubscribed\", \"You're now Silver\nElite\". Sentence case, 24–32px.\n\n**Status copy.** Two to four paragraphs and always the same order: what changed,\nwhat that means for the reader, and what to do.\n\nMigration and merge emails are the exception, and the larger set shows they have\ntheir own shape rather than just a fourth paragraph. They break the body into\n**question headings with an icon+text row under each** — \"What's the new app?\",\n\"What will happen to my account?\" — and then spell out, one row per asset, what\nsurvives and what does not: \"Miles you've earned: you can keep them\", \"Rewards\nyou've redeemed: you'll lose access on May 1st\", \"Trips you've taken: review\nthem now before they're gone\". A closing \"Stay tuned / no action is needed yet\"\nparagraph sets expectations for the follow-up email. Build the rows as\n`x-row :layout=\"[25, 75]\"` with the icon left and a bold line plus a paragraph\nright, and give them `:break-point` so they stack — unlike the tracking rows in\nshipping, these read fine stacked.\n\n**Consequence line.** The sentence that makes deletion and expiry emails work.\nAlways concrete and always dated — \"scheduled for deletion in 30 days, along\nwith your data\", \"your profile stops showing up\", \"unless you log in\". Keep it\nin the body copy; the corpus never boxes it or colors it red.\n\nThe completed set shows the same sentence doing the same job outside the\ndeletion emails, which is the more useful generalisation: a failed payment says\n\"you still have until May 22 to update your billing information, after which\nwe'll downgrade your account space\"; a username reclaim says \"if we don't hear\nfrom you within two weeks, we'll give you a temporary username\". Any lifecycle\nemail with a default outcome needs this line, not just the ones about deletion.\nOne source puts it in a **separate band below a divider**, under the button\nrather than above it — that works, and it keeps the button from reading as the\nonly option.\n\n**Primary CTA.** One button, the account action, near-universal. Filled in the\nbrand color or in black. Activation nudges repeat the same button a second time\nlower down, and that repetition is normal in this type.\n\n**Benefit list.** In \"account ready\" emails there is usually a block after the\nbutton: two to four benefits as icon plus text, a bulleted list, or a 2×2 grid\nof category tiles with images. Build the first as `x-row` with a narrow first\ncolumn, the second as `x-list`, and the grid as two `x-row`s.\n\n**Not-you line.** One email in five closes the body with the account-security\nidiom, in caps or bold: \"NOT YOU? If this account was not registered by you,\nfollow this link.\" It shows up in account-ready and verification emails, where\nsomebody may have typed the wrong address at signup, and it is the cheapest\nsupport-deflection in the type. Keep it as an inline `\u003ca\u003e` in an `x-paragraph`,\nnever a button.\n\n**Footer.** Unlike account-security, unsubscribe and preference links are\npresent nearly every time — these emails are lifecycle marketing as much as\nnotification.\n\n## Structural variants\n\n**Account ready** (~25%). Logo, hero, headline, two or three paragraphs, one\nbutton, then a benefit block or a category grid, then the footer. The longest\nvariant. Pick this for \"your account is ready\" and \"welcome to your account\"\nwhen the first-touch welcome has already gone out.\n\n**Status change notice** (~20%). Logo, headline, three paragraphs, one quiet\nbutton or an inline link, footer. No hero, or a small icon. Pick this for tier\nchanges, profile merges, plan changes and migrations. The larger set adds a\nregister this variant often takes: the **plain letter** — a greeting, four\nparagraphs, a sign-off from the team, and no art at all above a content grid and\none wide CTA. Price changes and terms changes are almost always written this\nway.\n\n**Activation nudge** (~15%). Logo, headline, one paragraph, one button, a\nproduct screenshot, the same button again, then an upsell band. Pick this when\nthe account exists but the reader never finished setting it up.\n\n**Keep-or-lose** (~10%). Logo, hero, greeting, the consequence paragraph, one\nprominent button, footer. Pick this for deletion warnings, expiry warnings and\ninactivity notices. See § The keep-or-lose choice.\n\n**Preference choice** (~10%). Logo, headline, one paragraph, and two side-by-side\noptions with a picture or an icon over each — one to stay, one to leave. Pick\nthis for opt-out flows and \"mute this campaign\" emails.\n\n**Activity notification** (~10%). A variant the 62-email sample was too small to\nshow. Logo or store nav, a headline that names the actor and the action in the\nreader's own inbox voice (\"ella started following you on Etsy!\", \"Smiles Davis\nand 6 others made changes in your shared folders\"), a circular avatar or a\nthumbnail of the _subject_ of the event, two or three lines of context about\nthat subject, one button that acts on it (\"Follow ella\"), then a three-tile grid\nof related content and a second, weaker button (\"See more of ella's favorites\").\nPick this for social follows, shares, invitations, grants and digest-style\nactivity alerts. The structural point is that the payload is a **person or an\nobject**, not a state, so the email is built around a thumbnail rather than\naround a paragraph.\n\n**Subscription state change** (~10%). Logo, a spot illustration, a headline\nstating the new billing state (\"We're sorry to see you go\", \"Your store has\nclosed\", \"Dropbox payment failed\"), two or three paragraphs carrying the\nconsequence and the date, one recovery button — `Reactivate`, `Update your\nbilling info`, `Re-open your store` — and a footer. There is no hero and no\nbenefit grid: the email is short on purpose. Pick this for cancellations, trial\nendings, failed payments, renewals and expiring cards, and read\n**email_patterns/billing_subscription.md** before you write the money copy — the\namounts, the dunning schedule and the terms language live there.\n\n## Subject lines\n\n| Move                   | Pattern                                                                                 |\n| ---------------------- | --------------------------------------------------------------------------------------- |\n| State, stated flat     | `Your {{company.name}} account is ready`                                                |\n| Congratulation         | `Congrats, you're in!` / `Your application decision`                                    |\n| Activation imperative  | `Please activate your account` / `Complete your profile`                                |\n| Stalled-account nudge  | `Your account is still waiting ⏰` / `Jump back in`                                     |\n| Consequence + deadline | `Your account closes in 30 days unless you login`                                       |\n| Log-in-or-lose         | `Log in to keep your account from being deleted`                                        |\n| Change notice          | `Important change to your account` / `An update regarding your account status`          |\n| Merge or migration     | `Your {{a}} and {{b}} profiles merged!` / `Move {{product}} to your account`            |\n| Confirmation ask       | `Confirm your newsletter subscription`                                                  |\n| Opt-out offer          | `Opt out of Father's Day?` / `How to mute {{campaign}} emails`                          |\n| Unsubscribe warning    | `You'll be unsubscribed from {{company.name}} emails`                                   |\n| Activity, actor first  | `{{actor}} started following you on {{company.name}}` / `{{actor}} sent you a Pin`      |\n| Subscription ended     | `Your {{company.name}} subscription has been canceled` / `Your online store has closed` |\n| Billing state          | `{{company.name}} payment failed` / `Your credit card is going to expire in 29 days!`   |\n| Access granted         | `Access to {{resource}} has been granted` / `Your account has been linked`              |\n| Unsubscribe confirmed  | `{{company.name}}: You are now unsubscribed`                                            |\n\nMost subjects are a plain statement of the new state. The median is 36\ncharacters and the middle half runs 28 to 43, which is a little longer than the\nfirst sample suggested. The deletion and expiry subjects are the exception and\nthe most effective pattern in the set: state the consequence and the deadline in\nthe same line.\n\nTwo figures the larger set corrects downward, both sharply. **Emoji appear in 12\nof the 146**, not a fifth — about 8%, and still mostly on the nudges (⏰ 👀 📲).\n**First-name personalization appears in 7 of the 146**, under one in twenty, not\na quarter. The activity notifications are where most of that personalization\nlives, and there it is the _other_ person's name that carries the subject, not\nthe reader's. Do not reach for `{{customer.first_name}}` in a lifecycle subject\nline just because the type feels personal — the corpus does not.\n\nThe five new rows are the shapes the doubled set added, and four of them share a\nproperty worth noticing: the subject names a thing that already finished. \"has\nbeen canceled\", \"has closed\", \"payment failed\", \"has been granted\", \"you are now\nunsubscribed\". A lifecycle subject that reports a completed state does not need\na verb for the reader, and none of these have one.\n\n## Preheaders\n\n- `Chime was created to help people lead healthier financial lives.`\n- `Start exploring the benefits of your account`\n- `Let's get you settled.`\n- `Congrats, you're in!`\n- `Complete your profile to get tailored content`\n- `A secure step in your health and fitness journey.`\n- `Temporarily adjust your preferences`\n- `We understand if you'd like to press pause`\n- `We strive to make our emails helpful and don't want to clutter your mailbox`\n- `Please review who's using your account, {{customer.email}}`\n- `We hope you'll explore the world with us again soon.`\n- `No action is needed yet — we'll email you again with instructions.`\n- `Recently used {{company.name}} but still received this email?`\n\nRoughly two thirds carry one. It adds the reason behind the state change, or the\nbenefit of acting. The opt-out emails use it to soften the ask, which is the one\nplace in this type where the preheader carries tone rather than fact. Keep it\nunder about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Activate: `Finish account setup` · `Finish enrolling` · `Grab my spot` ·\n  `Take me to my account`\n- Complete: `Complete your profile` · `Complete setup` · `Get started`\n- Keep: `Save account` · `Keep me subscribed` · `Keep profile active` ·\n  `Yes, I want deal alerts` · `I'm still {{customer.username}}`\n- Leave: `Unsubscribe` · `No, stay unsubscribed` · `Let's pause` ·\n  `Delete account`\n- Change: `Learn more` · `Log in to {{company.name}}` · `View statement`\n- Migration: `Get started` · `Move my data` · `Open {{product}}`\n- Recovery: `Reactivate {{company.name}}` · `Update your billing info` ·\n  `Re-open your store` · `Resubscribe`\n- Activity: `Follow {{actor}}` · `See more of {{actor}}'s favorites` ·\n  `View the change`\n\nTwo to four words. The keep button says what the reader keeps, not what the\nbrand wants — \"Save account\" and \"Keep me subscribed\" both name the reader's\npossession. The strongest example in the set goes further and writes the label\nas the reader's own sentence: a username-reclaim email labels its button \"I'm\nstill someone\", answering the question the headline asked. Sentence case,\nsometimes uppercase in the black-button brands, no trailing punctuation.\n\nThe recovery labels are the subscription-state variant's whole vocabulary, and\nthey are always about restoring the thing that stopped, never about buying. A\ncancellation confirmation that says \"Shop plans\" reads as a shakedown. \"Reactivate\"\nreads as a door left open.\n\n## Tone\n\n- Lead with the state, not the process. \"Your account is ready\", not \"We have\n  finished provisioning your account\".\n- Give the deadline as a number of days and a consequence, once, in the body.\n- Second person throughout, and say what happens to the reader's _data_, not to\n  their record. Migration and deletion emails that skip this get replies.\n- Do not sell in a deletion or a preference email. The corpus that does it reads\n  as a hostage note.\n- Preference emails are the one place a light touch works: \"We understand if\n  you'd like to press pause\" is the register.\n- Sign off with a team name in the notice and keep-or-lose variants; the account\n  ready and activation variants do not sign off.\n- Always leave a way back — a help link, a \"not you?\" line, a reply-to.\n- Answer the objection the email will provoke, in the email. The best\n  inactivity notice in the set carries a whole band headed \"Recently used\n  {{company.name}} but still received this email?\" that explains the reader\n  probably has a second account. That band is worth more than another paragraph\n  of persuasion.\n- In an activity notification, name the actor before the verb and never say\n  \"someone\" when you have the name. The subject line and the headline should\n  carry the same name.\n- In a subscription state change, say what does **not** happen too — \"nothing\n  has been lost\", \"none of your files will be deleted\". The reader's first fear\n  is data loss, and one clause defuses it.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. This set runs from 480px to 700px, so read the\n  source's own `@media` boundary and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to\n  match it. Do not assume 600.\n- ESP exports of this type often carry **two** breakpoints — a layout one around\n  600–700px and a smaller one that hides a divider or a badge below 480px. Carta\n  has one. Map the layout breakpoint and fold the small one into it with\n  `hide=\"small\"`, then say in the report which range now differs. **`hide` is an\n  enum, not a boolean**: `hide=\"small\"` means \"hidden below the breakpoint\".\n  `:hide=\"{ small: true }\"` is a compile error.\n- The two- and three-column header stacks on mobile, so the right-hand slot needs\n  its own ``:margin=\"{ small: `18px 0 0 0`, medium: `0` }\"`` to keep the gap it had\n  when it was beside the logo.\n- Body copy is often held to a fixed measure inside a wider band — a 536px `div`\n  inside a 648px column. Reproduce it with `\u003cx-box width=\"536px\"\u003e`, not by adding\n  side padding, or the button below it moves.\n- Explicit backgrounds on `x-base`, on `x-section`, and on any band whose fill\n  differs from the one it inherits — especially the dark footer band these\n  emails favor. A black footer that loses its background inverts to black text\n  on black.\n- Illustrations are usually flat PNGs with a solid ground, so they survive dark\n  mode. Wordmarks do not — use ``x-image :src=\"{ light: `…`, dark: `…` }\"``.\n\n## The keep-or-lose choice\n\nDeletion warnings, inactivity notices, and preference emails all end in the same\nband: the reader chooses between two outcomes, and the email has to make both of\nthem visible. Three renderings recur.\n\n**One button, consequence in the copy** (most common). A single prominent\nbutton — \"Save account\", \"Keep me subscribed\" — and the losing outcome stated in\nthe paragraph above it. This is the safest shape and the one the example below\nuses. The reader who does nothing loses by default, so the copy has to say so\nexplicitly.\n\n**Two buttons, unequal weight.** \"Yes, I want deal alerts\" filled, next to \"No,\nstay unsubscribed\" outlined. Build them as one `x-wrap` of two `x-cta`s so the\npair never wraps oddly:\n\n```html\n\u003cx-wrap gap=\"12px\" align-content=\"center\"\u003e\n  \u003cx-cta\n    href=\"{{event.keep_url}}\"\n    background=\"#0b3b2f\"\n    color=\"#ffffff\"\n    padding=\"14px 22px\"\n    border-radius=\"24px\"\n    margin=\"0\"\n    \u003eYes, keep my alerts\u003c/x-cta\n  \u003e\n  \u003cx-cta\n    href=\"{% unsubscribe_url %}\"\n    background=\"\"\n    color=\"#0b3b2f\"\n    border-style=\"solid\"\n    border-width=\"1px\"\n    border-color=\"#0b3b2f\"\n    padding=\"14px 22px\"\n    border-radius=\"24px\"\n    margin=\"0\"\n    \u003eNo, stay unsubscribed\u003c/x-cta\n  \u003e\n\u003c/x-wrap\u003e\n```\n\n**Two illustrated options.** A two-column `x-row`, each column holding an image,\na one-word heading (\"Stay\" / \"Leave\"), one line of copy, and a text link. This\nis the preference-choice variant and it reads as neutral, which is the point.\nGive the columns `:break-point=\"480\"` so they stack.\n\n**Two outcomes in separate bands.** The rendering the larger set added, and the\nmost honest of the four. Each outcome gets its own full-width band with its own\nbackground color, its own heading, its own paragraph and its own button, stacked\ndown the email: a white \"Help keep your data safe\" band with `Open {{product}}`,\nthen a black \"It's OK to bail\" band with `Delete account`, then a third band for\n\"Need help?\". Nothing is side by side, so nothing competes for width on a phone,\nand the losing outcome is impossible to miss. Build it as consecutive `x-box`es\nwith explicit `background` values — this is the shape where a missing band\nbackground does the most damage in dark mode, because the whole design is\ncarried by the alternation.\n\nUse it when the two outcomes are genuinely equal and the brand can afford to say\nso. Use one button and a consequence line instead when the brand would rather\nthe reader stayed.\n\n**The downgrade offer.** A fifth shape, and the only one that appears _after_\nthe choice has been made. An unsubscribe-confirmed email leads with a soft\nsign-off (\"Safe travels.\"), then offers a lesser frequency as the primary button\n— `Subscribe to weekly` — and an undo as a visually weaker secondary —\n`Resubscribe`. Two stacked full-width `x-cta`s, unequal in color, with a line of\ncopy above each. It converts a total loss into a partial one and it gives the\nmisclick a way back. Propose it whenever the brief is \"confirm the\nunsubscribe\".\n\nLiquid worth proposing:\n\n| Field                   | Token                                             |\n| ----------------------- | ------------------------------------------------- |\n| Days remaining          | `{{event.days_until_close}}`                      |\n| Deletion or expiry date | `{{event.closes_at \\| date: \"%B %-d, %Y\"}}`       |\n| Last activity           | `{{customer.last_seen_at \\| date: \"%B %-d, %Y\"}}` |\n| Account or member tier  | `{{customer.tier}}`                               |\n| Keep / confirm link     | `{{event.keep_url}}`                              |\n| Preference centre       | `{% manage_subscription_preferences %}`           |\n| Unsubscribe             | `{% unsubscribe_url %}`                           |\n| Plan or subscription    | `{{customer.plan_name}}`                          |\n| Renewal or charge date  | `{{event.renews_at \\| date: \"%B %-d, %Y\"}}`       |\n| Failed amount           | `{{event.amount_due}}`                            |\n| Activity actor          | `{{event.actor_name}}`, `{{event.actor_avatar}}`  |\n| Activity subject        | `{{event.object_name}}`, `{{event.object_url}}`   |\n\n## Example: inactivity and deletion warning\n\nSubject: `Your account closes in 30 days unless you sign in`\nPreheader: `We have not seen you in a while — sign in to keep your account and your data.`\n\nThis is the **keep-or-lose** variant: a three-slot header row, a full-bleed\nillustration, a large light-weight greeting, five short paragraphs ending in a\nsign-off, one square black button with an arrow glyph inside it, and a dark\nfooter built from two `x-row`s around a hairline rule. Change the illustration,\nthe five paragraphs and the button label first.\n\nThe completed set moves keep-or-lose from ~15% of the group to ~10%, so this\nexample is a smaller slice of the type than it was — but it stays, for two\nreasons. It is the only pixel-exact conversion in the file, and its skeleton\n(header row, hero, greeting, paragraph stack, one button, dark two-row footer)\nis the skeleton of the account-ready and status-notice variants too, which are\ntogether nearly half the group. § What to change for the other variants below\nwalks that translation. If the brief is an activity notification or a\nsubscription state change — the two variants the larger set added — this example\nis the wrong starting point: both are shorter, both are built around a thumbnail\nor a single sentence rather than a paragraph stack, and neither has a hero.\n\n\u003c!-- Converted from a real inactivity-warning exemplar. The conversion was\n     pixel-graded against the source render: score 1.000, with 0.01% of desktop\n     pixels and 0.00% of mobile pixels differing. The source loads four web\n     fonts from an S3 bucket that now returns 403, so the source itself renders\n     the fallback, and the conversion drops those font names and keeps the rest\n     of each stack. The source's second breakpoint (a rule below 480px that hides\n     the header divider) is folded onto the single Carta breakpoint with\n     hide=\"small\", so the divider is hidden between 481 and 699px where the\n     source still shows it. The x-wrap in the footer carries margin=\"-15px 0\"\n     because x-wrap adds half its gap as vertical margin on every item. The\n     markup below was then simplified for idiom and accessibility per review, so\n     it no longer reproduces the source pixel for pixel. The score describes the\n     original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#d6d6d5\"\n  font-family=\"'HelveticaNeue-Light', 'Helvetica Neue Light', Helvetica, Arial, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.75\"\n  color=\"#595959\"\n  :mobile-breakpoint=\"700\"\n\u003e\n  \u003cx-section\n    width=\"700px\"\n    padding=\"\"\n    background=\"#ffffff\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-row\n      :layout=\"[11.574, 7.716, 80.71]\"\n      :gap=\"0\"\n      :break-point=\"700\"\n      :padding=\"{ small: `32px 26px 26px`, medium: `26px` }\"\n      :font-size=\"0\"\n      :line-height=\"1\"\n      data-layer-label=\"Header\"\n    \u003e\n      \u003cx-column vertical-align=\"middle\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/150x32\"\n          alt=\"{{company.name}} logo\"\n          width=\"75px\"\n          align=\"left\"\n          style=\"display: block\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"middle\" padding=\"0 0 0 2px\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/11x25\"\n          alt=\"\"\n          width=\"11px\"\n          align=\"center\"\n          hide=\"small\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"middle\"\u003e\n        \u003cx-paragraph\n          :font-size=\"16\"\n          :line-height=\"1.75\"\n          color=\"#717172\"\n          :margin=\"{ small: `18px 0 0 0`, medium: `0` }\"\n          \u003ePrivacy\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/697x200\"\n      alt=\"\"\n      width=\"100%\"\n      data-layer-label=\"Hero\"\n    /\u003e\n\n    \u003cx-box padding=\"16px 26px 39px\" data-layer-label=\"Body\"\u003e\n      \u003cx-heading-1\n        :font-size=\"28\"\n        color=\"#000000\"\n        font-weight=\"normal\"\n        margin=\"0 0 46px 0\"\n        \u003eHi {{customer.first_name | default: \"there\"}},\u003c/x-heading-1\n      \u003e\n      \u003cx-box width=\"536px\" data-layer-label=\"Copy\"\u003e\n        \u003cx-paragraph margin=\"0 0 16px 0\"\n          \u003eYou have not used your account in a long time, so we want to confirm\n          whether you still want it.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0 0 16px 0\"\n          \u003eFor your protection, your account is scheduled for deletion in\n          {{event.days_until_close | default: 30}} days, along with your data.\n          If you want to keep your account, sign in with the app or at\n          example.com.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0 0 16px 0\"\n          \u003eYou can read more about our deletion process\n          \u003ca\n            style=\"color: #266ef1; text-decoration: none\"\n            href=\"https://example.com/help/account-deletion\"\n            \u003ehere\u003c/a\n          \u003e.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0 0 16px 0\"\n          \u003eThanks for being part of the community. We hope you will be back with\n          us again soon.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0 0 48px 0\"\u003eThe {{company.name}} team\u003c/x-paragraph\u003e\n      \u003c/x-box\u003e\n      \u003cx-cta\n        href=\"https://example.com/account/keep\"\n        background=\"#000000\"\n        color=\"#ffffff\"\n        padding=\"13px 16px\"\n        border-radius=\"0px\"\n        font-family=\"Helvetica, Arial, sans-serif\"\n        :font-size=\"14\"\n        :line-height=\"1.4285714\"\n        :letter-spacing=\"0.0714286\"\n        text-transform=\"uppercase\"\n        align=\"left\"\n        margin=\"0\"\n        data-layer-label=\"CTA\"\n        \u003esave account\u003cimg\n          src=\"https://placehold.co/32x24\"\n          width=\"16\"\n          height=\"12\"\n          alt=\"\"\n          style=\"\n            margin-left: 16px;\n            vertical-align: -1px;\n            border: none;\n            outline: none;\n            text-decoration: none;\n          \"\n      /\u003e\u003c/x-cta\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#000000\"\n      color=\"#ffffff\"\n      padding=\"41.44px 26px 60px\"\n      data-layer-label=\"Footer\"\n    \u003e\n      \u003cx-row\n        :layout=\"[51.6975, 48.3025]\"\n        :gap=\"0\"\n        :break-point=\"700\"\n        :font-size=\"0\"\n        :line-height=\"1\"\n      \u003e\n        \u003cx-column vertical-align=\"top\" padding=\"40px 0 0 0\"\u003e\n          \u003cx-image\n            src=\"https://placehold.co/180x40\"\n            alt=\"{{company.name}}\"\n            width=\"90px\"\n            align=\"left\"\n          /\u003e\n        \u003c/x-column\u003e\n        \u003cx-column vertical-align=\"top\" padding=\"40px 0 0 0\"\u003e\n          \u003cx-wrap gap=\"30px\" align-content=\"left\" margin=\"-15px 0\"\u003e\n            \u003cx-image\n              src=\"https://placehold.co/50x40\"\n              alt=\"Facebook\"\n              width=\"25px\"\n              href=\"https://facebook.com/example\"\n            /\u003e\n            \u003cx-image\n              src=\"https://placehold.co/50x40\"\n              alt=\"X\"\n              width=\"25px\"\n              href=\"https://x.com/example\"\n            /\u003e\n            \u003cx-image\n              src=\"https://placehold.co/50x40\"\n              alt=\"Instagram\"\n              width=\"25px\"\n              href=\"https://instagram.com/example\"\n            /\u003e\n            \u003cx-image\n              src=\"https://placehold.co/50x40\"\n              alt=\"LinkedIn\"\n              width=\"25px\"\n              href=\"https://linkedin.com/company/example\"\n            /\u003e\n          \u003c/x-wrap\u003e\n        \u003c/x-column\u003e\n      \u003c/x-row\u003e\n\n      \u003cx-hr :height=\"1\" background-color=\"#333333\" margin=\"40px 0 0 0\" /\u003e\n\n      \u003cx-row :layout=\"[51.6975, 48.3025]\" :gap=\"0\" :break-point=\"700\"\u003e\n        \u003cx-column vertical-align=\"top\" padding=\"30px 0 0 0\"\u003e\n          \u003cx-paragraph :font-size=\"14\" :line-height=\"2\" margin=\"0\"\n            \u003e\u003ca\n              href=\"https://example.com/help\"\n              style=\"color: #ffffff; text-decoration: none\"\n              \u003eGet help\u003c/a\n            \u003e\u003c/x-paragraph\n          \u003e\n          \u003cx-paragraph :font-size=\"14\" :line-height=\"2\" margin=\"0\"\n            \u003e\u003ca\n              href=\"{% unsubscribe_url %}\"\n              style=\"color: #ffffff; text-decoration: none\"\n              \u003eUnsubscribe\u003c/a\n            \u003e\u003c/x-paragraph\n          \u003e\n        \u003c/x-column\u003e\n        \u003cx-column vertical-align=\"top\" color=\"#9d9da3\"\u003e\n          \u003cx-paragraph\n            font-family=\"Helvetica, Arial, sans-serif\"\n            :font-size=\"12\"\n            :line-height=\"1.5\"\n            margin=\"30px 0 0 0\"\n            \u003e\u003cbr /\u003e{{company.name}}\u003cbr /\u003e123 Example Street\u003cbr /\u003eAnytown, CA\n            90210\u003c/x-paragraph\n          \u003e\n          \u003cx-paragraph\n            font-family=\"Helvetica, Arial, sans-serif\"\n            :font-size=\"12\"\n            :line-height=\"2.1666667\"\n            margin=\"6px 0 0 0\"\n            \u003e\u003ca\n              href=\"https://example.com/privacy\"\n              style=\"color: #ffffff; text-decoration: none\"\n              \u003ePrivacy\u003c/a\n            \u003e\u003cbr /\u003e\u003ca\n              href=\"https://example.com/terms\"\n              style=\"color: #ffffff; text-decoration: none\"\n              \u003eTerms\u003c/a\n            \u003e\u003c/x-paragraph\n          \u003e\n        \u003c/x-column\u003e\n      \u003c/x-row\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                         | Token to use instead                                      |\n| ---------------------------------------------- | --------------------------------------------------------- |\n| `background=\"#d6d6d5\"` on `x-base`             | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"`   |\n| `background=\"#ffffff\"` on `x-section`          | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`          |\n| `background=\"#000000\"` on the footer box       | `:background=\"globalStyles.colors.footerBackground_\u003cid\u003e\"` |\n| `color=\"#595959\"` on `x-base`                  | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`              |\n| `color=\"#000000\"` on the heading               | `:color=\"globalStyles.colors.headingText_\u003cid\u003e\"`           |\n| `color=\"#ffffff\"` on the footer box            | `:color=\"globalStyles.colors.onDark_\u003cid\u003e\"`                |\n| `color=\"#9d9da3\"` on the footer address column | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`             |\n| `color=\"#266ef1\"` on the inline link           | `:color=\"globalStyles.colors.link_\u003cid\u003e\"`                  |\n| `background=\"#000000\"` on the `x-cta`          | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`          |\n| `background-color=\"#333333\"` on the `x-hr`     | `:background-color=\"globalStyles.colors.border_\u003cid\u003e\"`     |\n| `padding=\"26px\"` on the header row             | `:padding=\"globalStyles.spacing.medium_\u003cid\u003e\"`             |\n| `font-family` on `x-base`                      | `font-family=\"\"` when a global style already sets it      |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to change for the other variants.** For **account ready**, replace the\nconsequence paragraph with two benefit paragraphs and add a 2×2 `x-row` grid of\ncategory tiles under the button. For **activation nudge**, repeat the `x-cta`\nafter a product screenshot. For **preference choice**, replace the single\n`x-cta` with a two-column `x-row` of illustrated options from § The\nkeep-or-lose choice. For an **activity notification**, replace the hero with a\ncentered avatar `x-image`, cut the paragraph stack to two lines about the actor,\nand add a three-column `x-row` of content tiles plus a second `x-cta` below the\nfirst. For a **subscription state change**, delete the hero and the header's\nextra slots, put a spot illustration above the heading, cut to three paragraphs\nand relabel the button to a recovery verb.\n\n**What to cut when the brief is simpler.** Delete the hero `x-image`, drop the\nheader row down to a single centered `x-image`, and replace the two footer\n`x-row`s with one `x-box` holding the address and the links. That leaves the\n**status change notice**, which is the shortest shape in the set.\n\n**Three numbers in this example are load-bearing.** `41.44px` reproduces an\nempty `\u003ch1\u003e`'s self-collapsing `0.67em` margin in the source; `[11.574, 7.716,\n80.71]` reproduces a header grid that is not a clean percentage — `x-row`\naccepts fractional layout values and emits them verbatim; and `margin=\"-15px 0\"`\non the `x-wrap` cancels the vertical half of its own `gap`. When you write a new\nemail rather than convert one, use round numbers and delete all three.\n","email_patterns/account_security.md":"---\nname: Account Security Email Patterns\ndescription: What a password-reset, email-verification, one-time-passcode, or new-sign-in email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Account Security Emails\n\nAn account security email carries one fact and one action: someone asked to\nreset a password, an address needs verifying, a code is needed to finish a\nsign-in, or an unfamiliar device just signed in. The reader is mid-task or\nalarmed, so the notice itself is the plainest thing in the corpus. No hero\nphotography, no offer, one accent color, and a footer that carries a help link\ninstead of a sale. Retail brands break that last rule and only that one — they\nkeep the notice plain and put the store underneath it.\n\nThe patterns below come from the account-security set: **115 emails, and this\nfile reads all 115.** Subject, category and screenshot-dimension metadata for\nall 115, rendered screenshots for 25, full HTML for 3. The crawl that produced\nthe set is complete, so this is the whole account-security group rather than a\nsample of it. The prevalence figures are still proportions read off metadata and\nscreenshots rather than a parse of every source, so treat them as close, not\nexact.\n\nAn earlier version of this file read 45 of these emails. Doubling the evidence\nmoved almost nothing in the anatomy — the type is as consistent as it looked —\nbut it did sharpen two tails, and both matter when you pick a variant.\n\n**Tail one: security-themed marketing.** 19 of the 115 are not transactional at\nall (a password-manager vendor's thought-leadership piece, a \"four simple steps\nto a safer account\" explainer, a passkeys launch, two loyalty promos from an\nidentity brand). That is about one in six, and it is the same ratio the 45-email\nsample showed, so the mix is real rather than an artifact of a small set. Those\nfollow the newsletter anatomy, not this one.\n\n**Tail two: the retail store footer.** A separate group — Wayfair, Zappos,\nJet.com, Postmates and a few more — send a genuinely transactional reset or\nverification and then hang a full store below it: a category nav band above the\nnotice, a two- or three-tile promo grid under the button, app-download badges, a\nbenefits strip, a social row and a marketing footer. The security notice itself\nis unchanged and occupies the top third. This shape was invisible at 45 emails\nand is about 8% of the set. See § Structural variants → Retail reset.\n\nThe anatomy below describes the ~96 transactional emails.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n 1 logo band       ~always   centered or left logo, 20-32px padding, white or one brand-color bar\n 2 store nav       ~10%      3-5 category links under the logo — retail brands only\n 3 spot image      ~35%      a small illustration or icon, 40-200px, centered — never a photo\n 4 headline        ~75%      one h1/h2, 20-30px: \"Reset your password\", \"New sign-in detected\"\n 5 greeting        ~50%      \"Hi {{customer.first_name}},\" on its own line\n 6 body copy       ~always   1-3 short paragraphs, 14-16px, stating what happened\n 7 code or details ~55%      the OTP as large bold text, or a label/value list of sign-in facts\n 8 primary CTA     ~60%      one button; absent by design in code-only emails and in some resets\n 9 fallback link   ~20%      \"Can't click the button? Copy and paste this link\" plus the raw URL\n10 expiry line     ~50%      \"This link is valid for 24 hours\", \"The code expires in 10 minutes\"\n11 if-this-wasn't-you ~60%   a paragraph with a \"change your password\" / \"let us know\" link\n12 divider         ~35%      1-3px rule between the notice and the fine print\n13 fine print      ~45%      12-14px muted paragraph explaining why the alert exists\n14 store tail      ~8%       promo grid, benefits strip, app badges — retail brands only\n15 social row      ~35%      3-5 linked icons, 28-40px\n16 footer          ~always   address and a help or contact link; unsubscribe often absent\n```\n\n**Logo band.** Almost every email opens with one, and it is the only branding\nin the message. Roughly a third put the logo on a filled brand-color bar\n(Zoom's blue, Grubhub's orange, Hulu's black); the rest sit on white. Left and\ncentered are about equally common.\n\n**Spot image.** When there is art it is a flat illustration or a single icon —\na shield, a key, a padlock, a face-ID spot, a check badge. It sits above the\nheadline at 40–200px wide and centered. Photography appears in only one email\nin the set, and that one is an email-verification message doing double duty as\na brand welcome. **Do not add a hero image to a security email.**\n\n**Headline.** Present in about three in four, and it restates the subject line\nalmost verbatim. Sentence case, 20–30px, one line. Password-changed\nconfirmations often add an exclamation mark (\"Password updated!\"); nothing else\nin the type does. A minority phrase the headline as a question instead of a\nstatement — \"Did you log in from a new device?\", \"Trouble logging in?\" — and\nthose always answer it in the first sentence below.\n\n**Body copy.** One to three paragraphs, short. The first sentence names the\nevent and the account: \"Someone requested that the password for your account be\nreset.\" \"We noticed an unfamiliar sign-in to your account using\nhello@example.com.\" Reset-request emails add \"if you did not request this, you\ncan ignore this email\" almost every time.\n\n**Code or details block.** This is the band that makes the type. See § The code\nand the detail block below.\n\n**Primary CTA.** One button, never two. Most reset and verification emails have\none; one-time-passcode emails almost never do, because the code is the payload\nand a button would compete with it. The button is a solid fill in the one brand\ncolor the email uses, 12–16px vertical padding, and a small radius — 3px to 5px\nis more common here than a pill.\n\nThe larger set corrects one claim the 45-email sample supported. A reset does\n**not** always carry a button: Are.na's reset and Netflix's password-changed\nnotice put the action in a plain inline `\u003ca\u003e` in the body copy and have no\n`x-cta` at all, and several sign-in alerts do the same. If the brief is a bare\nnotice, an inline link is a legitimate reading of the type, not a mistake.\n\n**Fallback link.** About one email in five prints the destination URL as text\ndirectly under the button — \"Can't click the button above? Copy and paste this\nlink into your browser:\" followed by the raw address. It is centered, 12–14px\nand muted. It exists because a security link that fails silently generates a\nsupport ticket, so keep it when you convert a source that has it, and add it\nwhen the brief is a code or an authorization the reader must complete on the\nsame device.\n\n**If-this-wasn't-you paragraph.** Near-universal in sign-in and\npassword-changed emails, and it is where the second and third links live\n(\"change your password\", \"enable two-factor authentication\", \"contact\nsupport\"). Keep them as inline `\u003ca\u003e` inside `x-paragraph`, not as buttons —\nthat is what every source does.\n\n**Footer.** A postal address and a help or contact link. Unsubscribe is missing\nfrom roughly half, because these are transactional. When you write a new one,\nfollow the sending workspace's policy rather than copying the source.\n\n## Structural variants\n\n**Action** (~40%). Logo band, optional icon, headline, one paragraph, one solid\nbutton, an expiry line, then a reassurance line. The shortest emails in the\ncorpus are this variant — several are under 550px tall. Pick this for password\nresets and email verification. At 115 emails this is the largest variant, not\nthe second largest; verification asks alone are a third of the set.\n\n**Notice** (~25%). Logo band, headline, one or two paragraphs, the detail\nblock, an \"if this wasn't you\" paragraph, footer. No button at all, or one weak\none. Pick this for new-sign-in alerts, password-changed confirmations, and\naccount-change notices — anything where the email reports something that already\nhappened.\n\n**Code** (~8%). Logo band, one paragraph, the code as large bold text, an\nexpiry line, a \"if you didn't request this, ignore this email\" line, footer.\nNo button. Often no headline either. Pick this for one-time passcodes and\nconfirmation codes. The code sometimes sits in a tinted rounded card with the\napp icon above it rather than loose on the white body — that is a box, not a new\ncomponent.\n\n**Retail reset** (~8%). The action or notice skeleton in the top third, then a\nstore: a category nav band above the notice, a two- or three-tile promo grid, a\nbenefits strip (\"Free shipping over $35 · Free returns · 24/7 service\"),\napp-download badges, a social row and a full marketing footer with legal fine\nprint. Wayfair, Zappos and Jet.com all send this. Pick it only when the sending\nbrand is a retailer and the workspace's own reset template already looks like\nthis — do not add a store to a security email that did not have one. Build the\ntail from **email_patterns/promotion_sale.md** § Product grid; the notice half\nis unchanged.\n\n**Security explainer** (~17%). A numbered or bulleted list of security advice\nwith an icon per row, a closing sign-off, and a full marketing footer. This is\nthe marketing tail of the set and it is larger than the first sample suggested.\nPick this only when the brief really is educational, and build it from the\nnewsletter anatomy instead.\n\n## Subject lines\n\n| Move                         | Pattern                                                                                    |\n| ---------------------------- | ------------------------------------------------------------------------------------------ |\n| Event, stated flat           | `New sign-in detected` / `New device sign-in`                                              |\n| Event plus product           | `New login to your {{company.name}} account`                                               |\n| Imperative reset             | `Reset password instructions` / `{{company.name}} Password Reset`                          |\n| Completed-state confirmation | `Your password has been changed` / `Your account has been updated`                         |\n| Verification ask             | `Verify your email` / `Verify your email address on {{company.name}}`                      |\n| Code in the subject          | `{{company.name}} Confirmation Code: 650500`                                               |\n| Code, described              | `Your one-time passcode` / `Code for signing in to {{company.name}}`                       |\n| Change notice                | `Important change to your account`                                                         |\n| Brand-voice reset            | `Let's Get You a Fresh Password` / `Nailed It! Your password has been updated.`            |\n| Authorization ask or grant   | `{{app}} is requesting access to your account` / `New {{company.name}} API token issued`   |\n| Capability granted to others | `Someone can now Tweet from your @{{customer.handle}} account.`                            |\n| The device as the subject    | `Your {{company.name}} Account - New Device` / `Details About Your Recent Sign-In`         |\n| Collaborative safety framing | `Help us keep your account safe with a security check`                                     |\n| Sender or work-address check | `Verify Your Sender Address: \"{{customer.first_name}}\"` / `Verify your work email address` |\n\nSubjects are short — the median is 32 characters and the middle half runs 23 to\n40 — and 81 of the 115 contain one of six words: password, sign-in, log-in,\nverify, code, account. Emoji appear in 2 subjects out of 115, both in the\nmarketing tail. Personalization appears in 3 and never carries the meaning.\nPutting the literal code in the subject line shows up in about half the code\nemails, and it is worth doing: it lets the reader copy the code from the inbox\nlist without opening anything.\n\nThe four new rows above are the shapes the larger set added. Authorization is\nthe biggest of them: a growing block of these emails is not about a password at\nall but about a device, an app, an API token or another person being granted\naccess to the account. They read as notices, they carry the detail block, and\ntheir CTA is an approval verb rather than a reset verb.\n\n## Preheaders\n\n- `Someone requested that the password for your account be reset.`\n- `Did you forget your password? Create a new one by clicking the button below.`\n- `We just need to make sure it's really you.`\n- `Verify your email address to continue using {{company.name}}.`\n- `Activate your account by verifying your email address.`\n- `650500 is your {{company.name}} verification code`\n- `Your account has been signed into from a new device.`\n- `We're just making sure this was you`\n- `We've changed your password, as you asked.`\n- `Need a helping hand? Visit our Help Center`\n- `A device or location we haven't seen before is requesting access.`\n- `If {{customer.first_name}} isn't you, don't click anything in this email.`\n- `Await your parcel of new account details.`\n\nRoughly two thirds carry one, which is a higher rate than most types — a\nsecurity email is read from the inbox list, so the preheader is doing real\nwork. It restates the event in one plain sentence, or repeats the code. Keep it\nunder about 90 characters and never make it cute.\n\nOne source does something worth knowing about: it renders the warning as a\n**visible** bar across the top of the email rather than as hidden preheader\ntext — a thin dark band carrying \"If {{customer.first_name}} isn't your handle,\ndon't click anything in this email!\" above the logo. Build that as a real\n`x-box` with a background, not as `x-hidden-text`, and set the message\npreheader separately.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Reset: `Reset your password` · `Create new password` · `Reset password` ·\n  `Change my password`\n- Verify: `Verify email` · `Confirm my account` · `Verify your email address`\n- Review: `Review activity` · `Check your account` · `Secure my account`\n- Approve, in the first person: `I authorize this computer` ·\n  `Yes, this was me` · `Approve this device`\n- Deny, in the first person: `I didn't do this — review my account` ·\n  `This wasn't me` · `Report this activity`\n- Weak second action, as an inline link: `change your password` ·\n  `enable two-factor authentication` · `let us know` · `contact Support`\n\nTwo to four words, sentence case, and the label repeats the headline's verb.\nThere is no persuasion here and no \"Get started\". One button per email is the\nrule, and the corpus keeps it: in the whole set only the security-explainer and\nretail-reset emails carry two, and in the retail resets the second button is\nselling something rather than securing anything.\n\nThe first-person labels are the shape the larger set added, and they belong to\nsign-in and device-authorization alerts. The button carries the reader's\nstatement about what happened, not the brand's instruction — \"I authorize this\ncomputer\", \"I didn't do this — review my account\". Pick the framing that matches\nthe risk: approve when the reader is mid-flow and waiting, deny when the email\nis an unsolicited alert and the safe path is to escalate.\n\n## Tone\n\n- State the event first, in the first sentence, in the past tense.\n- Second person, no marketing voice. Food and delivery brands are the only ones\n  that loosen up, and even then only in the headline.\n- Name the account by the address it belongs to. Half the sign-in emails print\n  the recipient's own email address in the first paragraph.\n- Always give the reader the \"if this wasn't you\" path, even when the email is\n  a confirmation of something benign.\n- Say how long the link or the code lasts. Half the corpus does, and the ones\n  that do not are the ones users write in about.\n- No exclamation marks except in password-changed confirmations.\n- Never link the phrase \"click here\" — the sources link the verb.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. These sources cluster at 480px and 600px. Read\n  the source's own `@media` boundary and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e`\n  to match it. Do not assume 600.\n- The most common mobile rule is a font-size bump on the body copy — 14px on\n  desktop growing to 16px on mobile, with the line height going 17px to 24px.\n  Carta expresses the size as `:font-size=\"{ small: 16, medium: 14 }\"` but\n  **`line-height` takes no responsive object**, so pick the ratio that matters\n  most and set it statically. That is a real limit, not an oversight.\n- Side padding collapses next: 30–40px on desktop to 15–20px on mobile.\n  ``:padding=\"{ small: `20px`, medium: `0 40px 30px 40px` }\"``.\n- Nothing else stacks. These emails are single-column by construction, so the\n  `x-row`/`:break-point` machinery rarely appears.\n- Explicit backgrounds on `x-base` and on every band whose fill differs from the\n  one it inherits, including the white ones. A security email that inverts badly\n  in dark mode looks like a phishing attempt, which is the one impression it\n  cannot afford. A band that already sits on the right colour needs no\n  `background` of its own.\n- A few sources are natively dark — a deep navy or black page with light text\n  from top to bottom, not a light email inverted. When the source is one of\n  those, set the dark colors as the ordinary values on `x-base` and the bands\n  and do **not** try to express it through `:src=\"{ light, dark }\"` pairs. Only\n  the logo and the icon glyphs need the pair, and in a natively dark email they\n  need it the other way round from usual.\n- Logos on a brand-color bar survive dark mode as-is. A dark logo on white does\n  not — use ``x-image :src=\"{ light: `…`, dark: `…` }\"`` rather than boxing the\n  logo in a white rectangle.\n\n## The code and the detail block\n\nTwo renderings of the same band, and the type's defining feature.\n\n**The code** is a paragraph, not a component of its own. Large (24–40px on\ndesktop), bold, wide-tracked, and in the darkest text color the email has —\noften `#000000` even when the body copy is a softer gray. Left-aligned as often\nas centered. Build it as an `x-paragraph`, and remember `letter-spacing` in\nCarta is **em**: a source `letter-spacing: 0.2em` is `:letter-spacing=\"0.2\"`,\nand a source `letter-spacing: 5px` on 28px type is `:letter-spacing=\"0.1786\"`.\n\n```html\n\u003cx-paragraph\n  margin=\"0 0 20px 0\"\n  color=\"#000000\"\n  font-weight=\"700\"\n  :font-size=\"{ small: 16, medium: 28 }\"\n  :letter-spacing=\"0.2\"\n  \u003e{{event.otp_code}}\u003c/x-paragraph\n\u003e\n```\n\n**The detail block** is a label/value list of the facts behind a sign-in. It is\nthe one place in this email type where `x-html` is the right answer, for the\nsame reason as the order-confirmation line-item table: it is a real data table\nwith an intrinsic first-column width. Keep the escaped subtree to the `\u003ctable\u003e`\nalone and put the band's background and padding on the `x-box` around it.\n\nThe larger set shows a third rendering that is simpler than either the table or\nthe bulleted list, and it is the most common one in the sign-in alerts: the\nfacts as **plain paragraph lines with the label bolded inline** — \"**When** Sun,\nAug 4, 2019, 11:40 AM EDT\" on one line, \"**Where** California, United States\" on\nthe next. No table, no bullets, no columns. It is one `x-paragraph` with `\u003cbr\u003e`s\nand `\u003cstrong\u003e`s, it never overflows a phone, and it is what you should reach for\nfirst. Two sources put that paragraph and the CTA together inside a single\nbordered `x-box`, which reads as a card and keeps the fact and the action in one\nglance.\n\nFields that recur, with Liquid names that are safe to propose. The percentages\nare proportions of the ~20 sign-in, device and authorization alerts in the set,\nnot of all 115:\n\n| Field            | Token                                                         | Seen in     |\n| ---------------- | ------------------------------------------------------------- | ----------- |\n| Date and time    | `{{event.signed_in_at \\| date: \"%B %-d, %Y at %I:%M %p %Z\"}}` | ~90%        |\n| Location         | `{{event.location}}`                                          | ~80%        |\n| Device or app    | `{{event.device}}`                                            | ~55%        |\n| Browser          | `{{event.browser}}`                                           | ~45%        |\n| Operating system | `{{event.operating_system}}`                                  | ~35%        |\n| IP address       | `{{event.ip_address}}`                                        | ~45%        |\n| Account email    | `{{customer.email}}`                                          | ~50%        |\n| One-time code    | `{{event.otp_code}}`                                          | code emails |\n| Expiry           | `{{event.expires_in_minutes}}`                                | ~50%        |\n| Requesting app   | `{{event.app_name}}`                                          | grants only |\n| Granted scope    | `{{event.permission_summary}}`                                | grants only |\n\nOnly a minority of these alerts actually reach for a table. Counting the three\nrenderings across the set, the bolded-label paragraph leads, a bulleted `x-list`\ninside the body copy is next, and the `\u003ctable\u003e` is last — it survives mainly in\nolder ESP exports. All three read the same to the user, and the first two need\nno `x-html`, so **prefer the paragraph or the list when you are writing a new\nemail rather than converting one**. Reach for `x-html` only when you are\nmatching a source that really has a table and the column alignment is\nload-bearing.\n\n**A fixed-width detail table is the one thing not to copy from a source**:\nseveral corpus emails set the inner table to a hard `width: 500px`, which\noverflows a phone. Use `width: 100%` unless you are matching a source pixel for\npixel.\n\n## Example: one-time passcode with a sign-in detail block\n\nSubject: `Code for signing in to {{company.name}}`\nPreheader: `{{event.otp_code}} is your sign-in code. It expires in 10 minutes.`\n\nThis is the **code** variant with a detail block bolted on, which is how the\nricher security emails are built: a brand-color logo bar, a white body carrying\nthe greeting, the code, the expiry line and the detail table, then a divider, a\nmuted \"why you are getting this\" paragraph, a gray social band, and a footer.\nThere is no button anywhere, and that is correct. Change the two band colors,\nthe code paragraph and the four detail rows first.\n\nThe full 115-email set puts the code variant at about 8% rather than the ~20%\nthe first sample suggested, so this example is no longer the most common shape\nin the type — the **action** variant is. It is still the right example to keep,\nbecause it is the only one that shows the code band and the detail block\ntogether, and both of those are the parts of a security email that are easy to\nget wrong. When the brief is a plain reset, use § Adapting this example below to\nstrip it down rather than starting from a different file.\n\n\u003c!-- Converted from a real one-time-passcode exemplar. The conversion was\n     pixel-graded against the source render: desktop 0.964, with 1.07% of desktop\n     pixels differing. Mobile scores 0.75 and the reason is the source, not the\n     conversion: its detail table is a hard 500px wide, so at a 375px viewport the\n     source document grows to ~560px and the screenshot is scaled down, while\n     Carta keeps the document at 375px. Setting that table to width: 100% fixes\n     the source's own bug and is what you should do when the brief is a new email\n     rather than a copy. The markup below was then simplified for idiom and\n     accessibility per review, so it no longer reproduces the source pixel for\n     pixel. The score describes the original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#EEEEEE\"\n  color=\"#232333\"\n  font-family=\"'Lato', Helvetica, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :letter-spacing=\"0.01\"\n  :mobile-breakpoint=\"601\"\n\u003e\n  \u003cx-section width=\"600px\" padding=\"\" data-layer-label=\"Email\"\u003e\n    \u003cx-box\n      background=\"#0E72ED\"\n      padding=\"20px 30px\"\n      data-layer-label=\"Logo band\"\n    \u003e\n      \u003cx-image\n        src=\"https://placehold.co/100x34\"\n        alt=\"{{company.name}} logo\"\n        href=\"https://example.com/\"\n        width=\"100px\"\n        align=\"left\"\n      /\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#ffffff\"\n      padding=\"0 30px 40px 30px\"\n      data-layer-label=\"Body\"\n    \u003e\n      \u003cx-paragraph margin=\"40px 0 0 0\"\n        \u003eHi {{customer.first_name | default: \"there\"}},\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"24px 0 20px 0\"\n        \u003eWe noticed a sign-in from a device or location you do not usually use.\n        If this was you, enter the code below to finish signing in.\u003c/x-paragraph\n      \u003e\n\n      \u003cx-paragraph\n        margin=\"0 0 20px 0\"\n        color=\"#000000\"\n        font-weight=\"700\"\n        :font-size=\"{ small: 16, medium: 28 }\"\n        :letter-spacing=\"0.2\"\n        \u003e{{event.otp_code}}\u003c/x-paragraph\n      \u003e\n\n      \u003cx-paragraph margin=\"0\"\u003eThe code expires in 10 minutes.\u003c/x-paragraph\u003e\n      \u003cx-paragraph margin=\"20px 0 20px 0\"\n        \u003eHere are the details of that sign-in:\u003c/x-paragraph\n      \u003e\n\n      \u003cx-box\n        background=\"#F7F7FC\"\n        padding=\"12px 0\"\n        data-layer-label=\"Sign-in details\"\n      \u003e\n        \u003cx-html\u003e\n          \u003ctable\n            border=\"0\"\n            cellpadding=\"0\"\n            cellspacing=\"0\"\n            style=\"border-collapse: collapse; font-family: helvetica, arial; font-size: 14px; line-height: 29px; color: #333333; width: 100%\"\n          \u003e\n            \u003ctbody\u003e\n              \u003ctr\u003e\n                \u003ctd style=\"color: #747487\"\u003eDate\u003c/td\u003e\n                \u003ctd\u003e{{event.signed_in_at | date: \"%m/%d/%Y %I:%M:%S %p\"}}\u003c/td\u003e\n              \u003c/tr\u003e\n              \u003ctr\u003e\n                \u003ctd style=\"color: #747487\"\u003eBrowser\u003c/td\u003e\n                \u003ctd\u003e{{event.browser | default: \"-\"}}\u003c/td\u003e\n              \u003c/tr\u003e\n              \u003ctr\u003e\n                \u003ctd style=\"color: #747487\"\u003eOperating System\u003c/td\u003e\n                \u003ctd\u003e{{event.operating_system}}\u003c/td\u003e\n              \u003c/tr\u003e\n              \u003ctr\u003e\n                \u003ctd style=\"color: #747487\"\u003eLocation\u003c/td\u003e\n                \u003ctd\u003e{{event.location}}\u003c/td\u003e\n              \u003c/tr\u003e\n            \u003c/tbody\u003e\n          \u003c/table\u003e\n        \u003c/x-html\u003e\n      \u003c/x-box\u003e\n\n      \u003cx-paragraph margin=\"24px 0 0 0\"\n        \u003eIf this was not you, please\n        \u003ca\n          href=\"https://example.com/report\"\n          style=\"text-decoration: none; color: #0E71EB\"\n          \u003elet us know\u003c/a\n        \u003e. We recommend that you change your password and turn on two-factor\n        authentication.\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"24px 0 0 0\"\u003eThank you,\u003c/x-paragraph\u003e\n      \u003cx-paragraph margin=\"0 0 24px 0\"\u003eThe {{company.name}} team\u003c/x-paragraph\u003e\n\n      \u003cx-hr :height=\"3\" background-color=\"#EDEDF4\" margin=\"0 0 24px 0\" /\u003e\n\n      \u003cx-paragraph\n        margin=\"0\"\n        color=\"#747487\"\n        :font-size=\"{ small: 16, medium: 12 }\"\n        \u003eTo keep your account secure we send an alert whenever we detect a\n        sign-in from a new browser, location, or device.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box background=\"#E4E4ED\" padding=\"30px 15px\" data-layer-label=\"Social\"\u003e\n      \u003cx-wrap gap=\"12px\" align-content=\"center\" padding=\"0 0 15px 0\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/28x28\"\n          alt=\"X\"\n          href=\"https://x.com/example\"\n          width=\"28px\"\n        /\u003e\n        \u003cx-image\n          src=\"https://placehold.co/28x28\"\n          alt=\"LinkedIn\"\n          href=\"https://linkedin.com/company/example\"\n          width=\"28px\"\n        /\u003e\n        \u003cx-image\n          src=\"https://placehold.co/28x28\"\n          alt=\"Blog\"\n          href=\"https://example.com/blog\"\n          width=\"28px\"\n        /\u003e\n      \u003c/x-wrap\u003e\n      \u003cx-paragraph\n        margin=\"0\"\n        :font-size=\"12\"\n        color=\"#828282\"\n        text-align=\"center\"\n        \u003e\u003ca href=\"tel:+15550100\" style=\"text-decoration: none; color: #828282\"\n          \u003e+1 555 010 0000\u003c/a\n        \u003e\u003cbr /\u003e{{company.name}} — All rights reserved\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box padding=\"30px 0 10px 0\" data-layer-label=\"Footer\"\u003e\n      \u003cx-paragraph\n        margin=\"0\"\n        :font-size=\"12\"\n        color=\"#969AA1\"\n        text-align=\"center\"\n        \u003eVisit\n        \u003ca href=\"https://example.com\" style=\"color: #969AA1\"\u003eexample.com\u003c/a\n        \u003e\u003cbr /\u003e\u003ca\n          href=\"https://example.com/contact\"\n          style=\"color: #969AA1; text-decoration: none\"\n          \u003e1 Example Street\u003cbr /\u003ePortland, OR 97201\u003c/a\n        \u003e\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                    | Token to use instead                                    |\n| ----------------------------------------- | ------------------------------------------------------- |\n| `background=\"#EEEEEE\"` on `x-base`        | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#0E72ED\"` on the logo band   | `:background=\"globalStyles.colors.brand_\u003cid\u003e\"`          |\n| `background=\"#ffffff\"` on the body box    | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`        |\n| `background=\"#F7F7FC\"` on the detail box  | `:background=\"globalStyles.colors.subtleSurface_\u003cid\u003e\"`  |\n| `color=\"#232333\"` on `x-base`             | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`            |\n| `color=\"#747487\"` / `#828282` / `#969AA1` | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`           |\n| `color=\"#0E71EB\"` on the inline link      | `:color=\"globalStyles.colors.link_\u003cid\u003e\"`                |\n| `background-color=\"#EDEDF4\"` on `x-hr`    | `:background-color=\"globalStyles.colors.border_\u003cid\u003e\"`   |\n| `padding=\"30px\"` on the body box          | `:padding=\"globalStyles.spacing.large_\u003cid\u003e\"`            |\n| `font-family` on `x-base`                 | `font-family=\"\"` when a global style already sets it    |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete the detail `x-box` and the\n`x-hr` and the fine-print paragraph. Delete the social `x-wrap`. That leaves the\n**code** variant: a logo bar, four paragraphs and a footer, and it is six\ncomponents long. To turn it into the **action** variant instead, replace the\ncode paragraph with one `x-cta` and change the expiry line to talk about the\nlink rather than the code.\n\n**Do not add a hero.** The reflex to give a bare email an image is wrong for\nthis type. If the layout feels empty, the corpus answer is a small centered\nillustration above the headline at 40–200px, never a photograph and never\nfull-bleed.\n\n**Keep `'Lato'` only because that source really loads it.** The `@font-face`\nrules are in the source's own `\u003cstyle\u003e` block. Most security emails name a font\nthey never load — drop that name and keep the rest of the stack, or Carta will\ninject a Google Fonts link the original never had and every line will wrap in a\ndifferent place. Carta only matches the first name in the stack.\n","email_patterns/alerts_reminders.md":"---\nname: Alert and Reminder Email Patterns\ndescription: What a system alert, usage warning, policy notice, deadline reminder, or expiry warning looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, the alert card and deadline line, and one converted Carta example to adapt.\n---\n\n# Alert and Reminder Emails\n\nAn alert says something has happened that the reader did not ask about. A\nreminder says something is about to happen that the reader already knows about.\nBoth are interruptions, so both are built the same way: state the fact in the\nsubject line, restate it in the headline, give the one number that matters, and\noffer one button.\n\nThe patterns below come from the alerts-reminders set: **250 emails**, of which\nthis file read subject and category metadata for the top 50 ranked exemplars\nplus about 70 more members by title, rendered screenshots for 13, and full HTML\nfor 1. Prevalence figures are eyeballed from that reading, not measured.\n\nThe set is genuinely broad — 98 members carry the corpus's `alert` category, 75\ncarry `reminder`, 54 carry `notice`, and 26 carry `emergency`. In practice it\nholds seven things: system and account alerts, usage-limit warnings, policy and\nprice-change notices, deadline reminders, trial-expiry warnings, behavioral\nnudges (\"your profile is about to be hidden\"), and appointment reminders. They\nshare the anatomy below. They differ mostly in how much the reader already knew.\n\n**Neighbouring files.** A new-sign-in or password alert belongs in\n**email_patterns/account_security.md**. An \"account closes in 30 days\" notice\nbelongs in **email_patterns/account_lifecycle.md**. A last-chance sale is a\npromotion wearing a reminder's clothes — build it from the promotion anatomy and\nborrow only the deadline line from here.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n1 logo band       ~always   centered or left logo; ~35% sit on a dark or brand-color card top\n2 alert headline  ~85%      one h1, 24-32px, stating the fact — often the whole payload\n3 hero            ~35%      illustration, product shot, or a screenshot of the thing in question\n4 greeting        ~45%      \"Hi {{customer.first_name}},\"\n5 what-happened   ~always   1-3 short paragraphs: the fact, the cause, the consequence\n6 detail card     ~40%      the resource, error, amount, or date, in a tinted or bordered box\n7 primary CTA     ~80%      one button repeating the headline's verb\n8 deadline line   ~50%      \"expires April 15\", \"2 hours left\", \"before you run out\"\n9 what-to-do list ~30%      2-5 bulleted or numbered steps, or icon+text rows\n10 fine print     ~40%      13-14px muted paragraph on why the alert exists\n11 social row     ~40%      3-5 linked icons\n12 footer         ~always   address, preferences, unsubscribe\n```\n\n**Logo band.** Always present. About a third of this set puts the logo, the\nheadline and the first button together on one dark or saturated card, which is\nthe type's most recognizable shape. See § The alert card and the deadline line.\n\n**Alert headline.** The fact, in the reader's terms, in one line: \"You're\napproaching your content limit\", \"Your credit card statement is available\",\n\"Profile about to be hidden\", \"Your trial ends tomorrow\". Sentence case. A\nwarning emoji at the front appears in about one in six and reads as urgent\nrather than shrill when it is the only one in the email.\n\n**What-happened copy.** Two to three short paragraphs at 16px. The first names\nthe fact and quantifies it (\"you can add 5 more before you hit your limit of\n25\"). The second says what to do. The third, when present, says what happens if\nthe reader does nothing.\n\n**Detail card.** A tinted or bordered box holding the specifics — the failing\nintegration and its error, the account and the amount due, the date and the\naddress of the appointment. Two renderings recur: a label/value grid, and a\n\"resource + status + edit link\" row. Build the grid as `x-row`s and the row as\none `x-row` with a narrow icon column.\n\n**Primary CTA.** One button, and its label is the headline's verb: \"Upgrade\nnow\", \"View statement\", \"Complete setup\". The alert-card variant repeats the\nsame button twice — once on the card, once at the end of the body — and that\nrepetition is correct here even though it is wrong in most other types.\n\n**Deadline line.** Half the set carries one. See § The alert card and the\ndeadline line.\n\n**Fine print.** \"We send this alert whenever…\", \"You are receiving this because\nyou have an account with us\". Roughly 40%, at 13–14px in the muted gray, just\nabove the footer. Include it: an unexplained alert reads as phishing.\n\n## Structural variants\n\n**Alert card** (~30%). A saturated or dark card carrying logo, headline and\nbutton, then a white card below it holding the greeting, two paragraphs, the\nsame button again and a sign-off, then a plain footer. Pick this for SaaS system\nalerts and usage warnings. It is the shape of the example below.\n\n**Plain notice** (~25%). Logo, headline, four to six paragraphs, a bulleted list\nof links, no button, footer. Pick this for terms, privacy, pricing and policy\nchanges — a button on a legal notice implies an action the reader does not have.\n\n**Data summary** (~15%). Logo, a status pill, headline, then a label/value table\nof account facts, then two buttons and a long explanatory footer. Pick this for\nstatements, balances, scores and usage reports.\n\n**Countdown** (~20%). Big hero art with a number in it, a short line of copy, one\nbutton, then three value-prop rows and a second button. Pick this for deadlines\nthe reader is choosing to meet — tax day, presale, registration close.\n\n**Activity digest** (~10%). Repeated cards, one per event, each with an avatar,\na title, a snippet and a \"read more\" link, then one button to the app. Pick this\nfor social and collaboration notifications.\n\n## Subject lines\n\n| Move                     | Pattern                                                                                  |\n| ------------------------ | ---------------------------------------------------------------------------------------- |\n| Warning-emoji prefix     | `⚠️ You're approaching your content limit` / `🚨 New flavor alert`                       |\n| Bracketed severity       | `[ALERT] Possible error on your {{integration}}` / `[Action required] …`                 |\n| Fact, stated flat        | `Your credit card statement is available` / `Your profile was just updated`              |\n| Consequence framing      | `Profile about to be hidden` / `Log in to keep your account open`                        |\n| Countdown, days          | `Just 7 days left to file taxes` / `{{n}} days left`                                     |\n| Countdown, hours         | `24 hours left to save big` / `Early bird specials — 2 hours left!`                      |\n| Tomorrow / today framing | `Your free trial ends tomorrow` / `Presale starts tomorrow`                              |\n| Last chance              | `Last chance: {{event.name}} is {{date}}`                                                |\n| Policy notice            | `We're updating our Terms and Privacy Policy` / `Update on our upcoming pricing changes` |\n| Running-out nudge        | `Are {{pet.name}} and {{pet2.name}} running low on food?` / `Refill before you run out`  |\n| Recall or safety         | `Important recall notice` / `Your safety is a top priority`                              |\n\nTwo patterns dominate: the bare statement of fact, and the number of units left.\nPut the number in the subject whenever there is one — \"7 days\", \"24 hours\", \"2\nhours\" — because it is the only part the reader acts on. Emoji appear in about a\nquarter, almost always a single ⚠️ 🚨 ⏰ 🔔 at the front. Bracketed prefixes are a\nB2B habit and they read as operational rather than promotional.\n\n## Preheaders\n\n- `Ask your admin to get unlimited recordings`\n- `We've identified 1 error that needs your review.`\n- `These updates will go into effect on April 20, 2026`\n- `Beat the April 15 deadline for Sole Proprietors, C Corps, and Estates and Trusts.`\n- `A quick check-in keeps you visible.`\n- `Order by tonight to get your gifts in time.`\n- `Pick a plan to stay on track and keep work moving.`\n- `Get your check-in details along with tips and reminders.`\n- `Don't miss out on the best seats — be the first to get tickets`\n- `It's not too late to order!`\n\nRoughly three quarters carry one, the highest rate of any type in the corpus —\nan alert is read from the inbox list and the preheader is the second half of the\nsentence. It should add the detail the subject could not fit: the date, the\ncount, or the action. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Fix: `Upgrade now` · `Complete setup` · `Troubleshoot` · `Update your app`\n- View: `View statement` · `See details` · `Review changes` · `Check your account`\n- Keep: `Keep profile active` · `Open the app` · `Save my spot`\n- Deadline: `Work on my return` · `Unlock presale access` · `Register now`\n- Policy notices: no button — inline links to each document instead\n\nTwo to three words. The label repeats the headline's verb, so a reader who only\nreads the headline and the button still knows what to do. One button, except in\nthe alert-card variant where the same button appears twice.\n\n## Tone\n\n- The first sentence is the fact. No greeting-first preamble in the alert\n  variants.\n- Quantify. \"5 more before your limit of 25\" beats \"you are close to your\n  limit\"; \"7 days\" beats \"soon\".\n- Give the date in full, not as a relative phrase alone. \"April 20, 2026\" with\n  \"in 7 days\" beside it.\n- Say what happens if the reader does nothing. This is the difference between an\n  alert and an announcement.\n- Never manufacture urgency the data does not support. The corpus's deadline\n  emails all have real deadlines, and the type's credibility depends on it.\n- Explain why the alert exists, once, at the bottom, in muted text.\n- Policy and recall notices stay flat and formal: no exclamation marks, no\n  emoji, no brand voice.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. This set clusters at 480px and 600px. Read the\n  source's own `@media` boundary and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to\n  match it. Do not assume 600.\n- The most common mobile rule in this set is a **side-margin change on the\n  headline**, not on the band: a 32px headline with 30px of side margin on\n  desktop and 40px on mobile so it wraps earlier.\n  ``:margin=\"{ small: `20px 40px 0 40px`, medium: `20px 30px 0 30px` }\"``.\n- Card shadows are frequently dropped below the breakpoint. `box-shadow` takes\n  no responsive object in Carta, so keep the desktop value and note the\n  difference — at a 10%-black shadow it is invisible in a pixel diff.\n- This type is where dark mode actually shows up: `@media (prefers-color-scheme:\ndark)` blocks appear in the SaaS alerts. Read them carefully, because they are\n  frequently buggy — one corpus email swaps its wrapper to `#1a1a1a` but leaves\n  its card white, so its body text renders white on white. Express the intent\n  rather than the bug: ``:background=\"{ light: `#FFFFFF`, dark: `#1a1a1a` }\"`` on\n  the card, and ``x-image :src=\"{ light: `…`, dark: `…` }\"`` on both logos.\n- There is no per-scheme prop for an inline `\u003ca\u003e`'s color. When a source's dark\n  block recolors links inside a paragraph, that part is not expressible without\n  `x-html` — say so in the report rather than dropping it silently.\n- Explicit backgrounds on `x-base` and on both cards. The alert card's saturated\n  fill is the one thing that must never fall back to transparent. A band that\n  matches the background it inherits does not need its own `background`.\n\n## The alert card and the deadline line\n\nTwo devices carry this type.\n\n**The alert card** is a single `x-box` with a background, a `border-radius` and a\n`box-shadow`, holding the logo, the headline and the primary button. It sits at\nthe top of the email, above a second white card that holds the body copy. The\npair reads as \"here is the alert, and here is the explanation\", and it lets the\nreader act without scrolling.\n\n```html\n\u003cx-box\n  background=\"#140B2A\"\n  border-radius=\"24px\"\n  box-shadow=\"0px 6px 24px 0px #0000001A\"\n\u003e\n  \u003cx-image\n    src=\"…\"\n    alt=\"{{company.name}}\"\n    width=\"165px\"\n    align=\"center\"\n    margin=\"28px 0 0\"\n  /\u003e\n  \u003cx-heading-1\n    :font-size=\"32\"\n    color=\"#FFFFFF\"\n    text-align=\"center\"\n    margin=\"20px 30px 0 30px\"\n    \u003eYou are approaching your limit\u003c/x-heading-1\n  \u003e\n  \u003cx-cta\n    href=\"{{event.upgrade_url}}\"\n    background=\"#625df5\"\n    color=\"#FFFFFF\"\n    padding=\"10px 20px\"\n    border-radius=\"18px\"\n    align=\"center\"\n    margin=\"24px 0\"\n    \u003eUpgrade\u0026nbsp;now\u003c/x-cta\n  \u003e\n\u003c/x-box\u003e\n```\n\nTwo details make it land. Put every vertical gap on one side only — a top-only\n`margin` on each child — so the result is identical whether or not margins\ncollapse. And bind the headline's words with `\u0026nbsp;` where the source does, or\na two-line headline becomes three at a different width.\n\n**The deadline line** is one sentence and it is the highest-value copy in the\nemail. It appears immediately above or below the button. Four shapes recur, in\ndescending order of how often the corpus uses them:\n\n| Shape             | Example                                                                     |\n| ----------------- | --------------------------------------------------------------------------- |\n| Units remaining   | `{{event.days_left}} days left to file`                                     |\n| Absolute deadline | `These updates take effect on {{event.effective_at \\| date: \"%B %-d, %Y\"}}` |\n| Both              | `Order by midnight tonight — that is 6 hours away`                          |\n| Consequence only  | `After that, your profile stops showing up in search`                       |\n\nFields worth proposing:\n\n| Field                | Token                                        |\n| -------------------- | -------------------------------------------- |\n| Units used / allowed | `{{event.used_count}}` / `{{event.limit}}`   |\n| Units remaining      | `{{event.remaining_count}}`                  |\n| Deadline             | `{{event.expires_at \\| date: \"%B %-d, %Y\"}}` |\n| Days left            | `{{event.days_left}}`                        |\n| The resource         | `{{event.resource_name}}`                    |\n| The error            | `{{event.error_message}}`                    |\n| Amount due           | `{{event.amount_due}}`                       |\n| Action URL           | `{{event.action_url}}`                       |\n\n## Example: usage-limit alert\n\nSubject: `⚠️ You're approaching your content limit`\nPreheader: `Ask your admin to unlock unlimited usage`\n\nThis is the **alert card** variant: a dark rounded card carrying the logo, a\n32px headline and a pill button; a white rounded card below it with a greeting,\ntwo paragraphs, the same button again and a sign-off; then a plain footer with a\nlink row, two social icons, the legal lines, a parent-brand logo and the\nunsubscribe line. Change the two card colors, the headline and the two\nparagraphs first.\n\n\u003c!-- Converted from a real usage-limit exemplar. The conversion was pixel-graded\n     against the source render: score 0.999, with 0.02% of desktop pixels and\n     0.02% of mobile pixels differing. The source's preheader div is display:none\n     and contributes no height, so the graded conversion contains no\n     x-hidden-text. The footer's 3.64px and 5.5px paddings are compensation for\n     x-wrap adding half its gap as vertical margin. The markup below was then\n     simplified for idiom and accessibility per review, so it no longer\n     reproduces the source pixel for pixel. The score describes the original\n     conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#F8F8F8\"\n  color=\"#292A2E\"\n  font-family=\"'Helvetica neue', Helvetica, Arial, Verdana, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"481\"\n\u003e\n  \u003cx-section width=\"600px\" padding=\"20px 0 0 0\" data-layer-label=\"Email\"\u003e\n    \u003cx-box\n      background=\"#140B2A\"\n      border-radius=\"24px\"\n      box-shadow=\"0px 6px 24px 0px #0000001A\"\n      data-layer-label=\"Hero card\"\n    \u003e\n      \u003cx-image\n        src=\"https://placehold.co/274x104\"\n        alt=\"{{company.name}} logo\"\n        href=\"https://example.com/\"\n        width=\"165px\"\n        align=\"center\"\n        margin=\"28px 0 0\"\n      /\u003e\n\n      \u003cx-heading-1\n        :font-size=\"32\"\n        color=\"#FFFFFF\"\n        text-align=\"center\"\n        :margin=\"{ small: `20px 40px 0 40px`, medium: `20px 30px 0 30px` }\"\n        \u003e\u003ca\n          href=\"https://example.com/\"\n          style=\"text-decoration: none; color: #ffffff; font-weight: bold\"\n          \u003eYou're approaching your content\u0026nbsp;limit\u003c/a\n        \u003e\u003c/x-heading-1\n      \u003e\n\n      \u003cx-cta\n        href=\"https://example.com/upgrade\"\n        background=\"#625df5\"\n        color=\"#FFFFFF\"\n        :line-height=\"1.375\"\n        font-weight=\"500\"\n        padding=\"10px 20px\"\n        border-radius=\"18px\"\n        border-style=\"solid\"\n        border-width=\"1px\"\n        border-color=\"#625df5\"\n        align=\"center\"\n        margin=\"24px 0 24px 0\"\n        \u003eUpgrade\u0026nbsp;now\u003c/x-cta\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#FFFFFF\"\n      border-radius=\"24px\"\n      box-shadow=\"0px 6px 24px 0px #0000001A\"\n      padding=\"20px 40px 32px 40px\"\n      margin=\"32px 0 0 0\"\n      data-layer-label=\"Body card\"\n    \u003e\n      \u003cx-paragraph margin=\"12px 0 0 0\"\n        \u003eHi {{customer.first_name | default: \"there\"}},\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"32px 0 0 0\"\n        \u003eLooks like you're getting a lot out of your account! That's awesome.\n        You can add {{event.remaining_count}} more items before you hit your\n        limit of {{event.limit}} total.\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"32px 0 0 0\"\n        \u003eWant to keep going without deleting anything? Ask your admin to\n        \u003ca\n          href=\"https://example.com/upgrade\"\n          style=\"text-decoration: none; color: #6356f0\"\n          \u003eupgrade your plan\u003c/a\n        \u003e\n        so you can get unlimited items, plus other benefits like extra storage,\n        advanced controls, and usage insights.\u003c/x-paragraph\n      \u003e\n      \u003cx-cta\n        href=\"https://example.com/upgrade\"\n        background=\"#625df5\"\n        color=\"#FFFFFF\"\n        :line-height=\"1.375\"\n        font-weight=\"500\"\n        padding=\"10px 20px\"\n        border-radius=\"18px\"\n        border-style=\"solid\"\n        border-width=\"1px\"\n        border-color=\"#625df5\"\n        align=\"left\"\n        margin=\"32px 0 0 0\"\n        \u003eUpgrade\u0026nbsp;now\u003c/x-cta\n      \u003e\n      \u003cx-paragraph margin=\"32px 0 0 0\"\n        \u003eHappy\u0026nbsp;building!\u003cbr /\u003eThe\u0026nbsp;{{company.name}}\u0026nbsp;team\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box padding=\"0 0 40px 0\" color=\"#626F86\" data-layer-label=\"Footer\"\u003e\n      \u003cx-paragraph :font-size=\"13\" text-align=\"center\" margin=\"40px 0 0 0\"\u003e\n        \u003ca\n          href=\"https://example.com/view-in-browser\"\n          style=\"color: #626f86; text-decoration: none\"\n          \u003eRead\u0026nbsp;in\u0026nbsp;browser\u003c/a\n        \u003e\n        •\n        \u003ca\n          href=\"https://example.com/privacy\"\n          style=\"color: #626f86; text-decoration: none\"\n          \u003ePrivacy\u0026nbsp;policy\u003c/a\n        \u003e\n        •\n        \u003ca\n          href=\"https://example.com/contact\"\n          style=\"color: #626f86; text-decoration: none\"\n          \u003eContact\u0026nbsp;us\u003c/a\n        \u003e\n        •\n        \u003ca\n          href=\"https://example.com/blog\"\n          style=\"color: #626f86; text-decoration: none\"\n          \u003eRead\u0026nbsp;our\u0026nbsp;blog\u003c/a\n        \u003e\n        •\n        \u003ca\n          href=\"https://example.com/community\"\n          style=\"color: #626f86; text-decoration: none\"\n          \u003eJoin\u0026nbsp;our\u0026nbsp;community\u003c/a\n        \u003e\n      \u003c/x-paragraph\u003e\n\n      \u003cx-wrap\n        gap=\"12px\"\n        align-content=\"center\"\n        padding=\"5.5px 0 0 8px\"\n        data-layer-label=\"Social\"\n      \u003e\n        \u003cx-image\n          src=\"https://placehold.co/49x49\"\n          alt=\"LinkedIn\"\n          href=\"https://linkedin.com/company/example\"\n          width=\"25px\"\n        /\u003e\n        \u003cx-image\n          src=\"https://placehold.co/48x48\"\n          alt=\"X\"\n          href=\"https://x.com/example\"\n          width=\"25px\"\n        /\u003e\n      \u003c/x-wrap\u003e\n\n      \u003cx-paragraph :font-size=\"13\" text-align=\"center\" margin=\"3.64px 0 0 0\"\n        \u003eCopyright 2026 {{company.name}}. All rights reserved. \u003cbr /\u003eWe are\n        located at 100 Example Street, Portland, OR, 97204, USA\u003c/x-paragraph\n      \u003e\n\n      \u003cx-image\n        src=\"https://placehold.co/593x70\"\n        alt=\"{{company.name}} parent brand\"\n        href=\"https://example.com/\"\n        width=\"116px\"\n        align=\"center\"\n        margin=\"20px 0 0\"\n      /\u003e\n\n      \u003cx-paragraph :font-size=\"13\" text-align=\"center\" margin=\"20px 0 0 0\"\n        \u003eIf you no longer wish to receive these emails, please\n        \u003ca\n          href=\"{% manage_subscription_preferences %}\"\n          style=\"\n            font-family: Helvetica, Arial, Verdana, sans-serif;\n            color: #626f86;\n            text-decoration: none;\n          \"\n          \u003eupdate your preferences\u003c/a\n        \u003e\n        or\n        \u003ca\n          href=\"{% unsubscribe_url %}\"\n          style=\"\n            font-family: Helvetica, Arial, Verdana, sans-serif;\n            color: #626f86;\n            text-decoration: none;\n          \"\n          \u003eunsubscribe\u0026nbsp;here\u003c/a\n        \u003e.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                      | Token to use instead                                    |\n| ------------------------------------------- | ------------------------------------------------------- |\n| `background=\"#F8F8F8\"` on `x-base`          | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#140B2A\"` on the hero card     | `:background=\"globalStyles.colors.brandDark_\u003cid\u003e\"`      |\n| `background=\"#FFFFFF\"` on the body card     | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`        |\n| `color=\"#292A2E\"` on `x-base`               | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`            |\n| `color=\"#626F86\"` on the footer box         | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`           |\n| `background=\"#625df5\"` on both `x-cta`s     | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`        |\n| `color=\"#6356f0\"` on the inline link        | `:color=\"globalStyles.colors.link_\u003cid\u003e\"`                |\n| `border-radius=\"24px\"` on the cards         | `:border-radius=\"globalStyles.radius.large_\u003cid\u003e\"`       |\n| `border-radius=\"18px\"` on the buttons       | `:border-radius=\"globalStyles.radius.pill_\u003cid\u003e\"`        |\n| `padding=\"20px 40px 32px 40px\"` on the card | `:padding=\"globalStyles.spacing.large_\u003cid\u003e\"`            |\n| `font-family` on `x-base`                   | `font-family=\"\"` when a global style already sets it    |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**Add dark mode when you write this fresh.** The source's own dark block is\nbroken. Set ``:background=\"{ light: `#FFFFFF`, dark: `#1a1a1a` }\"`` on the body\ncard, ``:background=\"{ light: `#F8F8F8`, dark: `#1a1a1a` }\"`` on `x-base`, and\n``:src=\"{ light: `…`, dark: `…` }\"`` on both logos, and check that the body text\ncolor still has contrast on the dark ground.\n\n**What to cut for the other variants.** Delete the hero card entirely and put\nthe headline at the top of the white card: that is the **plain notice**, and for\na policy change you should also delete both buttons and link each document\ninline. Replace the two body paragraphs with a label/value `x-row` stack to get\nthe **data summary**. Replace the whole white card with repeated avatar+snippet\n`x-row`s to get the **activity digest**.\n","email_patterns/apology_support.md":"---\nname: Apology and Support Email Patterns\ndescription: What an apology, service-disruption, or customer-support email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, the four-move apology paragraph, and one converted Carta example to adapt.\n---\n\n# Apology and Support Emails\n\nTwo jobs share one shape here. An **apology** email admits that the sender got\nsomething wrong — a wrong link, a wrong send, an outage, a delayed order — and\ntells the reader what to do about it. A **support** email answers a question\nbefore it is asked, or confirms that a request was received. Both are written as\na letter: a logo, a short stack of paragraphs, a sign-off from a named team, and\na footer. Neither carries a hero photograph in the usual case.\n\nThe patterns below come from the apology-support set: **127 emails**, of which\nthis file read subject and category metadata for the top 50 ranked exemplars\nplus 13 more members chosen by slug, rendered screenshots for 14, and full HTML\nfor 1. Prevalence figures are eyeballed from that reading, not measured.\n\n**Read this caveat before you trust the group name.** The set is dominated by\nits support half, not its apology half. Of the 127 members, only **8** carry the\ncorpus's `apology-oops` category; 46 carry `customer-service`, 43 carry\n`service`, and 26 carry `support`. A further slice is not customer support at\nall — the `support` slug also collects \"support a cause\" fundraising emails\n(wildfire relief, a Thanksgiving food drive). The genuine apology anatomy below\nis drawn from about 15 emails. It is consistent across those 15, but 15 is the\nevidence base, so prefer the shape to the percentage.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n1 logo band        ~always   centered or left logo, 20-30px padding, white or one brand color\n2 hero             ~25%      only when the brand is consumer-facing; a photo band or a wordmark\n3 headline         ~55%      one h1, 22-32px, naming the mistake: \"Oops, that was embarrassing\"\n4 greeting         ~65%      \"Hi {{customer.first_name}},\" or \"Dear customer,\"\n5 apology copy     ~always   3-5 short paragraphs — see § The apology paragraph\n6 what-to-do line  ~70%      one sentence telling the reader the corrected action\n7 primary CTA      ~50%      one button; apology emails often use a plain inline link instead\n8 choice pair      ~15%      two side-by-side buttons offering opposite outcomes\n9 compensation     ~35%      a discount code, credit, or extended offer, as bold inline text\n10 sign-off        ~70%      \"Thanks for your patience,\" + a named team or person\n11 divider         ~30%      1px rule above the footer\n12 social row      ~45%      3-5 linked icons, 30-32px\n13 footer          ~always   address, preferences link, help-center link\n```\n\n**Logo band.** Always present, and it is usually the only image in an apology.\nCentered slightly more often than left. Support emails put it on a colored bar\nabout as often as on white.\n\n**Hero.** Rare in apologies and common in support emails. When an apology does\ncarry one it is doing brand repair rather than explaining anything — a large\n\"OOPS\" wordmark, a photo band, an illustration of the thing that broke. Skip it\nunless the brief is consumer retail.\n\n**Headline.** About half. The good ones name the mistake in the reader's words\n(\"We may have accidentally unsubscribed you\", \"It's hard to stay in the loop\").\nThe rest of the corpus starts straight into the greeting, which reads as more\nserious and is the right choice for a B2B or account-facing apology.\n\n**Apology copy.** The band that defines the type. See § The apology paragraph.\n\n**Primary CTA.** Half the apologies have no button at all — the corrected link\nlives inline in a sentence, which is deliberate: a button turns an apology into\na campaign. When there is a button it repeats the corrected action once, in the\nbrand color. Support emails are the opposite: they lead with a large button\n(\"Get Support Now\") and then a list of self-serve links.\n\n**Choice pair.** A small but distinct pattern: two buttons side by side offering\nopposite outcomes, one filled and one outlined — \"Yes, I want deal alerts\" next\nto \"No, stay unsubscribed\". Build it as an `x-wrap` with two `x-cta`s so the\npair never stacks awkwardly, or as a two-column `x-row` when the source's\nbuttons are equal-width.\n\n**Compensation.** A third of the apologies offer something: a percent-off code,\na credit, an extended sale. The corpus renders it as bold inline text inside a\nparagraph far more often than as a styled coupon box.\n\n**Footer.** Address plus a preferences or help link. Apology emails almost always\nkeep the unsubscribe visible — several of them are about mailing-list mistakes,\nso hiding it would be a second offense.\n\n## Structural variants\n\n**Letter** (~45%). Logo, greeting, three to five paragraphs, sign-off, footer.\nNo hero, no button, links inline. Pick this for anything B2B, anything\naccount-facing, and any apology serious enough that a button would look\ntone-deaf.\n\n**Notice** (~20%). Logo, headline, one or two paragraphs inside a bordered or\ntinted card, one link, footer. The card is the whole design. Pick this for\n\"please disregard the previous email\" and for policy or status changes.\n\n**Brand-voice oops** (~15%). Big wordmark or illustration, one joke, a\ncompensation offer, one loud button, a footer. Pick this only for consumer\nretail, only when the mistake was harmless, and only when the brand already\nwrites this way.\n\n**Service disruption** (~10%). Photo header, greeting, an explanation of the\ncause, a paragraph about what happens to the reader's order or booking, a\ngoodwill code, a sign-off. Pick this for outages, weather delays, and shipping\ndisruptions.\n\n**Support hub** (~10%). Logo, a headline and one big button, then three to five\nicon+text rows linking to help articles, then a second block of \"other ways to\nreach us\" cards, then a large legal footer. Pick this for \"we're here to help\"\nand for post-contact confirmations. Build the rows as `x-row` with a narrow\nfirst column, exactly like a value-prop block.\n\n## Subject lines\n\n| Move                      | Pattern                                                               |\n| ------------------------- | --------------------------------------------------------------------- |\n| Oops, stated plainly      | `Oops, that was embarrassing` / `Whoops! We sent you the wrong email` |\n| Correction instruction    | `Please disregard: incorrect {{thing}} notification`                  |\n| Account-notice framing    | `Account notice: we may have accidentally unsubscribed you`           |\n| Update on a thing you own | `An update on your recent order` / `An important shipping update`     |\n| Bracketed severity tag    | `[Important update] Changes to our refund policy`                     |\n| Alert framing             | `[ALERT] Possible error on your {{integration}}`                      |\n| Offer of help             | `Need help?` / `Got questions? We've got you.`                        |\n| Receipt of a request      | `Thanks for contacting us` / `Your request to {{company.name}}`       |\n| Retry framing             | `Let's try that again` / `We sent the wrong link`                     |\n\nApology subjects are short and lead with the admission — \"oops\", \"whoops\",\n\"please disregard\", \"account notice\". Emoji appear in about one in six and only\nin the brand-voice variant. Bracketed prefixes (`[ALERT]`, `[Important Update]`)\nare a B2B/SaaS habit and they do work: they read as operational rather than\npromotional. Do not bury the mistake behind a clever line, and never use an\napology subject on an email that is really a sale.\n\n## Preheaders\n\n- `Let's try that again. Update your preferences and stay in the loop.`\n- `Weather has impacted our shipping operations`\n- `We've identified 1 error that needs your review.`\n- `We wanted to let you know about an update to our refund policy.`\n- `Extended (again): 50% off everything`\n- `All your burning questions answered`\n- `We've got resources to help you keep building`\n- `Consider this your life raft`\n\nAbout half carry one. In an apology it adds the fact the subject withheld — what\nhappened, or what the reader has to do now. In a support email it names the\nresource being offered. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Correction: `Update your preferences` · `Personalise your inbox, take 2` ·\n  `Try the link again`\n- Support: `Get support now` · `Visit the Help Center` · `Contact support` ·\n  `Chat with us`\n- Choice pair: `Yes, I want deal alerts` next to `No, stay unsubscribed`\n- Goodwill: `Shop the sale` · `Claim your credit`\n- Inline, no button: `contact us here` · `reply to this email` ·\n  `refer to your live ranking`\n\nTwo to five words. Support buttons are imperative and specific; apology buttons\nare the corrected action and nothing else. The choice pair is the one place two\nbuttons are correct — everywhere else, one button or none.\n\n## Tone\n\n- Name the mistake in the first sentence, in the active voice, with a subject.\n  \"We sent the wrong link\", not \"an error occurred\".\n- One apology, early, and then stop apologizing. The corpus never says sorry\n  twice.\n- Say what you did about it. Every apology in the set has a \"we have already\n  fixed this\" sentence, and it is the sentence that does the repair work.\n- Tell the reader whether they have to do anything. \"No need to call the\n  restaurant.\" \"Please ignore it.\" \"Your account is unaffected.\"\n- Sign off with a real team or person. Roughly seven in ten do.\n- Keep humor proportional to the harm. A broken discount code can be funny. A\n  billing error cannot.\n- Never bundle an apology with a new campaign. The compensation offer is allowed\n  because it is part of the repair; a product block is not.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. This set clusters at 480px. Read the source's\n  own `@media` boundary and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match it.\n  Do not assume 600.\n- The most common mobile rule is a padding collapse on the card: 40px of side\n  padding on desktop dropping to 20px. Express it as\n  ``:padding=\"{ small: `20px`, medium: `0 40px 30px 40px` }\"``.\n- ESP exports in this set frequently swap a 1px `border` for a 1px `padding` at\n  the mobile breakpoint while leaving the border in place, which insets the\n  content by 2px instead of 1px. That single pixel shifts every glyph in the\n  email and costs several percent of pixel diff. Reproduce it with a responsive\n  `x-section` padding: ``:padding=\"{ small: `1px`, medium: `0` }\"``.\n- Nothing stacks. These are single-column letters, so `x-row` only appears in the\n  support-hub variant's icon rows and in the choice pair.\n- Explicit backgrounds on `x-base`, on `x-section`, and on a white card over a\n  gray page. A card that loses its background in dark mode takes the whole layout\n  with it. A band that matches the background it inherits does not need its own.\n- No dark-mode-specific art in the set. Logos are the only asset at risk — use\n  ``x-image :src=\"{ light: `…`, dark: `…` }\"`` when the mark is dark on white.\n\n## The apology paragraph\n\nEvery credible apology in the corpus makes the same four moves, in the same\norder, one per paragraph. This is the band that carries the whole email, and it\nis copy work rather than layout work.\n\n1. **What happened, and when.** Date it. \"On Monday, February 3, you may have\n   received a notification about a change to your rank.\"\n2. **Why, and that it was ours.** One clause, no engineering detail, no passive\n   voice. \"Some of those notifications had incorrect information because of an\n   error on our end.\"\n3. **What the reader should do now.** Usually \"nothing\" — say so explicitly.\n   \"Please disregard that notification and refer to your live ranking.\"\n4. **What we did about it.** \"We have taken steps to make sure this does not\n   happen again.\"\n\nA fifth paragraph offering a way to reply appears in about two thirds, and the\nsign-off follows it.\n\nBuild all four as plain `x-paragraph` siblings inside one `x-box`, with the gap\ncarried on `margin-top` of each paragraph rather than split between neighbors —\nadjacent sibling margins collapse to the larger, so splitting a 20px gap into\n10px and 10px renders 10px, not 20px.\n\nLiquid worth proposing when the apology refers to a specific event:\n\n| Field                      | Token                                         |\n| -------------------------- | --------------------------------------------- |\n| When the bad send went out | `{{event.sent_at \\| date: \"%A, %B %-d, %Y\"}}` |\n| The affected order         | `{{event.order_id}}`                          |\n| The affected resource      | `{{event.resource_name}}`                     |\n| Goodwill code              | `{{event.promo_code}}`                        |\n| Corrected link             | `{{event.corrected_url}}`                     |\n\n## Example: correction notice\n\nSubject: `Please disregard: incorrect notification`\nPreheader: `We sent some accounts the wrong information. Here is what happened.`\n\nThis is the **notice** variant: a 1px-framed 600px column on a gray page, a\nsmall centered logo, a white rounded card holding the four-move apology, and a\nplain footer with the wordmark, the address, two links and a social row. There\nis no button, no image beyond the two logos, and no offer. Change the four\nparagraphs and the card's radius first; everything else is chrome.\n\n\u003c!-- Converted from a real correction-notice exemplar. The conversion was\n     pixel-graded against the source render: score 0.961, with 0.33% of desktop\n     pixels and 1.17% of mobile pixels differing. The source names \"Barlow, Open\n     Sans, Helvetica, Arial, sans-serif\" but loads no font file, so the conversion\n     drops both Google-font names and keeps \"Helvetica, Arial, sans-serif\" —\n     copying the stack verbatim would make Carta inject a webfont the original\n     never had. The markup below was then simplified for idiom and accessibility\n     per review, so it no longer reproduces the source pixel for pixel. The score\n     describes the original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#ffffff\"\n  color=\"#252530\"\n  font-family=\"Helvetica, Arial, sans-serif\"\n  :font-size=\"15\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"481\"\n\u003e\n  \u003cx-section\n    width=\"600px\"\n    :padding=\"{ small: `1px`, medium: `0` }\"\n    background=\"#f6f6f6\"\n    border-style=\"solid\"\n    border-width=\"1px\"\n    border-color=\"#f3f3f5\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-box\n      :padding=\"{ small: `20px`, medium: `0 40px 30px 40px` }\"\n      data-layer-label=\"Body\"\n    \u003e\n      \u003cx-image\n        src=\"https://placehold.co/100x100\"\n        alt=\"{{company.name}} logo\"\n        href=\"https://example.com/\"\n        width=\"50px\"\n        align=\"center\"\n        margin=\"25px 0\"\n      /\u003e\n\n      \u003cx-box\n        background=\"#ffffff\"\n        border-radius=\"13px\"\n        padding=\"30px 40px 30px 40px\"\n        data-layer-label=\"Notice card\"\n      \u003e\n        \u003cx-paragraph margin=\"10px 0 0 0\"\n          \u003eHi {{customer.first_name | default: \"there\"}},\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"20px 0 0 0\"\n          \u003eOn {{event.sent_at | date: \"%A, %B %-d, %Y\"}} you may have received a\n          notification from your account about a change to your plan. Some of\n          those notifications carried the wrong information because of an error\n          on our end, and we are sorry for any confusion it caused.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"20px 0 0 0\"\n          \u003ePlease disregard that notification. Your account page always shows\n          the current state.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"20px 0 0 0\"\n          \u003eWe have already fixed the cause, and we appreciate your patience\n          while we make our notifications more reliable.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"20px 0 0 0\"\n          \u003eIf you have any questions, reply to this email and your account team\n          will pick it up.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"20px 0 0 0\"\n          \u003eBest,\u003cbr /\u003e\u003cb\u003eThe {{company.name}} team\u003c/b\u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"40px 0 20px 5px\" data-layer-label=\"Footer\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/80x80\"\n          alt=\"{{company.name}}\"\n          href=\"https://example.com/\"\n          width=\"40px\"\n          align=\"left\"\n        /\u003e\n        \u003cx-paragraph margin=\"20px 0 0 0\" :font-size=\"14\"\n          \u003e{{company.name}}, 1 Example Street, Portland,\n          OR\u0026nbsp;97201\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0\" :font-size=\"14\"\n          \u003eCopyright \u0026copy;2026 {{company.name}}. All rights\n          reserved.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"20px 0 0 0\" :font-size=\"14\"\n          \u003e\u003ca\n            href=\"{% manage_subscription_preferences %}\"\n            style=\"text-decoration: none; color: #5746b2\"\n            \u003e\u003cstrong\u003eUpdate your email preferences\u003c/strong\u003e\u003c/a\n          \u003e\n          to choose the types of emails you receive.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0 0 10px 0\" :font-size=\"14\"\n          \u003e\u003ca\n            href=\"https://example.com/help\"\n            style=\"text-decoration: none; color: #5746b2\"\n            \u003e\u003cstrong\u003eVisit our help center\u003c/strong\u003e\u003c/a\n          \u003e.\u003c/x-paragraph\n        \u003e\n        \u003cx-wrap gap=\"13px\" align-content=\"left\" padding=\"10px 0 10px 0\"\u003e\n          \u003cx-image\n            src=\"https://placehold.co/32x31\"\n            alt=\"Facebook\"\n            href=\"https://facebook.com/example\"\n            width=\"32px\"\n          /\u003e\n          \u003cx-image\n            src=\"https://placehold.co/32x31\"\n            alt=\"LinkedIn\"\n            href=\"https://linkedin.com/company/example\"\n            width=\"32px\"\n          /\u003e\n          \u003cx-image\n            src=\"https://placehold.co/32x31\"\n            alt=\"X\"\n            href=\"https://x.com/example\"\n            width=\"32px\"\n          /\u003e\n          \u003cx-image\n            src=\"https://placehold.co/32x31\"\n            alt=\"Instagram\"\n            href=\"https://instagram.com/example\"\n            width=\"32px\"\n          /\u003e\n        \u003c/x-wrap\u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                    | Token to use instead                                    |\n| ----------------------------------------- | ------------------------------------------------------- |\n| `background=\"#ffffff\"` on `x-base`        | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#f6f6f6\"` on `x-section`     | `:background=\"globalStyles.colors.subtleSurface_\u003cid\u003e\"`  |\n| `background=\"#ffffff\"` on the notice card | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`        |\n| `border-color=\"#f3f3f5\"`                  | `:border-color=\"globalStyles.colors.border_\u003cid\u003e\"`       |\n| `color=\"#252530\"` on `x-base`             | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`            |\n| `color=\"#5746b2\"` on the footer links     | `:color=\"globalStyles.colors.link_\u003cid\u003e\"`                |\n| `border-radius=\"13px\"` on the card        | `:border-radius=\"globalStyles.radius.medium_\u003cid\u003e\"`      |\n| `padding=\"30px 40px\"` on the card         | `:padding=\"globalStyles.spacing.large_\u003cid\u003e\"`            |\n| `font-family` on `x-base`                 | `font-family=\"\"` when a global style already sets it    |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to add when the brief needs more.** For a service disruption, put a photo\nband above the card and a bold compensation line (\"Use **{{event.promo_code}}**\nfor 10% off your next order\") after the fourth paragraph. For the choice pair,\nreplace the sign-off with an `x-wrap` holding two `x-cta`s — one filled in the\nbrand color, one outlined with `background=\"\"` and a 1px border. For the support\nhub, replace the card's paragraphs with `x-row`s of icon plus heading plus one\nline of copy.\n\n**What to cut.** Delete the notice card's `x-box` and let the paragraphs sit\ndirectly on the gray band: that is the **letter** variant, and it is the most\ncommon shape in this set.\n","email_patterns/billing_subscription.md":"---\nname: Billing and Subscription Email Patterns\ndescription: What a trial-ending, renewal, charge, dunning or cancellation email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, the plan-and-price lockup, and one converted Carta example to adapt.\n---\n\n# Billing and Subscription Emails\n\nA billing email reports the money side of a subscription: a trial about to\nend, a renewal about to charge, a payment just taken, a card that failed, a\nplan that changed, a subscription that stopped. The reader opens it to check\ntwo facts — **how much** and **when** — and to find the one link that lets them\nchange either. Everything else in the email is context.\n\n**Where this file stops and `order_confirmation.md` starts.** That file owns\nreceipts: an itemised table, thumbnails, a subtotal/shipping/tax/total block,\nand the `x-html` escape hatch that a real line-item table needs. Do not rebuild\nany of that here. This file owns the recurring lifecycle, where the money is\nusually **one amount and one date** set in a sentence rather than a table. If\nthe brief mentions items, quantities or a totals column, go to\n**order_confirmation.md § Line items, totals and order data**. If it mentions a\nplan, a trial, a renewal date or a payment method, stay here.\n\nThe patterns below come from a 342-email corpus group. I read metadata for the\n35 pre-ranked exemplars plus 30 more lifecycle emails found by slug search,\nviewed 17 rendered desktop screenshots, summarised the structure of 6\n`email.html` files (widths, media queries, colors, table depth) and read 1 in\nfull for the conversion. **Every prevalence figure below is eyeballed from that\nsample, not counted across the group.**\n\n**The ranking is badly skewed for this type, so ignore it.** The top of the\nexemplar list is subscription-*adjacent* promotion, not billing: credit-card\nand business-banking offers, retail membership marketing, meal and pet-food\nsubscription promos. Only about six of the top 35 are true lifecycle billing\nemails. The trial-ending, renewal, dunning, price-change and cancellation\nmessages that this file describes sit deeper in the group and were pulled out\nby slug. If you sample the exemplars yourself, sample by slug, not by rank.\n\nThe senders also cluster: SaaS tools (Slack, Asana, Calendly, Zapier, Gorgias,\nOtter, Loom, Grammarly, Claude), streaming and media (YouTube, Paramount+,\nPrime), and a thin tail of consumer apps. There is almost no ecommerce and\nalmost no B2C finance in the lifecycle subset, so the register skews plain and\nproduct-led.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- The amount and date sit in a lockup of their own — a label/value stack such as\n  `Amount due` / `{{event.amount}}` and `Charged on` / the date — not inside a\n  running sentence.\n- Dunning: say what happened, put the failed amount (`{{event.amount}}` when the\n  brief gives none) and the deadline (a Liquid date when the brief gives none) in\n  that lockup, put the update-payment route on an `x-cta`, and keep the manage\n  link as an inline `\u003ca\u003e` below it. Every other variant\n  keeps the manage-plan route as an inline `\u003ca\u003e` in a paragraph, with no button.\n\n## Anatomy\n\n```text\n1  logo band            ~always   logo left or centered, 24-32px padding, brand or white bg\n2  plan / tier badge    ~25%      icon + \"Plus\" / \"Elite\" / \"TRIAL\" pill, right of the logo\n3  status headline      ~always   names the event, 24-38px, left aligned more often than centered\n4  greeting             ~45%      \"Hi {{first_name}},\" on its own line; common in renewals, rare in trials\n5  fact paragraph       ~always   1-3 sentences carrying the amount, the date and the card\n6  amount + date lockup ~70%      bold inside that paragraph, or a small label/value stack\n7  plan / price block   ~30%      plan name, price, billing period; sometimes two plans compared\n8  primary CTA          ~75%      one button: Upgrade / Renew / View plans\n9  what-you-lose list   ~35%      2-4 icon+text rows or bullets; trial-end and cancellation only\n10 reassurance line     ~40%      \"no action is required\" / \"no credit card is required\"\n11 manage link          ~85%      inline text link to billing settings — a link, not a button\n12 support line         ~50%      \"reply to this email\" or a help-centre link\n13 footer               ~always   address, preferences, unsubscribe, 12-14px muted text\n14 unsubscribe caveat   ~20%      \"Unsubscribing does not cancel your account or membership.\"\n```\n\n**Band 2 is this type's signature.** A quarter of the emails put a subscription\ntier next to the logo — a small icon plus a 12px bold label, right-aligned, or\na pill with uppercase text. It tells the reader which of their plans the email\nis about before they read a word. Build it as an `x-row` with the logo in the\nfirst column and an `x-wrap` (icon + label) in the second, not as a\nthree-column table.\n\n**Band 3 states the event in the present or perfect tense** — \"Your trial ends\ntomorrow\", \"Your membership has ended\", \"Only 7 more days of the Pro plan\". It\nis one `x-heading-1`. Do not soften it into a question; only the winback\nvariants do that, and they are re-engagement emails wearing a billing subject.\n\n**Bands 5 and 6 are the email.** The corpus writes the amount and the date into\nthe same sentence, in bold, inside an otherwise plain paragraph:\n\"your payment method will be charged **$199.00 + tax (when applicable) on July\n17, 2024**\". Roughly seven in ten do it this way rather than as a summary\ntable. See § The plan and price lockup.\n\n**Band 8 is one button and it is often absent.** Renewal notices frequently\nship with **no** button at all, because the desired action is \"do nothing\" —\nthe reader only needs the manage link. Trial-end and dunning emails always have\none. Average across the emails I opened is a little over one button.\n\n**Band 9 is a real `x-row` per item, not an `x-list`.** The recurring shape is a\n36-60px icon in a narrow first column and a bold 18px heading plus a 13-16px\nline of copy in the second. Set `:break-point` below 375 so the pairs never\nstack — an icon alone on one line reads as a bug.\n\n**Band 11 is the most reliable band in the whole type after the logo.** Nearly\nevery lifecycle email carries an inline link to billing settings, the account\ndashboard, or \"manage your membership\". It is almost never the primary button;\nit sits inside the fact paragraph or immediately under it. The only variant\nthat promotes it to a button is dunning.\n\n**Band 14 exists because unsubscribing and cancelling are different things.**\nAbout one in five footers says so explicitly, in italic 12px under the link\nrow. Keep it whenever the email is transactional and still carries\n`{% unsubscribe_url %}`.\n\n## Structural variants\n\n**Renewal notice** (~30%). Logo, optional tier badge, a warm headline, a\ngreeting, two or three paragraphs where the middle one carries the amount and\nthe date in bold, the manage link, and a footer. Often no button. Pick this\nwhen the brief says \"reminder\", \"renews on\", \"auto-renew\" or \"before we charge\nyou\". Price-*increase* letters are the same shape with one added sentence\n(\"we have recently increased the annual subscription to $35.99\") and a link to\nthe updated terms — do not build them as a separate layout.\n\n**Trial runway** (~30%). The largest and most designed variant, covering both\n\"ends in N days\" and \"has ended\". Logo, a headline naming the deadline, one\nsentence of consequence, then a what-you-lose list, then a card or panel\nholding an illustration, a sub-headline and the button. Often a second, quieter\nCTA at the bottom. Pick this when the brief mentions a trial, a free plan\ndowngrade, or a usage limit.\n\n**Charge or plan confirmation** (~15%). The smallest layout in the group and\nfrequently the whole email: a logo, three short paragraphs (thanks, what was\ncharged and when the next charge lands, how to change it), the inline manage link\ninside that third paragraph, one footer line. No hero, no list, no offer, and\n**no button** — this variant follows the band 11 rule, so the manage route stays\na link. Dunning is the only variant that promotes it to a button. Pick this for \"welcome to the Pro\nplan\", \"thank you for starting your membership\", \"your payment was processed\".\n\n**Expiry / dunning** (~15%). Distinguished by an urgency band rather than by\nstructure: a colored strip, an amber or red rule, or a bordered key/value panel\nshowing account id and status. The button is the loudest element on the page\nand says \"Renew now\". A discount often rides along (\"89% renewal discount\").\nPick this when access has stopped or is about to stop today.\n\n**Cancellation confirmation** (~10%). Headline confirms the cancellation, one\nparagraph gives the date access actually ends (which is not today), one\nparagraph offers exactly one reversal — resume, pause, or resubscribe — and the\nfooter carries the support number. Pick this when the brief says \"confirm\",\n\"cancelled\", or \"sorry to see you go\".\n\n## Subject lines\n\n| Move                  | Observed shape                                                                             |\n| --------------------- | -------------------------------------------------------------------------------------------- |\n| Countdown             | `Your {{company.name}} trial ends tomorrow` · `1 day left in your {{company.name}} trial`     |\n| Countdown with owner  | `{{account.name}}'s free trial of Pro ends in 7 days`                                        |\n| Trial in the past     | `Your free trial has ended` · `You've completed your free trial` · `Your trial ended. What's next?` |\n| Renewal ahead         | `Your subscription renews soon` · `Your {{company.name}} account will renew soon`             |\n| Renewal with a number | `Friendly Reminder: Your Subscription Will Renew in 60 days`                                 |\n| Renewal plus price    | `An update on your subscription renewal and pricing`                                          |\n| Named product         | `{{product.name}} subscription renewal`                                                       |\n| Expiry today          | `Your {{product.name}} subscription expires today`                                            |\n| Benefit loss          | `Only a few days before your benefits end — renew now.` · `Your membership has ended.`       |\n| Cancellation          | `Your subscription has been canceled` · `Your renewal has been canceled`                     |\n| Plan welcome          | `Welcome to the Pro plan` · `Thank you for starting your membership!`                        |\n| Usage limit           | `⚠️ You're approaching your content limit on {{company.name}}`                                |\n| Price change          | `[Important] Upcoming Changes to Your Basic Plan` · `An update on our pricing`                |\n\nLength clusters at 20-50 characters. Roughly half the subjects start with the\nword \"Your\", which is the strongest single pattern in the type — the email is\nabout the reader's account, and the subject says so in the first word. Emoji\nare rare, maybe one in eight, and they are functional rather than decorative:\n⌛️ for a deadline, ⚠️ for a limit, 😢 for a lapse. A bracketed severity tag\n(`[Important]`) shows up only in price-change announcements. First-name\npersonalization appears in about one in six and always at the front.\n\nSay the thing that happened. No corpus billing subject teases.\n\n## Preheaders\n\nReal preheaders, anonymized:\n\n- `You'll lose access to premium features on {{event.trial_ends_at | date: \"%B %-d\"}}.`\n- `Pick a plan to stay on track and keep work moving.`\n- `Here are your next steps with {{company.name}}`\n- `We recommend the Standard plan for your scheduling needs.`\n- `Enjoy another 12 months on {{event.renews_at | date: \"%B %-d, %Y\"}} with AI-powered writing help.`\n- `Yay for another year of member deals!`\n- `You will start seeing ads soon.`\n- `You've lost access to your member benefits.`\n- `Ask your admin to get unlimited recordings`\n- `Open for more details regarding your account`\n- `We're sorry to see you go.`\n- `You now have the ability to pause your account.`\n- `Starting {{event.effective_at | date: \"%B %-d\"}}, our free Starter Plan and paid Pro Plan are rolling out to you`\n\nAbout two in three carry one. The billing preheader states the **consequence**\nor the **next step**, never a restatement of the subject: when the subject\nnames the deadline, the preheader names what stops working. Keep it under about\n90 characters — several in this corpus run past 100 and get clipped mid-word.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Convert: `Upgrade now` · `Choose a plan` · `View plans` · `View your plan options` · `Subscribe to {{company.name}}`\n- Keep: `Renew now` · `RENEW NOW` · `Resume subscription` · `Lock in Pro pricing` · `Keep writing`\n- Use what you bought: `Explore {{product.name}}` · `Chat with {{product.name}}` · `Try it now`\n- Retain instead of cancel: `Pause account` · `See a better option`\n- Manage (a text link, not a button): `billing settings` · `account dash` · `Manage your membership` · `Manage preferences`\n- Help: `Contact our support team` · `Help center` · `Contact sales` · `Read all the details`\n\nTwo to four words, verb first, no trailing punctuation. One primary button is\nthe norm and a fair number of renewal notices have none. Uppercase labels\nsurvive in streaming and security brands (`RESUME SUBSCRIPTION`, `RENEW NOW`)\nand nowhere else — sentence case elsewhere.\n\n**Keep the manage/update-payment route as an inline link.** In roughly\nfive out of six emails it is a link inside a sentence, and promoting it to a\nbutton changes what the email is asking for. Dunning is the exception: there,\n\"Renew now\" *is* the manage route and it is the loudest thing on the page.\n\n## Tone\n\n- State the fact in the first sentence. No build-up, no greeting-plus-story.\n- Put the amount and the date in one sentence, and bold that clause.\n- Name the payment method when you have it (\"your Visa ending in 3089\"). It is\n  the fastest way for a reader to confirm the email is really about them.\n- \"No action is required\" is the most repeated sentence in the renewal variant.\n  Say it whenever it is true.\n- Do not apologize for charging, and do not thank the reader twice.\n- Dunning is direct about the consequence and never shaming: \"you cannot\n  receive the latest updates\", not \"you failed to pay\".\n- Cancellation confirms first, gives the real end-of-access date second, and\n  offers exactly one reversal third.\n- Give the reversal a deadline when one exists (\"before August 1\").\n- Sign off from a team, not a person: \"— The {{company.name}} team\".\n- Exclamation marks appear in about a third of the headlines and almost never\n  in the money sentence.\n\n## Responsive and dark-mode idioms\n\n- 16 of the 30 lifecycle emails I checked put the container at **600px**. A\n  cluster of app-style emails uses **480px**, and the tail runs 520-680px. Read\n  the source's own width; do not assume.\n- Breakpoints cluster at 600 and 480, but the group also uses 460, 500, 520,\n  559 and 599. Set `\u003cx-base :mobile-breakpoint=\"N\"\u003e` from the source's own\n  `@media` boundary, and remember `max-width: 599px` means\n  `:mobile-breakpoint=\"600\"`.\n- Several sources set the card as `width: 96%; max-width: 600px` rather than a\n  fixed pixel width. Carta has no `max-width`, so express it as\n  ``:width=\"{ small: `100%`, medium: `\u003cthe px value\u003e` }\"`` and put the real cap\n  in `medium`.\n- Side padding shrinks: 48px becomes 30px in most of these sources. Express it\n  as ``:padding=\"{ small: `…30px…`, medium: `…48px…` }\"``.\n- Headlines shrink one step, not two: 38 → 32, 24 → 20, 20 → 13 in the three\n  sources I measured. `:font-size=\"{ small: 32, medium: 38 }\"`.\n- Icon-plus-text feature rows stay side by side at every width in this group.\n  Use `x-row` with `:break-point` below 375, and `x-wrap` for social and badge\n  runs.\n- **Dark mode is handled more often here than in receipts** — 8 of the 30\n  carried a `prefers-color-scheme` block, usually swapping the page fill to\n  black, the card fill to a dark grey, and the brand band to a lighter tint.\n  Reproduce it with ``:background=\"{ light: `…`, dark: `…` }\"`` on `x-base`, on\n  `x-section` and on each band that carries its own fill, and with\n  ``:src=\"{ light: `…`, dark: `…` }\"`` on a logo that would disappear.\n- Set an explicit background on `x-base`, on `x-section`, and on every band\n  whose fill differs from the one it inherits. A transparent band is what breaks\n  in a dark-mode client. A band that repeats the fill it already inherits adds\n  nothing.\n\n## The plan and price lockup\n\nThe money is the point of the email, so it is worth knowing the three ways the\ncorpus renders it. Pick by how much data you have, not by taste.\n\n**A. Bold inside a sentence (~70%, the default).** One `x-paragraph` with a\n`\u003cstrong\u003e` around the clause that carries the amount and the date. No box, no\nrule, no label.\n\n```html\n\u003cx-paragraph margin=\"0 0 24px\"\n  \u003eYou can always view and make updates to your plan at any time in your\n  \u003ca href=\"{{account.billing_url}}\" style=\"color: #006bff; text-decoration: underline\"\n    \u003ebilling settings\u003c/a\n  \u003e.\n  \u003cstrong\n    \u003eUnless you make any changes, your payment method will be charged\n    {{event.subscription.amount}} + tax (when applicable)\n    on\u0026nbsp;{{event.subscription.renews_at | date: \"%B %-d, %Y\"}}.\u003c/strong\n  \u003e\u003c/x-paragraph\n\u003e\n```\n\nThe `\u0026nbsp;` before the date is not decoration. It stops \"on\" from stranding at\nthe end of a line, which is the one wrap that makes a renewal sentence read\nwrong.\n\n**B. A label/value price stack (~20%).** A 12-13px muted plan name, then the\nprice at 24-32px, with the currency and the billing period set small beside it,\nthen a muted \"renews through …\" line. Two plans stacked make a monthly/annual\ncomparison. Build it as an `x-box` with an `x-paragraph` label, an\n`x-heading-3` for the number, and an `x-row :layout=\"[…]\" :break-point=\"1\"`\nwhen the unit sits beside the figure — never `x-html`, and never let the number\nand its unit stack.\n\n**C. A bordered key/value panel (~10%).** Account id, user, status, discount —\nfour to six rows of label on the left and bold value on the right, striped or\nruled. This is the dunning variant's signature. Build it as repeated\n`x-row :layout=\"[50, 50]\" :break-point=\"1\"` inside one `x-box`. It only earns\n`x-html` if it is genuinely tabular and longer than about six rows.\n\nFields that recur, with Liquid names that are safe to propose. Use top-level\nevent attributes — write `{{event.renews_at}}`, not `{{event.data.renews_at}}`,\nunless the payload really nests a `data` object.\n\n| Field             | Token                                                        | Seen in |\n| ----------------- | ------------------------------------------------------------ | ------- |\n| Plan or tier name | `{{event.subscription.plan_name}}`                           | ~60%    |\n| Amount            | `{{event.subscription.amount}}`                              | ~70%    |\n| Currency          | `{{event.subscription.currency}}`                            | ~25%    |\n| Billing period    | `{{event.subscription.interval}}`                            | ~35%    |\n| Renewal date      | `{{event.subscription.renews_at \\| date: \"%B %-d, %Y\"}}`     | ~70%    |\n| Trial end date    | `{{event.trial_ends_at \\| date: \"%B %-d, %Y\"}}`              | ~30%    |\n| Access-ends date  | `{{event.subscription.access_ends_at \\| date: \"%b %-d, %Y\"}}` | ~20%    |\n| Payment method    | `{{event.subscription.payment_method}}`                      | ~25%    |\n| Billing page      | `{{account.billing_url}}`                                    | ~85%    |\n| Cancel page       | `{{account.cancel_url}}`                                     | ~30%    |\n\nNever let the amount wrap. When the figure sits in its own cell, add\n`style=\"white-space: nowrap;\"` to that `x-paragraph`.\n\n## Example: trial-ended upgrade with a plan comparison\n\nSubject: `Your free trial has ended`\nPreheader: `We recommend the Standard plan for the way your team works.`\n\nThis is the **trial-ending upgrade** variant, complete: a logo band above the\ncard, a decorative header strip, a headline, two paragraphs that name the\nrecommended plan, a tinted free-versus-paid comparison table, one filled\nbutton, a softer text link under it, a help band, and a rounded grey footer.\nIt carries a real CTA, which separates it from a renewal notice — a trial that\nends needs the reader to act. It has **no price**: the argument is what the\nreader keeps rather than what it costs. When your brief does have a figure, add\nthe form A money paragraph from § The plan and price lockup directly under the\ncomparison box. Change the header strip, the headline, the two paragraphs and\nthe six feature rows first. Everything else is chrome.\n\nThe comparison table is the one band here that is genuinely tabular — a header\nrow, per-column alignment, and column widths shared across seven rows — so it\nis the sanctioned use of `x-html`. Seven\nindependent `x-row`s cannot reproduce it: each row would size its own columns,\nand on a phone the three columns land in different places on every row. Keep\nthe escape hatch to the table itself and leave the tinted box, its radius and\nits padding as a real `x-box`.\n\n\u003c!-- Converted from a real trial-end exemplar. The conversion was pixel-graded\n     against the source render: score 1.000, with 0.0059% of desktop pixels and\n     0.0087% of mobile pixels differing. Three things in the markup below are the\n     reason it lands there, and all three are reusable:\n\n     1. The line-height multipliers carry a trailing digit on purpose.\n        `1.5333334` is 23px / 15px rounded *up*. Chrome lays out in 1/64px\n        units, so the exact ratio 1.5333333 computes to 22.99999 and floors to\n        22.98px, and a page loses a pixel every three or four lines. Round the\n        multiplier up, never down, whenever you are matching a source that sets\n        line-height in px.\n     2. `gap=\"0 20px\"` on the social `x-wrap` is the two-value form: vertical\n        first, then horizontal. A single `gap=\"20px\"` also puts 10px above and\n        below every icon, which pushed the whole footer 20px down.\n     3. `align=\"left\"` plus `text-align: left` on the first column of the\n        x-html table. A nested table in the source does not inherit the\n        `align=\"center\"` of the cell around it, but the same table inside an\n        `x-box text-align=\"center\"` does — so the labels centre unless you say\n        otherwise.\n\n     One caveat on what was graded: the grader's desktop viewport is 600px,\n     which is inside this source's own `@media (max-width: 600px)`. Both graded\n     viewports therefore use the `small` half of every responsive pair. The\n     `medium` values are the real desktop design and were read off the source\n     CSS, not verified against a screenshot.\n\n     The markup below was then simplified for idiom and accessibility per review,\n     so it no longer reproduces the source pixel for pixel. The score describes\n     the original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#F5F5F5\"\n  color=\"#0A2540\"\n  font-family=\"Helvetica, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.125\"\n  :mobile-breakpoint=\"601\"\n\u003e\n  \u003cx-section width=\"620px\" padding=\"0 10px 20px\" data-layer-label=\"Email\"\u003e\n    \u003cx-image src=\"https://placehold.co/302x73\" alt=\"{{company.name}}\" href=\"https://example.com/\" width=\"168px\" align=\"center\" margin=\"20px 0 14px\" data-layer-label=\"Logo\" /\u003e\n\n    \u003cx-box background=\"#FFFFFF\" border-radius=\"25px 25px 0 0\" data-layer-label=\"Card\"\u003e\n      \u003cx-image src=\"https://placehold.co/1200x170\" alt=\"Upgrade to keep using the premium features you love\" width=\"100%\" /\u003e\n\n      \u003cx-box :padding=\"{ small: `0 15px`, medium: `0 50px` }\" text-align=\"center\" data-layer-label=\"Body\"\u003e\n        \u003cx-heading-1 margin=\"0 0 20px\" :font-size=\"36\" :line-height=\"1.5\" font-weight=\"bold\" text-align=\"center\"\u003eUpgrade to keep using the premium features you love! \u003c/x-heading-1\u003e\n\n        \u003cx-paragraph margin=\"0 0 30px\" :font-size=\"15\" :line-height=\"1.5333334\" text-align=\"center\"\u003eYour 14-day trial may have ended, but your journey with {{company.name}} is just getting started. You can keep using our free plan, but based on how much you have built so far, \u003cb\u003ethe \u003ca href=\"https://example.com/\" style=\"color: #006bff; font-weight: bold; text-decoration: none;\"\u003eStandard\u003c/a\u003e plan seems like a perfect match for you\u003c/b\u003e.\u003c/x-paragraph\u003e\n\n        \u003cx-paragraph margin=\"0 0 25px\" :font-size=\"15\" :line-height=\"1.5333334\" text-align=\"center\"\u003eCheck out the top features you\u0026rsquo;ll get on the \u003ca href=\"https://example.com/\" style=\"color: #006bff; font-weight: bold; text-decoration: none;\"\u003eStandard\u003c/a\u003e plan:\u003c/x-paragraph\u003e\n\n        \u003cx-box background=\"#E6F0FF\" border-radius=\"10px\" padding=\"25px\" data-layer-label=\"Plan comparison\"\u003e\n          \u003cx-html\u003e\n            \u003ctable border=\"0\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" style=\"width: 100%;\"\u003e\n            \u003ctr\u003e\n              \u003ctd valign=\"top\" width=\"60%\" align=\"left\" style=\"padding: 0 0 15px; text-align: left;\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003e\u003cb\u003eFeatures\u003c/b\u003e\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003e\u003cb\u003eFree\u003c/b\u003e\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003e\u003cb\u003eStandard\u003c/b\u003e\u003c/p\u003e\u003c/td\u003e\n            \u003c/tr\u003e\n            \u003ctr\u003e\n              \u003ctd valign=\"top\" width=\"60%\" align=\"left\" style=\"padding: 0 0 15px; text-align: left;\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003eConnected accounts\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003e1\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003e6\u003c/p\u003e\u003c/td\u003e\n            \u003c/tr\u003e\n            \u003ctr\u003e\n              \u003ctd valign=\"top\" width=\"60%\" align=\"left\" style=\"padding: 0 0 15px; text-align: left;\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003eProject types\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003e1\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003eUnlimited\u003c/p\u003e\u003c/td\u003e\n            \u003c/tr\u003e\n            \u003ctr\u003e\n              \u003ctd valign=\"top\" width=\"60%\" align=\"left\" style=\"padding: 0 0 15px; text-align: left;\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003eCreate shared and team-wide projects\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"margin: 0;\"\u003e-\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"margin: 0;\"\u003e\u003cimg src=\"https://placehold.co/1600x1600\" alt=\"Included\" width=\"20\" style=\"vertical-align: middle; max-width: 100%;\"\u003e\u003c/p\u003e\u003c/td\u003e\n            \u003c/tr\u003e\n            \u003ctr\u003e\n              \u003ctd valign=\"top\" width=\"60%\" align=\"left\" style=\"padding: 0 0 15px; text-align: left;\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003eAutomate email and text reminder workflows\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"margin: 0;\"\u003e-\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"margin: 0;\"\u003e\u003cimg src=\"https://placehold.co/1600x1600\" alt=\"Included\" width=\"20\" style=\"vertical-align: middle; max-width: 100%;\"\u003e\u003c/p\u003e\u003c/td\u003e\n            \u003c/tr\u003e\n            \u003ctr\u003e\n              \u003ctd valign=\"top\" width=\"60%\" align=\"left\" style=\"padding: 0 0 15px; text-align: left;\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003eAdd your branding to every shared page\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"margin: 0;\"\u003e-\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"margin: 0;\"\u003e\u003cimg src=\"https://placehold.co/1600x1600\" alt=\"Included\" width=\"20\" style=\"vertical-align: middle; max-width: 100%;\"\u003e\u003c/p\u003e\u003c/td\u003e\n            \u003c/tr\u003e\n            \u003ctr\u003e\n              \u003ctd valign=\"top\" width=\"60%\" align=\"left\" style=\"text-align: left;\"\u003e\u003cp style=\"color: #0a2540; margin: 0 0 12px; font: 15px/23px Helvetica, sans-serif;\"\u003eCollect payments with a payment provider\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"margin: 0;\"\u003e-\u003c/p\u003e\u003c/td\u003e\n              \u003ctd valign=\"top\" width=\"20%\" align=\"center\"\u003e\u003cp style=\"margin: 0;\"\u003e\u003cimg src=\"https://placehold.co/1600x1600\" alt=\"Included\" width=\"20\" style=\"vertical-align: middle; max-width: 100%;\"\u003e\u003c/p\u003e\u003c/td\u003e\n            \u003c/tr\u003e\n            \u003c/table\u003e\n          \u003c/x-html\u003e\n        \u003c/x-box\u003e\n\n        \u003cx-cta href=\"https://example.com/\" background=\"#006BFF\" color=\"#FFFFFF\" font-family=\"Helvetica, Arial, sans-serif\" :font-size=\"18\" :line-height=\"1.1666667\" font-weight=\"bold\" padding=\"14px 30px\" border-radius=\"50px\" margin=\"30px 0 20px\"\u003eUpgrade now\u003c/x-cta\u003e\n\n        \u003cx-paragraph margin=\"0 0 30px\" :font-size=\"15\" :line-height=\"1.5333334\" text-align=\"center\"\u003eNeed a little more info? \u003ca href=\"https://example.com/\" style=\"color: #006bff; font-weight: bold; text-decoration: none;\"\u003eWatch this video overview\u003c/a\u003e to determine the best plan for your needs.\u003c/x-paragraph\u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box background=\"#EBEBEB\" :padding=\"{ small: `26px 15px 25px`, medium: `26px 50px 25px` }\" data-layer-label=\"Help\"\u003e\n      \u003cx-paragraph margin=\"0\" color=\"#000000\" :font-size=\"15\" :line-height=\"1.5\" text-align=\"center\"\u003e\u003cb\u003eNeed help?\u003c/b\u003e \u0026nbsp;\u003ca href=\"https://example.com/\" style=\"color: #006bff; text-decoration: underline;\"\u003eHelp Center\u003c/a\u003e \u0026#8204; | \u0026#8204; \u003ca href=\"https://example.com/\" style=\"color: #006bff; text-decoration: underline;\"\u003eCommunity\u003c/a\u003e \u0026#8204; | \u0026#8204; \u003ca href=\"https://example.com/\" style=\"color: #006bff; text-decoration: underline;\"\u003eFAQs\u003c/a\u003e \u0026#8204; | \u0026#8204; \u003ca href=\"https://example.com/\" style=\"color: #006bff; text-decoration: underline;\"\u003eContact Us\u003c/a\u003e\u003c/x-paragraph\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box background=\"#EBEBEB\" border-style=\"solid\" border-width=\"0 0 1px 0\" border-color=\"#AAAAAA\" data-layer-label=\"Divider\"\u003e\n      \u003cx-paragraph margin=\"0\" :font-size=\"1\" :line-height=\"1\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box background=\"#EBEBEB\" border-radius=\"0 0 25px 25px\" :padding=\"{ small: `28px 15px 50px`, medium: `28px 50px 50px` }\" data-layer-label=\"Footer\"\u003e\n      \u003cx-image src=\"https://placehold.co/302x73\" alt=\"{{company.name}}\" href=\"https://example.com/\" width=\"168px\" align=\"center\" margin=\"0 0 10px\" /\u003e\n\n      \u003cx-paragraph margin=\"0 0 25px\" :font-size=\"15\" :line-height=\"1.5\" text-align=\"center\"\u003e\u003ca href=\"https://example.com/\" style=\"color: #006bff; font-weight: bold; text-decoration: none;\"\u003eMore than a single workspace.\u003c/a\u003e\u003c/x-paragraph\u003e\n\n      \u003cx-wrap gap=\"0 20px\" align-content=\"center\" data-layer-label=\"Social\"\u003e\n        \u003cx-image src=\"https://placehold.co/48x48\" alt=\"LinkedIn\" href=\"https://example.com/\" width=\"24px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/48x48\" alt=\"Instagram\" href=\"https://example.com/\" width=\"24px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/48x48\" alt=\"YouTube\" href=\"https://example.com/\" width=\"24px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/48x48\" alt=\"X\" href=\"https://example.com/\" width=\"24px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/48x48\" alt=\"Facebook\" href=\"https://example.com/\" width=\"24px\" /\u003e\n      \u003c/x-wrap\u003e\n\n      \u003cx-paragraph margin=\"24px 0 10px\" color=\"#000000\" :font-size=\"14\" :line-height=\"1.5\" font-weight=\"300\" text-align=\"center\"\u003e123 Example Street, Suite 100, Springfield, ST 00000\u003c/x-paragraph\u003e\n\n      \u003cx-paragraph margin=\"0\" color=\"#000000\" :font-size=\"14\" :line-height=\"1.5\" font-weight=\"300\" text-align=\"center\"\u003eThis email was sent to {{customer.email}}. \u003ca href=\"{% unsubscribe_url %}\" style=\"color: #000000; text-decoration: underline;\"\u003eUpdate your notification settings or unsubscribe\u003c/a\u003e\u003c/x-paragraph\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                        | Token to use instead                                       |\n| --------------------------------------------- | ---------------------------------------------------------- |\n| `background=\"#F5F5F5\"` on `x-base`            | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"`    |\n| `background=\"#FFFFFF\"` on the card            | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`           |\n| `background=\"#EBEBEB\"` on the help and footer | `:background=\"globalStyles.colors.surfaceAlt_\u003cid\u003e\"`        |\n| `background=\"#E6F0FF\"` on the comparison box  | `:background=\"globalStyles.colors.surfaceAccent_\u003cid\u003e\"`     |\n| `color=\"#0A2540\"` on `x-base`                 | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`               |\n| `background=\"#006BFF\"` on the `x-cta`         | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`           |\n| `color: #006bff` on the inline links          | `:color=\"globalStyles.colors.link_\u003cid\u003e\"` on the `\u003ca\u003e` copy |\n| `border-color=\"#AAAAAA\"` on the divider       | `:border-color=\"globalStyles.colors.border_\u003cid\u003e\"`          |\n| `border-radius=\"25px 25px 0 0\"` on the card   | `:border-radius=\"globalStyles.radius.large_\u003cid\u003e\"`          |\n| `border-radius=\"10px\"` on the comparison box  | `:border-radius=\"globalStyles.radius.medium_\u003cid\u003e\"`         |\n| `padding=\"25px\"` on the comparison box        | `:padding=\"globalStyles.spacing.medium_\u003cid\u003e\"`              |\n| `font-family=\"Helvetica, sans-serif\"`         | `font-family=\"\"` when a global style already sets it       |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks. The `font:` and\n`color:` declarations inside the `x-html` table are not reachable by a token —\nif you tokenise the rest of the email, either leave the table as literal values\nor lift the shared ones into the `x-box` around it and delete them from each\ncell.\n\n**What to cut when the brief is simpler.** Delete the comparison `x-html` and\nthe tinted `x-box` around it, and the email is a plain trial-end notice: logo,\nstrip, headline, two paragraphs, one button. Delete the help band and its\ndivider `x-box` as a pair — the divider only reads as a divider because the\nband above it is the same grey. Drop the social `x-wrap` and the tagline\nparagraph and the footer is two lines of legal text.\n\n**What to add when the brief needs it.** A money figure goes in one\n`x-paragraph` under the comparison box, form A from § The plan and price\nlockup. A deadline goes in the same paragraph, never in the headline, because\nthe headline is already carrying the trial. A second, quieter route out —\n\"cancel instead\", \"compare all plans\" — goes in the help band, not next to the\nbutton.\n\n**What not to copy.** `alt=\"Included\"` on the checkmark image is doing real\nwork: a bare checkmark column reads as an empty cell in a screen reader, and\nthe `-` in the free column is a literal hyphen rather than an em dash for the\nsame reason. Keep both. The `\u0026#8204;` characters around the footer pipes are\nthe source's zero-width non-joiners, which stop some clients from linkifying\nthe separators — harmless to keep, safe to delete.\n","email_patterns/colleague_announcement.md":"---\nname: Colleague Announcement Email Pattern\ndescription: A named individual introducing themselves to existing colleagues, or a leader introducing an incoming teammate, without turning the message into a customer welcome.\n---\n\n# Colleague Announcement\n\n## Sender → recipient frame\n\nDefault frame: **a named individual or incoming teammate → existing colleagues**. The person named in the brief is the one joining; the recipients already belong to the organization. Preserve that direction in the subject, greeting, pronouns, body, and sign-off.\n\nIf a leader or people team is explicitly announcing somebody else, use **leader or people team → existing colleagues about the incoming teammate** instead. Do not switch between that third-person frame and a first-person note inside one email.\n\nAn automation called “welcome,” “onboarding,” or “new hire” does not change the frame. Never welcome the recipients to the team or imply that they are the new joiners unless the brief says they are.\n\n## Anatomy\n\nUse a concise personal-letter shape:\n\n1. Subject naming the introduction or the sender's new role.\n2. Preheader adding one useful detail rather than repeating the subject.\n3. Optional quiet wordmark or existing portrait; omit it when no suitable asset exists.\n4. Greeting to the existing colleagues.\n5. One short opening that names who is joining and in what role.\n6. Work focus: only the responsibilities, background, or priorities supplied in the brief.\n7. Optional personal note: only the family details or interests supplied in the brief.\n8. Personal sign-off in the sender's voice.\n\nNo marketing CTA, feature grid, offer band, postal address, unsubscribe link, or support navigation unless the user explicitly asks for it. An invitation such as “say hello when you see me” stays plain copy unless the user supplies a real destination and wants a button.\n\nFor a two-email sequence, make the narrative arcs genuinely different:\n\n- Work email: role → supplied background → supplied focus → brief sign-off.\n- Personal email: small bridge from the first note → supplied family/interests → warm sign-off.\n\nDo not repeat one generic introduction under different headings.\n\n## Voice and copy boundaries\n\n- First-person request (“from me,” “I'm Jordan”) → first-person singular throughout: “I'm joining…”, “I'll focus…”, “Outside work, I…”.\n- Third-person announcement request → one consistent announcer voice: “Maya joins us…”; do not fabricate quotes from Maya.\n- Existing colleagues are peers, not leads or customers. Prefer natural prose over onboarding language, promotional claims, or a benefit-card stack.\n- Use the brief as a closed fact set. Never invent the person's name, role title, employer history, education, family members, locations, hobbies, quotes, calendar plans, or contact details. A requested “background,” “family,” or “interests” section does not supply those facts. When the user asks you to create the draft now, use clearly editable copy such as `[Add professional background]` or `[Add family and interests]`; otherwise ask for details. Never turn those category words into plausible specifics or generic claims.\n- When tone guidance is absent, use warm conversational professionalism with restrained enthusiasm. Avoid “thrilled,” “game-changing,” emoji, resume-style headings such as “About My Role,” and decorative interest cards.\n\n## Subject and preheader patterns\n\nFirst-person subjects:\n\n- A quick hello from your new [role]\n- Hello from [name]\n- Looking forward to working with you\n\nThird-person subjects:\n\n- Meet [name], our new [role]\n- Welcoming [name] to [team]\n\nPreheaders should add the supplied focus or personal detail:\n\n- A short introduction and what I'll focus on first.\n- A little about my background before we start working together.\n- Two things I enjoy outside work.\n\nDo not use “Welcome to the team” as the email's main heading when the recipients are existing colleagues; it leaves the direction of welcome unclear.\n\n## Layout guidance\n\nKeep the hierarchy quiet: one heading, body copy, and a sign-off. Use one content column and the workspace's existing type, spacing, and radius tokens. A subtle divider or one restrained accent band is enough. Do not turn responsibilities, employers, family facts, or hobbies into interchangeable cards just to fill the page.\n\nIf the user asks for an image, follow SKILL.md step 1e. Reuse an uploaded or workspace asset, including a suitable asset listed in `BRAND.md`. When none fits, keep the email text-only and tell the user the image request remains unresolved. Never generate a person's likeness or persist a placeholder without explicit direction.\n","email_patterns/content_education.md":"---\nname: Content \u0026 Education Email Patterns\ndescription: What a content or educational email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, teaching-block conventions, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Content \u0026 Education Emails\n\nA content email teaches something and asks for nothing but attention. It names\nwhat the reader will learn, delivers a usable part of it inside the email, and\nlinks to the rest — a report, a recipe, a guide, a course, a webinar. The\ngive-first structure is what separates it from a promotion that happens to\ncontain facts.\n\nThe patterns below come from ~32 ranked exemplars in a 695-email\ncontent-and-education set (subject and description metadata for all 32, rendered\nscreenshots for 11, full HTML for 2). The set is a wide mix: B2B research\nreports, recipes, how-to guides, financial explainers, and university and\nnon-profit education. **Prevalence figures are eyeballed from those 32 emails,\nnot counted across the full set.** The group also overlaps heavily with\n**newsletter_digest.md** — when the send is recurring and has an issue identity,\nuse that file instead.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n1  logo band       ~always  logo, small, left or centered\n2  hero            ~70%     cover art for the asset, a photo, or an illustration\n3  headline        ~90%     what you will learn, or the title of the asset\n4  lede            ~85%     1-3 sentences on what is inside and who it is for\n5  primary CTA     ~85%     \"Read now\", \"Get the guide\", \"Watch the video\"\n6  teaching block  ~75%     the give: a bulleted takeaway list, 2-4 steps, or a recipe\n7  proof or stat   ~35%     one number, one quote, or a chart image\n8  secondary items ~40%     2-3 related resources with title + one line + link\n9  closing line    ~45%     a one-sentence conclusion or a sign-off\n10 secondary CTA   ~40%     repeats the primary, or points at a hub\n11 contact block   ~25%     \"questions?\", a phone number, an office-hours line\n12 social row      ~60%     3-5 linked icons\n13 footer legal    ~always  address, preference link, unsubscribe\n```\n\n**Headline.** Two shapes: the promise (\"Your ticket to the media showdown is\nhere\") and the question (\"What actually keeps people motivated at work?\"). Both\nare longer than a promotion headline — 6 to 12 words is normal — and they are\nset left-aligned more often than centered, which is the opposite of every other\ntype.\n\n**Lede.** Says what is inside and, usually, who it is for. It is the band that\ncarries the audience qualifier (\"for advertisers\", \"for small business owners\").\n\n**Teaching block.** The band that defines the type. Three renderings recur:\n\n- a bulleted `x-list` of four or five takeaways, each one sentence;\n- a numbered how-to with two to four steps, each with a heading and a line of\n  copy;\n- a resource row: title, a one-line summary, a \"read time\", and a link.\n\nA reader who never clicks should still have learned something from this band.\nThat is the intent, and it is why the list sits *below* the first CTA in most of\nthe corpus rather than above it.\n\n**Proof.** One statistic set large, a customer quote, or a chart image. It is\nevidence for the teaching block, not social proof for a product.\n\n**Contact block.** More common here than in any other type, because educational\nsends come from institutions — a support line, an enrolment team, office hours.\n\n## Structural variants\n\n**Report or asset delivery** (~30%). Cover art, a title, one lede, a button, then\na bulleted list of the findings, then a closing line. Pick this for a\ngated-content follow-up or a research drop. This is the variant the example below\nfollows.\n\n**How-to / recipe** (~25%). Hero photo, a short intro, then numbered steps with\ntheir own images, then a \"shop the ingredients\" or \"see the full method\" button.\nPick this for food, craft, and product-usage content.\n\n**Explainer letter** (~25%). Logo, a greeting, four to eight paragraphs with\nbold inline links, one or two buttons mid-body, an icon row of contact options,\nand a signed close. Pick this for education, finance, and healthcare.\n\n**Resource digest** (~20%). Two or three cards, each an image beside a title, a\ntwo-line summary, and a text link, plus one hub button. Pick this when the email\nis a shelf of assets rather than one lesson.\n\n## Subject lines\n\n| Move                             | Pattern                                                     |\n| -------------------------------- | ----------------------------------------------------------- |\n| Asset arrival (most common)      | `📥 It's here: your copy of {{asset.name}}`                 |\n| How-to                           | `The secret to silky cold foam`                             |\n| Question                         | `What would 30 days with {{product.name}} show you?`        |\n| Value framing                    | `The value of an online degree`                             |\n| List promise                     | `New data, trends, and tips for your business`              |\n| Named lesson                     | `How to cook with {{product.name}}`                         |\n| Curiosity                        | `You were this close to something better.`                  |\n| Seasonal hook                    | `Oh, hello, July!` (the month, spelled out)                 |\n| Direct instruction               | `Build your first {{feature.name}} with {{product.name}} ⚡` |\n| Ideas framing                    | `Ideas for meaningful {{topic}} study and discussion`       |\n\nSubjects run 30-60 characters and read as article titles. \"How\", \"what\", \"why\",\nand \"the secret to\" carry roughly a third of them. About a third carry one\nemoji, and the 📥 / 📊 / ⚡ family is more common here than the celebratory\nemoji used elsewhere. Discount language is absent.\n\n## Preheaders\n\n- `Our knockout report is ready for you.`\n- `Everything you need to get your ad dollars in the right spot.`\n- `A four-minute read, with the three numbers that matter.`\n- `Scroll down for the method — and the shortcut.`\n- `Plus: what to do when it does not go to plan.`\n- `Content article showing how to make it at home.`\n\nThe preheader states the payoff in plain terms: what the reader gets and roughly\nhow long it takes. Where a promotion preheader carries the offer, this one\ncarries the promise. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Read: `Read now` · `Read the full breakdown` · `Read the report`\n- Get: `Get the guide` · `Download the report` · `See the data`\n- Watch: `Watch the video` · `See how it works`\n- Do: `Try the recipe` · `Start your first project` · `Explore the calculator`\n- Explore: `Explore resources` · `See more tips` · `Browse the library`\n\nTwo to five words, sentence case, and almost always naming the format of the\nthing on the other side (\"report\", \"video\", \"guide\", \"recipe\"). Educational\nemails have the highest rate of *two* CTAs of any type — the same action at the\ntop and again after the teaching block — and the second is usually the same\nlabel, not a weaker variant.\n\n## Tone\n\n- Plain and declarative. The teaching band is written the way documentation is\n  written, not the way advertising is.\n- Numbers are specific and attributed: \"traditional TV time has declined (43%)\",\n  not \"TV is declining\".\n- Second person for the instruction, third person for the evidence.\n- One idea per bullet; bullets are full sentences and end without a period only\n  when they are fragments.\n- The closing line is a summary or a stance (\"Not-so-spoiler alert: digital\n  audio wins\"), and it is where the brand's voice is allowed to show.\n- No urgency, no scarcity. If a deadline exists it belongs in the fine print.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. This group's sources sit anywhere from 480px to\n  640px. Read the source's own `@media` boundary and set\n  `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match it.\n- **Read the source's utility classes, not only its inline styles.** Educational\n  templates from marketing-automation platforms carry class-per-value systems\n  (`.fs-30`, `.lh-31`, `.pt-30`, `.pb-50`, `.plr-25`) inside the media query.\n  The inline style is the *desktop* value and the class is the *mobile* value,\n  and when the email's own width sits below its breakpoint the class is what\n  actually renders. Converting the inline values alone reproduces a layout no\n  recipient ever sees.\n- Images inside those systems are switched by a class on the parent\n  (`.wi-100p img { width: 100% }`), not by an attribute on the image. Express it\n  as ``:width=\"{ small: `100%`, medium: `507px` }\"``.\n- `x-list` has no `margin` or `padding` prop. The rendered `\u003cul\u003e` keeps browser\n  defaults — `margin: 1em 0` and a 40px indent — where the source usually reset\n  them. Use the `style` prop:\n  `style=\"margin: 16px 0; padding: 0 0 0 40px;\"`. The `1em` is the list's own\n  inherited font-size, so a 14px list gains 14px, not 16px.\n- Dark backgrounds are common in this group (report covers, B2B research), so\n  give the page a `background` on `x-base` and set the dark value on the one box\n  that holds the card. Do not repeat it on every band. A band only needs its\n  own `background` when the value differs from the one it inherits.\n\n## The teaching block\n\nThe bulleted-takeaway shape, which is the most common of the three:\n\n```html\n\u003cx-box background=\"#000000\" color=\"#FFFFFF\" padding=\"1px 15px 23px 15px\"\u003e\n  \u003cx-paragraph :font-size=\"14\" margin=\"0\"\n    \u003eTake a peek at some of the insights headed your way:\u003cbr /\u003e\u003cbr /\u003e\u003c/x-paragraph\u003e\n  \u003cx-list\n    list-style=\"disc\"\n    :font-size=\"14\"\n    style=\"margin: 16px 0; padding: 0 0 0 40px;\"\n  \u003e\n    \u003cli\u003eOne finding, stated as a full sentence with its number in it\u003c/li\u003e\n    \u003cli\u003eA second finding that contrasts with the first\u003c/li\u003e\n    \u003cli\u003eA third that the reader can act on today\u003c/li\u003e\n  \u003c/x-list\u003e\n  \u003cx-paragraph :font-size=\"14\" margin=\"0\"\n    \u003e\u003cbr /\u003eThe one-sentence conclusion.\u003c/x-paragraph\u003e\n\u003c/x-box\u003e\n```\n\nThree things to keep:\n\n- **The intro sentence and the list are separate components.** The source ends\n  the intro with `\u003cbr /\u003e\u003cbr /\u003e`; keep those inline rather than converting them to\n  an `x-spacer`.\n- **The list takes its styling from its ancestors, not from the paragraph above\n  it.** Siblings do not inherit from each other. Set on `x-list` only the values\n  that differ from the box or the base — here the 14px size.\n- **The closing line leads with a `\u003cbr /\u003e`**, which is how the source separates\n  it from the list without a margin. A trailing `\u003cbr\u003e` adds no height; a leading\n  one adds a full line.\n\n## Example: report delivery\n\nSubject: `📥 It's here: your copy of {{asset.name}}`\nPreheader: `Our knockout report is ready for you.`\n\nThis is the **report or asset delivery** variant: a dark card, a small logo, an\nanimated cover, a left-aligned headline, a one-sentence lede, a pill button, a\nbulleted takeaway list with a closing line, and a footer with a rule, a social\nrow, and stacked legal links. Change the cover art, the headline, the lede, and\nthe four bullets first.\n\n\u003c!-- Converted from a real content exemplar. The conversion was pixel-graded\n     against the source render: score 0.267, with 20.0% of desktop pixels and\n     22.0% of mobile pixels differing. **This is the weakest of the six graded\n     examples in this directory.** The residual is vertical, and it comes from the\n     source's class-per-value system (see § Responsive above): the email's own\n     width sits below its 617px breakpoint, so every padding and font-size in the\n     original is the mobile class value rather than the inline one, and a handful\n     of those classes are still unresolved here — mostly in the logo band and the\n     footer. The markup below was then simplified for idiom and accessibility per\n     review, so it no longer reproduces the source pixel for pixel. Treat it as a\n     correct *shape* and re-derive the paddings from the source when you convert\n     one of these. The score describes the original conversion, not this\n     markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#ffffff\"\n  color=\"#FFFFFF\"\n  font-family=\"Arial, Helvetica, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"618\"\n\u003e\n  \u003cx-section width=\"600px\" padding=\"\" data-layer-label=\"Email\"\u003e\n    \u003cx-box background=\"#000000\" data-layer-label=\"Card\"\u003e\n      \u003cx-image\n        src=\"https://placehold.co/240x60\"\n        alt=\"{{company.name}}\"\n        href=\"https://example.com/\"\n        width=\"80px\"\n        align=\"left\"\n        margin=\"30px 22px 0\"\n        data-layer-label=\"Logo\"\n      /\u003e\n\n      \u003cx-image\n        src=\"https://placehold.co/1114x600\"\n        alt=\"{{asset.name}} — report cover\"\n        width=\"100%\"\n        margin=\"15px 22px 50px\"\n        data-layer-label=\"Cover\"\n      /\u003e\n\n      \u003cx-box padding=\"1px 15px 2px 15px\" data-layer-label=\"Headline\"\u003e\n        \u003cx-heading-1 :font-size=\"30\" font-weight=\"700\" margin=\"0\"\n          \u003eYour ticket to the media showdown is here.\u003c/x-heading-1\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"20px 15px 23px 15px\" data-layer-label=\"Lede\"\u003e\n        \u003cx-paragraph :font-size=\"14\" margin=\"0\"\n          \u003eGet ready — your copy of {{asset.name}} is here, and it is filled with\n          everything you need to put your budget in the right\n          place.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-cta\n        href=\"https://example.com/report\"\n        background=\"#FFFFFF\"\n        color=\"#000000\"\n        :font-size=\"14\"\n        :line-height=\"1.2857\"\n        font-weight=\"700\"\n        padding=\"10px 25px\"\n        border-radius=\"99px\"\n        align=\"left\"\n        margin=\"10px 15px 48px\"\n        data-layer-label=\"CTA\"\n        \u003eRead now\u003c/x-cta\n      \u003e\n\n      \u003cx-box padding=\"1px 15px 23px 15px\" data-layer-label=\"Takeaways\"\u003e\n        \u003cx-paragraph :font-size=\"14\" margin=\"0\"\n          \u003eTake a peek at some of the insights headed your way:\u003cbr /\u003e\u003cbr /\u003e\u003c/x-paragraph\n        \u003e\n        \u003cx-list\n          list-style=\"disc\"\n          :font-size=\"14\"\n          style=\"margin: 16px 0; padding: 0 0 0 40px;\"\n        \u003e\n          \u003cli\u003eOver the last decade, traditional TV time has declined 43%, while time spent with audio is up 183%\u003c/li\u003e\n          \u003cli\u003eAudio ads have a 49% better impact on lasting memory than broadcast ads\u003c/li\u003e\n          \u003cli\u003eListeners over 18 say the channel is 1.6x more authentic and trustworthy than video\u003c/li\u003e\n          \u003cli\u003e51% of listeners say it is replacing time they used to spend on social media\u003c/li\u003e\n        \u003c/x-list\u003e\n        \u003cx-paragraph :font-size=\"14\" margin=\"0\"\n          \u003e\u003cbr /\u003eNot-so-spoiler alert: the numbers are not close.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"30px 15px 40px 15px\" data-layer-label=\"Footer\"\u003e\n        \u003cx-box\n          padding=\"29px 0 0 0\"\n          border-style=\"solid\"\n          border-width=\"1px 0 0 0\"\n          border-color=\"#545353\"\n        \u003e\n          \u003cx-row :layout=\"[60, 40]\" :gap=\"0\" :break-point=\"1\"\u003e\n            \u003cx-column padding=\"0 0 20px 0\" vertical-align=\"middle\"\u003e\n              \u003cx-paragraph :font-size=\"12\" font-weight=\"700\" margin=\"0 0 12px 0\"\n                \u003eStay ahead of industry trends\u003c/x-paragraph\n              \u003e\n              \u003cx-wrap gap=\"30px\" align-content=\"left\"\u003e\n                \u003cx-image src=\"https://placehold.co/42x42\" alt=\"LinkedIn\" href=\"https://linkedin.com/company/example\" width=\"21px\" /\u003e\n                \u003cx-image src=\"https://placehold.co/42x42\" alt=\"X\" href=\"https://x.com/example\" width=\"21px\" /\u003e\n                \u003cx-image src=\"https://placehold.co/42x42\" alt=\"Instagram\" href=\"https://instagram.com/example\" width=\"21px\" /\u003e\n                \u003cx-image src=\"https://placehold.co/42x42\" alt=\"Facebook\" href=\"https://facebook.com/example\" width=\"21px\" /\u003e\n              \u003c/x-wrap\u003e\n            \u003c/x-column\u003e\n            \u003cx-column vertical-align=\"top\"\u003e\n              \u003cx-image src=\"https://placehold.co/261x60\" alt=\"{{company.name}}\" href=\"https://example.com/\" width=\"87px\" align=\"right\" /\u003e\n            \u003c/x-column\u003e\n          \u003c/x-row\u003e\n\n          \u003cx-paragraph :font-size=\"12\" :line-height=\"1.8333\" font-weight=\"700\" margin=\"0 0 12px 0\"\n            \u003e\u003ca href=\"https://example.com/help\" style=\"color: #FFFFFF; text-decoration: none;\"\u003eHelp\u003c/a\u003e\u003c/x-paragraph\n          \u003e\n          \u003cx-paragraph :font-size=\"12\" :line-height=\"1.8333\" font-weight=\"700\" margin=\"0 0 12px 0\"\n            \u003e\u003ca href=\"{% unsubscribe_url %}\" style=\"color: #FFFFFF; text-decoration: none;\"\u003eUnsubscribe\u003c/a\u003e\u003c/x-paragraph\n          \u003e\n          \u003cx-paragraph :font-size=\"12\" :line-height=\"1.8333\" font-weight=\"700\" margin=\"0 0 10px 0\"\n            \u003e\u003ca href=\"https://example.com/view\" style=\"color: #FFFFFF; text-decoration: none;\"\u003eView in browser\u003c/a\u003e\u003c/x-paragraph\n          \u003e\n          \u003cx-paragraph :font-size=\"12\" :line-height=\"1.8333\" font-weight=\"700\" margin=\"0\"\n            \u003e© {{\"now\" | date: \"%Y\"}} {{company.name}}. All rights\n            reserved.\u003c/x-paragraph\n          \u003e\n        \u003c/x-box\u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                 | Token to use instead                                    |\n| -------------------------------------- | ------------------------------------------------------- |\n| `background=\"#ffffff\"` on `x-base`     | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#000000\"` on the card     | `:background=\"globalStyles.colors.surfaceDark_\u003cid\u003e\"`    |\n| `color=\"#FFFFFF\"` on `x-base`          | `:color=\"globalStyles.colors.onDark_\u003cid\u003e\"`              |\n| `background=\"#FFFFFF\"` on the CTA      | `:background=\"globalStyles.colors.onDark_\u003cid\u003e\"`         |\n| `color=\"#000000\"` on the CTA           | `:color=\"globalStyles.colors.surfaceDark_\u003cid\u003e\"`         |\n| `border-color=\"#545353\"` in the footer | `:border-color=\"globalStyles.colors.borderOnDark_\u003cid\u003e\"` |\n| `border-radius=\"99px\"` on the CTA      | `:border-radius=\"globalStyles.radius.pill_\u003cid\u003e\"`        |\n| `font-family` on `x-base`              | `font-family=\"\"` when a global style already sets it    |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**Turning this into a how-to.** Replace the `x-list` with three `x-box`es, each\nholding an `x-image` (the step photo, `width=\"100%\"`), an `x-heading-2` with the\nstep number and name, and an `x-paragraph` with the instruction. Keep one button\nabove the steps and repeat it below.\n\n**Turning this into a resource digest.** Replace the `x-list` with two or three\n`x-row`s at `:layout=\"[40, 60]\"`, `:break-point=\"480\"`: a thumbnail on the left,\nand a title, a two-line summary, and a text link on the right.\n\n**What to cut when the brief is simpler.** Delete the takeaway list and the\nclosing line. Logo, cover, headline, lede, button, footer is a bare asset-delivery\nemail — but it gives the reader nothing if they do not click, which is the one\nthing this email type is supposed to avoid.\n","email_patterns/event_webinar.md":"---\nname: Event and Webinar Email Patterns\ndescription: What an event or webinar email looks like in practice across the whole lifecycle — invite, confirmation, reminder, recap — with the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Event and Webinar Emails\n\nAn event email sells or services an appointment. It names the event, says when\nand where it happens, and asks the reader to hold that slot. That is one job,\nbut it is spread over four sends: the invitation, the registration\nconfirmation, one or more reminders as the date closes in, and the recap or\nreplay afterwards. The four share a skeleton and differ in which band carries\nthe weight — the invite leads with the hero, the reminder leads with the clock,\nthe confirmation leads with the join link, the recap leads with the replay\nbutton.\n\nThe patterns below come from a 566-email set. I read the pre-dumped metadata\nfor the top 35 ranked exemplars, scanned subject line, sender, and category\nmetadata for all 566, opened another ten `meta.json` files by hand, viewed 20\nrendered desktop screenshots, and read one `email.html` in full plus scripted\nstructure passes (style blocks, media queries, widths, preheaders, time-zone\nstrings, calendar links) over about 55 more. **Every prevalence figure below is\neyeballed from that sample, not counted.**\n\nTwo caveats about the sample. First, the ranking skews hard to B2B SaaS: one\nemail-tooling brand appears four times in the top 35, one CRM vendor's\nconference three times, and two automation vendors twice each. Consumer and\nculture events — a magazine festival, a dog show, a museum first-Saturday, a\nconcert-recommendation blast — are present but outnumbered. Second, the group\nis loosely bounded: a few members are ordinary newsletters that happen to\nmention an event. Weight the invite and reminder patterns confidently; treat\nthe consumer-ticketing patterns as thinner evidence.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below, pick the lifecycle stage from § Structural\nvariants, and build the date block from § The date, time, and location block.\n\n## Anatomy\n\n```text\n1  logo band          ~60%     centered logo, 20-32px padding — often absent because the hero carries it\n2  hero / title card  ~85%     event name as baked-in type, usually with speaker headshots or brand art\n3  date-time lockup   ~70%     tinted band, or a line under the title, or baked into the hero\n4  greeting           ~40%     \"Hi {{first_name}},\" on its own line\n5  headline           ~70%     one h1, 24-32px, centered in invites, left-aligned in letter-style sends\n6  primary CTA        ~always  one button: Register / Reserve / Save your seat / Join now\n7  body copy          ~90%     2-4 short paragraphs: the problem, who is on, what you leave with\n8  speaker row        ~35%     headshot + name + title, as an x-row or as one composite image\n9  agenda list        ~55%     \"You'll learn how to\" — icon+text rows, a bulleted list, or a session table\n10 repeat CTA         ~55%     same label as band 6, after the agenda\n11 replay promise     ~30%     \"Can't make it live? Register anyway and we'll send the recording.\"\n12 calendar / join    ~10%     rare in invites, near-universal in confirmations and hour-before reminders\n13 social row         ~55%     3-7 linked icons, 20-32px, centered\n14 footer             ~always  address, preference link, unsubscribe, 12px muted text\n```\n\n**Hero / title card.** This is the band that identifies the email. It is almost\nalways one image carrying the event name, the date, and often the speakers'\nfaces as baked-in type. That costs accessibility, so put the event name and\ndate in the image's `alt` and repeat the date as real text underneath — which\nis what most senders already do. Conference sends use a full-bleed art banner;\nwebinar sends use a 16:9 \"title card\" that looks like a video thumbnail.\n\n**Date-time lockup.** See § The date, time, and location block. Treat it as a\nrequired band even when the brief does not mention it.\n\n**Primary CTA.** One button, high in the email, above the body copy in about\nhalf the invites. That is unusual — most email types bury the button after the\nargument. Event emails put it directly under the date because a reader who\nalready knows the event does not need the argument.\n\n**Body copy.** Three paragraphs is the median: a sentence of premise, a\nsentence naming who is on stage and when, and a sentence on what the reader\nleaves with. Reminders cut this to one paragraph.\n\n**Speaker row.** Two renderings recur about equally: a real `x-row` with a\ncircular headshot in a narrow first column and name plus title in the second,\nand a single composite image with the headshots and names already laid out.\nPrefer the `x-row` when you have the assets separately — the composite image is\nunreadable on a phone.\n\n**Agenda list.** In webinars this is \"You'll learn how to:\" followed by three\nto six icon-and-text rows. In conferences it is a session or track list with a\nper-row link (\"Buy tickets\" / \"Join the wait list\"). The icon rows are a\ntwo-column `x-row`, not an `x-wrap` — the text has to wrap beside the icon, not\nunder it.\n\n**Replay promise.** A short tinted band at the bottom of webinar invites and\nreminders, always the same sentence, always with the register link inline\nrather than as a second button. It is worth keeping: it converts the readers\nwho cannot attend.\n\n**Footer.** Always an address and an unsubscribe. Event senders also tend to\nadd \"you are receiving this because you are subscribed to Events \u0026 Webinars\",\nwhich is a separate subscription topic from the main list — keep it if the\nsource has it.\n\n## Structural variants\n\n**Invitation card** (~40%, stage: invite). Hero title card, date band, headline,\none button, three paragraphs, an agenda list, a repeat button, a replay line.\nContent width 600px, one white card on a tinted page. Pick this for a single\nwebinar, workshop, or livestream with one session and one host team.\n\n**Conference promo** (~20%, stage: invite, repeated over months). Much longer:\nart banner, a value paragraph, then a stack of blocks — tracks with\nillustrations, attendee statistics, sponsor logos, a ticket-price urgency box,\nand three or four buttons that all point at the same registration page. Content\nwidth 600-800px. Pick this when the event is multi-day, multi-track, and the\nemail has to serve people at very different stages of deciding.\n\n**Reminder** (~20%, stage: T-minus days or hours). One screen. Header image or\nlogo, one paragraph (\"this is happening tomorrow\"), the date restated in bold\nor as a countdown, one button, a signature. Some carry an `x-countdown` timer\nat the top. Pick this for anything sent inside the last week, and cut every\nband that is not the clock and the button.\n\n**Registration confirmation** (~10%, stage: post-signup). Narrow — 500-520px is\ncommon where invites are 600px. Logo, \"You're Registered!\", the date and time,\nthe session title, then two buttons side by side: the join link and add to\ncalendar. A grey note follows about not forwarding the personal access link, or\nabout running a system check. Pick this for the transactional send after a\nsignup form.\n\n**Recap / replay** (~10%, stage: after). \"So that's a wrap on X\" or \"The\nreplay's up\". A watch button near the top, then the promised resources as a\nlinked list, then a personal sign-off with a headshot. Photo grids replace the\nhero in in-person event recaps. Pick this for the 24-to-72-hour follow-up, and\nfor the \"we missed you\" send to no-shows.\n\n## Subject lines\n\n| Move                       | Pattern                                                                                                |\n| -------------------------- | ------------------------------------------------------------------------------------------------------ |\n| Bracket tag plus topic     | `[Webinar] Put your AI agent to work with {{company.name}}` / `[Deep dive] More reliable ACH settlement` |\n| Straight invitation        | `You're invited to {{event.name}} 2026` / `You're invited: Deconstructing Fintech (Live Webinar)`        |\n| Named speaker              | `{{speaker.name}} is speaking at {{event.name}}` / `Announcing the full {{event.name}} lineup`           |\n| Format up front            | `Livestream: How to use agents to scale your growth engine` / `GitOps in Action: Demo + Live Q\u0026A`        |\n| Countdown, days out        | `2 days out: {{company.name}}'s actual GTM AI playbook` / `One week to go: the {{event.name}} keynote`   |\n| Countdown, tomorrow        | `Reminder: Surcharging webinar tomorrow` / `{{event.name}} is tomorrow. Last chance to register!`        |\n| Countdown, minutes         | `It's time! {{event.name}} livestream starts soon` / `Reminder: our webinar is in one hour! 🗓`          |\n| Registration confirmed     | `You are registered: {{event.name}}` / `📅 You've just registered for {{company.name}}'s event`          |\n| Last chance                | `Last Call: our favorite {{product.name}} features, live` / `24 hours left! RSVP today!`                 |\n| Tickets and price          | `Tickets Are Now On Sale` / `Grab your {{event.name}} ticket before prices increase`                     |\n| Schedule or agenda drop    | `The schedule is live—your badge is waiting` / `[Big news] 45+ sessions for marketers.`                  |\n| Replay and on demand       | `The replay's up: {{session.name}}, on demand` / `On demand now: The best of {{event.name}}`             |\n| Recap                      | `What happened in Rome` / `Your {{event.name}} recap is ready. Keep the momentum going.`                 |\n| No-show follow-up          | `👀 We didn't see you at {{event.name}}` / `We missed you at Summit`                                    |\n\nLength clusters at 32-51 characters, median around 41 — longer than a welcome\nsubject because the event name is usually in it. Roughly one in six carries an\nemoji, most often 🗓 📅 🚨 👀 or a party glyph, and it sits at the start when\nit is a clock or calendar and at the end when it is decoration. First-name\npersonalization is rare, under one in ten, and the stage marker does the work\ninstead: put the countdown phrase (\"2 days out\", \"tomorrow\", \"in one hour\") at\nthe very front, where it survives truncation. Bracketed tags — `[Webinar]`,\n`[Starting Tomorrow]`, `[Just released]` — appear on about one subject in\ntwenty and are worth using because they let a reader triage a sequence of sends\nabout the same event.\n\n## Preheaders\n\n- `Join us August 19 to learn the \"why\" behind this year's design trends.`\n- `Live Wednesday, 10 AM PT — the team opens the playbook.`\n- `12PM ET tomorrow. Come with questions.`\n- `July 23, 2026`\n- `Register for free. Learn how to turn AI pilots into real results.`\n- `Register now to watch live — May 19, 9am PT.`\n- `Save $800 and register today for only $1,499`\n- `Opening Keynote is up first`\n- `Thank you for signing up!`\n- `Missed it live? No worries, the workflows still work the same on replay.`\n- `Three days in Rome with our top European creators`\n\nAbout three in four carry one, which is higher than most types. The reason is\nstructural: the subject names the event and the preheader carries the\nappointment. When the subject is a topic, the preheader is almost always the\ndate, the time, and the zone — sometimes nothing else, as in `July 23, 2026`.\nKeep it under about 90 characters and do not repeat the subject.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Register (invite): `Register now` · `Register for free` · `Reserve your spot` · `Save your seat` · `Save your spot` · `RSVP` · `Claim your free ticket` · `Get your badge →`\n- Register with a price: `Reserve your ticket for $99` · `Register now to lock in your spot` · `Buy tickets` · `Join the wait list`\n- Browse first: `Explore agenda` · `View the schedule` · `See the lineup` · `Learn more »`\n- Join (confirmation and day-of): `Join now` · `Enter the event` · `Watch now` · `Tune in`\n- Calendar: `Add to calendar` · `iCalendar` · `Google` · `Outlook` · `Yahoo`\n- After: `Watch the replay` · `Watch on demand` · `Read the full story` · `See upcoming events`\n\nTwo to four words, sentence case far more often than uppercase, and a trailing\n`→` on roughly a quarter of them. Unlike most types, two or three buttons per\nemail is normal and they usually carry the **same** label pointing at the same\nURL — the reader is meant to be able to convert from wherever they stopped\nreading. Keep them identical rather than inventing variety. The only place a\ndifferent label belongs is the pair in a confirmation (join versus calendar)\nand the browse link in a conference promo.\n\n## Tone\n\n- Second person, present and future tense. \"You will walk through the real\n  workflows.\" The event has not happened yet, and the copy says so.\n- The first sentence states the premise or the problem, not the invitation. The\n  invitation is the button.\n- Name the humans. Speaker first name, last name, and title appear in the body\n  copy of most webinar invites, and the title is what does the persuading.\n- Say what the reader leaves with: a template, a recording, a checklist, a\n  playbook. This sentence appears in the majority of the invites.\n- Reminders get shorter and blunter as the clock runs down. The hour-before\n  send is two sentences and a link.\n- Urgency is stated as a fact, not shouted: \"Badge prices rise as we get\n  closer\", \"Tickets are still only $99, but not for long\".\n- Recaps are written in the first person by a named host, and they thank people\n  before they sell anything.\n- Exclamation marks are common in confirmations (\"You're Registered!\") and rare\n  in invites.\n\n## Responsive and dark-mode idioms\n\n- Content width is 600px more often than anything else, but 680px, 800px, and —\n  for confirmations — 500px all show up. Read the source's outermost fixed\n  width; do not assume.\n- Breakpoints observed across five sources: 420, 480, 520, 600, 639, and 680.\n  Set `\u003cx-base :mobile-breakpoint=\"N\"\u003e` from the source's own `@media` boundary.\n- An email whose own column is narrower than its breakpoint renders its\n  **mobile** rules at every realistic viewport. A 600px email with an\n  `@media (max-width: 639px)` rule is one of these: the `small` half of every\n  responsive object is what a reader actually sees, so put the correct value\n  there and treat `medium` as documentation.\n- The most common responsive rule in these sources is the side gutter: 40px or\n  25px on desktop collapsing to 20px. Express it as\n  ``:padding=\"{ small: `8px 20px`, medium: `8px 25px` }\"``.\n- Agenda icon rows do not stack — a 30px icon beside wrapping text is the same\n  layout at 375px as at 600px. Use `x-row` with a small `:break-point` and a\n  percentage `:layout`, then pin the icon column with\n  `style=\"min-width: 30px;\"` on the `x-image`. Without it the percentage column\n  shrinks below the icon on a phone and the icon renders at half size.\n- Set an explicit `background` on `x-base` and on every band whose color differs\n  from the page — the white card bands, the tinted date band, the replay-note\n  band. Do not repeat a value the band already inherits. Very few of these\n  emails rely on a transparent band.\n- Two of the five sources I read ship real dark-mode blocks, and both do the\n  same thing: swap a light logo for a dark one with paired\n  `.light-img` / `.dark-img` classes and repaint the page and card backgrounds.\n  Carta expresses the swap as ``x-image :src=\"{ light: `…`, dark: `…` }\"`` and the\n  repaint as ``:background=\"{ light: `…`, dark: `…` }\"`` on the box.\n- Web fonts are loaded for real in most of these sources — conference brands\n  ship display faces. Check for the `\u003clink\u003e` or `@font-face` before you copy a\n  `font-family`, and drop any name the source only hinted at — Carta matches\n  only the first name in the stack against its Google Fonts catalog and injects\n  a `\u003clink\u003e` the original never had.\n\n## The date, time, and location block\n\nThis is the band that makes an event email an event email, and it is the one\nmost likely to be wrong after a conversion.\n\n**Three renderings recur.** A tinted full-width band directly under the hero,\nwith a small calendar icon and one bold line — the most common, and the easiest\nto keep editable. A plain line of text under the title, inside the hero's own\ncolored block. And a labelled pair inside a bordered card, usually near the\nsecond CTA:\n\n```text\nWhen:  September 30, 2026\nWhere: SFJAZZ Center, San Francisco\n```\n\nBuild the first as an `x-wrap` — an icon and a text label that must stay on one\nline — not as an `x-row`. `x-wrap` centers the pair as a unit the way the\nsource's shrink-to-fit table does. A label with no destination is an `x-cta`\nwith no `href`, which Carta renders as a styled label.\n\n**Always carry a time zone.** Nearly every email whose time I could read as\nlive text named one, and about half of the US B2B sends named two, in the shape\n`April 29 | 8:00 am PT / 11:00 am ET`. One named three. Never write a bare\n`11:00` — pick the sender's home zone and, if the audience is national, add the\nsecond. A recurring alternative is to give the date only and let the landing\npage localize the time.\n\n**The date is usually baked into the hero image as well.** That is fine, and it\nis why the text band exists — but it means changing the date means re-rendering\nthe image. Say so when you hand the email back.\n\n**Add-to-calendar is a confirmation feature, not an invite feature.** It is\nalmost absent from the invites in this sample and near-universal in the\nregistration confirmations, in one of two shapes: a single secondary button\nnext to the join button, or a four-button row labelled `iCalendar`, `Google`,\n`Outlook`, `Yahoo`. Both are an `x-wrap` of `x-cta`s. The links are ordinary\n`https://` URLs — a Google Calendar template URL, an `.ics` download — so\nnothing special is needed to carry them across.\n\n**In the hour-before reminder, the join link appears twice**: once as the\nbutton and once as the full URL in visible text, on its own line, so the reader\ncan copy it into another device. Keep the full URL as text. Do not shorten it,\nand do not turn it into a \"click here\".\n\n**Countdown timers** appear on last-chance reminders. Carta has a real\ncomponent for them: `\u003cx-countdown time=\"2026-07-22T16:00:00Z\" resolution=\"D\" /\u003e`.\nUse it instead of importing the source's animated GIF, which is a tracked\nthird-party image with an expiry.\n\n## Example: invitation card\n\nSubject: `[Webinar] Bring your favorite tools into one place`\nPreheader: `Learn the workflows our team uses every day, live and hands on`\n\nThis is the **invitation card** variant, and it is the shape to reach for when\nsomeone says \"webinar invite\" with no further detail: hero title card, tinted\ndate band, headline, button, three paragraphs, six agenda rows, a repeat\nbutton, the replay promise, and a footer. Change the hero image, the date line,\nthe headline, the three paragraphs, and the six agenda rows first. The card\ngeometry — 600px column on a 10px gutter, white bands with a rounded bottom cap\nover a `#fafafd` page — is the part worth keeping.\n\n\u003c!-- Converted from a real webinar-invite exemplar. The conversion was pixel-graded\n     against the source render: 0.46% of desktop pixels and 4.17% of mobile pixels\n     differed, desktop score 0.995, overall score 0.861 (the grader scores the worst\n     viewport). Genericized below: real copy replaced, images swapped for\n     placehold.co at the source's natural dimensions. The markup was then simplified\n     for idiom and accessibility per review, so it no longer reproduces the source\n     pixel for pixel. The score describes the original conversion, not this\n     markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#fafafd\"\n  color=\"#000000\"\n  font-family=\"Arial, Helvetica, sans-serif\"\n  :font-size=\"15\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"640\"\n\u003e\n  \u003cx-section width=\"600px\" padding=\"0 10px 20px 10px\" data-layer-label=\"Email\"\u003e\n    \u003cx-image\n      src=\"https://placehold.co/1200x677\"\n      alt=\"Live Workshop: build your first automated workflow\"\n      href=\"https://example.com/webinar\"\n      width=\"100%\"\n      margin=\"20px 0 0\"\n      data-layer-label=\"Hero\"\n    /\u003e\n\n    \u003cx-wrap\n      background=\"#ddeaff\"\n      gap=\"0px\"\n      align-content=\"center\"\n      vertical-align=\"middle\"\n      :padding=\"{ small: `11.5px 20px`, medium: `11.5px 40px` }\"\n      data-layer-label=\"Date band\"\n    \u003e\n      \u003cx-image\n        src=\"https://placehold.co/46x46\"\n        alt=\"Calendar icon\"\n        width=\"23px\"\n        margin=\"0 16px 0 0\"\n      /\u003e\n      \u003cx-cta\n        background=\"#ddeaff\"\n        color=\"#000000\"\n        :font-size=\"15\"\n        :line-height=\"1.15\"\n        font-weight=\"bold\"\n        padding=\"0\"\n        margin=\"0\"\n        border-radius=\"0\"\n        \u003eFriday, August 21st at 11:00 A.M. ET\u003c/x-cta\n      \u003e\n    \u003c/x-wrap\u003e\n\n    \u003cx-box background=\"#FFFFFF\" padding=\"10px 0 0 0\" data-layer-label=\"Intro\"\u003e\n      \u003cx-box padding=\"5px 20px 6px\"\u003e\n        \u003cx-heading-1 :font-size=\"28\" text-align=\"center\" margin=\"0\"\n          \u003eBring your favorite\u003cbr /\u003etools into one place\u003c/x-heading-1\n        \u003e\n      \u003c/x-box\u003e\n      \u003cx-cta\n        href=\"https://example.com/webinar\"\n        background=\"#2949E5\"\n        color=\"#FFFFFF\"\n        :font-size=\"20\"\n        :line-height=\"1.15\"\n        font-weight=\"bold\"\n        padding=\"12px 33px\"\n        border-radius=\"25px\"\n        align=\"center\"\n        margin=\"5px 20px 10px\"\n        \u003eReserve your spot\u003c/x-cta\n      \u003e\n      \u003cx-box\n        :padding=\"{ small: `10px 20px 5px`, medium: `10px 25px 5px` }\"\n        data-layer-label=\"Body copy\"\n      \u003e\n        \u003cx-paragraph margin=\"0\"\n          \u003eA tool can answer questions. It is far more useful when it works\n          directly on the files your team already keeps.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e\n        \u003cx-paragraph margin=\"0\"\n          \u003eJoin us for a live, hands-on session where you will connect your\n          editor, your notes, and the rest of your stack to one shared\n          workspace. Then you will learn the workflows that help you sort your\n          library, reuse what you have, and retire the repetitive steps you do\n          every week.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e\n        \u003cx-paragraph margin=\"0\"\n          \u003eBring your own account* and follow along with us. By the end of the\n          session your workspace will be connected to your library, and you will\n          have a starter pack of templates you can keep using after the\n          call.\u003cbr /\u003e\u003cbr /\u003eBy the end of our time you\u0026rsquo;ll learn how\n          to:\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-row\n      :layout=\"[8.33, 91.67]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      background=\"#FFFFFF\"\n      :padding=\"{ small: `8px 20px`, medium: `8px 25px` }\"\n      data-layer-label=\"Learn 1\"\n    \u003e\n      \u003cx-column padding=\"0 15px 0 0\" vertical-align=\"middle\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Link icon\"\n          width=\"30px\"\n          style=\"min-width: 30px;\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"middle\"\u003e\n        \u003cx-paragraph :font-size=\"16\" margin=\"0\"\n          \u003e\u003cspan style=\"font-size: 15px;\"\u003eConnect your workspace to your own\n          library\u003c/span\u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-row\n      :layout=\"[8.33, 91.67]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      background=\"#FFFFFF\"\n      :padding=\"{ small: `8px 20px`, medium: `8px 25px` }\"\n      data-layer-label=\"Learn 2\"\n    \u003e\n      \u003cx-column padding=\"0 15px 0 0\" vertical-align=\"middle\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Accessibility icon\"\n          width=\"30px\"\n          style=\"min-width: 30px;\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"middle\"\u003e\n        \u003cx-paragraph :font-size=\"16\" margin=\"0\"\n          \u003e\u003cspan style=\"font-size: 15px;\"\u003eAudit what you have for consistency\n          and accessibility\u003c/span\u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-row\n      :layout=\"[8.33, 91.67]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      background=\"#FFFFFF\"\n      :padding=\"{ small: `8px 20px`, medium: `8px 25px` }\"\n      data-layer-label=\"Learn 3\"\n    \u003e\n      \u003cx-column padding=\"0 15px 0 0\" vertical-align=\"middle\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Reuse icon\"\n          width=\"30px\"\n          style=\"min-width: 30px;\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"middle\"\u003e\n        \u003cx-paragraph :font-size=\"16\" margin=\"0\"\n          \u003e\u003cspan style=\"font-size: 15px;\"\u003eTurn long-form material into\n          ready-to-share pieces\u003c/span\u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-row\n      :layout=\"[8.33, 91.67]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      background=\"#FFFFFF\"\n      :padding=\"{ small: `8px 20px`, medium: `8px 25px` }\"\n      data-layer-label=\"Learn 4\"\n    \u003e\n      \u003cx-column padding=\"5px 15px 0 0\" vertical-align=\"top\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Tag icon\"\n          width=\"30px\"\n          style=\"min-width: 30px;\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"top\"\u003e\n        \u003cx-paragraph :font-size=\"16\" margin=\"0\"\n          \u003e\u003cspan style=\"font-size: 15px;\"\u003eMake bulk updates to your library,\n          like titles, tags, and captions, in minutes\u003c/span\u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-row\n      :layout=\"[8.33, 91.67]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      background=\"#FFFFFF\"\n      :padding=\"{ small: `8px 20px`, medium: `8px 25px` }\"\n      data-layer-label=\"Learn 5\"\n    \u003e\n      \u003cx-column padding=\"0 15px 0 0\" vertical-align=\"middle\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Chart icon\"\n          width=\"30px\"\n          style=\"min-width: 30px;\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"middle\"\u003e\n        \u003cx-paragraph :font-size=\"16\" margin=\"0\"\n          \u003e\u003cspan style=\"font-size: 15px;\"\u003eGather the numbers you need and share\n          them in a single click\u003c/span\u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-row\n      :layout=\"[8.33, 91.67]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      background=\"#FFFFFF\"\n      :padding=\"{ small: `8px 20px`, medium: `8px 25px` }\"\n      data-layer-label=\"Learn 6\"\n    \u003e\n      \u003cx-column padding=\"0 15px 0 0\" vertical-align=\"middle\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Report icon\"\n          width=\"30px\"\n          style=\"min-width: 30px;\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"middle\"\u003e\n        \u003cx-paragraph :font-size=\"16\" margin=\"0\"\n          \u003e\u003cspan style=\"font-size: 15px;\"\u003eAutomate the reporting you rebuild by\n          hand after every event\u003c/span\u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-box\n      background=\"#FFFFFF\"\n      :padding=\"{ small: `10px 20px 5px`, medium: `10px 25px 5px` }\"\n      data-layer-label=\"Closing copy\"\n    \u003e\n      \u003cx-paragraph margin=\"0\"\n        \u003eDo not miss the shortest route to the automations and the reporting\n        that\u0026rsquo;ll make you the most useful person on the\n        team.\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e\n      \u003cx-paragraph margin=\"0\"\n        \u003e\u003cem\n          \u003e*Some steps are available to account owners and managers only, so\n          check your permissions first.\u003c/em\n        \u003e\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box background=\"#FFFFFF\" padding=\"20px\" data-layer-label=\"CTA\"\u003e\n      \u003cx-cta\n        href=\"https://example.com/webinar\"\n        background=\"#2949E5\"\n        color=\"#FFFFFF\"\n        :font-size=\"18\"\n        :line-height=\"1.1667\"\n        font-weight=\"bold\"\n        padding=\"12px 31px\"\n        border-radius=\"25px\"\n        align=\"center\"\n        margin=\"0\"\n        \u003eReserve your spot\u003c/x-cta\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#ECF2FF\"\n      border-radius=\"0 0 8px 8px\"\n      padding=\"20px 20px 25px 20px\"\n      data-layer-label=\"Replay note\"\n    \u003e\n      \u003cx-paragraph margin=\"0\" text-align=\"center\"\n        \u003eIf you can\u0026rsquo;t make it live,\n        \u003ca\n          href=\"https://example.com/webinar\"\n          style=\"color: #2949e5; font-weight: bold; text-decoration: underline\"\n          \u003eregister anyway\u003c/a\n        \u003e\n        and we'll send you the recording.\u0026nbsp;\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box color=\"#999999\" padding=\"20px 0\" data-layer-label=\"Footer\"\u003e\n      \u003cx-image\n        src=\"https://placehold.co/160x36\"\n        alt=\"{{company.name}}\"\n        width=\"80px\"\n        align=\"center\"\n        margin=\"10px 20px\"\n      /\u003e\n      \u003cx-box padding=\"0 20px 10px\"\u003e\n        \u003cx-heading-2\n          :font-size=\"12\"\n          :line-height=\"1.75\"\n          font-weight=\"bold\"\n          text-align=\"center\"\n          margin=\"0\"\n          \u003eSent with care from the {{company.name}} team\u003c/x-heading-2\n        \u003e\n        \u003cx-paragraph\n          margin=\"10px 0 0\"\n          font-family=\"Helvetica, Arial, sans-serif\"\n          :font-size=\"12\"\n          text-align=\"center\"\n          \u003eExample Inc, 120 Example St, Portland, OR 97204, USA\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0\" text-align=\"center\"\u003e\n          \u003ca\n            href=\"{% unsubscribe_url %}\"\n            style=\"font-family: Helvetica, Arial, sans-serif; font-size: 12px; color: #999999; font-weight: bold; text-decoration: underline;\"\n            \u003eUnsubscribe\u003c/a\n          \u003e\n          \u003ca\n            href=\"{% manage_subscription_preferences %}\"\n            style=\"font-family: Helvetica, Arial, sans-serif; font-size: 12px; color: #999999; font-weight: bold; text-decoration: underline;\"\n            \u003eManage preferences\u003c/a\n          \u003e\n        \u003c/x-paragraph\u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                           | Token to use instead                                    |\n| ------------------------------------------------ | ------------------------------------------------------- |\n| `background=\"#fafafd\"` on `x-base`               | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#FFFFFF\"` on the card bands         | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`        |\n| `background=\"#ddeaff\"` on the date band          | `:background=\"globalStyles.colors.accentSurface_\u003cid\u003e\"`  |\n| `background=\"#ECF2FF\"` on the replay note        | `:background=\"globalStyles.colors.mutedSurface_\u003cid\u003e\"`   |\n| `background=\"#2949E5\"` on both CTAs              | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`        |\n| `color=\"#000000\"` on `x-base`                    | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`            |\n| `color=\"#999999\"` on the footer box              | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`           |\n| `color=\"#2949e5\"` on the inline register link    | `:color=\"globalStyles.colors.link_\u003cid\u003e\"`                |\n| `border-radius=\"25px\"` on the CTAs               | `:border-radius=\"globalStyles.radius.pill_\u003cid\u003e\"`        |\n| `border-radius=\"0 0 8px 8px\"` on the replay note | `:border-radius=\"globalStyles.radius.medium_\u003cid\u003e\"`      |\n| `font-family` on `x-base`                        | `font-family=\"\"` when a global style already sets it    |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete the six agenda `x-row`s and\nput a plain `x-list` in the body box. Delete the second `x-cta` box and the\nreplay-note band, which also carries the card's rounded bottom cap. That leaves\nhero, date band,\nheadline, button, copy, footer — the **reminder** variant, six components long.\nTo go the other way, add a speaker `x-row` between the headline and the body\ncopy, with a circular headshot in the first column.\n\n**What to change for the other stages.** For a **registration confirmation**,\nnarrow `x-section` to `500px`, replace the hero with a centered logo, change\nthe headline to \"You're registered\", and put two `x-cta`s in an `x-wrap` under\nthe date band — the join link and `Add to calendar`. For an **hour-before\nreminder**, delete everything between the date band and the CTA, and add the\nfull join URL as a linked `x-paragraph` under the button. For a **recap**, swap\nthe hero for a photo grid `x-row`, change the button to `Watch the replay`, and\nturn the agenda rows into an `x-list` of the resources you promised.\n\n**The odd numbers are load-bearing.** `11.5px` and `[8.33, 91.67]` are not\ntypos. `11.5px` absorbs the vertical margin `x-wrap` adds around its items.\n`[8.33, 91.67]` is `45px ÷ 540px`, the icon column as a share of the content\nwidth. An earlier version of the example also carried a `19.7px` hero gutter and\na `1.6875` footer line height. The first corrected for the line box of an\n`x-hidden-text` preheader and the second reproduced the source's percentage line\nheight. Both are gone, so the hero gutter is a round `20px` and the footer\ninherits the base 1.5. The `:font-size=\"16\"` wrapper with a\n`\u003cspan style=\"font-size: 15px\"\u003e` inside each agenda row is the same kind of\nthing: the source's cell is 16px and only the text run is 15px, so the line box\nis 24px and not 22.5px — flatten the two and every two-line row loses 3px. When\nyou write a new email rather than convert one, use round numbers.\n","email_patterns/loyalty_referral.md":"---\nname: Loyalty and Referral Email Patterns\ndescription: What loyalty and referral emails look like in practice — the band-by-band anatomy of both halves, structural variants, real subject and preheader patterns, the reward lockup, and one converted Carta example to adapt.\n---\n\n# Loyalty and Referral Emails\n\nThis group holds two related jobs. A **loyalty** email tells a member where\nthey stand — points earned, points left to a reward, tier reached, member-only\nprices — and sends them back to spend. A **referral** email asks the member to\nbring a friend, and pays both sides for it. They share a skeleton: a reward\nnumber set large near the top, one instruction, a numbered \"how it works\" row,\nand a long terms footnote. They differ in who the reward is for.\n\nThe group holds 459 emails. What follows comes from the 35 pre-dumped exemplar\nmetas, 15 more metas pulled from the rest of the top-50 ranking, subject lines\nfor 111 slugs from the full group, 15 rendered desktop screenshots read end to\nend, and 5 full HTML files read structurally (plus scripted scans of 24 more\nfor breakpoints, widths, dark-mode rules and button labels). **Every prevalence\nfigure below is eyeballed from that sample. Nothing here was counted across all\n459.**\n\n**The ranking for this group is skewed and you should not trust the top of it.**\n\"Loyalty / Rewards\" is applied in the source archive as a soft tag, so the\nhighest-ranked exemplars include product launches, newsletters, office-closure\nnotices and birthday emails from brands that happen to run a rewards program.\nEmails with real points or referral mechanics cluster deeper in the slug list.\nThe screenshots and subject lines below were sampled deliberately across the\nwhole group, not off the top of the ranking.\n\nIndustry skew is real too. Airlines, banks, credit cards, quick-service\nrestaurants and travel platforms dominate the loyalty half, and they write\nlonger, denser, more legally hedged emails than the ecommerce brands that\ndominate the referral half. Read § Structural variants before you copy a tone.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n1  member identity strip  ~40%     name + member number or balance, small type, top right\n2  logo band              ~always  centered logo 78-160px wide, 10-24px padding\n3  hero / reward lockup   ~85%     \"Give $5, Get $5\" or \"You earned 650 points\" — image or 28-46px type\n4  balance or progress    ~30%     big numeral in a card, often a progress bar under it\n5  greeting               ~25%     \"Hi {{first_name}},\" on its own line, or folded into the headline\n6  body copy              ~always  1-3 short paragraphs: what the reward is and what unlocks it\n7  primary CTA            ~always  one button — Refer / Invite / Redeem / View rewards\n8  referral link or code  ~25%     bordered box holding the URL or code, full content width\n9  share row              ~20%     3-5 share icons, centered, in an x-wrap\n10 how it works           ~65%     3 numbered steps: share it, they act, you get paid\n11 reward ladder or perks ~40%     points-to-reward table, or 2-4 benefit rows with icons\n12 secondary CTA          ~45%     weaker repeat of band 7 — outlined, or a plain link\n13 terms footnote         ~90%     asterisked 10-12px muted paragraph, often very long\n14 footer                 ~always  logo, social row, app badges, address, unsubscribe\n```\n\n**Member identity strip.** The loyalty half opens with one more often than not:\nthe recipient's name and their member number or current balance, set at 11-14px\nand pushed to the right of the logo. It is a real two-column `x-row`\n(`:layout=\"[50, 50]\"` or `[40, 60]`), not a centered header. Build it with\n`{{customer.first_name}}` and the member id on separate lines in the right\ncolumn. The referral half almost never has one.\n\n**Reward lockup.** This is the loudest thing in the email and the reason the\nreader keeps scrolling. Referral emails set a symmetrical money phrase — \"Give\n$5, Get $5\", \"Refer a new Host, make $600\", \"Refer 2 friends. Get 1 free\". About\nhalf of them ship it as baked-in type inside an image, which costs\naccessibility; keep the phrase in the image's `alt` and add a real heading when\nyou have the copy as text. Loyalty emails set the number instead — \"You Earned\n/ 650 / Points\". See § The reward lockup.\n\n**Body copy.** One to three paragraphs, and short. The shape is fixed: name the\nreward, name the condition (\"when they complete their first qualifying stay\",\n\"book three times before July 31\"), then point at the button. Every referral\nemail in the sample carries a `*` or `**` that resolves in band 13.\n\n**Primary CTA.** One button, and it is never ambiguous about the action. The\nreferral half sends the reader into a share flow; the loyalty half sends them\nto a rewards page or a store. Full-width pill buttons and inline auto-width\nbuttons are about equally common.\n\n**How it works.** Near-universal in the referral half and common in the\nloyalty half — three numbered steps, occasionally five. The recurring rendering\nis a two-column `x-row` per step: a narrow first column holding a circled\nnumeral or an icon, a wide second column holding a bold label and one line of\ncopy. A minority set it as three side-by-side columns with an icon above each\nlabel. Do not build the numbered version as three stacked columns — the label\nand copy must stay beside the numeral on mobile as well.\n\n**Reward ladder.** Loyalty emails often print the exchange rate: 950 points =\n1 free day, 1,900 = 2 free days, and so on. It is a real data table with a\nlabel column and a value column, and it is one of the few places `x-html`\nearns its keep. Two or three rows can stay as `x-row`s.\n\n**Terms footnote.** The longest band in the email and the one people skip when\nconverting. Financial and airline senders run 300-800 words at 10-12px in a\nmuted grey. Reproduce it — it is the legal basis for the offer. Keep the\nasterisk count consistent with the body copy.\n\n## Structural variants\n\n**Give-get referral** (~30% of the emails in this group with real mechanics).\nLogo, a symmetrical money lockup, two or three lines of copy, one share button,\na lifestyle photo, a \"how it works\" row, terms, footer. The reward is stated\ntwice — once in the lockup and once in the copy. Pick this when the brief says\nrefer a friend, invite, share, or \"give X get X\".\n\n**Points statement** (~25%). Member identity strip, a big balance numeral in a\nbordered card, a progress bar, \"View rewards\", then what the balance can buy.\nTriggered by an event (a purchase posted, points confirmed, a monthly summary),\nso it reads as transactional and the copy is thin. Pick this when the brief has\na real number to show.\n\n**Bonus-points promotion** (~25%). The most common shape in the whole group and\nthe most promotional. A headline that is an arithmetic promise (\"2X points this\nweekend\", \"earn 60,000 bonus points\"), a deadline, one button, and a long terms\nblock. No balance and no how-it-works. Pick this when the brief is a limited\ntime multiplier or a challenge.\n\n**Tier and benefits** (~15%). \"Welcome to the Preferred Plus tier\", \"your\nmembership perks\". A hero, a tier name set as the headline, then bands 11 and\n12 doing the work — bulleted or icon-led benefit rows, sometimes a 2x2 image\ngrid. Longest of the variants. Pick this on enrollment, an upgrade, or an\nannual status reset.\n\n**Program launch explainer** (~10%). Announces the programme rather than a\nreward. Numbered `REFERRAL 01 / REFERRAL 02` sections, a paragraph each, one\nbutton near the bottom, and a signed sign-off. Pick this only when the reader\nhas never heard of the programme.\n\n## Subject lines\n\n| Move                     | Pattern                                                                                        |\n| ------------------------ | ---------------------------------------------------------------------------------------------- |\n| Symmetrical give-get     | `Give $5, Get $5` · `Refer 2 friends. Get 1 free {{company.name}} webcam.`                     |\n| Referral with the amount | `Earn $600 for every new Host you refer` · `Get 2,000 points for every friend you refer`       |\n| Referral as a question   | `Got Friends? Get Rewards! Refer Now!` · `Want 30 days of free Premium access? Refer a friend` |\n| Referral nudge           | `Don't forget: Refer more friends, get more rewards` · `Tell a friend about us. Get rewarded.` |\n| Balance earned           | `You earned 329 points toward FREE food 🎉` · `Congrats! Your rewards points are now confirmed.` |\n| Statement or summary     | `{{customer.first_name}}, check out your latest account summary 🌟` · `Your Rewards Statement is ready` |\n| Bonus multiplier         | `This weekend only, double points on all purchases*💰` · `Snag 2X the Points 🍓`               |\n| Bonus with a number      | `Earn up to 100,000 bonus points!` · `Get 2,500 {{company.name}} Rewards points`               |\n| Deadline                 | `⚡️ LAST CHANCE_ 2X Member Points ends soon!` · `$40 in Rewards expire soon`                   |\n| Tier or status           | `You've reached a new rewards status: Cucumber Connoisseur! 🥒` · `An Update Regarding Your Account Status` |\n| Member pricing           | `{{customer.first_name}}, here are your member discounts!` · `Blue members: Don't miss these benefits` |\n| Programme welcome        | `Welcome to {{company.name}} Rewards 👋` · `Our new loyalty program launches soon!`             |\n\nLength runs 20 to 55 characters, longer than most transactional types because\nthe number is usually in the line. **The number is the strongest single\npattern**: roughly two in three subjects carry a figure — a dollar amount, a\npoints total, or a multiplier. Emoji appear in about a third, higher than the\ncorpus average, and they cluster on 🎁 🎉 💰 ⚡️ 💳 rather than faces.\nFirst-name personalization appears in roughly a third of the loyalty half and\nis rare in the referral half; when it appears it opens the line.\n\n## Preheaders\n\n- `There is no chance in losing when you refer a friend!`\n- `Now's the time to invite a friend—or several`\n- `Share the love and get up to $500 in gift cards!`\n- `Simply share your link with your friends.`\n- `Our Refer-a-friend Program earns you extra rewards`\n- `Redeem for a FREE McChicken®, Cheeseburger and more.`\n- `Congrats! You earned 3,000 Rewards points.`\n- `It pays to dine out`\n- `Members get more. Double points week ends soon.`\n- `More points, more perks`\n- `Save on new favorites at member prices – online and in-store.`\n\nMost emails in this group carry one — noticeably more than the corpus average,\nbecause the subject spends its length on the number and the preheader has to\ncarry the mechanism. Two shapes recur: restate the payoff in plainer words, or\nname the condition the subject left out. Keep it under about 90 characters.\nSeveral senders pad the preheader with a long run of `͏ ` characters to stop\nthe client pulling body copy in after it; drop that padding when you convert,\nit is an ESP artifact and not part of the design.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Referral: `Refer a friend` · `Invite friends now` · `Start referring` ·\n  `Tell your friends` · `Get Invite Link` · `Invite now`\n- Redeem: `Redeem Points` · `View Rewards` · `Claim your reward` ·\n  `View all rewards` · `Get your free day`\n- Activate: `Activate the challenge` · `Opt in` · `Get started`\n- Member shopping: `Discover discounts` · `Shop What's New` · `Book now`\n- Learn: `Learn more` · `Find out more` · `See All Benefits` · `Explore the App`\n- App: `DOWNLOAD APP` · `Download the app`\n\nTwo to three words. Sentence case is the majority, but uppercase is more common\nhere than in other types — roughly a third, and it tracks the loudness of the\nlockup above it. No trailing punctuation except in the launch variant, which\nallows an exclamation mark. **Two buttons is normal in this group**, unlike\nwelcome or receipt emails: the referral half repeats the same action after the\nhow-it-works row, and the second button is the weaker of the two — outlined, or\na plain arrow link. Never let the two buttons say different things.\n\n## Tone\n\n- Second person, present tense, and specific about the number. \"You earned 650\n  points\", not \"you have earned rewards\".\n- The reward comes first and the condition second. Never bury the condition.\n- Symmetry is the referral half's whole rhetorical device: give X / get X, you\n  and your friend, win-win. Keep both sides in one sentence.\n- Short sentences, two to three lines per paragraph.\n- Exclamation marks are common — about half the emails carry one, more than in\n  any other transactional-adjacent type.\n- Quick-service restaurants, sportswear and travel apps write loud and use\n  uppercase; banks, airlines and insurers write plain and hedge every number\n  with an asterisk.\n- The terms footnote uses a different voice entirely, and that is correct. Do\n  not rewrite it into brand voice.\n- Never imply the reward is already banked when it is conditional. \"You could\n  earn\" and \"you earned\" are different emails.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint. Sources in this group cluster at `max-width: 480px` and again\n  at `596-640px`. Read the source's own boundary and set\n  `\u003cx-base :mobile-breakpoint=\"N\"\u003e` one above it — a `max-width: 620px` rule is\n  `:mobile-breakpoint=\"621\"`.\n- Content width is 600px in most of the group, with 620, 640 and 650 as the\n  runners-up. `x-section` defaults to 640px, so state it.\n- The commonest responsive rule is a font-size step on the lockup and the\n  button: a 21px paragraph dropping to 19px, a 15px button label dropping to\n  12px. Express it as `:font-size=\"{ small: 19, medium: 21 }\"`.\n- The second commonest is side padding on the hero image: 0 on desktop,\n  24-35px on mobile, which is the reverse of the usual gutter rule. Read it,\n  do not assume.\n- Roughly a quarter of the group ships a `prefers-color-scheme: dark` block,\n  which is high. Its usual content is a swapped logo and a forced background.\n  Set an explicit `background` on `x-base` and on `x-section` rather than relying\n  on the page colour, and use ``x-image :src=\"{ light: `…`, dark: `…` }\"`` for a\n  logo that would disappear. Do not repeat the value on every band — a band only\n  needs its own `background` when it differs from the one it inherits.\n- Numbered how-it-works rows must not stack. Set `:break-point` below 320 on\n  the numeral/text `x-row` so the numeral stays beside its label on a phone.\n  Share-icon rows and app-store badge rows belong in `x-wrap`, which never\n  stacks.\n- A full-bleed white band behind a coloured 600px column is common. That is\n  `x-section outer-background=\"#FFFFFF\"`, not a wider section.\n\n## The reward lockup\n\nThe one band this group has that no other type does. Three renderings recur,\nand they are not interchangeable.\n\n**The money lockup (referral).** A symmetrical phrase set at 32-46px, centered,\noften bold condensed, usually shipped as a full-content-width image because the\nface is not email-safe. Convert it as a single `x-image width=\"100%\"` with no\nside margin on desktop, and put the phrase in `alt`. When you have\nit as live text, it is one `x-heading-1` with `:line-height` near 1.05 — the two\nhalves of the phrase should sit tight.\n\n**The balance card (loyalty).** A white or near-white card inset in the band,\nholding a label (\"You Earned\"), the numeral at 45-64px, and the unit\n(\"Points\") at 20-28px under it. Build it as one `x-box` with its own background\nand a `border-radius`, holding three `x-paragraph`s at three sizes — not as a\ntable. The numeral is the only element in the email allowed to be that large.\n\n**The progress bar.** Under the card in about half the balance emails: a\n1-8px rule, part of it in a brand colour or gradient, the rest muted. Sources\nbuild it as a two-cell table with fixed widths. In Carta it is an `x-row`\n`:layout=\"[X, 100-X]\" :gap=\"0\" :break-point=\"0\"` with an `x-hr` in each column,\nor a single `x-hr` when the bar is decorative. Do not use a background image —\nit will not render in Outlook and the bar carries the meaning.\n\n**The referral link box.** Where a programme gives a shareable URL or code, it\nappears as an `x-box` with a 1px border, a contrasting background, 12-20px\npadding, and the URL as centered link text that is deliberately allowed to be\nugly and long. Set `style=\"word-break: break-all;\"` on the paragraph — these\nURLs overflow a 375px viewport otherwise. A share-icon row (Facebook,\nMessenger, WhatsApp, X, Telegram) sits under it in an `x-wrap`.\n\n## Example: give-get referral\n\nSubject: `Give $5, Get $5`\nPreheader: `There is no way to lose when you invite a friend.`\n\nThis is the **give-get referral** variant with the money lockup as an image:\nlogo, lockup, three lines of copy, one pill button, a full-width lifestyle\nphoto, a dark trust band, an app-links row, and a long terms footnote. It has\nno how-it-works row, which is the one band you would most likely add — put it\nbetween the photo and the trust band as three `x-row`s. Change the lockup\nimage, the paragraph, and the button label first. Everything else is chrome.\n\n\u003c!-- Converted from a real referral exemplar. The conversion was pixel-graded\n     against the source render: score 0.890, with 0.29% of desktop pixels and\n     3.31% of mobile pixels differing. The mobile figure does not converge\n     further, because the source lays its two full-width images out at a\n     fractional height that Carta rounds the other way, and everything below the\n     photo is then off by one pixel. Desktop is the honest number here. The\n     markup below was then simplified for idiom and accessibility per review, so\n     it no longer reproduces the source pixel for pixel. The score describes the\n     original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#C0C0C0\"\n  color=\"#000000\"\n  font-family=\"Arial, Arial, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"621\"\n\u003e\n  \u003cx-section\n    width=\"600px\"\n    padding=\"\"\n    background=\"#86B1DE\"\n    outer-background=\"#FFFFFF\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-image\n      src=\"https://placehold.co/150x24\"\n      alt=\"{{company.name}}\"\n      width=\"150px\"\n      align=\"center\"\n      margin=\"15px 0 10px\"\n      data-layer-label=\"Logo\"\n    /\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/600x75\"\n      alt=\"Give $5, Get $5\"\n      href=\"https://example.com/refer\"\n      width=\"100%\"\n      :margin=\"{ small: `25px 30px 0 30px`, medium: `25px 0 0 0` }\"\n      data-layer-label=\"Offer lockup\"\n    /\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `15px 35px`, medium: `20px 60px 15px 60px` }\"\n      data-layer-label=\"Body\"\n    \u003e\n      \u003cx-paragraph\n        :font-size=\"{ small: 19, medium: 21 }\"\n        text-align=\"center\"\n        margin=\"0\"\n        \u003eThe best part of a night out? Sharing it with your people. Laughing.\n        Singing. Losing your voice together. Refer a friend in the app and you\n        will both get $5 to spend on anything you like. Terms\n        apply.*\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-cta\n      href=\"https://example.com/refer\"\n      background=\"#19CE85\"\n      color=\"#000000\"\n      :font-size=\"{ small: 12, medium: 15 }\"\n      :line-height=\"2\"\n      font-weight=\"700\"\n      padding=\"12px 60px\"\n      border-radius=\"25px\"\n      align=\"center\"\n      margin=\"15px 10px 20px\"\n      data-layer-label=\"CTA\"\n      \u003eDOWNLOAD APP\u003c/x-cta\n    \u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/600x708\"\n      alt=\"Two friends celebrating together\"\n      href=\"https://example.com/refer\"\n      width=\"100%\"\n      margin=\"5px 0 0\"\n      data-layer-label=\"Photo\"\n    /\u003e\n\n    \u003cx-box\n      background=\"#15171F\"\n      color=\"#FFFFFF\"\n      border-style=\"solid\"\n      border-width=\"1px 0 0 0\"\n      border-color=\"#000000\"\n      :padding=\"{ small: `20px 24px 25px 24px`, medium: `30px 24px 25px 24px` }\"\n      data-layer-label=\"Trust\"\n    \u003e\n      \u003cx-image\n        src=\"https://placehold.co/78x76\"\n        alt=\"{{company.name}} guarantee\"\n        href=\"https://example.com/\"\n        width=\"78px\"\n        align=\"center\"\n      /\u003e\n      \u003cx-heading-3\n        :font-size=\"{ small: 16, medium: 20 }\"\n        font-weight=\"700\"\n        text-align=\"center\"\n        margin=\"10px 0 20px 0\"\n        \u003e\u003ca\n          href=\"https://example.com/\"\n          style=\"text-decoration: none; color: #ffffff;\"\n          \u003eEvery Order Backed End to End.\u003cbr /\u003e100% Satisfaction Guaranteed.\u003c/a\n        \u003e\u003c/x-heading-3\n      \u003e\n      \u003cx-paragraph\n        :font-size=\"{ small: 12, medium: 14 }\"\n        text-align=\"center\"\n        margin=\"0\"\n        \u003e\u003ca\n          href=\"https://example.com/\"\n          style=\"text-decoration: none; color: #ffffff;\"\n          \u003eLowest Price Guarantee · Free Order Cancellation · On-Time Delivery\n          Promise\u003c/a\n        \u003e\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#15171F\"\n      padding=\"24px 0 29px 0\"\n      data-layer-label=\"Footer\"\n    \u003e\n      \u003cx-paragraph :font-size=\"0\" margin=\"0\" text-align=\"left\"\n        \u003e\u003ca\n          href=\"https://example.com/ios\"\n          style=\"padding: 5px 25px; display: inline-block; color: #f8f3f3; font-family: 'Arial', 'Arial', Sans-serif; font-size: 18px; line-height: normal; text-decoration: none; letter-spacing: normal;\"\n          \u003eiOS\u003c/a\n        \u003e\u003cspan\n          style=\"font-size: 18px; font-family: 'Arial', 'Arial', Sans-serif; color: #f8f3f3;\"\n          \u003e|\u003c/span\n        \u003e\u003ca\n          href=\"https://example.com/android\"\n          style=\"padding: 5px 25px; display: inline-block; color: #f8f3f3; font-family: 'Arial', 'Arial', Sans-serif; font-size: 18px; line-height: normal; text-decoration: none; letter-spacing: normal;\"\n          \u003eAndroid\u003c/a\n        \u003e\u003cspan\n          style=\"font-size: 18px; font-family: 'Arial', 'Arial', Sans-serif; color: #f8f3f3;\"\n          \u003e|\u003c/span\n        \u003e\u003ca\n          href=\"https://example.com/\"\n          style=\"padding: 5px 25px; display: inline-block; color: #f8f3f3; font-family: 'Arial', 'Arial', Sans-serif; font-size: 18px; line-height: normal; text-decoration: none; letter-spacing: normal;\"\n          \u003eWeb\u003c/a\n        \u003e\u003c/x-paragraph\n      \u003e\n\n      \u003cx-box padding=\"24px 25px 0 24px\"\u003e\n        \u003cx-hr :height=\"1\" background-color=\"#000000\" margin=\"0 0 1px 0\" /\u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"34px 25px 10px\"\u003e\n        \u003cx-paragraph :font-size=\"10\" color=\"#8B8B90\" margin=\"0\"\n          \u003e*The credit may be used only on future purchases in the app. Each\n          code is valid for a single use per account and is not transferable to\n          another user or redeemable for cash. Your code may not be combined\n          with another coupon or discount code. We reserve the right to any\n          remedy, including denial of the credit or cancellation of your account\n          or orders, if fraud, tampering, or violations of our Terms \u0026amp;\n          Conditions are found to occur. Additional restrictions may\n          apply.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"10px 10px 10px 25px\" color=\"#8B8B90\"\u003e\n        \u003cx-paragraph :font-size=\"12\" margin=\"0\"\u003e{{company.name}}\u003c/x-paragraph\u003e\n        \u003cx-paragraph :font-size=\"12\" margin=\"0\"\n          \u003e548 Example St, PMB 70955\u003cbr /\u003ePortland, OR 97201\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph :font-size=\"12\" margin=\"0\"\n          \u003eTo unsubscribe,\n          \u003ca\n            href=\"{% unsubscribe_url %}\"\n            style=\"text-decoration: none; color: #8b8b90;\"\n            \u003eclick here\u003c/a\n          \u003e.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                      | Token to use instead                                      |\n| ------------------------------------------- | --------------------------------------------------------- |\n| `background=\"#C0C0C0\"` on `x-base`          | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"`   |\n| `outer-background=\"#FFFFFF\"` on the section | `:outer-background=\"globalStyles.colors.surface_\u003cid\u003e\"`    |\n| `background=\"#86B1DE\"` on `x-section`       | `:background=\"globalStyles.colors.brandTint_\u003cid\u003e\"`        |\n| `background=\"#15171F\"` on the footer bands  | `:background=\"globalStyles.colors.footerBackground_\u003cid\u003e\"` |\n| `color=\"#FFFFFF\"` on the trust band         | `:color=\"globalStyles.colors.textOnDark_\u003cid\u003e\"`            |\n| `background=\"#19CE85\"` on the `x-cta`       | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`          |\n| `color=\"#8B8B90\"` in the legal text         | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`             |\n| `border-color=\"#000000\"` on the trust band  | `:border-color=\"globalStyles.colors.border_\u003cid\u003e\"`         |\n| `border-radius=\"25px\"` on the `x-cta`       | `:border-radius=\"globalStyles.radius.pill_\u003cid\u003e\"`          |\n| `font-family` on `x-base`                   | `font-family=\"\"` when a global style already sets it      |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete the trust band and the app\nlinks `x-paragraph`; the divider, terms and address boxes are the minimum\nfooter. Replace the lockup `x-image` with an `x-heading-1` when you have the\ngive-get phrase as text. That leaves five bands, and it is still a complete\nreferral email.\n\n**What to add for the other half.** For a **points statement**, put a member\nidentity `x-row` above the logo band, replace the lockup image with the balance\ncard described in § The reward lockup, and change the button to `View rewards`.\nFor a **how it works** row, add three `x-row :layout=\"[12, 88]\" :gap=\"0\"\n:break-point=\"0\"` blocks after the photo, each with a circled numeral in the\nfirst column and a bold label plus one line of copy in the second.\n\n**The two odd numbers left are load-bearing.** `:font-size=\"0\"` on the app-links\nparagraph removes the whitespace between its three inline-block links.\n`margin=\"0 0 1px 0\"` on the `x-hr` reproduces the 1px line box the source's\ndivider cell holds under its border. An earlier version of this example also\ncarried `:line-height=\"0.01\"` on `x-base`, a `:font-size=\"0\"` on each image band,\nand a `:line-height=\"1.1\"` on the terms paragraph. Those three collapsed a\nhidden-text line box, removed inline-image leading, and matched a 10px line\npitch. All three are gone, so the text inherits the base 1.5. When you write a\nnew email rather than convert one, use round numbers.\n","email_patterns/milestones_recap.md":"---\nname: Milestone \u0026 Recap Email Patterns\ndescription: What a year-in-review, recap, or milestone email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, stat-tile conventions, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Milestone \u0026 Recap Emails\n\nA recap email gives the reader their own numbers back. It names the period,\nshows two to six personal statistics, adds a line of meaning to each, and offers\none way to see more or share. A milestone email is the same shape for a single\nnumber (\"you sent your millionth message\"), and a progress or impact report is\nthe same shape for the sender's numbers rather than the reader's.\n\nThe patterns below come from ~32 ranked exemplars in a 159-email milestone set\n(subject and description metadata for all 32, rendered screenshots for 12, full\nHTML for 2). **This is the smallest group in the corpus** and it is dominated by\nDecember \"year in review\" sends, so the seasonal shape is over-represented\nrelative to monthly recaps and one-off milestones. **Prevalence figures are\neyeballed from those 32 emails, not counted across the full set.**\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n1  logo band        ~always  small logo, centered\n2  hero             ~85%     campaign artwork with the year or period in it, often a GIF\n3  greeting         ~55%     \"{{customer.first_name}}, let's look back at your year\"\n4  intro            ~70%     1-2 sentences framing the period\n5  primary CTA      ~65%     \"See your stats\", \"Unwrap your recap\" — often above the numbers\n6  stat tiles       ~80%     1-6 numbers, each with a label above and a meaning line below\n7  narrative block  ~45%     a paragraph or a highlight card between groups of tiles\n8  personal detail  ~40%     a place, a favourite item, a top category — the \"you\" moment\n9  share row        ~35%     \"Share on X\", \"Screenshot this\", a social pair of buttons\n10 secondary CTA    ~45%     repeats the primary or points into the product\n11 disclaimer       ~35%     \"how we counted\" footnotes, in 12px muted text\n12 social row       ~55%     3-5 linked icons\n13 footer legal     ~always  address, preference link, unsubscribe\n```\n\n**Hero.** Nearly always carries the year or the period as baked-in type, and is\nanimated in about a third of the corpus. Keep the period in the `alt` text —\n`alt=\"Your 2025 in review\"` — because it is the only place the date survives for\na reader with images off.\n\n**Stat tiles.** The band this type exists for. The recurring construction is a\nsmall uppercase label, a very large number (42-56px, often in a heavy or\ncondensed face), and one line of meaning under it. Tiles come as a single\nfull-width tile, a 2-across pair, or a stacked run of icon+number rows.\n\n**Narrative block.** Recaps that run long break the tiles into groups with a\nparagraph or a highlight card between them, so the email reads as a story rather\nthan a dashboard.\n\n**Personal detail.** The band people screenshot: a top item, a favourite place,\na \"you were in the top 15% of riders\". It is usually one sentence with one bold\nnoun in it.\n\n**Share row.** Two buttons or two linked images. Where a promotion has \"shop\nnow\", a recap has \"share this\".\n\n**Disclaimer.** Recaps count things, and most of them explain how in 12px muted\ntext above the footer (\"messages sent includes email, SMS, and push\").\n\n## Structural variants\n\n**Teaser** (~30%). Hero with the year, one headline, one line of copy, one\nbutton into the app or the web experience. No numbers in the email at all. Pick\nthis when the real recap lives somewhere else — and note that it is the shortest\nemail in the whole corpus. This is the variant the example below follows.\n\n**Stat sheet** (~35%). Hero, greeting, then three to six stat tiles with meaning\nlines, a disclaimer, and one button. Pick this when the numbers can be sent in\nthe email.\n\n**Narrative recap** (~20%). Alternating tiles and story blocks, a personal\ndetail, a share row, then related content. The long form, 3,000-7,000px tall.\nPick this for an annual flagship send.\n\n**Impact or progress report** (~15%). The sender's numbers, not the reader's:\nproduction figures, emissions, funds raised, with an honest-tradeoffs section.\nPick this for sustainability, non-profit, and B2B annual reports.\n\n## Subject lines\n\n| Move                             | Pattern                                                     |\n| -------------------------------- | ----------------------------------------------------------- |\n| Possessive recap (most common)   | `Your {{year}} {{company.name}} Recap`                      |\n| Ready framing                    | `🎉 Your Year in Review is ready!`                          |\n| Named metric                     | `You've sent 4.9M messages this year 🎊`                    |\n| Stats framing                    | `The stats are in: your year with {{company.name}}`         |\n| First name plus period           | `{{customer.first_name}}, here's your {{year}} snapshot.`   |\n| Report framing                   | `🏆 {{year}} Report: explore the year in numbers`           |\n| Playful wrap                     | `Your recap is still wrapped 🎁`                            |\n| Personality tease                | `Reveal your {{year}} personality ✨`                        |\n| Monthly recap                    | `Your {{month}} in motion 💜`                               |\n| Progress report                  | `Are we a paradox?`                                         |\n\nTwo thirds contain \"your\", and about half contain the year or the month. This is\nthe most emoji-heavy type in the corpus — roughly 60% carry one, usually 🎉 🎁 ✨\n🏆 — and the emoji almost always sits at the end. Length clusters at 25-50\ncharacters. First-name personalization appears in about a third.\n\n## Preheaders\n\n- `Duo crunched the numbers…`\n- `Check out your team's power players`\n- `See how you compare with everyone else.`\n- `Your top categories, your best month, and one surprise.`\n- `A look back at what you built this year.`\n- `Here is how we counted it.`\n\nThe preheader teases one specific number or category without giving it away.\nEllipses are more common here than anywhere else, because the whole email is\nbuilt on a reveal. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Reveal: `See your stats` · `Unwrap your recap` · `Reveal your {{year}}`\n- Explore: `Explore your dashboard` · `Take a deeper dive` · `See the full report`\n- Act: `Update now` · `Let's go` · `Start {{year_next}} strong`\n- Share: `Share on X` · `Share your stats on LinkedIn` · `Screenshot and share`\n- Read: `Read the report` · `See the methodology`\n\nTwo to four words, and the verb is almost always about *seeing* rather than\nbuying. Uppercase labels appear in about a third. The share CTA, when present,\nis a second button of equal weight rather than a weaker one — sharing is the\ngoal of the send, not an afterthought.\n\n## Tone\n\n- Celebratory but attributive: the achievement belongs to the reader, and the\n  sender is the narrator.\n- Second person and past tense for the numbers, future tense for the close\n  (\"here is to next year\").\n- Specific over round: \"4.9M messages\", \"19 trips\", \"128 miles\" — never \"lots\".\n- One line of meaning per number, and it is comparative (\"that puts you in the\n  top 15%\") or milestone-based (\"you passed 4M this year\").\n- Progress reports invert the tone into honesty: the good numbers first, then\n  the ones that did not move, stated plainly.\n- The disclaimer is written in the same voice as the rest, not in legal register.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. This group's sources sit between 480px and\n  640px. Read the source's own `@media` boundary and set\n  `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match it.\n- **A percentage container width is common here** and it is worth reproducing:\n  a source that sets `.container { width: 95% }` below its breakpoint becomes\n  ``\u003cx-section :width=\"{ small: `95%`, medium: `600px` }\"\u003e``. Hardcoding 600px\n  leaves the email 30px too wide on every phone.\n- Hero and CTA artwork loses its side padding on mobile in about half the corpus\n  — ``:padding=\"{ small: `0 0 16px 0`, medium: `0 24px 16px 24px` }\"`` — because\n  the artwork is the point and a gutter shrinks it.\n- Stat tiles that sit two-across on desktop stack on mobile: `x-row` with\n  `:break-point=\"480\"`. A single full-width tile needs no row at all.\n- Very large numbers need a mobile size: `:font-size=\"{ small: 42, medium: 56 }\"`.\n  `:line-height` is not responsive, so pick the desktop ratio.\n- Explicit `background` on `x-base` and on `x-section`. Add one to a tile only\n  when its fill differs from the band it sits in. A recap leans on tinted tiles\n  with borders, and both disappear in dark mode when the background is left\n  transparent.\n- Animated heroes are GIFs. `x-image` supports PNG, JPG, and GIF only — an APNG\n  or a WebP in the source must be replaced, and that is a § Step 8 report item.\n\n## Stat tiles\n\nThe tile is three components in a bordered box, and it repeats.\n\n```html\n\u003c!-- One full-width tile --\u003e\n\u003cx-box\n  background=\"#e4ffce\"\n  color=\"#0e464d\"\n  border-style=\"solid\"\n  border-width=\"2.5px\"\n  border-color=\"#0e464d\"\n  border-radius=\"18px\"\n  padding=\"16px 16px 8px\"\n  margin=\"8px 0 14px\"\n\u003e\n  \u003cx-heading-3 :font-size=\"15\" :line-height=\"1\" :letter-spacing=\"0.05\"\n    text-align=\"center\" text-transform=\"uppercase\" margin=\"0 0 4px 0\"\u003eMessages sent\u003c/x-heading-3\u003e\n  \u003cx-paragraph :font-size=\"{ small: 42, medium: 56 }\" :line-height=\"1\" :letter-spacing=\"-0.025\"\n    font-weight=\"900\" text-align=\"center\" margin=\"0\"\n    \u003e{{stats.messages_sent | default: \"0\"}}\u003c/x-paragraph\u003e\n  \u003cx-paragraph :font-size=\"16\" text-align=\"center\"\n    margin=\"4px 0 8px\"\u003eYou passed the 4M milestone.\u003c/x-paragraph\u003e\n\u003c/x-box\u003e\n\n\u003c!-- A two-across pair, stacking on mobile --\u003e\n\u003cx-row :layout=\"[50, 50]\" :gap=\"14\" :break-point=\"480\"\u003e\n  \u003cx-column\u003e\u003c!-- tile --\u003e\u003c/x-column\u003e\n  \u003cx-column\u003e\u003c!-- tile --\u003e\u003c/x-column\u003e\n\u003c/x-row\u003e\n```\n\nThree rules:\n\n- **Guard every number.** `{{stats.trips | default: \"0\"}}`. A recap with a blank\n  where the number should be is worse than no recap.\n- **The label is a heading, the number is a paragraph.** The number is not a\n  heading — it is data — and making it an `x-heading-1` breaks the heading order\n  when there are six tiles.\n- **`:letter-spacing` is in em.** A 56px number with -1.4px tracking is\n  `:letter-spacing=\"-0.025\"`, not `-1.4`.\n\n## Example: teaser recap\n\nSubject: `🎉 Your Year in Review is ready!`\nPreheader: `We crunched the numbers…`\n\nThis is the **teaser** variant, and it is the shortest complete email in the\ncorpus: a small logo, one big piece of hero artwork, a headline, one line of\ncopy, one button, and a two-part footer. The numbers live in the app. Change the\nhero, the headline, and the button label first.\n\n\u003c!-- Converted from a real milestone exemplar. The conversion was pixel-graded\n     against the source render: score 0.955, with 0.90% of desktop pixels and\n     1.34% of mobile pixels differing. The two details that took it from 16% to\n     under 1%: the section width is a percentage below the breakpoint (95%, not\n     600px), and the hero loses its 24px side inset on mobile while the message\n     band keeps it. The markup below was then simplified for idiom and\n     accessibility per review, so it no longer reproduces the source pixel for\n     pixel. The score describes the original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#ffffff\"\n  color=\"#4B4B4B\"\n  font-family=\"Helvetica, Arial, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.625\"\n  :mobile-breakpoint=\"625\"\n\u003e\n  \u003cx-section\n    :width=\"{ small: `95%`, medium: `600px` }\"\n    padding=\"\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-image\n      src=\"https://placehold.co/220x52\"\n      alt=\"{{company.name}}\"\n      href=\"https://example.com/\"\n      width=\"110px\"\n      align=\"center\"\n      margin=\"30px 0 26px\"\n      data-layer-label=\"Logo\"\n    /\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/1104x770\"\n      alt=\"Your {{year | default: '2025'}} in review\"\n      href=\"https://example.com/recap\"\n      width=\"100%\"\n      :margin=\"{ small: `15px 0 16px`, medium: `15px 24px 16px` }\"\n      data-layer-label=\"Hero\"\n    /\u003e\n\n    \u003cx-box padding=\"20px 24px 48px\" data-layer-label=\"Message\"\u003e\n      \u003cx-heading-1\n        :font-size=\"25\"\n        font-weight=\"700\"\n        text-align=\"center\"\n        margin=\"0 0 20px 0\"\n        \u003eYour {{year | default: \"2025\"}} stats are ready\u003c/x-heading-1\n      \u003e\n      \u003cx-paragraph color=\"#777777\" text-align=\"center\" margin=\"0 0 20px 0\"\n        \u003eHow do you compare with everyone else this year?\u003c/x-paragraph\n      \u003e\n      \u003cx-cta\n        href=\"https://example.com/recap\"\n        background=\"#1CB0F6\"\n        color=\"#ffffff\"\n        width=\"260px\"\n        :line-height=\"2.5\"\n        :letter-spacing=\"0.03125\"\n        font-weight=\"700\"\n        padding=\"0\"\n        border-style=\"solid\"\n        border-width=\"1px\"\n        border-color=\"#1CB0F6\"\n        border-radius=\"9px\"\n        box-shadow=\"0 4px 0 0 #1899D6\"\n        align=\"center\"\n        margin=\"0\"\n        \u003eSEE YOUR STATS\u003c/x-cta\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-hr :height=\"1\" background-color=\"#EBEBEB\" margin=\"0\" /\u003e\n\n    \u003cx-box color=\"#AFAFAF\" padding=\"16px 24px\" data-layer-label=\"Footer\"\u003e\n      \u003cx-wrap gap=\"16px\" align-content=\"left\" padding=\"0 0 16px 0\"\u003e\n        \u003cx-image src=\"https://placehold.co/46x46\" alt=\"Instagram\" href=\"https://instagram.com/example\" width=\"23px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/46x46\" alt=\"X\" href=\"https://x.com/example\" width=\"23px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/46x46\" alt=\"Facebook\" href=\"https://facebook.com/example\" width=\"23px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/46x46\" alt=\"TikTok\" href=\"https://tiktok.com/@example\" width=\"23px\" /\u003e\n      \u003c/x-wrap\u003e\n      \u003cx-paragraph :font-size=\"13\" margin=\"0 0 10px 0\"\n        \u003e{{company.name}}, 123 Example Street, Portland, OR 97204, USA\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph :font-size=\"13\" margin=\"0\"\n        \u003eYou are receiving this email because you are subscribed to\n        {{company.name}} updates.\u003cbr /\u003e\u003ca href=\"{% unsubscribe_url %}\" style=\"color: #AFAFAF; text-decoration: underline;\"\u003eUnsubscribe\u003c/a\u003e\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                   | Token to use instead                                    |\n| ---------------------------------------- | ------------------------------------------------------- |\n| `background=\"#ffffff\"` on `x-base`       | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#ffffff\"` on `x-section`    | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`        |\n| `color=\"#4B4B4B\"` on `x-base`            | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`            |\n| `color=\"#777777\"` on the supporting line | `:color=\"globalStyles.colors.secondaryText_\u003cid\u003e\"`       |\n| `color=\"#AFAFAF\"` on the footer box      | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`           |\n| `background=\"#1CB0F6\"` on the CTA        | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`        |\n| `box-shadow=\"0 4px 0 0 #1899D6\"`         | keep — a hard offset shadow is rarely tokenised         |\n| `background-color=\"#EBEBEB\"` on `x-hr`   | `:border-color=\"globalStyles.colors.border_\u003cid\u003e\"`       |\n| `border-radius=\"9px\"` on the CTA         | `:border-radius=\"globalStyles.radius.medium_\u003cid\u003e\"`      |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**Turning this into a stat sheet.** Insert the stat-tile markup from § Stat tiles\nbetween the hero and the message band: one full-width tile for the headline\nnumber, then an `x-row` at `:layout=\"[50, 50]\"` with `:gap=\"14\"` and\n`:break-point=\"480\"` for the next two, then a 12px `x-paragraph` disclaimer\nexplaining how each number was counted. Keep the single button at the end.\n\n**Adding a share row.** After the message band, add an `x-wrap` with two\n`x-cta`s of equal weight — \"Share on X\" and \"Share on LinkedIn\" — with the\nrecap URL in each `href`. `x-wrap` keeps them side by side at every width, which\nis what the corpus does.\n\n**Guard the period, not only the numbers.** `{{year | default: \"2025\"}}` appears\nin the subject, the hero `alt`, and the headline. A recap that renders \"Your\nin review\" is the failure mode this email type is most prone to.\n","email_patterns/newsletter_digest.md":"---\nname: Newsletter \u0026 Digest Email Patterns\ndescription: What a newsletter or digest looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, story-block conventions, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Newsletter \u0026 Digest Emails\n\nA newsletter is a recurring send with more than one thing in it. A digest is the\nsame shape with the items pulled from a feed. Both are read top to bottom, both\ncarry an issue identity (a masthead, a number, a date), and both end with a\nsubscription footer rather than a sales footer.\n\nThe patterns below come from ~32 ranked exemplars in a 2,191-email newsletter set\n(subject and description metadata for all 32, rendered screenshots for 11, full\nHTML for 3). The set mixes editorial newsletters, brand newsletters, SaaS product\nupdates, and link digests. **Prevalence figures are eyeballed from those 32\nemails, not counted across the full set.**\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- Lead plus blocks: the lead story ends with the one `x-cta`; each shorter item\n  is an `x-heading-2` that ends with an inline `\u003ca\u003e` link, not a button.\n- Each divider sits in its own `x-box`, whose padding spaces it; the `x-hr`\n  keeps `margin=\"0\"`.\n- A requested story image stays in the layout as an `x-image` with descriptive\n  `alt` even when no workspace asset fits: give it a clearly named stand-in `src`\n  and list it in the reply.\n\n## Anatomy\n\n```text\n1  masthead        ~always  logo or wordmark, centered; 24-40px padding\n2  issue line      ~45%     \"ISSUE 026\", a date, or a tagline under the masthead\n3  lead image      ~55%     one banner or article photo, full content width\n4  lead story      ~85%     headline + deck + 2-5 paragraphs, left-aligned\n5  lead CTA        ~65%     one button or a text link at the end of the lead story\n6  divider         ~70%     1px rule between sections\n7  story blocks    ~75%     2-5 repeats of: icon or thumbnail, title, 1-3 sentences, link\n8  section header  ~40%     \"Keep exploring\", \"The latest\" — small caps or a rule + label\n9  link list       ~35%     3-6 one-line links with a category label above each\n10 secondary CTA   ~45%     repeats the primary at the end\n11 social row      ~55%     3-5 linked icons\n12 footer legal    ~always  \"you are receiving this because…\", address, unsubscribe\n```\n\n**Masthead.** Centered wordmark on white or the brand's paper color. Newsletters\ncarry the smallest header of any type — a 110-160px logo with no nav row.\n\n**Issue line.** Where a newsletter differs from every other type. \"ISSUE 026\",\n\"July 17, 2026\", or a one-line tagline, set 12-14px, uppercase, tracked, often\nin a color that appears nowhere else in the email.\n\n**Lead story.** The only band with real paragraph density in the corpus — four\nto eight paragraphs is normal, at 16px with a 1.5-1.6 line height. Subheads\ninside the story are common and are usually bold `x-paragraph`s rather than real\nheadings.\n\n**Story blocks.** The repeating unit. Two layouts recur about equally: a\n32-48px icon left with the title and copy right (an `x-row` at roughly\n`[8, 92]` or `[12, 88]`), and a full-width thumbnail above the title. Each block\nends with a text link, not a button.\n\n**Dividers.** A 1px rule between story blocks is near-universal. `x-hr` defaults\nto `margin: 10px 0` and uses `background-color`, not `background` — reset both.\n\n**Tip and callout blocks.** A run of short standalone tips (\"3 quick tips this\nweek\") reads as a list of bare paragraphs unless each one is boxed. Give each\ntip its own `x-box` with an accent rule down the left edge —\n`border-style=\"solid\" border-width=\"0 0 0 3px\"` plus a `{ light, dark }`\n`border-color` — on the paper fill, 12-16px apart. Never a leading emoji. This\nmove is authored guidance carried over from the earlier `email_patterns.md`\n(#24229), not a corpus frequency: the corpus digests box their tips less often\nthan they should.\n\n**Link list.** A digest's tail: a category label (12px, uppercase, colored), the\nlink title (18-24px), and an arrow glyph or image at the right. Rules between\nitems, no images.\n\n**Footer.** Newsletter footers explain the subscription, not the company. \"You\nare receiving this because you subscribed to …\" appears in most of them, above\nthe address and the unsubscribe link.\n\n## Structural variants\n\n**Single-story letter** (~30%). Masthead, issue line, one long article, one\nbutton, footer. Reads like a blog post in an inbox. Pick this for editorial and\nresearch newsletters.\n\n**Lead plus blocks** (~35%). Masthead, lead image, lead story with a button,\nthen three to five story blocks separated by rules, then a repeat button. The\ndefault shape, and the one the example below follows.\n\n**Card digest** (~20%). Every item is a rounded card on a tinted page — a tag\npill, a headline, a photo, body copy, and a \"read more\" row. Pick this when\nitems are independent and equally weighted.\n\n**Link roundup** (~15%). Almost no images. A section header and six to ten\none-line links with category labels. Pick this for a curated links send.\n\n## Subject lines\n\n| Move                          | Pattern                                                      |\n| ----------------------------- | ------------------------------------------------------------ |\n| Question (most common)        | `What animal can only taste one thing?`                     |\n| Claim with a number           | `29% of Gen Z rate your email signature as a trust signal`  |\n| Topic plus parenthetical      | `The truth about dopamine detoxes (\u0026 whether to try one)`    |\n| Bracketed label               | `[Newsletter] The work-space-time-savings continuum`        |\n| Issue identity                | `{{newsletter.name}} · Issue {{issue.number}}`               |\n| Feature list                  | `Widgets, Help Desk, and three more updates`                |\n| Editorial line                | `Studio visit: {{feature.name}}`                            |\n| Seasonal                      | `Summer read`                                               |\n| Direct address                | `You have new updates for {{account.name}}`                 |\n| Curiosity gap                 | `The longevity variable nobody's talking about`             |\n\nNewsletter subjects are the longest of any type — 35-70 characters is normal,\nand they read as headlines rather than as offers. Roughly a fifth carry a\nbracketed or prefixed label that identifies the series. Emoji are uncommon\n(about one in six). The brand name is usually absent: the masthead does that job.\n\n## Preheaders\n\n- `Dinner gets real boring real fast.`\n- `Brain medicine, explained.`\n- `Plus: three things worth reading this week.`\n- `A closer look at the science behind this trend.`\n- `Here is what landed in {{newsletter.name}} this month.`\n- `Was this forwarded to you? Subscribe here.`\n\nDigest preheaders usually add a *second* item (\"Plus: …\") so the inbox preview\nshows two reasons to open. Editorial newsletters use a standing tagline instead,\nwhich repeats every issue. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Read: `Read more` · `Read the full story` · `Keep reading`\n- Explore: `Explore now` · `Discover more` · `See what's new`\n- Subscribe: `Become a member` · `Subscribe` · `Sign up here`\n- Do: `Start building` · `Try it now` · `Find support`\n- Archive: `View in browser` · `Read past issues`\n\nText links outnumber buttons roughly two to one in this type. A newsletter's\nbuttons are for the subscription action, not for the stories — each story block\nends with an inline link. When there are two buttons, they are the same button\nrepeated at the top and bottom, not two different offers.\n\n## Tone\n\n- Editorial voice: the writer has a point of view and says it in the first\n  sentence.\n- Longer paragraphs than any other type — three to five lines each is normal.\n- Second person for the reader, first person plural for the publication.\n- Subheads inside a long story are questions (\"Can you actually detox dopamine?\").\n- The sign-off names a person or a team, and recurring newsletters keep the same\n  sign-off every issue.\n- The footer reminds the reader why they get this — that sentence is part of the\n  voice, not boilerplate.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. Newsletter sources sit anywhere from 480px to\n  632px. Read the source's own `@media` boundary and set\n  `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match.\n- The page gutter is the most common responsive rule: a tinted page with 24px\n  gutters on desktop that goes edge to edge and white on mobile. Express the\n  padding as ``:padding=\"{ small: `0`, medium: `24px 24px 40px` }\"``. The\n  background swap cannot be responsive on `x-base` — pick the desktop value and\n  note the difference.\n- Icon+text story rows must **not** stack: use `x-row` with a very low\n  `:break-point` (`1`) so the icon stays beside the title at every width. Use\n  `x-wrap` for social icons for the same reason.\n- Buttons go full width on mobile in about half the corpus:\n  ``:width=\"{ small: `100%`, medium: `auto` }\"``, or leave the width off and let\n  the padding size it.\n- Set an explicit `background` on `x-base` and on the one box that holds the\n  body. Do not repeat it on every band — a band only needs its own `background`\n  when the value differs from the one it inherits. A newsletter is mostly text\n  on white, which is exactly the case a dark-mode client rewrites most\n  aggressively.\n- **Headless Chrome resolves `prefers-color-scheme: dark`.** If you are grading a\n  conversion against a source that ships dark-mode overrides, the source\n  screenshot will show its dark-mode treatment (inverted buttons, forced white\n  headers) while your conversion shows the light one. Check the source's\n  `@media (prefers-color-scheme: dark)` block before you chase that diff.\n\n## Story blocks and dividers\n\nThe repeating unit is three components, and getting the gaps right is most of\nthe work.\n\n```html\n\u003c!-- icon + title on one row, copy underneath, rule after --\u003e\n\u003cx-box background=\"#ffffff\" padding=\"32px 24px 0 24px\"\u003e\n  \u003cx-row :layout=\"[8, 92]\" :gap=\"0\" :break-point=\"1\"\u003e\n    \u003cx-column padding=\"0 8px 0 0\" vertical-align=\"middle\"\u003e\n      \u003cx-image src=\"https://placehold.co/64x64\" alt=\"\" width=\"32px\" style=\"height: 32px;\" /\u003e\n    \u003c/x-column\u003e\n    \u003cx-column vertical-align=\"middle\"\u003e\n      \u003cx-heading-2 :font-size=\"16\" font-weight=\"700\" margin=\"0\"\n        \u003eStory title\u003c/x-heading-2\u003e\n    \u003c/x-column\u003e\n  \u003c/x-row\u003e\n  \u003cx-paragraph margin=\"8px 0 0 0\"\u003eOne to three\n    sentences of summary, ending in a \u003ca href=\"https://example.com/story\"\u003elink\u003c/a\u003e.\u003c/x-paragraph\u003e\n\u003c/x-box\u003e\n\n\u003cx-box background=\"#ffffff\" padding=\"24px\"\u003e\n  \u003cx-hr :height=\"1\" background-color=\"#e5e5e5\" margin=\"0\" /\u003e\n\u003c/x-box\u003e\n```\n\nThree rules that cost fidelity when missed:\n\n- **Do not set `:line-height` on the icon column.** `x-column` has no\n  `line-height` prop, so the value never reaches the render. On an `x-image` it\n  styles the alt text only, and a tight value there hurts a screen-reader user.\n- **Give the icon an explicit height** through `style=\"height: 32px;\"` when the\n  source forces one — `x-image` has no `height` prop, and a wrong aspect ratio\n  moves every band below.\n- **The divider gets its own `x-box`.** Putting the rule's spacing on `x-hr`'s\n  `margin` fights the box's block formatting context; carrying it as box padding\n  is predictable.\n\n## Example: lead plus blocks\n\nSubject: `Access to everything, and three reasons it matters`\nPreheader: `Plus: what landed in {{newsletter.name}} this week.`\n\nThis is the **lead plus blocks** variant: a masthead, a full-width banner, a tag\npill, a serif headline, three paragraphs, a button, three icon+text story blocks\nseparated by rules, a repeated button, and a subscription footer. Change the\nbanner, the headline, the three paragraphs, and the three story blocks first.\n\n\u003c!-- Converted from a real newsletter exemplar. The conversion was pixel-graded\n     against the source render: score 0.561, with 8.93% of desktop pixels and\n     13.17% of mobile pixels differing. Almost all of that residual is the two\n     buttons: the source ships a prefers-color-scheme:dark override that inverts\n     them, and the grading browser resolves dark, so the source screenshot shows\n     white-on-white buttons that this conversion (correctly) renders dark. The\n     markup below was then simplified for idiom and accessibility per review, so\n     it no longer reproduces the source pixel for pixel. The score describes the\n     original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#f9f9f9\"\n  color=\"#242424\"\n  font-family=\"Arial, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"552\"\n\u003e\n  \u003cx-section\n    width=\"600px\"\n    :padding=\"{ small: `0`, medium: `24px 24px 40px` }\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-box background=\"#ffffff\" data-layer-label=\"Body\"\u003e\n      \u003cx-image\n        src=\"https://placehold.co/242x54\"\n        alt=\"{{newsletter.name}}\"\n        href=\"https://example.com/\"\n        width=\"121px\"\n        align=\"center\"\n        margin=\"32px 24px\"\n        data-layer-label=\"Masthead\"\n      /\u003e\n\n      \u003cx-image\n        src=\"https://placehold.co/1200x480\"\n        alt=\"Read deeper. Learn more. Feel better.\"\n        href=\"https://example.com/\"\n        width=\"100%\"\n        data-layer-label=\"Banner\"\n      /\u003e\n\n      \u003cx-cta\n        background=\"#FFC017\"\n        color=\"#000000\"\n        :font-size=\"13\"\n        :line-height=\"1.5385\"\n        font-weight=\"400\"\n        padding=\"4px 8px\"\n        border-radius=\"8px\"\n        align=\"center\"\n        margin=\"32px 24px 8px\"\n        data-layer-label=\"Issue tag\"\n        \u003eIssue {{issue.number | default: \"24\"}}\u003c/x-cta\n      \u003e\n\n      \u003cx-box padding=\"12px 24px 0\" data-layer-label=\"Headline\"\u003e\n        \u003cx-heading-1\n          font-family=\"Georgia, serif\"\n          :font-size=\"28\"\n          :letter-spacing=\"-0.01\"\n          font-weight=\"400\"\n          text-align=\"center\"\n          margin=\"0\"\n          \u003eAccess to everything.\u003cbr /\u003eNow\u0026nbsp;up\u0026nbsp;to\u0026nbsp;30%\u0026nbsp;off.\u003c/x-heading-1\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"16px 24px 0\" data-layer-label=\"Lead\"\u003e\n        \u003cx-paragraph margin=\"0\"\n          \u003eFirst off, well done. You've found a genuinely useful corner of the\n          internet.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"16px 0 0\"\n          \u003eUnlike most publishing platforms, {{newsletter.name}} is not subsidised\n          by advertising. It is supported by people who believe that storytelling\n          still matters.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"16px 0 0\"\n          \u003eHere you will find essays, practical advice, and stories that shift\n          your perspective — all written by our community of experts,\n          enthusiasts, and emerging voices.\u003c/x-paragraph\n        \u003e\n        \u003cx-cta\n          href=\"https://example.com/subscribe\"\n          background=\"#242424\"\n          color=\"#ffffff\"\n          font-weight=\"400\"\n          padding=\"9px 20px\"\n          border-style=\"solid\"\n          border-width=\"1px\"\n          border-color=\"#242424\"\n          border-radius=\"24px\"\n          align=\"center\"\n          margin=\"16px 0 0\"\n          data-layer-label=\"CTA\"\n          \u003eBecome a member\u003c/x-cta\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"32px 24px 0\" data-layer-label=\"Story 1\"\u003e\n        \u003cx-row :layout=\"[8, 92]\" :gap=\"0\" :break-point=\"1\"\u003e\n          \u003cx-column padding=\"0 8px 0 0\" vertical-align=\"middle\"\u003e\n            \u003cx-image src=\"https://placehold.co/64x64\" alt=\"\" width=\"32px\" style=\"height: 32px;\" /\u003e\n          \u003c/x-column\u003e\n          \u003cx-column vertical-align=\"middle\"\u003e\n            \u003cx-heading-2 :font-size=\"16\" font-weight=\"700\" margin=\"0\"\n              \u003eUnlock every single story\u003c/x-heading-2\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-paragraph margin=\"8px 0 0\"\n          \u003eWelcome to unlimited reading. While other platforms charge for every\n          newsletter, you get the whole archive the moment you sign\n          up.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"24px\" data-layer-label=\"Divider\"\u003e\n        \u003cx-hr :height=\"1\" background-color=\"#e5e5e5\" margin=\"0\" /\u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"0 24px\" data-layer-label=\"Story 2\"\u003e\n        \u003cx-row :layout=\"[8, 92]\" :gap=\"0\" :break-point=\"1\"\u003e\n          \u003cx-column padding=\"0 8px 0 0\" vertical-align=\"middle\"\u003e\n            \u003cx-image src=\"https://placehold.co/64x64\" alt=\"\" width=\"32px\" style=\"height: 32px;\" /\u003e\n          \u003c/x-column\u003e\n          \u003cx-column vertical-align=\"middle\"\u003e\n            \u003cx-heading-2 :font-size=\"16\" font-weight=\"700\" margin=\"0\"\n              \u003eFill your days with better reading\u003c/x-heading-2\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-paragraph margin=\"8px 0 0\"\n          \u003eTake your curiosity with you. With the mobile app you can spend the\n          gaps in your day on something worth reading.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"24px\" data-layer-label=\"Divider\"\u003e\n        \u003cx-hr :height=\"1\" background-color=\"#e5e5e5\" margin=\"0\" /\u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"0 24px\" data-layer-label=\"Story 3\"\u003e\n        \u003cx-row :layout=\"[8, 92]\" :gap=\"0\" :break-point=\"1\"\u003e\n          \u003cx-column padding=\"0 8px 0 0\" vertical-align=\"middle\"\u003e\n            \u003cx-image src=\"https://placehold.co/64x64\" alt=\"\" width=\"32px\" style=\"height: 32px;\" /\u003e\n          \u003c/x-column\u003e\n          \u003cx-column vertical-align=\"middle\"\u003e\n            \u003cx-heading-2 :font-size=\"16\" font-weight=\"700\" margin=\"0\"\n              \u003eChoose a kinder, more human internet\u003c/x-heading-2\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-paragraph margin=\"8px 0 0\"\n          \u003eSupport a place where standards still matter. Our trust and safety\n          team moderates actively and curbs disinformation.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-cta\n        href=\"https://example.com/subscribe\"\n        background=\"#242424\"\n        color=\"#ffffff\"\n        font-weight=\"400\"\n        padding=\"9px 20px\"\n        border-style=\"solid\"\n        border-width=\"1px\"\n        border-color=\"#242424\"\n        border-radius=\"24px\"\n        align=\"center\"\n        margin=\"24px 24px 32px\"\n        data-layer-label=\"CTA 2\"\n        \u003eSubscribe now\u003c/x-cta\n      \u003e\n\n      \u003cx-box padding=\"24px\" color=\"#6b6b6b\" data-layer-label=\"Footer\"\u003e\n        \u003cx-paragraph :font-size=\"12\" :line-height=\"1.8333\" text-align=\"center\" margin=\"0 0 8px 0\"\n          \u003eSent by\n          \u003ca href=\"https://example.com/\" style=\"color: #6b6b6b; text-decoration: underline;\"\u003e{{company.name}}\u003c/a\u003e\n          · 123 Example Street, Portland, OR 97204\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph :font-size=\"12\" :line-height=\"1.8333\" text-align=\"center\" margin=\"0\"\n          \u003e\u003ca href=\"{% unsubscribe_url %}\" style=\"color: #6b6b6b; text-decoration: underline;\"\u003eUnsubscribe\u003c/a\u003e\n          from this type of email ·\n          \u003ca href=\"{% manage_subscription_preferences %}\" style=\"color: #6b6b6b; text-decoration: underline;\"\u003eManage preferences\u003c/a\u003e ·\n          \u003ca href=\"https://example.com/help\" style=\"color: #6b6b6b; text-decoration: underline;\"\u003eHelp center\u003c/a\u003e ·\n          \u003ca href=\"https://example.com/privacy\" style=\"color: #6b6b6b; text-decoration: underline;\"\u003ePrivacy policy\u003c/a\u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                   | Token to use instead                                    |\n| ---------------------------------------- | ------------------------------------------------------- |\n| `background=\"#f9f9f9\"` on `x-base`       | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#ffffff\"` on the body box   | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`        |\n| `color=\"#242424\"` on `x-base`            | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`            |\n| `color=\"#6b6b6b\"` on the footer box      | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`           |\n| `background=\"#242424\"` on both CTAs      | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`        |\n| `background=\"#FFC017\"` on the issue tag  | `:background=\"globalStyles.colors.accent_\u003cid\u003e\"`         |\n| `background-color=\"#e5e5e5\"` on `x-hr`   | `:background-color=\"globalStyles.colors.border_\u003cid\u003e\"`   |\n| `font-family=\"Georgia, serif\"` on the h1 | `:font-family=\"globalStyles.fonts.display_\u003cid\u003e\"`        |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**Making it a real digest.** Duplicate the story block plus divider pair for\neach item, and replace the 32px icon with a thumbnail: change the row to\n`:layout=\"[30, 70]\"`, the image to `width=\"100%\"`, and add a 12px uppercase\ncategory `x-paragraph` above the title. That is the **card digest** shape without\nthe card.\n\n**What to cut when the brief is simpler.** Delete the banner, the issue tag, and\nthe second CTA. Masthead, headline, three paragraphs, one button, footer is the\n**single-story letter** variant and it is six components long.\n\n**Do not add a button to each story block.** Every newsletter in the corpus ends\nits story blocks with a text link and keeps buttons for the one subscription\naction. Five buttons in one email reads as five offers.\n","email_patterns/onboarding_activation.md":"---\nname: Onboarding \u0026 Activation Email Patterns\ndescription: What the emails after the welcome look like — the band-by-band anatomy of a step or lesson walkthrough, the structural variants, real subject and preheader patterns, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Onboarding and Activation Emails\n\nAn onboarding email is one of the messages that follow the welcome. The account\nalready exists. The reader has done something once, or has done nothing yet, and\nthe email exists to move them one step further: connect a source, import the\ndata, take the first trip, set the preferences, run a second task. It teaches\none thing and asks for one click.\n\n**This is not a welcome email, and the difference is structural.** A welcome\nthanks the reader, says who the brand is, and promises what arrives next\n(**welcome.md**). An onboarding email does none of that. It opens by placing the\nreader on a path they are already on (\"You've seen what the app can do with a\nsingle task\"), spends its middle teaching, and closes with a single next action.\nWhere a welcome carries an offer, an onboarding email carries a step band. Where\na welcome sets frequency expectations, an onboarding email states how little\neffort the next step costs. Read **welcome.md** for the first message and this\nfile for every message after it.\n\nThe patterns below come from a 1152-email corpus group. I read the ranked\nmetadata for 50 exemplars, viewed 16 desktop screenshots spread across the\nranking and across industries, and read six `email.html` files structurally, two\nof them line by line. Prevalence figures are eyeballed from that sample. Nothing\nhere is counted across all 1152.\n\n**Two warnings about the sample.** First, the corpus group is the source\narchive's \"Onboarding\" catch-all, so roughly **half of the top 50 exemplars are\nalso tagged `welcome`** — signup thank-yous from Ferrari, Vistaprint, Aldo, KOA,\nNikon, Carfax and a dozen more. Those belong to **welcome.md** and I excluded\nthem. What is left is genuinely post-welcome, but it is a smaller pile than the\ngroup size suggests. Second, the remainder skews hard to SaaS and consumer apps,\nand a handful of senders repeat across the ranking (Gamma three times, Claude\nfour, Rover and KOA twice each). Retail and services onboarding is thin\nevidence here — treat the consumer-app notes as less certain than the SaaS ones.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- A \"you haven't done X yet\" nudge is not a welcome: no signup confirmation.\n- Write the steps yourself from the brief (e.g. connect a source, invite a\n  teammate, build a first report); they are copy, not facts to ask for.\n- Setup steps are a numbered `x-list` (or, when each step needs a heading, one\n  row per step with an `x-heading-2` title) sharing one closing `x-cta`.\n\nTreat the corpus examples below as structural evidence, not facts to add. The\nbrief and verified workspace context are the complete fact set. If the brief\nsupplies one next step, include only that step: do not add setup times, product\nbehavior, later steps, support destinations, addresses, or compliance links.\nOnly render a CTA, help row, or footer detail when its real destination or value\nis supplied by the brief or workspace.\n\n## Anatomy\n\n```text\n1  logo band         ~always   left-aligned more often than centered, 24-36px padding\n2  eyebrow / status  ~25%      \"TRIAL STARTED\", a day count, a stat line, small caps\n3  headline          ~always   one h1, 24-34px, names the action or the payoff\n4  intro copy        ~90%      1-3 sentences: where you are, what is still missing\n5  hero              ~65%      product screenshot or photo, content width, 8-20px radius\n6  step band         ~75%      2-5 repeats of image + heading + copy, often + own button\n7  example card      ~30%      tinted rounded box holding a sample prompt or quote\n8  benefit checklist ~25%      check-icon + line rows, usually inside a tinted card\n9  primary CTA       ~95%      one button, the single next action, after the last step\n10 help row          ~40%      support centre, community, docs, \"Stuck?\" links\n11 app-store badges  ~25%      two 148-160px badges, consumer-app senders only\n12 social row        ~60%      3-6 linked icons, 24-40px\n13 footer            ~always   address, unsubscribe, preferences, 12-14px muted text\n```\n\n**Logo band.** Left-aligned wins here, unlike in welcome emails, and the band is\noften shallower — 24px rather than 32-40px. Several SaaS senders pair the logo\nwith a right-hand \"Log in\" pill (Zapier, Canva). Build that as an `x-row`; build\na lone logo as a single `x-image` with `align=\"left\"`.\n\n**Eyebrow / status.** A quarter of the corpus emails put a small line above the\nheadline that states the account's state rather than the content: `TRIAL\nSTARTED`, `Notifications set!`, `It's been 9 days since your last lesson.` It is\n20px or smaller, often uppercase, and it is the band that makes the email feel\ntransactional rather than promotional. Build it as an `x-heading-2` or a bold\n`x-paragraph` with an explicit `:font-size` — not as an `x-heading-1`.\n\n**Headline.** One per email, 24-34px, and almost always an instruction or a\ncapability rather than a greeting. It rarely contains the brand name.\n\n**Intro copy.** One to three sentences, and the shape is consistent: name the\nprogress already made, then name the gap. Two sentences is the median, which is\nshorter than a welcome's three-to-four-paragraph opener.\n\n**Step band.** The defining band of the type — see § The step band below.\n\n**Example card.** A tinted rounded box (16-20px radius) holding a sample prompt,\na quoted instruction, or a worked example, introduced by a short italic label\n(\"Try this:\"). Common in AI and developer-tool onboarding, rare elsewhere. Build\nit as an `x-box` inside an `x-box`: the outer one carries the band's side\npadding, the inner one carries the tint, the radius and its own padding.\n\n**Benefit checklist.** Check-icon plus one line of text, two to four rows, inside\na tinted card headed \"included in your trial\" or similar. Each row is an `x-row`\nwith a narrow first column (`:layout=\"[8, 92]\"` at a 640px width for a 24px\nicon), not an `x-list` — the icon is an image, not a bullet.\n\n**Primary CTA.** Nearly universal and nearly always singular *as a closing\naction*, even in emails carrying five other buttons. It sits after the last step\nand repeats the email's one job.\n\n**Help row.** Roughly two in five close the body with an escape hatch: support\ncentre, community, docs, a live workshop, \"reply to this email\". Zapier's whole\nmiddle band is five of them. This band does not exist in welcome emails and it is\na good tell that a brief is really an onboarding brief.\n\n**Footer.** As in every type: address, unsubscribe, usually a preference link.\nConsumer-app senders add the two app-store badges above it.\n\n## Structural variants\n\n**The CTA rule that decides the shape — read this before you pick a variant.**\nCount the reader's choices before you count the bands. Steps the reader picks\n*between* are **alternatives**, and each one gets its own button. Steps the reader\nworks *through* in order are a **sequence**, and the whole email gets one closing\nbutton with no per-step buttons. Getting this backwards is the most common\nstructural error in this type, so name which of the two you picked in your reply.\n\n**Step sequence** (~35%). Logo, headline, intro, then two to five repeats of\nimage + heading + copy, each with its own button **when the steps are\nalternatives the reader picks between**, then a closing CTA. Komoot,\nNinox, Gamma and RGE Studio all land here. Some number the steps with a large\nnumeral or a `01`-`04` label, which is the same variant with an ordering claim\nattached. Pick this when the brief lists more than one thing the reader could do\nnext.\n\n**Single lesson** (~20%). One idea, two or three prose sections, one or two\ntinted example cards, and one button at the very end. Claude and Zapier build\nthese. Pick this when the brief teaches one feature properly rather than\nsurveying several.\n\n**Activation receipt** (~15%). Eyebrow, headline, hero, a short paragraph, a\nchecklist of what the account just unlocked, one button, and a deadline line\n(\"Your trial ends on July 13.\"). Apollo and Seated. Pick this when the trigger is\na state change — trial started, notifications on, plan upgraded — rather than a\nlesson.\n\n**Preference capture** (~10%). A question headline, a stack of tappable option\nrows styled as buttons, and a \"personalize\" CTA. KOA's is the clearest. Pick this\nwhen the next action is data the sender needs from the reader, not work the\nreader does in the product.\n\n**Progress nudge** (~10%). Illustration or mascot, one sentence of elapsed-time\nor streak data, one button, and a social-proof card underneath (\"the average\nlearner completed 16 lessons\"). Duolingo. Short — under 800px tall. Pick this\nwhen the reader has stalled and there is nothing new to teach.\n\nThe remaining tenth is feature announcements sent into an onboarding stream\n(AllTrails, Claude, Mailchimp). They read as single-lesson emails with a \"now\nlive\" headline, so build them that way.\n\n## Subject lines\n\n| Move                       | Pattern                                                                             |\n| -------------------------- | ----------------------------------------------------------------------------------- |\n| Next-step imperative       | `Let's start designing 🎨` / `Turn your data into something real`                   |\n| Capability claim           | `Run multiple tasks at once with {{product.name}}`                                  |\n| Question, unstuck          | `Stuck? Or need automation ideas?` / `Why keep great ideas to yourself?`            |\n| How-to framing             | `It's easy to book pet care on {{company.name}}—see how!`                           |\n| Topic promise              | `Everything you need to know about {{topic}}`                                       |\n| State confirmed            | `Your 14-day {{company.name}} trial is now active` / `Notifications set!`           |\n| Unfinished task            | `{{customer.first_name}}—Don't forget to set your preferences`                      |\n| Elapsed time               | `⏩ Fast forward 8 weeks` / `It's been 9 days since your last lesson`                |\n| Quantified promise         | `Three quick wins, no dev resources needed`                                         |\n| Behaviour personalization  | `Recommended templates based on your search`                                        |\n| Now-live announcement      | `On-trail directions are now live`                                                  |\n| Possessive framing         | `Your tools. Your data. Your {{product.name}}.`                                     |\n\nThe strongest signal is a negative one: the word \"welcome\" is almost absent. I\nsaw it twice in roughly 25 genuinely post-welcome subjects, against two in three\nin the welcome set. Lines run longer than welcome subjects — 25 to 55 characters\n— because they carry a verb and an object. Around a fifth carry one emoji, and it\nis usually doing work rather than decorating (🎨 for design, 💪 for fitness, ⏩ for\nelapsed time, 📱 for an app). First-name personalization is rarer than in welcome\nemails, maybe one in ten, and it clusters on the nudge subjects rather than the\nlesson subjects. Sentence case dominates.\n\n## Preheaders\n\n- `Templates recommended just for you.`\n- `Watch this simple video and get booking!`\n- `Each connection takes about 60 seconds.`\n- `Navigating on the trail just got easier`\n- `Walk away from your computer. Come back to finished work.`\n- `We've got resources to help you keep building ➡️`\n- `See how easy it is to grow your reach with our simple tools.`\n- `It's been 9 days since your last lesson…`\n- `Download the app to see your active life come together`\n- `Get the lowdown on {{topic}} and find the perfect match for you.`\n- `Real relief, real changes, the real you`\n\nRoughly two in three carry one, which is higher than the welcome set. The job is\ndifferent too: an onboarding preheader most often **lowers the price of the\naction** — \"about 60 seconds\", \"watch this simple video\", \"just got easier\" —\nrather than restating the promise. Keep it under about 90 characters, and do not\nrepeat the subject's verb.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Start the work: `Start building` · `Start creating` · `Create a table` · `Search routes`\n- Keep going: `Continue building` · `Continue learning` · `Hand off more work`\n- Per-step: `Search from current location` · `Open your solution` · `Upload a document`\n- Learn: `Watch now \u003e` · `Check it out` · `See how` · `Learn more`\n- Activation: `Start working real prospects` · `Set your preferences` · `Personalize your experience`\n- Get the app: `Get the app` · `Download {{company.name}} today`\n- Help: `Talk to our team` · `Ask in our community` · `Visit our support center`\n\nThe closing button is two to four words. **Step buttons are longer** — four or\nfive words — because they name the exact action of their own step rather than the\nemail's; `Search from current location` is not a badly written button, it is a\nstep button. Sentence case throughout, with uppercase reserved for the older\nconsumer brands. No trailing punctuation.\n\nUnlike a welcome email, **more than one button is normal — but only when the\nsteps are alternatives.** The Step sequence variant gives every step its own\nbutton when the reader picks between the steps. When the reader works through them\nin order, drop the per-step buttons and keep one closing button. See the rule at\nthe top of § Structural variants. A help row adds text links after that. What\nstays singular either way is the *closing* action: one button, one job, at the\nend.\n\n## Tone\n\n- Second person, imperative for the action, present tense throughout.\n- The first sentence places the reader on the path. It never thanks them and it\n  never introduces the brand — both jobs belong to the welcome.\n- Effort reassurance is close to a house style: \"in under 3 minutes\", \"about 60\n  seconds\", \"no dev resources needed\", \"it's easier than you think\".\n- Concrete verbs and real UI nouns: \"Open the AI builder\", \"tap 'See hikes\n  here'\", \"click Add and Views\". Vague benefit copy is rare.\n- Short paragraphs, two to three lines. Bold runs inside a sentence carry the\n  UI names.\n- SaaS senders write instructional prose and often sign off with a name and a\n  team (\"Best regards, Victoria from Ninox\"). Consumer apps write encouraging\n  one-liners and let the mascot or the photography carry the feeling.\n- Exclamation marks are less common than in welcome emails, and the register is\n  noticeably flatter — this is documentation with a brand voice on top.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once, and the values in this group cluster at 480,\n  600 and 619-640. Read the source's own boundary and set\n  `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match. A source `@media (max-width: 619px)`\n  is `:mobile-breakpoint=\"620\"`.\n- The most common responsive change is the page gutter: 36px on desktop\n  collapsing to 24px on mobile. Express it as\n  ``:padding=\"{ small: `… 24px`, medium: `… 36px` }\"``.\n- The second most common is heading size, and it is a trap. Sources step both the\n  font-size and the line-height (`32px/38px` down to `28px/34px`), but Carta's\n  `:line-height` and `:letter-spacing` accept a **number only** — they reject a\n  `{ small, medium }` object, while `:font-size`, `:padding`, `align` and\n  `text-align` accept one. Set the ratio for the viewport you care about, then\n  absorb the leftover height in the other viewport's band padding. That single\n  correction moved the example below from 10.1% to 1.5% mobile difference.\n- ESP exports override padding **per block** inside the media query. One Beefree\n  export in this group carried fourteen block-specific mobile paddings. Read every\n  rule in the block before you write a single responsive object, or every band\n  below the first one drifts.\n- Step rows built as image-left / text-right stack on mobile: `x-row` with\n  `:break-point`. Icon rows, social rows and app-store badge pairs do not stack —\n  `x-wrap`, or an `x-row` with a low `:break-point`.\n- Hero and step images carry an 8-20px `border-radius` in most of the SaaS half\n  and almost none of the retail half. When the source puts a hairline border\n  around a rounded screenshot, pass it through `style` with `box-sizing:\n  border-box` — `x-image` has no border prop.\n- About one in four sources ships a `prefers-color-scheme: dark` block. It\n  usually recolours the band backgrounds, forces headings and paragraphs white,\n  and swaps a light logo for a dark one. Carta has no dark block: set an explicit\n  `background` on `x-base` and on `x-section` so a client's auto-dark cannot\n  repaint the page, then give a band its own `background` only when the value\n  differs from the one it inherits. Use ``x-image :src=\"{ light: `…`, dark: `…` }\"``\n  for the logo swap.\n- Roughly half the sources load a real web font via `\u003clink\u003e` or `@font-face`.\n  Check before copying the stack — Carta matches only the first name in a\n  `font-family` against its Google Fonts catalog and injects a `\u003clink\u003e` for it,\n  so a name the source never loaded changes every wrap point.\n\n## The step band\n\nThree renderings recur, and picking the wrong one is the most common structural\nerror in converting this type.\n\n**Stacked step** (most common). Full-width image, then heading, then copy, then\nthat step's button, repeated. In the source this is four sibling tables inside\none column — it is **not** a multi-column row, and it must not become an `x-row`.\nBuild each step as one `x-box` holding `x-image`, `x-heading-2`, `x-paragraph`,\n`x-cta`, and let the boxes repeat. Komoot and RGE Studio both do this.\n\n**Side-by-side step**. A 100-250px image or numeral on the left, heading and copy\non the right. This one *is* an `x-row`, usually `:layout=\"[40, 60]\"` or\n`:layout=\"[15, 85]\"` for a numeral, with `:break-point` set to the source's\nbreakpoint so it stacks. Gamma, Mailchimp and Ninox.\n\n**Icon step**. A 24-48px icon on the left, a bold label and one sentence on the\nright, five or six in a run, separated by 1px rules. Zapier and Rover. Same\n`x-row` shape with a much narrower first column, and the rules are `x-hr` with\n`margin=\"0\"` — remember `x-hr` defaults to `margin: 10px 0` and takes\n`background-color`, not `background`.\n\nTwo judgement calls come up every time. **Does each step get its own button?**\nApply the alternatives-versus-sequence rule at the top of § Structural variants:\nalternatives get a button each (KOA, Komoot, Ninox), a read-through sequence gets\none closing button and nothing per step (RGE Studio, Zapier).\n**Do the steps get numbers?** Only when the order is real. A numbered list that\nthe reader can do out of order reads as a chore.\n\n## Example: single-lesson walkthrough with tip cards\n\nSubject: `Run multiple tasks at once with {{product.name}}`\nPreheader: `Hand off a bigger job today and come back to finished work.`\n\nThis is the **single lesson** variant: logo, headline, one intro line, a hero\nscreenshot, then two teaching sections that each end in a tinted example card,\nand one closing button. It is a good skeleton to adapt because every band is a\nplain `x-box` or a bare `x-image` — there is not a single multi-column row above\nthe footer, which is what most onboarding emails actually look like once you stop\ncopying the source's tables. Change the headline, the two section headings and\ntheir paragraphs, and the card contents first. The footer, the paddings and the\npalette are chrome.\n\nNote one thing in the markup. The `:padding` objects are the source's per-block\nmobile overrides, not a single global gutter — several of them differ from each\nother on purpose.\n\n\u003c!-- Converted from a real onboarding exemplar. The conversion was pixel-graded\n     against the source render before genericization: 0.012% of desktop pixels and\n     1.50% of mobile pixels differed, score 0.95. The markup below was then\n     simplified for idiom and accessibility per review, so it no longer reproduces\n     the source pixel for pixel. The score describes the original conversion, not\n     this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#FFFFFF\"\n  color=\"#262624\"\n  font-family=\"Georgia, 'Times New Roman', serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"620\"\n\u003e\n  \u003cx-section\n    width=\"640px\"\n    padding=\"\"\n    background=\"#FAF9F5\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-image\n      src=\"https://placehold.co/129x28\"\n      alt=\"{{company.name}}\"\n      href=\"https://example.com/\"\n      width=\"133px\"\n      align=\"left\"\n      margin=\"24px 36px\"\n      data-layer-label=\"Header\"\n    /\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `21px 24px 10px 24px`, medium: `24px 36px 0 36px` }\"\n      data-layer-label=\"Headline\"\n    \u003e\n      \u003cx-heading-1\n        :font-size=\"{ small: 28, medium: 32 }\"\n        :letter-spacing=\"-0.015625\"\n        font-weight=\"400\"\n        margin=\"0\"\n        \u003eGive the assistant a bigger task\u003c/x-heading-1\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `8px 24px 36px 24px`, medium: `24px 36px` }\"\n      data-layer-label=\"Intro\"\n    \u003e\n      \u003cx-paragraph margin=\"0\"\n        \u003eYou have seen what the app can do with a single task. Now try handing\n        it more.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/1140x642\"\n      alt=\"The workspace running several tasks side by side\"\n      href=\"https://example.com/\"\n      width=\"100%\"\n      border-radius=\"20px\"\n      :margin=\"{ small: `0 24px`, medium: `0 36px` }\"\n      style=\"border: 1.19px solid #f1efe9; box-sizing: border-box\"\n      data-layer-label=\"Hero\"\n    /\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `21px 24px 10px 24px`, medium: `24px 36px 12px 36px` }\"\n      data-layer-label=\"Step 1 heading\"\n    \u003e\n      \u003cx-heading-2\n        :font-size=\"{ small: 20, medium: 24 }\"\n        :letter-spacing=\"-0.0104167\"\n        font-weight=\"400\"\n        margin=\"0\"\n        \u003eRun multiple tasks at once\u003c/x-heading-2\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `8px 24px`, medium: `8px 60px 8px 36px` }\"\n      data-layer-label=\"Step 1 body\"\n    \u003e\n      \u003cx-paragraph margin=\"0\"\n        \u003eStart two tasks in separate sessions and let the app work on both while\n        you focus on something else. Hand off the work, come back to finished\n        deliverables.\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"16px 0 0\" style=\"font-style: italic;\"\n        \u003eTry this:\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `0 24px 12px 24px`, medium: `0 36px 12px 36px` }\"\n      data-layer-label=\"Prompt cards\"\n    \u003e\n      \u003cx-box background=\"#F0EEE6\" border-radius=\"20px\" :padding=\"{ small: `20px 16px`, medium: `24px` }\"\u003e\n        \u003cx-paragraph\n          font-weight=\"700\"\n          :letter-spacing=\"-0.03125\"\n          margin=\"0 0 8px 0\"\n          \u003eTask 1:\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph style=\"font-style: italic;\" margin=\"0\"\n          \u003e\u0026ldquo;Summarize everything in my Documents folder from this\n          week.\u0026rdquo;\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n      \u003cx-box\n        background=\"#F0EEE6\"\n        border-radius=\"20px\"\n        margin=\"12px 0 0\"\n        :padding=\"{ small: `20px 16px`, medium: `24px` }\"\n      \u003e\n        \u003cx-paragraph\n          font-weight=\"700\"\n          :letter-spacing=\"-0.03125\"\n          margin=\"0 0 8px 0\"\n          \u003eTask 2:\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph style=\"font-style: italic;\" margin=\"0\"\n          \u003e\u0026ldquo;Check my email for anything urgent and draft\n          replies.\u0026rdquo;\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `21px 24px 10px 24px`, medium: `24px 36px 12px 36px` }\"\n      data-layer-label=\"Step 2 heading\"\n    \u003e\n      \u003cx-heading-2\n        :font-size=\"{ small: 20, medium: 24 }\"\n        :letter-spacing=\"-0.0104167\"\n        font-weight=\"400\"\n        margin=\"0\"\n        \u003eSet tasks to run on a schedule\u003c/x-heading-2\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `8px 24px`, medium: `8px 60px 8px 36px` }\"\n      data-layer-label=\"Step 2 body\"\n    \u003e\n      \u003cx-paragraph margin=\"0\"\n        \u003eSome work happens every day or every week. Set it up once and the app\n        handles it on repeat. Teams use scheduled tasks to eliminate the\n        recurring overhead\u0026mdash;weekly pipeline reviews, expense summaries, and\n        stakeholder updates that used to require a standing meeting or a manual\n        pull.\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"16px 0 0\" style=\"font-style: italic;\"\n        \u003eTry this:\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `0 24px 12px 24px`, medium: `0 36px 12px 36px` }\"\n      data-layer-label=\"Prompt card 3\"\n    \u003e\n      \u003cx-box background=\"#F0EEE6\" border-radius=\"20px\" :padding=\"{ small: `20px 16px`, medium: `24px` }\"\u003e\n        \u003cx-paragraph\n          font-weight=\"700\"\n          :letter-spacing=\"-0.03125\"\n          margin=\"0 0 8px 0\"\n          \u003eTask 1:\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph style=\"font-style: italic;\" margin=\"0\"\n          \u003e\u0026ldquo;Every Monday morning, check my Slack channels and calendar for\n          the week. Summarize what's coming up and flag anything that needs\n          prep.\u0026rdquo;\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-cta\n      href=\"https://example.com/\"\n      background=\"#141413\"\n      color=\"#FFFFFF\"\n      font-family=\"Arial, sans-serif\"\n      :font-size=\"18\"\n      :line-height=\"1.1666667\"\n      font-weight=\"400\"\n      padding=\"16px 36px\"\n      border-radius=\"10px\"\n      align=\"left\"\n      :margin=\"{ small: `12px 24px 36px 24px`, medium: `12px 36px 36px 36px` }\"\n      data-layer-label=\"Primary CTA\"\n      \u003eHand off more work\u003c/x-cta\n    \u003e\n\n    \u003cx-row\n      :layout=\"[50.7, 49.3]\"\n      :gap=\"0\"\n      :break-point=\"620\"\n      background=\"#141414\"\n      :padding=\"{ small: `36px 24px`, medium: `36px` }\"\n      data-layer-label=\"Footer\"\n    \u003e\n      \u003cx-column vertical-align=\"top\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/292x64\"\n          alt=\"{{company.name}}\"\n          href=\"https://example.com/\"\n          width=\"146px\"\n          :align=\"{ small: `center`, medium: `left` }\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column\n        :padding=\"{ small: `24px 0 0 0`, medium: `0` }\"\n        vertical-align=\"top\"\n      \u003e\n        \u003cx-row :layout=\"[33.34, 33.33, 33.33]\" :gap=\"0\" :break-point=\"320\"\u003e\n          \u003cx-column\u003e\n            \u003cx-image\n              src=\"https://placehold.co/72x72\"\n              alt=\"LinkedIn\"\n              href=\"https://example.com/\"\n              width=\"36px\"\n              :align=\"{ small: `center`, medium: `left` }\"\n            /\u003e\n          \u003c/x-column\u003e\n          \u003cx-column\u003e\n            \u003cx-image\n              src=\"https://placehold.co/72x72\"\n              alt=\"X\"\n              href=\"https://example.com/\"\n              width=\"36px\"\n              :align=\"{ small: `center`, medium: `left` }\"\n            /\u003e\n          \u003c/x-column\u003e\n          \u003cx-column\u003e\n            \u003cx-image\n              src=\"https://placehold.co/72x72\"\n              alt=\"YouTube\"\n              href=\"https://example.com/\"\n              width=\"36px\"\n              :align=\"{ small: `center`, medium: `left` }\"\n            /\u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-hr :height=\"1\" background-color=\"#87867F\" margin=\"24px 0 0 0\" /\u003e\n        \u003cx-paragraph\n          :font-size=\"12\"\n          color=\"#B0AEA5\"\n          margin=\"24px 0 0 0\"\n          :text-align=\"{ small: `center`, medium: `left` }\"\n          \u003eExample Company Inc, 100 Market Street, Suite 200,\n          \u003cspan style=\"white-space: nowrap\"\u003eSan Francisco, CA\u003c/span\u003e\n          \u003cspan style=\"white-space: nowrap\"\u003e94104\u003c/span\u003e\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph\n          :font-size=\"12\"\n          color=\"#B0AEA5\"\n          margin=\"24px 0 0 0\"\n          :text-align=\"{ small: `center`, medium: `left` }\"\n          \u003eThis email was sent to {{customer.email}}. To opt out of future\n          emails, click\n          \u003ca\n            href=\"{% unsubscribe_url %}\"\n            style=\"color: #b0aea5; text-decoration: underline\"\n            \u003eunsubscribe\u003c/a\n          \u003e.\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                       | Token to use instead                                      |\n| -------------------------------------------- | --------------------------------------------------------- |\n| `background=\"#FFFFFF\"` on `x-base`           | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"`   |\n| `background=\"#FAF9F5\"` on `x-section`        | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`          |\n| `background=\"#F0EEE6\"` on the example cards  | `:background=\"globalStyles.colors.surfaceMuted_\u003cid\u003e\"`     |\n| `color=\"#262624\"` on `x-base`                | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`              |\n| `background=\"#141413\"` on the CTA            | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`          |\n| `background=\"#141414\"` on the footer row     | `:background=\"globalStyles.colors.footerBackground_\u003cid\u003e\"` |\n| `color=\"#B0AEA5\"` in the footer              | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`             |\n| `background-color=\"#87867F\"` on the `x-hr`   | `:background-color=\"globalStyles.colors.divider_\u003cid\u003e\"`    |\n| `border-radius=\"20px\"` on the hero and cards | `:border-radius=\"globalStyles.radius.large_\u003cid\u003e\"`         |\n| `padding=\"24px\"` on the example cards        | `:padding=\"globalStyles.spacing.medium_\u003cid\u003e\"`             |\n| `font-family` on `x-base`                    | `font-family=\"\"` when a global style already sets it      |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete both example cards and their\nitalic \"Try this:\" labels, and you have a plain two-section lesson. Delete the\nsecond section as well and you have the **activation receipt** shape — logo,\nheadline, hero, one paragraph, one button — which is five components long. Going\nthe other way, turn each teaching section into an `x-row` with the screenshot on\nthe left and add a per-step `x-cta` on the right, and you have the **step\nsequence** variant without touching the header, the footer or the palette.\n\n**One fractional value is left, and it is load-bearing.** `1.19px` is not a\ntypo. It is the source's own hairline border on the hero screenshot. An earlier\nversion of this example also carried `20.5px`, `10.5px` and `11.125px` band\npaddings. Those compensated for a heading line-height Carta cannot change per\nbreakpoint. They went when the headings dropped their tight line-heights and\ninherited the base 1.5. When you write a new onboarding email rather than convert\none, use round numbers.\n","email_patterns/order_confirmation.md":"---\nname: Order Confirmation Email Patterns\ndescription: What recurs in order confirmations and their transactional-confirmation neighbours — band anatomy, five structural variants, subject and preheader patterns, the line-item table escape hatch, and one converted Carta example.\n---\n\n# Order Confirmation Emails\n\nAn order confirmation acknowledges a completed transaction. It sends within\nseconds of the event, it is expected, and the reader opens it to check one\nthing: that the right amount was charged for the right items. The reader does\nnot need persuading, so the email's job is to show the data and offer one route\nback into the account.\n\nThis file is distilled from 286 corpus emails. The group is built from the\nsource archive's Confirmation, Receipt / Payment, Post Purchase and\nTransactional categories, so it is **wider than literal order receipts**.\nRoughly a third of the highest-ranked emails in it are double opt-in or\nverify-email messages, and another handful are one-time codes, sign-in alerts\nand event registrations. True purchase receipts cluster deeper in the ranking.\nRead § Structural variants and pick the one the brief actually asks for.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- The footer reads as a receipt: postal address, a support line and the\n  unsubscribe link, no marketing block. Unsupplied address or support details\n  become visible `[Add postal address]`-style placeholders, not omissions.\n\n## Anatomy\n\nThe bands below describe the **purchase receipt** variant, which is the one an\nagent asked for an \"order confirmation\" almost always needs. The other variants\ndrop bands rather than add them.\n\n```text\n1  logo band          ~always   centered or left logo, 24-40px padding, brand or white bg\n2  status headline    ~always   \"Thank you for your order\" / \"Order #X confirmed\", 18-30px\n3  order-number line  ~70%      small caps or 12-14px muted line under the headline\n4  reassurance copy   ~85%      1-3 lines: what happens next, when it ships\n5  primary CTA        ~90%      \"View your order\" / \"Track your order\", one button\n6  progress tracker   ~20%      3 nodes (Confirmed / Shipped / Delivered), image or icon row\n7  divider            ~60%      1-2px rule, full width or content width\n8  ORDER SUMMARY head ~85%      14-20px, uppercase or title case, left aligned\n9  line-item rows     ~85%      thumbnail 60-80px + title + variant + qty + price, right aligned\n10 totals block       ~90%      subtotal / shipping / tax / discount / total, right half only\n11 customer info      ~55%      2 columns: shipping + billing address, then method + payment\n12 support line       ~60%      \"reply to this email or contact us at ...\" 13-14px\n13 secondary block    ~35%      referral offer, app download, product recommendations\n14 footer             ~always   logo, social icons, postal address, 12-16px muted\n```\n\n**Band 2 and 3 carry the whole message.** Half the corpus receipts put the\norder number in the headline, the other half put it on its own muted line under\nit. Either way it is one `x-heading-1` plus one `x-paragraph`, not two headings.\n\n**Band 5 is a single button.** Receipts in this corpus average 1.4 buttons.\nWhen there are two, the second is a weaker outline button under the first\n(\"View your order\" then \"Visit our store\"), not a competing offer.\n\n**Band 9 is the only place `x-html` belongs.** See § Line items, totals and\norder data.\n\n**Band 10 sits in the right 60% of the column on desktop and spans the full\nwidth on mobile.** Sources do this by putting an empty 40% spacer cell next to\nthe totals table and hiding it in the mobile media query. In Carta that is an\n`x-box` with ``:width=\"{ small: `100%`, medium: `336px` }\"`` and `align=\"right\"`.\n\n**Band 11 is a real two-column band** — `x-row :layout=\"[50, 50]\"` with an\n`x-heading-4`-sized label and an address paragraph in each column. Set\n`:break-point` above 375 here, because addresses must stack on a phone. This is\nthe opposite of the totals block, whose label/value pairs must never stack.\n\n**Band 14 is taller than a promotional footer.** Transactional footers carry a\npostal address and drop the marketing block, because the message is not\nmarketing. Keep the unsubscribe link anyway: the review only waives it once the\nemail is attached to a transactional message, and on a standalone draft a\nmissing one is an error-severity finding that locks the readiness score.\n\n## Structural variants\n\n**Purchase receipt with line items** — roughly half of the true order emails in\nthe group. All 14 bands, with 8-11 as the core. Pick this whenever the brief\nmentions items, a cart, a total, or a shipment.\n\n**Service or fare receipt** — about a fifth. Rides, deliveries, restaurant\ntabs, subscription renewals. Bands 8-10 collapse into one money block with the\ntotal set large at the top and the fare components under it. Band 9 is a list\nof charge names, not products, so it needs no thumbnails and no `x-html`.\n\n**Booking or reservation confirmation** — about a fifth. Bands 9 and 10 are\nreplaced by a details block: date, time, party size, address, and a row of\nutility actions (Add to calendar / Get directions / Modify / Cancel) built with\n`x-wrap` plus small `x-cta`s. A cancellation-policy paragraph is common.\n\n**Double opt-in or verify email** — the most common variant in the top-ranked\n30, and structurally the smallest. Logo, one hero image or headline, one line of\ncopy, one button, one \"if you did not request this, ignore this email\"\nparagraph, footer. No order data at all.\n\n**One-time code or registration confirmation** — about a tenth. The code is set\nas a 24-32px heading, followed by an expiry sentence and a fallback link. Event\nregistration is the same shape with a calendar-app button row instead of a code.\n\n## Subject lines\n\nGrouped by rhetorical move, shapes kept exactly as observed.\n\n| Move                | Observed shape                                                                              |\n| ------------------- | ------------------------------------------------------------------------------------------- |\n| Bare order id       | `Order 65000US confirmed` · `Order #WIMP6500 confirmed`                                     |\n| Possessive order id | `Your Order 6505600 is confirmed` · `Your order is confirmed`                               |\n| Gratitude           | `Thanks for your order` · `Thank you for your purchase!`                                    |\n| Gratitude + id      | `Your {{company.name}} order #{{event.order_id}} has been placed!`                          |\n| Receipt label       | `Receipt from {{company.name}}` · `Your receipt from {{company.name}} #5600-6505`           |\n| Third-party format  | `Order confirmation for {{customer.first_name}} from {{event.merchant_name}}`               |\n| Playful             | `We got your order. (You have great taste!)`                                                |\n| Booking             | `Reservation status: Your booking is confirmed` · `You are booked, {{customer.first_name}}` |\n| Verify              | `Confirm your email address` · `Verify your email` · `You are (almost) subscribed`          |\n| Code                | `Your login code is 650050` · `Your one-time passcode`                                      |\n\nLength runs 15 to 45 characters. Order ids appear in about half of the\npurchase-receipt subjects, which is the strongest single pattern in the group.\nEmoji are rare — roughly one in eight, and always a checkmark or a package\n(`AG1, confirmed ✅`), never a face. First-name personalization appears in about\na fifth, mostly in food-delivery and appointment emails.\n\n## Preheaders\n\nReal preheaders, anonymized:\n\n- `We will let you know as soon as it ships.`\n- `Thanks for your purchase. You will receive another email once your order #{{event.order_id}} has shipped.`\n- `Your order is being processed now`\n- `Your card should arrive within 3-7 days.`\n- `Receipt for {{event.order.total}} at {{company.name}} on {{event.placed_at | date: \"%b %-d %Y at %-I:%M %p\"}}.`\n- `Thanks for choosing {{company.name}} — here is your ride receipt!`\n- `You can add items until your shopper checks out.`\n- `We just need to make sure it is really you.`\n\nThe corpus preheader almost never repeats the subject. It carries the **next**\nfact: the shipping promise, the arrival window, or the amount charged. Keep it\nunder 90 characters. Two of the emails read had no preheader at all, and the\nsign-in alert repeated its subject verbatim — treat both as mistakes to avoid.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Order access: `View your order` · `View order details` · `View order`\n- Fulfilment: `Track your order` · `Track my package`\n- Store return: `Visit our store` · `Shop now`\n- Documents: `Download PDF invoice` · `View receipt`\n- Booking utility: `Add to calendar` · `Get directions` · `View in maps` · `Manage visit`\n- Support: `Contact us` · `Contact support` · `Help center`\n- Verify variant: `Confirm` · `Confirm your email` · `Verify email address`\n\nOne primary CTA per email is the norm. The corpus receipts do **not** repeat the\nprimary CTA near the footer the way promotional emails do. A second, weaker\nbutton appears in about a third of them, and it is either a store link or a\nsupport link. Labels are 2-4 words and start with a verb.\n\n## Tone\n\n- Second person, present and near-future tense: \"your order is being prepared\",\n  \"we will let you know when it ships\".\n- Confirm before anything else. The first sentence states that the thing\n  happened. No greeting-plus-buildup.\n- Plain numbers, no exclamation on money. Exclamation marks appear in roughly a\n  third of the headlines and almost never below band 5.\n- Say what happens next and give a window (\"within 3-7 days\", \"by Friday\").\n  Roughly two thirds do this.\n- Name the reversal path early — cancel, change, or return — when one exists.\n  About a quarter put a \"See an issue? Cancel your order\" line above the totals.\n- Sign off from a team, not a person: \"— Team {{company.name}}\".\n- Keep upsell below the totals. Every corpus receipt that sells something puts\n  it after band 12.\n\n## Responsive and dark-mode idioms\n\n- Sources set the content column with a percentage below the breakpoint and a\n  fixed pixel width above it. Carta:\n  ``:width=\"{ small: `94%`, medium: `560px` }\"`` with\n  `\u003cx-base :mobile-breakpoint=\"601\"\u003e`.\n- Side padding shrinks on mobile — 40px becomes 20px in most sources. Carta:\n  ``:padding=\"{ small: `20px`, medium: `40px` }\"``.\n- The totals spacer cell is hidden on mobile, so the totals block widens to\n  100%. Carta: ``:width=\"{ small: `100%`, medium: `336px` }\"``.\n- Address columns stack, totals rows do not. Use `:break-point=\"480\"` on the\n  address `x-row` and a value below 375 on every totals `x-row`.\n- Product thumbnails shrink from 60-80px to 40-55px in the mobile rules of the\n  larger sources. Carta `x-image` `:src` and `width` are both responsive.\n- Dark mode is mostly unhandled in this corpus. Where a source does handle it,\n  it swaps a dark logo for a light one. Carta:\n  ``:src=\"{ light: `…`, dark: `…` }\"`` on `x-image`, and explicit\n  ``:background=\"{ light: `…`, dark: `…` }\"`` pairs on the bands.\n\n## Line items, totals and order data\n\nThe line-item rows are the one place in this email type where `x-html` is\ncorrect. They are a real data table: a header row or per-column alignment, a\nright-aligned money column, and a repeat per item. Everything around them stays\nas components.\n\nKeep the escaped subtree as small as possible:\n\n- `x-html` — only the `\u003ctable\u003e` of item rows.\n- `x-hr`, `x-row`, `x-paragraph` — the totals block. Never raw HTML.\n- `x-cta` — the order button. Never raw HTML.\n- `x-heading-*` — the \"Order summary\" label. Never raw HTML.\n\nThe corpus emails are one-off exports with the items already flattened into\nstatic markup, so this file cannot tell you the real field names.\n**Tell the user the loop is their part.** Write the `{% for %}`, then say plainly\nin your reply which order fields you assumed and that the user must confirm them\nagainst their own event payload. The shape:\n\n```html\n\u003cx-html\u003e\n  \u003ctable\n    width=\"100%\"\n    cellspacing=\"0\"\n    role=\"presentation\"\n    style=\"border-collapse: collapse\"\n  \u003e\n    {% for item in event.order.items %}\n    \u003ctr\u003e\n      \u003ctd width=\"75\" style=\"padding: 0 15px 15px 0\"\u003e\n        \u003cimg\n          src=\"{{item.image_url}}\"\n          width=\"60\"\n          height=\"60\"\n          alt=\"{{item.title}}\"\n          style=\"border-radius: 8px; border: 1px solid #e5e5e5\"\n        /\u003e\n      \u003c/td\u003e\n      \u003ctd\n        style=\"width: 100%; padding-bottom: 15px; font-size: 16px; line-height: 1.4\"\n      \u003e\n        {{item.title}} × {{item.quantity}}\u003cbr /\u003e\n        \u003cspan style=\"font-size: 14px; color: #777777\"\u003e{{item.variant}}\u003c/span\u003e\n      \u003c/td\u003e\n      \u003ctd\n        align=\"right\"\n        style=\"white-space: nowrap; padding-bottom: 15px; font-weight: 600\"\n      \u003e\n        {{item.line_total}}\n      \u003c/td\u003e\n    \u003c/tr\u003e\n    {% endfor %}\n  \u003c/table\u003e\n\u003c/x-html\u003e\n```\n\nFields that recur across the receipts, with Liquid names that are safe to\npropose. Use top-level event attributes — write `{{event.order_id}}`, not\n`{{event.data.order_id}}`, unless the payload really nests a `data` object.\n\n| Field            | Token                                            | Seen in |\n| ---------------- | ------------------------------------------------ | ------- |\n| Order number     | `{{event.order_id}}`                             | ~85%    |\n| Order date       | `{{event.placed_at \\| date: \"%B %-d, %Y\"}}`      | ~45%    |\n| Item list        | `{% for item in event.order.items %}`            | ~85%    |\n| Subtotal         | `{{event.order.subtotal}}`                       | ~85%    |\n| Shipping         | `{{event.order.shipping}}`                       | ~75%    |\n| Tax              | `{{event.order.tax}}`                            | ~70%    |\n| Discount         | `{{event.order.discount}}`                       | ~30%    |\n| Total            | `{{event.order.total}} {{event.order.currency}}` | ~95%    |\n| Shipping address | `{{event.order.shipping_address}}`               | ~55%    |\n| Billing address  | `{{event.order.billing_address}}`                | ~40%    |\n| Shipping method  | `{{event.order.shipping_method}}`                | ~40%    |\n| Payment method   | `{{event.order.payment_method}}`                 | ~50%    |\n| Order link       | `{{event.order.status_url}}`                     | ~90%    |\n\n## Example: order confirmation with itemised summary\n\nSubject: `Order {{event.order_id}} confirmed`\nPreheader: `Thanks for ordering. We will let you know as soon as it ships.`\n\nThis is the **purchase receipt** variant with the customer-information band\nomitted, which is how the source shipped it. Bands: image header, greeting and\nnotes, two stacked buttons, full-width rule, order summary, totals, dark footer.\nTo adapt it, change the two brand colors and the header image first, then\nreplace the static item row with the Liquid loop from § Line items above.\n\n\u003c!-- Converted from a real order-confirmation exemplar. The conversion was\n     pixel-graded against the source render: desktop 0.963, mobile 0.955. The\n     markup below was then simplified for idiom and accessibility per review, so\n     it no longer reproduces the source pixel for pixel. The score describes the\n     original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#f1e6e0\"\n  color=\"#44533e\"\n  font-family=\"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"601\"\n\u003e\n  \u003cx-section\n    :width=\"{ small: `94%`, medium: `560px` }\"\n    padding=\"24px 4px 10px\"\n    data-layer-label=\"Header\"\n  \u003e\n    \u003cx-image\n      src=\"https://placehold.co/1040x680\"\n      alt=\"{{company.name}} order confirmation — thank you\"\n      width=\"100%\"\n    /\u003e\n  \u003c/x-section\u003e\n\n  \u003cx-section\n    :width=\"{ small: `94%`, medium: `560px` }\"\n    padding=\"0 3px 33px\"\n    data-layer-label=\"Body\"\n  \u003e\n    \u003cx-paragraph color=\"#777777\" margin=\"0\"\u003e\n      Hi {{customer.first_name | default: \"there\"}},\u003cbr /\u003e\n      Thanks for ordering. A few quick notes: an adult signature is required.\n      Please be ready to show proof of ID on delivery\u003cbr /\u003e\u003cbr /\u003e\u003cbr /\u003e\u003cb\n        \u003eRecipes:\u003c/b\n      \u003e\u003cbr /\u003e\n      All our recipes are on the site, and we add more each week. Let us know if\n      there is something you want to see.\u003cbr /\u003e\u003cbr /\u003e\n    \u003c/x-paragraph\u003e\n\n    \u003cx-hr :height=\"2\" background-color=\"#44533e\" margin=\"8px 0\" /\u003e\n\n    \u003cx-box padding=\"14px 0 0\" data-layer-label=\"Actions\"\u003e\n      \u003cx-cta\n        href=\"{{event.order.status_url}}\"\n        background=\"#f1e6e0\"\n        color=\"#44533e\"\n        padding=\"9px 30px\"\n        border-style=\"solid\"\n        border-width=\"1px\"\n        border-color=\"#44533e\"\n        border-radius=\"0px\"\n        :line-height=\"1.125\"\n        text-transform=\"uppercase\"\n        align=\"center\"\n        margin=\"9px 0\"\n        \u003eView your order\u003c/x-cta\n      \u003e\n      \u003cx-paragraph text-align=\"center\" font-weight=\"700\" margin=\"0\"\n        \u003eor\u003c/x-paragraph\n      \u003e\n      \u003cx-cta\n        href=\"https://example.com/shop\"\n        background=\"#44533e\"\n        color=\"#f1e6e0\"\n        padding=\"9px 30px\"\n        border-style=\"solid\"\n        border-width=\"1px\"\n        border-color=\"#44533e\"\n        border-radius=\"0px\"\n        :line-height=\"1.125\"\n        text-transform=\"uppercase\"\n        align=\"center\"\n        margin=\"9px 0 0\"\n        \u003eVisit our store\u003c/x-cta\n      \u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\n  \u003cx-section width=\"100%\" padding=\"0 1px\" data-layer-label=\"Divider\"\u003e\n    \u003cx-hr :height=\"2\" background-color=\"#44533e\" margin=\"8px 0\" /\u003e\n  \u003c/x-section\u003e\n\n  \u003cx-section\n    :width=\"{ small: `94%`, medium: `560px` }\"\n    padding=\"41px 2px 43px\"\n    data-layer-label=\"Order summary\"\n  \u003e\n    \u003cx-heading-3 :font-size=\"20\" font-weight=\"700\" margin=\"0 0 26px\"\n      \u003eORDER SUMMARY\u003c/x-heading-3\n    \u003e\n\n    \u003c!-- Only the item rows are raw. The source had no cellpadding on either\n         table, so Chrome's default 1px cell padding is part of the geometry —\n         do not add cellpadding=\"0\" here. --\u003e\n    \u003cx-html\u003e\n      \u003ctable\n        width=\"100%\"\n        cellspacing=\"0\"\n        role=\"presentation\"\n        style=\"\n          color: #44533e;\n          width: 100%;\n          border-spacing: 0;\n          border-collapse: collapse;\n          background-color: #f3e6e0;\n        \"\n      \u003e\n        \u003ctr\u003e\n          \u003ctd\u003e\n            \u003ctable style=\"border-spacing: 0; border-collapse: collapse\"\u003e\n              \u003ctr\u003e\n                \u003ctd\u003e\n                  \u003cimg\n                    src=\"https://placehold.co/60x60\"\n                    align=\"left\"\n                    width=\"60\"\n                    height=\"60\"\n                    alt=\"Product\"\n                    style=\"margin-right: 15px; border-radius: 8px; border: 1px solid #e5e5e5\"\n                  /\u003e\n                \u003c/td\u003e\n                \u003ctd style=\"width: 100%\"\u003e\n                  \u003cspan\n                    style=\"font-size: 16px; font-weight: 600; line-height: 1.4; color: #555555\"\n                    \u003eLimited edition bottle × 2\u003c/span\n                  \u003e\u003cbr /\u003e\n                \u003c/td\u003e\n                \u003ctd style=\"white-space: nowrap\"\u003e\n                  \u003cp\n                    align=\"right\"\n                    style=\"color: #555555; line-height: 150%; font-size: 16px; font-weight: 600; margin: 0 0 0 15px\"\n                  \u003e\n                    $60.00\n                  \u003c/p\u003e\n                \u003c/td\u003e\n              \u003c/tr\u003e\n            \u003c/table\u003e\n          \u003c/td\u003e\n        \u003c/tr\u003e\n      \u003c/table\u003e\n    \u003c/x-html\u003e\n\n    \u003cx-hr :height=\"1\" background-color=\"#e5e5e5\" margin=\"16px 0 0\" /\u003e\n\n    \u003cx-box\n      :width=\"{ small: `100%`, medium: `336px` }\"\n      align=\"right\"\n      padding=\"0 1px\"\n      margin=\"20px 0 0\"\n      data-layer-label=\"Totals\"\n    \u003e\n      \u003cx-row\n        :layout=\"[50, 50]\"\n        :gap=\"0\"\n        :break-point=\"1\"\n        padding=\"5px 0\"\n        border-style=\"solid\"\n        border-width=\"1px 0 0 0\"\n        border-color=\"#44533e\"\n      \u003e\n        \u003cx-column\n          \u003e\u003cx-paragraph color=\"#777777\" margin=\"0\"\n            \u003eSubtotal\u003c/x-paragraph\n          \u003e\u003c/x-column\n        \u003e\n        \u003cx-column\n          \u003e\u003cx-paragraph\n            text-align=\"right\"\n            color=\"#555555\"\n            font-weight=\"700\"\n            margin=\"0\"\n            \u003e$60.00\u003c/x-paragraph\n          \u003e\u003c/x-column\n        \u003e\n      \u003c/x-row\u003e\n      \u003cx-row :layout=\"[50, 50]\" :gap=\"0\" :break-point=\"1\" padding=\"5px 0\"\u003e\n        \u003cx-column\n          \u003e\u003cx-paragraph color=\"#777777\" margin=\"0\"\n            \u003eShipping\u003c/x-paragraph\n          \u003e\u003c/x-column\n        \u003e\n        \u003cx-column\n          \u003e\u003cx-paragraph\n            text-align=\"right\"\n            color=\"#555555\"\n            font-weight=\"700\"\n            margin=\"0\"\n            \u003e$0.00\u003c/x-paragraph\n          \u003e\u003c/x-column\n        \u003e\n      \u003c/x-row\u003e\n      \u003cx-row :layout=\"[50, 50]\" :gap=\"0\" :break-point=\"1\" padding=\"5px 0\"\u003e\n        \u003cx-column\n          \u003e\u003cx-paragraph color=\"#777777\" margin=\"0\"\u003eTaxes\u003c/x-paragraph\u003e\u003c/x-column\n        \u003e\n        \u003cx-column\n          \u003e\u003cx-paragraph\n            text-align=\"right\"\n            color=\"#555555\"\n            font-weight=\"700\"\n            margin=\"0\"\n            \u003e$0.00\u003c/x-paragraph\n          \u003e\u003c/x-column\n        \u003e\n      \u003c/x-row\u003e\n      \u003cx-row\n        :layout=\"[50, 50]\"\n        :gap=\"0\"\n        :break-point=\"1\"\n        padding=\"20px 0 0\"\n        margin=\"20px 0 0\"\n        border-style=\"solid\"\n        border-width=\"2px 0 0 0\"\n        border-color=\"#e5e5e5\"\n      \u003e\n        \u003cx-column\n          \u003e\u003cx-paragraph color=\"#777777\" font-weight=\"700\" margin=\"0\"\n            \u003eTotal\u003c/x-paragraph\n          \u003e\u003c/x-column\n        \u003e\n        \u003cx-column\n          \u003e\u003cx-paragraph text-align=\"right\" font-weight=\"700\" margin=\"0\"\n            \u003e$60.00 USD\u003c/x-paragraph\n          \u003e\u003c/x-column\n        \u003e\n      \u003c/x-row\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\n  \u003cx-section\n    :width=\"{ small: `94%`, medium: `560px` }\"\n    outer-background=\"#455643\"\n    background=\"#455643\"\n    color=\"#f1e6e0\"\n    padding=\"36px 2px 78px\"\n    data-layer-label=\"Footer\"\n  \u003e\n    \u003cx-image\n      src=\"https://placehold.co/1044x318\"\n      alt=\"{{company.name}}\"\n      width=\"100%\"\n      margin=\"0 0 32px\"\n    /\u003e\n    \u003cx-wrap gap=\"40px\" align-content=\"center\" margin=\"0 0 7px\"\u003e\n      \u003cx-image\n        src=\"https://placehold.co/30x30\"\n        alt=\"Instagram\"\n        href=\"https://example.com/instagram\"\n        width=\"30px\"\n      /\u003e\n      \u003cx-image\n        src=\"https://placehold.co/30x30\"\n        alt=\"Facebook\"\n        href=\"https://example.com/facebook\"\n        width=\"30px\"\n      /\u003e\n    \u003c/x-wrap\u003e\n    \u003cx-hr\n      :height=\"2\"\n      background-color=\"#f1e6e0\"\n      width=\"70%\"\n      align=\"center\"\n      margin=\"0\"\n    /\u003e\n    \u003cx-paragraph text-align=\"center\" margin=\"0\"\u003e\n      \u003cbr /\u003e{{company.name}}, 5634 N. Example St, Portland OR 97209, US\n    \u003c/x-paragraph\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal                     | Where                                    | Token                                  |\n| --------------------------- | ---------------------------------------- | -------------------------------------- |\n| `#f1e6e0` page background   | `x-base background`, outline `x-cta`     | `globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e`  |\n| `#44533e` brand ink         | text color, rules, solid `x-cta`         | `globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e`  |\n| `#455643` footer fill       | footer `outer-background` / `background` | `globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e`  |\n| `#777777` / `#555555` muted | body copy, totals labels and values      | `globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e`  |\n| `-apple-system, …` stack    | `x-base font-family`                     | `globalStyles.fonts.\u003csafeName\u003e_\u003cid\u003e`   |\n| `41px 2px 43px` padding     | order-summary `x-section`                | `globalStyles.spacing.\u003csafeName\u003e_\u003cid\u003e` |\n\nKeep the literals when no token matches. This example reproduces a real design,\nso do not restyle it onto the workspace palette unless the user asks.\n\nWhat to delete first when the brief is simpler:\n\n- The second `x-cta` and the `or` paragraph. Most receipts have one button.\n- The image header. Replace it with a logo `x-image` plus an `x-heading-1`\n  when the brand has no artwork.\n- The footer `x-image`. An `x-paragraph` with the postal address is enough.\n- The `x-html` block and the totals `x-box`, if the brief is a booking or\n  verify-email variant. Those two bands are what make this a purchase receipt.\n\nWhat to add when the brief needs it: the customer-information band from\n§ Anatomy (band 11) goes between the totals `x-box` and the footer, as an\n`x-row :layout=\"[50, 50]\" :break-point=\"480\"` inside the order-summary section.\n","email_patterns/product_launch.md":"---\nname: Product Launch Email Patterns\ndescription: What a product launch or \"new arrival\" email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Product Launch Emails\n\nA product launch email introduces something that did not exist last week. It\nnames the thing, shows it large, says what is new about it, and gives one way to\nsee more. It is the most design-led email type in the corpus: the picture does\nmost of the work and the copy is deliberately short.\n\nThe patterns below come from ~32 ranked exemplars in a 2,587-email product-launch\nset (subject and description metadata for all 32, rendered screenshots for 13,\nfull HTML for 3). The set is dominated by consumer ecommerce — apparel,\nfurniture, hardware, beauty — with a minority of SaaS feature launches. **Prevalence\nfigures are eyeballed from those 32 emails, not counted across the full set.**\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n1  logo band        ~75%     centered logo, small; often absent because the hero carries the wordmark\n2  hero             ~always  one large product photo, full content width or inset 15-30px\n3  eyebrow          ~40%     \"NEW\", \"JUST DROPPED\", \"INTRODUCING\" — 10-13px, uppercase, tracked\n4  headline         ~85%     the product name, or the campaign line; 24-48px\n5  lede             ~80%     1-3 sentences on what it is and why it exists\n6  detail images    ~60%     1-3 more photos: close-ups, a second colorway, the product in use\n7  spec or feature  ~45%     2-4 short feature lines, sometimes with icons\n8  primary CTA      ~always  one button or one linked image; \"Shop\", \"Explore\", \"Learn more\"\n9  secondary items  ~30%     related products or colorways in a 2-4 column row\n10 social proof     ~20%     one review quote, a star rating, or a press line\n11 nav row          ~45%     category links, uppercase, tracked, 10-12px\n12 footer legal     ~always  address, unsubscribe, preference link\n```\n\n**Hero.** Larger than in any other email type — often 60-80% of the first\nscreen. It is a single photograph on a flat brand background, not a collage.\nInset heroes (a 15-30px band of page color around the image) are about as common\nas full-bleed ones and are what makes these emails read as \"considered\".\n\n**Eyebrow.** A short uppercase line above the headline with heavy letter\nspacing. In Carta this is an `x-paragraph` with `text-transform=\"uppercase\"` and\n`:letter-spacing` in **em** — a 10px line with 1.5px tracking is\n`:letter-spacing=\"0.15\"`.\n\n**Headline.** Two shapes recur: the product name alone (\"New: Girard Throw\nBlanket\") and a campaign line that never names the product (\"An instrument of\nintention\"). The second is more common in fashion and interiors, and it always\ndepends on the hero image to carry the meaning.\n\n**Lede.** Short — two or three sentences, centered, often at an unusually large\nsize (24-28px) in a serif. This is the band that most often carries a trademark\n`\u003csup\u003e`; see the note at the end of this file, because a `\u003csup\u003e` changes the\nline box height.\n\n**Detail images.** Either stacked full-width or a 2-column row of close-ups. In\nthe corpus they are almost never captioned.\n\n**Primary CTA.** Roughly a third of launches use a **linked image** rather than\na text button, because the button is part of the campaign artwork. That is\n`x-image href=\"…\"`, never an image inside an `x-cta`.\n\n**Nav row.** Uppercase category links separated by pipes, at 10-12px with heavy\ntracking. On mobile this row usually stacks to one link per line, which the\nsource does with a hidden desktop table and a hidden mobile table — reproduce it\nwith `hide=\"small\"` / `hide=\"medium\"` rather than by guessing.\n\n## Structural variants\n\n**Editorial** (~35%). Inset hero, campaign headline, one centered lede, two\ndetail images, one linked-image CTA, a tracked nav row, a quiet footer. No\nprices, no specs. Pick this for fashion, interiors, and fragrance.\n\n**Spec-led** (~25%). Hero, product name, then three or four feature lines with\nicons or numbers, then a button and a price. Pick this for hardware, wearables,\nand anything with measurable improvements over a previous version.\n\n**Drop / collection** (~25%). Hero, a collection name, then a 2-4 column grid of\nthe pieces in the drop, each linked. One \"shop the collection\" button at the\nend. Pick this when the launch is several SKUs at once.\n\n**Feature release** (SaaS, ~15%). Logo, a short \"what's new\" headline, then one\nblock per feature — screenshot, feature name, one sentence, a \"learn more\" text\nlink — and one button at the end. Pick this for product updates and release\nnotes.\n\n## Subject lines\n\n| Move                        | Pattern                                              |\n| --------------------------- | ---------------------------------------------------- |\n| Introducing (most common)   | `Introducing the {{product.name}} Collection`        |\n| New plus name               | `New: {{product.name}}`                              |\n| Drop framing                | `New Drop: {{collection.name}}`                      |\n| Arrival                     | `It's arrived.` / `{{product.name}} has arrived`     |\n| Inside / behind the scenes  | `Inside the next generation of {{product.name}}`     |\n| Collaboration               | `{{company.name}} x {{partner.name}}`                |\n| Question                    | `Ball Star lover?` / `{{product.name}} lover?`       |\n| Version number              | `{{product.name}} 1.0: now 40% faster`               |\n| Early access                | `EARLY ACCESS: New gear for your next trip`          |\n| Back in stock               | `The {{product.name}} is back`                       |\n\n\"Introducing\", \"New\", and \"Meet\" together account for roughly half the subjects.\nLength clusters at 20-45 characters — shorter than any other type. Emoji are\nrare here (about one in six) compared with promotions, and the product name\nappears in the subject about 60% of the time.\n\n## Preheaders\n\n- `Utility. Artistry. Vintage inspirations.`\n- `The smallest and most advanced yet.`\n- `Shop the new collection before it goes.`\n- `Designed to run, built to last.`\n- `Here is what changed, and why.`\n- `Limited edition — available now.`\n\nLaunch preheaders extend the headline rather than restating it. When the\nheadline is a campaign line, the preheader is where the product category\nactually appears. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Shop: `Shop now` · `Shop the collection` · `Shop new arrivals`\n- Explore: `Explore the collection` · `Discover {{product.name}}` · `See the details`\n- Learn: `Learn more` · `Read the story` · `See how it works`\n- Try: `Try it now` · `Start free` · `Try risk-free for 30 nights`\n- Reserve: `Pre-order` · `Reserve yours` · `Get early access`\n\nTwo to five words, sentence case or uppercase in about equal measure. Launch\nemails carry the fewest buttons of any type — one is the norm, and a second\nappears only after a product grid. When the CTA is baked into the hero artwork,\nstill give the `x-image` an `alt` that reads as the button label.\n\n## Tone\n\n- Declarative and present tense. \"The collection finds poetry in pragmatic\n  design\", not \"we are excited to announce\".\n- Short. The median launch email is three sentences of body copy.\n- Third person about the product, first person plural about the brand.\n- Superlatives are used sparingly and are usually comparative against the\n  brand's own previous version (\"our largest suite to date\", \"the smallest yet\").\n- No urgency unless the drop is genuinely limited. Urgency belongs to\n  **promotion_sale.md**.\n- Trademark and registered marks appear inline (`™`, `®`) and are load-bearing\n  legal text — keep them.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. Launch emails cluster at `max-width: 480px`,\n  lower than other types, because the design is image-led and needs less\n  reflow. Read the source and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match.\n- Every fixed-width image needs a mobile half:\n  ``:width=\"{ small: `100%`, medium: `570px` }\"``. The source expresses this as\n  `width: 570px; max-width: 100%`, which is easy to read past.\n- Side padding usually appears **only** on mobile: ``:padding=\"{ small: `0 20px`, medium: `0` }\"``.\n  A full-bleed desktop hero gains a 20px gutter on a phone.\n- Large centered ledes drop a size on mobile — 28px to 24px is the common pair.\n  `:line-height` takes a single number, so when the source also changes the line\n  height (39px to 32px), duplicate the band with `hide=\"small\"` /\n  `hide=\"medium\"`. The desktop copy inherits the base size and line height. Give\n  the mobile copy its own exact values. That is what the example below does.\n- `hide=\"small\"` hides a component below the breakpoint; `hide=\"medium\"` hides it\n  at and above it. This is the faithful way to convert a source's\n  `.desktop-element` / `.mobile-element` pair.\n- Set an explicit `background` on `x-base` and on `x-section`. These emails lean\n  on warm off-white page colors that a dark-mode client will otherwise invert\n  into mud. A band only needs its own `background` when the value differs from\n  the one it inherits.\n\n## Trademarks, ledes, and the `\u003csup\u003e` line box\n\nThe launch lede is where the `\u003csup\u003e` problem shows up, and it is worth 4px per\nline — enough to push every band below it out of alignment.\n\nA `\u003csup\u003e` inherits `line-height`. In a source `\u003ctd style=\"line-height: 39px\"\u003e`\nthe superscript inherits **39px**, so its raised inline box is tall enough to\ngrow the line box. Carta's `:line-height=\"1.3929\"` is *unitless*, so the same\n`\u003csup\u003e` inherits `12px × 1.3929 = 17px` instead, the line box does not grow, and\nthe band comes out short.\n\n```html\n\u003c!-- WRONG — the sup inherits the unitless ratio and the band loses 4px per line --\u003e\n\u003cx-paragraph :font-size=\"28\" :line-height=\"1.3929\"\u003e\n  The new Faircroft\u003csup style=\"font-size: 12px;\"\u003e\u0026#8482;\u003c/sup\u003e Collection\n\u003c/x-paragraph\u003e\n\n\u003c!-- RIGHT — restate the source's computed line-height on the sup itself --\u003e\n\u003cx-paragraph :font-size=\"28\" :line-height=\"1.3929\"\u003e\n  The new Faircroft\u003csup style=\"font-size: 12px; line-height: 39px;\"\u003e\u0026#8482;\u003c/sup\u003e Collection\n\u003c/x-paragraph\u003e\n```\n\nTwo more traps in the same band:\n\n- **A `sup { font-size: 100% !important }` rule inside `\u003c!--[if gte mso 9]\u003e` does\n  not apply in any real browser.** Convert the inline value, not the Outlook\n  override.\n- **Non-breaking spaces decide where a headline wraps.** A source that writes\n  `Now up to 30% off.` is forcing a break point. Copy the\n  `\u0026nbsp;` characters verbatim or the line rewraps.\n\n## Example: editorial launch\n\nSubject: `Introducing the {{product.name}} Collection`\nPreheader: `Utility. Artistry. Vintage inspirations.`\n\nThis is the **editorial** variant: an inset hero, a centered lede, a second\ndetail image, a second lede, a linked-image CTA, a tracked nav row, and a\ntwo-line footer. It has no text button at all — the CTA is artwork. Change the\nthree images, the two ledes, and the nav labels first.\n\n\u003c!-- Converted from a real product-launch exemplar. The conversion was pixel-graded\n     against the source render: 0.30% of desktop pixels and 4.87% of mobile pixels\n     differed, score 0.838. The markup below was then simplified for idiom and\n     accessibility per review, so it no longer reproduces the source pixel for\n     pixel. The score describes the original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#141415\"\n  color=\"#403c3c\"\n  font-family=\"'Times New Roman', Times, Baskerville, Georgia, serif\"\n  :font-size=\"28\"\n  :line-height=\"1.3929\"\n  :mobile-breakpoint=\"481\"\n\u003e\n  \u003cx-section width=\"600px\" padding=\"\" background=\"#eae4d5\" data-layer-label=\"Email\"\u003e\n    \u003cx-image\n      src=\"https://placehold.co/1140x1112\"\n      alt=\"{{product.name}} — an instrument of intention\"\n      :width=\"{ small: `100%`, medium: `570px` }\"\n      align=\"center\"\n      :margin=\"{ small: `15px 20px 0`, medium: `15px 0 0` }\"\n      data-layer-label=\"Hero\"\n    /\u003e\n\n    \u003cx-box padding=\"10px 40px 60px 40px\" hide=\"small\" data-layer-label=\"Lede\"\u003e\n      \u003cx-paragraph text-align=\"center\" font-weight=\"100\" margin=\"0\"\n        \u003eNothing unneeded. Nothing false. The new\n        {{product.name}}\u003csup style=\"font-size: 12px; line-height: 39px;\"\u003e\u0026#8482;\u003c/sup\u003e\n        Collection celebrates the honest truth, laid plain. Its release marks the\n        debut of our first living finish.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      padding=\"0 20px 60px 20px\"\n      hide=\"medium\"\n      data-layer-label=\"Lede (mobile)\"\n    \u003e\n      \u003cx-paragraph\n        :font-size=\"24\"\n        :line-height=\"1.3333\"\n        text-align=\"center\"\n        font-weight=\"100\"\n        margin=\"0\"\n        \u003eNothing unneeded. Nothing false. The new\n        {{product.name}}\u003csup style=\"font-size: 12px; line-height: 32px;\"\u003e\u0026#8482;\u003c/sup\u003e\n        Collection celebrates the honest truth, laid plain. Its release marks the\n        debut of our first living finish.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/1140x388\"\n      alt=\"Two pieces from the {{product.name}} Collection, shown close up\"\n      :width=\"{ small: `100%`, medium: `570px` }\"\n      align=\"center\"\n      :margin=\"{ small: `0 20px`, medium: `0` }\"\n      data-layer-label=\"Detail image\"\n    /\u003e\n\n    \u003cx-box padding=\"50px 50px 40px 50px\" hide=\"small\" data-layer-label=\"Story\"\u003e\n      \u003cx-paragraph text-align=\"center\" font-weight=\"100\" margin=\"0\"\n        \u003eInspired by the workshops of the last century, the collection finds\n        poetry in pragmatic design. Our largest suite to date\u0026#8212;and yet, every\n        detail serves a purpose.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      padding=\"50px 20px 20px 20px\"\n      hide=\"medium\"\n      data-layer-label=\"Story (mobile)\"\n    \u003e\n      \u003cx-paragraph\n        :font-size=\"24\"\n        :line-height=\"1.3333\"\n        text-align=\"center\"\n        font-weight=\"100\"\n        margin=\"0\"\n        \u003eInspired by the workshops of the last century, the collection finds\n        poetry in pragmatic design. Our largest suite to date\u0026#8212;and yet, every\n        detail serves a purpose.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/920x94\"\n      alt=\"Explore the {{product.name}} Collection\"\n      href=\"https://example.com/collections/new\"\n      :width=\"{ small: `100%`, medium: `460px` }\"\n      align=\"center\"\n      :margin=\"{ small: `10px 20px 80px`, medium: `10px 0 80px` }\"\n      data-layer-label=\"CTA\"\n    /\u003e\n\n    \u003cx-box padding=\"20px 0 0 0\" data-layer-label=\"Nav\"\u003e\n      \u003cx-paragraph\n        font-family=\"Helvetica, Arial, sans-serif\"\n        :font-size=\"10\"\n        :line-height=\"3\"\n        :letter-spacing=\"0.15\"\n        font-weight=\"900\"\n        text-align=\"center\"\n        text-transform=\"uppercase\"\n        margin=\"0\"\n        hide=\"small\"\n        \u003e\u003ca href=\"https://example.com/kitchen\" style=\"color: #403c3c; text-decoration: none;\"\u003eKitchen\u003c/a\n        \u003e\u0026nbsp; |\u0026nbsp;\n        \u003ca href=\"https://example.com/bath\" style=\"color: #403c3c; text-decoration: none;\"\u003eBath\u003c/a\n        \u003e\u0026nbsp; |\u0026nbsp;\n        \u003ca href=\"https://example.com/shower\" style=\"color: #403c3c; text-decoration: none;\"\u003eShower\u003c/a\n        \u003e\u0026nbsp; |\u0026nbsp;\n        \u003ca href=\"https://example.com/about\" style=\"color: #403c3c; text-decoration: none;\"\n          \u003eOur world\u003csup style=\"line-height: 0; font-size: 55%; vertical-align: super;\"\u003e\u0026#174;\u003c/sup\u003e\u003c/a\n        \u003e\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph font-family=\"Helvetica, Arial, sans-serif\" :font-size=\"10\" :line-height=\"3\" :letter-spacing=\"0.15\" font-weight=\"900\" text-align=\"center\" text-transform=\"uppercase\" margin=\"0\" hide=\"medium\"\u003e\u003ca href=\"https://example.com/kitchen\" style=\"color: #403c3c; text-decoration: none;\"\u003eKitchen\u003c/a\u003e\u003c/x-paragraph\u003e\n      \u003cx-paragraph font-family=\"Helvetica, Arial, sans-serif\" :font-size=\"10\" :line-height=\"3\" :letter-spacing=\"0.15\" font-weight=\"900\" text-align=\"center\" text-transform=\"uppercase\" margin=\"0\" hide=\"medium\"\u003e\u003ca href=\"https://example.com/bath\" style=\"color: #403c3c; text-decoration: none;\"\u003eBath\u003c/a\u003e\u003c/x-paragraph\u003e\n      \u003cx-paragraph font-family=\"Helvetica, Arial, sans-serif\" :font-size=\"10\" :line-height=\"3\" :letter-spacing=\"0.15\" font-weight=\"900\" text-align=\"center\" text-transform=\"uppercase\" margin=\"0\" hide=\"medium\"\u003e\u003ca href=\"https://example.com/shower\" style=\"color: #403c3c; text-decoration: none;\"\u003eShower\u003c/a\u003e\u003c/x-paragraph\u003e\n      \u003cx-paragraph font-family=\"Helvetica, Arial, sans-serif\" :font-size=\"10\" :line-height=\"3\" :letter-spacing=\"0.15\" font-weight=\"900\" text-align=\"center\" text-transform=\"uppercase\" margin=\"0\" hide=\"medium\"\u003e\u003ca href=\"https://example.com/about\" style=\"color: #403c3c; text-decoration: none;\"\u003eOur world\u003csup style=\"line-height: 0; font-size: 55%; vertical-align: super;\"\u003e\u0026#174;\u003c/sup\u003e\u003c/a\u003e\u003c/x-paragraph\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box color=\"#827e7c\" padding=\"0 0 50px 0\" data-layer-label=\"Footer\"\u003e\n      \u003cx-paragraph\n        font-family=\"Helvetica, Arial, sans-serif\"\n        :font-size=\"10\"\n        :line-height=\"3\"\n        font-weight=\"500\"\n        text-align=\"center\"\n        margin=\"0\"\n        \u003e{{company.name}}\u003csup style=\"line-height: 0; font-size: 55%; vertical-align: super;\"\u003e\u0026#174;\u003c/sup\u003e\n        123 Example Street, Portland, OR 97204 USA\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph\n        font-family=\"Helvetica, Arial, sans-serif\"\n        :font-size=\"10\"\n        :line-height=\"2\"\n        font-weight=\"500\"\n        text-align=\"center\"\n        margin=\"0\"\n        \u003e\u003ca href=\"https://example.com/stores\" style=\"color: #827e7c; text-decoration: underline;\"\u003eFind a showroom\u003c/a\n        \u003e\u0026nbsp;\u0026nbsp;\u0026nbsp;\u003ca href=\"https://example.com/resources\" style=\"color: #827e7c; text-decoration: underline;\"\u003eDesign resources\u003c/a\n        \u003e\u0026nbsp;\u0026nbsp;\u0026nbsp;\u003ca href=\"https://example.com/contact\" style=\"color: #827e7c; text-decoration: underline;\"\u003eContact us\u003c/a\n        \u003e\u0026nbsp;\u0026nbsp;\u0026nbsp;\u003ca href=\"{% unsubscribe_url %}\" style=\"color: #827e7c; text-decoration: underline;\"\u003eUnsubscribe\u003c/a\n        \u003e\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                | Token to use instead                                    |\n| ------------------------------------- | ------------------------------------------------------- |\n| `background=\"#141415\"` on `x-base`    | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#eae4d5\"` on `x-section` | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`        |\n| `color=\"#403c3c\"` on `x-base`         | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`            |\n| `color=\"#827e7c\"` on the footer box   | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`           |\n| `padding=\"50px 50px 40px 50px\"`       | `:padding=\"globalStyles.spacing.xlarge_\u003cid\u003e\"`           |\n| `font-family` on `x-base`             | `font-family=\"\"` when a global style already sets it    |\n| the nav row's `font-family`           | `:font-family=\"globalStyles.fonts.ui_\u003cid\u003e\"`             |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete both mobile duplicates\n(`hide=\"medium\"`) and put a single `:font-size=\"{ small: 24, medium: 28 }\"` on\nthe surviving lede. You lose exact mobile line-height fidelity and gain half the\nmarkup — the right trade when you are writing a new email rather than converting\none. Delete the nav row entirely and the email is seven components long.\n\n**What to add for a spec-led launch.** After the second lede, insert an `x-row`\nat `:layout=\"[33, 34, 33]\"` with `:break-point=\"480\"` and its own `:padding`,\none `x-column` per feature: a 40-48px `x-image` icon, a bold `x-paragraph` for\nthe feature name, and a 14px `x-paragraph` for the sentence. Then replace the\nlinked-image CTA with a real `x-cta` so the price and the button sit together.\n\n**Use a real `x-cta` when you have the label as text.** The linked-image CTA\nabove is faithful to the source, but it is unreadable to screen readers beyond\nits `alt`, invisible to dark mode, and not editable in the visual editor. When\nyou write a new launch email, use `x-cta`.\n","email_patterns/promotion_sale.md":"---\nname: Promotion \u0026 Sale Email Patterns\ndescription: What a promotion or sale email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, offer-block conventions, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Promotion \u0026 Sale Emails\n\nA promotion email exists to move one offer. It names a discount, a price drop,\nor a limited window, shows the thing the discount applies to, and gives one way\nto act. Everything else in the email is scaffolding around that offer.\n\nThe patterns below come from ~32 ranked exemplars in a 1,990-email promotion set\n(subject and description metadata for all 32, rendered screenshots for 13, full\nHTML for 4). The set is mostly ecommerce and consumer subscription, with a\nminority of B2B event promotions. **Prevalence figures are eyeballed from those\n32 emails, not counted across the full set** — treat them as \"common / sometimes\n/ rare\" rather than as statistics.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- One `x-section` carries the width and every band (hero, offer, CTA, fine\n  print) is its own `x-box` inside it, each with its own `{ light, dark }`\n  background and matching text pair.\n\n## Anatomy\n\n```text\n1  utility strip     ~35%     one line above the logo: free shipping, offer terms, \"view online\"\n2  logo band         ~always  centered or left logo, 20-50px padding\n3  hero              ~85%     photo or offer artwork, full content width, often with baked-in type\n4  offer headline    ~90%     the number: \"40% off\", \"up to $1,100\", \"2 for $499\"\n5  offer detail      ~60%     code, expiry, eligibility — one or two short lines\n6  primary CTA       ~always  one button, brand fill, directly under the offer\n7  product grid      ~45%     2-4 items: image, name, price or rating, per-item link\n8  benefit tiles     ~30%     2-3 icon+text cards: shipping, returns, reviews\n9  secondary CTA     ~40%     repeats the primary after the grid, or a category row\n10 fine print        ~55%     asterisk terms, expiry date, exclusions — 12-14px muted\n11 social row        ~70%     3-5 linked icons, 20-40px, centered\n12 nav links         ~40%     SHOP MEN'S · SHOP WOMEN'S · category text links\n13 footer legal      ~always  address, preference link, unsubscribe\n```\n\n**Utility strip.** A single sentence on a tinted band above the logo. It carries\na standing offer (\"Free shipping on orders $99+\") rather than this email's\noffer. Build it as an `x-box` with its own background, not as part of the logo\nband — the two colors differ.\n\n**Hero.** Two thirds of the heroes carry the headline as baked-in type inside\nthe image. That is the corpus pattern and it costs accessibility: keep the\nheadline in the image's `alt`, and add a real `x-heading-1` when you have the\ncopy as text. Full-bleed heroes (no side padding) are more common here than in\nany other email type.\n\n**Offer headline.** The number is the message. It is set larger than any other\ntype in the email — 32-48px is normal, and 48px+ appears in about a quarter.\nSentence case and uppercase are about equally common.\n\n**Offer detail.** The code appears as bold inline text inside a sentence more\noften than as a styled coupon box. Expiry copy follows it in roughly half of the\noffers, either inline (\"Offer ends 3/16\") or as a separate fine-print line with\nan asterisk that ties back to the button label.\n\n**Primary CTA.** One button, immediately under the offer, before any product\ngrid. Pill buttons (`border-radius` 26-99px) are more common than rounded\nrectangles. A fixed-width button (200-260px) that goes full width on mobile is\nthe most common construction — ``:width=\"{ small: `100%`, medium: `220px` }\"``.\n\n**Product grid.** Three across is the median, two across for large product\nphotos. Each cell is an image, a name, and either a price or a star-rating\nimage. The cells are equal width and stack on mobile.\n\n**Benefit tiles.** Rounded cards (16-22px radius) on a tinted background with a\n24px icon over one line of copy. They sit below the grid and above the social\nrow, and they repeat across every send from that brand — a good custom-component\ncandidate.\n\n**Fine print.** Longer here than in any other type. Discount eligibility,\nexclusions, and expiry run to four or five lines of 12px muted text above the\nlegal footer.\n\n## Structural variants\n\n**Offer-led** (~40%). Logo, a large offer headline, one supporting paragraph,\none button, then fine print and footer. No product grid. Pick this when the\noffer applies to everything and the brief has one link.\n\n**Product-grid** (~25%). Hero with a category headline, then a 3-across grid of\nspecific products, then a single \"shop all\" button. Pick this when the offer is\n\"here is what is on sale\" rather than a single percentage.\n\n**Single-product** (~20%). One product, a large photo, two or three benefit\nlines, a price or discount, and one button. Reads like a product launch with a\nprice attached. Pick this for a hero SKU or a bundle.\n\n**Event or ticket** (~15%). Speaker or venue photo, date and location, a price\ntier (\"$99 before prices increase\"), and a register button. Pick this for\nconferences, festivals, and ticketed launches.\n\n## Subject lines\n\n| Move                         | Pattern                                                       |\n| ---------------------------- | ------------------------------------------------------------- |\n| Bare percentage (most common) | `Up to 30% off select essentials`                            |\n| Deadline first               | `Ends today: up to $1,100 to get stronger`                   |\n| Hours remaining              | `Just a few hours left to save 40%`                          |\n| Last call                    | `Last call to save 15% on your first order`                  |\n| Offer plus emoji             | `Smiles, you have a promo 🎉`                                 |\n| Benefit, price hidden        | `Steady energy for the whole family's fall schedule.`        |\n| Product news as offer        | `A new era of {{product.name}} 🥤`                            |\n| Personalized promo           | `{{customer.first_name}}, your promo is waiting`             |\n| Event pricing                | `Register now for $99 before prices increase`                |\n| Playful brand voice          | `So good, you'll forget they're good for you.`               |\n\nRoughly half of the subjects contain a number — a percentage, a dollar amount,\nor a deadline. Length clusters at 25-50 characters. About a third carry one\nemoji, almost always at the end. Urgency words (\"ends today\", \"last call\",\n\"hours left\") appear in about a quarter and are the strongest single pattern\nafter the number itself.\n\n## Preheaders\n\n- `Discover July's top-rated styles.`\n- `Explore the Sleep Better Tonight collection`\n- `$100 off the Pod, email exclusive`\n- `Savings of up to 40% off are waiting for you in your account.`\n- `Stack your 15% welcome offer with the value built into package deals.`\n- `Use code {{offer.code}} and save up to 30% before school starts.`\n- `Your copy is here and filled with everything you need.`\n\nNearly every promotion email carries one. It does the job the subject line could\nnot fit: if the subject names the percentage, the preheader names the product or\nthe deadline, and the other way round. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Shop: `Shop now` · `Shop bestsellers` · `Shop the sale` · `Stock up now`\n- Claim: `Get 40% off` · `Claim your offer` · `Redeem your discount`\n- Learn: `See the deal` · `View your offer` · `Read now`\n- Event: `Register now` · `Get your ticket` · `Save your seat`\n- App: `Get the deal in the app` · `Download the app`\n\nTwo to four words. Repeating the number inside the label (\"Get 40% off\") is\nabout as common as a generic label (\"Shop now\") and converts the button into a\nsecond statement of the offer. Uppercase labels are more common here than in\nother email types — roughly a third. When the button label carries an asterisk,\nthe matching fine-print line must exist below it.\n\n## Tone\n\n- Urgency, but stated as a fact: \"There are just a few hours left\", not \"Hurry!\"\n- Second person, present tense, short sentences.\n- The offer appears in the first 40 characters of the body copy.\n- Exclamation marks are less common than in welcome emails — the number carries\n  the excitement.\n- Ecommerce brands write in voice and will pun on the product. Subscription and\n  B2B brands stay plain and lead with the saving.\n- Terms are never hidden: expiry, exclusions, and eligibility are stated, in\n  smaller type, in the same email.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. Sources here sit between 480px and 632px. Read\n  the source's own `@media` boundary and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e`\n  to match it. Do not assume 600.\n- **Check which side of the boundary the email's own width falls on.** A 600px\n  email with `@media (max-width: 600px)` renders its *mobile* rules at exactly\n  600px, which is the width the grader screenshots at. When both viewports are\n  on the `small` side, the responsive objects never switch — that is correct.\n- Fixed-width buttons go full width on mobile:\n  ``:width=\"{ small: `100%`, medium: `220px` }\"``.\n- Hero images are `width=\"100%\"` on mobile and a fixed pixel width on desktop:\n  ``:width=\"{ small: `100%`, medium: `570px` }\"``. Omitting the `small` half is\n  the single most common mobile defect in a converted promotion email — the\n  image overflows the viewport and every band below it shifts.\n- Side padding collapses: 40px desktop to 20px mobile is the most common pair.\n- Offer headlines drop a size on mobile: `:font-size=\"{ small: 32, medium: 48 }\"`.\n  `:line-height` is **not** responsive — it takes a single number, so pick the\n  ratio that matters most and accept the other. When both must be exact,\n  duplicate the band with `hide=\"small\"` / `hide=\"medium\"`.\n- Explicit backgrounds on `x-base`, on `x-section`, and on any band whose fill\n  differs from the one it inherits. Promotion emails are the most likely of any\n  type to use a dark card on a light page, and a transparent band inverts\n  unpredictably in dark mode. Repeating a background a band already inherits\n  adds nothing.\n\n## The offer block\n\nThe offer block is the band this email type exists for, and it has three\nrecurring shapes.\n\n**Inline in a sentence** (most common). The code is bold text inside the body\ncopy: `Use code \u003cstrong\u003eSAVE20\u003c/strong\u003e before Friday.` Build it as one\n`x-paragraph` with a `\u003cstrong\u003e`. Do not promote it to its own component.\n\n**Stacked lockup.** A large percentage on one line, the code on the next, the\nexpiry on a third, all centered on a tinted `x-box`. Set the percentage as\n`x-heading-1` and the other two as `x-paragraph`s with `margin=\"0\"` and the gap\ncarried by the box padding.\n\n**Coupon card.** A bordered or dashed box holding the code, sometimes with a\n\"copy\" affordance the email cannot actually provide. Two nested `x-box`es — the\nouter carries the border colour as `background` and the border width as\n`padding`.\n\nWhatever the shape, the expiry line is not optional and it belongs with the\noffer, not only in the fine print. Guard every merge field:\n`{{offer.code | default: \"SAVE20\"}}`.\n\n## Example: offer-led promotion\n\nSubject: `Just a few hours left to save 40%`\nPreheader: `Explore the {{company.name}} collection before the offer ends.`\n\nThis is the **offer-led** variant: a logo band, a large offer headline, a top\nCTA, a full-bleed hero, three paragraphs of body copy, a repeated CTA, a\nfine-print line, and a four-part footer. Both buttons are the same component with\nthe same responsive width. Change the two headlines, the three paragraphs, and\nthe hero image first — everything else is chrome.\n\n\u003c!-- Converted from a real promotion exemplar. The conversion was pixel-graded\n     against the source render: score 0.672, with 6.35% of desktop pixels and\n     9.85% of mobile pixels differing. The residual was a font substitution, not\n     a structural error: the source loads a brand web font via @font-face that\n     Carta cannot carry across, so every line of body copy wraps one word\n     earlier in the original. The markup below was then simplified for idiom and\n     accessibility per review, so it no longer reproduces the source pixel for\n     pixel. The score describes the original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#E2DED9\"\n  color=\"#FFFFFF\"\n  font-family=\"Arial, sans-serif\"\n  :font-size=\"18\"\n  :line-height=\"1.6667\"\n  :mobile-breakpoint=\"601\"\n\u003e\n  \u003cx-section width=\"600px\" padding=\"\" data-layer-label=\"Email\"\u003e\n    \u003cx-box\n      background=\"#281466\"\n      :padding=\"{ small: `50px 20px 40px`, medium: `50px 40px 40px` }\"\n      data-layer-label=\"Offer\"\n    \u003e\n      \u003cx-image\n        src=\"https://placehold.co/420x84\"\n        alt=\"{{company.name}}\"\n        href=\"https://example.com/\"\n        width=\"210px\"\n        align=\"center\"\n        margin=\"0 0 40px\"\n      /\u003e\n      \u003cx-heading-1\n        :font-size=\"{ small: 32, medium: 48 }\"\n        text-align=\"center\"\n        margin=\"0 0 20px\"\n        \u003eSave before sundown\u003c/x-heading-1\n      \u003e\n      \u003cx-cta\n        href=\"https://example.com/offer\"\n        background=\"#0061EF\"\n        :width=\"{ small: `100%`, medium: `220px` }\"\n        :font-size=\"18\"\n        :line-height=\"1.1111\"\n        font-weight=\"700\"\n        padding=\"16px 10px\"\n        border-radius=\"26px\"\n        align=\"center\"\n        margin=\"0\"\n        \u003eGet 40% off\u003c/x-cta\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box background=\"#281466\" data-layer-label=\"Hero\"\u003e\n      \u003cx-image\n        src=\"https://placehold.co/1200x900\"\n        alt=\"40% off — offer ends soon\"\n        href=\"https://example.com/offer\"\n        width=\"100%\"\n      /\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#101340\"\n      :padding=\"{ small: `50px 32px 30px`, medium: `50px 40px 30px` }\"\n      data-layer-label=\"Body\"\n    \u003e\n      \u003cx-paragraph margin=\"0\"\n        \u003eHi {{customer.first_name | default: \"there\"}},\u003cbr /\u003e\u003cbr /\u003eThere are just\n        a few hours left to get 40% off. Sign up now to unlock the full\n        collection and get more out of every week.\u003cbr /\u003e\u003cbr /\u003eExplore everything\n        included in your plan — new releases, member-only sessions, and the\n        tools you use most, all in one place.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#101340\"\n      :padding=\"{ small: `0 20px 30px`, medium: `0 40px 30px` }\"\n      data-layer-label=\"CTA and fine print\"\n    \u003e\n      \u003cx-cta\n        href=\"https://example.com/offer\"\n        background=\"#0061EF\"\n        :width=\"{ small: `100%`, medium: `220px` }\"\n        :font-size=\"18\"\n        :line-height=\"1.1111\"\n        font-weight=\"700\"\n        padding=\"16px 10px\"\n        border-radius=\"26px\"\n        align=\"center\"\n        margin=\"0 0 30px\"\n        \u003eGet 40% off*\u003c/x-cta\n      \u003e\n      \u003cx-paragraph :font-size=\"14\" text-align=\"center\" margin=\"0\"\n        \u003e*Limited time only. Offer ends {{offer.ends_at | date: \"%-m/%-d/%y\"}}.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#101340\"\n      :padding=\"{ small: `50px 20px`, medium: `50px 40px` }\"\n      data-layer-label=\"Footer\"\n    \u003e\n      \u003cx-paragraph\n        :font-size=\"14\"\n        color=\"#9DA5B8\"\n        text-align=\"center\"\n        margin=\"0 0 30px 0\"\n        \u003eIf you have any questions, please visit our\n        \u003cstrong\n          \u003e\u003ca href=\"https://example.com/faq\" style=\"color: #FFFFFF; text-decoration: none;\"\n            \u003eFAQs\u003c/a\n          \u003e\u003c/strong\n        \u003e\n        or email us at\n        \u003cstrong\n          \u003e\u003ca href=\"mailto:help@example.com\" style=\"color: #FFFFFF; text-decoration: none;\"\n            \u003ehelp@example.com\u003c/a\n          \u003e\u003c/strong\n        \u003e. Our team can answer questions about your account.\u003c/x-paragraph\n      \u003e\n\n      \u003cx-hr\n        :height=\"2\"\n        background-color=\"#E2DED9\"\n        width=\"160px\"\n        align=\"center\"\n        margin=\"0 0 30px 0\"\n      /\u003e\n\n      \u003cx-wrap gap=\"20px\" align-content=\"center\" padding=\"0 0 30px 0\"\u003e\n        \u003cx-image src=\"https://placehold.co/40x40\" alt=\"TikTok\" href=\"https://tiktok.com/@example\" width=\"20px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/44x44\" alt=\"Instagram\" href=\"https://instagram.com/example\" width=\"22px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/44x44\" alt=\"LinkedIn\" href=\"https://linkedin.com/company/example\" width=\"22px\" /\u003e\n        \u003cx-image src=\"https://placehold.co/44x44\" alt=\"YouTube\" href=\"https://youtube.com/@example\" width=\"22px\" /\u003e\n      \u003c/x-wrap\u003e\n\n      \u003cx-paragraph\n        :font-size=\"12\"\n        :line-height=\"1.5\"\n        text-align=\"center\"\n        margin=\"0 0 30px 0\"\n        \u003e\u003ca href=\"https://example.com/account\" style=\"color: #FFFFFF; text-decoration: none;\"\u003eMy account\u003c/a\u003e\u0026nbsp;|\u0026nbsp;\u003ca href=\"https://example.com/how-it-works\" style=\"color: #FFFFFF; text-decoration: none;\"\u003eHow it works\u003c/a\u003e\u003cbr /\u003e\u003ca href=\"https://example.com/faq\" style=\"color: #FFFFFF; text-decoration: none;\"\u003eFAQs\u003c/a\u003e\u0026nbsp;|\u0026nbsp;\u003ca href=\"https://example.com/terms\" style=\"color: #FFFFFF; text-decoration: none;\"\u003eT\u0026amp;Cs\u003c/a\u003e\u0026nbsp;|\u0026nbsp;\u003ca href=\"https://example.com/privacy\" style=\"color: #FFFFFF; text-decoration: none;\"\u003ePrivacy Policy\u003c/a\u003e\u003c/x-paragraph\n      \u003e\n\n      \u003cx-paragraph\n        :font-size=\"12\"\n        :line-height=\"1.5\"\n        color=\"#9DA5B8\"\n        text-align=\"center\"\n        margin=\"0\"\n        \u003eYou have received this email as a registered user of\n        {{company.name}}.\u003cbr /\u003eYou can unsubscribe from these emails\n        \u003ca href=\"{% unsubscribe_url %}\" style=\"color: #FFFFFF; text-decoration: underline;\"\u003ehere\u003c/a\u003e.\u003cbr /\u003e{{company.name}},\n        123 Example Street, Portland, OR 97204, United States.\u003cbr /\u003e© {{\"now\" | date: \"%Y\"}}\n        {{company.name}}. All rights reserved.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                       | Token to use instead                                    |\n| -------------------------------------------- | ------------------------------------------------------- |\n| `background=\"#E2DED9\"` on `x-base`           | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#281466\"` on the offer and hero | `:background=\"globalStyles.colors.brandDark_\u003cid\u003e\"`      |\n| `background=\"#101340\"` on the lower bands    | `:background=\"globalStyles.colors.surfaceDark_\u003cid\u003e\"`    |\n| `background=\"#0061EF\"` on both CTAs          | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`        |\n| `color=\"#FFFFFF\"` on `x-base`                | `:color=\"globalStyles.colors.onDark_\u003cid\u003e\"`              |\n| `color=\"#9DA5B8\"` in the footer              | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`           |\n| `border-radius=\"26px\"` on the CTAs           | `:border-radius=\"globalStyles.radius.pill_\u003cid\u003e\"`        |\n| `padding=\"50px 40px 30px\"` on the body       | `:padding=\"globalStyles.spacing.large_\u003cid\u003e\"`            |\n| `font-family` on `x-base`                    | `font-family=\"\"` when a global style already sets it    |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to add when the brief has products.** Insert an `x-row` after the body\nband with three `x-column`s at `:layout=\"[33, 34, 33]\"`, `:gap=\"0\"`,\n`:break-point=\"480\"`. Carry the band's side inset on the row's own `:padding` —\nthe row does not need a wrapper box. Each column takes an\n`x-image width=\"100%\"`, an `x-paragraph` for the name, and one for the price. Keep the\nexisting CTA below it as the \"shop all\" button — that is the **product-grid**\nvariant.\n\n**What to cut when the brief is simpler.** Delete the top `x-cta` and the\nfine-print line. That leaves logo, headline, hero, copy, one button, footer,\nwhich is the shortest promotion email in the corpus and still complete.\n\n**The two buttons must stay identical.** A promotion email repeats one offer; a\nsecond button styled differently reads as a second offer. Both `x-cta`s here\ncarry the same background, radius, padding, and responsive width on purpose.\n","email_patterns/re_engagement.md":"---\nname: Re-engagement Email Patterns\ndescription: What a re-engagement or win-back email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, the stay-or-go dual CTA, and one converted Carta example to adapt.\n---\n\n# Re-engagement Emails\n\nA re-engagement email goes to a contact who has stopped opening, stopped\nlogging in, or stopped buying. It names the absence, gives a reason to come\nback, and asks for one small action. A win-back is the sharper version of the\nsame thing: the contact is about to be dropped from the list, and the email is\nthe last try. The sunset variant inverts the goal — the point of the email is\nto let people leave cleanly.\n\nThe patterns below come from a 765-email re-engagement set. I read the ranked\nmetadata for the top 35 exemplars, plus the subject line and category tags for\nall 765; viewed 15 rendered desktop screenshots spread across the ranking and\nacross industries; read one `email.html` in full and the structural skeleton\n(style blocks, breakpoints, widths, colors, fonts, button markup) of five more.\nEvery prevalence figure below is eyeballed from that sample. Nothing here was\ncounted across the full set unless the text says so.\n\n**Read the ranking skew before you trust the anatomy.** The top-ranked\nexemplars in this group are mostly *not* literal win-backs. They are\ncontent-led \"engagement\" emails — a product-update letter, a cash-flow digest,\na newsletter issue — that carry a `retention-reactivation` tag. They also\ncluster hard on a few senders and one industry: Mercury, American Express,\nTruist, Microsoft and Claude account for a third of the top 35, and fintech or\nSaaS accounts for most of it. The literal win-back subset is separate and\nsmaller: 67 of the 765 emails carry a `winback` tag. I worked from that subset\nfor the anatomy and the variants, because a brief that says \"re-engagement\"\nalmost always means that shape. If your brief is really \"send the lapsed\nsegment this month's product news\", read **newsletter_digest.md** or\n**product_launch.md** instead — those emails look nothing like the ones below.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- A we-miss-you win-back keeps a come-back `x-cta` (`Come back`, `Pick up where\n  you left off`) as its primary action even when the brief also asks for an\n  exit; the opt-out is a visible body link beside it. The unsubscribe-as-button\n  form is for sunset and list-hygiene briefs.\n- The footer keeps its own unsubscribe link even when the body offers an exit.\n\n## Anatomy\n\n```text\n1 logo band        ~always   centered logo, 15-30px padding, white or a soft brand tint\n2 hero             ~65%      photo or flat illustration at content width; often the mood, not the product\n3 emotional opener ~75%      one h1, 28-40px: \"We miss you\", \"It's been a while\", \"Ghosting us?\"\n4 greeting         ~50%      \"Hey there!\" / \"Hi {{first_name}},\" on its own line\n5 body copy        ~always   2-4 short paragraphs: name the gap, say what changed, invite\n6 incentive block  ~30%      percent off, free months, gift card, or an extended trial\n7 primary CTA      ~always   one button, brand fill, pill or 4-8px radius\n8 what's-new block ~45%      2-6 feature rows, numbered steps, or a 2-col card grid\n9 second CTA       ~35%      repeat after the what's-new block, or the stay-or-go pair\n10 social row      ~50%      2-5 linked icons, 24-32px, centered\n11 footer          ~always   address, unsubscribe, preference link, 10-12px muted text\n```\n\n**Logo band.** Small and quiet. Unlike a welcome email, the logo is rarely the\npoint — several of these emails put the whole brand mark inside the hero image\ninstead. Build it as one centered `x-image` carrying its own `margin`.\n\n**Hero.** Two thirds carry one. It is more often a mood or an illustration (a\nsad mascot, an hourglass, two hands passing a phone) than a product shot. When\nthe source's hero is a full-width `\u003cimg\u003e` with `max-width:100%`, keep\n`width=\"600px\"` on `x-image` and let the section carry the width — Carta scales\nit the same way.\n\n**Emotional opener.** This is the band that makes the type recognizable, and it\nis nearly always real text, not baked into the image. It is short, large, and\nfrequently uses a colored word inside the line — `WE \u003cspan style=\"color:\n#ff7973;\"\u003eMISS\u003c/span\u003e YOU`. Keep that inline `\u003cspan\u003e` inside `x-heading-1`\nrather than splitting the heading into three components.\n\n**Body copy.** Three paragraphs is the median, and the order is stable: name\nthe absence, say what has changed since, then invite. Roughly a fifth of the\nsources separate those paragraphs with `\u003cp\u003e\u0026nbsp;\u003c/p\u003e` rather than margins —\nthat is a full line box, not an empty block, while `\u003cp\u003e\u003c/p\u003e` is zero. Reproduce\neach one as `\u003cx-paragraph margin=\"0\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e` or the email loses\n~19px per gap.\n\n**Incentive block.** Around a third of the win-backs carry one, and it is\nusually a percentage or a number of free months rather than a coupon box: \"50%\noff 12- and 24-month plans\", \"2 free months\", \"$20 gift card\", \"extend your\ntrial for $1\". Urgency copy (\"ends in 24 hours\", \"final call\") follows it in\nabout half of those. Under a third of the emails I read have any offer at all —\ndo not invent one.\n\n**Primary CTA.** One per email, immediately after the body copy. Pill radii\n(30-40px) are more common here than in other types. The label almost always\ncontains a return verb — see § CTA phrasing.\n\n**What's-new block.** This is the second most distinctive band after the\nopener. A lapsed contact is being told the product moved on: Zoom lists six\ncheckmarked features, Strava a 2×3 icon grid, Slack a numbered three-step list,\nBrain.fm three icon+text rows. Build the icon rows as `x-row` with a narrow\nfirst column, or as a 2-column `x-row` of cards; never as `x-wrap`.\n\n**Footer.** Always carries the unsubscribe link, and here it carries more\nweight than usual — a re-engagement email is the last stop before a suppression\nlist, so the preference link next to unsubscribe is close to universal in the\nsources I read.\n\n## Structural variants\n\n**Miss-you note** (~35%). Logo, hero, emotional opener, greeting, two to four\nparagraphs, one button, footer. No offer, no feature grid. Pick this when the\nbrief has warmth but no budget and no product news — it is the shortest path to\na competent email of this type, and it is the example at the bottom of this file.\n\n**What-you-missed / re-introduction** (~25%). The email argues that the product\nis not what the reader remembers. Opener, one paragraph, then the what's-new\nblock, then a CTA repeated below it. Pick this for SaaS, a subscription, or any\nbrand that shipped something since the contact left.\n\n**Incentive win-back** (~20%). The offer is the headline: \"Hit reboot on your\nlearning with 50% off\", \"Final call: 2 free months\". Hero, large offer lockup,\none or two lines of terms, one button, and an expiry line. Pick this when the\nsegment is transactional and the offer is real.\n\n**Lapsed trial or subscription restart** (~10%). \"Your trial has ended\", \"Get\nyour access back\", \"Your preview is over\". Reads like a receipt with feelings:\na status statement, what access was lost, a price or plan line, and a Start /\nResume button. Pick this when the trigger is a billing event rather than\nsilence.\n\n**Sunset / permission check** (~10%). The email asks whether to keep sending at\nall. Either a two-column stay-or-go band, or a letter whose primary button is\nliterally \"Unsubscribe\". Pick this when the goal is list hygiene, deliverability,\nor a legal re-consent — see § The stay-or-go band.\n\n## Subject lines\n\n| Move                        | Pattern                                                                 |\n| --------------------------- | ----------------------------------------------------------------------- |\n| Direct miss (most common)   | `We miss you 😭` / `We missed you last night!`                          |\n| Elapsed time                | `It's been a while...` / `It's been a while since we said hello`        |\n| Possessive miss             | `Your {{product}} workspace misses you, {{customer.first_name}}`        |\n| Question / call-out         | `Are you ghosting us? Reactivate today` / `Have you been missing out?`  |\n| Still-open loop             | `Your account is still waiting` / `Is {{destination}} still on your mind?` |\n| Gentle nudge                | `Don't give up on learning Italian! 💔` / `Want to make progress? 📈`   |\n| Come-back imperative        | `Come back and restart your flow!` / `Welcome back!`                    |\n| Offer-led                   | `Hit reboot on your learning with 50% off 🤖` / `Extend your trial for just $1` |\n| Last-chance                 | `Final call: Get your 2 free months` / `Final Chance to Get $7 Off 💥`  |\n| Re-introduction             | `We'd like to re-introduce ourselves, {{customer.first_name}}`          |\n| Permission check            | `Pssst...we have a question for you.` / `Account notice: We may have accidentally unsubscribed you.` |\n| Survey / why-did-you-go     | `Quick question—what held you back?` / `Can you tell us a bit about yourself?` |\n\nAcross the 68 win-back and unsubscribe-tagged emails in the set, subject length\nhas a median of 36 characters and runs from 9 to 86. About a quarter carry one\nemoji, and it is a sad or pleading one far more often than a celebratory one —\n😢 😭 💔 👀 alongside the neutral 📈 ☕. First-name personalization appears in\nunder a tenth, always at the start or the end of the line. Question marks are\nmuch more common here than in any other type: roughly one subject in four is a\nquestion, because a question is the cheapest way to imply the reader owes an\nanswer.\n\n## Preheaders\n\n- `¡Te extrañamos!` (\"we miss you\", from a Spanish-language win-back)\n- `Reconnect with your team`\n- `It's been some time and we wanted to get in touch`\n- `Get back on track with a quick Italian lesson!`\n- `Exciting things await on the app!`\n- `Your we-miss-your-face discount`\n- `Become a subscriber to keep all the features you've tried.`\n- `Shopify Balance has been paying out rewards since you left.`\n- `Hey {{customer.first_name}}, it's not the first time we've been ghosted this summer, but we're shooting our shot one last time to keep in touch with you.`\n- `It's an eggscellent reason to open this email.`\n\nAbout four in five of the win-back sources carry one — noticeably more than in\nthe welcome set, because this type is fighting for the open. It does not\nrestate the subject; it adds the payload the subject withheld (the offer, the\nfeature, the reason). Keep it under about 90 characters. The last two examples\nabove are the exceptions: an overflowing preheader used as the opening\nparagraph, and a pun. Both are real, and both are risky.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Return verbs: `Come back` · `Get back to it` · `Get back on track` · `Jump Back In` · `Return to {{product}}` · `Pick up where you left off`\n- Reactivation: `Reactivate Pro` · `Resume subscription` · `Resubscribe` · `Start my subscription` · `Log in`\n- Offer: `Get 2 months free` · `Upgrade now` · `Claim my offer` · `Get $1 trial` · `Get your gift`\n- Discovery: `Explore sessions` · `Check it out` · `See what's new` · `Discover for yourself`\n- Ask: `Start survey` · `Speak your mind` · `Tell us why`\n- Stay-or-go: `Keep me posted!` · `Yes, I want deal alerts` · `Customize ›` · `No, stay unsubscribed` · `Unsubscribe`\n\nTwo to four words, sentence case slightly more often than uppercase, no\ntrailing punctuation except the occasional exclamation mark that the brand\nvoice already earns. The label carries the return metaphor — \"back\", \"resume\",\n\"return\", \"reboot\" — far more often than a generic \"Shop now\".\n\n**Count is the thing that differs from every other type.** Most email types\nhave one real CTA. Re-engagement routinely has two, and both are real: the\nprimary return button, plus either a repeat of it under the what's-new block or\nits opposite, an exit. Say this out loud when you brief the design, because a\nsecond button that is merely a weaker version of the first is the wrong answer\nhere. If your second CTA is the exit, style it as the visibly quieter of the\ntwo — outlined, or a plain text link — and never hide it.\n\n## Tone\n\n- Second person, present tense, and short. \"We noticed you haven't been around\n  lately.\"\n- The first sentence names the absence. It does not sell, and it does not\n  apologize for emailing.\n- Self-deprecation and mild guilt are both normal: a sad mascot, \"You made Duo\n  sad\", \"Are you ghosting us?\". Keep it light — the reader has to still like\n  you at the end.\n- Blame the inbox, not the reader: \"Inboxes get flooded. Emails get lost.\"\n- Exclamation marks in about half, which is more than any other type.\n- SaaS and fintech stay plain and factual; food, fitness, and consumer brands\n  go hard on voice.\n- A sunset email is written to be easy to say no to. Anything that reads as a\n  trick undoes the point of sending it.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once, and it is often just above the email's own\n  width — `@media (max-width: 620px)` on a 600px email. Read the source's own\n  boundary and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match it (`621` here).\n  When the email is narrower than its own breakpoint, **every** realistic\n  viewport is on the `small` side and your `medium` values never render. Put\n  the values you care about in `small`.\n- The most common responsive rule in this set is body padding: 50px side gutters\n  on desktop collapsing to 30px, expressed as\n  ``:padding=\"{ small: `15px 30px`, medium: `15px 50px 5px 50px` }\"``.\n- The second most common is a headline that stays put while the copy re-centers\n  — several sources only flip `text-align` at the breakpoint, which needs no\n  responsive prop at all if the desktop value is already centered.\n- Feature grids stack; icon and social rows do not. `x-row` with `:break-point`\n  for the first, `x-wrap` for the second.\n- Explicit backgrounds on `x-base` and on `x-section`. The tinted content column\n  over a white page background (`#f9f3f0` over `#ffffff`) is the single most\n  common treatment in this group, and it is what keeps these emails readable\n  when a dark-mode client inverts the page. Set the tint once on `x-section`. Do\n  not repeat it on every band — a band only needs its own `background` when the\n  value differs from the one it inherits.\n- Dark footers on a light body are common — a deep brand color band holding the\n  social icons and the legal text. Set `background` on that `x-box` explicitly;\n  a footer that relies on inheritance disappears in dark mode.\n- Animated GIF heroes appear in about a third of the win-backs. Carta treats\n  them as images, so they survive conversion, but check the file size.\n\n## The stay-or-go band\n\nThis is the band the type is famous for, and it is much rarer than the\nplaybooks suggest. Searching the full 765-email set for \"keep me subscribed\",\n\"stay subscribed\", or \"opt back in\" copy returns five emails. I found six\nacross the whole set that give the reader a real, visible choice to leave:\n\n- **Two columns, one image each.** \"No Hard Feelings\" over a two-column\n  `x-row`: a happy mascot above **Stay → Customize ›** and a crying one above\n  **Leave → Stop receiving these emails → Unsubscribe ›**. Both links are the\n  same size and the same color. This is the cleanest form.\n- **Two buttons side by side.** A filled **Yes, I want deal alerts** next to an\n  outlined **No, stay unsubscribed**. Build it as an `x-wrap` with two `x-cta`s\n  so they stay on one line.\n- **One button that is the unsubscribe.** A plain letter from a named person,\n  and the only styled button in the email says **Unsubscribe**. A second, later\n  CTA offers the alternative. Counter-intuitive and completely legitimate.\n- **Keep-me-in with an implied exit.** One button — **KEEP ME POSTED!** — and a\n  sentence saying what happens if you do nothing: \"Otherwise, we're taking the\n  hint and moving on.\"\n\nPick the two-column form when you have art. Pick the two-button form when you\ndo not. Pick the last one when the segment is large and you would rather lose\nsilence than force a decision. In all four, put the exit link in the body where\nit can be seen, and keep the footer unsubscribe as well — this band replaces\nnothing.\n\n## Example: miss-you note\n\nSubject: `We miss you 😊`\nPreheader: `Something new is waiting for you.`\n\nThis is the **miss-you note** variant, and it is the shape to reach for when\nthe brief has no offer and no feature news: logo band, hero, emotional opener,\nfour paragraphs, one pill button, a two-icon social row, and a dark footer.\nChange the hero image, the headline's colored word, the middle paragraph, and\nthe button label first. The `\u0026nbsp;` paragraphs are the source's paragraph\nspacing — keep them or replace all of them with margins, but do not mix.\n\n\u003c!-- Converted from a real re-engagement exemplar. The conversion was pixel-graded\n     against the source render: score 0.967, with 0.65% of desktop pixels and\n     0.99% of mobile pixels differing. Note the grader's desktop viewport is\n     600px, which is below this email's 620px breakpoint, so the source's mobile\n     rules apply at both viewports — that is why the `small` half of the body\n     padding is the one that renders. The markup below was then simplified for\n     idiom and accessibility per review, so it no longer reproduces the source\n     pixel for pixel. The score describes the original conversion, not this\n     markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#ffffff\"\n  color=\"#00615f\"\n  font-family=\"Arial, Sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"621\"\n\u003e\n  \u003cx-section\n    width=\"600px\"\n    padding=\"\"\n    background=\"#f9f3f0\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-image\n      src=\"https://placehold.co/2501x417\"\n      alt=\"{{company.name}}\"\n      href=\"https://example.com/\"\n      width=\"600px\"\n      align=\"center\"\n      margin=\"15px 0 25px\"\n      data-layer-label=\"Logo band\"\n    /\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/1200x675\"\n      alt=\"Two people sharing something from one phone to another\"\n      href=\"https://example.com/\"\n      width=\"600px\"\n      align=\"center\"\n      margin=\"5px 0\"\n      data-layer-label=\"Hero\"\n    /\u003e\n\n    \u003cx-box padding=\"15px 10px\" data-layer-label=\"Headline\"\u003e\n      \u003cx-heading-1\n        margin=\"0\"\n        :font-size=\"34\"\n        font-weight=\"700\"\n        text-align=\"center\"\n        \u003eWE \u003cspan style=\"color: #ff7973;\"\u003eMISS\u003c/span\u003e YOU\u003cbr /\u003e\u003c/x-heading-1\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      :padding=\"{ small: `15px 30px`, medium: `15px 50px 5px 50px` }\"\n      data-layer-label=\"Body\"\n    \u003e\n      \u003cx-paragraph margin=\"0\" text-align=\"center\"\n        \u003eHey {{customer.first_name | default: \"there\"}}!\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0\" text-align=\"center\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e\n      \u003cx-paragraph margin=\"0\" text-align=\"center\"\n        \u003eWe noticed you have not been around lately, and we have missed seeing\n        you. 😊\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0\" text-align=\"center\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e\n      \u003cx-paragraph margin=\"0\" text-align=\"center\"\n        \u003eWe have been busy adding things we think you will like. Have you tried\n        the new sharing feature? It is a quick way to get more out of your\n        account even when you are short on time. We have also added plenty of\n        new places near you, waiting to be discovered.\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph margin=\"0\" text-align=\"center\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e\n      \u003cx-paragraph margin=\"0\" text-align=\"center\"\n        \u003eToday is a good day to pick up right where you left off!\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-cta\n      href=\"https://example.com/open\"\n      background=\"#00615f\"\n      color=\"#FCF8F2\"\n      :font-size=\"18\"\n      :line-height=\"2\"\n      font-weight=\"700\"\n      padding=\"5px 25px\"\n      border-radius=\"36px\"\n      align=\"center\"\n      margin=\"25px 20px\"\n      data-layer-label=\"CTA\"\n      \u003eGet back to it\u003c/x-cta\n    \u003e\n\n    \u003cx-box\n      background=\"#00615f\"\n      color=\"#ffffff\"\n      padding=\"0 0 5px 0\"\n      data-layer-label=\"Footer\"\n    \u003e\n      \u003cx-wrap gap=\"14px\" align-content=\"center\" padding=\"22.9px 18px 8px 10px\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/418x418\"\n          alt=\"Facebook\"\n          href=\"https://facebook.com/example\"\n          width=\"32px\"\n        /\u003e\n        \u003cx-image\n          src=\"https://placehold.co/418x418\"\n          alt=\"Instagram\"\n          href=\"https://instagram.com/example\"\n          width=\"32px\"\n        /\u003e\n      \u003c/x-wrap\u003e\n\n      \u003cx-box padding=\"10px 20px\"\u003e\n        \u003cx-paragraph margin=\"0\" :font-size=\"12\" text-align=\"center\"\n          \u003ePlease do not reply to this email. You are receiving it because you\n          created a {{company.name}} account in our mobile app. If you no longer\n          want these emails you can update your email settings or\n          \u003ca\n            href=\"{% unsubscribe_url %}\"\n            style=\"text-decoration: underline; color: #67d695;\"\n            \u003eunsubscribe\u003c/a\n          \u003e.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003cx-box padding=\"10px\"\u003e\n        \u003cx-paragraph margin=\"0\" :font-size=\"10\" text-align=\"center\"\n          \u003e{{company.name}} — 1 Example Street, 1000 Somewhere,\n          Country\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                    | Token to use instead                                      |\n| ----------------------------------------- | --------------------------------------------------------- |\n| `background=\"#ffffff\"` on `x-base`        | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"`   |\n| `background=\"#f9f3f0\"` on `x-section`     | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`          |\n| `color=\"#00615f\"` on `x-base`             | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`              |\n| `color=\"#ff7973\"` in the headline span    | `:color=\"globalStyles.colors.accent_\u003cid\u003e\"`                |\n| `background=\"#00615f\"` on the CTA         | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`          |\n| `background=\"#00615f\"` on the footer box  | `:background=\"globalStyles.colors.footerBackground_\u003cid\u003e\"` |\n| `color=\"#ffffff\"` on the footer box       | `:color=\"globalStyles.colors.textOnDark_\u003cid\u003e\"`            |\n| `color=\"#67d695\"` on the unsubscribe link | `:color=\"globalStyles.colors.linkOnDark_\u003cid\u003e\"`            |\n| `border-radius=\"36px\"` on the CTA         | `:border-radius=\"globalStyles.radius.pill_\u003cid\u003e\"`          |\n| `font-family` on `x-base`                 | `font-family=\"\"` when a global style already sets it      |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete the hero `x-image` and the\nemail still reads — the opener plus four paragraphs plus the button is the\nwhole argument. Delete the social `x-wrap` and the footer collapses to two text\nboxes. What you must not cut is the unsubscribe link or the preference wording\nnext to it: this email goes to a segment that is one step from suppression, and\na hidden exit is what turns an ignored email into a spam complaint.\n\n**What to add for the other variants.** For **what-you-missed**, put an `x-row`\nof two `x-column` cards (or three icon+text rows) between the `x-cta` and the\nfooter, then repeat the `x-cta` below it — the repeat is the second real button\nthis type expects. For **incentive win-back**, put the offer in the headline\nand add one `x-paragraph :font-size=\"12\"` of terms under the button. For\n**sunset**, replace the single `x-cta` with the stay-or-go band described\nabove.\n\n**The fractional paddings are load-bearing.** `22.9px` and the asymmetric\n`18px` right padding on the social `x-wrap` are not typos. `22.9px` and `8px`\nabsorb the half-leading that `x-wrap` puts above and below a 32px inline image,\nso the icon row ends up as tall as the source's table cell. The `18px`\nreproduces the source's social table being an\ninline-block with a `width=\"100px\"` attribute and only 91.9px of content, which\nparks the icon pair 4px left of the column's center. When you write a new email\nrather than convert one, use round numbers.\n","email_patterns/recommendations.md":"---\nname: Recommendation Email Patterns\ndescription: What a personalized recommendation email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, item-block conventions, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Recommendation Emails\n\nA recommendation email says \"here are things we picked for you\". It names the\nreason for the pick, shows two to six items, and links each one. The reason is\nwhat separates it from a promotion: a promotion pushes an offer, a\nrecommendation pushes a match.\n\nThe patterns below come from ~32 ranked exemplars in a 312-email recommendation\nset (subject and description metadata for all 32, rendered screenshots for 12,\nfull HTML for 2). **This is the smallest group in the corpus**, and its exemplar\nranking skews toward ecommerce product grids and B2B resource picks — treat the\nprevalence figures below, which are eyeballed from those 32 emails rather than\ncounted, as weaker evidence than in the larger groups.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy\n\n```text\n1  logo band          ~always  centered or left logo, sometimes with a small nav row\n2  hero               ~60%     one image that sets the theme, not one of the items\n3  greeting / reason  ~55%     \"Because you liked X\", \"Picked for your routine\"\n4  section header     ~65%     \"Recommended for you\", \"Tools we think you'll like\"\n5  item blocks        ~always  2-6 repeats: image or icon, name, one or two lines, link\n6  per-item link      ~70%     \"View recipe\", \"Shop now\", \"Read more\" — text, not a button\n7  primary CTA        ~75%     one button: \"See all\", \"Shop the edit\", \"Get started\"\n8  supporting block   ~35%     a quiz, a preference link, or \"not quite right?\"\n9  social proof       ~25%     rating, review count, or \"1 million+ happy customers\"\n10 social row         ~60%     3-5 linked icons\n11 footer legal       ~always  address, preference link, unsubscribe\n```\n\n**Reason line.** The band that makes the email a recommendation. It is one\nsentence, near the top, and it names the input: a past purchase, a quiz answer,\na category, or a usage pattern. Guard every merge field in it —\n`{{customer.last_category | default: \"your favourites\"}}` — because the reason\nline is the first thing to break on a thin profile.\n\n**Item blocks.** Three shapes recur. An icon-left row with the name and copy on\nthe right (an `x-row` at roughly `[12, 88]`), a 2-3 column product grid, and a\nfull-width card with a large image above the name. Icon-left dominates in B2B\nand SaaS; the grid dominates in ecommerce.\n\n**Per-item link.** Text links, underlined or with a trailing arrow glyph. Buttons\nper item appear in about a fifth of the corpus and only when there are two or\nthree items, never with six.\n\n**Primary CTA.** One button after the items. Its label is a superset of the\nitems (\"See all recommendations\"), not a repeat of one of them.\n\n**Supporting block.** A quiz link, a \"tell us more\" prompt, or a preferences\nlink. It exists because the recommendation may be wrong, and it is the polite\nversion of that admission.\n\n**Social proof.** A rating row, a review count, or a customer-count banner,\nusually directly under the item list.\n\n## Structural variants\n\n**Product grid** (~35%). Hero, reason line, then a 2-3 column grid of products\nwith names and prices, then a \"shop all\" button. Pick this for ecommerce with\nfour or more items.\n\n**Reason-led list** (~30%). No hero. A short intro paragraph, then three\nicon+text blocks, each naming a capability or a category, then one button. Pick\nthis for SaaS onboarding and B2B resource picks. This is the variant the example\nbelow follows.\n\n**Single pick** (~20%). One recommendation, treated like a product launch: large\nimage, name, why-you, price, one button. Pick this when the model is confident\nor the catalogue is small.\n\n**Quiz or preference** (~15%). A short pitch, one illustration, and a button\ninto a quiz or preference centre, with recommendations promised afterwards. Pick\nthis when there is no profile to recommend from yet.\n\n## Subject lines\n\n| Move                          | Pattern                                                    |\n| ----------------------------- | ---------------------------------------------------------- |\n| Direct address plus picks     | `{{customer.first_name}}, check out these opportunities`   |\n| Possibility framing           | `What's possible with {{product.name}}`                    |\n| Curated framing               | `The styles everyone's talking about`                      |\n| Numbered promise              | `Only 7 things separate good emails from great ones`       |\n| Category pick                 | `Running gear for winter's worst`                          |\n| Personal-shopper framing      | `Shopping for a tool? Here's your personal stylist.`       |\n| Recipe or use-case            | `The best recipes are meant to be shared`                  |\n| Restock / routine             | `The smarter way to restock your routine`                  |\n| Playful                       | `Nom nom nom` / `Their secret's not safe with us`          |\n| Seasonal edit                 | `Celebrate {{event.name}} with our picks`                  |\n\nSubjects average 30-55 characters. First-name personalization appears in about a\nquarter — higher than most types, because the email is already personal. The\nword \"you\" or \"your\" appears in roughly half. Emoji appear in about a third,\nusually one, at the end.\n\n## Preheaders\n\n- `Plant smarter with {{company.name}} 🌿`\n- `Three features that make {{product.name}} more useful for real work.`\n- `Picked for you, based on what you have been reading.`\n- `Curated for your routine — and your budget.`\n- `See what other {{customer.segment}} customers are choosing.`\n\nThe preheader names the basis for the picks when the subject does not. In the\ncorpus it is also where \"based on your…\" copy lives, keeping the subject line\nclean. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Browse: `See all` · `Shop the edit` · `Browse recommendations`\n- Item-level: `View recipe` · `View product` · `Read more` · `Learn more`\n- Start: `Get started` · `Try it now` · `Start building`\n- Refine: `Take the quiz` · `Update your preferences` · `Tell us what you like`\n- Compare: `Compare plans` · `See the full guide`\n\nItem links are two to three words and repeat verbatim down the list — that\nrepetition is the point, because it makes the list scannable. The single button\nis longer and broader. Never mix button styles between items.\n\n## Tone\n\n- The reason comes first: \"Once you have used {{product.name}} a few times, here\n  are features that make it more useful.\"\n- Second person throughout, and specific: name the category, the past purchase,\n  or the behaviour.\n- One or two sentences per item, no more. The item name carries the meaning.\n- No urgency and no discount language unless the recommendation genuinely has a\n  price attached.\n- Confident but not absolute — \"we think you'll like\", \"picked for you\", \"many\n  people start here\".\n- The supporting block admits the picks may be wrong, and offers a way to fix it.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. Recommendation sources cluster at 480px and\n  640px. Read the source's own `@media` boundary and set\n  `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match it.\n- Icon+text item rows keep two columns at every width: `x-row` with\n  `:break-point=\"1\"`. Product grids stack: `x-row` with `:break-point=\"480\"`.\n- Item images need a mobile width: ``:width=\"{ small: `100%`, medium: `187px` }\"``.\n- Buttons go full width on mobile in about half the corpus:\n  `width=\"100%\"` inside a `{ small, medium }` object, or no width at all.\n- Card backgrounds are explicit — a recommendation card that relies on the page\n  colour disappears in dark mode along with its border.\n- **Beware a source that does not reflow at all.** Several emails in this group\n  are fixed-width tables with no `@media` rule, or with a container rule their\n  inner tables override. They overflow a 375px viewport instead of stacking.\n  A faithful Carta conversion *will* reflow, so a pixel diff at the mobile\n  viewport measures the source's clipping, not your conversion.\n\n## The item block\n\nThe repeating unit, in the two layouts that cover most of the corpus.\n\n```html\n\u003c!-- Icon-left: stays two columns at every width --\u003e\n\u003cx-row :layout=\"[12, 88]\" :gap=\"0\" :break-point=\"1\"\u003e\n  \u003cx-column :padding=\"{ small: `12px 0 12px 12px`, medium: `0 0 0 12px` }\"\u003e\n    \u003cx-image src=\"https://placehold.co/116x116\" alt=\"\" width=\"58px\" style=\"height: 58px;\" /\u003e\n  \u003c/x-column\u003e\n  \u003cx-column :padding=\"{ small: `24px 16px 24px 12px`, medium: `0 36px 0 12px` }\"\u003e\n    \u003cx-paragraph margin=\"0\"\n      \u003e\u003ca href=\"https://example.com/item\" style=\"color: #262624; text-decoration: underline; font-weight: 700;\"\n        \u003eItem name\u003c/a\u003e\n      followed by one or two sentences that say why this pick fits.\u003c/x-paragraph\u003e\n  \u003c/x-column\u003e\n\u003c/x-row\u003e\n\n\u003c!-- Product grid: stacks on mobile --\u003e\n\u003cx-row :layout=\"[33, 34, 33]\" :gap=\"16\" :break-point=\"480\"\u003e\n  \u003cx-column\u003e\n    \u003cx-image src=\"https://placehold.co/374x374\" alt=\"{{item.name}}\" width=\"100%\" border-radius=\"8px\" /\u003e\n    \u003cx-paragraph :font-size=\"14\" text-align=\"center\" margin=\"8px 0 0 0\"\u003e{{item.name}}\u003c/x-paragraph\u003e\n  \u003c/x-column\u003e\n  \u003c!-- repeat --\u003e\n\u003c/x-row\u003e\n```\n\nTwo things to get right:\n\n- **Do not set `:line-height` on the icon column.** `x-column` has no\n  `line-height` prop, so the value never reaches the render. On the `x-image`\n  itself it styles the alt text only, and a tight value there hurts a\n  screen-reader user.\n- **The bold link is inside the paragraph**, not a separate heading. The corpus\n  writes the item name as a bold link at the head of the sentence, so keeping it\n  inline preserves both the wrap and the reading order.\n\n## Example: reason-led list\n\nSubject: `What's possible with {{product.name}}`\nPreheader: `Three features that make {{product.name}} more useful for real work.`\n\nThis is the **reason-led list** variant: a full-width hero, a one-sentence\nreason, three icon+text item blocks, a bordered CTA card with two buttons, a\nsecondary \"evaluating for your team?\" block, and a two-column dark footer.\nChange the hero, the reason line, and the three item blocks first.\n\n\u003c!-- Converted from a real recommendation exemplar. The conversion was\n     pixel-graded against the source render: desktop 0.879, with 3.63% of desktop\n     pixels differing. The mobile viewport reports 30% and drags the combined\n     score to 0, but that number is not a conversion defect: the source is a\n     fixed 640px layout whose container rule its inner tables override, so at\n     375px the original overflows and is clipped while this conversion reflows\n     correctly. The markup below was then simplified for idiom and accessibility\n     per review, so it no longer reproduces the source pixel for pixel. The score\n     describes the original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#ffffff\"\n  color=\"#262624\"\n  font-family=\"Georgia, Arial, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :mobile-breakpoint=\"641\"\n\u003e\n  \u003cx-section width=\"640px\" padding=\"\" data-layer-label=\"Email\"\u003e\n    \u003cx-image\n      src=\"https://placehold.co/1280x420\"\n      alt=\"{{company.name}}\"\n      width=\"100%\"\n      data-layer-label=\"Hero\"\n    /\u003e\n\n    \u003cx-box\n      background=\"#F0EEE6\"\n      :padding=\"{ small: `36px 24px 0 24px`, medium: `36px 60px 0 36px` }\"\n      data-layer-label=\"Reason\"\n    \u003e\n      \u003cx-paragraph margin=\"0 0 24px 0\"\n        \u003eOnce you've used {{product.name}} a few times, here are the features\n        that make it more useful for your work.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-row\n      :layout=\"[12, 88]\"\n      :gap=\"0\"\n      :break-point=\"1\"\n      background=\"#F0EEE6\"\n      :padding=\"{ small: `16px 24px`, medium: `16px 36px` }\"\n      data-layer-label=\"Item 1\"\n    \u003e\n      \u003cx-column :padding=\"{ small: `12px 0 12px 12px`, medium: `0 0 0 12px` }\"\u003e\n        \u003cx-image src=\"https://placehold.co/116x116\" alt=\"\" width=\"58px\" style=\"height: 58px;\" /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column :padding=\"{ small: `24px 16px 24px 12px`, medium: `0 36px 0 12px` }\"\u003e\n        \u003cx-paragraph margin=\"0\"\n          \u003e\u003ca href=\"https://example.com/projects\" style=\"color: #262624; text-decoration: underline; font-weight: 700;\"\u003eProjects\u003c/a\u003e\n          keep everything together. Add the documents, notes, and context that\n          matter for a piece of work, and stop re-explaining the background\n          every time you start.\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-row\n      :layout=\"[12, 88]\"\n      :gap=\"0\"\n      :break-point=\"1\"\n      background=\"#F0EEE6\"\n      :padding=\"{ small: `16px 24px`, medium: `16px 36px` }\"\n      data-layer-label=\"Item 2\"\n    \u003e\n      \u003cx-column :padding=\"{ small: `12px 0 12px 12px`, medium: `0 0 0 12px` }\"\u003e\n        \u003cx-image src=\"https://placehold.co/130x88\" alt=\"\" width=\"65px\" style=\"height: 44px;\" /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column :padding=\"{ small: `24px 16px 24px 12px`, medium: `0 36px 0 12px` }\"\u003e\n        \u003cx-paragraph margin=\"0\"\n          \u003e\u003ca href=\"https://example.com/artifacts\" style=\"color: #262624; text-decoration: underline; font-weight: 700;\"\u003eArtifacts\u003c/a\u003e\n          let you create and edit in real time. Documents, spreadsheets, and\n          code appear in their own window, so you can watch the changes as they\n          happen.\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-row\n      :layout=\"[12, 88]\"\n      :gap=\"0\"\n      :break-point=\"1\"\n      background=\"#F0EEE6\"\n      :padding=\"{ small: `16px 24px`, medium: `16px 36px 24px 36px` }\"\n      data-layer-label=\"Item 3\"\n    \u003e\n      \u003cx-column :padding=\"{ small: `12px 0 12px 12px`, medium: `0 0 0 12px` }\"\u003e\n        \u003cx-image src=\"https://placehold.co/112x112\" alt=\"\" width=\"56px\" style=\"height: 56px;\" /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column :padding=\"{ small: `24px 16px 24px 12px`, medium: `0 36px 0 12px` }\"\u003e\n        \u003cx-paragraph margin=\"0\"\n          \u003e\u003ca href=\"https://example.com/connect\" style=\"color: #262624; text-decoration: underline; font-weight: 700;\"\u003eConnections\u003c/a\u003e\n          link the tools you already use, so the work happens where your\n          information already lives.\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-box\n      background=\"#F0EEE6\"\n      :padding=\"{ small: `36px 24px`, medium: `24px 36px` }\"\n      data-layer-label=\"CTA card\"\n    \u003e\n      \u003cx-box\n        background=\"#FAF9F5\"\n        border-style=\"solid\"\n        border-width=\"1px\"\n        border-color=\"#D1CFC5\"\n        border-radius=\"20px\"\n        padding=\"40px 34px\"\n      \u003e\n        \u003cx-image src=\"https://placehold.co/180x180\" alt=\"\" width=\"90px\" align=\"center\" style=\"height: 90px;\" /\u003e\n        \u003cx-paragraph\n          :font-size=\"{ small: 27, medium: 36 }\"\n          color=\"#141413\"\n          text-align=\"center\"\n          :margin=\"{ small: `0 0 16px 0`, medium: `24px 0 16px 0` }\"\n          \u003eTry it on your work\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph text-align=\"center\" margin=\"0 0 24px 0\"\n          \u003eUpload a document and ask for the key decisions. Draft an outline for\n          the presentation you owe someone. Connect your files and work directly\n          against them.\u003c/x-paragraph\n        \u003e\n        \u003cx-cta\n          href=\"https://example.com/start\"\n          background=\"#141413\"\n          color=\"#FFFFFF\"\n          font-family=\"Arial, sans-serif\"\n          :line-height=\"2.875\"\n          font-weight=\"400\"\n          width=\"100%\"\n          padding=\"0 16px\"\n          border-radius=\"10px\"\n          margin=\"8px 0\"\n          \u003eGet started\u003c/x-cta\n        \u003e\n        \u003cx-cta\n          href=\"https://example.com/watch\"\n          background=\"transparent\"\n          color=\"#262624\"\n          font-family=\"Arial, sans-serif\"\n          :line-height=\"2.875\"\n          font-weight=\"400\"\n          width=\"100%\"\n          padding=\"0 16px\"\n          border-style=\"solid\"\n          border-width=\"1px\"\n          border-color=\"#E3DACC\"\n          border-radius=\"10px\"\n          margin=\"8px 0\"\n          \u003eWatch the walkthrough\u003c/x-cta\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box\n      background=\"#FAF9F5\"\n      :padding=\"{ small: `40px 24px 36px 24px`, medium: `40px 60px 36px 60px` }\"\n      data-layer-label=\"Supporting block\"\n    \u003e\n      \u003cx-paragraph\n        :font-size=\"24\"\n        text-align=\"center\"\n        :letter-spacing=\"-0.0104\"\n        margin=\"0 0 20px 0\"\n        \u003eNot quite what you were looking for?\u003c/x-paragraph\n      \u003e\n      \u003cx-paragraph\n        text-align=\"center\"\n        :margin=\"{ small: `8px 0 0 0`, medium: `0` }\"\n        \u003eMost people try a few of these on their own work first, to see which\n        ones fit how they actually work.\u003cbr /\u003e\u003cbr /\u003eTell us what you care about\n        and the next set of picks will be closer.\u003c/x-paragraph\n      \u003e\n      \u003cx-cta\n        href=\"{% manage_subscription_preferences %}\"\n        background=\"#141413\"\n        color=\"#FFFFFF\"\n        font-family=\"Arial, sans-serif\"\n        :font-size=\"18\"\n        :line-height=\"2.6667\"\n        font-weight=\"400\"\n        padding=\"0 36px\"\n        border-radius=\"10px\"\n        align=\"center\"\n        margin=\"24px 0 0\"\n        \u003eUpdate your preferences\u003c/x-cta\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-row\n      :layout=\"[31, 69]\"\n      :gap=\"0\"\n      :break-point=\"641\"\n      background=\"#141413\"\n      color=\"#B0AEA5\"\n      :padding=\"{ small: `36px 24px`, medium: `36px` }\"\n      data-layer-label=\"Footer\"\n    \u003e\n      \u003cx-column :padding=\"{ small: `0 0 24px 0`, medium: `0 24px 0 0` }\" vertical-align=\"top\"\u003e\n        \u003cx-image src=\"https://placehold.co/266x60\" alt=\"{{company.name}}\" width=\"133px\" align=\"left\" /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column vertical-align=\"top\"\u003e\n        \u003cx-wrap gap=\"24px\" align-content=\"left\"\u003e\n          \u003cx-image src=\"https://placehold.co/72x72\" alt=\"LinkedIn\" href=\"https://linkedin.com/company/example\" width=\"36px\" /\u003e\n          \u003cx-image src=\"https://placehold.co/72x72\" alt=\"X\" href=\"https://x.com/example\" width=\"36px\" /\u003e\n          \u003cx-image src=\"https://placehold.co/72x72\" alt=\"YouTube\" href=\"https://youtube.com/@example\" width=\"36px\" /\u003e\n        \u003c/x-wrap\u003e\n        \u003cx-hr :height=\"1\" background-color=\"#87867F\" margin=\"24px 0\" /\u003e\n        \u003cx-paragraph font-family=\"Georgia, 'Times New Roman', serif\" :font-size=\"12\" margin=\"0 0 16px 0\"\n          \u003e{{company.name}}, 123 Example Street, PMB 90375, Portland, OR\n          97204\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph font-family=\"Georgia, 'Times New Roman', serif\" :font-size=\"12\" margin=\"0 0 16px 0\"\n          \u003eThis email was sent to {{customer.email}}. To opt out of future\n          emails, click\n          \u003ca href=\"{% unsubscribe_url %}\" style=\"color: #B0AEA5; text-decoration: underline;\"\u003eunsubscribe\u003c/a\u003e.\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                   | Token to use instead                                              |\n| ---------------------------------------- | ----------------------------------------------------------------- |\n| `background=\"#F0EEE6\"` on the item rows  | `:background=\"globalStyles.colors.surfaceAlt_\u003cid\u003e\"`               |\n| `background=\"#FAF9F5\"` on the cards      | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`                  |\n| `border-color=\"#D1CFC5\"` on the CTA card | `:border-color=\"globalStyles.colors.border_\u003cid\u003e\"`                 |\n| `background=\"#141413\"` on the buttons    | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`                  |\n| `background=\"#141413\"` on the footer row | `:background=\"globalStyles.colors.footer_\u003cid\u003e\"`                   |\n| `color=\"#262624\"` on `x-base`            | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`                      |\n| `color=\"#B0AEA5\"` on the footer row      | `:color=\"globalStyles.colors.mutedOnDark_\u003cid\u003e\"`                   |\n| `border-radius=\"20px\"` / `\"10px\"`        | `:border-radius=\"globalStyles.radius.large_\u003cid\u003e\"` / `medium_\u003cid\u003e` |\n| `font-family` on `x-base`                | `font-family=\"\"` when a global style already sets it              |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**Turning this into a product grid.** Replace the three icon+text `x-row`s with\none `x-row` at `:layout=\"[33, 34, 33]\"`, `:gap=\"16\"`, `:break-point=\"480\"`, each\ncolumn carrying an `x-image width=\"100%\"`, an `x-paragraph` for the name, and an\n`x-paragraph` for the price. Keep the CTA card underneath as the \"shop all\"\nstep.\n\n**What to cut when the brief is simpler.** Delete the supporting block and its\nbutton. Hero, reason, three items, one CTA card, footer is the shortest complete\nrecommendation email in the corpus.\n\n**Keep the reason line.** It is the one band that cannot be removed without the\nemail turning into a promotion. If there is no data to build a reason from, use\nthe **quiz or preference** variant instead of a generic pick list.\n","email_patterns/review_feedback.md":"---\nname: Review and Feedback Email Patterns\ndescription: What a review request, survey invite, or social-proof email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, the rating widget row, and one converted Carta example to adapt.\n---\n\n# Review and Feedback Emails\n\nTwo related asks live here. The **feedback ask** goes out after an event, a\npurchase, or a support contact and wants the reader to click one rating or open\none survey. The **social-proof email** goes the other way: it takes reviews the\nbrand already has and uses them to sell. They share a footer and a hero habit\nand almost nothing else, so decide which one you are writing before you build\nanything.\n\nThe patterns below come from the review-feedback set: **277 emails**, of which\nthis file read subject and category metadata for the top 50 ranked exemplars\nplus 14 more members chosen by slug, rendered screenshots for 14, and full HTML\nfor 1. Prevalence figures are eyeballed from that reading, not measured.\n\n**The group is two thirds social proof.** Of the 277 members, 165 carry the\ncorpus's `review-testimonial` category and only 101 carry `survey-feedback`, and\nthe top-50 exemplar ranking leans further toward the marketing half — the\nhighest-ranked members are product emails that happen to quote a customer. The\nfeedback-ask anatomy below is drawn from roughly 25 emails read across both the\nranked exemplars and the wider membership. Prefer its shape to its percentages.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n## Anatomy — the feedback ask\n\n```text\n1 logo band       ~always   centered logo, 20-30px padding, white or a tinted page color\n2 hero            ~55%      a photo of the thing being rated, or an illustration; full column width\n3 headline        ~80%      one h1, 20-32px, phrased as a question or a request\n4 greeting        ~40%      \"Hi {{customer.first_name}},\" — less common than in other types\n5 body copy       ~always   1-3 short paragraphs saying what you want and how long it takes\n6 rating widget   ~30%      a row of stars, a 0-10 scale, a yes/no pair, or a stacked option list\n7 primary CTA     ~85%      one button: \"Take the survey\", \"Write your review\", \"Rate this event\"\n8 time promise    ~55%      \"It only takes a minute\", \"\u003c 1 min\", \"12 questions\"\n9 incentive       ~35%      a gift card, a prize draw, or a donation, as bold copy or its own band\n10 secondary ask  ~30%      a quieter follow-up link — follow the organizer, download the app\n11 social row     ~50%      3-5 linked icons\n12 footer         ~always   address, preferences, unsubscribe\n```\n\n## Anatomy — the social-proof email\n\n```text\n1 logo/nav band   ~always   logo plus 3-5 category links\n2 hero            ~always   product or lifestyle photo, full column width\n3 headline        ~always   a claim the reviews support\n4 star row        ~70%      3-5 star glyphs or a star image, centered, above the quotes\n5 quote block     ~always   2-5 pull quotes, italic or large, each with a \"— First L.\" attribution\n6 product row     ~60%      1-3 products with price and a per-item button\n7 primary CTA     ~always   \"Shop now\" / \"Sign me up\"\n8 value props     ~40%      3 icon+text columns\n9 footer          ~always   the brand's standard marketing footer\n```\n\n**Logo band.** Centered in the feedback ask; the social-proof email usually\ncarries the brand's full nav row instead, because it is a marketing send.\n\n**Hero.** In a feedback ask it shows the thing being rated — the venue, the\nevent crowd, the product that shipped. About half the asks have none at all and\nopen straight into the headline, and those are the shortest and clearest emails\nin the set.\n\n**Headline.** Nearly always present and nearly always a question: \"How was it?\",\n\"What did you think of {{product}}?\", \"Got two minutes?\". The imperative form\n(\"Tell us about your recent event\") is the other half.\n\n**Body copy.** Two moves: say what you want, and say what it costs the reader.\n\"Let us know what was great and what could have been better\" plus \"this won't\neven take a minute\" is the whole email in many cases.\n\n**Rating widget.** The band that distinguishes this type. See § The rating\nwidget row.\n\n**Primary CTA.** One button, in the brand's darkest color, pill or small radius.\nFeedback asks are the one transactional-ish type where the button is\nnear-universal — the click _is_ the conversion.\n\n**Incentive.** A third of the asks pay for the response: a $20–$40 gift card, a\n$100 prize draw, a tree planted per response, a discount code. It appears in the\nsubject line as often as in the body, and when it is in the body it is bold\ninline text rather than a coupon box.\n\n**Quote block.** In the social-proof email each quote is a paragraph in the\nbrand's display face with a short attribution line under it — \"— Katherine G.\"\nBuild them as `x-paragraph` siblings, not as a table.\n\n## Structural variants\n\n**Bare ask** (~30%). Logo, headline, two paragraphs, one button, footer. No\nhero, no widget. The B2B and SaaS survey invites are all this shape, often as a\nsigned letter from a named person. Pick this when the survey tool owns the\nexperience and the email is only a door.\n\n**Hero ask** (~30%). Logo, a photo of the thing being rated, headline, one\nparagraph, one button, a quieter secondary link, footer. Pick this for\npost-event, post-stay, and post-purchase asks.\n\n**Widget ask** (~20%). Logo, headline, a clickable rating row, then a thank-you\nor a fallback button, footer. Pick this when a one-click response is worth more\nthan a complete survey — the widget roughly doubles response rates by removing\nthe landing page, and the corpus uses it for NPS and for \"was this helpful?\".\n\n**Testimonial wall** (~15%). Hero, claim headline, star row, three to five\nquotes, one button. Pick this for social proof with no ask attached.\n\n**Review-driven product email** (~5%). A normal promotional email with one\nquote and a star row dropped in above the product grid. Build it from the\npromotion or product-launch anatomy, not this one.\n\n## Subject lines\n\n| Move              | Pattern                                                                                  |\n| ----------------- | ---------------------------------------------------------------------------------------- |\n| Direct question   | `How was it? Rate {{event.name}}` / `What did you think of {{product}}?`                 |\n| Did-you-go check  | `Did you go to {{place}}?`                                                               |\n| Time-cost promise | `Got two minutes?` / `Can we ask you one question?`                                      |\n| Plain ask         | `Tell us all about it` / `We want to hear from you`                                      |\n| Help-us framing   | `Help us help you, {{customer.first_name}}` / `Help us advocate for you`                 |\n| Incentive first   | `$25 for your thoughts?` / `Get $20 for your feedback about {{company.name}}`            |\n| Prize draw        | `Tell us what you think and win $100` / `Take this survey, you could win $100`           |\n| Cause-linked      | `Grow forests with your feedback`                                                        |\n| Social proof      | `5 stars all around` / `Thank you for over 5,000 reviews` / `Real stories, real results` |\n| Quote as subject  | `'My mattress is making me late.' — Tia`                                                 |\n\nFeedback subjects are short and end in a question mark about half the time.\nPersonalization is more common here than in most types — roughly a third open\nwith the first name — because the ask is personal. Naming the incentive or the\ntime cost in the subject is the single most common lever. Emoji appear in about\na fifth and cluster on the survey invites (📣 🙏 🤑).\n\n## Preheaders\n\n- `\u003c 1 min for a chance to win $100!`\n- `Answer 12 questions to assess your hiring maturity.`\n- `We'd appreciate your feedback`\n- `Re-live it with a review`\n- `A quick reminder from {{company.name}}`\n- `Lets get to know each other!`\n- `ISO real insights from real email makers, like you.`\n- `10/10, would wipe again`\n- `\"Exceptional quality, incredible detail\"`\n- `She called it life-changing. We think she's right.`\n\nRoughly two thirds carry one. In a feedback ask it quantifies the cost or the\nprize — the number the subject line did not have room for. In a social-proof\nemail it is a customer quote, which is the strongest use of a preheader anywhere\nin the corpus. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Rate: `Rate this event` · `Rate your order` · `Leave a review`\n- Survey: `Take the survey` · `Start survey` · `Complete the survey` ·\n  `I'll participate`\n- Write: `Write your review` · `Publish a review` · `Share your thoughts`\n- Incentivized: `Take the survey and win $100` · `Get my $20`\n- Social proof: `Shop now` · `Sign me up!` · `I'm already sold`\n- Secondary, quieter: `Follow the organizer` · `See more` · `Get the app`\n\nTwo to four words, sentence case, and the verb matches the ask exactly — a\nbutton that says \"Get started\" on a review request loses to one that says\n\"Write your review\". Time cost belongs next to the button, not inside it.\n\n## Tone\n\n- Ask once, early, and say how long it takes. The two sentences that matter are\n  the ask and the cost.\n- Explain what the feedback is for. \"Your feedback helps others find events they\n  will love.\" \"Your insights help us build the product.\" The corpus asks that\n  do not explain read as extraction.\n- Name the thing being rated, in the subject and again in the first line.\n- Second person, present tense, no superlatives. This is one of the few types\n  where the sender is asking for a favor, and it reads better plain.\n- Say thank you before the survey, not only after.\n- Social-proof emails let the customer talk. The brand's own copy around a\n  quote block is two lines at most, and the quotes keep their imperfect\n  punctuation.\n- Attribute every quote to a name, even a first name and an initial. An\n  unattributed quote reads as invented.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. This set sits anywhere from 480px to 620px, so\n  read the source's own `@media` boundary and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e`\n  to match it. Do not assume 600. When the source's own column width falls below\n  its breakpoint — a 600px email with a `max-width: 620px` rule — both graded\n  viewports render the mobile branch, and that is correct rather than a bug.\n- ESP exports of this type often ship **two complete copies of the body**,\n  toggled by `mobile_hide` / `desktop_hide` classes. Carta cannot swap two\n  different subtrees at a breakpoint. Convert the branch that matches the\n  source's own column width, and report the other one as dropped.\n- The usual mobile rules are a side-padding collapse (20px to 10px) and a\n  headline size drop. Express them as\n  ``:padding=\"{ small: `10px`, medium: `20px` }\"`` and\n  `:font-size=\"{ small: 20, medium: 28 }\"`.\n- Rating widgets must not stack. Build the row with `x-row` and a low\n  `:break-point`, or with `x-wrap`, and check it at 320px — an 11-cell NPS scale\n  is the widest thing in the type.\n- Explicit backgrounds on `x-base` and on `x-section`. Tinted page colors\n  (`#f5f5f0`, `#faf9f5`) are common here and they disappear in dark mode unless\n  the section declares one. Do not repeat the value on every band — a band only\n  needs its own `background` when it differs from the one it inherits.\n- Star glyphs drawn as text (★) invert fine. Star **images** on a transparent\n  background disappear on dark backgrounds — give them a `background-color` or\n  supply ``x-image :src=\"{ light: `…`, dark: `…` }\"``.\n\n## The rating widget row\n\nA rating widget in email is not a form. Every option is a plain link that\ncarries the answer in its own URL, and the landing page records the click. Four\nrenderings recur.\n\n**The 0–10 scale (NPS).** Eleven small linked boxes, usually two rows of six and\nfive, with \"Not likely at all\" and \"Extremely likely\" labels above or beside\nthem. Build it as two `x-row`s of six and five `x-column`s, or as one `x-wrap`\nof eleven `x-cta`s with a fixed width — `x-wrap` is easier and does not stack,\nbut note that `x-wrap` adds half its `gap` as vertical padding too, so a\n`gap=\"8px\"` row is 8px taller than you expect.\n\n**The star row.** Three to five linked star images, 24–40px, centered. Use\n`x-wrap` with `x-image href=` per star; a linked image is never an `x-image`\ninside an `x-cta`.\n\n**The two-way choice.** \"Yes\" next to \"No\", or a thumbs pair, as two `x-cta`s in\nan `x-wrap` — one filled, one outlined.\n\n**The stacked option list.** Six to ten full-width outlined buttons, one per\nanswer, each 40–50px tall with left-aligned text. This is the survey-tool export\nshape. Build each as an `x-cta` with `width=\"100%\"`, `background=\"\"`, a 1px\nborder and `text-align=\"left\"`, stacked in an `x-box`.\n\nGive every option its own URL with the answer encoded:\n\n```html\n\u003cx-wrap gap=\"8px\" align-content=\"center\"\u003e\n  \u003cx-cta\n    href=\"{{event.survey_url}}?score=0\"\n    background=\"#ffffff\"\n    color=\"#1e0a3c\"\n    border-style=\"solid\"\n    border-width=\"1px\"\n    border-color=\"#c9c6d6\"\n    padding=\"10px 14px\"\n    border-radius=\"4px\"\n    margin=\"0\"\n    \u003e0\u003c/x-cta\n  \u003e\n  \u003c!-- …1 through 10… --\u003e\n\u003c/x-wrap\u003e\n```\n\nFields worth proposing when the ask is tied to a specific event:\n\n| Field                | Token                                         |\n| -------------------- | --------------------------------------------- |\n| Survey or review URL | `{{event.survey_url}}`                        |\n| Thing being rated    | `{{event.name}}` / `{{event.product_title}}`  |\n| When it happened     | `{{event.occurred_at \\| date: \"%B %-d, %Y\"}}` |\n| Order it relates to  | `{{event.order_id}}`                          |\n| Incentive amount     | `{{event.incentive_amount}}`                  |\n| Response deadline    | `{{event.closes_at \\| date: \"%B %-d\"}}`       |\n\nTwo warnings. Real `\u003cform\u003e` elements and AMP interactivity do not survive\nconversion — if a source has them, they become links and you must say so in the\nreport. And an email that renders a widget must still work when images are\nblocked: keep the numbers and the labels as text, never as one sliced image.\n\n## Example: post-event review request\n\nSubject: `How was it? Rate your recent event`\nPreheader: `{{customer.first_name | default: \"there\"}}, we want your feedback.`\n\nThis is the **hero ask** at full length: a tinted single column, a centered\nlogo, a full-width photo of the event, an uppercase headline, one paragraph, one\npill button, a quieter \"follow the organizer\" line, then the marketing footer —\nwordmark, three social icons, a rule, two app badges and the legal block. Change\nthe hero, the headline, the paragraph and the button label first. Everything\nbelow the follow-up line is chrome you can delete wholesale.\n\n\u003c!-- Converted from a real post-event review-request exemplar. The conversion was\n     pixel-graded against the source render: desktop 0.998, mobile 0.998. The\n     source ships two copies of its body toggled by mobile_hide / desktop_hide\n     classes, and this conversion reproduces the branch that renders at the\n     source's own 600px column width and drops the other, which is a real loss\n     and is reported as one. The markup below was then simplified for idiom and\n     accessibility per review, so it no longer reproduces the source pixel for\n     pixel. The score describes the original conversion, not this markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#ffffff\"\n  font-family=\"Helvetica, Arial, Sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  color=\"#000000\"\n  :mobile-breakpoint=\"621\"\n\u003e\n  \u003cx-section\n    :width=\"{ small: `100%`, medium: `600px` }\"\n    padding=\"\"\n    background=\"#f5f5f0\"\n    outer-background=\"#ffffff\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-image\n      src=\"https://placehold.co/600x108\"\n      alt=\"{{company.name}} logo\"\n      href=\"https://example.com/\"\n      width=\"150px\"\n      align=\"center\"\n      margin=\"35px 0 15px\"\n      data-layer-label=\"Logo\"\n    /\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/1200x640\"\n      alt=\"The event you attended\"\n      href=\"https://example.com/events/recent\"\n      width=\"100%\"\n      data-layer-label=\"Hero\"\n    /\u003e\n\n    \u003cx-box padding=\"25px 15px 15px\" data-layer-label=\"Headline\"\u003e\n      \u003cx-heading-1\n        :font-size=\"20\"\n        font-weight=\"700\"\n        color=\"#231f1b\"\n        text-align=\"center\"\n        margin=\"0\"\n        \u003eTELL US ABOUT\u003cbr /\u003eYOUR RECENT EVENT\u0026nbsp;\u003cbr\n      /\u003e\u003c/x-heading-1\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box padding=\"0 10px 10px\" data-layer-label=\"Intro copy\"\u003e\n      \u003cx-paragraph color=\"#1e0a3c\" text-align=\"center\" margin=\"0\"\n        \u003eThanks for attending, {{customer.first_name | default: \"there\"}}. Let\n        us know what was great and what could have been better. Your feedback\n        helps others find events they will love.\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-cta\n      href=\"https://example.com/events/recent/rate\"\n      background=\"#231f1b\"\n      color=\"#f5f5f0\"\n      :font-size=\"15\"\n      :line-height=\"2\"\n      font-weight=\"700\"\n      padding=\"6px 25px\"\n      border-radius=\"32px\"\n      border-style=\"solid\"\n      border-width=\"1px\"\n      border-color=\"#f5f5f0\"\n      align=\"center\"\n      margin=\"10px 20px 20px 0\"\n      data-layer-label=\"Button\"\n      \u003eRate this event\u003c/x-cta\n    \u003e\n\n    \u003cx-box padding=\"20px 20px 35px\" data-layer-label=\"Follow up\"\u003e\n      \u003cx-paragraph color=\"#231f1b\" text-align=\"center\" margin=\"0\"\n        \u003eThis won\u0026rsquo;t even take a minute.\u003cbr /\u003eLoved this event?\u003cbr /\u003e\u003cstrong\n          \u003e\u003ca\n            href=\"https://example.com/organizers/example\"\n            style=\"text-decoration: underline; color: #5f80ff\"\n            \u003eFollow the organizer.\u003c/a\n          \u003e\u003c/strong\n        \u003e\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-image\n      src=\"https://placehold.co/414x74\"\n      alt=\"{{company.name}}\"\n      href=\"https://example.com/\"\n      width=\"180px\"\n      align=\"center\"\n      margin=\"30px 0 24px\"\n      data-layer-label=\"Footer logo\"\n    /\u003e\n\n    \u003cx-row\n      :layout=\"[33, 34, 33]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      data-layer-label=\"Social\"\n    \u003e\n      \u003cx-column padding=\"0 0 4px 0\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Instagram\"\n          href=\"https://instagram.com/example\"\n          width=\"30px\"\n          align=\"right\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column padding=\"0 0 4px 0\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"Facebook\"\n          href=\"https://facebook.com/example\"\n          width=\"30px\"\n          align=\"center\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column padding=\"0 0 4px 0\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/60x60\"\n          alt=\"TikTok\"\n          href=\"https://tiktok.com/@example\"\n          width=\"30px\"\n          align=\"left\"\n        /\u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-box padding=\"16px 22px 10px\" data-layer-label=\"Divider\"\u003e\n      \u003cx-hr :height=\"1\" background-color=\"#d1c7ba\" margin=\"0\" /\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-box padding=\"20px 0 10px\" data-layer-label=\"App heading\"\u003e\n      \u003cx-paragraph\n        :font-size=\"17\"\n        font-weight=\"700\"\n        color=\"#221D19\"\n        text-transform=\"uppercase\"\n        text-align=\"center\"\n        margin=\"0\"\n        \u003eGet the app\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n\n    \u003cx-row\n      :layout=\"[50, 50]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      data-layer-label=\"App badges\"\n    \u003e\n      \u003cx-column padding=\"0 5px 0 0\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/177x59\"\n          alt=\"Download on the App Store\"\n          href=\"https://example.com/apps/ios\"\n          width=\"130px\"\n          align=\"right\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column padding=\"0 0 0 5px\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/177x59\"\n          alt=\"Get it on Google Play\"\n          href=\"https://example.com/apps/android\"\n          width=\"130px\"\n          align=\"left\"\n        /\u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-box padding=\"24px 20px 46px\" data-layer-label=\"Legal\"\u003e\n      \u003cx-paragraph\n        :font-size=\"14\"\n        color=\"#221D19\"\n        text-align=\"center\"\n        margin=\"0\"\n        \u003eCopyright \u0026copy; 2026 {{company.name}}. All rights reserved.\n        \u003cb style=\"text-decoration: underline\"\n          \u003e\u003ca\n            href=\"https://example.com/privacy\"\n            style=\"text-decoration: underline; color: #221d19\"\n            \u003ePrivacy Policy\u003c/a\n          \u003e\u003c/b\n        \u003e.\u003cbr /\u003e123 Example Street, Suite 100, Portland, OR 97204\u003cbr /\u003e\u003cbr /\u003e\u003ca\n          style=\"color: #221d19; text-decoration: underline; font-weight: 700\"\n          href=\"{% unsubscribe_url %}\"\n          \u003eUnsubscribe\u003c/a\n        \u003e\u003c/x-paragraph\n      \u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                         | Token to use instead                                          |\n| ---------------------------------------------- | ------------------------------------------------------------- |\n| `outer-background=\"#ffffff\"` on `x-section`    | `:outer-background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#f5f5f0\"` on `x-section`          | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`              |\n| `color=\"#231f1b\"` on the headline and copy     | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`                  |\n| `color=\"#221D19\"` in the footer                | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`                 |\n| `background=\"#231f1b\"` on the `x-cta`          | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`              |\n| `color=\"#5f80ff\"` on the inline link           | `:color=\"globalStyles.colors.link_\u003cid\u003e\"`                      |\n| `background-color=\"#d1c7ba\"` on the `x-hr`     | `:background-color=\"globalStyles.colors.border_\u003cid\u003e\"`         |\n| `border-radius=\"32px\"` on the `x-cta`          | `:border-radius=\"globalStyles.radius.pill_\u003cid\u003e\"`              |\n| `padding=\"25px 15px 15px\"` on the headline box | `:padding=\"globalStyles.spacing.medium_\u003cid\u003e\"`                 |\n| `font-family` on `x-base`                      | `font-family=\"\"` when a global style already sets it          |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete the hero `x-image`, the app\nheading, the app-badge `x-row` and the divider. That leaves the **bare ask**:\nlogo, headline, one paragraph, one button, footer, and it is six components\nlong. To make it a **widget ask** instead, put a rating `x-wrap` from § The\nrating widget row where the `x-cta` is, and keep the button underneath as the\nfallback for \"none of these\".\n\n**One quirk this example is carrying deliberately.** The trailing `\u003cbr /\u003e` after\nthe headline is in the source and costs nothing: a `\u003cbr\u003e` immediately before a\nclosing tag adds no height.\n","email_patterns/shipping_delivery.md":"---\nname: Shipping and Delivery Email Patterns\ndescription: What a shipment notification, delivery confirmation, or shipping-offer email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, the tracking block, and one converted Carta example to adapt.\n---\n\n# Shipping and Delivery Emails\n\nTwo different emails carry the word \"shipping\". The **shipment notification** is\ntransactional: the order left the warehouse, here is the carrier, the tracking\nnumber and the date it arrives. The **shipping offer** is promotional: shipping\nis free this week, or today is the last day to order for Christmas. They share\nalmost nothing but the vocabulary, so decide which one you are writing before\nyou build anything.\n\nThe patterns below come from the shipping-delivery set: **92 emails, and this\nfile reads all 92.** Subject, category and screenshot-dimension metadata for all\n92, rendered screenshots for 25, full HTML for 4. The crawl that produced the\nset is complete, so this is the whole shipping-delivery group rather than a\nsample of it. The prevalence figures are still proportions read off metadata and\nscreenshots rather than a parse of every source, so treat them as close, not\nexact.\n\n**The notification evidence base more than doubled.** An earlier version of this\nfile counted about 15 shipment notifications in a 64-email set and said so as a\nwarning. The completed set has **35 post-purchase transactional messages out of\n92**: 28 shipment or delivery events, plus an order confirmation, an\norder-processing notice, two delay notices, a return receipt, a shipping-label\nemail and a next-shipment reminder. The notification anatomy below now rests on\nthose 35, and the doubling changed real numbers in it — the track button and the\ntracking block are both less universal than 15 emails suggested. Where a figure\nmoved, the paragraph under the diagram says so.\n\nThe other 57 are still what they were: free-shipping campaigns, holiday\nshipping-deadline emails, returns-policy explainers and packaging-vendor\nmarketing. The group remains majority promotional, but no longer overwhelmingly\nso — it is roughly 40/60 now rather than 25/75.\n\n**Neighbouring file.** A receipt for the purchase itself belongs in\n**email_patterns/order_confirmation.md**, and its § Line items, totals and order\ndata applies here verbatim — a shipment notification that lists items uses the\nsame block. Use this file for the shipping event and the tracking data. The\ncompleted set contains two genuine order confirmations that were filed here by\nthe corpus's own tagging — if the brief is \"we got your order\", you are in the\nwrong file.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- The track button is always there: an `x-cta` (\"Track my package\") with\n  `href=\"{{event.tracking_url}}\"` when the brief gives no URL, above a tracking\n  block (carrier, tracking number) whose `x-row` carries no `:break-point`.\n- Keep the items-shipped band (a Liquid loop) even when tracking is the focus.\n- Hedge every date (\"Estimated delivery\"); no emoji in the subject.\n\n## Anatomy — the shipment notification\n\n```text\n 1 logo band       ~always   centered logo or wordmark art, 20-40px padding\n 2 store nav       ~25%      3-5 category links under the logo, on retail notifications\n 3 order strip     ~35%      \"Order #650500 | Order date: Sep 25\" as one small line above the headline\n 4 status headline ~always   \"Your order is on its way\", \"Shipment delivered\" — the payload\n 5 hero or spot    ~55%      a lifestyle photo, a typographic wordmark, or a line-art parcel icon\n 6 progress meter  ~20%      \"Estimated delivery | In transit\" over a dotted rail with a truck glyph\n 7 greeting        ~35%      \"Hi {{customer.first_name}},\"\n 8 status copy     ~always   1-2 sentences: what happened, which carrier, when it arrives\n 9 track CTA       ~70%      one button: \"Track my package\"; a second \"Order details\" in ~30%\n10 tracking block  ~70%      ETA, carrier, tracking number, address — see § The tracking block\n11 items shipped   ~65%      thumbnail + title + quantity (+ price) per item\n12 totals          ~35%      discount, subtotal, tax, shipping, total — right-aligned money column\n13 help line       ~55%      \"Haven't received it?\", \"Questions? Contact us\"\n14 FAQ block       ~30%      2-4 bolded questions with a paragraph each, below the fold\n15 cross-sell      ~45%      2-4 product tiles or a \"complete your kit\" row\n16 benefits strip  ~30%      3-4 icon+text columns: free shipping, free returns, 24/7 support\n17 social row      ~50%      3-5 linked icons\n18 footer          ~always   address, unsubscribe\n```\n\n## Anatomy — the shipping offer\n\n```text\n1 nav band        ~60%      logo plus 3-5 category links, or a promo bar above it\n2 hero            ~always   large product or lifestyle photo with the offer set in type\n3 offer headline  ~always   \"Free shipping sitewide\", \"Last day for holiday delivery\"\n4 deadline line   ~85%      the cutoff date or time, usually bold, usually its own line\n5 carrier cutoffs ~25%      3-4 bold \"service: date\" lines, one per shipping speed\n6 primary CTA     ~85%      \"Shop now\"\n7 product grid    ~60%      2-4 tiles, each an image plus a name and a small button\n8 terms line      ~55%      10-12px muted: minimum spend, regions excluded\n9 footer          ~always   the brand's standard marketing footer\n```\n\n**Logo band.** Always present in both halves. The notifications often replace a\nlogo with a piece of typographic art carrying the status (\"YOUR ORDER IS ON ITS\nWAY!\") — that art is the headline, so put the words in the image's `alt` and\nkeep a real heading when you have the copy as text.\n\n**Status headline.** The whole point of the email, and it is written as a state\nrather than an instruction: \"Your order has shipped\", \"Your gear has arrived\",\n\"A shipment from order #650050 is on the way\", \"Shipment delivered\".\n\n**Status copy.** One or two sentences, and it always contains three facts: that\nit shipped, who is carrying it, and when it lands. \"Your order has now shipped.\nYour package is being delivered by USPS and is scheduled to be delivered Friday,\nJuly 07.\"\n\n**Progress meter.** A band the smaller sample missed. A two-column strip pairing\n\"Estimated Delivery / Monday Mar 29\" on the left with a state word — \"In\ntransit\", \"Ready to go\" — on the right, over a dotted rail carrying a circled\ntruck or box glyph that sits at one of three or four stops. It replaces the\nstatus paragraph rather than supplementing it, and it is the signature of emails\nsent through a post-purchase platform rather than by the store's own ESP. The\nrail is **one image**, not layout: do not try to build it from `x-hr` and\n`x-image`, because the glyph has to land on a specific stop and the rail is\nusually a single SVG or PNG. Build the band as a two-column `x-row` with no\n`:break-point`, put the date text in the left column and the state word plus the\nrail image in the right, and swap the image for the delivery state.\n\n**Track CTA.** Common, first when present, and uppercase in about half the set.\nA second button — \"Order details\", \"Manage your order\" — appears in about a\nthird, side by side with the first; build the pair as an `x-wrap` so it never\nstacks awkwardly.\n\nAt 35 notifications this is the figure that moved most: it is roughly 70%, not\nthe ~90% the 15-email sample showed. Three things pull it down. Delivered\nnotices frequently carry **no button at all**, because the parcel has arrived\nand there is nothing to track. Several notifications carry only \"See order\ndetails\" or \"View your order\" and never mention tracking. And the exception\nnotices carry \"Contact support\". Read the source before you assume a track\nbutton belongs.\n\n**Tracking block.** The band that defines the type, present in about 70% rather\nthan the ~85% the smaller sample showed — some notifications carry the status,\nthe items and nothing else, and hand the reader off to an order page. See\n§ The tracking block.\n\n**Items shipped.** Two thirds list what is in this shipment, as a thumbnail on\nthe left and title, quantity and total on the right. This is a partial-shipment\nlist, not the order — say \"items in this shipment\", not \"your order\".\n\nWhen an order ships in more than one parcel, the richer sources **repeat the\nwhole header-plus-items unit per package**: a \"Package 1\" bar with its own track\nbutton and its own item rows, then \"Package 2\" with its own. Model that as a\n`{% for shipment in event.order.shipments %}` loop around the bar and the item\nloop, not as one flat list — the tracking number is per parcel, and flattening\nit loses the only fact the reader wants.\n\n**FAQ block.** About a third of the notifications close with two to four bolded\nquestions and a paragraph each — \"How do I track my shipment?\", \"Not at home to\nsign? Pick up instead\", \"Missed deliveries\". It sits below the fold, under the\nitems, and it is the same support-deflection move as the help line but longer.\nBuild it as plain `x-paragraph` pairs, not as an accordion or a list.\n\n**Help line.** \"Haven't received your package yet? We recommend taking a look\naround your home to see if the carrier may have hidden your package.\" Delivered\nnotices need this more than shipped notices do, and it is the copy that stops\nthe support ticket.\n\n**Deadline line** (offers). The date, in bold, on its own line: \"Order by\nmidnight PST tonight\", \"guaranteed delivery by December 24\", \"our holiday\nshipping cutoff is 12/10\". Without it the email is just a discount.\n\n**Carrier cutoffs** (offers). A quarter of the deadline emails give the reader a\n_list_ rather than a line, one bold row per service level: \"UPS 3-Day Select:\nDecember 21 / UPS 2nd Day Air: December 22 / UPS Next Day Air: December 23\".\nBuild it as one centered `x-paragraph` with `\u003cstrong\u003e` and `\u003cbr\u003e`s, not as an\n`x-list` and not as a table — the sources set it as a block of bold type and the\nalignment is centered, which a bulleted list will not give you. A note in\nregular weight underneath usually explains what the free option does.\n\n**Nav band** (offers). Present in about 60%, not always. A real minority of the\nbest offer emails open with nothing but a small centered wordmark and put the\ncategory links in the footer instead. When the source has no nav band, do not\nadd one.\n\n**Primary CTA** (offers). About 85%. The exception is the pure deadline email:\nsome of them state the cutoffs, show a product grid where every tile is itself a\nlink, and never render a button. That is deliberate — the grid is the call to\naction — so do not bolt a \"Shop now\" onto a source that has none.\n\n## Structural variants\n\nThe two halves do not compete for the same percentage, so they are counted\nseparately: the first five are proportions of the 35 notifications, the last\nfour of the 57 offers.\n\n**Shipped notice** (~55% of the notifications). Wordmark or logo, status\nheadline, one paragraph, the track button, the tracking block, the items list,\ntotals, footer. Pick this for the first shipping event. It is a clearer majority\nof the notifications than the smaller sample showed.\n\n**Delivered notice** (~25%). The same skeleton with the headline in the past\ntense, the tracking block trimmed to the address and the carrier, and a\n\"haven't received it?\" paragraph promoted above the items. Usually carries a\ncross-sell block at the bottom and often carries no button at all. Pick this for\nthe delivery event.\n\n**Tracking-only** (~8%). Logo, hero or spot icon, one paragraph, one button,\nfooter. No items, no totals. Pick this when the tracking page is the whole\nexperience.\n\n**Exception notice** (~6%). Logo, a spot illustration of a damaged or delayed\nparcel, a headline naming the problem plainly (\"There's an issue with your\nshipment\", \"An update on your recent order\"), two or three paragraphs that\nexplain what went wrong and what the reader should do, one \"Contact support\"\nbutton, a reply-to line, footer. **No tracking block, no items, no cross-sell.**\nThese are the shortest notifications in the set and the copy is written in the\nfirst person by a named team. Pick this for damage, delay and lost-parcel\nmessages, and see **email_patterns/apology_support.md** for the apology register.\n\n**Return received** (~3%). A colored band carrying \"Your return is complete\",\nthe refund amount as the payload in large type, a \"Return summary\" label/value\nblock (return date, drop-off location, express code), a small totals block\nshowing what the reader is getting back, the returned item with its thumbnail,\na feedback line, footer. The refund figure occupies the slot the tracking number\noccupies in a shipped notice. Pick this for return-received and refund-issued\nmessages.\n\n**Free-shipping offer** (~45% of the offers). Nav bar, hero with the offer in\ntype, one button, a benefits strip (free shipping / free returns / guarantee) as\nthree icon+text rows, then a product grid. Pick this for a sitewide shipping\npromo.\n\n**Shipping-deadline promo** (~35%). Hero photo, \"it's not too late\" headline,\nthe cutoff in bold or a carrier-cutoff list, one button or none, a gift grid.\nPick this for holiday cutoffs.\n\n**Returns explainer** (~10%). Hero, a headline about the returns policy rather\nthan a sale, two or three numbered steps as icon+text rows, one button. Pick\nthis for \"free returns\" and \"how our returns work\" campaigns.\n\n**Logistics marketing** (~10%). A packaging vendor, a carrier or a delivery\nplatform selling its own service. This is a normal product-marketing email that\nhappens to be about shipping — build it from\n**email_patterns/product_launch.md** or **promotion_sale.md**, not from either\nanatomy here.\n\n## Subject lines\n\n| Move                     | Pattern                                                                                                 |\n| ------------------------ | ------------------------------------------------------------------------------------------------------- |\n| Order number in the line | `A shipment from order #{{event.order_id}} is on the way`                                               |\n| Possessive, shipped      | `Your {{company.name}} order has shipped` / `Your order #{{event.order_id}} just shipped!`              |\n| Tracking framing         | `Your tracking information for order {{event.order_id}}`                                                |\n| Delivered, flat          | `A shipment from order #{{event.order_id}} has been delivered`                                          |\n| Delivered, warm          | `Your new favorites have been delivered` / `Your gear from order {{event.order_id}} has been delivered` |\n| Anticipation             | `Good things are on the way` / `Headed your way: a box!`                                                |\n| Free shipping            | `Free shipping sitewide ends Sunday` / `Cookies ship free`                                              |\n| Last-chance shipping     | `Last chance for free ground shipping` / `Last call for free shipping`                                  |\n| Dated delivery guarantee | `Delivered by December 24` / `Last chance for holiday delivery`                                         |\n| Returns and policy       | `Free 30-day returns \u0026 exchanges` / `Send back your returns for free`                                   |\n| Progress state           | `Your package is out for delivery` / `Your shipment is on its way. Order No. {{event.order_id}}`        |\n| Exception, stated flat   | `There's an issue with your shipment #{{event.order_id}}` / `An update on your recent order`            |\n| Return received          | `Thanks for your return` / `Your return is complete`                                                    |\n| Cutoff list              | `Holiday shipping deadlines` / `Our holiday shipping deadlines!`                                        |\n\nNotification subjects put the order number in the line about four times in ten —\n15 of the 35 — and that is worth keeping, because it is how the reader finds the\nemail again three days later. They are flat and unstyled, and the completed set\nconfirms the strongest claim in this section at more than double the evidence:\n**zero of the 35 notifications carry an emoji.**\n\nThe offer half is the opposite, but less so than the smaller sample suggested:\n18 of the 57 use one (🎁 📦 ✨ 🍪 🥃), so it is about one in three rather than\nhalf. Urgency words run throughout regardless.\n\nThe exception and return subjects are the two shapes the larger set added, and\nthey share a rule: neither one apologises in the subject line. \"There's an issue\nwith your shipment\" and \"Your return is complete\" both state the fact and leave\nthe tone to the body.\n\n## Preheaders\n\n- `Track your shipment inside.`\n- `Get Ready To Shine.`\n- `Start the happy dance! Your package has been delivered.`\n- `Your order has now shipped.`\n- `Order by midnight PST tonight with free ground shipping on orders over $50.`\n- `Final call: standard shipping ends today for Christmas arrival`\n- `Free shipping is going, going....`\n- `In case you needed another reason to shop.`\n- `Delivery's on us when you spend $2,000 or more.`\n- `This week is the last chance for guaranteed delivery before Christmas.`\n\nRoughly two thirds carry one. In a notification it either promises the tracking\nlink or states the delivery date — never both, and never a joke that delays the\nfact. In an offer it carries the qualifying condition the subject had no room\nfor: the minimum spend, the cutoff time, the region.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Track: `Track my package` · `Track your order` · `Track order` ·\n  `Track package` · `Track shipment`\n- Order: `Order details` · `Manage your order` · `See order details` ·\n  `View your order` · `Order status`\n- Delivered follow-up: `Write a review` · `Shop now` · `Contact support`\n- Exception: `Contact support` · `Get in touch` · `FAQ me`\n- Offer: `Shop now` · `Shop gifts` · `Shop the sale` · `Check out our gift guide`\n- Returns: `Start a return` · `See our returns policy`\n\nTwo to three words. The track button is uppercase in about half the\nnotifications, which is one of the few places uppercase reads as functional\nrather than shouty. One primary button; the second is always the weaker of the\ntwo and always about the order rather than the parcel.\n\nTwo additions from the larger set. When there is no tracking to give, the sole\nbutton is an **order** label rather than a track label — `View your order`,\n`See order details` — and it is often followed by a plain \"or\" and an inline\nlink to the store, not a second button. And when the notification carries a\nmulti-package split, the track button repeats per package and belongs **inside**\nthe package header bar, small and inline, not as a full-width `x-cta` in its own\nband.\n\n## Tone\n\n- Lead with the state of the parcel, not with thanks. The receipt already said\n  thank you.\n- Give the carrier and the date in the first sentence. Those two facts are why\n  the email was opened.\n- Do not promise a delivery date you cannot keep — say \"scheduled to arrive\" or\n  \"estimated\".\n- Say \"items in this shipment\", never \"your order\", when the order ships in\n  parts.\n- Delivered notices should say what to do when the parcel is not there. It is\n  the highest-value sentence in the type.\n- Brand voice is allowed in the headline and nowhere else. \"We released the\n  carrier pigeons\" works as a headline above a plain address block.\n- Offers must name the condition — minimum spend, region, cutoff time — in the\n  body, not only in the terms line.\n- An exception notice names the problem in the first sentence and says whether\n  the goods are still usable before it says sorry. The sources that work put the\n  reassurance (\"melted capsules are perfectly safe to drink\") ahead of the\n  apology, and sign off from a named team.\n- A return notice leads with the money. The refund figure is the fact the reader\n  opened the email for, so it goes above the summary, not inside it.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. This set clusters at 480px, with a few sources\n  at 414px and 600px. Read the source's own `@media` boundary and set\n  `\u003cx-base :mobile-breakpoint=\"N\"\u003e` to match it. Do not assume 600.\n- The dominant mobile rule is a padding collapse on the card: 40–46px of side\n  padding on desktop dropping to 18px or less.\n  ``:padding=\"{ small: `29px 18px 9px`, medium: `29px 46px 9px` }\"``.\n- The tracking and line-item rows are the one place `x-row` really earns its\n  keep, and they must **not** stack — an icon above its label reads as two\n  unrelated things. Leave `:break-point` unset (or below 320) on those rows and\n  shift the padding responsively instead.\n- Where a source swaps a three-column layout for a two-column one at the\n  breakpoint, Carta cannot swap subtrees. Duplicate the band and drive the pair\n  with `hide=\"small\"` and `hide=\"medium\"` — that is what the source's own\n  totals block needs, and the same trick handles a font-size the responsive\n  props cannot reach. `hide` is an enum, not a boolean.\n- Set an explicit `background` on `x-base` and on the card `x-box`. The white\n  card on a gray page is the standard shape here, and it disappears in dark mode\n  without one. Do not repeat the value on the bands inside the card.\n- Carrier logos and icon glyphs are the assets at risk in dark mode. Supply\n  ``x-image :src=\"{ light: `…`, dark: `…` }\"`` for anything drawn as a dark line\n  on transparency.\n\n## The tracking block\n\nThe band the reader came for. Three renderings recur.\n\n**Icon plus label/value rows** (most common in the notifications). One `x-row`\nper fact: a 45–50px icon in a narrow left column, and a bold label with the\nvalue under it in the right column. Four rows is the usual set — estimated\ndelivery date, shipping address, shipped by, tracking number. Build them as\nreal `x-row`/`x-column`, not as `x-html`: they are layout, not tabular data.\n\n```html\n\u003cx-row :layout=\"[30, 70]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n  \u003cx-column vertical-align=\"middle\" padding=\"9px 18px 8px\"\u003e\n    \u003cx-image\n      src=\"…\"\n      alt=\"tracking\"\n      width=\"50px\"\n      align=\"center\"\n      style=\"display: block; margin-left: auto; margin-right: auto\"\n    /\u003e\n  \u003c/x-column\u003e\n  \u003cx-column vertical-align=\"middle\" padding=\"9px 18px 8px\"\u003e\n    \u003cx-paragraph margin=\"0\"\n      \u003e\u003cspan style=\"font-size: 18px\"\n        \u003e\u003cstrong\u003eTracking #\u003c/strong\u003e\u003cbr /\u003e\u003cspan style=\"font-size: 14px\"\n          \u003e\u003ca\n            href=\"{{event.order.tracking_url}}\"\n            style=\"text-decoration: none; color: #7828ea\"\n            \u003e{{event.order.tracking_number}}\u003c/a\n          \u003e\u003c/span\n        \u003e\u003c/span\n      \u003e\u003c/x-paragraph\n    \u003e\n  \u003c/x-column\u003e\n\u003c/x-row\u003e\n```\n\n**The two-column fact grid.** A tinted band split into two or three columns —\n\"Shipping address\" and \"Shipping method\" side by side, \"Order number\" and\n\"Tracking number\" below. Same components, no icons, `:layout=\"[50, 50]\"`.\n\n**The inline pair.** Just two lines under the button: \"Tracking #\" and the\nnumber as a link. The minimum that works, and the right choice when the tracking\npage carries the detail.\n\n**The stacked label block.** The fourth rendering, and the one the larger set\nadded: no columns and no tint, just \"Shipping Address:\" in bold on its own line\nwith the address underneath, then \"Carrier Name:\", then \"Tracking Number:\", each\nseparated by a blank line. It is a run of `x-paragraph`s and nothing else. Long\naddresses and long carrier names cannot break it, which is why the sources that\nserve the widest device mix use it.\n\nA fifth pattern shows up once and is worth naming so you recognise it: a static\n**map image of the delivery address** beside the address text, in a two-column\nrow. It is an ordinary `x-image` pointing at a map-tile URL. Treat it as\ndecoration — set a meaningful `alt`, and never make it the only place the\naddress appears.\n\nFour rules for all of them. Make the tracking number itself the link, because\nthat is what people copy. Keep the label bold and the value regular — the source\nset is unanimous. Never put the tracking block below the fold: the button and\nthe number belong in the first screen. And when the carrier has a logo, put it\nin the block as an `x-image` with the carrier name beside it in text, never as\nthe only carrier identifier — it is the asset most likely to vanish in dark\nmode.\n\nFields that recur, with Liquid names that are safe to propose. Use top-level\nevent attributes — write `{{event.order_id}}`, not `{{event.data.order_id}}`,\nunless the payload really nests a `data` object.\n\n| Field                | Token                                                                 | Seen in |\n| -------------------- | --------------------------------------------------------------------- | ------- |\n| Order number         | `{{event.order_id}}`                                                  | ~90%    |\n| Tracking number      | `{{event.order.tracking_number}}`                                     | ~85%    |\n| Tracking URL         | `{{event.order.tracking_url}}`                                        | ~90%    |\n| Carrier              | `{{event.order.carrier}}`                                             | ~75%    |\n| Estimated delivery   | `{{event.order.estimated_delivery_date}}`                             | ~70%    |\n| Ship date            | `{{event.order.shipped_at \\| date: \"%m/%d/%Y\"}}`                      | ~35%    |\n| Shipping method      | `{{event.order.shipping_method}}`                                     | ~45%    |\n| Shipping address     | `{{event.order.shipping_address.line1}}` and friends                  | ~70%    |\n| Item loop            | `{% for item in event.order.items %}`                                 | ~65%    |\n| Item quantity, total | `{{item.quantity}}`, `{{item.total}}`                                 | ~65%    |\n| Totals               | `{{event.order.subtotal}}`, `.tax_total`, `.shipping_total`, `.total` | ~35%    |\n| Shipment loop        | `{% for shipment in event.order.shipments %}`                         | ~15%    |\n| Delivery state       | `{{event.order.shipment_status}}`                                     | ~20%    |\n| Refund amount        | `{{event.return.refund_total}}`                                       | returns |\n| Return drop-off      | `{{event.return.location}}`, `{{event.return.code}}`                  | returns |\n\n**The Liquid loop is an addition you make, not something the source had.** The\ncorpus emails are one-off exports with two or three items already flattened into\nstatic markup. Wrapping the item `x-row` in `{% for item in event.order.items %}`\nis correct and it is what the example below does — just say so when you report\nthe conversion.\n\n## Example: shipment notification with tracking and items\n\nSubject: `A shipment from order #{{event.order_id}} is on the way`\nPreheader: `Track your shipment inside.` — the source's own preheader div is\nempty, so this one is an addition. Set it in `content.preheader_text`.\n\nThis is the **shipped notice** at full length: a white card on a gray page,\ndivided by dashed rules into a wordmark, a one-paragraph status, a track button,\na four-row tracking block, an items list, a totals block and a small footer.\nChange the wordmark art, the status paragraph and the four tracking rows first.\nThe dashed rules are the brand's whole visual identity here — swap them for a\nsolid `x-hr` and the email becomes generic.\n\nThe completed set confirms this example rather than dating it: the shipped\nnotice is now a clearer majority of the notifications than it was, and every\nband this example carries is at or above its stated prevalence. Two things the\nlarger set added are deliberately **not** in it, and both are cuts you make by\nadding rather than editing — a progress meter above the status paragraph, and a\ncross-sell grid below the totals. Add either only when the source has it.\n\n\u003c!-- Converted from a real shipment-notification exemplar. The conversion was\n     pixel-graded against the source render: 0.00% of desktop pixels and 0.00% of\n     mobile pixels differed, score 1.000 — a pixel-exact conversion. Two things\n     changed on the way to this printed version. The single line item is wrapped\n     in a Liquid for-loop, which the source did not have. And the source ships\n     two copies of its totals block — a three-column one for desktop and a\n     two-column one below 481px, driven by hide=\"small\" / hide=\"medium\", because\n     Carta cannot swap subtrees at a breakpoint. Only the desktop copy is printed\n     here. The footer keeps its own hide pair, because the source changes the font\n     size there rather than the layout. The markup below was then simplified for\n     idiom and accessibility per review, so it no longer reproduces the source\n     pixel for pixel. The score describes the original conversion, not this\n     markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#eeeeee\"\n  font-family=\"'Helvetica Neue', Arial\"\n  :font-size=\"14\"\n  :line-height=\"1.3\"\n  color=\"#1c263b\"\n  :mobile-breakpoint=\"481\"\n\u003e\n  \u003cx-section\n    width=\"600px\"\n    :padding=\"{ small: `10px`, medium: `50px 0px 20px` }\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-box\n      background=\"#ffffff\"\n      style=\"background: #ffffff;\"\n      data-layer-label=\"Card\"\n    \u003e\n      \u003c!-- 1. dashed rule --\u003e\n      \u003cx-box padding=\"18px 40px\" data-layer-label=\"Rule 1\"\u003e\n        \u003cx-hr\n          :height=\"3\"\n          border-style=\"dashed\"\n          background-color=\"#c9b297\"\n          margin=\"0\"\n        /\u003e\n      \u003c/x-box\u003e\n\n      \u003c!-- 2. wordmark --\u003e\n      \u003cx-image\n        src=\"https://placehold.co/564x112\"\n        alt=\"Your order is on its way\"\n        width=\"564px\"\n        align=\"left\"\n        margin=\"9px 18px\"\n        style=\"display: block\"\n        data-layer-label=\"Wordmark\"\n      /\u003e\n\n      \u003c!-- 3. intro copy --\u003e\n      \u003cx-box padding=\"25px 18px 9px\" data-layer-label=\"Intro\"\u003e\n        \u003cx-paragraph\n          text-align=\"center\"\n          font-family=\"verdana, geneva, sans-serif\"\n          color=\"#000000\"\n          margin=\"0px 0px 14px\"\n          \u003eHi {{customer.first_name | default: \"there\"}}, your order has now\n          shipped. Your package is being delivered by {{event.order.carrier}}\n          and is scheduled to arrive\n          {{event.order.estimated_delivery_date}}.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      \u003c!-- 4. track button --\u003e\n      \u003cx-cta\n        href=\"https://example.com/orders/{{event.order_id}}/tracking\"\n        background=\"#f3ceb2\"\n        color=\"#000000\"\n        font-family=\"Verdana\"\n        :font-size=\"16\"\n        :line-height=\"1\"\n        font-weight=\"700\"\n        padding=\"15px\"\n        border-radius=\"5px\"\n        align=\"center\"\n        margin=\"9px 18px\"\n        data-layer-label=\"Track CTA\"\n        \u003eTRACK MY PACKAGE\u003c/x-cta\n      \u003e\n\n      \u003c!-- 5. dashed rule --\u003e\n      \u003cx-box padding=\"18px 40px\" data-layer-label=\"Rule 2\"\u003e\n        \u003cx-hr\n          :height=\"3\"\n          border-style=\"dashed\"\n          background-color=\"#c9b297\"\n          margin=\"0\"\n        /\u003e\n      \u003c/x-box\u003e\n\n      \u003c!-- 6. shipment detail rows --\u003e\n      \u003cx-box padding=\"17px 18px 26px\" data-layer-label=\"Shipment details\"\u003e\n        \u003cx-row :layout=\"[30, 70]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n          \u003cx-column\n            vertical-align=\"middle\"\n            :padding=\"{ small: `9px 0px 8px 18px`, medium: `9px 18px 8px` }\"\n          \u003e\n            \u003cx-image\n              src=\"https://placehold.co/45x43\"\n              alt=\"estimated delivery date\"\n              width=\"45px\"\n              align=\"center\"\n              style=\"display: block; margin-left: auto; margin-right: auto\"\n            /\u003e\n          \u003c/x-column\u003e\n          \u003cx-column\n            vertical-align=\"middle\"\n            :padding=\"{ small: `9px 9px 8px 27px`, medium: `9px 18px 8px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\"\n              \u003e\u003cspan style=\"font-size: 18px\"\n                \u003e\u003cstrong\u003eEstimated Delivery Date\u003c/strong\u003e\u003cbr /\u003e\u003cspan\n                  style=\"font-size: 14px\"\n                  \u003e{{event.order.estimated_delivery_date}}\u003c/span\n                \u003e\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-row :layout=\"[30, 70]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n          \u003cx-column\n            vertical-align=\"middle\"\n            :padding=\"{ small: `9px 0px 8px 18px`, medium: `9px 18px 8px` }\"\n          \u003e\n            \u003cx-image\n              src=\"https://placehold.co/50x48\"\n              alt=\"shipping address\"\n              width=\"50px\"\n              align=\"center\"\n              style=\"display: block; margin-left: auto; margin-right: auto\"\n            /\u003e\n          \u003c/x-column\u003e\n          \u003cx-column\n            vertical-align=\"middle\"\n            :padding=\"{ small: `9px 9px 8px 27px`, medium: `9px 18px 8px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\"\n              \u003e\u003cspan style=\"font-size: 18px\"\n                \u003e\u003cstrong\u003eShipping Address\u003c/strong\u003e\u003cbr /\u003e\u003cspan\n                  style=\"font-size: 14px\"\n                  \u003e{{customer.first_name}} {{customer.last_name}}\u003cbr /\u003e{{event.order.shipping_address.line1}}\u003cbr /\u003e{{event.order.shipping_address.city}},\n                  {{event.order.shipping_address.state}}\n                  {{event.order.shipping_address.postal_code}}\u003cbr /\u003e{{event.order.shipping_address.country}}\u003c/span\n                \u003e\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-row :layout=\"[30, 70]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n          \u003cx-column\n            vertical-align=\"middle\"\n            :padding=\"{ small: `9px 0px 8px 18px`, medium: `9px 18px 8px` }\"\n          \u003e\n            \u003cx-image\n              src=\"https://placehold.co/50x40\"\n              alt=\"shipping details\"\n              width=\"50px\"\n              align=\"center\"\n              style=\"display: block; margin-left: auto; margin-right: auto\"\n            /\u003e\n          \u003c/x-column\u003e\n          \u003cx-column\n            vertical-align=\"middle\"\n            :padding=\"{ small: `9px 9px 8px 27px`, medium: `9px 18px 8px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0px 0px 14px\"\n              \u003e\u003cspan style=\"font-size: 18px\"\n                \u003e\u003cstrong\u003eShipped By\u003c/strong\u003e\u003cbr /\u003e\u003cspan style=\"font-size: 14px\"\n                  \u003e{{event.order.carrier}}\u003c/span\n                \u003e\u003cbr /\u003e\u003cbr /\u003e\u003cstrong\u003eTracking #\u003c/strong\u003e\u003cbr /\u003e\u003ca\n                  style=\"\n                    color: #7828ea;\n                    font-style: normal;\n                    font-weight: normal;\n                    text-decoration: none;\n                  \"\n                  href=\"https://example.com/orders/{{event.order_id}}/tracking\"\n                  \u003e\u003cspan style=\"font-size: 14px\"\n                    \u003e{{event.order.tracking_number}}\n                  \u003c/span\u003e\u003c/a\n                \u003e\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n            \u003cx-paragraph margin=\"0\"\u003e\u0026nbsp;\u003c/x-paragraph\u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n      \u003c/x-box\u003e\n\n      \u003c!-- 7. dashed rule --\u003e\n      \u003cx-box padding=\"18px 40px\" data-layer-label=\"Rule 3\"\u003e\n        \u003cx-hr\n          :height=\"3\"\n          border-style=\"dashed\"\n          background-color=\"#c9b297\"\n          margin=\"0\"\n        /\u003e\n      \u003c/x-box\u003e\n\n      \u003c!-- 8. items heading --\u003e\n      \u003cx-box\n        :padding=\"{ small: `29px 18px 27px`, medium: `29px 46px 27px` }\"\n        data-layer-label=\"Items heading\"\n      \u003e\n        \u003cx-paragraph text-align=\"center\" margin=\"0\"\n          \u003e\u003cspan\n            style=\"\n              font-family:\n                century gothic,\n                applegothic,\n                sans-serif;\n            \"\n            \u003e\u003cstrong\n              \u003e\u003cspan style=\"font-size: 18px\"\n                \u003eItems in this shipment for order {{event.order_id}}\u003c/span\n              \u003e\u003c/strong\n            \u003e\u003c/span\n          \u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n\n      {% for item in event.order.items %}\n      \u003c!-- 9. line item, repeated once per item in the shipment --\u003e\n      \u003cx-row\n        :layout=\"[30, 70]\"\n        :gap=\"0\"\n        padding=\"3px 18px 38px\"\n        style=\"table-layout: fixed\"\n        data-layer-label=\"Line item\"\n      \u003e\n        \u003cx-column\n          vertical-align=\"middle\"\n          :padding=\"{ small: `10px 2.54px 10px 9px`, medium: `10px 0px` }\"\n          style=\"font-size: 0px;\"\n        \u003e\n          \u003cx-image\n            src=\"https://placehold.co/169x169\"\n            alt=\"{{item.name}}\"\n            href=\"https://example.com/products/{{item.sku}}\"\n            width=\"100%\"\n            style=\"display: block\"\n          /\u003e\n        \u003c/x-column\u003e\n        \u003cx-column\n          vertical-align=\"middle\"\n          :padding=\"{ small: `10px 9px 10px 15.47px`, medium: `10px 0px` }\"\n        \u003e\n          \u003cx-paragraph margin=\"0px 0px 14px\"\n            \u003e\u003cb\u003e{{item.name}}\u003c/b\u003e\u003c/x-paragraph\n          \u003e\n          \u003cx-paragraph margin=\"0\"\n            \u003eQuantity: {{item.quantity}}\u003cbr /\u003e\u003cbr /\u003eTotal:\n            {{item.total}}\u003c/x-paragraph\n          \u003e\n        \u003c/x-column\u003e\n      \u003c/x-row\u003e\n      {% endfor %}\n\n      \u003c!-- 10. solid rule --\u003e\n      \u003cx-box padding=\"18px 40px\" data-layer-label=\"Rule 4\"\u003e\n        \u003cx-hr\n          :height=\"1\"\n          border-style=\"solid\"\n          background-color=\"#cccccc\"\n          margin=\"0\"\n        /\u003e\n      \u003c/x-box\u003e\n\n      \u003c!-- 11. totals --\u003e\n      \u003cx-box padding=\"17px 18px\" data-layer-label=\"Totals\"\u003e\n        \u003cx-row :layout=\"[30, 35, 35]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          /\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\"\n              \u003e\u003cspan style=\"font-size: 16px\"\n                \u003e\u003cstrong\u003eDiscount\u003c/strong\n                \u003e\u003cbr /\u003e({{event.order.discount_code}})\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\" text-align=\"right\"\n              \u003e{{event.order.discount_total}}\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-row :layout=\"[30, 35, 35]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          /\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\"\n              \u003e\u003cspan style=\"font-size: 16px\"\n                \u003e\u003cstrong\u003eSubtotal\u003c/strong\u003e\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\" text-align=\"right\"\n              \u003e{{event.order.subtotal}}\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-row :layout=\"[30, 35, 35]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          /\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\"\n              \u003e\u003cspan style=\"font-size: 16px\"\n                \u003e\u003cstrong\u003eTax\u003c/strong\u003e\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\" text-align=\"right\"\n              \u003e{{event.order.tax_total}}\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-row :layout=\"[30, 35, 35]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          /\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\"\n              \u003e\u003cspan style=\"font-size: 16px\"\n                \u003e\u003cstrong\u003eShipping\u003c/strong\u003e\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\" text-align=\"right\"\n              \u003e{{event.order.shipping_total}}\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n        \u003cx-row :layout=\"[30, 35, 35]\" :gap=\"0\" style=\"table-layout: fixed\"\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          /\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\"\n              \u003e\u003cspan style=\"font-size: 18px\"\n                \u003e\u003cstrong\u003eTotal\u003c/strong\u003e\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n          \u003cx-column\n            vertical-align=\"top\"\n            :padding=\"{ small: `4px 9px`, medium: `4px 0px` }\"\n          \u003e\n            \u003cx-paragraph margin=\"0\" text-align=\"right\"\n              \u003e\u003cspan style=\"font-size: 18px\"\n                \u003e{{event.order.total}}\u003c/span\n              \u003e\u003c/x-paragraph\n            \u003e\n          \u003c/x-column\u003e\n        \u003c/x-row\u003e\n      \u003c/x-box\u003e\n\n      \u003c!-- 12. solid rule --\u003e\n      \u003cx-box padding=\"18px 40px\" data-layer-label=\"Rule 5\"\u003e\n        \u003cx-hr\n          :height=\"1\"\n          border-style=\"solid\"\n          background-color=\"#cccccc\"\n          margin=\"0\"\n        /\u003e\n      \u003c/x-box\u003e\n\n      \u003c!-- 13. footer --\u003e\n      \u003cx-box color=\"#000000\" padding=\"20px 18px 50px\" data-layer-label=\"Footer\"\u003e\n        \u003cx-paragraph hide=\"small\" text-align=\"center\" :font-size=\"12\" margin=\"0\"\n          \u003e{{company.name}}, 101 Main St. Springfield, IL 62704 \u0026middot;\n          \u003ca href=\"{% unsubscribe_url %}\" style=\"color: #000000\"\n            \u003eUnsubscribe\u003c/a\n          \u003e\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph hide=\"medium\" text-align=\"center\" margin=\"0\"\n          \u003e\u003cspan style=\"font-size: 10px\"\n            \u003e{{company.name}}, 101 Main St. Springfield, IL 62704 \u0026middot;\n            \u003ca href=\"{% unsubscribe_url %}\" style=\"color: #000000\"\n              \u003eUnsubscribe\u003c/a\n            \u003e\u003c/span\n          \u003e\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                                 | Token to use instead                                    |\n| ------------------------------------------------------ | ------------------------------------------------------- |\n| `background=\"#eeeeee\"` on `x-base`                     | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"` |\n| `background=\"#ffffff\"` on the card `x-box`             | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`        |\n| `color=\"#1c263b\"` on `x-base`                          | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`            |\n| `color=\"#000000\"` on the intro copy and the footer box | `:color=\"globalStyles.colors.\u003csafeName\u003e_\u003cid\u003e\"`          |\n| `background=\"#f3ceb2\"` on the `x-cta`                  | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"`        |\n| `background-color=\"#c9b297\"` on the dashed rules       | `:background-color=\"globalStyles.colors.accent_\u003cid\u003e\"`   |\n| `background-color=\"#cccccc\"` on the solid rules        | `:background-color=\"globalStyles.colors.border_\u003cid\u003e\"`   |\n| `color=\"#7828ea\"` on the tracking link                 | `:color=\"globalStyles.colors.link_\u003cid\u003e\"`                |\n| `border-radius=\"5px\"` on the `x-cta`                   | `:border-radius=\"globalStyles.radius.small_\u003cid\u003e\"`       |\n| `padding=\"18px 40px\"` on the rule boxes                | `:padding=\"globalStyles.spacing.medium_\u003cid\u003e\"`           |\n| `font-family` on `x-base`                              | `font-family=\"\"` when a global style already sets it    |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete the items loop, the totals\n`x-box` and one of the dashed rules. That leaves the **tracking-only** variant:\nwordmark, one paragraph, one button, four tracking rows, footer, and it is nine\ncomponents long. For a **delivered notice**, change the wordmark and the status\nparagraph to the past tense, cut the estimated-delivery row from the tracking\nblock, and add a \"haven't received it?\" paragraph directly under the button.\n\n**Six details in this example are load-bearing.** The `x-row`s carry **no\n`:break-point`**, which is what keeps the icon beside its label at every width —\na stacked tracking block is unreadable. `style=\"table-layout: fixed\"` holds the\n30/70 split when a long address would otherwise widen its column. The nested\n`\u003cspan style=\"font-size: 18px\"\u003e` around each label wraps the `\u003cbr\u003e`s and drives\nevery line box in the block, so flattening it into the component's `:font-size`\nmoves the whole band. `style=\"font-size: 0px;\"` on the line-item column is the\nonly way to collapse the strut, because `:font-size=\"0\"` is falsy and gets\ndropped, and because an `x-image` with an `href` is wrapped in an inline-block\n`\u003ca\u003e` that `display: block` cannot reach. The asymmetric mobile paddings\n(`'9px 0px 8px 18px'` against `'9px 18px 8px'`) compensate for the source's\nmobile stylesheet adding cell padding _outside_ its percentage width, where\nCarta keeps the column at exactly 30%. And `x-column` has no `hide` prop, which\nis why a band that changes column count at the breakpoint has to be duplicated\nat the `x-box` level.\n","email_patterns/welcome.md":"---\nname: Welcome Email Patterns\ndescription: What a welcome email looks like in practice — the band-by-band anatomy, structural variants, real subject and preheader patterns, CTA phrasing, and one converted Carta example to adapt.\n---\n\n# Welcome Emails\n\nA welcome email is the first message after a signup. It confirms the signup,\nsays who the sender is, sets the expectation for what arrives next, and gives\none thing to do now. Senders treat it as brand work rather than as a receipt:\nalmost every email in the corpus carries a hero image, a logo band, and a\ndesigned footer.\n\nThe patterns below come from ~30 ranked exemplars in a 967-email welcome set\n(subject and description metadata for all 30, rendered screenshots for 12, full\nHTML for 5). The set mixes newsletter opt-ins, ecommerce list signups, and SaaS\naccount signups. All three share the anatomy. They differ mostly in whether the\nemail carries an offer.\n\n**Before writing any markup**, complete the SKILL.md § Steps 1a–1d preflight:\nfetch global styles, check for custom components, and look for a matching saved\ntemplate. Then read § Anatomy below and pick the variant that fits the brief.\n\n**Most often missed in this type:**\n\n- The logo band is always there: the workspace logo (custom component, BRAND.md\n  asset or asset library) or, with none, the brand name as a text wordmark in its\n  own band.\n- Every band — logo, greeting and body, CTA, footer — is its own `x-box` inside the\n  one width-carrying `x-section`.\n- The CTA stays even when the brief names no URL: point it at an obvious\n  `https://example.com/REPLACE-WITH-…` link and say so.\n\nTreat the corpus examples below as structural evidence, not facts to add. The\nbrief and verified workspace context are the complete fact set. Do not add\noffers, product behavior, setup times, later steps, support destinations,\naddresses, or compliance links that were not supplied. Only render a CTA,\nvalue-prop step, support row, or footer detail when its real destination or\nvalue is supplied by the brief or workspace.\n\n## Anatomy\n\n```text\n1 logo band       ~always    centered or left logo, 24-32px padding, white/near-white or brand bg\n2 hero            ~70%       full-width photo or illustration, content-width, often 8px radius\n3 greeting        ~60%       \"Hi {{first_name}},\" on its own line above the body copy\n4 headline        ~75%       one h1, 24-32px; sits in the hero, or above the body copy\n5 body copy       ~always    2-4 short paragraphs: thanks, who we are, what arrives next\n6 offer block     ~35%       discount percent + code, as text or as a styled box\n7 primary CTA     ~90%       one button, brand fill, 12-16px vertical padding\n8 value props     ~35%       2-4 icon+text rows, a numbered how-to, or a bulleted list\n9 secondary nav   ~30%       category buttons or text links, in an x-wrap\n10 social row     ~50%       3-5 linked icons, 24-40px, centered, in an x-wrap\n11 footer         ~always    address, preference link, unsubscribe, 12-14px muted text\n```\n\n**Logo band.** Almost every email opens with one. Centered is slightly more\ncommon than left-aligned. A right-hand slot next to a left logo (a wordmark, a\n\"view in browser\" link, or a nav row) shows up in roughly a quarter. Build it as\nan `x-row` when that second slot exists, and as a single centered `x-image`\notherwise — the three-cell \"logo between two filler cells\" table in the source is\nnot a three-column layout.\n\n**Hero.** Usually one image at the full content width. Roughly half of the heroes\ncarry the headline as baked-in type inside the image. That is a real corpus\npattern and it costs accessibility, so keep the headline in the image's `alt` and\nadd a real `x-heading-1` when you have the copy as text.\n\n**Greeting.** A separate line, not folded into the first paragraph. Always\nguard the token: `{{customer.first_name | default: \"there\"}}`.\n\n**Body copy.** Three paragraphs is the median. The shape is consistent: thank\nthem, say what the brand is in one or two sentences, then say what lands in the\ninbox next and how often.\n\n**Offer block.** In ecommerce welcomes it is the point of the email. The code\nappears as bold inline text inside a paragraph more often than as a styled\ncoupon box. Expiry copy (\"expires in 10 days\") follows it in about half of the\noffer emails.\n\n**Primary CTA.** One per email, at the end of the body copy. Full-content-width\npill buttons and inline auto-width buttons are about equally common. Emails with\nvalue props often repeat a second CTA after that block — that is the only place\na second button is normal.\n\n**Value props.** Three renderings recur: an icon on the left with a heading and\na line of copy on the right (`x-row` with a narrow first column), a numbered\nhow-to with circled numerals, and a plain bulleted `x-list`.\n\n**Footer.** Always includes a postal address and an unsubscribe link. Roughly a\nthird also carry a \"manage preferences\" link next to it, and about a fifth carry\nan app-store badge.\n\n## Structural variants\n\n**Image-led** (~45%). Logo band, then a tall hero, then a short block of copy and\none CTA. The email is mostly picture. Pick this when the brand has photography\nand the brief has little copy.\n\n**Letter-style** (~25%). Logo band, then a signed note from a founder or an\neditor, sometimes with a portrait. Body copy runs to five or six paragraphs and\nthe CTA is quiet — an outlined button or a plain text link. No offer. Pick this\nfor a newsletter or a mission-led brand.\n\n**Offer-led** (~20%). The discount is the headline. Hero, a large percent-off\nlockup, the code, an expiry line, and a \"Shop now\" button, then category links.\nPick this when the signup came from a discount capture form.\n\n**Utility** (~10%). No hero photo, a small illustration or icon instead, one\nshort paragraph, one CTA, and a \"here is what to do next\" row. Pick this for a\nSaaS or account signup where the next step is real work.\n\n## Subject lines\n\n| Move                        | Pattern                                                                 |\n| --------------------------- | ----------------------------------------------------------------------- |\n| Plain welcome (most common) | `Welcome to {{company.name}}`                                           |\n| Welcome plus emoji          | `Welcome to the {{company.name}} Newsletter 👋`                         |\n| Welcome plus name           | `Welcome to {{company.name}}, {{customer.first_name}}!`                 |\n| Welcome plus offer          | `✨ Welcome to {{company.name}} + 20% Off ✨`                           |\n| Thanks framing              | `Thanks for signing up` / `Thank you for subscribing.`                  |\n| Confirmation framing        | `You're in. Now for the good stuff.` / `you're signed up!`              |\n| Community framing           | `Welcome to the community!` / `Welcome to our world`                    |\n| Offer only                  | `Your 10% Off Is Inside 🎁` / `Find your 20% off voucher inside`        |\n| Brand voice                 | `Hey there, welcome to your smile family!` / `DING DONG 10% OFF INSIDE` |\n| Next-step framing           | `Get your first prototype running in minutes`                           |\n\nRoughly two in three subjects contain the literal word \"welcome\". Length clusters\nat 20–45 characters. Around a quarter carry one emoji, almost always at the end\nor wrapped around the line — 👋 ✨ 🎁 are the recurring ones. First-name\npersonalization appears in about a fifth, and always at the start or end of the\nline, never mid-sentence.\n\n## Preheaders\n\n- `Thanks for signing up!`\n- `Welcome! Thanks for signing up`\n- `Welcome discount inside! Get 10% off your next purchase.`\n- `Redeem your welcome offer`\n- `We've got resources, stories and news you won't want to miss`\n- `New collections, inspiration, and exclusive events await.`\n- `A special welcome message from our CEO and Founder.`\n- `We're so excited you found us!`\n\nRoughly half the corpus emails carry one. It restates the promise rather than\nthe subject — when the subject says \"welcome\", the preheader says what arrives\nnext, or names the offer. Keep it under about 90 characters.\n\n**The preheader is envelope metadata, not markup.** Put it in\n`content.preheader_text` when you create or update the email. Do not build a\npreheader band in the markup. `x-hidden-text` is the email equivalent of the web\n`.sr-only` class, for screen-reader-only text such as an icon label. It is not a\npreheader vehicle: it renders as an inline-block, so it adds a line box above the\nfirst band and duplicates a field the product already owns.\n\n## CTA phrasing\n\n- Shopping: `Shop now` · `Shop the collection` · `Explore`\n- Account: `Customize your profile` · `Get started` · `Complete your profile`\n- Content: `Learn more` · `Read more` · `Start watching`\n- Offer: `Apply discount code` · `Claim your offer` · `Redeem 15% off`\n- Utility: `Download the app` · `Find more savings` · `See FAQs`\n\nTwo to four words, sentence case more often than uppercase, and no trailing\npunctuation. One primary button is the norm. A second button appears only after\na value-prop block or in the footer, and it is always the weaker of the two —\noutlined, muted, or a plain link.\n\n**Match the button's weight to the variant.** Image-led and Offer-led welcomes\ntake a solid button. The Letter-style variant keeps the CTA quiet — an outlined\nbutton or a plain text link — because a signed personal note that ends in a loud\nsolid button reads as an ad.\n\n## Tone\n\n- Second person, present tense. \"You will be the first to get updates.\"\n- The first sentence thanks or confirms. It never sells.\n- Short sentences and short paragraphs — two to three lines each on desktop.\n- Exclamation marks in roughly a third, and almost never more than one.\n- Ecommerce brands write in brand voice, sometimes heavily. SaaS and healthcare\n  brands stay plain.\n- Expectation-setting is near-universal: say what arrives next and how often.\n- The founder-letter variant signs off with a name and a role.\n\n## Responsive and dark-mode idioms\n\n- One breakpoint, declared once. Sources sit anywhere from 480px to 664px, so\n  read the source's own `@media` boundary and set `\u003cx-base :mobile-breakpoint=\"N\"\u003e`\n  to match it. Do not assume 600.\n- The most common responsive rule is page padding: a 32px gutter on desktop that\n  collapses to 0 on mobile. Express it as ``:padding=\"{ small: `0`, medium: `32px` }\"``.\n- Second-most common is font size: a 24px headline that grows to 32px on wide\n  viewports. `:font-size=\"{ small: 24, medium: 32 }\"`.\n- Multi-column value-prop rows stack. Icon and social rows do not. Use `x-row`\n  with `:break-point` for the first and `x-wrap` for the second — `x-wrap` keeps\n  items inline at every width.\n- Explicit band backgrounds. Very few emails rely on a transparent band, which\n  is what keeps them readable in a dark-mode client. Set `background` on every\n  `x-box`, `x-row`, and `x-wrap` whose fill differs from the one it inherits, and\n  leave it off the bands that already sit on the right color.\n- Dark-mode-specific art is rare. When a logo would disappear on a dark\n  background, use ``x-image :src=\"{ light: `…`, dark: `…` }\"`` rather than boxing\n  the logo in white.\n\n## Example: image-led newsletter welcome\n\nSubject: `Welcome to the {{company.name}} Newsletter 👋`\nPreheader: `Thanks for signing up — here is what lands in your inbox next.`\n\nThis is the **image-led** variant at its simplest: a two-slot logo band, one\nhero, four paragraphs, a social row, two buttons, and a three-cell footer. It\nuses `x-wrap` twice, which is the right component for both the icon row and the\nbutton pair — neither should stack on mobile. Change the hero image, the four\nparagraphs, and the two button labels first. Everything else is chrome.\n\n\u003c!-- Converted from a real welcome exemplar. The conversion was pixel-graded\n     against the source render: score 0.997, with 0.03% of desktop pixels and\n     0.10% of mobile pixels differing. The source declares no preheader, so\n     nothing in the markup carries one. The markup below was then simplified for\n     idiom and accessibility per review, so it no longer reproduces the source\n     pixel for pixel. The score describes the original conversion, not this\n     markup. --\u003e\n\n```html\n\u003cx-base\n  background=\"#EAEAEA\"\n  color=\"#201D1D\"\n  font-family=\"-apple-system, BlinkMacSystemFont, Arial, Helvetica, sans-serif\"\n  :font-size=\"16\"\n  :line-height=\"1.5\"\n  :letter-spacing=\"-0.0078\"\n  :mobile-breakpoint=\"664\"\n\u003e\n  \u003cx-section\n    width=\"664px\"\n    :padding=\"{ small: `0`, medium: `32px` }\"\n    data-layer-label=\"Email\"\n  \u003e\n    \u003cx-row\n      :layout=\"[76, 24]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      background=\"#F4F4F4\"\n      border-style=\"solid\"\n      border-width=\"0 0 1px 0\"\n      border-color=\"#E1E1E1\"\n      border-radius=\"4px 4px 0 0\"\n      data-layer-label=\"Logo band\"\n    \u003e\n      \u003cx-column padding=\"31.6px 28px 32px 28px\" vertical-align=\"middle\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/583x129\"\n          alt=\"{{company.name}} logo\"\n          href=\"https://example.com/\"\n          width=\"112px\"\n          align=\"left\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column padding=\"24px 32px\" vertical-align=\"middle\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/392x128\"\n          alt=\"Visit {{company.name}}\"\n          href=\"https://example.com/\"\n          width=\"80px\"\n          align=\"right\"\n        /\u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-box background=\"#F4F4F4\" data-layer-label=\"Body\"\u003e\n      \u003cx-image\n        src=\"https://placehold.co/580x464\"\n        alt=\"Thanks for signing up — you just joined a growing community.\"\n        width=\"100%\"\n        border-radius=\"8px\"\n        margin=\"12px 12px 0\"\n      /\u003e\n      \u003cx-box padding=\"32px\"\u003e\n        \u003cx-paragraph margin=\"0 0 22.4px 0\"\n          \u003eHi {{customer.first_name | default: \"there\"}},\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0 0 22.4px 0\"\n          \u003eWelcome to a growing community of people who care about the same\n          things you do.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0 0 22.4px 0\"\n          \u003eYou will be the first to hear about new releases, product\n          improvements, and behind-the-scenes notes from the team. We will also\n          highlight work from the community and give you early access to what is\n          coming next.\u003c/x-paragraph\n        \u003e\n        \u003cx-paragraph margin=\"0\"\n          \u003eExpect one email a week. Nothing else.\u003c/x-paragraph\n        \u003e\n      \u003c/x-box\u003e\n    \u003c/x-box\u003e\n\n    \u003cx-wrap\n      gap=\"2px\"\n      align-content=\"center\"\n      background=\"#F4F4F4\"\n      border-style=\"solid\"\n      border-width=\"1px 0 0 0\"\n      border-color=\"#E1E1E1\"\n      padding=\"24px 16px 29.4px 16px\"\n      data-layer-label=\"Social\"\n    \u003e\n      \u003cx-image\n        src=\"https://placehold.co/40x40\"\n        alt=\"Instagram\"\n        href=\"https://instagram.com/example\"\n        width=\"40px\"\n      /\u003e\n      \u003cx-image\n        src=\"https://placehold.co/40x40\"\n        alt=\"LinkedIn\"\n        href=\"https://linkedin.com/company/example\"\n        width=\"40px\"\n      /\u003e\n      \u003cx-image\n        src=\"https://placehold.co/40x40\"\n        alt=\"X\"\n        href=\"https://x.com/example\"\n        width=\"40px\"\n      /\u003e\n      \u003cx-image\n        src=\"https://placehold.co/40x40\"\n        alt=\"YouTube\"\n        href=\"https://youtube.com/@example\"\n        width=\"40px\"\n      /\u003e\n    \u003c/x-wrap\u003e\n\n    \u003cx-wrap\n      gap=\"4px\"\n      align-content=\"center\"\n      background=\"#F4F4F4\"\n      border-style=\"solid\"\n      border-width=\"1px 0 0 0\"\n      border-color=\"#E1E1E1\"\n      padding=\"30px 16px 70px 16px\"\n      data-layer-label=\"Buttons\"\n    \u003e\n      \u003cx-cta\n        href=\"https://example.com/\"\n        background=\"#201D1D\"\n        color=\"#F4F4F4\"\n        :font-size=\"17\"\n        :line-height=\"1\"\n        :letter-spacing=\"-0.0074\"\n        padding=\"14px 20px 15px 20px\"\n        border-radius=\"3px\"\n        margin=\"0\"\n        \u003eWebsite\u003c/x-cta\n      \u003e\n      \u003cx-cta\n        href=\"https://example.com/login\"\n        background=\"#F84131\"\n        color=\"#F4F4F4\"\n        :font-size=\"17\"\n        :line-height=\"1\"\n        :letter-spacing=\"-0.0074\"\n        padding=\"14px 20px 15px 20px\"\n        border-radius=\"100px\"\n        margin=\"0\"\n        \u003eLog in\u003c/x-cta\n      \u003e\n    \u003c/x-wrap\u003e\n\n    \u003cx-row\n      :layout=\"[40, 20, 40]\"\n      :gap=\"0\"\n      :break-point=\"320\"\n      background=\"#F4F4F4\"\n      border-radius=\"0 0 4px 4px\"\n      data-layer-label=\"Footer\"\n    \u003e\n      \u003cx-column padding=\"32px\" vertical-align=\"middle\"\u003e\n        \u003cx-paragraph\n          margin=\"0\"\n          font-family=\"monospace\"\n          :font-size=\"12\"\n          color=\"#817F7F\"\n          text-transform=\"uppercase\"\n          style=\"white-space: nowrap;\"\n          \u003eMade for makers\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n      \u003cx-column padding=\"25px 16px\" vertical-align=\"top\"\u003e\n        \u003cx-image\n          src=\"https://placehold.co/24x24\"\n          alt=\"{{company.name}}\"\n          href=\"https://example.com/\"\n          width=\"24px\"\n          align=\"center\"\n        /\u003e\n      \u003c/x-column\u003e\n      \u003cx-column padding=\"32px\" vertical-align=\"middle\"\u003e\n        \u003cx-paragraph\n          margin=\"0\"\n          font-family=\"monospace\"\n          :font-size=\"12\"\n          color=\"#817F7F\"\n          text-transform=\"uppercase\"\n          text-align=\"right\"\n          style=\"white-space: nowrap;\"\n          \u003eBuilt to last\u003c/x-paragraph\n        \u003e\n      \u003c/x-column\u003e\n    \u003c/x-row\u003e\n\n    \u003cx-box padding=\"24px\" data-layer-label=\"Legal\"\u003e\n      \u003cx-paragraph\n        margin=\"0\"\n        :font-size=\"14\"\n        color=\"#817F7F\"\n        text-align=\"center\"\n      \u003e\n        \u003ca\n          href=\"{% unsubscribe_url %}\"\n          style=\"color: #817F7F; text-decoration: none;\"\n          \u003eUnsubscribe\u003c/a\n        \u003e\n        |\n        \u003ca\n          href=\"{% manage_subscription_preferences %}\"\n          style=\"color: #817F7F; text-decoration: none;\"\n          \u003eManage subscriptions\u003c/a\n        \u003e\n      \u003c/x-paragraph\u003e\n    \u003c/x-box\u003e\n  \u003c/x-section\u003e\n\u003c/x-base\u003e\n```\n\n### Adapting this example\n\nEvery hex, radius and font literal below is a placeholder for a workspace token.\nBind it as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`, where `\u003csafeName\u003e` and `_\u003cid\u003e`\nboth come from the global-styles response you fetched in preflight step 1a — an\nunresolvable token renders as empty and publishing does not warn you. See\ncomponent_syntax.md § Global Styles \u0026 Component.utils.\n\n| Literal in the example                         | Token to use instead                                                |\n| ---------------------------------------------- | ------------------------------------------------------------------- |\n| `background=\"#EAEAEA\"` on `x-base`             | `:background=\"globalStyles.colors.pageBackground_\u003cid\u003e\"`             |\n| `background=\"#F4F4F4\"` on every band           | `:background=\"globalStyles.colors.surface_\u003cid\u003e\"`                    |\n| `color=\"#201D1D\"` on `x-base`                  | `:color=\"globalStyles.colors.bodyText_\u003cid\u003e\"`                        |\n| `color=\"#817F7F\"` in the footer                | `:color=\"globalStyles.colors.mutedText_\u003cid\u003e\"`                       |\n| `background=\"#201D1D\"` / `#F84131` on the CTAs | `:background=\"globalStyles.colors.primary_\u003cid\u003e\"` / `secondary_\u003cid\u003e` |\n| `border-color=\"#E1E1E1\"`                       | `:border-color=\"globalStyles.colors.border_\u003cid\u003e\"`                   |\n| `padding=\"32px\"` on the body box               | `:padding=\"globalStyles.spacing.large_\u003cid\u003e\"`                        |\n| `border-radius=\"8px\"` on the hero              | `:border-radius=\"globalStyles.radius.medium_\u003cid\u003e\"`                  |\n| `font-family` on `x-base`                      | `font-family=\"\"` when a global style already sets it                |\n\nMatch a token only when its value equals the literal. Do not restyle a\nconversion onto the workspace palette unless the user asks.\n\n**What to cut when the brief is simpler.** Delete the second logo-band column\nand turn the `x-row` into a single centered `x-image`. Delete the button\n`x-wrap` and put one `x-cta` at the end of the body box. Replace the three-cell\nfooter `x-row` with one `x-box` holding the address and the links. That leaves\nthe **utility** variant, and it is four components long.\n\n**The fractional paddings are load-bearing.** `31.6px`, `22.4px`, and `29.4px`\nare not typos. They reproduce sub-pixel geometry from the source: `22.4px` is the\nsource's own paragraph gap, and the other two absorb the baseline gap a source's\ninline-block image row leaves behind. When you write a new email rather than\nconvert one, use round numbers.\n","email_review.md":"# Email Review: QA Report + Readiness Score\n\nRun the Design Studio **Review** for an email: one API call computes every QA check (Liquid, source validity, links, images, accessibility, spam, unsubscribe compliance, size, subject/preheader), the findings, and the 0-100 readiness score. It is the exact report the editor's Review panel renders; the user sees the same rows and the same score you do.\n\nUse it whenever the user asks whether an email is **ready to send**, wants it **checked / QA'd / reviewed**, or before **publishing** (see **journeys.md**), and to **verify your own work** after editing an email's content.\n\nWhen a review comes back clean on an email that matters, you may mention one step further exists: a real-client inbox preview (Apple Mail, Gmail, Outlook, dark mode) via **inbox_previews.md** — paid from the customer's credits, so mention it, never run it unbidden; the free screenshot check (**visual_verification.md**) stays the default visual pass.\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/nodes/$NODE_ID/review -X POST\n```\n\n- **POST with no body** reviews the node's **saved** content. (The optional body exists for the editor's unsaved-state overrides; as an agent you save through the update endpoints, so omit it.)\n- **Design Studio emails only.** The review takes a DS **node id**. An email built in another editor (its template's `editor` field is anything other than `\"parcel\"`: rich text, code, drag-and-drop) has no node and no review; say that plainly instead of hunting for a node, and check content the editor-appropriate way (`@skills/fly-api/templates.md`).\n- **EMAIL nodes only** within Design Studio; other node types (components, folders, WhatsApp, inbox) return 400.\n- **Expensive**: the run renders the email and fans out to live validators (every link and image is fetched, an axe audit and a SpamAssassin scan run). Run it **once per revision**, after your edits are saved, not between each one, and never in a polling loop. It's rate-limited per environment (**10 calls per 2 seconds**); on a 429, wait a few seconds and retry once. On a `503`, see **SKILL.md § Transient Failures** — retry once on a `dependency_timeout`, and at most once on any other 503 before reporting it.\n\n## Reading the report\n\nTop-level `status` first; it gates everything else:\n\n| `status` | Meaning | What to do |\n|---|---|---|\n| `complete` | Every check ran; findings and score are authoritative | Report the score (or, while `locked`, the lock) and act on findings |\n| `partial` | At least one check errored or ran degraded (see `checks`) | Use the findings you have, but say which checks didn't fully run. **Never present a partial run as a clean pass.** |\n| `render_failed` | The email itself failed to render; no check had content to inspect | Ignore score/findings; fix the render first (usually broken markup or transformers: re-read the content, or try `emails/{id}/render` from **nodes_advanced.md** for the error) |\n\n`checks` maps each check family to its health: `complete` (authoritative), `degraded` (ran on reduced input; `error` says what was missing, and the findings are real but coverage was partial), `error` (didn't run; contributed nothing), `skipped` (no server-side signal exists; today only `failed-components`, whose input is editor-session compile state).\n\n**`checks` says which legs ran, not which passed.** A leg is `complete` when it finished; it can be `complete` and still have produced the finding that locked the score. Nothing failing there means nothing *errored*, not that the email is clean — the failures are in `findings[]`, and its `check` names are a different set from the `checks` keys (`liquid` covers `liquid-errors` and `liquid-variables`, `links` covers `broken-links`, `source` covers `multiple-roots`, `reserved-attributes` and `token-literal-clash`). Don't try to join the two.\n\n**`score.counts` and `findings[]` cannot disagree** — the score is computed from the same list you are given. If `score.counts.error` is 1, that finding is in the response, so read it rather than concluding the API withheld it or diagnosing the email by hand:\n\n```bash\njq '[.findings[] | select(.state == \"fail\")] | map({check, severity, title, summary, detail_anchor})' /tmp/review.json\n```\n\nA top-level `.errors` or `.label` is not a field of the report; projecting one returns null and reads like an empty result.\n\n`score` is an object holding the readiness score, exactly as the panel shows it. The fields below sit inside it, not at the top level: read `.score.score`, `.score.locked`, `.score.counts`. While `locked`, the panel shows no number, and the report you get usually carries none either:\n\n| Field | Meaning |\n|---|---|\n| `score`, `color`, `label` | The 0-100 score (usually absent while `locked`) with its display color (`red`/`yellow`/`green`) and label |\n| `locked` | `true` while any **error**-severity finding is open. The editor hides the number and shows \"Fix N errors to unlock your score\" (N is `counts.error`). See **While the score is locked** below. |\n| `warning_fixes_to_raise_score` | How many warning fixes before the score first goes up (the editor shows \"Fix N more warnings to start raising your score\"). `0` when the next warning fix already raises it. Useful for \"what should I fix first?\"; ignore it while `locked`. |\n| `counts` | Open findings per severity (`error` / `warning` / `tip`) |\n| `raw_score` | An uncapped number kept for analytics. The editor never shows it: never quote it. |\n\n**While the score is locked, the user has no score.** The editor shows a lock and no number. If the report still has a `score`, don't quote it; don't work one out from `counts` or quote one from an earlier review either: any figure you derive still carries −40 per open error, so it is not the number the user will see once they fix anything. And because the editor shows only the lock either side of a warning or tip fix, never report a locked score rising or falling. This holds **even when the user asks for the number or how much a fix raised it**: answer with what they see. For example: \"There's no number yet: 1 error locks the readiness score, and the editor shows the score once it's fixed. Fixing the link cleared that warning, but the score stays locked until the unsubscribe link is added.\"\n\n**The editor can be locked when the report isn't.** Two error checks run only in the editor: `failed-components` (reported here as `skipped`) and broken bound slot names (`\u003cslot :name\u003e`), which has no entry in the report. If the user says the panel shows a lock your report doesn't have, that is why: ask what the panel's error says rather than contradicting them.\n\n`findings[]` are the rows. Only `state: \"fail\"` findings need action (`pass` rows are the healthy checks). A failing row with `dismissed: true` was acknowledged by the author in the editor's Review panel (it sits in a Dismissed section there with a Restore action): it is already excluded from `score` and `counts`, so do not report it as open work or try to fix it, and never dismiss a finding on the user's behalf (dismissals live in the email's settings and are the author's call). Each carries `check` (which check produced it), `severity` (`error` blocks sending \u003e `warning` \u003e `tip`), `title`/`summary`/`details` (the copy the user sees), and often a `fix` describing the intended remedy. The copy says what is wrong, not which file: an image finding names its image in `detail_anchor` (the image's address), so two `Image failed to load` rows are told apart by that field, never by title or summary. A broken link's address is in `fix.errors[]` and `details.links[]`.\n\n| `fix.kind` | The intended remedy (do it via the API) |\n|---|---|\n| `agent` | `fix.errors[]` is your instruction list (e.g. \"add an unsubscribe link\"). Apply it to the content yourself: `PUT .../design_studio/emails/{id}` (**nodes.md**). `should_revalidate: true` means it's a concrete apply-now change; `gather_input: true` means ask the user what they want first. |\n| `ai_options` | Write the missing envelope field (`fix.envelope_field`: subject or preheader). Generate a value (use `fix.prompt` as guidance) and PUT it, or offer options to the user. |\n| `ai_alt` | Write alt text for the image at `fix.src` and set it on that element in the markup. |\n| `optimize_image` | The image at `fix.url` is heavier than the limit. **Leave the image in place** — the Asset Optimizer is browser-only, and you have no way to compress an asset. Report the warning and tell the user to open the finding in the editor's Review panel and click **Optimize** (it saves a compressed copy and swaps the URL for them). Never delete the image, and never substitute a different one you picked yourself. |\n\nThe report covers the full Review panel: SpamAssassin, broken links, image validation, accessibility (including contrast in light, dark, and forced-dark modes), Liquid errors, the unsubscribe link, the preheader, bare URLs and bare email addresses, and Gmail's ~102 KB clip limit. One check has no panel row: `token-literal-clash` reports a literal sitting on a value the workspace has tokenised itself, which drifts the day that token changes. Tokens still holding their created-with default don't count — nobody chose those. It reports at `tip` severity, so it never moves the colour band.\n\n`details` carries the full validator payloads behind the findings: `details.links[]` / `details.images[]` (per-URL outcomes with the raw validation responses), `details.spam` (score + per-rule breakdown), `details.accessibility` (raw audit). Read them when the user asks *why* (\"which links are broken?\", \"what's driving my spam score?\") instead of re-validating anything yourself.\n\n## Workflow: review, fix, confirm\n\nWhen the user hands you content to create and then review, save it as given — no fixes of your own, the sender included — so the first review shows what was wrong and the second confirms your fixes cleared it.\n\n```bash\n# 1. Run the review ONCE, capture the whole report, then query it locally.\n#    Every POST re-runs the full validator fan-out, so never re-POST just to\n#    read a different field.\ncio api /v1/environments/$ENV_ID/ds/nodes/$NODE_ID/review -X POST \u003e /tmp/review.json\njq '{status, score: {score: .score.score, color: .score.color, locked: .score.locked},\n     failing: [.findings[] | select(.state == \"fail\")\n               | {id, severity, title, summary, detail_anchor, fix_kind: .fix.kind}]}' /tmp/review.json\n# later: jq '.details.links' /tmp/review.json  (same run, no new request)\n```\n\n2. **Report severity-first, briefly.** Lead with the verdict. Locked: \"Not ready to send: N errors lock the readiness score\", with no number. Unlocked: the score and label exactly as returned. Then list `error` findings (they block the score), then warnings, then tips. Use the findings' own `title`/`summary` copy, which matches what the user sees in the editor, and name what each one points at — a broken link by its full URL written out (`details.links[]`; \"your URL\" is not enough), an image by its address — so the user can find it without opening the panel. Don't dump the raw report, don't pad the reply with what passed, and don't add problems or caveats the report does not contain.\n\n3. **Fix what the user wants fixed** through the normal content endpoints (**nodes.md**: `PUT .../design_studio/emails/{id}` with the amended markup or envelope). The `fix` field tells you the intended remedy per finding; for content edits, read the current content first and change only what the finding names. **Don't silently overwrite something the user explicitly specified** (their exact CTA copy, a deliberate no-preheader) just because a finding flags it. Surface the finding and let them decide.\n\n   **Never delete content to clear a finding.** Removing an image, section, link, or block is not a fix — it silently costs the user the design they asked for, and a warning is worth far less than a hero image. If the remedy is to *change* an asset or a value and you can't perform that change (a heavy image, a broken external URL you don't have a replacement for, a spam-wording judgment), the correct outcome is to leave the content alone and hand the user the finding. This applies to content you authored from a template as much as to content they wrote: the template's images are theirs.\n\n4. **Re-run once** after your fixes are saved and confirm the findings cleared. Once unlocked, report the new score as returned. While still locked, the user sees no number either side of your fix, so don't report the score moving. If a finding you fixed still fails, your edit didn't address what the check measures; re-read its `details` before trying again.\n\n## Common Mistakes\n\n- **The most common error on a fresh email is the missing sender.** `From address can't be blank` (an error-severity `liquid-errors` finding) locks the score until the envelope has a sender. Set `envelope.from_id` on the email (pick an identity from `GET /v1/environments/{environment_id}/identities`) and re-review.\n- **One POST per revision. Never re-POST to re-read.** The response you already have contains everything (`findings`, `score`, `details.*`); save it and query it locally instead of re-running the fan-out for a different `--jq`.\n- **GET doesn't exist.** The review is a computation, not a resource: `POST`.\n- **Review runs against saved content.** If you just wrote markup, make sure the `PUT` succeeded before reviewing, or you'll review the previous revision.\n- **Link and image verdicts are cached per URL.** Up to 10 minutes, 2 minutes for a failing one. Everything else in the report is recomputed, so fixing a link's *destination* (the URL itself, or the page it points at) can still show the old broken-link finding on the next review — say so rather than reporting the fix as failed. Changing the markup to point at a different URL is validated immediately.\n- **Don't treat `partial` as clean.** A check with `status: \"error\"` contributed nothing; its problems may exist and be invisible. Name it (\"the link check couldn't run\") rather than implying a full pass.\n- **A `degraded` check's findings are real.** Coverage was reduced (its `error` says how, e.g. snippet content wasn't scanned), not wrong. Report findings normally; mention the gap only when it matters to the user's question.\n- **Don't re-implement checks.** Broken links, image weights, contrast, spam scoring: the report already measured all of it with the same validators the editor uses. Read `details.*` instead of fetching URLs or auditing HTML yourself.\n- **Score semantics**: while `locked`, the user sees no number until every error is fixed, so fix errors first. Fixing a warning changes nothing the user sees: never report it as the score rising or falling.\n- **Don't chase 100.** A yellow report whose warnings you've explained is a legitimate place to stop — `warning` findings are advisory (`Review warnings before sending`), not blockers, and one heavy image scores 88. Report the score and the warning as they stand; deleting or downgrading the content that caused it, to hand back a greener number, is the wrong trade every time.\n- **Not for other editors.** If the user's email turns out to be a non-DS template (`editor` != \"parcel\"), there is nothing to review here; don't create a DS copy just to score it unless the user asks for a migration.\n- **`review` is not `render`.** `emails/{id}/render` (**nodes_advanced.md**) returns compiled markup; `review` runs QA on it. For \"why is my email empty,\" start with render.\n\n## Relationship to the accessibility deep-dive\n\nThe review's `accessibility` check runs the full automated audit (axe + email-specific rules) server-side; those findings land in `findings[]` with `check: \"accessibility\"` and the raw audit in `details.accessibility`. **email_accessibility.md** remains the *judgment-based* pass (reading copy in context, evaluating alt-text quality, touch targets): reach for it when the user wants a thorough accessibility audit beyond the automated rules, and start it from this report's data rather than re-running the mechanical checks by hand.\n","global_styles.md":"# Design Studio API: Global Styles \u0026 Email Groups\n\nAll endpoints are `cio api` under `/v1/environments/{environment_id}/design_studio` — GET to read, PATCH to change. The full-replacement endpoint (and deletion) lives under `/ds/global_styles`.\n\n\u003e **Tokens vs rules.** Global styles are the *values* — a colour named `primary`, a\n\u003e 16px body size. Rules about which value belongs where are a separate concern:\n\u003e when the workspace has a brand design system document (`brand_document.md`, when\n\u003e it appears in this skill's file list), that is the authority on usage. Read this\n\u003e file when you need to inspect or change the token values themselves.\n\n## Global Styles\n\n| Method | Full Path | Description |\n| ------ | --------- | ----------- |\n| GET | `/v1/environments/{environment_id}/design_studio/global_styles` | Get workspace design tokens. Auto-creates defaults if none exist; backfills missing component groups. Read token ids here before PATCHing. Always returns the bare token tree. **The composite `GET /ds/global_styles` differs**: on workspaces with the brand document feature it returns a `{ \"styles\": \u003ctoken tree\u003e, \"brand\": \u003cbrand doc\u003e }` envelope — project it with `(.styles // .)` so both shapes work. A null projection means you projected the wrong shape, never that the workspace has no tokens. |\n| PATCH | `/v1/environments/{environment_id}/design_studio/global_styles` | Declaratively update token/variant values and add new tokens/variants. Body: `{ \"update\": {...}, \"create\": [...] }`. Cannot delete. Returns 204. See **Declarative Update (PATCH)** below. |\n\n### Declarative Update (PATCH)\n\n`PATCH /v1/environments/{environment_id}/design_studio/global_styles` applies a small, additive change without re-sending the whole object. It is safe by construction: it operates on the stored styles server-side, so it **cannot delete tokens or wipe the object**, and the server owns all `$extensions` metadata (you never send `id`/`index`).\n\nCovers **variable tokens** (`color`, `font`, `radius`, `spacing`) and **component variants** (`button`, `textStyle`, `link`, `divider`, `image`, `section`, `inbox`). It **cannot delete** — to remove a token or variant, tell the user to use the Design Studio UI.\n\n```json\n{\n  \"update\": {\n    \"\u003cvariable-token-id\u003e\": { \"value\": \"#4A90D9\" },\n    \"\u003cvariant-id\u003e\": { \"properties\": {\n        \"background\": { \"value\": \"#FF8800\" },\n        \"border-radius\": { \"type\": \"dimension\", \"value\": \"8px\" }\n    } }\n  },\n  \"create\": [\n    { \"group\": \"color\", \"name\": \"brand-accent\", \"type\": \"color\", \"value\": \"#FF8800\" },\n    { \"group\": \"button\", \"name\": \"ghost\", \"properties\": {\n        \"background\": { \"type\": \"color\", \"value\": \"#FFFFFF\" },\n        \"color\":      { \"type\": \"color\", \"value\": \"#111111\" }\n    } }\n  ]\n}\n```\n\n- **`update`** is keyed by the token's **id** (`$extensions[\"io.parcel.global-styles\"].id` from a GET), not its name — names can change, ids are stable. Omitted tokens are untouched.\n  - Variable token → `value` sets `$value`.\n  - Variant → `properties` maps each property to `{ value }` to change an existing property (its `$type` is kept) or `{ type, value }` to add a new one. New variants start nearly empty, so adding properties on update is normal.\n- **`create`** adds a new token; the server mints its `id`/`index`. Fails if the name already exists in the group.\n  - Variable token → `group` + `name` + `type` + `value`.\n  - Variant → `group` + `name` + `properties` (each `{ type, value }`). A newly created variant never becomes the group default.\n- Each component property has a **fixed `type`** — `border-radius`→`dimension`, `color`/`background`→`color`, `font-size`/`opacity`/`line-height`→`number`, `font-weight`→`fontWeight`, `border-style`→`strokeStyle`, `text-decoration`→`textDecoration`, etc. A wrong type returns **400** naming the expected one. Number properties take a numeric value (`16`), not a string (`\"16px\"`).\n- At least one `update` or `create` is required. Errors return **400** with a message explaining what went wrong (unknown id, name collision, missing/unknown property, unsupported group/type/value).\n\nFlow: **GET** `/design_studio/global_styles` to read current tokens and their ids → **PATCH** `/design_studio/global_styles` with just your changes.\n\n### Token Structure\n\n[Design Tokens](https://design-tokens.github.io/community-group/format/) format.\n\n**Variable token groups:** `color`, `font`, `radius`, `spacing`. Each token has `$type`, `$value`, `$extensions` (with unique `id` and `index`).\n\n**Component style groups:** `button`, `textStyle`, `link`, `divider`, `image`, `section`; the `inbox` pattern group uses the same variant structure. Each entry has component-specific properties plus `$extensions` (with `id` and `index`). These groups are not directly referenceable through `globalStyles.*` in markup.\n\nThe theme level also requires `$extensions` with `io.parcel.global-styles` containing `id` (string) and `version` (string).\n\n```json\n{\n  \"default\": {\n    \"$extensions\": {\n      \"io.parcel.global-styles\": { \"id\": \"...\", \"version\": \"1\" }\n    },\n    \"color\": {\n      \"brand-primary\": {\n        \"$type\": \"color\",\n        \"$value\": \"#4A90D9\",\n        \"$extensions\": {\n          \"io.parcel.global-styles\": { \"id\": \"...\", \"index\": 0 }\n        }\n      }\n    },\n    \"button\": {\n      \"primary\": {\n        \"background\": { \"$type\": \"color\", \"$value\": \"#4A90D9\" },\n        \"$extensions\": {\n          \"io.parcel.global-styles\": { \"id\": \"...\", \"index\": 0 }\n        }\n      }\n    }\n  }\n}\n```\n\n### Allowed Variable Token Types\n\n`color`, `dimension`, `fontWeight`, `duration`, `file`, `fontFamily`, `align`, `shadow`, `textTransform`, `textDecoration`, `language`, `direction`.\n\n### Referencing Tokens from Markup\n\nVariable tokens are referenced from template markup as `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cid\u003e`:\n\n- `\u003cgroup\u003e`: `colors`, `fonts`, `radius`, or `spacing` — `color` and `font` are pluralized, but `radius` and `spacing` are **not**.\n- `\u003csafeName\u003e`: the JSON key with non-word chars stripped, then lowercased. `brand-cream` → `brandcream`, `neutral-100` → `neutral100`. Non-word characters (hyphens, spaces, dots) are stripped *before* camelCasing, so they never act as boundaries. Underscores survive the strip and do (`heading_color` → `headingColor`), as do existing internal capitals.\n- `\u003cid\u003e`: the value of `$extensions[\"io.parcel.global-styles\"].id`. Default IDs are 12-char alphanumeric.\n\nExample — `{ \"brand-cream\": { \"$extensions\": { \"io.parcel.global-styles\": { \"id\": \"abc123def456\" } } } }` → `:background=\"globalStyles.colors.brandcream_abc123def456\"`. The path is evaluated as a JS expression, so dot-notation only and no hyphens.\n\n### Choosing a color token\n\nMatch color tokens by their **`$value`**, never by what the name sounds like — names don't reliably describe values, and a name-guess (`Neutral100` \"must be white\") has shipped black text on a black background while the agent reported \"done\".\n\n- **Generic color request** (\"make this white\", \"turn the heading red\", \"use #1a73e8\") → turn the request into a target hex (`white` → `#ffffff`), then compare every color token's `$value` to it. Use the token only if it reads as the same color at a glance (`#fafafa` for white, yes; `#ededed`, no). If nothing is that close, write the literal hex (`color=\"#ffffff\"`) and optionally offer to add a token. The `_\u003cid\u003e` reference pins the exact token, so another token with a similar name is no reason to fall back to the literal.\n- **Token / brand request** (\"use our brand primary\", \"make this header brand-cream\", \"apply Neutral100\") → the user named a token, so pick **by name**: resolve that token from the fetched styles and reference it by `\u003csafeName\u003e_\u003cid\u003e` — no value matching, no literal fallback, and no need to ask when the name is unambiguous. Only the colliding-name case below interrupts this.\n- **Colliding names → ask.** Before referencing a token by name, compare the name against every color token with case and punctuation stripped (`Neutral100`, `neutral-100`, `neutral_100` all collide). If more than one matches and their values differ, do not pick — ask the user which one they mean, listing every candidate's key, hex **and id** (the id is what tells them apart).\n\n### Applying Component Styles\n\nComponent styles are applied as defaults or named variants, not as `globalStyles.*` expressions:\n\n- `button`, `divider`, `image`, `section`, and `inbox` use `#variant=\"\u003cname\u003e\"` on `x-cta`, `x-hr`, `x-image`, `x-section`, and `x-inbox-message` respectively. Omit `#variant` to follow the group's default.\n- `link` uses `data-variant=\"\u003cname\u003e\"` on inline `\u003ca\u003e` elements.\n- `textStyle` entries are fixed typography slots that Carta applies as component defaults; they are not selected with a variant attribute.\n\nDo not write paths such as `globalStyles.button.primary_\u003cid\u003e` or `globalStyles.textStyle.paragraph_\u003cid\u003e` — Carta exposes only variable-token groups in the `globalStyles` expression context.\n\n**Variant names are user vocabulary.** When a user says \"the primary button\", \"make it secondary\", or \"swap the primary and secondary button colors\", they mean these named variants — check the `button` group (and `link`, `divider`, `section` as relevant) before interpreting the words any other way. Apply the variant (`#variant=\"secondary\"`) instead of copying its hex into `background`/`color`, so the button follows the variant if it is later edited in the Styles panel. If an email's buttons are hardcoded and neither carries a variant, say so and confirm which button should take which variant before editing (see **SKILL.md § Editing an Existing Email**).\n\n### Changing styles\n\nMake changes with the PATCH — it's additive and id-addressed, so you send only what changes; **never reconstruct the whole object.** You cannot delete tokens or variants through the API; if the user wants something removed, point them to the Design Studio UI.\n\n### Validation\n\nThe server validates the resulting styles and returns **400** on any violation:\n\n- Only the `\"default\"` theme is allowed\n- Theme-level `$extensions` must have `io.parcel.global-styles` with `id` (string) and `version` (string)\n- Each variable token must have a valid `$type` (see allowed types above)\n- Every token (and variant) must have `$extensions` with `io.parcel.global-styles` containing `id` (string) and `index` (number) — the server mints these on `create`\n- Font token URLs must be `http://` or `https://` CSS stylesheet links — direct font files (`.woff`, `.woff2`, `.ttf`, `.otf`, `.eot`) are rejected\n- Component variants reject unknown property names\n\n**Gotchas:** Address tokens by **id**, not name (names shift). On a variant `update`, include a property's `type` only when adding it; omit `type` to change an existing property's value. Font token `url` must be an `http(s)` link to a `.css` stylesheet, not a `.woff`/`.ttf`. No deletes — use the UI.\n\n## Email Groups\n\nNamed lists of email addresses for test sends. Go ui_api surface.\n\n| Method | Path                   | Body                                            | Description                                                                         |\n| ------ | ---------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------- |\n| GET    | `.../email_groups`     | —                                               | List all groups. Returns `[{ id, name, email_addresses, created_at, updated_at }]`. |\n| POST   | `.../email_groups`     | `{ \"name\": \"...\", \"email_addresses\": [\"...\"] }` | Create group. Max 25 addresses. 204 on success.                                     |\n| PUT    | `.../email_groups/:id` | Same as create                                  | Update group. 204 on success.                                                       |\n| DELETE | `.../email_groups/:id` | —                                               | Delete group. 204 on success.                                                       |\n","in_app.md":"---\nfeature_flag: in-app_design-studio\n---\n\n# In-app Messages in Design Studio\n\nThis account has Design Studio in-app, so a new in-app message for an automation action, a one-time send, a transactional message or an anonymous broadcast is built here as an `IN_APP` node and linked to its destination, and one already in Design Studio is changed on its node and published. **For in-app, the link is the message's first publish:** it renders the node and writes the message into the destination's template, which is what delivers. After that, a change to the node reaches the destination only when you publish it (§ Edit and publish).\n\n**Build the message here, not on its template**, unless it's one of the cases under What stays on the template. `@skills/fly-api/in_app.md` describes the template path, so read it only for the markup. Its opening line (\"Content is set directly on the template\") and the template writes in its § Setting In-App Content don't apply to a message you build here.\n\n## What stays on the template\n\nOnly these are authored with `@skills/fly-api/in_app.md`, with no node:\n\n- A message with **translations or an A/B test**: a transactional message, newsletter, automation step or anonymous broadcast whose message has other languages, or a newsletter or step with A/B test variations. The link answers 409 for one, because it writes only one of its templates.\n- A **survey** or a **tour**. A new node is a default in-app message, and the link refuses a survey or tour action with a 400.\n- A message that **already exists on its template**. Edit it there (`@skills/fly-api/in_app.md` § Updating an existing message); the link refuses to replace it.\n\nA message already in Design Studio (its template's `editor` is `\"parcel\"`) is changed on its node and published (§ Edit and publish), and gains languages there too (§ Languages). Its template refuses content writes.\n\n## Create, then link\n\nA transactional message that doesn't exist yet comes first: provision and create it with `@skills/fly-api/transactional_in_app.md` Steps 0 and 1 (`\"type\": \"in_app\"`). Build and link its content below in place of that file's Step 2, which writes the template. Its Step 3 activates the message and Step 4 sends it: take them only when the user asks. Don't add languages to a message you'll link here.\n\n1. **Create the node.** Decide its folder first (**SKILL.md** step 1g).\n\n   ```\n   POST /v1/environments/$ENV_ID/design_studio/in_app\n   ```\n\n   ```json\n   { \"name\": \"Welcome modal\", \"parent_folder_id\": null, \"content\": \"\u003cx-base\u003e\u003cx-message display-type=\\\"modal\\\"\u003e...\u003c/x-message\u003e\u003c/x-base\u003e\" }\n   ```\n\n   `content` is the in-app component markup that `@skills/fly-api/in_app.md` documents in § Step 0 (global styles), § Component Markup, § Components and § Patterns. The message's display comes from its `x-message` props. A compile with `/previews/carta_content` is only a check, because the link renders the node and writes the template itself. Never write `body` or `body_json` on the destination yourself: once its template holds a message, the link refuses it. The response is `{ \"in_app\": { \"id\": \"\u003cnode_id\u003e\", \"content\": ... } }`. Check that its `content` holds your markup. An empty one means the markup never reached the request, for example a shell variable set in an earlier command, which doesn't carry over. Put the markup on that same node with `PUT /design_studio/in_app/\u003cnode_id\u003e` rather than creating another.\n\n2. **Get the content right before linking.** The link puts the message live, and a later edit reaches the destination only through a publish, so revise with `PUT /design_studio/in_app/\u003cnode_id\u003e` and a new `content` first. Never link a node with empty `content`: the destination would become a Design Studio template with no message. If a node you linked turns out to have empty `content`, judged by the node rather than a template read, put the content on that node with `PUT /design_studio/in_app/\u003cnode_id\u003e`, then publish it with `POST /design_studio/in_app/\u003cnode_id\u003e/publish` and confirm as in step 4.\n\n3. **Link** it with the destination's entity id, in the same target shapes as email (**journeys.md** § Flow, step 2): `campaign_action` with the action's `action_id`, `newsletter` with `newsletter_id`, or `transactional_message` with the message's `transactional_message_id`. An anonymous broadcast takes `in_app_broadcast` with its `in_app_broadcast_id` (§ An anonymous broadcast). All are integers, though the automation and newsletter reads return ids as strings.\n\n   ```\n   POST /v1/environments/$ENV_ID/design_studio/in_app/\u003cnode_id\u003e/link\n   ```\n\n   ```json\n   { \"target\": { \"type\": \"campaign_action\", \"action_id\": 42 } }\n   ```\n\n   The response is `{ \"node_id\", \"template_id\", \"target\", \"operation\": \"link\" }`. Linking the same destination again changes nothing. A node that already has another destination is copied instead (§ Reusing a message).\n\n4. **Confirm** with `GET /v1/environments/$ENV_ID/templates/\u003ctemplate_id\u003e`, which answers `{ \"template\": { ... } }`: `template.editor` is `\"parcel\"` and `template.body` holds the rendered message. A field that reads null means the read missed `template`, not that the link failed, so fix the read rather than writing the template or linking another node.\n\n5. **Report** that the message is built in Design Studio and linked to its step, one-time send, transactional message or anonymous broadcast, which sends it from now on, and that a later change reaches it once it's published. Link the message's editor page, `https://fly.customer.io/workspaces/$ENV_ID/design-studio/editor/in-app/\u003cnode_id\u003e`, with the `node_id` the link answered, a copy's when it copied (**SKILL.md** § Linking to the UI).\n\nOn a live automation, follow **SKILL.md** § CRITICAL: Live Automation Safety before step 3. The link changes what people receive at once, so it is the step that approval covers. Judge liveness by the automation's own `state` and `scheduled_start`, which `GET /campaigns/\u003cid\u003e` nests under `campaign`. An action's `action_state` is not the automation's state: a draft's actions report `running` too. A transactional message is live, and needs the same approval, when its `state` is `active` or its `has_sent_message` is true. `GET /transactional_messages` lists each message with both, under `transactional_messages`. An anonymous broadcast is live when its `state` is `running` or `scheduled`, which `GET /in_app_broadcasts/\u003cid\u003e` nests under `in_app_broadcast`, and a link or publish to a running one changes what every visitor sees at once.\n\n## Edit and publish\n\nA change to a message already in Design Studio is made on its node, and reaches the destination when you publish the node.\n\n1. **Find the node.** When the request is about the message open in the editor, the `\u003ccontext\u003e` block's current URL names its node: `/design-studio/editor/in-app/\u003cnode_id\u003e`. When it names a destination, `GET /ds/journeys/\u003ctemplate_id\u003e` turns the destination's template id into `{ \"node_id\": ... }`; take it with `--jq '.node_id' -r`. An automation lists each step's `template_id` under `actions` in `GET /campaigns/\u003cid\u003e`, a one-time send its templates under `templates` in `GET /newsletters/\u003cid\u003e/templates`, `GET /transactional_messages` each message's `template_id`, and `GET /in_app_broadcasts/\u003cid\u003e` an anonymous broadcast's templates under `templates`, its message's being the one with no `language`. Read the node with `GET /design_studio/in_app/\u003cnode_id\u003e`, which answers `{ \"in_app\": { \"content\": ... } }`, and take the markup you will edit with `--jq '.in_app.content' -r`. Without `-r`, `--jq` prints a string quoted: an id then gets a 404, and markup comes back with its `\u003c` escaped, which breaks the message if you write it back. A language's template or editor page gives that language's node, which this read and step 3's edit answer 404 for: `GET /ds/nodes/\u003cnode_id\u003e` shows its `language`, and § Languages says how to change it.\n2. **Check for changes already waiting.** `GET /ds/nodes/\u003cnode_id\u003e/check_unpublished_changes` answers `{ \"has_unpublished_changes\": true|false }`. `true` before you edit means publishing would change more than your edit, because the node already differs from what its destination sends, for example through an edit someone saved without publishing: tell the user and ask before going on.\n3. **Edit** with `PUT /design_studio/in_app/\u003cnode_id\u003e` and the whole new `content`: the current markup with only the change asked for. It answers 204 with no body, and the destination goes on sending the old message.\n4. **Publish** with `POST /design_studio/in_app/\u003cnode_id\u003e/publish` and `{}`. It renders the node, writes it into the template it is linked to, and answers `{ \"template_ids\": [...], \"version_id\": \"...\" }`.\n5. **Confirm** as step 4 of § Create, then link does: `template.body` holds the change.\n6. **Report** that the change is published, with the editor link (§ Create, then link step 5) to the node you changed. Real sends pick it up within about two minutes, because the render service keeps each template that long, while a test send (`@skills/fly-api/test_sends.md` § In-App) shows it at once.\n\nOn a destination **SKILL.md** § CRITICAL: Live Automation Safety calls live, publish is the step that section covers for an edit, as the link is for a new message. For an agent both writes are gated: the PUT and then the publish each answer `428 capability_required` with the `required_scopes` to request. Request each one when it asks, with a reason that names the destination, its state and that approving puts the edit live, then rerun that write. Finish both before you reply: an approval ends with your reply, and an edit saved without its publish waits on the node for whoever publishes it next. If the publish is declined or refused, say the edit is saved but not live.\n\n## Languages\n\nA message in Design Studio keeps each language on a node of its own, in its default node's language group, and each language's node is linked to that language's template. `GET /ds/nodes/\u003cnode_id\u003e/language_translations` lists the group from any of its nodes under `nodes`, each with its `id` and `language`, `\"\"` for the default's.\n\n1. **Add a language.** First run § Edit and publish step 2's check on the default's node. Every publish writes the default too, so `true` means publishing the new language would also put changes nobody has published live: tell the user and ask before going on. Then `POST /ds/nodes/\u003cnode_id\u003e/language_translations` with `{ \"languages\": [\"es\"] }`. Its `translations` give each new node's `id` and `language`. A new node starts with the default's markup, which `GET /design_studio/in_app/\u003cdefault node_id\u003e` reads, and its template is made when you publish. Add languages here rather than through the one-time send's, transactional message's or automation's own language routes. A language the group already has is refused: change that one as step 2 says. A message linked to an anonymous broadcast answers 409, because an anonymous broadcast can't take languages from Design Studio yet: tell the user.\n2. **Edit a language** on its own node. `/design_studio/in_app/\u003cnode_id\u003e` serves only the default's node and answers 404 for a language's. For a language you just added in step 1, skip the check: step 1 ran it on the default, and the check answers `true` for a language until its first publish. For any other language, first run § Edit and publish step 2's check on its node. `true` means the message, in this language or another, holds changes nobody has published, and the check can't say which. Every publish writes the default too, so publishing this language would put them live: tell the user and ask before going on. Then read the language's current markup with `GET /ds/nodes/\u003cnode_id\u003e` and `--jq '.parts.content' -r`, unquoted as in § Edit and publish step 1, and write the whole markup back with only the change asked for: `PATCH /ds/nodes/\u003cnode_id\u003e` with `{ \"parts\": { \"content\": \"...\" } }`, which answers `{ \"updated_at\": ... }`. If the read prints nothing, or `null`, the node has no markup to edit: stop and tell the user, and never write a language's markup from an empty read.\n3. **Publish** with `POST /design_studio/in_app/\u003cnode_id\u003e/publish` on any node of the message. `{}` writes every language, and `{ \"languages\": [\"es\"] }` the default and the ones it names. Its `template_ids` lists each template it wrote, a new language's included. Confirm and report as § Edit and publish's steps 5 and 6 do, with an editor link to the node of each language you changed. On a live destination, the PATCH and the publish each ask for approval as that section's PUT and publish do.\n\nA language added through those other routes, a one-time send's or transactional message's translation or an automation step's language variant, is a template holding a copy of the default's message and no node, so `GET /ds/journeys/\u003ctemplate_id\u003e` finds none for it. It sends that copy until the same language is added here and published, which writes the language into that template and links it.\n\nRemoving a language isn't covered here, so tell the user. Never delete a language's template with `DELETE /templates/\u003ctemplate_id\u003e`: its node stays, and the next publish gives the language a new template.\n\n## What the link and publish keep\n\nThe destination's delivery settings carry over unchanged: priority, persistence, page rules, expiry, targeting and attributes. A new destination starts with the defaults, a copy brings the settings of the message it copies (§ Reusing a message), and a publish writes them back as they are. A tour's link and every publish write it persistent, which it needs to carry across page loads, so a tour whose template isn't persistent yet reports unpublished changes until it's published. Changing one isn't covered here: if the user asked for one, say so, and don't write it on the template, which refuses it. An anonymous broadcast is the exception (§ An anonymous broadcast).\n\n## An anonymous broadcast\n\nCreate, schedule and start the broadcast with `@skills/fly-api/anonymous_broadcasts.md`. `GET /in_app_broadcasts` with `--params '{\"search\": \"\u003cname\u003e\"}'` finds one by name: each broadcast is under `in_app_broadcasts` with its `id`, and their templates are under `templates`. Its page rules, expiry, targeting, persistence and attributes belong to the broadcast and stay on its template, where that file sets them with `PUT /templates/\u003ctemplate_id\u003e` before the link. Read the template first (`GET /templates/\u003ctemplate_id\u003e`, under `template`) and send its whole `body_json` back with only the requested keys changed. Never send `body`, `carta_content` or `message_id`: the link refuses a template that holds a message. Once linked, the template refuses them all with a 422, and they change only on the broadcast's setup page in the app, so tell the user that. Its § Common Pitfalls item 3, which writes the message on the template, doesn't apply: build the message here and link it with `{ \"target\": { \"type\": \"in_app_broadcast\", \"in_app_broadcast_id\": \u003cin_app_broadcast.id\u003e } }`.\n\nOnly the message's display, from its markup, and its priority come from the node. Gist shows the message to visitors as written, so its Liquid can use snippets alone, which the link and every publish resolve: a customer attribute, event data or any other variable is refused with a 422, and a snippet changed later reaches visitors at the next publish. Linking or publishing to an anonymous broadcast also needs permission to edit anonymous in-app messages, and a message with translations in Design Studio can't go into one yet.\n\n## Reusing a message\n\nTo find one, `GET /design_studio/in_app` lists the in-app messages under `in_app`, each with its `sub_type` (`default`, `survey` or `tour`) and `is_linked`, and `--params '{\"sub_type\": \"tour\"}'` lists one kind. `GET /ds/node_actions/linkable` lists them too, with each one's preview in `latest_render`, when its `node_type` is `IN_APP`: `--params '{\"node_type\": \"IN_APP\", \"sub_type\": \"tour\", \"offset\": 0, \"limit\": 50}'`. Its `type` is `template`, `linked`, `unlinked` or `all`, never a channel, and `sub_type` without `node_type` `IN_APP` answers 400. Link only a `default` message: a survey or a tour stays on its template (§ What stays on the template).\n\nA node sends to one destination, so linking a node that already has one copies it and links the copy. The response then says `\"operation\": \"duplicate\"`, and its `node_id` is the copy's. The copy is named `[Copy] ` and the node's name, less any copy mark it already has, unless the link also passes `\"name\"`. It starts with the node's content, settings, folder and delivery settings as they stand now, including edits that haven't reached the original's destination. Its forms get ids of their own, apart from a lead-capture form, which keeps posting to the original's, and translations aren't copied. A copy onto an anonymous broadcast, or of a node that backs one, brings only its priority, because the broadcast owns the other settings. From then on the two are separate: tell the user the original is unchanged, and that an edit to one never reaches the other.\n\n## When the link refuses\n\n| Response | What it means | What to do |\n|---|---|---|\n| 403 \"Design Studio in-app messages aren't enabled for this account\" | The account's flag is off | Author it on the template (`@skills/fly-api/in_app.md`). Don't retry |\n| 409 \"destination is already linked to a different Design Studio in-app message\" | Another node owns the destination | `\"force\": true` replaces that link. Use it only when the user asks |\n| 409 \"destination already holds an in-app message made outside Design Studio\" | The template has a message of its own | Leave it, and edit it on the template or ask the user |\n| 409 \"in-app message has no content\" | An empty node over a destination that sends | Add content, then link |\n| 409 \"…has translations…\", \"…A/B test variation…\" or \"…is running an A/B test…\" | The destination sends languages or A/B test variations the link wouldn't write | Tell the user. Author it on the templates, where its languages and variations live, unless they ask you to remove them or end the test |\n| 400 \"…sub_type is … but its campaign action's is …\" | A survey or tour action | Author it on the template |\n| 422 \"…Liquid doesn't parse in its…\" | Liquid the link would write fails every send. The error's `meta` names the node and each field: `body` and `display_value` are its markup's, and the others its delivery settings | Fix the Liquid in the markup, then link again. For Liquid in a setting, tell the user |\n| 422 \"…can't be delivered…\" | A delivery setting the link would write, such as its expiry, is one delivery can't use, or the body is over the size a message can be | Tell the user. Don't retry |\n| 422 | The message can't be delivered as written, such as a tooltip with no target | Fix the markup, then link again |\n| 403 \"…needs permission to create in-app messages\" | The node already has a destination, so the link would copy it | Tell the user. Don't retry |\n| 403 \"…needs permission to edit anonymous in-app messages\" | The user can't edit anonymous broadcasts | Tell the user. Don't retry |\n| 422 \"…can't be resolved for an anonymous broadcast's visitors…\" | The message's Liquid uses something other than snippets, which visitors would see as written, or doesn't parse | Make it parse and use snippets alone, then link again |\n| 422 \"…has an expiration in-app delivery can't take…\" | The running broadcast's template holds an expiry that has passed | Tell the user: it's changed on the broadcast's setup page |\n| 409 \"in-app message has translations…\" | The node has translations, which an anonymous broadcast can't take from Design Studio yet | Tell the user. Link a message without translations, unless they ask you to remove them |\n| 500 `anonymous_broadcast_sync_failed`, \"…updating the anonymous broadcast's in-app delivery failed…\" | The link stands, but visitors may still see an older message | Publish the node the error names, or link again, to try again |\n\nDon't move an in-app message's link with `ds/node_actions/relink`.\n\n## When publish refuses\n\n| Response | What it means | What to do |\n|---|---|---|\n| 400 \"…is not linked…\" | The node has no destination | Link it (§ Create, then link) |\n| 400 \"…has no translation…\" | `languages` names a language the message doesn't have | Tell the user which languages it has. Add one only if they ask, as § Languages says |\n| 422 \"…has no content…\" | The node's `content` is empty | Add content, then publish |\n| 422, such as \"…has no message in it…\" or a tooltip with no target | The message can't be delivered as written | Fix the markup, then publish again |\n| 422 \"…Liquid doesn't parse in its…\" | Liquid the publish would write fails every send. The error's `meta` names each node, with its `language`, and each field: `body` and `display_value` are its markup's, and the others its delivery settings | Fix the Liquid in each named node's markup, as § Edit and publish or § Languages edits it, then publish again. For Liquid in a setting, tell the user |\n| 422 \"…can't be delivered…\" | A delivery setting the template carries, such as its expiry, is one delivery can't use, or the body is over the size a message can be | Tell the user. Don't retry |\n| 403 \"publishing requires the \"Publish content\" permission\" | The user can't publish content | Tell the user. Don't retry |\n| 403, 409, 422 or 500 on an anonymous broadcast | As the link's rows for one say | As the link's rows say; after a 500, publish again |\n| 409 \"This message is a draft in an Automation…\" | The message is part of an automation's draft | Say its changes go live when the automation is published |\n| 409 \"…changed while it was being published…\" | Someone saved the message while it was publishing, so nothing went live | Read the node again (§ Edit and publish, step 1). If it holds more than your edit, publishing again puts that change live too: tell the user and ask first |\n","inbox.md":"---\nname: Design Studio Inbox\ndescription: Author inbox messages visually in Design Studio — create an \u003cx-inbox-message\u003e node, then link and publish it to a transactional message, one-time send, or automation action.\n---\n\n# Design Studio API: Inbox\n\nAuthor inbox content visually in Design Studio as an `\u003cx-inbox-message\u003e` Carta\nnode, then link and publish it to a Journeys destination. On publish the DS\nserializer converts the markup into the inbox `body_json` shape and embeds node\nmetadata so the message stays editable in the composer.\n\nThis is the Design Studio path. The code-owned alternative — writing `body_json`\ndirectly — and the full delivered payload schema (`topics`, `type`, `priority`,\n`expiration`, `properties`) are in `@skills/fly-api/inbox.md`. Both paths land on\nthe same delivered `body_json`; publishing a DS node just generates it for you.\n\nAll endpoints are `cio api` under `/v1/environments/{environment_id}/...`. Bodies\nare **snake_case**. Pass the markup as a `--arg` value and let `--json` build the body — `cio`'s\nbundled gojq encodes the `content` string (no external `jq`, no shell escaping).\nSee **nodes.md § Shell Safety for Design Studio Payloads**.\n\n## Create / Get / Update / Delete\n\n| Operation | Path | Body / Returns |\n|---|---|---|\n| Create | `POST /design_studio/inbox` | `{ name, parent_folder_id?, content?, settings? }` — `content` is `\u003cx-inbox-message\u003e` Carta markup; `parent_folder_id` omitted means the root, so decide it first (**SKILL.md step 1g**). Response: `{ inbox: { id, … } }`. |\n| Get | `GET /design_studio/inbox/{id}` | `{ inbox: { …, content, settings } }` — source markup in `content`. |\n| List | `GET /design_studio/inbox` | `{ inbox: [stubs], folders: [...], meta: {...} }` — stubs carry no content. Same query params as the other types (see **nodes.md § List**). |\n| Update | `PUT /design_studio/inbox/{id}` | `{ name?, parent_folder_id?, content?, settings? }` — partial; send only changed fields. |\n| Delete | `DELETE /design_studio/inbox/{id}` | **409** if linked to Journeys. No plain unlink exists: transfer the link to another unlinked node first (`POST /ds/node_actions/relink`) or remove the destination — the user's call. |\n\nThe create/get responses carry the **source markup**, not the serialized\n`body_json` — that's produced at publish time.\n\n## Link \u0026 Publish\n\nInbox uses the same link/publish flow as email, on the inbox sibling paths.\n`link` takes the same `{ \"target\": {...} }` body as email.\n\n| Link | Publish |\n|---|---|\n| `POST /design_studio/inbox/{node_id}/link` | `POST /design_studio/inbox/{node_id}/publish` |\n\n- `target.type` accepts the same set as email — `transactional_message`,\n  `newsletter`, or `campaign_action`.\n- Publish promotes the node's `body_json` **as-is** (inbox is not rendered\n  server-side), so link doubles as the initial publish and `publish`\n  re-serializes the node after later edits.\n- **Multi-language** inbox messages publish the whole language group.\n\nFor the email link/publish flow this mirrors, and `unpublished_changes`, see\n**journeys.md**. For versions and feedback see **nodes_advanced.md**.\n\n## Markup: `\u003cx-inbox-message\u003e`\n\nThe markup goes in the `content` field of the create/update call. Inbox uses its\nown root component `\u003cx-inbox-message\u003e` — not `\u003cx-base\u003e`, `\u003cx-section\u003e`, or\n`\u003cx-message\u003e`.\n\n### Component Reference\n\n`\u003cx-inbox-message\u003e` is the root element — one per message. It is **not** wrapped\nin `\u003cx-base\u003e` or `\u003cx-section\u003e`. On publish the serializer walks the markup and\nextracts fields into the inbox `body_json` shape (see `@skills/fly-api/inbox.md`\nfor the delivered payload schema).\n\n#### Determining the `topic` value\n\nThe `topic` attribute links the message to the workspace's notification inbox.\nQuery for an existing inbox first; create one only if none exists (each workspace\nis limited to one).\n\n**List inboxes:**\n\n```\nGET /v1/environments/{environment_id}/notification_inboxes\n```\n\nResponse: `{ \"inboxes\": [{ \"id\", \"uuid\", \"topic\", \"name\", \"state\", … }] }`.\nUse the `topic` field from the first (and only) inbox — it is already in\n`cio_inbox_\u003cuuid\u003e` format.\n\n**Create an inbox** (only when the list is empty):\n\n```\nPOST /v1/environments/{environment_id}/notification_inboxes\n```\n\nBody: `{ \"name\": \"\u003cinbox name\u003e\" }`. Response: `{ \"inbox\": { \"uuid\", \"topic\", … } }`.\nThe backend generates the `topic` as `cio_inbox_\u003cuuid\u003e` automatically.\n\n**Publish the inbox** (required for messages to render on the user's site):\n\nA newly created inbox starts in `draft` state. Messages linked to a draft inbox\nwill not render in the SDK. Update the inbox state to `published` so the SDK\npicks it up:\n\n```\nPUT /v1/environments/{environment_id}/notification_inboxes/{uuid}\n```\n\nBody: `{ \"state\": \"published\" }`. Check the `state` field from the list/create\nresponse — if already `\"published\"`, skip this step.\n\n#### `\u003cx-inbox-message\u003e` attributes\n\nAll attributes are optional. The serializer reads them from the root element.\n\n| Attribute | Maps to | Description |\n|---|---|---|\n| `topic` | `topics[0]` | The workspace inbox's UUID, formatted as `cio_inbox_\u003cuuid\u003e`. Each workspace has one inbox; use its ID here. **Must not** start with `_cio` (reserved prefix) — the `cio_inbox_` prefix (no leading underscore) is the system-generated format and passes validation. |\n| `type` | `type` | The message layout variant: `basic`, `image`, or `cta`. Determines which child elements the SDK renders. |\n| `behavior` | `messageAction.behavior` | Whole-message tap action: `openUrl`, `openDeeplink`, `dismiss`, or `performAction` (custom action — value goes in `action`). |\n| `href` | `messageAction.action` | URL for `behavior=\"openUrl\"`. |\n| `deep-link` | `messageAction.action` | Deep link for `behavior=\"openDeeplink\"`. |\n| `action` | `messageAction.action` | Custom action value for `behavior=\"performAction\"`. |\n| `:new-tab` | `messageAction.newTab` | `\"true\"` to open in a new tab (dynamic syntax required). |\n| `:dismiss-on-click` | `messageAction.dismiss` | `\"true\"` to dismiss on tap (dynamic syntax required). |\n| `tracked-response-name` | `messageAction.name` | Analytics tracked-response name for whole-message tap. |\n\n##### Expiration attributes\n\nExpiration defaults to relative 60 days (`5184000` seconds) when no expiration\nattributes are present.\n\n| Attribute | Description |\n|---|---|\n| `expiration-kind` | `relative` (default) or `absolute`. |\n| `expiration-mode` | `fixed` (default) or `liquid` (for Liquid expressions in the value). |\n| `expiration-value` | **Relative only.** Count + unit string, e.g. `\"30 days\"`, `\"12 hours\"`. Bare number without a unit suffix falls back to days. With `expiration-mode=\"liquid\"`: a Liquid expression, e.g. `\"{{ event.ttl }} hours\"`. |\n| `expiration-iso` | **Absolute + fixed only.** ISO-8601 timestamp, e.g. `\"2026-12-31T23:59:59Z\"`. |\n| `expiration-liquid` | **Absolute + liquid only.** Liquid expression resolving to a timestamp, e.g. `\"{{ event.expires_at }}\"`. |\n\n#### Child elements\n\nThe serializer extracts the **first** of each child element it finds. Order in\nthe markup does not matter — the SDK controls visual layout.\n\n| Element | Required | Maps to | Notes |\n|---|---|---|---|\n| `\u003cx-heading-4\u003e` | yes | `properties.title` | Inner text becomes the title. First `\u003cx-heading-4\u003e` wins. |\n| `\u003cx-paragraph\u003e` | yes | `properties.body` | Inner text becomes the body. Prefers a **direct child** of `\u003cx-inbox-message\u003e`; falls back to recursive search. |\n| `\u003cx-image src=\"...\"\u003e` | no | `properties.media` | `src` attribute becomes the image URL. |\n| `\u003cx-cta\u003e` | no | `properties.cta` | Inner text → `cta.label`. Accepts the same action attributes as the root element (`behavior`, `href`, `deep-link`, `action`, `:new-tab`, `:dismiss-on-click`, `tracked-response-name`). |\n\nThe serializer always emits `properties.timestamp` as\n`{{ 'now' | date: '%Y-%m-%dT%H:%M:%SZ' }}` and `priority` as `10` — these are\nnot configurable from the markup. In the visual editor, this is reflected within the `\u003cx-subtitle\u003e` component that is read only.\n\n### Variant: Basic (heading + body text)\n\nLayout order: heading → body → subtitle.\n\n```html\n\u003cx-inbox-message type=\"basic\" behavior=\"dismiss\"\u003e\n  \u003cx-heading-4 margin=\"0\" :enable-dragging=\"false\"\u003eHeading\u003c/x-heading-4\u003e\n  \u003cx-paragraph :enable-dragging=\"false\"\u003eParagraph\u003c/x-paragraph\u003e\n  \u003cx-subtitle margin=\"0\" :enable-dragging=\"false\"\u003eJust now\u003c/x-subtitle\u003e\n\u003c/x-inbox-message\u003e\n```\n\n### Variant: Image (basic + image)\n\nLayout order: heading → image → body → subtitle.\n\n```html\n\u003cx-inbox-message type=\"image\" behavior=\"dismiss\"\u003e\n  \u003cx-heading-4 margin=\"0\" :enable-dragging=\"false\"\u003eHeading\u003c/x-heading-4\u003e\n  \u003cx-image margin=\"12px 0\" :enable-dragging=\"false\" /\u003e\n  \u003cx-paragraph :enable-dragging=\"false\"\u003eParagraph\u003c/x-paragraph\u003e\n  \u003cx-subtitle margin=\"0\" :enable-dragging=\"false\"\u003eJust now\u003c/x-subtitle\u003e\n\u003c/x-inbox-message\u003e\n```\n\n### Variant: Call to Action (basic + button)\n\nLayout order: heading → body → row(subtitle, cta). The subtitle and CTA sit\nside-by-side in an `\u003cx-row\u003e`.\n\n```html\n\u003cx-inbox-message type=\"cta\" behavior=\"dismiss\"\u003e\n  \u003cx-heading-4 margin=\"0\" :enable-dragging=\"false\"\u003eHeading\u003c/x-heading-4\u003e\n  \u003cx-paragraph :enable-dragging=\"false\"\u003eParagraph\u003c/x-paragraph\u003e\n  \u003cx-row :gap=\"8\" :layout='[\"auto\",\"auto\"]' :enable-dragging=\"false\"\u003e\n    \u003cx-column vertical-align=\"middle\"\u003e\n      \u003cx-subtitle margin=\"0\" :enable-dragging=\"false\"\u003eJust now\u003c/x-subtitle\u003e\n    \u003c/x-column\u003e\n    \u003cx-column vertical-align=\"middle\"\u003e\n      \u003cx-cta align=\"right\" behavior=\"dismiss\" tracked-response-name=\"clicked\" :enable-dragging=\"false\"\u003eButton\u003c/x-cta\u003e\n    \u003c/x-column\u003e\n  \u003c/x-row\u003e\n\u003c/x-inbox-message\u003e\n```\n\n### Common Mistakes\n\n- **Wrong root element.** `\u003cx-base\u003e`, `\u003cx-section\u003e`, `\u003cx-message\u003e` are for\n  email/in-app — inbox uses `\u003cx-inbox-message\u003e` only.\n- **Wrong heading level.** The serializer reads `\u003cx-heading-4\u003e` — other heading\n  levels (`\u003cx-heading-1\u003e`, `\u003cx-heading-2\u003e`, etc.) are ignored for the title.\n- **`behavior` without the matching URL attribute.** `openUrl` needs `href`,\n  `openDeeplink` needs `deep-link`. Mixing them up produces an empty action.\n- **Static syntax for booleans.** `:new-tab=\"true\"` and `:dismiss-on-click=\"true\"`\n  require the `:` prefix (dynamic syntax). `new-tab=\"true\"` passes the string\n  `\"true\"`, not a boolean.\n- **Reserved `_cio` prefix.** Topics and types starting with `_cio` trigger a\n  validation warning. `cio_inbox_*` (no underscore prefix) is fine — that's the\n  system-generated topic format.\n- **Multiple children of same type.** Only the first `\u003cx-heading-4\u003e`, first\n  `\u003cx-paragraph\u003e` (direct child preferred), first `\u003cx-cta\u003e`, and first\n  `\u003cx-image\u003e` are extracted. Extras are silently ignored.\n- **Nested paragraph ambiguity.** If you use `\u003cx-row\u003e` with an `\u003cx-paragraph\u003e`\n  inside a column (e.g. for a timestamp layout), put the body `\u003cx-paragraph\u003e` as\n  a direct child of `\u003cx-inbox-message\u003e` so the serializer picks it up first.\n","inbox_previews.md":"---\nname: Design Studio Inbox Previews\ndescription: Render an email on real clients (Apple Mail, Gmail, Outlook, dark mode) and share vendor screenshots. The one paid Design Studio operation, so quote the credit cost and confirm before submitting.\nminimum_plan: essentials\nallow_free: true\n---\n\n# Inbox Previews: Real-Client Screenshots (paid)\n\nRender a Design Studio email on real email clients (Apple Mail, Gmail, Outlook, and more, in light and dark mode) and get vendor screenshots of the actual rendering. This is the API behind the editor's \"Inbox Preview\" tab; runs you start here appear in that tab's history and run pages.\n\n**This is the one paid operation in Design Studio: each device screenshot costs 1 credit** from a shared account pool (25 free per month, plus purchased packs). Everything else you do while iterating (renders, reviews, our own screenshots) is free. Treat inbox previews as the final confirmation before publishing, not as an iteration loop.\n\n## When to use: almost never on your own initiative\n\n**Default to the screenshot loop, not this.** For \"how does it look\", \"check it visually\", \"is the layout right\", and your own verify-before-done pass, use **visual_verification.md**: it costs a fraction of a cent on our side and none of the customer's credits, and you can see the image yourself. This skill spends the customer's credit pool (25 free per month) on vendor renders you cannot even view. Reach for it only when the user **explicitly asks for real email clients**:\n\n- \"Run an inbox preview\" / \"test this on real inboxes\"\n- \"How does this render in Outlook / Apple Mail / Gmail / dark mode?\" (a named client or mode is the explicit ask)\n- A final pre-publish check where the user asked for real-client confidence.\n\n\"Does it look good?\" is the screenshot loop. When in doubt, do the screenshot check and offer this as the paid next step; never substitute a paid run for a screenshot check.\n\n**Not for iteration, even when asked.** While content is still changing, iterate on the free loop: `.../design_studio/emails/:id/render` or the render lock-in below for production-fidelity HTML, **email_review.md** for the QA report, **visual_verification.md** for the visual. A content edit invalidates the previous run's cache, so previewing every draft burns the free pool in about three rounds. Save the paid run for the final version.\n\n## Cost rules (know these before quoting)\n\n- Cost = (distinct rendered contents) x (devices). Translations of a message that render byte-identical are collapsed server-side and charged once.\n- A (content, device) pair previewed in the last 5 days is served from cache and **not charged**. The charge happens upfront at submit; the server already deducts known cache hits.\n- Devices that fail at the vendor (bounce) are **refunded automatically**.\n- You cannot purchase credits. On a hard block at zero, say so and link Plans \u0026 Billing as a labeled Markdown link, e.g. `[Plans \u0026 Billing](https://fly.customer.io/settings/billing/your-plan)`.\n\n### Accounts that pay from their balance instead\n\nSome pay-as-you-go (Flex) accounts pay for previews from their account balance, in dollars, and have no credits. `GET /v1/environments/$ENV_ID/ds/inbox_previews/wallet?previews=\u003cdevices\u003e` tells you which: 200 returns `{\"price_per_preview\": 0.25, \"shortfall\": 0}` in dollars, 404 means the account pays in credits and everything below applies as written. For a 200 account:\n\n- The cost rules above hold (cache and bounces are free, translations collapse), but the quote is in dollars: up to devices x `price_per_preview`. A `shortfall` above 0 is what the balance is short of the run; say so before rendering anything. Ignore `/credits`; its pools do not apply.\n- A 422 on submit reads \"Insufficient account balance. Add at least $X to send these previews.\" Show it verbatim, offer a smaller device set, and link Plans \u0026 Billing for adding funds (you cannot add them).\n\n## The flow\n\n### 1. Check the balance (free)\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/inbox_previews/credits\n```\n\nReturns credit pools: `[{\"tier\":\"free\",\"credits_original\":25,\"credits_remaining\":4,\"expires_at\":...}, {\"tier\":\"paid\",...}]`. Sum `credits_remaining`. If the planned run costs more than the balance, stop and tell the user before rendering anything. For an account that pays from its balance, read `/wallet` instead (see above).\n\n### 2. Pick devices (free)\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/inbox_previews/clients --jq '.clients[] | {id, client, os}'\n```\n\n**Default to a small \"smart set\" of 3 to 5 devices** covering the clients that actually break email: one Apple Mail (its dark-mode variant if the user cares about dark mode), one Gmail, one Outlook on Windows. Only go wider when the user names devices or asks for broad coverage, and quote the cost either way. Never submit the whole catalog.\n\nIf the workspace has saved device sets, prefer one that fits: `GET .../ds/inbox_previews/client_groups` returns named groups whose `clients` arrays you can pass straight through.\n\n### 3. Lock in the render (free)\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/inbox_previews/renders -X POST \\\n  --json '{\"node_ids\": [\"$NODE_UUID\"], \"sample_data\": {\"customer\": {\"first_name\": \"Ada\"}}}'\n```\n\n- `node_ids` takes 1 to 51 node UUIDs: one email, or a message's whole language group in one call.\n- `sample_data` is the Liquid variable object to render with. Use realistic values (a profile's attributes), the same way the editor's preview would.\n- `lax_mode` (optional, default false) renders a variable the sample data does not carry as an empty string instead of failing the node. **Prefer supplying the value.** `sample_data` covers the caller-supplied namespaces — `{{customer.*}}`, `{{event.*}}`, `{{trigger.*}}`, `{{objects.*}}`, `{{journey.*}}` — so for those a blank is a choice, not a constraint. `{{snippets.*}}` is the exception: the workspace owns that key and sample data cannot override it. Reach for `lax_mode` when the layout is what the user wants to see and the values are not, or when realistic data is not worth assembling for a screenshot. Render once strictly first even then — it is free, and it is the only thing that tells you which variables the lax run will blank. Because the screenshot then has gaps, step 4 makes you disclose that *before* the charge, not only in the report after it. It forgives missing variables and nothing else: a malformed tag still fails the node.\n- Poll the returned `render_job_id` until no node is `pending`, in one bounded bash call:\n\n```bash\nfor i in $(seq 1 12); do\n  cio api /v1/environments/$ENV_ID/ds/inbox_previews/renders/$RENDER_JOB_ID \u003e /tmp/render.json\n  grep -q '\"pending\"' /tmp/render.json || break; sleep 5\ndone; cat /tmp/render.json\n```\n\nEach node comes back with `status` (`pending` / `error` / `success`) and, on success, the `liquid_render_hash` you need for the submit. On `error`, fix the content first; do not submit a batch containing a failed node. A `liquid` error usually names the variables your `sample_data` did not carry — add them (a `trigger` object for a transactional email's `{{trigger.*}}`, an `event` one for an event-triggered send) and re-render, which is free. A `snippets.\u003cname\u003e` miss is the one you cannot fix that way: the workspace owns that key, so restore the snippet or drop the reference. If the user is checking layout rather than content, `lax_mode` skips the data entirely.\n\nAdding `?include_content=true` to the poll returns the rendered `subject` and `content` HTML. That makes this endpoint a free production-fidelity render check on its own, useful even when nobody is buying previews — with `lax_mode` set it is not production-fidelity, since the blanks are not what a send would normally produce.\n\n### 4. Quote and confirm (MANDATORY stop, same etiquette as mass_replace_value.md)\n\nBefore any charge, present the plan and wait for an explicit yes:\n\n```\nInbox preview plan:\n  Email: \"Welcome v2\" (1 rendered content; 3 translations collapsed into it)\n  Devices (4): Apple Mail 16 (Dark), Gmail Android, Outlook 2019 Windows, Gmail Chrome\n  Cost: up to 4 credits (devices already previewed for this exact content are free)\n  Balance: 21 credits remaining\n\nRun it? Screenshots take a couple of minutes to generate.\n```\n\n**If you rendered with `lax_mode`, the quote must say so.** The screenshots will come back with gaps where that content belongs, and this is the last moment the user can decline on that basis: a blank screenshot is still `Complete`, which is charged and never refunded.\n\nName the variables you know are empty — the ones the step 3 `liquid` error reported, plus any you already knew you could not supply — and say the list is partial rather than implying it is the whole set. Treat it as partial, because you cannot tell whether it is: a render reports the misses it hit, one per field, and a snippet's own body can carry references the node's markup never shows. Add it as a line in the plan, above the `Run it?` question:\n\n```\n  Note: rendered with lax mode — {{trigger.verification_code}} and possibly others will render empty\n```\n\nDo **not** POST the previews until the user confirms. A request like \"how does it look in Outlook?\" is a reason to propose a run, not permission to charge.\n\nThe submit is also capability-gated for you: expect a 428 naming `spend:inbox_preview:\u003cnode_uuid\u003e:\u003cdevice_count\u003e` scopes (the leaf is the batch's device count, so a bigger run always needs a fresh card). Call `request_capability` with exactly those scopes (on its own turn) and a reason that repeats the quote, e.g. \"Run a paid inbox preview on 4 devices (up to 4 credits)\". The approval card is the user's binding consent; the conversational quote above is how they know what they are approving. After the grant, retry the POST unchanged.\n\n### 5. Submit (paid)\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/inbox_previews/previews -X POST \\\n  --json '{\n    \"name\": \"Agent: dark-mode check after hero redesign\",\n    \"renders\": [{\"node_id\": \"$NODE_UUID\", \"render_hash\": \"$LIQUID_RENDER_HASH\"}],\n    \"clients\": [\"applemail16_dm\", \"gmail_android\", \"outlook19_win\"]\n  }'\n```\n\n- Always set `name`, prefixed `Agent: `, saying what the run was for. The history keeps runs for months; unnamed agent runs make it unnavigable.\n- `renders` pairs each node UUID with the `liquid_render_hash` from step 3. A hash that no longer matches the node's content is refused: re-render (step 3) after any edit.\n- `clients` are ids exactly as the clients endpoint spells them.\n- Success returns `{\"run_id\": 42, \"preview_job_id\": \"...\"}`. Use the numeric `run_id` for the poll and the run-page link below; `preview_job_id` is the internal replay key and reads as noise to people. A 422 here is the authoritative insufficient-credits answer even if the balance you read a moment ago said otherwise; surface its `detail` verbatim and stop (see Errors).\n- Resubmitting the identical payload (same nodes, hashes, and devices) on the same day returns the existing batch and charges nothing, so retrying a submit after a timeout is safe.\n\n### 6. Poll until processed\n\n```bash\nfor i in $(seq 1 60); do\n  cio api /v1/environments/$ENV_ID/ds/inbox_previews/previews/$RUN_ID \u003e /tmp/preview.json\n  grep -q '\"is_processed\":true' /tmp/preview.json \u0026\u0026 break; sleep 15\ndone\ncio api /v1/environments/$ENV_ID/ds/inbox_previews/previews/$RUN_ID --jq \\\n  '{done: .is_processed, requested: .total_previews_requested, cached: .total_previews_cached, ok: .total_previews_succeeded, bounced: .total_previews_bounced}'\n```\n\nCapture the final response once to `/tmp/preview.json` and query it locally for the report; do not re-fetch the batch for every `--jq`. Fully cached runs finish instantly; real vendor renders typically land within a few minutes. Tile `status` values are the vendor's spelling: `Pending`, `Processing`, `Complete`, `Bounced`; only the last two are terminal. If nothing has left `Pending` after 15 minutes, stop polling and tell the user the run looks stuck and support may need a look; the credits stay attached to the run and failures are refunded.\n\n### 7. Report and link (do not fetch the screenshots)\n\nSummarize per device from `node_previews[].previews[]`: `client`, `os`, `status`, and `error` when present. Mention refunds when `total_previews_bounced \u003e 0` (\"2 devices failed at the vendor; those credits were refunded\"). If you rendered with `lax_mode`, repeat the step 4 note — the variables you know rendered empty, and that the list is partial — since the screenshots show gaps a real send would fill and only you know why.\n\nThen link the run page so the user can view the screenshots. Always as a labeled Markdown link — never paste the bare URL, whose UUID segments read as noise in chat. The last segment is the numeric `run_id` the submit returned, never the `preview_job_id` hash:\n\n```\n[View the inbox previews](https://fly.customer.io/workspaces/$ENV_ID/design-studio/editor/$NODE_UUID/inbox-previews/$RUN_ID)\n```\n\nPrefer a label naming the email, e.g. `[Inbox previews for \"March Newsletter\"](…)`. The node segment is the node's UUID `id`, never its `short_id` (see SKILL.md, Editor links). For a multi-node run, link the node the user was working on; the run page shows that node's tiles. The run also appears under the editor's **Review and test → Inbox Preview** tab, which is how the user finds it again later — worth saying when they ask where runs live.\n\n**You cannot view the screenshots yourself.** Tile `screenshots` / `thumbnail` URLs are authenticated capture routes on our own API that answer raw image bytes — nothing you can look at or usefully pipe through the CLI. Do not fetch them and do not claim you looked at them. Report the per-device statuses, link the run page, and if you need to see a rendering with your own eyes use **visual_verification.md** (our renderer, free), being clear it is not a real-client screenshot.\n\n## Errors\n\n| Response | Meaning | Do |\n|---|---|---|\n| 403 `plan_restricted` (\"not available on the Builder plan\") | Inbox previews are excluded from the Builder plan in this phase. | Say real-client previews are not part of the Builder plan, and offer the free alternatives: render check (step 3), review (**email_review.md**), our-renderer screenshot (**visual_verification.md**). Do not retry. |\n| 403 with a bare `forbidden` detail (no plan named) | Either inbox previews are not in the account's current plan (a trial, or a paused subscription), or the user lacks the workspace's email permissions — the response does not say which. | Say real-client previews need a paid, active plan and the workspace's email permissions, and offer the same free alternatives. Do not retry. |\n| 422 on submit, `detail` names a credit or account-balance shortfall | Balance cannot cover the run; nothing was charged. | Show the detail verbatim, offer to shrink the device set, and link `[Plans \u0026 Billing](https://fly.customer.io/settings/billing/your-plan)` for buying credits or adding funds (you can do neither). |\n| 4xx naming a render hash | The content changed after step 3, or the render did not succeed. | Re-run step 3 on the current content and submit with the fresh hash. |\n| 403 naming the `write:live` scope on `POST /previews` (external MCP/API tokens only) | The token lacks the `write:live` OAuth scope this one money-spending route requires of scoped tokens. The workflow agent is exempt — its consent is the capability card. | Frame it as the admin's chosen policy and say the free render and review paths still work. Do not retry or work around it. |\n| 428 on `POST /previews` naming `spend:inbox_preview:...` scopes | The paid submit needs a user-approved capability grant; the scope leaf pins the device count. | `request_capability` with exactly the named scopes and a reason quoting the cost, then retry (step 4). A denied card is a \"no\": drop the run without argument. |\n\nThe account's \"Edit live data\" setting does **not** gate preview runs: a run edits nothing, and the capability card the user approves is the consent for the spend. With that toggle off you can still run previews — every other live-edit restriction stands.\n\n## Guardrails\n\n- **One paid run per task.** Iterate on free renders and reviews; preview once at the end. If the user asks to re-preview after an edit, re-quote: the edit voided the cache.\n- **Quote before every charge**, even a 1-credit run, and even when the user already said \"preview it\" earlier in the conversation for different content.\n- **Never select more devices than the user's question needs.** \"Does it work in dark mode?\" is one or two dark-mode devices, not the catalog.\n","journeys.md":"# Design Studio: Journeys Integration Endpoints\n\nConnect a Design Studio email to an automation action, one-time send, or transactional\nmessage using the **node-centric** endpoints under\n`/v1/environments/$ENV_ID/design_studio/...`, with `cio api`. You work from an\nemail **node id**: the API resolves and converts the destination's template for\nyou.\n\n\u003e **This page only wires an email into a destination that already exists — it does\n\u003e not build an automation.** Create the campaign and its actions, trigger, and\n\u003e exit conditions first via `@skills/fly-api/automations.md`; see\n\u003e `@skills/fly-api/automation_filters.md` for triggers and exit conditions, and then link the\n\u003e `campaign_action` here.\n\n## Convert an existing Bee email\n\nFor a user-requested Bee → Design Studio migration, use the existing conversion\nroutes to carry the Bee design into native Design Studio content. The create →\nlink → publish flow below uses separately authored content; it does not import\nthe Bee design. Get the user's agreement to change editors first.\n\nWhen explaining the API sequence, include `convert_from_bee` with the existing\nnumeric `template_id`, its top-level `id` as the new `node_id`, review,\n`confirm_bee_conversion` with `templates: []`, and pending-only\n`revert_bee_conversion`. Include the live-destination restriction and stale-tab\nwarning below.\n\n**Automated conversion is limited to non-live destinations.** The conversion\nroutes currently lack the server-side agent live-data toggle, `write:live` scope\ncheck and capability gate used by normal publishing. Before creating a pending\nconversion, resolve its Journeys destination and read its state using the live\ncriteria in **Editing live data — agent permission gate** below. If any destination\nin the language group is live, or its state cannot be established, hand the\nmigration to the user in the UI and stop before conversion/confirmation. This\nrestriction applies even when live edits are enabled, the MCP token has\n`write:live`, or the user has approved a capability card. Do not call confirmation\nexpecting a 403 or 428 to protect the live content. Keep the automation's state\nunchanged; do not suggest stopping it to make this ungated route available.\n\n1. Read `GET /v1/environments/$ENV_ID/templates/\u003ctemplate_id\u003e` and verify\n   `.template.editor` is `\"bee\"`. Use the numeric **template id**, not the action\n   or campaign id. **Always warn the user to close open Bee editor tabs, even\n   when explaining a future migration:** saving a stale tab can overwrite the\n   confirmed Design Studio link.\n2. **For a verified non-live destination only**, create the pending conversion.\n   For live or unknown destinations, use the UI handoff above instead:\n\n   ```bash\n   cio api /v1/environments/$ENV_ID/ds/node_actions/convert_from_bee -X POST \\\n     --json '{\"template_id\": 123}'\n   ```\n\n   **The response's top-level `id` is the new node UUID**: use it as `node_id`\n   for inspection, confirmation and cancellation. Include this mapping when\n   explaining the migration. The server converts\n   the default email and discovers its language variants, creating the nodes and\n   Journeys links. The original template content and editor remain unchanged\n   until confirmation. Omit `source_template_id` when migrating this email in place.\n3. Inspect the converted email with `GET /design_studio/emails/\u003cnode_id\u003e` and\n   its `/render` route; follow **email_review.md** before committing it. Preserve\n   the existing design and Liquid, and surface conversion/review problems.\n4. **Confirm a verified non-live destination only**, after inspecting the\n   conversion. Hand live or unknown destinations to the user in the UI; this\n   endpoint has no server-side live-data/capability gate. Confirmation renders\n   server-side and writes the converted content into the original template(s),\n   switching their editor to `\"parcel\"`. It is the publishing step for this flow.\n   Re-read the destination state before confirming; if it has become live or the\n   state is unknown, stop and hand confirmation to the user in the UI:\n\n   ```bash\n   cio api /v1/environments/$ENV_ID/ds/node_actions/confirm_bee_conversion -X POST \\\n     --json '{\"node_id\": \"\u003cnode UUID\u003e\", \"templates\": []}'\n   ```\n\n   An empty `templates` array asks the server to render the whole language group;\n   no hand-built HTML payload or separate link/publish is needed. The success\n   response is `{\"status\":\"ok\"}`. Re-read the template's editor and the linked\n   email before reporting completion. Later edits use the normal edit → publish flow.\n5. **Cancel a pending conversion** instead of confirming when it is unwanted or\n   unacceptable:\n\n   ```bash\n   cio api /v1/environments/$ENV_ID/ds/node_actions/revert_bee_conversion -X POST \\\n     --json '{\"node_id\": \"\u003cnode UUID\u003e\"}'\n   ```\n\n   Success is `{\"status\":\"reverted\"}`: the pending nodes and links are deleted,\n   while the original Bee template was never changed. This route cannot undo an\n   already confirmed conversion; do not describe it as post-confirm rollback.\n\n## Flow: create → link → publish\n\n1. **Create the email** with its content (or reuse an existing email node id):\n\n   ```\n   POST /v1/environments/$ENV_ID/design_studio/emails\n   ```\n\n   ```json\n   { \"name\": \"Welcome\", \"content\": { \"subject\": \"Welcome!\", \"html\": \"\u003cx-base\u003e...\u003c/x-base\u003e\", \"text\": \"...\" } }\n   ```\n\n   The response is `{ \"email\": { \"id\": \"\u003cnode_id\u003e\", ... } }`. Node ids are v4\n   UUIDs. `content` fields: `subject`, `preheader_text`, `html` (component\n   markup), `amp`, `text` — all optional.\n\n2. **Link** the email to its destination. Pass the destination **entity id**,\n   never a template id — the endpoint resolves that destination's default\n   template, converts it to Design Studio, and links the node:\n\n   ```\n   POST /v1/environments/$ENV_ID/design_studio/emails/\u003cnode_id\u003e/link\n   ```\n\n   ```json\n   { \"target\": { \"type\": \"transactional_message\", \"transactional_message_id\": 123 } }\n   ```\n\n   `target.type` is one of:\n\n   | `type` | id field |\n   |---|---|\n   | `transactional_message` | `transactional_message_id` |\n   | `newsletter` | `newsletter_id` |\n   | `campaign_action` | `action_id` |\n\n   Read the destination and take its id from the response — each type names\n   its own id field (above). For a `campaign_action` that's the action's `id` in\n   `actions[]` from `GET /campaigns/:id` (not the campaign id), passed as\n   `action_id`:\n\n   ```json\n   { \"target\": { \"type\": \"campaign_action\", \"action_id\": 42 } }\n   ```\n\n   The response is `{ \"node_id\", \"template_id\", \"target\" }`.\n\n3. **Publish** — renders the email server-side and writes the rendered output\n   into the linked template. This is the only step that changes what the\n   automation / one-time send / message actually sends:\n\n   ```\n   POST /v1/environments/$ENV_ID/design_studio/emails/\u003cnode_id\u003e/publish\n   ```\n\n   Body `{}` publishes the whole language group; `{ \"languages\": [\"es\",\"fr\"] }`\n   restricts to a subset (the default language always publishes). Codes match the\n   email's translations in any case (`pt-BR` finds `pt-br`); a language the email\n   has no translation in is refused with a 400 that names it and publishes\n   nothing, so add the translation first. The response is `{ \"mappings\": [...] }`.\n\n**Never stop after step 2.** Linking converts the destination's template to\nDesign Studio but writes no content, so a link without a publish leaves the\nautomation or message pointing at an **empty template** — a live automation that\nsends nothing. If a publish fails or is refused, say the destination is still\nempty and needs a publish; don't report the email as connected and move on.\n\nTo revise content after publishing, `PUT /v1/.../design_studio/emails/\u003cnode_id\u003e`\nwith a `content` object, then publish again. Editing without publishing leaves\nthe live message on the previously published content.\n\n\u003e **Before publishing, run the review** (**email_review.md**) and surface any\n\u003e `error`-severity findings to the user. They're the same blockers the editor's\n\u003e Review panel shows, and publishing over them sends a broken email.\n\n## Constraints\n\n- **Email nodes only.** Link and publish reject non-email nodes and translation\n  nodes — link the **default-language** email; its translations publish as part\n  of the same group.\n- **One destination per email, and `link` never re-points it.** Linking an\n  email that already links a *different* destination returns **400**.\n  Re-linking the same destination is idempotent. There is **no plain unlink**:\n  an existing link can only be **transferred** to another *unlinked* node\n  (`POST /ds/node_actions/relink`, see **Re-pointing an email**) or taken over\n  with `force`, so a destination that has ever been linked keeps *some* Design\n  Studio email until the destination itself is removed. Don't hunt for a\n  `DELETE …/link` or an `unlink` flag — neither exists — and don't flip the\n  destination template's `editor` away from `parcel` to force one: that severs\n  Design Studio for that message for good (it becomes a code-editor email,\n  losing the composer, components, global styles, review, and language groups,\n  with no way back).\n- **Taking over a destination.** If the destination's default template is already\n  linked to a *different* Design Studio email, link returns **409**. Pass\n  `\"force\": true` to take it over — this unlinks the other email, so only do it\n  with the user's intent.\n- Node ids must be **v4 UUIDs** (agent-generated v5 ids are rejected with 400).\n- **Don't link to A/B split or variant actions at all.** Neither link mode does\n  what you want, and one fails silently:\n  - Linking to an A/B **variant** sub-action returns **400** `\"campaign action has\n    no default template to link\"` — default-template resolution excludes variant\n    templates (those with a `parent_action_id`).\n  - Linking to the **parent split** (`split_randomized`) action **silently succeeds\n    (2xx)** but writes the content onto the split's cleared default-slot template —\n    which the split **never sends from** (the variants send). The content simply\n    never ships. This silent no-op is worse than the clean 400.\n\n  For A/B email automations, set each variant's content on the **variant's own\n  existing message**, not via a linked DS node:\n  - If the pre-split action was **already a Design Studio email** (`editor:\n    \"parcel\"`), each variant has its own DS node — revise that existing node and\n    republish.\n  - Otherwise the variants are plain templates with no node — edit them with `PUT\n    /v1/environments/$ENV_ID/templates/:template_id`.\n\n  Either way, do **not** create standalone DS email nodes and link them to split or\n  variant actions. See `@skills/fly-api/ab_tests.md`.\n\n## Reading a node's destination\n\nBefore advising on or changing a link, find out where the email actually goes:\n\n```\nGET /v1/environments/$ENV_ID/ds/nodes/\u003cnode_id\u003e/journeys\n```\n\nVerified response:\n\n```json\n{\"links\":[{\"node_id\":\"0e1f77c6-…\",\"journeys_template_id\":11,\"environment_id\":1,\n           \"journeys_parent_entity_type\":\"action\",\"journeys_parent_entity_id\":3,\n           \"tracked\":false}]}\n```\n\n`journeys_parent_entity_type` is `action`, `newsletter` or\n`transactional_message`. An empty `links` array means the node is unlinked;\n`404` means the node doesn't exist.\n\n**It returns the parent's *id*, not its name**, so naming the destination in\nprose takes one more call: for `action`, read the automation\n(`GET /v1/environments/$ENV_ID/campaigns/\u003ccampaign_id\u003e`) and match\n`journeys_parent_entity_id` against `actions[].id` to get the step name; for\n`newsletter` or `transactional_message`, fetch that entity by id. Do that rather\nthan reporting a bare id — \"still linked to the Welcome step\" is what the user\ncan act on, \"still linked to action 3\" is not.\n\n**Only this route and its reverse (below) tell you where a node is linked.** Don't try to\nread it off the email itself — `GET /design_studio/emails/{id}` does **not**\ncarry a usable destination (its `linked_template` reads `null` even for a linked\nnode), and the list stubs' `is_linked` is only a boolean: it tells you *whether*,\nnever *where*. Guessing the destination from the automation's shape instead\nproduces hedged answers like \"most likely your Welcome step\", which is not good\nenough when you are about to tell someone what is still sending.\n\nUse it whenever you are asked whether an email can be moved (the answer depends\non whether it is linked at all — an *unlinked* email just links to the new\ndestination, no copy needed), and before reporting what happened after a\nduplicate, so you can name the original's destination instead of hedging.\n\n## Finding the email behind a template\n\nTo get the Design Studio email behind a template (`template_id` on an action,\none-time send, or transactional message):\n\n```\nGET /v1/environments/$ENV_ID/ds/journeys/\u003ctemplate_id\u003e\n→ {\"node_id\":\"0e1f77c6-…\",\"environment_id\":1}\n```\n\nRead `.node_id` (there is no `.node`). A 404 means no email is linked.\n`ds/node_actions/linkable` can't do this lookup: it has no link targets.\n\n## Re-pointing an email: duplicate, then link or relink\n\n`link` will not move a linked email, and a linked email cannot be deleted either\n(**409**). Both supported flows start by **duplicating the node**, so the old\ndestination keeps a Design Studio email:\n\n```\nPOST /v1/environments/$ENV_ID/ds/node_actions/bulk_duplicate\n```\n\n```json\n{ \"node_ids\": [\"\u003cnode_id\u003e\"] }\n```\n\nThe response is `{ \"duplicated\": [{ \"original_id\", \"new_id\" }] }`. The copy\ncarries the same content, is **unlinked**, and brings the whole language group\nwith it. Use\n`POST /v1/environments/$ENV_ID/ds/nodes/\u003cnode_id\u003e/duplicate_multi_language_group`\ninstead when the copy needs a specific name (it takes `{ \"name\", \"parent_id\" }`\nand only accepts a language-group default node); otherwise the copy is named\n`[Copy] \u003coriginal name\u003e`.\n\n`bulk_duplicate` handles **email, whatsapp, component, and folder** nodes. It\nreturns 400 for **sms** and **inbox** nodes — for those, create a new node with\nthe same content (`POST /design_studio/sms`, `POST /design_studio/inbox`) and\nuse it as the copy in either flow below.\n\nThen pick the flow by what the user means, and name your pick when you explain it — \"instead\", \"move\" and \"switch\" mean the move-shaped flow; \"also\" and \"as well\" mean the copy:\n\n- **\"Put this email on the new step as well\"** — the original keeps sending\n  where it is. Run **link** → **publish** on the copy (`new_id`) against the new\n  destination. The original is untouched.\n- **\"Move this email to the new step\"** — the original node (its name, versions,\n  comments, feedback) must be the one on the new destination. Hand its\n  *existing* link to the copy, then link the original to the new destination:\n\n  ```\n  POST /v1/environments/$ENV_ID/ds/node_actions/relink\n  ```\n\n  ```json\n  { \"from_node_id\": \"\u003coriginal node_id\u003e\", \"to_node_id\": \"\u003cnew_id\u003e\" }\n  ```\n\n  Response: `{ \"node_id\", \"template_id\", \"from_node_deleted\": false }`. This\n  moves the link row only — the old destination's template keeps its published\n  content and stays `parcel`; the copy now owns it. The original is now\n  unlinked: run **link** → **publish** on it against the new destination.\n  Preconditions: `from_node_id` must hold exactly one link and `to_node_id`\n  must be **unlinked** (400 otherwise). It is type-agnostic, so it also serves\n  sms, inbox and whatsapp nodes. When the old destination is **live** (running\n  automation, sent one-time send) it is capability-gated like other live edits\n  (428). Never send `delete_from_node: true` here — it deletes the email you\n  are moving.\n\nEither way the old destination still carries a live email — the copy in the\nsecond flow, the original in the first. Nothing leaves it empty; what happens\nto it is the user's decision.\n\n**Tell the user before you do it.** Neither flow is a plain move, and the\nconsequences are theirs to weigh:\n\n- the old destination stays linked to a Design Studio email (the original or the\n  copy) and keeps sending whatever was last published there;\n- they end up with two emails to maintain, and future edits to one do not reach\n  the other;\n- whether the old destination should also be changed, emptied, or removed is\n  their call — you cannot leave it unlinked, so say so plainly rather than\n  implying the move is complete.\n\nSo: state that `link` can't re-point the email, say which flow you'll use and\nwhy, name both emails in the result, and ask what should happen to the old\ndestination. Never present a duplicate as if the original had been moved.\n\n**This governs the report you write *after* acting, not only advice given\nbeforehand.** Once you have duplicated, linked and published, the closing\nmessage must still carry all three of:\n\n- which email now serves the new destination — name it (the copy is\n  `[Copy] \u003coriginal name\u003e` unless you chose one);\n- which email is **still linked to the old destination and still sends there**\n  (the original, or the copy after a relink) — name that destination, reading it\n  from § *Reading a node's destination* rather than inferring it, and never\n  hedge it (\"most likely your Welcome step\" is a failure: one call settles it);\n- that you could not leave that destination unlinked, so whether it keeps this\n  content, gets different content, or is removed is the user's call.\n\nReporting only what you created reads as a completed move, which is the one\nconclusion that is wrong: the user ends up with two live emails and no idea the\nfirst is still going out.\n\n## Editing live data — agent permission gate\n\nLinked content edits, **link**, and **publish** enforce the workspace's **\"Allow\nagent to edit live data\"** setting. When it is off, the operation returns **403\n`agent_editing_disabled`** for a live destination. A destination is live when it\nis an automation whose `state` is running, evaluating, or sunsetting, or whose\n`scheduled_start` is greater than zero; a non-cancelled scheduled or already-sent\none-time send; an active or previously-sent transactional message; or a running\nor scheduled anonymous broadcast. A transactional message is non-live only when\nit is inactive **and** has never sent.\n\nFor SMS, the same gate covers edits to a linked node's **content, recipient, or\nsender**. None of those fields is a safe draft-only edit while its destination\nis live.\n\nHandle the 403 the way **fly_api/SKILL.md § Agent Permission Errors** prescribes:\nframe it as the workspace admin's chosen policy, link to [AI \u0026 MCP\nsettings](/settings/ai), note only an admin can flip it, and don't retry until\nit's on. The API response itself does not include a settings URL. Even when the\nflag is on, read the destination state and get the user's approval through\n`request_capability` before publishing to a live destination — see **SKILL.md §\nLive Automation Safety**. The API gate is a backstop, not a substitute for that\npre-flight — when you explain the gate, say you would still check the destination's state and ask\nthe user before each live publish. Do not tell the user the existing linked node can still be edited\nwhile the setting is off and its destination is live. Offer a separate unlinked\nnode for further drafting, or make the destination non-live where supported.\n\n## Unpublished changes\n\n```\nGET /v1/environments/$ENV_ID/design_studio/emails/\u003cnode_id\u003e/unpublished_changes\n```\n\nReturns `{ \"has_unpublished_changes\": true|false }` — true when the node (or any\nnode in its language group) has edits not yet pushed to the linked template.\n\n## Other channels\n\nThe same link/publish flow extends to other Design Studio channels on their\nsibling paths:\n\n- **SMS** — create / link / publish an `\u003cx-sms\u003e` node on the sibling paths\n  (`/design_studio/sms`, `/design_studio/sms/{id}/link`, `/publish`). Full\n  walkthrough, markup grammar, sender/recipient rules, and the gate on linked\n  content, recipient, or sender edits:\n  `@skills/fly-api/sms_design_studio.md`. Note this applies only to a workspace\n  on the custom-SMS channel (`sms_action`) — a legacy-Twilio workspace has no\n  SMS node at all (`@skills/fly-api/sms.md § Which SMS channel is this\n  workspace on?`). Link rejects a translation node (`cannot link a\n  translation; link the default-language sms`), and publish resolves the\n  language-group default before writing. **Unlike email, publish does not\n  promote the group** — it writes the default node's content into its one\n  linked template and stops there, so a translated SMS never reaches a\n  template. The call still answers 200; don't report the translations as\n  live.\n- **WhatsApp** — create / link / publish an\n  `\u003cx-whatsapp\u003e` node; publish submits the template to Meta for approval. See\n  **whatsapp_patterns.md**.\n- **Inbox** — link / publish on the sibling paths\n  (`/design_studio/inbox/{id}/link`, `/publish`); link accepts a transactional\n  message, a one-time send, or a campaign action. Inbox content can also be\n  authored directly on the template's `body_json` instead — see\n  `@skills/fly-api/inbox.md` — or visually in Design Studio (`inbox.md`).\n- **In-app** — create / link / publish on the sibling paths\n  (`/design_studio/in_app`, `/design_studio/in_app/{id}/link`, `/publish`)\n  only where the account has Design Studio in-app, which `in_app.md` then\n  covers. Its link writes the message, and its publish writes later edits.\n  Without it, author in-app on the template per `@skills/fly-api/in_app.md`.\n\nThe live-data gate and the result-reporting rules on this page apply to every\nchannel's link and publish above, in-app's included. The 400/409 link\nconstraints apply to every channel above except in-app, whose own are in\n`in_app.md`.\n\n## Versions\n\nSaving, listing, restoring, and deleting node versions is **node-generic** (any\nchannel) — see **nodes_advanced.md § Versions**.\n\n## Reporting results to the user\n\nDistinguish these end states so the user knows whether the change is live. Do not\nsay \"I updated the email\" without saying which:\n\n1. **Saved, not published.** You created or `PUT` the email but did not publish.\n   The automation / one-time send / message still sends the previously published\n   content (or nothing, if never published). Say so explicitly.\n2. **Published.** Use **only** when publish returned 2xx. State the change is now\n   live.\n3. **Blocked (403).** Link or publish returned the live-data 403. The draft is\n   saved; the live message is unchanged. Surface the settings link and follow the\n   permission-error guidance above.\n\nFor the create → link → publish flow, never claim the live automation reflects the\nchange unless publish returned 2xx. For Bee migration, confirmation is the publishing\nstep; report completion only after successful confirmation and the read-back above.\n","mass_replace_value.md":"# Mass-Replace a Raw Value with a Global-Styles Variable\n\nReplaces every occurrence of a hardcoded literal style value (e.g. `#fff`) across Design Studio nodes with a global-styles variable reference, creating the variable first if needed. **Read global_styles.md before planning the API calls. Dry-run and get explicit confirmation before writing anything.**\n\n## When to use\n\n- \"Replace #fff everywhere with my white variable\"\n- \"Make all my emails use the brand-primary token instead of #4A90D9\"\n- \"Adopt this variable across all nodes\"\n- Any request to replace a hardcoded color/spacing/radius/font with a global-styles token.\n\nThe read-only audits **promote_design_tokens.md** and **design_system_audit.md** surface candidates; this is the operation that acts on them.\n\n## Inputs (resolve all before starting)\n\n| Input                    | Required | Notes                                                                           |\n| ------------------------ | -------- | ------------------------------------------------------------------------------- |\n| Literal value to replace | ✅       | e.g. `#fff`, `16px`, `Helvetica, sans-serif`                                    |\n| Target variable          | ✅       | An existing variable name/id **or** \"create new\" + name + group + `$type`       |\n| Scope                    | Optional | Folder ID, node type (`emails`\\|`components`), or specific attribute/tag filter |\n| Match equivalent forms   | Optional | Default **on** for colors (e.g. `#fff` = `#ffffff` = `rgb(255,255,255)`)        |\n\nIf any required input is missing, ask the user before proceeding. Do not guess.\n\n## Procedure\n\n### Step 1 — Resolve or create the variable\n\n```bash\ncio api /v1/environments/$ENV_ID/design_studio/global_styles\n```\n\n**If pointing at an existing variable:**\n\n- Find it by name or id in the response.\n- Record its group, its JSON key (the name as it appears in the response object), its `id` from `$extensions[\"io.parcel.global-styles\"].id`, and its `$value`.\n- Normalise `$value` to confirm it matches (or is equivalent to) the literal being replaced. Warn the user if they don't match.\n\n**If creating a new variable** — *plan it now, but do not write yet.* Creation happens in Step 5, only after the user confirms, so a cancelled dry-run never leaves an orphan token in the workspace. Determine and record:\n\n1. The group (`color`, `font`, `radius`, `spacing`) and a valid `$type` (see **global_styles.md § Allowed Variable Token Types**).\n2. The token name.\n3. The normalised `$value` (the value the literal being replaced normalises to).\n\nDo not generate an `id` or `index`. The declarative PATCH endpoint owns that metadata. The full reference path is not known until the token is created and re-fetched in Step 5; show `\u003cserver-minted-id\u003e` in the dry-run.\n\n**Compute the reference path** for use in rewrites:\n\n```\nglobalStyles.\u003cpluralGroup\u003e.\u003csafeName\u003e_\u003cid\u003e\n```\n\n- `pluralGroup`: `color` → `colors`, `font` → `fonts`, `radius` → `radius` (unchanged), `spacing` → `spacing` (unchanged).\n- `safeName`: the JSON key (the variable name as it appears in the global_styles object) with non-word characters stripped and the result camelCased — underscores and existing internal capitals start a new word (`heading_color` → `headingColor`), while a plain hyphenated key just loses its hyphens (`brand-cream` → `brandcream`). See § safeName worked examples and **global_styles.md § Referencing Tokens from Markup** for the canonical rule.\n\nStore this path — every rewrite uses it verbatim.\n\n### Step 2 — Find candidate nodes\n\n**Important:** full-text search (`GET /ds/node_actions/search`, `--params '{\"search_query\": \"\u003cliteral\u003e\"}'`) indexes html content but returns line/preview matches, not structured attribute values — it won't reliably locate or let you precisely rewrite a hex color or style value in an attribute. To find exact attribute-value occurrences:\n\n- Enumerate candidate nodes (paginate `cio api .../design_studio/emails` and `.../components` — **nodes.md § List**), fetch each node's markup, and scan the markup directly for the literal. **Honour the scope from the inputs:** if a folder was given, pass `parent_folder_id=\u003cid\u003e` to the list calls; if a node type was given (`emails` or `components`), enumerate only that type. Never scan or rewrite nodes outside the requested scope.\n- Only use the search endpoint to find nodes by name if the user is looking for a named token or email.\n\nFor each candidate node:\n\n- Record its id, name, and type (`email` or `component`).\n- Fetch its full source markup:\n  - Email: `cio api .../design_studio/emails/{id} --jq '.email.content.html' -r`\n  - Component: `cio api .../design_studio/components/{id} --jq '.component.content' -r`\n\nIf **match equivalent forms** is on and the literal is a color, scan for all equivalent forms (3-digit hex, 6-digit hex, `rgb()`, named CSS equivalent) in the same pass.\n\n### Step 3 — Identify rewrite targets\n\nScan each node's markup. For every HTML attribute whose value exactly equals the literal (or an equivalent form, if matching equivalents):\n\n**Only rewrite if:**\n\n- The attribute value is the literal **in its entirety** — not part of a larger expression or string.\n- The value is not inside a Liquid expression (`{{ }}` or `{% %}`).\n- The value does not already contain a `globalStyles.*` reference.\n- The attribute role matches the scope filter (if one was given — e.g. only `background-color` attributes, not `color`).\n\n**How to rewrite:**\n\n- If the attribute has no prefix: add a `:` prefix and set the value to the reference path.\n  - `background=\"#fff\"` → `:background=\"globalStyles.colors.white_\u003cid\u003e\"`\n- If the attribute already has a `#set:` prefix: preserve it, replace only the value portion.\n- If the attribute already has a `:` prefix: replace only the value portion.\n- Inline `style=\"...\"` attributes: **do not rewrite them.** A `globalStyles.*` expression inside a plain CSS string is not evaluated and becomes invalid CSS. Record each inline-style occurrence as skipped. If the same style is available as a component prop, offer a separate, explicitly reviewed rewrite to that prop; do not do that conversion as part of this literal replacement.\n\nRecord for each match: node id, attribute name, original value, replacement value, line context (a short snippet for the diff).\n\n### Step 4 — Dry-run output (MANDATORY — stop here for confirmation)\n\nPresent a clear diff for every node that would be changed:\n\n```\nNode: \"Example Visual Editor Email\" (email)\n  background=\"#fff\"  →  :background=\"globalStyles.colors.white_abc123\"\n  border-color=\"#fff\"  →  :border-color=\"globalStyles.colors.white_abc123\"\n\nNode: \"nc-base\" (component)\n  color=\"#fff\"  →  :color=\"globalStyles.colors.white_abc123\"\n\nTotal: 3 replacements across 2 nodes.\nVariable: globalStyles.colors.white_abc123  ($value: #ffffff)\n```\n\nIf a new variable is being created, show that it will be created with `$value: \u003cnormalised\u003e` before listing the node changes. Use `globalStyles.\u003cgroup\u003e.\u003csafeName\u003e_\u003cserver-minted-id\u003e` in the proposed diffs and state that the final path will be resolved after creation. List skipped inline-style occurrences separately; they do not count toward the replacement total.\n\n**Then stop and ask:**\n\n\u003e \"Ready to apply these N replacements across M nodes? Reply 'yes' to proceed or 'no' to cancel.\"\n\nDo **not** write anything until the user confirms.\n\n### Step 5 — Apply\n\nOn explicit confirmation:\n\n**First, if creating a new variable, write it now** with the declarative global-styles PATCH (**global_styles.md § Declarative Update (PATCH)**):\n\n1. Create only the planned token (returns **204**):\n   ```bash\n   cio api /v1/environments/$ENV_ID/design_studio/global_styles -X PATCH \\\n     --json '{create:[{group:\"color\",name:\"white\",type:\"color\",value:\"#ffffff\"}]}'\n   ```\n   Substitute the planned group, name, type, and value; never send `$extensions`, `id`, or `index`.\n2. Re-GET `/design_studio/global_styles`, find the new token by group and exact name, read the server-minted `id`, and compute its final reference path — before rewriting any node.\n\n**Then rewrite the nodes.** For each node with changes:\n\n1. Fetch fresh markup directly from the API (do not reuse the copy from Step 2 — it may be stale).\n2. Apply all substitutions for that node.\n3. Write the updated markup back. Pass the markup as an `--arg` value and let `cio`'s `--json` build the body (multi-line markup is fine — see **nodes.md § Shell Safety**); for long markup, write it to a file and bind with `--arg h=@file`:\n   - Email:\n     ```bash\n     cio api /v1/environments/$ENV_ID/design_studio/emails/{id} -X PUT \\\n       --arg h=@/tmp/node_markup.html --json '{content:{html:$h}}'\n     ```\n   - Component:\n     ```bash\n     cio api /v1/environments/$ENV_ID/design_studio/components/{id} -X PUT \\\n       --arg h=@/tmp/node_markup.html --json '{content:$h}'\n     ```\n\nEditing a node does **not** publish — a linked Journeys destination keeps the previously published content until you publish again (**journeys.md**).\n\n### Step 6 — Report\n\nAfter all writes complete, output:\n\n```\n✅ Done. Variable used: globalStyles.colors.white_abc123\n\nModified nodes (N):\n- \"Example Visual Editor Email\" (email) — 2 replacements\n- \"nc-base\" (component) — 1 replacement\n```\n\nIf any node PUT failed, list it with the error and note that it was skipped.\n\n## Guardrails\n\n- **Never write node content without explicit user confirmation.** The dry-run in Step 4 is mandatory on every invocation.\n- **Full-value matches only.** Do not rewrite attributes where the literal appears as part of a larger expression. If in doubt, skip and note it in the dry-run output.\n- **Create variables with declarative PATCH only.** Never reconstruct or PUT the full global-styles object for this operation. The server mints required `$extensions` metadata.\n- **Re-GET after creating a variable** to read the server-minted `id` before computing the reference path.\n- **Liquid is off-limits.** Never modify values inside `{{ }}` or `{% %}` tags, even if they contain the literal string.\n- **Inline and composite style strings are off-limits.** For `style=\"color:#fff; padding:16px\"`, report the `#fff` occurrence as skipped. Never insert a `globalStyles.*` expression into CSS text.\n- If the scope filter names a specific attribute (e.g. \"only replace in background-color\"), honour it strictly — do not replace the same value in other attributes.\n- If no candidate nodes are found after scanning, say so and stop. Do not proceed to dry-run with an empty list.\n\n## safeName derivation — worked examples\n\n| JSON key (variable name) | safeName       |\n| ------------------------ | -------------- |\n| `white`                  | `white`        |\n| `brand-cream`            | `brandcream`   |\n| `heading_color`          | `headingColor` |\n| `neutral-100`            | `neutral100`   |\n\nRule: strip characters that are not `[a-zA-Z0-9_]` (so hyphens are removed), then camelCase across word boundaries — underscores and existing internal capitals start a new word (`heading_color` → `headingColor`), while a plain hyphenated key just loses its hyphens (`brand-cream` → `brandcream`). See **global_styles.md § Referencing Tokens from Markup** for the canonical rule.\n\n## Relevant API endpoints\n\n```\nGET  /v1/environments/$ENV_ID/design_studio/global_styles\nPATCH /v1/environments/$ENV_ID/design_studio/global_styles\n      body: { \"create\": [{ \"group\": \"color\", \"name\": \"white\", \"type\": \"color\", \"value\": \"#ffffff\" }] }   →  204\n\nGET  /v1/environments/$ENV_ID/design_studio/emails        (paginated; .emails[])\nGET  /v1/environments/$ENV_ID/design_studio/components     (paginated; .components[])\n\nGET  /v1/environments/$ENV_ID/design_studio/emails/{id}        → .email.content.html\nPUT  /v1/environments/$ENV_ID/design_studio/emails/{id}        body: { \"content\": { \"html\": \"…\" } }\n\nGET  /v1/environments/$ENV_ID/design_studio/components/{id}    → .component.content\nPUT  /v1/environments/$ENV_ID/design_studio/components/{id}    body: { \"content\": \"…\" }\n```\n\nAll calls go through `cio api`, which handles authentication and builds the JSON body via its bundled encoder (`--arg`/`--json`) — no manual escaping, no separate HTTP client. See **nodes.md § Shell Safety for Design Studio Payloads** for the `--arg`/`--json`/`--jq` patterns.\n","nodes.md":"# Design Studio API: Nodes\n\nQuery values go in `--params` as a JSON object — `cio api` rejects a `?k=v` path (`path must not contain query or fragment characters`). A path written here as `?a=b` names the parameters, not the command line. Path placeholders left literal in a path (`{environment_id}`, `{id}`) are filled from the same JSON object. Pass every value **raw**: the CLI percent-encodes it for you, so pre-encoding a space as `%20` sends the literal `%2520`. Every value must be a **scalar** — an array is rejected (`param key \"…\" has non-scalar value`) — and each key is set once, so a parameter the route wants repeated (`allowed_types`) carries one value per call; sending it twice is refused outright (`param key \"…\" appears more than once`). Don't send `-X POST` to a GET route — you get a 405.\n\n`--params` also accepts a `k=v\u0026k2=v2` query string, but there you have to percent-encode the values yourself (`%20` for a space, `%2B` for a plus). Prefer the JSON object. A **bracketed** key (`channel[]`, `parents[]`) is fine through `cio api` — it carries one value like any other key — but the MCP `cio_*` tools reject it (`param key \"channel[]\" contains invalid characters`), so a route that only filters through a bracketed parameter is unreachable from those.\n\nEverything is `cio api`, under `/v1/environments/{environment_id}/...`. Address by **what you're working with**:\n\n- **`/design_studio/...` — typed content resources.** Create / get / list / update / delete each type with its own typed body: `emails`, `folders`, `components`, and the other channels — `sms`, `whatsapp`, `in_app`, `inbox` — which carry the same full CRUD. Plus per-message **link** / **publish** for `emails`, `sms`, `whatsapp` and `inbox` (`in_app` links and publishes only where the account has Design Studio in-app, covered by **in_app.md**; without it, author in-app on its Journeys destination instead), email **translations** (see **journeys.md**), and **render** (see **nodes_advanced.md**). Bodies are **snake_case** with a structured `content` object.\n- **`/ds/...` — node-level operations** addressed by node id, type-agnostic: `node_actions/search`, `node_actions/linkable`, `node_actions/get_started`, `node_actions/relink`, bulk move/delete, `component_definitions`, `components/references` (**POST**, body `component_ids`), feedback comments, `validate_component_name`, `recently_edited`, node `versions` (**nodes_advanced.md**), `global_styles` (**global_styles.md**), and a generic delete-by-id.\n\n\u003e **Author content through the typed `/design_studio/{type}` endpoints below.** A generic `POST /ds/nodes` does exist (`type` in the body), but it makes you supply the node `id` yourself and it must be a **v4 UUID** — anything else is rejected, and a non-v4 id that slipped in elsewhere produces a node that cannot be published. The typed endpoints generate the id for you and take the structured `content` shape each type expects, so prefer them for authoring. `cio api` sends whatever path you give it, so a wrong one fails at the server rather than in the CLI; confirm any endpoint with `cio schema` first.\n\n\u003e **Addressing:** Design Studio node IDs are server-minted v4 UUIDs. Use the node's `id` from a list or `ds/node_actions/linkable` response for `/design_studio/emails/{id}` and `/ds/nodes/{node_id}`. A numeric ID is a legacy/Journeys template ID for `/templates/{template_id}` and is not a Design Studio email ID; using it on `/design_studio/emails/{id}` returns `email not found`. To turn a template ID into its email's UUID, call `GET /ds/journeys/{template_id}` and read `node_id` (`journeys.md` § Finding the email behind a template). `short_id` is not a substitute for the UUID.\n\n\u003e **Route names:** use `/design_studio/emails` and `/ds/recently_edited`. The guessed `/ds/emails`, hyphenated `/design-studio`, and `/ds/node_actions/recently_edited` routes do not exist.\n\n\u003e ui_api request/response bodies use **snake_case** (`parent_folder_id`, `is_template`, `preheader_text`). The created resource is wrapped under its type key, e.g. `{ \"email\": { \"id\": … } }`.\n\n\u003e **`cio api --json` rejects *literal, unescaped* control characters in string values — but *escaped* ones (`\\n`, `\\t`, `\\r`, `\\uXXXX`) are valid JSON and pass through.** A raw newline in a string fails as `JSON payload is not valid JSON: invalid character '\\n' in string literal` (other literal control chars as `JSON payload contains an unescaped control character at byte position N (U+XXXX)`). So multi-line markup is fine **as long as a real JSON encoder builds the payload** — the `--arg` + `--json` pattern in § Shell Safety lets `cio`'s gojq escape newlines for you. You do **not** need to flatten markup to one line; only **hand-spliced** JSON (e.g. `'{\"content\":\"'\"$MARKUP\"'\"}'`) embeds a raw newline and fails.\n\n### Shell Safety for Design Studio Payloads\n\nMarkup has Liquid tags, nested quotes, and unicode. Pass it as a `--arg` value and let `cio`'s `--json` build the body — the bundled gojq encodes it (no external `jq`, no shell escaping). Multi-line markup is fine; put email markup in `content.html`, other channels' in `content`. Read a field back with `--jq` (add `-r` for a raw string like an id), not by echoing the response through another parser:\n\n```bash\nMARKUP='\u003cx-base font-family=\"Arial, sans-serif\" :font-size=\"16\" :line-height=\"1.6\"\u003e\u003cx-section padding=\"20px\"\u003e\u003cx-heading-1\u003eHi {{customer.first_name | default: \"there\"}}!\u003c/x-heading-1\u003e\u003cx-cta href=\"https://example.com\" background=\"#007bff\" color=\"#ffffff\" align=\"center\"\u003eGet Started\u003c/x-cta\u003e\u003c/x-section\u003e\u003c/x-base\u003e'\nEMAIL_ID=$(cio api /v1/environments/$ENV_ID/design_studio/emails -X POST \\\n  --arg h=\"$MARKUP\" --json '{name:\"Welcome Email\",content:{subject:\"Welcome!\",html:$h}}' \\\n  --jq '.email.id' -r)\n```\n\nEmail markup runs long. For a large body, write it to a file and bind it with `--arg h=@file` (a `@`-prefixed value reads from a file, like `--json @file`) instead of a shell variable — same `$h` reference, no quoting limits:\n\n```bash\nEMAIL_ID=$(cio api /v1/environments/$ENV_ID/design_studio/emails -X POST \\\n  --arg h=@/tmp/email_markup.html --json '{name:\"Welcome Email\",content:{subject:\"Welcome!\",html:$h}}' \\\n  --jq '.email.id' -r)\n```\n\n---\n\n## Create (typed, per content type)\n\nThe server assigns the node id — you do **not** generate one. The response wraps the created resource under its type key.\n\n**Every create takes `parent_folder_id`, and omitting it files the node at the root.** Decide the folder first — **SKILL.md step 1g (Where it lives)** walks the ladder: a folder the user named, the node open in the editor, the campaign's name, no folders at all, then a match on the email's type, then ask. Resolve names and list candidates with **§ Folders** below.\n\n\u003e **The request body is flat — the type-key wrapper is response-only.** `POST`/`PUT` bodies carry `name`, `content`, `envelope`, … at the top level. Copying the response shape back into a request (`{\"email\": {…}}`) never works, but *how* it fails depends on the caller: a strict one (the `cio_*` MCP tools, which send `X-Validate: strict`) gets `json: unknown field \"email\"`, while a lenient one (`cio api`) has the wrapper silently ignored — every real field goes missing and the 400 complains about `name` instead. Neither message points at the wrapper, so check the body shape first. Same for every type (`{\"component\": …}`, `{\"folder\": …}`).\n\n### Email — `POST /design_studio/emails`\n\n| Field | Type | Required | Description |\n|---|---|---|---|\n| `name` | string | yes | Display name (unique within parent folder) |\n| `parent_folder_id` | UUID \\| null | no | Parent folder, decided by **SKILL.md step 1g** before the create; `null`/omit = root, right only when that step ends there |\n| `is_template` | bool | no | Mark as a saved template |\n| `content` | object | no | `{ subject, preheader_text, html, amp, text }` — `html` is component markup |\n| `envelope` | object | no | `{ from_id, reply_to_id, recipient, bcc, fake_bcc, cc, headers: [{name, value}] }` (snake_case) |\n| `transformers` | object | no | `url_parameters`, `css_inliner`, `accessibility`, … |\n\n**`envelope.from_id` and `reply_to_id` are integer sender-identity ids — unquoted JSON numbers, never strings and never email addresses.** `{\"from_id\": 8}`, not `{\"from_id\": \"8\"}` or `{\"from_id\": \"no-reply@example.com\"}`.\n\n**The trap: `GET /identities` returns each `id` as a JSON *string*** (`{\"identities\":[{\"id\":\"8\", …}]}`) — it is serialized `,string`. Pasting that value straight into the envelope sends `\"8\"` and fails. **Convert it to a number.** With `cio api`, `--jq -r` unquotes it:\n\n```bash\nFROM_ID=$(cio api /v1/environments/$ENV_ID/identities --jq '.identities[0].id' -r)\ncio api /v1/environments/$ENV_ID/design_studio/emails -X POST \\\n  --arg h=@/tmp/email_markup.html \\\n  --json '{name:\"Welcome Email\",content:{subject:\"Welcome!\",html:$h},envelope:{from_id:'\"$FROM_ID\"'}}'\n```\n\nNote `from_id:'\"$FROM_ID\"'` interpolates a bare number. `from_id:\"'\"$FROM_ID\"'\"` would quote it again and fail.\n\n**Don't reach for `in_use=true` here.** That filter means \"already referenced by a campaign, newsletter, or transactional message\" — not \"usable\". A verified sender nobody has wired into a campaign yet is filtered out, so on a fresh workspace the list comes back empty and the create that follows posts an envelope with no sender. List unfiltered (the default `type` is `email`) and pick from what comes back.\n\nA quoted id is a **type** error. Servers now say `envelope.from_id must be an integer, got a string`; older ones reported the same failure as `envelope contains an unknown field`, which reads as if `from_id` were misnamed. It is not — re-send the same field as a number. **Never respond to it by stripping `cc`, `bcc`, or `headers` hunting for the \"unknown\" field.**\n\nSetting the sender on the linked Journeys template instead (`from_identity_id`, also an integer) is a valid alternative — see **journeys.md**.\n\nThere is **no `template_id`** — to start from a template, GET the template's content and pass it into a new create (see **§ Start from a Template**).\n\n### Folder — `POST /design_studio/folders`\n\n`{ \"name\": \"...\", \"parent_folder_id\": \u003cuuid|null\u003e }`\n\n### Component — `POST /design_studio/components`\n\n`{ \"name\": \"...\", \"tag\": \"...\", \"parent_folder_id\": \u003cuuid|null\u003e, \"content\": \"\u003ccomponent file\u003e\" }` — `tag` is the custom-element name (e.g. `company-header`), unique in the workspace.\n\n**`content` must be a complete component source file** — a `\u003cscript\u003e` block (`export const config` with `label` and `presets`, plus `props`/`slots` as needed) and a `\u003ctemplate\u003e` block with exactly one `#root` element. **Never raw `\u003cx-*\u003e` markup by itself.** The API does not validate structure, so raw markup saves fine and even renders in Code View — but the component shows up **blank in the visual editor** (\"No base component found\") and never appears in the insert menu. Required structure, minimal valid file, and post-create verification: **custom_components.md**.\n\n**Gotchas:**\n- `name` must be unique among siblings in the same parent folder.\n- The create response carries the **source markup**, not rendered output. Verify a render before relying on it: email via **§ Render** (**nodes_advanced.md**), or by publishing (**journeys.md**).\n- **Never re-create on a parse failure.** If a POST returned 2xx the resource exists — even if you failed to read the id from the response. Don't POST again (that duplicates). List the type (below) to find it.\n\n---\n\n## Get\n\n| Type | Path | Returns |\n|---|---|---|\n| Email | `GET /design_studio/emails/{email_uuid}` | `{ email: { …, content: {subject, preheader_text, html, amp, text}, envelope, transformers } }` — source markup in `content.html`; `{email_uuid}` is the node UUID, not a numeric Journeys template ID |\n| Folder | `GET /design_studio/folders/{id}` | `{ folder: { … } }` |\n| Component | `GET /design_studio/components/{id}` | `{ component: { …, tag, content } }` — source markup in `content` |\n\n- **Compiled email HTML without publishing:** `GET /design_studio/emails/{id}/render` → `.html` (Liquid tags left intact; email-only). See **nodes_advanced.md § Render**.\n- **A node by id when you don't know its type** (e.g. a search / linkable result): `GET /ds/nodes/{node_id}`.\n\n---\n\n## List\n\n`GET /design_studio/{emails|folders|components}` → `{ \u003ctype\u003e: [stubs], folders: [...], meta: { pagination, filters } }`. Stubs carry `id`, `name`, flags (`is_template` / `is_linked` / …) and `parent_folder_id` — **no content**. Query params: `parent_folder_id`, `direct_descendants_only`, `sort_by` (created|updated|name), `sort_order`, `page`, `limit`, and `created_*` / `updated_*` date bounds.\n\nThe other channels have the same typed list at their own path — `sms`, `whatsapp`, `in_app`, `inbox`.\n\n**Every *content* typed list — `emails`, `sms`, `whatsapp`, `in_app`, `inbox` — hides language variants**, keeping only each group's default node (so does `linkable`). `components` and `folders` never carry a language, and their list queries omit the clause, so they are unaffected. For an exhaustive pass over a content type, use `GET /ds/nodes` — unpaginated, unfiltered by language, all types in one response — and narrow on `type` yourself.\n\nTo locate nodes across folders, use full-text **search** or **linkable** on `/ds` (below), then GET the specific resource.\n\n---\n\n## Folders\n\n`GET /ds/folders` → every folder in the workspace, unpaginated, sorted by `breadcrumb` — the path joined with ` / ` and no leading slash: a root folder is `Newsletters`, a nested one `Lifecycle / Onboarding`. One call answers \"which folders exist\", \"what is this folder's path\" and \"is there a folder called X\" — use it to resolve a folder the user named and to pick the candidates when you have to ask (**SKILL.md step 1g**):\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/folders --jq '.[] | \"\\(.breadcrumb)  id=\\(.id)\"'\n```\n\n**The response is a bare JSON array — `[{ id, name, parent_id, breadcrumb }, …]` — with no `folders` wrapper.** `.folders[]` matches nothing, and behind a `2\u003e/dev/null || echo none` it looks exactly like a workspace with no folders; two runs of the same prompt then file the email at the root or in a folder depending on which shape the jq guessed. Project with `.[]`. An empty result is the only evidence of \"no folders\" once the projection is right; when in doubt, `cio api /v1/environments/$ENV_ID/ds/folders` with no `--jq` and read the raw response. The typed `GET /design_studio/folders` is the paginated list, wrapped as `{ folders: [...] }`, with no paths; `GET /design_studio/folders/{id}` fetches one.\n\nTo move a node after the fact, `PUT` its typed resource with `parent_folder_id` (§ Update — a UUID moves it into that folder, `null` to the root). Several at once: `POST /ds/node_actions/bulk_move` (**nodes_advanced.md**).\n\n---\n\n## Update (typed, partial)\n\nSend only the fields you are changing.\n\n| Type | Path | Body |\n|---|---|---|\n| Email | `PUT /design_studio/emails/{email_uuid}` | `{ name?, is_template?, parent_folder_id?, content?, envelope?, transformers? }` — use the node UUID; this is the content-edit route for Design Studio emails |\n| Component | `PUT /design_studio/components/{id}` | `{ name?, tag?, parent_folder_id?, content? }` |\n| Folder | `PUT /design_studio/folders/{id}` | `{ name?, parent_folder_id? }` |\n\n`parent_folder_id` is triple-state: omit = no change, `null` = move to root, a UUID = move into that folder. Email `content.html` is component markup; a component's `content` is the complete source file (`\u003cscript\u003e` + `\u003ctemplate\u003e` — same rule as create, see **custom_components.md**), so when updating a component always PUT the whole file, never just the inner markup. Editing does **not** publish — a linked Journeys destination keeps the previously published content until you publish again (**journeys.md**).\n\n```bash\njq -e . /tmp/update.json \u003e/dev/null || { echo \"update.json missing or invalid; refusing to PUT\"; exit 1; }\ncio api /v1/environments/$ENV_ID/design_studio/emails/$EMAIL_UUID \\\n  --params \"{\\\"environment_id\\\":\\\"$ENV_ID\\\",\\\"id\\\":\\\"$EMAIL_UUID\\\"}\" -X PUT --json @/tmp/update.json\n```\n\nFor an email whose template response has `editor: \"parcel\"`, update the Design Studio source with `PUT /design_studio/emails/{email_uuid}` (for example, `{ \"content\": { \"html\": \"…\" } }`) and publish when the user wants the linked destination changed. Do **not** send body content to `PUT /templates/{template_id}`: that route addresses the legacy numeric template, not the Design Studio source. Customer service-account callers that send Design Studio body content there receive 422 (apart from limited envelope fields); other callers can silently clobber the compiled body without changing the source. Get `email_uuid` as the `node_id` from `GET /ds/journeys/{template_id}`. Read the DS email first when the user asks to edit it, and preserve fields they did not ask to change.\n\n---\n\n## Delete\n\n- **Typed:** `DELETE /design_studio/{emails|folders|components}/{id}`. Returns **409** if the node is linked to Journeys. There is no plain unlink to clear that: either transfer the link to another unlinked node first (`POST /ds/node_actions/relink`, e.g. onto a duplicate — `journeys.md` § Re-pointing an email) and then delete, or remove the destination itself. Both are the user's decision.\n- **Generic by id** (any type, when you don't know it; folders delete recursively): `DELETE /ds/nodes/{node_id}`.\n\n---\n\n## Start from a Template\n\nTemplates are email nodes marked `is_template: true`. Typed email create has no `template_id`, so copy in two steps:\n\n1. Find the template with one JSON params object: `cio api /v1/environments/$ENV_ID/ds/node_actions/linkable --params '{\"type\":\"template\",\"offset\":0,\"limit\":50}'` (paginated; add `\"search_term\":\"…\"` to the same object — note it is `search_term` here, not the `search_query` that § Search uses), or list typed email stubs with `cio api /v1/environments/$ENV_ID/design_studio/emails --params '{\"is_template\":\"true\"}'`.\n2. Fetch its content: `GET /design_studio/emails/{template_uuid}` → use its `content` (and `envelope` / `transformers`) as the body of a new `POST /design_studio/emails`. `{template_uuid}` is the linkable result's UUID `id`, not a numeric legacy template ID.\n\nThis copies the template's markup, styles, and custom components into the new email.\n\nFor link, publish, and translations see **journeys.md**. For versions, render, feedback, validation, and recently-edited see **nodes_advanced.md**.\n\n---\n\n## Components \u0026 Discovery (`/ds`)\n\nThese are node-level / cross-node queries — not the typed component resource (that's `/design_studio/components`, above).\n\n| Method | Path | Description |\n|---|---|---|\n| GET | `/ds/component_definitions` | Every custom component with parsed props, slots, and presets. |\n| POST | `/ds/components/references` | Nodes using a component. Body `{ \"component_ids\": [\"\u003cuuid\u003e\", ...] }` — max 100, each a valid UUID. Returns `{ \"references\": { \"\u003ccomponent_id\u003e\": [{ id, type }] } }` — a map keyed by component id, with an entry for every id you sent and `[]` for ones nothing references. |\n| GET | `/ds/node_actions/search` | Full-text search across all emails/components (content, text, AMP, names). Query params: `search_query` (the text — not `query`), optional `parent_id` to limit to a folder, optional `allowed_types` (EMAIL, FOLDER, COMPONENT, WHATSAPP, IN_APP, INBOX_MESSAGE — **not** SMS), one type per call. See § Search below for the call. |\n\n### Custom Components\n\nCustom components are reusable blocks (headers, footers, logos, social links, etc.) created by workspace users. They use kebab-case tag names that do **NOT** start with `x-` (e.g., `\u003ccompany-header\u003e`, `\u003cemail-footer\u003e`, `\u003csocial-links\u003e`). Custom components accept props defined by their creator.\n\n### Inspecting Custom Component Source\n\n`GET /ds/component_definitions` returns parsed props, slots, and presets. To see the raw markup, fetch the component resource:\n\n`GET /design_studio/components/{id}` → the source markup is in `content`.\n\n### Search\n\nThe parameter is `search_query`, and it goes in `--params` as JSON — a `?search_query=` path is rejected by `cio api`. Pass the term raw; the CLI encodes it:\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/node_actions/search --params '{\"search_query\": \"company-footer\"}'\n```\n\nThis is a GET route — `-X POST` returns 405.\n\n`allowed_types` takes **one** type per call — `--params '{\"search_query\": \"company-footer\", \"allowed_types\": \"COMPONENT\"}'`. An array is rejected (`has non-scalar value`), and `cio api` sets each key once — repeating it is refused with `param key \"allowed_types\" appears more than once`, so the repeated-key form the route expects for several types is not reachable. To cover more than one type, omit the filter (all six types are searched) or call once per type.\n\n**Don't reach for a comma-separated list.** Valid values are `EMAIL`, `FOLDER`, `COMPONENT`, `WHATSAPP`, `IN_APP`, `INBOX_MESSAGE`; anything else is silently dropped, and when *every* supplied value is unrecognized the route returns `200` with an empty result set. So `\"allowed_types\": \"EMAIL,COMPONENT\"` matches nothing and looks exactly like \"this workspace has no such email.\" When a search comes back empty, re-run it with no `allowed_types` before reporting nothing found.\n\n**`SMS` is not a value here.** An SMS node is a real Design Studio node type, but this search drops them from its results: `\"allowed_types\": \"SMS\"` is dropped like any unrecognized value and returns `200` with nothing, and an *unfiltered* search never surfaces an SMS node either. The exclusion happens *after* the query — SMS rows still match and still count against the 250-node result cap, so in an SMS-heavy workspace they can crowd out email and component hits and inflate `limited_set`. Don't read either result as \"this workspace has no SMS node\" — this route simply cannot see them. To reach an SMS node, list it with `GET /design_studio/sms` (typed, paginated — `limit` defaults to 1000), fetch it by id with `GET /ds/nodes/{node_id}`, or take `node_actions/linkable` (`--params '{\"node_type\": \"SMS\", \"offset\": 0, \"limit\": 50}'`).\n\n**None of those three is a complete inventory.** The two list routes keep only a language group's *default* node and hide its variants — the typed list and `linkable` by different clauses that come to the same thing — and the by-id fetch reads exactly the node you name, variant or not. When you have to be exhaustive over SMS (an audit, a \"did we change it everywhere\"), use `GET /ds/nodes`: no language filter, no pagination, every node type in one response — then filter to `type == \"SMS\"` yourself.\n\n#### Search Response\n\n```json\n{\n  \"number_of_files\": 3, \"number_of_matches\": 7, \"limited_set\": false,\n  \"results\": [{ \"path\": \"/Onboarding/Welcome\", \"part\": \"html\", \"file_id\": \"\u003cnode-id\u003e\",\n    \"matches\": [{ \"line\": 5, \"character\": 12, \"preview\": \"...before \u003chighlight\u003ewelcome\u003c/highlight\u003e after...\" }] }]\n}\n```\n\n`part`: `name`, `html`, `content`, `text`, `amp`. `limited_set` true when truncated (250+ files or 10k+ matches).\n\nWhen `limited_set` is false, the results are the complete answer to \"which nodes contain X\" — report them as they are. Fetch a hit only when the task needs its content, not to re-check the search.\n\nFor component name validation, see **nodes_advanced.md § Validation**.\n","nodes_advanced.md":"# Design Studio API: Advanced Node Operations\n\nFor core node CRUD (create, get, update, delete) and custom components, see **nodes.md**.\n\nAll endpoints are `cio api` under `/v1/environments/{environment_id}/ds` (or `/design_studio` where noted). Query values go in `--params` as a JSON object — `cio api` rejects a `?k=v` path. A path written here as `?a=b` names the parameters, not the command line.\n\nFull-text search lives in **nodes.md § Search** (`node_actions/search`, parameter `search_query`).\n\n## Bulk Operations\n\n| Method | Path | Body | Notes |\n|---|---|---|---|\n| POST | `/ds/node_actions/bulk_delete` | `{ \"node_ids\": [\"...\"] }` | Delete multiple nodes. Each node needs its own type's delete permission (folders: assets delete); 403 if any is missing, and nothing is deleted. 409 if any linked to Journeys. |\n| POST | `/ds/node_actions/bulk_move` | `{ \"node_ids\": [...], \"parent_id\": \"...\" }` | Move multiple nodes into a folder. |\n\nTo publish multiple emails, publish each email node individually via `POST /v1/environments/{environment_id}/design_studio/emails/{id}/publish` (see **journeys.md**).\n\n---\n\n## Recently Edited\n\nUse the node-level route (underscore, not `node_actions/recently_edited` or a hyphenated path):\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/recently_edited --params '{\"count\":10}'\n```\n\n`count` is optional and ranges from 1 to 10; the default is 10. The response contains the current user's recently created or edited Design Studio nodes, each with its UUID `id`.\n\n---\n\n## Validation\n\n| Method | Path | Params | Returns |\n|---|---|---|---|\n| GET | `/ds/nodes/{node_id}/validate_component_name` | `name` | `null` on success, 400 if invalid/duplicate. |\n| GET | `/ds/nodes/{node_id}/check_unpublished_changes` | — | Whether the node has edits not yet published. |\n\n---\n\n## Render (inspect compiled output)\n\n`GET /v1/environments/{environment_id}/design_studio/emails/{id}/render`\n\nRenders an email's content and returns it **without publishing** — the same\ncompiled markup the publish path would write to the linked template, read-only,\nso you can catch an empty or broken render before linking/publishing. Returns\n`{ node_id, node_type, language?, html, amp?, text? }`.\n\n- **Does not evaluate Liquid.** The result is the template markup with Liquid\n  tags (`{{customer.first_name}}`) intact, exactly as publish stores them — not a\n  recipient-facing email. For a Liquid-rendered email with sample data, rendered\n  subject included, use the free `POST /v1/environments/{environment_id}/ds/inbox_previews/renders`\n  with `sample_data`, polled with `include_content=true` (**inbox_previews.md**). It\n  takes the node UUID directly and charges nothing.\n- **Render is per message type, and only the Carta→HTML channels have one.**\n  This route is email-only — only email compiles Carta markup to HTML, so it's\n  the only type with a render endpoint. Needs the email **view** grant.\n\n---\n\n## Linkable Nodes\n\n`GET /v1/environments/{environment_id}/ds/node_actions/linkable` — paginated list of nodes that can be linked to Journeys.\n\nUse one JSON `--params` object; do not append a query string or repeat `--params` flags:\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/node_actions/linkable \\\n  --params '{\"offset\":0,\"limit\":20,\"type\":\"all\"}'\n```\n\n`offset` and `limit` are required (`limit` is 1–250). `type` is optional and defaults to `all`; when supplied it must be exactly one of `template`, `linked`, `unlinked`, or `all` — **not** `email`. Add optional `search_term` to that same JSON object to filter by name — note it is `search_term` here, not the `search_query` that node search uses. Optional `node_type` selects the channel: `EMAIL`, `WHATSAPP`, or `SMS`, defaulting to `EMAIL`. Each result's `id` is the Design Studio node UUID used by `/design_studio/emails/{id}`.\n\nThe response is `{\"nodes\":[…],\"total\":N}`, so iterate `.nodes[]`, not the top level. Each node carries `is_linked` (a boolean) but no action, template, or destination fields. Use this route to browse or search emails by name, not to find the email behind a known action, one-time send, or template: for that, call `GET /ds/journeys/{template_id}` (`journeys.md` § Finding the email behind a template).\n\n---\n\n## Get Started\n\n`GET /v1/environments/{environment_id}/ds/node_actions/get_started/{type}` — create an onboarding example node for the given type (e.g. `email`).\n\n---\n\n## Feedback Comments\n\nPart of the \"Request Feedback\" workflow. Comment types: `COMMENT`, `ANNOTATION`, `APPROVAL`, `REQUEST_CHANGES`.\n\nBase: `/v1/environments/{environment_id}/ds/nodes/{node_id}/feedback/comments`\n\n| Method | Path | Body | Description |\n|---|---|---|---|\n| GET | `.../comments` | — | List top-level comments with user info (name, gravatar). |\n| POST | `.../comments` | `{ \"id\": \"\u003cuuid\u003e\", \"version_id\": \"\u003cuuid\u003e\", \"type\": \"COMMENT\", \"content\"?: {}, \"meta\"?: {}, \"reply_to\"?: \"\u003cuuid\u003e\" }` | Create comment. Client-generated `id`. 204 on success. |\n| DELETE | `.../comments/{comment_id}` | — | Delete (creator only, 403 otherwise). 204 on success. |\n| GET | `.../comments/{comment_id}/replies` | — | Get replies to a comment. |\n| POST | `.../comments/{comment_id}/resolve` | — | Resolve top-level comment. No body. 204 on success. Can't resolve replies. |\n\nComment response shape: `{ id, node_id, version_id, reply_to, status, type, content, meta, created_by, created_at, resolved_at, resolved_by, reply_count, user: { id, name, image } }`.\n\n### Request Feedback\n\n`PUT /v1/environments/{environment_id}/ds/nodes/{node_id}/request_feedback` — body `{ \"name\"?: \"...\", \"description\"?: \"...\" }`. EMAIL only.\n\n`GET /v1/environments/{environment_id}/ds/nodes/{node_id}/feedback/versions` — feedback versions for the node.\n\n---\n\n## Language Translations\n\nTranslations are managed on the node-centric email surface, with `cio api`.\n\n| Method | Path | Body | Description |\n|---|---|---|---|\n| POST | `/design_studio/emails/{id}/languages` | `{ \"language\": \"es\", \"content\": {…}, \"envelope\": {…} }` | Create **one** translation variant. `language` is required; `content`/`envelope`/`transformers` are optional. Call once per language. Max 50 per group. |\n| GET | `/design_studio/emails/{id}/languages` | — | List all translations for the email's language group. |\n| GET | `/design_studio/emails/{id}/languages/{language}` | — | Get a single translation. |\n| PUT | `/design_studio/emails/{id}/languages/{language}` | content object | Update a translation. |\n| DELETE | `/design_studio/emails/{id}/languages/{language}` | — | Remove a translation. |\n\nEach POST creates a single translation; call it once per target language. The translations are separate, linked copies of the email, and Customer.io automatically serves the correct version based on the profile's language attribute. Liquid conditionals (`{% if customer.language == \"fr\" %}`) are not a translation approach — they duplicate markup in one node, break the visual editor, and bypass automatic language routing.\n\n**Gotchas:**\n- Only EMAIL nodes can have translations\n- Calling on a translation node auto-finds the default\n- Language codes normalized to lowercase, duplicates rejected\n- Translations publish as part of the email's language group — see **journeys.md § Publish**\n\n## Versions\n\nSave, list, inspect, restore, and delete node version snapshots. **Node-generic**\n— works for any versionable node type (email,\ncomponent, and the plan/flag-gated channels; folders are rejected). Because it's keyed by node id and type-agnostic,\nit lives on the node-level `/ds` surface. Every payload carries `node_type` so you\nknow the channel. Base: `/v1/environments/{environment_id}/ds/nodes/{node_id}/versions`.\n\n| Method | Path | Body | Description |\n|---|---|---|---|\n| POST | `.../versions` | `{ \"name\": \"...\", \"description\"?: \"...\" }` | Save a snapshot of the node + its component dependencies. `name` is **required** (400 if missing/empty). Returns `{ version }`. |\n| GET | `.../versions` | — | List versions, newest first. Optional `start_date`/`end_date` query params (unix seconds, both together). Returns `{ versions: [...] }`. |\n| GET | `.../versions/{version_id}` | — | Get a version with its content snapshot: `{ version, node, dependencies }`. `node`/`dependencies` carry `content`/`text`/`amp` plus `settings`/`envelope` as JSON objects. (Dependency entries you lack view permission for are omitted.) |\n| POST | `.../versions/{version_id}/restore` | — | Roll **the node itself** back to the snapshot. Returns `{ node_id, version_id, restored_node_ids }` (just the node). |\n| DELETE | `.../versions/{version_id}` | — | Delete a version. **409** if it was created on publish (those anchor live content). |\n\n**Restore reverts only the node, not its shared components.** A version captures\nthe node plus the components it depended on, but restore writes back **only the\nprimary node** — shared components are left as-is, so rolling one node back can't\nsilently change every other node that reuses the same component. `restored_node_ids`\ntherefore lists just the node.\n\n**Restore edits the draft only — it does not publish.** A linked automation /\none-time send / message keeps sending the previously published content until you\npublish again (see **journeys.md**). Report \"restored, not yet published\" the\nsame way you would for an edit.\n\nPermissions narrow to the node's channel: a caller needs that channel's edit\ngrant to save/restore, view grant to list/get, delete grant to delete.\n\n## Workspace Settings\n\n| Method | Path | Body | Description |\n|---|---|---|---|\n| GET / PUT | `/v1/environments/{environment_id}/ds/settings` | `{ \"settings\": { ... } }` | Read / update workspace Design Studio settings (partial merge). |\n","org_patterns.md":"---\nname: Org Patterns\ndescription: This workspace's own distilled email patterns (ORG_PATTERNS.md) — what it covers, how it layers with BRAND.md and the generic type files, and how you regenerate and correct it.\nfeature_flag: improve-ds-agent\n---\n\n# Org Patterns\n\n`ORG_PATTERNS.md` is this workspace's own email, described: the band anatomy its emails\nactually use, the structural variants each type comes in, its house style, and its\nsubject and CTA conventions — distilled from its Design Studio emails. Where the\nfiles under `email_patterns/` say what a welcome email looks like *in general*, this\nsays what a welcome email looks like *here*.\n\n**Read it before writing or editing any email content.** Not only when the user asks\nfor something \"on-pattern\" — the document exists so that email matching how this team\nalready writes doesn't depend on them remembering to ask.\n\n## Reading and writing it\n\nOne document, two surfaces — use whichever your session has:\n\n- **Copilot filesystem** — the document is mounted as `ORG_PATTERNS.md`: `read(\"ORG_PATTERNS.md\")`\n  to read, `write(\"ORG_PATTERNS.md\")` to replace. Prefer this path when you have it.\n- **API** — `cio api` (or the MCP `cio_read_api` / `cio_write_api` tools):\n\n| Method | Path | Notes |\n|---|---|---|\n| GET | `/v1/environments/{environment_id}/ds/org_patterns_document` | Returns `{ markdown, exists, generated_at, node_ids, node_count, origin, new_linked_emails_since_generated, max_bytes, max_lines }`; unset fields are omitted. `exists: false` means the workspace has not distilled one yet — not an error. |\n| PUT | `/v1/environments/{environment_id}/ds/org_patterns_document` | Body `{ \"markdown\": \"...\" }`. Replaces the whole document and marks it `reviewed`. `markdown` must be non-empty, at most `max_bytes` bytes, and at most `max_lines` lines (both from the GET — a document full of short table rows can hit the line cap while well under the byte cap). Omit `node_ids`/`generated_at` on an edit — the stored provenance is preserved. |\n| POST | `/v1/environments/{environment_id}/ds/org_patterns_document/generate` | Starts a distillation run. Returns `{ workflow_id, run_id }`. |\n| GET | `/v1/environments/{environment_id}/ds/org_patterns_document/generate/status` | Query `workflow_id` and `run_id`, both required. Returns `{ status, phase, nodes, document, node_ids, generated_at, skipped_nodes, max_bytes, max_lines, reason }`. |\n\nTwo fields describe the document rather than its content:\n\n- `origin` — `auto_generated` (the pipeline wrote it and nobody has confirmed it) or\n  `reviewed` (a person, or you on their instruction, wrote or corrected it).\n- `new_linked_emails_since_generated` — linked emails changed since the run that the\n  document does not cover. This is the staleness number.\n\nBoth also reach you without an API call: the copilot read prepends a one-line HTML\ncomment carrying them, e.g.\n`\u003c!-- distilled 2026-03-04 from 12 emails; origin: auto_generated; 4 linked emails changed since --\u003e`.\n**That line is provenance, not content.** Never quote it back to the user, never\nreproduce it in a `write`, and never treat it as a section of the document.\n\nNot every workspace has one. If the read returns `exists: false` (or the copilot\nplaceholder saying no document exists), build from the generic type files and say you\nare working from generic patterns rather than theirs. Distillation happens on its own\nonce the workspace has enough email, so do not tell the user to go and create one and\ndo not start one yourself. If the **read** itself is missing or forbidden, the workspace doesn't\nhave the feature — carry on without it and don't mention it. A 403 on the **write**\nwhen the read works is different: the caller lacks the email edit permission — tell\nthe user what you wanted to change instead of dropping it silently.\n\n## Read order and precedence\n\nThree layers, read in this order, each narrower than the one before:\n\n1. **BRAND.md** — the brand's rules: colour roles and prohibited pairings, the type\n   scale, spacing, voice (**brand_document.md**).\n2. **ORG_PATTERNS.md** — this workspace's anatomy: band order and contents per email\n   type, structural variants and when each is used, house style, subject and\n   preheader conventions, CTA phrasing.\n3. **email_patterns.md** → the type file it names — Carta syntax, the graded\n   conversion example, and the anatomy for any type ORG_PATTERNS.md doesn't cover.\n\nWhere ORG_PATTERNS.md and a generic type file disagree about structure — band order, what\nthe hero carries, how long a subject line runs — **ORG_PATTERNS.md wins**: it was read off\nthis sender's email, the type file off a cross-sender corpus. Keep taking markup\nsyntax and the worked example from the type file; ORG_PATTERNS.md carries no Carta.\nTell the user about each such departure in plain words — what the usual layout does (\"most cart\nemails lead with a hero image\") and that you followed their own emails instead.\n\nWhere ORG_PATTERNS.md and BRAND.md disagree about a value or a prohibition, **BRAND.md\nwins** — brand rules are approved, patterns are only observed. Mention the\ndiscrepancy: it usually means the emails predate the brand document.\n\nRead its **Not observed** section as a coverage gap, not a ban. It lists what the\naudited emails happened not to do. If the user asks for one of those things, write it.\n\n**The user's brief outranks both pattern layers.** ORG_PATTERNS.md describes what this\nworkspace has sent, not what it must send. When the user asks for something different\nfrom the observed pattern — \"a welcome email, but nothing like our current ones\" —\nfollow the brief, take structure from the generic type file, and say you are departing\nfrom their observed pattern. Only BRAND.md's rules still bind: patterns bend to the\nbrief, brand rules do not.\n\n## Regenerating it\n\nThere is no Org Patterns page. You own this document's whole lifecycle: the pipeline\ngenerates it the first time it is needed, saves its own output, and you regenerate it\nwhen it goes stale.\n\n**Never hand-distill.** Do not audit email nodes, summarise them, and present the\nresult as this workspace's patterns — a document written that way looks identical to\na distilled one and nothing downstream can tell them apart. Run the pipeline instead.\n\n### When to run it\n\n- **The user asks.** \"Distill our email patterns\", \"refresh our patterns\", \"audit our\n  emails and write it up\" — run it.\n- **The document is old.** From the provenance comment or the GET: it was distilled\n  more than 30 days ago. Offer a refresh **once**, finish the task you were asked to\n  do either way, and drop it if the user declines or ignores it. Do not re-offer\n  later in the session. The changed-emails count in the provenance line is context,\n  not a trigger — do not offer a refresh because of it alone.\n- **Repeated corrections.** If the user has corrected the same document two or more\n  times in a session, or a correction contradicts something the document states as\n  observed, say the document looks out of date and offer to regenerate it.\n\nDo not run it because the file is missing — reading a missing document already\nstarted a run.\n\n### The loop\n\n1. `POST .../ds/org_patterns_document/generate` → `{ workflow_id, run_id }`.\n2. Poll `GET .../ds/org_patterns_document/generate/status?workflow_id=…\u0026run_id=…`\n   about every 2.5 seconds. `phase` moves through `selecting` → `distilling` →\n   `authoring`, and `nodes` lists each audited email as `pending`, `ready`, or\n   `failed`. Say which phase it is in rather than going silent.\n3. On `status: \"completed\"`, the run **has already saved** the document with\n   `origin: auto_generated`. Do not PUT it back — a PUT would mark it reviewed when\n   nobody reviewed it. Read the `document` field, compare it against the previous\n   text you read before starting, and tell the user what actually changed: which\n   sections are new, which claims changed, what the coverage now is\n   (`node_ids`/`skipped_nodes`). \"Regenerated\" on its own is not a summary.\n4. On `status: \"failed\"`, `reason` says why. **Every reason is worth retrying except\n   `not_enough_emails`**, which means the workspace has fewer than three Design Studio\n   emails and no retry can change that — say so, say a run will work once it has three, and stop; the document comes only from a run. `not_enough_readable_emails`\n   in particular is usually transient: offer one retry.\n\nA second `generate` supersedes a run already in flight, so never start one while you\nare still polling.\n\n## Showing it\n\nWhen the user asks what their patterns are, or to see the document, read it and show\nit. Strip the provenance comment first. Say when it was distilled, from how many\nemails, and — when `origin` is `auto_generated` — that it was generated automatically\nand nobody has confirmed it, so corrections are welcome.\n\n## Editing it\n\nA write replaces the whole document — there is no partial update.\n\n**Only write when the user asks you to change a pattern**, typically to correct\nsomething the distillation read wrong. It is workspace-wide: every future email, from\nevery teammate and every session, is built against it. Same discipline as a BRAND.md\nedit:\n\n1. Read the current document first and edit from that text — a blind write loses\n   everything you didn't reproduce.\n2. Change only what was asked. Preserve the document's own header block, the section\n   structure, and everything else verbatim.\n3. Keep the document's own conventions: counts rather than percentages, claims\n   labelled observed or inferred, `globalStyles.*` references verbatim, no number you\n   did not read off a node.\n4. Tell the user what you changed, and that it affects future email for everyone in\n   the workspace.\n\nA write marks the document `reviewed`, which is the point: it records that someone\nlooked at this text. Refuse to write an empty document, and never write the\nplaceholder — or the provenance comment — back as if it were one.\n\nCorrecting is not regenerating. A wrong claim about one email type is an edit. A\ndocument that describes email the workspace stopped sending is a regeneration.\n","promote_component_variants.md":"# Promote Recurring Component Patterns to Variants (Audit)\n\nRead-only audit. Scans Design Studio nodes for repeated explicit prop combinations on variant-keyed components and recommends promoting them to named global-styles variants. **Never writes anything** — it only reports candidates.\n\n## When to use\n\n- \"What component variants should I create?\"\n- \"Find repeated button/section styles I should turn into variants\"\n- \"Audit my CTAs for variant candidates\"\n- Any request to find recurring component patterns or suggest new variants.\n\nFor a full design-system pass (tokens **and** variants in one go), use **design_system_audit.md** instead.\n\n## Background: variant-keyed groups and their tags\n\nOnly these groups support variants. Map each tag to its group when reading instances:\n\n| Group     | Tag carrying the variant | Variant attribute |\n| --------- | ------------------------ | ----------------- |\n| `button`  | `x-cta`                  | `#variant`        |\n| `divider` | `x-hr`                   | `#variant`        |\n| `image`   | `x-image`                | `#variant`        |\n| `section` | `x-section`              | `#variant`        |\n| `inbox`   | `x-inbox`                | `#variant`        |\n| `link`    | inline `\u003ca\u003e`             | `data-variant`    |\n\n`textStyle` is **not** variant-keyed — skip it entirely.\n\n## Inputs (resolve before starting)\n\n| Input            | Default                                 |\n| ---------------- | --------------------------------------- |\n| Scope            | Whole workspace (emails + components)   |\n| Folder ID        | None                                    |\n| Node type filter | `emails` and `components`               |\n| Min cluster size | ≥ 3 instances across ≥ 2 distinct nodes |\n\nIf the user specifies a scope, folder, node type filter, or minimum cluster size, honour it. Otherwise use the defaults above.\n\n## Procedure\n\n### Step 1 — Inventory existing variants\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/global_styles\n```\n\nFor each of the six supported groups (`button`, `divider`, `image`, `section`, `inbox`, `link`):\n\n- Collect every defined variant's full prop bag (`$value` fields) and its `$extensions`.\n- Identify the **default variant**: the one where `$extensions[\"io.parcel.global-styles\"].default === true`. If none is flagged, treat the variant with the lowest `index` as the default.\n- Normalise all prop values (see Step 4) so you can compare them consistently later.\n\nSee **global_styles.md** for the full token structure, response shape, and `$extensions` format.\n\nStore: `{ group → { defaultProps: {...}, variants: [{ name, props, id, index }] } }`.\n\n### Step 2 — Learn the prop schema\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/component_definitions\n```\n\nFor each variant-keyed tag (`x-cta`, `x-hr`, `x-image`, `x-section`, `x-inbox`), record:\n\n- The full list of valid prop names\n- Which props are slot content vs style props (slot content must be excluded when comparing)\n- Any prop that holds a `globalStyles.*` reference (resolve it before comparing)\n\nFor `\u003ca data-variant\u003e` (link group), the relevant props are inline style and class attributes — not slot content.\n\n### Step 3 — Collect instances\n\nEnumerate email and component nodes (see **nodes.md § List** — paginate with `page` / `limit`). **Honour the scope inputs:** pass `parent_folder_id=\u003cid\u003e` when a Folder ID is set, and enumerate only the selected type when the Node type filter names one — don't scan the whole workspace for a scoped request.\n\n- `cio api /v1/environments/$ENV_ID/design_studio/emails` → fetch each email's markup via `cio api .../emails/{id} --jq '.email.content.html' -r`\n- `cio api /v1/environments/$ENV_ID/design_studio/components` → fetch each component's markup via `cio api .../components/{id} --jq '.component.content' -r`\n\nFor each node's markup, scan for every instance of the six variant-keyed tags. For each instance:\n\n- Extract **explicit props only** — attributes that are actually set on the element. Do not infer defaults.\n- **Record** the declared variant from `#variant` / `data-variant` (don't count it as an override prop). You'll use it in Step 5 to pick the comparison baseline; an instance with no variant attribute uses the group default.\n- **Exclude** slot content (inner HTML / child elements).\n- Strip any Liquid expressions (`{{ }}`, `{% %}`) — treat those prop values as non-comparable and skip them.\n- Record: `{ group, nodeId, nodeName, instanceIndex, declaredVariant, props: { propName: rawValue } }`.\n\n### Step 4 — Normalise prop values\n\nApply consistent normalisation so that equivalent values cluster together:\n\n- **Hex colours:** lowercase, expand 3-digit → 6-digit (`#fff` → `#ffffff`)\n- **RGB/RGBA:** convert to hex where possible\n- **Named CSS colours:** `white` → `#ffffff`, `black` → `#000000`, etc.\n- **Dimensions:** lowercase, no extra whitespace (`16PX` → `16px`)\n- **Font stacks:** collapse whitespace, lowercase, strip outer quotes\n- **`globalStyles.*` references:** resolve to the variable's current `$value` before comparing (fetch from the Step 1 inventory)\n\nApply the same normalisation to the existing variant prop bags fetched in Step 1.\n\n### Step 5 — Compute delta vs the instance's active variant\n\nFor each instance, pick its **base variant**: the variant named by the `declaredVariant` recorded in Step 3, or the group default when it has none. Compare the instance's normalised explicit props against that base variant's normalised props (from the Step 1 inventory):\n\n```\ndelta = { propName: value }  for every prop where instance value ≠ base-variant value\n```\n\nCompare against the instance's *active* variant, not always the group default — comparing a non-default instance against the default would fold that variant's own styling into the delta and surface spurious candidates.\n\nIf the instance has no explicit overrides beyond its base variant, its delta is empty — these instances already match an existing variant and are not candidates for a new one.\n\n### Step 6 — Cluster by delta\n\nWithin each group, group instances by their delta (i.e. identical or near-identical sets of prop overrides). Two deltas are the same cluster if:\n\n- Every prop name matches\n- Every prop value matches after normalisation\n\nNear-identical: if all but one prop match, note the varying prop explicitly in the report (\"varies by `border-color`\") but still cluster them — this is a judgment call; expose it in the output.\n\nFor each cluster compute:\n\n- **Instance count** = total number of matching instances\n- **Node spread** = number of distinct nodes those instances appear in\n\n### Step 7 — Drop already-covered clusters\n\nFor each cluster, compare its delta-resolved prop bag against every existing variant's prop bag (from Step 1) in the same group. If the cluster's resolved props exactly match an existing variant, drop it — no action needed.\n\n### Step 8 — Filter and rank\n\nDrop clusters where instance count \u003c min threshold **or** node spread \u003c 2 (unless the user overrode this).\n\nRank remaining clusters descending by `instance count × node spread` within each group.\n\n### Step 9 — Propose variants\n\nFor each surviving cluster:\n\n1. **Proposed name** (kebab-case): derive from the dominant prop values if possible (e.g. `white-on-dark`, `pill-outline`, `large-cta`). Otherwise use `\u003cgroup\u003e-variant-\u003crank\u003e` as a fallback.\n2. **Full prop bag**: the group's default props merged with the cluster's delta — this is the complete set of props the new variant would define.\n3. **`$extensions`** the variant would get — see **global_styles.md** for the required fields (`id`, `index`; omit `default` on new variants).\n4. **Usage path**: `#variant=\"\u003cproposed-name\u003e\"` (for `x-cta`, `x-hr`, `x-image`, `x-section`, `x-inbox`) or `data-variant=\"\u003cproposed-name\u003e\"` (for `\u003ca\u003e`). Note that names may contain spaces — encode verbatim.\n5. **Instance list**: up to 5 examples showing `nodeId / nodeName / instanceIndex` that would collapse onto this variant.\n\n## Output format\n\nGroup results by component group. Within each group, present a ranked markdown table:\n\n```\n### Group: button\n\n| # | Proposed name | Prop bag (delta) | Instances (count / nodes) | Sample nodes |\n|---|---|---|---|---|\n| 1 | white-on-dark | background: #0b353b; color: #ffffff; border-radius: 4px | 7 instances / 3 nodes | Email A, Email B, Component C |\n...\n```\n\nAfter all groups, add a summary paragraph:\n\n- Total nodes scanned\n- Total instances found across all variant-keyed tags\n- Total existing variants found (and therefore skipped)\n- Total new variant candidates surfaced\n\nEnd with this offer:\n\n\u003e \"Would you like me to create any of these variants and roll them out? Creating a variant is a global-styles read-modify-write (**global_styles.md § Update**) — append the new variant to its group with a fresh `id`/`index` — then **bulk_apply_variant.md** sets it on every matching instance (dry-run + confirmation first). Name the rows (or 'all') and I'll proceed.\"\n\n## Guardrails\n\n- **Strictly read-only.** This audit never calls `PUT /ds/global_styles` and never modifies any node — applying a candidate is a separate, explicitly-confirmed step.\n- Always normalise both instance props and existing variant props before comparing — never flag a pattern already covered by an existing variant in a different format.\n- If a group has no instances at all, omit it from the output rather than showing an empty table.\n- If the workspace has no Design Studio nodes with markup, say so and stop.\n- If individual node fetches fail, skip and count them as errors — note the count in the output, don't abort.\n- The clustering threshold is a judgment call — state the threshold used at the top of the report and remind the user they can re-run with a lower threshold to surface smaller patterns.\n\n## Notes \u0026 gotchas\n\n- `#variant` references use the **bare variant name**, encoded verbatim including any spaces (e.g. `#variant=\"dark cta\"` is valid).\n- `data-variant` on `\u003ca\u003e` tags is the link group's mechanism — not `#variant`.\n- When computing the delta, only consider props in the tag's known prop schema (from Step 2). Unknown/arbitrary attributes may be user content, not style props — treat them cautiously and note any that appear.\n- If `component_definitions` returns no schema for a tag, fall back to treating all non-slot, non-variant attributes as candidate props, and flag this in the report.\n- `textStyle` group has no variant key — skip any `x-text` or similar tags entirely.\n- Prefer semantic proposed names over mechanical ones (`color-ffffff`) — look at the dominant visual role of the prop combination to suggest something a designer would recognise.\n\n## API reference\n\nSee **global_styles.md** for the full global styles API reference — endpoint paths, token and variant structure, `$extensions` format, validation rules, and the read-modify-write pattern. Node enumeration and markup fetching (**nodes.md**):\n\n- `GET /v1/environments/$ENV_ID/design_studio/emails[?parent_folder_id=X]` — paginated; `.emails[]`\n- `GET /v1/environments/$ENV_ID/design_studio/components[?parent_folder_id=X]` — paginated; `.components[]`\n- `GET /v1/environments/$ENV_ID/design_studio/emails/{id}` → `.email.content.html`\n- `GET /v1/environments/$ENV_ID/design_studio/components/{id}` → `.component.content`\n- `GET /v1/environments/$ENV_ID/ds/component_definitions` → array of `{ id, name, definition }` with prop schemas\n\nAll calls go through `cio api`, which handles authentication.\n","promote_design_tokens.md":"# Promote Raw Values to Global-Styles Variables (Audit)\n\nRead-only audit. Scans all Design Studio nodes for frequently-repeated literal style values (colors, spacing, radii, font families) that are **not** yet defined as global-styles variables, and recommends which ones to promote as design tokens. **Never writes anything** — it only reports candidates. To act on a candidate, hand it to **mass_replace_value.md**.\n\n## When to use\n\n- \"What raw values should I turn into variables?\"\n- \"Find colors/spacing I'm repeating across my emails\"\n- \"Audit my workspace for missing design tokens\"\n- Any request to audit hardcoded style values or find reuse candidates in Design Studio.\n\nFor a full design-system pass (tokens **and** variants in one go), use **design_system_audit.md** instead.\n\n## Inputs (resolve before starting)\n\n| Input                   | Default                                      |\n| ----------------------- | -------------------------------------------- |\n| Scope                   | Whole workspace (both emails and components) |\n| Folder ID               | None (full workspace)                        |\n| Node type filter        | `emails` and `components`                    |\n| Minimum reach threshold | ≥ 2 distinct nodes                           |\n\nIf the user specifies a folder ID, a node type (`emails` or `components`), or a reach threshold, honour it. Otherwise use the defaults above.\n\n## Procedure\n\n### Step 1 — Inventory existing tokens\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/global_styles\n```\n\nFor every variable in the response:\n\n- Record its `$value`, its group (`color`, `font`, `radius`, `spacing`), its `id`, and its `index`.\n- Normalise the `$value` (see Step 4) and store in a lookup set so you can recognise it later and avoid re-recommending it.\n\nSee **global_styles.md** for the full token structure, response shape, and `$extensions` format.\n\n### Step 2 — Enumerate nodes\n\nPage through the node lists (see **nodes.md § List** — paginate with `page` / `limit`, read `meta.pagination`):\n\n```bash\ncio api /v1/environments/$ENV_ID/design_studio/emails\ncio api /v1/environments/$ENV_ID/design_studio/components\n```\n\n**Honour the scope inputs:** if a `parent_folder_id` was given, pass it as a query parameter; if the Node type filter names a single type (`emails` or `components`), enumerate only that list. Otherwise enumerate both.\n\nFor each node, fetch its source markup:\n\n- Email: `cio api /v1/environments/$ENV_ID/design_studio/emails/{id} --jq '.email.content.html' -r`\n- Component: `cio api /v1/environments/$ENV_ID/design_studio/components/{id} --jq '.component.content' -r`\n\nKeep track of each node's `id` and its human-readable name (or folder path if available) for the final report.\n\n### Step 3 — Extract literals from markup\n\nScan each piece of source markup for inline attribute values and style declarations. For every value found, record:\n\n- The raw value string\n- The attribute or CSS property it appeared on (e.g. `background-color`, `padding`, `font-family`, `border-radius`)\n- The component tag or element it belongs to\n- The node ID\n\n**What to extract and how to bucket:**\n\n| Pattern                                                               | Examples                          | Role / group |\n| --------------------------------------------------------------------- | --------------------------------- | ------------ |\n| Hex colors                                                            | `#fff`, `#1a2b3c`                 | `color`      |\n| RGB/RGBA                                                              | `rgb(255,0,0)`, `rgba(0,0,0,0.5)` | `color`      |\n| Named CSS colors                                                      | `red`, `white`, `transparent`     | `color`      |\n| px/em/rem on `padding`, `margin`, `gap`, `spacing`, `width`, `height` | `16px`, `1.5rem`                  | `spacing`    |\n| px/em/rem on `border-radius` or `radius` attribute                    | `4px`, `0.5rem`                   | `radius`     |\n| `font-family` values                                                  | `\"Inter, sans-serif\"`             | `font`       |\n\n**Skip:**\n\n- Any value that appears inside a Liquid tag (`{{ }}` or `{% %}`)\n- Any value that is already a `globalStyles.*` reference (e.g. `globalStyles.colors.primary_abc123`)\n- Purely numeric values with no unit and no colour syntax (those aren't promotable tokens)\n\n### Step 4 — Normalise for clustering\n\nApply these normalisation rules so that equivalent values collapse to one cluster:\n\n- **Hex colours:** lowercase all letters, expand 3-digit to 6-digit (`#fff` → `#ffffff`)\n- **RGB/RGBA:** convert to lowercase hex (drop alpha for RGB; for RGBA keep as `rgba(r,g,b,a)` normalised form with no extra whitespace)\n- **Named colours:** convert to their hex equivalent where unambiguous (e.g. `white` → `#ffffff`, `black` → `#000000`); leave rare names as-is\n- **Font stacks:** collapse all internal whitespace, lowercase, strip outer quotes\n- **Dimensions:** lowercase, no extra whitespace (`16PX` → `16px`)\n\nRun the same normalisation on every existing variable's `$value` (from Step 1) so the comparison is apples-to-apples.\n\n### Step 5 — Drop already-tokenised values\n\nAfter normalisation, remove any literal whose normalised form **exactly matches** an existing variable's normalised `$value` in the same role/group. Do not recommend values that are already tokens.\n\n### Step 6 — Cluster and rank\n\nGroup remaining literals by **normalised value + role** (a `#ffffff` used as a `color` is a different cluster from `#ffffff` used as a `spacing` source — though that's unlikely; apply judgment for ambiguous cases).\n\nFor each cluster compute:\n\n- **Reach** = number of distinct nodes the value appears in\n- **Occurrences** = total number of times it appears across all nodes\n\nDrop clusters where reach \u003c the minimum threshold (default 2).\n\nRank descending by `reach × occurrences` (i.e. high-reach and high-frequency values come first).\n\n### Step 7 — Propose token names\n\nFor each cluster in the ranked list, propose:\n\n1. **Suggested name** (kebab-case): derive from context clues if possible (e.g. attribute name + value → `border-radius-4`, `spacing-16`, `color-primary` if it looks like a brand colour). Otherwise use `\u003cgroup\u003e-\u003cnormalisedValue\u003e` (e.g. `color-1a2b3c`, `spacing-16px`).\n2. **Target group**: `color`, `font`, `radius`, or `spacing`.\n3. **Reference path** that the user would use after promotion — see **global_styles.md § Referencing Tokens from Markup** for the safeName derivation rules and pluralisation (`colors`, `fonts`, `radius`, `spacing`).\n4. **Sample node paths**: list up to 3 node names/IDs where the value appears.\n\n## Output format\n\nPresent the results as a ranked markdown table followed by a summary paragraph:\n\n```\n| # | Value | Suggested name | Group | Reach (nodes / occurrences) | Sample nodes |\n|---|---|---|---|---|---|\n| 1 | #1a2b3c | color-1a2b3c | color | 8 nodes / 24 occurrences | Email A, Component B, Email C |\n...\n```\n\nAfter the table, add a short paragraph noting:\n\n- How many nodes were scanned in total\n- How many existing tokens were found (and therefore excluded)\n- How many unique candidates were surfaced\n\nEnd with this offer:\n\n\u003e \"Would you like me to hand any of these candidates to the **mass-replace** operation (**mass_replace_value.md**) to create the variables and rewrite all references? Just tell me which rows (or all of them) and I'll proceed.\"\n\n## Guardrails\n\n- **This audit is strictly read-only.** Never call `PUT /ds/global_styles`, never update any node, never write any file.\n- Always normalise both sides before comparing; never flag a value that is already a token in a different format.\n- If the workspace has zero Design Studio nodes, say so clearly and stop.\n- If the API returns errors for individual nodes, skip them and note the count of skipped nodes in the output rather than aborting the whole audit.\n- Do not surface candidates below the reach threshold; keep the report focused on high-impact tokens.\n\n## API reference\n\nSee **global_styles.md** for the full global styles API reference — endpoint paths, token structure, validation rules, and the read-modify-write pattern. Node enumeration and markup fetching follow the standard Design Studio node API (**nodes.md**):\n\n- `GET /v1/environments/$ENV_ID/design_studio/emails[?parent_folder_id=X]` — paginated email list; stubs at `.emails[]`\n- `GET /v1/environments/$ENV_ID/design_studio/components[?parent_folder_id=X]` — paginated component list; stubs at `.components[]`\n- `GET /v1/environments/$ENV_ID/design_studio/emails/{id}` → `.email.content.html`\n- `GET /v1/environments/$ENV_ID/design_studio/components/{id}` → `.component.content`\n\nAll calls go through `cio api`, which handles authentication.\n","sign_up_forms.md":"---\nfeature_flag: sign-up-forms\n---\n\n# Sign-up Forms\n\nA sign-up form is a Design Studio node (`SIGN_UP_FORM`) served through an in-app broadcast of type `sign_up_form`. The broadcast holds the form's state (drafted, scheduled, running, stopped) and its templates, one set per output. The form's own routes live under `/v1/environments/:eid/design_studio/sign_up_forms`; the broadcast routes below are sign-up form only and return 422 for a custom broadcast.\n\nThis file is the endpoint mechanics. For the end-to-end workflow of building a new form from a prompt — create, author, outputs, launch, links — read `skills/recipes/creating_a_sign_up_form.md` first.\n\nTheir get and list routes carry the `dependency_timeout` code (**SKILL.md** § Transient Failures).\n\n## Creating\n\nCreate a form with `POST /v1/environments/:eid/design_studio/sign_up_forms` (`name`, `content`, optional `parent_folder_id`). One call creates the node, its drafted broadcast and the link between them, and returns `is_linked: true` with the broadcast's id, state and templates. A Liquid error in `content` returns 422 on `content`. To start from another form, send its node id as `copy_from` (`content` is then ignored): the new form gets its own broadcast with a copy of every output and language of that form's broadcast, and new form ids that keep the source forms' field mappings. Its goal, frequency and audience start as a new form's. An unknown `copy_from` returns 404. The generic `POST /ds/nodes` rejects `SIGN_UP_FORM` with a 400, and `POST /in_app_broadcasts/:id/copy` on a form's broadcast returns 409.\n\nThe form's Journeys link (`GET /ds/nodes/{node_id}/journeys`, **journeys.md**) reports `journeys_parent_entity_type: \"in_app_broadcast\"` with the broadcast's id as `journeys_parent_entity_id`.\n\n## Broadcasts\n\n`GET /v1/environments/:eid/in_app_broadcasts` lists custom broadcasts by default; the API takes `types[]=sign_up_form` to list form broadcasts, but `cio api --params` cannot send that filter — from the agent CLI, list forms with the `design_studio/sign_up_forms` stub list instead (**fly_api** `sign_up_forms.md`). `search` filters by name (case-insensitive substring). Pass `page` (0-based) to get one page of 20; `meta.pagination.total` counts every matching form. Results are most recently updated first; pass `sort` (`name`, `updated` or `state`) and `asc=true` to change that, since without `asc` the sort is descending.\n\n## Outputs\n\nA form renders through one or more outputs: `in_app` (SDK delivery), `embedded` (embed script) and `hosted` (hosted page).\n\n| Endpoint | Use for |\n|----------|---------|\n| `POST /v1/environments/:eid/in_app_broadcasts/:id/outputs` | Enable an output (`in_app`, `embedded`, `hosted`) by copying the form's default (oldest) template for each language. Idempotent. On a scheduled or running form the copied content must pass launch's checks: 422 `form_content_invalid` lists them under `meta.problems` against the template being copied, so fix that template first. A 409 `output_content_changed` means the form changed meanwhile; retry |\n| `DELETE /v1/environments/:eid/in_app_broadcasts/:id/outputs/:output_type` | Remove an output and its templates. A form keeps at least one output (removing the last returns 422) |\n\nOutputs change on draft, scheduled and running forms, and are locked (422 `form_locked`) while the form is starting, stopping or deleting. On a running form a change publishes at once: the hosted page or embed script of an output turned on goes live, and one turned off comes down. On a scheduled form, turning hosted on or off puts up or takes down the placeholder page, which shows the `before-open-message` set on the template's `\u003cx-base\u003e`.\n\n## Saving content\n\nEach output has its own template, and the form inside them must stay identical. Leave each `\u003cx-form\u003e`'s `form-id` as it is: every output and language posts to the form's one form id, and a save or launch that would put a template on another returns 422 `form_structure_mismatch`. Save a form's content with `POST /v1/environments/:eid/design_studio/sign_up_forms/:id/publish`, sending every output's template for a language in one request. Here `:id` is the form's node UUID, as on the sibling `sign_up_forms/:id` routes (the broadcast's templates carry it as `ds_node_id`); the numeric `in_app_broadcast_id` is still accepted but deprecated. This one request is the whole save. Don't use `PUT .../sign_up_forms/:id/templates` instead: it is the editor's save while typing, takes the same body, and a scheduled or live form refuses it with 409 `form_live_explicit_save_required`. A draft form's save only writes. A scheduled or running form's save runs launch's content checks, then writes; a running form also republishes the hosted page and embed script. A scheduled form's save republishes its placeholder page and embed script, so a changed `before-open-message` shows at its links; its content publishes at go-live without checking again. A failed check returns 422 `form_content_invalid` and saves nothing; `errors[0].meta.problems` lists each failing `template_id`, `output` and `errors`, including stored templates the request did not send. While the form is starting, stopping or deleting, saves return 422 `form_locked`. Change just the element asked for (one button's label, say) in each template's `body` and `body_json`, rather than a find-and-replace over the whole template that also rewrites copy elsewhere. `PUT /templates/:template_id` rejects `body`/`body_json` changes on a form's templates with a 422; other fields such as `name` still update there.\n\n`body` is the compiled HTML of the new `carta_content` (`POST /previews/carta_content`). One call, the sandbox's `jq` has no `--argjson`, so let `cio`'s `--json` build it:\n\n```bash\nfor t in $T1 $T2; do  # every output's template_id\n  cio api /v1/environments/$ENV_ID/templates/$t --jq '.template.body_json' -r \u003e /tmp/c$t.json\n  jq -r '.carta_content' /tmp/c$t.json \u003e /tmp/m$t.txt   # edit /tmp/m$t.txt here, the same way for every output\n  cio api /v1/environments/$ENV_ID/previews/carta_content -X POST --arg c=@/tmp/m$t.txt --json '{content:$c}' --jq .html -r \u003e /tmp/h$t.txt\ndone\ncio api /v1/environments/$ENV_ID/design_studio/sign_up_forms/$FORM_ID/templates -X PUT \\\n  --arg c1=@/tmp/c$T1.json --arg m1=@/tmp/m$T1.txt --arg h1=@/tmp/h$T1.txt \\\n  --arg c2=@/tmp/c$T2.json --arg m2=@/tmp/m$T2.txt --arg h2=@/tmp/h$T2.txt \\\n  --json \"{forms:[],templates:[{template_id:$T1,body:\\$h1,body_json:(\\$c1|fromjson|.carta_content=\\$m1|tojson)},{template_id:$T2,body:\\$h2,body_json:(\\$c2|fromjson|.carta_content=\\$m2|tojson)}]}\"\n```\nOne `--arg` triple and one `templates` entry per output.\n\n## Metrics\n\n```\nGET /v1/environments/:environment_id/in_app_broadcasts/:id/output_metrics\n```\n\nReturns `metrics`, one entry per output the form currently has (`in_app`, `embedded`, `hosted`), each with `output_type`, the `template_ids` summed into it, and a `metric` block in the same shape as `GET .../in_app_broadcasts/:id/metrics` (`viewed`, `clicked`, `converted`, `dismissed` series and `total_*` sums).\n\nThe `content_metrics` export (**fly_api** `imports_exports.md`) adds `output_type` to a form's rows.\n\n```\nGET /v1/environments/:environment_id/sign_up_forms/metrics?ids[]=1\u0026ids[]=2\n```\n\nReturns lifetime totals for a page of forms in one call (at most 50 `ids[]`): one `metrics` entry per id that is a sign-up form (others are dropped), each with `in_app_broadcast_id`, `viewed`, `submitted`, `profiles_created`, and `last_submitted_at` (unix seconds; absent when the form has no submissions).\n\n## Relinking\n\n`POST /ds/node_actions/relink` (**journeys.md**) keeps a form's link on sign-up form nodes: a form's link only moves to another form node, and a form node only takes a form's link (400 otherwise, 422 while sign-up forms are disabled). Moving a form's link needs the in-app broadcast edit permission.\n\n## Deleting\n\n- `DELETE /v1/environments/:eid/design_studio/sign_up_forms/:id` deletes the form and its broadcast, taking down its hosted page, embed script and SDK delivery. It needs the in-app broadcast delete permission, and a live (scheduled, starting, running or stopping) form also needs a `delete:in_app_broadcast:\u003cid\u003e` grant. A form linked to anything other than its own broadcast returns 409.\n- Deleting the form's folder, `DELETE /ds/nodes/{node_id}` and `POST /ds/node_actions/bulk_delete` delete it the same way, with the same permission and grant; any other linked node in the delete still returns 409 (**nodes.md** § Delete).\n- `DELETE /v1/environments/:eid/in_app_broadcasts/:id` on a form's broadcast returns 409: delete the form instead.\n","visual_verification.md":"# Visual Verification (screenshot check)\n\nRender the finished email to an image and look at it. The screenshot shows what the recipient sees. `GET .../render` answers \"did it compile\"; the review report answers \"are the checks passing\"; the screenshot answers what neither can: visual truth. Use it to **check** your work, not to browse. It is **our renderer's** truth, though — real clients (Gmail, Outlook, dark mode) can differ. That gap is covered by **inbox_previews.md**, but that run spends the customer's credits and is worth it almost never on your own initiative: this loop is far cheaper, so use it by default and reach for a paid inbox preview only when the user explicitly asks for real clients.\n\n## When to screenshot\n\n**Once, right before telling the user the work is done** — not after every edit. Each render is a billed vendor call, and inspecting the image costs image tokens.\n\nScreenshot when:\n\n- You touched **layout, structure, images, or styles** — anything that could plausibly change what the email looks like.\n- The user described the goal **visually** — \"make it pop\", \"fix the spacing\", \"it looks broken\".\n- The email is **from scratch** — always.\n\nSkip it when the change could not plausibly alter geometry or color:\n\n- Copy edits, subject/preheader changes, swapping a link URL.\n- Values the user dictated exactly (a specific hex, an exact padding) — there is nothing to judge.\n\n## Review first, then screenshot\n\nRun the server review (`POST /ds/nodes/{node_id}/review` — SKILL.md § Email Readiness Checks) **before** spending a render. It catches the mechanical findings — Liquid errors, broken links and references, contrast — without a render. The screenshot is for what the review cannot see: alignment, spacing, stretched images, content escaping the column, whether the result matches what the user asked for.\n\n## The loop, hard-capped\n\nRender → inspect → fix → re-render. **At most 3 renders per task** (the initial check plus two fix verifications); the mobile render below brings the absolute maximum to **4**. Re-render only when you changed something visual in response to what you saw in the image — never to double-check an email you didn't touch. At the cap: stop, tell the user what still concerns you, and do not keep rendering.\n\n## Mobile pass for from-scratch emails\n\nFor an email you built from scratch, the final check is two renders: **width 700 (desktop) and width 375 (mobile)**. Mobile is where `:break-point` columns stack and `{ small, medium }` hide/show blocks flip — a clean desktop render says nothing about it. The mobile render counts inside the cap (4 renders total including it).\n\n## What to look for in the image\n\n- **Geometry** — misaligned elements; text overflowing its container; images stretched, squashed, or showing a broken-image icon; uneven spacing between sections; content escaping the email column.\n- **Legibility** — low-contrast text on its background; the CTA's text against the button color AND the button against the page background; text sitting on a busy image.\n- **Intent** — does it match what the user asked for: brand colors applied, sections in the requested order, no placeholder text left in.\n\n## Mechanics\n\n`POST /v1/environments/$ENV_ID/ds/exports/screenshot` — send exactly **one of `node_id` or `html`**, plus `width` (required; clamped to 300–2000). Returns `{ url, expires_at, mime_type, width_px, height_px }` — `url` is a signed HTTPS GET link valid for **ten minutes**.\n\n| Input | Body | Use for |\n|---|---|---|\n| Saved node | `{ \"node_id\": \"\u003cuuid\u003e\", \"width\": 700 }` | Compiles and renders a saved EMAIL node exactly as the editor shows it — the normal path. |\n| Draft source | `{ \"html\": \"\u003cmarkup\u003e\", \"compile\": true, \"width\": 700 }` | Uncompiled editor source not yet saved to a node — compiled, then rendered. Preview before create/update. |\n| Literal HTML | `{ \"html\": \"...\", \"width\": 700 }` | Already-compiled HTML, captured as-is. |\n\n**Mint, then fetch — immediately.** The two steps are one move: get the URL, then call your `fetch` tool on it right away — the image comes back viewable. Save the mint response to a file first, so the URL survives a botched copy:\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/exports/screenshot -X POST \\\n  --arg id=\"$EMAIL_ID\" --json '{node_id:$id,width:700}' \u003e /tmp/shot_desktop.json\njq -r '.url' /tmp/shot_desktop.json\n# now fetch(\u003cthat url\u003e) right away — the signed link expires in ten minutes\n```\n\nFor a draft with the markup in a file (§ Shell Safety in **nodes.md**):\n\n```bash\ncio api /v1/environments/$ENV_ID/ds/exports/screenshot -X POST \\\n  --arg h=@/tmp/email_markup.html --json '{html:$h,compile:true,width:700}' \u003e /tmp/shot_draft.json\njq -r '.url' /tmp/shot_draft.json\n```\n\n**Copy the URL exactly.** The signature is ~340 characters of high-entropy text and one wrong character makes storage answer 403 — which looks like expiry but is not. That is why the response goes to a file:\n\n- **Fetch fails within seconds of minting** → you almost certainly mis-copied the URL. Print it again (`jq -r '.url' /tmp/shot_desktop.json`) and retry the fetch once with a fresh copy. No new render happened, so this does **not** count toward the cap.\n- **Fetch fails ten minutes or more after minting** → genuinely expired. Mint a new screenshot (a fresh render — counts toward the cap).\n\n**Report only what you saw.** If a fetch fails and the retry fails too, that check did not happen — say so plainly (\"I could not verify the mobile layout\") and never describe an image you did not see. A confident guess dressed as a verification is worse than an honest gap.\n\n**Errors:**\n\n- **400 \"could not be compiled\"** — your markup is the problem. Fix it (SKILL.md § Prop Mistakes / § Diagnosing Empty Renders) and render again.\n- **400 \"took too long\"** — slow remote assets (images the renderer has to download). Don't retry; note it to the user and move on.\n- **429 / 503** — rate limited or rendering busy. Retry once, then move on without the screenshot.\n","whatsapp_patterns.md":"---\nname: Design Studio WhatsApp\ndescription: Draft WhatsApp message templates in Design Studio — create an \u003cx-whatsapp\u003e node, then link and publish it (publish submits the template to Meta for approval).\nminimum_plan: essentials\n---\n\n# Design Studio WhatsApp\n\n\u003e **Already approved by Meta or Twilio?** Wrong page — fill it onto the destination instead (`@skills/fly-api/whatsapp.md`). Publishing here submits a **new** template for approval, which takes hours to days.\n\nDraft WhatsApp templates in Design Studio as `\u003cx-whatsapp\u003e` Carta nodes, then link\nand publish them. This produces a Design Studio **WhatsApp draft** — the user\nreviews it in the DS editor; publish submits it to Meta for approval (the agent\ndoes NOT submit to Meta directly).\n\nAll endpoints are `cio api` under `/v1/environments/{environment_id}/...`. Bodies\nare **snake_case**. Pass the markup as a `--arg` value and let `--json` build the body — `cio`'s\nbundled gojq encodes the `content` string (no external `jq`, no shell escaping).\nSee **nodes.md § Shell Safety for Design Studio Payloads**.\n\nBefore drafting, confirm the workspace actually has WhatsApp credentials:\n`cio api /v1/environments/$ENV_ID/whatsapp/check` — `{\"status\": \"ok\", \"success\": true}` means set up. Anything else (`no credentials`, `token invalid`, `token not authorized`, `missing permissions: …`) means the template could be created but never published; send the user to Settings → WhatsApp instead.\n\n## Create / Get / Update / Delete\n\n| Operation | Path | Body / Returns |\n|---|---|---|\n| Create | `POST /design_studio/whatsapp` | `{ name, category, language, recipient, content, parent_folder_id?, sender_id? }` — `category` is `MARKETING` or `UTILITY`; `language` is a BCP-47 code (e.g. `en_US`); `recipient` e.g. `{{customer.phone}}`; `content` is `\u003cx-whatsapp\u003e` markup. Response: `{ whatsapp: { id, … } }`. |\n| Get | `GET /design_studio/whatsapp/{id}` | `{ whatsapp: { …, category, language, status, recipient, sender_id, content } }` — source markup in `content`. |\n| List | `GET /design_studio/whatsapp` | `{ whatsapp: [stubs], folders: [...], meta: {...} }` — stubs carry no content. Same query params as the other types (see **nodes.md § List**). |\n| Update | `PUT /design_studio/whatsapp/{id}` | `{ name?, parent_folder_id?, category?, language?, recipient?, sender_id?, content? }` — partial; `sender_id: null` clears the sender. **Once submitted to Meta, `content`, `category` and `language` are locked**: changing any of them answers **409** unless the node's `status` is `REJECTED`. Name, folder, recipient and sender stay editable. To change the copy of a pending or approved template, create a new WhatsApp node. |\n| Delete | `DELETE /design_studio/whatsapp/{id}` | **409** if linked to Journeys. No plain unlink exists: transfer the link to another unlinked node first (`POST /ds/node_actions/relink`) or remove the destination — the user's call. |\n\n## Link \u0026 Publish\n\n| Link | Publish |\n|---|---|\n| `POST /design_studio/whatsapp/{node_id}/link` | `POST /design_studio/whatsapp/{node_id}/publish` |\n\n- `target.type` accepts the same set as email — `transactional_message`,\n  `newsletter`, or `campaign_action`.\n- Publish **submits the template to Meta for approval** (not a plain render); the\n  response carries the Meta `whatsapp_template_id`, `category`, and approval\n  `status`. A published template is not sendable until Meta approves it.\n- Publishing creates a Meta template, so it needs the WhatsApp **create** grant\n  (not just edit) — a missing grant surfaces as **403**.\n- Publish only goes through for a node never submitted or one Meta\n  `REJECTED`. A `PENDING` or `APPROVED` node answers **409**: a second\n  submission would mint a new, unapproved Meta template and repoint the linked\n  destination at it. To change the copy of a pending or approved template,\n  create a new WhatsApp node.\n\nFor the email link/publish flow this mirrors, see **journeys.md**. For versions\nand feedback see **nodes_advanced.md**.\n\n## Markup: `\u003cx-whatsapp\u003e`\n\nThe markup goes in the `content` field of the create/update call.\n\n## Markup Grammar\n\nWhatsApp templates use a small XML grammar rooted at `\u003cx-whatsapp\u003e`. This markup is the `content` field on create and update.\n\n```\n\u003cx-whatsapp\u003e\n  \u003cx-wa-header\u003e...\u003c/x-wa-header\u003e               \u003c!-- optional, mutually exclusive with x-wa-header-media --\u003e\n  \u003cx-wa-header-media src=\"...\" /\u003e              \u003c!-- optional, mutually exclusive with x-wa-header --\u003e\n  \u003cx-wa-body\u003e...\u003c/x-wa-body\u003e                   \u003c!-- required --\u003e\n  \u003cx-wa-footer\u003e...\u003c/x-wa-footer\u003e               \u003c!-- optional --\u003e\n  \u003cx-wa-actions\u003e                               \u003c!-- optional --\u003e\n    \u003cx-wa-buttons\u003e...\u003c/x-wa-buttons\u003e           \u003c!-- url / phone / copy buttons --\u003e\n    \u003cx-wa-quick-replies\u003e...\u003c/x-wa-quick-replies\u003e  \u003c!-- preset reply buttons --\u003e\n  \u003c/x-wa-actions\u003e\n\u003c/x-whatsapp\u003e\n```\n\nOnly `\u003cx-wa-body\u003e` is required. Everything else is optional. **A template cannot have both `\u003cx-wa-header\u003e` and `\u003cx-wa-header-media\u003e`** — pick one.\n\n## Create Body Shape\n\n`POST /design_studio/whatsapp`. There is no `body` field — the markup goes in `content`.\n\n| Field | Required | Description |\n|---|---|---|\n| `name` | yes | Human-readable template name (e.g., `\"order_confirmation\"`). Lowercase + underscores is conventional. |\n| `language` | yes | BCP-47 language code with underscore separator (e.g., `en_US`, `es_ES`, `pt_BR`). |\n| `category` | yes | One of `MARKETING` or `UTILITY`. **No other values accepted.** |\n| `recipient` | yes | Liquid resolving to the destination phone number, e.g. `{{customer.phone}}`. Empty or omitted → 400. |\n| `content` | in practice | The full `\u003cx-whatsapp\u003e` XML markup. Unlike the fields above it is **not** validated — an omitted or empty `content` is accepted and creates an empty node rather than failing. Always send it. |\n| `sender_id` | no | Sender **identity** id (an integer) to send from, not a phone number. Stored on the node's envelope; omitting it leaves the node without one. It must be a **WhatsApp** identity — `GET /identities` lists every channel's, so filter on `template_type: whatsapp`; an email or SMS identity, an unknown id, or an id ≤ 0 is a 400. |\n| `parent_folder_id` | no | Folder UUID, or null for the root. An empty string is rejected. Decide it before the create — **SKILL.md step 1g** (Where it lives); omitted means the root. |\n\n`template_examples` is **not** a create field — see § Template Examples.\n\n## Component Reference\n\n### Text Header — `\u003cx-wa-header\u003e`\n\nPlain-text header above the body. Supports Liquid for parameter substitution. Cannot coexist with `\u003cx-wa-header-media\u003e`.\n\n```xml\n\u003cx-wa-header\u003eYour order from {{company.name}}\u003c/x-wa-header\u003e\n```\n\nLiquid expressions inside the header are surfaced to Meta as positional parameters; provide examples under the `\"header\"` key in `template_examples`.\n\n### Media Header — `\u003cx-wa-header-media\u003e`\n\nImage / video / document header. Format is auto-detected from the file extension in `src`:\n\n| Extension | Meta format |\n|---|---|\n| `.pdf` | `DOCUMENT` |\n| `.mp4` | `VIDEO` |\n| any other | `IMAGE` |\n\n```xml\n\u003cx-wa-header-media src=\"https://cdn.example.com/banner.png\" /\u003e\n```\n\n`src` is set to a working URL during draft authoring; the media handle is generated programmatically at submission time, not from XML.\n\n### Body — `\u003cx-wa-body\u003e`\n\nRequired. The main message text. Supports Liquid for personalization.\n\n```xml\n\u003cx-wa-body\u003eHi {{customer.first_name | default: \"there\"}}, your order {{event.order_id}} is confirmed.\u003c/x-wa-body\u003e\n```\n\nNewlines are okay inside `\u003cx-wa-body\u003e` and are a good way to separate short lines or paragraphs in chat-style copy. Keep the newlines directly in the body text; do not use HTML `\u003cbr\u003e` tags.\n\nNo other HTML belongs in a WhatsApp component either — body, header, footer and button labels are all plain text. A tag wrapping text (`\u003cb\u003e`, `\u003cspan\u003e`) fails the parse and names the tag, rather than quietly dropping what it wrapped; a tag holding no text is dropped. Use WhatsApp's own `*bold*` and `_italic_` markers for emphasis.\n\nProvide example values under the `\"body\"` key in `template_examples`. Keep total body text under ~1024 characters (Meta's hard limit).\n\n### Footer — `\u003cx-wa-footer\u003e`\n\nPlain text, single line. **Liquid is not supported** in footers — use literal text only.\n\n```xml\n\u003cx-wa-footer\u003eReply STOP to unsubscribe\u003c/x-wa-footer\u003e\n```\n\n### Buttons — `\u003cx-wa-buttons\u003e`\n\nContainer for up to ~3 call-to-action buttons. Three button kinds, can be mixed in one `\u003cx-wa-buttons\u003e` block:\n\n**URL button** — opens a URL in the user's browser. URL may contain a single Liquid placeholder. Example values for the URL parameter go under the `\"button:url:\u003cidx\u003e\"` key (where `\u003cidx\u003e` is the 0-based position within the buttons block).\n\n```xml\n\u003cx-wa-url-button url=\"https://example.com/orders/{{event.order_id}}\"\u003eTrack Order\u003c/x-wa-url-button\u003e\n```\n\n**Phone-number button** — initiates a call. Phone number must be in international format.\n\n```xml\n\u003cx-wa-phone-number-button phone_number=\"+15551234567\"\u003eCall Support\u003c/x-wa-phone-number-button\u003e\n```\n\n**Copy button** — copies a code to the user's clipboard. `code` may contain Liquid. Buttons of this kind have no visible text. Example value under `\"button:copy_code:\u003cidx\u003e\"`.\n\n```xml\n\u003cx-wa-copy-button code=\"{{event.promo_code}}\" /\u003e\n```\n\n### Quick Replies — `\u003cx-wa-quick-replies\u003e`\n\nContainer for preset reply buttons. Each reply is a literal label — **Liquid is not supported in quick replies**.\n\n```xml\n\u003cx-wa-quick-replies\u003e\n  \u003cx-wa-quick-reply\u003eYes\u003c/x-wa-quick-reply\u003e\n  \u003cx-wa-quick-reply\u003eNo\u003c/x-wa-quick-reply\u003e\n  \u003cx-wa-quick-reply\u003eMaybe later\u003c/x-wa-quick-reply\u003e\n\u003c/x-wa-quick-replies\u003e\n```\n\nQuick replies and CTA buttons can both appear inside `\u003cx-wa-actions\u003e`. Meta collapses them into a single BUTTONS component, preserving source order.\n\n## Language Codes\n\nUse the underscore form Meta expects (BCP-47 with underscore separator), not hyphenated IETF form.\n\n| Code | Language |\n|---|---|\n| `en_US` | English (US) — most common default |\n| `en_GB` | English (UK) |\n| `es_ES` | Spanish (Spain) |\n| `es_MX` | Spanish (Mexico) |\n| `pt_BR` | Portuguese (Brazil) |\n| `pt_PT` | Portuguese (Portugal) |\n| `fr` | French |\n| `de` | German |\n| `it` | Italian |\n| `ja` | Japanese |\n| `ko` | Korean |\n| `zh_CN` | Chinese (Simplified) |\n\nIf unsure, ask the user for the target audience's locale. **Do not invent language codes** — Meta rejects unknown values.\n\n## Categories — When to Use Which\n\nWhatsApp templates have exactly two user-pickable categories. The choice determines whether Meta requires opt-in and what content is allowed.\n\n### `UTILITY`\nTransactional, account-related, or expected follow-ups to a user-initiated action. Meta approves these fastest and they have the broadest delivery rights.\n\n**Use when:** order confirmations, shipping updates, appointment reminders, OTP / verification codes, password resets, account alerts, support replies, receipts.\n\n**Do not use when:** the message is promotional in nature, even if it's dressed up as transactional.\n\n### `MARKETING`\nPromotions, announcements, offers, re-engagement, one-time sends. Requires explicit user opt-in. Meta scrutinizes content more strictly.\n\n**Use when:** sales, product launches, abandoned-cart nudges, re-engagement automations, one-time sends, event invitations.\n\n**Do not use when:** the message is purely informational about a user-initiated action — that's `UTILITY`.\n\n\u003e **Mismatched category is a top rejection reason.** A promotional message mis-labeled as `UTILITY` will be rejected, and repeat violations can throttle the WABA's sending rights. If the body contains promotional language (\"save\", \"off\", \"deal\", \"exclusive offer\", \"limited time\"), it almost certainly belongs in `MARKETING`.\n\n## Patterns\n\n### Order Confirmation (UTILITY)\n```xml\n\u003cx-whatsapp\u003e\n  \u003cx-wa-header\u003eOrder Confirmed\u003c/x-wa-header\u003e\n  \u003cx-wa-body\u003eHi {{customer.first_name | default: \"there\"}}, your order #{{event.order_id}} for {{event.order_total | money}} is confirmed.\n\nEstimated delivery: {{event.estimated_delivery | date: \"%B %d\"}}\u003c/x-wa-body\u003e\n  \u003cx-wa-footer\u003eThanks for shopping with us\u003c/x-wa-footer\u003e\n  \u003cx-wa-actions\u003e\n    \u003cx-wa-buttons\u003e\n      \u003cx-wa-url-button url=\"https://example.com/orders/{{event.order_id}}\"\u003eTrack Order\u003c/x-wa-url-button\u003e\n    \u003c/x-wa-buttons\u003e\n  \u003c/x-wa-actions\u003e\n\u003c/x-whatsapp\u003e\n```\n\n### Shipping Update with Media Header (UTILITY)\n```xml\n\u003cx-whatsapp\u003e\n  \u003cx-wa-header-media src=\"https://cdn.example.com/shipping-banner.png\" /\u003e\n  \u003cx-wa-body\u003e{{customer.first_name}}, your order #{{event.order_id}} is on its way!\n\nCarrier: {{event.carrier}}\nTracking #: {{event.tracking_number}}\nETA: {{event.eta | date: \"%A, %B %d\"}}\u003c/x-wa-body\u003e\n  \u003cx-wa-actions\u003e\n    \u003cx-wa-buttons\u003e\n      \u003cx-wa-url-button url=\"{{event.tracking_url}}\"\u003eTrack Package\u003c/x-wa-url-button\u003e\n      \u003cx-wa-phone-number-button phone_number=\"+15551234567\"\u003eContact Support\u003c/x-wa-phone-number-button\u003e\n    \u003c/x-wa-buttons\u003e\n  \u003c/x-wa-actions\u003e\n\u003c/x-whatsapp\u003e\n```\n\n### OTP / Verification Code with Copy Button (UTILITY)\n```xml\n\u003cx-whatsapp\u003e\n  \u003cx-wa-body\u003eYour {{company.name}} verification code is {{event.otp_code}}.\n\nThis code expires in {{event.expires_in_minutes}} minutes. Do not share it with anyone.\u003c/x-wa-body\u003e\n  \u003cx-wa-footer\u003eThis code is for your account only\u003c/x-wa-footer\u003e\n  \u003cx-wa-actions\u003e\n    \u003cx-wa-buttons\u003e\n      \u003cx-wa-copy-button code=\"{{event.otp_code}}\" /\u003e\n    \u003c/x-wa-buttons\u003e\n  \u003c/x-wa-actions\u003e\n\u003c/x-whatsapp\u003e\n```\n\n### Appointment Reminder with Quick Replies (UTILITY)\n```xml\n\u003cx-whatsapp\u003e\n  \u003cx-wa-header\u003eAppointment Reminder\u003c/x-wa-header\u003e\n  \u003cx-wa-body\u003eHi {{customer.first_name}}, this is a reminder about your appointment with {{event.provider_name}} on {{event.appointment_date | date: \"%A, %B %d at %I:%M %p\"}}.\u003c/x-wa-body\u003e\n  \u003cx-wa-actions\u003e\n    \u003cx-wa-quick-replies\u003e\n      \u003cx-wa-quick-reply\u003eConfirm\u003c/x-wa-quick-reply\u003e\n      \u003cx-wa-quick-reply\u003eReschedule\u003c/x-wa-quick-reply\u003e\n      \u003cx-wa-quick-reply\u003eCancel\u003c/x-wa-quick-reply\u003e\n    \u003c/x-wa-quick-replies\u003e\n  \u003c/x-wa-actions\u003e\n\u003c/x-whatsapp\u003e\n```\n\n### Promotional Announcement (MARKETING)\n```xml\n\u003cx-whatsapp\u003e\n  \u003cx-wa-header-media src=\"https://cdn.example.com/sale-banner.png\" /\u003e\n  \u003cx-wa-body\u003e{{customer.first_name}}, our biggest sale of the year is here!\n\nGet {{event.discount_percent}}% off everything through {{event.sale_end_date | date: \"%B %d\"}}.\u003c/x-wa-body\u003e\n  \u003cx-wa-footer\u003eUse code at checkout\u003c/x-wa-footer\u003e\n  \u003cx-wa-actions\u003e\n    \u003cx-wa-buttons\u003e\n      \u003cx-wa-copy-button code=\"{{event.promo_code}}\" /\u003e\n      \u003cx-wa-url-button url=\"{{event.shop_url}}\"\u003eShop Now\u003c/x-wa-url-button\u003e\n    \u003c/x-wa-buttons\u003e\n  \u003c/x-wa-actions\u003e\n\u003c/x-whatsapp\u003e\n```\n\n### Re-engagement (MARKETING)\n```xml\n\u003cx-whatsapp\u003e\n  \u003cx-wa-body\u003eWe miss you, {{customer.first_name}}!\n\nIt's been a while since your last visit. Here's {{event.discount_percent}}% off your next order: {{event.promo_code}}.\n\nValid through {{event.expires | date: \"%B %d\"}}.\u003c/x-wa-body\u003e\n  \u003cx-wa-actions\u003e\n    \u003cx-wa-buttons\u003e\n      \u003cx-wa-url-button url=\"{{event.shop_url}}\"\u003eCome Back\u003c/x-wa-url-button\u003e\n    \u003c/x-wa-buttons\u003e\n    \u003cx-wa-quick-replies\u003e\n      \u003cx-wa-quick-reply\u003eNot interested\u003c/x-wa-quick-reply\u003e\n    \u003c/x-wa-quick-replies\u003e\n  \u003c/x-wa-actions\u003e\n\u003c/x-whatsapp\u003e\n```\n\n## Template Examples (`template_examples`)\n\nWhen a component contains Liquid expressions Meta sees as positional parameters, Meta needs concrete example values to render the template during review. Example values are NOT used for delivery — only for Meta's approval rendering.\n\nThese examples **cannot be set through the create endpoint** (`POST .../design_studio/whatsapp` ignores them) — the user adds them in the Design Studio editor before submitting to Meta. The shape below is what the editor expects.\n\nMap shape: `{ \"\u003ccomponent_key\u003e\": { \"\u003cparam_id\u003e\": \"\u003cexample value\u003e\" } }`. Parameter IDs must include the braces, e.g. `\"{{1}}\"`; do not use bare numeric keys like `\"1\"`.\n\n| Component | Key | Param IDs |\n|---|---|---|\n| Header (text) | `\"header\"` | `\"{{1}}\"`, `\"{{2}}\"`, ... (positional) |\n| Body | `\"body\"` | `\"{{1}}\"`, `\"{{2}}\"`, ... (positional) |\n| URL button at index N | `\"button:url:N\"` | `\"{{1}}\"` (single URL placeholder allowed) |\n| Copy button at index N | `\"button:copy_code:N\"` | `\"{{1}}\"` (Meta requires example even for static codes) |\n\nPhone-number buttons, footers, and quick-reply buttons take no examples.\n\nExample `template_examples` mapping for the Order Confirmation pattern (the user enters these in the editor):\n```json\n{\n  \"template_examples\": {\n    \"body\": {\n      \"{{1}}\": \"Jordan\",\n      \"{{2}}\": \"ORD-48211\",\n      \"{{3}}\": \"$84.99\",\n      \"{{4}}\": \"March 15\"\n    },\n    \"button:url:0\": {\n      \"{{1}}\": \"ORD-48211\"\n    }\n  }\n}\n```\n\nIf unsure what examples to provide, use realistic-but-fake values (`\"Jordan\"` not `\"USER_1\"`).\n\n## Meta Approval-Policy Callouts\n\nAnticipate these rejection reasons. A clean first draft saves a Meta review cycle (which can take 24+ hours).\n\n- **Promotional content in `UTILITY`** — see § Categories. If the body could be rewritten as an ad, it's `MARKETING`.\n- **Missing parameter examples** — when a component contains `{{liquid}}` Meta sees as parameters, the corresponding `template_examples` entries must be present. Without them Meta cannot render and will reject.\n- **URLs without `https://`** — bare `example.com` or `http://example.com` URLs are rejected. Always use `https://` on URL buttons and media headers.\n- **Forbidden categories of content** — gambling, regulated medical claims, weapons, tobacco, illegal services. Decline to draft these and surface to the user.\n- **Generic / spammy phrasing** — phrases like \"Hi Customer\" (no personalization) or \"click here\" without context get rejected for lack of utility. Use `{{customer.first_name}}` when name is known; write specific calls-to-action.\n- **Excessive emojis or all-caps** — Meta rejects messages that look spammy. Use emojis sparingly (1-2 per message) and avoid SHOUTING.\n- **Phone numbers embedded in body text** — phone numbers must be modeled as `\u003cx-wa-phone-number-button\u003e`, not written into the body as `Call us at 555-1234`. Move phone numbers to the actions block.\n- **Both header types present** — only one of `\u003cx-wa-header\u003e` or `\u003cx-wa-header-media\u003e` is allowed per template. The parser rejects templates that include both.\n- **Liquid in unsupported components** — footers and quick-reply labels do not support Liquid. Drafts that put `{{variables}}` there will surface validation errors.\n\n### Tone\n\n- Open with personalization when possible: `Hi {{customer.first_name | default: \"there\"}}, ...`\n- Be concise — WhatsApp is a chat surface, not email. Aim for 2-5 short lines in the body.\n- One clear call-to-action per template (or none for pure status updates).\n- Match the category's voice — `UTILITY` is calm and direct; `MARKETING` can be more energetic.\n\n## Naming Conventions\n\nThe `name` field is used internally and surfaced to the user. Conventional shape: lowercase with underscores, descriptive of the template's purpose.\n\n| Good | Bad |\n|---|---|\n| `order_confirmation` | `Template1` |\n| `abandoned_cart_24h` | `marketing_message` |\n| `appointment_reminder` | `whatsapp_msg` |\n| `password_reset_otp` | `template_v2_final_FINAL` |\n"},"hashes":{"SKILL.md":"a51cfb54f87da2022e58f1bc059a40cd6134463c97371f2fedbd50828a673438","brand_document.md":"0262a593c470d287b61260324b285220dcd9a6f078dde17d061d369998e9e459","bulk_apply_variant.md":"2b1bb3472ea533386140cc8b99479883a96aafc4e1c97aa3538be0b104f9b47f","component_syntax.md":"77f0befb126bf448047e442908470a0a71479b87d39cad7c5d4eab660987dd6f","components_reference.md":"750168c27b13bda44a2d1b00ebeb5bf1a5ea37bacb72e0602351bfb39e2ab7d3","custom_components.md":"ec6df85910db7d8bf7778ca7250072e51dfe56442a9986dcfda939a50e9c792d","design_system_audit.md":"0831dff33767ecea932ee87f8f8d471406067636c30a79c43edeb2c43ecb6d16","drafts.md":"5bb963ffc5c407144adf8060889bdd096802f0443b242d0d48e6a032567429d2","email_accessibility.md":"497c9ac8be707cdcfa3a39c4516cad2ba2f3471dab64f736b976aa92198e2c84","email_craft.md":"c3e126d955cf6fee9c345095c1338fa3bf8dae8b9bc87bd24f241937497c01bc","email_patterns.md":"63d9f64863aa530d49d197d6e6e17e2879c9cee88ed7714612146078d1cb192f","email_patterns/abandoned_cart.md":"07161c7cbe6c22e969f47723fbc4b0c3259f93480f36a265996b864215a4db51","email_patterns/account_lifecycle.md":"9faf22e81337e6a544038795b78a5b09f0b9e86b8c79bbb8655b012ff9e4a9d1","email_patterns/account_security.md":"15d20177e1964247ebfe020496495961e33de08d704e20c4e3f9feca9f3593cb","email_patterns/alerts_reminders.md":"758b5c341fb44c417908fcb16b8f907985828e64f2e36802b80e661fb7d533b7","email_patterns/apology_support.md":"bb1fc7e88c0e205d3411dfafdf93f2bc8fd5a06b9e5dc3d98b5414e99720abe2","email_patterns/billing_subscription.md":"4c26944b7596569b997542a16f943ae6990300875f2626b0a1a369eb69263460","email_patterns/colleague_announcement.md":"c47513e38393b3cb2f875776b7f2038fb500a9e9440d3fd0ddd7a8bf710dff0d","email_patterns/content_education.md":"85fe3eeab3a7cd21ca9a5bab422f7e8a9ab4d3433f97492e6512763d4b167ca0","email_patterns/event_webinar.md":"e51ff3d238f20e83ae9cc3db06ed2b572d04979a906cd2c33f03924a3ce67f7b","email_patterns/loyalty_referral.md":"a6f94d925fa66e5da00f179ad1e68748de17041785f3d34d6aed30a2f1a58adb","email_patterns/milestones_recap.md":"105145a14faf2029788386fce09e8a026eccede47447d771b5da1b5516813300","email_patterns/newsletter_digest.md":"ef8bd3fd2f4f8b7b8a7fac571703c401bf4b87f564debfa40e231140d5d3d373","email_patterns/onboarding_activation.md":"16fb5303a1f8423c3402470da54399d2a4bb4a18c1c285bf3a645370581dc3de","email_patterns/order_confirmation.md":"e0e3da607e02dd9dca35564a9bd1c204e34513160a3697a3d0241bed6ece7011","email_patterns/product_launch.md":"6384625beb636bddfde7356e5c0b0a470206269fc54226f5c8f718c0cc435e67","email_patterns/promotion_sale.md":"200316a97cf1cb8e225ebe8eff377c11e5d78db041828e1480b18a6b1247e66a","email_patterns/re_engagement.md":"2eefcdcc151d216303ec9d030fa24f11d5cd81d288fe5aebe552816381c823f2","email_patterns/recommendations.md":"377f47152b34af933f501c09152f527d0219a4817627cdc9ddfe640e61d7df04","email_patterns/review_feedback.md":"18191b6781d0d876954030ad86e19053cee09ec3d55603decb782ef8f163b9a1","email_patterns/shipping_delivery.md":"c4498c11e097cc60154a31b846037dcfefc3c6ce1788b0641dbf33b238a50ffc","email_patterns/welcome.md":"c37532251ad1a29fedcd973d93779ace0b8565fb17bca9905ea5628f8bec1768","email_review.md":"b2719acfcea76dcc1db2f52aae1ed00875bf115d3818f48bc8184fdef686651b","global_styles.md":"239c0904374a93feecf6516543caffa7504d9b13c107d305c7ddcbe41a437a1e","in_app.md":"2366f9f1e2130f39354f9fbc43efe181c74d4cda589cf90d894526acc34e91a7","inbox.md":"6f726a807fd392acc0c94fe2a9729ba658672b744bd9f3aaa498f4f0ad05a64d","inbox_previews.md":"bc863f1c5d35456a5f036c9eec103e671b4c019a66143073defb235dd71cdc2d","journeys.md":"d9ef1f58fc651291648611191fd9d0246481800407102ff7b1dadbad38748202","mass_replace_value.md":"fea460a08a72af953c0af40241646ecf53c2a107dedb8b816895628eeeb7fcc0","nodes.md":"99c98081998c09b8323a788b1e6e8887c883ad988c63f6a6f1748373f54e409f","nodes_advanced.md":"cc0cfd96ca7cbaad466d1d69e9054f0e46822cd636303e757a27194bf1a58a2a","org_patterns.md":"4e07e87a853ea8e112869f159980e91c2003436c7606c3bc12106823c0f4b0b7","promote_component_variants.md":"a94f2b97495eb54e0a39c64eccf686647e3f8a1da5602bf9fe6a98249b14b0a6","promote_design_tokens.md":"60339dce43313d970b6868e6d5a674e412a09377372f75baec21c56344df65d0","sign_up_forms.md":"adbe2e9fd039eb4cb1c8042de3fd87a38287a09a483417e3f25d9242edcc2335","visual_verification.md":"20a7ac53aa779c62107dc49f13dda6818897d38b3ca8442f5873c948fc3539f0","whatsapp_patterns.md":"69995d50496f426b8e10ad554acc9ea950597078952c2b46e0bda4b317a8c9b8"},"sizes":{"SKILL.md":50890,"brand_document.md":14600,"bulk_apply_variant.md":8965,"component_syntax.md":21626,"components_reference.md":26407,"custom_components.md":31028,"design_system_audit.md":7370,"drafts.md":6481,"email_accessibility.md":14950,"email_craft.md":29459,"email_patterns.md":13856,"email_patterns/abandoned_cart.md":25135,"email_patterns/account_lifecycle.md":37560,"email_patterns/account_security.md":32213,"email_patterns/alerts_reminders.md":24892,"email_patterns/apology_support.md":21296,"email_patterns/billing_subscription.md":36657,"email_patterns/colleague_announcement.md":4742,"email_patterns/content_education.md":19999,"email_patterns/event_webinar.md":34501,"email_patterns/loyalty_referral.md":27503,"email_patterns/milestones_recap.md":17549,"email_patterns/newsletter_digest.md":22314,"email_patterns/onboarding_activation.md":32052,"email_patterns/order_confirmation.md":25677,"email_patterns/product_launch.md":21246,"email_patterns/promotion_sale.md":21102,"email_patterns/re_engagement.md":25446,"email_patterns/recommendations.md":22001,"email_patterns/review_feedback.md":24039,"email_patterns/shipping_delivery.md":46863,"email_patterns/welcome.md":20577,"email_review.md":16153,"global_styles.md":12061,"in_app.md":22103,"inbox.md":10926,"inbox_previews.md":16669,"journeys.md":22806,"mass_replace_value.md":12399,"nodes.md":23552,"nodes_advanced.md":9746,"org_patterns.md":10170,"promote_component_variants.md":11557,"promote_design_tokens.md":8832,"sign_up_forms.md":8655,"visual_verification.md":6061,"whatsapp_patterns.md":19205}},{"path":"recipes","name":"Recipes","description":"Non-API workflow and language-reference playbooks — consultative analysis for subjective questions, automation performance analysis workflow, goal-based segment building, mobile SDK geofencing, and Liquid templating syntax. Cross-referenced from the API skills above.","content":"# Recipes\n\nWorkflow guides and language references that don't map to a single API. Read the relevant sub-file before answering questions in these areas — each one is a self-contained playbook.\n\n\u003c!-- subfile-index: rows are auto-annotated at serve time with the plan each\n     linked file requires, and dropped when a feature flag hides the file; the\n     gate lives in each sub-file's frontmatter (minimum_plan / feature_flag),\n     not here. Don't restate it in a row. --\u003e\n| File | When to read |\n|------|--------------|\n| **analysis.md** | The user asks a subjective or evaluative question (rank, compare, \"which is best/worst\") without specifying the criteria. Read this **before** picking any metric. |\n| **analyzing_automations.md** | The user asks you to analyze automation performance, troubleshoot delivery or bounces, understand metrics, or compare automations. |\n| **diagnosing_a_failure.md** | The user reports something broken — sends failing, a message not arriving, an automation not firing, a change that didn't take. Read this **before** naming a cause: it is the evidence discipline (read back what you wrote, don't state an untested cause, a failed remedy refutes the theory, escalate only after the cheap checks), and it routes on to the symptom tree. |\n| **diagnosing_workspace_health.md** | The user asks about workspace health, performance, processing delays, queue backlogs, or any alert from the workspace health dashboard — including the \"Continue in agent\" handoff. |\n| **creating_a_goal.md** | The user wants to create a new goal — including the \"Build with agent\" entry point from the goals empty state. Semantic-first flow: existing-goal signal, intent, measure, impact, sources. |\n| **creating_a_sign_up_form.md** | The user wants to build a sign-up form end to end — create, author fields and success state, enable hosted/embedded/in-app outputs, launch (with confirmation) and hand back the links — or change, stop or restart a launched one. |\n| **goal_segments.md** | The user wants to build a segment based on goal completion (completed, didn't complete, almost completed, repeat completers). Pairs with `fly-api/data_driven_segments.md`. |\n| **mobile_geofencing.md** | The user wants to enable or troubleshoot mobile geofencing on Android, iOS, React Native, Flutter, or Expo. Covers native modules, config-plugin/wrapper opt-ins, permissions, background delivery, events, and the end-to-end diagnostic chain. |\n| **periodic_send_report.md** | You're producing a recurring report of what was sent / how it performed over a time window (e.g. a weekly automation report routine). Read this before deciding what to query — it prevents reporting on stale messages and missing recent sends. |\n| **auditing_from_addresses.md** | The user asks whether any email From address sends, or could send, from an unverified domain — dynamic or Liquid senders, a sender audit, or an undeliverable saying the from address does not belong to a verified sending domain. Gives a verdict per address (broken now, at risk, safe, can't tell) backed by evidence. |\n| **liquid_syntax.md** | Any Liquid templating question — attributes, objects, filters, control flow, escaping, common pitfalls. |\n\u003c!-- /subfile-index --\u003e\n\nThese skills don't make Customer.io API calls themselves — they teach decision-making, workflows, or templating syntax. For API mechanics, read the relevant `fly-api/`, `cdp-api/`, or `design-studio/` sub-file.\n","files":{"analysis.md":"# Consultative Analysis\n\nUse this skill when a user asks a subjective question — one that requires a judgment call about what \"good,\" \"bad,\" or \"successful\" means. This includes any question where the user is asking you to rank, compare, or evaluate without specifying the criteria.\n\n## Your Job\n\nRecommend a meaningful way to evaluate, explain why, and let the user choose. Don't silently pick a metric and present it as the answer — and don't default to vanity metrics. Your role is to make sure the numbers answer the right question.\n\n## When This Applies\n\n- \"What are my most successful automations?\"\n- \"Which segments are performing best?\"\n- \"Show me my worst one-time sends\"\n- \"What's working and what isn't?\"\n- Any ranking or comparison where \"best\" or \"worst\" isn't defined\n\nThis applies across the product: automations, segments, one-time sends, broadcasts, profiles, deliveries.\n\n## When to Skip This\n\n- The user already specified the metric: \"Which automation has the highest click rate?\"\n- The user is asking a factual question, not an evaluative one: \"How many automations are active?\"\n- You're running unattended in a routine/cron (see below)\n\n## How to Do It\n\n### Lead with a recommendation\n\nHave an opinion. Based on what you know about the user's workspace and goals, recommend which lens is most useful for their situation. If the entity has goals configured, lead with conversion rate. If not, lead with engagement (CTOR or click rate). Present your recommendation alongside 1-2 alternatives so the user can choose.\n\n### Explain the tradeoffs\n\nUse a framework like this to help the user understand what different metrics actually tell them:\n\n| Tier | Metrics | What it tells you |\n|------|---------|-------------------|\n| **Outcome** (strongest signal) | Conversion rate, revenue per message, goal completions | Did it actually drive the desired result? |\n| **Engagement** (good signal) | Click-to-open rate, click rate | Are people interested and taking action? |\n| **Reach** (weakest signal) | Sends, opens, journey starts | How many people did it touch? Scale, not effectiveness. |\n\nAdapt the framework to the entity type. For segments, \"success\" might mean conversion rate of members vs. non-members. For one-time sends, it might be sustained engagement over time vs. one-off open rates.\n\n### Avoid vanity metrics as a default\n\nRaw volume (sends, journey starts, total opens) measures scale, not effectiveness. An automation that sent 100K emails with 0.1% conversion isn't comparable to one that sent 5K with 15% conversion — those are different questions entirely. Vanity metrics are easy to pull but rarely tell the real story.\n\n### Let the user choose\n\nOffer to show results across multiple dimensions, or let them pick the lens that matters most. Once they choose, go deep.\n\nMake the recommendation before running a ranking, after reading the entity's data skill (Getting the Data, below) so you recommend metrics it can actually rank by. If you pull data to inform it, that query is already a ranking: order it by the recommended rate with the data skill's `sent \u003e= 100` floor, not by sends or opens.\n\n## Routines and Cron\n\nWhen running unattended in a routine, there is no user to answer clarifying questions. Skip the consultative step and use smart defaults:\n\n- Lead with conversion rate if goals are configured\n- Otherwise use click-to-open rate (CTOR)\n- Note the metric you used in your output so the user understands the ranking\n\n## Getting the Data\n\nThis skill defines the metric — it never fetches data. Once the metric is chosen (by the user, or by the cron defaults above), STOP and read the data skill for the entity BEFORE writing any query:\n\n| Entity being ranked/compared | Read |\n|---|---|\n| Automations, one-time sends (broadcasts), transactional messages | `fly-api/deliverability.md` |\n| Segment growth / membership change | `fly-api/deliverability.md` (segment_events) |\n| Page/screen views, site traffic | `fly-api/site_activity.md` |\n| Profiles created/deleted/attribute changes | `fly-api/profile_changes.md` |\n| Data-pipeline / ingest failures | `fly-api/pipeline_health.md` |\n\nNever rank or compare entities from the standard metrics endpoints or ad-hoc SQL when a governed view above covers the question — the data skills carry the ranking guardrails (send-cohort dating, minimum-volume floors, bounded unique rates) that ad-hoc pulls miss, and skipping them produces confidently wrong rankings.\n","analyzing_automations.md":"---\nminimum_plan: essentials\n---\n\n# Analyzing Automation Performance\n\nUse this skill when a user asks you to analyze an automation, troubleshoot delivery issues, understand metrics, or compare automation performance.\n\n## Workflow\n\nFollow these steps in order. Don't skip steps — each one builds context for the next.\n\n### Step 0: Clarify Success Criteria\n\nIf the user asks a subjective question about automation performance — ranking, comparing, or evaluating without specifying the criteria — read the analysis skill first: `read(\"skills/recipes/analysis.md\")`. Follow its consultative workflow before proceeding to Step 1.\n\nSkip this step if the user already specified the metric they care about (e.g., \"which automation has the highest click rate?\").\n\n### Step 1: Get Automation Details\n\nRead the automation to understand its structure:\n\n```\nread(\"/workspace/campaigns/{id}\")\n```\n\nNote the automation type (broadcast vs triggered), state (active/draft/archived), channels, action/message IDs, trigger filters, and any branch actions (conditional, multi-split, random cohort). You'll need the structure to interpret step-to-step count differences later — branches and trigger filters route profiles away by design.\n\n### Step 2: Get Metrics\n\nRead the automation metrics:\n\n```\nread(\"/workspace/campaigns/{id}/metrics\")\n```\n\nCalculate these key ratios from the metrics:\n\n| Ratio | Formula | Concern threshold (email only) |\n|-------|---------|-------------------|\n| Delivery rate | delivered / sent | \u003c 95% |\n| Bounce rate | bounced / sent | \u003e 2% |\n| Failure rate | failed / sent | \u003e 2% |\n| Open rate | opened / delivered | \u003c 15% |\n| Click rate | clicked / delivered | \u003c 3% |\n| Click-to-open rate | clicked / opened | \u003c 10% |\n| Spam complaint rate | spammed / delivered | \u003e 0.1% |\n| Unsubscribe rate | unsubscribed / delivered | \u003e 0.5% |\n| Conversion rate | converted / delivered | context-dependent |\n\n**The concern thresholds above are email benchmarks — never apply them to any other channel.** For push, SMS, webhook, in-app, and other non-email actions, read that channel's metrics from `.campaign_metrics.\u003cchannel\u003e` and compare only against the workspace's own history for the same channel. In-app messages in particular are only delivered/opened when the person opens the app while the message is active, so an in-app delivery rate far below the 95% email threshold is structural, not a deliverability problem — do not flag it.\n\n**Always use human-only metrics** (not total) to exclude bot activity. If total opens far exceed human opens, flag bot activity as context.\n\n**Label every count and rate you report as unique people or total events.** Event counts include repeats — one person opening five times is five opens — so event-based rates can exceed 100%. A rate over 100% is a repeat-event artifact; report it as such, never as \"% of recipients\".\n\n**Match the time window to the question.** Totals default to lifetime; pass `start`/`end` for the user's date range and state it (see `automation_metrics.md`). When reconciling against the UI's open/click columns (which are *total*, not human), report both.\n\n### Step 3: Review Templates\n\nFor each message/action in the automation, read the template:\n\n```\nread(\"/workspace/templates/{template_id}\")\n```\n\nReview subject lines, body content, CTAs, personalization, and links.\n\n### Step 4: Categorize the Problem\n\nFor email actions, check these conditions in order (first match wins). For non-email channels the fixed thresholds don't apply — categorize by comparing each rate to the workspace's own baseline for that channel:\n\n1. **Deliverability issue** — delivery rate \u003c 95% or bounce rate \u003e 2% or failure rate \u003e 2%\n2. **Spam complaint issue** — spam complaint rate \u003e 0.1%\n3. **Low engagement** — click rate \u003c 3% or click-to-open rate \u003c 10%\n4. **Low open rate** — open rate \u003c 15%\n5. **High unsubscribe rate** — unsubscribe rate \u003e 0.5%\n6. **Low conversion** — conversion rate below expected threshold\n7. **Performing well** — no major issues detected\n\nThen follow the appropriate investigation below.\n\n### Step 5: Investigate\n\n#### Deliverability Issues\n\n1. **Read the actual bounce reasons before attributing a cause.** Pull a sample of the automation's bounced deliveries (`GET .../deliveries` scoped with `campaign_id` — or `newsletter_id` for a broadcast — plus `metric: \"bounced\"`; don't pull workspace-wide bounces, which belong to other sends and misattribute the cause) and read each one's `meta.reason` — the receiving server's reply, carried on the delivery in that list (usually the raw provider reply; sometimes a generic fallback, or absent when the provider sent no detail — the single-delivery details view does not carry it for bounces). Sample several; the replies name the cause. Classify before theorizing:\n\n   | Bounce reason says | Cause | What helps |\n   |---|---|---|\n   | \"user unknown\", \"mailbox not found\", `5.1.1` | Invalid addresses — list quality | Fix the source that collects/imports addresses; stop sending to them |\n   | `5.7.1` \"rejected by policy\", \"not authorized\", \"prohibited\" | Recipient-side policy — the receiving server refuses mail for that mailbox (common for school or corporate accounts restricted from external mail) | Nothing sender-side fixes this; exclude those recipients |\n   | Rejection citing SPF, DKIM, or DMARC | Authentication failure | Confirm auth records are actually passing (step 5) |\n   | Names a blocklist or the sending IP/domain's reputation | Sender reputation | The only case where IP reputation work applies — and what you offer is sender-side: list hygiene, volume pacing, content and auth. See the blocklist guardrail below before offering any remedy |\n   | \"mailbox full\", \"try again later\", `4.x.x` deferrals | Temporary (soft bounce) | Usually self-resolves; no action needed |\n\n2. Look at metrics over time — did delivery drop suddenly (ISP block, auth failure) or gradually (reputation, list quality)? Normal variance is 1–3% day-to-day; a drop \u003e5% suddenly or \u003e10% over a week is a real problem worth investigating.\n3. Find similar automations and compare their delivery rates to establish a baseline\n4. If the problem is workspace-wide (not just this automation) **and the bounce reasons point at reputation or authentication**, check domain authentication (SPF, DKIM, DMARC) and IP reputation\n5. Suspect authentication specifically when the drop is sudden right after a sending-domain or auth change, or failures concentrate on one mailbox provider (e.g. Gmail or Yahoo only). Setup alone doesn't prove it works — confirm SPF/DKIM/DMARC are actually passing rather than assuming\n6. **Identify which ISP is blocking**: the metrics API doesn't break down by ISP — export delivery logs, group by recipient domain, and look for patterns (e.g. all @gmail.com addresses bouncing)\n7. **Isolate content as the cause**: send an identical automation but change the subject line, remove all links, or send plain text only — then compare delivery rates to determine if content is the issue\n8. **Escalate to Customer.io support** only if: delivery rate is below 80% after checking everything, there's a sudden drop with no configuration changes, a specific ISP is blocking despite verified auth, or a working automation and failing automation are otherwise identical. Have ready: automation IDs, time-series data showing when the drop occurred, bounce types and failure messages, and what you've already checked. Do not suggest a support ticket just because the bounce count is high — when the bounce reasons identify the cause, give the fix for that cause instead.\n\n**Dedicated IP guardrail.** Never name the shared IP pool as the culprit, recommend a dedicated IP, or route the user to support or their account manager for one, unless the bounce reasons themselves show a sender-reputation problem — rejections citing a blocklist or the sending IP's reputation, across multiple recipient domains. Recipient-side policy rejections, invalid addresses, authentication failures, and content filtering are all unaffected by which IP sends the mail; a dedicated IP also requires sustained volume and warm-up, so for most senders it makes deliverability worse, not better.\n\n**Blocklist and pool-placement guardrail.** A blocklist mention in a bounce reply is not work you can hand to Customer.io. Never offer to file a delisting request or say the deliverability team can file one, and never suggest the user ask to be moved to \"healthier\" IPs or have their traffic rotated within the shared pool. Minor and regional lists (a single-country spam list, one ISP's temporary block) are not delisted on request and in some cases cannot be at all, and shared-pool placement can reflect the sender's type, so it isn't reassigned on request either. A rotation happens only when Customer.io's own analysis says it benefits the sender — never because you offered it, which just sets up an expectation that gets refused. Weigh a listing by its reach before raising it at all: one small ISP rejecting mail is not the cause of a workspace-wide delivery problem, and naming individual pool IPs to the user invites exactly the request we can't fulfill. Give the sender-side fix instead — stop sending to invalid and unengaged addresses, pace volume, and fix the content or authentication signals that got the mail flagged.\n\n#### Low Open Rate\n\n1. Look at open trends over time\n2. Compare to similar automations' open rates\n3. Review subject lines: compelling? Clear? Appropriate length? Spam trigger words?\n4. Check send time — was it optimal for the audience?\n5. Check from name/address — was it changed recently?\n6. Watch for the inbox-placement signature: a normal delivery rate but very low opens (especially alongside any spam complaints) usually means mail is landing in spam rather than the inbox — investigate content, links, and authentication, not delivery\n\n#### Low Engagement (Clicks)\n\n1. Look at click trends over time\n2. Compare to similar automations' click and click-to-open rates\n3. Review CTAs: clear? Prominent? Actionable?\n4. Check content relevance and mobile rendering\n5. Too many or too few links?\n\n#### Spam Complaints\n\n1. Look at when complaints occurred — immediate spike or gradual?\n2. Compare to similar automations' spam rates\n3. Review for misleading subject lines, excessive sales language, unclear unsubscribe options\n4. Check if audience is properly opted-in\n\n#### High Unsubscribes\n\n1. Look at unsubscribe trends\n2. Compare to similar automations\n3. Check send frequency — too often?\n4. Content relevance — does it match what subscribers signed up for?\n\n#### Low Conversion\n\n1. **Check the workflow structure before claiming a drop-off.** A step receiving fewer people than an earlier step is not automatically loss — conditional branches, multi-splits, random cohorts, and trigger filters route profiles away by design. If the count difference matches a branch split or trigger filter (Step 1), report it as routing, not drop-off.\n2. Calculate both conversion rate (converted/delivered) and click-to-conversion rate (converted/clicked)\n3. Identify where the funnel drops off:\n   - Low clicks but good click-to-conversion? → Content/CTA problem\n   - Good clicks but low click-to-conversion? → Landing page or offer problem\n4. Review CTA value proposition\n\n### Step 6: Summarize and Recommend\n\nPresent your findings:\n\n1. **Summary**: delivery performance, engagement metrics, content quality\n2. **Key issues**: 3-5 specific problems or opportunities, with supporting data\n3. **Recommendations**: concrete next steps ordered by impact, referencing specific metrics\n\n## Journey-Level Debugging\n\nWhen investigating why specific people didn't reach an action (not aggregate metrics), see `@skills/fly-api/automation_debugging.md`. The most common cause is the person being **unsubscribed** — not the previous action failing.\n\n## Important: Performance Is Relative\n\n**Never judge metrics against \"industry benchmarks.\"** Always compare against the workspace's own historical baseline.\n\n- **\u003c 5% change** from baseline: normal fluctuation, don't flag\n- **5-15% change**: worth monitoring\n- **15-30% change**: investigate\n- **\u003e 30% change**: critical issue\n\nWhen comparing automations, ensure you're comparing like-for-like:\n- Same day of week and time of day\n- Same channel type\n- Same audience type (new vs returning, active vs inactive)\n- Similar time period (seasonality matters)\n\n## Metric Pitfalls\n\n- **Bot activity**: If clicks exceed opens, bots are present. Always use human-only metrics.\n- **Rates over 100%**: Event counts include repeat events per person. A rate above 100% means total events were divided by people — switch to unique counts, or report it explicitly as a repeat-event artifact.\n- **Cross-channel benchmarks**: Never score one channel's rates against another channel's thresholds — in-app, push, and SMS have their own baselines, and the email table in Step 2 applies to email only.\n- **Branch/trigger-filter routing**: A \"massive drop-off\" between steps is often just a branch or trigger filter routing profiles down another path. Verify against the workflow structure before reporting it as loss.\n- **Small samples**: An automation with 100 sends is not statistically significant. Flag this.\n- **Mixed metrics**: When comparing automations, always use the same metric type (human vs total, unique vs event count).\n- **Time zone**: Metrics are calculated in Eastern Time. Account for this in time-series analysis.\n","auditing_from_addresses.md":"# Auditing From Addresses for Unverified Domains\n\nRead this when the user asks whether any email From address sends, or could send, from a\ndomain the workspace hasn't verified — \"are my dynamic From addresses safe\", \"which senders\nuse unverified domains\", a CSM-supplied audit prompt, or an undeliverable whose reason is\n`from address ... does not belong to a verified sending domain`. The answer is a **verdict\nper address, backed by evidence** — never a description of the Liquid alone.\n\nThe warehouse has no sender dimension, and the delivery list carries no From, so a \"list\nevery From address sent in the last 30 days\" census is not possible. When the user asks for\none, say so in a sentence, then run the audit below. Never answer from the delivery list: an\nempty or short list says nothing about which From addresses are configured to send.\n\n## Verdicts — assign exactly one per address\n\n| Verdict | Meaning |\n|---|---|\n| **Broken now** | A recent delivery, or the data the Liquid reads, holds an address on an unverified domain — or a fixed address on an unverified domain is used by any message, since every send from it is affected |\n| **At risk** | The domain can come from data, and every current value you checked is on a verified domain |\n| **Safe** | Every possible result is on a verified domain: the domain is fixed text, or the Liquid compares the domain against the verified domains |\n| **Can't tell** | The value comes from an event or trigger payload and the sampled deliveries don't settle it — name the exact check the user should run |\n\nA guard that only tests presence — `{% if x %}`, `!= blank`, `.size \u003e 0`, `!= \"\"` — does\n**not** make an address Safe. It catches a missing value, not a wrong domain, and it is the\ncommonest pattern in workspaces that are broken today. A display name that varies while the\naddress stays fixed is Safe.\n\n## Steps\n\n1. **Verified domains.** `cio api /v1/environments/$ENV_ID/domains`. Verified means DKIM,\n   SPF and the domain itself all verified. Also read `/mail_servers`: a workspace that sends\n   through custom SMTP manages its own domains, so say its unverified domains are not\n   blocked by Customer.io and the audit is advisory.\n2. **Sender identities.** `cio api /v1/environments/$ENV_ID/identities`, email type only.\n   A Liquid identity carries its raw template in `email`, and its `verified` is always\n   `undetermined` — judge it from the Liquid and the evidence, never from that field. A fixed\n   identity whose domain is missing from step 1 entirely is a finding too.\n3. **What uses each one.** For every Liquid or unverified identity:\n   `cio api \"/v1/environments/$ENV_ID/message_library_items?from_id=\u003cidentity id\u003e\"`. Each item\n   names its parent and `parent_status`. Do **not** page the whole message library to build\n   this — in large workspaces paging drops items. Live parents (running automations, active\n   transactional messages) rank above drafts and stopped ones; an identity nothing uses gets\n   one line at the end. One-time broadcasts that already sent are left out by default because\n   they won't send again; add `status[]=newsletter_sent` only when the user asks about past\n   sends.\n4. **Evidence, before any verdict.** By where the Liquid reads the address from:\n   - Profile attribute (`customer.\u003cname\u003e`): `GET .../attributes_values?attribute=\u003cname\u003e` —\n     up to 50 recent distinct values. An empty list can mean the attribute is hidden from\n     this user, not that it is unset; say which.\n   - Event or trigger field (`event.\u003cname\u003e`, `trigger.\u003cname\u003e`): find the event that triggers\n     the automation, then `GET .../event_attributes_values?ename=\u003cevent\u003e\u0026attribute=\u003cname\u003e`.\n     For nested fields, transactional messages, or an empty result, sample 20 or more recent\n     deliveries of each live message — `GET .../deliveries?action_id=\u003cid\u003e\u0026type=email` (or\n     `transactional_message_id=` / `newsletter_id=`), then `GET .../delivery_details/{id}` and\n     read `delivery_detail.from`.\n   - Object attribute: `GET .../object_attributes_values/:type_id?attribute=\u003ckey\u003e` (see\n     `fly-api/customers.md`).\n   - Snippet: `GET .../snippets/{id}` and read every address in its value.\n   - A delivery in state `undeliverable` whose `failure_message` says the from address does\n     not belong to a verified sending domain is **Broken now** on its own.\n\n   These are samples, so a clean result supports **At risk**, never Safe — only the Liquid\n   itself can make an address Safe.\n\n## Report\n\nLead with one line — \"2 broken now, 3 at risk, 6 safe\" — even when the user asked for a\ncensus or a fix. Then **Broken now**, **At risk** and\n**Can't tell**, live messages first, each with the identity, the field it reads, the messages\nusing it and the evidence you saw. Collapse Safe to a line per address. List identities that\nno message uses on their own line at the end, never under Safe — an unused address whose\ndomain comes from data is not Safe. State what you did not check (a sampled field, an\nunreadable payload) next to the verdict it limits.\n\nDescribe what happens to a Broken now send only as far as you have seen it. A delivery in\nstate `undeliverable` with the unverified-domain reason means Customer.io now blocks these\nsends. Without one, say the emails still go out through Customer.io's shared fallback\ndomain, fail DMARC, and likely land in spam or get rejected — not that they fail as\nundeliverable.\n\n## Fixes\n\n- **The field holds the sender's own team address** (an owner, CSM or account manager):\n  rewrite with a domain guard that falls back to a fixed verified address.\n\n  ```liquid\n  {% assign e = \u003cfield\u003e | strip | downcase %}{% assign d = e | split: \"@\" | last %}{% if d == \"\u003cverified-1\u003e\" or d == \"\u003cverified-2\u003e\" %}{{ e }}{% else %}\u003cfallback@verified-1\u003e{% endif %}\n  ```\n\n- **The field holds a customer's, guest's or agent's own email** (`guest_email`,\n  `sender_email`, `personalData.email`, `inviter_email`): don't patch the From. Send from a\n  fixed verified address and move the field into Reply-To — no guard can make someone\n  else's mailbox a domain the workspace owns.\n- **A fixed address on an unverified domain:** verify the domain, or move its messages to an\n  identity on a verified domain.\n\nReport only. Don't edit identities or templates during an audit — most affected messages are\nlive — even when the user asked you to fix them. For each Broken now or At risk address, show\nthe complete replacement Liquid, filled in with this workspace's verified domains and a real\nfallback address, and ask the user to approve it before changing anything.\n","creating_a_goal.md":"---\nminimum_plan: essentials\nfeature_flag: show-goals\n---\n\n# Creating a Goal\n\nGuided, semantic-first flow for helping a user create a standalone goal — the \"Build with agent\" entry point from the goals empty state lands here. This skill teaches the conversation flow and decision-making; for endpoint mechanics (fields, enums, refresh endpoints), run `cio skills read fly-api/goals`.\n\nThe flow mirrors the goal creation wizard in the app: **Measure → Impact (optional) → Sources (optional)**. Name and description are captured up front, not as a separate step.\n\n## Step 1: Look for existing signal first\n\nBefore asking the user anything, check whether existing automations or broadcasts already have a **conversion goal** configured — this is a per-automation/per-broadcast setting, separate from standalone goals, and it's the strongest signal for what this workspace already cares about measuring.\n\nAutomations and broadcasts share the same conversion field set:\n\n- `conversion` (bool) — whether conversion tracking is on at all.\n- `conversion_type` — `perform_event` (event-based) or `enter`/`exit` (segment-based). This is the field that tells you which shape to read next. (`identified` exists only on anonymous in-app broadcasts — automations and broadcasts never accept it.)\n- `conversion_event_name` + `conversion_event_filters` — set when `conversion_type` is `perform_event`.\n- `conversion_segment_id` — set when `conversion_type` is `enter`/`exit`.\n- `conversion_window` + `conversion_action` — attribution window in seconds, and which delivery event is credited: `receiving`, `opening`, or `clicking`. On automations an empty value means `receiving`; on broadcasts the field is **required** and an empty or omitted value is a 422.\n\nFor automations, set the goal with `update_type: \"conversion\"`; if profiles who convert should also exit the automation, recreate the goal criteria in `global_exit_conditions` with a second `update_type: \"exit_conditions\"` request (never by enabling `exit_on_conversion_matched` — there is no \"exit when goal is met\" option in any automation's UI) — run `cio skills read fly-api/automations.md` for the field list, validation rules, and the two-request ordering. For broadcasts, they're part of the `tracking` field group — run `cio skills read fly-api/messaging.md`.\n\nList automations and broadcasts via the CLI tool and check each one's conversion fields. Check the list response first — don't fetch every automation/broadcast individually if the list call already returns these fields.\n\nIf you find relevant conversions already configured, lead with them:\n\n\u003e \"Hey, we found some potential goals already set up in your workspace — want to start with one of these, or build a new one?\"\n\nPresent each candidate briefly (which automation/broadcast, and what it converts on — the event or segment). If the user picks one, use its conversion definition as the starting point for the goal's Measure step (Step 3) rather than starting from scratch.\n\nIf nothing relevant turns up, or the user wants something new, move to Step 2.\n\n## Step 2: Ask what they're trying to measure\n\nDon't ask for a specific event, segment, or attribute first — ask what outcome they care about, in plain words. For example: \"What are you trying to measure? (e.g. purchases, trial upgrades, demo bookings, reactivation)\"\n\nFrom their answer, draft:\n- **Name** — short, human-readable (e.g. \"Trial to paid conversion\")\n- **Description** — one sentence restating the outcome in their words\n\nConfirm both with the user before moving on. These are set on the goal directly, not part of the Measure/Impact/Sources stepper.\n\n## Step 3: Measure — the goal condition\n\nThis is the only required step. Help the user define the condition that counts as completing the goal. The condition isn't limited to events — run `cio skills read fly-api/goals.md` for the `goal_event_type` table before building anything, and pick the shape that actually matches what the user described:\n\n- **Event** — the profile performs a specific event, optionally narrowed by attribute conditions on that event (e.g. `purchase` where `amount \u003e 0`). Run `cio skills read fly-api/workspace.md` (Data Index) if you need to look up existing event names/attributes — never guess the stored name from the user's phrasing.\n- **Segment** — the profile enters or exits a segment (e.g. \"became a subscriber\" as a segment membership rather than a single event).\n- **Attribute** — a profile attribute crosses a threshold or matches a value (e.g. `plan = \"paid\"`).\n- **Delivery metric** — the profile receives, opens, or clicks a specific message, automation, or broadcast.\n\n`goal_event_type` picks the shape, and it is **not** cross-validated against `goal_event`/`goal_event_filter` — a mismatched type is accepted and the goal simply never records a completion. Double-check the type against the table in `fly-api/goals.md` before creating.\n\nIf the user picked an existing conversion from Step 1, its condition (event or segment) is the starting point here — confirm it still matches what they want to measure rather than re-deriving it from scratch.\n\nIf the user describes a multi-step journey (\"first they view pricing, then they subscribe\"), that's a **funnel**: a funnel start event plus the goal event, within a time window. Funnels are optional — only set one up if the user actually describes a preceding step, and remember all three funnel fields (`funnel_start_event_type`, `funnel_start_event`, `funnel_time_window_seconds`) must be set together.\n\nDon't let this step drift into impact or attribution — keep it scoped to \"when does someone complete this goal.\"\n\n## Step 4: Impact (optional, skippable)\n\nAsk if they want to assign a value to each completion — this is optional and easy to skip. If they do:\n\n- **Custom** — a fixed value + currency (e.g. every completion is worth $50)\n- **Event attribute** — pull the value from an attribute on the goal event (e.g. `amount` on a `purchase` event) + currency\n\nIf the user doesn't have a clear value in mind, don't push — skip this step. A half-filled impact (value without currency, or vice versa) isn't valid, so either set both or skip entirely.\n\n## Step 5: Sources — attribution (optional, skippable)\n\nAsk whether any existing automations or broadcasts should get credit when someone reaches this goal: \"Should any of your automations or broadcasts be credited when someone hits this goal?\"\n\n- If yes, help them pick from existing automations, broadcasts, or transactional messages (`attached_campaigns`, `attached_newsletters`, `attached_transactional_messages`). Cross-reference the candidates you found in Step 1 if relevant.\n- If they're not sure, or don't want to attribute yet, skip — this can be added later from the goal's edit page.\n\n## Step 6: Create and link\n\nConfirm the full picture back to the user before creating — name, description, goal condition (and funnel if any), impact if set, sources if set. Create the goal per `cio skills read fly-api/goals`, then link the user to it:\n\n```\nhttps://fly.customer.io/workspaces/$ENV_ID/journeys/goals/:goal_id\n```\n\n## Common Pitfalls\n\n1. **Don't ask for the condition before understanding the outcome.** Leading with \"what event do you want to track?\" assumes events before you know the shape, and skips the semantic framing that makes this flow different from just calling the create endpoint directly.\n2. **Impact and Sources are both genuinely optional.** Don't stall the conversation trying to extract a value or attribution sources from a user who doesn't have one in mind — offer once, accept a skip, move on.\n3. **Funnel fields are all-or-nothing.** If the user mentions a funnel, all three funnel fields must be set together — see `fly-api/goals.md` pitfalls.\n4. **A conversion goal on an automation/broadcast is not the same object as a standalone goal.** It's a per-automation/per-broadcast setting (`conversion`/`conversion_type`, or `conversion_event_name`/`conversion_segment_id` on broadcasts) — separate from the standalone goals this recipe creates. Step 1 surfaces it as a signal to reuse, but creating the goal here still means going through Measure/Impact/Sources; it isn't \"attaching\" to the existing conversion config.\n\n## Next Steps\n\nAfter creating the goal, offer to:\n- Build a segment from goal completion — `cio skills read recipes/goal_segments.md`\n- Review attribution once the goal has some completions, via the `goal_refresh` endpoint (`fly-api/goals.md`)\n","creating_a_sign_up_form.md":"---\nfeature_flag: sign-up-forms\n---\n\n# Creating a Sign-up Form\n\nEnd-to-end playbook for building a sign-up form from a prompt — e.g. \"create a sign-up form that collects email and first name, with a hosted page\" — and handing back the links. This file teaches the sequence and the gotchas; for endpoint mechanics (request shapes, error codes, the save pipeline) read `design-studio/sign_up_forms.md` first and `fly-api/sign_up_forms.md` for finding and saving existing forms. Discover current request shapes with `cio schema`, not from memory.\n\n## The shape of a form\n\nA sign-up form is one Design Studio node served through an in-app broadcast of type `sign_up_form`. It renders through up to three outputs — `in_app` (SDK delivery), `embedded` (embed script) and `hosted` (hosted page) — each with its own template per language. The form structure inside those templates must stay identical; only styling may differ per output. The app's editor syncs structure across outputs automatically, but over the API **you are the sync**: make the same structural edit in every output's template and save them all in one publish call. Never edit one output's template alone.\n\n## Workflow\n\n### 1. Create the form (drafted)\n\n`POST /v1/environments/:eid/design_studio/sign_up_forms` creates the node, its drafted broadcast and their link in one call, and returns the broadcast with its templates (**design-studio/sign_up_forms.md** § Creating). Decide `parent_folder_id` before the create — don't pile new forms at the workspace root. The broadcast starts with audience `anonymous` and frequency defaults; nothing is visible to anyone yet.\n\nBecause a drafted form is invisible, create it right away rather than blocking on questions — even when the request also says to launch, or asks for something that needs adjusting (a reserved field name: pick a sensible alternative and say so). Open choices resolve at the launch confirmation, where the user sees the whole draft at once.\n\n### 2. Author the content\n\nFields, button and success state are standard in-app carta markup (**fly-api/in_app.md** § Form components): one `x-form` containing `x-input` fields (or an `x-lead-form`) and a submit `x-cta` (`type=\"submit\"`). For \"email and first name\", two `x-input`s — `name=\"email\" type=\"email\" required` and `name=\"first_name\"` — beat `x-lead-form`, whose field names (`lead_capture_*`) are fixed. The success state is the submit CTA's `behavior`: `dismiss` to close, or `showMessage` with `show-message-target` pointing at a second `x-message` step that thanks the visitor.\n\nRules the save and launch checks enforce: at most one `x-form` per message; the form needs at least one field and a submit button; at most 20 fields; field names must not be reserved (`form_id`, `form_name`, `form_type`, `form_url`, `form_url_params`, `message`). Keep personalization out of the form: most visitors are anonymous, so profile Liquid has nothing to resolve against.\n\nSave through `POST .../design_studio/sign_up_forms/:id/publish`, sending every output's template for the language in one request (**design-studio/sign_up_forms.md** § Saving content — including the compile pipeline for `body`).\n\n### 3. Enable the outputs the user asked for\n\n`POST /v1/environments/:eid/in_app_broadcasts/:id/outputs` with `in_app`, `embedded` or `hosted` copies the form's default template into the new output (**design-studio/sign_up_forms.md** § Outputs). A hosted page or embed snippet means enabling `hosted` or `embedded` here. Create starts every form with an `in_app` output, and a live form delivers every `in_app` template through the SDK — so a \"hosted page\" form would also pop up in-app unless that default is removed. When the user did not ask for in-app delivery, enable the requested outputs first, then `DELETE .../in_app_broadcasts/:id/outputs/in_app`; the remove must come second because a form keeps at least one output. Styling is per output — restyle the new output's template if asked, but keep the structure identical.\n\n### 4. Check the launch prerequisites\n\nLaunching needs a JavaScript (CDP) source and a tracking credential in the workspace — the form's page and embed script bake them in. A launch without one is a 422 that names what's missing (\"You need to set up a JavaScript integration…\" / \"…a tracking credential…\"); surface that to the user rather than retrying. A multi-source workspace can pin the choice with `cdp_source_id` / `site_id` on the broadcast update; omitted, the most recent integration is used.\n\n### 5. Launch — only with the user's explicit confirmation\n\n**Never set a form live on your own — but never stall the create waiting for permission either.** \"Create X and put it live\" means: build the draft now (steps 1–3), then stop at this step. Summarize the draft — fields, outputs, audience, frequency, schedule if any — and ask the user to confirm. Call out an `in_app` output the user never asked for: it means SDK pop-up delivery to every anonymous visitor, and the confirmation is the user's chance to catch an unexpected channel before the form goes live. Only then `PUT /v1/environments/:eid/in_app_broadcasts/:id` with `update_type: \"send\"` (live now) or `\"schedule\"` with `scheduled_at` and `timezone`. Launch re-runs the content checks, but reports failures differently from the publish and output routes: the 422 carries no `form_content_invalid` code and no `meta.problems` — the problems come back as text in `errors[0].detail` (\"Fix this form's errors in Design Studio before you launch it. …\"). Read the detail, fix and re-publish, then ask again before retrying the launch.\n\n### 6. Hand back the links\n\nThe launch call returns before the links exist: publishing runs in a background workflow after the launch commits, and each URL is written only when its output is on. Re-read `GET /v1/environments/:eid/in_app_broadcasts/:id`; if a URL you expect is not there yet, wait briefly and re-read rather than telling the user it is missing. Return only the link for each output the form enables — `hosted_page_url` when `hosted` is on, `embed_script_url` when `embedded` is on (neither exists when only `in_app` is on). A scheduled form gets its final links right away: `hosted_page_url` serves a placeholder page and `embed_script_url` a script that shows a placeholder message where the form will go, each replaced in place by the form at go-live. Both show the `before-open-message` set on the `\u003cx-base\u003e` of that output's template, or \"This form isn't open yet. Check back soon.\" when it is empty. Hand both back as usual and tell the user the form appears at the scheduled time; a snippet pasted before then picks it up without changes. Give the embed URL as a snippet the user can paste: `\u003cscript src=\"EMBED_SCRIPT_URL\" async\u003e\u003c/script\u003e`, placed in the page's `\u003cbody\u003e` where the form should render (the script inserts the form at the tag's position, falling back to the end of the body).\n\n## Changing, stopping or restarting a live form\n\nA launched form is not immutable: it can be edited in place (below), stopped, and launched again later.\n\nA scheduled or running form still accepts edits, but only through the explicit publish route — the same `POST .../sign_up_forms/:id/publish`, which re-runs launch's checks and, on a running form, republishes the hosted page and embed script (the editor's `PUT .../templates` save refuses with 409 `form_live_explicit_save_required`). Treat a live edit like a launch: show the user what will change and confirm before publishing. The user asking for the edit is **not** that confirmation — they may not know the form republishes to live visitors the moment it saves, so tell them that and get a yes before the publish call. While the form is starting, stopping or deleting, every save and output change returns 422 `form_locked` — wait and retry, don't route around it.\n\nTake a form down with `update_type: \"stop\"` (or `\"deschedule\"` for a scheduled one) on the broadcast update; both need an edit grant since they remove the form for end users. A stopped form keeps its content and outputs and can go live again — the same launch flow as step 5, `update_type: \"send\"` or `\"schedule\"`, with the same summary-and-confirm before it. To iterate on a design without touching the stopped original, `copy_from` on the create gives a fresh drafted form with the same content and field mappings (**design-studio/sign_up_forms.md** § Creating).\n","diagnosing_a_failure.md":"# Diagnosing a Reported Failure\n\nRead this the moment a user reports something broken — a send failing, an automation not firing,\na change that didn't take. It's about **how you reach a cause**, not which one; the symptom trees\nbelow still apply.\n\n| Symptom | Tree |\n|---|---|\n| A message didn't send or arrive (any channel) | `fly-api/messaging_troubleshooting.md`, starting at § Read the Message Status First |\n| A person didn't enter, didn't get a message, didn't convert | `fly-api/automation_debugging.md` |\n| Low delivery, engagement, or bounce-rate questions | `analyzing_automations.md` |\n| Processing delays, queue backlogs, a health-dashboard alert | `diagnosing_workspace_health.md` |\n\nThe four rules below bind whichever tree you follow — a confident wrong cause costs the customer a\nremediation they didn't need.\n\n## 1. Read back whatever this session wrote\n\nIf you authored or edited the thing that's now failing, **read it back first** — don't rule it out\nfrom memory. A `200` on the write means the request was accepted, not that the content landed: a\nfield the API ignored or a value that serialized to empty both return `200`.\n\n```bash\ncio api /v1/environments/$ENV_ID/templates/$TEMPLATE_ID --jq '.template.body'\n```\n\nEmpty or `null` is the cause — stop there, regardless of how long ago you wrote it. See\n`fly-api/whatsapp.md § Verify the Content Landed` for the channel-specific version.\n\n## 2. A cause you haven't read is not a cause\n\nNever state a cause that no call you made this session tested.\n\n- A field isn't a cause because it looks alarming — `EXPIRED`, `PENDING`, `false` are usually\n  vendor passthrough that gates nothing. Name what consumes the field, or you have a curiosity,\n  not a diagnosis.\n- Don't invent a mechanism to keep a theory alive. Calling a healthy reading \"cached\" or \"stale\"\n  when nothing you read says so is fabrication, and sends the user to fix something that wasn't\n  broken.\n- Don't hedge a refuted theory back in (\"that can also cause failures\", \"worth re-verifying\n  anyway\") — the user acts on the fix you give them.\n- A theory the user brings is still a theory — verify it, and say so plainly when the record\n  disagrees (`fly-api/automation_debugging.md § First confirm the person actually didn't enter`).\n\n\"I don't know yet, let me check X\" costs one turn. A wrong cause costs the user a remediation and\nyou the next three.\n\n## 3. A failed remedy refutes the theory behind it\n\nIf something you suggested didn't fix it, that's evidence against your diagnosis — not a cue for\nthe next remedy of the same kind (re-verify → re-sync → test send, still failing, is the same\nunverified premise three times).\n\nDrop the theory. Get data you didn't have — the newest delivery's activity log, the artifact\nitself (rule 1), the journey record — and let a new theory explain the evidence, not just replace\nthe old one. `fly-api/automation_debugging.md § Before You Say It Is Fixed` spells this out with\nthe delivery calls.\n\n## 4. Escalate only after the cheap checks\n\n\"This is a deeper issue\" or \"not something I can fix from here\" is a claim about evidence. Before\nyou make it, you must have read the artifact in the failing path (rule 1) and pulled the record\nthat carries the failure reason — and be able to say what each one showed.\n\nAn escalation that skips that one-call check turns a fixable configuration problem into a support\nticket. `diagnosing_workspace_health.md` and `analyzing_automations.md § 8` cover when a stall or\nopaque error is genuinely escalatable, once these checks are done. If you truly can't get the\nevidence, say that plainly instead.\n","diagnosing_workspace_health.md":"# Diagnosing Workspace Health\n\nUse this skill when a user asks about workspace health, performance, processing delays, or workspace alerts — including the \"Continue in agent\" handoff from a workspace health alert. Your job is to look past the symptom (a delay, a backlog, a failure count) and identify the upstream cause the user can act on. The per-alert sections below are *guides*, not flowcharts — read the prose, then decide what to look at, rather than walking each bullet in order.\n\n## Discipline\n\nThese are the principles that produce useful answers. Most bad responses come from skipping one of them.\n\n1. **Pull data before listing possible causes.** If you find yourself writing \"could be X, Y, or Z\" or \"likely caused by...\", stop and check the data that distinguishes them. For failures, `read(\"/workspace/logs?type=\u003cfailure_type\u003e\u0026limit=50\")` returns the failing action, sources, and reason directly — start there, not with circumstantial signals (recently-changed automations, ghost workload, action_status profile counts).\n\n2. **Rank by activity, not recency.** An automation or source created today is not automatically more likely to be the culprit than an older one. Rank suspects by today's activity volume — action volume, journey starts, send counts — not by `created_at` or `updated_at`. A \"ReferralApril\" automation from last year can still be the source if its action is firing today.\n\n3. **Recognize action-lifecycle event names.** Event names matching `^(in_app|webhook|push|slack|twilio|line|output|inbox|whatsapp|urban_airship)_\\d+$` (e.g. `in_app_19725`, `webhook_7384`, `output_42`) are platform-generated lifecycle events for action ID N, not custom user events. A single delivery emits multiple of them across its lifecycle (`drafted_action` → `attempted_action` → `sent_action` / `failed_action`), so the count for one name can be much larger than the underlying delivery count. If one of these dominates `top_events`, the action is firing heavily — could be a failure loop (every retry adds another lifecycle event), could be heavy successful sending. Pull the action directly via `read(\"/workspace/actions/{action_id}\")` to find the automation, and check the matching `failed_events_today` key to tell the two cases apart. Do not recommend \"check if your SDK is misfiring this event\" — the name is generated by the platform.\n\n   The channel prefix doesn't always match the failure-counter key. Use this mapping:\n   - `output_` (email) → `failed_email` (NOT `failed_output`)\n   - `in_app_`, `webhook_`, `push_`, `slack_`, `twilio_`, `line_` → key matches the prefix (`failed_in_app`, `failed_webhook`, etc.)\n   - `inbox_`, `whatsapp_`, `urban_airship_` → not tracked in `failed_events_today`. Pull the action's delivery records directly instead of cross-referencing.\n\n4. **Be honest when data narrows but doesn't pinpoint.** When the data narrows the cause to a category but doesn't identify the specific source/template/endpoint, say so plainly: \"the data shows X is the category, but the specific Y isn't visible from here — can you check Z on your side?\" Don't fabricate specifics (a particular event name, a particular webhook URL) the API responses don't actually contain. If an API call fails (401, empty array), say so explicitly rather than reporting it as inconclusive.\n\n5. **Don't guess remediation on opaque errors.** If a failed task has an empty/null `error` string, don't guess \"likely permission issues\" or \"probably config error\" — escalate to support to retrieve the underlying log.\n\n6. **Ingress and egress are independent.** A failure storm on egress (deliveries, journey actions, webhooks) does not consume ingress capacity, and vice versa — they're separately measured (`ingress_messages_total` vs `egress_messages_total`). If ingress throughput drops, the cause is ingress-side (source volume, dynamic-segment cost, identify backlog); don't attribute it to egress workload.\n\n7. **Never surface the health score.** The numeric `score` field — and any \"X/100\", \"degraded\", \"healthy\" status header derived from it — must not appear in your response. It's an internal aggregate that doesn't tell the user anything actionable. Lead with the most important alert and what to do about it.\n\n## Workflow\n\nStart by reading `read(\"/workspace/health\")`. The fields that matter:\n\n- `alerts[]` — what to investigate. Empty means the workspace is healthy; don't manufacture problems.\n- `processing.categories[]` and `egress.categories[]` — ingress and egress queues, with `pending`, `delay_seconds`, `delay_tier`, and `delay_cause`.\n- `delay_cause` on a delayed category — `volume` (the workspace's own send volume, draining at its normal rate: explain and reassure, don't escalate), `platform` (throughput has collapsed relative to what this workspace sustains: escalate), `rate_limited` (deliberate pacing from a workspace-level limit), or `campaign_throttle` (campaigns pacing themselves at a throttle set on the campaign: answer from `throttled_sends`). Read this before deciding whether a delay is a fault at all; a large broadcast can push a category to `major_delay` with nothing wrong, and a campaign throttled to a handful of messages a minute will read as hours behind indefinitely.\n- `secondary_cause`, `unthrottled_pending` and `unthrottled_delay_seconds` — alongside `campaign_throttle` only. The throttled subqueues dominate `delay_seconds`, so these say what the messages they aren't pacing are doing. `secondary_cause: platform` there is a real slowdown sitting behind a throttle, and is escalatable even though the primary cause is not — the per-category alert points at the campaign's message settings, so escalate from `support_payload` rather than expecting a support button on that alert.\n- `throttled_sends` on a delivery category — the campaigns whose own message throttle is pacing them, with `id`, `name`, `queued`, `messages_per_minute` and `clear_seconds`. Name them rather than describing the backlog generically; the throttle can only be changed in that campaign's message settings.\n- `egress.rate_limited_categories` — categories where the backlog is intentional (rate-limited), not a problem.\n- `tasks.running_tasks[]`, `queued_by_priority`, `failed[]` — backfill state.\n- `stats.processed_today`, `stats.throughput`, `stats.failed_events_today` — volume and failure context.\n- `disabled_queues[]` — categories manually paused.\n\nIf multiple alerts are firing, read them all before drilling. Workspace-health issues cascade — a noisy integration causes both an `identify` delay and a `failed_events` alert; lower-priority queues can be paused as a *side effect* of an ingress backlog. Pick the upstream alert. Stalled or disabled-queue alerts trump everything else; they're never side effects.\n\nLead the response with the most likely root cause and one concrete action the user can take. Many backlogs are self-healing once the upstream cause stops — say so explicitly when that's the case, and use `drain_seconds` to set expectations (a few minutes for short backlogs, \"expect normal operation by ~X\" for longer ones). If the diagnosis points to a platform-side issue (stalled processing, queues disabled, an internal-error backfill, opaque errors), recommend contacting Customer.io support — don't propose customer-side workarounds. After the diagnosis, link to the workspace health dashboard at `https://fly.customer.io/workspaces/{environment_id}/journeys/health` so the user can verify resolution; if your recommendation names a specific resource, link to it directly (see `fly-api/SKILL.md` § Linking to the UI).\n\n### Category → ingress queue mapping\n\nEach ingress alert category aggregates multiple underlying queues. The backlog could be in any of them, and they often carry very different work — a `Profile updates delayed (identify)` alert can be driven by ordinary updates, deletions, suppressions, or device changes, depending on what's pending.\n\n| Alert category | Queues that feed it |\n|---|---|\n| `identify` | `identify`, `delete`, `devices`, `suppress` |\n| `events` | `event`, `webhook_event`, `content`, `custom_form_submissions` |\n| `campaigns` | `campaign_start`, `campaign_continue`, `campaign_retry`, `campaign_restart`, `scheduled_campaign_state_chg` |\n| `broadcasts` | `newsletter`, `in_app_broadcast` |\n| `transactional` | `transactional_message`, `scheduled_message_send` |\n| `csv_imports` | `import_csv_identify` |\n| `integrations` | `import_identify`, `import_other`, `import_objrel`, `sql_import` |\n| `objects` | `object`, `object_relationship`, `relationship_update`, `object_relationship_update`, `related_condition_update`, `related_object_updated` |\n| `segments` | `refresh`, `static_segment_changes` |\n\nTo see what's actually pending in one of these queues — not just the incoming traffic mix from CDP source metrics — use:\n\n```\nread(\"/workspace/health/ingress_breakdown?queue={queue_name}\u0026limit=1000\")\n```\n\nReturns an aggregated sample of pending messages: `by_kind` (the operation type — e.g. `set` / `update_only` / `delete_customer` / `suppress` / `device_remove`), `by_source` (non-PII source classification), `by_identifier_type` (`id` / `email` / `anonymous` / `internal`), plus `top_attributes` for `identify` and `top_event_names` for `event`. Sample size defaults to 1000, caps at 5000.\n\nThe response is PII-stripped by design: attribute and event *names* appear, but never their values; source keys are built only from source-category metadata (`type`, `subtype`, `api`, `campaignID`, `actionID`, `importID`, `formType`); IPs, emails, anonymous IDs, raw user IDs, session IDs, and site IDs never appear.\n\nCurrently inspectable queues: `identify`, `delete`, `devices`, `suppress`, `event`. Other queues return an error — file a follow-up if you need them.\n\n## Per-alert reference\n\n### \"Profile updates delayed\" (`identify`)\n\nThe `identify` queue carries new and updated profiles. A backlog here delays segment re-evaluation and the automations that depend on fresh profile state.\n\n**Signals to read first**\n- `processed_today[\"identify\"]` vs `prev_day` — is volume elevated?\n- `stats.throughput[\"ingress\"].current` — healthy is roughly low-thousands/sec; substantially lower means processing is bottlenecked, not just busy.\n- `tasks.running_tasks[]` for `Segment`, `ConditionSchedule`, `AttributeBackfill`, `Import` — multi-million-row backfills touch profiles.\n- `processing.segments_paused` — if true, the system has shed segment work to keep ingress moving.\n- The pending mix across the queues that feed this category (`identify`, `delete`, `devices`, `suppress`) — use `read(\"/workspace/health/ingress_breakdown?queue={queue}\u0026limit=1000\")` per queue. CDP source metrics describe *incoming* traffic; the breakdown describes what's actually *pending*. The answer is not always in the `identify` queue itself — an automation that fires DELETE webhooks will pile up in `delete`, not `identify`, even though both feed the same alert category.\n\n**How to reason about it**\n\nIngress backlogs almost always mean the profile's traffic exceeded the workspace's steady-state capacity for a while — arrivals came in faster than they could be processed. This is normal profile behaviour; the queue drains at the current throughput rate. The two questions worth answering: what drove the volume (so the user can throttle if needed), and is there anything to do to recover faster?\n\nPer-source identify metrics rank sources, and the dominant source usually accounts for most of the increase. For sync-driven sources (Salesforce, warehouse, reverse ETL), `last_sync_time` vs `sync_interval` is the lever: a 5-minute interval drives sustained volume; a sync that hasn't run in many intervals is wedged and needs a restart from the Sync Runs UI; `backfill_type=required` plus a recent `created_at` is initial backfill and expected. A backfill is rarely the dominant cause of a backlog much larger than its row count — sanity-check magnitudes before naming a backfill as the culprit. If no source dominates (top \u003c30% of volume) and no backfill is running, say so honestly; a recent integration go-live or downstream consumer change is likely, so ask what changed in the last 24h.\n\nIngress processing is shared across all the ingress categories — identify, events, objects, segments, automations, etc. all draw from the same pool of capacity. So the workspace's throughput rate is partly a property of how much work each ingress event triggers across the whole pool: every dynamic segment that re-evaluates, every automation that might match a trigger, every relationship cascade. Workspaces with more of these have a lower per-event ceiling and less headroom for bursts. If `stats.throughput[\"ingress\"].current` is on the lower end (substantially below the low-thousands-per-second range that's typical when the system is unconstrained), suggesting archival of unused dynamic segments (`in_use_active=false`) or unused automations can raise the ceiling and help recovery — even when the backlog shows up only in `identify`, the relief comes from reducing total ingress work across categories. This is a recovery lever, not the cause of the backlog. Member count is a proxy for archival impact. One pattern that does cost noticeably: an automation whose actions update data that a segment's condition reads, creating a self-reinforcing loop — `read(\"/workspace/segments/{id}/usage\")` finds where each segment is referenced; pausing the automation or changing the segment usage breaks the loop.\n\nIf throughput is near zero while `prev_day` was non-zero, that's a platform stall — rare and not customer-side; escalate.\n\n**APIs**\n- `GET /cdp/api/workspaces/{workspace_id}/sources` and `/sources/statuses` — list and per-source status\n- `GET /cdp/api/workspaces/{workspace_id}/sources/{id}/metrics?duration=1h\u0026event=identify` — per-source contribution\n- `GET /cdp/api/workspaces/{workspace_id}/sources/{id}/syncs` — sync schedules and last-run state\n- `GET /cdp/api/workspaces/{workspace_id}/sources/{id}/events?limit=20` — payload samples\n- `read(\"/workspace/segments?type=dynamic\u0026size=100\")`, `read(\"/workspace/segments/{id}/count\")`, `read(\"/workspace/segments/{id}/usage\")` — for archival impact and loop-pattern detection\n\n**Common mistakes**\n- Framing segment archival or automation pauses as \"the fix\" for the backlog. The backlog is volume-driven; archival raises the workspace's throughput ceiling, which helps recovery — it doesn't address the cause.\n- Naming a small backfill as the cause of a much larger backlog.\n- Reporting \"no dominant source\" when the per-source data was unreachable. Say \"couldn't fetch source breakdown.\"\n\n### \"Event processing delayed\" (`events`)\n\nThe `events` queue carries incoming custom events. A backlog here delays segment recomputation, event-triggered automations, and conversion attribution.\n\n**Signals to read first**\n- `read(\"/workspace/health/top_events\")` — top event names by today's count (no prev-day baseline, so you can rank but not compute \"% above normal\").\n- `stats.throughput[\"ingress\"].current` — same baseline as identify.\n- `failed_events_today[\"failed_event\"]` — events being rejected at ingestion.\n- `processed_today[\"events\"]` vs `prev_day` — overall volume context.\n- `read(\"/workspace/health/ingress_breakdown?queue=event\u0026limit=1000\")` for what's pending — sample includes `by_kind` (regular / backfilled / webhook), `by_source`, and `top_event_names` from the queue head. Useful when `top_events` doesn't match the in-flight backlog (e.g. a backfill is dumping events with a name that's not yet dominant in today's totals).\n\n**How to reason about it**\n\nFirst check whether the dominant `top_events` name matches the action-lifecycle pattern (per Discipline §3). If yes, it's a platform-generated lifecycle event — not custom user code. A failure loop is one of the patterns that produces a dominant lifecycle name; check the matching `failed_events_today` key to confirm, then pivot to the failing action (\"Failed activities\" below). If failures aren't elevated, the automation behind the action is just sending heavily — pivot to \"Campaign processing delayed\" instead. Either way, don't run the per-source CDP investigation; these names don't originate from a CDP source.\n\nIf the top event is a normal custom event (`page_viewed`, `app_opened`, a conversion event), the backlog is from event volume itself — per-source CDP metrics scoped to the event name and a few sample payloads identify the origin. Look for the same `userId` repeating in tight intervals (loop signal), payloads with no `userId` (anonymous flood), or unusually large payloads. Cross-check against `event_names/{event_name}` so a known-high-volume event (the workspace's normal traffic) isn't mistaken for a misfire. If the top event is \u003c30% of total volume, no single event is driving it; ask the user what changed in the last 24h.\n\nThe same shared-ingress note from the identify alert applies: ingress processing is shared across categories, and per-event ceiling depends on how much work each event triggers across the pool (segment re-evaluation, automation matches, cascade updates). Archiving unused dynamic segments and automations can raise that ceiling and help recovery — a lever for bursts, not the cause of the backlog. The send→update→re-evaluate loop pattern (an automation action updating data that a segment's condition reads) does add noticeable per-event cost; `read(\"/workspace/segments/{id}/usage\")` finds where each segment is referenced.\n\n**APIs**\n- `read(\"/workspace/health/top_events\")`, `read(\"/workspace/event_names/{event_name}\")`, `read(\"/workspace/actions/{action_id}\")`\n- `GET /cdp/api/workspaces/{workspace_id}/sources/{id}/metrics?duration=1h\u0026event={name}` and `/events?limit=20`\n- `read(\"/workspace/segments?type=dynamic\u0026size=100\")`, `read(\"/workspace/segments/{id}/count\")`, `read(\"/workspace/segments/{id}/usage\")`\n\n**Common mistakes**\n- Treating an action-lifecycle name (`in_app_19725`, `output_42`) as user-emitted (\"check your SDK\"). It's platform-generated.\n- Framing segment archival as \"the fix\" rather than as a recovery lever.\n- Reporting the top event name as the cause without confirming it isn't the workspace's normal high-volume event.\n\n### \"Campaign processing delayed\" (`campaigns`)\n\nActive automations are progressing slowly through actions and branches. The queue is workspace-shared, so 1–3 high-volume automations can saturate it for everyone.\n\n**Signals to read first**\n- `read(\"/workspace/health/campaigns\")` — workspace-wide processed count.\n- Running automations (`state=running`, `first_started` set), ranked by today's `journey_metrics.started`.\n- For each suspect, `action_status` (where work is concentrated) and config (action types, trigger).\n- `failed_events_today[\"failed_action\"]` — actions erroring inside journeys.\n\n**How to reason about it**\n\nRank running automations by today's started journeys, then drill into the top one or two. Several patterns commonly drive automation load:\n\nA *ghost workload* automation processes many profiles without sending — its actions are `update_attribute`, `send_event`, manual segment-membership writes, etc. Compare `journey_metrics.started` against channel send totals (`metric.total_sent` for email, `campaign_metrics.\u003cchannel\u003e.total_sent` for others); a high-started/low-sent automation is doing journey-only work that won't appear in delivery alerts.\n\nA *time-step accumulator* parks profiles at `delay`, `time_window`, or `wait_until` actions. Each parked profile gets re-checked when its delay expires, contributing steady journey_actions load. API-triggered broadcasts are the usual high-volume workaround but they don't support time delays — for designs where the delay is essential, there's no clean alternative; flag to support if the queue is consistently behind.\n\nA *re-segmentation feed*: a segment-triggered automation whose segment is currently rebuilding. Every member added to the segment fires the trigger. Walk `tasks.running_tasks[]` for `Segment` backfills if multiple top automations share a segment trigger.\n\nA *slow-egress backup*: webhooks or another egress queue is backed up, so automations with that action type stall waiting for the action to complete. Cross-reference `egress.webhooks` (or the relevant queue) before pausing the automation — fix the destination first.\n\nRecently-modified automations aren't automatically suspect, but `updated` timestamps from the last 24h paired with high `started` suggest a recent change widened the trigger.\n\nIf metrics aren't returning data (automations just started), say \"metrics haven't caught up; check back in 5 minutes.\"\n\n**APIs**\n- `read(\"/workspace/health/campaigns\")`\n- `read(\"/workspace/campaigns?state=running\")`, `read(\"/workspace/campaigns/{id}\")`, `/campaigns/{id}/action_status`\n- `read(\"/workspace/campaigns/{campaign_id}/metrics?version=2\u0026tz=UTC\u0026res=hourly\u0026start={today_midnight_unix}\")`\n\n**Common mistakes**\n- Picking suspects by `updated_at` rather than today's activity volume.\n- Concluding \"automation X is the cause\" from action_status profile counts alone without checking metrics.\n- Missing that a webhook backup is the actual bottleneck and the automation is downstream.\n\n### \"Broadcast sends backed up\" (`broadcasts`)\n\nOne-time send and broadcast sends are queued.\n\n**Signals to read first**\n- In-flight one-time sends: `send_state` is `sending` (filter client-side). Don't rebuild this from `scheduled_at`/`sent_at`: a completed send clears `scheduled_at`, so the timestamp pair can't separate in-flight from finished. **`awaiting_winner` is not in-flight** — it outranks `sending`, so it covers both a split test still dispatching its sample and one parked indefinitely waiting for a human to pick a winner, and `send_state` alone can't tell those apart. Counting it here turns every abandoned split test into a phantom delivery backlog; if you need the dispatching ones, check that broadcast's `scheduled_at` or `/send_status` remaining count.\n- For each, `send_status` (progress) and `counts` (total recipients).\n- `egress.categories[name=message_delivery]` and `egress.rate_limited_categories[message_delivery]` — is delivery the actual bottleneck?\n- `egress.categories[name=message_delivery].throttled_sends` — if populated, named campaigns are pacing themselves; with `delay_cause: campaign_throttle` and no `secondary_cause` that is the whole answer.\n\n**How to reason about it**\n\nIf `message_delivery` is also delayed or rate-limited, the bottleneck is delivery, not the broadcasts queue — pivot to that playbook. If delivery is healthy, look for an in-flight one-time send with millions of recipients: usually one or two one-time sends launched in the last hour account for most of the queue. Multiple one-time sends scheduled around the same time can compete for capacity even when each individually is reasonable; staggering is the structural fix. If `send_status` isn't useful, list in-flight one-time sends with their recipient counts and let the user pick.\n\n**APIs**\n- `read(\"/workspace/newsletters\")`, `read(\"/workspace/newsletters/{id}/send_status\")`, `read(\"/workspace/newsletters/{id}/counts\")`\n\n**Common mistakes**\n- Naming a stalled one-time send as the cause when delivery itself is rate-limited (the one-time send is sending at the safe rate; nothing's wrong on the broadcast side).\n\n### \"Transactional messages delayed\" (`transactional`)\n\nTransactional ingestion (the workspace's transactional template invocations) is exceeding processing capacity. Threshold is tighter (60s) — recipients expect near-real-time for password resets, OTPs, receipts.\n\n**Signals to read first**\n- `processed_today` for the transactional bucket vs `prev_day`.\n- `egress.rate_limited_categories[transactional_delivery]` — if true, the delay is intentional throttling.\n- Top transactional templates by today's `total_sent`.\n- `failed_events_today[\"failed_email\"]`, `[\"failed_push\"]`, `[\"failed_twilio\"]` — provider-side rejections on the relevant channel.\n\n**How to reason about it**\n\nIf delivery is rate-limited, that's the answer — messages are sending at the protective rate; consider raising the rate limit if this is unexpected. Otherwise rank templates by today's send count. One pattern worth checking specifically for transactional: a template firing in a retry loop — today's send count dramatically higher than any prior day, often paired with a recently-modified template or a recent application code change that removed a deduplication step.\n\nIf failure counts on the channel are also elevated, the issue may be downstream provider rejections rather than ingestion capacity. If no template dominates (top \u003c30%), say \"transactional load is broad\" and ask the user about recent application changes.\n\n**APIs**\n- `read(\"/workspace/transactional_messages\")`, `read(\"/workspace/transactional_messages/{id}\")`\n- `read(\"/workspace/transactional_messages/{id}/metrics?version=2\u0026tz=UTC\u0026res=hourly\u0026start={today_midnight_unix}\")`\n\n**Common mistakes**\n- Diagnosing template ranking when delivery is rate-limited (the rate-limit is the answer).\n\n### \"CSV imports processing\" (`csv_imports` / `imports`)\n\nA CSV import is in flight.\n\n**Signals to read first**\n- Active imports — `state` ∈ {`preprocessing`, `preprocessed`, `validating`, `validated`, `importing`}.\n- For each, `rows_to_import`, `rows_imported`, `percent_complete`, `created_at`.\n- `tasks.running_tasks[]` for `Import` type — corresponding backfill.\n\n**How to reason about it**\n\nThe import with the largest `rows_to_import - rows_imported` drives the backlog. ETA is roughly `remaining / (rows_imported / age_in_seconds)`. If `rows_imported` hasn't moved between two recent calls, the import may be wedged — deprioritise (`POST /workspace/backfills/{backfill_id}/deprioritize`) or escalate. Multiple imports queued behind the active one means scheduled imports are stacking; recommend staggering future runs.\n\n**APIs**\n- `read(\"/workspace/imports\")`, `read(\"/workspace/imports/{id}\")`\n- `POST /workspace/backfills/{backfill_id}/deprioritize`\n\n**Common mistakes**\n- Reporting `csv_imports` delayed without naming the active import and its progress.\n\n### \"Integration imports processing\" (`integrations`)\n\nA connected integration (Segment, warehouse sync, reverse ETL, etc.) is pushing data faster than it's being processed.\n\n**Signals to read first**\n- `GET /cdp/api/workspaces/{workspace_id}/sources/statuses` — per-source `enabled` / `disabled` / `no_recent_data`.\n- For enabled sources, 1-hour metrics totals (`?duration=1h\u0026event=all`).\n- For sync sources, `last_sync_time` vs `sync_interval`.\n- `failed_events_today[\"failed_event\"]`, `[\"failed_attribute_change\"]` — bad payloads.\n\n**How to reason about it**\n\nRank enabled sources by 1-hour throughput. The dominant source usually accounts for most of the volume; sample payloads identify quality issues (missing identifiers, malformed traits, schema drift). For sync sources the same triage as identify applies — wedged syncs need a restart, short intervals can be raised, recently-required backfills are expected to produce a burst. Recently-connected sources or recently-modified sync configs are common causes of sustained volume increases.\n\nSee `read(\"skills/cdp-api/sources.md\")` for the full sync model.\n\n**APIs**\n- `GET /cdp/api/workspaces/{workspace_id}/sources`, `/sources/statuses`, `/sources/{id}/metrics`, `/sources/{id}/syncs`, `/sources/{id}/events?limit=20`\n\n**Common mistakes**\n- Concluding \"no dominant source\" when the per-source data was unreachable (401, empty); say \"couldn't fetch source breakdown\" instead.\n\n### \"Object updates delayed\" (`objects`)\n\nCustom object and relationship data is updating slowly. This also slows segments that use object conditions.\n\n**Signals to read first**\n- `read(\"/workspace/health/objects\")` — per-type stats; rank `types[]` by `processed_today.current`.\n- `tasks.running_tasks[]` for `RelationshipBackfill`, `ObjectBackfill`, `RelatedConditionUpdate`.\n- CDP source syncs where `mapping[].cdp_type` is `\"group\"` or `\"relationship\"` — these push objects or their person-object links.\n- `egress.categories[name=related_updates]` — cascade bottleneck.\n\n**How to reason about it**\n\nThe dominant object type by `processed_today.current` is the suspect. A bulk backfill running (e.g. importing 5M company records) explains the load and will pass when complete. CDP syncs pushing objects work the same way as identify-level syncs — `sync_interval`, `last_sync_time`, and recent backfills are the usual levers.\n\nA high `related_updates` egress delay means the cascade is the bottleneck, not ingestion — every object change fans out to membership recomputes for segments that reference the object type. Pause the most expensive dependent segment, or wait if the upstream change is one-time.\n\n**APIs**\n- `read(\"/workspace/health/objects\")`\n- `GET /cdp/api/workspaces/{workspace_id}/sources/syncs`\n\n**Common mistakes**\n- Diagnosing object ingestion when the actual bottleneck is the cascade (`related_updates`).\n\n### \"Segment updates behind\" (`segments`)\n\nSegment changes and scheduled re-evaluations are queued.\n\n**Signals to read first**\n- `processing.segments_paused` — if true, builds are intentionally paused while ingress catches up.\n- `tasks.running_tasks[]` for type `Segment` or `ConditionSchedule`.\n- `tasks.queued_by_priority` — stacked re-evaluations.\n- For each rebuilding segment, `progress` and member count.\n\n**How to reason about it**\n\nIf `segments_paused == true`, the segment alert is a side effect of an ingress alert that's also firing — the segment work resumes automatically. Investigate the ingress alert.\n\nIf a build is running, score it by member count and progress. Multi-million-member rebuilds take real time. A segment with a recent `updated_at` (within the alert window) was likely re-saved with a new condition, which triggers a full rebuild — this is the most common change-driven cause. Multiple segment backfills queued means re-evaluations are stacking; the user can re-prioritise an urgent one.\n\n**APIs**\n- `read(\"/workspace/segments/{id}\")`, `read(\"/workspace/segments/{id}/count\")`\n\n**Common mistakes**\n- Recommending action when `segments_paused == true` — the only useful action is to fix the ingress alert.\n\n### \"Lower-priority queues paused\" (synthetic)\n\nThis alert fires alongside a high-priority backlog. The system is intentionally pausing low-priority work to free capacity. There's no investigation; find the high-priority alert and address that. This one resolves on its own.\n\n### \"Message delivery delayed\" (`message_delivery`)\n\nEmail, push, SMS deliveries are queued. The bottleneck is one of three things: rate-limit (intentional), provider-side rejection (deliverability), or capacity.\n\n**Signals to read first**\n- `egress.rate_limited_categories[message_delivery]` — if true, intentional throttling; don't drill further.\n- `egress.categories[name=message_delivery].throttled_sends` — per-campaign message throttles; name the campaigns. Stop there only if `secondary_cause` is absent: when it is set, `unthrottled_pending` messages are behind for a reason the throttle does not explain, and that part is worth drilling into.\n- Workspace bounce/complaint rates today vs yesterday from `metrics/all_deliveries`.\n- Per-inbox-provider trends from `metrics/deliverability` (email only; feature-gated, may 404).\n- Top campaigns/newsletters by today's `total_bounced` / `total_spammed`.\n- Sender domain auth (SPF/DKIM/DMARC).\n\n**How to reason about it**\n\nRate-limit triage first. If not rate-limited, look for a deliverability shape: a bounce rate spike means providers are rejecting (list-quality issue); a complaint rate spike means content or audience targeting is causing recipients to mark as spam; a sudden delivery rate drop with no bounce/complaint signal means provider-side throttling (IP reputation, sending pattern).\n\nThe automation or one-time send with the largest delta in today's bounced/spammed counts is usually the source of the bad traffic. SPF/DKIM/DMARC failures cause provider-wide throttling that lifts within hours of fixing DNS — see `read(\"skills/fly-api/channels.md\")` for sender domain endpoints. If metrics show no anomaly but the queue is delayed, capacity is the residual cause for high-volume workspaces; suggest staggering sends.\n\n**APIs**\n- `GET /v1/environments/{environment_id}/metrics/all_deliveries?version=2\u0026tz=UTC\u0026res=hourly\u0026start={today_midnight}`\n- `GET /v1/environments/{environment_id}/metrics/deliverability?version=2\u0026tz=UTC\u0026res=daily\u0026start={7d_ago}`\n- `read(\"/workspace/campaigns?state=running\")`, `read(\"/workspace/campaigns/{id}/metrics?version=2\u0026tz=UTC\u0026res=hourly\u0026start={today_midnight}\")`\n\n**Common mistakes**\n- Diagnosing capacity when the issue is rate-limited (intentional) or domain auth (DNS, not capacity).\n- Offering a blocklist delisting request, or a move to \"healthier\" shared-pool IPs, as the remedy — neither is yours to offer. See `read(\"skills/recipes/analyzing_automations.md\")` § Deliverability Issues.\n\n### \"Transactional delivery delayed\" (`transactional_delivery`)\n\nSame shape as message delivery, scoped to transactional channels with a tighter SLA (60s). Use `transactional_messages` instead of automations for the per-template breakdown, and the rate-limit category is `rate_limited_categories[transactional_delivery]`.\n\nOne pattern worth checking specifically for transactional: a template firing in a retry loop, dramatically higher today than prior days. Ask the user about recent application code changes that might have removed a deduplication step.\n\n### \"Webhook delivery backed up\" (`webhooks`)\n\nOutbound journey webhooks are queued. The cause is usually a slow or failing endpoint.\n\n**Signals to read first**\n- `egress.rate_limited_categories[webhooks]` — intentional throttling.\n- `failed_events_today[\"failed_webhook\"]` — endpoints erroring; retries contribute to the backlog.\n- Running automations with `webhook` actions, ranked by today's webhook send/fail counts (`campaign_metrics.webhook`).\n- The action's `webhook_url` (from the action detail endpoint).\n\n**How to reason about it**\n\nRate-limit triage first. Then identify the automation whose webhook is failing the most — the action ID and URL pin the specific destination. The skill can't see the actual response logs, so the user has to confirm whether the URL is reachable and what its typical response time is. If `failed_webhook` is high but no automation's metrics show concentrated failures, the failures may be journey-action-level rather than campaign-level — name the running automations and ask the user which webhook destination they recently touched.\n\n**APIs**\n- `read(\"/workspace/campaigns?state=running\")`, `read(\"/workspace/campaigns/{id}\")`, `read(\"/workspace/campaigns/{id}/action_status\")`\n- `read(\"/workspace/actions/{action_id}\")`\n- `GET /v1/environments/{environment_id}/campaigns/{id}/metrics?version=2\u0026tz=UTC\u0026res=hourly\u0026start={today_midnight}`\n\n### \"Journey actions delayed\" (`journey_actions`)\n\nJourney lookups and profile-update actions are slow. There's no per-template breakdown exposed for action latency, so the diagnosis is necessarily approximate.\n\n**Signals to read first**\n- `failed_events_today[\"failed_action\"]` — actions erroring (start with the failed-events playbook for a per-action breakdown via `/logs?type=failed_action`).\n- Running automations by today's action volume (`action_metrics` per action ID).\n- Action types most likely to slow this queue: `data_lookup`, complex `update_profile` with liquid, `branch` with expensive conditions.\n\n**How to reason about it**\n\nIf `failed_action` is elevated, the failed-activities playbook's `read(\"/workspace/logs?type=failed_action\u0026limit=50\")` chain identifies the specific action — do that first. If actions are slow but not failing, rank automations by action volume and inspect their action types. One pattern that does slow actions noticeably: liquid referencing a profile attribute that doesn't exist on most profiles, forcing every evaluation to walk the full profile. See `read(\"skills/recipes/liquid_syntax.md\")`.\n\n**APIs**\n- `read(\"/workspace/logs?type=failed_action\u0026limit=50\")` (preferred for failure-shaped alerts)\n- `read(\"/workspace/campaigns/{id}\")`, `read(\"/workspace/campaigns/{id}/metrics?version=2\u0026tz=UTC\u0026res=hourly\u0026start={today_midnight_unix}\")`\n\n### \"Profile merging delayed\" (`alias`)\n\nAnonymous-to-known profile merges are queued. Almost always a cascade from elevated identify volume.\n\nCross-check `processed_today[\"identify\"]` vs `prev_day`. If identify is also elevated, alias is the natural cascade and will drain when identify catches up. SDK clients with anonymous tracking (page_viewed, screen_viewed) followed by an identify call generate alias work; CDP source metrics for sources sending anonymous events show the upstream. If alias is delayed *without* corresponding identify volume, that's unusual — escalate.\n\n### \"Related data updates delayed\" (`related_updates`)\n\nCascading updates from object or relationship changes are queued. Same diagnostic as object-updates-delayed: identify the dominant object type by `related_updates.current` in `/health/objects`, find the upstream source pushing the changes, and check how many segments depend on the changing object type. Each object update fans out to one membership recompute per dependent segment.\n\nIf the upstream change is one-time, the cascade will drain. If it's continuous, throttle the source or pause the heaviest dependent segment.\n\n### Failed task alert (`tasks`, error)\n\nA specific backfill or background task failed. The alert title names it.\n\n**Signals to read first**\n- `read(\"/workspace/backfills?state=Failure\")` (case-sensitive — `Failure` not `failure`); `error`, `type`, `object_name`, `started`, `completed` are all on the list. No separate detail endpoint.\n- For `Import` failures, `read(\"/workspace/imports/{id}\")` has richer metadata.\n\n**How to reason about it**\n\nCategorise the `error` string:\n\n- *Transient/infrastructure* (`timeout`, `connection refused`, `temporary`, `5xx`, `rate limit`) → retry is appropriate.\n- *Data validation* (`invalid attribute`, `missing required`, `parse error`, `cannot decode`) → source data is bad; fix data, then retry.\n- *Reference integrity* (`not found`, `deleted`, `does not exist`) → the referenced campaign/segment/object was removed; the backfill is moot.\n- *Permission/auth* (`unauthorized`, `forbidden`, `invalid credentials`) → workspace credential issue.\n- *Unfamiliar/internal* — escalate.\n\nIf the same `type` is failing across multiple recent runs, it's a systemic issue — escalate, don't requeue blindly.\n\n**APIs**\n- `read(\"/workspace/backfills?state=Failure\")`, `read(\"/workspace/imports/{id}\")`\n- `POST /workspace/backfills/{id}/requeue`\n\n**Common mistakes**\n- Guessing remediation when the error is empty/null or unfamiliar (per Discipline §5). Escalate so support can pull the underlying log.\n\n### Long-running task alert (`tasks`)\n\nA task has been running for \u003e30 minutes.\n\nIdentify the task by `type`, `object_name`, and `started`. Each backfill type has a runtime envelope:\n\n- `Segment` / `ConditionSchedule` — multi-million-member rebuilds legitimately take hours.\n- `Import` — check `rows_to_import` and `percent_complete`.\n- `NewsletterSend` — check recipient count.\n- `RelationshipBackfill` / `ObjectBackfill` — bulk update of objects.\n\nA 1k-row import running for an hour is wedged. Multi-million-row work over hours is expected. If the task is reasonable for its size, let it finish; if it's wedged, deprioritise or escalate. If queued tasks are blocked behind it and one is more urgent, deprioritise the long-running one.\n\n**APIs**\n- `read(\"/workspace/segments/{id}/count\")`, `read(\"/workspace/imports/{id}\")`, `read(\"/workspace/newsletters/{id}/counts\")`\n- `POST /workspace/backfills/{backfill_id}/deprioritize`\n\n### Tasks queued alert (`tasks`)\n\n≥5 tasks queued behind the running task.\n\nWalk `tasks.queued_by_priority` to find the loaded queue, then look at what's running ahead of it. If the running task is reasonable for its size, the queue drains when it finishes. If the running task is the long-running one, the queued tasks won't move until the user intervenes — see the long-running playbook.\n\n### Failed activities alert (`failed_events`)\n\n`stats.failed_events_today` exceeds the threshold. The activity logs endpoint is the fastest way to identify the specific failing action and reason — start there for any failure-shaped alert.\n\n**Signals to read first**\n- The dominant key in `failed_events_today` (e.g. `failed_action`, `failed_webhook`, `failed_in_app`, `failed_email`).\n- Recent log entries for that type: `read(\"/workspace/logs?type={failure_type}\u0026limit=50\")`.\n\n**How to reason about it**\n\nEach log entry has `.name` in `{channel}_{action_id}` format (per Discipline §3), `.sources[0].campaign_id` / `campaign_name` / `delivery_id`, and `.attrs.reason`. Group by `.name` to find the dominant failing action — in most workspaces, one action accounts for \u003e90% of failures. Fetch the action and its template; cross-reference `.attrs.reason` against the template body, the action's webhook URL, the in-app body_json, etc. The reason usually points at a specific liquid expression, attribute reference, URL, or provider error.\n\nInternal failures (`failed_query_collection`, `failed_batch_update`, `failed_object_journeys`) often have opaque or platform-shaped reasons — escalate to support with the action name and timeframe.\n\n`failed_llm_action`: check the action's LLM model config and confirm the model is available and the prompt template renders.\n\nIf `.attrs.reason` is redacted (masquerade session) or the logs endpoint returns nothing, name the dominant `.name` and `campaign_name` from what you can see and ask the user to inspect directly — don't try to reverse-engineer the reason.\n\n**APIs**\n- `read(\"/workspace/logs?type={failure_type}\u0026limit=50\")`, `read(\"/workspace/actions/{action_id}\")`, `read(\"/workspace/templates/{template_id}\")`\n\n**Common mistakes**\n- Speculating from circumstantial signals (recently-changed automations, action_status profile counts) before pulling the logs — they have the answer directly.\n- Treating redacted `.attrs.reason` as inconclusive; name what you can see and ask the user.\n\n### Queue processing disabled alert (`disabled_queues`, error)\n\nOne or more queue categories are manually disabled. The message lists the customer-facing impact (\"emails are not being delivered\", etc.).\n\n`disabled_queues[]` lists the disabled categories. These are typically paused by Customer.io support during incidents or by request — the user should contact support to confirm whether the pause is still needed.\n\n### Processing stalled / Delivery stalled alert (error)\n\nItems are pending but the processing rate has dropped to zero (`total_pending \u003e 0` and `stats.throughput[].current == 0` while `prev_day` was non-zero). This is a platform-side issue — recommend contacting Customer.io support immediately. Don't propose customer-side workarounds.\n\n### Unusual data volume spike alert (`processing_details`, info)\n\nToday's processed volume is ≥50% above yesterday at the same elapsed time. The percentage is in the alert message. This is info-level — if no other alerts are firing, the spike isn't a problem in itself.\n\nDecompose by ingestion type — `processed_today[\"identify\"]`, `[\"events\"]`, `[\"egress\"]`, `[\"outgoing\"]` each have a prev-day baseline. The sub-bucket with the largest delta points at the right reasoning:\n\n- *identify* → run the profile-updates-delayed reasoning to identify the dominant source.\n- *events* → run the event-processing reasoning, including the action-lifecycle name check.\n- *egress* → look for one-time sends or automations launched today with much larger audiences than usual.\n\nA new integration going live (`created_at` in the last 24h on a CDP source) drives expected one-time backfill volume. A sync schedule change (interval reduced from 1h to 5min) drives sustained increase. Volume context outside the workspace — a marketing event, a press release driving traffic — isn't visible to the API; ask the user about recent changes if no single dominant cause emerges.\n\n**APIs**\n- `GET /cdp/api/workspaces/{workspace_id}/sources` — recently-created sources.\n","goal_segments.md":"---\nminimum_plan: essentials\nfeature_flag: show-goals\n---\n\n# Goal-Based Segment Builder\n\nBuild audience segments from goal completion data. Use this skill when a user wants to create a segment based on who completed a goal, who didn't, who almost completed it, or who completed it multiple times.\n\n## Prerequisites\n\n- An existing goal with a goal event already tracking data. Run `cio skills read fly-api/goals` for goal endpoints.\n- Before creating any segment, run `cio skills read fly-api/data_driven_segments` and `cio skills read fly-api/segment_conditions` — they cover the creation endpoint and condition mechanics. This skill handles the goal-specific strategy; the segments skills handle segment mechanics.\n\n## Step 1: Get Goal Context\n\nFetch the goal details to extract the goal event name:\n\n```\ncio api /v1/environments/$ENV_ID/goals/\u003cGOAL_ID\u003e\n```\n\nExtract the `goal_event` field — this is the event name that fires when a profile completes the goal.\n\nOptionally fetch goal completion volume for context:\n\n```\n# Both bounds are required — without range_kind the route treats the window as\n# custom and 400s on a missing start or end. Compute them, never hand-write epochs.\nEND=$(date -u +%s); START=$((END - 30*86400))\ncio api /v1/environments/$ENV_ID/goals/\u003cGOAL_ID\u003e/goal_refresh \\\n  --params \"{\\\"start\\\": $START, \\\"end\\\": $END}\"\n```\n\nPresent a brief summary to the user: goal name, goal event, and approximate completion count.\n\n## Step 2: Choose Audience Type\n\nPresent these options and let the user pick one:\n\n| # | Audience Type | Who's included | Use case |\n|---|---------------|----------------|----------|\n| 1 | **Already completed** | Fired the goal event | Exclusions, upsells, LTV analysis |\n| 2 | **Didn't complete** | Never fired the goal event | Re-engagement, win-back automations |\n| 3 | **Almost completed** | Hit a funnel milestone but not the goal | Nudge automations (requires funnel configured on goal) |\n| 4 | **Repeat completers** | Completed 2+ times | High-value profile targeting, loyalty |\n\nWait for the user to choose before proceeding.\n\n## Step 3: Build Segment Conditions\n\nRun `cio skills read fly-api/data_driven_segments` for the creation endpoint, then `cio skills read fly-api/segment_conditions` for the conditions format, minimal grouping rules, condition types, and operators.\n\nTranslate the selected audience type into event-based conditions. These conditions are evaluated server-side by the segment engine — the agent does not need to fetch or enumerate individual profiles.\n\n| Audience type | Condition logic |\n|---------------|----------------|\n| Already completed (type 1) | Single event condition matching the goal event |\n| Didn't complete (type 2) | Same event condition with `inverse: true` |\n| Almost completed (type 3) | Funnel start event (inverse: false) AND goal event (inverse: true) |\n| Repeat completers (type 4) | Goal event condition with `times: 2` or higher |\n\n## Step 4: Create the Segment\n\nSuggest a name (see Naming Conventions below) and confirm with the user. Create the dynamic segment using the endpoint and format documented by `cio skills read fly-api/data_driven_segments`. Include the conditions from Step 3 and a description explaining the audience type and goal.\n\nAfter creation, the segment enters `\"build\"` state. Tell the user the segment is building and share a link:\n\n```\nhttps://fly.customer.io/workspaces/$ENV_ID/journeys/segments/SEGMENT_ID\n```\n\n## Next Steps\n\nAfter the segment is created, offer to:\n- Attach it to an automation as the trigger segment — run `cio skills read fly-api/automations.md` for the workflow\n- Create a complementary segment (e.g., an exclusion segment for profiles who already completed the goal)\n- Review the goal's attribution data to see which automations are already driving goal completions\n\n## Naming Conventions\n\nSuggest names based on the audience type. Let the user override.\n\n| Audience Type | Suggested Name |\n|---------------|---------------|\n| Already completed | \"[Goal Name] - Completed\" |\n| Didn't complete | \"[Goal Name] - Not Yet Completed\" |\n| Almost completed | \"[Goal Name] - Funnel Drop-off\" |\n| Repeat completers | \"[Goal Name] - Repeat Completers\" |\n\n## Error Handling\n\n| Situation | What to do |\n|-----------|-----------|\n| No completers found | The goal event may not have fired yet, or the event name may be wrong. Confirm the goal event name and check if the goal has any completion data. |\n| API error on segment creation | Check the error message. Common causes: invalid conditions format (see segment_conditions.md), missing required fields, duplicate segment name. |\n\n## Common Pitfalls\n\n1. **Static segments cannot become dynamic.** Once created as static, you cannot add conditions later. If the user changes their mind, create a new dynamic segment.\n\n2. **Event name must match exactly.** The goal event name used in segment conditions must match the `goal_event` field from the goal exactly — it's case-sensitive.\n\n## Limitations\n\nIf a user asks to build a **lookalike audience**, **predictive audience**, or **propensity-scored segment** from goal data: you cannot do this reliably. These approaches require fetching and analyzing individual profiles in bulk, and the available APIs are not designed for that — profile retrieval is one profile at a time, and there is no endpoint to list which profiles completed a goal. The results would be based on small, non-representative samples and would be misleading even if they look plausible.\n\nExplain this limitation honestly and offer the event-based segment types above instead. If the user wants predictive or lookalike functionality, suggest they raise it as a feature request.\n","liquid_syntax.md":"# Liquid Syntax Guide for Customer.io\n\nComplete guide to using Liquid templating language for personalizing messages in Customer.io.\n\n## Overview\n\nLiquid is a templating language that allows you to personalize messages with profile data, event properties, and object relationships. It consists of three core concepts:\n\n1. **Tags**: Define logic and control flow (`{% if %}`, `{% for %}`, etc.)\n2. **Keys**: Dynamically render objects and attributes (`{{customer.name}}`)\n3. **Filters**: Modify liquid output (`| capitalize`, `| default: \"value\"`)\n\n## Choosing the Right Data Source — CRITICAL\n\nBefore writing any Liquid, determine where the data lives:\n\n| Data lives on... | Prefix | Example |\n|---|---|---|\n| The person directly (email, name, plan, custom attributes) | `customer.` | `{{ customer.first_name }}` |\n| A related object (Salesforce account, company, subscription) | `objects.\u003cslug\u003e[0].` | `{{ objects.salesforce_accounts[0].Tier__c }}` |\n| The event data that triggered the automation | `event.` | `{{ event.product_name }}` |\n| The object/relationship that triggered the automation | `trigger.` | `{{ trigger.salesforce_account.Tier__c }}` (**singular** slug) |\n| Collection data (queried via workflow action) | `journey.` | `{{ journey.recommended_products }}` |\n\n**Signals you need `objects.` instead of `customer.`:**\n- User mentions \"account\", \"company\", \"organization\", or any entity that isn't the person\n- Attribute names ending in `__c` (Salesforce custom fields — these live on Salesforce objects)\n- User says \"related\", \"associated\", or \"who's [entity] is...\"\n- The data index shows the attribute on an object type, not on the person\n\n**Never use `customer.\u003cobject_slug\u003e.\u003cattr\u003e` or `customer.\u003cobject_attr\u003e`.** The `customer.` prefix only accesses attributes stored directly on the person profile. Object data requires the `objects.` prefix with an index.\n\n## When Liquid Isn't the Answer — CHECK BEFORE ANSWERING\n\nLiquid substitutes and formats values that already exist. It cannot write prose, judge, or condense. If the value the user wants isn't stored anywhere yet and has to be *produced* per profile, Liquid is the wrong tool and an **LLM action** is the right one — see `@skills/fly-api/llm_actions.md`.\n\nThe distinction is not what the user says, it's whether the value already exists:\n\n| User asks for | Value exists? | Tool |\n|---|---|---|\n| \"Use their first name / last browsed product / plan tier in the subject\" | Yes — stored attribute or event property | **Liquid** |\n| \"Show the 3 products they viewed\" | Yes — collection query, then Liquid | **`collection_query_action`** → Liquid (`data_operations.md`) |\n| \"Different copy per tier / per language\" | Yes — branch or `{% if %}` on a stored value | **Liquid** / `flow_control.md` |\n| \"*Summarize* each account's recent activity\" | No — has to be condensed from many events | **LLM action** |\n| \"*Write* a subject line based on what they browsed\" | No — the product name exists, the sentence doesn't | **LLM action** |\n| \"*Classify* this reply / *score* this lead / *judge* whether they're a good fit\" | No — has to be inferred | **LLM action** |\n\nThe tell is the verb: **summarize, write, classify, score, rewrite, recommend, judge, extract-from-freetext** cannot be done in Liquid at any level of cleverness. Interpolate, format, branch, loop, and default all can.\n\nWhen a request could be either — \"a subject line based on what they browsed\" can mean one templated line the user writes or a line written per profile — answer with both: the Liquid version, and the LLM action by name as the way to have each line written for the person.\n\nTwo failure modes, both bad:\n\n- **Answering with Liquid when the value doesn't exist.** `{{ event.product_name }}` echoes a product name; it does not write a subject line. Handing the user string interpolation for a \"write me…\" request looks like an answer and isn't. If the request needs a sentence written per profile, say so and point at an LLM action.\n- **Proposing an LLM action when Liquid already does the job.** Don't reach for a model to insert a first name, format a date, or pick between two variants on a stored field — that's slower, costs credits, and can fail at send time where Liquid can't. When a stored value plus a filter covers it, use Liquid and don't mention LLM actions.\n\n## Available Data Objects\n\n### Profile Attributes\n\nAccess profile data using the `customer` object:\n\n```liquid\n{{customer.first_name}}\n{{customer.email}}\n{{customer.plan_type}}\n{{customer.created_at}}\n```\n\n### Event Properties\n\nReference data from event-triggered automations using the `event` object. In Liquid, `event` is already the event data object. Do **not** add `.data` unless the event payload itself contains a nested attribute named `data`.\n\n```liquid\n{{event.product_name}}\n{{event.price}}\n{{event.appointment_time | date: \"%A, %B %-d at %-I:%M %p\"}}\n```\n\nEvent metadata is exposed separately:\n\n```liquid\n{{event_name}}\n{{event_id}}\n{{event_timestamp | date: \"%B %-d, %Y\"}}\n```\n\nThe UI and API may label the stored payload as `event_data`, but message Liquid uses top-level `event.\u003cattribute_name\u003e` paths. For an event payload with `{\"product_name\":\"Backpack\",\"price\":49}`, write `{{event.product_name}}` and `{{event.price}}`, not `{{event.data.product_name}}`.\n\n### Journey Attributes\n\nJourney attributes temporarily store data for use in a workflow. To reference journey attribute data, use the `journey` object:\n\n```liquid\n{{journey.discount_code}}\n{{journey.temp_value}}\n```\n\n### Trigger Properties\n\nUse the `trigger` object to reference data that triggered a transactional message, API-triggered broadcast, or automations triggered by a webhook, object, or relationship to an object:\n\n```liquid\n{{trigger.customer.first_name}}\n{{trigger.\u003csingular_slug\u003e.attribute_name}}\n{{trigger.relationship.status}}\n{{trigger.\u003ctop_level_key\u003e}}              # webhook-triggered automation: the POST body itself\n```\n\nFor a **webhook-triggered automation** `trigger` is the incoming POST body bound directly — `{{trigger.email}}` for `{\"email\": ...}`, no `data` wrapper — and `{{customer.*}}` is **always** undefined: a webhook automation never matches or creates a person, so there is no case where `customer.email` resolves. See `fly_api/automation_triggers.md § Webhook-triggered`.\n\n### Objects and Relationships\n\nReference object and relationship data associated with people.\n\n**Basic Object Syntax:**\n```liquid\n{{objects.\u003cobject_type\u003e[index].\u003cattribute_name\u003e}}\n```\n\n**Relationship Data:**\n```liquid\n{{objects.\u003cobject_type\u003e[index].relationship.\u003cattribute_name\u003e}}\n```\n\n**Important Object Limitations:**\n- Maximum of 10 objects per type can be referenced — a Liquid reference limit on object **instances** per profile, unrelated to the workspace cap on how many object *types* may exist (see `fly_api/customers.md`)\n- The array is ordered by object id descending, so `[0]` is the highest-id related object and `[9]` the lowest of the ten\n- `[0]` is **not** necessarily the object that triggered the automation — use `{{trigger.\u003csingular_slug\u003e}}` for that\n- Object references are relative to each person\n\n**Example:**\n```liquid\n{% if objects.fly_accounts[0].company %}\n  {{ objects.fly_accounts[0].company }}\n{% elsif customer.company %}\n  {{customer.company}}\n{% else %}\n  Your company\n{% endif %}\n```\n\n**Looping Through Objects:**\n```liquid\n{% for class in objects.online_classes %}\n  {% if class.relationship.status == \"not_started\" %}\n    - {{class.name}}\n  {% endif %}\n{% endfor %}\n```\n\n**Checking Object Count:**\n```liquid\n{% assign classes_count = objects.online_classes | size %}\n{% if classes_count \u003e 0 %}\n  Check out related courses!\n{% else %}\n  Time to sign up for a class!\n{% endif %}\n```\n\n### Collection Data\n\n**Collections are NOT directly accessible in Liquid.** There is no `collections[1].items`, `collections.\u003cname\u003e`, or any similar Liquid object. Attempting to use these patterns will render empty.\n\nTo use collection data in a message, you MUST add a **Collection Query action** (`collection_query_action`) to the automation workflow before the message action. The query stores results in a journey attribute, which the template then references:\n\n```liquid\n{% for item in journey.recommended_products %}\n  {{ item.name }} - {{ item.price }}\n{% endfor %}\n```\n\nSee `@skills/fly-api/data_operations.md` § Collection Query for configuring the action.\n\n### Meta Keys\n\nAccess message and automation metadata:\n\n```liquid\n{{campaign.id}}\n{{campaign.name}}\n{{delivery_id}}\n{{message.id}}\n{{message.name}}\n```\n\n### Unsubscribe \u0026 Subscription Links\n\nGenerate unsubscribe links for your messages. These are **Liquid tags** (`{% %}`), not variables (`{{ }}`):\n\n```liquid\n{% unsubscribe_url %}\n{% manage_subscription_preferences_url %}\n```\n\n**Common mistake:** `{{unsubscribe_url}}` (double braces) will render as empty text. Always use `{% unsubscribe_url %}` (tag syntax with `{% %}`).\n\nUsage in an email:\n```html\n\u003ca href=\"{% unsubscribe_url %}\"\u003eUnsubscribe\u003c/a\u003e\n\u003ca href=\"{% manage_subscription_preferences_url %}\"\u003eManage preferences\u003c/a\u003e\n```\n\n### Snippets\n\nReuse content across messages:\n\n```liquid\n{% snippet \"footer\" %}\n```\n\n## Control Flow Tags\n\n### Conditional Logic (if/elsif/else)\n\n```liquid\n{% if customer.first_name %}\n  Hi {{ customer.first_name }}!\n{% else %}\n  Hi there!\n{% endif %}\n```\n\n```liquid\n{% if customer.lifetime_value \u003e 100 %}\n  Thanks for being a loyal customer!\n{% elsif customer.lifetime_value \u003e 50 %}\n  We appreciate your business!\n{% else %}\n  Welcome!\n{% endif %}\n```\n\n### Negation (unless)\n\n```liquid\n{% unless customer.opted_out %}\n  Here's our latest newsletter!\n{% endunless %}\n```\n\n### Iteration (for)\n\n```liquid\n{% for item in customer.purchases %}\n  - {{ item.name }}: ${{ item.price }}\n{% endfor %}\n```\n\n**With Sorting:**\n```liquid\n{% assign sorted_items = customer.purchases | sort: 'price' %}\n{% for item in sorted_items %}\n  - {{ item.name }}\n{% endfor %}\n```\n\n### Case/When\n\n```liquid\n{% case customer.plan %}\n  {% when 'basic' %}\n    You're on the Basic plan\n  {% when 'pro' %}\n    You're on the Pro plan\n  {% else %}\n    You're on the Free plan\n{% endcase %}\n```\n\n## Variable Tags\n\n### Assign\n\n```liquid\n{% assign full_name = customer.first_name | append: \" \" | append: customer.last_name %}\nHello {{ full_name }}!\n```\n\n### Capture\n\n```liquid\n{% capture greeting %}\n  Hello {{ customer.first_name }}, welcome to {{ customer.city }}!\n{% endcapture %}\n{{ greeting }}\n```\n\n## Common Filters\n\n### String Filters\n\n- `capitalize`: Uppercase first letter\n  ```liquid\n  {{ customer.first_name | capitalize }}\n  ```\n\n- `upcase` / `downcase`: Convert to upper/lowercase\n  ```liquid\n  {{ customer.email | downcase }}\n  ```\n\n- `append` / `prepend`: Add strings\n  ```liquid\n  {{ customer.first_name | append: \" Smith\" }}\n  ```\n\n- `replace`: Replace text\n  ```liquid\n  {{ customer.city | replace: \"New York\", \"NY\" }}\n  ```\n\n- `split`: Break strings into arrays\n  ```liquid\n  {% assign names = customer.full_name | split: \" \" %}\n  ```\n\n- `strip`: Remove whitespace\n  ```liquid\n  {{ customer.name | strip }}\n  ```\n\n- `truncate`: Limit length\n  ```liquid\n  {{ customer.bio | truncate: 100 }}\n  ```\n\n### Array Filters\n\n- `size`: Get array length\n  ```liquid\n  {% assign count = customer.purchases | size %}\n  ```\n\n- `first` / `last`: Get first/last element\n  ```liquid\n  {{ customer.purchases | first }}\n  ```\n\n- `join`: Combine array into string\n  ```liquid\n  {{ customer.tags | join: \", \" }}\n  ```\n\n- `sort`: Sort array\n  ```liquid\n  {% assign sorted = customer.items | sort: 'name' %}\n  ```\n\n- `sort_natural`: Case-insensitive sort\n  ```liquid\n  {% assign sorted = customer.categories | sort_natural %}\n  ```\n\n- `compact`: Remove nil/empty values\n  ```liquid\n  {% assign clean_list = customer.items | compact %}\n  ```\n\n- `concat`: Concatenate arrays\n  ```liquid\n  {% assign all_items = array1 | concat: array2 %}\n  ```\n\n- `group_by`: Group by attribute\n  ```liquid\n  {% assign by_type = customer.purchases | group_by: \"type\" %}\n  ```\n\n### Number Filters\n\n- `plus` / `minus` / `times` / `divided_by`: Math operations\n  ```liquid\n  {{ customer.points | plus: 100 }}\n  {{ price | times: 0.9 }}\n  ```\n\n- `modulo`: Remainder\n  ```liquid\n  {{ customer.id | modulo: 2 }}\n  ```\n\n- `abs`: Absolute value\n  ```liquid\n  {{ difference | abs }}\n  ```\n\n- `round` / `ceil` / `floor`: Rounding\n  ```liquid\n  {{ price | round: 2 }}\n  ```\n\n- `format_number`: Format with regional settings\n  ```liquid\n  {{ price | format_number: \"en-US\" }}\n  ```\n\n- `currency`: Format as currency\n  ```liquid\n  {{ price | currency: \"USD\" }}\n  ```\n\n### Date Filters\n\n- `date`: Format dates\n  ```liquid\n  {{ 'now' | date: \"%B %-d, %Y\" }}\n  {{ customer.created_at | date: \"%m/%d/%Y\" }}\n  ```\n\n**Common Date Formats:**\n- `%Y` - Year (2024)\n- `%m` - Month (01-12)\n- `%B` - Month name (January)\n- `%d` - Day (01-31)\n- `%A` - Day name (Monday)\n- `%H:%M` - Time (14:30)\n- `%s` - Unix timestamp\n\n**Date Calculations:**\n```liquid\n{% assign current_date = 'now' | date: '%s' %}\n{% assign future_date = customer.trial_end %}\n{% assign days_remaining = future_date | minus: current_date | divided_by: 86400 %}\n```\n\n### Utility Filters\n\n- `default`: Provide fallback value\n  ```liquid\n  {{ customer.first_name | default: \"Friend\" }}\n  ```\n\n- `escape`: HTML escape\n  ```liquid\n  {{ customer.comment | escape }}\n  ```\n\n- `strip_html`: Remove HTML tags\n  ```liquid\n  {{ content | strip_html }}\n  ```\n\n- `url_encode`: URL encode strings\n  ```liquid\n  {{ customer.search_term | url_encode }}\n  ```\n\n- `to_json`: Write a value as JSON, such as a list or object in a webhook body. Leave the tag unquoted; quotes around it break the JSON.\n  ```liquid\n  {\"items\": {{ journey.recommended_products | to_json }}}\n  ```\n  `json` and `jsonify` don't serialize on the legacy Liquid engine (`template_engine` 0). They return the value unchanged, so an object renders as Ruby hash text (`{\"a\"=\u003e1}`) and a list runs its items together. `to_json` works on both engines.\n\n## Customer.io-Specific Tags\n\n### Link Tracking\n\n```liquid\n{% cio_link url:https://example.com %}\n```\n\n### View in Browser\n\n```liquid\n{{view_in_browser_url}}\n```\n\n### Countdown Timer\n\n```liquid\n{% countdown point:64 font:roboto weight:light fg:000000 bg:f2f6f9 time:\"2024-12-31 23:59:59 (GMT)\" locale:en looping:true resolution:S frames:2 %}\n```\n\n## Common Patterns and Recipes\n\n### Personalized Greeting with Fallback\n\n```liquid\n{% if customer.first_name %}\n  Hi {{ customer.first_name }}!\n{% else %}\n  Hi there!\n{% endif %}\n```\n\n### Numeric Attribute Comparison\n\n```liquid\n{% assign my_attribute = customer.my_attribute | plus: 0 %}\n{% if my_attribute \u003e 0 %}\n  Your attribute is greater than 0!\n{% else %}\n  Oh no, your attribute is 0 or negative.\n{% endif %}\n```\n\n### Dynamic URLs\n\n```liquid\n\u003ca href=\"http://www.yoursite.com/{{customer.user_id}}\"\u003eView Profile\u003c/a\u003e\n```\n\n### Day-of-Week Conditional\n\n```liquid\n{% assign day = 'now' | date: '%A' %}\n{% if day == 'Friday' %}\n  Have a great weekend!\n{% else %}\n  Have a great day!\n{% endif %}\n```\n\n### Trial Days Remaining\n\n```liquid\n{% assign current_date = 'now' | date: '%s' %}\n{% assign trial_end = customer.trial_end %}\n{% assign days_left = trial_end | minus: current_date | divided_by: 86400 %}\nYou have {{ days_left }} days left in your trial.\n```\n\n### Looping with Sorting and Grouping\n\n```liquid\n{% assign sorted_groups = customer.purchases | group_by: \"type\" | sort: 'name' %}\n{% for group in sorted_groups %}\n  {{ group.name | capitalize }}\n  {% assign sorted_items = group.items | sort: 'title' %}\n  {% for item in sorted_items %}\n    - {{ item.title }}\n  {% endfor %}\n{% endfor %}\n```\n\n### Handling Attributes with Special Characters\n\nFor attributes with spaces or special characters, use bracket notation:\n\n```liquid\n{{ customer[\"current city\"] }}\n{{ customer[\"first-name\"] }}\n```\n\n## Best Practices\n\n1. **Always Include Fallbacks**: Use `default` filter or `if/else` to handle missing data\n   ```liquid\n   {{ customer.first_name | default: \"Friend\" }}\n   ```\n\n2. **Avoid Special Characters in Attribute Names**: Use underscores instead of spaces, periods, or hyphens\n\n3. **Type Conversion for Math**: Use `plus: 0` to convert strings to numbers\n   ```liquid\n   {% assign num = customer.string_value | plus: 0 %}\n   ```\n\n4. **Preview Your Liquid**: Always preview messages to verify personalization works correctly\n\n5. **Understand Object Limitations**: Remember that only 10 object instances per type are accessible to a profile — the 10 with the highest object ids\n\n6. **Use Appropriate Object Types**: Choose the right object (`customer`, `event`, `trigger`, `objects`) for your use case\n\n7. **Check for Empty Values**: Test for blank values before using them\n   ```liquid\n   {% if customer.name != blank %}\n     Hello {{ customer.name }}\n   {% endif %}\n   ```\n\n## Trigger Data vs Object Data\n\n**A namespace that doesn't exist renders as empty string with no error** — the message sends with a blank where the value should be. Get the shape right up front; there is no failure to catch later.\n\n### For Object-Triggered Automations\n\n- **Trigger data** — the one object that triggered this journey. Uses the object type's **singular** slug:\n  ```liquid\n  {{trigger.reservation.check_in_date}}\n  ```\n\n- **Object data** — every object of that type related to the person, as an array under the **plural** slug, ordered by object id descending. `[0]` is the highest-id such object, not necessarily the trigger:\n  ```liquid\n  {{objects.reservations[0].check_in_date}}\n  ```\n\nPrefer trigger data when the message is about the triggering object. Reach for `objects.*` only when you genuinely want the profile's related objects (e.g. listing them all).\n\n### For Relationship-Triggered Automations\n\n- `{{customer.first_name}}` - The recipient of the message\n- `{{trigger.customer.first_name}}` - The person with the relationship that triggered the automation\n- `{{trigger.relationship.status}}` - The relationship attribute that triggered\n\n`trigger.customer` and `trigger.relationship` exist **only** on relationship-triggered automations. A date- or attribute-triggered `object`-type automation has a triggering object but no triggering relationship or person, so it gets `trigger.\u003csingular_slug\u003e` only.\n\n### Namespaces that do not exist\n\n`{{trigger.relationship_attributes.*}}` and `{{object.*}}` (singular root) do not exist. `{{trigger.\u003cplural_slug\u003e}}` is also wrong — the delivery path sets the **singular** slug (`SafeSingularSlug`), falling back to the plural only for a legacy type that has no singular slug. Event- and segment-triggered automations have no `trigger.\u003cobject\u003e` namespace at all — they only see `objects.*`.\n\n## Limitations and Considerations\n\n1. **Object References Are Relative**: The same liquid code may produce different results for different people based on their object relationships\n\n2. **Maximum 10 Objects**: Only 10 object instances per type can be referenced for a given profile — the 10 with the highest object ids, not a limit on object types\n\n3. **Array Indexing**: ordered by object id descending — `[0]` is the highest id, `[9]` the lowest of the ten\n\n4. **Whitespace Handling**: Be mindful of extra whitespace in conditionals; use `-` to strip whitespace:\n   ```liquid\n   {% if condition -%}\n     content\n   {%- endif %}\n   ```\n\n## Debugging Tips\n\n1. **Use Default Values**: Helps identify when data is missing\n   ```liquid\n   {{ customer.name | default: \"[NAME MISSING]\" }}\n   ```\n\n2. **Check Object Existence**: Verify objects exist before accessing them\n   ```liquid\n   {% assign count = objects.product | size %}\n   {% if count \u003e 0 %}\n     {{ objects.product[0].name }}\n   {% endif %}\n   ```\n\n3. **Type Checking**: Ensure numeric operations work by converting strings\n   ```liquid\n   {% assign num = customer.age | plus: 0 %}\n   ```\n\n4. **Whole rows missing from a drag-and-drop (Bee) email**: `{% if %}` tags wrapping entire rows in the compiled body are the editor's **row display conditions**, editable on the row itself in the visual editor — not hand-written Liquid to fix in HTML. See `@skills/fly-api/templates.md § Bee row display conditions` before answering.\n\n## Additional Resources\n\n- [Customer.io Liquid Documentation](https://docs.customer.io/journeys/using-liquid/)\n- [Liquid Tag List](https://docs.customer.io/journeys/liquid-tag-list/)\n- [Liquid Recipes](https://docs.customer.io/journeys/liquid-recipes/)\n- [Objects in Liquid](https://docs.customer.io/journeys/objects-in-liquid/)\n","mobile_geofencing.md":"---\nminimum_plan: premium\n---\n\n# Mobile Geofencing SDK Setup\n\nUse this playbook when a user asks how to enable, configure, or troubleshoot Customer.io geofencing in a mobile app. Pair it with `fly-api/geofences.md` when they also need to create geosets or fences in the workspace.\n\nKeep answers to what this playbook states, and give its guidance in full where it is specific. Don't add matching rules, distances, or OS behavior it doesn't describe.\n\n## Before changing the app\n\nConfirm all of these first:\n\n- The workspace includes the **Geofences** feature. A 400 with \"Upgrade your plan\" from the geoset API is the plan gate.\n- The app uses a Customer.io **mobile source Write Key** for the correct workspace and region.\n- The app calls `identify` before expecting transitions. The SDK only tracks transitions for an identified user.\n- Testing uses a real device. Simulators and emulators can exercise configuration, but they are not proof of OS background-region delivery.\n- The app can request precise/foreground location and the platform's background location tier. Without background permission, transitions are limited to foreground behavior.\n\nDo not infer native or wrapper version floors from release-preparation tags: their changelogs do not establish reliable first-supported versions. Use published releases whose package metadata exposes the geofence module described below, and keep optional native artifacts on the same SDK version. Expo is explicit: geofencing requires `customerio-expo-plugin` **3.7.0+**, **Expo SDK 53+**, and a development/standalone build; Expo Go is not supported.\n\n## Android native\n\nTell the user to be on a published Android SDK release that ships the `geofence` artifact, and to add it at the same version as their other Customer.io modules — say both, rather than give a version number:\n\n```kotlin\n// A published SDK release that ships geofence, the same version as your other Customer.io modules.\nimplementation(\"io.customer.android:geofence:\u003ccustomer-io-sdk-version\u003e\")\n```\n\nDeclare the permissions in the host app, not just the SDK module:\n\n```xml\n\u003cuses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\" /\u003e\n\u003cuses-permission android:name=\"android.permission.ACCESS_BACKGROUND_LOCATION\" /\u003e\n\u003cuses-permission android:name=\"android.permission.RECEIVE_BOOT_COMPLETED\" /\u003e\n```\n\nRequest foreground location at runtime. On Android 10/API 29 and newer, request background location through the platform's separate flow; declaring it is not enough.\n\nRegister both modules before `CustomerIO.initialize`:\n\n```kotlin\nval config = CustomerIOConfigBuilder(application, writeKey)\n    .addCustomerIOModule(ModuleLocation())\n    .addCustomerIOModule(ModuleGeofence())\n    .build()\n\nCustomerIO.initialize(config)\n```\n\n`ModuleLocation` is required even when ordinary location analytics are off. Locations acquired for geofence synchronization are silent and do not emit `CIO Location Update`.\n\nThe default mode is automatic. For a host-controlled location lifecycle:\n\n```kotlin\nModuleGeofence(\n    GeofenceModuleConfig.Builder()\n        .setLocationMode(GeofenceLocationMode.MANUAL)\n        .build()\n)\n\n// After identify + location permission, and whenever the host wants to refresh:\nModuleGeofence.instance().refreshFromCurrentLocation()\n```\n\nWhen explaining automatic mode, state explicitly that every location fix acquired for geofence synchronization is **silent**: it does not emit a `CIO Location Update` analytics event. This privacy boundary applies in automatic and manual refresh paths.\n\nDo not describe 19 as an Android OS hard limit. It is the current server-supplied business-fence count, not an Android platform cap.\n\n## iOS native\n\nWith Swift Package Manager, add the **Location** and **LocationGeofence** products. With CocoaPods, add `CustomerIO/LocationGeofence` (the pod dependency is `CustomerIOLocationGeofence` and pulls in Location).\n\nIn the host target:\n\n- Add `NSLocationWhenInUseUsageDescription` and `NSLocationAlwaysAndWhenInUseUsageDescription`.\n- Request When In Use authorization first, then Always authorization when the user understands the background benefit.\n\nRegister both modules during the native app's normal SDK initialization, including when iOS launches the app for a location event:\n\n```swift\nlet config = SDKConfigBuilder(cdpApiKey: writeKey)\n    .addModule(LocationModule(config: LocationConfig(mode: .onAppStart)))\n    .addModule(GeofenceModule())\n    .build()\n\nCustomerIO.initialize(withConfig: config)\n```\n\nNative apps do **not** need `GeofenceModule.bootstrapForBackgroundDelivery`; that entry point exists for wrapper apps whose JS/Dart runtime does not run on a cold wake. Native `allowBackgroundDelivery` defaults to **false**. Present the setup above as the default and this as a separate opt-in. If the app requires immediate cold-wake delivery, enable it on `SDKConfigBuilder`—never on `GeofenceModuleConfig`—before `build()`:\n\n```swift\nlet config = SDKConfigBuilder(cdpApiKey: writeKey)\n    .addModule(LocationModule(config: LocationConfig(mode: .onAppStart)))\n    .addModule(GeofenceModule())\n    .allowBackgroundDelivery(true)\n    .build()\n\nCustomerIO.initialize(withConfig: config)\n```\n\nThis persists the CDP API key on disk. It is an explicit security tradeoff: apps that fetch the key from a backend should leave it off and accept that a cold-wake transition queues until the next foreground session.\n\nThe default geofence mode is automatic. For manual mode, construct `GeofenceModule(config: GeofenceModuleConfig(locationMode: .manual))` and call `CustomerIO.geofence.refreshFromCurrentLocation()` after identify and permission grant, and whenever the host wants a silent refresh.\n\niOS can monitor at most 20 regions. Customer.io reserves one for its movement trigger, so the iOS business-fence cap is 19. The server ranks nearby enabled fences; the SDK refreshes that set as the device moves. Do not promise an exact refresh distance because the value is platform/server configured.\n\n## React Native and Flutter\n\nThese wrappers need both a build-time opt-in and runtime configuration.\n\n| Platform | Android build opt-in | iOS build opt-in | Runtime opt-in |\n|---|---|---|---|\n| React Native | `customerio_geofence_enabled=true` in `android/gradle.properties` | include lowercase `\"geofence\"` in the existing `customerio-reactnative` pod's `:subspecs` list, e.g. `:subspecs =\u003e [\"apn\", \"geofence\"]` (or `\"fcm\"`) | `geofence: {}` or `geofence: { locationMode: ... }` in `CustomerIO.initialize` |\n| Flutter | `customerio_geofence_enabled=true` in `android/gradle.properties` | CocoaPods: `pod 'customer_io/geofence', :path =\u003e '.symlinks/plugins/customer_io/ios'`; SPM: the same Gradle property, or `CIO_GEOFENCE=true` for custom/add-to-app builds | `geofenceConfig: GeofenceConfig()` or manual mode |\n\nOn iOS, wrapper apps must call `GeofenceModule.bootstrapForBackgroundDelivery(launchOptions:)` from the host AppDelegate because a JS/Dart runtime does not normally initialize on a location cold wake. In a Swift AppDelegate, import `CioLocationGeofence` and call it directly. The API is Swift-only, so an Objective-C or Objective-C++ AppDelegate needs a small Swift bridge that makes this call. Keep the same Info.plist permission strings as native iOS. The wrappers default `allowBackgroundDelivery` on when the geofence module is configured, unless the app explicitly overrides it; because that option persists the CDP API key on disk, turn it off when the key is fetched from a backend and accept next-foreground delivery.\n\nThe public refresh calls are:\n\n- React Native: `CustomerIO.geofence.refreshFromCurrentLocation()`\n- Flutter: `CustomerIO.geofence.refreshFromCurrentLocation()`\n\n## Expo\n\nThe Expo config plugin supports geofencing without ejecting to a bare workflow. Add the build-time `geofence` option at the **plugin top level**; for the plugin's auto-initialization path, runtime `locationMode` belongs under `config.geofence`. Do not put `enabled` inside `config.geofence`—that does not opt the native module into the build.\n\n```json\n{\n  \"expo\": {\n    \"plugins\": [\n      [\n        \"customerio-expo-plugin\",\n        {\n          \"config\": {\n            \"cdpApiKey\": \"\u003cCDP_WRITE_KEY\u003e\",\n            \"geofence\": { \"locationMode\": \"AUTOMATIC\" }\n          },\n          \"android\": {},\n          \"ios\": {},\n          \"geofence\": { \"enabled\": true }\n        }\n      ]\n    ],\n    \"ios\": {\n      \"infoPlist\": {\n        \"NSLocationWhenInUseUsageDescription\": \"\u003cwhy the app uses foreground location\u003e\",\n        \"NSLocationAlwaysAndWhenInUseUsageDescription\": \"\u003cwhy the app uses background location\u003e\"\n      }\n    },\n    \"android\": {\n      \"permissions\": [\n        \"android.permission.ACCESS_FINE_LOCATION\",\n        \"android.permission.ACCESS_COARSE_LOCATION\",\n        \"android.permission.ACCESS_BACKGROUND_LOCATION\",\n        \"android.permission.RECEIVE_BOOT_COMPLETED\"\n      ]\n    }\n  }\n}\n```\n\nThen run `npx expo prebuild`. This is the normal config-plugin/CNG workflow; it does not mean the app must eject. The plugin adds the native Android/iOS geofence dependencies, enables Location transitively, and injects the iOS cold-wake AppDelegate bootstrap. The host still owns the runtime foreground/background permission flow.\n\nWhen `config` is present, the plugin generates SDK initialization, defaults iOS background delivery on for geofencing, and accepts `config.ios.allowBackgroundDelivery` as an override. That option persists the CDP API key on disk; set it to false when the key is fetched from a backend. If the app omits plugin `config` and initializes Customer.io in JavaScript instead, the top-level `geofence.enabled` build opt-in is still required and the app must pass both its runtime `geofence` config and any `ios.allowBackgroundDelivery` override to `CustomerIO.initialize` in JavaScript. Do not add a partial plugin `config` merely to set that override: presence of `config` selects the generated auto-initialization path. For manual location mode use `\"locationMode\": \"MANUAL\"` and call `CustomerIO.geofence.refreshFromCurrentLocation()` after identify and permission grant.\n\n## What the SDK sends\n\nThe nearby-fence lookup is server-side and authenticated by the mobile source; the request contains coordinates, not a profile identity. Transition tracking is different: it requires the SDK to have an identified user.\n\nEach observed transition produces a `Geofence Transition` track event for each geoset containing that fence. Its properties include:\n\n- `transition`: `enter`, `dwell`, or `exit`\n- `geofenceId`\n- `transitionId`: stable across retries, and the same on every event one crossing fans out to (one event per containing geoset)\n- `geosetId` when the fence belongs to a set\n- `geofenceName` when available\n- `visitId` for a correlated SDK-observed stay, when available\n- `enteredAt`, `dwellThresholdSeconds`, `dwellDurationSeconds`, `visitDurationSeconds`, `visitDurationUncertaintySeconds`, and `detectionSource` when the SDK has reliable evidence for that value\n- `metadata`: the fence attributes, preserving scalar types\n\nDurations are whole seconds measured fix to fix and rounded up. `visitDurationSeconds` runs from the fix that detected the entry to the one that detected the exit, and `visitDurationUncertaintySeconds` is how much of it may be time after the device left (the gap since the last fix that placed it inside). Entry detection can lag too, so the real stay can begin before the recorded entry. The SDK sends at most one DWELL per tracked visit; editing the fence or an interruption in monitoring, such as a reboot, resets the visit. DWELL has no SDK cooldown while ENTER and EXIT do, so a quick return visit can produce a DWELL without its ENTER or EXIT. Do not build automations that require an enter, a dwell, and an exit for every visit.\n\nFor a fence that belongs to multiple geosets, the SDK fans one observed transition out to one event per containing geoset, all sharing the same `transitionId`. Delivery is retryable and can be at-least-once. Downstream consumers should deduplicate with `transitionId` plus the geoset when necessary; they should not deduplicate every per-geoset event into one record.\n\nJourneys normalizes this track event at ingestion. In Pipelines/SDK payloads the event is named `Geofence Transition` and uses the camelCase properties above. In Journeys activity and workflow filters it becomes event `type: \"geofence\"`, name `enter`, `dwell`, or `exit`, with the supported snake_case identifiers `attrs.geoset_id` and `attrs.geofence_id`; fence `metadata` is nested at `attrs.attributes`. When the SDK supplied them and the workspace's Customer.io destination mapping includes them, rows also carry `attrs.visit_id`, `attrs.entered_at`, `attrs.dwell_threshold_seconds`, `attrs.dwell_duration_seconds`, `attrs.visit_duration_seconds`, and `attrs.detection_source`. `visitDurationUncertaintySeconds` is not mapped into Journeys; read it from Pipelines. Missing timing fields mean the SDK did not have reliable evidence and must not be interpreted as zero. Do not search Journeys Activity Logs for the display name or camelCase keys, and do not promise other normalized attributes such as `attrs.transition_id` unless they are observed on the actual row; use Pipelines when `transitionId` is required. When explaining what to search for, state the fan-out plainly: one crossing yields one event per containing geoset, all with the same `transitionId`.\n\nThe Journeys Activity Logs API filters are `type=geofence_entered`,\n`type=geofence_dwelled`, and `type=geofence_exited`. Use those tokens when searching\nactivity; the returned rows use the separate normalized `type` and `name` above.\n\nDWELL-triggered automations only start for events timestamped within the last hour.\nOlder queued DWELL events still appear in activity and segments, but do not start\nthe automation. Within that hour, DWELL delivered after EXIT can still trigger;\nthe event does not prove the device is currently inside the fence.\n\n## Troubleshooting order\n\nCheck the chain in this order. Stop at the first broken link when you are diagnosing a specific\nfailure you can reproduce — but when the user asks to be walked through the debugging, take them\nthrough every step to the end, including the downstream ones after ingestion. \"No events appear\" is a\nwalkthrough request: the break may be anywhere, and stopping at ingestion leaves the half of the chain\nthat carries the event to an automation unexamined.\n\n1. **Feature and data:** the workspace can list geosets; at least one enabled fence is near the device and has a valid 100–10,000m radius. If what you read contradicts the user (they say fences are configured, the API shows none, or the call errors), report exactly what you saw, ask them to confirm the workspace and region, and keep walking the remaining links — creating fences is their call once the cause is confirmed.\n2. **Package:** the geofence module is present at build time. For RN/Flutter, verify the Gradle property or iOS subspec/SPM flag and rebuild native artifacts.\n3. **Initialization:** Location and Geofence modules are both registered; the mobile source Write Key and region are correct.\n4. **Identity:** the app identified the intended profile before the crossing.\n5. **Permission:** precise foreground location is granted; background/Always is granted for background delivery.\n6. **Refresh:** in manual mode the host called `refreshFromCurrentLocation`; in either mode, use that call after a newly granted permission to force a silent refresh.\n7. **OS registration:** inspect SDK debug logs for registered-region count, missing permission, missing Location module, or no nearby fences. Respect iOS's 19-business-region cap.\n8. **Transition delivery:** on iOS, verify the `allowBackgroundDelivery` choice and its API-key persistence tradeoff. For RN/Flutter, verify the host AppDelegate bootstrap; Expo injects it. In Pipelines look for the `Geofence Transition` track event. In Journeys Activity Logs, filter with `geofence_entered`, `geofence_dwelled`, or `geofence_exited`; the resulting row is normalized to `type: geofence`, name `enter`/`dwell`/`exit`, and snake-case attributes such as `attrs.geoset_id`. Name both forms to the user so they search each surface for the right one.\n9. **Data flow:** if the event reaches Pipelines but not an automation/destination, inspect the source-to-destination connection, action subscription, filters, and destination delivery errors. The SDK cannot repair a missing downstream mapping.\n\nDo not stop at ingestion when the user is missing a downstream outcome; continue\nthrough the connection, action/subscription, filter, and delivery-error checks.\nDo not start by changing refresh distances, inventing coordinates, or recreating geosets. First prove which link in the chain is missing.\n","periodic_send_report.md":"---\nminimum_plan: essentials\n---\n\n# Periodic Send Report (scheduled routines)\n\nUse this skill when producing a recurring report of what **sent** and how it **performed** over a time window — a \"weekly automation report\", \"monthly recap\", or any unattended routine that summarizes a period's activity.\n\n## The one rule\n\nRe-discover the window's activity from the API on every run. Never report on automation/newsletter IDs remembered from a prior run, from `MEMORY.md`, or from the routine prompt — anything sent after that set was captured is silently missed.\n\n## Send dates come from `sent_at` — nowhere else\n\nWhether a message falls in the window is decided by the `sent_at` timestamp in the API response, and only that. Never infer a send date from the message's **name** (a \"24/07\" in a newsletter name is copy, not data), from its ID, or from its position in a list. Every row in the newsletter list response carries `sent_at` — read it. The list endpoint also filters by send time **server-side** when given `type=sent\u0026sort=sent\u0026start_time=$START\u0026end_time=$END` (the Step 2 query below) — use that; do not fall back to fetching unfiltered pages and eyeballing names.\n\n## Multi-workspace routines\n\nA routine that covers several workspaces runs the **entire** ritual below once per workspace, substituting that workspace's environment id for `$ENV_ID` in every call. Results and pagination are per-workspace — page 2 of one workspace's list says nothing about another workspace, and a \"no sends\" conclusion for one workspace must come from that workspace's own three sources.\n\n## Step 1: Fix the window\n\nSet `$START` and `$END` to the window bounds (unix timestamps) and `$TZ` to the workspace timezone. State the exact window in the report. A \"past 7 days\" run computes both bounds at run time — with `date` in the shell (e.g. `END=$(date -u +%s); START=$((END - 7*86400))`), **never hand-written literal epoch numbers**: a miscomputed constant silently queries the wrong year and reports \"no sends\" for a week that had them.\n\n## Step 2: Enumerate what sent\n\nDon't sample an unsorted newsletter list — it's ordered by *last-updated*, so it misses sends and surfaces stale ones. Use all three sources:\n\nNewsletters / broadcasts, by send time:\n\n```\ncio api /v1/environments/$ENV_ID/newsletters \\\n  --params \"{\\\"type\\\": \\\"sent\\\", \\\"sort\\\": \\\"sent\\\", \\\"asc\\\": false, \\\"start_time\\\": $START, \\\"end_time\\\": $END}\"\n```\n\n**Both `type=sent` and `sort=sent` are required — they do different jobs:**\n\n- **`type=sent`** picks the rows: it drops drafts/scheduled (which have no `sent_at`) and scopes the `start_time`/`end_time` window to `sent_at` — a `type=sent` window never ranges over last-updated.\n- **`sort=sent`** orders newest-send-first, and on older deployments it is also what routes the window to `sent_at` (there, the sort key chooses the date field — the default sort ranges over `updated_at`). Always include it; it makes the query correct everywhere.\n\n`page` is **0-based** (`\"page\": 0`, then `1`, …, in the same `--params` object); page until a page returns fewer than a full page.\n\nAutomations + newsletters + transactional at once:\n\n```\ncio api /v1/environments/$ENV_ID/metrics/analysis \\\n  --params \"{\\\"version\\\": \\\"2\\\", \\\"tz\\\": \\\"$TZ\\\", \\\"res\\\": \\\"days\\\", \\\"start\\\": $START, \\\"end\\\": $END}\"\n```\n\nReturns each with non-zero delivery metrics in the window. Run `cio skills read fly-api/analytics` for the response shape.\n\nLag-independent backstop (all channels) — the raw delivery log, written at send time:\n\n```\ncio api /v1/environments/$ENV_ID/deliveries \\\n  --params \"{\\\"metric\\\": \\\"sent\\\", \\\"start_ts\\\": $START, \\\"end_ts\\\": $END}\"\n```\n\nRows here mean sends happened even when analysis is quiet. Uses `start_ts`/`end_ts` (not `start`/`end`); cursor-paginated via `meta.continuation` (pass it back as another `--params` key, `\"continuation\": \"\u003ctoken\u003e\"`) — page past the small first page. Add `campaign_id` or `transactional_message_id` to the same `--params` object to attribute. `type` (the channel) filters on an exact channel name, and SMS is two of them — `twilio` for the classic SMS action, `sms` for the message-channel action — so `\"type\": \"sms\"` drops every legacy Twilio send and under-counts the week. Leave `type` off and split the rows yourself on each row's `action_type` — SMS is `twilio_action` **plus** `sms_action`. (The row carries no bare channel field; `action_type` is the one that is serialized.)\n\nA quiet `metrics/analysis` does **not** mean nothing sent — its metrics lag by hours. Confirm against a send-time source (the `sort=sent` newsletter query for broadcasts, the delivery log for automations/transactional) before concluding \"no sends\"; if a send shows there but not in analysis, the send wins.\n\n## Step 3: Get window-scoped per-message numbers\n\nThe per-automation and per-newsletter breakdowns for the window are already in the Step 2 `metrics/analysis` response — use those for the report body.\n\n- Deeper per-automation detail, window-scoped — `res` is **required** here or the call 400s:\n  ```\n  cio api /v1/environments/$ENV_ID/campaigns/\u003cID\u003e/metrics \\\n     --params \"{\\\"version\\\": \\\"2\\\", \\\"tz\\\": \\\"$TZ\\\", \\\"res\\\": \\\"days\\\", \\\"start\\\": $START, \\\"end\\\": $END}\"\n  ```\n- **Do not** use `/v1/environments/$ENV_ID/newsletters/\u003cID\u003e/metrics` for window numbers — it ignores date params and returns **all-time** totals. Take a newsletter's window figures from the `metrics/analysis` breakdown or the delivery log. (Reporting all-time newsletter totals as the current period is the exact failure this recipe exists to prevent.)\n\nFor IDs that surfaced only in the delivery-log backstop (analysis lagging), report the delivery-log send count. Transactional: use the analysis numbers, or the delivery log when analysis lags. Drop nothing.\n\n## Step 4: Reconcile\n\nPer-message metrics, analysis totals, and delivery-log counts should agree. On disagreement, a lagging analysis is the usual cause — trust the send-time sources; never report the smaller number.\n\n## Step 5: Report honestly\n\nLabel the exact period every number covers; never present a prior period as the current window. Say \"no sends\" only when the newsletter query, analysis, and delivery log are all empty — skimming one page of an unfiltered list is never grounds for that conclusion. Distinguish \"no activity\" from \"couldn't determine\" (e.g. an errored query).\n"},"hashes":{"SKILL.md":"eacb365d23c1ba23d0cb34b790edc6c84fa694225560839093c4f79cd89de31e","analysis.md":"e3a49c2b29832b975edfd01370ff1fe33ba2f29aa6461d9bab6ea9085a23d4f9","analyzing_automations.md":"ca15d85a558a55f14ce938e396365272c683e94353e74619c56ce31164be4bf5","auditing_from_addresses.md":"560b72e5f4d94e21fc9805c1bac5313b4bd46af8cf8a74bfc6c4677adfb8db4e","creating_a_goal.md":"c64a1c7b25d7dd4d1e6c611e6990e18e893b77c547b712c8acf4c9b4b30afd62","creating_a_sign_up_form.md":"fd6f24e834a7f191a6e38aeb758c21273827e12bed4d9a6b4ede56dffbc48add","diagnosing_a_failure.md":"47fa2b95eced1ca73ee63a419aceb375ccd8230b64f405c287cfa99a787abfb2","diagnosing_workspace_health.md":"2ba62869cad2ad1f5c4d4c94ae8f22120f0dd470594fae5e27812ba325977c2a","goal_segments.md":"4aaef937485a7ee72af75624aff81bfac93a3fe6b04b59877f2bf00e02af4774","liquid_syntax.md":"5e55df67e38398ecb3243810ea2da3d290b309b134daf61c83883a4bccda1bce","mobile_geofencing.md":"0fdce631729dfde8c230c44ca21a92142330cba003704737009585fee4b420cc","periodic_send_report.md":"e1b675478ae7719181bde9026da1ad27989dadfd0c55aacbebf856fc7e87efd5"},"sizes":{"SKILL.md":3463,"analysis.md":4460,"analyzing_automations.md":13563,"auditing_from_addresses.md":6611,"creating_a_goal.md":8559,"creating_a_sign_up_form.md":8466,"diagnosing_a_failure.md":3646,"diagnosing_workspace_health.md":44551,"goal_segments.md":5696,"liquid_syntax.md":20523,"mobile_geofencing.md":16823,"periodic_send_report.md":6411}}]}