# HeliumRises Connect Agent Guide

HeliumRises Connect is the governed, multi-tenant newsletter and email-automation
service for the GFA ecosystem.

> **AI agents: do not use the interactive “Continue with GFAVIP” browser login.**
> Authenticate headlessly through PowerLobster → GFAVIP Wallet, then call the
> Connect API with the resulting bearer token as described below.

- Canonical origin: `https://connect.heliumrises.com`
- API base: `https://connect.heliumrises.com/api/v1`
- OpenAPI 3.1: `https://connect.heliumrises.com/openapi.json`
- Readable API reference: `https://connect.heliumrises.com/docs`
- Authentication authority: GFAVIP Wallet
- Human sign-in: GFAVIP Wallet Secure Code Exchange
- Headless agent sign-in: PowerLobster Identity → GFAVIP Wallet

## Available now versus planned

Available now:

- Wallet-authenticated human and PowerLobster agent access;
- explicit workspace memberships and role/capability authorization;
- contacts, typed fields, provenance, audiences, consent, and suppressions;
- centralized send-eligibility decisions and preference links;
- sender domains, DNS/provider readiness, and sender identities;
- versioned templates, sanitized email content, preview, and campaign drafts;
- capped test sends to a typed recipient, and controlled provider-event rehearsals;
- immutable campaign scheduling behind closed production gates;
- Mailgun event processing, reporting, operational controls, launch preflight, and
  isolated synthetic capacity rehearsals;
- protected generic CSV uploads, reusable mappings, aggregate-only dry runs,
  checksum-bound owner/admin approval, suppression-first apply, reconciliation,
  and automatic payload cleanup;
- workspace-isolated automation drafts, immutable graph versions, typed triggers,
  nodes and edges, dependency validation, previews, and review-ready state.
- checksum-verified Phase 5C legacy reconstruction into disposable review
  drafts, plus an owner-approved, GFA-only production draft loader; recovered
  content remains outside Git.
- Durable automation enrollments, scheduled database waits, attempts, events,
  eligibility decisions, pause/cancel/retry/replay, and operator observability.
  An automation runs once it is published; `audience_subscribed`, `tag_added`
  and `tag_removed` enroll contacts, and a `send_email` step records an
  automation send that the delivery worker then hands to the provider under the
  same gates a campaign passes.
- Phase 5E deterministic local-only automation-runtime rehearsal using a
  disposable database, simulated clock, restart recovery, redacted evidence,
  and an explicit zero-external-call reconciliation.
- Operator-changeable platform gates. Rate limiting, automations, production
  sending, test sends and synthetic rehearsals are database rows resolved as
  `environment ceiling AND stored row`, changed from the console without a
  redeploy. They can flip while your run is in progress -- see "Platform gates
  move underneath you".
- Campaign targeting across several audiences at once, narrowed by tag with
  `any`/`all` matching, frozen into the scheduled snapshot. **Console only for
  writes**; the API creates single-audience drafts and reads any shape back.
- A workspace tag catalogue: registration, bulk apply and remove across
  resolved audiences, and rename/merge. **Console only.** Capped at 50 tags per
  contact.
- Console administration of operators, organizations and workspaces, including
  role changes and a workspace sending off switch.
- Editing and deleting sending domains and sender identities, and deleting
  audiences and unsent campaigns. **Console only.** Every delete is refused
  while anything still references the record.
- External subscription-source ingestion, authenticated by a per-source
  credential or a Wallet grant scoped to that source, never by an operator
  bearer token. Creating a source and issuing, rotating or revoking its
  credential is **console only**.

Planned, not currently available:

- AI-content-generation endpoints;
- application credentials independent of GFAVIP user/agent identity;
- dynamic segments, privacy export/deletion, agent-card discovery.

Automation execution is **no longer planned; it is live.** A published
automation enrolls contacts and its `send_email` steps hand real messages to the
delivery worker under the same gates a campaign passes. The `automation_enabled`
platform gate is the kill switch.

`edit_automations` governs authoring. Publishing and pausing are console-only
today -- see "What the API cannot do". `generate_ai_content` remains reserved;
its presence in the capability table does not mean an AI-authoring route exists.

## Actions that can start a real email sequence

Read this before writing anything. Two ordinary-looking write endpoints are
automation triggers, so a call an agent thinks is bookkeeping can put a contact
into a published sequence that sends real email.

| Call | Capability | Trigger it fires |
|---|---|---|
| `POST /audiences/{audience_id}/subscribe` | `upsert_contacts` | `audience_subscribed` |
| Console tag apply / remove | `upsert_contacts` | `tag_added` / `tag_removed` |

The trigger fires only for automations whose current version is `published` and
whose trigger type matches. If the workspace has no published automation, these
calls do exactly what they appear to do.

**Before subscribing contacts in bulk, check what is published:**

```bash
curl -fsS "${WORKSPACE_API}/automations" -H "${AUTH_HEADER}" |
jq '[.automations[] | select(.status == "published") | {id, name, trigger: .trigger_type}]'
```

An empty array means subscribing is inert. A non-empty one means every contact
you subscribe to a matching audience may begin receiving mail within a minute.

One asymmetry worth knowing: `POST /contacts/upsert` accepts a `tags` array and
writes it directly to the contact, which does **not** fire `tag_added`. Only the
console's tag apply/remove path does. Do not rely on this to move contacts
quietly -- it is an inconsistency in the current implementation, not a
guarantee.

## Five-minute headless-agent quick start

Requirements: `curl`, `jq`, a PowerLobster agent API key, and an explicit Connect
workspace membership for that agent's GFAVIP username.

### 1. Authenticate without printing credentials

```bash
read -rsp "PowerLobster API key: " POWERLOBSTER_API_KEY
printf "\n"

POWERLOBSTER_IDENTITY_TOKEN="$(
  curl -fsS -X POST https://powerlobster.com/api/agent/identity-token \
    -H "Authorization: Bearer ${POWERLOBSTER_API_KEY}" |
  jq -er '.identity_token'
)"
unset POWERLOBSTER_API_KEY

GFAVIP_SSO_TOKEN="$(
  curl -fsS -X POST https://wallet.gfavip.com/api/auth/powerlobster \
    -H "Content-Type: application/json" \
    --data "$(jq -cn --arg token "${POWERLOBSTER_IDENTITY_TOKEN}" '{token:$token}')" |
  jq -er '.sso_token'
)"
unset POWERLOBSTER_IDENTITY_TOKEN
```

Do not echo either token, place it in a URL, or commit it. Never send a PowerLobster API key
or identity token to Connect.

### 2. Inspect the live identity response

```http
GET https://connect.heliumrises.com/api/v1/me
Authorization: Bearer <GFAVIP_SSO_TOKEN>
```

```bash
CONNECT_ORIGIN="https://connect.heliumrises.com"
AUTH_HEADER="Authorization: Bearer ${GFAVIP_SSO_TOKEN}"

curl -fsS "${CONNECT_ORIGIN}/api/v1/me" \
  -H "${AUTH_HEADER}" |
jq
```

The actual response shape is:

```json
{
  "user": {
    "gfavip_user_id": "stable-wallet-user-id",
    "username": "pl-agent-name",
    "identity_type": "agent",
    "platform_role": null
  },
  "auth_method": "bearer",
  "csrf_token": null,
  "workspaces": [
    {
      "organization": "gfa-community",
      "workspace": "gfa-community",
      "role": "editor",
      "is_archived": false
    }
  ]
}
```

There is no top-level `memberships` field. The membership array is `workspaces`.

### 3. List assigned workspaces

```bash
curl -fsS "${CONNECT_ORIGIN}/api/v1/workspaces" \
  -H "${AUTH_HEADER}" |
jq
```

Select only an organization/workspace pair returned by this endpoint:

```bash
ORGANIZATION="gfa-community"
WORKSPACE="gfa-community"
WORKSPACE_API="${CONNECT_ORIGIN}/api/v1/organizations/${ORGANIZATION}/workspaces/${WORKSPACE}"
```

### 4. Look up a contact

Requires `view`.

```bash
CONTACT_EMAIL="person@example.com"
curl -fsS --get "${WORKSPACE_API}/contacts/lookup" \
  -H "${AUTH_HEADER}" \
  --data-urlencode "email=${CONTACT_EMAIL}" |
jq
```

Save the returned `contact.id`:

```bash
CONTACT_ID="replace-with-contact-id"
```

### 5. Inspect an audience

Requires `view`.

```bash
AUDIENCE_ID="replace-with-audience-id"
curl -fsS "${WORKSPACE_API}/audiences/${AUDIENCE_ID}" \
  -H "${AUTH_HEADER}" |
jq
```

### 6. Check centralized eligibility

Requires `view`. This is read-only and never contacts Mailgun or a recipient.

```bash
curl -fsS \
  "${WORKSPACE_API}/contacts/${CONTACT_ID}/eligibility/${AUDIENCE_ID}" \
  -H "${AUTH_HEADER}" |
jq
```

Treat `eligible: false` and every `reasons` value as authoritative. Do not recreate
or bypass eligibility logic in an agent.

### 7. Create a template and immutable content version

Requires `edit_content`. These calls are database-only and send nothing.

```bash
TEMPLATE_ID="$(
  curl -fsS -X POST "${WORKSPACE_API}/email-templates" \
    -H "${AUTH_HEADER}" \
    -H "Content-Type: application/json" \
    --data '{
      "name": "Agent quick-start template",
      "slug": "agent-quick-start",
      "description": "Draft-only example"
    }' |
  jq -er '.email_template.id'
)"

CONTENT_VERSION_ID="$(
  curl -fsS -X POST \
    "${WORKSPACE_API}/email-templates/${TEMPLATE_ID}/versions" \
    -H "${AUTH_HEADER}" \
    -H "Content-Type: application/json" \
    --data '{
      "subject": "Hello {{ first_name }}",
      "preheader": "Draft preview",
      "html": "<p>Hello {{ first_name }}</p><p><a href=\"{{ unsubscribe_url }}\">Unsubscribe</a></p>",
      "plain_text": "Hello {{ first_name }}\n\nUnsubscribe: {{ unsubscribe_url }}",
      "personalization_defaults": {"first_name": "there"}
    }' |
  jq -er '.content_version.id'
)"
```

Slugs are unique per workspace. Change the example slug when repeating it.

The `html` above is a minimal shape check, **not** a newsletter you should copy.
Real content must follow **Email HTML that survives sanitizing** below, and you
must read `validation.stripped` on every response.

### 8. Create a campaign draft

Requires `edit_content`. Draft creation schedules and sends nothing.

```bash
SENDER_ID="replace-with-existing-sender-identity-id"
CAMPAIGN_ID="$(
  curl -fsS -X POST "${WORKSPACE_API}/campaign-drafts" \
    -H "${AUTH_HEADER}" \
    -H "Content-Type: application/json" \
    --data "$(jq -cn \
      --arg name "Agent quick-start draft" \
      --arg audience_id "${AUDIENCE_ID}" \
      --arg sender_identity_id "${SENDER_ID}" \
      --arg content_version_id "${CONTENT_VERSION_ID}" \
      '{
        name:$name,
        audience_id:$audience_id,
        sender_identity_id:$sender_identity_id,
        content_version_id:$content_version_id,
        notes:"Draft only; do not schedule"
      }')" |
  jq -er '.campaign.id'
)"
```

### 9. Run or inspect launch preflight

Running preflight requires `manage_senders`; `view` can inspect the latest result.
An editor such as the launch Arthur Blaze identity can inspect but cannot run it.
Preflight sends no email and opens no gate.

```bash
# Read latest result (view)
curl -fsS "${WORKSPACE_API}/operations/preflight" \
  -H "${AUTH_HEADER}" |
jq

# Create a new retained report (manage_senders)
curl -fsS -X POST "${WORKSPACE_API}/operations/preflight" \
  -H "${AUTH_HEADER}" \
  -H "Content-Type: application/json" \
  --data '{}' |
jq
```

When finished, remove the Wallet token from the shell:

```bash
unset GFAVIP_SSO_TOKEN AUTH_HEADER
```

### 10. Prepare a protected CSV import

Requires `upsert_contacts`. Uploading and dry-running never contact Mailgun and
never enable sending. Use only an approved local file; do not print its rows.

```bash
CSV_PATH="/absolute/protected/path/contacts.csv"
AUDIENCE_ID="replace-with-audience-id"

IMPORT_ID="$(
  curl -fsS -X POST "${WORKSPACE_API}/imports/csv" \
    -H "${AUTH_HEADER}" \
    -F "file=@${CSV_PATH};type=text/csv" \
    -F "audience_id=${AUDIENCE_ID}" \
    -F "source_label=Approved event registration export" |
  jq -er '.import.id'
)"
```

Configure an explicit mapping and queue the zero-write analysis:

```bash
curl -fsS -X PATCH "${WORKSPACE_API}/imports/csv/${IMPORT_ID}/mapping" \
  -H "${AUTH_HEADER}" \
  -H "Content-Type: application/json" \
  --data '{
    "mapping": {
      "email": "Email",
      "first_name": "First Name",
      "status": "Status"
    },
    "consent_policy": "status_column",
    "policy": {
      "consent_source": "documented-event-registration",
      "wording_version": "event-form-v2",
      "evidence_reference": "approved-registration-export",
      "consent_basis": "Documented newsletter opt-in field.",
      "status_values": {
        "subscribed": "subscribed",
        "unsubscribed": "audience_unsubscribe",
        "bounced": "hard_bounce"
      },
      "default_status": "review_required",
      "static_tags": ["event"]
    }
  }' |
jq
```

Poll `GET /imports/csv/{id}` until `ready_for_review`. Reports contain aggregate
counts only. `editor` can prepare the import but cannot approve apply.
`owner` or `brand_admin` must independently submit the exact confirmation
returned by the console/OpenAPI workflow. Approval and apply remain forbidden
while any test-send, sender, workspace, activation, or global production gate is
open.

### 11. Create an automation draft

Requires `edit_automations`. This writes one immutable workflow version and
makes zero provider or webhook calls. A draft enrolls nobody. Once a version is
marked ready for review and published, and the platform's Automations switch is
on, published automations enroll real contacts and their email steps send real
mail through the same delivery gates as a campaign. `runtime_enabled` and
`enrolling` on the response say which state you are in.

```bash
AUTOMATION_ID="$(
  curl -fsS -X POST "${WORKSPACE_API}/automations" \
    -H "${AUTH_HEADER}" \
    -H "Content-Type: application/json" \
    --data '{
      "name": "Agent authoring shell",
      "slug": "agent-authoring-shell",
      "description": "Agent authoring example",
      "definition": {
        "trigger": {"type": "manual", "config": {}},
        "start_node": "finish",
        "nodes": [
          {"key": "finish", "type": "end", "config": {}}
        ],
        "edges": []
      },
      "change_notes": "Initial shell"
    }' |
  jq -er '.automation.id'
)"

curl -fsS "${WORKSPACE_API}/automations/${AUTOMATION_ID}" \
  -H "${AUTH_HEADER}" |
jq '.automation | {
  status,
  current_version,
  runtime_enabled,
  enrolling,
  selected_version: {
    checksum: .selected_version.definition_checksum,
    validation: .selected_version.validation,
    preview: .selected_version.preview
  }
}'
```

`ready_for_review` is a checksum-bound documentation state. It is not
publication, activation, approval to contact recipients, or permission to
execute.

The runtime exposes governed read APIs for observability, plus synthetic
enrollment and per-enrollment control. An automation must be `published` before
anything enrolls into it: `draft`, `ready_for_review`, `paused` and `archived`
all refuse.

**Publishing and pausing are console-only.** There is no
`POST /automations/{id}/publish` or `/pause` on the API. An agent can author a
version, take it to `ready_for_review`, archive it and restore it, but a human
in the console makes it live. Publishing is reachable from `ready_for_review`
and from `paused`; pausing stops new enrollments and leaves anyone mid-sequence
where they are.

Real enrollment is not an API operation either. Contacts enter a published
automation only through its trigger — audience subscription or a console tag
change. The API's `synthetic-enrollments` route is rehearsal infrastructure and
is described below.

Rehearsal enrollment is still its own path and still requires an archived
`synthetic-rehearsal-*` workspace, a `.invalid` contact bound to the same
rehearsal ID, and an `Idempotency-Key`. A `.invalid` contact with no rehearsal
context is refused outright.

The `automation_enabled` platform gate is the kill switch for all of it. It has
no environment ceiling, defaults on, and closing it stops new enrollments and
every step in flight without a redeploy.

The Phase 5E CLI creates its own temporary SQLite database and synthetic
welcome-course structure, then deletes the database after the rehearsal. It is
hard-blocked in production, never loads the recovered GFA course, and emits
only aggregate redacted evidence. It is acceptance infrastructure, not an API
or an activation path.

Protected Phase 5C reconstruction is intentionally not exposed through the
public API. Its synthetic CLI rejects production. The separately approved
production loader is restricted to GFA Community, requires the workspace
owner, exact approval reference and confirmation, accepts only workspace-local
disabled dependencies, and defaults to a savepoint dry run unless `--commit`
is supplied. Neither path creates contacts, enrollments, execution state, or
external calls. Agents must not copy recovered subjects, bodies, URLs, sender
metadata, assets, or the handoff into Git or prompts.

Removed 2026-09-29 from every automation response: `authoring_only`,
`execution_available`, `activation_available`, `enrollment_available`, and from
the list responses `runtime_foundation_available`, `runtime_synthetic_only`,
`synthetic_only`, `production_enrollment_available`. They were constants from
the synthetic-only phase. Use `runtime_enabled` and `enrolling` instead.
Versions saved before that date still store the old keys internally but they
are not returned.

## Email HTML that survives sanitizing

Every `html` you post is rewritten against an allowlist before it is stored.
Only the rewritten copy is ever sent. Anything outside the allowlist is removed
silently, and `valid` still comes back `true`, so a response that looks like a
success can still mean the email lost its entire design.

This is not hypothetical. Two production newsletters were authored with
`bgcolor`, `background`, `align`, `border` and `role`, all of which the
allowlist rejected at the time. Both were stored 300 bytes lighter than
submitted, every background colour gone, and nothing in the response said so.

### Always read `validation.stripped`

`POST .../email-templates/{id}/versions` returns:

```json
{
  "content_version": {
    "id": "…",
    "version": 4,
    "validation": {
      "valid": true,
      "links": 2,
      "images": 0,
      "tokens": ["first_name", "unsubscribe_url"],
      "missing_defaults": [],
      "plain_text_generated": false,
      "unsubscribe_present": true,
      "stripped": {
        "tags": ["h5"],
        "attributes": ["class"],
        "css_properties": ["overflow", "text-transform"],
        "protocols": ["http"]
      }
    }
  }
}
```

**Any non-empty list under `stripped` is a defect in your HTML.** The version is
stored, but what will be sent no longer matches what you submitted. Rewrite with
allowlisted markup and post a new version. Do not report the work as complete
while `stripped` is non-empty.

Attributes and CSS are only reported for tags that survived. A tag listed under
`tags` takes its own attributes and styles with it, so fix the tag first.

That rule decides which list a problem lands in, so the example above needs its
input spelled out. It is the response to exactly this HTML:

```html
<h5>Heading</h5>
<p class="lead" style="overflow:hidden;text-transform:uppercase;">Hello {{ first_name }}</p>
<p><a href="http://example.com/post">Read it</a></p>
<a href="{{ unsubscribe_url }}">Unsubscribe</a>
```

`class`, `overflow` and `text-transform` are reported because they sit on a `p`,
which survives. Move all three onto the `h5` and the response changes to
`tags: ["h5"]` with `attributes` and `css_properties` both **empty** -- the `h5`
was removed and took them with it. Same mistakes, different report, because the
fix is different: one is "stop using `class`", the other is "stop using `h5`".

`stripped` catches removals, not everything that can go wrong. It will not tell
you that a relative URL is useless in an inbox, that your layout is ugly, or
that you linked the wrong page. Preview the version and read it.

### Allowlist

Tags:

```
a b blockquote br div em h1 h2 h3 h4 hr i img li ol p span strong
table tbody td th thead tr ul
```

Attributes, per tag. `style` is the only attribute allowed on every tag:

| Tag | Attributes |
| --- | --- |
| any | `style` |
| `a` | `href`, `title` |
| `img` | `src`, `alt`, `width`, `height` |
| `table` | `width`, `cellpadding`, `cellspacing`, `border`, `align`, `bgcolor`, `role` |
| `tr` | `align`, `bgcolor` |
| `td`, `th` | `width`, `colspan`, `rowspan`, `align`, `bgcolor` |

CSS properties inside `style`:

```
background  background-color  border  border-bottom  border-left
border-radius  border-right  border-top  color  display
font-family  font-size  font-weight  letter-spacing  line-height
margin  margin-bottom  margin-left  margin-right  margin-top
max-width  padding  padding-bottom  padding-left  padding-right
padding-top  text-align  text-decoration  width
```

Frequently attempted and **not** allowed: `class`, `id`, `valign`, `height` as
an attribute or property, `border-collapse`, `overflow`, `max-height`,
`text-transform`, `vertical-align`, and `mso-*`. There is no CSS file and no
`<head>`; style every element inline. Write uppercase text literally, since
`text-transform` will not survive.

`<style>` deserves its own warning. The tag is removed but **its contents are
kept as body text**, so `<style>p{color:red}</style>` renders the literal text
`p{color:red}` at the top of the email. Never send a `<style>` block.

URL protocols are limited to **`https`** and **`mailto`**. A rejected protocol
costs the attribute, not the tag, and the damage is quiet:

- `<a href="http://…">` keeps its text and loses its `href`, leaving dead text;
- `<img src="http://…">` loses its `src` and renders as a broken image.

Both appear under `stripped.protocols`. Relative URLs like `/posts/x` survive
sanitizing but are meaningless in an inbox, and nothing will warn you. Always
write absolute `https://` URLs.

### Hard rejections

These do not strip quietly. They fail the whole request with `422`:

- tags `script`, `iframe`, `object`, `embed`, `form`, `input`, `button`, `svg`,
  `math`, `link`, `meta`;
- any `on…=` handler, `javascript:`, `data:text/html`, `expression(`;
- **`url(` anywhere**. No CSS background images, no web fonts. Images must be
  `<img src="https://…">`;
- a subject or preheader containing `<` or `>`;
- HTML without `{{ unsubscribe_url }}`;
- `{{ unsubscribe_url }}` inside the subject or preheader;
- any token outside `first_name`, `last_name`, `company`, `email`,
  `unsubscribe_url`.

### House email structure

Use this skeleton for newsletters. It is what the current production
newsletters use, it sanitizes losslessly, and it renders correctly in Gmail,
Apple Mail, and Outlook. Substitute the workspace's palette from the table
below.

**Start from this skeleton, not from an existing template.** Copying the HTML
of last week's newsletter is the obvious shortcut and it is how the house
format gets lost: templates in a workspace predate this section, so the most
recent thing in there is not necessarily the most correct thing. It has already
happened once -- a newsletter was rebuilt on the format, then the following
week's was started from the older layout and arrived with no header band,
outlined boxes instead of buttons, and none of the brand palette. Nothing
flagged it, because `stripped` was empty: every one of those choices is valid
markup. `stripped` polices the allowlist. It has no opinion on whether the
email looks like the brand.

Send the inbox preview line as the `preheader` request field. It is injected
above this markup as a hidden block; do not write that block yourself.

```html
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" bgcolor="PAGE_BG" style="width:100%;margin:0;padding:0;background:PAGE_BG;">
<tr><td align="center" style="padding:24px 10px;">
  <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" bgcolor="#ffffff" style="width:100%;max-width:600px;background:#ffffff;border:1px solid CARD_BORDER;border-radius:10px;">
    <tr><td bgcolor="HEADER_BG" style="padding:26px 28px 22px;background:HEADER_BG;font-family:Arial,Helvetica,sans-serif;color:#ffffff;">
      <p style="margin:0 0 8px;font-size:12px;line-height:1.4;letter-spacing:1.2px;font-weight:bold;color:EYEBROW;">BRAND · ISSUE</p>
      <p style="margin:0;font-size:25px;line-height:1.25;font-weight:bold;color:#ffffff;">Headline</p>
    </td></tr>
    <tr><td style="padding:30px 28px 24px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.65;color:#1a1a1a;">
      <p style="margin:0 0 18px;">Hi {{ first_name }},</p>
      <p style="margin:0 0 18px;">Body paragraph.</p>
      <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:26px 0;">
        <tr><td bgcolor="BUTTON_BG" align="center" style="background:BUTTON_BG;border-radius:6px;">
          <a href="https://example.com/" style="display:inline-block;padding:14px 28px;font-family:Arial,Helvetica,sans-serif;font-size:16px;font-weight:bold;color:BUTTON_TEXT;text-decoration:none;">Call to action</a>
        </td></tr>
      </table>
      <p style="margin:0;">Reply and tell me.</p>
      <p style="margin:22px 0 0;">Signature</p>
    </td></tr>
    <tr><td bgcolor="FOOTER_BG" style="padding:20px 28px;background:FOOTER_BG;border-top:1px solid CARD_BORDER;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:1.5;color:#4b5563;">
      <a href="{{ unsubscribe_url }}" style="color:#4b5563;text-decoration:underline;">Unsubscribe</a>
    </td></tr>
  </table>
</td></tr>
</table>
```

Rules that skeleton encodes, and that reviewers check:

1. **Buttons are solid, never outlined.** Put the fill on the `<td>` as both
   `bgcolor` and `background`, and give the `<a>` `display:inline-block`,
   padding, and `text-decoration:none`. A bordered box wrapping an underlined
   link reads as a warning callout, not a button.
2. **Every email has a header band** with an eyebrow and a headline. An email
   that opens on a bare paragraph looks unfinished.
3. **The footer must differ from the page background.** If they match, the card
   dissolves into the page and looks broken.
4. **One accent colour per brand.** Do not mix a grey CTA, an orange CTA, and a
   third link colour in one email.
5. **Send a `preheader`.** It is the inbox preview line beside the subject.
   The platform injects it into the body as a hidden block at send and preview
   time, so you do not need to write that block yourself; the skeleton shows it
   only so you can see where it lands. Omit the field and the inbox falls back
   to whatever text the layout opens with, which is the eyebrow and "Hi there,".
   If you do write your own hidden block at the top of the body, the platform
   leaves it alone rather than adding a second one.
6. **Link to public canonical URLs.** Do not route readers through preview or
   redirect hosts when the destination has a public address.
7. **Plain text is not optional.** Send `plain_text` mirroring the HTML with
   full URLs written out. Omit it and one is generated from the markup.
8. **Widths:** outer table `100%`, card `max-width:600px`.

### Workspace branding

Sampled from each brand's own published material. Use the row for the workspace
you are posting to. Ask before inventing colours for a workspace not listed
here; this table grows as workspaces are enabled.

**`gfa-community`** — Global From Anywhere / Global From Asia, from the GFA
brand book:

| Slot | Value |
| --- | --- |
| `PAGE_BG` | `#d2d0d2` |
| `CARD_BORDER` | `#c4c2c4` |
| `HEADER_BG` | `#124e2c` dark green |
| `EYEBROW` | `#29b973` light green |
| `BUTTON_BG` | `#15689e` blue |
| `BUTTON_TEXT` | `#ffffff` |
| `FOOTER_BG` | `#eeeeee` |
| Accent for sub-headings | `#15689e` |

Full logo files: `http://resources.globalfromasia.com/logos.html`.

**`mikes-blog`** — Mike's Blog, sampled from mikesblog.com:

| Slot | Value |
| --- | --- |
| `PAGE_BG` | `#faf6ee` cream |
| `CARD_BORDER` | `#e7dfcd` |
| `HEADER_BG` | `#1a1a1a` ink |
| `EYEBROW` | `#ffffff` |
| `BUTTON_BG` | `#f2c12e` gold |
| `BUTTON_TEXT` | `#1a1a1a` |
| `FOOTER_BG` | `#f1eada` |
| Accent for inline links | `#1a1a1a` bold |

Mike's Blog gold is too light for white text. Keep button text ink.

**`skyinclude`** — SkyInclude, from the site's own tokens in the skyinclude
repo. The site is dark with neon accents; email is not, so the palette is
carried on the header band and the button rather than by inverting the card:

| Slot | Value |
| --- | --- |
| `PAGE_BG` | `#e2e7ea` |
| `CARD_BORDER` | `#d3d9dc` |
| `HEADER_BG` | `#0a0a0f` near-black |
| `EYEBROW` | `#00ff88` neon green |
| `BUTTON_BG` | `#00ff88` neon green |
| `BUTTON_TEXT` | `#0a0a0f` |
| `FOOTER_BG` | `#f2f5f6` |
| Accent for headings and inline links | `#0b6b46` deep green |

Two rules this palette has to keep. The neon green is a **fill only**: as text
on white it measures 1.3:1 and is unreadable, which is why headings and links
use the deep green at 6.6:1 instead. And the button takes near-black text, not
white -- white on neon green is also 1.3:1. On the near-black band the neon
green reads at 14.7:1 and the white headline at 19.8:1.

Body text is `#1a1a1a` and secondary text `#4b5563` in every workspace. Keep
white for the card body; the palette belongs on the page, header, button, and
footer.

### Before you hand a version over

- `validation.stripped` is empty on all three lists;
- `validation.missing_defaults` is empty;
- `validation.links` matches the number of links you intended;
- every `href` is `https://` or `{{ unsubscribe_url }}`;
- you rendered the preview and read it, rather than assuming.

## MCP: tool access for an agent that speaks it

Everything above and below is the REST API. There is a second surface for an
agent that speaks the Model Context Protocol, and it is a different door with
a different permission unit -- read `/llms.txt` before calling it, which is
the agent-facing map of that surface (the tool list, the permission model, and
what the first call should be).

- Endpoint: `POST https://connect.heliumrises.com/mcp`, JSON-RPC 2.0.
  `initialize`, `ping`, `tools/list`, `tools/call`.
- Two ways in, both a bearer on `Authorization`. An **OAuth 2.1 connector**
  (claude.ai, Claude Desktop, Claude Code: add the server URL as a custom
  connector, sign in with GFAVIP Wallet, approve the consent screen), or the
  same **GFAVIP Wallet bearer** a headless agent already uses for the REST API
  above. Neither widens anything: both resolve to one operator, and every
  workspace-scoped call is checked against that operator's own membership.

Eleven tools. A tool your roles do not reach is absent from `tools/list`, and
calling it anyway returns the same `Unknown tool` error as a name that does
not exist:

- no workspace needed: `heliumrises_whoami`, `heliumrises_list_workspaces`
- `analyst` and up: `heliumrises_list_audiences`
- `editor` and up: `heliumrises_create_audience`
- `brand_admin` / `owner`: `heliumrises_list_sources`,
  `heliumrises_create_source`, `heliumrises_set_source_origins`,
  `heliumrises_grant_access`
- platform owner: `heliumrises_create_organization`,
  `heliumrises_create_workspace`, `heliumrises_set_platform_role`

Call `heliumrises_whoami` first. A new agent identity belongs to no workspace
and can do nothing else until a human grants it access; `heliumrises_whoami`
names the exact page and form to ask for.

Sending, scheduling, and issuing a subscription source's static credential are
not exposed over MCP, exactly as they are not exposed over the REST API.

## Authentication and authorization rules

An agent authenticates as its own GFAVIP identity. Its owner or squad context does
not grant permissions in HeliumRises Connect.

1. `POST https://powerlobster.com/api/agent/identity-token` with the agent API key.
2. `POST https://wallet.gfavip.com/api/auth/powerlobster` with the identity token.
3. Use the returned Wallet `sso_token` as a Connect bearer token.
4. Connect validates it server-to-server with Wallet.
5. Connect applies its own explicit workspace membership and role.

All private API requests require:

```http
Authorization: Bearer <GFAVIP_SSO_TOKEN>
Accept: application/json
```

Human browser sessions may also call the API. A mutating request authenticated by
session cookie additionally requires `X-CSRF-Token`. Bearer-authenticated requests
do not use CSRF tokens.

A third path exists for server-to-server callers with no browser session and no
Wallet bearer token of their own (GFA Console today): `X-API-Key` plus `X-User-ID`
naming the GFAVIP user being acted for. This key is not a substitute for a bearer
token -- it is scoped to the `edit_content` capability only, and every other
capability refuses it regardless of the named user's actual role. It exists for
one caller's authoring integration, not for general agent use; an agent should
authenticate as itself with a Wallet bearer token as described above. A request
to an unknown path under `/api/v1` from a service-keyed caller returns `403`,
not `404` -- a typo'd URL reads as a scope error, so check the path carefully
before assuming a route is closed to this key.

A fourth path exists for an application that provisions its own subscription
source on an operator's behalf (MemoUpdate today): a Wallet-issued **delegated
app grant token**, sent as the bearer on `Authorization` instead of an
`sso_token`. It is an RS256 JWT with audience `connect`, verified locally
against Connect's configured Wallet public keys, so no Wallet call is made and
a Wallet outage does not stall it.

- Scope: `connect:workspace:<workspace_id>:manage_sources`. The workspace id is
  the `id` from `GET /api/v1/organizations/{organization_slug}/workspaces/{workspace_slug}`
  -- never a slug, which is unique only inside one organization.
- The token must carry `acting_user_id`. A *service* grant names no acting user
  and is refused with `401`. An `acting_user_id` who has never signed in to
  Connect is refused with `403`: a signed claim does not create an operator.
- The grant narrows and never widens. The acting user's own workspace
  membership and role are checked first, so a role without
  `manage_subscription_sources` is still refused `403`, exactly as a browser
  session would be.
- It reaches exactly four operations, and nothing else:
  list subscription sources, create a subscription source, replace a source's
  allowed origins, and list the workspace's audiences. Every other route under
  `/api/v1` -- `/api/v1/me` and `/api/v1/workspaces` included -- answers `403`
  to this credential, even where the acting user's own role would allow it.
- No CSRF token applies, as with any bearer-authenticated request.

This is not the credential an agent should hold. An agent authenticates as
itself with a Wallet bearer token as described above; a delegated grant exists
for one application doing one provisioning job for a named person.

An unassigned workspace is intentionally returned as `404`. Capabilities are
derived from the caller's local role, never GFAVIP tier, human owner, or
PowerLobster squad.

## Roles and capabilities

| Capability | owner | brand_admin | editor | analyst |
|---|:---:|:---:|:---:|:---:|
| `view` | yes | yes | yes | yes |
| `upsert_contacts` | yes | yes | yes | no |
| `delete_contacts` | yes | yes | no | no |
| `approve_imports` | yes | yes | no | no |
| `edit_content` | yes | yes | yes | no |
| `send_test` | yes | yes | yes | no |
| `schedule_campaign` | yes | yes | yes | no |
| `manage_senders` | yes | yes | no | no |
| `pause_delivery` | yes | yes | no | no |
| `resolve_suppressions` | yes | yes | no | no |
| `manage_subscription_sources` | yes | yes | no | no |
| `manage_team_roles` | yes | yes | no | no |
| `manage_audiences` | yes | yes | no | no |
| `edit_automations` | yes | yes | yes | no |
| `generate_ai_content` | planned API | planned API | planned API | no |

Archived workspaces remove `send_test`, `schedule_campaign`, and `pause_delivery`
even when the role normally has them.

`manage_audiences`, `manage_team_roles` and `delete_contacts` are real and
enforced, but none has an API route today: all three gate console operations
only. Holding them changes nothing about what a bearer token can do.

`delete_contacts` permanently removes a contact and is deliberately narrower
than `upsert_contacts`, which an editor holds. It is refused outright while the
contact has any campaign recipient, delivery record, message event, automation
send, automation enrollment or suppression -- so a contact anything was ever
sent to cannot be deleted at all, only suppressed. The database enforces the
same rule independently: `consent_events` stays append-only, and its DELETE is
permitted only for a contact with none of that send history (migration
20260921_0039).

`manage_subscription_sources` gates listing a workspace's subscription
sources, creating one, and replacing a source's allowed origins — all three
have an API route, listed in "Provisioning" under "Subscription sources"
below. It still gates issuing, rotating and revoking a source's credential,
which remain console-only. It does not gate the two subscription-source
ingestion endpoints in "Subscription sources" below — those authenticate the
source itself, not this capability or any operator role.

Some operations need **workspace ownership on top of** a capability, so a
`brand_admin` holding every capability above is still refused: activating a
sender identity, activating workspace sending, and running a synthetic
rehearsal, which also needs platform ownership. Renaming or merging a tag is
gated on workspace ownership alone, with no capability involved — and it is
console-only.

## What the API cannot do

These exist in the product and have **no API route**. An agent should not
attempt them, and should tell its operator that a human in the console is
required rather than retrying or working around them.

| Operation | Where it lives | Why it is not on the API |
|---|---|---|
| Publish or pause an automation | Console | Making a sequence live is a human decision |
| Register, apply, remove, rename or merge a tag | Console | Bulk tagging is an automation trigger; rename is a workspace-owner operation |
| Create a multi-audience or tag-filtered campaign draft | Console | The API writes single-audience drafts only; see below |
| Delete an audience or an unsent campaign | Console | `manage_audiences`, plus reference checks |
| Edit or delete a sending domain or sender identity | Console | `manage_senders`, plus reference checks |
| Change a platform gate | Console, platform owner | Kill switches are not automatable |
| Read or acknowledge an operational alert | Console, platform owner | Acknowledgement is a person claiming a problem; an agent cannot make that claim |
| Read the platform audit log | Console, platform owner | It records what agents did, among others |
| Add an operator or change a role | Console | `manage_team_roles` |
| Turn workspace sending off | Console | Deliberately a human action |
| Issue, rotate or revoke a subscription source's credential | Console | `manage_subscription_sources` |

**Campaign draft targeting.** `POST /campaign-drafts` and
`POST /campaign-drafts/{id}/versions` accept a single `audience_id` and cannot
set a tag filter. Both return the full shape:

```json
{"draft": {"audience_id": "aud_1", "audience_ids": ["aud_1"], "...": "..."}}
```

`audience_ids` is always the authoritative list. `audience_id` is a convenience
that is populated only when the draft targets exactly one audience and is
**`null` for a multi-audience draft** — which the console can create and the API
can read. Read `audience_ids`; treat `audience_id` as legacy.

## Subscription sources

A subscription source lets an external application — MemoUpdate forms today — add
contacts to audiences in one workspace: either an explicit set of them, or every
live audience the workspace has, including ones added later. Two endpoints serve
it.

**These authenticate differently from everything else in this guide.** They
identify the *source*, not an operator, and there are two ways to do it. Both
are a bearer token in the `Authorization` header.

1. **A static source credential**, which looks like `hrs_src_…` and is issued
   per source in the console. It carries no scope of its own: whatever the
   source itself is configured to allow is the only limit on what it can reach.
2. **A Wallet-issued grant token**, whose scope names the source and carries the
   permission the endpoint requires. The scope is
   `connect:source:<source-id>:<permissions>`, where `<source-id>` is the
   source's UUID — never its slug, which is unique only within one workspace —
   and `<permissions>` is one or more permission names joined by `-`. Use
   `events.write` to ingest and `audiences.read` to list audiences; a grant
   carrying `audiences.read-events.write` does both, which is what a caller that
   renders an audience picker and then delivers to it needs. A grant naming only
   one of them is refused by the other endpoint.

Your operator bearer token does **not** work on either endpoint, and neither of
these works anywhere else in this API. Required capability for both: an
authenticated source (not an operator capability) — neither is gated by
`ROLE_CAPABILITIES`, so they do not appear in "Implemented endpoint/capability
matrix" below, which covers Wallet-bearer routes only.

- `POST /api/v1/organizations/<org>/workspaces/<ws>/subscription-sources/<slug>/events`
  — submit one consented contact. The caller asserts consent; Connect records
  the claim and does not re-verify it. Idempotent on the caller's key, so a
  repeat returns the stored receipt rather than subscribing anyone twice.
  The body is `application/json` and its top-level keys are a closed set of
  exactly eight, all required: `schema_version`
  (`"connect.subscription-source-event.v1"`), `source_event_id` (UUIDv4),
  `destination_audience_id` (UUIDv4), `contact`, `consent_granted` (must be
  `true`), `captured_at` (ISO-8601 with a timezone, within now+5 minutes and
  now-30 days), `consent`, and `source`. Any extra top-level key is a `422`.
  `Idempotency-Key` is a required header matching `^[^\s]{12,200}$`.
  - `contact`: `email` required and already normalized; optional `first_name`,
    `last_name` (≤160), `locale` (≤40).
  - `consent`: exactly `wording_id` (1-160), `wording_sha256` (64 lowercase
    hex), `privacy_policy_url` (HTTPS, ≤2048), `privacy_policy_version`
    (1-160). No optional consent field exists.
  - `source`: `form_id`, `form_version`, `page_origin` and
    `evidence_schema_version` are required. `evidence_schema_version` is one
    of `"memoupdate.newsletter-consent-capture.v1"` (a MemoUpdate newsletter
    capture) or `"connect.account-signup-consent.v1"` (a first-party site
    recording that someone created an account under a privacy notice that
    covered email) — name the shape of evidence you actually hold.
    `page_origin` is an HTTPS **origin** — no path, query or fragment — and
    must already be in the source's allowed origins. Optional:
    `popup_campaign_id` (UUIDv4) and `popup_version` (integer ≥1), which must
    be sent together or not at all; `page_path`; and `attribution`.
  - `source.page_path` (optional): the path the visitor was on. 1-512
    characters, must start with `/`, must not begin with `//` or `/\`, and
    must not contain `?`, `#`, whitespace, or a control character. Path
    only — never the query, the fragment, or the host. Absent means unknown.
  - `source.attribution` (optional): an object whose keys are a non-empty
    subset of `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`,
    `utm_term`, `ref`. Each value is a string of 1-200 characters with no
    control characters and no `@`. An empty object, an unknown key, or an
    email-shaped value is refused. Only `ref` is a referral code. Connect
    records these as the caller's assertion and never verifies them, so do
    not send an email address, a session identifier, or anything else you
    would not want an operator to read on a contact page.
  - A caller that sends neither `page_path` nor `attribution` is unaffected by
    their existence: the version strings do not change, and the stored record
    is identical to what it was before they were added.
  - Refusals are `422` for the whole event. Nothing is truncated or dropped
    server-side, so a `422` means fix the payload, not retry it.
- `GET /api/v1/organizations/<org>/workspaces/<ws>/subscription-sources/<slug>/audiences`
  — list the audiences this source is allowed to write to. Never includes an
  archived audience, and never one from another workspace.

A source allows audiences in one of two ways. Most name an explicit set, and an
audience outside that set is refused even if the caller names it. A source
created with `allows_all_audiences` may write to any audience in its own
workspace, and names no set at all. Both refuse an archived audience, an
audience belonging to another workspace, and an audience that does not exist —
all three with the same message, so the endpoint cannot be used to discover
which is which.

An origin outside the source's allowed origins is refused regardless of what the
calling application stores, in either mode.

### Provisioning

Listing and creating a source, listing a workspace's audiences, and replacing a
source's allowed origins are available to an operator holding
`manage_subscription_sources` — a workspace `owner` or `brand_admin` — through
these routes:

- `GET /api/v1/organizations/<org>/workspaces/<ws>/audiences` — capability
  `view`. Archived audiences are listed and flagged, not hidden.
- `GET /api/v1/organizations/<org>/workspaces/<ws>/subscription-sources`
- `POST /api/v1/organizations/<org>/workspaces/<ws>/subscription-sources` —
  returns the new source's **id**, which is what a grant scope names. Choose how
  the source allows audiences: send `allowed_audience_ids` with at least one live
  audience in this workspace, or send `"allows_all_audiences": true` and no
  audience ids, which lets the source write to any live audience in its own
  workspace including ones created later. Sending a true flag together with a
  non-empty `allowed_audience_ids` is a `422`; so is an empty
  `allowed_audience_ids` without the flag, since a source that can write nowhere
  is what `suspended` is for. Every source in every response reports its mode as
  `allows_all_audiences`.
- `PATCH /api/v1/organizations/<org>/workspaces/<ws>/subscription-sources/<slug>/origins`
  — the complete origin list, never a delta.

**Issuing, rotating and revoking a credential are console-only**, and no API is
planned: that flow shows a secret exactly once, which is not a shape an
unattended caller should drive. A source created through the API is expected to
hold no static credential at all and to authenticate with a grant instead.

Grants are not issued here either. A grant is created in GFAVIP Wallet by the
calling application, against a scope naming a source this workspace owns.
Connect only verifies the token it is presented with; it never mints one.

## Implemented endpoint/capability matrix

All routes below are under
`/api/v1/organizations/{organization}/workspaces/{workspace}` unless shown as
absolute.

| Method and route | Required capability |
|---|---|
| `GET /api/v1/me` | authenticated |
| `GET /api/v1/workspaces` | authenticated |
| `GET /` | `view` |
| `POST /contacts/upsert` | `upsert_contacts` |
| `POST /contact-field-definitions` | `upsert_contacts` |
| `GET /contacts/lookup` | `view` |
| `GET /audiences` | `view` |
| `GET /subscription-sources` | `manage_subscription_sources` |
| `POST /subscription-sources` | `manage_subscription_sources` |
| `PATCH /subscription-sources/{source_slug}/origins` | `manage_subscription_sources` |
| `POST /audiences` | `upsert_contacts` |
| `GET /audiences/{audience_id}` | `view` |
| `POST /audiences/{audience_id}/archive` | `upsert_contacts` |
| `POST /audiences/{audience_id}/subscribe` | `upsert_contacts` |
| `POST /audiences/{audience_id}/unsubscribe` | `upsert_contacts` |
| `POST /contacts/{contact_id}/suppress` | `resolve_suppressions` |
| `POST /suppressions/{suppression_id}/resolve` | `resolve_suppressions` |
| `GET /contacts/{contact_id}/eligibility/{audience_id}` | `view` |
| `POST /contacts/{contact_id}/preference-token` | `upsert_contacts` |
| `GET /imports/csv` | `view` |
| `POST /imports/csv` | `upsert_contacts` |
| `GET /imports/csv/{job_id}` | `view` |
| `PATCH /imports/csv/{job_id}/mapping` | `upsert_contacts` |
| `POST /imports/csv/{job_id}/approve` | `approve_imports` |
| `POST /imports/csv/{job_id}/retry` | `upsert_contacts` |
| `POST /imports/csv/{job_id}/cancel` | `upsert_contacts` |
| `GET /imports/csv-mapping-profiles` | `view` |
| `GET /automations` | `view` |
| `POST /automations` | `edit_automations` |
| `GET /automations/{automation_id}` | `view` |
| `GET /automations/{automation_id}/versions/{version_id}` | `view` |
| `POST /automations/{automation_id}/versions` | `edit_automations` |
| `POST /automations/{automation_id}/ready-for-review` | `edit_automations` |
| `POST /automations/{automation_id}/archive` | `edit_automations` |
| `POST /automations/{automation_id}/restore` | `edit_automations` |
| `GET /automations/{automation_id}/enrollments` | `view` |
| `GET /automation-enrollments/{enrollment_id}` | `view` |
| `POST /automations/{automation_id}/versions/{version_id}/synthetic-enrollments` | `edit_automations` |
| `POST /automation-enrollments/{enrollment_id}/{pause\|resume\|cancel\|replay}` | `pause_delivery` |
| `POST /sending-domains` | `manage_senders` |
| `POST /sending-domains/{domain_id}/verify` | `manage_senders` |
| `POST /sending-domains/{domain_id}/register-webhooks` | `manage_senders` |
| `POST /sender-identities` | `manage_senders` |
| `POST /sender-identities/{sender_id}/activate` | `manage_senders` + platform/workspace owner |
| `POST /activate-sending` | `manage_senders` + platform/workspace owner |
| `POST /email-templates` | `edit_content` |
| `POST /email-templates/{template_id}/archive` | `edit_content` |
| `POST /email-templates/{template_id}/versions` | `edit_content` |
| `POST /campaign-drafts` | `edit_content` |
| `POST /campaign-drafts/{campaign_id}/versions` | `edit_content` |
| `POST /test-sends` | `send_test` |
| `POST /rehearsals` | `send_test` |
| `GET /rehearsals/{rehearsal_id}` | `view` |
| `POST /campaign-drafts/{campaign_id}/schedule` | `schedule_campaign` |
| `GET /campaign-runs/{run_id}` | `view` |
| `GET /campaign-runs/{run_id}/report` | `view` |
| `POST /campaign-runs/{run_id}/unschedule` | `schedule_campaign` |
| `POST /campaign-runs/{run_id}/{pause\|resume\|cancel}` | `pause_delivery`, or `schedule_campaign` for a run you scheduled yourself |
| `POST /campaign-webhook` | `manage_senders` |
| `POST /provider-events/{event_id}/replay` | `pause_delivery` |
| `GET /operations/preflight` | `view` |
| `POST /operations/preflight` | `manage_senders` |
| `POST /operations/synthetic-rehearsals` | `manage_senders` + platform/workspace owner |
| `GET /operations/synthetic-rehearsals/{run_id}` | `view` |

`POST /audiences/{audience_id}/subscribe` returns `422` when an unresolved
suppression blocks the contact -- platform-wide, scoped to this workspace, or
scoped to this audience -- and names the blocking scope and reason in the
error. There is no way to override this. Resolve the suppression first via
`POST /suppressions/{suppression_id}/resolve` (`resolve_suppressions`), then
subscribe again.

`GET /campaign-runs/{run_id}/report` returns per-recipient outcome fields alongside the
original raw-event fields. Under normal operation, each accepted recipient falls into
exactly one of `delivered`, `hard_bounced`, `soft_failed_only`, or `no_terminal_event`,
and those four sum to `sent`. A recipient that was delivered and later hard bounced counts only as
`hard_bounced`. `unsubscribes_attributed` is inferred from suppression timing and is
null when the run never started. `delivery_rate` and `bounce_rate` are percentages and
are null when `sent` is zero. The original `events` map remains a raw provider event
count and may exceed `sent`.

See `/openapi.json` for exact request fields, response schemas, status codes,
idempotency rules, and operation-level safety metadata.

## Stopping a send you started

`pause_delivery` is `brand_admin` and up and carries every run in the
workspace. An `editor` holds `schedule_campaign` but not `pause_delivery`, so
an editor -- which is the role most agent identities hold -- can book a real
send. That editor may also pause, resume and cancel **the runs they scheduled
themselves**, matched on the run's `approved_by_gfavip_id`, and no others.

A run somebody else scheduled answers `403` with
`reason="not_the_scheduling_operator"` in the audit row. Ask a workspace admin
rather than retrying.

Cancel is terminal: the campaign goes to `cancelled`, it cannot return to
draft, and anyone already sent to has been sent to. Use
`POST /campaign-runs/{run_id}/unschedule` instead while the run is still
`scheduled` and nothing has gone out; that returns an editable draft.

See `docs/DECISION_2026-09-29_RUN_STOP_AUTHORITY.md`.

## Platform gates move underneath you

Five platform switches used to be environment variables that only changed on a
redeploy. They are now database rows a platform owner flips from the console,
and each resolves as:

```
effective = environment ceiling AND stored row
```

Enabling needs both. Disabling needs either. Turning one **off** always works
and bites within about a minute, because workers refresh once per loop and web
requests cache only for the life of one request.

| Gate | Ceiling variable | What closing it stops |
|---|---|---|
| `rate_limit_enabled` | none | Nothing; it removes the cap on public endpoints |
| `automation_enabled` | none | New enrollments and every step in flight |
| `production_sending_enabled` | `ALLOW_PRODUCTION_SENDING_ACTIVATION` | All real campaign and automation delivery |
| `test_sends_enabled` | `TEST_SENDS_ENABLED` | Test sends |
| `synthetic_rehearsals_enabled` | `SYNTHETIC_REHEARSALS_ENABLED` | Synthetic capacity rehearsals |

What this means for an agent:

- **A gate can close between two of your calls.** A `422` on a send-adjacent
  operation may simply mean a human just shut something off. Do not retry in a
  loop; surface the refusal and stop.
- **The reverse is also true.** An operation that failed a moment ago may
  succeed now. Never cache a gate decision across a run, and never infer that a
  gate is open because an earlier call worked.
- **The gates are not readable through the API.** Their state is visible only in
  the console at `/admin/settings`. Treat the refusal message on a failed call
  as the authority.
- **You cannot change one.** There is no API route, by design.

## Safety gates: what can send

| Action | What it does | Mailgun/provider contact | Recipient contact |
|---|---|:---:|:---:|
| Preview | Renders sanitized content in the authenticated console | no | no |
| Eligibility check | Evaluates consent, suppressions, membership, frequency, duplicate, archive, and sending gates | no | no |
| Draft/template authoring | Writes immutable database versions | no | no |
| Provider check | Tests the configured Mailgun credential reference | yes, read/check only | no |
| Domain verification | Reads DNS and Mailgun domain status | yes, read/check only | no |
| Test send | Queues one email to the address given, within the workspace caps | yes | one typed recipient |
| Controlled rehearsal | Runs one gated test send and reconciles signed provider events | yes | one typed recipient |
| Synthetic rehearsal | Exercises 100–10,000 synthetic recipients in an isolated transaction and rolls operational rows back | no; asserted zero | no |
| CSV upload | Encrypts a protected UTF-8 CSV and stores no row values in responses | no | no |
| CSV dry run | Validates, deduplicates, reports conflicts, and calculates suppression impact with zero contact writes | no | no |
| CSV apply | After independent checksum-bound approval, writes contacts/consent/suppressions with suppressions first | no | no |
| Automation authoring | Validates and stores immutable triggers, nodes, edges, dependencies, and previews | no | no |
| Automation ready for review | Records a checksum-bound authoring milestone only; it is not activation | no | no |
| Automation runtime | Stores durable enrollments, waits, attempts, events, retries and replay for published automations | yes, through the automation send worker | yes |
| Audience subscribe | Records consent and membership — **and fires `audience_subscribed`**, so it can enroll into a published automation | not during the HTTP request | later, if an automation is published |
| Tag apply or remove (console) | Bulk tag change across resolved audiences — **fires `tag_added`/`tag_removed`** | not during the request | later, if an automation is published |
| Contact upsert with `tags` | Writes `tags_json` directly; fires no trigger | no | no |
| Audience, campaign, sender, domain or connection delete (console) | Removes the record after refusing while anything still references it | no | no |
| Platform gate change (console) | Flips one switch platform-wide within about a minute | no | no; but closing one stops delivery |
| Phase 5E automation rehearsal | Uses a disposable local database and simulated clock to verify the synthetic runtime, restart recovery, controls, eligibility, and zero-call safety; hard-blocked in production | no; asserted zero | no |
| Protected reconstruction inspect | Verifies the approved encrypted handoff and emits a redacted aggregate report; non-production CLI only | no | no |
| Protected reconstruction synthetic apply | Creates immutable review drafts in a disposable non-production database; hard-blocked in production | no | no |
| Approved production draft load | After Mike and Manly approval, writes nine immutable review templates and one non-executable automation draft only; GFA owner CLI, checksum and exact confirmation required | no | no |
| Campaign scheduling | Creates an immutable snapshot and durable scheduled run only if all production gates are open | not during the HTTP request | later, only through worker |
| Production sending | Worker prepares centrally eligible recipients and sends idempotently | yes | eligible campaign recipients |

### Test-send gates

Every test send requires:

- `send_test`;
- `TEST_SENDS_ENABLED=true` as the ceiling, plus Test sends turned on in Platform settings;
- a 12–200 character `Idempotency-Key`;
- a sender identity on a domain with a current passing live check;
- a verified and active provider connection;
- the workspace's hourly cap (`TEST_SEND_MAX_PER_HOUR`, default 10) and daily cap
  (`TEST_SEND_MAX_PER_DAY`, default 50) not yet reached.

There is no recipient allowlist and no confirmation phrase. The recipient is whatever
address the request names.

### Controlled-rehearsal gates

A controlled rehearsal has every test-send gate plus:

- exact confirmation `RUN CONTROLLED REHEARSAL`;
- its own `Idempotency-Key`;
- signed/deduplicated Mailgun events for reconciliation.

### Synthetic-rehearsal gates

A synthetic rehearsal requires:

- `manage_senders`;
- both platform owner and workspace owner;
- `SYNTHETIC_REHEARSALS_ENABLED=true` as the ceiling, plus Synthetic rehearsals turned on in
  Platform settings;
- 100–10,000 recipients;
- body `idempotency_key` of 12–200 characters;
- exact confirmation `RUN {recipient_count} SYNTHETIC REHEARSAL`.

It uses `.invalid` addresses, no subscriber data, no Mailgun adapter, zero provider
calls, and rolls back all synthetic operational data while retaining only the
report and audit evidence.

### Campaign scheduling and production gates

Scheduling requires `schedule_campaign`, a timezone-aware future time, an
`Idempotency-Key`, a draft campaign, non-archived audience/content, and a ready
sender. It also fails closed unless:

- application activation gate is open (`ALLOW_PRODUCTION_SENDING_ACTIVATION=true`);
- global production sending is enabled -- its own switch in Platform settings,
  behind the activation gate above as its ceiling;
- workspace production sending is enabled;
- provider is verified and active;
- domain has a current passing verification;
- sender is explicitly enabled.

At delivery time, every recipient still passes centralized eligibility. Current
production marketing sending remains disabled until explicit owner approval.

## Empty-workspace onboarding checklist

Complete in this order. The Mailgun credential is platform-wide; a platform
owner sets and enables it once in Platform settings.

0. List published automations (`GET /automations`, filter `status == "published"`).
   In an empty workspace this is empty and step 3 is inert. In a workspace that
   already runs sequences, subscribing contacts starts them.
1. Confirm the agent appears in `GET /api/v1/me` with the intended workspace role.
2. Create or import contacts with provenance.
   For CSV, upload → map → dry run → owner/admin approval → reconcile; never
   treat file presence as consent.
3. Create an audience and attach only reviewed consent evidence.
4. Review legacy-consent classifications and suppressions.
5. Add the brand-specific sending domain (it must already exist in Mailgun); the app reads its DNS records and DKIM selector from Mailgun. `provider_connection_id` is optional; the workspace's connection is used when omitted.
6. Run live DNS/domain verification. A domain is ready only when all eight Mailgun webhook events post to this workspace; if `webhooks.missing_events` is not empty, call `register-webhooks` (it only adds this workspace's URL and never removes one). If `webhooks.error_code` is set, Mailgun could not be read and `missing_events` does not describe the domain; run verification again rather than register.
7. Create a sender identity; leave production activation closed.
8. Create a template and immutable content version; preview it.
9. Send one test to a reviewer's address.
10. Run and reconcile one controlled rehearsal.
11. Run the isolated synthetic capacity rehearsal if owner-approved.
12. Run launch preflight and resolve every block.
13. Create a campaign draft. The API writes a single-audience draft; ask a human
    to use the console if the send needs several audiences or a tag filter.
14. Do not schedule or enable production sending without the separate explicit
    owner approval. Workspace activation is console-only and requires the
    workspace owner.

## Error and idempotency handling

- Success bodies are JSON.
- Current API error bodies are Flask `text/html`; branch on HTTP status, not body
  parsing.
- Retain the response `X-Correlation-ID` for support and audit lookup.
- Wallet validation outages fail closed with `503`.
- Invalid/expired Wallet tokens return `401`.
- Missing workspace membership is intentionally hidden as `404`.
- Role/owner failures return `403`.
- Validation and safety-gate failures return `422`.
- Uniqueness/source-identity conflicts may return `409`.
- Use the documented `Idempotency-Key` on test sends, controlled rehearsals, and
  scheduling. Synthetic rehearsal uses `idempotency_key` in its JSON body.
- CSV apply is idempotent through its checksum-bound job and per-row ledger.
  Failed worker claims are recovered; changing a mapping or report invalidates
  approval. Cancellation, expiry, and completion permanently destroy the
  encrypted upload payload.

## Upstream identity contract

The authoritative GFAVIP Headless guide is:
`https://wallet.gfavip.com/skill.md`.

Wallet and PowerLobster remain responsible for identity issuance. This service
accepts only the Wallet-issued bearer and remains responsible for local
authorization, consent, suppression, delivery safety, and audit.

## Campaign lifecycle webhooks

This service can POST a small JSON event to a receiver you host, at four
points in a campaign run's lifecycle. This is the only channel a downstream
system has for learning what happened to a run -- there is no polling
endpoint for it.

### The four events

| Event | Fired when |
|---|---|
| `campaign.scheduled` | A campaign run is created in the `scheduled` state. |
| `campaign.completed` | A run reaches `completed` -- either because it had zero eligible recipients, or because every recipient finished with no failures. |
| `campaign.cancelled` | A run is cancelled, or a not-yet-started schedule is withdrawn (both land the run in `cancelled`). |
| `campaign.failed` | A run reaches `failed` -- either because recipient preparation raised an exception, or because at least one recipient failed during sending. |

A run also passes through `preparing`, `ready`, `sending`, and `paused`.
None of those states emit an event. A receiver should not wait for them and
has no way to observe them through this contract.

One exception worth knowing about: if a worker claims a run for preparation
and dies mid-claim, the scheduler's recovery pass snaps the run back to
`scheduled` without re-emitting `campaign.scheduled`. From the receiver's
side this is invisible -- it already has the one `campaign.scheduled` event
for that run and nothing about the run's identity or schedule time changed.

### Payload

Every event is a POST with this exact shape (from
`campaign_webhooks.py::_payload`):

```json
{
  "event": "campaign.completed",
  "event_id": "5f0b6e9b1cf4491ea9d3b8c6a2e7d4f1",
  "organization_slug": "acme",
  "workspace_slug": "marketing",
  "campaign_id": "3f9c1e2a-...",
  "template_id": "9b1e4a7c-...",
  "run_id": "8a2d5f6b-...",
  "state": "completed",
  "name": "August newsletter",
  "subject": "Your August update",
  "timestamp": "2026-08-21T14:32:07.512481+00:00"
}
```

Field notes:

- `event` -- one of the four event names above.
- `event_id` -- see below.
- `organization_slug` / `workspace_slug` -- identify the workspace the run
  belongs to.
- `campaign_id` / `run_id` -- HeliumRises identifiers. There is no route from
  a campaign id back to a run id available to a receiver; keep both if you
  need to reference the run later.
- `template_id` -- the email template the run's content came from. Match
  events on this, not `campaign_id`: an agent creates the template, never the
  campaign, so it is the only identifier an agent-driven receiver actually
  has going in.
- `state` -- the run's state at the moment of the event (`scheduled`,
  `completed`, `cancelled`, or `failed`). It always matches what the event
  name implies.
- `name` / `subject` -- the campaign's frozen name and subject line at the
  version that was scheduled. These come from the run's snapshot, which is a
  required relationship on every run, so in practice they are always
  present. Treat them as nullable in your receiver anyway rather than relying
  on that.
- `timestamp` -- ISO 8601, UTC. For `campaign.scheduled` this is the
  scheduled send time (`scheduled_for`); for `campaign.completed` it is when
  the run finished (`completed_at`); for `campaign.cancelled` and
  `campaign.failed` there is no dedicated column for when that happened, so
  this is simply the time the event was emitted.

Nothing else is sent. No recipient list, no recipient email addresses, no
message content, no per-recipient counts.

### Authentication

If a secret is configured for the workspace, every request carries it as
`X-Webhook-Secret: <secret>`. If no secret is configured, the header is
omitted entirely -- there is no signature scheme layered on top of it, so an
unconfigured secret means an unauthenticated POST. Configure a secret and
reject any request that doesn't carry the expected value.

### Delivery contract

This is not a retrying webhook. What you can rely on:

- An event is sent only after the state change it describes has been
  committed, never as the change is being made. If the transaction that made
  the change rolls back, nothing is sent at all -- so an event you receive is
  always about a run whose new state is durable, and you will never be told
  about a cancellation or a schedule that then failed to land.
- One attempt. No retry, ever, for any status code or error.
- A 5-second connect timeout and a 5-second read timeout, with redirects
  disabled. This is not a total deadline on the call: a receiver that
  accepts the connection and the request but then trickles the response
  slowly, without ever going more than 5 seconds between bytes, can still
  hold the connection open longer than 5 seconds in total.
- Any response the receiver returns -- 2xx, 4xx, 5xx, or none at all (timeout,
  connection refused, DNS failure) -- is treated identically: the attempt is
  logged and the function moves on. Nothing about the response code changes
  future behavior, because there is no future attempt for that event.

**A failed delivery is lost.** If your receiver is down, slow, or returns an
error, that event does not come back. This is a deliberate trade, not an
oversight: an earlier version of this contract retried at 0s/30s/120s
in-process, and three attempts against a dead receiver held up the scheduler
tick for close enough to the 180-second heartbeat threshold that a handful of
stale runs made the sending service report itself unhealthy. Retrying inline
was judged more dangerous to email sending than losing a webhook, so it was
removed.

Practically, this means: if your receiver has downtime, it will miss events
for any campaign run whose lifecycle transition happened during that window,
and there is no automatic way to get them back. Reconciliation is a human
process -- someone on the HeliumRises side would need to look at
`campaign_webhook_deliveries` for your workspace and replay the relevant
events by hand. Build your receiver assuming gaps are possible, and treat an
extended outage on your end as something that needs manual follow-up, not
something the contract will paper over.

### `event_id`

`event_id` is a fresh UUID4 hex string generated once per lifecycle
transition, at the moment the transition is made. Because there is
no retry, it is currently one-to-one with a delivery attempt -- it will not
arrive twice for the same event under normal operation. Dedupe on it anyway.
Nothing in this contract prevents a human replaying a delivery by hand later,
and treating `event_id` as the unique key protects your receiver against that
without extra logic.

### What is never sent

No recipient data (no email addresses, no per-recipient state, no counts) and
no email content (no HTML, no plain text, no subject line beyond the
campaign-level `subject` above). If you need per-recipient outcomes, that is
a separate, deliberately out-of-scope concern for this contract -- it is not
available through these events.

### Configuring it

There is no console UI for this. Configuration happens only through the API
route, and requires the `manage_senders` capability:

```
POST /api/v1/organizations/{organization_slug}/workspaces/{workspace_slug}/campaign-webhook
Content-Type: application/json

{
  "campaign_webhook_url": "https://example.org/webhooks/heliumrises",
  "campaign_webhook_secret": "<a secret you generate>"
}
```

Either field can be sent alone -- a field omitted from the body leaves the
stored value untouched; a field present with an empty string or `null`
clears it. Clearing `campaign_webhook_url` (or never setting it) means the
workspace emits nothing: silence is the default, not an error. The secret is
never returned by any endpoint, including this one -- responses only report
whether a secret is currently set.
