This document contains all credentials and instructions needed for an AI agent to post on your social media accounts. Read this file before posting. Each platform section is self-contained with credentials, posting instructions, and curl samples.
| Platform | Auth method | Token expiry | Max length | Status |
|---|---|---|---|---|
| Mastodon | Bearer token | Never | 500 chars | ✅ Ready |
| OAuth2 Bearer | Varies (~2 months) | ~3000 chars | ✅ Ready | |
| Bluesky | App password + session | Session: 2h, password: never | 300 chars | ✅ Ready |
| X | OAuth 1.0a | Never | 280 chars |
How to get these: Go to your Mastodon instance → Preferences → Development → New Application. Grant
write:statusesandwrite:mediascopes. Copy the access token. See: https://docs.joinmastodon.org/client/token/
- Instance :
https://<YOUR_INSTANCE>(e.g.https://mastodon.social) - Handle :
@<YOUR_HANDLE>@<YOUR_INSTANCE> - Access Token :
<MASTODON_ACCESS_TOKEN> - Token expiry : never
curl -s -X POST https://<YOUR_INSTANCE>/api/v1/statuses \
-H "Authorization: Bearer <MASTODON_ACCESS_TOKEN>" \
-d "status=MESSAGE_HERE" \
-d "visibility=public"visibility:public,unlisted,private,direct
Doc: https://docs.joinmastodon.org/methods/media/
- Upload the image → get a
media_id - Attach the
media_idto the status
# Step 1: Upload image
curl -s -X POST https://<YOUR_INSTANCE>/api/v2/media \
-H "Authorization: Bearer <MASTODON_ACCESS_TOKEN>" \
-F "file=@/path/to/image.png" \
-F "description=Alt text for accessibility"
# Step 2: Post with media_id from step 1
curl -s -X POST https://<YOUR_INSTANCE>/api/v1/statuses \
-H "Authorization: Bearer <MASTODON_ACCESS_TOKEN>" \
-d "status=MESSAGE_HERE" \
-d "media_ids[]=MEDIA_ID_HERE" \
-d "visibility=public"- Max 500 characters per post
- Supports hashtags natively (#hashtag)
- No token refresh needed
- Images: max 4 per post, formats jpg/png/gif, max 16MB
How to get these:
- You must first create a Company Page on LinkedIn (even a dummy one) — it's required to create a developer app. Go to https://www.linkedin.com/company/setup/new/ and fill in the minimum fields (name, URL — can be any URL you own or a placeholder).
- Create an app at https://www.linkedin.com/developers/apps → associate it with your Company Page.
- On the app's Products tab, request access to Share on LinkedIn (
w_member_social) and Sign In with LinkedIn using OpenID Connect (openid,profile).- Go to the Auth tab to copy your Client ID and Client Secret.
- Follow the OAuth flow below to get an access token.
See: https://learn.microsoft.com/en-us/linkedin/shared/authentication/authorization-code-flow
- Client ID :
<LINKEDIN_CLIENT_ID> - Client Secret :
<LINKEDIN_CLIENT_SECRET> - Access Token :
<LINKEDIN_ACCESS_TOKEN> - Token expiry : ~2 months from creation (check your token's actual expiry)
- Member URN :
urn:li:person:<YOUR_MEMBER_ID> - Redirect URI :
https://httpbin.org/get(or any URI you control)
curl -s -X POST "https://api.linkedin.com/rest/posts" \
-H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-H "LinkedIn-Version: 202504" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-d '{
"author": "urn:li:person:<YOUR_MEMBER_ID>",
"commentary": "MESSAGE_HERE",
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": []
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}'Doc: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api
- Register the upload → get an
uploadUrlandimage URN - Upload the binary image to the
uploadUrl - Create the post referencing the
image URN
# Step 1: Register upload
curl -s -X POST "https://api.linkedin.com/rest/images?action=initializeUpload" \
-H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-H "LinkedIn-Version: 202504" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-d '{"initializeUploadRequest": {"owner": "urn:li:person:<YOUR_MEMBER_ID>"}}'
# Step 2: Upload binary (use uploadUrl from step 1)
curl -s -X PUT "UPLOAD_URL_HERE" \
-H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
--upload-file /path/to/image.png
# Step 3: Post with image (use image URN from step 1)
curl -s -X POST "https://api.linkedin.com/rest/posts" \
-H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-H "LinkedIn-Version: 202504" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-d '{
"author": "urn:li:person:<YOUR_MEMBER_ID>",
"commentary": "MESSAGE_HERE",
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": []
},
"content": {
"media": {
"id": "IMAGE_URN_HERE",
"title": "Image title"
}
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}'visibility:PUBLIC,CONNECTIONSfeedDistribution:MAIN_FEED,NONE
When the message contains a URL but no image, add a content.article block to force LinkedIn to render a link preview card. Without this, the API may not crawl the URL for OG metadata.
curl -s -X POST "https://api.linkedin.com/rest/posts" \
-H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-H "LinkedIn-Version: 202504" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-d '{
"author": "urn:li:person:<YOUR_MEMBER_ID>",
"commentary": "MESSAGE_HERE",
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": []
},
"content": {
"article": {
"source": "URL_HERE",
"title": "TITLE_HERE",
"description": "DESCRIPTION_HERE"
}
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}'Note: when posting with an image, use the content.media block instead (see above). Do not combine article and media.
LinkedIn's content.article block alone does not reliably render a video thumbnail/vignette for YouTube links. To force a proper link card with the video thumbnail, you must:
- Download the YouTube thumbnail image (e.g.
https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg) - Upload it to LinkedIn as an image (register upload → upload binary)
- Reference the image URN in the
content.article.thumbnailfield
# Step 1: Download YouTube thumbnail
curl -s -o /tmp/yt_thumb.jpg "https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg"
# Step 2: Register image upload
curl -s -X POST "https://api.linkedin.com/rest/images?action=initializeUpload" \
-H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-H "LinkedIn-Version: 202504" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-d '{"initializeUploadRequest": {"owner": "urn:li:person:<YOUR_MEMBER_ID>"}}'
# Step 3: Upload the thumbnail (use uploadUrl from step 2)
curl -s -X PUT "UPLOAD_URL_HERE" \
-H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
--upload-file /tmp/yt_thumb.jpg
# Step 4: Post with article + thumbnail (use image URN from step 2)
curl -s -X POST "https://api.linkedin.com/rest/posts" \
-H "Authorization: Bearer <LINKEDIN_ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-H "LinkedIn-Version: 202504" \
-H "X-Restli-Protocol-Version: 2.0.0" \
-d '{
"author": "urn:li:person:<YOUR_MEMBER_ID>",
"commentary": "MESSAGE_HERE",
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": []
},
"content": {
"article": {
"source": "YOUTUBE_URL_HERE",
"title": "VIDEO_TITLE_HERE",
"description": "VIDEO_DESCRIPTION_HERE",
"thumbnail": "IMAGE_URN_HERE"
}
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}'YouTube thumbnail URL patterns:
- Max resolution:
https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg - High quality:
https://img.youtube.com/vi/VIDEO_ID/hqdefault.jpg - Standard:
https://img.youtube.com/vi/VIDEO_ID/sddefault.jpg
Try maxresdefault.jpg first; fall back to hqdefault.jpg if it returns a 404 (not all videos have max-res thumbnails).
LinkedIn does not support refresh tokens on the free tier. When the token expires, redo the full OAuth flow:
- Open in browser:
https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=<LINKEDIN_CLIENT_ID>&redirect_uri=<YOUR_REDIRECT_URI>&scope=openid%20profile%20w_member_social
-
Authorize, copy the
codefrom the redirect URL -
Exchange the code:
curl -s -X POST https://www.linkedin.com/oauth/v2/accessToken \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=CODE_HERE" \
--data-urlencode "redirect_uri=<YOUR_REDIRECT_URI>" \
--data-urlencode "client_id=<LINKEDIN_CLIENT_ID>" \
--data-urlencode "client_secret=<LINKEDIN_CLIENT_SECRET>" | python3 -m json.tool- Update the Access Token above. The Member URN never changes.
How to get these: Go to https://bsky.app → Settings → Privacy and Security → App Passwords → Add App Password. Your DID can be found by calling the
com.atproto.identity.resolveHandleendpoint with your handle. See: https://atproto.com/guides/applications
- Handle :
<YOUR_HANDLE>.bsky.social - App Password :
<BLUESKY_APP_PASSWORD> - DID :
<YOUR_DID>(e.g.did:plc:abc123...) - PDS :
<YOUR_PDS_URL>(e.g.https://shimeji.us-east.host.bsky.network)
Step 1 — Create session (returns accessJwt, valid ~2 hours):
curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
-H "Content-Type: application/json" \
-d '{
"identifier": "<YOUR_HANDLE>.bsky.social",
"password": "<BLUESKY_APP_PASSWORD>"
}'Step 2 — Post (use accessJwt from step 1):
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
curl -s -X POST "<YOUR_PDS_URL>/xrpc/com.atproto.repo.createRecord" \
-H "Authorization: Bearer ACCESS_JWT_HERE" \
-H "Content-Type: application/json" \
-d "{
\"repo\": \"<YOUR_DID>\",
\"collection\": \"app.bsky.feed.post\",
\"record\": {
\"\$type\": \"app.bsky.feed.post\",
\"text\": \"MESSAGE_HERE\",
\"createdAt\": \"$NOW\"
}
}"Doc: https://atproto.com/blog/create-post
- Upload the image blob → get a
blobreference - Create the post with an
embedcontaining the blob
# Step 1: Upload image (use accessJwt from session)
curl -s -X POST "<YOUR_PDS_URL>/xrpc/com.atproto.repo.uploadBlob" \
-H "Authorization: Bearer ACCESS_JWT_HERE" \
-H "Content-Type: image/png" \
--data-binary @/path/to/image.png
# Step 2: Post with image (use blob ref from step 1)
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
curl -s -X POST "<YOUR_PDS_URL>/xrpc/com.atproto.repo.createRecord" \
-H "Authorization: Bearer ACCESS_JWT_HERE" \
-H "Content-Type: application/json" \
-d "{
\"repo\": \"<YOUR_DID>\",
\"collection\": \"app.bsky.feed.post\",
\"record\": {
\"\$type\": \"app.bsky.feed.post\",
\"text\": \"MESSAGE_HERE\",
\"createdAt\": \"$NOW\",
\"embed\": {
\"\$type\": \"app.bsky.embed.images\",
\"images\": [{
\"alt\": \"Alt text for accessibility\",
\"image\": BLOB_REF_FROM_STEP_1
}]
}
}
}"When the message contains a URL but no image, add an embed of type app.bsky.embed.external to force Bluesky to render a link preview card. Without this, the API does not automatically detect URLs or generate link cards.
Additionally, to make the URL clickable in the post text, you must add a facets entry of type app.bsky.richtext.facet#link. The byteStart and byteEnd must match the exact byte offsets of the URL in the UTF-8 encoded text.
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
curl -s -X POST "<YOUR_PDS_URL>/xrpc/com.atproto.repo.createRecord" \
-H "Authorization: Bearer ACCESS_JWT_HERE" \
-H "Content-Type: application/json" \
-d "{
\"repo\": \"<YOUR_DID>\",
\"collection\": \"app.bsky.feed.post\",
\"record\": {
\"\$type\": \"app.bsky.feed.post\",
\"text\": \"MESSAGE_HERE\",
\"createdAt\": \"$NOW\",
\"facets\": [{
\"index\": {
\"byteStart\": BYTE_START,
\"byteEnd\": BYTE_END
},
\"features\": [{
\"\$type\": \"app.bsky.richtext.facet#link\",
\"uri\": \"URL_HERE\"
}]
}],
\"embed\": {
\"\$type\": \"app.bsky.embed.external\",
\"external\": {
\"uri\": \"URL_HERE\",
\"title\": \"TITLE_HERE\",
\"description\": \"DESCRIPTION_HERE\"
}
}
}
}"Note: the external.thumb field is optional — you can include a blob reference to a thumbnail image if desired. If omitted, Bluesky will render the card without a thumbnail. When posting with an image, use app.bsky.embed.images instead (see above). Do not combine external and images embeds (use app.bsky.embed.recordWithMedia if you need both).
Bluesky does not auto-fetch thumbnails for external links. Without a thumb blob, the link card renders with no image. To get a proper video vignette, you must:
- Download the YouTube thumbnail
- Upload it as a blob
- Include the full blob reference in
external.thumb
# Step 1: Download YouTube thumbnail
curl -s -o /tmp/yt_thumb.jpg "https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg"
# Step 2: Upload as blob (use accessJwt from session)
curl -s -X POST "<YOUR_PDS_URL>/xrpc/com.atproto.repo.uploadBlob" \
-H "Authorization: Bearer ACCESS_JWT_HERE" \
-H "Content-Type: image/jpeg" \
--data-binary @/tmp/yt_thumb.jpg
# → Returns: {"blob": {"$type": "blob", "ref": {"$link": "BLOB_HASH"}, "mimeType": "image/jpeg", "size": SIZE}}
# Step 3: Post with thumbnail in external embed
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
curl -s -X POST "<YOUR_PDS_URL>/xrpc/com.atproto.repo.createRecord" \
-H "Authorization: Bearer ACCESS_JWT_HERE" \
-H "Content-Type: application/json" \
-d "{
\"repo\": \"<YOUR_DID>\",
\"collection\": \"app.bsky.feed.post\",
\"record\": {
\"\$type\": \"app.bsky.feed.post\",
\"text\": \"MESSAGE_HERE\",
\"createdAt\": \"$NOW\",
\"facets\": [{
\"index\": {
\"byteStart\": BYTE_START,
\"byteEnd\": BYTE_END
},
\"features\": [{
\"\$type\": \"app.bsky.richtext.facet#link\",
\"uri\": \"YOUTUBE_URL_HERE\"
}]
}],
\"embed\": {
\"\$type\": \"app.bsky.embed.external\",
\"external\": {
\"uri\": \"YOUTUBE_URL_HERE\",
\"title\": \"VIDEO_TITLE_HERE\",
\"description\": \"VIDEO_DESCRIPTION_HERE\",
\"thumb\": {
\"\$type\": \"blob\",
\"ref\": {
\"\$link\": \"BLOB_HASH_FROM_STEP_2\"
},
\"mimeType\": \"image/jpeg\",
\"size\": SIZE_FROM_STEP_2
}
}
}
}
}"Important: the thumb field must contain the full blob object (with $type, ref.$link, mimeType, and size) — not just the hash. Copy the entire blob object from the upload response.
YouTube thumbnail URL patterns:
- Max resolution:
https://img.youtube.com/vi/VIDEO_ID/maxresdefault.jpg - High quality:
https://img.youtube.com/vi/VIDEO_ID/hqdefault.jpg - Standard:
https://img.youtube.com/vi/VIDEO_ID/sddefault.jpg
Try maxresdefault.jpg first; fall back to hqdefault.jpg if it returns a 404.
To delete a Bluesky post, extract the rkey from the post URI (the last segment after the final /):
curl -s -X POST "<YOUR_PDS_URL>/xrpc/com.atproto.repo.deleteRecord" \
-H "Authorization: Bearer ACCESS_JWT_HERE" \
-H "Content-Type: application/json" \
-d '{
"repo": "<YOUR_DID>",
"collection": "app.bsky.feed.post",
"rkey": "RKEY_HERE"
}'- Max 300 characters (graphemes) per post
- App password never expires
- Session token (
accessJwt) expires after ~2 hours — create a new session each time - Images: max 4 per post, formats jpg/png, max 1MB per image
How to get these: Sign up at https://developer.x.com → create a project and app → go to "Keys and tokens" tab → generate Consumer Keys (API Key & Secret) and Access Token & Secret. Free tier gives you write access. See: https://developer.x.com/en/docs/authentication/oauth-1-0a
- Developer console : https://developer.x.com/en/portal/dashboard
- App Name :
<YOUR_APP_NAME> - Consumer Key :
<X_CONSUMER_KEY> - Consumer Secret :
<X_CONSUMER_SECRET> - Access Token :
<X_ACCESS_TOKEN> - Access Token Secret :
<X_ACCESS_TOKEN_SECRET> - Bearer Token :
<X_BEARER_TOKEN> - OAuth2 Client ID :
<X_OAUTH2_CLIENT_ID> - OAuth2 Client Secret :
<X_OAUTH2_CLIENT_SECRET> - Token expiry : never (OAuth 1.0a)
X requires OAuth 1.0a signature generation. Use this bash+python script:
AUTH_HEADER=$(python3 << 'PYEOF'
import urllib.parse, hmac, hashlib, base64, time, uuid
consumer_key = "<X_CONSUMER_KEY>"
consumer_secret = "<X_CONSUMER_SECRET>"
access_token = "<X_ACCESS_TOKEN>"
access_token_secret = "<X_ACCESS_TOKEN_SECRET>"
url = "https://api.x.com/2/tweets"
method = "POST"
oauth_params = {
"oauth_consumer_key": consumer_key,
"oauth_nonce": uuid.uuid4().hex,
"oauth_signature_method": "HMAC-SHA1",
"oauth_timestamp": str(int(time.time())),
"oauth_token": access_token,
"oauth_version": "1.0"
}
params_str = "&".join(f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(v, safe='')}" for k, v in sorted(oauth_params.items()))
base_string = f"{method}&{urllib.parse.quote(url, safe='')}&{urllib.parse.quote(params_str, safe='')}"
signing_key = f"{urllib.parse.quote(consumer_secret, safe='')}&{urllib.parse.quote(access_token_secret, safe='')}"
signature = base64.b64encode(hmac.new(signing_key.encode(), base_string.encode(), hashlib.sha1).digest()).decode()
oauth_params["oauth_signature"] = signature
print("OAuth " + ", ".join(f'{k}="{urllib.parse.quote(v, safe="")}"' for k, v in sorted(oauth_params.items())))
PYEOF
)
curl -s -X POST "https://api.x.com/2/tweets" \
-H "Authorization: $AUTH_HEADER" \
-H "Content-Type: application/json" \
-d '{"text":"MESSAGE_HERE"}' | python3 -m json.toolDoc: https://developer.x.com/en/docs/x-api/tweets/manage-tweets/api-reference/post-tweets (media) + https://developer.x.com/en/docs/x-api/media/upload-media/api-reference
- Upload the image via media upload endpoint → get a
media_id - Create the tweet referencing the
media_id
Note: media upload still uses the v1.1 endpoint. The OAuth 1.0a signature must be generated for the upload URL.
- Max 280 characters per post
- Free tier : 1,500 tweets/month, credits reset monthly
- Tokens never expire — no refresh needed
- If you get
CreditsDepletederror, wait for monthly credit reset - OAuth2 credentials stored above for reference but OAuth 1.0a is preferred (simpler, no expiry)
- Always confirm with the user before posting publicly
- Character limits : respect each platform's limit (280 X, 300 Bluesky, 500 Mastodon, ~3000 LinkedIn)
- LinkedIn token : check expiry date before posting. If expired, guide user through refresh procedure
- Bluesky : always create a fresh session before posting (step 1 then step 2)
- X : if credits are depleted, inform the user and skip X
- Test posts : use
visibility=directon Mastodon,visibility=CONNECTIONS+feedDistribution=NONEon LinkedIn. Bluesky and X have no private post option — warn the user - Hashtags : supported natively on Mastodon and X. LinkedIn uses them in the commentary text. Bluesky requires facet annotations for clickable hashtags (plain #text works as display only)
- YouTube / video URLs on LinkedIn : the basic
content.articleblock does NOT reliably render a video thumbnail. Always download the YouTube thumbnail, upload it to LinkedIn, and pass the image URN incontent.article.thumbnail. See the "Posting with a video URL" section under LinkedIn - YouTube / video URLs on Bluesky : Bluesky does NOT auto-fetch thumbnails for external links. Always download the YouTube thumbnail, upload it as a blob, and include the full blob reference in
external.thumb. See the "Posting with a video URL" section under Bluesky
When asking the agent to post with an image, provide one of:
- A local file path : e.g.
~/Pictures/my-image.png— the agent will use this path directly in the curl upload commands - A URL : the agent will download it first to a temp file, then upload it
- A file in the vault : reference it like
Attachments/my-image.png— the agent will resolve the full path
Supported formats: JPG, PNG (all platforms). GIF supported on Mastodon and X only. Recommended: provide a square or 16:9 image, at least 1200x675px for best rendering across platforms. Always provide alt text for accessibility — the agent should ask for it if not provided.