Shadow sends and the kill switch
Why a message or campaign comes back "shadow", "deferred", or "stopped" instead of "sent" — and which of several gates is actually holding it back.
Every send path in Club — a 1:1 reply, a scheduled broadcast, an auto-message — routes through the same firewall-then-engine pipeline, but each layer can hold a message back for a different reason. This page is the map of which "not sent" you're looking at and what actually controls it.
The 1:1 send status ladder
POST /inbox/send (and retry) returns exactly one of six statuses:
status | What it means | What the chatter sees |
|---|---|---|
blocked | The content firewall refused it. Nothing was queued or logged. | "Not sent: …" with a concrete, fixable reason where the category maps to one. |
stopped | The cross-tenant emergency brake is down. | "Not sent: sending is stopped for this account right now" — no retry offered, because retrying can't help. |
deferred | The engine's single send slot was busy with a previous message. | "Not sent: the previous message is still going out. Tap Retry." |
shadow | Passed every check, but live sending is off account-wide. | "Saved — it goes out once sending opens." |
queued | Live sending is on; the engine accepted the send and delivery is async. | "Sending…" |
sent | A confirmed, acknowledged delivery. | "Delivered to the fan." |
"shadow" is the default state of the whole product today
A global ENGINE_WRITE_READY setting controls whether 'live' mode resolves to a real sending
executor or to a stub that logs intent and sends nothing. While it's off — which it is by
default — every 1:1 send, broadcast send, and auto-message send in the product lands as
shadow, never sent. This is the intended current state of the whole system, not a
per-account misconfiguration to chase down.
stopped is a different lever from shadow
stopped comes from a separate, cross-tenant kill switch an operator can throw to halt every
workspace at once — genuinely an emergency brake, not a per-account setting a workspace owner
toggles. It's distinct from a per-owner opt-out (AutomationOptIn.kill_switch), which only affects
that one workspace's automation sends. If a send is coming back stopped, the fix isn't anything
on the workspace side.
Broadcasts have their own three-state lane, not the same two flags
Scheduling a campaign under /api/admin/broadcasts doesn't reuse the 1:1 send ladder directly — it
rides a lane with three states, surfaced on the schedule/list responses:
| Lane | Meaning |
|---|---|
off | The broadcast-sending tick isn't running at all. |
shadow | The tick is running, passes every message through every door, and sends nothing — a full rehearsal. |
live | The tick is running and the engine is ready; sends go out for real. |
Getting to live requires two separate gates to agree: a settings flag the lane itself checks,
and a file-based readiness flag a watchdog process sets once it's satisfied the engine is actually
healthy. Either one alone leaves the lane at shadow — this is deliberately fail-closed on both
sides, not an oversight to reconcile.
A rehearsal doesn't mark anything as sent
In shadow mode the lane's tick doesn't leave any row in a state that looks terminal — it runs
the full check sequence and reports what would have gone and what would have been blocked,
without stamping any send row as done. This is intentional: if it silently marked shadow sends as
terminal, turning live sending on later would mean those messages never actually go, because the
system would already believe they did.
Track-B automation campaigns need to pass a soak, not just flip a flag
Auto-messages and the in-chat "Broadcast" composer both run on a third, separate engine
(Track B / core.automation) with its own path to going live: a campaign starts in shadow and
status: 'draft', and before it's allowed to send for real it has to clear a soak — a
harness that checks its shadow-run history for full audience coverage, zero firewall escapes
(a blocked-verdict message that somehow still sent — this must always be zero), acceptable send
pacing, and a passing evaluation window. All four have to pass before a soak_passed_at stamp is
written; short of that, the campaign simply cannot promote to live no matter what toggle is
flipped in the UI.
What to actually check, in order
- Is this a 1:1 send, a
/broadcastscampaign, or an auto-message / in-chat "Broadcast"? Each has its own gate above — don't diagnose one using another's model. - For a 1:1 send: read the exact
statusthe API returned, not just "it didn't send." - For a broadcast: check the
lanefield on the campaign/list response. - For an auto-message or Track-B campaign: check whether it has cleared soak yet.
What's next
- Two campaign systems — why a campaign you built doesn't show up where you expect it.
- Reply to a waiting fan — the everyday 1:1 send path this table describes.
Admin endpoints
Every internal admin-panel route Club serves — path, method, what it does, owner-only or not.
Two campaign systems, both live today
Why a broadcast created under /broadcasts never shows up in the in-chat "Broadcast" composer (or vice versa) — two independent systems share the word "campaign" but aren't merged.