Publishing
How the Conductor drafts A/B variants, how LinkedIn and Gmail connections work via Unipile Hosted Auth, how real vs. sandbox is decided, how reauth surfaces, and how engagement flows back in — polling for LinkedIn, webhook for Gmail.
The generate → publish → measure arc
The second half of the growth loop starts from a variant pair. You ask the Conductor for two drafts, pick one (or both), confirm if needed, and the system dispatches a channel publish task on Trigger.dev. For real LinkedIn publishes, engagement streams back through a scheduled poller that writes raw_events; the Signal Synthesizer converts them into learnings that the Comparator + Curator consume.
- Draft variants — channel-specific slash commands:
/linkedin <angle>for short-form posts (200–900 chars) or/blog <angle>for long-form Sanity posts (800–1500 words). A generic/variantsis available for picking the channel inline. All three route togenerate_variants, which invokes the Content Generator agent with a per-channel Zod schema viaOutput.object. Emits avariant_pairartifact. - Pick & publish — click Publish A, Publish B, or Publish both. The Conductor calls
publish_variant. - Real-or-sandbox decision — for Sanity, the pure
resolvePublishPathhelper decides (always real; policy is relaxed post-cutover). For LinkedIn, the publish task itself inspectschannel_connectionsand the account kind (see the LinkedIn decision tree). If the outcome is real +quick_publish_enabled=false, the artifact comes back in awaiting_confirmation. - Confirm — clicking Confirm publish re-invokes the tool with
confirmed=true. - Dispatch — the tool triggers the matching channel task (
channel.sanity.publish/channel.linkedin.publish) and returns apublish_statusartifact in state publishing. - Publish lands — for real LinkedIn:
client.users.createPostis called, thenclient.users.getPostcanonicalises thesocial_id+ baseline counters, and both are written topublishes.unipile_social_id+publishes.last_poll_snapshot. Apublish_succeededsystem_event with action chips lands in the conversation. - Engagement flows in — for real LinkedIn: every 15 minutes the
linkedin-poll-engagementscheduled task diffsimpressions_counter/reaction_counter/comment_counteragainstlast_poll_snapshot. On counter bumps, paginated reactions + comments are fetched and written toraw_events, dedup'd onsource_event_id. For Sanity and sandboxed publishes, the per-publish Monitor task simulates engagement (writesraw_eventswithis_simulated=true). - Loop-close — see the Manual vs. Autopilot routes section below for how
raw_eventsbecome learnings and eventually a new master_context version. - Background events —
publish_succeeded,publish_sandboxed,publish_failed, and (on LinkedIn checkpoints)integration_degradedland as system_events in the source conversation.
Connecting LinkedIn (Hosted Auth)
LinkedIn real-publishing is powered by Unipile Hosted Auth. Connections are one-per-account — a single LinkedIn session on your account powers every project under it.
- Open any project's primary conversation. The pinned
project_statuscard's Channels section shows a Connect LinkedIn button when no live connection exists. - Click it. The browser POSTs
/api/channels/linkedin/connect, which role-checks the membership (see below), inserts achannel_connectionsrow withstatus='pending', and callsclient.account.createHostedAuthLinkwithname=<connectionId>as the correlation field. - You're redirected to Unipile's hosted page in a top-level window — not an iframe; LinkedIn's checkpoint flow (captcha, phone verification) breaks inside frames.
- Complete LinkedIn login on Unipile's page. Unipile POSTs to
/api/webhooks/unipile/accountswith{ status, account_id, name }; we look up the connection byname, flipstatusto live, and stashprovider_account_id. - Unipile redirects the browser to
/a/[accountSlug]?linkedin_connected=1, which mounts a toast and marks the connection live in the project_status card (via Realtime onchannel_connections).
Role rules (enforced in /api/channels/linkedin/connect): non-demo accounts allow owner / admin / editor; viewers get viewer_cannot_connect. Thepublic_demo account requires role='owner' (admin/editor getdemo_requires_owner) — the demo owner seat is what owns the CapitalCodersEngine LinkedIn session used in demos.
LinkedIn publish decision tree
Unlike Sanity, LinkedIn doesn't decide real vs. sandbox in the pure policy helper — the decision needs a live channel_connections row read, which isn't available to the Conductor. So the publish task itself inspects connection + account kind:
channel.linkedin.publish run()
│
├─ load project + accounts!inner(kind)
├─ load newest non-failed channel_connections row for (account_id, channel='linkedin')
│
├─ isDemoAccount = (account.kind === 'public_demo')
├─ hasLiveConnection = (connection.status === 'live' && provider_account_id !== null)
├─ canGoReal = !isDemoAccount && hasLiveConnection
│
├─ canGoReal TRUE
│ │
│ └─ publishRealLinkedIn()
│ ├─ client.users.createPost({ account_id, text })
│ ├─ client.users.getPost → social_id + share_url + baseline counters
│ ├─ INSERT publishes (state='live', unipile_social_id, last_poll_snapshot)
│ ├─ append publish_succeeded system_event with chips:
│ │ [Open on LinkedIn, Simulate engagement, Run monitor, Compare variants]
│ └─ on Unipile reauth error:
│ ├─ markConnectionReauth (status → 'reauth')
│ ├─ append integration_degraded with [Reconnect LinkedIn] chip
│ └─ fall back to publishSandboxLinkedIn(nudge=true)
│
└─ canGoReal FALSE
│
└─ publishSandboxLinkedIn()
├─ INSERT sandboxed_publishes
├─ reason = isDemoAccount ? 'sandbox_only_channel' : 'no_credentials'
├─ append publish_sandboxed with chips:
│ [Preview, Open sandbox, Simulate engagement, Run monitor,
│ Connect LinkedIn to publish for real] (last chip only when nudge=true)
└─ (autopilot ON) schedule monitor.linkedin.simulated at T+24h
Reauth flow (integration_degraded)
LinkedIn sessions expire or get paused by a checkpoint. When the publish task calls Unipile and gets back a reauth-category error, the following happens — this is also how the scheduled poller behaves when it hits a stale connection:
markConnectionReauthflipschannel_connections.statusfrom live to reauth and stashes the error message.- An
integration_degradedsystem_event lands in the source conversation (or primary, for autonomous polls). Its card renders with a red border + a Reconnect LinkedIn action chip; seecomponents/chat/system-event.tsx. - The current publish falls back to sandbox so the user's click isn't wasted; the sandboxed_publish row is marked
reason='no_credentials'with a "Connect LinkedIn to publish for real" chip on its own system_event. - The Reconnect chip POSTs
/api/channels/linkedin/connect?type=reconnect, which creates a fresh Hosted Auth link scoped to the existing provider account. User completes it, status flips back to live via webhook, future publishes take the real path again.
Engagement polling (not webhooks)
Unipile does not have a webhook source for post reactions / comments — the webhook enum is messaging/account_status/users/calendar/email, none of which cover "someone liked your post". So engagement ingestion is polling, not push.
- Scheduled poller —
trigger/channels/linkedin-poll-engagement.tsruns every 15 minutes. It loads the set of livepublishesrows within their measurement window, callsclient.users.getPost({ account_id, post_id: unipile_social_id })to read fresh counters, and diffs againstlast_poll_snapshot. - Counter bump → detail fetch — on reactions_counter / comment_counter increase, the task paginates
GET /posts/{social_id}/reactionsandclient.users.getAllPostComments({post_id})and writes per-eventraw_eventsrows, dedup'd onsource_event_id(the LinkedIn reaction/comment external ID). - impressions are recorded as a single counter-delta event per poll cycle.
- Run monitor (on-demand) — the
/monitorslash command (and the Run monitor action chip) dispatches the same poller for a specific publish, then runs the Signal Synthesizer inline over the resulting raw_events. Use when you don't want to wait for the next 15-minute tick.
The authoritative Unipile API reference lives at docs/research/2026-04-20-unipile-api-reference.md — endpoint shapes, field names, and the confidence markers (CONFIRMED / INFERRED / UNVERIFIED) are pinned there. Engineers adding new LinkedIn surface area should read that first; see also the technical reference's Trigger.dev section for how the poller fits into the task graph.
Gmail (Slice 6)
Gmail reuses everything Slice 5 built for LinkedIn — channel_connections, Hosted Auth, categorizeUnipileError, the integration_degradedUX — and adds three things on top: a per-recipient A/B send, webhook-driven reply ingestion, and a threaded reply-back.
Connecting Gmail
- On any project's pinned
project_statuscard, the Channels section shows a Connect Gmail button when no live connection exists. - Click it. The browser POSTs
/api/channels/gmail/connect(role rules identical to LinkedIn: owner/admin/editor, demo requires owner). Achannel_connectionsrow is inserted withchannel='gmail',status='pending'. The route then callscreateHostedAuthLink({ channel: "gmail", ... })withproviders: ["GOOGLE"]. - You're redirected to Unipile's hosted OAuth page. Google's consent screen appears under Unipile's verified app (we skip our own Google Cloud verification via Unipile's CASA Tier 2 certificate).
- Consent, return to Unipile. Unipile POSTs
/api/webhooks/unipile/accountswith the connect result; the handler'sdetectChannelFromPayloadseesGOOGLE(orGMAIL/GOOGLE_OAUTH) and updates the Gmail row to live. - Browser redirects to
/a/[accountSlug]?gmail=connected. Toast + Realtime update.
Sending A/B via /email
Unlike LinkedIn where both variants target the same audience, Gmail's A/B is one recipient per variant:
- User types
/email alice@acme.com, bob@acme.comfollowed by a brief. - The Conductor routes to
generate_variants({ channel: "gmail", recipients: [<a>, <b>], brief }). The Content Generator uses a Gmail-specific Zod schema that returns{ recipient, subject, body }per variant, withrecipientmatching the A/B assignment. - A
variant_pairartifact lands in chat. Publishing chips route throughpublish_variant({ channel: "gmail" })→channel.gmail.publish(the same task, one run per variant). - Each run checks the connection, calls
client.email.send({ account_id, to: [{ identifier }], subject, body }), canonicalises the RFC Message-ID (via an optionalclient.email.getOnecall if the send response doesn't includemessage_id), and writes apublishesrow withunipile_email_id,rfc_message_id, andgmail_recipient.
Reply ingestion — webhook, not polling
Unlike LinkedIn feed events, Unipile does webhook inbound email. The/api/webhooks/unipile/email route handles mail_received events — no 15-minute scheduled poller needed.
- Filter chain: verify
X-Webhook-Secret→ event must bemail_received→origin === "external"(skip our own sends, which Unipile tags"unipile") →payload.in_reply_to.idmust be present (skip cold inbound). - Primary correlation:
SELECT publishes WHERE unipile_email_id = payload.in_reply_to.id. The Unipile id of the parent message IS our correlation key — nothread_idneeded (the webhook payload doesn't include one). - Secondary correlation: if primary misses, match
rfc_message_id = payload.in_reply_to.message_id. - Multi-turn correlation: if both miss, check
raw_events WHERE event_type='sent_reply' AND payload->>'email_id' = payload.in_reply_to.id— lets replies to our AI replies correlate back to the original publish. - On match: INSERT
raw_eventswithevent_type='reply',source_event_id=payload.email_id. The existing unique index handles Unipile's at-least-once webhook delivery.
One-time webhook setup lives in docs/runbooks/slice-6-unipile-webhook-setup.md. Register once per environment via Unipile's dashboard or REST — one webhook fans across all current AND future Gmail connections on the tenant.
Reply-back — chip and slash command
Operators never type or copy an email id. Two affordances:
- Reply chip on any Gmail publish_succeeded card → dispatches action
reply_email:<publishId>. Conductor asks "What should the reply say?" → drafts → previews → on confirm, sends viaclient.email.send({ ..., reply_to: parent.unipile_email_id })(Unipile handles theIn-Reply-Toheader → the reply clusters correctly in the recipient's Gmail thread). /reply <intent>slash command — no publishId. The tool resolves implicitly: exactly 1 Gmail thread with replies in the project → proceed. 0 or 2+ → error asking the user to click the chip on a specific card. (No disambiguation artifact — the chip is the canonical path for ambiguous cases.)
Every sent reply writes a raw_events row with event_type='sent_reply', so the Synthesizer sees the full conversation — inbound replies AND our outbound ones — when it runs.
Manual vs. Autopilot routes
Humans always click Publish. Autopilot controls what happens AFTER the publish — the loop-closing work (Monitor → Signal Synthesizer → Variant Comparator → Curator → new master_context version). The toggle for a project is projects.auto_close_loop_enabled.
Autopilot OFF · manual / demo route (default)
The user drives every step. After a publish lands, action chips appear on thepublish_succeeded event and on the variant_pair card that progressively walk the loop forward:
- Run monitor — for LinkedIn, dispatches the poller now + runs the Signal Synthesizer inline. For Gmail, runs the Synthesizer over reply events already delivered via
mail_receivedwebhook (and seeds 1–2 simulated replies if none landed yet). For Sanity + sandboxed publishes, generates simulated engagement. Produces oneengagement_batchlearning row per variant. - Simulate engagement — demo-only helper: adds N synthetic likes/comments so you can imbalance A vs B before comparing without waiting for real engagement.
- Compare variants — appears on the
variant_paircard once both A and B haveengagement_batchlearnings. Runs the Variant Comparator, writes avariant_comparisonlearning, emitsvariant_pair_measured, renders avariant_performanceartifact. - Update master context — appears on the
variant_pair_measuredevent once at least one unapplied learning exists. Runs the Curator, writes a newmaster_contextsrow (v(n+1),is_current=true), emitsmaster_context_updated, renders amaster_context_diffartifact.
Autopilot ON · automated route
Same agents, same learnings — the difference is who clicks. Timers replace clicks:
- Monitor auto-dispatches at
T + channel.measurement_window_hours(24h default for Sanity and LinkedIn). For real LinkedIn, the scheduled 15-minute poller is already writing raw_events the whole time — the 24h Monitor just runs the Signal Synthesizer over the accumulated events. - Variant Comparator auto-fires once both A and B have
engagement_batchlearnings (reactive check at the end of each Synthesizer run). - Curator runs on a daily cron at 02:00 UTC per project with unapplied learnings. The previous 1/hour rate limit was removed for demo.
Every autonomous step still surfaces as a system_event in primary; you can read the transcript later and understand exactly what the system did. To opt a project into autopilot, toggle the Autopilot chip in the context strip — owner/admin only, enforced at /api/projects/[id]/settings.
The two paths side by side
AUTOPILOT OFF (manual / demo) AUTOPILOT ON (automated)
───────────────────────────── ────────────────────────
Human publishes A+B Human publishes A+B
│ │
▼ ▼
(LinkedIn poller writes raw_ (LinkedIn poller writes raw_
events every 15 min in the events every 15 min in the
background) background)
│ │
▼ ▼
[chip: Run monitor] (Monitor auto at T+channel
OR [chip: Simulate engagement] .measurement_window_hours)
│ │
▼ ▼
Monitor + Signal Monitor + Signal
Synthesizer (inline) Synthesizer (inline)
│ │
▼ ▼
engagement_batch engagement_batch
learning × 2 learning × 2
│ │
▼ ▼
[chip: Compare variants] (reactive: fires when
│ both A+B have learnings)
│ │
▼ ▼
Variant Comparator Variant Comparator
│ │
▼ ▼
variant_comparison variant_comparison
learning learning
│ │
▼ ▼
[chip: Update master context] (daily cron 02:00 UTC)
│ │
▼ ▼
Curator Curator
│ │
▼ ▼
master_contexts v(n+1) master_contexts v(n+1)
Same agents, same learnings, same new master_context version — the only difference is whether a chip click or a timer fires each step.
Channel registry
Each channel is one entry in lib/channels/registry.ts, one adapter module under lib/channels/, and task files under trigger/channels/. Both channels in the registry have isReal: true after Slice 5:
sanitySanity blogisReal · yeschannel.sanity.publishlinkedinLinkedInisReal · yes (via Unipile Hosted Auth)channel.linkedin.publishgmailGmailisReal · yes (via Unipile Hosted Auth + Google OAuth)channel.gmail.publishPolicy gate (Sanity) + in-task decision (LinkedIn)
The pure gate in lib/channels/policy.ts returns the upstream hint:
channel.isReal === false→ always sandbox (currently no such channel).channel.id === 'sanity'→ always real (relaxed post-cutover — single Caldrin-owned blog, membership is enough).channel.id === 'linkedin'→ sandbox from the gate (the real/sandbox decision actually happens inside the publish task, which has access tochannel_connections).
Effective publish matrix post-Slice-5:
| Channel | Personal / Team · no conn | Personal / Team · live conn | public_demo · member |
|---|---|---|---|
| Sanity | Real | Real | Real |
| Sandbox + Connect nudge | Real (via Unipile) | Sandbox (regardless of connection) | |
| Gmail | Sandbox + Connect nudge | Real (via Unipile + Google OAuth) | Sandbox (regardless of connection) |
Quick Publish toggle
Owner/admin of a project can flip Quick Publish on in the context strip. When on, real-path publishes skip the confirmation step and dispatch immediately. When off (default), the artifact comes back in awaiting_confirmation — a dedicated state that exists only in the UI, not in publishes.state.
Non-owners see a read-only pill when the toggle is enabled, and nothing when disabled. POST /api/projects/[id]/settings updates the flag; the route enforces owner/admin via the account_memberships role.
State vocabularies
Two distinct state machines — deliberately separated so the UI can show more states than the DB stores:
publishes.state(5 values, persisted): scheduled · publishing · live · failed · sandboxedpublish_status.data.state(6 values, artifact-only): adds awaiting_confirmation for the confirm-then-publish flowchannel_connections.status(4 values): pending · live · reauth · failed. The stub status was retired post-cutover.
Monitor tasks
Sanity + sandboxed-LinkedIn monitors still simulate engagement — they INSERT a raw_events row with is_simulated=true and realistic metric shape. Real LinkedIn publishes get their engagement from the scheduled poller described above; the Monitor task in that case just runs the Signal Synthesizer over accumulated events.
Real Gmail publishes get their engagement from the mail_received webhook — no scheduled poller needed. monitor.gmail.simulated runs the Synthesizer over whatever real reply events have accumulated in raw_events; if none have landed (user clicked Run monitor before anyone replied) it seeds 1–2 simulated replies so the loop still has data to learn from. On autopilot the monitor fires at T + 24h after send.
Adding a channel
- Add an entry to
CHANNEL_REGISTRYwith a uniqueid, labels, and the two task IDs. - Write the adapter module (
lib/channels/your-channel.ts) with its credentials error type. - Add a case to
resolvePublishPathdeciding real vs. sandbox (or if the channel needs live-connection data like LinkedIn, decide inside the publish task). - Create the Trigger tasks (
trigger/channels/your-channel-publish.ts+...-monitor.ts; optional...-poll-engagement.tsfor polled channels). - Extend the Content Generator's per-channel schema map if the shape differs.
- If your channel needs per-account OAuth, add to
channel_connectionsand wire a/api/channels/your-channel/connectendpoint.
No changes to the Conductor tools, artifact renderers, or settings UI required.