Developers People CRM

Platform

People CRM

Frontpage People CRM: the per-site person graph, identity rules, status and follow-up, hide versus forget, CLI verbs, and the public v1 API.

What this is

People CRM is Frontpage’s first-party CRM. It is a per-site person graph: one row per identifiable human on that site, plus an immutable timeline of what they did and what the owner wrote down. The purpose is operational, not marketing. You use it to answer “who is this, where are they, and what happens next?” without turning inbox rows into a second product.

It is a platform capability, like Analytics or SEO. It is not a module. There is no widget to drop on a page and no Modules-hub card. Do not invent markup for it. Do not enable it through frontpage modules.

frontpage people list
frontpage people get <id>
frontpage people add --email alex@example.com --name Alex
frontpage people set <id> --status working
frontpage people note <id> --body "Left a voicemail"
frontpage people follow-up <id> --at 2026-09-12T00:00:00Z
frontpage people hide <id>

Requires frontpage-host 0.1.18 or later. Older global installs report Unknown command: people. Upgrade with npm i -g frontpage-host@latest.

Why three systems exist

Agents and developers keep mixing these up. They are siblings, not replacements.

System What it stores What it is for Command / API
Inbox Raw submissions (each form row, signup, order, review-ask) The ledger. Nothing is merged. Rows are not deleted. frontpage inbox …
People CRM One person per site, joined on email or phone The working list: status, follow-up, notes, hide frontpage people …
Destinations Outbound copies to HubSpot, Pipedrive, or Privyr The owner’s external CRM. Frontpage does not read those back. Forms destinations
  • Inbox stays the source of truth for “what was submitted.” People never replaces it.
  • Connecting Privyr or HubSpot does not create Frontpage people, and Frontpage people do not sync outward.
  • Same email on two Frontpage sites is two people. There is no account-wide join and no cross-site People view.
  • Frontpage billing customers are not People. Do not mint a person from a Stripe customer id.

Who can touch whose people

The People list, CLI, API, and editor tools are included on Growth. Forms and payments still create people on every plan. People uses the same Public CLI fence as every other site-scoped command (push, inbox, seo, …). A key cannot target another tenant’s graph by guessing a slug, a site UUID, or a person UUID.

Who you are What happens
No key, or a bad key HTTP 401. CLI exit 4. Nothing is read or written.
Valid key, but you are not a member of --site HTTP 404 Site not found. Existence is hidden. Same response for an unknown slug, a soft-deleted site, or a purged site.
Member with role viewer list and get work. Every mutate verb (add, set, note, rm-note, follow-up, hide, unhide, status create/archive) returns HTTP 403. CLI exit 4.
Member with role editor or admin Read and write People on that site only.
Person UUID copied from site B, used with --site of site A HTTP 404 Person not found. Every People query is (claim.siteId, person_id). The client cannot pass a site UUID.
  • --site is a subdomain slug only (example: acme). Not a custom domain, not acme.frontpage.host, not a site UUID, not a person UUID. See Flags for the exact slug rules.
  • The server binds work to the site that slug maps to after membership is checked. JSON bodies that include siteId are ignored. Lib calls always use claim.siteId from that authorize step.
  • API keys never get platform-admin fleet override. A Frontpage staff key still needs real membership on the target site.
  • Keys are account-wide. One fp_live_… acts as that user on every site they belong to. There is no per-site key and no editor-only key in v1. Sharing a key shares People (and push, inbox, settings) on all of those sites.
  • If someone is a real editor or admin of the victim site, they can mutate People there. That is membership, not a fence hole.
  • Same email on two sites is two people. There is no cross-site People view and no account-wide join.
  • The Public CLI does not expose site members, invites, or ownership transfer. You cannot add or remove collaborators through frontpage. That stays in the editor.
  • Cookie routes (/api/sites/people…) use the signed-in editor session and gateSite, not an API key. Do not call those from the CLI.

Full key and 401 / 403 / 404 table: Isolation & keys.

Targeting a site (--site)

Every People verb is site-scoped. The CLI must know which subdomain you mean before it talks to the API.

  1. --site <slug>, -s <slug>, or --site=<slug> on this command. Wins for this process only. Does not rewrite .frontpage/state.json.
  2. Otherwise the slug in .frontpage/state.json from frontpage use <slug> in the current working directory.
  3. If neither is set, the CLI exits 2: No site selected. Run: frontpage use <subdomain> or pass --site <subdomain>.
# Folder already linked
cd ~/sites/acme
frontpage use acme
frontpage people list

# One-off, any folder
frontpage people list --site acme
frontpage people add --site bakery --email alex@example.com

# Rejected locally (exit 2), never sent
frontpage people list --site acme.frontpage.host
frontpage people list --site https://acme.frontpage.host
frontpage people list --site www.acme.com

The slug is trimmed and lowercased. It must match ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$ and must not contain ., /, or :. That is the same rule as frontpage use. A syntactically valid slug you do not belong to still returns 404, not 403.

Identity (the join key)

A person is identified by email or phone, never by name. Name is a label. Two “Alex” rows with different emails are two people. A name-only add is rejected.

  • One stored email and one stored phone per person. Both are written normalized.
  • Email: trim, lowercase, then this shape: /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/. Spaces, missing @, or a TLD shorter than 2 characters fail. Invalid input is treated as missing, not stored as a unique key.
  • Phone: see the format table below. Unparseable phone is missing. It is not stored.
  • On a given site, an email belongs to at most one person, and a phone belongs to at most one person.
  • Lookup order: email first, else phone. If both are present and they point at two different people, email wins. Frontpage writes an identity.conflict event instead of merging or stealing the other identifier.
  • Empty fields can be filled later. An identifier that already belongs to someone else on the same site returns 409 (“already belongs to someone else”).
  • A person must keep at least one identifier. Clearing both email and phone in one patch fails.

Email format

Flag: --email <string>. The CLI sends the raw string. The server normalizes before write.

You type Stored as Notes
Alex@Example.com alex@example.com Trim + lowercase
ada@bakery.com ada@bakery.com Surrounding whitespace dropped
not-an-email (missing) On add, if there is also no valid phone: 400 An email or phone is required. On set --email: 400 That email is not valid.
"" (empty) on set cleared Allowed only if a phone remains

There is no separate “normalized” column. What you see in JSON is what is unique. Plus-addressing (ada+vip@example.com) is a different person from ada@example.com. Frontpage does not collapse Gmail dots.

Phone format

Flag: --phone <string>. Punctuation is fine. The server strips every non-digit, then applies these rules. Stored value is always E.164 with a leading +.

  1. Trim. Empty string is missing.
  2. Keep only digits. If the digit count is under 8 or over 15, reject (missing).
  3. If the raw string starts with + (before stripping), store + plus those digits. Country code is whatever you typed. This is how international numbers work.
  4. Else if there are exactly 10 digits, store +1 plus those digits (US default).
  5. Else if there are 11 digits and they start with 1, store + plus those digits.
  6. Else if there are 11 or more digits, store + plus those digits (country code already in the digits).
  7. Otherwise reject. An 8 or 9 digit local number without a leading + is not stored.
You type Stored as Why
(555) 123-4567 +15551234567 10 US digits, no leading +
555-123-4567 / 5551234567 +15551234567 Same rule
15551234567 +15551234567 11 digits starting with 1
+1 (555) 123-4567 +15551234567 Leading + is honored first. Do not get a second 1
+44 7911 123456 +447911123456 International: start with +
+4479829126 +4479829126 8 to 15 digits after stripping, leading +
447911123456 (no +) +447911123456 12 digits, treated as already including country code
123, call me, 555-1234 (missing) Too few digits, or 7 US local without +
12345678 (8 digits, no +) (missing) Must start with + when it is not a US 10 / NANP 11

On add, a garbage phone with no valid email is 400 An email or phone is required. On set --phone, a non-empty string that does not parse is 400 That phone number is not valid. set --phone "" clears phone if an email remains.

Name format

Flag: --name <string>. Trimmed. Max 200 characters (longer input is cut). Empty after trim is stored as null. Name is never a join key and never unique.

On mint, the name you pass is stored. On a later attach (same email or phone already on the site), an existing name is kept. A later ingest or add does not overwrite it. Rename with set --name.

# Valid
frontpage people add --email Alex@Example.com
# stored as alex@example.com

frontpage people add --phone "(555) 123-4567" --name Alex
# stored as +15551234567

frontpage people add --phone "+44 7911 123456" --name Nia
# stored as +447911123456

# Rejected (CLI exit 2 if you omit both flags; API 400 if both normalize to missing)
frontpage people add --name Alex

# Two sites, same email = two people
frontpage people add --email alex@example.com --site bakery
frontpage people add --email alex@example.com --site salon

Status

Each person has one status slug. This is not a HubSpot lifecycle, not a deal object, not MQL/SQL, not a probability, and not a kanban product. It is a single label the owner can change.

Defaults are seeded on first status read for that site: new (New), working (Working), won (Won), lost (Lost). Owners may add, rename, or archive labels. Cap is 12 live statuses. You cannot archive the last remaining live status. Archived slugs cannot be assigned.

The CLI can set --status <slug>. It does not list or create labels. Use the v1 status routes below for that. The value must be a live slug on that site (defaults: new, working, won, lost). An unknown or archived slug is rejected. Status slugs are lowercase; when you create a label via v1, the slug is derived as lowercase letters and digits with other characters turned into _, trimmed, max 40 characters.

Auto-status (dumb, deterministic)

When a form, signup, booking, or payment lands on a person, status follows a small rule table. There is no builder, no score, and no “if they opened the email.” Manual set --status always wins over a later rule that does not fire.

Event type If mint / first identifier If the person already exists
Any mint (including manual add) new n/a
form.submitted, signup.subscribed, signup.resubscribed new new only if current is empty or new. Does not pull Won or Working backward
booking.requested working working only if current is empty or new
payment.paid, order.paid, square_order.paid won won, including from lost
Refunds, fulfill, Square created, owner notes no auto change no auto change
  • Nothing auto-sets lost. Lost is an owner decision.
  • Nothing auto-pulls won backward. A later form submit does not demote a paid customer.
  • These rules run when a form, signup, booking, or payment lands on a person.

Follow-up

Follow-up is a single timestamp on the person (followUpAt), not a task table and not a reminder email. Set it or clear it. There is no recurrence.

  • Due means follow_up_at is before the start of tomorrow UTC.
  • That definition is intentional: a time later today is already due. A time tomorrow 00:00 UTC or later is upcoming.
  • List with --follow-up due|upcoming|none.
  • Summary due counts visible (not hidden) people whose follow-up is due.
frontpage people follow-up <id> --at 2026-09-12T00:00:00Z
frontpage people follow-up <id> --clear
frontpage people list --follow-up due

Hide is not forget

Owner “delete” in v1 is hide. It sets hiddenAt. Default lists omit hidden rows. Unique email and phone stay held so a later ingest (or add) on the same site with the same identifier un-hides and attaches instead of minting a duplicate.

  • frontpage people list --hidden lists only hidden rows.
  • unhide restores the row without changing email, phone, or status.
  • Hide is not a legal wipe. Do not promise that hide erases PII, and do not invent a people forget command.
  • Inbox rows are still not hard-deleted. Hide does not remove a form submission.

Timeline

Each person has a timeline of what they did and what the owner wrote down. Visitor events are never edited or deleted. Notes the owner adds can be removed.

Visitor types:

form.submitted, booking.requested, signup.subscribed, signup.resubscribed, payment.paid, payment.refunded, order.paid, order.refunded, order.fulfilled, square_order.created, square_order.paid.

Owner / system types (live today via CLI and v1):

person.added, note.added, person.updated, status.changed, follow_up.set, follow_up.cleared, person.hidden, person.unhidden, identity.conflict.

  • Visitor event cards are never edited or deleted.
  • Only owner notes (source === manual and type === note.added) can be removed (frontpage people rm-note).
  • add --note "…" mints the person and writes note.added in one step. note after the fact appends another note.added.
  • set --notes "…" patches the person’s notes column (a sticky blurb). That is different from timeline notes. Prefer note for a dated call log.

CLI map

Requires frontpage-host 0.1.18 or later. Unknown leftover flags fail closed (exit 2). Viewer can list and get. Editor can mutate. Treat payloads as PII: do not paste emails, phones, or note bodies into public logs.

Global flags that apply to every verb: --site <slug> / -s / --site=<slug>, --json, --quiet. See Flags. --yes does nothing here (it is only for unpublish / revert).

Verb What it does Required Optional flags
list One page of people plus a site-wide summary none (bare frontpage people is list) list filters below
get One person plus recent timeline (newest first, default cap 50) person id --id if you do not use the positional id
add Mint, or attach if that email/phone already exists on this site. Always source: manual --email and/or --phone --name, --note
set Patch only the flags you pass person id, plus at least one field flag --name, --email, --phone, --notes, --status
note Append a dated note.added on the timeline person id and --body (or --note)
rm-note Delete one owner note person id and --event (or --event-id)
follow-up Set or clear followUpAt person id and either --at or --clear
hide Set hiddenAt. Unique email/phone stay held person id
unhide Clear hiddenAt person id

There is no people statuses CLI verb, no forget, no merge, no email, and no sms. Status labels are v1-only (see below).

Person id

Every verb except list and add needs the person UUID. That is the id field from list, add, or get. It is not an email, not a phone, and not a name.

  • Positional: frontpage people get 81d0affb-1008-471d-9160-a35842de70a7
  • Flag: frontpage people get --id 81d0affb-1008-471d-9160-a35842de70a7
  • If you pass both, --id wins and the leftover positional is treated as an unknown argument (exit 2).
  • A UUID that does not exist on this site returns 404 Person not found.

List flags

Filters combine with AND. Default list omits hidden rows. summary is always site-wide (all visible people, plus a hidden count), not just this page. An unknown --source, --sort, or --follow-up value is ignored by the server (you get the default list, not an error). Prefer the values below.

Flag Allowed values Default What it does
--q / --query any string none Case-insensitive substring match on name, email, or phone (including the stored + form). Useful: --q +1555 or --q alex@
--status exact live slug, e.g. new, working, won, lost all statuses Exact match. Not a prefix. Custom slugs work if they exist on that site
--source form | booking | signup | payment | order | square all Person has at least one timeline event in that family. Sites with only owner-added people return empty for these. manual is not a list filter
--form-slug form instance slug, e.g. contact, lead-gen none Has a form or booking event whose payload formSlug equals this string.
--follow-up due | upcoming | none all due: follow_up_at before start of tomorrow UTC (a time later today is already due). upcoming: tomorrow 00:00 UTC or later. none: no follow-up set
--hidden boolean flag, no value off (visible only) When present, list only hidden rows. There is no --hidden false. Omit the flag for the default
--sort last_seen | first_seen | name | follow_up last_seen follow_up sorts nulls last. Pass the printed next: / nextCursor back as --cursor when paging
--last-seen-days positive integer none lastSeenAt within the last N × 86400 seconds. 0 or a non-number is ignored
--limit integer 1 to 200 50 Page size. Values above 200 are clamped to 200. Values below 1 become 1
--cursor opaque string from the previous page’s nextCursor first page Do not invent a cursor. When human output still has more pages it prints next: <cursor>

Add flags

Flag Required Value
--email yes, unless --phone is present See email format. At least one of email or phone must survive normalize
--phone yes, unless --email is present See phone format
--name no Display label, max 200 after trim
--note no Timeline note.added written in the same request as the mint. Trimmed. Empty note is ignored and the event type becomes person.added instead

If that email or phone already exists on this site, add attaches (and un-hides if the row was hidden). You do not get a second person. JSON includes created / duplicate when you use --json.

Set flags

Only flags you pass are patched. The CLI omits keys you did not set. Do not reconstruct a JSON body that includes "email": null unless you intend to clear email. set --status working must not send empty identifier keys.

Flag Value Clear
--name string, max 200 --name "" stores null
--email valid email after normalize --email "" if a phone remains. Invalid non-empty → 400
--phone valid phone after normalize --phone "" if an email remains. Invalid non-empty → 400
--notes sticky blurb on the person row (not a timeline event) --notes "" stores null. Prefer note for a dated call log
--status live slug on this site cannot clear; omit the flag to leave status alone

Clearing both identifiers in one patch fails with A person must keep an email or a phone. Moving an email or phone onto a row that already has it returns 409 That email already belongs to someone else. (or phone).

Note, follow-up, hide

Flag Verb Value
--body / --note note Non-empty after trim. Required. --body and --note are aliases; --body is checked first
--event / --event-id rm-note Timeline event UUID from get. Only source === manual and type === note.added can be removed. Visitor events (payments, forms) cannot
--at follow-up Timestamp parsed by new Date(string). Prefer ISO-8601 with a timezone, e.g. 2026-09-12T00:00:00Z. Date-only 2026-09-12 is UTC midnight. A string the engine cannot parse is 400 Follow-up must be a valid timestamp.
--clear follow-up Boolean flag, no value. Sets followUpAt to null. If you pass both --clear and --at, clear wins

hide and unhide take no extra flags. Both are idempotent: hiding an already hidden row does not write a second person.hidden event.

Public v1 API

Base: https://app.frontpage.host/api/v1/sites/:slug/people. Authenticate like every other public CLI call (API key). Viewer for reads, editor for writes. Reads are RL-READ. Mutations are RL-WRITE-LIGHT (30 / min). See Rate limits.

Successful bodies are the usual v1 envelope: the fields below sit next to slug (and requestId on errors). Audit actions: site.people.add, site.people.set, site.people.note, site.people.status_upsert, site.people.status_archive, plus hide / unhide / follow-up.

Method Path Role Body / query
GET /people viewer q, status, source, formSlug, followUp, hidden=1, sort, lastSeenDays, limit, cursor
POST /people editor { email?, phone?, name?, note? } (email or phone required). 201
GET /people/:id viewer Person + timeline
PATCH /people/:id editor Only keys you send: name, email, phone, notes, status
POST /people/:id/hide editor Empty object
POST /people/:id/unhide editor Empty object
PATCH /people/:id/follow-up editor { at: iso | null }
POST /people/:id/events editor { body: "note text" } only. 201. Owner notes only
DELETE /people/:id/events/:eventId editor Owner note.added only
GET /people/statuses viewer Seeds defaults on first call
POST /people/statuses editor { label, slug? }. Rename if slug exists; else create (cap 12 live)
DELETE /people/statuses/:statusSlug editor Archive (not hard-delete). Cannot archive the last live status

Cookie twins (editor browser session, same rules, not public-key): /api/sites/people, /item, /hide, /unhide, /follow-up, /notes, /statuses. Do not call these from the CLI.

Response shapes

List (abbreviated). summary is site-wide, not just this page:

{
  "slug": "acme",
  "people": [
    {
      "id": "81d0affb-1008-471d-9160-a35842de70a7",
      "siteId": "…",
      "email": "alex@example.com",
      "phone": "+15551234567",
      "name": "Alex",
      "status": "working",
      "followUpAt": "2026-09-12T00:00:00.000Z",
      "hiddenAt": null,
      "firstSeenAt": "2026-09-10T04:09:50.919Z",
      "lastSeenAt": "2026-09-10T04:09:50.919Z",
      "sourceFirst": "manual",
      "notes": null,
      "attrs": {},
      "lastEvent": { "type": "note.added", "summary": "Called them", "occurredAt": "…" }
    }
  ],
  "nextCursor": null,
  "summary": {
    "total": 1,
    "due": 1,
    "hidden": 0,
    "byStatus": [
      { "slug": "new", "label": "New", "count": 0 },
      { "slug": "working", "label": "Working", "count": 1 },
      { "slug": "won", "label": "Won", "count": 0 },
      { "slug": "lost", "label": "Lost", "count": 0 }
    ],
    "bySource": [],
    "byFormSlug": []
  }
}

get adds events (newest first, default cap 50). Human CLI output is one line per person, then one indented line per event.

Worked example

export FRONTPAGE_API_KEY=fp_live_…
frontpage use acme

# Who is already on the graph?
frontpage people list --json

# Capture someone you just spoke to (email or phone required)
frontpage people add --email alex@example.com --name Alex --note "Called from the homepage form"

# Move them, book a follow-up, add a dated note
frontpage people set <id> --status working
frontpage people follow-up <id> --at 2026-09-12T00:00:00Z
frontpage people note <id> --body "Left a voicemail"

# Morning list
frontpage people list --follow-up due --json

# Wrong person? Hide. Same email later will un-hide, not duplicate.
frontpage people hide <id>
frontpage people list --hidden

Errors you will actually see

Situation HTTP / CLI Message (typical)
Missing --email and --phone on add exit 2, no request CLI usage line
Both identifiers normalize to missing 400 / exit 2 An email or phone is required.
Unknown leftover flag exit 2, no request Unknown flag
No --site and no frontpage use exit 2 No site selected.
--site acme.frontpage.host exit 2 Must be a subdomain slug, not a domain or URL
Bad or missing API key 401 / exit 4 Unauthorized
Viewer trying to add/set/hide 403 / exit 4 Forbidden (member without privilege)
Slug you do not belong to 404 / exit 4 Site not found.
Person UUID not on this site 404 / exit 4 Person not found.
Email or phone already on another row 409 / exit 3 That email already belongs to someone else.
Invalid set --email / --phone 400 / exit 2 That email is not valid. / That phone number is not valid.
Clear last identifier 400 / exit 2 A person must keep an email or a phone.
Unknown or archived --status 400 / exit 2 Unknown or archived status.
Bad --at 400 / exit 2 Follow-up must be a valid timestamp.
Remove a payment / form event 400 / exit 2 Only owner notes can be removed.
Rate limit 429 / exit 2 rate_limited. Honor Retry-After

Reads are RL-READ (120 / min per key). Mutations are RL-WRITE-LIGHT (30 / min per key). See Rate limits and Errors & exit codes.

Agent rules

  • Read this page and frontpage help people before inventing verbs, flags, or body fields.
  • Unknown leftover flags fail closed. Do not “helpfully” pass through extra flags.
  • Do not use inbox … --mark-read unless the user asked. Inbox is a different system.
  • Do not paste People payloads into public logs, issues, or chat that leaves the owner’s machine.
  • Do not add People rows to a customer site unless the user asked. Prefer a throwaway or the site they named.
  • Do not treat hide as legal delete. Do not invent forget, merge, email, or sms.
  • Do not write HubSpot/Pipedrive/Privyr tokens or webhooks into People. Destinations stay on forms.
  • Forms, signups, bookings, and payments create people automatically. Use add for people who have not done those things yet.
  • Status-only PATCH: send { "status": "working" } only. Never send null identifiers by accident.
  • People mutations do not change staging HTML and do not require publish.

Common mistakes

Mistake What actually happens
Add with name only Rejected. Email or phone is required
Use People as a replacement for inbox Inbox still holds every raw row. People is the merged working list
Assume a form submit stays only in inbox Inbox still stores the raw row. People also gets (or updates) the person
Hide, then add the same email again The hidden row un-hides. You do not get a second person
Move an email onto someone else 409 conflict. Identifiers are not stolen
PATCH status with email: undefined in a reconstructed body Treated as a clear if the key is present. Omit the key
Delete a payment event from the timeline Rejected. Only manual notes are removable
Install an old frontpage binary Unknown command: people. Need frontpage-host@0.1.18+

Isolation (why a CRM bug must not 409 a visitor)

People lives in the product app only. The public site gateway never talks to People. A bad People write cannot fail a visitor form post. Destinations keep dispatching on their own path. Do not invent a People module, and do not route visitor HTTP through People.

Cross-tenant isolation is the same membership fence as the rest of v1. Developers cannot push another tenant’s files, cannot add or hide people on a site they do not belong to, and cannot drive People with a stolen person UUID on the wrong --site. What they can do is use a shared account-wide key on every site that key’s owner already belongs to. Treat keys like passwords.

Also see