# HeliumRises Connect Agent Guide

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

- 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, Mailgun secret references, DNS/provider readiness, and sender identities;
- versioned templates, sanitized email content, preview, and campaign drafts;
- gated allowlisted test sends 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` enrol 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 Mailgun connections, 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 token
  instead of 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 enrols 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 a non-executable automation draft

Requires `edit_automations`. This writes one immutable workflow version and
makes zero provider or webhook calls. It cannot enroll a contact or execute a
step.

```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": "Non-executable Phase 5B 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,
  authoring_only,
  execution_available,
  activation_available,
  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 enrols 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.

## 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.

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.

Body text is `#1a1a1a` and secondary text `#4b5563` in both workspaces. 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.

## 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.

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 |
| `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` and `manage_team_roles` are real and enforced, but neither has
an API route today: both gate console operations only. Holding them changes
nothing about what a bearer token can do.

`manage_subscription_sources` is the same shape: it gates console provisioning
of subscription sources (create, issue/rotate/revoke credential) and has no API
route. It does not gate the two subscription-source endpoints in "Subscription
sources" below — those check a source token instead, 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 Mailgun connection, 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 |
| Create a subscription source, or issue, rotate or revoke its 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 specific audiences in one workspace. Two endpoints serve it.

**These use a different credential from everything else in this guide.** They
authenticate with a source token that looks like `hrs_src_…`, issued per source
in the console. Your operator bearer token does **not** work on them, and a
source token does not work anywhere else. Required capability for both:
source token (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.
- `GET /api/v1/organizations/<org>/workspaces/<ws>/subscription-sources/<slug>/audiences`
  — list the audiences this source is allowed to write to. Returns only
  non-archived audiences on the source's allowlist.

An audience outside the source's allowlist is refused even if the caller names
it, and an origin outside the source's allowed origins is refused regardless of
what the calling application stores.

### Provisioning is console-only

Creating a source, issuing or rotating its credential, and revoking it are done
in the console by a workspace `owner` or `brand_admin`, under **Subscription
sources**. There is no API for them and none is planned: the issue flow shows a
secret exactly once, which is not a shape an unattended caller should drive.
Do not look for an undocumented endpoint.

## 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` |
| `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}/readiness` | `manage_senders` |
| `POST /sending-domains/{domain_id}/provider` | `manage_senders` |
| `POST /sending-domains/{domain_id}/verify` | `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 /provider-connections` | `manage_senders` |
| `POST /provider-connections/{connection_id}/check` | `manage_senders` |
| `POST /provider-connections/{connection_id}/activate` | `manage_senders` |
| `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` |
| `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.

## 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` | Allowlisted 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 a configured allowlisted address | yes | one allowlisted recipient |
| Controlled rehearsal | Runs one gated test send and reconciles signed provider events | yes | one allowlisted 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 enrol 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;
- exact recipient membership in `TEST_RECIPIENT_ALLOWLIST`;
- exact confirmation `SEND TEST EMAIL`;
- a 12–200 character `Idempotency-Key`;
- verified current DNS/domain evidence;
- verified and active provider connection;
- configured hourly test limit.

### 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:

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. Create a secret-reference-only Mailgun provider connection.
6. Check the provider; activate it only for approved test activity.
7. Create the brand-specific sending domain and configure provider/DKIM metadata.
8. Run live DNS/domain verification.
9. Create a sender identity; leave production activation closed.
10. Create a template and immutable content version; preview it.
11. Run one allowlisted test send.
12. Run and reconcile one controlled rehearsal.
13. Run the isolated synthetic capacity rehearsal if owner-approved.
14. Run launch preflight and resolve every block.
15. 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.
16. 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.
