Help & Documentation

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:

  1. market_trends — where the category is moving
  2. competitive_intel — what competitors are doing
  3. audience_intent — what customers are signalling they want
  4. channel_campaign_intel — which channels + messages are working
  5. win_loss_conversion — why deals are won or lost
  6. adjacent_threats — risks from adjacent categories
  7. 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_degraded event.
  • 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_view
  • list_recent_signals — render research_brief of recent signals
  • kickoff_research — trigger the 7-domain pipeline (24h idempotent)
  • analyze_domain — focused sub-60s single-domain research
  • generate_artifact — emit any visualization artifact type
  • generate_document — emit a downloadable PPTX / DOCX / XLSX / CSV
  • generate_variants — draft an A/B variant_pair for a channel
  • publish_variant — dispatch to a channel publish task
  • suggest_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.

  1. Researcher — coordinates the 7-domain audit (research-pipeline Trigger task).
  2. Domain Research subagents (×7, parallel) — one Sonnet call per PESTEL domain with web search; writes research_signals.
  3. Curator (Seed) — runs at the end of the initial research pipeline; produces the first master_contexts row.
  4. Conductor — the chat orchestrator.
  5. Variant Generator — the Content Generator; invoked by generate_variants; Sonnet + Output.object with per-channel Zod schema; emits a variant_pair.
  6. Policy GateresolvePublishPath in lib/channels/policy.ts; pure function of (channel, account, membership, project).
  7. Publisher — the Trigger publish task per channel (channel.sanity.publish, channel.linkedin.publish, channel.gmail.publish).
  8. 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/email webhook (no polling).
  9. Signal Synthesizer — reads raw_events for one publish and writes one engagement_batch learning row.
  10. Variant Comparator — once both A and B have learnings, writes a variant_comparison learning and emits variant_pair_measured; renders a variant_performance artifact.
  11. Curator (Daily / On-demand) — reads unapplied learnings, writes master_contexts v(n+1), renders a master_context_diff artifact.
  12. 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_received webhook.
  • 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 v6 UIMessage[] array, atomically appended via the append_conversation_message RPC for background events, full-array overwritten by /api/chat's onFinish for 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 is applied_to_mc_version_id set by the Curator.
  • raw_events — append-only, dedup'd on source_event_id. For LinkedIn publishes, this is the Unipile reaction/comment external ID (polled every 15 min). For Gmail publishes, this is the Unipile email_id of the inbound reply (pushed via webhook). Both land with the same event_type-tagged shape that the Synthesizer consumes.
  • Realtime: Supabase Realtime subscribes the workspace to conversations + channel_connections row 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.

  1. Human publishes a variant pair.
  2. 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_events as engagement arrives.
  3. 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 learnings row per variant.
  4. Human clicks "Compare variants" once both A and B have learnings → Variant Comparator runs, writes a variant_comparison learning, emits variant_pair_measured.
  5. Human clicks "Update master context" → Curator reads new learnings, writes master_contexts v(n+1), emits master_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.

  1. Human publishes a variant pair.
  2. Monitor auto-dispatches at T + channel.measurement_window_hours (24h by default for Sanity + LinkedIn). For LinkedIn, the scheduled 15-minute poller is accumulating raw_events the whole time.
  3. Signal Synthesizer runs inline after Monitor.
  4. Variant Comparator auto-fires once both A and B have engagement_batch learnings.
  5. 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.