The "how" of this project. Read this when you need to run something — setup, dev, debug, deploy. Architectural "why" lives in CONTEXT.md; rules live in CLAUDE.md.
- Python 3.12 (project uses 3.12.x; see
backend/.python-version) - Node.js 20+ (
node --version) - npm 10+ (
npm --version) - Supabase project (free tier is enough) — sign up at supabase.com
- LinkedIn developer app (single, App #1) with these products enabled:
- "Share on LinkedIn"
- "Sign In with LinkedIn using OpenID Connect"
- Note: "Community Management API" (company-page posting) is deferred — it requires a registered legal entity. MVP posts as the authenticated member.
- Reddit app (script type) at https://www.reddit.com/prefs/apps
- Groq API key from https://console.groq.com
# 1. From the repo root
cd /Users/sacomtechnologies/Desktop/projects/AutoPost
# 2. Backend
cd backend
python3.12 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
playwright install chromium
# 3. Frontend
cd ../frontend
npm install
# 4. Environment — two files, not one
cp backend/.env.example backend/.env # fill with backend secrets
cp frontend/.env.local.example frontend/.env.local # fill with NEXT_PUBLIC_* only
# 5. Database migrations
cd backend
source venv/bin/activate
alembic upgrade headOne-time only (rotating it makes all stored LinkedIn tokens unreadable):
cd backend && source venv/bin/activate
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"Paste the output into backend/.env as APP_SECRET_KEY=....
Rendered post visuals live in a public Supabase Storage bucket so Phase 6's LinkedIn publisher can fetch them via URL. One-time setup:
- Supabase dashboard → your project → Storage → New bucket
- Name:
autopost-assets(must matchSUPABASE_STORAGE_BUCKETinbackend/.env) - Public bucket: ✅ on
- Allowed MIME types: leave blank, or
image/png, image/jpeg, image/webp, image/gif(optional). Templated cards upload as PNG; AI images upload as JPEG (photographic — PNG blows past the size cap); custom uploads can be PNG/JPEG/WebP/GIF (posted raw, exactly as uploaded). If you restrict this list, any format you leave out will fail with a 400 on upload. If you restrict it toimage/pngonly, AI-image and custom uploads both break. - File size limit:
5 MB(optional). AI images render as JPEG q85 (~0.3–1 MB), well under this; templated PNGs are ~1–2 MB.
No new credential is needed — backend writes use the existing
SUPABASE_SERVICE_ROLE_KEY, which bypasses storage RLS.
Supabase exposes three connection strings. Use the Session Pooler URL on port 5432 for DATABASE_URL:
- Settings → Database → "Connection string" → "Session Pooler"
- Format:
postgresql+psycopg://postgres.PROJECTREF:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres
Do not use the Transaction Pooler (port 6543) — it breaks Alembic's advisory locks. The Direct connection (also port 5432) works too but is IPv6-only by default; Session Pooler is the safe IPv4-friendly choice.
Two terminals.
Terminal 1 — Backend (FastAPI + APScheduler):
cd backend
source venv/bin/activate
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000Health check: http://127.0.0.1:8000/health → {"status":"ok"}
API docs: http://127.0.0.1:8000/docs
On startup the logs should show: APScheduler started.
Terminal 2 — Frontend (Next.js):
cd frontend
npm run devDashboard: http://localhost:3000
The tool is invite-only. Two one-time setup steps + how the gate works.
1. Disable public signups in Supabase (required — otherwise anyone can self-provision):
- Supabase dashboard → your project → Authentication → Sign In / Providers (a.k.a. Providers/Settings) → Email.
- Turn OFF "Allow new users to sign up" (the Allow signups / Enable sign ups toggle).
- The frontend login already sends
shouldCreateUser: false, but this dashboard toggle is the real server-side gate — set both.
- The frontend login already sends
2. Create the bootstrap admin's Supabase user (they can't self-sign-up now):
- The bootstrap admin email is
BOOTSTRAP_ADMIN_EMAILSinbackend/.env(defaultnestwhirr@gmail.com; comma-separated, case-insensitive). - Because signups are off, create their auth user manually: Supabase dashboard → Authentication → Users → Add user → Send invitation (or Create new user) with that email.
- On their first magic-link sign-in the backend JIT-provisions the
usersrow asrole=admin. Everyone else is denied (403) until invited via the/adminpanel (lands in Chunk F).
3. Set SUPABASE_SERVICE_ROLE_KEY in backend/.env (required for the admin panel):
- The admin approve/invite flow calls Supabase's admin API (
invite_user_by_email), which needs the service_role key — not the anon key. - Supabase dashboard → Project Settings → API → Project API keys →
service_role(secret). Paste intobackend/.envasSUPABASE_SERVICE_ROLE_KEY=.... Never expose it to the frontend (it bypasses RLS).
How the gate behaves now:
- A valid Supabase token whose email is not already in
usersand not a bootstrap admin → backend returns 403 (access is invite-only…). - Bootstrap admins are the only self-provisioning path; they always heal back to
admineven if demoted. - Every private row is scoped to a workspace (one workspace per user today). Approving a request provisions the
usersrow and itsworkspacesrow, keyed on the Supabase UUID, so first-login JIT recognizes the invitee. GET /api/v1/me→{id, email, role, workspace_id}(frontend usesroleto gate the admin UI).
Once a bootstrap admin is signed in, everything is UI-driven — no dashboard round-trips:
- Sign in as a bootstrap admin → the left rail shows an Admin entry (admins only).
/admin?tab=requests— the pending access-request queue. Each row: Approve (sends the Supabase invite email, provisions the user+workspace aseditor, stamps the requestapproved) or Deny (audit-stampsdenied, no email)./admin?tab=users— the Users table. Change a user's role via the<select>(PATCH; blocked from demoting the last admin → 409), or Revoke (DELETE — hard-deletes the local user + workspace + their posts/accounts; self-revoke and last-admin revoke are blocked). The Supabase Auth identity survives a revoke, but JIT will deny it on next login until re-invited.
Direct invite (skip the request form): /admin?tab=users → Invite user → email. Same as approving a request. Duplicate email → 409.
The public form posts to POST /access-requests (no auth) — seed a pending request with a real inbox you control, then approve it in the UI:
curl -X POST http://127.0.0.1:8000/api/v1/access-requests \
-H "Content-Type: application/json" \
-d '{"email":"you+invitee@gmail.com","name":"Test Invitee","reason":"smoke test"}'
# → 202 {"ok":true} (always 202, even on honeypot/dupe — opaque by design)Then /admin?tab=requests → Approve → the invite email lands in that inbox. Use a real deliverable address — Supabase rejects @example.com and other invalid domains with a 400 that surfaces as a 502 + toast in the UI.
Note: the approve→
invite_user_by_emailpath is mocked in the test suite (no network). The first real approve should be smoke-tested by an admin against a disposable-but-deliverable inbox.
- Sign into the dashboard via Supabase magic link.
- Click "Connect LinkedIn" in the header.
- Approve the requested scopes:
openid,profile,email,w_member_social. - You should be redirected back with a success banner showing your connected member name.
- Verify in the DB:
SELECT user_id, account_kind, member_sub, expires_at FROM linkedin_accounts;
account_kindwill be'personal'(MVP). Theaccess_token/refresh_tokencolumns are Fernet-encrypted blobs — don't expect plaintext.
Bluesky uses an App Password rather than OAuth — the atproto Python SDK is pre-1.0 and still session-based.
- On Bluesky web → Settings → Privacy and security → App passwords → "Add app password". Label it "Nestwhirr". Copy the generated
xxxx-xxxx-xxxx-xxxxstring immediately (Bluesky only shows it once). - On the dashboard → Bluesky section → Connect Bluesky → enter handle (e.g.
nestwhirr.bsky.social) + the app password. - The backend calls
Client.login(...)against the AT Protocol to validate before persisting. Success returns the connected DID. Both handle + password are stored inbluesky_accounts, the password Fernet-encrypted. - Verify in the DB:
SELECT user_id, handle, did, display_name FROM bluesky_accounts;
Rotate the app password by revoking on bsky.app and reconnecting — the connect endpoint upserts on (user_id, handle).
Mastodon is federated — every instance is its own server, so there's no central app to pre-register. The backend registers an OAuth client on your instance on the fly at connect time. Nothing to set up beforehand beyond the env vars below.
Env (already defaulted in backend/.env.example):
MASTODON_REDIRECT_URI=http://127.0.0.1:8000/api/v1/auth/mastodon/callbackMASTODON_CLIENT_NAME=Nestwhirr AutoPostMASTODON_SCOPES=read write
Steps:
- On the dashboard → Mastodon section → Connect Mastodon → enter your instance (e.g.
mastodon.socialorhachyderm.io). - Backend calls
POST {instance}/api/v1/appsto register a client, then redirects you to that instance's/oauth/authorize. Approve there. - On callback the backend exchanges the code, calls
verify_credentials, and persists the Fernet-encrypted access token inmastodon_accounts. You're bounced back with a success banner. - Verify in the DB:
SELECT user_id, instance_url, handle, display_name FROM mastodon_accounts;
Notes:
- Mastodon access tokens don't expire by default and no refresh token is issued, so there's no day-60 reconnect cycle like LinkedIn. If a token is revoked instance-side, reconnect.
- The per-instance
client_id/client_secretare not stored — they ride inside the encrypted OAuthstateand are discarded after callback. - Char limit reality: Mastodon caps posts at 500 chars, but the canonical draft body is LinkedIn-shaped (~1100–1600 chars). Until Phase 12's per-platform prompt matrix lands, a Mastodon target on a normal draft will fail the orchestrator's 500-char gate with a clear error (this is expected). A hand-shortened ≤500-char draft publishes fine.
Threads is a single fixed Meta app (LinkedIn-shaped OAuth, not federated). It's disabled by default behind PLATFORM_THREADS_ENABLED — you can't connect until Meta App Review approves threads_basic + threads_content_publish (same legal-gate shape as LinkedIn org posting). Code ships ahead of approval; pre-approval the API only works against the app's developer/tester accounts.
Env (in backend/.env, stubbed in backend/.env.example):
THREADS_APP_ID=/THREADS_APP_SECRET=— the Threads app's client id/secret (Meta dashboard → Threads product → API setup), not the parent Meta app's.THREADS_REDIRECT_URI=http://127.0.0.1:8000/api/v1/auth/threads/callback(must match the Meta app config exactly).THREADS_SCOPES=threads_basic,threads_content_publishTHREADS_API_VERSION=v1.0PLATFORM_THREADS_ENABLED=false→ flip totrueonce approved. When false:/auth/threads/startreturns 503 and the dashboard +/generatepicker hide Threads.
Steps (once enabled + approved):
- Dashboard → Threads section → Connect Threads → redirect to Meta's authorize → approve.
- On callback the backend exchanges the code for a short-lived token, immediately upgrades it to a 60-day long-lived token, resolves
/me, and persists the Fernet-encrypted token +expires_atinthreads_accounts. - Verify:
SELECT user_id, username, display_name, expires_at FROM threads_accounts;
Notes:
- Token refresh is automatic + in place. The 60-day long-lived token refreshes inside the publisher (
_ensure_fresh_token, 2-day lead) — Threads'/refresh_access_tokenreturns a fresh token, there's no separate refresh token. If the token fully dies (>60d untouched), a 401 surfaces and you reconnect. - Char limit: Threads caps at 500 chars — same Phase-12 caveat as Mastodon. A LinkedIn-shaped draft fails the orchestrator's gate; a ≤500-char draft publishes.
- Images: Threads fetches the image from our public Supabase bucket URL itself — nothing to upload. Carousels publish as 2–20 image containers assembled into one CAROUSEL post.
Instagram uses Meta's Instagram Login flow (NOT Facebook Login — the lightweight path that skips the legacy Facebook-Page link). Like Threads it's a single fixed Meta app and is disabled by default behind PLATFORM_INSTAGRAM_ENABLED — you can't connect until Meta App Review approves the Instagram content-publish scopes. Code ships ahead of approval; pre-approval the API only works against the app's developer/tester accounts.
Account prerequisite: the Instagram account must be a Creator or Business (Professional) account — personal accounts can't publish via API. Converting is free, reversible, and takes ~30s in the IG mobile app settings.
Env (in backend/.env, stubbed in backend/.env.example):
INSTAGRAM_APP_ID=/INSTAGRAM_APP_SECRET=— the Instagram app's client id/secret (Meta dashboard → Instagram product → API setup with Instagram Login).INSTAGRAM_REDIRECT_URI=http://127.0.0.1:8000/api/v1/auth/instagram/callback(must match the Meta app config exactly).INSTAGRAM_SCOPES=instagram_business_basic,instagram_business_content_publish(the Instagram-Login scope naming; the Facebook-Login variant isinstagram_basic/instagram_content_publish).INSTAGRAM_API_VERSION=v21.0PLATFORM_INSTAGRAM_ENABLED=false→ flip totrueonce approved. When false:/auth/instagram/startreturns 503 and the dashboard +/generatepicker hide Instagram.
Steps (once enabled + approved):
- Convert the IG account to Creator/Business if it isn't already.
- Dashboard → Instagram section → Connect Instagram → redirect to Meta's authorize → approve.
- On callback the backend exchanges the code for a short-lived token (+ the IG-scoped
user_id), immediately upgrades it to a 60-day long-lived token, resolves/me, and persists the Fernet-encrypted token +expires_atininstagram_accounts. - Verify:
SELECT user_id, username, account_type, expires_at FROM instagram_accounts;
Notes:
- Token refresh is automatic + in place — same as Threads (
_ensure_fresh_token, 2-day lead,ig_refresh_token, no separate refresh token; full death after ~60d untouched → reconnect). - Instagram requires an image. There is no text-only IG post — a draft targeting Instagram with no rendered card fails the publish loudly. Render a card first. Use square (1080×1080) or 4:5 art; the minimal-dark templates already render square.
- Caption limit: 2200 chars (far above our LinkedIn-shaped body, so the char-limit gate effectively never trips for IG).
- Images: IG fetches the image from our public Supabase bucket URL itself — nothing to upload. Carousels publish as 2–10 image containers assembled into one CAROUSEL post.
X is a single fixed app, OAuth 2.0 Authorization Code + PKCE. It's disabled by default behind PLATFORM_X_ENABLED. Unlike the Meta platforms, the gate here is cost, not App Review: the X API has no free tier for new developers (as of 2026) — posting is billed ~$0.015 per post (~$0.20 if the post contains a link). You must provision a paid X app before you can connect.
Setup:
- At developer.x.com, create a project + app, then enable OAuth 2.0 with app type Web App / Confidential client.
- Set the app's callback/redirect URL to match
X_REDIRECT_URI(defaulthttp://127.0.0.1:8000/api/v1/auth/x/callback). - Ensure the app requests scopes
tweet.read tweet.write users.read media.write offline.access(media.writeis required to upload images;offline.accessto get a refresh token). - Fill
backend/.env:X_CLIENT_ID=/X_CLIENT_SECRET=— the app's OAuth 2.0 Client ID/Secret.X_REDIRECT_URI=— must exactly match the app's registered callback.X_SCOPES=tweet.read tweet.write users.read media.write offline.accessPLATFORM_X_ENABLED=false→ flip totrueonce the app is provisioned. When false:/auth/x/startreturns 503 and the dashboard +/generatepicker hide X.
Connect:
- Dashboard → Settings → Connections → X section → Connect X → redirect to X's authorize → approve.
- On callback the backend exchanges the code + PKCE verifier (carried in the encrypted state) for the access + refresh token pair, resolves
/2/users/me, and persists both Fernet-encrypted tokens +expires_atinx_accounts. - Verify:
SELECT username, x_user_id, expires_at FROM x_accounts;
Notes:
- Token refresh is automatic — and actually exercised. X access tokens are short-lived (~2h) with a rotating refresh token;
_ensure_fresh_token(5-min lead) refreshes and re-persists BOTH tokens on every publish that lands near expiry. If the refresh token itself is revoked/dies, a 401 surfaces and you reconnect. - Char limit: 280 (standard tier). Drafts targeting X get a Bluesky-shaped 280-char rewrite from the seeded per-platform prompt; an over-cap variant fails the orchestrator's gate (never truncated).
- Images: X does not fetch from a URL — the publisher downloads each rendered card from Supabase and uploads the bytes via
POST /2/media/upload(one-shot, ≤4 images), then attaches the returnedmedia_ids to the tweet. - Cost: every publish to X bills your X account. Keep
PLATFORM_X_ENABLED=falsewhen you don't want to spend.
You can keep PLATFORM_X_ENABLED=true without connecting an X account — use the pipeline to generate X-shaped content and post it by hand:
- In
/drafts, select X in the generate picker → each draft gets its own 280-char X rewrite. - Open the draft cockpit → platform preview → X tab → Copy text to grab the exact variant.
- In the Visual panel, click download (next to "full size", and one per carousel slide) to save the rendered image, then attach it in the X composer by hand.
- If you run Publish now / Schedule on a draft that includes X, the X leg is marked "manual" (skipped) — not failed — so your LinkedIn/Bluesky legs publish and the pipeline stays green. Connect a real X account later and X publishes via the API automatically.
(These will work as the relevant phase lands. Currently Phase 0 — only health-check works.)
curl -X POST http://127.0.0.1:8000/api/v1/posts/generate \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"category":"founder_insight"}'Categories (Phase 12): founder_insight, engineering_lesson, ai_automation, startup_opinion, dev_tooling, pricing_business_model, product_postmortem, design_thinking. Pass "platforms": ["linkedin","bluesky",...] to fan out — each non-LinkedIn target is rewritten by that platform's prompt variant (LinkedIn-shaped body stays on the Post; per-platform text lands on post_targets.commentary).
Or use the "Generate draft" button in the dashboard.
# Auto-pick template by post category
curl -X POST http://127.0.0.1:8000/api/v1/posts/$POST_ID/render \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{}'
# Explicit template
curl -X POST http://127.0.0.1:8000/api/v1/posts/$POST_ID/render \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"template_id":"<uuid>"}'Response includes image_url — a browser-fetchable path served by the dev-only
StaticFiles mount at /static/assets. PNG also lands on disk at
backend/storage/assets/{post_id}/{asset_id}.png. Every call creates a new
asset; old ones are kept.
curl http://127.0.0.1:8000/api/v1/posts/$POST_ID/assets \
-H "Authorization: Bearer $JWT"curl http://127.0.0.1:8000/api/v1/templates -H "Authorization: Bearer $JWT"Dashboard → draft detail → Approve → Schedule → pick date/time → confirm. Verify the APScheduler job:
SELECT id, next_run_time FROM apscheduler_jobs;Dashboard → Publish now, or:
curl -X POST http://127.0.0.1:8000/api/v1/posts/$POST_ID/publish-now \
-H "Authorization: Bearer $JWT"curl -X POST http://127.0.0.1:8000/api/v1/trends/refresh \
-H "Authorization: Bearer $JWT"UPDATE posts
SET status='draft', error=NULL, scheduled_at=NULL
WHERE id='<post-id>';
DELETE FROM apscheduler_jobs WHERE id='publish_<post-id>';- Backend logs → stdout of the uvicorn process. Structured JSON in production, human-readable in dev.
- APScheduler logs are nested under the
apschedulerlogger. - LLM call audit trail →
ai_generationstable. - Publish attempts →
publishing_logstable.
SELECT id, next_run_time
FROM apscheduler_jobs
ORDER BY next_run_time;LinkedIn responses are saved to publishing_logs.linkedin_response. Useful keys:
serviceErrorCode— LinkedIn-specific error codemessage— human-readable reasonstatus— HTTP status
Common codes:
401— token expired; refresh flow should have caught this, investigateoauth.py403— missing scope or LinkedIn org permission422— payload shape error; checkugcPostsbody
curl -X POST http://127.0.0.1:8000/api/v1/posts/$POST_ID/render \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" -d '{}'A new file lands in backend/storage/assets/{post_id}/{asset_id}.png and a new
GeneratedAsset row is inserted. Old assets are kept — disambiguate via
created_at on GET /posts/{id}/assets.
cd backend
source venv/bin/activate
playwright install chromium
playwright install-deps # Linux only- Open
backend/app/retrieval/rss.py. - Append the feed URL to the
DEFAULT_FEEDSlist. - No DB migration needed. Per-feed cap (
MAX_ENTRIES_PER_FEED) and age cutoff (MAX_ENTRY_AGE) apply to every feed uniformly. - Trigger
POST /api/v1/trends/refreshto verify it's pulled (checkper_source.rssin the response increments by up toMAX_ENTRIES_PER_FEED).
- Insert a new row in the
promptstable with aname(= category), aplatform(linkedin/bluesky/mastodon/threads/instagram, or NULL for the "any/legacy" variant), the promptbody, andis_active=true. The matrix key is(name, platform, version). - Bump the
versionif you're replacing an existing one (set the old(name, platform)versionis_active=false). - The generator loads via
_load_active_prompt(category, platform)— it prefers the exact per-platform row, then falls back toplatform IS NULL. The LinkedIn row is used byrun_pipeline(Stage-1 writer); the other platforms' rows are used byrun_platform_rewrite(Stage-2 rewrite of the canonical LinkedIn post). To re-voice a platform, flipis_active— no code deploy.
- Drop a new HTML file in
backend/app/rendering/templates/. Keep the minimal-dark identity: black background, off-white serif headline, sans body, accent line + brand strap footer. Tailwind via CDN inside the file. - Write a one-row Alembic migration mirroring
d8e2f4a91c56_seed_templates.py— bulk-insert{name, category, html_path}intotemplates. Runalembic upgrade head. - The new template is available via
template_idonPOST /posts/{id}/render. To make it a category default, edit_DEFAULT_TEMPLATE_BY_CATEGORYinbackend/app/rendering/renderer.py. - It appears automatically in
GET /api/v1/templates(the future picker feeds from this).
- Add the value to the
PostCategoryenum inbackend/app/models/base.py. - Alembic migration: add the value to the
post_categoryPG enum inside anop.get_context().autocommit_block()(Postgres forbids using a freshly-added enum value in the same transaction), thenbulk_insertits prompt rows. Mirrorc5f9a26b81e4_seed_phase12_categories.py— ideally seed all 5 platform variants (LinkedIn = Stage-1 writer prompt; Bluesky/Threads/Mastodon/Instagram = Stage-2 rewrite prompts honoring each char budget). At minimum seed the LinkedIn row. - Optionally map it in
_DEFAULT_TEMPLATE_BY_CATEGORY(renderer.py); unmapped categories fall back toinsight_card. - Frontend dropdown is a manual TS list, not API-driven: add the value to the
PostCategoryunion +CATEGORY_LABEL+CATEGORY_COLORSin all four pages that render category chips (app/drafts/page.tsx,app/drafts/[id]/page.tsx,app/scheduled/page.tsx,app/published/page.tsx), and toGENERATABLEinapp/drafts/page.tsxto expose it in the generate dropdown.tsc --noEmitwill flag any map you miss.
Not yet — MVP is local-only. This section will be expanded when Phase 8 hardening lands and the user requests deployment.
Sketch of expected steps when we get there:
- Backend → Render or Fly.io free tier (Dockerfile required).
- Frontend → Vercel (zero-config).
- DB → Supabase (already managed).
- APScheduler: keep in-process for now; if the API auto-scales, move to a single-instance worker process to avoid duplicate jobs.
- Env diffs:
APP_BASE_URL,FRONTEND_BASE_URL,LINKEDIN_REDIRECT_URIall change to production hostnames; LinkedIn app must be updated with the new redirect URI.
| What | Where |
|---|---|
| Architecture & decisions | CONTEXT.md |
| Rules for Claude | CLAUDE.md |
| Progress / status | CHECKLIST.md |
| This file (how-to) | INSTRUCTIONS.md |
| Backend code | backend/app/ |
| Frontend code | frontend/app/ |
| DB migrations | backend/alembic/versions/ |
| Generated images (dev) | backend/storage/assets/ |