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 ↗
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.
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.
The profile is the source of truth for everything the system does on behalf of a talent:
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_id NOT NULL.tenant_users table with roles (owner / editor / viewer).FK → talents.id. FK → tenants.id. One per (platform, talent, channel).channel is immutable. V1: 'mcp' only.audience_members + audience_identities + merge UI. See V2 §7.
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.
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.
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.
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.
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.
FastAPI api + asyncio worker in one process. Splits into two services only if/when V2 §13 triggers fire.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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."
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.
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.
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.
pg_cron-driven function. On each tick, iterates active talents and fans out work to the LLM agents. No reasoning of its own.
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.
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.
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.
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.
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.
Aggregates post performance, account health, inbox SLAs. SQL queries, not an LLM agent. Feeds the dashboard and informs ContentAgent's next-brief decisions.
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.
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.
POST /v1/messages — Anthropic Messages API shapePOST /v1/chat/completions — OpenAI Chat Completions shapeGET /v1/models — list available model IDsGET /health — router health
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.
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.
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).
# 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
...
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: ...
Articles, posts, DMs, comments, reactions. MCP via Composio. Cleanest API surface — the reference V1 implementation.
Tweets, threads, replies, quotes, DMs (Composio scope-dependent). MCP via Composio against the official X API.
Video upload, descriptions, community posts, comment replies. MCP via Composio against the YouTube Data API. Long-form video assets come from the V1 MediaPipeline.
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.
Subreddit posts, comments, replies, modmail. MCP via Composio against the Reddit OAuth API.
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.
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.
Drop-in. Agents and dashboard pick them up automatically once registered.
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.
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 / vendor | MCP source | Server URL | Auth |
|---|---|---|---|
| Higgsfield (image + video gen) | direct | https://mcp.higgsfield.ai/mcp | OAuth at first connect; refresh token vaulted |
| Composio | per Composio config | COMPOSIO_API_KEY + per-account OAuth | |
| X / Twitter | Composio | per Composio config | COMPOSIO_API_KEY + per-account OAuth |
| YouTube | Composio | per Composio config | COMPOSIO_API_KEY + per-account OAuth |
| Composio | per Composio config | COMPOSIO_API_KEY + per-account OAuth | |
| Composio | per Composio config | COMPOSIO_API_KEY + per-account OAuth | |
| Instagram (read-only) | Composio | per Composio config | COMPOSIO_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.
https://mcp.higgsfield.ai/mcp as an MCP client.| Operation | X | YouTube | TikTok | ||||
|---|---|---|---|---|---|---|---|
| Post text | ✓ | ✓ | ✓ (community) | ✓ | ✓ | ✗ | V2 §1 |
| Post image / photo | ✓ | ✓ | n/a | ✓ | ✓ | ✗ | V2 §1 |
| Post video / reel | ✓ | ✓ | ✓ | ✓ | ~ | ✗ | V2 §1 |
| Read feed | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | V2 §1 |
| Read comments / mentions | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | V2 §1 |
| Reply to comment | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | V2 §1 |
| Read DMs | ~ | ~ | n/a | ~ | ✓ (modmail) | ✗ | V2 §1 |
| Send DM | ~ | ~ | n/a | ~ | ✓ (modmail) | ✗ | V2 §1 |
| Delete post | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | V2 §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.
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.
channel column is set on enrollment and is immutable at the DB level (CHECK + trigger that rejects updates).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.'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)
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.
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.
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.
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.
content table. Generated media is cached so an identical brief never regenerates.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.
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.
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.
approved rows./me), transcribed voice memos can return as polished narration in their voice.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
slug column (e.g. aurora-lee). The catch-all email forwarding model means slugs collide across tenants, so uniqueness is enforced tenant-globally, not tenant-scoped, for this one column.<slug>+post@dr-social.app for post requests. Reserve +post, +settings, +inbox as future intents — the IMAP parser refuses unknown suffixes for now.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.
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:
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.
source_post_ids land on a deny-list per talent; the service skips them on subsequent runs.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.
-- 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
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.
source enum values: auto_fetched_tiktok, auto_fetched_instagram_full.
~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.
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 (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'.
fingerprints, iproxy_connections, devices, ip_rotations, sessions (Browser/Device-channel identity primitives). V1 only stores OAuth tokens in accounts.vault_ref.
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)
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.
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).
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.)
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.
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).
Every talent in the tenant, with status, accounts grouped (V1 platforms marked active, TT/IG-write marked V2-pending), queue volume, profile completeness.
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.
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).
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.
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.
/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.
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.
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).
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 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.
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.
Metadata of every stored credential — secrets are never displayed, only "last verified" timestamps and expiry. A red "revoke everything & pause my persona" panic switch.
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.
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.
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.
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 inserts a tick job at the tenant's configured interval → Workflow picks it up and fans out.accounts.posting_schedule_json. The tick checks "is it time?" and enqueues a publish job — but only for items already approved in content_queue.-- 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;
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.
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.
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.
All LLM calls go to https://www.abfs.tech/v1/ — see §5. Bearer-auth API key per environment (ABFS_LLM_API_KEY).
Python 3.12 · asyncio · uv
FastAPI
Server-rendered Python templates + htmx. No Node, no Vite, no React. React + Vite → V2 §11.
abfs.tech LLM router — Anthropic + OpenAI-compatible endpoints
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.
Cloudflare Email Routing catch-all on dr-social.app → operator Gmail mailbox → IMAP poll by the worker every 30s. No SES/Postmark in V1.
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.
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).
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.
OpenAI Whisper via the abfs.tech LLM router (Whisper endpoint). Used by RequestAgent to transcribe voice memos attached to post requests.
Supabase Postgres (schema in plain SQL or one tiny migration tool)
Supabase Vault (no Vaultwarden)
Supabase Storage (no S3 / GCS / Drive)
Postgres jobs table + pg_cron (no Redis, no Celery)
htmx polling at 5s. Supabase Realtime → V2 §10.
# 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)
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).
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).
| Environment | URL | Deploys from | Gate |
|---|---|---|---|
| Production | drsocial-production.up.railway.app ↗ | master only |
(planned: CI check once tests exist) |
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
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.