linkedin-poster
Sends notifications and draft content to a Telegram group via the Telegram Bot API, including daily summaries, token renewal alarms, post success/failure alerts, and episode chapters with link previews.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@linkedin-postercheck this draft, then post it to my feed"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
linkedin-poster
Post to your own LinkedIn feed from Claude Code, a shell, or a cron job. One Cloudflare Worker, no dependencies, three surfaces: an MCP server so an agent can publish directly, an HTTP API for scripts, and a voice linter that refuses to publish text carrying the tells of machine-written prose.
Built because a good post is worth automating and a bad em-dash is not worth shipping. This README has no em-dashes in it either, which is the point.
Claude Code ──MCP──┐
├──> Worker ──> LinkedIn Posts API ──> your feed
curl / CI ──HTTP───┘ │
├──> KV (one 60-day token) + daily cron
└──> Telegram group (what happened, and what broke)What it is not
This posts as one member: you. It is not a scheduling SaaS, not a multi-tenant tool, and it does not touch anybody else's account. Everything it needs is self-serve; no LinkedIn partner program is involved.
Related MCP server: mcp-server-linkedin
The constraint that shapes everything
LinkedIn access tokens last 60 days, and programmatic refresh tokens are limited to approved Marketing Developer Platform partners. A self-serve app never gets one. There is no way around this, and any guide promising set-and-forget LinkedIn automation is wrong.
What there is: while your current token is still valid and you are logged into LinkedIn, hitting the authorization endpoint again skips the consent screen and just redirects. So renewal is one click every seven weeks, as long as you do it before the token dies. Miss the window and you get the consent screen back, which is two clicks instead of one.
That is why this service ships with a cron trigger. From 14 days out it puts a renewal alarm at the top of the daily summary, every morning, while silent renewal still works. See Notifications.
Setup
1. LinkedIn side (manual, about ten minutes)
You need a LinkedIn Page. The developer portal makes you attach the app to one and have a page admin verify it.
Create the app at linkedin.com/developers. Note the Client ID and Client Secret.
Products tab, request both (they are Open Permissions and approve instantly):
Sign In with LinkedIn using OpenID Connect →
openid,profileShare on LinkedIn →
w_member_social
Auth tab → add the redirect URL. It must be HTTPS, absolute, and match
REDIRECT_URIcharacter for character:https://your-worker.example.com/auth/callback
2. Worker side
git clone https://github.com/stepanic/linkedin-poster.git
cd linkedin-poster
npm install
# KV to hold the token
npx wrangler kv namespace create TOKENS
npx wrangler kv namespace create TOKENS --preview
# paste both ids into wrangler.jsonc
# edit wrangler.jsonc: name, routes, REDIRECT_URI, account_id
npx wrangler secret put LINKEDIN_CLIENT_ID
npx wrangler secret put LINKEDIN_CLIENT_SECRET
npx wrangler secret put API_TOKEN # openssl rand -hex 32, guards /post and /mcp
npx wrangler secret put SETUP_KEY # openssl rand -hex 32, guards /auth/start
npx wrangler secret put NOTIFY_WEBHOOK # optional, any URL taking a JSON POST
npx wrangler secret put TELEGRAM_BOT_TOKEN # optional, see Notifications below
npx wrangler secret put TELEGRAM_CHAT_ID
npm run deploy3. Connect your account
Open https://your-worker.example.com/auth/start?key=<SETUP_KEY> in a browser,
approve once, done. The page tells you the expiry date.
SETUP_KEY is not decoration. Without it, anyone who found the URL could bind
their LinkedIn account to your Worker and overwrite your stored token.
Using it from Claude Code
claude mcp add --transport http linkedin https://your-worker.example.com/mcp \
--header "Authorization: Bearer $API_TOKEN"The tools:
Tool | What it does |
| Lints, then publishes. Refuses on errors unless |
| Lints without publishing. Use it while drafting. |
| Who it posts as, and how many days the token has left. |
| Lists the drafts waiting for review. |
| Reads one draft in full, so it can be edited before it goes out. |
| Publishes a draft, as written or with replacement text. |
| Drops one without publishing. |
Then, in a session: "check this draft against the linter, then post it." Or, on a morning when the group has a draft in it: "show me draft S5bScs9QuOk, drop the last paragraph and publish it."
Using it from a shell
# dry run: lint only, publish nothing
curl -s -X POST https://your-worker.example.com/post \
-H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"…","dry_run":true}'
# publish
curl -s -X POST https://your-worker.example.com/post \
-H "Authorization: Bearer $API_TOKEN" -H 'Content-Type: application/json' \
-d '{"text":"…","visibility":"PUBLIC"}'
# token state
curl -s https://your-worker.example.com/status -H "Authorization: Bearer $API_TOKEN"/post returns 201 with the post URN and its public URL, 422 when the linter
blocks it, 503 when the token is missing or dead.
Notifications
Without this the service fails the way every unattended service fails: quietly, and you find out on the day a post does not go out. A Telegram group fixes that for the price of one bot.
# 1. @BotFather in Telegram, /newbot, copy the token
npx wrangler secret put TELEGRAM_BOT_TOKEN
# 2. make a group, add the bot, send /start@yourbot in it
# (a bot with privacy mode on sees only messages that mention it)
# 3. read the group id back, then store it
open "https://your-worker.example.com/telegram/chatid?key=<SETUP_KEY>"
npx wrangler secret put TELEGRAM_CHAT_ID
# 4. prove it works
open "https://your-worker.example.com/telegram/test?key=<SETUP_KEY>"What arrives:
Event | When |
Daily summary | every cron run, quiet days included |
Renewal alarm | from 14 days out, on top of that day's summary |
Reconnected | a fresh token was stored, with the new expiry date |
Posted | every published post, with its link |
Blocked | the linter refused a draft, with the rules that fired |
Rejected | LinkedIn refused a post, with a guess at why |
No usable token | a publish attempt found the token missing or dead |
Quota | the day crosses 120 of the 150 requests LinkedIn allows a member |
Draft ready | the nightly pipeline wrote a post for a new episode, with the text in full |
Draft link | the episode link on its own, so its Open Graph card can be seen before the post goes out |
Two design decisions worth knowing before you change anything:
The daily summary is the heartbeat. It goes out even when nothing happened, so its absence is itself the alarm and nothing has to monitor the monitor. That is also why a renewal alarm rides on top of the summary rather than arriving as a second message: exactly one message a morning, or you train yourself to skim.
The renewal link carries a one-time nonce, not the setup key. A link with
SETUP_KEY in it would park a long-lived secret in a chat log for good. The
cron mints a nonce instead, valid for seven days, replaced by the next reminder,
and deleted the moment a token is stored. Link previews are disabled on every
message so Telegram cannot spend the nonce by prefetching it.
Set none of it and the service behaves exactly as it did before: NOTIFY_WEBHOOK
if you have one, the log if you do not.
GET /telegram/preview?key=<SETUP_KEY>&kind=daily renders any message without
sending it, which is how to edit the wording without spamming the group. Add
&send=1 to see it on a phone.
The Bot API transport is tested against a local stub, including the supergroup migration that silently breaks Telegram bots months after setup:
npm run test:telegramDrafts from the domovina.ai pipeline
This Worker also serves as the inbox for posts written by something else. The
nightly pipeline in fetch.domovina.tv transcribes new podcast episodes,
publishes them to domovina.ai, and then, as its last step, asks Claude for a
LinkedIn post about each new one and parks it here.
nightly pipeline ──> scripts/draft-episodes.ts ──> POST /drafts ──> KV + Telegram
(claude -p) │
you, next morning ▼
linkedin_publish_draftNothing is published automatically, and nothing here can publish: POST /drafts
stores and announces, and only a person calling linkedin_publish_draft (or
POST /drafts/{id}/publish) sends anything to LinkedIn. Drafts expire on their
own after 14 days, and the morning summary lists the ones still waiting.
# what the nightly does, by hand
npm run drafts -- --dry-run # write the posts, send nothing
npm run drafts # write them and put them in the group
npm run drafts -- --id VIDEOID # one specific episode, window ignored
npm run drafts -- --seed # mark the window as done, write nothing
npm run drafts -- --id VIDEOID --chapters-only # resend just the chaptersEach draft is followed by the episode's chapters, one silent message per
chapter (POST /drafts/{id}/chapters), each with a large preview of
domovina.ai/v/{id}/t/{sec}. It is the same pattern as share_to_whatsapp.js
in fetch.domovina.tv. Chapters come from the CDN's article.json, are paced
3.5 s apart to stay under Telegram's group limit, and are recorded in
.state/drafted.json as they land, so a rerun only sends what is missing.
--no-chapters turns them off.
The script lives here rather than in the pipeline repo because everything it
knows about is a LinkedIn concern: the voice, the linter, the bearer token, the
group. It reads the pipeline's output directory and nothing else. See
docs/2026-09-17-prijedlozi-objava-iz-nightlyja.md.
The linter
Errors block publishing; warnings do not.
Rule | Severity | Why |
| error | The clearest tell of generated prose. A comma or a full stop always works. |
| error | LinkedIn rejects commentary over 3000 characters. |
| error | Obvious, but easy to hit when piping from a file. |
| warning | LinkedIn renders none of it. Asterisks and hash marks show up literally. |
Hyphens are never flagged. read-only tier, two-day sprint and
per-client limits are correct and must stay. Only U+2014 and U+2013 are errors.
Editing src/lint.ts is the intended way to make this yours. The rules encode
one person's writing conventions, not a universal standard.
Things that will surprise you
The Posts API does not scrape URLs, but LinkedIn still linkifies them. Verified on a live post: a bare URL is shortened to
lnkd.in/...and is clickable. What you do not get is the preview card with an image and title. For that you need a thumbnail uploaded through the Images API plus acontent.articleblock. Not implemented here yet.LinkedIn-Versionis mandatory and versions get sunset.202508went dark on 17 August 2026. Treat the value inwrangler.jsoncas a maintenance item./v2/ugcPostsis deprecated. This uses/rest/posts, which replaced it.Changing the requested scopes invalidates every existing token. You will have to re-authorize.
150 requests per member per day. Per member, not per app.
Layout
File | Contains |
| Routes, OAuth flow, cron reminder |
| OAuth and Posts API calls, all endpoints in one place |
| MCP server, hand-rolled JSON-RPC, stateless |
| The voice linter |
| KV token record, expiry maths, timing-safe secret compare |
| Which events reach the group, and in what words |
| Bot API transport, retries, supergroup migration |
| The day's counters, which the summary reads back |
| Drafts in KV: store, list, discard, expire |
| Turns new domovina.ai episodes into drafts, run by the nightly |
Local development
cp .dev.vars.example .dev.vars # fill in, never commit
npm run types # regenerate Env after editing wrangler.jsonc
npm run typecheck
npm run devwrangler dev --local gives you a local KV, so the whole flow short of the real
LinkedIn calls can be exercised without credentials.
Notes from a real setup
docs/2026-08-27-postavljanje-i-zamke.md
records what setting this up from zero actually cost: the portal rejecting any app
name containing "LinkedIn", the irreversible Page association, OAuth scopes that
stay empty until you refresh the page, %20 versus + in the scope parameter, a
negative DNS cache that made a working deployment look dead, and how secrets were
handled so none of them passed through a terminal.
docs/2026-08-27-telegram-obavijesti.md
covers the notification layer: the supergroup migration that breaks Telegram bots
in silence, why the heartbeat is a summary rather than a ping, and why the
renewal link carries a nonce.
Licence
MIT. The rules in src/lint.ts are mine; the mechanism is yours to take.
This server cannot be deployed
Maintenance
Related MCP Connectors
Schedule and publish LinkedIn posts to profiles and company pages from AI agents.
Publish and schedule LinkedIn posts from your AI assistant.
Draft LinkedIn posts in your voice, then schedule or publish them through LinkedIn's official API.
Schedule and publish posts to X, Bluesky, Mastodon, LinkedIn and Reddit from your agent.
Related MCP Servers
- AlicenseAqualityCmaintenanceEnables AI agents to publish posts, images, comments, and reactions to LinkedIn as the authenticated user, with built-in safety features like daily budgets and deduplication.912 npm1Apache 2.0
- AlicenseAqualityDmaintenanceEnables AI agents to publish posts, images, videos, and articles to LinkedIn.10MIT
- FlicenseNot gradedqualityBmaintenanceEnables AI agents to draft, format, and manage LinkedIn posts through a review pipeline, with human approval via a web UI before publication.-
- FlicenseNot gradedqualityBmaintenanceEnables AI assistants to publish public text posts to an authenticated LinkedIn profile via the Model Context Protocol.-