Open-source social media management for creators, agencies, and SMBs.
About BrightBean Studio
BrightBean Studio is an open-source, self-hostable social media management platform built for creators, agencies and SMBs. It does what Sendible, SocialPilot, or ContentStudio do, but free and without per-seat, per-channel, or per-workspace limits. Plan, compose, schedule, approve, publish, and monitor content across Facebook, Instagram, LinkedIn, TikTok, YouTube, Pinterest, Threads, Bluesky, Google Business Profile, Mastodon, and DEV.to from a single multi-workspace dashboard.
It's for people managing many client accounts under one roof who'd rather own their social stack than pay $100–300/month to a SaaS vendor. Every feature is available to every user. No paid tier, no feature gate, no upsell.
A free hosted version is available at brightbean.xyz/studio. You can also deploy it yourself with a one-click button on Heroku, Render, or Railway, run it on your own VPS via Docker, or run it locally. All platform integrations talk directly to the official first-party APIs using your own developer credentials, so there's no aggregator middleman, no vendor lock-in, and no third party sitting between you and your data.
Features
| Multi-workspace & teams | Unlimited orgs → workspaces → members. Granular RBAC with custom roles, invitations, and a separate Client role for external collaborators. |
| Content composer | Rich editor with per-platform caption/media overrides, version history, reusable templates, content categories & tags, a Kanban idea board. |
| Calendar & scheduling | Visual calendar with recurring weekly posting slots per account and named queues that auto-assign posts to the next available slot. |
| Publishing engine | Direct first-party API integrations (no aggregator), automatic retries, per-account rate-limit tracking, and a 90-day publish audit log. |
| Approval workflows | Configurable stages (none / optional / internal / internal + client), threaded internal & external comments, reminders, and a full audit trail. |
| Unified social inbox | Comments, mentions, DMs, and reviews from every connected platform in one place, with sentiment analysis, assignments, threaded replies, and historical backfill. |
| Analytics | Per-post and channel-level performance from every connected platform's native API, with KPI cards, 7/30/90-day trend charts, and a sortable all-posts table for views, engagement, follower growth, reach, and watch time. |
| Media library | Org- and workspace-scoped libraries with nested folders, auto-generated platform-optimized variants, alt text, and built-in Unsplash stock-photo search in the composer. |
| Client portal | Passwordless 30-day magic-link access so clients can approve or reject posts without creating an account. |
| Notifications | In-app, email, and webhook delivery with per-user preferences for every event type. |
| Security & ops | Encrypted token & credential storage, Google SSO, Sentry support, and a 14-day org-deletion grace period for non-YouTube data. YouTube connections and copied API data are removed immediately when deletion is requested. 2FA (TOTP) is on the roadmap. |
| White-label friendly | Per-workspace branding (logo, colors) and workspace defaults for hashtags, first comments, and posting templates. |
A quick look
![]() Visual calendar - drag-and-drop scheduling with recurring slots and queues. | |
![]() Post editor - composer with per-platform overrides and previews. | ![]() Idea board - Kanban workflow to keep track of all your post ideas. |
![]() Connect anything - 10+ first-party integrations, no aggregator. | ![]() Performance analytics - per-post and channel-level metrics with KPI cards and trend charts. |
Supported Platforms
| Platform | Publish | Comments | DMs | Insights |
|---|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ | |
| ✓ | ✓ | ✓ | ✓ | |
| ✓ | ✓ | ✓ | ✓ | |
| ✓ | ✓ | — | ✓ | |
| ✓ | ✓ | — | ✓ | |
| ✓ | — | — | ✓ | |
| ✓ | ✓ | — | ✓ | |
| ✓ | — | — | ✓ | |
| ✓ | ✓ | — | ✓ | |
| ✓ | ✓ | — | — | |
| ✓ | — | — | ✓ | |
| ✓ | ✓ | — | — | |
| ✓ | — | — | — |
Hosted Version
A free hosted version of Brightbean Studio is available at brightbean.xyz/studio. It runs the same codebase as this repository, with no setup or maintenance required.
If you'd rather self-host, choose one of the options below.
One-Click Deploy
| Heroku | Render | Railway |
|---|---|---|
After deploying, set these environment variables in your platform's dashboard:
| Variable | Required | Description |
|---|---|---|
DJANGO_SETTINGS_MODULE | Auto-set | config.settings.production. Set if deployment config has not placed it. |
SECRET_KEY | Auto-generated | Django secret key. Set automatically by the deploy button. |
ENCRYPTION_KEY_SALT | Auto-generated | Encryption salt. Set automatically by the deploy button. |
DATABASE_URL | Auto-provisioned | PostgreSQL connection string. Set automatically. |
ALLOWED_HOSTS | Yes | Your app's domain, e.g. your-app.herokuapp.com |
APP_URL | Yes | Full public URL, e.g. https://your-app.herokuapp.com |
STORAGE_BACKEND | No | Set to s3 for S3/R2 storage. Default: local. Heroku, Render, and Railway have ephemeral filesystems, so uploaded files are lost on redeploy without S3. |
SERVE_MEDIA | No | Only used with STORAGE_BACKEND=local. Default: true, so Django serves uploads at /media/. Those files are served unauthenticated — anyone with the path can fetch them. That is required in this mode: Instagram, Threads, Facebook, Pinterest, Google Business and dev.to fetch attachment URLs server-side when publishing, so /media/ must be publicly reachable. Only the media library, avatars and workspace icons are exposed this way (PUBLIC_MEDIA_PREFIXES in config/urls.py); comment attachments go through a permission-checked view. Set to false only when a reverse proxy or CDN serves those prefixes on the same public path instead. |
CADDY_MEDIA_ROOT | No | docker-compose only. Where Caddy reads uploads from. Default: /app/media. Set to /var/empty when STORAGE_BACKEND=s3 — the media_data volume survives the switch and Caddy cannot see SERVE_MEDIA, so it would otherwise keep serving whatever uploads were left behind. |
S3_ENDPOINT_URL | If using S3 | S3-compatible endpoint URL |
S3_ACCESS_KEY_ID | If using S3 | S3 access key |
S3_SECRET_ACCESS_KEY | If using S3 | S3 secret key |
S3_BUCKET_NAME | If using S3 | S3 bucket name |
EMAIL_HOST | No | SMTP server for sending invitations and password resets |
EMAIL_PORT | No | SMTP port (default: 587) |
EMAIL_HOST_USER | No | SMTP username |
EMAIL_HOST_PASSWORD | No | SMTP password |
GOOGLE_AUTH_CLIENT_ID | No | For Google OAuth login. Get from Google Cloud Console → Credentials. |
GOOGLE_AUTH_CLIENT_SECRET | No | Google OAuth secret |
CUSTOM_SIGNUP_URL | No | Where "Sign up" links point, e.g. a waitlist or marketing page: the login page's link, the email sent when someone resets the password of an address with no account, and the redirect after an owner deletes their organization. Must be an http(s):// URL or a path starting with /. Default: the built-in /accounts/signup/. With SIGNUP_MODE=open that page stays open either way (so your page can link back to it); with invite or closed, everyone who may not sign up is redirected here instead (see Restricting Sign-up). People with a pending invite are sent to the built-in signup, since only it accepts the invite (except with closed, where invites cannot create accounts). |
UNSPLASH_ACCESS_KEY | No | Enables Unsplash stock-photo search in the composer. Create a free app at unsplash.com/developers. |
For social media API keys, see Platform Credentials. Full variable reference: .env.example.
Quick Start (Docker)
git clone https://github.com/brightbeanxyz/brightbean-studio.git
cd brightbean-studio
cp .env.example .envEdit .env - change DATABASE_URL to point to the Docker service name:
DATABASE_URL=postgres://postgres:postgres@postgres:5432/brightbean
Then start everything:
docker compose up -d --build
docker compose exec app python manage.py createsuperuserDatabase migrations run automatically via the migrate Compose service before
the app and worker services start, so there is no separate migrate step.
Tailwind compiles automatically via the tailwind Compose service. First build
takes ~60–90 seconds (running npm install in a fresh container); subsequent
starts are instant. Watch progress with docker compose logs -f tailwind.
Open http://localhost:8000 - you're running.
Fully Local Development (without Docker)
Run everything natively - no Docker, no PostgreSQL install. Uses SQLite for the database.
Prerequisites
- Python 3.12+
- Node.js 20+
Setup
1. Clone and configure
git clone https://github.com/brightbeanxyz/brightbean-studio.git
cd brightbean-studio
cp .env.example .env2. Switch to SQLite
Open .env and replace the DATABASE_URL line:
DATABASE_URL=sqlite:///db.sqlite3
That's it - no database server to install or manage.
3. Set up Python
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt4. Set up Tailwind CSS
cd theme/static_src
npm install
cd ../..5. Run database migrations
python manage.py migrate6. Create your admin account
python manage.py createsuperuser7. Start the app (3 terminal tabs)
Tab 1 - Tailwind watcher:
cd theme/static_src && npm run startTab 2 - Django dev server:
source .venv/bin/activate
python manage.py runserverTab 3 - Background worker:
source .venv/bin/activate
python manage.py run_studio_workerOpen http://localhost:8000 and log in with the superuser you created.
Daily workflow (Docker-free)
source .venv/bin/activate # activate Python env
python manage.py runserver # start web server
# (open another tab)
python manage.py run_studio_worker # start workerNote: SQLite is fine for local development and small deployments. For production or heavy concurrent usage, switch to PostgreSQL.
Running Tests
pytestWith coverage:
pytest --cov=apps --cov-report=term-missingLinting & Type Checking
ruff check . # lint
ruff format --check . # format check
mypy apps/ config/ --ignore-missing-imports # type checkAuto-fix lint issues:
ruff check --fix .
ruff format .Production Deployment
Docker Compose on a VPS (recommended)
# On your server:
git clone https://github.com/brightbeanxyz/brightbean-studio.git
cd brightbean-studio
cp .env.example .env
# Edit .env:
# SECRET_KEY=<generate a random 50+ char string>
# DEBUG=false
# ALLOWED_HOSTS=yourdomain.com
# APP_URL=https://yourdomain.com
# DATABASE_URL=postgres://postgres:<strong-password>@postgres:5432/brightbean
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
docker compose exec app python manage.py createsuperuserThis starts 5 containers: app (Gunicorn), worker, PostgreSQL, Caddy (auto-HTTPS), and a one-shot migrate container that runs database migrations automatically on startup. Edit the Caddyfile with your domain.
With the default STORAGE_BACKEND=local, Caddy serves uploaded media directly from the media_data volume at /media/, so large images and video never occupy a Gunicorn worker thread and byte-range requests (video seeking) work. Django's own /media/ route stays available as the fallback for deployments without this proxy.
To update:
git pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --buildOther Platforms
| Platform | Config file | Notes |
|---|---|---|
| Heroku | Procfile + app.json | Deploy-button ready. Must use Basic+ dynos (Eco dynos break the worker). |
| Railway | railway.toml | The one-click template provisions three services: web (Gunicorn, runs migrate on startup), worker (python manage.py run_studio_worker), and managed PostgreSQL. The web service's startup migrate fires the post_migrate hooks that register the recurring tasks, so scheduling works out of the box. |
| Render | render.yaml | Blueprint with web, worker, PostgreSQL. Must use paid tier. |
All platforms with ephemeral filesystems require STORAGE_BACKEND=s3 - see .env.example for S3 configuration.
Memory sizing. Importing the app costs roughly 100 MB before it serves a request, and a warmed Gunicorn worker settles near 200 MB - so the shipped command runs a single threaded worker (--workers 1 --threads 4), which fits a 512 MB dyno with room for media handling. Raise --workers only when you raise the memory to match; a rule of thumb is 250 MB per worker. Do not add --max-requests: gthread stops heartbeating at the start of the request that trips the counter, so the arbiter kills the worker mid-request once --timeout (30s) passes - which drops whichever upload happened to be that request, and uploads here can be up to 1 GB. The worker process needs the same headroom: it downloads videos to disk and streams them to the platform, and if it is killed mid-publish (Heroku's R15, an OOM kill, a deploy) the affected post is failed by the confirmation sweep rather than left in limbo.
One worker, reserved publishing capacity. run_studio_worker runs one process with two task execution threads: one for publishing and publish confirmation, and one for inbox sync, analytics, health checks, media, and maintenance. It claims jobs only when their thread is available and keeps the existing database locks, retries, and repeating schedules. Task logs include dispatch delay and execution duration. The default dispatcher poll interval is one second (--sleep). With the shipped publisher and web concurrency limits, peak database demand is 17 connections, below the 20-connection PostgreSQL limit.
Why Heroku uses --duration 3600. Peak allocations can raise RSS permanently because CPython and glibc retain freed pages. Hourly recycling stops admitting general work first, while publishing continues until that job finishes; the publishing thread then drains and the process exits cleanly. SIGINT/SIGTERM stop admission on both threads and let in-flight work finish, subject to the platform's shutdown grace period. Unfinished jobs retain their locks for normal recovery. Don't go below ~1800s, where Heroku's crash cool-off starts to engage. Docker, Render, and Railway use the command without a duration, so they do not depend on a clean-exit restart policy. The legacy python manage.py process_tasks command remains available for rollback.
MALLOC_ARENA_MAX. glibc gives each thread its own arena (up to 64 MB) capped at 8 x nproc, and containers report the host's core count, so the cap is effectively unbounded. This app has real thread churn - the publisher builds a fresh pool every 15s and boto3's managed transfer adds ten threads per download - and those arenas are never returned to the OS. On Heroku this is already set to 2 by the repo's .profile, which applies to web, worker, the release phase and one-off heroku run dynos alike; it is written as a default rather than an override, so a config var still wins if you want to tune it. Deploy targets that build from the Dockerfile (Render, Railway, docker-compose) do not read .profile - set it in their own environment config if the host is memory-tight.
See architecture.md for detailed per-platform instructions and cost breakdowns.
Restricting Sign-up
A self-hosted install has to be reachable from the internet — that is where
the platforms deliver OAuth callbacks and webhooks — so by default anyone
who finds the domain can create an account. SIGNUP_MODE controls that:
| Value | Who may create an account |
|---|---|
open (default) | Anyone. |
invite | Only visitors who followed a valid invitation link. |
closed | Nobody. Create accounts in the Django admin. Invitations can only be sent to people who already have an account. |
SIGNUP_MODE=inviteApplies to both the email form and social logins — guarding only the form
would leave the provider callback open, since SOCIALACCOUNT_AUTO_SIGNUP
creates the account there without ever rendering the signup page.
Signing in is never affected, including for users who authenticate through Google: an existing account is connected by email rather than signed up.
In invite mode the invitation must be unaccepted and unexpired, so a
single link cannot be reused indefinitely. Invitations are sent from
Settings → Members.
What a visitor who may not sign up sees depends on CUSTOM_SIGNUP_URL:
- Empty: a "Sign-up is closed" page, and the login page hides its "Sign up" link.
- Set (e.g.
https://example.com/signup): they are redirected there — from/accounts/signup/and from the Google callback alike, before any account is created — and the login page's "Sign up" link points there too. Ininvitemode, visitors with a valid invitation still get the built-in signup. Studio refuses to start if the URL points back at its own/accounts/signup/, which would loop.
Things to expect with a redirect: a new Google user goes through Google's consent screen before being redirected; someone whose Google email differs from the email of their existing account counts as new; and an invitee who opens the link in one browser and signs up in another has no invitation there.
An unrecognised value stops the app at startup rather than falling back to a default — a typo that silently leaves sign-up open is the failure worth being loud about.
Project Structure
brightbean-studio/
├── config/
│ ├── settings/
│ │ ├── base.py # Shared settings
│ │ ├── development.py # Local dev overrides
│ │ ├── production.py # Production hardening
│ │ └── test.py # Test overrides
│ ├── urls.py # Root URL configuration
│ ├── wsgi.py
│ └── asgi.py
├── apps/
│ ├── accounts/ # Custom User model, auth, OAuth, sessions
│ ├── organizations/ # Organization management
│ ├── workspaces/ # Workspace CRUD
│ ├── members/ # RBAC, invitations, middleware, decorators
│ ├── settings_manager/ # Configurable defaults with cascade logic
│ ├── credentials/ # Platform API credential storage (encrypted)
│ └── common/ # Shared: encrypted fields, scoped model managers
├── providers/ # Social platform API modules (one file per platform)
├── templates/ # Django templates
│ ├── base.html # Layout with sidebar + nav
│ └── components/ # Reusable HTMX partials
├── static/
│ └── js/ # Vendored HTMX + Alpine.js
├── theme/ # django-tailwind theme app
│ └── static_src/
│ ├── src/styles.css # Tailwind directives
│ └── tailwind.config.js
├── Dockerfile
├── docker-compose.yml # Dev: app + worker + postgres
├── docker-compose.prod.yml # Prod override: adds Caddy, uses Gunicorn
├── Caddyfile # Reverse proxy + auto-HTTPS config
├── .env.example # All environment variables
├── Procfile # Heroku
├── app.json # Heroku deploy button
├── railway.toml # Railway config
└── render.yaml # Render blueprint
Settings selection: The
DJANGO_SETTINGS_MODULEenvironment variable controls which settings file Django uses. The defaults are already wired for each context:manage.pyusesdevelopment,wsgi.py/asgi.pyuseproduction, andpytestusestest(viapyproject.toml). Docker Compose files and platform deploy configs (Heroku, Render) also set it explicitly. You only need to override it manually if you want a non-default module for a specific command, e.g.DJANGO_SETTINGS_MODULE=config.settings.production python manage.py check --deploy.
Platform Credentials
To connect social media accounts, you need API credentials from each platform's developer portal. You can set these via environment variables in .env (see .env.example) or, per organization, through the Django admin at {APP_URL}/admin/ → Credentials → Platform credentials (superuser only). If a platform is configured in both places, the .env value takes precedence.
Admin UI access (superuser only): The Django admin at {APP_URL}/admin/ (for example https://brightbean.example.com/admin/) is restricted to superuser accounts — only a superuser can view or edit platform credentials there. If you don't already have one, create a superuser, then sign in and open Credentials → Platform credentials:
python manage.py createsuperuser
# Docker: docker compose exec app python manage.py createsuperuserRedirect URI: When registering your app on any platform, set the OAuth redirect URI to:
{APP_URL}/social-accounts/callback/{platform}/
For example, if your APP_URL is https://brightbean.example.com, the Facebook redirect URI would be https://brightbean.example.com/social-accounts/callback/facebook/.
TikTok: use the slug
social1instead oftiktok— TikTok rejects redirect URIs containing their brand name. See the TikTok section.
Meta (Facebook, Instagram, Threads)
Facebook and Instagram share the same Meta app credentials. Threads runs on the same Meta app but uses a separate app identity — its own App ID, App Secret, and redirect URI list (steps 6-7 below).
-
Go to Meta for Developers and create a new app (type: Business)
-
Under App Settings → Basic, copy your App ID and App Secret — the plain ones, for Facebook and Instagram. Once the Threads use case is added this page also lists a Threads App ID / Threads App Secret; those are a different app identity and belong in the Threads variables in step 7, not here.
-
In the App Dashboard, go to Use cases and add the following four use cases. For each use case, click into it and go to Permissions and features to add the required optional permissions:
Use case: "Manage everything on your Page" (Facebook)
- This use case auto-includes
business_management,pages_show_list, andpublic_profile - Add these optional permissions:
pages_manage_posts,pages_manage_engagement,pages_read_engagement,pages_read_user_content,pages_manage_metadata,read_insights
Use case: "Messenger from Meta" (Facebook Messaging)
- Required to enable the
pages_messagingpermission, which is not available under the "Manage Pages" use case - Add the optional permission:
pages_messaging
Use case: "Manage messaging & content on Instagram" (Instagram)
- Add these permissions:
instagram_basic,instagram_content_publish,instagram_manage_comments,instagram_manage_insights,instagram_manage_messages,pages_manage_metadata
Use case: "Access the Threads API" (Threads)
- This use case auto-includes
threads_basic - Add these optional permissions:
threads_content_publish,threads_manage_insights,threads_manage_replies
- This use case auto-includes
-
Under Facebook Login → Settings → Valid OAuth Redirect URIs, add the following redirect URIs:
{APP_URL}/social-accounts/callback/facebook/ {APP_URL}/social-accounts/callback/instagram/Threads is separate. Its callback does not belong here — the Threads use case keeps its own redirect URI list. See step 7.
-
Webhooks (required for the inbox). In the App Dashboard, go to Webhooks (also reachable via Use cases → Manage everything on your Page → Webhooks) and subscribe to the Page object:
-
Callback URL:
{APP_URL}/webhooks/facebook/ -
Verify token: the value of
FACEBOOK_WEBHOOK_VERIFY_TOKENfrom your.env(any random string; generate one and set the env var before clicking Verify and save) -
After verification, subscribe to the
feed,mention, andmessagesfields.feedis what carries comments on your Page's posts.
This step is easy to miss and fails silently. Connecting a Page subscribes it to your app automatically, so the account shows as healthy either way — but without the callback URL configured here, Meta never delivers anything. Comments then arrive only through the 5-minute polling fallback, and mentions not at all. Run
python manage.py diagnose_facebook --account-id <uuid>to check.If you connect Instagram through this Facebook app, also subscribe to the
Instagramobject on the same Webhooks page:-
Callback URL:
{APP_URL}/webhooks/facebook/(the same endpoint — it handles both platforms) -
Subscribe to the
comments,mentions, andmessagesfields.
commentsandmentionsbelong to the Instagram object, not the Page — a Page accepts only its own fields (feed,mention,messages, …). Studio subscribes the Instagram account automatically on connect, but the object-level configuration here can only be done in the dashboard. Without it, Instagram comments arrive only via the 5-minute poll and mentions not at all. -
-
Set the environment variables:
PLATFORM_FACEBOOK_APP_ID=your-app-id PLATFORM_FACEBOOK_APP_SECRET=your-app-secret # Optional: Facebook Login for Business configuration ID for agency/multi-business onboarding PLATFORM_FACEBOOK_CONFIG_ID=your-configuration-id FACEBOOK_WEBHOOK_VERIFY_TOKEN=your-random-verify-tokenWhen
PLATFORM_FACEBOOK_CONFIG_IDis set, Facebook and the Facebook-login Instagram connector use that Login for Business configuration instead of sending an ad-hocscopelist. Configure the required permissions and asset selection in Meta's configuration, and include every business whose Pages should be available to the login. Includeinstagram_manage_messagesandpages_manage_metadatain that configuration too; changing the code's scope list does not change Meta's configuration. -
Threads: the "Access the Threads API" use case gets its own App ID, App Secret, and redirect URIs. Go to Use cases → Access the Threads API → Settings and add the Threads redirect URI:
{APP_URL}/social-accounts/callback/threads/Then copy the Threads App ID and Threads App Secret (also listed under App settings → Basic, alongside — and different from — your Facebook App ID) and set:
PLATFORM_THREADS_APP_ID=your-threads-app-id PLATFORM_THREADS_APP_SECRET=your-threads-app-secretThese have no fallback: until both are set, Threads shows as Not Configured on the connect page. Sending the Facebook App ID to Threads fails with error
4476002.
Instagram DMs and reply windows
Both Instagram connectors receive DMs and supported incoming media in the unified
inbox and send text replies. The Facebook Login connector uses the selected
Page's token and ID for conversations and sending, while incoming webhook entries
identify the Instagram account. Studio adds messages to that Page's existing
subscription without replacing Facebook feed/mention fields.
For Facebook Login Instagram messaging, the login needs instagram_manage_messages
and pages_manage_metadata, a linked professional account, and the Page's
MESSAGING task. In Instagram, enable Settings → Messages and story replies →
Message controls → Connected tools → Allow access to messages. Reconnect existing
accounts after adding permissions, including permissions in any Login for Business
configuration. Missing DM permissions do not disable publishing or comment polling.
Self-hosted installations must configure the required permissions and access levels on their own Meta apps.
When PLATFORM_FACEBOOK_CONFIG_ID is set, Facebook Login for Business gets its
permissions from Meta's configuration rather than the OAuth scope list. Include
instagram_manage_messages and pages_manage_metadata in that configuration;
changing the code's scope list does not change Meta's configuration.
For external customers, complete Meta App Review, the required Advanced Access,
and business verification. Prepare a review recording showing login and account
selection, a customer DM appearing in Studio, an incoming attachment, a text reply,
and receipt of that reply in Instagram. Configure the Instagram object's messages
field and callback in the Meta dashboard; the Page subscription alone does not
configure the app-level callback.
REST/MCP sends use the 24-hour window measured from the customer's latest verified message to that account. They may save drafts after that window closes. A person can review and send from the web inbox for up to seven days only after Meta approves Human Agent access and an administrator enables the matching app setting:
PLATFORM_FACEBOOK_HUMAN_AGENT_ENABLED=truefor Facebook Messenger and Instagram via Facebook Login.PLATFORM_INSTAGRAM_HUMAN_AGENT_ENABLED=truefor Instagram Direct.- For organization credentials, set
human_agent_enabledtotrueon the applicable platform's credential JSON.
These settings default to false. Do not enable them before approval. No endpoint or MCP argument can declare a programmatic reply human-operated. After seven days, or when the message timestamp cannot be verified, wait for a new customer message.
After deployment, apply migrations and check each affected connection:
python manage.py migrate
python manage.py diagnose_instagram_messages --account-id <uuid> --refresh-scopes
python manage.py subscribe_webhooks --platform instagram --retry-failedThe diagnostic command does not send DMs; --refresh-scopes saves the verified
permission result. Reconnect any account with missing grants before retrying its
subscription. Live verification should cover text and media reception, a text
reply within 24 hours, duplicate/echo suppression, and an approved human support
reply after 24 hours. Incoming media URLs may expire; open Instagram if an
attachment cannot load. Outgoing media and new automated reply workflows are not
included.
Instagram (Direct, via Instagram Login)
The Instagram (Direct) connector uses the Instagram API with Instagram Login - a separate OAuth flow from the Facebook Login-based Instagram connector above. It works with Professional Instagram accounts (Business or Creator) without requiring a linked Facebook Page.
Account-type requirement: Personal Instagram accounts have no API access since the Instagram Basic Display API was retired on 2024-12-04. Users must convert their account to Professional first (free, in IG Settings → Account type and tools → Switch to professional account).
- In the same Meta app, go to Use cases and add the "Instagram API" use case
- Under API setup with Instagram Login, note your Instagram App ID and Instagram App Secret (these are different from your Facebook App ID/Secret)
- Go to Permissions and features and add the required permissions:
instagram_business_basic,instagram_business_content_publish,instagram_business_manage_comments,instagram_business_manage_messages,instagram_business_manage_insights
- Under API setup with Instagram Login → Step 4: Set up Instagram business login, click Set up and add the redirect URI (must match exactly, including the trailing slash):
{APP_URL}/social-accounts/callback/instagram_login/ - Under API setup with Instagram Login → Step 3: Configure webhooks, set:
- Callback URL:
{APP_URL}/webhooks/instagram_login/ - Verify token: the value of
INSTAGRAM_LOGIN_WEBHOOK_VERIFY_TOKENfrom your.env(any random string; generate one and set the env var before clicking Verify and save). After verification, subscribe to themessages,comments, andmentionsfields.
- Callback URL:
- Set the environment variables:
PLATFORM_INSTAGRAM_APP_ID=your-instagram-app-id PLATFORM_INSTAGRAM_APP_SECRET=your-instagram-app-secret INSTAGRAM_LOGIN_WEBHOOK_VERIFY_TOKEN=your-random-verify-token
Brightbean Studio supports two LinkedIn paths. Pick whichever your LinkedIn dev app can obtain - or both, on separate apps.
Path A - Personal-only (any individual developer can do this):
- Go to the LinkedIn Developer Portal and create a new app (no Company Page verification required).
- Under Products, request access to (both auto-approved):
- Sign In with LinkedIn using OpenID Connect
- Share on LinkedIn
- Under Auth, add the redirect URI:
{APP_URL}/social-accounts/callback/linkedin_personal/ - Scopes:
openid,profile,email,w_member_social. - Set the environment variables:
PLATFORM_LINKEDIN_PERSONAL_CLIENT_ID=your-client-id PLATFORM_LINKEDIN_PERSONAL_CLIENT_SECRET=your-client-secret
Limitations of Path A: access tokens last ~60 days and LinkedIn does not issue refresh tokens for these scopes - users must manually reconnect every ~60 days. Inbox / comment-reading is not available for personal accounts on this path.
Path B - Company Pages (also enables full Personal features):
- Go to the LinkedIn Developer Portal and create a new app.
- Verify the app's association with a LinkedIn Company Page.
- Under Products, request access to:
- Community Management API (restricted - requires LinkedIn review)
- Under Auth, add both redirect URIs:
{APP_URL}/social-accounts/callback/linkedin_personal/ {APP_URL}/social-accounts/callback/linkedin_company/ - Scopes:
- Personal:
r_basicprofile,w_member_social,r_member_social - Company:
r_basicprofile,w_member_social,w_organization_social,r_organization_social,rw_organization_admin
- Personal:
- Set the environment variables:
PLATFORM_LINKEDIN_COMPANY_CLIENT_ID=your-client-id PLATFORM_LINKEDIN_COMPANY_CLIENT_SECRET=your-client-secret
If you set only the Path B (Company) credentials, Brightbean Studio automatically reuses them for personal connections too - refresh tokens (365-day) and inbox both work. You only need Path A vars if you have a separate Personal-only app.
Note: "Sign In with LinkedIn using OpenID Connect" / "Share on LinkedIn" and "Community Management API" are mutually exclusive on a single LinkedIn app. You need separate apps for Path A and Path B.
Backwards compatibility: the legacy
PLATFORM_LINKEDIN_CLIENT_ID/PLATFORM_LINKEDIN_CLIENT_SECRETenv vars are still honored as a fallback for bothlinkedin_personalandlinkedin_company- existing self-hosters keep working without changes. The legacy credentials are assumed to be CM-approved; if your legacy app is OIDC-only, migrate it toPLATFORM_LINKEDIN_PERSONAL_*.
TikTok
- Go to the TikTok Developer Portal and create a new app
- Add the products Login Kit and Content Posting API
- Configure the redirect URI — use
social1, nottiktok(TikTok rejects URIs containing their brand name):{APP_URL}/social-accounts/callback/social1/ - Required scopes:
user.info.basic,video.publish,video.upload,video.list - Note: TikTok uses Client Key (not Client ID). Copy the Client Key and Client Secret from your app dashboard
- Set the environment variables:
PLATFORM_TIKTOK_CLIENT_KEY=your-client-key PLATFORM_TIKTOK_CLIENT_SECRET=your-client-secret - For production review, record the demo video against a TikTok Sandbox showing every requested scope in use.
Google (YouTube, Google Business Profile)
YouTube and Google Business Profile share the same Google Cloud credentials.
YouTube analytics uses native channel reports: views, total watch time, average percentage viewed, likes, comments, shares and subscribers gained. The 7/30/90-day windows follow Pacific calendar dates and end yesterday. Missing reports remain unavailable; daily chart gaps are not reconstructed from lifetime totals. YouTube engagement rates are not calculated. Headline averages come from the whole-period report rather than averages of daily percentages.
Existing YouTube connections keep their grants and do not require a migration reconnect. New connections and voluntary renewals record the user who authorized them. Disconnecting YouTube, deleting that recorded authorizing user, or requesting organization deletion immediately removes YouTube connections and copied API data, including comment and publish-error notification copies. Original authored posts and uploaded media remain; other organization data keeps its 14-day deletion grace period. Cancelling organization deletion requires reconnecting YouTube.
The daily YouTube compliance worker checks authorization and retained video existence in every connection status, including videos older than 90 days. Copied profiles/comments are refreshed by day 25 and removed by day 29 if retrieval still fails. Authorized statistical history is retained for 180 days; current lifetime statistics also require verified authorization and video existence. Revocation is attempted after local cleanup commits; transient failures use an encrypted outbox that expires after seven days.
Automatic age-based enforcement defaults to off until staging counts and worker
execution have been reviewed (YOUTUBE_COMPLIANCE_ENFORCEMENT=false). Explicit
deletion requests and confirmed permanent grant rejection still trigger immediate
cleanup. See the YouTube audit implementation and rollout notes
before enabling enforcement. Intelligence is outside this remediation.
- Go to the Google Cloud Console and create a new project (or select an existing one)
- Enable the following APIs under APIs & Services → Library:
- YouTube Data API v3 (for YouTube)
- YouTube Analytics API (for native YouTube channel reports)
- My Business Account Management API, My Business Business Information API, and Google My Business API (for Google Business Profile)
- Go to APIs & Services → Credentials and create an OAuth 2.0 Client ID (type: Web application)
- Add the following redirect URIs under Authorized redirect URIs:
{APP_URL}/social-accounts/callback/youtube/ {APP_URL}/social-accounts/callback/google_business/ - Copy the Client ID and Client Secret
- Required scopes:
- YouTube:
https://www.googleapis.com/auth/youtube.upload,https://www.googleapis.com/auth/youtube.readonly,https://www.googleapis.com/auth/youtube.force-ssl,https://www.googleapis.com/auth/yt-analytics.readonly - Google Business Profile:
https://www.googleapis.com/auth/business.manage
- YouTube:
- Set the environment variables:
PLATFORM_GOOGLE_CLIENT_ID=your-client-id PLATFORM_GOOGLE_CLIENT_SECRET=your-client-secret
- Go to the Pinterest Developer Portal and create a new app
- Under your app settings, add the redirect URI:
{APP_URL}/social-accounts/callback/pinterest/ - Copy the App ID and App Secret
- Required scopes:
user_accounts:read,boards:read,boards:write,pins:read,pins:write - Set the environment variables:
PLATFORM_PINTEREST_APP_ID=your-app-id PLATFORM_PINTEREST_APP_SECRET=your-app-secret
Bluesky
No developer app registration needed. Users connect by entering their Bluesky handle and an App Password:
- Log in to Bluesky
- Go to Settings → Privacy and Security → App Passwords
- Create a new app password and use it when connecting your account in Brightbean Studio
Mastodon
No developer app registration needed. Brightbean Studio automatically registers an OAuth application on each Mastodon instance when a user connects their account. Users just need to enter their instance URL (e.g., mastodon.social).
DEV.to
No developer app registration needed. Users connect by entering a personal API key:
- Log in to DEV.to and open Settings → Extensions
- Under DEV Community API Keys, enter a description (e.g.
Brightbean) and click Generate API Key - Copy the generated key and paste it when connecting your account in Brightbean Studio
Posts publish as DEV.to articles (title + Markdown body). The key can be revoked at any time from the same settings page.
Inbox: Backfill Historical Messages
See the Supported Platforms matrix above for per-platform inbox capabilities.
To import historical messages (e.g., from the last 7 days):
python manage.py backfill_inbox --days 7Options:
--days N- Number of days to backfill (default: 7)--platform NAME- Only backfill a specific platform (e.g.,facebook,youtube,linkedin,tiktok)--account-id UUID- Only backfill a specific account
For Facebook this recovers comments on the Page's posts from the last 30 days — useful after fixing a webhook that was never delivering, since comments missed while it was broken are otherwise invisible:
python manage.py backfill_inbox --platform facebook --days 30Inbox: Diagnosing a Facebook Page That Receives Nothing
When a Page's comments never reach the inbox, or a post's first comment never appears, the cause is usually a permission that was not granted or a webhook that was never configured — neither of which is visible from inside the app. Ask Meta directly:
python manage.py diagnose_facebook --account-id <uuid>It reports the token's granted scopes (a missing pages_manage_engagement is why first comments fail), which app is subscribed to the Page and to which fields (feed is what carries comments), and whether comments are readable at all. Add --subscribe to repair a missing Page subscription in place, or --json for output to paste into an incident.
API & MCP for Agents
BrightBean Studio ships a REST API and an MCP (Model Context Protocol) server so agents and scripts can read analytics, manage media, and create or schedule posts. Both share the same authentication, permission model, rate limits, and audit log. Pick whichever protocol fits your client.
Base URL: {APP_URL}/api/v1/ (e.g. https://your-studio.example.com/api/v1/)
Authentication
Issue an API key from Organization → API Keys. Keys are workspace-scoped, can be allowlisted to specific social accounts, and inherit a subset of the issuer's workspace permissions. Revocation takes effect immediately. Send the key as a Bearer token:
Authorization: Bearer bb_studio_...
Also send a User-Agent header naming your client (e.g. User-Agent: my-agent/1.0). Cloudflare's Browser Integrity Check, which is on by default and fronts the hosted studio.brightbean.xyz, rejects Python's standard-library default (Python-urllib/3.x) with 403 and a plain-text error code: 1010 body before the request reaches Studio. Any value of your own gets through; requests, httpx, curl, and Node clients already send one that does.
Permission keys: create_posts, publish_directly, upload_media, view_analytics, use_inbox, reply_from_inbox. Each endpoint requires the relevant permission; missing permissions return 403.
Rate Limits
| Scope | Limit |
|---|---|
| Per-key writes | 120 / min |
| Per-key reads | 300 / min |
| Per-workspace aggregate | 1000 / min |
| Per-key calls, all kinds | 1,000 / hour |
| Per-key calls, all kinds | 20,000 / day |
The hourly and daily caps count REST and MCP calls together (every JSON-RPC message, including ping and tools/list), over fixed UTC hours and UTC days. Calls refused with 429 don't count against them. Rate-limit responses (429) include a tier (per_key_hourly, per_key_daily, …) and Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining headers; for the hourly and daily caps Retry-After is the time until the window resets.
Don't poll faster than the data changes. Studio syncs the inbox every 5 minutes and analytics every hour, so GET /inbox/ more often than every 5 minutes, or analytics more often than hourly, returns the same data and spends your budget.
REST Endpoints
| Method | Path | Purpose | Permission |
|---|---|---|---|
GET | /me | Inspect caller scope and workspace permissions | — |
GET | /accounts | List connected social accounts | — |
POST | /posts | Create a draft or scheduled post | create_posts (+ publish_directly to schedule) |
GET | /posts/{post_id} | Read a single post | — |
PATCH | /posts/{post_id} | Update draft fields | create_posts |
POST | /posts/{post_id}/schedule | Schedule a draft | create_posts + publish_directly |
POST | /posts/{post_id}/cancel | Revert a scheduled post to draft | create_posts |
GET | /analytics/accounts/{account_id} | Channel analytics summary (7/30/90-day window) | view_analytics |
GET | /analytics/posts/{post_id} | Post analytics with per-platform metrics | view_analytics |
POST | /media | Upload a media file (multipart) | upload_media |
GET | /media/{media_id} | Retrieve a media asset | — |
GET | /media | List media assets (filter, paginate) | — |
GET | /inbox | List inbox messages (filter by status/type/account, paginate) | use_inbox |
GET | /inbox/{message_id} | Read one inbox message with its reply thread | use_inbox |
POST | /inbox/{message_id}/replies | Draft a reply (set send: true to deliver it now) | use_inbox (+ reply_from_inbox to send) |
PATCH | /inbox/replies/{reply_id} | Edit a draft reply | use_inbox |
POST | /inbox/replies/{reply_id}/send | Deliver a draft reply to the platform | reply_from_inbox |
DELETE | /inbox/replies/{reply_id} | Discard a draft reply | use_inbox |
POST | /mcp | JSON-RPC 2.0 endpoint for MCP clients | — |
Post creation, media uploads, and inbox reply creation accept idempotency_key (or Idempotency-Key header) for safe retries.
For inbox reply creation, reuse the same key and request to replay the original response without creating or sending another reply. A failed create-and-send response is replayed too; retrieve the retained failed reply from the message thread and retry delivery via /inbox/replies/{reply_id}/send.
MCP Tools
The MCP server lives at POST {APP_URL}/api/v1/mcp and speaks JSON-RPC 2.0 over Streamable HTTP. It implements the standard initialize, tools/list, tools/call, and ping methods. Tools:
| Tool | Purpose | Permission |
|---|---|---|
list_accounts | List social accounts this API key can act on | — |
create_draft | Create a draft post (caption, title, media, first comment, optional proposed publish time) | create_posts |
schedule_post | Create and schedule a post in one step | create_posts + publish_directly |
schedule_draft | Schedule an existing draft | create_posts + publish_directly |
get_post | Retrieve a post with aggregate status and per-platform state | — |
list_posts | List posts newest-first, with optional status filter and cursor pagination | — |
cancel_post | Revert a scheduled post back to draft | create_posts |
search_media | Find media assets by query, type, tags, or folder | — |
get_media | Retrieve a single media asset by ID | — |
upload_media | Upload a small base64-encoded file (≤ 1 MB raw). For larger files, use REST POST /media. | upload_media |
get_account_analytics | Channel analytics over a rolling 7–90 day window | view_analytics |
get_post_analytics | Per-platform metrics for a single post (safe for polling drafts) | view_analytics |
list_inbox_messages | List inbox items (comments, mentions, DMs, reviews) with their reply threads | use_inbox |
get_inbox_message | Retrieve one inbox message and its reply thread | use_inbox |
create_reply_draft | Draft a reply to an inbox message (saved, not sent) | use_inbox |
update_reply_draft | Replace the body of a draft (or failed) reply | use_inbox |
discard_reply_draft | Delete a draft (or failed) reply | use_inbox |
send_reply | Deliver a reply (reply_id, or message_id + body to draft-and-send) | reply_from_inbox |
Connecting an MCP client
The server is at {APP_URL}/api/v1/mcp and supports two authentication modes — pick whichever your client uses.
Claude Desktop (and other native OAuth connectors). In Claude Desktop open Settings → Connectors → Add custom connector, name it, and enter the server URL {APP_URL}/api/v1/mcp. Claude registers itself (Dynamic Client Registration) and opens a browser to log in to BrightBean Studio and approve access — no API key required. Any Studio user can connect; the connection acts with their own workspace permissions (read-only roles get the read tools, while posting/scheduling/uploading require the matching permission), operating on their last-active workspace. Requires Studio to be served over a public https URL.
Claude Code, Cursor, custom agents (static API key). Point the client at the same URL and send an API key as a Bearer token (Authorization: Bearer bb_studio_...). For Claude Code:
claude mcp add --transport http brightbean {APP_URL}/api/v1/mcp \
--header "Authorization: Bearer bb_studio_..."Pre-built agent skill
Don't want to wire up your own client? The companion brightbean-studio-agent repository hosts a holistic agent skill that drives BrightBean Studio end-to-end through the REST API and MCP tools documented above.
Tech Stack
| Layer | Technology |
|---|---|
| Backend | Django 5.x |
| Frontend | Django templates, HTMX, Alpine.js |
| CSS | Tailwind CSS 4 via django-tailwind |
| Database | PostgreSQL 16+ |
| Background jobs | django-background-tasks (no Redis required) |
| Auth | django-allauth (email + Google OAuth) |
| Media | Pillow (images), FFmpeg (video) |
| Deployment | Docker, Gunicorn, Caddy |
Troubleshooting
Docker: postgres container is unhealthy
Wait 10-15 seconds after docker compose up for the health check to pass, then retry your command. Check logs with docker compose logs postgres.
python manage.py migrate fails with connection errors
Make sure PostgreSQL is running and healthy. For Docker: docker compose ps should show postgres as "healthy". For local: verify the DATABASE_URL in .env matches your setup.
Tailwind CSS changes not appearing
Make sure the Tailwind watcher is running: cd theme/static_src && npm run start. If styles still don't update, try npm run build for a full rebuild.
OAuth callback errors ("redirect URI mismatch")
The redirect URI registered on the platform must exactly match {APP_URL}/social-accounts/callback/{platform}/. Check that APP_URL in .env matches the URL you're accessing (including http vs https and port number).
Threads: "No app ID was provided in the request" (error 4476002)
Threads uses its own App ID, not the Facebook one. Set PLATFORM_THREADS_APP_ID / PLATFORM_THREADS_APP_SECRET from Use cases → Access the Threads API → Settings, and register {APP_URL}/social-accounts/callback/threads/ in that same panel — the Facebook Login redirect URI list does not cover Threads. See the Meta section.
Background tasks not running (posts not publishing)
Make sure the worker is running: python manage.py run_studio_worker. In Docker: check docker compose logs worker.
A post is stuck on "Publishing"
It shouldn't stay there. confirm_pending_publishes runs every 60s and settles anything in that status: asynchronous publishes (TikTok accepts the upload, then transcodes) are confirmed against the platform and marked published or failed with the platform's own reason, and a post whose worker died mid-publish is failed after PUBLISHER_STALE_PUBLISHING_TIMEOUT so it becomes editable and retryable again. It is never re-published automatically - we can't tell "the platform never saw it" from "the platform took it and we crashed before recording that", and a duplicate video on a live account can't be undone. A third case is kept distinct on purpose: when the platform accepted the upload but we can't reach it to ask what happened, the sweep keeps reconciling for PUBLISHER_UNCONFIRMED_TIMEOUT (6h by default) and, if it never learns the answer, fails the post with copy that tells the user to check the account before republishing rather than to try again - the post may already be live. If posts sit on "Publishing" for longer than that, the worker isn't running (see above) or is being killed repeatedly - check its memory.
Uploaded images 404 in production (and Instagram/Facebook/Pinterest posts fail)
With STORAGE_BACKEND=local, /media/ must be publicly reachable. Check that SERVE_MEDIA is not set to false unless your reverse proxy serves MEDIA_ROOT at that same path, and that MEDIA_ROOT is on a persistent volume. Those platforms fetch attachment URLs server-side, so a 404 there fails the publish, not just the thumbnail.
Contributing
See CONTRIBUTING.md for development setup, coding guidelines, and how to submit pull requests.
Security
To report a security vulnerability, see SECURITY.md. Do not open a public issue.
License
AGPL-3.0 - see LICENSE for details.




