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:
- Research — continuous PESTEL-style evidence collection across 7 domains, crystallised into a versioned
master_context. - Generate — draft A/B
variant_pairs for one channel, schema-validated per channel, grounded in the current master_context + recent signals. - 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.
/emailsends one email per variant (A to recipient 1, B to recipient 2). - 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 isin_reply_to.id === publishes.unipile_email_id. Both write dedup'draw_events. The Signal Synthesizer turns events into learnings, the Variant Comparator picks a winner, the Curator writes a newmaster_contextsversion.
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
| Concern | Choice | Why |
|---|---|---|
| Framework | Next.js 16 App Router on Vercel Fluid Compute | React 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. |
| Language | TypeScript (strict) | Generated Supabase types + Zod-validated tool IO + typed AI SDK tool calls. |
| UI | Tailwind + shadcn/ui + lucide-react | Copy-into-repo primitives; no runtime cost; full customisation. |
| Agents | Vercel AI SDK v6 | Uniform generateText / streamText / tool / Output.object API across providers; native to Next.js streaming. |
| LLM provider | Anthropic Claude via Vercel AI Gateway | Gateway gives zero-data-retention, model string routing ("anthropic/claude-sonnet-4-6"), built-in fallbacks, and observability. |
| Models | Sonnet 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 + Auth | Supabase (Postgres 17 + GoTrue) | RLS-first, Realtime, generated types, append-only learnings, service-role writes from route handlers + Trigger tasks. |
| Realtime | Supabase Realtime (Postgres changes) | UI subscribes to conversations + channel_connections row updates so background events and connection status stream live without polling. |
| Background jobs | Trigger.dev v4 | First-class retries, checkpointing, schemaTask validation, queues, scheduled tasks (15-min LinkedIn poller + daily Curator cron), delay + idempotency. |
| Blog channel | Sanity (@sanity/client) | Real-publish for any member of any account. Drafts post documents via the API — no schema deploy needed for dev. |
| Social channel | Unipile (unipile-node-sdk v1.9.x) for LinkedIn | Hosted Auth cookie session, client.users.createPost for publishing, client.users.getPost + getAllPostComments for engagement, REST /api/v1/posts/{id}/reactions for reactions. |
| Email channel | Unipile (unipile-node-sdk v1.9.x) for Gmail | Hosted 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. |
| Hosting | Vercel (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.usersrow. Mirrored toprofiles. - 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_urlaccounts— slug, display_name, kind (personal/team/public_demo), created_byaccount_memberships— (account_id, user_id, role). Roles:owner/admin/editor/viewer.- Trigger:
handle_new_useronauth.userscreates 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 fromauto_publish_enabledin Slice 4 — see Autopilot semantics below)research_signals— project_id, domain (7 PESTEL values as enum), content, source_url, confidence, is_factmaster_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) andlast_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.idmatches this),rfc_message_id(RFC 5322 Message-ID secondary fallback), andgmail_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_events— XOR 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 Unipileemail_idof the inbound reply or the AI-sentsent_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 fromauto_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') useprovider='unipile';provider_account_idis the Unipile account ID;statusenum (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.
| Agent | Model | Invoked by | Output |
|---|---|---|---|
| Conductor | Sonnet 4.6 | POST /api/chat on every user message | Streamed UI messages + one tool call + suggest_follow_ups terminator |
| Domain Subagents (×7) | Sonnet 4.6 + web search | research-pipeline Trigger task, fanned out one-per-domain | N signals INSERTed into research_signals with domain + confidence |
| Curator (Seed) | Sonnet 4.6 | End of research-pipeline, after all 7 subagents return | One master_context row (version bumped + is_current flipped atomically) |
| Content Generator | Sonnet 4.6 (Output.object) | generate_variants Conductor tool | Two 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_enabled | One 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 chip | Per-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 Suggester | Haiku 4.5 | /api/conversations/[id]/suggest-title, fired from onFinish | Updates conversations.title once per first-exchange |
| Signal Synthesizer | Sonnet 4.6 | Inline at end of each Monitor run (manual or auto); reads raw_events for one publish | One engagement_batch learning row per variant |
| Variant Comparator | Sonnet 4.6 | Manual 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.6 | Manual 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:
- Load conversation + project + master_context + signal count (parallel admin reads).
- Filter out role=
'system'background-event messages from the LLM input — they'd convert to empty text blocks and Anthropic rejects those. - Call
streamTextwith the Conductor's tools; stop atstepCountIs(10). - On
onFinish: persist the full updatedmessage_historyarray, ingest anytool-*parts asartifactsrows (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, emitsmaster_context_viewlist_recent_signals— optional domain filter, emitsresearch_briefkickoff_research— dispatchesresearch-pipelineon Trigger; idempotent per project/dayanalyze_domain— inline sub-60s single-domain research using a Sonnet subagentgenerate_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 adocument_export_cardgenerate_variants— invokes Content Generator; emitsvariant_pair. Triggered by/linkedin,/blog, or the generic/variants.publish_variant— dispatches the channel publish Trigger task; emitspublish_statusrun_monitor— on-demand: dispatches the LinkedIn poller (or simulated monitor) for a specific publish, then runs the Signal Synthesizer inlineseed_engagement— demo helper: inserts simulated likes/comments on a publish for imbalance testingcompare_variants— runs the Variant Comparator manually once both A+B have learnings; emitsvariant_performanceupdate_master_context— runs the Curator manually; emitsmaster_context_diffshow_learnings— lists recent learning rows for the projectsuggest_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:
- Mark
projects.research_status='running'+ storeresearch_trigger_run_id. - 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. - Bulk INSERT signals into
research_signals. - Invoke Curator (Sonnet) over all signals — produces a master_context payload.
- In one transaction: set all existing master_contexts for this project to
is_current=false, INSERT the new row withis_current=trueandversion_n = previous + 1. - Append a
research_completesystem_event to the primary conversation viaappend_conversation_messageRPC. - Emit a
project_audit_summaryartifact 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:
market_trends— category movementcompetitive_intel— competitor movesaudience_intent— signals of customer demandchannel_campaign_intel— which channels + messages winwin_loss_conversion— deal-level insightadjacent_threats— risk from adjacent categoriescontextual_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.
- Registry entry —
lib/channels/registry.ts: id, label, publishTaskId, monitorTaskId,isReal, measurementWindowHours, supports. - Adapter module —
lib/channels/<channel>.ts: the actual API call + aMissing<Channel>CredentialsErrortyped error so the caller can distinguish creds-missing from transient failures. - Policy case —
lib/channels/policy.ts: a branch inresolvePublishPathdeciding real vs sandbox for this channel. - Publish task —
trigger/channels/<channel>-publish.ts: aschemaTaskwith retry config, calls the adapter, writespublishes/sandboxed_publishes, dispatches monitor. - Monitor task —
trigger/channels/<channel>-monitor.ts: runs 2 minutes after publish, writesraw_events.
Two channels ship: both with isReal: true.
sanity— relaxed post-cutover; real-path for any member of any account, includingpublic_demo. The per-projectquick_publish_enabledtoggle is the UX knob controlling whether a confirmation prompt fires first.linkedin— real-path only when the account has a livechannel_connectionsrow (Unipile provider). The policy gate returns sandbox as an upstream hint; the actual real/sandbox decision happens insidechannel.linkedin.publish, which can read the live connection row.public_demoalways 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 realPure 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(fromCHANNEL_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_batchlearning for the samevariant_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
learningsrows are append-only.applied_to_mc_version_idis 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:
- Scheduled —
trigger/channels/linkedin-poll-engagement.ts, a Trigger.dev scheduled task running every 15 minutes. Loads the set ofpublishesrows wherestate='live'and the measurement window hasn't elapsed; for each, callsclient.users.getPost({ account_id, post_id: unipile_social_id }), diffs againstlast_poll_snapshot, and on counter bumps paginates reactions + comments. - On-demand — the
run_monitoraction chip +/monitorslash 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 source —
lib/types/artifacts.ts: theARTIFACT_TYPEStuple is the single source of truth for the enum; downstream imports it as a literal-union type. - Rendering —
components/chat/message.tsxsees atool-toolNamepart, extracts.output.artifact, passes toArtifactRenderer. Renderer lazy-importscomponents/artifacts/<type>.tsxand mounts. - Validation — every renderer validates
props.datawith 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 fortool-*parts and upsertsartifactsrows idempotently on (conversation_id, turn_id, type). - Hydration — the server component for a conversation reads
pinned_to_hub=trueartifacts and renders them above the thread.project_statusspecifically 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
onFinishoverwrites the whole array with the post-stream value. AI SDK guaranteesupdatedcontains all prior messages + the new exchange. append_conversation_messageRPC 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 pending → live (via the Unipile webhook), or from live → reauth (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_tempon every function that elevates. Prevents search-path attacks where an attacker plants a shadowaccountstable in their own schema.- Demo-owner promotion uses exact-match email lookup (
unnest+lower(trim())equality). Earlierposition()-based substring match alloweda@gmail.comto matchavishkaindula@gmail.com; caught and fixed in20260419100006_slice3_hardening.sql. - Role gates —
/api/projects/[id]/settingsonly lets owner/admin flipquick_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,resolvePublishPathdefaults tosandboxfor 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 viabatchTriggerAndWait; retries on transient web_search failures.channel.sanity.publish— calls@sanity/clientto create a draftpostdocument, writespublishes, dispatches Sanity monitor.channel.linkedin.publish— decides real vs. sandbox usingchannel_connections. Real path:client.users.createPost+getPost, persistsunipile_social_id+last_poll_snapshot. Sandbox path:sandboxed_publishesrow with optional Connect nudge.channel.gmail.publish— same real/sandbox shape as LinkedIn but usesclient.email.send. Persistsunipile_email_id(later canonicalized by themail_sentwebhook from tracking id → real id),rfc_message_id, andgmail_recipient. No scheduled poller — inbound replies arrive viamail_receivedwebhook.monitor.sanity.simulated/monitor.linkedin.simulated/monitor.gmail.simulated— deterministic; writesraw_events; idempotent so a re-dispatch doesn't duplicate. The Gmail monitor runs the Synthesizer over real reply events already inraw_eventsand only seeds 1–2 simulated replies when none have arrived.trigger/channels/linkedin-poll-engagement.ts— scheduled 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 avariant_comparisonlearning and thevariant_performanceartifact.trigger/loop/curator-daily.ts— scheduled daily at 02:00 UTC. For each project withauto_close_loop_enabled=trueand 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
| Slice | Scope | Status |
|---|---|---|
| Pre-hack (GrowthIQ) | 7-domain research + master_context curator, proof-of-loop half | Shipped — lives in references/veracity-hackathon-capital-coders/ |
| Slice 1 | Auth + multi-tenant accounts + projects + public demo | Shipped |
| Slice 2 | Conversation workspace, Conductor, artifact library, research pipeline integration | Shipped |
| Slice 3 | variant_pair + publish_status + Sanity + LinkedIn-sandbox + policy gate + Quick Publish | Shipped |
| Slice 4 | Signal Synthesizer + Variant Comparator + Curator (manual + autopilot routes). Renames auto_publish_enabled → auto_close_loop_enabled. Adds learnings table. | Shipped |
| Slice 5 | Real 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 6 | Real 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 generation | Planned |
17 · Environment variables
| Variable | Where | Purpose |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | app + browser | Supabase project REST URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY | browser | Anon key for client-side auth + Realtime |
SUPABASE_SERVICE_ROLE_KEY | server only | Admin client inside route handlers + Trigger tasks |
AI_GATEWAY_API_KEY | server + Trigger | Vercel AI Gateway credential — routes to Anthropic |
ANTHROPIC_API_KEY | optional | Direct provider fallback. Prefer Gateway. |
SANITY_PROJECT_ID | server + Trigger | Sanity blog channel adapter |
SANITY_DATASET | server + Trigger | Usually production; draft docs use the drafts. prefix |
SANITY_API_TOKEN | server + Trigger | Write-scoped token. Without it, real-path Sanity throws MissingSanityCredentialsError; falls back to sandbox. |
UNIPILE_DSN | server + Trigger | Tenant base URL from the Unipile dashboard — e.g. https://api9.unipile.com:13939. SDK constructor takes this as the first arg. |
UNIPILE_API_KEY | server + Trigger | Access token sent as X-API-KEY on every Unipile request. Grant Accounts:*, Users:*, Webhooks:* scopes. |
UNIPILE_WEBHOOK_URL | server (used when creating Hosted Auth links) | Public webhook URL Unipile POSTs to on auth outcome — <APP_URL>/api/webhooks/unipile/accounts. |
UNIPILE_DEMO_MODE | optional | When '1', suppresses integration_degraded events on reauth — useful for hackathon demos where reauth noise distracts from the narrative. |
CALDRIN_DEMO_OWNER_EMAILS | Supabase DB config (app.demo_owner_emails) | Comma-separated allow-list for demo-account owner promotion. Exact match, case-insensitive. |
TRIGGER_SECRET_KEY | server + Trigger | Authenticates tasks.trigger() from the Next app |
NEXT_PUBLIC_APP_URL | app + OAuth | http://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:resetafter editing a migration — wipes + reapplies + regenerates typespnpm gen-typeson its own regenerateslib/types/database.types.tsfrom 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:
- Supabase (migrations + config):
supabase db push
Forapp.demo_owner_emails: set via the Supabase dashboard under Project Settings → Database → Custom config, or as a PG parameter. - Trigger.dev (tasks):
pnpm dlx trigger.dev@latest deploy
Deploys every task intrigger/. Triggers run in their own execution environment; they need the same env vars as the app. - 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
- Append the name to
ARTIFACT_TYPESinlib/types/artifacts.ts. - Create
components/artifacts/<type>.tsxwith a Zod schema at the top. - Add a case to
ArtifactRenderer's type switch (or rely on the existing lazy-import-by-name if your filename matches). - Add an entry to
ARTIFACT_REFERENCEinlib/chat/slash-commands.tsso/artifactssurfaces it.
Add a Conductor tool
- Create
lib/ai/tools/conductor/<toolName>.tsexporting a factory that returns an AI SDKtool(...). - Wire it in
app/api/chat/route.ts'stoolsmap. - Update the Conductor system prompt (
lib/ai/agents/conductor.ts) with an intent-keyword → tool mapping line. - 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
- Use
schemaTask(nevertask) so the payload is Zod-validated. - Set an explicit
retryconfig. Default tomaxAttempts: 3, exponential 500ms → 30s. - Use
idempotencyKeys.create()for any sub-trigger whose side effect must be exactly-once. - Use the admin Supabase client — tasks never have a user JWT.
Add a migration
- New file under
supabase/migrations/, timestamp-prefixed. Follow the existing naming:YYYYMMDDNNNNNN_what.sql. - Enable RLS on any new table + author policies before writing seed data.
- If you're creating a
SECURITY DEFINERfunction, always setsearch_path = public, pg_temp. pnpm db:resetlocally — type regeneration is part of it.- Never
ALTERexisting 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.