Help & Documentation

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.

  1. 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 /variants is available for picking the channel inline. All three route to generate_variants, which invokes the Content Generator agent with a per-channel Zod schema via Output.object. Emits a variant_pair artifact.
  2. Pick & publish — click Publish A, Publish B, or Publish both. The Conductor calls publish_variant.
  3. Real-or-sandbox decision — for Sanity, the pure resolvePublishPath helper decides (always real; policy is relaxed post-cutover). For LinkedIn, the publish task itself inspects channel_connections and the account kind (see the LinkedIn decision tree). If the outcome is real + quick_publish_enabled=false, the artifact comes back in awaiting_confirmation.
  4. Confirm — clicking Confirm publish re-invokes the tool with confirmed=true.
  5. Dispatch — the tool triggers the matching channel task (channel.sanity.publish / channel.linkedin.publish) and returns a publish_status artifact in state publishing.
  6. Publish lands — for real LinkedIn: client.users.createPost is called, then client.users.getPost canonicalises the social_id + baseline counters, and both are written to publishes.unipile_social_id + publishes.last_poll_snapshot. A publish_succeeded system_event with action chips lands in the conversation.
  7. Engagement flows in — for real LinkedIn: every 15 minutes the linkedin-poll-engagement scheduled task diffs impressions_counter / reaction_counter / comment_counter against last_poll_snapshot. On counter bumps, paginated reactions + comments are fetched and written to raw_events, dedup'd on source_event_id. For Sanity and sandboxed publishes, the per-publish Monitor task simulates engagement (writes raw_events with is_simulated=true).
  8. Loop-close — see the Manual vs. Autopilot routes section below for how raw_events become learnings and eventually a new master_context version.
  9. Background eventspublish_succeeded, publish_sandboxed, publish_failed, and (on LinkedIn checkpoints) integration_degraded land 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.

  1. Open any project's primary conversation. The pinned project_status card's Channels section shows a Connect LinkedIn button when no live connection exists.
  2. Click it. The browser POSTs /api/channels/linkedin/connect, which role-checks the membership (see below), inserts a channel_connections row with status='pending', and calls client.account.createHostedAuthLink with name=<connectionId> as the correlation field.
  3. 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.
  4. Complete LinkedIn login on Unipile's page. Unipile POSTs to /api/webhooks/unipile/accounts with { status, account_id, name }; we look up the connection by name, flip status to live, and stash provider_account_id.
  5. 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 on channel_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:

  1. markConnectionReauth flips channel_connections.status from live to reauth and stashes the error message.
  2. An integration_degraded system_event lands in the source conversation (or primary, for autonomous polls). Its card renders with a red border + a Reconnect LinkedIn action chip; see components/chat/system-event.tsx.
  3. 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.
  4. 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 pollertrigger/channels/linkedin-poll-engagement.ts runs every 15 minutes. It loads the set of live publishes rows within their measurement window, calls client.users.getPost({ account_id, post_id: unipile_social_id }) to read fresh counters, and diffs against last_poll_snapshot.
  • Counter bump → detail fetch — on reactions_counter / comment_counter increase, the task paginates GET /posts/{social_id}/reactions and client.users.getAllPostComments({post_id}) and writes per-event raw_events rows, dedup'd on source_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 /monitor slash 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

  1. On any project's pinned project_status card, the Channels section shows a Connect Gmail button when no live connection exists.
  2. Click it. The browser POSTs /api/channels/gmail/connect (role rules identical to LinkedIn: owner/admin/editor, demo requires owner). A channel_connections row is inserted with channel='gmail', status='pending'. The route then calls createHostedAuthLink({ channel: "gmail", ... }) with providers: ["GOOGLE"].
  3. 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).
  4. Consent, return to Unipile. Unipile POSTs /api/webhooks/unipile/accounts with the connect result; the handler's detectChannelFromPayload sees GOOGLE (or GMAIL / GOOGLE_OAUTH) and updates the Gmail row to live.
  5. 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:

  1. User types /email alice@acme.com, bob@acme.com followed by a brief.
  2. 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, with recipient matching the A/B assignment.
  3. A variant_pair artifact lands in chat. Publishing chips route through publish_variant({ channel: "gmail" })channel.gmail.publish (the same task, one run per variant).
  4. Each run checks the connection, calls client.email.send({ account_id, to: [{ identifier }], subject, body }), canonicalises the RFC Message-ID (via an optional client.email.getOne call if the send response doesn't include message_id), and writes a publishes row with unipile_email_id, rfc_message_id, and gmail_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 be mail_receivedorigin === "external" (skip our own sends, which Unipile tags "unipile") → payload.in_reply_to.id must 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 — no thread_id needed (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_events with event_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 via client.email.send({ ..., reply_to: parent.unipile_email_id }) (Unipile handles the In-Reply-To header → 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_received webhook (and seeds 1–2 simulated replies if none landed yet). For Sanity + sandboxed publishes, generates simulated engagement. Produces one engagement_batch learning 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_pair card once both A and B have engagement_batch learnings. Runs the Variant Comparator, writes a variant_comparison learning, emits variant_pair_measured, renders a variant_performance artifact.
  • Update master context — appears on the variant_pair_measured event once at least one unapplied learning exists. Runs the Curator, writes a new master_contexts row (v(n+1), is_current=true), emits master_context_updated, renders a master_context_diff artifact.

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_batch learnings (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 · yes
Task: channel.sanity.publish
Adapter: @sanity/client — drafts.post-${timestamp}-${slug} document
Schema: markdown body, 800–1500 words, H1 title + SEO description
Policy: Real for any member of any account (including public_demo). The per-project quick_publish_enabled toggle controls whether a confirmation prompt fires first.
linkedinLinkedInisReal · yes (via Unipile Hosted Auth)
Task: channel.linkedin.publish
Adapter: unipile-node-sdk · client.users.createPost + getPost + getAllPostComments; REST /api/v1/posts/{id}/reactions for reactions
Schema: plain text, 200–900 chars, zero markdown, no hashtag spam
Policy: Non-demo account with a live channel_connections row → real. Otherwise → sandbox (with Connect LinkedIn nudge when the account could go real). public_demo always sandbox at publish-time even with a connection — only the owner can connect, and the connection is used for polling demos.
gmailGmailisReal · yes (via Unipile Hosted Auth + Google OAuth)
Task: channel.gmail.publish
Adapter: unipile-node-sdk · client.email.send + client.email.getOne; mail_received webhook at /api/webhooks/unipile/email for inbound replies
Schema: per-variant { recipient, subject, body } — subject ≤80 chars, body 80–300 words plain text
Policy: Non-demo account with a live channel_connections row → real. Otherwise → sandbox (with Connect Gmail nudge when the account could go real). public_demo owner-only for connect.

Policy 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 to channel_connections).

Effective publish matrix post-Slice-5:

ChannelPersonal / Team · no connPersonal / Team · live connpublic_demo · member
SanityRealRealReal
LinkedInSandbox + Connect nudgeReal (via Unipile)Sandbox (regardless of connection)
GmailSandbox + Connect nudgeReal (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 · sandboxed
  • publish_status.data.state (6 values, artifact-only): adds awaiting_confirmation for the confirm-then-publish flow
  • channel_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

  1. Add an entry to CHANNEL_REGISTRY with a unique id, labels, and the two task IDs.
  2. Write the adapter module (lib/channels/your-channel.ts) with its credentials error type.
  3. Add a case to resolvePublishPath deciding real vs. sandbox (or if the channel needs live-connection data like LinkedIn, decide inside the publish task).
  4. Create the Trigger tasks (trigger/channels/your-channel-publish.ts + ...-monitor.ts; optional ...-poll-engagement.ts for polled channels).
  5. Extend the Content Generator's per-channel schema map if the shape differs.
  6. If your channel needs per-account OAuth, add to channel_connections and wire a /api/channels/your-channel/connect endpoint.

No changes to the Conductor tools, artifact renderers, or settings UI required.