# Sequence Authoring

<!-- BEGIN GENERATED: guide-map -->
## Guide map

Read only the section needed for the next decision. Line ranges are exact
for this generated runtime target.

| Section | What it covers | Lines |
| --- | --- | --- |
| [Purpose](#purpose) | Scope, ownership, and the central operating question. | 26-40 |
| [Use This When](#use-this-when) | Tasks and conditions that belong in this guide. | 41-58 |
| [Do Not Use This For](#do-not-use-this-for) | Boundaries and the correct routes for excluded work. | 59-71 |
| [Mental Model](#mental-model) | Concepts and state boundaries needed for correct operation. | 72-172 |
| [Preflight](#preflight) | State, authority, inputs, and readiness to verify before execution. | 173-194 |
| [Risk And Side Effects](#risk-and-side-effects) | Effects, approvals, cost, privacy, and retry boundaries. | 195-206 |
| [Canonical Operating Pattern](#canonical-operating-pattern) | The normal end-to-end execution sequence. | 207-224 |
| [Core SDK Surfaces](#core-sdk-surfaces) | Exact source-backed callable contracts owned by this guide. | 225-248 |
| [Data Contracts](#data-contracts) | Required identities, fields, evidence, handles, and durable shapes. | 249-645 |
| [Examples](#examples) | Representative usage patterns and code. | 646-733 |
| [Verification](#verification) | Evidence required before reporting success. | 734-752 |
| [Troubleshooting](#troubleshooting) | Failure diagnosis and corrective routes. | 753-765 |
| [Related References](#related-references) | Adjacent boundaries and when to cross them. | 766-778 |
<!-- END GENERATED: guide-map -->

## Purpose

Use this reference when designing sequence behavior and its operational
audience sources: `audience_source`, `initial_filter`, `steps`, copy variables,
source preview, and AI filter preview.

This file is about authoring the draft and proving it with previews. It owns
sequence-bound draft prose, step copy, copy variables, and
`vf.sequences.verify_copy(...)` when copy is being saved into draft steps. It
does not own standalone copy advice, recipient-grounded note generation after
audience rows exist, staging, source-run promotion, publish, send, audience
repair, sender setup, purchases, or live runtime diagnostics. Those belong to
`copy.md`, `sequences.md`, `artifacts_jobs.md`, `senders.md`, and
`safety.md`.

## Use This When

- The user asks to create a new outbound sequence draft.
- The user asks to change sequence copy, LinkedIn branches, wait timing, or
  filter prompts.
- The user asks how to structure campaign stages, stage goals, wait timing, or
  candidate variants before saving a sequence draft.
- The user asks to draft or review outbound email, LinkedIn connection, or
  LinkedIn DM copy before saving it into a draft sequence. For standalone copy
  advice with no sequence write, use `copy.md`.
- You need to check copy variables, register, CTA ladder, or run
  `vf.sequences.verify_copy(...)` on the exact draft steps.
- You need to choose or change an operational `source_kind` and source config.
- You need to preview source rows or AI-filter pass/reject rows before
  activation.
- You need to explain what fields the visual builder should receive for a
  sequence source, filter, or step list.

## Do Not Use This For

| Task | Use Instead | Reason |
| --- | --- | --- |
| Finding the audience universe | `audience_discovery.md`, `crm.md`, `web.md`, `linkedin.md`, `places.md` | Acquisition owns provider choice, schema discovery, suppression, and job/artifact handles. |
| Row shaping or variable validation over existing rows | `row_operations.md` | Row operations owns local row transforms and `vf.rows.check_variable_coverage(...)`. |
| Standalone outbound copy advice or review | `copy.md` | Copy owns audience angle, register, CTA shape, channel fit, and critique before an SDK operation exists. |
| Uploading CSV/artifact rows to a sequence source | `sequences.md`, `artifacts_jobs.md` | Upload/stage/promote mutates sequence audience state and needs mapping safety. |
| Publishing, sending, or buying sender/domain resources | `safety.md`, `sequences.md`, `mailboxes.md` | These are customer-visible or purchase-sensitive operations that need explicit confirmation. |
| Workflow-generated sequence activation | `workflow_operations.md`, `workflow_composition.md`, then `sequences.md` | Workflows own repeatability, checkpointing, Play sync, and activation boundaries. |
| Personalization notes after recipient rows exist | `copy.md` | Copy owns grounded note generation and sequence note writes. |
| Publishing, sending, or sender/domain setup after copy is approved | `sequences.md`, `mailboxes.md`, `safety.md` | Copy approval is not activation or infrastructure approval. |

## Mental Model

Sequence authoring has two revisioned inputs, one operational input, and three
proof loops.

The revisioned inputs:

- `initial_filter`: the gate between source rows and outreach rows.
- `steps`: channel topology, wait timing, subject/body copy, variables, and
  LinkedIn branch behavior.

The operational input is `audience_source`: source kind, acquisition config,
lifecycle, schedule, and attached audience containers. These change without a
publish. The source entry node and its graph edge remain revisioned topology.
Adding or changing a source affects future acquisition only. Existing imported
people remain attached and are not reimported. Disconnecting a source stops
future acquisition but retains existing people and source history.

The proof loops:

- `vf.sequences.verify_copy(...)` checks the exact step list before a draft is
  created or updated.
- `vf.sequences.run_source_preview(...)` proves the source config returns the
  expected sample rows.
- `vf.sequences.run_ai_filter_preview(...)` proves the filter prompt accepts
  and rejects the right rows, with inspectable reasons.

### Copy vs Personalization

The boundary is:

| Work | Owner | Why |
| --- | --- | --- |
| Standalone email/LinkedIn copy advice, critique, register, CTA shape, and channel fit | `copy.md` | These are copy judgments before an SDK operation exists. |
| Draft sequence step text, variables, waits, branches, and copy verification | `sequence_authoring.md` | These are properties of the editable draft sequence. |
| Recipient-specific `{{personalization_note}}` generation, dry-run/full-audience note jobs, and reviewed note/enrichment writes | `copy.md` | These require known recipient rows and grounded evidence. |
| Staging rows, publishing, activating, direct sends, sender readiness, or purchases | `sequences.md`, `mailboxes.md`, `safety.md` | These mutate audience, runtime, customer-visible, or paid state. |

Copy drafting is not sequence activation, and standalone copy advice is not a
sequence write. A passing
`vf.sequences.verify_copy(...)` result only means the draft passed mechanical
rule checks (bans, structure, length, channel constraints). It is not an LLM
semantic grade and not proof the copy is good; run `copy.md` craft rules and
`copy_critic.md` for that. A pass is also not approval to stage rows, publish,
activate, or send.

### Source Selection

Always write one audience profile before copy:

```text
source_kind / persona / register / angle
```

Examples:

- `incubator / YC W26 founders and growth operators / founder-peer / cohort timing plus growth workflow`
- `job_posting / VP Sales at fintechs hiring SDRs / operator-specific / hiring-signal pain`
- `manual / supplied customer account list expanded to buying committee / practical / account expansion`

If a mixed audience spans startups and enterprise accounts, split the sequence
or ask the user. The source, proof, register, and CTA usually differ enough
that one sequence weakens both groups.

### Campaign Structure

Before drafting `steps`, write the campaign plan outside the SDK payload:

```text
campaign = audience + offer + campaign mechanic
stage goals = what each touch is meant to prove or ask
candidate angles = alternate messages to compare before choosing final steps
```

Each stage should have a distinct job. A useful arc is:

- Stage 1: introduce the primary reason this recipient should care.
- Stage 2: add one new proof point, objection answer, or secondary angle.
- Stage 3: compress the strongest reason and make the ask easier to answer.
- Final stage: use the current breakup soft-out rules; do not make the close
  sender-centric or ask who else to email unless the user explicitly wants
  referral-routing copy.

Four to six stages can be a reasonable planning range for cold email, but it
is not a rule. Fewer touches are often better for narrow, high-context
audiences; more touches need a real reason, a distinct point per stage, and
sender/safety review before activation.

Use wait timing as a heuristic, not a default law. A common email-only pattern
is about 3 days after stage 1, then about 7 days between later stages
(`wait_hours=72`, then `168`, then `168`). Channel topology, sender limits,
recipient context, and user intent override this pattern.

Plan simple-step variants as candidate step sets or candidate angles before
choosing the final `steps`. For actual A/B variants, use the full editor
workflow payload through `vf.sequences.update_draft(workflow=...)`. Do not
invent a `variants` field in `steps`, a top-level `variants` argument, or
pipe-delimited syntax such as `Hey|Hi`. Every
candidate body/subject that might be saved needs the same copy lint and
`vf.sequences.verify_copy(...)` gate as the final version.

## Preflight

1. Verify auth and workspace identity before any draft write:
   `vf.auth.status()`, `vf.session.init()`, and provider status when source
   config depends on CRM, LinkedIn, or another provider.
2. If editing, fetch the current sequence with `vf.sequences.get(...)` and
   preserve draft ids, source node ids, filter node ids, ingress ids, and
   version metadata returned by the SDK.
3. If the source shape is unfamiliar, call
   `vf.sequences.list_audience_sources(...)` and open the owning acquisition
   reference. The registry and backend schema win over examples in this file.
4. Define the audience profile, source config, campaign stage goals, wait
   timing, candidate angles, final step list, and filter prompt before calling
   `create_draft` or `update_*`.
5. Run `vf.sequences.verify_copy(steps=steps, goal_mode="commercial")` on the
   exact final step list unless the user explicitly asks to save unverified
   draft copy.
6. Use `allow_unfiltered=True` only when the user explicitly wants an
   unfiltered/manual source. Otherwise provide `initial_filter.ai_filter_prompt`.
7. Use `skip_copy_verification=True` only when the user explicitly chooses to
   save unverified copy.

## Risk And Side Effects

| Operation | Risk | Rule |
| --- | --- | --- |
| `vf.sequences.get(...)`, `vf.sequences.source_preview_latest(...)`, `vf.sequences.ai_filter_cached_results(...)` | Platform read | Safe after workspace and sequence id are verified. |
| `vf.sequences.verify_copy(...)` | Copy-quality read | Use before draft writes; do not treat a pass as publish approval. |
| `vf.sequences.run_source_preview(...)` | Provider/artifact job | May use provider state or credits depending on the source. Print source id, limits, status, and sample counts. |
| `vf.sequences.run_ai_filter_preview(...)` | AI/provider preview job | Inspect pass/reject/needs-data rows and reasons before persisting the prompt. |
| `vf.sequences.create_draft(...)`, `update_steps(...)`, `update_ai_filter_prompt(...)` | Reviewable draft write | Changes revisioned behavior. Print `sequence_id`, `builder_url`, and what changed. |
| `vf.sequences.add_source(...)`, `update_source_config(...)`, `disconnect_source(...)` | Operational source write | Does not require publish. Preserve existing people and report the exact ingress, lifecycle, and future acquisition change. |
| CSV/artifact staging, source-run promotion, publish, send | Audience or customer-visible mutation | Leave this file and use `sequences.md`, `artifacts_jobs.md`, and `safety.md`. Require confirmation for publish/send. |

## Canonical Operating Pattern

1. Write the audience profile: `source_kind / persona / register / angle`.
2. Choose a source kind from the registry. Do not invent config fields.
3. Outline the campaign plan: audience, offer, campaign mechanic, stage goals,
   wait timing, and candidate angles.
4. Draft `initial_filter` from the business reason for outreach.
5. Draft `steps` with allowed channels, variables, wait times, and branch
   topology.
6. Run `vf.sequences.verify_copy(...)` on the exact final steps.
7. Create or update the draft.
8. Return `builder_url` immediately so the operator can inspect the builder.
9. Run source preview and AI filter preview with bounded timeout.
10. Inspect `passed_rows`, `rejected_rows`, `needs_data_rows`, and
   `ai_filter_reason` before saving filter prompt changes.
11. If the next task is staging, publishing, sending, or repairing audience
    rows, route to `sequences.md` and `safety.md`.

## Core SDK Surfaces

<!-- BEGIN GENERATED: core-sdk-surfaces -->
| Function | Use When | Inputs | Output And Handles To Preserve | Side Effect | Source |
| --- | --- | --- | --- | --- | --- |
| `vf.sequences.add_source` | Use to add or reuse an operational source ingress for future acquisition without changing the published sequence revision. | <strong>Required</strong>: `sequence_id` (str; required): Verbiflow sequence id.; `source_kind` (str; required): Operational audience source kind to add under the existing source entry node.; `source_node_id` (str; required): Sequence source node id to preview, stage, upload, or update.<br><strong>Optional</strong>: `config` (JsonObject \| None; optional; default `None`): Operational acquisition config for future source runs. This does not change the draft revision and does not require publish.; `display_name` (str \| None; optional; default `None`): Optional label for this operational source ingress, not the sequence or sender name.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `sequence_source` payload returned by the SDK operation. Inspect the documented function reference and `raw`/dictionary fields when debugging provider-specific data.<br><strong>Common next calls</strong>: `vf.sequences.update_source_config` | platform_write | installed SDK: `verbiflow/sequences.py:504-557` |
| `vf.sequences.ai_filter_cached_results` | Use after saving/publishing a prompt to read cached AI-filter results without starting a new preview. | <strong>Required</strong>: `filter_node_id` (str; required): Sequence AI-filter node id to preview or update.; `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `dict` with saved-prompt AI filter counts, branch counts, cached/materialized rows, and pending audience counts. | read | installed SDK: `verbiflow/sequences.py:3202-3227` |
| `vf.sequences.ai_filter_preview_status` | Use when `run_ai_filter_preview(wait=False)` returned `filter_run_id`, or when a long preview returned partial rows and `after_seq`. | <strong>Required</strong>: `filter_node_id` (str; required): Sequence AI-filter node id to preview or update.; `filter_run_id` (str; required): AI filter preview run id returned by `sequences.run_ai_filter_preview(wait=False)` or a partial preview response.; `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `after_seq` (int; optional; default `0`): Workflow event cursor from the previous poll response; pass 0 for the first page.; `limit` (int; optional; default `200`): Maximum number of records, preview rows, or results to request.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `dict` event page for an AI filter preview run: run status, events since `after_seq`, `last_seq`, and `terminal`. | read | installed SDK: `verbiflow/sequences.py:3160-3200` |
| `vf.sequences.create_draft` | Use to create a new editable sequence draft before previewing, editing, or handing off to activation. | <strong>Required</strong>: none<br><strong>Optional</strong>: `allow_unfiltered` (boolean; optional): Set true only when the user explicitly wants an unfiltered/manual audience. Otherwise pass initial_filter.ai_filter_prompt as the ICP gate.; `audience_source` (object; optional): Initial sequence source object, e.g. {'source_kind': 'incubator', 'config': {'incubator_name': 'Y Combinator', 'batch_ids': ['W26', 'S25']}}. Read sequence_authoring.md before filling this.; `copy_verification` (object; optional): Passing mechanical vf.sequences.verify_copy(...) receipt for authored copy (rules-only draft gate, not craft quality). Required unless skip_copy_verification=True.; `initial_filter` (object; optional): Initial audience filter required by default. Prefer {'label': '...', 'ai_filter_prompt': '...'} for fuzzy ICP criteria; use deterministic rules only for crisp fields.; `initial_steps` (list[object] \| None; optional; default `None`): Back-compat alias for steps. Prefer steps for new SDK calls.; `name` (string; optional): Name for the new sequence draft shown in the workspace.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `sender_selection` (JsonObject \| None; optional; default `None`): Optional full-editor sender selection payload to apply after creation.; `sequence_name` (str \| None; optional; default `None`): Back-compat alias for name. Prefer name for new SDK calls.; `settings` (JsonObject \| None; optional; default `None`): Optional full-editor SequenceSettings payload to apply after creation.; `skip_copy_verification` (boolean; optional): Set true only when the user explicitly wants to save copy without running vf.sequences.verify_copy(...).; `steps` (array; optional): Ordered sequence step objects. Each item describes channel/action, timing, copy fields, and personalization variables; see `references/sequence_authoring.md`.; `workflow` (JsonObject \| None; optional; default `None`): Optional full-editor workflow payload. Action variants, weights, attachments, activation conditions, source entry topology, and per-node sender selection belong inside this workflow payload. Source ingress configuration is operational; use add_source, update_source_config, or disconnect_source instead. Include a filter node unless allow_unfiltered=True is explicit. | `dict` with sequence draft state, `sequence_id`, builder/editor links, draft/source/filter metadata, and version state. Preserve `builder_url`, `sequence_id`, source node ids, filter node ids, ingress ids, and draft version before previewing or editing.<br><strong>Common next calls</strong>: `vf.sequences.get`, `vf.sequences.run_source_preview` | platform_write | installed SDK: `verbiflow/sequences.py:133-329` |
| `vf.sequences.disconnect_source` | Use to stop future acquisition from one source while retaining its existing imported people and history. | <strong>Required</strong>: `ingress_id` (str; required): Sequence ingress/source-instance id used when patching or staging a specific source.; `sequence_id` (str; required): Verbiflow sequence id.; `source_node_id` (str; required): Sequence source node id to preview, stage, upload, or update.<br><strong>Optional</strong>: `expected_lifecycle_generation` (int \| None; optional; default `None`): Expected lifecycle generation parameter for `sequences.disconnect_source`.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `sequence_source` payload returned by the SDK operation. Inspect the documented function reference and `raw`/dictionary fields when debugging provider-specific data. | platform_write | installed SDK: `verbiflow/sequences.py:559-599` |
| `vf.sequences.list_audience_sources` | Use to list supported audience source kinds and configuration options; use `sequences.get(sequence_id)` for an existing sequence's source nodes or ingress ids. | <strong>Required</strong>: none<br><strong>Optional</strong>: `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `dict` with supported audience source kinds, provider/config options, labels, and schema metadata. Use `sequences.get(...)` for existing sequence source nodes or ingress ids. | read | installed SDK: `verbiflow/sequences.py:1012-1029` |
| `vf.sequences.list_sources` | Use to read the operational source registry. Source config and lifecycle changes here do not require publish. | <strong>Required</strong>: `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `sequence_sources` payload returned by the SDK operation. Inspect the documented function reference and `raw`/dictionary fields when debugging provider-specific data.<br><strong>Common next calls</strong>: `vf.sequences.update_source_config` | read | installed SDK: `verbiflow/sequences.py:473-502` |
| `vf.sequences.run_ai_filter_preview` | Use to test an AI filter prompt on sample source rows and return pass/reject/needs-data rows before saving it. | <strong>Required</strong>: `filter_node_id` (str; required): Sequence AI-filter node id to preview or update.; `prompt` (str; required): Prompt parameter for `sequences.run_ai_filter_preview`.; `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `poll_interval_seconds` (float; optional; default `1.5`): Seconds between SDK polling requests for source or AI-filter preview completion.; `timeout_seconds` (float; optional; default `240.0`): Maximum seconds to wait before returning partial status instead of blocking indefinitely.; `wait` (bool; optional; default `True`): If false, return immediately with a job/status handle; if true or a timeout integer, poll until completion or timeout. | `dict` with `filter_run_id`, pass/reject/needs-data counts, `rows`, `passed_rows`, `rejected_rows`, `needs_data_rows`, `after_seq`, and `ai_filter_reason` fields. | artifact_job | installed SDK: `verbiflow/sequences.py:3088-3158` |
| `vf.sequences.run_source_preview` | Use to run or fetch sample source rows and diagnostics before AI filtering, staging, sandboxing, or publishing. | <strong>Required</strong>: `sequence_id` (str; required): Verbiflow sequence id.; `source_node_id` (str; required): Sequence source node id to preview, stage, upload, or update.<br><strong>Optional</strong>: `force_refresh` (bool; optional; default `False`): When true, bypass cached source preview rows and start a fresh source pull.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `poll_interval_seconds` (float; optional; default `2.5`): Seconds between SDK polling requests for source or AI-filter preview completion.; `timeout_seconds` (float; optional; default `180.0`): Maximum seconds to wait before returning partial status instead of blocking indefinitely.; `wait` (bool; optional; default `True`): If false, return immediately with a job/status handle; if true or a timeout integer, poll until completion or timeout. | `dict` with preview rows, source config diagnostics, and validation messages. | artifact_job | installed SDK: `verbiflow/sequences.py:2974-3055` |
| `vf.sequences.source_preview_latest` | Use to fetch the latest source preview rows without starting a fresh provider/source run. | <strong>Required</strong>: `sequence_id` (str; required): Verbiflow sequence id.; `source_node_id` (str; required): Sequence source node id to preview, stage, upload, or update.<br><strong>Optional</strong>: `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `dict` with preview rows, source config diagnostics, and validation messages. | read | installed SDK: `verbiflow/sequences.py:3057-3086` |
| `vf.sequences.template` | Use to get a deterministic local starter template for sequence step structure. | <strong>Required</strong>: none<br><strong>Optional</strong>: `channel_mix` (str; optional; default `'email_linkedin'`): Channel mix parameter for `sequences.template`. | `dict` with local deterministic sequence template structure, default steps, variables, and channel metadata. | local | installed SDK: `verbiflow/sequences.py:3918-3930` |
| `vf.sequences.template_variables` | Use to compose the sequence `{{template}}` authoring keyspace from attached lead-list import field_schema plus sender defaults before sequence-bound `vf.copy.lint` or publish-aligned variable checks. Schema-only composition over existing import routes — not a dedicated variables API and not per-contact coverage (use `rows.check_variable_coverage` for values). | <strong>Required</strong>: `sequence_id` (str; required): Sequence id whose attached lead-list field schemas to compose.<br><strong>Optional</strong>: `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `dict` with `keys` (sorted allowlist for `{{var}}` authoring/lint), `audience_keys`, `sender_keys`, `default_keys`, `has_audience_schema`, `lead_list_ids`, `schema_hashes`, and `source` (`composed_from_import_status_field_schema`). Schema-only; not per-contact coverage. Pass `keys` to `vf.copy.lint(allowed_variables=...)`.<br><strong>Common next calls</strong>: `vf.copy.lint`, `vf.sequences.import_status`, `vf.sequences.imports`, `vf.rows.check_variable_coverage` | read | installed SDK: `verbiflow/sequences.py:3603-3680` |
| `vf.sequences.update_ai_filter_prompt` | Use to save an edited AI filter prompt after previewing it. | <strong>Required</strong>: `filter_node_id` (str; required): Sequence AI-filter node id to preview or update.; `prompt` (str; required): Prompt parameter for `sequences.update_ai_filter_prompt`.; `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. | `dict` with sequence draft/published state, steps, sources, version, and routing metadata. | platform_write | installed SDK: `verbiflow/sequences.py:3311-3349` |
| `vf.sequences.update_draft` | Use to patch revisioned sequence behavior: settings, sender selection, workflow topology, filters, and action variants. Use the dedicated source methods for acquisition config and lifecycle. | <strong>Required</strong>: `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `copy_verification` (JsonObject \| None; optional; default `None`): Copy verification parameter for `sequences.update_draft`.; `draft_id` (str \| None; optional; default `None`): Sequence draft id used for optimistic source/step updates when already known.; `expected_draft_version` (int \| None; optional; default `None`): Optimistic concurrency version for sequence draft updates.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `sender_selection` (JsonObject \| None; optional; default `None`): Sender selection parameter for `sequences.update_draft`.; `sequence_name` (str \| None; optional; default `None`): Optional new sequence name to save on the draft.; `settings` (JsonObject \| None; optional; default `None`): Settings parameter for `sequences.update_draft`.; `skip_copy_verification` (bool; optional; default `False`): Skip copy verification parameter for `sequences.update_draft`.; `workflow` (JsonObject \| None; optional; default `None`): Revisioned workflow topology and behavior. Do not include sourceConfig, ingresses, activeIngressId, or lead_list_ids; use the dedicated source methods for operational acquisition state. | `dict` with sequence draft/published state, steps, sources, version, and routing metadata. | platform_write | installed SDK: `verbiflow/sequences.py:331-419` |
| `vf.sequences.update_source_config` | Use after `list_sources(...)` to edit one source ingress. Pass exact `source_node_id` and `ingress_id` values when more than one candidate exists; omit them only when selection is singular. | <strong>Required</strong>: `config` (JsonObject; required): Operational acquisition config for future source runs. Existing imported people are retained and are not reimported.; `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `ingress_id` (str \| None; optional; default `None`): Sequence ingress/source-instance id used when patching or staging a specific source.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `source_node_id` (str \| None; optional; default `None`): Sequence source node id to preview, stage, upload, or update. | `dict` with updated operational source config, ingress identity, and source generations. | platform_write | installed SDK: `verbiflow/sequences.py:601-665` |
| `vf.sequences.update_steps` | Use when replacing or editing draft sequence steps and copy. | <strong>Required</strong>: `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `initial_steps` (list[object] \| None; optional; default `None`): Optional initial sequence step definitions used when creating or replacing a draft.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `steps` (list[object] \| None; optional; default `None`): Ordered sequence step objects. Each item describes channel/action, timing, copy fields, and personalization variables; see `references/sequence_authoring.md`.<br><strong>Note</strong>: Pass either `steps` or `initial_steps`; the SDK normalizes both to draft initial_steps. | `dict` with sequence draft/published state, steps, sources, version, and routing metadata. | platform_write | installed SDK: `verbiflow/sequences.py:421-471` |
| `vf.sequences.verify_copy` | Use before create_draft/update_steps to run mechanical copy rule checks (bans, structure, length, channel constraints) and obtain a draft receipt. Not an LLM semantic grade; use copy.md and copy_critic.md for craft quality. Does not approve staging, activation, or customer-visible sends. | <strong>Required</strong>: `steps` (array; required): Ordered sequence step objects. Each item describes channel/action, timing, copy fields, and personalization variables; see `references/sequence_authoring.md`.<br><strong>Optional</strong>: `goal_mode` (string; optional): Verification goal for the exact steps, such as commercial or feedback. Use commercial for outbound sales/growth sequences unless the user says otherwise.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `sender_context` (object; optional): Optional sender/company context object accepted for forward-compat. Currently unused by mechanical rule checks (no semantic/LLM pass).; `target_context` (object; optional): Optional target account context object accepted for forward-compat. Currently unused by mechanical rule checks (no semantic/LLM pass). | `dict` with copy-quality findings, blocking issues, warnings, and suggested fixes. | read | installed SDK: `verbiflow/sequences.py:3682-3728` |
<!-- END GENERATED: core-sdk-surfaces -->

## Data Contracts

### Source Input

`audience_source` is an object with an outer `source_kind` and optional
source-specific `config`:

```python
audience_source = {
    "source_kind": "incubator",
    "config": {
        "incubator_name": "Y Combinator",
        "batch_ids": ["W26", "S25"],
        "target_title_tags": ["founder", "co-founder", "ceo"],
        "max_per_company": 3,
    },
}
```

Incubator sources take one or more batches via `batch_ids`. Persisted
configs that still carry the older scalar `batch_id` keep working; when
both fields are present, `batch_ids` wins. Adding a batch to an existing
sequence source later is safe: the next source run pulls the new batch's
companies, and promotion/materialization dedupe keeps already-enrolled
contacts from being re-added.

Do not nest another `source_kind` inside `config` for draft creation. The
backend selects the source from `audience_source.source_kind`; `config` should
contain only the fields for that source.

Illustrative source shapes:

```python
# User-provided CSV, reviewed artifact rows, or a manual source that will be
# staged later through sequences.md.
{"source_kind": "manual"}

# Accelerator/cohort/batch (one or more batches).
{"source_kind": "incubator", "config": {
    "incubator_name": "Y Combinator",
    "batch_ids": ["W26", "S25"],
    "target_title_tags": ["founder", "ceo"],
    "max_per_company": 3,
}}

# Broad people search from title/location/company constraints.
{"source_kind": "targeted_search", "config": {
    "job_title_tags": ["VP Engineering", "Head of Product"],
    "location_tags": ["United States"],
    "company_linkedin_urls": ["https://www.linkedin.com/company/example"],
}}

# Hiring-signal source.
{"source_kind": "job_posting", "config": {
    "job_title_tags": ["Account Executive", "SDR"],
    "location_tags": ["United States"],
    "days": 30,
    "include_hiring_manager": True,
    "target_title_tags": ["VP Sales", "Head of Revenue"],
    "max_per_company": 3,
}}

# CRM-backed audience. Discover properties with vf.crm.list_properties(...) first.
{"source_kind": "crm", "config": {
    "provider": "hubspot",
    "data_type": "contacts",
    "filters": [{"property": "lifecyclestage", "operator": "EQ", "value": "lead"}],
    "target_titles_tags": ["Founder", "VP Sales"],
    "max_per_company": 2,
    "include_properties": ["company", "jobtitle", "hs_object_id"],
}}

# LinkedIn engagement audience from a known post URL or query.
{"source_kind": "linkedin_post_reactors", "config": {
    "query": "https://www.linkedin.com/posts/example-post",
    "interaction_types": ["like", "comment", "repost"],
}}
```

Rules:

- Treat `vf.sequences.list_audience_sources(...)` as the source registry. The
  examples above are not exhaustive.
- The backend may support source kinds that are hidden from the registry until
  enabled. Do not use hidden kinds unless the registry or product context
  proves they are available for the workspace.
- If values are unclear, read `audience_discovery.md` and use
  source-specific discovery first, such as `vf.sources.incubators.options(...)`
  or `vf.crm.list_properties(...)`.
- Preserve provenance fields such as program, batch, job URL, company domain,
  CRM id, or LinkedIn URL.
- Use `max_per_company` or explicit limits when expanding company-level
  sources to people.
- Do not use unsupported CRM fields such as `related_filters` in this authoring
  path. Complex CRM relationship logic belongs in `crm.md` or a workflow source
  step that produces reviewed rows before sequence staging.
- Do not publish from an unpreviewed source.

### AI Filter Prompt

Prefer `initial_filter={"label": "...", "ai_filter_prompt": "..."}` when the
pass/fail logic depends on fuzzy company or person fit.

Good prompt structure:

```python
initial_filter = {
    "label": "Founder/operator ICP",
    "ai_filter_prompt": (
        "Pass founders, co-founders, CEOs, COOs, or growth operators at YC W26 companies "
        "where the company appears likely to benefit from outbound, account expansion, or "
        "sales-assisted growth. Prefer B2B, AI, developer tools, data, security, fintech, "
        "healthcare, infrastructure, and workflow software companies. Exclude investors, "
        "advisors, students, agencies, unrelated service providers, and rows with weak company identity."
    ),
}
```

A strong filter prompt includes:

- Pass criteria: who should continue.
- Prefer criteria: which rows should rank higher but not be mandatory.
- Exclusion criteria: who must be dropped.
- Evidence fields: company description, title, source provenance, CRM status,
  recent job, LinkedIn evidence, or other fields the source actually returns.
- Uncertainty rule: reject or hold rows with weak identity, missing company, or
  ambiguous persona.

When criteria are fuzzy, include a few examples in the prompt. One good
positive example and one good negative example can materially improve the
filter; one to three of each is usually enough. Do not stuff dozens of examples
into the prompt unless the distinctions truly require them, and only use
examples whose fields match what the source actually returns.

Use deterministic `rules` only for crisp fields like exact lifecycle stage,
explicit country, numeric employee count, known suppression flag, or date
range.

### Step Input

Each step object uses:

- `channel`: one of `email`, `reply_email`, `linkedin_connection`,
  `linkedin_message`, or `linkedin_inmail`.
- `subject`: required for `email` and `linkedin_inmail`. Do not include
  `subject` on `reply_email`; reply steps thread under the previous email and
  the send layer handles the reply subject.
- `body`: email HTML or plain text, LinkedIn connection note, LinkedIn DM, or
  InMail message.
- `wait_hours`: optional delay before this step or branch.
- `on_accepted`: branch after a LinkedIn connection is accepted.
- `on_not_accepted`: branch after a LinkedIn connection times out.

There is no generic `variants` field in this documented step contract. If you
want to test alternate copy in the simple path, create candidate step lists
during planning, choose the final list to save, and verify that exact list. For
real A/B variants, use the full editor workflow contract below.

Channel content rules:

- `email` and `reply_email` bodies persist as rich email HTML. The SDK accepts
  plain text or HTML and passes step bodies through verbatim; Bridge normalizes
  paragraph-authored copy into Gmail-compose HTML at ingestion.
- `linkedin_connection` body is a single-line note, no HTML, no line breaks, max
  300 characters.
- `linkedin_message` body is plain LinkedIn message text. It is not email HTML
  and should not include a signoff.
- `linkedin_inmail` uses a single-line subject plus message body. Treat the
  body as LinkedIn-visible message text; if an editor-authored rich body is
  present, the provider boundary normalizes it to visible text before send.
- `open_tracking`, `link_tracking`, unsubscribe header, timezone, send window,
  daily limits, ESP routing, reply-agent, CRM reply config, and personalization
  config are sequence `settings`, not step fields.

Topology rules:

- A `linkedin_connection` step should usually have both `on_accepted` and
  `on_not_accepted`.
- A branching LinkedIn connection must be the last step in its containing
  outer list. Put all continuation inside `on_accepted` or `on_not_accepted`.
- The accepted branch should use `linkedin_message`.
- The accepted branch may include a second LinkedIn DM about 72 hours later,
  but it must be at most 2 sentences and add new proof or one sharper question.
- The not-accepted branch should usually use a short email fallback, max 40
  words, and should not mention the ignored connection request.
- The not-accepted email body MUST end with a soft-out clause that gives the
  recipient an easy no — "...or is this not a priority right now?" /
  "...or is browser infra not on the roadmap?" / "...or are you not
  evaluating this currently?". Never ship a breakup that's only the offer
  ("Want me to send the doc?") — the recipient who ignored the prior LinkedIn
  step needs explicit permission to opt out without ghosting again. The
  soft-out should reference the recipient's context (the company /
  the topic), not the sender's situation.
- A `reply_email` step must omit `subject` and its immediately preceding step
  in the same chain must be `email` or `reply_email`.
- Do not hide real sends inside helper functions. Publish and direct sends
  need explicit confirmation.

### Full Editor Workflow

Use `vf.sequences.update_draft(...)` with `settings`, `sender_selection`, and
`workflow` when you need exact parity with the frontend sequence editor. This is
the canonical full CRUD surface for editor-only options; do not mirror those
fields into a separate SDK-only shape.

`vf.sequences.create_draft(...)` can accept the same full-editor payloads during
creation. Internally that path creates the base sequence first and then patches
the full editor fields. If the follow-up patch fails, the SDK raises
`PartialCreateError` with `sequence_id` and `resume_hint`; resume with
`vf.sequences.update_draft(sequence_id, ...)` instead of creating another draft.

Full editor workflow supports:

- A/B action variants in `node.data.settings.variants[]` with `id`, `subject`,
  `body`, `weight`, and per-variant `attachments`.
- Email attachments with `kind="email_attachment"` and LinkedIn attachments
  with `kind="pdf"` or `kind="video"`.
- Action `activation_conditions`, per-action `sender_selection`,
  action-branch `branch_sender_selections`, filter branch `sender_selection`,
  and filter `else_sender_selection`.
- Source entry-node topology. Operational ingress configuration and audience
  acquisition use the dedicated source methods and do not require publish.

Canonical action content in the editor workflow:

```python
workflow = {
    "nodes": [
        {
            "id": "n_email",
            "type": "email",
            "position": {"x": 250, "y": 160},
            "data": {
                "settings": {
                    "variants": [
                        {
                            "id": "v_a",
                            "subject": "{{company_name}} idea",
                            "body": "<p>Hi {{first_name}}, ...</p>",
                            "weight": 60,
                            "attachments": [],
                        },
                        {
                            "id": "v_b",
                            "subject": "{{company_name}} workflow",
                            "body": "<p>Hi {{first_name}}, ...</p>",
                            "weight": 40,
                        },
                    ]
                }
            },
        }
    ],
    "edges": [],
}
```

The frontend serializes variants by placing the visible editor body in
`variants[].body`; the backend maps that body into the channel-specific
authoring slot (`body`, `message`, or `note`) based on the block type. For
email and reply email, body is rich HTML. For LinkedIn connection/message/InMail,
body is LinkedIn-visible text unless the visual editor intentionally produced a
rich body that will later be normalized to visible text.

Email HTML follows the Gmail-compose grammar (see the email body serialization
contract in `docs/sequence-contract.md`): on this editor-workflow path, adjacent
blocks render as plain line breaks with no gap, so a blank line between
paragraphs must be written explicitly as an empty block (`<p></p>` or
`<div><br /></div>`). On the `initial_steps`/create path, prefer plain text:
`\n` is a plain line break, `\n\n` is a blank line — exactly what the
recipient sees. Adjacent `<p>` blocks there expand into blank-line-separated
paragraphs; `<div>`-grammar HTML (a body read back from an SDK-created step)
is already explicit and re-submits unchanged. Builder-authored bodies are
stored as `<p>` grammar where adjacent blocks mean plain line breaks — do not
round-trip those through `initial_steps` (they would gain blank lines); edit
them in the builder or via `update_draft(workflow=...)`.

### Copy Variables

Template variables are **audience-schema + sender scoped**, not a fixed global
list. See `docs/sequence-contract.md` (Authored Copy Variable Contract). The
same keyspace is shared by publish validation, `sequences.template_variables`,
and sequence-mode `vf.copy.lint`.

**Resolve keys for a sequence (required for sequence-bound lint):**

```python
# Composes existing routes only: get lead lists + import_status.field_schema
# (same import field_schema source as the editor; allowlist matches publish). No dedicated variables API.
tv = vf.sequences.template_variables(sequence_id=sequence_id)
keys = tv["keys"]  # audience schema keys ∪ sender defaults
# Resolve once per draft session; reuse keys on every step lint.
vf.copy.lint(body, allowed_variables=keys, channel="email")
# or one-shot (re-fetches schema composition each call):
vf.copy.lint(body, sequence_id=sequence_id, channel="email")
```

**Always available without an attached audience (defaults):**

- Contact: `{{first_name}}`, `{{last_name}}`, `{{full_name}}`, `{{company_name}}`
- Sender (from mailbox/sender account at send): `{{sender_name}}`,
  `{{sender_first_name}}`, `{{sender_last_name}}`, `{{sender_email}}`,
  `{{sender_title}}`, `{{sender_calendar_link}}`

**With an attached audience / lead-list schema**, any field key that exists on
the audience authoring schema is a valid `{{var}}`. That includes:

- Canonical contact/company fields and aliases (`email`, `job_title`,
  `linkedin_url`, `phone`, `industry`, `location`, `company_domain`, …)
- Source/CSV mapped columns that survived into the schema
- Contact/company enrichment keys written onto the lead (including custom keys
  and `{{personalization_note}}` when that enrichment exists)

Authoring/publish validation is schema-only: the key must exist on the
audience authoring schema (or the sender default set). It does **not** prove
every contact has a non-empty value. Runtime is strict per person: missing or
blank `{{var}}` fails that instance before send (never renders empty).

**Do not invent** `{{their_company}}`, `{{role}}`, camelCase keys
(`firstName`, `linkedinUrl`), or other names not on the schema. Prefer
`vf.rows.check_variable_coverage(...)` before staging rows that must cover
custom variables. Prefer the literal company name when a sender-company var
is unnecessary.

**Bracket placeholders are banned:** `[Sender]`, `[First Name]`,
`[calendar link]`, `[Signature]`, `[Title]`. They render literally.

**Lint modes:**

| Mode | Call | Keyspace |
| --- | --- | --- |
| Standalone (default) | `vf.copy.lint(text)` | Fixed AI-draft safety net only |
| Explicit | `vf.copy.lint(text, allowed_variables=keys)` | Caller-supplied keys |
| Sequence | `vf.copy.lint(text, sequence_id=...)` | `template_variables` for that sequence |

Do not strip real enrichment vars to please standalone lint — use sequence mode.

### Copy Rules

Craft (hook, voice, economic value, AI structure, SHIP gate) lives in `copy.md`
and **must** pass `copy_critic.md` before save. This section is **sequence field
constraints** only.

Field rules:

- Email signoff is `{{sender_first_name}}` on its own line.
- LinkedIn connection notes and LinkedIn messages do not use signoffs.
- No em dashes or en dashes; no bracket placeholders.
- No links, calendar, demo, or "15 minutes" on first touch unless the user
  explicitly requests that motion.
- No reply-word or numbered-response mechanics.

Register rules (field tone, not full craft):

- Founder-peer: concise, direct, concrete trigger, small ask.
- Operator or manager: workflow pain and process impact.
- Enterprise, clinical, security, or executive: proper, proof-led when real.

CTA Ladder (sequence stage, craft details in `copy_critic.md`):

- First touch: one concrete artifact or binary ask (not meeting-first).
- Follow-up: one new reason or sharper offer.
- Accepted LinkedIn DM: one sentence of context plus one small ask.
- Later email / breakup: soft-out required on final email
  (`…, or is this not a priority?` shape; max 40 words).

Subject rules:

- 2-4 words preferred, 7 words max.
- Include `{{company_name}}` or a concrete trigger.
- Do not write `re:`, `fwd:`, emojis, urgency, "quick question", product pitch,
  excessive punctuation, or all caps.

First email:

- Prefer 60-75 words; soft ceiling ~90 (aligns with `copy.md`).
- Use `Hi {{first_name}},` or `{{first_name}},` depending on register.
- Use `{{personalization_note}}` when recipient-specific context is needed.
- Keep the ask smaller than a meeting unless the user requested a meeting
  motion.

LinkedIn connection:

- Stay under 300 characters; target 280 raw characters or less.
- No pitch, URL, calendar, demo, or meeting ask. Never sell.
- A contextual "Would be great to connect." is acceptable only after a real
  reason. Do not use "would love" / "happy to connect" (local lint flags them).
- Shape: `Hi {{first_name}}, saw <specific source/proof>. Would be great to connect.`

LinkedIn message:

- Max 4 sentences for the first accepted-branch DM.
- Tie back to the email/context and make one concrete offer.
- No signoff, no calendar link, no long value-prop repeat.
- A second DM, if used, should be about 72 hours later, at most 2 sentences,
  and should add new proof or a sharper question.

## Examples

### Create A Draft After Copy Verification

```python
import verbiflow as vf

vf.auth.ensure_logged_in()

verification = vf.sequences.verify_copy(
    steps=steps,
    goal_mode="commercial",
)
if verification.get("pass") is not True:
    raise RuntimeError(f"copy verification failed: {verification}")

draft = vf.sequences.create_draft(
    name="YC W26 Founder Play",
    audience_source=audience_source,
    initial_filter=initial_filter,
    copy_verification=verification,
    steps=steps,
)
print(draft["sequence_id"], draft["builder_url"])
```

### Preview Source Rows And AI Filter Results

Resolve real `source_node_id` and `filter_node_id` from `vf.sequences.get(...)`
or from the current builder state. Do not invent ids when editing an existing
sequence.

```python
detail = vf.sequences.get(draft["sequence_id"])
nodes = detail["workflow"]["nodes"]
source_node_id = next(
    node["id"] for node in nodes if node["type"] == "source_audience"
)
filter_node_id = next(
    node["id"] for node in nodes if node["type"] == "filter"
)

source_preview = vf.sequences.run_source_preview(
    sequence_id=detail["sequence_id"],
    source_node_id=source_node_id,
    timeout_seconds=180,
)
print(source_preview["status"], len(source_preview.get("rows", [])))

filter_preview = vf.sequences.run_ai_filter_preview(
    sequence_id=detail["sequence_id"],
    filter_node_id=filter_node_id,
    prompt=initial_filter["ai_filter_prompt"],
    timeout_seconds=240,
)
print(
    filter_preview["passed_count"],
    filter_preview["failed_count"],
    filter_preview["needs_data_count"],
)
for row in filter_preview["passed_rows"][:5]:
    print("PASS", row["row_value"].get("email"), row["row_value"].get("ai_filter_reason"))
for row in filter_preview["rejected_rows"][:5]:
    print("REJECT", row["row_value"].get("email"), row["row_value"].get("ai_filter_reason"))
```

If the preview returns `is_partial=True`, keep polling with a bounded timeout
and preserve `filter_run_id` plus `after_seq`:

```python
page = vf.sequences.ai_filter_preview_status(
    sequence_id=detail["sequence_id"],
    filter_node_id=filter_node_id,
    filter_run_id=filter_preview["filter_run_id"],
    after_seq=filter_preview["after_seq"],
)
```

If a saved prompt already exists and you need current cached results, call
`vf.sequences.ai_filter_cached_results(...)`.

### Manual Source Handoff

For reviewed local CSVs or workflow outputs, create the draft with
`{"source_kind": "manual"}` and then leave this reference. Use `sequences.md`
and `artifacts_jobs.md` to resolve source/ingress ids, validate column mapping,
stage rows, inspect source run ids, and decide whether promotion is safe.

## Verification

Before claiming the draft is ready:

- `vf.sequences.verify_copy(...)` returns a passing result for the exact final
  `steps`.
- `vf.sequences.create_draft(...)` or `update_steps(...)` returned the expected
  `sequence_id`, `builder_url`, and draft metadata.
- Source preview returns the expected row count, row shape, provenance fields,
  and no obvious wrong persona/company rows.
- AI filter preview returns plausible `passed_count`, `failed_count`,
  `needs_data_count`, and `branch_counts`.
- Sample `passed_rows`, `rejected_rows`, and `needs_data_rows` have credible
  `ai_filter_reason` values.
- If good rows fail, loosen the prompt and rerun preview. If bad rows pass,
  tighten it and rerun preview.
- Only after the preview is accepted should you persist the prompt with
  `vf.sequences.update_ai_filter_prompt(...)`.

## Troubleshooting

| Symptom | Likely Cause | Fix |
| --- | --- | --- |
| `create_draft` rejects missing `initial_filter` | The SDK requires an ICP gate by default. | Add `initial_filter.ai_filter_prompt`, or use `allow_unfiltered=True` only after explicit user approval. |
| `create_draft` rejects `copy_verification` | Authored copy has not passed the client-side copy checkpoint, or verification failed. | Rerun `vf.sequences.verify_copy(...)` on the final simple-step copy, then pass that result. |
| `reply_email` validation fails | The reply has no immediate email/reply-email predecessor in the same chain. | Move it after an `email` or `reply_email`, or change it to `email`. |
| Branching LinkedIn step validation fails | There are outer steps after a branching `linkedin_connection`. | Move continuation into `on_accepted` and `on_not_accepted`, or make the connection step unbranched. |
| Source preview returns cached rows after config changes | `run_source_preview` reuses completed rows when `force_refresh=False`. | Pass `force_refresh=True` when the source config changed or stale data is unacceptable. |
| AI filter preview says no sample rows exist | The source feeding the filter has no preview rows. | Run `vf.sequences.run_source_preview(...)` first. |
| CRM source ignores a requested related-object filter | That field is not part of the current sequence authoring source schema. | Use `crm.md` or a workflow source step to produce reviewed rows before staging. |
| Variables render literally | Placeholder is not mapped or is bracket-style text. | Use safe `{{variable}}` names and run `vf.rows.check_variable_coverage(...)` before staging. |

## Related References

| Reference | Use For |
| --- | --- |
| `audience_discovery.md` | Choosing provider-backed people/account discovery before a sequence exists. |
| `crm.md` | HubSpot properties, associations, suppression, and CRM graph semantics. |
| `row_operations.md` | Local row shaping and variable coverage checks. |
| `artifacts_jobs.md` | Job/artifact handles, CSV exports, and durable row handoffs. |
| `copy.md` | Recipient-specific note generation and sequence personalization writes. |
| `sequences.md` | Staging, source-run promotion, publish, transfer, delete, imports, and live sequence operations. |
| `senders.md` | Sender readiness, mailbox setup, sandbox sends, and matching. |
| `safety.md` | Approval boundaries for credits, staging, publish, send, and purchases. |
| `workflow_operations.md`, `workflow_schedules.md` | Generated workflow runs/reports, then canonical Play sync or scheduling. |
