Publish posts programmatically to a LinkedIn Company Page via the versioned Posts API (/rest/posts), including text posts, single-image posts, multi-image ca...
---
name: linkedin-page-publisher
description: Publish posts programmatically to a LinkedIn Company Page via the versioned Posts API (/rest/posts), including text posts, single-image posts, multi-image carousels, video posts, and article link previews. Handles the full multi-step media upload flow (initializeUpload → PUT binary → wait for LinkedIn to finish processing → reference URN in the post). Use this skill whenever the user wants to post to LinkedIn, publish to a Company Page, automate LinkedIn content, cross-post to LinkedIn from another system, build a LinkedIn publishing bot or scheduler, or upload images or videos to LinkedIn programmatically — even when they don't say "API" explicitly. Also use it when the user mentions errors from the LinkedIn Posts API, w_organization_social scope, urn:li:organization URNs, UGC posts, or /rest/images and /rest/videos endpoints.
---
# LinkedIn Page Publisher
Publishes content to a LinkedIn Company Page through LinkedIn's versioned REST API (`/rest/posts`). The skill ships a Python CLI (`scripts/publish.py`) that also works as an importable library, plus a one-time OAuth helper (`scripts/get_token.py`) to obtain the initial access token.
## When to reach for what
- **User just wants to post something** → run `scripts/publish.py` directly with the right subcommand (see "Publishing" below).
- **User is setting this up for the first time and doesn't have a token yet** → walk them through `references/setup.md`, which covers creating the LinkedIn Developer app, requesting Community Management API access, and running `scripts/get_token.py` to complete the 3-legged OAuth flow.
- **User hits a cryptic API error** → consult `references/troubleshooting.md` before guessing. LinkedIn's error messages are often misleading (e.g., "unauthorized" frequently means "wrong scope" or "not a page admin," not "bad token").
- **User wants to extend the skill** (schedule posts, add analytics, wrap it in a web service, etc.) → import from `scripts/lib/` rather than shelling out to the CLI. The library layer is the contract; the CLI is just one consumer of it.
## Environment variables
These three cover every use case. Don't invent a config file — env vars compose better with cron, CI, and shell scripts.
| Variable | Required | What it is |
|---|---|---|
| `LINKEDIN_ACCESS_TOKEN` | yes | OAuth 2.0 access token with `w_organization_social` scope. Valid for 60 days; refresh tokens last 365 days. |
| `LINKEDIN_ORG_ID` | yes | Numeric organization ID only (e.g. `5515715`), not the full URN. The script prepends `urn:li:organization:`. Find it at `https://www.linkedin.com/company/<slug>/admin/` — the URL shows the numeric ID once you're in the admin view. |
| `LINKEDIN_API_VERSION` | no | YYYYMM format, e.g. `202602`. Defaults to `202602` (February 2026). LinkedIn supports each version for a minimum of one year, so bump this deliberately when new features ship. |
## Publishing
`scripts/publish.py` exposes five subcommands. Every subcommand accepts `--text` (post commentary, up to 3,000 characters) and `--dry-run` (prints the request body without calling the API — useful for debugging).
### Text-only post
```bash
python scripts/publish.py text --text "Announcing our Q1 roadmap. Three big bets this year: ..."
```
### Single image
```bash
python scripts/publish.py image path/to/photo.jpg \
--text "At the AI builders meetup in Lima last night" \
--alt "Twelve people seated around a conference table with laptops open"
```
`--alt` is required. Accessible alt text is also what LinkedIn's ranking signals care about, so don't skip it.
### Multi-image carousel (2–20 images)
```bash
python scripts/publish.py multi-image img1.jpg img2.jpg img3.jpg \
--text "Recap from OpenClaw's monthly hackathon" \
--alt "Photo of attendees" "Photo of winning demo" "Group photo at the end"
```
Pass one `--alt` per image, in order. If the count doesn't match, the CLI errors out before hitting the API.
### Video
```bash
python scripts/publish.py video path/to/demo.mp4 \
--text "Demo of our new agent flow" \
--title "Agent flow walkthrough"
```
Video uploads take longer. The script auto-detects file size and uses single-part upload under 200 MB or multipart upload above. It polls LinkedIn's video status endpoint until the asset reaches `AVAILABLE` before creating the post — posting against a still-processing video produces a post with a broken player.
### Article link preview
```bash
python scripts/publish.py article https://example.com/my-blog-post \
--text "Wrote up how we built this — thoughts welcome"
```
LinkedIn scrapes the URL's OpenGraph metadata to render the preview card. If the target page's OG tags are missing or wrong, the preview will look bad — that's a site issue, not an API issue. The CLI prints the article URN in the response so the user can verify.
## Using the library from other Python code
```python
from scripts.lib.client import LinkedInClient
from scripts.lib.posts import post_text, post_image, post_video, post_article, post_multi_image
client = LinkedInClient() # reads env vars
# Text
post_text(client, "Hello, page followers!")
# Image
post_image(client, "photo.jpg", text="At the meetup", alt="Group photo")
# Video (handles small and multipart automatically)
post_video(client, "demo.mp4", text="Quick demo", title="Demo")
# Article
post_article(client, "https://example.com/post", text="Worth a read")
```
All post functions return the post URN (e.g. `urn:li:share:7045020441609936898`) on success and raise on failure. Don't swallow exceptions — the error messages carry the LinkedIn response body, which is the only useful debugging signal.
## Why the upload flow has so many steps
LinkedIn's media upload is a three-step handshake:
1. **Register** — `POST /rest/images?action=initializeUpload` (or `/rest/videos?action=initializeUpload`). LinkedIn returns an upload URL (or several, for multipart) and a pre-assigned URN (`urn:li:image:...` or `urn:li:video:...`).
2. **Upload** — `PUT` the binary bytes to the returned upload URL(s). No auth header on these PUT calls — the URL itself is pre-signed.
3. **Reference** — create the post with the URN in `content.media.id`.
For videos, there's an implicit fourth step: LinkedIn processes the video asynchronously. If you create the post immediately after the PUT, the video may not be ready and the post will be broken. The library polls `GET /rest/videos/{urn}` until `status == AVAILABLE` before returning the URN. Multipart video uploads also need a `finalizeUpload` call with the ETags from each part — the library handles this.
This is why the skill bundles upload helpers rather than expecting callers to reimplement them — the edge cases (async processing, multipart, alt text on multi-image) are where naive implementations break.
## Rate limits and scope
- **Personal token limit**: roughly 100 calls/day/member. Respect this when building schedulers.
- **Scope required**: `w_organization_social`. The authenticating user must be an admin of the Company Page — being an employee is not enough.
- **Post character limit**: 3,000. The API returns HTTP 422 if exceeded. The library checks locally before calling so the failure is cheaper.
- **Access token lifetime**: 60 days. Refresh tokens last 365 days and can be used to mint new access tokens without re-prompting the user. `get_token.py` saves both.
## What LinkedIn's API cannot do (as of 2026)
Don't promise the user these — they require manual work in LinkedIn's web UI:
- **Long-form articles** (the Medium-style ones with a title, cover, and body) — web UI only.
- **Newsletters** — web UI only.
- **Document posts / PDF carousels** — no API support.
- **Polls** — no API support.
- **@mentions of people or companies in post text** — no API support. The text will publish, but the mention won't be a link.
- **Native scheduling** — the API posts immediately. Build scheduling with cron or a queue.
If the user asks for any of the above, say so upfront rather than trying to hack around it.
## Debugging etiquette
When the user reports an error, ask for:
1. The exact command they ran (redacting the token).
2. The full response body LinkedIn returned — the `serviceErrorCode` and the `message` fields carry the real signal.
3. Whether the token still works for a simple `GET /rest/posts?author=urn:li:organization:<id>&q=author`. If this 401s, the token is the problem, not the post.
Then check `references/troubleshooting.md` against the specific error code before guessing.
don't have the plugin yet? install it then click "run inline in claude" again.
structured the original prose into implexa's six required components (intent, inputs, procedure, decision points, output contract, outcome signal), made implicit media upload details explicit, added edge cases for token expiry, multipart video, rate limits, and admin access, documented all env vars and external connections, and preserved the original author's cli and library design intact.
publish content to a LinkedIn Company Page through LinkedIn's versioned REST API (/rest/posts). use this when the user wants to post to LinkedIn, publish to a Company Page, automate LinkedIn content, cross-post from another system, build a LinkedIn publishing bot or scheduler, or upload images or videos to LinkedIn programmatically. also use it when the user mentions errors from the LinkedIn Posts API, w_organization_social scope, urn:li:organization URNs, UGC posts, or /rest/images and /rest/videos endpoints. the skill ships a Python CLI (scripts/publish.py) that works as both a standalone tool and an importable library, plus a one-time OAuth helper (scripts/get_token.py) to obtain the initial access token.
Environment variables (required unless noted):
| Variable | Required | Notes |
|---|---|---|
LINKEDIN_ACCESS_TOKEN |
yes | OAuth 2.0 access token with w_organization_social scope. valid for 60 days; refresh tokens last 365 days. obtain via scripts/get_token.py and the LinkedIn Developer app setup. store securely (never commit to git). |
LINKEDIN_ORG_ID |
yes | numeric organization ID only (e.g. 5515715), not the full URN. the script prepends urn:li:organization:. find it at https://www.linkedin.com/company/<slug>/admin/ in the URL once you're in admin view. |
LINKEDIN_API_VERSION |
no | YYYYMM format, e.g. 202602. defaults to 202602 (February 2026). LinkedIn supports each version for a minimum of one year; bump deliberately when new features ship. |
External connections:
w_organization_social scope approval. the authenticating user must be an admin of the Company Page (employee status is not sufficient). setup walkthrough in references/setup.md.CLI subcommands:
python scripts/publish.py text (text-only post)python scripts/publish.py image (single image with alt text)python scripts/publish.py multi-image (2-20 image carousel)python scripts/publish.py video (video with optional title)python scripts/publish.py article (article link preview)all subcommands accept --text (post commentary, up to 3,000 characters) and --dry-run (prints request body without calling the API).
step 1: obtain access token (one-time setup).
input: none (user runs script).
run python scripts/get_token.py. this launches a local OAuth 2.0 flow. the script opens a browser, prompts the user to log in and approve w_organization_social scope on the LinkedIn Developer app, then captures the auth code. the script saves the access token and refresh token to a secure location and exports them as environment variables.
output: LINKEDIN_ACCESS_TOKEN and LINKEDIN_REFRESH_TOKEN ready to use.
step 2: set organization ID.
input: user's LinkedIn Company Page URL.
user navigates to https://www.linkedin.com/company/<slug>/admin/ and locates the numeric ID in the browser URL bar. user sets LINKEDIN_ORG_ID environment variable to this number (e.g. export LINKEDIN_ORG_ID=5515715).
output: LINKEDIN_ORG_ID environment variable set.
step 3: choose content type and prepare inputs. input: user's post content (text, image, video, or article URL). user selects the appropriate subcommand based on the content type:
python scripts/publish.py text --text "..."python scripts/publish.py image path/to/photo.jpg --text "..." --alt "..."python scripts/publish.py multi-image img1.jpg img2.jpg ... --text "..." --alt "alt1" "alt2" ...python scripts/publish.py video path/to/demo.mp4 --text "..." --title "..."python scripts/publish.py article https://example.com/post --text "..."
output: validated input arguments ready for API call.step 4: validate and optionally dry-run. input: prepared arguments from step 3. the CLI validates inputs locally before calling the API:
--dry-run flag to print the request body without calling LinkedIn.
output: validated inputs, optionally printed request body for inspection.step 5: register media with LinkedIn (for image, video, multi-image, or article).
input: validated image, video, or article inputs.
the library calls POST /rest/images?action=initializeUpload (images) or POST /rest/videos?action=initializeUpload (videos). LinkedIn responds with an upload URL (or multiple URLs for multipart video uploads) and a pre-assigned URN (e.g. urn:li:image:... or urn:li:video:...).
output: upload URL(s) and media URN. for multi-image posts, repeat this step for each image.
step 6: upload binary media to LinkedIn (for image, video, or multi-image).
input: upload URL(s) from step 5, binary file contents.
the library PUTs the binary bytes to the returned upload URL(s). these URLs are pre-signed; no auth header is needed.
PUT call per file.PUT calls with part numbers. the library calculates ETags for each part.
after all PUTs complete, for multipart uploads, the library calls POST /rest/videos/{urn}?action=finalizeUpload with the ETags to signal to LinkedIn that the upload is complete.
output: upload acknowledged by LinkedIn. for videos, the asset enters asynchronous processing.step 7: poll video status (videos only).
input: video URN from step 5.
for video posts, the library polls GET /rest/videos/{urn} every 2-5 seconds until status == AVAILABLE. this prevents posting with a broken video player (the post would show if you create it before processing finishes).
the poll timeout is 5 minutes; if the video is not AVAILABLE by then, the library raises an error with the LinkedIn response body.
output: video URN confirmed as AVAILABLE and ready to reference in post.
step 8: create the post with media URN(s).
input: post text, media URN(s) from steps 5-7, optional article metadata.
the library calls POST /rest/posts with the post payload:
{ "author": "urn:li:organization:...", "commentary": "..." }{ "author": "urn:li:organization:...", "commentary": "...", "content": { "media": { "id": "urn:li:image:..." } } } (multi-image uses media array){ "author": "urn:li:organization:...", "commentary": "...", "content": { "article": { "source": "..." } } }
output: post URN (e.g. urn:li:share:7045020441609936898) on success.step 9: return result to user.
input: post URN from step 8.
the library returns the post URN. the CLI prints it to stdout. if the user ran with --dry-run, step 8 never executes and the request body is printed instead.
output: post URN or error message with LinkedIn's response body (for debugging).
if the user has an existing access token but it's expired (401 Unauthorized from LinkedIn):
the library checks the token's exp claim. if expired, the library attempts to refresh using the stored refresh token by calling LinkedIn's token endpoint. if a refresh token is not available, the flow fails and the user must re-run scripts/get_token.py. if the refresh succeeds, the new access token is used for the post.
if the user is not an admin of the Company Page:
LinkedIn returns HTTP 403 Forbidden with serviceErrorCode "NOT_AUTHORIZED". the error message is misleading (says "unauthorized" not "not an admin"). direct the user to check their admin status in the Company Page settings, not to check their token.
if alt text count does not match image count (multi-image posts):
the CLI validates this locally before calling the API and errors out with a clear message. the user must provide exactly one --alt per image in order.
if a single-image or multi-image file is not found or not readable: the CLI exits with a file-not-found error before calling the API. this is cheaper than discovering it mid-upload.
if the post text exceeds 3,000 characters: the CLI validates this locally and errors out before calling the API. LinkedIn returns HTTP 422 if the text is too long.
if a video file is ≤ 200 MB (single-part upload path):
the library auto-detects file size and uses a single PUT call, then polls for AVAILABLE status.
if a video file is > 200 MB (multipart upload path):
the library chunks the file, PUTs each chunk with a part number, collects ETags, then calls finalizeUpload to signal completion. polling for AVAILABLE still occurs.
if a video does not reach AVAILABLE status within 5 minutes:
the library raises an error with the last-known video status and the LinkedIn response body. the user must retry or check the video file for corruption.
if the article URL's OpenGraph metadata is missing or malformed: LinkedIn scrapes the URL server-side. if OG tags are absent, the preview card will be blank or minimal. this is a site issue, not an API issue. the post still succeeds; the preview quality is not the API's responsibility.
if the user provides --dry-run:
all validation steps 1-4 execute, but step 8 (the actual API call) is skipped. the CLI prints the request payload to stdout for inspection. this is useful for testing without hitting the API quota.
if rate limit is hit (100 calls/day/member): LinkedIn returns HTTP 429 Too Many Requests. the library does not retry (the user owns throttling logic). the error message is clear. if the user is building a scheduler, they must space posts or use a queue.
on success (all subcommands):
urn:li:share:7045020441609936898) printed to stdout.on failure:
serviceErrorCode, the HTTP status code, and the full response body.if --dry-run is used:
library usage (from Python):
post_text(client, text: str) -> str returns post URN.post_image(client, image_path: str, text: str, alt: str) -> str returns post URN.post_video(client, video_path: str, text: str, title: str = None) -> str returns post URN (after polling for AVAILABLE).post_article(client, article_url: str, text: str) -> str returns post URN.post_multi_image(client, image_paths: list[str], text: str, alts: list[str]) -> str returns post URN.user knows the skill worked when:
--dry-run mode printed a valid JSON request body that the user can inspect before committing.user knows something failed when:
serviceErrorCode (e.g. "NOT_AUTHORIZED", "INVALID_SCOPE"). cross-reference this in references/troubleshooting.md.scripts/get_token.py.--dry-run to inspect).