The Capability LinkedIn Never Shipped: Designing a Pipeline Around One Irreducible Paste
LinkedIn's API can fire feed posts on its own. It cannot create a Pulse article. I stopped trying to route around that gap and built the human paste into the pipeline as a first-class, gated state instead.
View companion repoThe pipeline that automates everything except one paste
I built a content pipeline that mines my Claude Code sessions, scores the candidates, drafts posts, stages them for LinkedIn, and publishes on a schedule. Every step of it runs unattended except one. Somewhere between "draft saved" and "article live," a human has to open a browser and paste. Not because I hadn't gotten around to automating that step yet. Because the capability does not exist to automate.
I spent longer than I want to admit looking for a way around it before I accepted the constraint and designed for it instead. What follows is the shape that design took once I stopped treating the manual step as a bug to route around and started treating it as a state the pipeline could reason about.
The short version: LinkedIn's API can post to the feed. It cannot create or publish a Pulse long-form article. Every automation idea that pretends otherwise eventually collides with that wall, so the honest move is to build a pipeline that knows exactly where the wall is, stops cleanly at it, and resumes cleanly on the other side.
The asymmetry: a feed API and no Pulse API
The pulse-registry skill names the constraint in its opening line: "LinkedIn's API cannot create Pulse articles — only the web editor can. But the feed teaser (linkedin.md short post) IS API-publishable and runs on a M/W/F cadence via Supabase + launchd." Two surfaces, one API. That single sentence is the entire reason this pipeline has a manual step at all.
The Supabase side confirms the shape of it. Per the skill's documentation of scripts/seed-schedule.js, each post gets four platform rows: linkedin-article, linkedin, x, newsletter. Three of those four fire on their own from code. The linkedin-article row is different by design: it "fail[s] until the Pulse URL is registered," and only requeue-after-url-added.js moves it back to pending once that URL exists. The row does not represent a bug in the scheduler. It represents an accurate model of the world: this teaser cannot go out until an article exists at a URL, and no code path can conjure that URL, because no code path can create the article.
I tried, briefly, to talk myself out of this. Could a headless session log into LinkedIn and drive the editor the same way a human would, then click Publish itself? Technically yes, for the drafting half. That's exactly what the pipeline does today, through chrome-devtools rather than a REST call. But driving a browser through a login flow to programmatically publish long-form content on someone's behalf is a different category of automation than posting a status update, and the platform draws that line deliberately, not by oversight. Cleverness at the DOM level doesn't dissolve the asymmetry. It just moves where the human decision has to happen. So I decided the honest design keeps that decision explicit rather than hidden inside a script that quietly does the thing the platform didn't build an API for.
The human step as state, not as an outage
The design choice that made this pipeline trustworthy instead of merely automated was refusing to treat the manual step as an error condition. An outage is something you retry. A human step is something you wait for, and the difference matters: retrying an impossible operation burns cycles and produces log noise that looks like a real failure. Waiting for a known, named, gated state produces a system that tells you exactly what it's waiting on.
linkedin-drafter is the skill that gets a post to "Draft - saved," and its hard rules are explicit about the boundary it will not cross: "Never click Publish. Never click Schedule. The linkedin-no-publish hook enforces this at the tool-call layer; this skill MUST NOT attempt to bypass it. The output is always a 'Draft - saved' state." That's a nine-step chrome-devtools recipe, run 33 times to produce every live draft in the series, and every one of those 33 runs stops at the same wall on purpose. Rule two adds a second refusal: "Refuse to start without a cover," and rule three a third: "Refuse to start without body content." Each refusal has a name and a reason, not a silent failure.
linkedin-publisher is the gated inverse, and its framing is precise about what it is: "the sanctioned inverse of linkedin-drafter ... the deliberate, gated exception to the plugin's draft-only posture." It requires WA_ALLOW_PUBLISH=1 to be set in the shell before it will touch a Publish button, and it refuses without that variable, printing the exact export WA_ALLOW_PUBLISH=1 line the operator needs. The enforcement lives one layer below the skill, in hooks/linkedin-no-publish.js, a PreToolUse hook that pattern-matches every chrome-devtools call against LinkedIn's domain for Publish, Schedule, /article/publish/, and /article/schedule/. When it hits, the stderr the agent receives is not a generic denial. It is the runbook:
[withagents/linkedin-no-publish] Blocked.
reason: tool input matches forbidden pattern /\bPublish\b/i
tool: evaluate_script
This plugin's standing intent is 'draft only' for LinkedIn Pulse articles.
Publish and Schedule actions are structurally blocked at the hook layer.
To allow this specific call deliberately, run with WA_ALLOW_PUBLISH=1 in env.
Do NOT remove this hook to work around the guard -- that defeats the purpose.
That's the whole design principle in one error message. The gate doesn't just say no; it says exactly what condition flips it to yes, and it says that removing the gate is not the correct move even when the operator genuinely wants to publish. The refusal is the documentation. Anyone reading the blocked call learns the escape hatch without opening a separate doc.
linkedin-publisher layers its own refusals on top once the operator does set the env var. It refuses a double-publish if the slug already has a non-empty url in the live registry. It refuses to start without a well-formed draft_url matching ^https://www\.linkedin\.com/article/edit/\d+/$, so a missing draft routes back to linkedin-drafter instead of improvising. And its rule four is a constraint I find genuinely elegant: "Capture the live URL from the live DOM only. Never construct or guess the /pulse/ URL. It is read from location.href after the publish commits." The one artifact this whole handoff depends on, the live URL, is never synthesized. It is only ever observed.
The registry handoff that releases downstream automation
None of the refusal discipline above matters if the human's action doesn't flow back into the system that's waiting on it. That's what pulse-url-registry.json and .linkedin-staging/draft-url-registry.json are for, and the pulse-registry skill draws the split cleanly: the staging registry tracks drafts, keyed by slug, with a draft_url matching the /article/edit/<id>/ pattern; the live registry tracks published articles, keyed by slug, with the public /pulse/<title-slug>-<author>-<hash>/ URL. "Live posts only," the skill states about the second file, "drafts go in .linkedin-staging/draft-url-registry.json." Two files, two states, no ambiguity about which one a given slug should be in at any moment.
linkedin-publisher's rule six closes the loop explicitly: "On success, invoke /wa-pulse-register <slug> <pulse-url> so the feed teaser is released. The publish is not 'done' until the registry + requeue run." That command writes the entry, and scripts/requeue-after-url-added.js picks it up from there, per the pulse-registry skill's contract: it "flips scheduled_posts.status: failed → pending," is idempotent on a slug already pending, and "refuses if the registry entry has no url." Nothing downstream fires on a guess. It fires on a URL that was read off a live DOM after a human clicked a button that a hook was actively watching.
As of this writing, the series has 44 posts. pulse-url-registry.json shows 27 of them live on Pulse; .linkedin-staging/draft-url-registry.json shows 16 sitting as saved drafts, one gated step away from live; and the staging directory holds all 44 payload files, meaning every post in the series has cleared the drafting pipeline's preconditions even where the human step hasn't happened yet. That gap between 44 staged and 27 live is not a queue of failures. It is 16 rows accurately reporting that a human hasn't gotten to them, sitting in failed inside Supabase, ready to flip the moment they do. A dashboard that reads that state honestly shows a backlog, not an incident.
What generalizes: the tombstone over the retry loop
Every pipeline eventually meets a platform that shipped an API for one half of a workflow and not the other. The reflex is to treat the missing half as a temporary gap and write a retry loop around it: exponential backoff, a cron job that keeps checking, a "we'll catch it eventually" comment. That reflex is wrong whenever the gap is structural rather than transient. Retrying an API call that will never succeed because the API doesn't exist just produces log spam that looks identical to a real outage, and eventually someone stops trusting the alerts.
The pattern that held up here is closer to a tombstone than a retry: a row that sits in a known, named, non-retrying state until an external event, a human action, moves it forward. linkedin-article rows don't loop against LinkedIn hoping the API will show up. They sit at failed, honestly, until requeue-after-url-added.js sees a URL and flips them once. Modeling the human step this way costs almost nothing in engineering effort. The hard part isn't the code; the hard part is admitting up front which capability the platform never shipped, instead of discovering it three retries deep into a script that was never going to work.
If you're building around a platform gap like this, the four pieces that transferred cleanly from this pipeline are: name the asymmetry explicitly, the way pulse-registry does in its first paragraph; gate the manual action with a refusal whose error text doubles as the runbook, the way linkedin-no-publish.js does; never let the pipeline fabricate the artifact only a human action can produce, the way linkedin-publisher reads the live URL off the DOM instead of guessing it; and let the human's action release automation downstream through one auditable file, the way the registry handoff does, rather than through an undocumented side effect. None of that requires the missing API to exist. It just requires designing as if you already know it doesn't.
Continue the series
- 46SeriesObservability You Can See But Not Keep: A Live Stream Is Not a RecordA dashboard that renders a run in real time convinces you observability is solved. The real test is whether you can answer a question about a run that finished yesterday — and on my agent platform, the honest answer was zero rows.
- 48SeriesRejected Is Not Dead: The Liveness Probe That Killed Healthy TunnelsMy self-healing SSH daemon treated an auth rejection as proof the tunnel was gone. A rejection is proof the far end is alive and answering.
- 45SeriesThe Silent No-Op: When Your CMS Writes to a File the Renderer Never ReadsI edited a post, saved it, watched the deploy finish, and the page did not change. Nothing errored. Nothing warned me. The write succeeded and the read path ignored it, and that silence is the whole bug.
- 49SeriesReadback Verification: When 'Typed: ✅' Means the Wrong Field Has Your EmailAn automated form filler reported success on every field while silently writing each value one field late. The tool's own success signal confirmed a write happened, not that the right value landed in the right place.