LinkedIn Has No Pulse API: Designing a Pipeline Around a Manual Gate
The publisher fires LinkedIn feed posts on its own. It cannot create a Pulse article. So I built the whole content OS around one irreducible human paste.
View companion repoThe pipeline that automates everything except the one thing it can't
I built a content pipeline that mines my Claude Code sessions, scores them, drafts posts, and publishes to LinkedIn. The mining runs on a schedule. The scoring runs against a weights file. The drafting runs through a gated chain. The publishing runs on a launchd poller every five minutes. Almost end to end, a session becomes a published post with no hands on it.
Almost. There is one step in the middle where a human has to stop, open a browser, and paste. Not because I gave up on automating it. Because LinkedIn does not expose an API for it, and no amount of cleverness routes around a capability the platform never shipped.
LinkedIn's content surface splits in two. There are feed posts (the short UGC shares in the timeline), and there are Pulse Articles (the long-form posts with their own /pulse/ URL). The feed has an API. Pulse does not. You can create, schedule, and publish a feed post from code. You cannot create a Pulse article from code at all — only a human, in the web editor, can.
That single fact reshaped the entire pipeline. This post is the design that fell out of it: a publisher that posts feed shares on its own, refuses to fire a teaser for an article that does not exist yet, and waits for a human to paste before automation takes the wheel back.
The feed path: fully automated UGC
The one engine is scripts/publish-scheduled.js. It reads pending rows from a scheduled_posts store, claims each one atomically, and posts to the platform. For LinkedIn feed shares, the path is a UGC post to LinkedIn's REST API:
const LI_API = "https://api.linkedin.com";
const LI_VERSION = "202604";
const postBody = {
author: token.author_urn,
commentary,
visibility: "PUBLIC",
distribution: {
feedDistribution: "MAIN_FEED",
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: "PUBLISHED",
isReshareDisabledByAuthor: false,
};
const res = await fetch(`${LI_API}/rest/posts`, {
method: "POST",
headers: {
Authorization: `Bearer ${token.access_token}`,
"LinkedIn-Version": LI_VERSION,
"X-Restli-Protocol-Version": "2.0.0",
"Content-Type": "application/json",
},
body: JSON.stringify(postBody),
});
That works. feedDistribution: "MAIN_FEED" puts the share in the timeline. LinkedIn-Version: 202604 pins the API version. The poller fires this with no human in the loop. Feed posting is the part of the pipeline that behaves the way you want a pipeline to behave.
One detail in this path matters for reach, and it is not obvious. The canonical link does not go in the post body. It goes in the first comment:
// Drop UTM'd link in first comment so the essay itself stays unsuppressed
const linkComment = `Full post + code: ${buildUtmUrl(row.post_slug, "linkedin", "essay")}\nCompanion repo: ${repoUrlForSlug(row.post_slug)}`;
await fetch(`${LI_API}/rest/socialActions/${encodeURIComponent(urn)}/comments`, {
method: "POST",
headers: { /* ... */ },
body: JSON.stringify({
actor: token.author_urn,
object: urn,
message: { text: linkComment },
}),
});
LinkedIn suppresses the reach of posts that carry outbound links in the body. Putting the link in the first comment keeps the post body unsuppressed and still gives readers the click target. The body sells; the comment links. That is a workaround for a different platform quirk, but it shares the spirit of the whole design: the platform has opinions you did not ask for, and you build around them.
The Pulse path: there is no path
Now the part that has no API. A Pulse Article is the long-form post with the /pulse/ URL — the format I actually want for these field-notes posts, because the body lives on LinkedIn instead of behind a feed-post link. There is no endpoint to create one. There is no endpoint to publish one. The publisher knows this, and it refuses to pretend otherwise.
scheduled_posts carries a platform column. One of its values is linkedin-article. When the poller encounters a row with that platform, it does not call an API. It marks the row failed and moves on, with a comment that records exactly why:
// W4 hotfix: linkedin-article is now MANUAL-PASTE ONLY (W3 pivot 2026-05-24).
// The auto-publisher must NEVER fire feed-share previews — that's exactly what
// the operator deleted. Keep the row as 'failed' with its SUPERSEDED message,
// do NOT call publishLinkedInArticle, do NOT retry.
if (row.platform === "linkedin-article") {
await supabase
.from("scheduled_posts")
.update({ status: "failed", attempt_count: row.attempt_count })
.eq("id", row.id);
console.log(`SKIP linkedin-article ${row.post_slug} (manual-paste track per W3/W4)`);
continue;
}
Note the deliberate non-behavior: no retry. The poller does not increment the attempt count and try again on the next tick. A retry would imply the failure is transient — that the API might succeed if it tried later. It will not. The failure is structural, so the row stays failed as a tombstone, not a backlog item.
This shape arrived on a specific date. The comment dates the decision to the "W3 pivot" of 2026-05-24, when the pipeline stopped trying to auto-fire linkedin-article rows. Before that, the system tried to treat articles like feed posts. The platform does not allow it. The tombstone is the scar.
The refusal gate: don't fire a teaser for an article that doesn't exist
The manual gate creates a second problem. If a human has to paste the Pulse article, then the feed teaser that points at it cannot fire until the article is actually live and has a URL. Fire too early and you publish a feed post linking to a Pulse article that does not exist yet.
The publisher handles this the same way it handles every other unmet precondition: it refuses. Before the LinkedIn feed essay path makes any API call, it requires the Pulse URL from a registry, keyed by post slug:
function requirePulseUrl(slug) {
const entry = loadPulseRegistry()[slug];
if (!entry || !entry.url || String(entry.url).trim() === "") {
throw new Error(`REFUSE: no pulse_url in registry for slug=${slug} — paste the Pulse article first, update pulse-url-registry.json, then run scripts/requeue-after-url-added.js ${slug}`);
}
return entry.url;
}
requirePulseUrl(slug) throws before the network call if the registry entry is missing or its URL is empty. The orchestrator catches the throw and marks the row failed, with the refusal message attached. The error text is also the runbook: paste the article, update the registry, run the requeue script. The refusal does not just stop the wrong thing from happening; it tells the operator what the right thing is.
The registry itself documents its own workflow. pulse-url-registry.json carries a _workflow key that spells out the four manual steps in order:
"_workflow": "1. paste article in LinkedIn Pulse editor, 2. copy resulting URL, 3. add entry below: \"<slug>\": { \"url\": \"<URL>\", \"added\": \"<ISO-8601>\" }, 4. run: node scripts/requeue-after-url-added.js <slug>"
The reason for the registry is correctness of the teaser link. A feed teaser must point at the canonical Pulse article URL, not at a withagents.dev post URL. The essay path takes the registered Pulse URL and substitutes it into the teaser body before posting, so the share in the timeline links to the live LinkedIn article. Without the registry, the teaser would either link to the wrong place or link to nothing. The gate guarantees it links to the real thing.
The +10 minute teaser: automation takes back the wheel
After the human pastes and registers the URL, the pipeline resumes. The mechanism is scripts/requeue-after-url-added.js. With --at <publish-iso> --offset-min 10, it flips the failed teaser row back to pending and schedules it for ten minutes after the article went live:
let update = { status: "pending", error_message: null };
if (at) {
const anchorMs = Date.parse(at);
if (Number.isNaN(anchorMs)) {
console.error(`SKIP ${slug} #${row.id}: --at "${at}" is not a valid ISO-8601 timestamp`);
return { ok: false, reason: "bad-anchor" };
}
const targetMs = anchorMs + offsetMin * 60_000;
update.scheduled_for = new Date(targetMs).toISOString();
update.attempt_count = 0;
update.max_attempts = 3;
}
Two lines carry the design. update.scheduled_for = anchor + offsetMin*60_000 sets the teaser to fire ten minutes after the article. And update.attempt_count = 0 resets the counter — without that reset, the poller's "max attempts reached" skip would permanently refuse the resurrected row, because it had already burned its attempts as a tombstone. The comment in the script names this directly: it is the tombstone fix that lets a previously-failed row come back to life.
So the full lifecycle of a single post is: the human pastes the article into LinkedIn's web editor and copies the URL. The operator adds the URL to the registry and runs the requeue script with the publish timestamp. The script schedules the feed teaser for ten minutes later and resets the attempt count. The five-minute poller picks up the now-pending row, calls requirePulseUrl (which now succeeds because the URL is registered), substitutes the Pulse URL into the teaser, and fires the feed share. Ten minutes after the article goes live, a feed post drives traffic to it. The only manual step is the paste. Everything before it and everything after it is code.
Why design around the gate instead of fighting it
The temptation, every time, is to make the gate go away. Scrape the editor. Drive the web UI with a headless browser. Reverse-engineer the internal endpoint the editor calls. I did not do any of that, and the refusal gate is the reason: a pipeline that refuses cleanly at a known boundary is more maintainable than one that fakes its way past it.
A scraped or browser-driven publish path is a path that breaks every time LinkedIn ships a UI change, fails silently, and is impossible to tell apart from a real publish without checking the result by hand. The manual paste is annoying, but it is honest. It happens in the one place a human is already looking at the article, it takes seconds, and it leaves a real /pulse/ URL that everything downstream can trust. The registry entry is the proof the article exists. The refusal gate enforces that proof before it lets the teaser fire.
This is the same discipline the rest of my agentic tooling runs on, applied to a marketing pipeline: cite real evidence or refuse. The Pulse URL in the registry is the evidence. requirePulseUrl is the gate. The linkedin-article tombstone is the refusal that never retries because the precondition can never be met by code. None of it pretends a capability exists that does not.
The result is a pipeline I trust to run unattended, because it stops itself at the exact point where running unattended would produce a lie. Of the 36 posts in this series, 17 are live on Pulse with registered URLs; another 19 are scheduled natively inside LinkedIn's own editor, firing from late June into August. Every one of the live ones went through the same gate. The paste is the price of a long-form format LinkedIn will not let a machine create. I decided that was a price worth designing around, not around.
The companion repo for the series is github.com/krzemienski/agentic-development-guide. The publisher, the registry, and the requeue script are the real source behind every code block above.
Continue the series
- 36SeriesShannon v1.2.0: Multi-Stage Agentic Work as a Sequence of Provable StepsA Claude Code plugin that refuses to say 'done' until the evidence is on disk. 36 skills, 11 agents, 22 commands, 7 hooks — and a doctor that reads its own contract.
- 35SeriesSublimate: Three Tiers for Distilling Workflows From Your Own SessionsAnthropic shipped Dynamic Workflows in Claude Code this week. The harder question is which of your hand-rolled patterns belongs as a Workflow, which belongs as a Skill, and which belongs as a Subagent.
- 34Edge CasesWhen Your Drafter Doubles the Body: Building the WithAgents Content PluginThe clear step in our LinkedIn drafter never actually cleared. The new body pasted on top of the old one, the editor doubled, and the bug only showed up on the 34th run. Here is what we shipped instead.