Social Posting
Richfolio can optionally publish its buy signals to public social accounts — X/Twitter, a Facebook Page, a Threads account, and a LinkedIn Page — alongside the email + Telegram briefs. It runs only in daily and intraday modes (never weekly or refresh).
This is fully opt-in. If you set no social credentials, nothing is posted and the rest of Richfolio works exactly as before — each platform logs a “credentials not set — skipping” line and moves on.
What gets posted
Posts are deliberately generic so nothing private leaks:
- Only STRONG BUY and BUY signals (HOLD/WAIT are skipped).
- Each signal shows ticker, action, confidence, a short reason, and optionally a value rating — nothing else.
- Target-portfolio and watch-list tickers are merged uniformly as “signals” — there is no “portfolio vs watchlist” label, so your holdings are never revealed.
- Never posted: allocations, gaps, suggested buy amounts, share counts, total portfolio value.
buildSignalLines()insrc/socialContent.tsis the single allowlist chokepoint, andsanitizeReason()strips any allocation gap, dollar sizing, or overlap-discount text the AI may write into its reason. Both are covered by unit tests. - The ticker is rendered as a clickable
#hashtagon Facebook / Threads / LinkedIn (cashtags only work on X, where it stays a$cashtag). A configurable set of generic hashtags is appended on the non-X platforms to boost reach. - Every post ends with a disclaimer: “Not financial advice. Auto-generated by Richfolio.”
Enable / disable
A social block in config.json is the master switch (defaults shown):
"social": {
"enabled": true,
"includeLinkInX": false,
"hashtags": ["investing", "stocks", "stockmarket", "ETFs"]
}
enabled: false— disables all social posting regardless of credentials.includeLinkInX— include the analysis link in X posts. Off by default because a link raises X’s pay-per-use cost.hashtags— generic hashtags appended on Facebook / Threads / LinkedIn (the leading#is optional). Not added on X.
Each platform additionally gates on its own credentials, so a platform with no keys is skipped even when enabled is true.
Secrets summary
Add these as repository Secrets (Settings → Secrets and variables → Actions → Secrets). All are optional — set only the platforms you want.
| Secret | Platform | Notes |
|---|---|---|
FACEBOOK_PAGE_ID | Your Page’s numeric id | |
FACEBOOK_PAGE_TOKEN | Long-lived Page access token | |
THREADS_USER_ID | Threads | Your Threads numeric user id |
THREADS_ACCESS_TOKEN | Threads | Long-lived token (expires ~60 days) |
THREADS_TOKEN_PAT | Threads | Optional PAT to auto-refresh the Threads token (see below) |
LINKEDIN_ACCESS_TOKEN | OAuth 2.0 token with w_organization_social | |
LINKEDIN_ORG_URN | e.g. urn:li:organization:123456 | |
X_API_KEY / X_API_SECRET | X/Twitter | OAuth 1.0a consumer keys |
X_ACCESS_TOKEN / X_ACCESS_TOKEN_SECRET | X/Twitter | OAuth 1.0a user tokens |
Facebook Page setup
The runtime needs just two values: FACEBOOK_PAGE_ID and a long-lived FACEBOOK_PAGE_TOKEN. Because you post to your own Page as its admin, you can stay in the app’s Development mode — no App Review required.
1. Create a Meta app
- Go to developers.facebook.com → My Apps → Create App.
- Pick the Other → Business app type and name it (e.g. “Richfolio Poster”).
2. Add the Page-management use case
- In the app, open Use cases → choose Manage everything on your Page.
- Open its Permissions and Add (Standard Access is enough — ignore “Get Advanced Access”):
pages_manage_posts— publish to the feed (the essential one)pages_read_engagementpages_show_list
They should show “Ready for testing” — that means usable now for you as admin.
3. Get a user token
- Open the Graph API Explorer.
- Select your app → Get User Access Token → tick the three
pages_*permissions → Generate Access Token → approve.
4. Mint a long-lived Page token
Richfolio ships a helper that does the token exchange for you. Temporarily add to .env:
FACEBOOK_PAGE_ID=your_page_id # numeric Page id
FACEBOOK_APP_SECRET=... # App settings → Basic → App secret
FB_USER_TOKEN=... # the user token from step 3
Then run:
npx tsx smoke/fb-page-token.ts
It exchanges the short-lived user token for a long-lived one, finds your Page, and prints a non-expiring Page token. Paste that into FACEBOOK_PAGE_TOKEN, then delete FACEBOOK_APP_SECRET and FB_USER_TOKEN from .env — they are setup-only and never used at runtime.
5. Verify
npx tsx smoke/smoke-facebook.ts # checks the token (no posting)
npx tsx smoke/smoke-facebook.ts --post --cleanup # posts a test, then deletes it
A PASS confirms the token is a valid Page token and posting works end-to-end.
6. Add to GitHub
Add FACEBOOK_PAGE_ID and FACEBOOK_PAGE_TOKEN as repository Secrets. Do not add the app secret or user token.
Token longevity: the Page token is effectively permanent, but it dies if you change your Facebook password, revoke the app, or stop being a Page admin. If posting stops, re-mint from step 3.
Threads setup
Threads reuses the same Meta app as Facebook. You need a THREADS_USER_ID and a long-lived THREADS_ACCESS_TOKEN. The account you authorize is the one posts appear from — use your brand Threads account, which must be public.
1. Add the Threads use case to the Meta app: Use cases → Access the Threads API. Enable the scopes threads_basic and threads_content_publish.
2. Add yourself as a Threads Tester: App Dashboard → App roles → Roles → Add People → Threads Tester → enter your Threads username → accept the invite in the Threads app (Settings → Account → Website permissions).
3. Generate a long-lived token: in the Threads use-case Settings, the User Token Generator lists your tester account → Generate token. This is already long-lived — no exchange needed. Paste it into .env as THREADS_ACCESS_TOKEN.
4. Discover your user id and verify:
npx tsx smoke/smoke-threads.ts # prints THREADS_USER_ID; set it in .env
npx tsx smoke/smoke-threads.ts --post --cleanup # posts a test (delete may be manual)
5. Add THREADS_USER_ID and THREADS_ACCESS_TOKEN to GitHub Secrets.
Token expiry & auto-refresh: Threads long-lived tokens expire in ~60 days. The workflow
.github/workflows/refresh-threads-token.ymlrefreshes the token monthly and writes it back to the secret — if you also add aTHREADS_TOKEN_PATsecret (a fine-grained PAT with repository Secrets: Read and write). Without that PAT, refresh the token manually before it expires. Since it is set but unrefreshable, the workflow now fails loudly in that state rather than passing silently — which is how three green monthly runs once hid an expired token.
LinkedIn Page setup
LinkedIn is free but the most gated. You need:
- A LinkedIn Developer app associated with your Company Page.
- The “Community Management API” product, which grants the
w_organization_socialscope. Requesting it needs your app’s company association to be verified (Settings tab) and submission of an access form (it asks for a registered business name). - An OAuth 2.0 access token with
w_organization_social, generated by an admin of the Page.
Set:
LINKEDIN_ACCESS_TOKEN— the OAuth 2.0 token.LINKEDIN_ORG_URN— your organization URN, e.g.urn:li:organization:123456(the number is in your Company Page URL).LINKEDIN_API_VERSION— optional override (defaults to a recentYYYYMM).
Verify with npx tsx smoke/smoke-linkedin.ts (token check) and --post --cleanup (publish test).
LinkedIn access tokens expire in ~60 days (refresh tokens last ~365). Plan to refresh periodically. Posting to an organization requires a registered business; if you don’t have one, you can adapt the poster to post from a personal profile via the self-serve “Share on LinkedIn” product (
w_member_social).
X / Twitter setup
X has no free posting tier since February 2026. Posting is pay-per-use (~$0.015 per post, more if it contains a link). Richfolio ships the X poster but it stays dormant until you add keys.
Posting uses OAuth 1.0a user context. In the X Developer Portal, create a project/app with Read and Write permissions and generate:
X_API_KEY/X_API_SECRET— the consumer (API) key & secret.X_ACCESS_TOKEN/X_ACCESS_TOKEN_SECRET— the access token & secret for your account.
Leave includeLinkInX off in config.json to keep per-post cost low.
How it runs
In daily and intraday modes, after the email and Telegram sends, Richfolio calls sendSocialPosts() (src/social.ts). Each platform posts inside its own try/catch, so one platform failing never blocks the others — or the email/Telegram that already went out.
If you fork Richfolio, social posting stays off until you complete the setup above with your own accounts — credentials cannot be shared, since each token posts to its owner’s account.