Dr.Social

Multi-tenant, talent-centric social-media orchestrator built for marketing agencies that manage many independent talents. White-label persona agents act in each talent's voice, owner-approved content queues gate every post, and channel-isolated execution keeps accounts safe.

Status: V1 design phase — MCP-channel platforms + full media generation. V1 covers every platform whose official API is rich enough to drive via Composio MCP: LinkedIn (full), X / Twitter, YouTube, Facebook, Reddit, and Instagram (read-only via Graph API). TikTok and Instagram-write are not in V1 — those require a Device backend that lands when our partner's GADS hub is reachable. See V2 §1 (Device channel) for the unblock plan, and ARCHITECTURE_V2.html for the full deferred set.

Live mockups: Prod — operator dashboard ↗  ·  Prod — talent portal ↗

Contents — V1

  1. Mission
  2. Multi-Tenancy & Talent Model
  3. V1 Assumptions (autonomous-mode choices)
  4. System Overview
  5. The 6 Layers
  6. Orchestration — 4 LLM Agents + Services
  7. LLM Provider (abfs.tech router)
  8. Platform Adapter Layer
  9. Execution Backend (MCP)
  10. Identity (V1 minimal set)
  11. Content Pipeline
  12. Post Request Pipeline
  13. Talent Asset Auto-Fetch (onboarding UX)
  14. Data Model
  15. Dashboards (operator + talent)
  16. Workflows & Scheduling
  17. Tech Stack
  18. Deployment (Railway)
  19. Repo Layout

1. Mission

A single system that can create, read, update, and delete on any major social platform. V1 covers everything whose official API is rich enough to drive through Composio MCP: LinkedIn X / Twitter YouTube Facebook Reddit Instagram (read-only). V2 adds TikTok and Instagram-write via the Device channel, and Threads via Browser.

Who this is for

Dr.Social is built for marketing agencies. A single agency is one tenant and manages many independent talents (not a group — each talent is an autonomous, real human personality with their own goals, voice, look, and audience). Every talent runs one or more accounts across the V1 platform set, and is represented end-to-end in the system by a rich talent profile stored in the database.

What a talent profile holds

The profile is the source of truth for everything the system does on behalf of a talent:

Capabilities (V1)

V1 platform scope: all platforms reachable through Composio MCP — LinkedIn (full), X / Twitter, YouTube, Facebook, Reddit, plus Instagram read-only via the Graph API. TikTok + Instagram-write → V2 §1 (those require the GADS device hub). The talent portal still shows a talent's TikTok and full-IG handles as profile data and as V2-pending placeholders so no UX migration is needed when Device unlocks.
Core safety rule: every social account is permanently bound to one execution channel for its entire lifetime — no mixing, no fallback, no migration. V1 has one channel (MCP); V2 adds Device, then Browser. Channel-binding is the single most important constraint in the system; it caps blast radius when a channel gets burned and prevents the behavioral inconsistencies that trip platform anti-fraud. See the isolation rule below.

1.b Multi-Tenancy & Talent Model

Dr.Social is a multi-tenant SaaS: every record in the system is owned by a tenant (a client of ours — an agency, a creator team, a brand). Inside a tenant, the unit of "who" is a talent — a real human personality whose social presence we manage. One talent can run many accounts, across many platforms, on any channel that's been wired up. This shape is the spine of the data model and shows up in every agent, every query, every dashboard view — independent of which channels are live in a given release.

tenant (a paying client of Dr.Social) │ ├── talents (the humans the tenant wants to be online — talent, founders, brand personas) │ │ │ ├── talent_profile (face refs, voice clone, style guide, brand kit, bio variants…) │ │ │ └── accounts (one per platform-per-channel; channel is immutable — see §8) │ │ │ ├── content, content_queue, jobs, events │ │ │ └── threads / messages / comments │ └── tenant_users (agency operators)

🏢 Tenant

  • The billing entity and the security boundary.
  • Every non-global table carries tenant_id NOT NULL.
  • Supabase Row-Level Security (RLS) enforces tenant isolation at the database — even a bug in application code can't leak data across tenants.
  • Operators belong to a tenant via Supabase Auth + a tenant_users table with roles (owner / editor / viewer).

🧑 Talent

  • A real, independent human persona — the entity the social content is "about." An agency-tenant typically manages many of these.
  • Identity & contact: real name, display name, emails, phone, timezone, language, link to the owner-user who approves their content.
  • Personality & goals: tone-of-voice, personality narrative, stories/lore, content pillars, hashtag preferences, "do not say" list, KPIs and goals the talent is optimizing for.
  • Media library: face references, voice clone (opt-in), brand kit — reused across every active account. In V1 only LinkedIn consumes these, but they're authored once.
  • Connected accounts & secrets: per-account OAuth refresh tokens, vaulted under the talent.
  • Content queue & schedule: drafts, ready-to-post pieces — owned by the talent, not by individual accounts.
  • ContentAgent and PersonaAgent always condition generation and replies on this profile.

📲 Account

  • FK → talents.id. FK → tenants.id. One per (platform, talent, channel).
  • The account's channel is immutable. V1: 'mcp' only.
  • Talents can also have placeholder handles on TT/IG saved as profile data — those become real accounts when V2 channels light up.
  • Analytics roll up two ways: per-account (operational) and per-talent (cross-platform reach).

What "talent-centric" buys us

Deferred to V2: cross-platform audience identity resolution — "@aurora_fan_tt on TikTok = @aurorafan on IG = same human." V1 reports per-account follower stats only; V2 adds audience_members + audience_identities + merge UI. See V2 §7.
Tenant isolation is enforced in two layers: (1) Supabase RLS policies on every table — USING (tenant_id = auth.jwt() ->> 'tenant_id') for operator-facing queries. (2) Service-role queries made by the worker always include WHERE tenant_id = $job.tenant_id — verified in code review and via composite FKs (tenant_id, id) so the database refuses cross-tenant attachments.

1.c V1 Assumptions (planning-mode autonomous choices)

This section records the calls I made when the user explicitly asked me to keep the V1 stack as minimal as possible and not interrupt for clarification. Override any of these by editing this section + the relevant downstream sections.

1. V1 = every MCP-able platform

Device + Browser channels deferred. V1 covers everything Composio MCP can drive at the official-API level: LinkedIn (full), X, YouTube, Facebook, Reddit, and Instagram-read. TikTok and Instagram-write specifically wait on V2 §1 because they need a real device. Talents still profile their TT and full-IG handles in V1 so when Device unlocks, no UX migration is needed.

2. Media-gen via direct MCP only — Higgsfield in V1

ContentAgent generates text via the abfs.tech LLM router, and images + video via Higgsfield's direct MCP server (https://mcp.higgsfield.ai/mcp). Soul characters keep face/brand consistent across every output. FFmpeg bundles into the container for cuts, captions, and per-platform aspect-ratio swaps. Replicate and ElevenLabs are V2 supplements — only if Higgsfield's image or voice coverage falls short.

3. Voice-memo transcription is in V1

Email post-requests can attach voice memos and get them transcribed via the Whisper endpoint on the abfs.tech LLM router. Voice cloning for narration is opt-in per talent and uses whatever voice capability Higgsfield exposes; ElevenLabs joins only if needed.

4. Single Railway service

FastAPI api + asyncio worker in one process. Splits into two services only if/when V2 §13 triggers fire.

5. Frontend = htmx + Python templates

No Node toolchain, no React, no Vite. Polling at 5s instead of Supabase Realtime. React + Vite arrives in V2 §11 only when an interactive surface truly needs it.

6. pg_cron is "best-available"

Supabase Pro tier ($25/mo) is required for pg_cron. If V1 runs on Supabase Free tier instead, the worker uses a plain asyncio sleep-loop in the same process (functionally equivalent for one talent at a time). The scheduling code abstracts which one is in use.

7. Talent slug uniqueness

Tenant-globally unique. The catch-all email model means slugs collide across tenants if they're tenant-scoped, so we elevate to global. Talent display names can still repeat freely.

8. Composio one key

V1 uses one Composio workspace and one API key for the single (prod) environment. Splitting into per-env keys is trivial later if billing or scope-isolation ever demands it.

9. Cloudflare catch-all targets operator Gmail

For MVP, every +post@dr-social.app goes to easytoremind123@gmail.com. Gmail App Password + IMAP poll picks them up. Sender allowlists and a dedicated mailbox per talent join V2.

10. Approval-gate is non-bypassable in V1

Operator-override mode is in the schema and UI but defaults to off. Every post requires explicit talent approval before publish. No "force send" path in V1 — that's a customer-asks-for-it feature.

If any of the above turn out wrong: flip the assumption in this section first, then update the downstream sections that referenced it. The V2 doc tracks the "graduation triggers" for everything deferred.

2. System Overview

The system stacks into six layers. Each upper layer only knows about the layer immediately below it through a stable interface, so the lower layers are individually replaceable. V1 has a single execution backend (MCP); the platform adapter layer is built so adding Device / Browser channels in V2 doesn't touch agents above.

┌────────────────────────────────────────────┐ │ 👤 Dashboards (operator + talent /me) │ │ Python templates + htmx · poll for live │ └─────────────────────┬──────────────────────┘ │ ┌─────────────────────▼──────────────────────┐ │ 🧠 Orchestration │ │ 4 LLM agents (Persona · Content · │ │ Inbox · Moderation) + cron Workflow │ │ ↳ all LLM calls → abfs.tech router │ └─────────────────────┬──────────────────────┘ │ ┌─────────────────────▼──────────────────────┐ │ 🧩 Platform Adapter Interface │ │ LinkedIn (V1) · IG, TT, … (V2) │ └─────────────────────┬──────────────────────┘ │ ▼ ┌──────────────────┐ │ MCP │ │ Composio │ │ (official │ │ platform APIs) │ └────────┬─────────┘ │ ┌─────────────────────▼──────────────────────┐ │ 🔐 Identity (V1 minimal) │ │ OAuth refresh tokens · Supabase Vault │ └─────────────────────┬──────────────────────┘ │ ┌─────────────────────▼──────────────────────┐ │ 💾 Supabase (single backend) │ │ Postgres · Auth · Storage · Vault · │ │ pg_cron │ └────────────────────────────────────────────┘
What's not pictured (in V2, in this order): the Device execution channel + GADS hub (Appium against real iOS/Android phones — unlocks TikTok + Instagram → V2 §1), then the Browser execution channel + iproxy proxy fleet + per-account fingerprint pool + burn-detection loop → V2 §2–§5.

3. The 6 Layers

Layer 6 · Surface

Dashboards

Two server-rendered web UIs: operator dashboard (multi-talent supervision) and talent portal (/me, single-talent self-service with the approval queue). Python templates + htmx for partials. Polling for "live" panels at 5s.

Layer 5 · Orchestration

Agents & Workflow

4 LLM-driven agents (PersonaAgent, ContentAgent, InboxAgent, ModerationAgent) — all routing through the abfs.tech LLM router. Plus the cron Workflow handler and a handful of plain Python services (AccountImporter, RequestAgent for raw-media preprocessing, Analytics SQL).

Layer 4 · Platforms

Platform Adapter Interface

One transparent SocialPlatform contract. LinkedInAdapter ships in V1; InstagramAdapter + TikTokAdapter stubs exist but are inert until V2 §1 wires up Device. Adding Twitter or YouTube means writing one more adapter — agents above don't change.

Layer 3 · Execution

One Backend (V1: MCP)

MCP (Composio.dev) — official platform APIs over OAuth. The channel-isolation invariant still applies (one channel per account, immutable, no fallback) — it just operates trivially when only one channel exists. See channel isolation rule. Device channel arrives in V2 §1; Browser in V2 §2.

Layer 2 · Identity

V1 Minimal: OAuth Tokens

Supabase Vault holds OAuth refresh tokens issued by Composio at account-import time. No fingerprint pool, no proxy registry, no signup automation, no 2FA relay — those are V2 §2–§6. V1 talents bring existing LinkedIn accounts and OAuth-grant them via Composio.

Layer 1 · Data

Supabase — one backend for everything (multi-tenant)

Postgres for state (tenant-scoped via RLS), Supabase Storage for media, Supabase Vault for secrets, Auth for operator login, pg_cron for the workflow tick. No Redis, no Vaultwarden, no separate object store, no Realtime in V1 (polling is enough; Realtime arrives in V2 §10).

4. Orchestration — 4 LLM Agents + Services

V1 has exactly four agents that need an LLM in the loop — anything reasoning, generating, or classifying. Everything else is a plain Python module (no agent ceremony). Don't commit to a heavyweight agent framework (e.g. Google ADK A2A) until V2 actually needs peer-to-peer agent topology; V1's orchestration is a star (Workflow → agent) and a simple async function-call graph is enough.

LLM agent

🎭 PersonaAgent white-label, stateless

A stateless function: persona_decide(talent_id, context) → action[]. On every call it loads the talent's profile (personality, voice, stories, goals, media library, content pillars, "do not say" list, connected accounts) and decides what to post / reply to / wait on, in that talent's voice. No daemon, no long-lived instance — each tick fires a fresh call. Owns: "what to post next," "reply to this DM in voice," "draft this brief."

LLM agent

🎬 ContentAgent

Generates text + media across every V1 platform — always conditioned on the target talent's profile (voice, brand kit, style guide, do-not-say list). Brief in, post body + caption + image / video out. Delegates media generation to Higgsfield's direct MCP server (image + video with the talent's Soul character) and FFmpeg in-container for cuts, captions, and format swaps per platform. ContentAgent also selects from pre-uploaded talent_assets[kind='picture'|'video'] when no generation is needed.

LLM agent

📥 InboxAgent

Reads DMs, comments, mentions, replies on every V1 platform (via Composio MCP — subject to each platform's official-API scope). Classifies (lead / fan / spam / escalation). Drafts in-character replies via PersonaAgent for the talent or operator to approve. Polls on a cadence; webhooks where Composio exposes them.

LLM agent

🛡 ModerationAgent

Filters generated content and inbound messages for brand-rule / sensitivity / "do not say" violations before either is acted on. Rules-based first pass (literal "do not say" hits), LLM second pass for tone/brand judgement calls.

Services (plain Python — no agent ceremony)

service

⏱ Workflow

pg_cron-driven function. On each tick, iterates active talents and fans out work to the LLM agents. No reasoning of its own.

service

🪪 AccountImporter

Imports existing accounts (LinkedIn, X, YouTube, Facebook, Reddit, IG-read) via the Composio OAuth handshake. Validates by issuing a health-check call. No signup automation, no 2FA relay — those are V2 §6.

service

📤 PostAgent

Picks an account, picks a piece of approved content, picks the bound backend, publishes. Records the post ID. Folded into the platform adapter — not its own LLM agent in V1.

service

📝 RequestAgent

Pre-processes raw media attached to a post request: scene detection, OCR, Whisper transcription. Then hands off to ContentAgent for per-platform variant generation. No LLM directly — it's a pipeline of media-analysis calls.

service

🎨 MediaPipeline

Thin wrapper that ContentAgent calls for the actual generation. Routes everything to Higgsfield via its direct MCP server in V1 (Soul character for image+video). Per-platform format/length variants go through FFmpeg. Caches outputs in Supabase Storage keyed by content hash so the same brief never regenerates. The routing layer is config-driven — if Higgsfield doesn't cover a particular generation type, Replicate / ElevenLabs slot in via the same registry when V2 fires.

service

📸 AssetFetcher

Onboarding-UX helper. Pulls the talent's own photos and videos from their connected accounts (LinkedIn, X, YouTube, Facebook, Reddit, IG-read) via Composio MCP, scores them on face presence / sharpness / variety, and drops the top N into the talent's media library as pending_review. Talent curates from /me → Brand & voice; nothing trains a Soul character or feeds generation until they approve. See §10.b.

service

📊 Analytics

Aggregates post performance, account health, inbox SLAs. SQL queries, not an LLM agent. Feeds the dashboard and informs ContentAgent's next-brief decisions.

Example tick (cron → per-talent fan-out)

workflow.tick()
  for talent in tenants.active_talents(due_now):
      ctx = load_talent_context(talent)                  # profile, recent posts, inbox, queue

      for account in ctx.accounts:                       # V1: every MCP-channel account
          if persona_decide_should_post(talent, account, ctx):
              brief  = persona_decide_brief(talent, account, ctx)
              draft  = content_generate(talent, brief)         # ContentAgent
              if moderation_approve(draft):
                  content_queue.enqueue(talent, account, draft,
                                        status='awaiting_owner_approval')

          # publish only what the owner has already approved
          for piece in content_queue.due_and_approved(talent, account):
              post_publish(account, piece)                     # PostAgent / adapter

          for msg in inbox_fetch(account):                     # InboxAgent
              if moderation_approve_inbound(msg):
                  reply = persona_draft_reply(talent, msg)
                  inbox_send(account, msg.thread, reply,
                             status='awaiting_owner_approval')

Two invariants to notice: (1) no backend is selected at runtime — the dispatcher reads accounts.channel and refuses any other (V1: always 'mcp'). (2) nothing publishes without owner approval — every draft and reply lands in the queue and only the pieces the human owner has approved are eligible.

5. LLM Provider — abfs.tech router

All four LLM agents call a single internal provider: the abfs.tech LLM router at https://www.abfs.tech/v1/. The router is already operational and serves Anthropic Claude models through Anthropic-compatible and OpenAI-compatible endpoints, with subscription rotation and per-key logging. Dr.Social doesn't talk to Anthropic / OpenAI / any model provider directly — only to this router.

Endpoints

  • POST /v1/messages — Anthropic Messages API shape
  • POST /v1/chat/completions — OpenAI Chat Completions shape
  • GET /v1/models — list available model IDs
  • GET /health — router health

Auth

One API key for the single Dr.Social environment (prod). Sent as either Authorization: Bearer <key> or x-api-key: <key>. Keys are issued from the abfs.tech admin dashboard at /admin and stored in Supabase Vault on our side. The Railway env var is ABFS_LLM_API_KEY.

Streaming

Server-Sent Events on both endpoints. Anthropic streams pass through verbatim (message_start, content_block_delta, message_stop). For ContentAgent caption work we use buffered responses; for InboxAgent reply drafting we stream so the operator sees the draft as it composes.

Model selection

Literal model IDs pass through; /regex/flags syntax matches against the available model list and picks the best fit. V1 defaults: Sonnet for content generation + inbox drafting, Haiku for moderation classification, Opus for the rare expensive PersonaAgent calls (long-context, multi-account decisions).

Why this matters

# Pseudocode — every LLM call in Dr.Social goes through this client.
class LlmRouterClient:
    def __init__(self, base_url="https://www.abfs.tech", api_key=ENV["ABFS_LLM_API_KEY"]):
        self.base_url = base_url
        self.api_key  = api_key

    async def messages(self, *, model, system, messages, stream=False):
        # POST /v1/messages — Anthropic-shaped
        ...

    async def chat(self, *, model, messages, stream=False):
        # POST /v1/chat/completions — OpenAI-shaped
        ...

6. Platform Adapter Layer

Every social network is reduced to one Python interface. Agents above call this interface — they never know whether they're driving LinkedIn or Instagram or anything else.

class SocialPlatform(Protocol):
    name: str  # "linkedin" | "instagram" | "tiktok" | ...

    # account lifecycle (V1 = OAuth-import-only)
    async def import_account(self, profile: ImportedProfile, backend: Backend) -> Account: ...
    async def login(self, account: Account, backend: Backend) -> Session: ...
    async def logout(self, account: Account) -> None: ...
    async def health_check(self, account: Account) -> AccountHealth: ...

    # create / update / delete
    async def publish_post(self, account: Account, content: Content) -> PostRef: ...
    async def edit_post(self, account: Account, post: PostRef, patch: ContentPatch) -> PostRef: ...
    async def delete_post(self, account: Account, post: PostRef) -> None: ...

    # read
    async def read_feed(self, account: Account, limit: int) -> list[Post]: ...
    async def read_post(self, account: Account, post: PostRef) -> Post: ...
    async def read_dms(self, account: Account, since: datetime) -> list[Message]: ...
    async def read_comments(self, account: Account, post: PostRef) -> list[Comment]: ...
    async def read_mentions(self, account: Account, since: datetime) -> list[Mention]: ...

    # respond
    async def send_dm(self, account: Account, thread: ThreadRef, body: str, media: list = []) -> None: ...
    async def reply_comment(self, account: Account, comment: CommentRef, body: str) -> None: ...
    async def react(self, account: Account, target: Ref, reaction: str) -> None: ...
V1 — live

LinkedInAdapter

Articles, posts, DMs, comments, reactions. MCP via Composio. Cleanest API surface — the reference V1 implementation.

V1 — live

XAdapter (Twitter)

Tweets, threads, replies, quotes, DMs (Composio scope-dependent). MCP via Composio against the official X API.

V1 — live

YouTubeAdapter

Video upload, descriptions, community posts, comment replies. MCP via Composio against the YouTube Data API. Long-form video assets come from the V1 MediaPipeline.

V1 — live

FacebookAdapter

Page posts, photos, comments, replies. MCP via Composio against the Graph API. Personal-profile posting is not covered by the official API and stays out of V1.

V1 — live

RedditAdapter

Subreddit posts, comments, replies, modmail. MCP via Composio against the Reddit OAuth API.

V1 — partial (read-only)

InstagramAdapter

Read-only in V1 via the official Graph API: read feed, read comments, read mentions, read insights. Posting / DMs require the Device channel — those paths stay stubbed and light up with V2 §1.

V2 §1 — stub in V1

TikTokAdapter

Video upload, replies, DMs. The official Content Posting API is too narrow to drive at agency volume — implementation file exists as a typed stub so the adapter registry doesn't crash when a talent has TT accounts on their profile. Inert until V2 §1 lands the Device channel.

future

ThreadsAdapter, BlueskyAdapter…

Drop-in. Agents and dashboard pick them up automatically once registered.

7. Execution Backend (V1: MCP only)

V1 ships with one execution backend — MCP. Device + Browser channels are documented in V2 (their absence is what makes V1 tractable). The MCP backend itself is multi-vendor by design: every social platform or media vendor is reached through a per-platform MCP server URL, looked up at dispatch time from a registry.

MCP server registry — direct first, Composio fallback

Rule: if a platform or vendor exposes a direct MCP server, Dr.Social connects to it directly. Composio is used only for platforms that don't yet have direct MCP support. The registry is a per-deployment config table; swapping a platform from Composio to a direct MCP server is a config change with no agent-level rewrites.

Platform / vendorMCP sourceServer URLAuth
Higgsfield (image + video gen)directhttps://mcp.higgsfield.ai/mcpOAuth at first connect; refresh token vaulted
LinkedInComposioper Composio configCOMPOSIO_API_KEY + per-account OAuth
X / TwitterComposioper Composio configCOMPOSIO_API_KEY + per-account OAuth
YouTubeComposioper Composio configCOMPOSIO_API_KEY + per-account OAuth
FacebookComposioper Composio configCOMPOSIO_API_KEY + per-account OAuth
RedditComposioper Composio configCOMPOSIO_API_KEY + per-account OAuth
Instagram (read-only)Composioper Composio configCOMPOSIO_API_KEY + per-account OAuth

When an official MCP server for one of the Composio-routed platforms ships (e.g. an official LinkedIn MCP), we update the registry row and the worker starts using it on next deploy. No platform-adapter rewrite, no migration.

V1 — live

🔌 Composio MCP — fallback aggregator

  • Calls platform official REST/GraphQL endpoints via MCP tools.
  • OAuth-managed credentials per account.
  • Used for every social platform without a direct MCP server today: LinkedIn, X, YouTube, Facebook, Reddit, IG-read.
  • Subject to API-tier rate limits + feature coverage; TikTok and IG-write fall short of that bar and wait on Device.
V1 — live

🎨 Higgsfield MCP — direct vendor MCP

  • Image + video generation with per-talent Soul characters (face/brand consistent).
  • Worker connects to https://mcp.higgsfield.ai/mcp as an MCP client.
  • No API key — auth is OAuth at first connect (workspace sign-in); the refresh token is stored in Supabase Vault.
  • Per-tenant or per-talent connection — separate Higgsfield workspaces give per-talent media budgets.
V2 brings two more channels:

Capability matrix (V1) — per platform via MCP

Operation LinkedIn X YouTube Facebook Reddit Instagram TikTok
Post text (community)V2 §1
Post image / photon/aV2 §1
Post video / reel~V2 §1
Read feedV2 §1
Read comments / mentionsV2 §1
Reply to commentV2 §1
Read DMs~~n/a~ (modmail)V2 §1
Send DM~~n/a~ (modmail)V2 §1
Delete postV2 §1
Account signup→ V2 §6 (every platform)

= supported in V1. ~ = Composio scope-dependent (works if the OAuth grant includes the right scopes; some platforms have restricted DM access). = not in the official API, needs Device channel. n/a = the platform itself doesn't have the operation.

⛔ Channel isolation rule (hard constraint)

One account, one channel — forever.
Every social account is permanently bound to exactly one execution backend at enrollment time. V1: accounts.channel = 'mcp' always. V2 expands the CHECK to include 'device' then 'browser'. The binding is the account's identity. The orchestrator will refuse to act on an account through any other channel. There is no fallback, no failover, no "try the other backend." Ever.

Why this rule exists

  • Anti-correlation: platforms correlate device IDs, IPs, fingerprints, and OAuth grants. An account that suddenly hops between channels trips trust signals instantly.
  • Blast-radius cap: if a channel gets burned, only accounts on that channel are affected. Other channels stay clean.
  • Forensic clarity: when an account gets banned, the cause is unambiguous — you know which channel's signals to investigate without confounding variables.
  • Behavioral consistency: a real human uses one entry point. Mixing channels is super-human and statistically detectable.

What this rule enforces

  • The account's channel column is set on enrollment and is immutable at the DB level (CHECK + trigger that rejects updates).
  • The worker reads account.channel at job dispatch and loads only that backend. No required_channel duplicate column on jobs — the account row is the source of truth.
  • If an operation is requested on an account whose channel can't perform it (e.g. video upload on MCP), the operation is rejected at the orchestrator. Never silently rerouted.
  • If the channel becomes unavailable, the account is paused — not migrated.
  • Each channel has its own isolated identity primitives. In V1 there's only one: an 'mcp' account has an OAuth grant only. These never cross.
-- V1: only MCP is allowed
alter table accounts
  add column channel text not null
  check (channel in ('mcp'));         -- V2 expands: add 'device', then 'browser'

create or replace function lock_channel() returns trigger as $$
begin
  if NEW.channel is distinct from OLD.channel then
    raise exception 'channel is immutable on account %', OLD.id;
  end if;
  return NEW;
end $$ language plpgsql;

create trigger accounts_channel_immutable
  before update on accounts
  for each row execute function lock_channel();

-- worker dispatch (no `required_channel` on jobs; the account row is truth)
job = claim_next_job()
account = load(job.account_id)
backend = load_backend(account.channel)         -- V1: always Composio MCP
backend.execute(job)

8. Identity (V1 minimal set)

V1 needs the absolute minimum to act on existing MCP-channel accounts. Anti-bot stealth, signup automation, 2FA relay, fingerprint pools, proxy fleets — all V2.

🔐 Credential Vault

Supabase Vault — encrypted credentials stored in the same Postgres. In V1 it holds only OAuth refresh tokens issued by Composio. RLS restricts which service role can decrypt what; never decrypted into logs.

📂 Account import (V1)

Talents (or operators with talent permission) complete a Composio OAuth grant once per V1-platform account (LinkedIn, X, YouTube, Facebook, Reddit, IG-read). Validated by a health-check call. No automated signup — that's V2 §6. No cookie-paste import in V1 either — that path arrives with the Device channel in V2 §1.

🚨 Burn signal (V1 minimal)

Adapters report outcome (ok / soft-block / hard-block) on every call. V1 reaction: pause the account, alert the operator. MCP doesn't really "burn" the same way Browser/Device do — issues here are mostly rate limits or revoked OAuth scopes. Full burn-detection loop is V2 §5.

Deferred to V2: session-cookie credentials (Device channel), fingerprint pool (per-account stable browser fingerprints), iproxy.online mobile-carrier proxies, automated burn rotation, 2FA relay (Telegram bot + Dr.Emails IMAP worker), account signup automation. See V2 §1 for Device + V2 §2–§6 for the rest.

9. Content Pipeline

ContentBrief ──► ContentAgent ──► post body + media ──► ModerationAgent ──► PublishQueue ▲ │ │ │ ├─► LLM router (abfs.tech) — body, caption, hashtags, Whisper │ │ │ ├─► MediaPipeline (V1) │ │ ├─► Higgsfield (direct MCP) — image + video, Soul characters │ │ └─► FFmpeg (in-process) — cuts, captions, format/length per platform │ │ │ ├─► talent_assets[picture|video] — also available for selection │ └─► Supabase Storage (bytes) + content row in Postgres (metadata) │ Analytics (what worked last week → next brief)

10. Post Request Pipeline

Content reaches the approval queue from two distinct sources — both treated equally downstream:

Post requests matter because most talents have specific things they want said or shown that the AI won't dream up on its own — a launch, a reaction, a behind-the-scenes moment. Media is optional: a request can be just a prompt + platforms, just media + platforms, or both.

What a post request carries

Two ingress paths (both V1)

🌐 Browser form

The talent's /me portal exposes a "Request a post" form with a prominent platform picker (chips per connected account across all V1 platforms), a prompt textarea, and an optional drag-and-drop area for media. Attachments upload directly to Supabase Storage (per-talent path, RLS-scoped). The submit creates one post_requests row.

📥 Email via Cloudflare catch-all + Gmail IMAP poll

Each talent gets a stable address <talent-slug>+post@dr-social.app (e.g. aurora-lee+post@dr-social.app). DNS for dr-social.app is on Cloudflare; an Email Routing catch-all rule forwards every inbound message to one operator mailbox (currently easytoremind123@gmail.com for the MVP). The Dr.Social worker IMAP-polls that mailbox every 30s with an App Password, parses the original To: header to identify the talent, drops attachments into Storage, transcribes any voice memos for the prompt, and creates the same post_requests row as the browser form.

Pipeline stages

post_request (prompt? + target_accounts[] + media[]? + submitting user) │ ▼ ┌────────────────────┐ │ RequestAgent │ normalize + analyze (plain Python service) │ (service) │ · attachments: scene detection / cuts / OCR / transcript └─────────┬──────────┘ · prompt: intent classification, length hints │ ▼ one fan-out per target_account (V1: any of LinkedIn / X / YouTube / Facebook / Reddit) ┌────────────────────┐ │ ContentAgent │ produce a platform-tailored variant │ (LLM agent) │ · format per platform └─────────┬──────────┘ · caption in the talent's voice (via abfs.tech LLM router) │ ▼ ┌────────────────────┐ │ ModerationAgent │ brand-rule + sensitivity checks └─────────┬──────────┘ │ ▼ content_queue.status = awaiting_owner_approval (one row per platform variant, all linked to the same post_request) │ ▼ (talent clicks Approve per variant on /me) content_queue.status = approved │ ▼ PostAgent (service) publishes on the scheduled slot

What the pipeline produces

Schema additions (V1 sketch)

post_requests          (id, tenant_id, talent_id,
                        source CHECK in ('browser', 'email'),
                        prompt_text NULL,                  -- empty = let AI pick from pillars
                        target_account_ids[],              -- V1: any V1 platform; TT/IG-write disabled in picker
                        schedule_hint NULL,
                        sender_email NULL,                 -- populated for source='email'
                        gmail_message_id NULL UNIQUE,      -- populated for source='email'; dedup key
                        status in ('received','analyzing','drafting','complete','failed'),
                        created_at, processed_at)

post_request_attachments (id, post_request_id, storage_path, mime, bytes, duration_s,
                          analysis_json NULL,              -- scenes, faces, hooks, OCR …
                          transcript_text NULL)
                          -- zero rows if the request had no media

content_queue.origin            enum: 'ai_initiated' | 'request_browser' | 'request_email'
content_queue.post_request_id   NULL unless origin is 'request_*'
content_queue.rejection_reason  captured at owner rejection — fed into talent style guide

Talent slug rules

10.b Talent Asset Auto-Fetch (onboarding UX)

Asking a brand-new talent to "upload 10–30 photos of yourself plus voice samples and a brand kit" is the boring chore that kills onboarding. Most talents have years of media already on their connected social accounts — Dr.Social's V1 onboarding flow turns that into the path of least resistance by letting talents hand over their social handles and having agents fetch, score, and curate the best photos autonomously. The talent reviews a shortlist; they don't have to dig through their camera roll.

Consent model — clean by construction

The talent connects their social accounts during onboarding via the same Composio OAuth flow Dr.Social already uses for publishing. The OAuth grant gives Dr.Social authorized API access to that talent's own posts — no scraping, no third-party data, no consent ambiguity. Then a single explicit toggle in /me → Brand & voice:

"Auto-fetch my photos and videos from my connected accounts to train my AI." Off by default. When on, the AssetFetcher service starts pulling. The talent always sees and approves each fetched asset in a review pile before anything is used for Soul-character training or content generation.

Pipeline

Talent toggles "Auto-fetch" ON in /me Brand & voice │ ▼ ┌────────────────────┐ │ AssetFetcher │ plain Python service — no LLM in the loop │ (new service) │ · pulls posts via Composio MCP from each connected account └─────────┬──────────┘ (LinkedIn, X, YouTube, Facebook, Reddit, IG-read) │ ▼ ┌────────────────────┐ │ asset scorer │ for each image/video: │ (CPU-side) │ · face-detect (Haar / dlib) — keep frames with a clear face └─────────┬──────────┘ · resolution / blur (Laplacian variance) — keep sharp ones │ · variety penalty — don't pick 8 near-duplicates ▼ · profile-pic dedupe — skip the literal current avatar top N candidates (default N=20) │ ▼ talent_assets rows inserted with status='pending_review' │ ▼ talent opens /me → Brand & voice → "Review fetched photos (20)" │ approves / rejects each (or "Approve all 20") ▼ talent_assets.status = 'approved' → eligible for Higgsfield Soul training and ContentAgent "pick an image" selection

What the service does NOT do

Continuous mode

Once initial onboarding is done, the talent can leave auto-fetch on. A weekly cron run re-pulls the most recent N posts and adds anything new to the review pile. New posts are never auto-approved — the talent always curates. This keeps the asset library fresh without surprise content moving into training pools behind the talent's back.

Schema additions (V1)

-- talent_assets gains provenance + curation fields
talent_assets:
  ADD COLUMN source           CHECK in ('uploaded','auto_fetched_instagram','auto_fetched_linkedin',
                                       'auto_fetched_x','auto_fetched_youtube','auto_fetched_facebook',
                                       'auto_fetched_reddit') DEFAULT 'uploaded'
  ADD COLUMN source_post_id   TEXT NULL                  -- platform-side post id, for dedupe
  ADD COLUMN fetched_at       TIMESTAMPTZ NULL
  ADD COLUMN review_status    CHECK in ('approved','pending_review','rejected') DEFAULT 'approved'
  ADD COLUMN rejection_reason TEXT NULL
  ADD COLUMN face_score       REAL NULL                  -- 0..1, populated by scorer
  ADD COLUMN sharpness_score  REAL NULL                  -- Laplacian variance, normalized

talent_asset_denylist        (id, talent_id, platform, source_post_id, denied_at, reason)
                             -- rows the talent explicitly rejected; AssetFetcher skips these

Higgsfield training trigger

The talent's /me → Brand & voice panel shows a "Train my Soul character" button. It enables once ≥5 approved talent_assets[kind='face'] exist. Clicking the button hands those approved photos to Higgsfield's MCP server (train_soul_character tool call). The resulting Soul ID is stored on the talent profile and used by every subsequent image/video generation. Re-training is also a button — fires when the talent adds new approved face photos and wants the Soul model refreshed.

V2 upgrade: when V2 §1 Device channel lands and TikTok / Instagram-write activate, AssetFetcher gains those sources too (via the Device-channel session, not via Composio). Same review-pile UX; new source enum values: auto_fetched_tiktok, auto_fetched_instagram_full.

11. Data Model (V1)

~9 tables. Every tenant-scoped table carries tenant_id NOT NULL and is gated by an RLS policy. Composite FKs (tenant_id, id) on every cross-table reference make the database refuse cross-tenant attachments.

Tenancy & identity

tenants                (id, name, plan, status,
                        default_tick_interval_seconds,
                        created_at)
                       -- one row per agency. Per-talent override → V2 §9

tenant_users           (id, tenant_id, supabase_user_id,
                        role CHECK in ('owner','editor','viewer'))

talents                (id, tenant_id, slug UNIQUE,
                        display_name, real_name,
                        bio_short, bio_long, timezone, language, status,
                        owner_user_id,                       -- the real human who must approve
                        primary_email, contact_emails[], phone,
                        created_at)

talent_profile         (talent_id PK,
                        personality_md, stories_md,
                        goals_json,
                        face_ref_urls[], voice_clone_ref NULL,
                        brand_kit_json, style_guide_md,
                        content_pillars[], hashtag_preferences[], do_not_say[],
                        updated_at)
                       -- 1:1 with talents; split out only for size

talent_assets          (id, talent_id, kind in ('face','voice','logo','wardrobe','bgm',
                                                'picture','video','document'),
                        storage_url, hash, metadata_json, created_at)

Accounts & channel-bound identity

accounts               (id, tenant_id, talent_id, platform, handle, status,
                        channel CHECK (channel in ('mcp')) IMMUTABLE,
                        vault_ref,                          -- OAuth refresh token (Composio)
                        posting_schedule_json,
                        created_at)
                       -- one row per (platform, talent, channel)
                       -- V1: every row has channel='mcp';
                       --     platform ∈ ('linkedin','x','youtube','facebook','reddit','instagram')
                       --     (instagram in V1 is read-only via Graph API).
                       -- V2 §1 adds channel='device' for full-IG/TT; V2 §2 adds 'browser'.
Deferred schema (V2): fingerprints, iproxy_connections, devices, ip_rotations, sessions (Browser/Device-channel identity primitives). V1 only stores OAuth tokens in accounts.vault_ref.

Content, queue, requests

content                (id, tenant_id, talent_id, hash, type, asset_url,
                        caption, hashtags, generated_by, moderated, created_at)

content_queue          (id, tenant_id, talent_id, account_id, content_id,
                        origin enum ('ai_initiated','request_browser','request_email'),
                        post_request_id NULL,
                        scheduled_for,
                        state CHECK in
                          ('draft','awaiting_owner_approval','approved','rejected',
                           'queued','published','failed'),
                        approved_by_user_id, approved_at,
                        rejected_reason,
                        created_at, updated_at)
                       -- per-talent publish queue shown on /me

post_requests          (id, tenant_id, talent_id,
                        source CHECK in ('browser', 'email'),
                        prompt_text NULL,
                        target_account_ids[],
                        schedule_hint NULL,
                        sender_email NULL,
                        gmail_message_id NULL UNIQUE,
                        status in ('received','analyzing','drafting','complete','failed'),
                        created_at, processed_at)

post_request_attachments (id, post_request_id, storage_path, mime, bytes, duration_s,
                          analysis_json NULL, transcript_text NULL)

Inbox & activity

threads                (id, tenant_id, account_id, peer_handle, last_message_at)
                       -- V2 §7 adds audience_identity_id (cross-platform identity resolution)

messages               (id, tenant_id, thread_id, direction, body, media_urls,
                        classified_as, status, created_at)

jobs                   (id, tenant_id, account_id, kind, payload_json, status, run_after)
                       -- no required_channel column; account.channel is the source of truth

events                 (id, tenant_id, ts, kind, account_id, payload_json)
                       -- audit + activity stream

Invariants enforced in Postgres: (1) accounts.channel is immutable (trigger blocks UPDATE). (2) tenant_id consistency — every FK across tables uses composite (tenant_id, id), so the database refuses to attach an account in tenant A to a talent in tenant B. (3) RLS policies on every tenant-scoped table. (4) talents.slug is globally unique (cross-tenant) because of the catch-all email model.

V2 schema additions (kept here as a forward reference): channel-binding tables (V2 §1 + §2 + §3 + §4 + §5: devices, fingerprints, iproxy_connections, sessions, ip_rotations), audience identity (V2 §7: audience_members, audience_identities, audience_interactions, identity_link_signals), talent_request_senders (V2 §8), talents.tick_interval_seconds (V2 §9).

12. Dashboards (operator + talent self-service)

Two server-rendered web UIs, two different audiences, one shared backend. Both are tenant-scoped — Supabase RLS enforces tenant isolation on every query. Python templates + htmx for partials; polling at ~5s for "live" panels. (React + Vite frontend → V2 §11. Supabase Realtime → V2 §10.)

13.a — Operator dashboard (V1: 6 pages)

Top-level navigation pivots around talents, not accounts. Agency staff see all talents under their tenant, run overrides, supervise inboxes, and watch system health. V1 acts on every MCP-channel account (LinkedIn, X, YouTube, Facebook, Reddit, IG-read); TikTok and IG-write handles appear as profile-level placeholders waiting on V2 §1.

📊 Overview

Live activity stream, accounts grouped by talent, queue-next-24h snapshot, inbox-awaiting-operator, channel health (V1 shows only MCP active), plus an analytics rollup panel (folded in — no standalone Analytics page in V1).

🧑 Talents

Every talent in the tenant, with status, accounts grouped (V1 platforms marked active, TT/IG-write marked V2-pending), queue volume, profile completeness.

📲 Accounts

Every managed account across every platform. V1 active rows are MCP-channel (LinkedIn, X, YouTube, Facebook, Reddit, IG-read); placeholder rows for TikTok and IG-write are shown with a V2-pending pill and no actions enabled.

🗓 Publish Queue

All scheduled posts across the tenant: draft → moderated → awaiting owner approval → approved → queued → published. Moderation flags shown inline (no standalone Moderation page in V1). Operators can reorder, pull a piece, or force-approve (only when the talent has opted into operator-override).

💬 Inbox

In-character reply drafts from PersonaAgent pending approval, escalations, conversation timeline per peer, per-account inbox. V1 covers DMs (where the platform's official API exposes them — LinkedIn, X, Facebook, Reddit modmail) plus comments and mentions on every V1 platform including IG-read.

⚙ Settings

Integrations (Supabase, abfs.tech LLM router key, Composio, Higgsfield direct MCP, Gmail IMAP), team & roles, defaults (tenant tick cadence, content language, posting cap, voice-clone opt-in policy), billing, danger zone.

Deferred operator pages (V2): Audience explorer (V2 §7), Fingerprints (V2 §4), iproxy Fleet (V2 §3), Devices + MCP Grants (V2 §1 / channel-detail pages), Agents live-status, Jobs inspector, Audit Log UI, standalone Moderation, standalone Analytics. Each maps to the V2 feature that justifies adding it. See V2 §12.

13.b — Talent self-service portal (/me)

A standalone single-page workspace for the talent themselves. Scoped to one talent — the one whose owner_user_id matches the logged-in Supabase Auth user. Different chrome from the operator dashboard (warmer accent, no tenant switcher, no cross-talent navigation). Mockup: dashboard/me.html.

✅ Approval queue primary action

Pending content piece-by-piece, grouped by post idea with per-platform variants stacked under each idea. Tagged AI-initiated or from your request. The talent can approve each platform independently, or click "Approve all N variants" on the parent. Reject with a reason (fed into the style guide), edit, or reschedule. V1 variants cover LinkedIn / X / YouTube / Facebook / Reddit; TikTok and IG-write items appear greyed out with a V2 pill.

📝 Request a post

Form with a prominent platform picker (chips per connected account; TikTok and IG-write marked V2-pending), a prompt textarea, and an optional drag-and-drop area for media (including voice memos, transcribed by Whisper). Feeds the Post Request Pipeline. A "recent requests" list shows each request's status (analyzing → drafting → in queue).

👤 Profile (CRUD)

Display name, tagline, short bio, personality traits, goals this quarter, content pillars, target audience. These fields drive every PersonaAgent draft on every channel — V1 LinkedIn now, every channel V2 later.

🎨 Brand & voice (CRUD + auto-fetch)

Brand colors, logo/wordmark, face reference images, voice samples (for opt-in audio cloning), tone-of-voice positive examples, never-say list. ModerationAgent hard-blocks anything in the never-say list. Auto-fetch toggle pulls top photos from the talent's connected accounts via AssetFetcher (§10.b) into a review pile — no upload chore at onboarding. A "Train my Soul character" button kicks off Higgsfield training once ≥5 face photos are approved.

📲 Connected accounts

Per-platform handles with health and channel binding visible. LinkedIn, X, YouTube, Facebook, Reddit, and Instagram-read are V1-live (MCP / Composio); TikTok and IG-write handles show as "V2 pending — Device channel arriving" with no action buttons enabled. Re-authenticate on demand per platform. Automated signup is V2 §6.

🔐 Vault (read-only inventory)

Metadata of every stored credential — secrets are never displayed, only "last verified" timestamps and expiry. A red "revoke everything & pause my persona" panic switch.

⚙ Preferences (CRUD)

Daily posting cap, blackout windows, timezone, default content language, two-factor on the portal, and the critical operator-override toggle (off by default). Per-talent tick interval → V2 §9; V1 inherits from the tenant.

📈 Your growth private to the talent

The talent's own analytics — total followers and 7d/30d growth, per-platform breakdown, top posts, audience composition + geographies, what content patterns are above/below their own benchmark. V1 covers every live platform (LinkedIn, X, YouTube, Facebook, Reddit, IG-read); TikTok and IG-write light up when V2 §1 lands.

Separation by design: the talent portal and the operator portal share data (same Supabase tables, same RLS policies) but not chrome. A talent never lands on the operator sidebar; an operator who wants to see the talent's view jumps via "Preview talent portal" and is rendered as that talent's session.

13. Workflows & Scheduling

Scheduling lives inside Supabase via pg_cron. A row in a jobs table is inserted on every tick; the worker process polls jobs WHERE status='pending' using SELECT … FOR UPDATE SKIP LOCKED — a clean Postgres-native queue with no Redis or Celery.

Tick cadence is tenant-level in V1

V1 has a single cadence per tenant — tenants.default_tick_interval_seconds — driving every talent in that tenant. The Workflow handler iterates the tenant's active talents on each tick and fans out per talent. Per-talent override and dynamic adjustment (warm-up, blackouts, burn back-off) → V2 §9.

-- pg_cron entry — tenant cadence configurable, not hard-coded
select cron.schedule(
  'drsocial-baseline-tick',
  '* * * * *',                                         -- once per minute baseline
  $$ insert into jobs (kind, payload) values ('tick', '{}') $$
);

-- Workflow handler (per tick, per tenant)
for tenant in tenants.active():
    if not tenant.is_tick_due(now):
        continue
    for talent in tenant.active_talents():
        fan_out(talent, now)

-- Atomic job pick
select id, kind, payload
from jobs
where status = 'pending' and run_after <= now()
order by run_after
limit 1
for update skip locked;

14. Tech Stack

One Railway service, one managed backend (Supabase), one external LLM provider (abfs.tech router), plus media-model vendors and Composio for the MCP channel. That's the whole infrastructure for V1.

Railway service (single)

🌐 API + Worker + Dashboard

Python FastAPI serving REST + the htmx-rendered dashboards, plus the asyncio job loop running as a parallel task in the same process. One container, one port, one restart loop. Splitting into two services → V2 §13 when load demands it.

managed

🗄 Supabase

Postgres + Auth + Storage + Vault + pg_cron. The only database. The only secret store. The only object store. The only scheduler. Realtime + Edge Functions reserved for V2 features.

external

🧠 abfs.tech LLM router

All LLM calls go to https://www.abfs.tech/v1/ — see §5. Bearer-auth API key per environment (ABFS_LLM_API_KEY).

What's used (V1)

Runtime

Python 3.12 · asyncio · uv

API + UI server

FastAPI

Dashboard frontend

Server-rendered Python templates + htmx. No Node, no Vite, no React. React + Vite → V2 §11.

LLM provider

abfs.tech LLM router — Anthropic + OpenAI-compatible endpoints

MCP execution channel

Per-platform MCP server registry — direct MCP first, Composio fallback. Higgsfield direct (image+video gen, no API key). Composio wraps LinkedIn, X, YouTube, Facebook, Reddit, and Instagram (read-only). See §7 registry.

Email post-request intake

Cloudflare Email Routing catch-all on dr-social.app → operator Gmail mailbox → IMAP poll by the worker every 30s. No SES/Postmark in V1.

Image + video generation

Higgsfield via its direct MCP server (https://mcp.higgsfield.ai/mcp) — Soul characters for talent-consistent face/brand. Auth is OAuth at first connect; refresh tokens vaulted. No traditional API key.

Voice / narration

Best-effort via Higgsfield's MCP capabilities at V1. If a talent specifically needs higher-quality voice cloning than Higgsfield ships, ElevenLabs is the V2 unlock (kept on file in .env.local, off by default).

Audio + video editing

FFmpeg bundled into the container image. Cuts, captions, format swaps, per-platform aspect ratios (portrait for X video / IG-read references, landscape for LinkedIn / YouTube). No external vendor.

Transcription

OpenAI Whisper via the abfs.tech LLM router (Whisper endpoint). Used by RequestAgent to transcribe voice memos attached to post requests.

Persistence

Supabase Postgres (schema in plain SQL or one tiny migration tool)

Secrets

Supabase Vault (no Vaultwarden)

Media storage

Supabase Storage (no S3 / GCS / Drive)

Queue + scheduling

Postgres jobs table + pg_cron (no Redis, no Celery)

Live updates

htmx polling at 5s. Supabase Realtime → V2 §10.

What's deliberately NOT used (V1)

14.b Deployment — Railway.app

┌────────────────────────────────────────────────────────────────┐ │ Railway.app │ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ service: drsocial (single) │ │ │ │ FastAPI + htmx dashboard + worker loop │ │ │ │ one Dockerfile · $PORT public │ │ │ └──────────┬───────────────────────────────┘ │ │ │ │ └──────────────┼─────────────────────────────────────────────────┘ │ │ ┌─────────┬─────────┬───────────┬────────────┐ ▼ ▼ ▼ ▼ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ abfs │ │ Composio │ │ Supabase │ │ Higgsfield │ │ .tech │ │ (MCP for │ │ (managed)│ │ direct MCP │ │ LLM + │ │ LinkedIn │ │ Postgres │ │ Soul chars │ │ Whisper │ │ X, YT, │ │ Storage │ │ video+image│ │ router │ │ FB, Rdt, │ │ Vault │ │ no API key │ │ │ │ IG-read) │ │ pg_cron │ │ OAuth only │ └─────────┘ └──────────┘ └──────────┘ └────────────┘ ▲ │ IMAP poll (30s) │ ┌──────────────┐ │ Gmail inbox │ │ (operator's) │ └──────▲───────┘ │ │ catch-all forward ┌──────────────┐ │ Cloudflare │ │ Email Routing│ │ dr-social.app│ └──────────────┘ V2 §1 adds: GADS hub (Anderson's Mac via Cloudflare Tunnel) → Appium per device → iOS / Android phones. Not pictured here because V1 doesn't talk to it.

V1 environment variables

# Auto-set by Railway
PORT
RAILWAY_ENVIRONMENT_NAME

# Supabase (production — the only environment)
SUPABASE_PROD_URL
SUPABASE_PROD_SERVICE_KEY
SUPABASE_PROD_ANON_KEY
SUPABASE_PROD_DB_PASS             # direct Postgres pool (worker job queue)

# LLM provider (abfs.tech router)
ABFS_LLM_API_KEY                  # bearer for https://www.abfs.tech/v1/

# MCP execution channel
# Rule: direct MCP first, Composio fallback (see §7 MCP server registry)

# Direct MCP — Higgsfield (image + video gen, Soul characters)
# No API key — OAuth at first connect, refresh token vaulted.
HIGGSFIELD_MCP_URL=https://mcp.higgsfield.ai/mcp

# Composio MCP — fallback for LinkedIn, X, YouTube, Facebook, Reddit, IG-read
COMPOSIO_API_KEY

# Email post-request intake (Gmail IMAP poll, V1 MVP shape)
GMAIL_INTAKE_USER                 # e.g. easytoremind123@gmail.com
GMAIL_INTAKE_APP_PASSWORD         # Gmail App Password (requires 2FA on the account)
V2 adds env vars in order: GADS_HUB_URL, GADS_CLIENT_ID, GADS_CLIENT_SECRET (V2 §1, Device channel); REPLICATE_API_TOKEN if Higgsfield's image gen needs supplementing; ELEVENLABS_API_KEY if Higgsfield's voice capability falls short; per-iproxy-connection API keys (V2 §3, Browser channel); plus TELEGRAM_BOT_TOKEN when the 2FA-relay path lights up (V2 §6).

Live environments

One Railway environment — production — runs the FastAPI app from src/drsocial/main.py: api + worker + ticker in one process. The worker only spins up when Supabase env vars are set, so a freshly-cloned tree still serves the architecture docs + mockups without any credentials. There is deliberately no dev/staging/preview environment: the Higgsfield OAuth chain is a single-use rotating token owned by prod, and any second running copy of the app would steal rotations and kill prod media auth (Railway PR environments are disabled for this reason).

EnvironmentURLDeploys fromGate
Production drsocial-production.up.railway.app ↗ master only (planned: CI check once tests exist)

15. Repo Layout

V1 implementation

Dr.Social/
├── src/drsocial/
│   ├── agents/                  # the 4 LLM agents — each thin, all call llm_router
│   │   ├── persona.py           # persona_decide_*  — stateless functions
│   │   ├── content.py           # content_generate
│   │   ├── inbox.py             # inbox_fetch + classify + reply-draft
│   │   └── moderation.py        # moderation_approve, moderation_approve_inbound
│   ├── services/                # plain Python — no agent ceremony
│   │   ├── workflow.py          # ticker — enqueues per-account jobs
│   │   ├── ai_initiated.py      # one AI-initiated post (persona→content→mod→media→queue)
│   │   ├── request_agent.py     # post_request → content_queue (awaiting approval)
│   │   ├── post_publisher.py    # publishes approved content via platform adapter
│   │   ├── inbox_runner.py      # polls one account's inbox, classifies, drafts replies
│   │   ├── asset_fetcher.py     # §10.b — pulls talent's own posts into review pile
│   │   ├── account_importer.py  # Composio OAuth import + health check
│   │   └── analytics.py         # SQL rollups
│   ├── intake/                  # post-request ingress
│   │   ├── browser.py           # /me form handler
│   │   └── gmail_imap.py        # IMAP poller; parses To: → talent_slug; dedup via gmail_message_id
│   ├── platforms/               # adapter layer
│   │   ├── base.py              # SocialPlatform protocol
│   │   ├── _composio_adapter.py # shared base — every Composio-backed platform
│   │   ├── linkedin.py / x.py / youtube.py / facebook.py / reddit.py  # V1 — live, via Composio MCP
│   │   ├── instagram.py         # V1 read-only; write paths raise NotImplementedError (V2 §1)
│   │   ├── tiktok.py            # stub — wired live in V2 §1 (Device)
│   │   └── registry.py          # name → adapter lookup
│   ├── backends/                # execution backends — V1 is MCP-only
│   │   ├── mcp.py               # minimal MCP JSON-RPC client
│   │   └── registry.py          # platform→target map (direct MCP first, Composio fallback)
│   ├── media/                   # MediaPipeline — generation + caching
│   │   ├── pipeline.py          # routes briefs to higgsfield
│   │   ├── higgsfield.py        # video + image (Soul character) direct MCP client
│   │   └── ffmpeg.py            # cuts, aspect, captions (subprocess wrapper)
│   ├── llm_router.py            # thin client for https://www.abfs.tech/v1/
│   ├── supabase_client.py       # service/user clients + asyncpg pool for SKIP LOCKED
│   ├── jobs.py                  # job queue helpers (FOR UPDATE SKIP LOCKED)
│   ├── worker.py                # job dispatcher + tick driver
│   ├── api.py                   # FastAPI — serves dashboard + /me + /api endpoints
│   ├── logging_setup.py
│   ├── settings.py
│   └── main.py                  # entrypoint: runs api + worker + ticker in one asyncio loop
├── dashboard/                   # htmx + Jinja templates, served by api.py
├── supabase/
│   ├── migrations/              # plain .sql files
│   └── seed.sql
├── tests/
├── Dockerfile                   # single image — runs api + worker
├── railway.json                 # single-service Railway config
└── pyproject.toml
What V2 adds to the repo: backends/gads.py (Device-channel client, V2 §1) — wires up platforms/instagram.py + platforms/tiktok.py with no platform-adapter rewrites. Then backends/browser.py + services/fingerprints.py + services/iproxy.py + services/burn_detection.py + services/signup.py + services/audience_resolver.py (V2 §2–§7). supabase/functions/inbound_email/ appears if Gmail's IMAP rate limits ever push us to SES/Postmark (V2 §8). dashboard/ swaps htmx for React + Vite if V2 §11 fires; the single Dockerfile splits into Dockerfile.api + Dockerfile.worker if V2 §13 fires.