Core concepts
The data-model shape, the routing rules, and the architectural choices you'll want to know when using or extending the system.
Accounts, projects, conversations
- Account — a tenant. Three kinds:
personal,team,public_demo(only one — the Caldrin Demo). - Project — a subject you're researching. Belongs to an account. Gets its own master_context, signals, and conversations.
- Conversation — a chat thread. Every project has one primary conversation (auto-created, cannot be deleted). You can create any number of named conversations via the switcher.
Master context
The master_context is the system's crystallised understanding of a project — positioning, audience segments, voice guidelines, proven angles. It's versioned (v1, v2, …) with exactly one current version per project at any time.
New versions are produced by the Curator (Seed) at the end of the initial research pipeline, or by the Curator (Daily / On-demand) once the loop closes and unapplied learnings accumulate. Every version is append-only — you can always diff an old version against the current one via the master_context_diff artifact.
Research signals
Signals are the raw evidence collected by the 7-domain research pipeline. Each signal has a domain tag, content, source URL, a confidence score, and a is_fact flag (fact vs. interpretation).
The 7 PESTEL domains are:
- market_trends — where the category is moving
- competitive_intel — what competitors are doing
- audience_intent — what customers are signalling they want
- channel_campaign_intel — which channels + messages are working
- win_loss_conversion — why deals are won or lost
- adjacent_threats — risks from adjacent categories
- contextual_temporal — time-sensitive signals (news, policy, hiring trends)
Channel connections
A channel_connections row is one per (account × channel × provider). Both LinkedIn and Gmail use Unipile as the provider — LinkedIn authenticates via a cookie session, Gmail via Google OAuth consent on Unipile's verified app. Status is an enum: pending · live · reauth · failed.
- pending — Hosted Auth link generated, user hasn't completed yet.
- live — Unipile confirmed the session; publishes to that channel go real.
- reauth — LinkedIn checkpoint (captcha / phone) or Gmail token expiry; publishes fall back to sandbox with a "Reconnect" chip on the
integration_degradedevent. - failed — terminal error during auth; user must start a new connection.
Connections are tied to the account, not the project — one LinkedIn + one Gmail connection under an account powers every project in it. See the publishing page for how the decision tree uses the connection status.
The Conductor + its tools
One Conductor (Claude Sonnet 4.6 through the Vercel AI Gateway) orchestrates the chat with its tool belt. There is no hidden sub-agent dispatcher at the chat layer — the Conductor picks the right tool based on intent keywords in its system prompt, emits the artifact, and ends with a suggest_follow_ups call.
show_master_context— render current master_context_viewlist_recent_signals— render research_brief of recent signalskickoff_research— trigger the 7-domain pipeline (24h idempotent)analyze_domain— focused sub-60s single-domain researchgenerate_artifact— emit any visualization artifact typegenerate_document— emit a downloadable PPTX / DOCX / XLSX / CSVgenerate_variants— draft an A/Bvariant_pairfor a channelpublish_variant— dispatch to a channel publish tasksuggest_follow_ups— 2–4 clickable chips above the composer
The 10-agent growth stack
Multi-agent doesn't mean agent soup. Caldrin Growth has one orchestrator (the Conductor) and a set of specialists invoked through 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. Plus Autopilot — the timer-driven orchestrator that drives the same specialists from a cron rather than a chip click.
- Researcher — coordinates the 7-domain audit (research-pipeline Trigger task).
- Domain Research subagents (×7, parallel) — one Sonnet call per PESTEL domain with web search; writes
research_signals. - Curator (Seed) — runs at the end of the initial research pipeline; produces the first
master_contextsrow. - Conductor — the chat orchestrator.
- Variant Generator — the Content Generator; invoked by
generate_variants; Sonnet +Output.objectwith per-channel Zod schema; emits avariant_pair. - Policy Gate —
resolvePublishPathinlib/channels/policy.ts; pure function of (channel, account, membership, project). - Publisher — the Trigger publish task per channel (
channel.sanity.publish,channel.linkedin.publish,channel.gmail.publish). - Monitor — the Trigger monitor task. For LinkedIn, also the scheduled polling task (
linkedin-poll-engagement) that diffs counters every 15 minutes. For Gmail, replies arrive via the/api/webhooks/unipile/emailwebhook (no polling). - Signal Synthesizer — reads
raw_eventsfor one publish and writes oneengagement_batchlearning row. - Variant Comparator — once both A and B have learnings, writes a
variant_comparisonlearning and emitsvariant_pair_measured; renders avariant_performanceartifact. - Curator (Daily / On-demand) — reads unapplied learnings, writes
master_contextsv(n+1), renders amaster_context_diffartifact. - Autopilot — not a new agent; the daily cron + per-publish delays that fire Monitor → Comparator → Curator automatically when
projects.auto_close_loop_enabled=true.
Background events & routing
Background events are system_event messages that land in a conversation from outside the stream — a completed research run, a successful publish, a variant-pair measurement, an integration_degraded event when a LinkedIn or Gmail session needs reauth. They appear as colour-accented cards with action chips.
Routing rule:
- Autonomous events (the system acted without a specific conversation asking) → always primary. Examples: scheduled-autopilot run completing, LinkedIn reauth triggered by the polling task, Gmail replies ingested via the
mail_receivedwebhook. - User-initiated events (the conversation that triggered it) → that source conversation. Example: you asked to kick off research from conversation X, the result lands in X.
Implementation: appendSystemEvent({ projectId, sourceConversationId, ... }) in lib/conversations/events.ts — if sourceConversationId is null, the helper resolves primary.
Persistence model
conversations.message_history(JSONB) is the source of truth — full AI SDK v6UIMessage[]array, atomically appended via theappend_conversation_messageRPC for background events, full-array overwritten by/api/chat'sonFinishfor assistant turns.artifacts(relational table) mirrors every rendered artifact for future hub pinning. Upsert is keyed on(conversation_id, turn_id, type)— idempotent on retry.learnings(append-only) is the knowledge substrate.source_kind='engagement_batch'rows come from the Synthesizer,source_kind='variant_comparison'from the Comparator. Only mutation isapplied_to_mc_version_idset by the Curator.raw_events— append-only, dedup'd onsource_event_id. For LinkedIn publishes, this is the Unipile reaction/comment external ID (polled every 15 min). For Gmail publishes, this is the Unipileemail_idof the inbound reply (pushed via webhook). Both land with the sameevent_type-tagged shape that the Synthesizer consumes.- Realtime: Supabase Realtime subscribes the workspace to
conversations+channel_connectionsrow updates. Background events and connection-status changes appear in the UI without a page reload.
Publishing & channels
Every channel is registered in lib/channels/registry.ts with an isReal flag, two Trigger task IDs (publish + monitor), and a measurement window. Sanity, LinkedIn, and Gmail all have isReal: true.
The resolvePublishPath helper in lib/channels/policy.ts is a pure function of (channel, account, membership, project). Sanity resolves to real for any member of any account — including public_demo — because it's a single Caldrin-owned blog and the per-projectquick_publish_enabled toggle is the UX knob that governs confirmation. LinkedIn and Gmail resolve their real/sandbox decision inside their publish task: if the account has a live channel_connections row for the channel and isn't the public_demo account, publish goes real via client.users.createPost (LinkedIn) or client.email.send (Gmail); otherwise it lands in sandbox (with a "Connect ... for real" nudge chip when the account could go real).
Each publish dispatch creates one row in publishes or sandboxed_publishes. For real LinkedIn publishes, publishes.unipile_social_id and publishes.last_poll_snapshot are populated at publish-time from client.users.getPost; the scheduled poller then writes delta raw_events. For real Gmail publishes, publishes.unipile_email_id, rfc_message_id, and gmail_recipient are populated at publish-time; inbound replies correlate via in_reply_to.id === unipile_email_id in the mail_received webhook handler, which writes raw_events directly. The full flow is documented on the publishing page.
Quick Publish toggle
Owners and admins see a Quick Publish button in the context strip. When on, real-path publishes skip the confirmation step; when off (default) every real publish lands in the artifact state awaiting_confirmation and requires a click. POST /api/projects/[id]/settings is the only route that can flip it; the handler enforces owner/admin via account_memberships.role.
Autopilot — what it actually means
Humans always click Publish. Autopilot is about what happens AFTER the publish — the loop-closing work (Monitor → Signal Synthesizer → Variant Comparator → Curator → new master_context version). Generation and publishing themselves stay in human hands at every tier of the product. The toggle's column in the DB is projects.auto_close_loop_enabled.
Autopilot OFF · manual / demo route
The user drives every step of the loop after publishing. Great for demos (each click is a visible cause and effect) and for real users who want tight editorial control.
- Human publishes a variant pair.
- Human engages with the channel (adds comments, reacts, lets the post breathe). For real LinkedIn publishes, the scheduled 15-minute poller is already quietly writing
raw_eventsas engagement arrives. - Human clicks "Run Monitor" on the publish_succeeded event → for LinkedIn this triggers the poller on-demand, then runs the Signal Synthesizer inline over the resulting raw_events, writing one
learningsrow per variant. - Human clicks "Compare variants" once both A and B have learnings → Variant Comparator runs, writes a
variant_comparisonlearning, emitsvariant_pair_measured. - Human clicks "Update master context" → Curator reads new learnings, writes
master_contextsv(n+1), emitsmaster_context_updated.
Autopilot ON · automated route
Same exact agents, same exact learnings — but the post-publish steps fire on timers instead of clicks. Good for production use where editorial oversight happens at the master_context level, not per-publish.
- Human publishes a variant pair.
- Monitor auto-dispatches at
T + channel.measurement_window_hours(24h by default for Sanity + LinkedIn). For LinkedIn, the scheduled 15-minute poller is accumulatingraw_eventsthe whole time. - Signal Synthesizer runs inline after Monitor.
- Variant Comparator auto-fires once both A and B have engagement_batch learnings.
- Curator runs on a daily cron at 02:00 UTC per project with unapplied learnings. The previous 1/hour rate limit was removed for demo.
Every autonomous step still surfaces as a system_event in the primary conversation — the difference is just who clicked.