A CLI-based agent that fetches blog posts from Hugo-based blog platforms via RSS feeds, formats them into platform-specific social media posts using an LLM, and publishes them to LinkedIn, X (Twitter), and Facebook.
The agent is platform-driven — each blog platform is registered in a JSON registry and the agent can operate independently per platform. Adding a new blog platform requires only a new entry in the registry file, no code changes.
RSS Feed ──> Record Check ──> Post Selection ──> LLM Formatting ──> LinkedIn + X + Facebook Post ──> Record Save
Five FastMCP servers handle the core operations, coordinated by a central orchestrator:
| MCP Server | Role |
|---|---|
| rss-fetcher | Fetches and parses Hugo RSS feeds and individual blog posts |
| record-keeper | Tracks published posts in JSON, prevents duplicates |
| linkedin-poster | Posts formatted content to LinkedIn personal profile |
| x-poster | Posts formatted tweets to X (Twitter) via tweepy |
| facebook-poster | Posts formatted content to a Facebook Page via Graph API |
Provide a specific blog post URL. The agent auto-detects the platform, fetches the content, formats it, posts it to LinkedIn, X, and Facebook, and records the result. Also used to retry failed platforms for a previously posted URL.
python -m agent_engine.social_agent.main --url "https://blog.aspose.com/some-post"Specify a platform ID. The agent fetches the RSS feed, picks the latest unpublished post, formats it, posts it to LinkedIn, X, and Facebook, and records the result.
python -m agent_engine.social_agent.main --auto --platform aspose
python -m agent_engine.social_agent.main --auto --platform groupdocs
python -m agent_engine.social_agent.main --auto --platform conholdate- Python 3.10+
- A self-hosted GPT-OSS LLM endpoint (OpenAI-compatible API)
- A LinkedIn OAuth 2.0 access token with
w_member_socialscope - X (Twitter) OAuth 1.0a credentials with Read and Write permissions
- A Facebook Page Access Token with
pages_read_engagementandpages_manage_postspermissions
cd socialAgentpip install "mcp<2.0.0" feedparser httpx beautifulsoup4 aiofiles pydantic-settings openai tweepyCopy the example file and fill in your credentials:
cp .env.example .envEdit .env with your values:
# LLM Configuration (GPT-OSS)
PROFESSIONALIZE_BASE_URL=https://your-gpt-oss-endpoint.com/v1
PROFESSIONALIZE_API_KEY_2=your-api-key
PROFESSIONALIZE_LLM_MODEL=your-model-name
# LinkedIn Configuration
LINKEDIN_ACCESS_TOKEN=your-linkedin-oauth-token
# X (Twitter) Configuration
X_API_KEY=your-x-api-key
X_API_SECRET=your-x-api-secret
X_ACCESS_TOKEN=your-x-access-token
X_ACCESS_TOKEN_SECRET=your-x-access-token-secret
# Facebook Page Configuration (default — used by aspose-cloud)
FACEBOOK_PAGE_ID=your-facebook-page-id
FACEBOOK_PAGE_ACCESS_TOKEN=your-facebook-page-access-token
# Facebook Page Configuration (platform-specific overrides)
FACEBOOK_GROUPDOCS_PAGE_ID=your-groupdocs-facebook-page-id
FACEBOOK_GROUPDOCS_PAGE_ACCESS_TOKEN=your-groupdocs-facebook-page-access-token
FACEBOOK_CONHOLDATE_PAGE_ID=your-conholdate-facebook-page-id
FACEBOOK_CONHOLDATE_PAGE_ACCESS_TOKEN=your-conholdate-facebook-page-access-token
FACEBOOK_ASPOSE_PAGE_ID=your-aspose-facebook-page-id
FACEBOOK_ASPOSE_PAGE_ACCESS_TOKEN=your-aspose-facebook-page-access-token
Note:
FACEBOOK_ASPOSE_*is for the plainasposeplatform (blog.aspose.com), a separate blog and Facebook Page fromaspose-cloud(blog.aspose.cloud), which keeps using the defaultFACEBOOK_PAGE_ID/FACEBOOK_PAGE_ACCESS_TOKENabove.
| Variable | Description |
|---|---|
PROFESSIONALIZE_BASE_URL |
Base URL of your OpenAI-compatible LLM endpoint |
PROFESSIONALIZE_API_KEY_2 |
API key for the LLM endpoint |
PROFESSIONALIZE_LLM_MODEL |
Model name to use for text generation |
LINKEDIN_ACCESS_TOKEN |
Pre-generated LinkedIn OAuth 2.0 access token |
X_API_KEY |
X (Twitter) API consumer key |
X_API_SECRET |
X (Twitter) API consumer secret |
X_ACCESS_TOKEN |
X (Twitter) OAuth 1.0a access token |
X_ACCESS_TOKEN_SECRET |
X (Twitter) OAuth 1.0a access token secret |
FACEBOOK_PAGE_ID |
Facebook Page numeric ID (default, used by aspose-cloud) |
FACEBOOK_PAGE_ACCESS_TOKEN |
Facebook Page Access Token (default, used by aspose-cloud) |
FACEBOOK_{BRAND}_PAGE_ID |
Platform-specific Facebook Page ID (e.g. FACEBOOK_GROUPDOCS_PAGE_ID) |
FACEBOOK_{BRAND}_PAGE_ACCESS_TOKEN |
Platform-specific Facebook Page Access Token |
LinkedIn OAuth tokens expire every 60 days. When the agent logs LinkedIn token is invalid or expired, follow these steps to renew:
- Go to LinkedIn Developer Apps
- Select your app
- Go to the Auth tab
- Under OAuth 2.0 tools, click Generate token
- Select scopes:
w_member_social,openid,profile - Authorize and copy the new token
Note: Token generation works on Safari but may fail on Google Chrome. If you get errors in Chrome, switch to Safari.
- Update
LINKEDIN_ACCESS_TOKENin your.envfile (local) - Update
LINKEDIN_ACCESS_TOKENin GitHub repo Settings > Secrets and variables > Actions (for GitHub Actions) - Update
LINKEDIN_TOKEN_EXPIRES_ATto the new expiry date (today + 60 days):
LINKEDIN_TOKEN_EXPIRES_AT=2026-07-19
The agent prints a console warning when the token is within 7 days of expiring, so you can renew proactively.
Since LinkedIn doesn't support automated token renewal (unlike Facebook — see below), a scheduled workflow (.github/workflows/linkedin-token-alert.yml) runs every 2 weeks (1st and 15th) and checks the LINKEDIN_TOKEN_EXPIRES_AT repo variable (Settings → Secrets and variables → Actions → Variables tab, not Secrets — it's just a date). It automatically opens a GitHub issue titled "LinkedIn token expiring soon" once 14 days or fewer remain — the wider 14-day threshold (vs. the biweekly check interval) guarantees the alert can't be missed between runs. The issue includes the renewal steps above and auto-closes itself once you update the variable to a date more than 14 days out.
Remember: after renewing the token, update both LINKEDIN_ACCESS_TOKEN (secret) and LINKEDIN_TOKEN_EXPIRES_AT (variable) — the alert only clears once the variable reflects the new expiry.
Unlike LinkedIn, Facebook Page tokens can be renewed automatically without a browser login, because Meta allows re-exchanging a still-valid long-lived user token for a fresh one via API. A scheduled workflow (.github/workflows/facebook-token-renewal.yml) does this on the 1st of every month, well inside the ~60-day expiry window, for the Aspose.Cloud, GroupDocs.Cloud, Conholdate, and Aspose pages.
scripts/renew_facebook_tokens.pyexchanges the stored long-lived user token for a fresh one (fb_exchange_tokengrant) for each brand.- It re-derives that brand's Page Access Token from the refreshed user token.
- The workflow pushes both refreshed values back into the repo's GitHub Actions secrets via
gh secret set, using a dedicated PAT.
Each brand (Aspose.Cloud, GroupDocs.Cloud, Conholdate, Aspose) has its own Facebook App, so credentials are not shared between them.
| Variable | Where to get it |
|---|---|
FACEBOOK_APP_ID / FACEBOOK_APP_SECRET |
Facebook Developer Apps → select the app tied to the Aspose.Cloud page → Settings → Basic |
FACEBOOK_USER_ACCESS_TOKEN |
Graph API Explorer → select that same app → Generate Access Token → grant pages_read_engagement, pages_manage_posts (this seeds the automation; only needs to be pasted in once) |
FACEBOOK_GROUPDOCS_APP_ID / FACEBOOK_GROUPDOCS_APP_SECRET |
Same steps, but select the app tied to the GroupDocs.Cloud page |
FACEBOOK_GROUPDOCS_USER_ACCESS_TOKEN |
Same steps, with the GroupDocs app selected in Graph API Explorer |
FACEBOOK_CONHOLDATE_APP_ID / FACEBOOK_CONHOLDATE_APP_SECRET |
Same steps, but select the app tied to the Conholdate page |
FACEBOOK_CONHOLDATE_USER_ACCESS_TOKEN |
Same steps, with the Conholdate app selected in Graph API Explorer |
FACEBOOK_ASPOSE_APP_ID / FACEBOOK_ASPOSE_APP_SECRET |
Same steps, but select the app tied to the plain Aspose (blog.aspose.com) page |
FACEBOOK_ASPOSE_USER_ACCESS_TOKEN |
Same steps, with the Aspose app selected in Graph API Explorer — must grant pages_manage_posts, not just pages_read_engagement, or posting will fail with (#200) Insufficient permissions to post |
FACEBOOK_TOKEN_RENEWAL_PAT |
A fine-grained GitHub PAT, scoped to this repo only, with the "Secrets" repository permission set to Read and write. Lets the workflow call gh secret set. |
Add all of the above to GitHub repo Settings → Secrets and variables → Actions (the app/user-token ones also belong in your local .env if you want to test the script locally).
After that, renewal is fully automatic — the workflow keeps overwriting FACEBOOK_USER_ACCESS_TOKEN, FACEBOOK_PAGE_ACCESS_TOKEN, FACEBOOK_GROUPDOCS_USER_ACCESS_TOKEN, FACEBOOK_GROUPDOCS_PAGE_ACCESS_TOKEN, FACEBOOK_CONHOLDATE_USER_ACCESS_TOKEN, FACEBOOK_CONHOLDATE_PAGE_ACCESS_TOKEN, FACEBOOK_ASPOSE_USER_ACCESS_TOKEN, and FACEBOOK_ASPOSE_PAGE_ACCESS_TOKEN with fresh values each month, so you should never need to manually regenerate a Facebook token again unless the automation itself fails (e.g. an app credential is revoked).
To test manually: Actions → Facebook Token Renewal → Run workflow.
Facebook requires a Page Access Token (not a User Access Token) to post to a Page. Follow these steps:
- Go to Facebook Developers and create an app (type: Business)
- Add the "Facebook Login for Business" product to your app
- Go to the Graph API Explorer
- Select your app from the dropdown
- Click "Generate Access Token"
- Grant these permissions:
pages_read_engagement,pages_manage_posts
The User token gives you access to your Pages, but you need the Page's own token to post. Run:
curl "https://graph.facebook.com/v21.0/me/accounts?access_token=YOUR_USER_TOKEN"This returns a list of Pages you manage:
{
"data": [
{
"access_token": "EAAxxxxx_PAGE_TOKEN_xxxxx",
"name": "Your Page Name",
"id": "123456789012345"
}
]
}Copy the access_token and id from the Page entry.
curl "https://graph.facebook.com/v21.0/me?access_token=YOUR_PAGE_TOKEN"This should return the Page's name and ID (not your personal name). If it shows the Page name, the token is correct.
FACEBOOK_PAGE_ID=123456789012345
FACEBOOK_PAGE_ACCESS_TOKEN=EAAxxxxx_PAGE_TOKEN_xxxxx
python -m agent_engine.social_agent.main --url "https://blog.aspose.com/some-post" --target facebookIf you see this error:
Insufficient permissions to post: (#200) If posting to a group, requires app being
installed in the group...
You are using a User Access Token instead of a Page Access Token. Go back to Step 3 and use the me/accounts endpoint to get the Page's own token.
Tokens from the Graph API Explorer expire in ~1 hour. For production use, exchange for a long-lived token (~60 days):
1. Exchange short-lived User Token for a long-lived User Token:
curl -G "https://graph.facebook.com/v19.0/oauth/access_token" \
--data-urlencode "grant_type=fb_exchange_token" \
--data-urlencode "client_id=YOUR_APP_ID" \
--data-urlencode "client_secret=YOUR_APP_SECRET" \
--data-urlencode "fb_exchange_token=YOUR_SHORT_LIVED_USER_TOKEN"Response:
{
"access_token": "LONG_LIVED_USER_TOKEN",
"token_type": "bearer",
"expires_in": 5183423
}expires_in is in seconds (~60 days).
2. Exchange long-lived User Token for a Page Token:
curl "https://graph.facebook.com/v19.0/me/accounts?access_token=LONG_LIVED_USER_TOKEN"The Page Access Token returned from a long-lived User Token does not expire. Copy the access_token and id from your Page entry and update .env / GitHub Actions secrets.
3. Verify your token:
curl "https://graph.facebook.com/v19.0/debug_token?input_token=YOUR_PAGE_TOKEN&access_token=YOUR_APP_ID|YOUR_APP_SECRET"Check expires_at — a value of 0 means the token never expires.
Security notes:
- Never expose
client_secretin frontend code - Store tokens in environment variables or GitHub Actions secrets
- Rotate credentials immediately if exposed
All commands must be run from the project root directory.
python -m agent_engine.social_agent.main --url "https://blog.aspose.com/some-post"python -m agent_engine.social_agent.main --auto --platform aspose# Post to Facebook only
python -m agent_engine.social_agent.main --url "https://blog.aspose.com/some-post" --target facebook
# Post to LinkedIn only
python -m agent_engine.social_agent.main --url "https://blog.aspose.com/some-post" --target linkedin
# Post to X only
python -m agent_engine.social_agent.main --url "https://blog.aspose.com/some-post" --target x
# Post to all platforms (default)
python -m agent_engine.social_agent.main --url "https://blog.aspose.com/some-post" --target allpython -m agent_engine.social_agent.main --autoOutput:
Error: --platform is required with --auto
Available platforms:
- aspose (Aspose Blog)
- groupdocs (GroupDocs Blog)
- conholdate (Conholdate Blog)
python -m agent_engine.social_agent.mainAdding a new brand requires no code changes — just configuration:
Edit registry/platforms_registry.json and add a new entry:
{
"id": "conholdate",
"name": "Conholdate Blog",
"url": "https://blog.conholdate.com",
"rss_feed": "https://blog.conholdate.com/index.xml",
"active": true
}Facebook credentials are resolved dynamically based on the platform name. The naming convention:
FACEBOOK_{BRAND}_PAGE_ID
FACEBOOK_{BRAND}_PAGE_ACCESS_TOKEN
Where {BRAND} is the first part of the platform ID (before -), uppercased. Exception: aspose-cloud is hardcoded to always use the default FACEBOOK_PAGE_ID/FACEBOOK_PAGE_ACCESS_TOKEN and never does a brand lookup — this is because its brand prefix (aspose) would otherwise collide with the separate, plain aspose platform below. If you add a new hyphenated platform whose prefix collides with an existing bare platform ID, apply the same exclusion in mcp_tools.py and orchestrator.py.
| Platform ID | Facebook env vars |
|---|---|
aspose-cloud |
FACEBOOK_PAGE_ID + FACEBOOK_PAGE_ACCESS_TOKEN (default, hardcoded — see exception above) |
groupdocs-cloud |
FACEBOOK_GROUPDOCS_PAGE_ID + FACEBOOK_GROUPDOCS_PAGE_ACCESS_TOKEN |
conholdate |
FACEBOOK_CONHOLDATE_PAGE_ID + FACEBOOK_CONHOLDATE_PAGE_ACCESS_TOKEN |
aspose |
FACEBOOK_ASPOSE_PAGE_ID + FACEBOOK_ASPOSE_PAGE_ACCESS_TOKEN |
Add these to your .env file locally and as GitHub Actions secrets.
If no platform-specific credentials are found, the default FACEBOOK_PAGE_ID / FACEBOOK_PAGE_ACCESS_TOKEN are used.
LinkedIn and X credentials are shared across all brands.
Copy an existing workflow file (e.g. .github/workflows/blogcaster-groupdocs-cloud.yml) and change:
name:BlogCaster — Your Brandschedule: Pick days that don't overlap with existing workflowsplatform default: Your platform ID (e.g.conholdate)- Facebook env vars: Use your brand-specific secret names
Current schedule:
| Workflow | Days | Cron |
|---|---|---|
| BlogCaster — Aspose Cloud | Mon & Fri | 0 5 * * 1,5 |
| BlogCaster — GroupDocs Cloud | Tue & Thu | 0 5 * * 2,4 |
| BlogCaster — Conholdate | Wed & Sat | 0 5 * * 3,6 |
| BlogCaster — Aspose | Sun (latest) & Mon (gsc) | 0 5 * * 0,1 |
python -m agent_engine.social_agent.main --auto --platform conholdate --target facebookTrigger manually via workflow_dispatch in the Actions tab, then confirm the scheduled run fires on the expected day.
socialAgent/
├── agent_engine/
│ └── social_agent/
│ ├── main.py # CLI entry point
│ ├── config.py # Pydantic BaseSettings config
│ ├── agent_logic/
│ │ └── orchestrator.py # Core workflow (manual + auto modes)
│ ├── tools/
│ │ └── mcp_tools.py # MCP server wrappers
│ └── utils/
│ ├── helpers.py # Logger setup + registry helpers
│ ├── llm_service.py # LLM formatting via OpenAI SDK
│ └── prompts.py # Platform-specific prompt templates
├── mcp-servers/
│ ├── rss-fetcher/server.py # RSS feed parsing (feedparser + httpx + bs4)
│ ├── linkedin-poster/server.py # LinkedIn API posting (httpx)
│ ├── x-poster/server.py # X (Twitter) API posting (tweepy)
│ ├── facebook-poster/server.py # Facebook Page posting (httpx + Graph API)
│ └── record-keeper/server.py # JSON-based record storage (aiofiles)
├── registry/
│ └── platforms_registry.json # Blog platform registry
├── content/
│ └── records/
│ └── published_record.json # Published post records
├── logs/
│ └── logs.txt # Application logs
├── .env # Credentials (not committed)
├── .env.example # Credential template
└── .gitignore
All operations are logged to logs/logs.txt with timestamps. Console output shows INFO-level messages, the log file captures DEBUG-level detail.
2026-03-17 03:28:49 | INFO | social_agent | Starting Auto Mode for platform: aspose
2026-03-17 03:28:50 | INFO | social_agent | Fetched 10 posts from RSS feed
2026-03-17 03:28:51 | INFO | social_agent | Selected unpublished post: "Convert OneNote..." — https://...
2026-03-17 03:28:58 | INFO | social_agent | LLM formatted post (102 words)
2026-03-17 03:29:01 | INFO | social_agent | LinkedIn post successful: post_id=urn:li:share:...
2026-03-17 03:29:01 | INFO | social_agent | Record saved for https://...
The agent tracks posting results per platform. In Auto Mode, any URL with an existing record is skipped — it always picks a fresh post. In Manual Mode (--url), if a URL was previously posted to some platforms but not all, the agent retries only the failed platforms.
URLs are normalized (lowercased, trailing slashes stripped, query parameters removed) to catch variations like:
https://blog.aspose.com/post/vshttps://blog.aspose.com/posthttps://blog.aspose.com/post?utm=123vshttps://blog.aspose.com/post
Records are only saved when at least one platform succeeds. If all platforms fail, no record is written so the URL can be retried on the next run.
- Professional tone, 150-200 words
- Hook line that grabs attention
- 3-5 relevant hashtags
- Blog URL at the end
- One punchy sentence + 2-3 relevant hashtags
- Blog URL appended automatically
- Total tweet fits within 280 characters
- Community-focused, friendly conversational tone, 150-200 words
- Curiosity-driven hook line
- Clear call-to-action (read the article, share thoughts, tag someone)
- 3-5 relevant hashtags
- Blog URL attached as a link
| Issue | Solution |
|---|---|
PROFESSIONALIZE_BASE_URL is not set |
Create .env file from .env.example and fill in LLM credentials |
LinkedIn token is expired or invalid (401) |
Generate a new LinkedIn OAuth token and update .env |
Insufficient permissions (403) |
Ensure your LinkedIn token has w_member_social scope |
All recent posts have already been shared |
All RSS feed posts are already in the record — wait for new blog posts |
X credentials have insufficient permissions (403) |
Regenerate X Access Token & Secret after enabling Read and Write permissions |
X API rate limit exceeded (429) |
Wait a few minutes and try again |
Facebook token is expired or invalid |
Get a new Page Access Token (see Facebook Page Setup above) |
Insufficient permissions to post: (#200) |
You are using a User token instead of a Page token — use me/accounts to get the Page token |
Facebook API rate limit exceeded |
Wait a few minutes and try again |
Platform 'xxx' not found |
Check registry/platforms_registry.json for available platform IDs |
- Medium / Dev.to — Full article republishing (not just social snippets) for stronger SEO backlinks
- Blogger / WordPress — Cross-post full articles to blogging platforms for wider reach and backlink injection
- Parallel LLM calls — Run
format_for_linkedin(),format_for_x(), andformat_for_facebook()concurrently viaasyncio.gather()instead of sequentially, reducing total formatting time as more platforms are added - Content quality validation — Pre-validate scraped content quality before sending to LLM, preventing wasted LLM calls on low-quality or garbage pages
- YAML-based prompt configuration — Move platform prompts from code to YAML config files, enabling non-developers to tweak tone, length, and style without code changes
- REST API wrapper — Expose the orchestrator as HTTP endpoints via FastAPI, enabling integration with web interfaces and workflow automation tools
- Lightweight connector pattern — As platform count grows beyond 5+, evaluate a plugin-based connector architecture as an alternative to spinning up one MCP subprocess per platform
| Goal | Change Required |
|---|---|
| Add a new blog platform | New entry in registry/platforms_registry.json |
| Add a web UI | FastAPI layer wrapping the existing MCP tools |
| Switch to a database | Replace record-keeper/server.py internals (same MCP interface) |