# Copy

<!-- 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-60 |
| [Use This When](#use-this-when) | Tasks and conditions that belong in this guide. | 61-84 |
| [Do Not Use This For](#do-not-use-this-for) | Boundaries and the correct routes for excluded work. | 85-100 |
| [Mental Model](#mental-model) | Concepts and state boundaries needed for correct operation. | 101-389 |
| [Preflight](#preflight) | State, authority, inputs, and readiness to verify before execution. | 390-417 |
| [Risk And Side Effects](#risk-and-side-effects) | Effects, approvals, cost, privacy, and retry boundaries. | 418-431 |
| [Canonical Operating Pattern](#canonical-operating-pattern) | The normal end-to-end execution sequence. | 432-509 |
| [Core SDK Surfaces](#core-sdk-surfaces) | Exact source-backed callable contracts owned by this guide. | 510-523 |
| [Data Contracts](#data-contracts) | Required identities, fields, evidence, handles, and durable shapes. | 524-596 |
| [Examples](#examples) | Representative usage patterns and code. | 597-652 |
| [Verification](#verification) | Evidence required before reporting success. | 653-666 |
| [Troubleshooting](#troubleshooting) | Failure diagnosis and corrective routes. | 667-687 |
| [Related References](#related-references) | Adjacent boundaries and when to cross them. | 688-697 |
<!-- END GENERATED: guide-map -->

## Purpose

Use this reference for outbound copy advice, drafting, critique, and grounded
personalization. It owns copy judgment before an SDK operation exists, and it
also owns the SDK execution paths that generate, preview, inspect, or write
`personalization_note`-style context once recipient rows or LinkedIn targets
exist.
Standalone outbound copy advice starts here, and grounded personalization
execution stays here instead of a separate personalization reference.
For any user-visible copy, the default standard is specific human writing:
plain, grounded, opinionated when the context allows it, and free of sterile
LLM texture unless the user explicitly asks for a different register.

**Must-run order for any draft you will show or save:** brief → draft →
`copy_critic.md` (binary checks + golden imitation; SHIP required) →
mechanical `vf.copy.lint` → show. Lint green is a mechanical floor. Critic
SHIP is the craft gate. Prefer an independent critic pass (second look or
subagent) over scoring your own first draft.

For **sequence-bound** bodies, lint with schema keys — not bare standalone
lint: `vf.copy.lint(text, sequence_id=...)` or
`allowed_variables=vf.sequences.template_variables(...)["keys"]`. Bare
`vf.copy.lint(text)` is only the fixed AI-draft safety net and will
false-flag real enrichment vars.

This file answers one question: what text should be said, why is it truthful,
and when is it safe to turn that text into grounded per-recipient copy inputs?
It does not own audience acquisition, sequence draft topology, source staging,
publish, direct sends, sender setup, paid purchases, or live sequence runtime
diagnostics.

Craft depth loads on demand: `copy_critic.md` (ship/rewrite gate) and
`copy_ai_tells.md` (phrase/structure AI-tell catalog). Keep personalization
execution in this file.

## Use This When

- The user asks for copy advice, rewriting, critique, tone, positioning, or
  outbound message variants without a sequence id or SDK operation.
- The user asks for final user-facing wording and does not give a contrary
  style. Default to human voice, not neutral encyclopedia prose or chatbot
  polish.
- The user wants email, LinkedIn connection, LinkedIn DM, follow-up, subject
  line, CTA, or value-proposition copy drafted outside a live sequence edit.
- The user asks whether copy is too generic, too long, too salesy, too vague,
  too personalized, or too risky.
- You need to decide whether a cold outbound angle has a real campaign
  mechanic: the observable fact that makes this message timely and specific.
- You need to decide whether a proposed personalization angle is grounded
  enough before any provider job or sequence note write.
- Existing LinkedIn profile URLs need grounded one-off personalization output.
- An existing sequence audience needs a dry-run personalization preview before
  writing notes to the full audience.
- A sequence lead or staged row needs a reviewed `personalization_note`.
- A materialized sequence audience lead needs contact/company enrichment
  variables merged for copy rendering.
- Rows need context from profile/recent-post/company/investor research before
  personalization.

## Do Not Use This For

| Need | Use Instead | Why |
| --- | --- | --- |
| Saving copy into a draft Verbiflow sequence | `sequence_authoring.md` | Sequence authoring owns draft source/filter/step shape, exact step fields, copy variables, and sequence copy verification. |
| Authoring sequence source/filter/step structure, copy verification, or source/AI-filter previews | `sequence_authoring.md` | Sequence authoring owns draft/source/copy configuration and copy-quality checks for draft writes. |
| Full qualitative ship/rewrite after draft | `copy_critic.md` | Critic owns swap test, sterile-voice, density, CTA quality in the craft-first loop (before or with lint). |
| Full AI phrase/structure catalog | `copy_ai_tells.md` | Progressive-load tell catalog; keep this file craft-first. |
| Audience discovery, enrichment, or row preparation | `audience_discovery.md`, `enrichment.md`, `row_operations.md`, `artifacts_jobs.md` | Copy quality depends on the evidence, but these references own how rows and facts are acquired or shaped. |
| Canonical email, profile, company, phone, address, or company research enrichment | `enrichment.md` | Enrichment produces factual context; copy turns reviewed context into prose or copy inputs. |
| Deterministic row shaping, variable coverage, joins, or qualification | `row_operations.md`, `artifacts_jobs.md` | These prepare rows and artifacts before copy or personalization. |
| Sequence staging, publish, activation, transfer, delete, or audience repair | `sequences.md` | Sequence lifecycle owns activation and current-audience mutation boundaries. |
| Staging rows, publishing, activation, direct sends, or sender/domain purchases | `sequences.md`, `linkedin.md`, `mailboxes.md`, `safety.md` | Copy approval is not approval to mutate an audience, send, publish, or spend money. |
| Email verification, bounce risk, mailbox readiness, sender identity, or profile-photo setup | `enrichment.md`, `senders.md`, `safety.md` | Copy can ask whether send-readiness risk is controlled, but those references own validation and execution. |
| Workflow checkpointing, report rendering, or Play sync | `workflow_operations.md`, `workflow_reports.md`, `workflow_schedules.md` | Run/report operation and canonical Play sync are separate from copy-input generation. |

## Mental Model

Copy has four layers. Keep them separate.

| Layer | Owns | Route |
| --- | --- | --- |
| Copy strategy | Audience belief, pain, trigger, promise, proof, CTA, register, and channel fit. | `copy.md` |
| Sequence draft shape | Step topology, exact sequence fields, variables, waits, branches, source preview, AI filter preview, and copy verification. | `sequence_authoring.md` |
| Personalization execution | `{{personalization_note}}` generation, dry-run/full-audience note jobs, reviewed note writes, and enrichment writes after recipient rows exist. | `copy.md` |
| Activation | Staging, imports, publishing, activation, direct sends, sender readiness, and sender/domain purchases. | `sequences.md`, `linkedin.md`, `mailboxes.md`, `safety.md` |

Personalization is part of copy quality. In this file, "personalization" means
both deciding whether a message has a truthful, recipient-relevant hook and
running or writing grounded per-recipient notes through SDK surfaces.

There are three different personalization execution paths. Do not mix them.

| Path | Surface | Scope | Writes? | Preserve |
| --- | --- | --- | --- | --- |
| One-off generation | `vf.personalization.generate(...)` then `vf.personalization.status(job_id)` | Supplied LinkedIn targets or URLs | No sequence write; starts provider/LLM work | `job_id`, status, counts, result rows, reasons/signals |
| Sequence preview/full run | `vf.sequences.personalization_run(...)` then `vf.sequences.personalization_progress(...)` | Existing sequence audience | SDK default `dry_run=True` writes nothing; `dry_run=False` writes notes to full audience | `workflow_id`, dry-run flag, counts, written/skipped rows |
| Manual/enrichment write | `vf.sequences.set_personalization_note(...)` or `vf.sequences.update_audience_enrichments(...)` | One materialized lead or one staged row | Platform write | `contact_id` or `source_run_id` + `row_identifier`, updated fields |

Personalization is not a fallback for weak data. The backend is designed to
return no note when it lacks a strong signal. Empty is usually better than
generic praise or unverifiable claims.

### Drafting loop (required order)

Avoiding banned phrases is only half the job. Do not draft → lint → ship.

1. **Brief** — audience, mechanic, mentionable hook, economic value or
   non-commercial outcome, channel, one CTA, proof available.
2. **Draft** — shortest version that can earn the action.
3. **Self-critic** — run `copy_critic.md` (swap test, density, voice, CTA).
4. **Mechanical lint** — on every final line; rewrite critical/high before
   show. Standalone AI draft: `vf.copy.lint(text)`. Sequence-bound:
   `vf.copy.lint(text, sequence_id=...)` (or `allowed_variables=` from
   `sequences.template_variables`); do not strip enrichment vars to silence
   standalone lint.
5. **Backend rules when sequence-bound** — `vf.copy.lint(..., llm=True,
   channel=actual_channel)` for the nested receipt key `llm` (historical name;
   mechanical rules only, not a semantic LLM grade). Prefer
   `sequence_id=`/`allowed_variables=` on the same call when the body uses
   audience schema keys.
6. **Handoff** — sequence save → `sequence_authoring.md`; notes → paths below.

Clean-but-dead copy is a fail even when lint is green. See `copy_critic.md`.

### Inbox respect and economic value

Cold email works because the inbox is still a little bit sacred: personal,
high-signal, and tied to real work. Treat that as an operating constraint, not
a slogan. Do not occupy the inbox unless recipient fit, timing, claim, proof,
and ask justify the interruption.

For commercial cold outbound, every promise must clearly ladder to one of
three economic outcomes:

- make money;
- save money;
- save time.

Other benefits are mechanisms, not peer categories. Risk reduction,
compliance, visibility, hiring quality, fewer errors, better data, and less
manual work matter only when the copy connects them back to making money,
saving money, or saving time. If the economic value is unclear, rewrite the
offer before drafting. For non-commercial copy such as feedback requests,
intros, event attendance, content engagement, or no-ask nurture, name the
non-commercial outcome explicitly and keep the ask proportionate. Do not force
a fake money/time claim onto a relationship or community message.

Before drafting, name the campaign mechanic: the targetable fact that makes
this message timely and specific. Then separate the mentionable hook: the fact
you are allowed to put in the copy. Good mechanics include open roles, public
hiring signals, a specific public ad, a funding event, a technology migration,
a new market, a public workflow change, or consented first-party intent such as
a visited page. A mechanic can be useful without being mentionable: page intent
may justify who enters the campaign, while the visible copy uses a safer public
company-level hook or no hook at all. Do not write "saw you visited..." unless
the user explicitly confirms the recipient consented to that exact use. Do not
mention private, creepy, inferred, or non-consented tracking facts in copy.
Weak mechanics are broad personas, generic titles, "saw your post" without
relevance, or a guessed pain.

Before using a persona, sniff for pain: who is currently feeling the pain this
offer solves, and what observable fact proves it? Persona is not a pain
signal. "VP Sales at B2B SaaS" is a targeting label; "company is hiring SDRs
and has no visible routed-prospecting workflow" is a possible pain signal.
When all you have is title and company type, keep the claim segment-level or
route back to audience discovery/enrichment for better evidence.

Personalization is useful only when it explains why this recipient should care
now. It is not decoration, proof of effort, or a way to make a weak offer feel
custom.

### Channel taxonomy

Use one channel language while drafting, and map it to SDK fields only when
needed.

| Advice label | Sequence step channel | Personalization `type` | Meaning |
| --- | --- | --- | --- |
| Cold email | `email` | `email` | New email thread. Body must re-anchor context because the recipient may have no memory of prior touches. |
| Email reply | `reply_email` | n/a | Threaded email reply. No subject in sequence steps; visible thread context may be assumed. |
| LinkedIn connection request | `linkedin_connection` | `connection_request` | Short connection note. The goal is acceptance, not the full pitch. |
| LinkedIn DM/reply | `linkedin_message` | `linkedin_message` | LinkedIn message after a known relationship or accepted connection. More casual and DM-like than email. |

"Follow-up" is not a channel. It is a step intent or sequence topology. A
follow-up can be a new-thread email, a threaded email reply, a LinkedIn
connection request, or a LinkedIn message, and the copy shape changes with the
actual channel.

### Channel copy shapes

| Channel | Subject / signoff | Shape | Hard bans | Length |
| --- | --- | --- | --- | --- |
| Cold email (`email`) | Terse work-email subject (2–4 words preferred); signoff `{{sender_first_name}}` | Hook → mechanism (plain how) → one real proof if available → one low-friction ask | First-touch links, calendar, demo, 15 min unless user asked; vague "quick chat" | Prefer 60–75 words; soft ~90; hard ~200 |
| Email reply (`reply_email`) | No subject in steps; signoff yes | One **new** reason or sharper offer; no re-open of step 1 | "Following up", "checking in", "circling back" as the whole body | ~30–50 words |
| LinkedIn connection (`linkedin_connection`) | No subject; no signoff | Real reason to connect, then stop | Pitch, product, customers, URL, calendar, demo, multi-sentence VP, "would love" | ≤300 chars; target ≤280 |
| LinkedIn DM (`linkedin_message`) | No subject; no signoff | DM tone; reintroduce if not same visible thread; one concrete offer or question | Pasted full email pitch; multi-ask | ≤4 short sentences first DM |

Imitation targets and fail/pass pairs: `copy_critic.md` goldens.

### Human voice principles

Avoiding banned phrases is only half the job. Clean, correct, voiceless copy
still reads machine-written when every sentence has the same rhythm, every
claim is sanded smooth, and no person seems to be thinking behind it.

Default to a real speaker with judgment:

- **Have a point of view.** React when the channel allows it; do not only list facts.
- **No preamble or postamble.** Open on the hook or offer; end on the one ask (or breakup soft-out). Do not announce writing ("I wanted to reach out…"), summarize that you summarized, or close with help-desk tails ("let me know if you need anything").
- **Don't parrot the recipient.** Never open by restating their title, company mission, or LinkedIn about-blurb back at them. A mentionable hook is a *new* relevant fact, not their bio read aloud.
- **Warmth is earned.** No fawning openers. Praise only when it *is* the mentionable hook and clearly ladders to the offer; otherwise cut it. Do not force wit or jokes into first-touch commercial email.
- **Sender voice.** If the user provides samples or explicit style prefs, match that speaker. If not, default peer-operator voice — do not invent a quirky brand personality for multi-step sequences.
- **Vary rhythm.** Mix short and longer lines. Perfect three-part symmetry often feels synthetic.
- **Use first person when the sender would.** Contractions are fine when a peer would use them.
- **Calibrate register.** Peer language, not slang, emoji, fake chumminess, or chat-app leakage (`ppl`, `bc` stay banned). Peer shorthand (`YC`, `MIT`, `a16z`, `SOC 2`, `VP`) is fine. Do not invent insider slang the recipient's peers would not use.
- **Name concrete stakes.** "Managers wake up to a queue of unreviewed agent changes" beats "this is concerning."

Voice is not license to invent. Every factual claim still needs evidence.
Clean-but-dead before/after shapes live in `copy_critic.md`.

### Trigger words and local linting

Mechanical gates only. They do not prove the copy is good. Critic score (35/50)
and backend `pass` (score ≥ 7, zero criticals) are independent floors.

| Mode | Call | What it catches | Cost |
| --- | --- | --- | --- |
| Standalone regex (default) | `vf.copy.lint(text)` | Em/en dashes, banned phrases, begging/low-status phrasing, bracket placeholders, unknown `{{var}}` against the **fixed AI-draft allowlist only**. | Local, sub-100ms, no network, no credits. |
| Sequence / explicit keyspace | `vf.copy.lint(text, sequence_id=...)` or `allowed_variables=keys` | Same mechanical tells; `{{var}}` checked against audience schema + sender defaults (via `template_variables` / caller keys). Use for sequence-bound bodies. | `sequence_id=` does Bridge reads once (compose import field_schema); prefer resolve once and reuse `keys`. |
| + backend rules | `vf.copy.lint(..., llm=True, channel=actual_channel)` | Everything above PLUS channel structure, length caps, signoff, subject rules via the sequence copy verifier. **Not an LLM semantic judge** and not a grade on hook quality, specificity, or voice. | Backend network call. Nested result key is still `llm` (historical name). |

After the critic pass, run regex lint on every final line before show. Treat
any `critical` finding as a hard stop. Treat `high` findings as fix-before-show
unless the user wants raw text. Run `llm=True` with the actual channel before
`vf.sequences.create_draft` (receipt required —
`vf.copy.lint(..., llm=True)` returns a `llm` block with the same shape
`create_draft` accepts as `copy_verification`). Use
`copy_verification = lint_result["llm"]`.
The top-level lint result has local regex fields plus `passed`; it is not the
sequence draft receipt.
Lint each body with its own channel: `email`, `reply_email`,
`linkedin_connection`, or `linkedin_message`. Use `linkedin_connection` for a
sequence connection step; use `connection_request` only as the
`vf.personalization.generate(...)` type.

```python
lint_result = vf.copy.lint(email_body, llm=True, channel="email", subject=subject)
copy_verification = lint_result["llm"]
vf.copy.lint(linkedin_dm, llm=True, channel="linkedin_message")
```

Adding a ban: edit `flage/beshared/src/beshared/personalization/regex_filters.py`
first, then mirror into `verbiflow/copy.py` (parity-tested offline mirror).

#### Trigger phrases the regex already flags

Source of truth is `vf.copy.lint` / `verbiflow/copy.py` (mirrored from
beshared). Do not restate every pattern here. Patterns already flagged include
em/en dashes; bracket placeholders (`[First Name]`, `[Sender]`); invented
`{{var}}`s outside the **standalone lint** allowlist only (`first_name`,
`last_name`, `company_name`, `sender_first_name`, `sender_last_name`,
`sender_company_name`, `sender_calendar_link`, `personalization_note` — not
the sequence runtime keyspace); vague CTAs such as
"compare notes", `"quick chat"`, "worth a quick chat",
"worth sending/sharing/forwarding/shooting/dropping/firing",
"X-min chat/call/sync/coffee/connect/compare/catch",
"happy to connect/chat/share/discuss", "would love (to)",
"no worries if not/too busy", "totally understand if"; cliché openers
("hope you're well", "circling back", "just wanted to"); chat-app leakage.

That standalone allowlist is an **AI-draft safety net**, not the sequence
runtime surface. For sequence-bound copy, compose keys from existing import
field schemas (editor variable dropdown data):

```python
tv = vf.sequences.template_variables(sequence_id=sequence_id)
vf.copy.lint(body, allowed_variables=tv["keys"], channel="email")
# or vf.copy.lint(body, sequence_id=sequence_id, channel="email")
```

`template_variables` is SDK-side composition of `get` + `import_status`
field_schema — not a new backend product API. Same keyspace as publish.
Call once per draft session; reuse `tv["keys"]` on subsequent step lints.
See `sequence_authoring.md` Copy Variables.

Do not expand the ban list by vibes. `"I'd love to chat"` (I'd, not "would")
and `"Hope you're doing well"` are not regex bans by themselves. Still judge
them by context: meeting-first first-touch asks need explicit user intent or
warm/later-step context; a greeting line should not crowd out the real hook.
Local lint has no LinkedIn-connect carve-out for "happy to" / "would love" —
use a concrete reason plus "Would be great to connect." if you need a soft
close on a connection note.

### Copy authoring principles (from the backend personalizer)

These rules keep generated notes and agent-authored bodies aligned. Pre-
internalize them so local lint stays a safety net, not the primary craft:

- **Anchor to the recipient, not the third-person event.** "your post" / "your
  launch" / "your hire". Never "Saw the X talk" if you were not in the room
  (use "Congrats on the X talk").
- **ONE specific noun per line.** No compound noun stacks.
- **Vary the opener verb to match the event.** Job change → "Congrats on
  joining {company}." Talk → "Congrats on the {event} talk." Authored post →
  "Loved/Liked/Read your post on X." Default "Saw your X" only as last resort.
- **Bare acknowledgment of a post is filler.** Approve a post-reference only
  when the topic is in the same problem space as the value prop or the post is
  a recipient-owned milestone.
- **No celebrating someone else's milestone.** Portfolio company, colleague,
  alma mater, or third-party win is not personalization.
- **Job-change lines: drop the role title.** "Congrats on joining {company}"
  not "...as a Founding Engineer".
- **Institutional shorthand.** "YC", "MIT", "a16z". Peer words for systems and
  standards; no invented insider jargon.
- **No high-friction first-touch asks by default.** No links, calendar, demo,
  "15 minutes", or meeting-first motion unless the user explicitly requests it.
  LinkedIn connection requests never sell: no URL, calendar, demo, meeting ask,
  product pitch, or customer namedrop.
- **Assume zero recipient memory in the BODY of a new email thread.** Re-anchor
  who the sender is, what the company does, and the offer. Subjects stay terse.
  Only `reply_email` can lean on a visible thread; `linkedin_message` after a
  connect-and-never-replied needs a short reintroduction.
- **Breakups stay short.** Target ≤ 40 words. One re-anchor + one ask. Do not
  mention the ignored prior step.
- **Breakups MUST end with a soft-out clause.** Last sentence shape:
  `<offer/ask>, or <soft-out>?` joined by `, or `. Soft-outs are a recipient
  out (their roadmap/timing/setup), not sender softeners. They are legal even
  though "happy to" / "would love" / "worth a chat" stay banned. Rotate
  phrasing; never "or should I stop reaching out?".

  - Bad: "Worth a look for {{company_name}}?" — offer with no out.
  - Bad: "Should I send the details?" — offer with no out.
  - Good: "Should I send the details, or is this not a priority right now?"
  - Good: "I have a short doc on it for {{company_name}}, or has browser infra moved down the list?"

- **Canonical breakup shape:**

  > Hi {{first_name}},
  >
  > {{sender_company_name}} {does X} for {peer-segment} ({customer-1}, {customer-2}). If {{company_name}} is building in that area, I can send a short {artifact}, or is this not on the roadmap right now?
  >
  > {{sender_first_name}}

  Real example (good):
  > Hi {{first_name}},
  >
  > Browserbase runs cloud browsers for engineering teams doing agents, scraping, or test automation. If {{company_name}} is building in that area, I can send a short architecture doc, or is this not on the roadmap right now?
  >
  > {{sender_first_name}}

  Social proof: real customers from `sender_context.customers` only; empty →
  skip proof; never invent names. Prefer the literal company name if you do not
  need `{{sender_company_name}}`.

### AI copy pitfalls (write-time)

Full catalog: `copy_ai_tells.md`. Do not add fuzzy vocabulary to regex.
Highest-impact kills: binary contrasts ("It's not X, it's Y"), dramatic
fragmentation, throat-clearing, vague declaratives, false agency, SaaS jargon.
**Unsupported spintax and word shuffling:** do not ship `Hey|Hi|Hello`,
raw variables such as `first_name`, or synonym-shuffled lines unless the
renderer supports that syntax. Lint and verify every rendered alternative,
not just the base draft.

## Preflight

1. Fill the brief fields in Data Contracts (audience, pain_signal,
   campaign_mechanic, mentionable_hook, economic_value or
   non_commercial_outcome, argument_quality, channel, cta, evidence). Do not
   treat "follow-up" as a channel. Booked-call CTAs need explicit user intent
   or warm/later-step context.
2. Boundary: advice only, draft sequence edit, personalization job, or send.
3. For personalization execution, verify auth/workspace with
   `vf.auth.status()` or `vf.session.init()`.
4. Confirm target rows belong to the intended sequence, source run, artifact,
   or workflow run.
5. Ensure target contacts have LinkedIn person profile URLs when the path
   depends on profile or recent-post context.
6. Print a redacted sample with names, company, title, LinkedIn URL,
   available context fields, and sequence variables.
7. Resolve factual context first with `enrichment.md` when needed.
8. Check variable coverage with `vf.rows.check_variable_coverage(...)` before
   staging or publishing copy that references generated variables.
9. For sequence personalization, run preview first. Only pass `dry_run=False`
   after preview and copy-variable checks are acceptable.
10. For manual note writes, prove identity: either `contact_id` for a
   materialized lead, or both `source_run_id` and `row_identifier` for a
   staged row.

If the boundary is unclear, treat the work as advice only and do not call SDK
mutation surfaces.

## Risk And Side Effects

| Work | Risk | Rule |
| --- | --- | --- |
| Standalone copy advice or drafting | No platform side effect | Safe to do in-chat. Be explicit about assumptions and missing evidence. |
| Copy that mentions recipient-specific facts | Trust and compliance risk | Use only facts supplied by the user or resolved through the owning evidence reference. Do not invent achievements, pains, relationships, or intent. |
| Sequence-bound copy | Platform write risk once saved | Route to `sequence_authoring.md` before any draft mutation or sequence copy verification call. |
| `vf.personalization.generate(...)` | Provider/LLM work and observed Growth usage; no sequence write | Bound input count, preserve `job_id`, poll status, inspect approved/skipped/not_found counts. |
| `vf.sequences.personalization_run(..., dry_run=True)` | Provider/LLM work and observed recent-post/profile usage; writes nothing | Default SDK path. Use for preview and sample review. |
| `vf.sequences.personalization_run(..., dry_run=False)` | Provider/LLM work plus platform writes to the full sequence audience | Treat as a meaningful sequence mutation; print sequence id, audience size, and require explicit intent. |
| `vf.sequences.set_personalization_note(...)` | Platform write to one materialized lead or one staged row | Use only after the note has been reviewed or deterministically computed. |
| `vf.sequences.update_audience_enrichments(...)` | Platform write to one materialized lead; optional enrolled-runtime refresh | Use when updating variables for already materialized audience contacts. Be explicit about `refresh_enrolled_instances`. |
| Sending, publishing, or buying resources | Customer-visible or billing risk | Route to `safety.md` and the owning execution reference; require explicit confirmation immediately before the call. |

## Canonical Operating Pattern

Craft follows **Drafting loop (required order)** above (brief → draft →
`copy_critic.md` → lint → show). Then:

1. If the copy will enter a Verbiflow sequence, hand off to
   `sequence_authoring.md` with the mechanical receipt.
2. If per-recipient notes must be generated or written, use the execution
   patterns below: preview first, preserve job/workflow ids, inspect result
   rows, then write only after review.

### One-Off Personalization

```python
import verbiflow as vf

vf.auth.ensure_logged_in()

job = vf.personalization.generate(
    linkedin_urls=["https://www.linkedin.com/in/example"],
    sender_context={
        "company_name": "Example",
        "sender_first_name": "Ada",
    },
    type="email",
    value_prop="Help teams ship reliable AI agents.",
    cta="Should I send the eval checklist?",
    sequence_brief=(
        "Outbound to engineering leaders at AI infrastructure companies who "
        "recently posted about evals."
    ),
)
print("personalization job", job["job_id"], job["status"], job.get("input_count"))

status = vf.personalization.status(str(job["job_id"]))
print(status.get("finished"), status.get("approved_count"), status.get("skipped_count"))
```

### Sequence Personalization Preview

```python
preview = vf.sequences.personalization_run(
    sequence_id="seq_...",
    dry_run=True,
    max_contacts=10,
)
workflow_id = str(preview["workflow_id"])
print("preview workflow", workflow_id, preview)

progress = vf.sequences.personalization_progress(
    sequence_id="seq_...",
    workflow_id=workflow_id,
)
print(progress["status"], progress["written"], progress["skipped"])
```

### Variable Coverage From Artifact Rows

```python
page = vf.artifacts.get("gda_...", limit=50)
coverage = vf.rows.check_variable_coverage(
    rows=page.records,
    variables=["first_name", "company_name", "personalization_note"],
)
print(coverage["covered"], coverage["missing_variables"])
```

### Reviewed Note Write

```python
result = vf.sequences.set_personalization_note(
    sequence_id="seq_...",
    contact_id="contact_...",
    personalization_note="Read your AI evals post about flaky judge scores.",
)
print(result)
```

## Core SDK Surfaces

<!-- BEGIN GENERATED: core-sdk-surfaces -->
| Function | Use When | Inputs | Output And Handles To Preserve | Side Effect | Source |
| --- | --- | --- | --- | --- | --- |
| `vf.copy.lint` | Lint copy for mechanical AI/outbound tells. Default mode is local regex (sub-100ms): em-dashes, banned phrases, begging language, bracket placeholders, and template vars against the standalone AI-draft allowlist. For sequence-bound copy pass sequence_id= or allowed_variables= (from sequences.template_variables, which reuses import field_schema) so audience schema keys are not false-flagged. Pass llm=True (historical name; not an LLM) to also call vf.sequences.verify_copy(...) for backend mechanical rule checks. Does not grade hook quality, voice, or specificity; use copy_critic.md. | <strong>Required</strong>: `text` (str \| Sequence[str]; required): A single string or list of strings (one per copy line) to lint.<br><strong>Optional</strong>: `allowed_variables` (Sequence[str] \| None; optional; default `None`): Explicit authoring keyspace for {{var}} checks (e.g. keys from sequences.template_variables, which composes import field_schema). Mutually exclusive with sequence_id. When omitted, standalone AI-draft allowlist is used.; `channel` (str; optional; default `'email'`): Channel context for backend rule checks when llm=True: email, reply_email, linkedin_connection, linkedin_message, or linkedin_inmail. Ignored when llm=False. Default email.; `llm` (bool; optional; default `False`): Historical flag name kept for API compatibility: does NOT run an LLM. When true, also call vf.sequences.verify_copy(...) for backend mechanical rule checks (channel structure, length caps, signoff, subject rules, additional bans). Network call; nested result key is still "llm" for receipt shape. Qualitative craft is agent judgment (copy_critic.md), not this flag. Default false.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID. Used when llm=True or sequence_id is set (workspace-scoped Bridge reads).; `sequence_id` (str \| None; optional; default `None`): Sequence id: compose allowlist from sequences.get + import_status field_schema (via template_variables). Mutually exclusive with allowed_variables. Prefer for sequence-bound copy.; `subject` (str \| None; optional; default `None`): Optional subject line; sent to backend rule checks when llm=True and channel=email or linkedin_inmail. Ignored for replies and non-subject channels. | `copy_lint_result` 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.verify_copy`, `vf.sequences.template_variables` | read | installed SDK: `verbiflow/copy.py:325-475` |
| `vf.personalization.generate` | Use for one-off LinkedIn-target personalization through Bridge after sender context, value prop, CTA, and sequence brief are known. | <strong>Required</strong>: `cta` (string; required): Call to action the generated line or message should support.; `sender_context` (object; required): Required sender/company context used to ground generated copy, such as sender name, company, role, company description, customers, investors, or country.; `sequence_brief` (string; required): Two to four sentences grounding the sequence: product, target persona/company, audience trigger, and why the angle matters.; `type` (email \| connection_request \| linkedin_message; required): Output channel shape: email, connection_request, or linkedin_message.; `value_prop` (string; required): Sender value proposition used to keep the generated hook tied to the sequence goal.<br><strong>Optional</strong>: `linkedin_urls` (array; optional): LinkedIn person profile URLs to personalize. Use `targets` instead when each recipient has separate context.; `posts_within_days` (integer; optional): Recent-post lookback window for personalization evidence. Backend accepts 1..180 days.; `targets` (array; optional): Personalization target rows with `linkedin_url` and optional per-recipient `context`; use when you have grounded per-lead context.; `template` (string; optional): Optional template containing a `{{personalization_note}}` slot. When provided, results return one short note line instead of full bespoke copy.; `today_iso` (string; optional): Today iso parameter for `personalization.generate`.<br><strong>Note</strong>: Pass either `targets` or `linkedin_urls`. Backend requires `sender_context`, `type`, `value_prop`, `cta`, and `sequence_brief`; extra keys are rejected. | `dict` with one-off personalization job status. Preserve `job_id`; poll `vf.personalization.status(job_id)` before reading final results.<br><strong>Inspect</strong>: Start returns `job_id`; poll `vf.personalization.status(job_id)` until `finished=True`. Completed rows include approved/skipped/not_found counts plus per-recipient reason and signal fields.<br><strong>Common next calls</strong>: `vf.personalization.status` | provider_job | installed SDK: `verbiflow/personalization.py:14-57` |
| `vf.personalization.status` | Use to poll a one-off personalization job until terminal before reading result rows. | <strong>Required</strong>: `job_id` (str; required): Backend job id returned by a start/search/enrichment/qualification call.<br><strong>Optional</strong>: none | `dict` with one-off personalization job status. Preserve `job_id`; poll `vf.personalization.status(job_id)` before reading final results.<br><strong>Inspect</strong>: Use `finished`, `failed`, `input_count`, `fetched_row_count`, and result counts to identify which step failed. | read | installed SDK: `verbiflow/personalization.py:59-72` |
| `vf.sequences.personalization_progress` | Use to poll a sequence personalization workflow started by `sequences.personalization_run`. | <strong>Required</strong>: `sequence_id` (str; required): Verbiflow sequence id.; `workflow_id` (str; required): Sequence personalization workflow id returned by `sequences.personalization_run`.<br><strong>Optional</strong>: `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.<br><strong>Note</strong>: `workflow_id` is required by Bridge; preserve it from `sequences.personalization_run(...)`. | `dict` with `sequence_id`, `workflow_id`, status, finished/failed flags, latest step, total/written/skipped counts, result rows when complete, and error message when failed.<br><strong>Inspect</strong>: Returns the latest workflow snapshot; final successful responses include per-contact result rows. | read | installed SDK: `verbiflow/sequences.py:3564-3601` |
| `vf.sequences.personalization_run` | Use to start sequence personalization preview by default; pass dry_run=False explicitly only when writing notes to the full audience is intended. | <strong>Required</strong>: `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `contact_ids` (list[str] \| None; optional; default `None`): Optional contact ids for dry-run preview. Ignored by backend run-for-all, so the SDK rejects them when dry_run is false.; `dry_run` (bool; optional; default `True`): When true, run a preview that writes nothing. Pass false explicitly only when notes should be written to the full sequence audience.; `max_contacts` (int; optional; default `10`): Maximum random audience contacts sampled for dry-run preview when contact_ids are omitted. Clamped to 1..1000.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.<br><strong>Note</strong>: SDK default is `dry_run=True` and writes nothing. `dry_run=False` writes notes to the full sequence audience and ignores contact_ids, so the SDK rejects that combination. | `dict` ack for a sequence personalization workflow. Preserve `workflow_id`, `sequence_id`, dry-run state, and counts; poll `vf.sequences.personalization_progress(...)`.<br><strong>Inspect</strong>: Returns an ack; preserve `workflow_id` for progress polling. It is not the final result payload.<br><strong>Common next calls</strong>: `vf.sequences.personalization_progress` | provider_job_or_platform_write | installed SDK: `verbiflow/sequences.py:3387-3460` |
| `vf.sequences.set_personalization_note` | Use to write a personalization/enrichment note for a sequence contact or source run. | <strong>Required</strong>: `personalization_note` (str; required): Visible personalization note to attach as `personalization_note` for one materialized contact or one staged source row.; `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `contact_id` (str \| None; optional; default `None`): Verbiflow contact/person id to attach personalization notes or routing metadata to.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `row_identifier` (str \| None; optional; default `None`): Staged-row identifier required with source_run_id when contact_id is not provided.; `source_run_id` (str \| None; optional; default `None`): Sequence source run id that produced the row/note being updated.<br><strong>Note</strong>: Provide either `contact_id` for a materialized audience lead, or both `source_run_id` and `row_identifier` for a staged row. | `dict` with updated personalization/enrichment fields, sequence id, row/contact identifiers, draft version, validation messages, and optional enrolled-instance refresh counts.<br><strong>Inspect</strong>: Contact-id path updates a materialized audience lead; source-run path updates one staged row before materialization. | platform_write | installed SDK: `verbiflow/sequences.py:3462-3508` |
| `vf.sequences.update_audience_enrichments` | Use to merge personalization/contact/company enrichment variables onto an existing materialized sequence audience lead. | <strong>Required</strong>: `contact_id` (str; required): Verbiflow contact/person id to attach personalization notes or routing metadata to.; `sequence_id` (str; required): Verbiflow sequence id.<br><strong>Optional</strong>: `company_enrichments` (Mapping[str, object \| None] \| None; optional; default `None`): Company enrichments parameter for `sequences.update_audience_enrichments`.; `contact_enrichments` (Mapping[str, object \| None] \| None; optional; default `None`): Contact enrichments parameter for `sequences.update_audience_enrichments`.; `org_id` (str \| None; optional; default `None`): Verbiflow organization/workspace UUID.; `refresh_enrolled_instances` (bool; optional; default `False`): When true, refresh already-enrolled runtime instances from updated audience attributes where backend repair permits it.<br><strong>Note</strong>: Requires `contact_id` for an existing materialized audience lead and at least one of `contact_enrichments` or `company_enrichments`. | `dict` with updated personalization/enrichment fields, sequence id, row/contact identifiers, draft version, validation messages, and optional enrolled-instance refresh counts.<br><strong>Inspect</strong>: Merges variables onto one materialized audience lead. `refresh_enrolled_instances=True` may update already-enrolled runtime attributes. | platform_write | installed SDK: `verbiflow/sequences.py:3510-3562` |
<!-- END GENERATED: core-sdk-surfaces -->

## Data Contracts

For standalone copy work, keep the working brief explicit:

| Field | Meaning |
| --- | --- |
| `audience` | Who receives the message. Include role, company type, pain signal, and trigger when known. |
| `pain_signal` | The observable fact showing the recipient or account is likely feeling the problem now. Persona alone is not a pain signal. |
| `campaign_mechanic` | The targetable fact that makes the message timely and specific. |
| `mentionable_hook` | The public, consented, or user-supplied fact allowed to appear in the copy. |
| `decision_maker_map` | Who feels the pain, who owns the workflow, who approves/buys, and whether multi-threading is appropriate. |
| `economic_value` | For commercial cold outbound, the promise category: make money, save money, or save time. |
| `non_commercial_outcome` | For feedback, intro, event, content engagement, or no-ask nurture copy, the relationship/community outcome that justifies the message. |
| `argument_quality` | Internal drafting check: claim, warrant, evidence, and impact. Do not render the labels in final copy. |
| `sender_context` | Who is sending and why they are credible. |
| `offer` | The actual thing being proposed, not a vague outcome. |
| `proof` | Concrete reason to believe the offer. |
| `channel` | Cold email, email reply, LinkedIn connection request, LinkedIn DM/reply, or internal review note. Map to one of the four visible outbound channels before linting, generation, or sequence handoff. |
| `cta` | The single requested next action. |
| `evidence` | Facts allowed in the copy. Empty evidence should produce generic segment copy, not invented personalization. |

### One-Off Personalization Payload

`vf.personalization.generate(...)` forwards kwargs to Bridge. Bridge rejects
unknown fields and requires real one-off personalization inputs:

| Field | Required | Meaning |
| --- | --- | --- |
| `targets` or `linkedin_urls` | one required | Recipient LinkedIn person profile URLs. Use `targets` when each recipient has its own extra context. |
| `targets[].context` | optional | Per-recipient grounding such as prior chat, meeting note, or known question. Server trims it before use. |
| `sender_context` | required | Sender/company facts: company name, description, cohort, customers, investors, sender name, schools, prior employers, country. |
| `type` | required | Output channel shape: `email`, `connection_request`, or `linkedin_message`. |
| `template` | optional | If present, output is a short `personalization_note` line for the `{{personalization_note}}` slot. If absent, output is full bespoke copy for the channel. |
| `value_prop` | required | Sender value proposition used to keep the hook relevant. |
| `cta` | required | Call to action the generated note/message should support. |
| `sequence_brief` | required | Two to four sentences explaining product, persona/company target, audience trigger, and why this angle matters. |
| `posts_within_days` | optional | Recent-post lookback, default `20`, backend accepts `1..180`. |

Status/result rows include `linkedin_url`, `found`,
`personalization_note`, `bespoke_subject`, `bespoke_body`, `reason`,
`signal_type`, and `signal_date`.

### Sequence Personalization

`vf.sequences.personalization_run(...)` returns an ack, not results. Preserve
`workflow_id` and poll `vf.sequences.personalization_progress(...)`.

| Field | Meaning |
| --- | --- |
| `sequence_id` | Sequence being personalized. |
| `dry_run` | SDK default `True`; preview writes nothing. `False` writes notes to the full audience. |
| `contact_ids` | Optional preview contact ids. Only valid with `dry_run=True`. |
| `max_contacts` | Random preview sample size when contact ids are omitted, clamped to `1..1000`. |
| `workflow_id` | Required for progress polling. |

Progress includes `status`, `finished`, `failed`, `latest_step`, `total`,
`written`, `skipped`, `results`, and `error_message`.

### Note And Enrichment Writes

`vf.sequences.set_personalization_note(...)` has two mutually exclusive target
shapes:

| Target | Required Fields | Effect |
| --- | --- | --- |
| Materialized audience lead | `sequence_id`, `contact_id`, `personalization_note` | Updates audience lead enrichments. |
| Staged source row | `sequence_id`, `source_run_id`, `row_identifier`, `personalization_note` | Updates staging row enrichments before materialization. |

`vf.sequences.update_audience_enrichments(...)` is broader: it merges
`contact_enrichments` and/or `company_enrichments` onto one materialized
audience lead. Use `refresh_enrolled_instances=True` only when you intend to
refresh already-enrolled runtime variables and have reviewed the impact.

## Examples

### Standalone Copy Review

```text
Audience: VP Sales at B2B SaaS companies hiring SDRs
Pain signal: public SDR openings suggest list-building or routing work is increasing
Campaign mechanic: hiring pages mention SDR openings
Mentionable hook: public SDR job openings
Decision-maker map: VP Sales feels pipeline coverage pain; RevOps may own routing; founder may approve at small companies
Economic value: save time
Argument quality: claim = SDR hiring creates more prospecting ops; warrant = hiring pages prove the workflow exists; evidence = public openings; impact = reps spend less time building lists by hand
Sender: Workflow automation company for GTM teams
Channel: cold email
CTA: send a 3-step SDR hiring workflow teardown
Evidence: hiring pages mention SDR openings
```

Run `copy_critic.md` against that brief before show. Mechanic and hook can
differ:

```text
Campaign mechanic: consented first-party visit to the pricing page
Mentionable hook: no direct hook; segment-level note about procurement teams
Economic value: save time
```

### Channel Shape Examples

Gold-standard before/after pairs live in `copy_critic.md` (required imitation
before SHIP). Minimal channel floor:

Cold email: see critic golden #1 After (hook + mechanism + soft-out ask).

LinkedIn connection (reason only, no pitch):

```text
Hi {{first_name}}, saw {{company_name}} is hiring SDRs. Would be great to
connect.
```

LinkedIn DM/reply:

```text
Thanks for connecting, {{first_name}}. Saw the SDR hiring push at
{{company_name}}. I have a short teardown on cutting manual prospecting from
that workflow. Should I send it?
```

### Handoff To Sequence Authoring

If the user says "save this as a sequence" or gives a sequence id, stop using
this file as the execution guide. Open `sequence_authoring.md`, preserve draft
ids/version metadata, map copy into exact step objects, and use the sequence
copy verification flow there.

## Verification

Standalone copy is correct when:

- facts stay inside the brief or routed evidence;
- `copy_critic.md` returns SHIP (or score ≥ 35/50) before show;
- mechanical lint/rule pass is a floor, not a craft grade;
- channel shape matches cold email, email reply, LinkedIn connection, or
  LinkedIn DM;
- sequence-bound work hands off to `sequence_authoring.md`;
- personalization preserves job/workflow ids, dry-runs before writes, and
  inspects written/skipped/error counts;
- the copy does not imply activation, sending, or approval.

## Troubleshooting

| Symptom | Fix |
| --- | --- |
| Copy reads generic | Add a real audience trigger or proof point. If none exists, keep the copy honestly segment-level. |
| Persona is doing all the work | Add the observable pain signal or route to `audience_discovery.md` / `enrichment.md` to find one. |
| Argument sounds plausible but weak | Write claim, warrant, evidence, and impact on scratch. If one is missing, fix the campaign idea before polishing. |
| Commercial cold outbound has no economic value | Rewrite the offer until it clearly helps the recipient make money, save money, or save time. |
| Non-commercial copy has no outcome | Name the relationship/community outcome, then shrink the ask until it is proportionate to that outcome. |
| Copy has no campaign mechanic | Find an observable fact that explains why this recipient should care now, or keep the copy segment-level and lower the claim. |
| Copy is clean but lifeless | Add a real speaker: point of view, varied rhythm, concrete stakes, or first person when the sender would naturally use it. Run `copy_critic.md`. |
| Copy feels over-personalized | Remove facts that are not clearly relevant to the offer or desired action. |
| CTA is weak | Replace multiple asks with one concrete next step. |
| LinkedIn copy reads like email | Remove subject/signoff, compress context, make it more DM-like, and keep one small ask. |
| Lint is green but draft still sucks | Mechanical gates passed; craft failed. Run `copy_critic.md` and `copy_ai_tells.md`. |
| User asks to save/publish/send | Leave this file. Use the owning SDK reference and `safety.md` where required. |
| Personalization is requested but rows are unknown | Acquire or identify recipients first; use `audience_discovery.md`, `enrichment.md`, or `sequences.md` as appropriate. |
| One-off personalization returns mostly skipped rows | Inspect `reason`, `signal_type`, and source evidence. Empty is preferable to generic praise. |
| Sequence personalization write would affect too many contacts | Run `dry_run=True`, bound `max_contacts`, inspect samples, and require explicit intent before `dry_run=False`. |
| Note write target is ambiguous | Use either `contact_id` for a materialized lead or `source_run_id` plus `row_identifier` for a staged row. |

## Related References

| Reference | Use When |
| --- | --- |
| `copy_critic.md` | Qualitative ship/rewrite after draft; swap test and density gate. |
| `copy_ai_tells.md` | Full phrase/structure AI-tell catalog after clean-but-dead drafts. |
| `sequence_authoring.md` | Copy must become draft sequence steps, variables, waits, branches, or verified sequence copy. |
| `sequences.md` | Approved draft/audience work moves toward staging, publishing, activation, transfer, delete, or live sequence mutation. |
| `enrichment.md` | Copy needs factual contact, company, profile, recent-post, domain, investor, or company-research context. |
| `safety.md` | The next action is customer-visible, spend-sensitive, publish/send-related, or otherwise requires confirmation. |
