OpenClaw AI Agent Integration
Connect OpenClaw (open-source autonomous AI agent) to Publora for multi-platform social media posting.
Prerequisites
- OpenClaw installed (docs.openclaw.ai)
- Publora account with API key (starts with
sk_) - At least one social media account connected in Publora
Recommended: REST API
For autonomous agents like OpenClaw, we recommend using the REST API directly instead of MCP:
- ✅ Simple HTTP requests — no session management
- ✅ Standard headers — no special Accept header requirements
- ✅ Easier to debug — standard curl/httpie works
- ✅ More reliable — fewer moving parts
See REST API examples below for complete code.
Alternative: MCP Protocol
MCP is better suited for interactive AI assistants (Claude Code, Cursor) where a human talks to the AI. If you still want to use MCP with OpenClaw:
Configuration File (Recommended)
Create config/mcporter.json:
{
"mcpServers": {
"publora": {
"url": "https://mcp.publora.com",
"headers": {
"Authorization": "Bearer sk_YOUR_API_KEY"
}
}
}
}mcporter CLI
mcporter list --config config/mcporter.jsonNote: Using CLI flags like
--headermay not work with all mcporter versions. Prefer the config file method.
Troubleshooting:
- If
mcporter listreports auth required even with a Bearer header in this config, mcporter likely merged a different config source (~/.claude.json,~/.mcporter/…). Re-run with--verboseto see which file supplied thepubloraentry.- Don't run
mcporter auth publora.mcp.publora.comdoes support OAuth 2.1 (DCR + PKCE), but its consent step is an interactive "paste your API key" web page — a headless CLI can't complete it. For OpenClaw/mcporter, authenticate with a static key header (Authorization: Bearer sk_…orx-publora-key) instead. (The OAuth flow is meant for the claude.ai web connector.)
Using with OpenClaw
Once connected, talk to OpenClaw naturally:
"Show my connected social accounts"
"Schedule a LinkedIn post for tomorrow at 9am"
"Post this announcement to all my platforms"
"How did my last post perform?"OpenClaw will use Publora's MCP tools automatically.
Autonomous Agent Example
Complete Python implementation for autonomous social media management:
import asyncio
from datetime import datetime, timedelta
from contextlib import asynccontextmanager
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
@asynccontextmanager
async def publora_session(api_key: str):
"""Context manager for Publora MCP connection."""
headers = {"Authorization": f"Bearer {api_key}"}
async with streamablehttp_client("https://mcp.publora.com", headers=headers) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
async def get_connections(session):
"""Get all connected platforms."""
result = await session.call_tool("list_connections", {})
return result.content[0].text
async def schedule_post(session, content: str, platforms: list, scheduled_time: str):
"""Schedule a post to specified platforms."""
result = await session.call_tool("create_post", {
"content": content,
"platforms": platforms,
"scheduledTime": scheduled_time
})
return result.content[0].text
async def get_scheduled_posts(session):
"""List all scheduled posts."""
result = await session.call_tool("list_posts", {
"status": "scheduled"
})
return result.content[0].text
async def main():
async with publora_session("sk_YOUR_API_KEY") as session:
# Get connected platforms
connections = await get_connections(session)
print("Connected platforms:", connections)
# Schedule a post for next Monday at 2pm UTC
next_monday = datetime.now() + timedelta(days=(7 - datetime.now().weekday()) % 7)
scheduled_time = next_monday.replace(
hour=14, minute=0, second=0, microsecond=0
).isoformat() + "Z"
result = await schedule_post(
session,
content="Automated post from OpenClaw agent!",
platforms=["linkedin-YOUR_PLATFORM_ID"],
scheduled_time=scheduled_time
)
print("Scheduled:", result)
# Check scheduled posts
posts = await get_scheduled_posts(session)
print("Upcoming posts:", posts)
asyncio.run(main())REST API Alternative
For direct API access without MCP:
Create Post (Node.js/TypeScript)
const response = await fetch('https://api.publora.com/api/v1/create-post', {
method: 'POST',
headers: {
'x-publora-key': 'sk_YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: 'Your post content here',
platforms: ['linkedin-connection-id'],
scheduledTime: new Date(Date.now() + 60000).toISOString(), // 1 minute from now
}),
});
const data = await response.json();
console.log('Post created:', data);Create Post (Python)
import requests
from datetime import datetime, timedelta
response = requests.post(
'https://api.publora.com/api/v1/create-post',
headers={'x-publora-key': 'sk_YOUR_API_KEY'},
json={
'content': 'Post content',
'platforms': ['linkedin-connection-id'],
'scheduledTime': (datetime.utcnow() + timedelta(minutes=1)).isoformat() + 'Z',
}
)
print('Post created:', response.json())Scheduled Post (Python)
from datetime import datetime, timedelta
import requests
# Schedule for next Monday at 2pm UTC
next_monday = datetime.utcnow() + timedelta(days=(7 - datetime.utcnow().weekday()) % 7)
scheduled_time = next_monday.replace(hour=14, minute=0, second=0).isoformat() + 'Z'
response = requests.post(
'https://api.publora.com/api/v1/create-post',
headers={'x-publora-key': 'sk_YOUR_API_KEY'},
json={
'content': 'Scheduled post content',
'platforms': ['linkedin-connection-id'],
'scheduledTime': scheduled_time,
}
)
print('Post scheduled:', response.json())Media Upload
Fastest (one call): pass
mediaUrls(public https URLs) tocreate-posttogether withscheduledTime— Publora downloads and attaches the media server-side, then schedules. No upload round-trip.Local files (draft → upload → schedule): attaching media via
get-upload-urlto an already-scheduled post demotes it back todraft(postGroupDemoted: true). So create a draft (noscheduledTime), attach, then schedule withupdate-post. The step-by-step example below uses this flow.
import requests
from datetime import datetime, timedelta
# Step 1: Create a DRAFT (omit scheduledTime) so media can attach without a demote
post_response = requests.post(
'https://api.publora.com/api/v1/create-post',
headers={'x-publora-key': 'sk_YOUR_API_KEY'},
json={
'content': 'Check out this image!',
'platforms': ['linkedin-connection-id'],
# No scheduledTime = draft
}
)
post_data = post_response.json()
post_group_id = post_data['postGroupId']
# Step 2: Request presigned upload URL
upload_url_response = requests.post(
'https://api.publora.com/api/v1/get-upload-url',
headers={'x-publora-key': 'sk_YOUR_API_KEY'},
json={
'postGroupId': post_group_id,
'fileName': 'image.jpg',
'contentType': 'image/jpeg',
'type': 'image'
}
)
upload_data = upload_url_response.json()
# Step 3: Upload file to presigned URL
with open('image.jpg', 'rb') as f:
requests.put(
upload_data['uploadUrl'],
data=f.read(),
headers={'Content-Type': 'image/jpeg'}
)
# Step 4 (optional): finalize/validate the upload early
requests.post(
f'https://api.publora.com/api/v1/complete-media/{upload_data["mediaId"]}',
headers={'x-publora-key': 'sk_YOUR_API_KEY'},
)
# Step 5: Schedule the now-media-bearing draft
requests.put(
f'https://api.publora.com/api/v1/update-post/{post_group_id}',
headers={'x-publora-key': 'sk_YOUR_API_KEY'},
json={'status': 'scheduled', 'scheduledTime': (datetime.utcnow() + timedelta(hours=1)).isoformat() + 'Z'},
)
print('Image attached and post scheduled:', post_group_id)
# --- One-shot alternative: skip steps 2-5 entirely ---
# requests.post('https://api.publora.com/api/v1/create-post',
# headers={'x-publora-key': 'sk_YOUR_API_KEY'},
# json={'content': 'Check out this image!', 'platforms': ['linkedin-connection-id'],
# 'scheduledTime': (datetime.utcnow() + timedelta(hours=1)).isoformat() + 'Z',
# 'mediaUrls': ['https://example.com/photo.jpg']})Platform Capabilities
| Platform | Characters | Images | Video | Special Features |
|---|---|---|---|---|
| 3,000 | 10 | 30 min / 500 MB | Documents (≤100 MB), multi-image, @mentions | |
| X/Twitter | 280 (25K premium) | 4 | 140s / 512 MB | Auto-threading |
| 2,200 | 10 | Reels 900s / 300 MB, feed 3600s, 60s carousel | Reels & Stories, JPEG/PNG/WebP via API | |
| Threads | 500 (10K with text attachment) | 20 | 5 min / 1 GB | Threading disabled |
| TikTok | 2,200 | 35 | 10 min / 4 GB | Image carousel or video |
| YouTube | 100 title / 5,000 desc | 0 | 12 h / 256 GB | Shorts support |
| 63,206 | 10 | 45 min / 2 GB | Page posts, Reels 90s / 1 GB | |
| Bluesky | 300 | 4 | 3 min / 100 MB | Auto-facet detection |
| Mastodon | 500* | 4 | ~99 MB | Instance-variable |
| Telegram | 4,096 (1,024 captions) | 10 | 50 MB (Bot API) | Markdown/HTML |
| 100 title / 800 desc | 1 | 15 min / 2 GB | Connect-only; not publishable |
*Varies by instance
Production Best Practices
Rate Limiting
import time
from functools import wraps
def rate_limit(calls_per_minute=60):
min_interval = 60.0 / calls_per_minute
last_called = [0.0]
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
elapsed = time.time() - last_called[0]
if elapsed < min_interval:
await asyncio.sleep(min_interval - elapsed)
last_called[0] = time.time()
return await func(*args, **kwargs)
return wrapper
return decorator
@rate_limit(calls_per_minute=30)
async def safe_api_call(session, tool_name, params):
return await session.call_tool(tool_name, params)Retry with Exponential Backoff
import asyncio
from functools import wraps
def retry_with_backoff(max_retries=3, base_delay=1):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
for attempt in range(max_retries):
try:
return await func(*args, **kwargs)
except Exception as e:
if attempt == max_retries - 1:
raise
delay = base_delay * (2 ** attempt)
print(f"Attempt {attempt + 1} failed, retrying in {delay}s...")
await asyncio.sleep(delay)
return wrapper
return decorator
@retry_with_backoff(max_retries=3)
async def reliable_post(agent, content, platforms, time):
return await agent.schedule_post(content, platforms, time)Error Handling
async def safe_schedule_post(agent, content, platforms, scheduled_time):
try:
result = await agent.schedule_post(content, platforms, scheduled_time)
return {"success": True, "data": result}
except Exception as e:
error_msg = str(e)
if "rate limit" in error_msg.lower():
return {"success": False, "error": "Rate limited, try again later"}
elif "unauthorized" in error_msg.lower():
return {"success": False, "error": "Invalid API key"}
elif "platform not found" in error_msg.lower():
return {"success": False, "error": "Invalid platform ID"}
else:
return {"success": False, "error": error_msg}Next Steps
- Tools Reference — All 14 MCP tools
- Client Setup — Other MCP clients
- Examples — More conversation examples