frontpage-host 0.1.24 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PEOPLE.md CHANGED
@@ -1,169 +1,568 @@
1
- # People: groups, stages, rules, imports, and campaigns
2
-
3
- Start with manage_people {operation:"overview"} and {operation:"sources"}. Results provide stable IDs. Do not invent IDs or identify a form by its display name. A form keeps its slug when renamed; a removed form needs a new rule source.
4
-
5
- Call manage_people with {operation, payload:{...fields}}. For campaign operations add area:"campaign". The tool binds this site's identity and permission; never pass siteId or slug. All examples below are payloads. CLI equivalents: frontpage people workspace OPERATION --data 'JSON' --site SLUG --json, or --file payload.json. All operations also have named commands: frontpage people groups create, rules update, membership move, etc. Run frontpage help people for flags.
6
-
7
- ## Groups and stages
8
- A person can belong to many groups and one stage per group. Manual groups keep membership until an action changes it. Conditions groups reevaluate saved facts, including time. Unknown facts do not remove people. Manual include/exclude overrides conditions; override:null returns to automatic membership. hold:true stops this person's automatic changes for that group. Hidden people are excluded from campaign audiences and automated emails.
9
-
10
- - groups.list {archived:false}; groups.get {id}; groups.create {label:"Customers",color:"emerald",mode:"manual"}; groups.update {id,label,color,mode,conditions}; groups.archive/restore {id}.
11
- - Conditions example: groups.create {label:"Repeat buyers",mode:"conditions",conditions:{field:"purchaseCount",op:"gte",value:2}}.
12
- - groups.preview {id,personId} or {conditions,cursor}. Preview sends nothing. Page through nextCursor.
13
- - stages.create {groupId,label:"New",position:0,color:"sky",terminal:false}; stages.update {id,groupId,label,position,color,terminal}; stages.archive/restore {id,groupId}.
14
- - membership.get {personId}; membership.add/remove {personId,groupId}; membership.move {personId,fromGroupId,groupId}; membership.stage {personId,groupId,stageId}; membership.advance {personId,groupId}; membership.override {personId,groupId,override:"include"|"exclude"|null}; membership.hold {personId,groupId,hold:true|false}. reset:true starts again at the first stage when adding/moving. Otherwise prior stage is retained on reentry. Advance follows ordered nonterminal stages and stops at a terminal stage; select a terminal stage explicitly.
15
- - person.permission {personId,allowed:true|false} records the owner's email-permission decision. A saved unsubscribe or suppression still wins. Membership or platform signup never grants permission.
16
-
17
- Existing tags become manual groups. Existing statuses become stages in Customer progress. Migration keeps person IDs, history, notes, follow-ups, and hidden state, and sends no email.
18
-
19
- ## When / If / Then
20
- rules.create/update {id?,name,trigger,conditions:null|CONDITION,actions:[ACTION],enabled:false|true}. Draft first, validate/preview, then enable. rules.list/get/enable/pause/archive {id}; rules.runs {ruleId?,cursor?,limit}; rules.retry {runId} or {ruleId}. Changed or paused rules cannot replay old runs. Temporary failures retry with backoff; permanent failures stay visible until corrected. Durable action receipts prevent duplicate sends on retries. Rule edits create a new version and do not replay past events. Membership cycles and conflicting stage targets are rejected.
21
-
22
- WHEN events: person.added, person.updated, form.submitted (optional formSlug), booking.requested, signup.subscribed/resubscribed/unsubscribed, payment.paid/refunded, order.paid/refunded/fulfilled (order.paid accepts productId), square_order.paid, group.joined/left (groupId), stage.entered (groupId,stageId), group.elapsed (groupId,delayMinutes), stage.elapsed (groupId,stageId,delayMinutes). Use sources for available forms/products. The stored form slug and product itemId survive display-name changes. Missing sources fail validation instead of silently selecting something else.
23
-
24
- IF accepts {all:[CONDITION,...]} or {any:[CONDITION,...]}, up to four levels/100 clauses. Leaf forms:
25
- - {field:"email"|"phone"|"name",op:"is"|"is_not"|"contains"|"present",value?:text}
26
- - {field:"lastSeenDays"|"purchaseCount"|"spentCents"|"lastPurchaseDays",op:"is"|"is_not"|"gte"|"lte"|"present",value?:integer}
27
- - {field:"subscribed",op:"is"|"is_not",value:boolean}
28
- - {groupId,stageId?,member:boolean}; {formSlug,submitted:boolean}.
29
-
30
- THEN supports 1–10 ordered actions:
31
- {type:"add_group"|"remove_group",groupId}; {type:"move_group",fromGroupId,groupId}; {type:"set_stage",groupId,stageId,fromStageIds?:[ids]}; {type:"advance_stage",groupId,fromStageIds?:[ids]}; {type:"set_follow_up",minutes}; {type:"clear_follow_up"}; {type:"send_template",templateId}; {type:"start_series",seriesId}; {type:"cancel_series",seriesId}.
32
- Use fromStageIds to prevent late payment/form events from resetting later progress. Cancel series requires a group/stage trigger and affects only enrollments started for that group. Existing series enrollments remain once per email/series; starting again does not reset them.
33
-
34
- Example: rules.create {name:"Contact leads",trigger:{event:"form.submitted",formSlug:"contact"},conditions:null,actions:[{type:"add_group",groupId:LEADS},{type:"set_follow_up",minutes:1440}],enabled:true}.
35
- Example: rules.create {name:"Paid customers",trigger:{event:"order.paid"},conditions:{groupId:LEADS,member:true},actions:[{type:"move_group",fromGroupId:LEADS,groupId:CUSTOMERS}],enabled:true}.
36
- Example: rules.create {name:"Follow up after two days",trigger:{event:"stage.elapsed",groupId:LEADS,stageId:NEW,delayMinutes:2880},conditions:{field:"subscribed",op:"is",value:true},actions:[{type:"send_template",templateId:TEMPLATE}],enabled:true}.
37
-
38
- ## Attach an email
39
- attachments.create {groupId,stageId?,emailKind:"template"|"series",emailId,name?,conditions?,enabled?}. This creates an ordinary editable rule on group.joined or stage.entered. Use one action label: Attach an email to this group/stage. Choose a saved email template or active series. Automated emails never use campaigns or inline content. Create/edit templates and series through the existing mail commands/tools. Pause/archive the returned rule to detach it.
40
-
41
- ## CSV
42
- import.preview {csv,mapping?,headersOnly?:true}; mapping maps email/phone/name/emailPermission to exact CSV headers. Example mapping:{email:"Email address",name:"Full name"}. RFC 4180 quoted commas/newlines, BOM and CRLF are supported. Files up to 5 MB and 10,000 people. Provide email and/or phone. Duplicate contacts collapse; conflicting email/phone matches are rejected rather than merged. Preview includes row errors and duplicates and sends nothing.
43
- import.start {csv,mapping,groupIds?:[ids],requestKey:UUID}; import.get {id}; import.retry {id}. Reuse requestKey for the same import after a network error; never change its contents. The worker retains its row cursor, outcomes and errors. Retry only failed rows after fixing the identity conflict. Imports send no email. Optional emailPermission column must represent existing permission; it never reverses a saved withdrawal.
44
-
45
- ## One-off campaigns in Emails
46
- People selects the audience; Emails owns drafting and sending. No template-to-campaign action exists. Create a one_off draft with mail, then use manage_people area:"campaign":
47
- preview {audience}; audience {documentId,audience}; send {documentId,requestKey}; get {documentId}.
48
- Audience: {personIds:[ids]} OR {groupIds:[ids]} OR {stageIds:[ids]} OR {filters:{q?,source?,formSlug?,followUp?,lastSeenDays?}}. Combine ID lists as a union, filters narrow the result. {filters:{}} selects all eligible visible people. Preview returns matched/eligible/noEmail/suppressed/permissionMissing counts. Send freezes the eligible recipients and content. It deduplicates email addresses; missing email, hidden contacts, missing permission, and unsubscribe/suppression are excluded. Delivery rechecks permission. Queuing and delivery use the existing email worker, shared provider pacing and site/platform caps. Reuse the same requestKey after an ambiguous send response to prevent sending twice.
49
- CLI: frontpage mail campaign preview --file audience.json; campaign audience --id DRAFT --file audience.json; campaign send --id DRAFT --request-key UUID; campaign get --id DRAFT.
50
-
51
- ## Company account sync
52
- company.get/set {enabled:true,groupId?} is restricted to a platform admin on landing. It backfills Frontpage accounts and adds later signups through a bounded saved cursor, with user ID deduplication. Backfill never fires rules; new accounts after sync activation can fire group entry rules, subject to email permission. It preserves owner edits and does not grant marketing permission. Ordinary tenants cannot query platform users. Pause with enabled:false. Existing contacts remain.
53
-
54
- ## CLI recipes
55
-
56
- Run `frontpage people overview --site demo --json` first. Copy the returned group, stage, person, email, and series IDs into the examples below. IDs are UUIDs, except a form slug or product item ID. Quotes around a JSON body are shell quotes; the JSON itself uses double quotes.
57
-
58
- ```bash
59
- frontpage people groups create --label Leads --color sky --site demo --json
60
- frontpage people groups list --site demo --json
61
- frontpage people stages create --group GROUP_ID --label New --position 0 --site demo --json
62
- frontpage people stages create --group GROUP_ID --label Working --position 1 --site demo --json
63
- frontpage people stages create --group GROUP_ID --label Won --position 2 --terminal true --site demo --json
64
- frontpage people membership add --person PERSON_ID --group GROUP_ID --site demo --json
65
- frontpage people membership stage --person PERSON_ID --group GROUP_ID --stage STAGE_ID --site demo --json
66
- frontpage people membership hold --person PERSON_ID --group GROUP_ID --hold true --site demo --json
67
- frontpage people membership move --person PERSON_ID --from-group OLD_GROUP_ID --group NEW_GROUP_ID --site demo --json
68
- frontpage people membership remove --person PERSON_ID --group GROUP_ID --site demo --json
69
- frontpage people membership override --person PERSON_ID --group GROUP_ID --override auto --site demo --json
70
- frontpage people list --group GROUP_ID --stage STAGE_ID --site demo --json
71
- frontpage people export --group GROUP_ID --out leads.csv --site demo
72
- frontpage people attachments create --group GROUP_ID --email-kind template --email-id TEMPLATE_ID --enabled true --site demo --json
73
- frontpage people attachments create --group GROUP_ID --stage STAGE_ID --email-kind series --email-id SERIES_ID --enabled true --site demo --json
74
- frontpage people sources --site demo --json
75
- frontpage people rules create --file rule.json --site demo --json
76
- frontpage people rules preview --file rule.json --person PERSON_ID --site demo --json
77
- frontpage people rules update --id RULE_ID --file rule.json --site demo --json
78
- frontpage people rules enable --id RULE_ID --site demo --json
79
- frontpage people rules pause --id RULE_ID --site demo --json
80
- frontpage people rules runs --rule RULE_ID --site demo --json
81
- frontpage people rules retry --run RUN_ID --site demo --json
82
- frontpage people person permission --person PERSON_ID --allowed true --site demo --json
83
- ```
84
-
85
- Use this `rule.json`, replacing IDs and the form slug with values from `overview` and `sources`:
86
-
87
- ```json
1
+ # People CRM: CLI and agent guide
2
+
3
+ **One contact record. Many groups. Clear progress.**
4
+
5
+ People keeps a business’s contacts, notes, follow-ups, and history together. Groups organize contacts; optional stages track progress inside each group. Rules can update both and send a saved email or series. One-off campaigns stay in Emails.
6
+
7
+ ## Get started
8
+
9
+ Use the current `frontpage-host` CLI. Sign in, then choose a site using `--site` on each command or `frontpage use YOUR_SITE`. A site is its subdomain slug, such as `acme`, not a full URL or UUID.
10
+
11
+ ```
12
+ npm install -g frontpage-host@latest
13
+ frontpage login
14
+ frontpage people overview --site YOUR_SITE --json
15
+ frontpage people sources --site YOUR_SITE --json
16
+ frontpage people list --site YOUR_SITE --limit 50 --json
17
+ ```
18
+
19
+ Replace `YOUR_SITE` and all uppercase ID placeholders before running an example. Copy IDs from real results: groups, stages, people, rules, templates, and series use UUIDs. Forms use a stable form slug; products use their item ID. Do not invent IDs.
20
+
21
+ **Access stays scoped to your site.**
22
+
23
+ People requires Growth or above. Public API and CLI access requires Scale, Enterprise, or Internal, plus membership in the chosen site. Viewers can read; editors and admins can change data. A request cannot select another tenant through its JSON body.
24
+
25
+ `--json` returns structured output. Complex data accepts `--file payload.json` or `--data 'JSON'`. Use `frontpage help people` and `frontpage help mail` for command flags.
26
+
27
+ ## Contacts, notes, and history
28
+
29
+ Each contact needs a valid email or phone. A name is optional. If you supply both identifiers, both must be valid. Matching email or phone updates the existing contact; conflicting identities fail instead of merging two people.
30
+
31
+ ```
32
+ frontpage people add --email alex@example.com --name "Alex Rivera" --site YOUR_SITE --json
33
+ frontpage people get PERSON_ID --site YOUR_SITE --json
34
+ frontpage people set PERSON_ID --name "Alex Rivera" --phone +12025550166 --site YOUR_SITE --json
35
+ frontpage people set PERSON_ID --address-name "Alex Rivera" --line1 "12 Main St" --line2 "Suite 4" --city Austin --region TX --postal 78701 --country US --site YOUR_SITE --json
36
+ frontpage people note PERSON_ID --body "Asked for a quote. Call on Friday." --site YOUR_SITE --json
37
+ frontpage people follow-up PERSON_ID --at 2026-11-06T15:00:00Z --site YOUR_SITE --json
38
+ frontpage people list --follow-up due --site YOUR_SITE --json
39
+ frontpage people hide PERSON_ID --site YOUR_SITE --json
40
+ frontpage people unhide PERSON_ID --site YOUR_SITE --json
41
+ ```
42
+
43
+ - **Details:** name, email, phone, mailing address, original source, first seen, and last seen. Address fields include recipient name, line 1, line 2, city, region, postal code, and country.
44
+ - **Notes:** `people note PERSON_ID --body "…"` adds a manual note. `people rm-note PERSON_ID --event NOTE_EVENT_ID` removes an owner’s manual note. Source submissions and payments are not removable notes.
45
+ - **Follow-up:** set an ISO date with `--at`; clear it with `people follow-up PERSON_ID --clear`. The person page highlights overdue dates.
46
+ - **Activity:** `people get` returns the recent 50 events. The person page shows only populated categories with counts. Group and stage pages do not show a separate activity feed.
47
+ - **Hide and restore:** hide is recoverable. It retains identity, history, and membership. It excludes the person from campaign audiences and automated emails. Later source ingest may restore them.
48
+
49
+ ### Find and export people
50
+
51
+ `people list` and `people export` accept search, group, stage, source, form, follow-up, hidden state, sort, recency, and legacy status/tag filters. List defaults to 50 contacts, supports up to 200 per page, and returns `nextCursor`. Pass that cursor to `--cursor` to continue. Export writes UTF-8 CSV; it is a contact export, not a complete activity archive.
52
+
53
+ ## Groups: organize people
54
+
55
+ A person can belong to several groups at once. Use a group for Leads, Customers, Newsletter, or another useful list. Rename a group freely: its ID stays the same.
56
+
57
+ ### Add people yourself or with rules
58
+
59
+ A manual group keeps membership until you, the agent, an import, or a rule changes it. These commands also manage the groups shown in the People filter.
60
+
61
+ ```
62
+ frontpage people groups create --label Leads --color sky --mode manual --site YOUR_SITE --json
63
+ frontpage people groups list --site YOUR_SITE --json
64
+ frontpage people groups update --id GROUP_ID --label "New enquiries" --site YOUR_SITE --json
65
+ frontpage people membership add --person PERSON_ID --group GROUP_ID --site YOUR_SITE --json
66
+ frontpage people membership get --person PERSON_ID --site YOUR_SITE --json
67
+ frontpage people list --group GROUP_ID --site YOUR_SITE --json
68
+ frontpage people export --group GROUP_ID --out leads.csv --site YOUR_SITE
69
+ frontpage people groups archive --id GROUP_ID --site YOUR_SITE --json
70
+ frontpage people groups restore --id GROUP_ID --site YOUR_SITE --json
71
+ ```
72
+
73
+ ### Keep a group matched to conditions
74
+
75
+ An automatic group uses saved contact facts. The worker checks contacts in rotating batches and updates membership as those facts or time change. Preview first. Save this as `repeat-buyers.json`:
76
+
77
+ ```
88
78
  {
89
- "name": "New contact leads",
90
- "trigger": {"event": "form.submitted", "formSlug": "contact"},
91
- "conditions": {"field": "email", "op": "present"},
79
+ "label": "Repeat buyers",
80
+ "color": "emerald",
81
+ "mode": "conditions",
82
+ "conditions": {
83
+ "all": [
84
+ {
85
+ "field": "purchaseCount",
86
+ "op": "gte",
87
+ "value": 2
88
+ },
89
+ {
90
+ "field": "lastPurchaseDays",
91
+ "op": "lte",
92
+ "value": 90
93
+ }
94
+ ]
95
+ }
96
+ }
97
+ ```
98
+
99
+ ```
100
+ frontpage people groups preview --file repeat-buyers.json --site YOUR_SITE --json
101
+ frontpage people groups create --file repeat-buyers.json --site YOUR_SITE --json
102
+ frontpage people membership override --person PERSON_ID --group GROUP_ID --override include --site YOUR_SITE --json
103
+ frontpage people membership override --person PERSON_ID --group GROUP_ID --override exclude --site YOUR_SITE --json
104
+ frontpage people membership override --person PERSON_ID --group GROUP_ID --override auto --site YOUR_SITE --json
105
+ frontpage people membership hold --person PERSON_ID --group GROUP_ID --hold true --site YOUR_SITE --json
106
+ frontpage people membership hold --person PERSON_ID --group GROUP_ID --hold false --site YOUR_SITE --json
107
+ ```
108
+
109
+ `include` keeps one person in an automatic group; `exclude` keeps them out. `auto` clears the exception. A hold pauses automatic changes and that group’s attached email for this person. It does not prevent an owner from making an explicit change.
110
+
111
+ **Preserve existing records.**
112
+
113
+ Existing tags become manual groups. Existing statuses become stages in Customer progress. Setup keeps person IDs, notes, follow-ups, hidden state, and history, and sends no email. Old tag/status CLI commands remain supported. New integrations should use group and stage IDs.
114
+
115
+ Archive groups instead of deleting their history. Assignments stay saved; related rules pause. Restoring a group does not reactivate those rules. Review and activate them explicitly.
116
+
117
+ ## Stages: track progress within a group
118
+
119
+ Stages are optional. A person can have one current stage per group, or no stage. A simple funnel might use New → Working → Customer. Save the group before adding stages or their rules.
120
+
121
+ ```
122
+ frontpage people stages create --group GROUP_ID --label New --color sky --position 0 --site YOUR_SITE --json
123
+ frontpage people stages create --group GROUP_ID --label Working --color yellow --position 1 --site YOUR_SITE --json
124
+ frontpage people stages create --group GROUP_ID --label Customer --color emerald --position 2 --terminal true --site YOUR_SITE --json
125
+ frontpage people membership stage --person PERSON_ID --group GROUP_ID --stage STAGE_ID --site YOUR_SITE --json
126
+ frontpage people membership advance --person PERSON_ID --group GROUP_ID --site YOUR_SITE --json
127
+ frontpage people stages reorder --data '{"groupId":"GROUP_ID","stageIds":["NEW_ID","WORKING_ID","CUSTOMER_ID"]}' --site YOUR_SITE --json
128
+ frontpage people membership stage --data '{"personId":"PERSON_ID","groupId":"GROUP_ID","stageId":null}' --site YOUR_SITE --json
129
+ frontpage people stages archive --id STAGE_ID --group GROUP_ID --site YOUR_SITE --json
130
+ frontpage people stages restore --id STAGE_ID --group GROUP_ID --site YOUR_SITE --json
131
+ ```
132
+
133
+ - Joining a group starts at its first stage when no previous stage exists. Rejoining keeps the prior stage; `reset:true` on add/move starts again.
134
+ - `membership.advance` follows ordered nonterminal stages. It stops when the current stage is final. Choose a final stage explicitly with `membership.stage` or a rule’s `set_stage` action.
135
+ - `stageId:null` clears the stage while keeping group membership.
136
+ - `stages.reorder` requires every active stage once. A duplicate, incomplete, or stale order fails without partially changing positions.
137
+ - Archiving a stage keeps its assignments and history and pauses related rules. Restoring it leaves those rules paused.
138
+
139
+ ## Rules: When / If / Then
140
+
141
+ Rules live beside their group or stage. Each has a start event, optional checks, and ordered actions. The editor saves new rules paused. Activate only after reviewing the result; activation applies to future events, not past submissions.
142
+
143
+ ### Example: follow up on an enquiry
144
+
145
+ Use a form slug from `people sources`. Save this as `enquiry-rule.json`:
146
+
147
+ ```
148
+ {
149
+ "name": "Follow up on contact enquiries",
150
+ "trigger": {
151
+ "event": "form.submitted",
152
+ "formSlug": "contact",
153
+ "owner": {
154
+ "groupId": "GROUP_ID"
155
+ }
156
+ },
157
+ "conditions": {
158
+ "field": "email",
159
+ "op": "present"
160
+ },
92
161
  "actions": [
93
- {"type": "add_group", "groupId": "GROUP_ID"},
94
- {"type": "set_follow_up", "minutes": 1440}
162
+ {
163
+ "type": "add_group",
164
+ "groupId": "GROUP_ID"
165
+ },
166
+ {
167
+ "type": "set_follow_up",
168
+ "minutes": 1440
169
+ }
95
170
  ],
96
171
  "enabled": false
97
172
  }
98
173
  ```
99
174
 
100
- A condition group example (`group.json`):
175
+ ```
176
+ frontpage people rules validate --file enquiry-rule.json --site YOUR_SITE --json
177
+ frontpage people rules preview --file enquiry-rule.json --person PERSON_ID --site YOUR_SITE --json
178
+ frontpage people rules create --file enquiry-rule.json --site YOUR_SITE --json
179
+ frontpage people rules enable --id RULE_ID --site YOUR_SITE --json
180
+ frontpage people rules pause --id RULE_ID --site YOUR_SITE --json
181
+ frontpage people rules update --id RULE_ID --file enquiry-rule.json --site YOUR_SITE --json
182
+ frontpage people rules runs --rule RULE_ID --limit 50 --site YOUR_SITE --json
183
+ frontpage people rules retry --run RUN_ID --site YOUR_SITE --json
184
+ frontpage people rules archive --id RULE_ID --site YOUR_SITE --json
185
+ ```
186
+
187
+ `trigger.owner` only places the rule in the editor. It never limits who can start the event. A form rule can belong to a stage without requiring the form submission to contain a group ID. Use `conditions` to limit it to existing members. Use `trigger.groupId` and `trigger.stageId` for membership events themselves.
101
188
 
102
- ```json
103
- {"label":"Repeat buyers","mode":"conditions","conditions":{"all":[{"field":"purchaseCount","op":"gte","value":2},{"field":"lastPurchaseDays","op":"lte","value":90}]}}
189
+ ### When: choose a real event
190
+
191
+ | Event | When it occurs | Selector |
192
+ | ------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------- |
193
+ | person.added / person.updated | A contact is added or edited. | — |
194
+ | form.submitted | A form receives a submission. | formSlug optional |
195
+ | booking.requested | Someone requests an appointment. | — |
196
+ | signup.subscribed / signup.resubscribed / signup.unsubscribed | A mailing-list subscription changes. | — |
197
+ | payment.paid / payment.refunded | A payment succeeds or is refunded. | — |
198
+ | order.paid / order.refunded / order.fulfilled | A store order is paid, refunded, or fulfilled. | productId optional on order.paid |
199
+ | square\_order.paid | A Square order is paid. | — |
200
+ | group.joined / group.left | A person enters or leaves a group. | groupId required |
201
+ | stage.entered | A person enters a stage. | groupId and stageId required |
202
+ | group.elapsed | A person spends the chosen time in a group. | groupId and delayMinutes required |
203
+ | stage.elapsed | A person spends the chosen time in a stage. | groupId, stageId, and delayMinutes required |
204
+
205
+ Forms are detected from the current site’s module instances. Product choices come from active store items. Renaming a display label keeps the saved slug/item ID, so the rule keeps working. Deleting or replacing that ID requires updating the rule; validation rejects a missing source.
206
+
207
+ ### If: add only useful checks
208
+
209
+ Leave conditions `null` to run for every matching event. Use `{all:[…]}` when every check must match, or `{any:[…]}` when one is enough. Sets accept 1–20 children, up to four levels and 100 total clauses.
210
+
211
+ | Field or shape | Meaning | Comparison |
212
+ | ---------------------------------- | ------------------------------------------------------------------------------ | ------------------------------ |
213
+ | purchaseCount | Number of recorded paid order/payment events. | is, is\_not, gte, lte, present |
214
+ | spentCents | Total amountCents from those paid events. Integer cents, not a decimal amount. | is, is\_not, gte, lte, present |
215
+ | lastPurchaseDays | Whole days since the latest recorded paid event. | is, is\_not, gte, lte, present |
216
+ | lastSeenDays | Whole days since the contact’s last CRM activity. | is, is\_not, gte, lte, present |
217
+ | email / phone / name | Contact details. present needs no value. | is, is\_not, contains, present |
218
+ | formSlug + submitted | Whether this person submitted the named form. | submitted: true or false |
219
+ | groupId + member; stageId optional | Whether a person belongs to a group or stage. | member: true or false |
220
+ | subscribed | Existing advanced subscription condition. Send eligibility remains separate. | is, is\_not with boolean value |
221
+
222
+ Numeric values are whole numbers from 0 to 1,000,000,000. Amounts use integer cents: 2500 means 25.00. Purchase facts count recorded paid order/payment events and sum their amounts; they are not net revenue after refunds and do not convert currencies. Last activity means CRM activity, not anonymous page views. Unknown facts do not remove a person from an automatic group.
223
+
224
+ The simple editor offers Purchases, Activity, and Contact details. Existing advanced conditions—including nested sets and membership checks—remain available through the CLI and agent and stay intact during UI edits. Email permission is handled separately before each send.
225
+
226
+ ### Then: perform ordered actions
227
+
228
+ | Action type | Fields | Result |
229
+ | -------------------------- | ------------------------------- | --------------------------------------------------------------- |
230
+ | add\_group / remove\_group | groupId | Add to or remove from a manual group. |
231
+ | move\_group | fromGroupId, groupId | Move between manual groups. |
232
+ | set\_stage | groupId, stageId, fromStageIds? | Set a stage; an optional guard limits eligible starting stages. |
233
+ | advance\_stage | groupId, fromStageIds? | Advance through nonterminal stages. |
234
+ | set\_follow\_up | minutes | Set a follow-up after this many minutes. |
235
+ | clear\_follow\_up | — | Clear the person’s follow-up. |
236
+ | send\_template | templateId | Send a saved template if eligible. |
237
+ | start\_series | seriesId | Start an active series with saved steps. |
238
+ | cancel\_series | seriesId | Stop this group’s enrollment; requires a group/stage trigger. |
239
+
240
+ A rule accepts 1–10 actions. Add/remove/move actions target manual groups; automatic groups use conditions and owner exceptions. `fromStageIds` prevents a late event from resetting someone who has moved further through the funnel.
241
+
242
+ ### Example: move a paid lead to Customer
243
+
244
+ ```
245
+ {
246
+ "name": "Mark paid leads as customers",
247
+ "trigger": {
248
+ "event": "order.paid",
249
+ "owner": {
250
+ "groupId": "GROUP_ID",
251
+ "stageId": "CUSTOMER_ID"
252
+ }
253
+ },
254
+ "conditions": {
255
+ "groupId": "GROUP_ID",
256
+ "member": true
257
+ },
258
+ "actions": [
259
+ {
260
+ "type": "set_stage",
261
+ "groupId": "GROUP_ID",
262
+ "stageId": "CUSTOMER_ID",
263
+ "fromStageIds": [
264
+ "NEW_ID",
265
+ "WORKING_ID"
266
+ ]
267
+ }
268
+ ],
269
+ "enabled": false
270
+ }
271
+ ```
272
+
273
+ ### Example: send a follow-up after two days
274
+
275
+ ```
276
+ {
277
+ "name": "Follow up after two days",
278
+ "trigger": {
279
+ "event": "stage.elapsed",
280
+ "groupId": "GROUP_ID",
281
+ "stageId": "WORKING_ID",
282
+ "delayMinutes": 2880,
283
+ "owner": {
284
+ "groupId": "GROUP_ID",
285
+ "stageId": "WORKING_ID"
286
+ }
287
+ },
288
+ "conditions": null,
289
+ "actions": [
290
+ {
291
+ "type": "send_template",
292
+ "templateId": "TEMPLATE_ID"
293
+ }
294
+ ],
295
+ "enabled": false
296
+ }
297
+ ```
298
+
299
+ Timed rules use minutes: 2880 is two days. They run through the worker, not a browser timer. Use `rules runs` to inspect outcomes and `rules retry` to retry eligible failed work. Rule edits create a new version; changed or paused rules cannot replay old runs.
300
+
301
+ ## Attach an email to a group or stage
302
+
303
+ Use one action, **Attach an email**, then choose a saved email template or an active series. An attachment creates a normal editable rule on group entry or stage entry. Campaigns are one-off emails and cannot be selected for automation.
304
+
305
+ Save a reusable template as `welcome.json`:
306
+
307
+ ```
308
+ {
309
+ "name": "Welcome to our business",
310
+ "subject": "Thanks for getting in touch",
311
+ "preheader": "Here is what happens next.",
312
+ "blocks": [
313
+ {
314
+ "type": "paragraph",
315
+ "text": "Thanks for getting in touch. Our team will contact you soon."
316
+ }
317
+ ]
318
+ }
104
319
  ```
105
320
 
106
- ```bash
107
- frontpage people groups create --file group.json --site demo --json
108
- frontpage people groups preview --file group.json --site demo --json
109
- frontpage people groups update --id GROUP_ID --file group.json --site demo --json
110
- frontpage people groups archive --id GROUP_ID --site demo --json
111
- frontpage people groups restore --id GROUP_ID --site demo --json
321
+ ```
322
+ frontpage mail template create --file welcome.json --site YOUR_SITE --json
323
+ frontpage people attachments create --group GROUP_ID --email-kind template --email-id TEMPLATE_ID --enabled false --site YOUR_SITE --json
324
+ frontpage mail series create --name "Getting started" --site YOUR_SITE --json
325
+ frontpage mail series steps set SERIES_ID --step template=TEMPLATE_ID,delay=0 --site YOUR_SITE --json
326
+ frontpage mail series activate SERIES_ID --site YOUR_SITE --json
327
+ frontpage people attachments create --group GROUP_ID --stage STAGE_ID --email-kind series --email-id SERIES_ID --enabled false --site YOUR_SITE --json
328
+ frontpage people rules enable --id ATTACHMENT_RULE_ID --site YOUR_SITE --json
329
+ ```
330
+
331
+ Copy the attachment’s returned rule ID before enabling it. Pass `--enabled false` to review it first. Omitting `enabled` on the API keeps its existing enabled-by-default behavior; the editor explicitly saves paused attachments.
332
+
333
+ - A real group/stage reentry can send another template. Duplicate events and action retries do not send that same action twice.
334
+ - An existing email/series enrollment does not restart when the person reenters.
335
+ - Removing someone from a group does not automatically cancel a series. Add `cancel_series` to a group/stage rule when that is the intended behavior; it affects that group’s enrollment only.
336
+ - Pause or archive the attachment rule to detach it. This does not erase its history.
337
+
338
+ See the [Email guide](/developers/mail) for content blocks, template variables, series step delays, sender settings, and email limits.
339
+
340
+ ## Email permission is independent of groups
341
+
342
+ Group membership never grants email permission. A person who opts out stays in their groups and stages, while automated marketing emails and campaigns stop. Every delivery checks permission again, so an unsubscribe after audience selection still stops delivery.
343
+
344
+ ```
345
+ frontpage people person permission --person PERSON_ID --allowed false --site YOUR_SITE --json
346
+ frontpage people membership get --person PERSON_ID --site YOUR_SITE --json
347
+ # Only record permission when the person has actually given it:
348
+ frontpage people person permission --person PERSON_ID --allowed true --site YOUR_SITE --json
112
349
  ```
113
350
 
114
- CSV mapping file (`mapping.json`):
351
+ `membership.get` includes email eligibility and the reason a contact cannot receive mail. Missing email, hidden state, missing permission, saved unsubscribe, and suppression can block delivery. An owner can record a new permission decision, but cannot reverse a mailing-list unsubscribe or suppression. CSV values cannot reverse an existing opt-out, including a recorded false decision. Do not add a permission check to every group or stage; delivery already enforces it.
115
352
 
116
- ```json
117
- {"mapping":{"email":"Email address","name":"Full name","phone":"Phone","emailPermission":"Permission"},"groupIds":["GROUP_ID"]}
353
+ ## Import a customer list from CSV
354
+
355
+ CSV imports accept up to 5 MB and 10,000 contact rows. Quoted commas, quoted newlines, UTF-8 BOM, and CRLF are supported. Larger lists need separate files; the UI does not render an unbounded list of failures.
356
+
357
+ ### 1. Upload and map columns
358
+
359
+ ```
360
+ Full name,Email address,Phone,Permission
361
+ Alex Rivera,alex@example.com,,
362
+ "Pat, Jr.",pat@example.com,+12025550166,yes
363
+ Phone customer,,+12025550199,
364
+ ```
365
+
366
+ Map exact header names in `mapping.json`. The permission column is optional; omit it when permission is unknown. `yes`/`true` records existing permission and `no`/`false` records no permission.
367
+
368
+ ```
369
+ {
370
+ "mapping": {
371
+ "name": "Full name",
372
+ "email": "Email address",
373
+ "phone": "Phone",
374
+ "emailPermission": "Permission"
375
+ },
376
+ "groupIds": [
377
+ "GROUP_ID"
378
+ ]
379
+ }
118
380
  ```
119
381
 
120
- ```bash
121
- frontpage people import preview --csv customers.csv --file mapping.json --site demo --json
122
- frontpage people import start --csv customers.csv --file mapping.json --request-key REQUEST_UUID --site demo --json
123
- frontpage people import get --id IMPORT_ID --site demo --json
124
- frontpage people import retry --id IMPORT_ID --site demo --json
382
+ ### 2. Review matching and errors
383
+
384
+ Preview returns real new/existing counts, normalized rows, fields that can fill blank saved details, duplicates, and errors. A name is optional; email or phone is required. Existing nonblank contact details stay saved. A repeated identity collapses into one contact; conflicting email/phone matches fail instead of merging different people.
385
+
386
+ | Problem | How to fix it |
387
+ | ------------------------------------ | --------------------------------------------------------------------------------------------- |
388
+ | Invalid email | Use a complete email, or clear it when a valid phone exists. |
389
+ | Invalid phone | Use a valid full number, preferably with country code, or clear it when a valid email exists. |
390
+ | Email or phone missing | Provide at least one contact identifier. |
391
+ | Conflicting identities | Correct the CSV or the saved contact. The importer will not merge two people. |
392
+ | Invalid permission or column mapping | Use true/false or yes/no; map existing unique headers. |
393
+
394
+ The editor groups failures by reason, shows five examples per page, and shows ten ready contacts per page. There is no error download. Fix rows in the editor or upload a corrected CSV. The API returns bounded results for the accepted file limit.
395
+
396
+ ### 3. Start and follow the saved import
397
+
398
+ ```
399
+ frontpage people import preview --csv customers.csv --file mapping.json --site YOUR_SITE --json
400
+ frontpage people import start --csv customers.csv --file mapping.json --request-key REQUEST_UUID --site YOUR_SITE --json
401
+ frontpage people import get --id IMPORT_ID --site YOUR_SITE --json
402
+ # After correcting a saved contact conflict, retry the same job:
403
+ frontpage people import retry --id IMPORT_ID --site YOUR_SITE --json
125
404
  ```
126
405
 
127
- Permission values are true/false or yes/no. Leave permission blank when unknown. Phone-only contacts can enter groups but cannot receive email. CSV import runs in saved batches; inspect the result for row errors before assuming every record was accepted. Fix an existing identity conflict, then retry failed rows. To correct the CSV itself, preview and start a new import with a new request key. A retry never changes the original file.
406
+ Choose a fresh request UUID for each new import. If a response is lost, repeat `import.start` with the same key and unchanged CSV/mapping/groups. It returns the existing job. Reusing a key for different contents returns a conflict.
407
+
408
+ The worker saves its cursor and each row’s outcome, so closing the editor does not stop the job. Use `import.get` to inspect completion and failed rows. Correcting the CSV requires a new preview and new import key. `import.retry` retries failed rows from the original saved file after you fix the underlying saved contact conflict. Completed rows keep their receipts. Imports do not send emails or grant permission through group membership.
409
+
410
+ ## Email all people, a group, a stage, or selected people
128
411
 
129
- Campaign audience file (`audience.json`):
412
+ The People button names the selected audience, such as **Email all people** or **Email Newsletter**. It opens a one-off draft in Emails. Drafting and sending stay there. A campaign does not use a template as starting content.
413
+
414
+ Save `audience.json`:
130
415
 
131
- ```json
132
- {"audience":{"groupIds":["GROUP_ID"]}}
133
416
  ```
417
+ {
418
+ "audience": {
419
+ "groupIds": [
420
+ "GROUP_ID"
421
+ ]
422
+ }
423
+ }
424
+ ```
425
+
426
+ | Audience | JSON inside audience |
427
+ | ------------------ | ------------------------------------------- |
428
+ | All visible people | `{"filters":{}}` |
429
+ | Groups | `{"groupIds":["GROUP_ID"]}` |
430
+ | Stages | `{"stageIds":["STAGE_ID"]}` |
431
+ | Selected people | `{"personIds":["PERSON_ID"]}` |
432
+ | A filtered list | `{"filters":{"q":"Alex","followUp":"due"}}` |
134
433
 
135
- Use `stageIds` instead for one stage, or `personIds` for selected people. To select the whole visible People list use `{"audience":{"filters":{}}}`. Create an email draft using the existing email builder/CLI; campaigns do not start from templates.
434
+ ID lists combine as a union; filters narrow the result. Filters support `q`, `source`, `formSlug`, `followUp`, and `lastSeenDays`. An empty audience object is invalid; all people uses `{"filters":{}}` explicitly.
136
435
 
137
- ```bash
138
- frontpage mail email create --file draft.json --name "Customer update" --site demo --json
139
- frontpage mail campaign preview --file audience.json --site demo --json
140
- frontpage mail campaign audience --id DRAFT_ID --file audience.json --site demo --json
141
- frontpage mail campaign send --id DRAFT_ID --request-key REQUEST_UUID --site demo --json
142
- frontpage mail campaign get --id DRAFT_ID --site demo --json
436
+ `update.json` uses the same saved email content shape shown above, with your campaign’s subject and blocks. Create it as an email draft, not a template:
437
+
438
+ ```
439
+ frontpage mail email create --file update.json --name "Customer update" --site YOUR_SITE --json
440
+ frontpage mail campaign preview --file audience.json --site YOUR_SITE --json
441
+ frontpage mail campaign audience --id DRAFT_ID --file audience.json --site YOUR_SITE --json
442
+ # This command sends the campaign. Inspect the preview and draft first.
443
+ frontpage mail campaign send --id DRAFT_ID --request-key REQUEST_UUID --site YOUR_SITE --json
444
+ frontpage mail campaign get --id DRAFT_ID --site YOUR_SITE --json
143
445
  ```
144
446
 
145
- Company sync is available only to a platform admin in the landing workspace:
447
+ Preview reports matched, eligible, no-email, suppressed, and permission-missing counts and sends nothing. Sending freezes content and eligible recipients, deduplicates email addresses, and uses the existing paced email queue. Reuse the send request key after a lost response. Check `campaign get` for queued, sent, pending, failed, and skipped work; queued does not mean delivered.
448
+
449
+ ## Frontpage account sync
450
+
451
+ This admin-only feature adds Frontpage’s own account holders to the `landing` People workspace and keeps later signups in sync. It is not available to ordinary businesses. Businesses import their customer lists with CSV.
146
452
 
147
- ```bash
453
+ ```
148
454
  frontpage people company get --site landing --json
149
455
  frontpage people company set --enabled true --site landing --json
150
456
  frontpage people company set --enabled false --site landing --json
151
457
  ```
152
458
 
153
- ## API and agent parity
459
+ Sync uses saved batches and Frontpage user IDs to avoid duplicates. The backfill does not fire rules or grant email permission. Later signups can trigger group entry rules, subject to normal eligibility. Sync preserves owner edits. Pausing it leaves existing contacts saved. Supply an existing `groupId` through `--data` if you want a particular sync group.
460
+
461
+ ## CLI, API, and agent reference
462
+
463
+ The editor, public API, CLI, and site agent call the same application service. Every operation below is available without opening the UI.
464
+
465
+ ```
466
+ frontpage people workspace stages.reorder --file order.json --site YOUR_SITE --json
467
+ frontpage people rules create --data '{"name":"New leads","trigger":{"event":"person.added"},"conditions":null,"actions":[{"type":"set_follow_up","minutes":1440}],"enabled":false}' --site YOUR_SITE --json
468
+ ```
469
+
470
+ ### Public API
471
+
472
+ ```
473
+ POST /api/v1/sites/YOUR_SITE/people/workspace
474
+ Authorization: Bearer YOUR_API_KEY
475
+ Content-Type: application/json
154
476
 
155
- The same application service handles editor actions, the public CLI, and the site agent. Use POST `/api/v1/sites/SLUG/people/workspace` with an API key and body `{"operation":"groups.create","label":"Leads"}`. Campaign operations use POST `/api/v1/sites/SLUG/mail/campaign` with `operation: preview|audience|send|get`. Read actions require viewer access; writes require editor access. Public CLI access still requires Scale, Enterprise, or Internal. People and Emails keep their existing plan checks. Tenant IDs come from authenticated access, never the JSON body.
477
+ {"operation":"groups.create","label":"Leads","color":"sky","mode":"manual"}
478
+ ```
156
479
 
157
- The agent uses `get_people_docs` for the complete reference and `manage_people` for every workspace action. For example `{"operation":"membership.add","payload":{"personId":"PERSON_ID","groupId":"GROUP_ID"}}`; a campaign uses `{"area":"campaign","operation":"preview","payload":{"audience":{"groupIds":["GROUP_ID"]}}}`. The agent can create templates and series with existing Email tools, then attach their IDs. No step requires opening the UI.
480
+ The public workspace endpoint takes operation fields at the top level. The agent tool uses a payload object instead. Campaign operations use `POST /api/v1/sites/YOUR_SITE/mail/campaign` with `operation` set to preview, audience, send, or get. Existing contact endpoints remain available under `/api/v1/sites/YOUR_SITE/people`.
158
481
 
159
- ## Timing, limits, and safe changes
482
+ ### Site agent
160
483
 
161
- The existing minute worker evaluates new events, saved imports, company account sync, dynamic membership, timed rules, and pending actions. A busy queue can take longer than one minute. Form labels can change without breaking rules: the saved form slug is the identity. Changing/removing that slug creates a different source; choose it explicitly and update the rule. Product titles can change; rules keep the product item ID. Rename groups/stages freely; rules keep their IDs. Archive rather than deleting history.
484
+ Ask the agent to use `get_people_docs`, then `manage_people`. Its tool binds the current site and access rights; do not pass a site ID or slug. Example membership request:
162
485
 
163
- Rules act on events after activation, not historical submissions. Every real group entry or stage entry can start its attached email; a duplicate event or retry cannot send it twice. An existing series enrollment does not restart on reentry. A hold stops automatic group changes and its attachment; lifting it does not replay failed work automatically. Review the run and retry deliberately. A queued series continues unless a rule explicitly stops that group's series. Missing permission, unsubscribe, and suppression always stop automated delivery.
486
+ ```
487
+ {
488
+ "operation": "membership.add",
489
+ "payload": {
490
+ "personId": "PERSON_ID",
491
+ "groupId": "GROUP_ID"
492
+ }
493
+ }
494
+ ```
164
495
 
165
- Temporary database/provider failures retry up to eight attempts with delays from one minute to one hour. Permanent errors remain visible with the failed action and a manual retry. Earlier completed actions retain their receipts. Paused, archived, or changed rules do not replay old work. Condition previews and import previews send nothing. Conditions groups reevaluate all contacts in rotating batches; they do not require browser listeners.
496
+ Example campaign preview:
166
497
 
167
- Campaign queuing uses batches of 100. Sending uses the existing send window, hourly/monthly/site caps, platform daily cap, kill switch, and a shared provider slot across workers. Status `queued` means messages reached the existing send queue, not that every email was delivered. `campaign get` reports the accepted, sent, pending, failed, and skipped work. An unsubscribe after audience selection still prevents delivery.
498
+ ```
499
+ {
500
+ "area": "campaign",
501
+ "operation": "preview",
502
+ "payload": {
503
+ "audience": {
504
+ "stageIds": [
505
+ "STAGE_ID"
506
+ ]
507
+ }
508
+ }
509
+ }
510
+ ```
168
511
 
169
- Legacy tag/status commands remain compatible with converted groups/stages. New integrations should use group/stage IDs; historical fields remain for older callers and exports. All existing person IDs, notes, follow-ups, hidden state, source events, and external CRM destination behavior remain intact.
512
+ The agent can create and edit saved templates and series with the existing Email tools, then attach their IDs. It can inspect runs, retry failures, update contacts, import lists, and manage group exceptions directly.
513
+
514
+ ### Workspace operation reference
515
+
516
+ | Operation | Payload fields | Purpose |
517
+ | ------------------------------------------ | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
518
+ | overview | — | Active groups, stages, rules, saved emails, series, and setup state. |
519
+ | sources | — | Supported events and current form/product IDs. |
520
+ | groups.list | archived? | List groups; archived:true includes archived groups. |
521
+ | groups.get | id | One group with its stages. |
522
+ | groups.create | label, color?, mode?, conditions? | Create a group. |
523
+ | groups.update | id, changed fields | Edit a group without changing its ID. |
524
+ | groups.archive / groups.restore | id | Archive or restore a group. Related rules remain paused on restore. |
525
+ | groups.preview | id or conditions; personId? or cursor? | Check conditions without changing membership. |
526
+ | stages.create | groupId, label, color?, position?, terminal? | Create a stage in a saved group. |
527
+ | stages.update | id, groupId, changed fields | Edit a stage. |
528
+ | stages.reorder | groupId, stageIds | Save every active stage ID in order, once each. |
529
+ | stages.archive / stages.restore | id, groupId | Keep assignments and history while archiving or restoring. |
530
+ | membership.get | personId | Memberships and email eligibility. |
531
+ | membership.add / membership.remove | personId, groupId; reset? on add | Join or leave a manual group. |
532
+ | membership.move | personId, fromGroupId, groupId, reset? | Leave one manual group and join another. |
533
+ | membership.stage | personId, groupId, stageId | Set a stage; null clears it without leaving the group. |
534
+ | membership.advance | personId, groupId | Move to the next nonterminal stage. |
535
+ | membership.override | personId, groupId, override | Use include, exclude, or null for automatic membership. |
536
+ | membership.hold | personId, groupId, hold | Pause or resume automatic changes for this person/group. |
537
+ | rules.list | archived? | List rules; archived:true includes archived rules. |
538
+ | rules.get | id | Read the saved rule and version. |
539
+ | rules.create | name, trigger, conditions?, actions, enabled? | Create a rule. Pass enabled:false to review it first. |
540
+ | rules.update | id, changed fields | Save a new rule version. |
541
+ | rules.enable / rules.pause / rules.archive | id | Activate future events, pause, or archive. |
542
+ | rules.validate | rule fields or id | Validate references and automation cycles without saving. |
543
+ | rules.preview | rule fields or id; personId? | Evaluate a person or return validation only; sends nothing. |
544
+ | rules.runs | ruleId?, cursor?, limit? | Inspect saved outcomes and failed actions. |
545
+ | rules.retry | runId or ruleId | Retry eligible failed work; completed action receipts stay saved. |
546
+ | attachments.create | groupId, stageId?, emailKind, emailId, enabled?, name?, conditions? | Create an ordinary group/stage entry rule for a template or series. |
547
+ | person.permission | personId, allowed | Record an email-permission decision; mailing-list unsubscribe and suppression still win. |
548
+ | audience.preview | audience | Preview matched and eligible People for a campaign. |
549
+ | import.preview | csv, mapping?, headersOnly? | Validate a CSV and match existing contacts; sends nothing. |
550
+ | import.start | csv, mapping?, groupIds?, requestKey | Start a saved import. |
551
+ | import.get | id | Read the import cursor, outcomes, and errors. |
552
+ | import.retry | id | Retry failed rows after correcting saved identity conflicts. |
553
+ | company.get | — | Read the admin-only Frontpage account sync. |
554
+ | company.set | enabled, groupId? | Start or pause that sync in landing; platform admin only. |
555
+
556
+ ## Limits, timing, and recovery
557
+
558
+ - **Worker timing:** the existing minute worker handles saved imports, account sync, condition groups, timed rules, and pending actions. A busy queue can take longer than one minute.
559
+ - **Temporary failures:** eligible rule actions retry with bounded backoff, up to eight attempts. Permanent errors stay visible until corrected. Completed actions retain their receipts.
560
+ - **Safe activation:** new UI rules and attachments start paused. Enabling applies to future events. Updating a rule changes its version and does not replay old events.
561
+ - **Safe membership:** cycle checks reject rules that would repeatedly add/remove or move people in a loop. Stage guards prevent late events from resetting later progress.
562
+ - **Safe email:** sending uses the existing monthly/hourly/site limits, platform cap, send window, provider pacing, and kill switch. Permission and suppression are checked at delivery.
563
+ - **Safe retries:** reuse a request key only for the same import or send. Inspect the saved job/run before starting a replacement.
564
+ - **Safe changes:** rename labels while retaining IDs. Archive instead of deleting records. Review paused rules when restoring groups or stages.
565
+
566
+ **Keep scope clear.**
567
+
568
+ People does not replace the raw Inbox, Store orders, or the email send ledger. It combines the contact’s history and organization. Those source tools and their existing CLI commands remain available.
@@ -1,7 +1,8 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join, relative, sep } from "node:path";
3
3
  import { resolveSlug } from "../site.js";
4
- import { EXIT_OK, EXIT_USAGE, v1Get, v1Put } from "../http/client.js";
4
+ import { CliHttpError, EXIT_CONFLICT, EXIT_OK, EXIT_USAGE, v1Get, v1Put, } from "../http/client.js";
5
+ import { gitBlobSha } from "../git-blob-sha.js";
5
6
  import { readState, writeState } from "../state/frontpage-state.js";
6
7
  import { emitErr, emitOk, emitUsage, isJson } from "../runtime.js";
7
8
  import { waitForAgentThenCompile } from "./wait-site-compile.js";
@@ -47,7 +48,9 @@ async function walkAllowlisted(cwd) {
47
48
  for (const ent of entries) {
48
49
  const child = join(abs, ent.name);
49
50
  if (ent.isDirectory()) {
50
- if (ent.name === "node_modules" || ent.name === ".git" || ent.name === ".frontpage")
51
+ if (ent.name === "node_modules" ||
52
+ ent.name === ".git" ||
53
+ ent.name === ".frontpage")
51
54
  continue;
52
55
  await walk(child);
53
56
  }
@@ -83,7 +86,9 @@ export async function runPush(args) {
83
86
  const messageIdx = work.indexOf("--message");
84
87
  const message = messageIdx >= 0
85
88
  ? work[messageIdx + 1]
86
- : work.find((a) => a.startsWith("--message="))?.slice("--message=".length);
89
+ : work
90
+ .find((a) => a.startsWith("--message="))
91
+ ?.slice("--message=".length);
87
92
  const cwd = process.cwd();
88
93
  const state = await readState(cwd);
89
94
  if (!state.baseCommitSha) {
@@ -94,6 +99,7 @@ export async function runPush(args) {
94
99
  return emitUsage("push", "No allowlisted files (src/, public/, content/) found in this folder.", EXIT_USAGE);
95
100
  }
96
101
  const listing = await v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/files`);
102
+ const remoteByPath = new Map(listing.files.map((file) => [file.path, file.blobSha]));
97
103
  const files = [
98
104
  ...pushDeletesForMissingLocal(paths, listing.files.map((f) => f.path)),
99
105
  ];
@@ -102,21 +108,38 @@ export async function runPush(args) {
102
108
  const st = await stat(abs);
103
109
  if (!st.isFile())
104
110
  continue;
111
+ const buf = await readFile(abs);
112
+ if (gitBlobSha(buf) === remoteByPath.get(path))
113
+ continue;
105
114
  if (isTextPath(path)) {
106
- const content = await readFile(abs, "utf8");
115
+ const content = buf.toString("utf8");
107
116
  files.push({ path, encoding: "utf-8", content });
108
117
  }
109
118
  else {
110
- const buf = await readFile(abs);
111
- files.push({ path, encoding: "base64", content: buf.toString("base64") });
119
+ files.push({
120
+ path,
121
+ encoding: "base64",
122
+ content: buf.toString("base64"),
123
+ });
112
124
  }
113
125
  }
114
- const data = await v1Put(`/api/v1/sites/${encodeURIComponent(slug)}/files`, {
115
- baseCommitSha: state.baseCommitSha,
116
- expectedHeadSha: state.baseCommitSha,
117
- message: message || undefined,
118
- files,
119
- });
126
+ if (!files.length && listing.baseCommitSha !== state.baseCommitSha) {
127
+ throw new CliHttpError("Remote tip moved since your last pull. Run frontpage pull and retry.", EXIT_CONFLICT, 409, "conflict");
128
+ }
129
+ const data = files.length
130
+ ? await v1Put(`/api/v1/sites/${encodeURIComponent(slug)}/files`, {
131
+ baseCommitSha: state.baseCommitSha,
132
+ expectedHeadSha: state.baseCommitSha,
133
+ message: message || undefined,
134
+ files,
135
+ })
136
+ : {
137
+ changed: false,
138
+ commitSha: state.baseCommitSha,
139
+ baseCommitSha: state.baseCommitSha,
140
+ filesChanged: [],
141
+ warnings: [],
142
+ };
120
143
  if (data.commitSha) {
121
144
  await writeState({ ...state, slug, baseCommitSha: data.commitSha }, cwd);
122
145
  }
package/dist/registry.js CHANGED
@@ -461,7 +461,7 @@ export const COMMANDS = [
461
461
  "frontpage people export",
462
462
  ],
463
463
  flags: [
464
- "Workspace: groups list|get|create|update|archive|restore|preview; stages create|update|archive|restore; membership get|add|remove|move|stage|advance|override|hold; rules list|get|create|update|enable|pause|archive|validate|preview|runs|retry; attachments create; import preview|start|get|retry; person permission; company get|set; audience preview; overview; sources",
464
+ "Workspace: groups list|get|create|update|archive|restore|preview; stages create|update|reorder|archive|restore; membership get|add|remove|move|stage|advance|override|hold; rules list|get|create|update|enable|pause|archive|validate|preview|runs|retry; attachments create; import preview|start|get|retry; person permission; company get|set; audience preview; overview; sources",
465
465
  "All workspace operations accept --file JSON or --data JSON. Named flags: --id --label --name --group --stage --person --from-group --mode --color --position --terminal --enabled --hold --allowed --archived --reset --override include|exclude|auto --email-kind template|series --email-id --rule --run --cursor --limit --request-key. Boolean flags take true|false. Complex conditions/actions/mappings use JSON.",
466
466
  "CSV: --csv PATH plus --file mapping.json. import start requires a stable --request-key. Retry the same import with the same key and contents.",
467
467
  "list/export: --group GROUP_ID --stage STAGE_ID. See package PEOPLE.md for full examples and every JSON shape.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "frontpage-host",
3
- "version": "0.1.24",
4
- "description": "Frontpage CLI \u2014 manage your site from the terminal",
3
+ "version": "0.1.25",
4
+ "description": "Frontpage CLI — manage your site from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "frontpage": "dist/bin.js"