Help & Documentation

Technical reference

A developer-grade walk through Caldrin Growth — architecture, data model, agent topology, Unipile integration, Trigger.dev tasks, the publishing decision tree, security model, and deployment. Written so a new engineer can be productive by the end of a morning, and so a judge can reason about the system without reading the source.

Authoritative Unipile references: docs/research/2026-04-20-unipile-api-reference.md for Slice 5 (LinkedIn) and docs/research/2026-04-22-unipile-gmail-reference.md for Slice 6 (Gmail) — endpoint shapes, SDK method names, field names, and CONFIRMED / INFERRED / UNVERIFIED markers. Read first when touching the respective channel surface.

1 · The pitch + the growth loop

Caldrin Growth is a multi-agent SaaS that closes the growth loop — the engineering problem most growth teams solve with a patchwork of tabs. The four stages:

  1. Research — continuous PESTEL-style evidence collection across 7 domains, crystallised into a versioned master_context.
  2. Generate — draft A/B variant_pairs for one channel, schema-validated per channel, grounded in the current master_context + recent signals.
  3. Publish — dispatch a channel publish task. Sanity publishes are real for any member; LinkedIn posts and Gmail sends are real when the account has a live Unipile connection for that channel and isn't the public demo. Sandbox when neither. /email sends one email per variant (A to recipient 1, B to recipient 2).
  4. Measure — LinkedIn uses a 15-minute scheduled poller against Unipile GET /posts/{social_id}. Gmail uses a push webhook (mail_received) via /api/webhooks/unipile/email — correlation is in_reply_to.id === publishes.unipile_email_id. Both write dedup'd raw_events. The Signal Synthesizer turns events into learnings, the Variant Comparator picks a winner, the Curator writes a new master_contexts version.

Every stage is chat-native. The UI is one composer + a stream of artifacts. There is no dashboard. The workspace is the conversation, and every agent output is a typed artifact rendered inline.

Hackathon framing: the brief's four hard constraints are (a) must be multi-agent, (b) dynamic UIs in conversation, (c) live signal not training data, (d) the loop must close. Caldrin Growth's architecture is designed to hit all four without scope-bloat — everything that isn't immediately on the critical path is a stub behind the same adapter interface as the real thing.

2 · Tech stack

ConcernChoiceWhy
FrameworkNext.js 16 App Router on Vercel Fluid ComputeReact Server Components for DB-adjacent pages; streaming for chat; route handlers for API; Fluid runtime for long-lived chat streams without per-request cold starts.
LanguageTypeScript (strict)Generated Supabase types + Zod-validated tool IO + typed AI SDK tool calls.
UITailwind + shadcn/ui + lucide-reactCopy-into-repo primitives; no runtime cost; full customisation.
AgentsVercel AI SDK v6Uniform generateText / streamText / tool / Output.object API across providers; native to Next.js streaming.
LLM providerAnthropic Claude via Vercel AI GatewayGateway gives zero-data-retention, model string routing ("anthropic/claude-sonnet-4-6"), built-in fallbacks, and observability.
ModelsSonnet 4.6 (Conductor, Content Gen, Synthesizer, Comparator, Curator) + Haiku 4.5 (title suggestions, light synthesis)Opus-tier reasoning where structure matters; fast model for low-stakes derivations.
DB + AuthSupabase (Postgres 17 + GoTrue)RLS-first, Realtime, generated types, append-only learnings, service-role writes from route handlers + Trigger tasks.
RealtimeSupabase Realtime (Postgres changes)UI subscribes to conversations + channel_connections row updates so background events and connection status stream live without polling.
Background jobsTrigger.dev v4First-class retries, checkpointing, schemaTask validation, queues, scheduled tasks (15-min LinkedIn poller + daily Curator cron), delay + idempotency.
Blog channelSanity (@sanity/client)Real-publish for any member of any account. Drafts post documents via the API — no schema deploy needed for dev.
Social channelUnipile (unipile-node-sdk v1.9.x) for LinkedInHosted Auth cookie session, client.users.createPost for publishing, client.users.getPost + getAllPostComments for engagement, REST /api/v1/posts/{id}/reactions for reactions.
Email channelUnipile (unipile-node-sdk v1.9.x) for GmailHosted Auth via Google OAuth on Unipile's CASA Tier 2 app, client.email.send for outbound (+ reply_to for threading), mail_received webhook for inbound replies. No polling.
HostingVercel (app) + Trigger.dev (workers) + Supabase Cloud (db)Three clean boundaries: Next app, async workers, stateful data.

3 · System architecture

Three planes talking through Postgres + Realtime:


       ┌────────────────────────────────────────────────────────────┐
       │                      BROWSER (React)                       │
       │   Workspace chat + artifact stream + SlashPalette          │
       │   useChat() · useWorkspaceRealtime() · artifact renderers  │
       └───────────────▲───────────────────────────▲────────────────┘
                       │ WebSocket                  │ SSE (AI SDK)
                       │ (Supabase Realtime)        │
    ┌──────────────────┴─────────┐   ┌──────────────┴─────────────────┐
    │   Supabase (Postgres+Auth) │   │   Next.js app  (Vercel)        │
    │   RLS · triggers · RPCs    │   │   /api/chat  — Conductor       │
    │   Realtime publishes       │   │   /api/projects/…/settings     │
    │   conversations.message_ ─◀┼───│   /api/conversations/…/title   │
    │   history (JSONB) updates  │   │   (service role admin client)  │
    └────────────────▲───────────┘   └────────┬───────────────────────┘
                     │                        │ tasks.trigger()
                     │                        ▼
                     │          ┌─────────────────────────────────┐
                     │          │   Trigger.dev v4 workers        │
                     │          │   research-pipeline             │
                     └──────────┤   channel.sanity.publish        │
                      admin DB  │   channel.linkedin.publish      │
                      writes    │   monitor.sanity/linkedin.*     │
                                └────────────────▲────────────────┘
                                                 │
                                        Anthropic (via AI Gateway)
                                        + Sanity / LinkedIn adapters

Key rule: the Next app speaks to Postgres under two identities — SSR client (user JWT, subject to RLS) for read-your-own, and admin client (service role) only server-side for writes the user is authorised for but that cross tenant boundaries (e.g. a monitor inserting a raw_events row). Trigger.dev workers always use admin.

4 · Multi-tenancy model

Three concentric tenants:

  • User — one auth.users row. Mirrored to profiles.
  • Account — a workspace. Three kinds: personal (auto-created on sign-up), team (future), public_demo (seeded singleton — the Caldrin Demo). Every user gets a viewer seat on the demo automatically.
  • Project — a subject under an account. Owns its own master_context, signals, conversations, publishes.

Demo-owner promotion: users whose email matches an exact entry in app.demo_owner_emails (Postgres setting) get an owner seat on the demo account instead of viewer. The exact-match is enforced in handle_new_user via unnest(string_to_array(...)) — an earlier version used position() and was caught by the code-review gate; see migration 20260419100006_slice3_hardening.sql. Default allow-list is a single email baked into the migration; CI/prod override via CALDRIN_DEMO_OWNER_EMAILS env wired into Supabase config.

5 · Data model

Migrations grouped by concern:

Core tenancy (20260416…)

  • profiles — id, email, display_name, avatar_url
  • accounts — slug, display_name, kind (personal / team / public_demo), created_by
  • account_memberships — (account_id, user_id, role). Roles: owner / admin / editor / viewer.
  • Trigger: handle_new_user on auth.users creates profile + personal account + demo membership in one transaction.

Research (20260416…)

  • projects — account_id, slug, display_name, subject_url, research_status, research_trigger_run_id, quick_publish_enabled, auto_close_loop_enabled (renamed from auto_publish_enabled in Slice 4 — see Autopilot semantics below)
  • research_signals — project_id, domain (7 PESTEL values as enum), content, source_url, confidence, is_fact
  • master_contexts — project_id, version_n, is_current, payload (JSONB), generated_at. Exactly one row per project has is_current=true.

Conversations + artifacts (20260419…)

  • conversations — project_id, slug, title, is_primary, message_history (JSONB — the UI message array, source of truth)
  • artifacts — conversation_id, turn_id, type, title, description, data (JSONB), source, pinned_to_hub, pinned_at. UNIQUE INDEX on (conversation_id, turn_id, type) makes ingestion idempotent.
  • RPC append_conversation_message(conv_id, msg) — the only way to atomically append a background-event message to an in-flight conversation without racing with an assistant stream writing the full array.

Publishing (20260419100…) — Slice 3

  • variant_pairs — project_id, conversation_id, channel, topic, variant_a, variant_b (both JSONB)
  • publishes — variant_pair_id, variant_label (A/B), channel, state (scheduled/publishing/live/failed/sandboxed), external_url, trigger_run_id, error. Slice 5 added: unipile_social_id (canonical LinkedIn URN used as lookup key for reactions/comments/getPost) and last_poll_snapshot (JSONB: { impressions, reactions, comments, reposts, polledAt } — the counter baseline the scheduled poller diffs against). Slice 6 added: unipile_email_id (primary correlation key for Gmail reply webhooks — in_reply_to.id matches this), rfc_message_id (RFC 5322 Message-ID secondary fallback), and gmail_recipient (denormalised address for UI display).
  • sandboxed_publishes — same shape but for channels that never went out to a real API. Separate table so learning ingestion can treat them distinctly.
  • raw_eventsXOR on (publish_id, sandboxed_publish_id) — exactly one is non-null. kind, metrics (JSONB), is_simulated. Slice 5 added: source_event_id — external identifier used as the dedup key. For LinkedIn: the reaction/comment/impression external id. For Gmail (Slice 6): the Unipile email_id of the inbound reply or the AI-sent sent_reply. The same unique index handles both at-least-once webhook delivery (Gmail) and poller re-runs (LinkedIn).

Slice 4 — loop-close (20260420100…)

  • projects.auto_close_loop_enabled — renamed from auto_publish_enabled. The column governs Autopilot (post-publish automation); generation + publishing stay human-click even with Autopilot ON.
  • learnings — append-only. project_id, source_kind (engagement_batch / variant_comparison), source_publish_id, source_variant_pair_id, payload (JSONB), applied_to_mc_version_id (only field ever mutated — set by the Curator).

Slice 5 — channel connections (20260420100003…)

  • channel_connections — (account_id, channel, provider) tuple. Both LinkedIn (channel='linkedin') and Gmail (channel='gmail') use provider='unipile'; provider_account_id is the Unipile account ID; status enum (pending / live / reauth / failed). The stub status was retired post-cutover. Has Realtime enabled so the project_status card updates instantly on status change.
  • One live connection per (account, channel) — drives each publish task's real/sandbox decision and (for LinkedIn) the scheduled poller's work loop.

6 · Agent topology

Multi-agent ≠ agent soup. Caldrin Growth has one orchestrator (the Conductor) and a handful of specialists that are only invoked via Trigger.dev tasks or AI SDK sub-calls. None of the specialists sees the chat stream; none of them writes to the DB through the user's JWT.

AgentModelInvoked byOutput
ConductorSonnet 4.6POST /api/chat on every user messageStreamed UI messages + one tool call + suggest_follow_ups terminator
Domain Subagents (×7)Sonnet 4.6 + web searchresearch-pipeline Trigger task, fanned out one-per-domainN signals INSERTed into research_signals with domain + confidence
Curator (Seed)Sonnet 4.6End of research-pipeline, after all 7 subagents returnOne master_context row (version bumped + is_current flipped atomically)
Content GeneratorSonnet 4.6 (Output.object)generate_variants Conductor toolTwo variants matching a per-channel Zod schema (returned inline, not via a Trigger task)
Channel Monitors (Sanity + sandboxed LinkedIn)— (deterministic)Each publish task dispatches its monitor; delays gated on auto_close_loop_enabledOne raw_events row with simulated metrics + is_simulated=true
LinkedIn Engagement Poller— (deterministic, SDK + REST)Scheduled every 15 minutes (trigger/channels/linkedin-poll-engagement.ts) + on-demand via the /monitor slash command + Run monitor chipPer-publish: diffs counters via client.users.getPost, fetches reactions (REST) + comments (getAllPostComments) on bump, writes per-event raw_events dedup'd on source_event_id. On reauth error → flips connection to reauth, emits integration_degraded.
Title SuggesterHaiku 4.5/api/conversations/[id]/suggest-title, fired from onFinishUpdates conversations.title once per first-exchange
Signal SynthesizerSonnet 4.6Inline at end of each Monitor run (manual or auto); reads raw_events for one publishOne engagement_batch learning row per variant
Variant ComparatorSonnet 4.6Manual via chip (autopilot OFF) or reactive when both A+B have learnings (autopilot ON)One variant_comparison learning row; emits variant_pair_measured; renders variant_performance artifact
Curator (Daily / On-demand)Sonnet 4.6Manual via chip (autopilot OFF) or daily cron at 02:00 UTC (autopilot ON). The previous 1/hr rate limit was removed for demo.New master_contexts row (v(n+1)); emits master_context_updated; renders master_context_diff

7 · The Conductor & its tools

Defined in lib/ai/agents/conductor.ts (system prompt) and wired in app/api/chat/route.ts. On each request:

  1. Load conversation + project + master_context + signal count (parallel admin reads).
  2. Filter out role='system' background-event messages from the LLM input — they'd convert to empty text blocks and Anthropic rejects those.
  3. Call streamText with the Conductor's tools; stop at stepCountIs(10).
  4. On onFinish: persist the full updated message_history array, ingest any tool-* parts as artifacts rows (idempotent on turn_id), fire-and-forget the title suggester if this is the first exchange.

The Conductor's tool belt

  • show_master_context — reads current row, emits master_context_view
  • list_recent_signals — optional domain filter, emits research_brief
  • kickoff_research — dispatches research-pipeline on Trigger; idempotent per project/day
  • analyze_domain — inline sub-60s single-domain research using a Sonnet subagent
  • generate_artifact — ad-hoc viz (scorecard, comparison_table, bar_chart, pie_chart, radar_chart, mind_map, mermaid_diagram, key_findings, swot_analysis, timeline)
  • generate_document — server-side PPTX/DOCX/XLSX/CSV generation, emits a document_export_card
  • generate_variants — invokes Content Generator; emits variant_pair. Triggered by /linkedin, /blog, or the generic /variants.
  • publish_variant — dispatches the channel publish Trigger task; emits publish_status
  • run_monitor — on-demand: dispatches the LinkedIn poller (or simulated monitor) for a specific publish, then runs the Signal Synthesizer inline
  • seed_engagement — demo helper: inserts simulated likes/comments on a publish for imbalance testing
  • compare_variants — runs the Variant Comparator manually once both A+B have learnings; emits variant_performance
  • update_master_context — runs the Curator manually; emits master_context_diff
  • show_learnings — lists recent learning rows for the project
  • suggest_follow_ups — required terminator; emits 2–4 chips above the composer

Every tool's execute() returns { artifact: { type, title, description, data } }. The AI SDK streams that as a tool-toolName UI message part. components/chat/message.tsx dispatches it to ArtifactRenderer, which lazy-imports the matching file in components/artifacts/ and mounts it.

8 · Research pipeline

Defined in trigger/research-pipeline.ts as a Trigger.dev schemaTask. Steps:

  1. Mark projects.research_status='running' + store research_trigger_run_id.
  2. Fan out 7 domain subagents via batchTriggerAndWait. Each subagent is one Sonnet call with a web_search tool, returning N signals with confidence + is_fact.
  3. Bulk INSERT signals into research_signals.
  4. Invoke Curator (Sonnet) over all signals — produces a master_context payload.
  5. In one transaction: set all existing master_contexts for this project to is_current=false, INSERT the new row with is_current=true and version_n = previous + 1.
  6. Append a research_complete system_event to the primary conversation via append_conversation_message RPC.
  7. Emit a project_audit_summary artifact the first time through.

The 7 PESTEL domains

Stored as a Postgres enum so both app code and the CHECK in research_signals stay aligned:

  1. market_trends — category movement
  2. competitive_intel — competitor moves
  3. audience_intent — signals of customer demand
  4. channel_campaign_intel — which channels + messages win
  5. win_loss_conversion — deal-level insight
  6. adjacent_threats — risk from adjacent categories
  7. contextual_temporal — news, policy, hiring trends

9 · Publishing architecture

The canonical flow, end-to-end (LinkedIn real path):


 user: "/linkedin pipeline automation for RevOps leaders"
          │
          ▼
 POST /api/chat
          │
          ├─ Conductor picks generate_variants
          │        │
          │        ▼
          │   Content Generator (Sonnet, Output.object)
          │   schema = PER_CHANNEL_SCHEMA["linkedin"]  // 200-900 chars
          │        │
          │        ▼
          │   variant_pairs INSERT ⇒ variantPairId
          │        │
          │        ▼
          │   artifact: variant_pair  (side-by-side A/B with chips)
          │
 user: clicks "Publish A"  ⇒  "Publish variant A of ... to LinkedIn"
          │
          ▼
 POST /api/chat
          │
          ├─ Conductor picks publish_variant
          │        │
          │        ▼
          │   resolvePublishPath(channel, account, membership, project)
          │        │
          │        ├─ "sandbox"     ⇒  dispatch channel.X.publish
          │        │                   with sandbox=true
          │        │                   ⇒  artifact: publish_status
          │        │                           state = publishing
          │        │
          │        └─ "real"
          │             │
          │             ├─ quick_publish_enabled=false && !confirmed
          │             │      ⇒  return early, artifact state =
          │             │         awaiting_confirmation
          │             │
          │             └─ else ⇒ dispatch with sandbox=false
          │
 Trigger.dev: channel.linkedin.publish
          │
          ├─ load project + accounts!inner(kind)
          ├─ load channel_connections(account_id, channel='linkedin')
          │     status='live' + provider_account_id → canGoReal
          │
          ├─ REAL path (non-demo + live conn):
          │     ├─ client.users.createPost({ account_id, text })
          │     ├─ client.users.getPost({ account_id, post_id })
          │     │     → social_id, share_url, impressions/reaction/comment counters
          │     ├─ INSERT publishes (state='live', unipile_social_id, last_poll_snapshot)
          │     └─ append publish_succeeded system_event
          │
          ├─ on Unipile reauth-category error:
          │     ├─ markConnectionReauth (status → 'reauth')
          │     ├─ append integration_degraded event w/ Reconnect chip
          │     └─ fall back to sandbox with nudge=true
          │
          └─ SANDBOX path:
                ├─ INSERT sandboxed_publishes (reason='no_credentials' | 'sandbox_only_channel')
                ├─ append publish_sandboxed system_event (w/ Connect nudge if applicable)
                └─ if autopilot ON → schedule monitor.linkedin.simulated (T+24h)

 Scheduled every 15 min: trigger/channels/linkedin-poll-engagement.ts
          │
          └─ for each live publish in its measurement window:
                ├─ client.users.getPost → fresh counters
                ├─ diff against last_poll_snapshot
                ├─ on bump: client.users.getAllPostComments + REST /reactions
                ├─ INSERT raw_events dedup'd on source_event_id
                └─ UPDATE publishes.last_poll_snapshot

Two state vocabularies, on purpose

DB publishes.state has 5 values: scheduled · publishing · live · failed · sandboxed. UI artifact publish_status.data.state has 6: the same five plus awaiting_confirmation, which only exists as an in-flight display state before any row is written. Separating them means the confirm step never leaks into the persisted publish lifecycle.

10 · Channel adapter pattern

Every channel is five touch-points. Adding a channel = changing those five files + nothing else.

  1. Registry entrylib/channels/registry.ts: id, label, publishTaskId, monitorTaskId, isReal, measurementWindowHours, supports.
  2. Adapter modulelib/channels/<channel>.ts: the actual API call + a Missing<Channel>CredentialsError typed error so the caller can distinguish creds-missing from transient failures.
  3. Policy caselib/channels/policy.ts: a branch in resolvePublishPath deciding real vs sandbox for this channel.
  4. Publish tasktrigger/channels/<channel>-publish.ts: a schemaTask with retry config, calls the adapter, writes publishes/sandboxed_publishes, dispatches monitor.
  5. Monitor tasktrigger/channels/<channel>-monitor.ts: runs 2 minutes after publish, writes raw_events.

Two channels ship: both with isReal: true.

  • sanity — relaxed post-cutover; real-path for any member of any account, including public_demo. The per-project quick_publish_enabled toggle is the UX knob controlling whether a confirmation prompt fires first.
  • linkedin — real-path only when the account has a live channel_connections row (Unipile provider). The policy gate returns sandbox as an upstream hint; the actual real/sandbox decision happens inside channel.linkedin.publish, which can read the live connection row. public_demo always lands in sandbox at publish-time even with a live connection — that was a conscious call to keep random demo-visitor actions off the team's real LinkedIn feed.

The policy gate is pure

resolvePublishPath(channel, account, membership, project) → 'real' | 'sandbox'

  if (!channel.isReal) return 'sandbox';

  if (channel.id === 'sanity') {
    // Single Caldrin-owned blog, env-configured. Any member of any account
    // (including public_demo) can publish. quick_publish_enabled governs
    // whether a confirmation prompt fires — UX knob, not a hard block.
    return 'real';
  }

  if (channel.id === 'linkedin') return 'sandbox';  // real/sandbox decided in the task

  return 'sandbox';   // default-deny — unknown channels never go real

Pure functions are testable and reviewable. The gate does one thing: decide. Everything else (confirmation, dispatch, monitor, polling) lives in the tool or task.

10.5 · Autopilot semantics

Autopilot does not automate the publish. Humans always click Publish at every product tier. Autopilot controls what happens after the publish — the loop-closing work: Monitor → Signal Synthesizer → Variant Comparator → Curator → new master_context version. One toggle per project: projects.auto_close_loop_enabled.

The loop below is identical either way. The difference is only what fires each step:

  • Autopilot OFF — user clicks "Run monitor" / "Compare variants" / "Update master context" chips. Loop is open until the user closes it.
  • Autopilot ON — Monitor auto-fires at T + channel.measurement_window_hours (from CHANNEL_REGISTRY, 24h default). Comparator auto-fires when both A+B have learnings. Curator runs on a daily cron at 02:00 UTC per project, rate-limited to 1/hour.

Schema note: projects.auto_publish_enabled was renamed to projects.auto_close_loop_enabled in Slice 4. The old name implied Autopilot generated and published autonomously — which was never the plan. Downstream code (context strip toggle, /api/projects/[id]/settings, project_status artifact) follows the same name.

Slice 5 added no new agent. It added real channel integrations (Unipile for LinkedIn), the channel_connections table + reauth UX, and the scheduled engagement poller that replaces the simulated monitor on the LinkedIn real path.

Loop-close architecture (shipped)

Every agent is one pure function (runSignalSynthesizer, runVariantComparator, runCurator), callable from two entry points: inline from an API route (manual path), or wrapped in a Trigger schemaTask (auto path). Same function body, two callers.


                 ┌────────────────────────────────────────────┐
                 │  HUMAN PUBLISHES A+B (always manual)       │
                 └─────────────────┬──────────────────────────┘
                                   │
          ┌────────────────────────┴────────────────────────┐
          │  projects.auto_close_loop_enabled?              │
          └───────────────┬─────────────────┬───────────────┘
                    OFF   │                 │   ON
                          │                 │
     ┌────────────────────┘                 └─────────────────────┐
     │ Loop is OPEN. Chips appear.                                 │ Monitor auto-
     │                                                             │ dispatches at
     │ [chip: Run monitor] / [chip: Simulate engagement]           │ T + channel.
     │                                                             │ measurement_
     │                                                             │ window_hours
     ▼                                                             ▼
 POST /api/publishes/:id/monitor                         (publish task sets delay)
 dispatches monitor-trigger task                                   │
                                                                   │
                        ┌──────────────────────────────┐           │
                        │ MONITOR TRIGGER TASK         │ ◀─────────┘
                        │ 1. read/simulate engagement  │
                        │ 2. insert raw_events         │
                        │ 3. runSignalSynthesizer()    │ ◀─ INLINE
                        │ 4. insert learnings row      │
                        │    (source_kind=engagement_  │
                        │     batch)                   │
                        │ 5. if autopilot ON && both   │
                        │    A+B have learnings →      │
                        │    dispatch comparator-task  │
                        └──────────────────────────────┘
                                    │
           ┌────────────────────────┴────────────────────────┐
           │                                                 │
   OFF: [chip: Compare variants]              ON: reactive dispatch
   POST /api/variant-pairs/:id/compare                       │
   (inline)                                  (trigger task wraps same fn)
           │                                                 │
           └──────────────────┬──────────────────────────────┘
                              ▼
                  runVariantComparator({ variantPairId })
                              │
                              ├─ insert learnings (variant_comparison)
                              ├─ append variant_pair_measured event
                              └─ return variant_performance artifact

                       ─────────────────────────────────────

   OFF: [chip: Update master context]         ON: daily cron 02:00 UTC
   POST /api/projects/:id/curate              (per project with unapplied
   (inline)                                    learnings, 1/hr rate limit)
           │                                                 │
           └──────────────────┬──────────────────────────────┘
                              ▼
                         runCurator({ projectId })
                              │
                              ├─ read unapplied learnings since current mc
                              ├─ Sonnet + Output.object(MasterContextSchema)
                              ├─ insert master_contexts row (v(n+1))
                              ├─ set learnings.applied_to_mc_version_id
                              ├─ append master_context_updated event
                              └─ return master_context_diff artifact

Invariants:

  • Monitor and Synthesizer are always co-located. Synthesizer never runs without a Monitor (or the LinkedIn poller) having just written events for the same publish.
  • Comparator only fires when both A and B have at least one engagement_batch learning for the same variant_pair_id.
  • Every autonomous step still appends a system_event in primary so the user can review the autonomous work on their next visit.
  • New learnings rows are append-only. applied_to_mc_version_id is the only field mutated after insert (by the Curator).
  • The LinkedIn poller dedupes on raw_events.source_event_id; re-running (e.g. via on-demand Run monitor) is safe.

LinkedIn engagement polling (Slice 5)

Unipile has no webhook source for post reactions / comments (the webhook enum covers messaging/account_status/users/calendar/email only). So LinkedIn engagement must be read by polling. Two entry points:

  • Scheduledtrigger/channels/linkedin-poll-engagement.ts, a Trigger.dev scheduled task running every 15 minutes. Loads the set of publishes rows where state='live' and the measurement window hasn't elapsed; for each, calls client.users.getPost({ account_id, post_id: unipile_social_id }), diffs against last_poll_snapshot, and on counter bumps paginates reactions + comments.
  • On-demand — the run_monitor action chip + /monitor slash command dispatches the same task body for a specific publish, then runs the Signal Synthesizer inline over the resulting raw_events. Useful in demos to not wait for the next 15-minute tick.

Each reaction / comment becomes one raw_events row, dedup'd on source_event_id. impressions are recorded as a single counter-delta event per poll cycle. On a Unipile reauth-category error, the poller flips the connection to reauth and emits an integration_degraded system_event with a Reconnect chip — same mechanism the publish task uses.

11 · Artifact system

The user-facing catalog is at /help/artifacts; this section is the mechanics.

  • Definition sourcelib/types/artifacts.ts: the ARTIFACT_TYPES tuple is the single source of truth for the enum; downstream imports it as a literal-union type.
  • Renderingcomponents/chat/message.tsx sees a tool-toolName part, extracts .output.artifact, passes to ArtifactRenderer. Renderer lazy-imports components/artifacts/<type>.tsx and mounts.
  • Validation — every renderer validates props.data with a Zod schema. Malformed payloads fall back to a collapsed <details> block showing raw JSON — the stream never crashes.
  • Persistence — on onFinish, the chat route walks the latest assistant message for tool-* parts and upserts artifacts rows idempotently on (conversation_id, turn_id, type).
  • Hydration — the server component for a conversation reads pinned_to_hub=true artifacts and renders them above the thread. project_status specifically gets its data rehydrated from live DB every render — the seeded row's values (master_context_version=null, last_audit_at=null) would drift otherwise.

12 · Persistence & source of truth

conversations.message_history (JSONB) is the canonical store — a full AI SDK v6 UIMessage[] array. Two writers:

  • /api/chat's onFinish overwrites the whole array with the post-stream value. AI SDK guarantees updated contains all prior messages + the new exchange.
  • append_conversation_message RPC atomically pushes a single message onto the JSONB array. Used only for background events (research_complete, publish_succeeded) where there is no in-flight stream to lose to.

A second-sourced artifacts table mirrors every rendered artifact so they can be pinned, linked, listed in a future hub view. The UNIQUE index on (conversation_id, turn_id, type) makes the ingestion idempotent on retry — if onFinish runs twice (rare but possible under cancellation), we don't double-write.

System-role messages (the background events) are filtered out of the array passed to convertToModelMessages — they're UI-only annotations. Anthropic rejects empty content blocks, and non-text parts of role=system messages convert to empty blocks. See the comment block in app/api/chat/route.ts.

Id-less assistant messages are also filtered before persist + render. If a stream fails mid-flight, the AI SDK sometimes emits a ghost message without an id. We drop those so they don't accumulate and cause re-render oddities.

13 · Realtime

The workspace uses @supabase/supabase-js's Realtime client to subscribe to conversations row UPDATEs for the current conversation. When a background Trigger task appends a system_event via the RPC, Postgres emits a Realtime event, the browser merges the new message_history into local React state, and the artifact stream re-renders.

The pinned project_status card also subscribes to channel_connections row UPDATEs for its account — so when a LinkedIn connection flips from pendinglive (via the Unipile webhook), or from livereauth (via the publish task or poller), the Connect / Reconnect / status pill updates without a refresh.

Reauth UX: when the LinkedIn publish task or the scheduled poller encounters a Unipile reauth-category error, markConnectionReauth flips the connection status and an integration_degraded system_event is appended. The card is rendered by the integration_degraded case in components/chat/system-event.tsx with a red left border and a Reconnect LinkedIn action chip. Clicking it re-enters the Hosted Auth flow scoped to the existing provider account.

This gives the "autonomous agent writes to your chat" UX with two subscriptions and zero polling. Backpressure is native — missed updates on reconnect are reconciled by a fetch on mount.

14 · Security model

  • RLS on every table. No row is readable without an auth context. Admin client bypasses RLS but never sees user input directly — always wrapped in a server route that authorises first.
  • SECURITY DEFINER + search_path = public, pg_temp on every function that elevates. Prevents search-path attacks where an attacker plants a shadow accounts table in their own schema.
  • Demo-owner promotion uses exact-match email lookup (unnest + lower(trim()) equality). Earlier position()-based substring match allowed a@gmail.com to match avishkaindula@gmail.com; caught and fixed in 20260419100006_slice3_hardening.sql.
  • Role gates/api/projects/[id]/settings only lets owner/admin flip quick_publish_enabled. Chat routes allow any member of the account (viewers included) to read + emit artifacts; write-heavy tools (publish) gate through the policy helper.
  • Channel policy gate is the second layer of defence. Even if a viewer could somehow invoke publish_variant, resolvePublishPath defaults to sandbox for any account kind × channel combo not explicitly allow-listed.
  • Service role key is never shipped to the browser. Only referenced in server components, route handlers, and Trigger tasks.
  • Idempotency keys on monitor dispatches prevent double-counted engagement rows if a publish retries.

15 · Trigger.dev integration

Configured in trigger.config.ts. We use schemaTask for every task so the payload is Zod-validated at the boundary — misshaped triggers fail fast instead of silently writing bad rows.

  • research-pipeline — long-running; fans out 7 domain subagents via batchTriggerAndWait; retries on transient web_search failures.
  • channel.sanity.publish — calls @sanity/client to create a draft post document, writes publishes, dispatches Sanity monitor.
  • channel.linkedin.publish — decides real vs. sandbox using channel_connections. Real path: client.users.createPost + getPost, persists unipile_social_id + last_poll_snapshot. Sandbox path: sandboxed_publishes row with optional Connect nudge.
  • channel.gmail.publish — same real/sandbox shape as LinkedIn but uses client.email.send. Persists unipile_email_id (later canonicalized by the mail_sent webhook from tracking id → real id), rfc_message_id, and gmail_recipient. No scheduled poller — inbound replies arrive via mail_received webhook.
  • monitor.sanity.simulated / monitor.linkedin.simulated / monitor.gmail.simulated — deterministic; writes raw_events; idempotent so a re-dispatch doesn't duplicate. The Gmail monitor runs the Synthesizer over real reply events already in raw_events and only seeds 1–2 simulated replies when none have arrived.
  • trigger/channels/linkedin-poll-engagement.tsscheduled every 15 minutes. Iterates live LinkedIn publishes in window, reads fresh counters via Unipile, writes delta raw_events. Handles reauth errors.
  • trigger/loop/variant-comparator.ts — manual trigger or reactive dispatch after both A+B have engagement_batch learnings. Sonnet + Output.object; writes a variant_comparison learning and the variant_performance artifact.
  • trigger/loop/curator-daily.ts — scheduled daily at 02:00 UTC. For each project with auto_close_loop_enabled=true and at least one unapplied learning, runs the Curator and writes a new master_context version.

Trigger tasks run under a service-role admin client (never the user's JWT), so they can write tenant-crossing rows (e.g. monitor writes to a project row the user can't see from the browser). Access is still bounded: tasks only read/write the specific project_id in their payload.

16 · Slice roadmap

SliceScopeStatus
Pre-hack (GrowthIQ)7-domain research + master_context curator, proof-of-loop halfShipped — lives in references/veracity-hackathon-capital-coders/
Slice 1Auth + multi-tenant accounts + projects + public demoShipped
Slice 2Conversation workspace, Conductor, artifact library, research pipeline integrationShipped
Slice 3variant_pair + publish_status + Sanity + LinkedIn-sandbox + policy gate + Quick PublishShipped
Slice 4Signal Synthesizer + Variant Comparator + Curator (manual + autopilot routes). Renames auto_publish_enabledauto_close_loop_enabled. Adds learnings table.Shipped
Slice 5Real LinkedIn via Unipile Hosted Auth + createPost. channel_connections table with per-account Unipile session, reauth flow, integration_degraded system_event with Reconnect chip. Scheduled 15-minute engagement poller writing dedup'd raw_events. Sanity policy relaxed to any-member-any-account. Curator 1/hr rate limit removed for demo.Shipped
Slice 6Real Gmail A/B outreach via Unipile Hosted Auth + Google OAuth. mail_received webhook for reply ingestion (no polling). /email slash command with one recipient per variant. Reply chip + /reply slash command for threaded reply-backs. publishes.unipile_email_id + rfc_message_id + gmail_recipient columns. monitor.gmail.simulated Trigger task that runs the Synthesizer over webhook-delivered reply events (seeding 1–2 simulated replies only when none have arrived yet).Shipped
Slice 7+More channels (Outlook, Instagram, WhatsApp, X — all via Unipile), audio/video generationPlanned

17 · Environment variables

VariableWherePurpose
NEXT_PUBLIC_SUPABASE_URLapp + browserSupabase project REST URL
NEXT_PUBLIC_SUPABASE_ANON_KEYbrowserAnon key for client-side auth + Realtime
SUPABASE_SERVICE_ROLE_KEYserver onlyAdmin client inside route handlers + Trigger tasks
AI_GATEWAY_API_KEYserver + TriggerVercel AI Gateway credential — routes to Anthropic
ANTHROPIC_API_KEYoptionalDirect provider fallback. Prefer Gateway.
SANITY_PROJECT_IDserver + TriggerSanity blog channel adapter
SANITY_DATASETserver + TriggerUsually production; draft docs use the drafts. prefix
SANITY_API_TOKENserver + TriggerWrite-scoped token. Without it, real-path Sanity throws MissingSanityCredentialsError; falls back to sandbox.
UNIPILE_DSNserver + TriggerTenant base URL from the Unipile dashboard — e.g. https://api9.unipile.com:13939. SDK constructor takes this as the first arg.
UNIPILE_API_KEYserver + TriggerAccess token sent as X-API-KEY on every Unipile request. Grant Accounts:*, Users:*, Webhooks:* scopes.
UNIPILE_WEBHOOK_URLserver (used when creating Hosted Auth links)Public webhook URL Unipile POSTs to on auth outcome — <APP_URL>/api/webhooks/unipile/accounts.
UNIPILE_DEMO_MODEoptionalWhen '1', suppresses integration_degraded events on reauth — useful for hackathon demos where reauth noise distracts from the narrative.
CALDRIN_DEMO_OWNER_EMAILSSupabase DB config (app.demo_owner_emails)Comma-separated allow-list for demo-account owner promotion. Exact match, case-insensitive.
TRIGGER_SECRET_KEYserver + TriggerAuthenticates tasks.trigger() from the Next app
NEXT_PUBLIC_APP_URLapp + OAuthhttp://localhost:3000 locally, https://caldringrowth.com in prod. Wired into OAuth callback + Unipile redirect URLs.

18 · Local development

First-time setup:

# 1 Install deps
pnpm install

# 2 Start local Supabase (Docker required)
supabase start        # ~2 min first boot

# 3 Apply migrations + regenerate types
pnpm db:reset         # runs: supabase db reset && pnpm gen-types

# 4 Copy .env.example → .env, fill in:
#     NEXT_PUBLIC_SUPABASE_URL  (from `supabase status`)
#     NEXT_PUBLIC_SUPABASE_ANON_KEY
#     SUPABASE_SERVICE_ROLE_KEY
#     AI_GATEWAY_API_KEY
#     SANITY_*  (optional — leave blank to force sandbox on Sanity too)
#     UNIPILE_DSN + UNIPILE_API_KEY  (optional — without these, LinkedIn stays sandbox)
#     UNIPILE_WEBHOOK_URL  (public URL for Unipile auth-outcome webhook)
#     TRIGGER_SECRET_KEY  (from your Trigger.dev dashboard)

# 5 Run the stack (two terminals)
pnpm dev                             # Next on :3000
pnpm dlx trigger.dev@latest dev      # Trigger worker — connects to your TD project

Day-to-day:

  • pnpm db:reset after editing a migration — wipes + reapplies + regenerates types
  • pnpm gen-types on its own regenerates lib/types/database.types.ts from a live DB
  • Postgres CLI into local: psql "$(supabase status -o env | grep DB_URL | cut -d= -f2)"
  • Trigger worker auto-reloads on every file change in trigger/

19 · Deployment

Three targets, three commands:

  1. Supabase (migrations + config):
    supabase db push
    For app.demo_owner_emails: set via the Supabase dashboard under Project Settings → Database → Custom config, or as a PG parameter.
  2. Trigger.dev (tasks):
    pnpm dlx trigger.dev@latest deploy
    Deploys every task in trigger/. Triggers run in their own execution environment; they need the same env vars as the app.
  3. Vercel (Next app):
    git push origin main   # auto-deploy via Vercel's GitHub integration
    Set production env vars in the Vercel dashboard (same list as .env.example). Production domain: caldringrowth.com. OAuth callback: https://caldringrowth.com/auth/callback.

Deployment order matters for breaking changes: always supabase db push before the Next deploy that depends on the new schema, and trigger.dev deploy after the Next deploy if the triggers consume new DB columns. For Slice 3 the order is: migrations → trigger tasks → Next app.

20 · Code layout

app/
  app/                        # Authenticated app routes
    a/[accountSlug]/          # Account scope
      p/[projectSlug]/        # Project scope
        c/[conversationSlug]/ # Conversation scope (the main workspace page)
  help/                       # Public documentation (you're here) — no auth
  api/                        # Route handlers — no RSC
    chat/route.ts             # Conductor entrypoint (streamText + tools + onFinish)
    channels/linkedin/connect/route.ts        # Unipile Hosted Auth link creation
    webhooks/unipile/accounts/route.ts        # Unipile auth outcome webhook
    conversations/[id]/suggest-title/route.ts
    projects/[id]/settings/route.ts

components/
  app/                        # Shell chrome — context-strip, switchers
  chat/                       # Workspace, message, composer, slash palette
    system-event.tsx          # Renders system_events including integration_degraded + Reconnect chip
  artifacts/                  # One file per artifact type — typed, Zod-validated

lib/
  ai/
    agents/                   # conductor.ts, content-generator.ts, curator-seed.ts, domain-subagent.ts, synthesizer.ts, comparator.ts, curator.ts
    providers.ts              # MODELS — gateway string map
    tools/conductor/          # Conductor tools, one file each
  accounts/                   # server-side helpers for accounts / memberships
  channels/                   # registry.ts · policy.ts · sanity.ts · linkedin.ts · unipile.ts · linkedin-poll.ts
  channel-connections/        # server.ts — insert/update/markConnectionReauth
  conversations/              # server.ts · messages.ts · events.ts (background)
  projects/                   # server.ts · ensure-research.ts
  publishes/server.ts         # insertPublish (with unipileSocialId + lastPollSnapshot) · insertSandboxedPublish · recordVariantPair
  chat/slash-commands.ts      # Palette definitions + /artifacts + /help reference overlays
  supabase/server.ts          # SSR createClient factory
  types/
    artifacts.ts              # ARTIFACT_TYPES source of truth
    database.types.ts         # Generated by `pnpm gen-types`

trigger/
  research-pipeline.ts
  channels/
    sanity-publish.ts · sanity-monitor.ts
    linkedin-publish.ts · linkedin-monitor.ts
    linkedin-poll-engagement.ts          ← Slice 5: scheduled every 15 min
    gmail-publish.ts · gmail-monitor.ts  ← Slice 6
  loop/
    variant-comparator.ts                ← Slice 4
    curator-daily.ts                     ← Slice 4: daily cron 02:00 UTC

docs/
  research/
    2026-04-20-unipile-api-reference.md  ← AUTHORITATIVE Slice 5 reference

supabase/migrations/          # ordered SQL files
  20260416*                   # Slice 1 — tenancy + research
  20260419000*                # Slice 2 — conversations + artifacts
  20260419100*                # Slice 3 — publishing (variant_pairs · publishes · raw_events · quick_publish · demo_owner)
  20260420100*                # Slice 4 + 5 — rename_auto_close_loop · learnings · channel_connections (+ realtime)

21 · How to extend

Add an artifact type

  1. Append the name to ARTIFACT_TYPES in lib/types/artifacts.ts.
  2. Create components/artifacts/<type>.tsx with a Zod schema at the top.
  3. Add a case to ArtifactRenderer's type switch (or rely on the existing lazy-import-by-name if your filename matches).
  4. Add an entry to ARTIFACT_REFERENCE in lib/chat/slash-commands.ts so /artifacts surfaces it.

Add a Conductor tool

  1. Create lib/ai/tools/conductor/<toolName>.ts exporting a factory that returns an AI SDK tool(...).
  2. Wire it in app/api/chat/route.ts's tools map.
  3. Update the Conductor system prompt (lib/ai/agents/conductor.ts) with an intent-keyword → tool mapping line.
  4. If the tool emits an artifact, do steps 1–4 of "Add an artifact type" in parallel.

Add a channel

See the publishing page — 5 files, no other code needs to move.

Add a Trigger task

  1. Use schemaTask (never task) so the payload is Zod-validated.
  2. Set an explicit retry config. Default to maxAttempts: 3, exponential 500ms → 30s.
  3. Use idempotencyKeys.create() for any sub-trigger whose side effect must be exactly-once.
  4. Use the admin Supabase client — tasks never have a user JWT.

Add a migration

  1. New file under supabase/migrations/, timestamp-prefixed. Follow the existing naming: YYYYMMDDNNNNNN_what.sql.
  2. Enable RLS on any new table + author policies before writing seed data.
  3. If you're creating a SECURITY DEFINER function, always set search_path = public, pg_temp.
  4. pnpm db:reset locally — type regeneration is part of it.
  5. Never ALTER existing migrations — add a new one. Migrations are append-only.

Colophon

Caldrin Growth · Final round submission to the vectoragents.ai "From Signal to Action" hackathon. Architected + built by Avishka Indula (architecture + codebase), with Akshay (signal research + Hubspot/LinkedIn seats) and Danul (social seats + demo marketing site). Production domain: caldringrowth.com.