Let your agent run the calendar.
An MCP server and a REST API over the same scheduler the dashboard uses. Point Claude, Cursor or your own agent at it and it can draft, validate and schedule posts to Instagram, Facebook, TikTok, X, Pinterest and YouTube.
Quickstart
Create a key, connect an MCP client, and get a first post on the calendar. About five minutes.
Create an API key
In the dashboard, go to Settings → API keys and create a key. Choose a scope preset while you are there — draft is the default and the one to start with: an agent using it can compose posts, but every post lands as a draft for you to approve.
utsk_live_a1b2c3d4e5f6.WKq7Lm3PxZ2vN8tRfY0sHcJ4bA6dE1gU9iO5nQwT2xMorganization_id parameter anywhere in the API, because the key already decides which workspace you are talking to.API access is on paid plans only. See pricing — Hobby has no API access.
Connect Claude
Point Claude Code at the remote MCP server. Nothing to install.
claude mcp add --transport http uptrended-social \
https://uptrended.social/mcp \
--header "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here"For Claude Desktop, Cursor, VS Code and anything else that only speaks stdio, see MCP setup below.
Ask for a post
The agent should do three things in order: list your connected accounts, validate a draft payload against the platform rules, then create the post.
Draft a post announcing our Tuesday launch and put it on
the calendar for 9am Tuesday, Instagram and X. Validate it
before you create anything.With a draft key it comes back as a draft, waiting for you in the dashboard. With a write key it is scheduled for real.
The workflow that works
Every posting task follows the same three steps, and skipping one is how agents produce payloads that fail:
- list_accounts — every target needs a real
social_account_id. They cannot be guessed from a handle. - validate_post — a dry run. Writes nothing, consumes no quota, never touches a live account, and returns per-target errors. Iterate here until it is clean.
- create_post — one call, all targets. Read the response rather than assuming it scheduled.
Connecting accounts is not in the API
Linking an Instagram, Facebook, TikTok, X, Pinterest or YouTube account is an interactive OAuth flow: the platform shows its own consent screen in a browser, and in several cases the person has to pick which Page, board or channel to grant. That cannot be driven headlessly, and shipping an API that pretends otherwise would just fail confusingly.
So: connect accounts once at uptrended.social, in the browser. After that they appear in list_accounts and in GET /api/v1/accounts, and everything else is programmable.
MCP setup
UT Social is a remote Streamable HTTP MCP server. Use it directly if your client supports remote servers with custom headers; there is an npx bridge for the ones that don’t.
Remote (preferred)
Endpoint: https://uptrended.social/mcp
Transport: Streamable HTTP
Header: Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-hereNo install, no local process, and the tool list is always current. Claude Code:
claude mcp add --transport http uptrended-social \
https://uptrended.social/mcp \
--header "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here"stdio bridge (fallback)
For clients that can only spawn a local command — Claude Desktop, Cursor, VS Code — npx -y @uptrended/mcp proxies stdio to the same endpoint. It implements no tools of its own; it forwards JSON-RPC frames, so the server stays the only source of truth for what is available.
{
"mcpServers": {
"uptrended-social": {
"command": "npx",
"args": ["-y", "@uptrended/mcp"],
"env": {
"UTSOCIAL_API_KEY": "utsk_live_xxxxxxxxxxxx.your-secret-here"
}
}
}
}macOS: ~/Library/Application Support/Claude/claude_desktop_config.json. Windows: %APPDATA%\Claude\claude_desktop_config.json. Restart the app afterwards — it reads that file only at launch.
{
"mcpServers": {
"uptrended-social": {
"command": "npx",
"args": ["-y", "@uptrended/mcp"],
"env": {
"UTSOCIAL_API_KEY": "utsk_live_xxxxxxxxxxxx.your-secret-here"
}
}
}
}{
"inputs": [
{
"id": "utsocial-key",
"type": "promptString",
"description": "UT Social API key",
"password": true
}
],
"servers": {
"uptrended-social": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@uptrended/mcp"],
"env": { "UTSOCIAL_API_KEY": "${input:utsocial-key}" }
}
}
}Bridge configuration
| Variable | Required | Default |
|---|---|---|
| UTSOCIAL_API_KEY | yes | — |
| UTSOCIAL_BASE_URL | no | https://uptrended.social |
The bridge validates the key format locally and writes every diagnostic to stderr, because stdout is the MCP wire. In Claude Desktop those land in ~/Library/Logs/Claude/mcp-server-uptrended-social.log.
Tools
Tools are filtered by the key’s scopes. A read-only key does not get refused when it calls create_post — it never sees the tool at all, which stops an agent from planning around a capability it does not have.
| Tool | Does | Scope |
|---|---|---|
| list_accounts | Connected accounts and their ids | accounts:read |
| list_media | Media already in the workspace library | media:read |
| ingest_media | Fetch a public URL server-side (100 MB cap) | media:write |
| validate_post | Dry run. No writes, no quota, no live account touched | posts:read |
| create_post | Create a post across every target at once | posts:draft (+ posts:write to schedule) |
| get_post | One post, with per-target status | posts:read |
| list_posts | Keyset-paginated post list | posts:read |
| cancel_post | Stop a scheduled post, keep the record | posts:write |
| reschedule_post | Move a scheduled post | posts:write |
| delete_post | Remove a post entirely | posts:write |
| get_usage | Quota used and remaining | usage:read |
Why validate_post matters more than it sounds
Platform rules are inconsistent enough that a payload which looks obviously fine is often not. validate_post takes the exact object you would pass to create_post and reports what each target would reject — for free, and without publishing anything. Two or three validate round-trips before a create is normal.
Per-platform rules it catches
- X — 280 weighted characters. Every URL counts as 23 no matter its real length, and CJK characters and most emoji count double. A 250-character draft plus two links does not fit.
- YouTube — descriptions cap at 5,000 UTF-8 bytes, not characters. Emoji are four bytes each, so an emoji-heavy description can fail well under 5,000 characters. Titles cap at 100 characters.
- Instagram — captions cap at 2,200 characters, and
post_and_storyforks into two independent targets that succeed or fail separately. Report per-target results, not one verdict. - TikTok — needs an explicit music-usage agreement flag, which is a legal acknowledgement and has no sensible default. Apps that have not completed TikTok’s audit are restricted to
SELF_ONLYvisibility; that is TikTok’s rule and cannot be worked around. - Pinterest — a pin needs a
board_id. Valid boards come back with the account inlist_accounts. - Facebook — Pages only. Personal profiles are not publishable through the platform API.
Quota is per post, not per target
One post fanned out to six platforms is one unit of quota. Six single-platform posts are six. Put every target in a single create_post call unless the content itself genuinely differs per platform — and even then, per-target overrides are usually the better answer than separate posts.
REST API
Everything the MCP server does is also plain HTTP, with identical shapes.
curl https://uptrended.social/api/v1/accounts \
-H "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here"Conventions
- Base URL https://uptrended.social/api/v1. Every request needs
Authorization: Bearer utsk_live_…. - Collections return
{ data: [...], next_cursor, has_more }and paginate by keyset. Follownext_cursorwhilehas_moreis true; there are no page numbers. - Errors return
{ error, code, request_id }. Quote therequest_idwhen reporting a problem. organization_idis never accepted in a request body. The key determines the workspace.- Mutating calls accept an
Idempotency-Keyheader.
Endpoints
| Endpoint | Description | Scope |
|---|---|---|
| GET/me | Key identity, workspace, plan, granted scopes | — |
| GET/usage | Quota used and remaining for the period | usage:read |
| GET/accounts | Connected social accounts and their ids | accounts:read |
| GET/media | Media library | media:read |
| POST/media/ingest | Fetch a public URL server-side. 100 MB cap | media:write |
| POST/media/upload-url | Signed URL for a direct upload. Larger files | media:write |
| POST/media/register | Register an upload once the bytes have landed | media:write |
| DELETE/media/:id | Delete a media item | media:write |
| GET/posts | List posts. Keyset paginated | posts:read |
| POST/posts/validate | Dry run. Writes nothing, no quota | posts:read |
| POST/posts | Create a post across all targets | posts:draft (+ posts:write to schedule) |
| GET/posts/:id | One post with per-target status | posts:read |
| POST/posts/:id/cancel | Stop a scheduled post, keep the record | posts:write |
| POST/posts/:id/reschedule | Move it. Cheaper than delete-and-recreate | posts:write |
| POST/posts/:id/retry | Retry failed targets | posts:write |
| DELETE/posts/:id | Delete a post | posts:write |
Validate, then create
curl https://uptrended.social/api/v1/posts/validate \
-H "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here" \
-H "Content-Type: application/json" \
-d '{
"caption": "Launching Tuesday. Here is what changed.",
"scheduled_at": "2026-08-04T09:00:00-05:00",
"targets": [
{ "social_account_id": "acc_2f9…", "kind": "post" },
{ "social_account_id": "acc_7c1…", "kind": "post" }
]
}'A clean validate returns no target errors. Then send the same body to POST /api/v1/posts with an Idempotency-Key.
draft-scoped key, that create saves a draft and does not schedule it. The response says so. Read the returned status rather than treating 200 as “scheduled”.Media
Two ways to get bytes into a post.
- Ingest a URL — POST /media/ingest (or the
ingest_mediatool) fetches a publicly reachable URL server-side. 100 MB cap. This covers most cases. - Direct upload — POST /media/upload-url returns a signed URL, you PUT the bytes to it, then POST /media/register to add it to the library. Use this for larger files or local bytes.
Check list_media before re-ingesting an asset you already have. Media carries its own per-platform constraints — aspect ratio, duration, codec — and validate_post checks those too, which is one more reason to validate before creating.
Scopes
A key carries one of three presets. Pick the least it needs — an agent cannot exceed its key, so the scope is the actual safety boundary, not the prompt.
Look, don’t touch
Accounts, posts, media and usage, read-only. Mutating tools are not even listed.
Default · recommended
Everything read can do, plus create_post — but posts are saved as drafts for a human to approve, never scheduled.
Publishes for real
Schedules and publishes to live accounts, and can cancel, reschedule and delete. Give this out deliberately.
Presets expand into the granular scopes named in the tables above — accounts:read, media:write, posts:draft, posts:write, usage:read. GET /api/v1/me returns exactly what a key was granted.
200 from create_post on a draft key means “saved as a draft”, not “scheduled”. The response distinguishes them; anything reporting back to a human should read it.Plans & rate limits
| Plan | API access | Requests / min / key | Keys |
|---|---|---|---|
| Hobby — free | None | — | 0 |
| Starter — $19 | Yes | 60 | 2 |
| Growth — $49 | Yes | 120 | 5 |
| Scale — $99 | Yes | 300 | 20 |
Rate limits are per key per minute, so splitting traffic across two keys on the same workspace genuinely doubles headroom — up to the key cap. Exceeding a cap returns 402 PLAN_REQUIRED at key-creation time, not at request time.
A rate-limited response carries Retry-After plus RateLimit-* headers. Honour Retry-After rather than retrying on a fixed timer.
Idempotency
Every mutating call accepts an Idempotency-Key header. Use it, and reuse it on retry.
curl https://uptrended.social/api/v1/posts \
-H "Authorization: Bearer utsk_live_xxxxxxxxxxxx.your-secret-here" \
-H "Idempotency-Key: 6f1c9e2a-4b77-4a10-9d3e-8f2b5c0a71de" \
-H "Content-Type: application/json" \
-d @post.json- Generate one fresh UUID per logical post.
- On a retry — timeout, 5xx, dropped connection — send the same key again. The server returns the original result instead of creating a second post.
- Generating a new key on retry is how you double-post to a real audience. It is the most damaging mistake available here and the easiest to avoid.
- If a create failed ambiguously and no key was sent, do not blindly retry — call GET /api/v1/posts and check whether it landed.
Errors
Every error is { error, code, request_id }. 401, 402 and 403 are terminal — retrying will not resolve them.
| Status | Code | Meaning | Do |
|---|---|---|---|
| 401 | UNAUTHORIZED | Key invalid, revoked or deleted | Stop. Issue a new key in Settings → API keys. |
| 402 | PLAN_REQUIRED | Plan does not include API access, or a cap was hit | Stop. Upgrade or free up a key slot. |
| 403 | INSUFFICIENT_SCOPE | The key’s scopes do not cover this call | Stop. Do not route around it with another endpoint. |
| 404 | NOT_FOUND | Unknown id, or an id belonging to another workspace | Re-list. A stale id from earlier is the usual cause. |
| 422 | VALIDATION_FAILED | Payload rejected, with per-target detail | Fix it and run validate again. |
| 429 | RATE_LIMITED | Per-key per-minute limit exceeded | Back off for Retry-After seconds. |
Cross-workspace ids return 404 rather than 403 on purpose — a key should not be able to probe for the existence of resources it cannot reach.
Machine-readable summary
Point an agent at /docs/llms.txt for a plain-text version of this page.