Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6104efd
feat(campaigns): campaign recipients, audience members and opt-out se…
purpshell Sep 20, 2026
51a7c95
docs(campaigns): document recipients, audiences and opt-out settings
purpshell Sep 20, 2026
e4ff1e3
docs(campaigns): re-pin the contract and SDK baselines
purpshell Sep 20, 2026
1954024
docs(campaigns): re-pin to the final contract revision
purpshell Sep 20, 2026
48a2fed
fix(campaigns): stop promising a generated key and retries on appends
purpshell Sep 22, 2026
f16bae5
Merge origin/dev into t3code/campaigns-p0-cli
purpshell Sep 22, 2026
bd0edbd
fix(campaigns): refuse file imports without a mapping and empty-phone…
purpshell Sep 22, 2026
ea3719c
docs(campaigns): move the SDK baseline to sdks 2862336
purpshell Sep 22, 2026
5fad0ba
fix(campaigns): refuse phones the request schema rejects; quote-aware…
purpshell Sep 22, 2026
c72acac
fix(campaigns): refuse empty JSON batches and the __proto__ variable …
purpshell Sep 22, 2026
484a0c2
docs(cli): describe campaign deduplication and operation inspection
purpshell Sep 22, 2026
574717d
Merge dev into Campaigns P0 CLI preserving SDK publication gate
purpshell Sep 23, 2026
f92b3ef
Fix CSV row reporting and JSON recipient validation
purpshell Sep 23, 2026
422abdc
Reject reserved JSON recipient variable name
purpshell Sep 23, 2026
4402031
Validate CSV values and report no-op audience removal
purpshell Sep 23, 2026
9341066
Pin published Campaigns SDK for CLI
purpshell Sep 23, 2026
0ac1a3e
Merge current CLI dev into Campaigns P0
purpshell Sep 23, 2026
b7342bc
Resolve CSV delimiter ties using phone column
purpshell Sep 23, 2026
277001f
Choose CSV separator by phone header
purpshell Sep 23, 2026
2922a92
Merge CLI retention changes into Campaigns P0
purpshell Sep 23, 2026
c36cb96
Remove duplicate CLI topic declarations
purpshell Sep 23, 2026
2905559
Merge Functions CLI into Campaigns P0
purpshell Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 133 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ prerelease in `package.json`.
The version output includes both runtime components:

```text
polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.0
polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20260923115544
```

The SDK component reports the version constant built into the SDK. SDK dev
prereleases report `0.1.0-dev.0`, not their full prerelease version; run
`npm ls @polymorfa/sdk` to see the installed build.
The SDK component reports the version built into the installed SDK. Dev
prereleases include their full timestamped version. Run `npm ls @polymorfa/sdk`
to see the installed build.

## Authentication profiles

Expand Down Expand Up @@ -1063,31 +1063,142 @@ polymorfa campaign requeue campaign_123 \
--include-skipped-error
```

Launch, pause, resume, and stop return accepted durable work; they do not wait
for the state transition. Check the campaign with `polymorfa campaign view`.
Launch, pause, and resume return accepted durable work; they do not wait for
the state transition. Check the campaign with `polymorfa campaign view`.
Follow the returned operation with `polymorfa operation wait` or
`polymorfa operation watch`; see [Operations](#operations).

Stop is terminal and requires `--yes`. A campaign that has an active delivery
run answers with an operation ID, as the other lifecycle commands do. A
campaign with no active run is cancelled immediately and answers without one;
the CLI reports that outcome instead of printing an empty operation.

Launch times accept epoch milliseconds or timezone-qualified ISO 8601
instants. List returns one complete newest-first array with no cursor. Requeue
is synchronous and reports only the number moved. Every mutation accepts
`--idempotency-key`. `campaign create` and `campaign launch` send an
`Idempotency-Key` by default, generating a fresh key for each invocation when
you omit the flag. Pass the same explicit key from the first attempt when
retrying either command in another process, with the same input, credential,
and API version.

Both commands use the [retry identity](#retry-identity) contract above: within
24 hours, a recorded success returns `409 idempotency_completed` without the
original response body; an uncertain outcome stays blocked rather than running
again. Save the first campaign or operation response and inspect campaign
state before using a new key. Pause, resume, stop, and requeue do not persist
the header, so it is not server-side deduplication for them.
is synchronous and reports only the number moved.

Create, launch, pause, resume and stop accept `--idempotency-key` to deduplicate
the same request for 24 hours. A successful request repeated with the same key
returns `409 idempotency_completed`, not the original response. Changed input
returns `409 idempotency_conflict`; an in-progress or uncertain result must be
resolved before trying a new key. The SDK generates a fresh key for create and
launch when the flag is omitted. Supply an explicit key from the first attempt
to reuse it across CLI invocations with the same input, credential and API
version. Save the first campaign or operation response and inspect campaign
state before using a new key. Pause, resume and stop persist an explicit key;
they do not generate one when the flag is omitted.

Requeue also accepts the header, but the API does not deduplicate it. Each
invocation moves recipients eligible at that time, so a retry can move newly
failed recipients rather than returning the first result.

### Campaign recipients

Recipients are appended to a campaign that has not started sending, and read
back one cursor page at a time:

```shell
polymorfa campaign recipients add campaign_123 \
--project-slug launch-project \
--csv contacts.csv \
--phone-column Phone \
--variable "firstName=First name"

polymorfa campaign recipients add campaign_123 \
--project-slug launch-project \
--input recipients.json

polymorfa campaign recipients list campaign_123 \
--project-slug launch-project \
--status skipped \
--limit 100
```

One append accepts at most 1,000 entries. `--csv` reads the file locally and
sends the rows inline; it does not upload the file. Blank rows are ignored, but
a row with other values and an empty phone cell stops the command before
anything is sent, naming the row numbers (the header is row 1). `--input`
entries with an empty phone, and any phone longer than 64 characters, are
refused the same way, as is an empty `--input` array, because the API would
reject the whole request. JSON variables must use valid names and string values;
CSV and JSON variable values are limited to 1,024 characters. Reported invalid
rows for `--csv` refer to the
original spreadsheet rows, including blank rows. `__proto__` is not accepted as a `--variable` name. Comma,
semicolon and tab separators are detected from the header row; separators
inside quoted fields are ignored. A phone already on the
campaign is skipped, and invalid entries are reported rather than added, so the
result carries `added`, `duplicateCount`, `invalidCount` and up to 20
`invalidRows`.

The API does not replay an append, so the CLI sends it once and never retries
it automatically, with or without `--idempotency-key`. A resent append would
count the first attempt's rows as duplicates. If a connection drops before the
result arrives, list the recipients before appending again.

List returns `{data, page}`. Pass `page.nextCursor` back as `--cursor` while
`page.hasMore` is true; the CLI prints the next cursor in human output. Filter
with `--status queued|sending|sent|delivered|read|failed|skipped`. A recipient
suppressed by an opt-out appears as `skipped` with `lastError` `opted_out`.

The source exposes no campaign update, delete, validate, archive, duplicate,
recipient, event, history, or replay route. The CLI does not substitute
similarly named Platform campaign methods for these gaps. To follow or stop a
campaign's operation, use the `operation` commands in [Operations](#operations).
event, history, or replay route on the Messaging surface. The CLI does not
substitute similarly named Platform campaign methods for these gaps. To follow
or stop a campaign's operation, use the `operation` commands in
[Operations](#operations).

## Campaign audiences

Audience commands are organization-scoped and use the Platform API:

```shell
polymorfa audience create --name "August launch" \
--csv contacts.csv \
--phone-column Phone \
--variable "firstName=First name"

polymorfa audience create --name "August launch" \
--file-id upload_1 \
--mapping mapping.json

polymorfa audience members add list_123 --csv contacts.csv
polymorfa audience members list list_123 --limit 100
polymorfa audience members remove list_123 +15551234567
```

`--csv` sends up to 1,000 members inline. `--file-id` imports a file that was
already uploaded through the API and needs `--mapping`, a JSON document naming
the phone column and any variable columns:

```json
{"phone": "Phone", "variables": {"firstName": "First name"}}
```

Creation and appends report the same import counts as campaign recipients, so a
caller can reconcile what was accepted. Like recipient appends, `members add` is
sent once and never retried automatically. Member listing is cursor-paged like
recipient listing.

## Campaign opt-outs

Opt-out suppression is organization-wide. Keyword capture has its own settings:

```shell
polymorfa optout settings view
polymorfa optout settings update \
--enabled \
--opt-out-keyword STOP \
--opt-out-keyword BAJA \
--opt-in-keyword START
polymorfa optout settings update --no-enabled --opt-out-keyword STOP
```

Update replaces the whole settings document, so pass the complete keyword set
every time; the CLI refuses a partial update rather than clearing keywords the
caller did not mention. At least one opt-out keyword is required even when
capture is off. When capture is on, a matching reply suppresses the contact and
the API emits `contact.opted_out`; an opt-in keyword emits `contact.opted_in`.
A suppressed contact is skipped at dispatch, which is visible as a `skipped`
recipient with `lastError` `opted_out`.

## Developer events and webhooks

Expand Down
142 changes: 123 additions & 19 deletions docs/command-research/campaigns.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Messaging campaign command research

- **Status:** Complete nine-operation organization-key implementation slice
- **Reviewed:** 2026-08-20
- **SDK baseline:** `b4d5f3c8ce51bb0c171264eec8961797314cb172`
- **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients,
audiences and opt-out settings
- **Reviewed:** 2026-09-23
- **SDK baseline:** `8ce8f7bae55fbc03da5a68e5609864d5742dc201`
- **Contract:** `polymorfa/polymorfa` `f4a340da3b74248232ebea73f3e72b42f667beef` (merged `dev`)

The API contract is merged into `dev`, and the SDK dev prerelease
`0.1.0-dev.20260923115544` includes the Campaigns P0 methods. These notes do
not claim deployed availability.

## Sources reviewed

Expand Down Expand Up @@ -33,6 +39,21 @@ campaign
resume <campaign> --project-slug <slug>
stop <campaign> --project-slug <slug> --yes
requeue <campaign> --project-slug <slug> [--include-skipped-error]
recipients
add <campaign> --project-slug <slug> (--csv <file> | --input <file|->)
list <campaign> --project-slug <slug> [--status <status>] [--cursor] [--limit]

audience
create --name <name> [--csv <file> | --file-id <id> --mapping <file|->]
members
add <audience> (--csv <file> | --input <file|->)
list <audience> [--cursor] [--limit]
remove <audience> <phone>

optout
settings
view
update --enabled|--no-enabled --opt-out-keyword <word>... [--opt-in-keyword <word>...]
```

The CLI uses only `MessagingClient.campaigns`. Every campaign command requires an
Expand Down Expand Up @@ -61,38 +82,121 @@ a terminal transition and requires `--yes`.

Launch, pause, resume, and stop append durable commands and return an operation
ID plus the campaign's present state. Human output says “Accepted” and does not
say the transition completed. The returned IDs belong to the Messaging campaign
API. Operation inspection is console-only, so the CLI cannot poll them. The
unpublished `--surface messaging` selector was removed rather than retained as
a compatibility alias.
say the transition completed. Use `operation view`, `operation wait`, or
`operation watch` to inspect the returned ID through the public Platform
operations resource; `operation cancel` requests supported cancellation.
These commands use their own team/project credential context. The unpublished
`--surface messaging` selector remains removed.

Requeue is different: it is a direct transaction and returns only the number
of recipients moved. Failed recipients are always considered;
`--include-skipped-error` adds recipients skipped with an error. The CLI reports
that count without inventing per-recipient outcomes.

Every mutation accepts `--idempotency-key` as request identity, but the pinned
handlers do not persist the header. The help text does not claim deduplication:
an unseen successful create can duplicate, a repeated lifecycle request can
conflict or append another intent, and a repeated requeue normally returns
zero after the first transaction moved its eligible recipients.
Create, launch, pause, resume and stop accept `--idempotency-key` with a 24-hour
server deduplication window. The middleware is mounted after authentication,
rate limits and client rules, before the campaign routes under `/messaging`.
Its identity includes the credential, project, method, route, API version and
key; the fingerprint includes path parameters and the canonical request body.
A repeated successful request returns `409 idempotency_completed`, not the
original response body. Changed input returns `409 idempotency_conflict`;
in-flight requests return `idempotency_in_progress`, and uncertain outcomes
remain fenced as `idempotency_outcome_unknown`. Do not switch keys merely to
bypass an uncertain result; inspect the campaign or operation first. The SDK
generates a key for create and launch when none is supplied; pause, resume and
stop require the flag to opt into this protection across calls.

Requeue is not in that middleware's operation list. Its handler calls
`CampaignRepository.apiRequeue` directly, so `--idempotency-key` carries no
replay guarantee. Each invocation moves the rows eligible at that moment; a
retry can return zero or move recipients that failed since the first attempt.

Recipient and audience-member appends have no declared replay at all. From SDK
`b663914` the SDK generates no key for them and sends each append once unless
the caller sets both a per-request retry count and a key, and the CLI never
sets a retry count. A resent
append would report the first attempt's rows as duplicates, so the flag help
says the request is not retried.

List returns the complete newest-first array. There is no cursor or automatic
pagination. JSON output preserves complete campaign and analytics objects,
including live counters and forward-compatible fields; human output selects
stable operational fields.

## Campaigns P0 additions

Stop no longer always starts an operation. The contract answers with a nullable
`operationId`: a campaign with no active delivery run is cancelled immediately.
Human output distinguishes the two outcomes instead of printing
"operation null", which is what the previous renderer would have produced.

Recipient listing is the first cursor-paged campaign read. It mirrors the
Platform response exactly (`{data, page}`) rather than collapsing it, so JSON
consumers keep `page.nextCursor` and `page.hasMore`. Automatic multi-page
collection was deliberately not added: the existing `--paginate`/`--max-items`
machinery belongs to the developer-event commands and their owner semantics,
and campaign recipient reads have no owner flag. One page per invocation keeps
the command honest about what it fetched.

Appends accept partial success. The API skips repeated phone numbers and
reports invalid entries rather than rejecting the batch, so the CLI prints
`added`, `duplicateCount`, `invalidCount` and the rejected rows instead of
claiming every entry landed. The 1,000-entry cap is checked before transport so
an oversized file fails with a specific message rather than a 400.

`--csv` reads a spreadsheet locally and sends its rows inline. It does not
implement the API's two-step file upload: that flow issues an upload URL, takes
a POST of the bytes, and returns a storage ID. Inventing a client for it was
out of scope, so `--file-id` accepts a storage ID the caller already obtained
and reaches the server-side import through the documented `mapping` shape.

Column matching follows the API: the exact header first, then a case-folded
match. `--variable name=column` is repeatable. oclif splits repeated flag values
on whitespace, so a column name containing a space must be quoted; this is a
shell-level requirement, not a CLI limitation, and the help text says so.

Opt-out settings are a replace, not a patch. The API requires `enabled`,
`optOutKeywords` and `optInKeywords` together, so the CLI requires
`--enabled`/`--no-enabled` and at least one `--opt-out-keyword`, and refuses a
partial update rather than silently clearing keywords the caller omitted.
Keywords are de-duplicated in order before transport.

The Platform campaign update route accepts `recipientListId` to point an
unlaunched draft at another audience. The CLI exposes no campaign update
command: the Messaging surface the `campaign` topic is built on has no update
route, and the Platform one keeps an open request body. That operation stays
reachable through `polymorfa api`. Adding a `campaign update` command would
mean introducing a Platform-backed command under a Messaging-scoped topic that
already takes `--project-slug` rather than a project ID, which is the confusion
this topic was designed to avoid.

The same revision declares `projectId` as a query parameter on the
single-campaign Platform operations, and a later one declares the `409` that
update answers after launch. No CLI command calls those operations, so no flag
changed. The API schema permits an omitted `projectId` for project tokens;
the SDK's organization-only campaign resource requires it. Its create method
also requires the typed `CreatePlatformCampaignRequest` with a name and project
ID. CLI campaign commands still use the separate Messaging resource with an
explicit project slug.

## Explicit gaps

The source exposes no Messaging campaign update, delete, archive, duplicate,
validation, recipient list, event inspection, history, replay, watch, listener,
or operation cancel route. It also exposes no response cursor. The CLI does not
substitute similarly named Platform campaign operations or use `raw.request` to
invent those workflows.
The Messaging surface now exposes recipient listing and appending; the CLI
covers both. It still exposes no campaign update, delete, archive, duplicate,
validation, event inspection, history, replay, watch, listener, or operation
cancel route. The separate Platform-backed `operation` commands inspect, wait
for and cancel supported campaign operations; they do not add those missing
Messaging campaign routes.

Audience listing, retrieval and deletion, the upload-URL route, and the
single/batch opt-out phone routes remain uncovered by dedicated commands in
this slice: they keep open `PlatformPayload` contracts in the SDK and are
reachable through `polymorfa api`. Only the typed Campaigns P0 operations got
commands.

Stripe `listen` and Wrangler `tail` informed the separate Polymorfa developer
event listener. It is not a campaign watch command and does not change the
campaign API gaps above.

At this exact baseline the SDK ledger records 331 total / 207 covered / 58
missing / 66 excluded / 0 partial / 0 changed operations.
At this exact baseline the SDK ledger records 441 total / 325 covered / 5
missing / 111 excluded / 0 partial / 0 changed operations.
Loading