Building a LinkedIn-publishing agent skill in Claude Code

I write LinkedIn posts in Markdown (and yes, AI helps me write them as well). I tag people, drop in screenshots, schedule for the morning, sometimes link to an SVG diagram I made in Mermaid. The posting itself is the boring part: open LinkedIn in a browser, paste in chunks, fix the formatting LinkedIn ate, attach the images one at a time, click Post.

I wanted that step automated. Not talking now about the writing: Just the publishing (and all the work around it).

There are commercial tools for this: Buffer, Hootsuite, Typefully, Publer. They use the official LinkedIn API, charge per post and need OAuth access to my account. I didn’t want any of that. I wanted Claude Code to drive my own browser, replicate the actions I’d take myself and stop when I told it to stop. I wanted to go through the full skill creation process for learning purposes.

This article is about how that became a Claude Code skill called linkedin-post. What it does, what it deliberately doesn’t and the longer story of how it got built. There was a moment in the process where the only way forward was to put a human’s hand on a real mouse.

Table of contents

Open Table of contents

What it is, and what it isn’t

The skill is a runbook. You point it at a Markdown file and tell it what to do:

Post linkedin-post.md to LinkedIn.

Schedule linkedin-post.md for tomorrow at 10am.

Draft linkedin-post.md and let me review before posting.

It reads the file, parses the body, converts the formatting into something LinkedIn actually renders (more on that), opens the LinkedIn composer in a browser session you’ve already logged into, fills everything in, attaches images and either posts, schedules or stops at the unposted modal so you can click Post yourself. Think of Claude Computer (and the like), but a tad more efficient (works with the DOM, not with screenshots).

What it is not: a content generator. The post body comes from your file. There is no --prompt "write me a clever post about...". For help writing the post, use a writing skill or a writing tool. This one publishes.

It also doesn’t do video, document attachments, polls, articles (LinkedIn’s long-form), comments, reactions, DMs or any kind of cross-posting. It is a single, narrow tool. That was deliberate.

Why drive a browser instead of using the API

LinkedIn does have an API. You can post to it. You can schedule via it. There’s a real path.

For me, to be honest, the main reason was learning. I did want to have the experience of creating a complex skill from scratch. And to add to that, other reasons were:

  • Cost. Most third-party tools that wrap the API charge per post or sit behind paywalls or have limited free-tiers.
  • Approval. Creating a tool using the API requires registering it in LinkedIn and getting write-post permission. This is a multi-week loop with uncertain outcome for personal use.

Driving the browser as a real user gets around all of that. The cost is that the AI is interpreting what it sees on the page instead of making clean API calls, which uses more tokens per post. I mitigate this by having the skill read structured page descriptions instead of full screenshots, and by keeping the noisy automation work in a separate subagent so the main conversation context stays small.

There’s also fragility: if LinkedIn changes the composer’s UI, the skill breaks until I update it. I accept that. The runbook lives in one file so patching it is a quick edit, and the gotchas are documented in plain English so future-me knows what to look at.

What this approach doesn’t do is ask me for OAuth, charge me per post or expose me to a third party who now has a write token to my LinkedIn account. For a few-times-a-week posting cadence and a personal account, the tradeoff goes the right way. For high-volume social-media management at a company, the tradeoff goes the other way and you should use the API tools.

This skill is not trying to be the right answer for everyone. It’s a working answer for one case: someone who writes in Markdown, trusts a logged-in browser more than an OAuth grant and would rather pay in (subscription) tokens than money. It’s also way cheaper than something like Claude Computer, since Claude Computer navigates via literal screenshots while this skill reads the page structurally.

How I started: empirically, not from docs

The first thing I did wasn’t write code. It was open the LinkedIn composer in a Claude-driven browser and start poking.

Claude Code probed each capability using agent-browser before writing any skill code. Can Claude type bold text? (Not directly. LinkedIn doesn’t render Markdown, but there are Unicode characters that look bold and survive submission.) Can it attach an SVG? (No. LinkedIn rejects it, even after the file lands in the upload field.) Can it create a real @mention? (Yes, but only if it types letter by letter and lets LinkedIn’s autocomplete dropdown appear. Pasting @username in one shot leaves it as plain text.)

I treated LinkedIn as a black box and reverse-engineered its rules through tests, not docs. Every gotcha I found went straight into a memory file. By the time I started writing the skill, that file was the source of truth, and the skill’s runbook is now mostly a copy of it with the relevant commands added.

That order matters. If I’d started by writing code, I’d have spent a long time being surprised by LinkedIn. Probing first meant the surprises arrived before I’d locked in any architecture to be surprised about.

The product decisions, not the technical ones

A bunch of choices shape what it’s like to use this skill. None of them are technical. They’re product decisions, and they’re more important than the implementation.

Where the post body lives

A Markdown file usually has more than one thing in it: frontmatter, your post body, maybe some authoring notes you don’t want published. I needed a clean separator.

The convention is markers:

---
title: My post
linkedin:
  schedule: "+30m"
---

Authoring notes for myself or for the skill go here. The skill scans this region for free-form directives like "no preview" or "use the matchamplified link as the preview."

<!-- linkedin:start -->
This is the actual post body that gets published.
<!-- linkedin:end -->

Anything down here is silently ignored. Even directives. Even comments. Especially future ideas you don't want accidentally posted.

I went back and forth on whether anything after linkedin:end should still be parsed for directives. I decided no. The marker is the marker. If you want the skill to do something, put it before the body, where you’d put any other instruction. Keeping the post body and the metadata cleanly separated removes a whole category of “wait, did I accidentally just publish that?” moments.

Three places parameters can come from

You can set the post’s behaviour in three places:

  1. The prompt you type. “Schedule for tomorrow 10am, no preview.”
  2. Free-form English in the directive region of the file. “Show no link previews. Schedule for tomorrow at 10am.”
  3. YAML frontmatter with structured keys.

Precedence is prompt → directives → frontmatter. The skill resolves them, then asks me a question for anything ambiguous before it opens the browser. By the time I see a composer fill itself in, every decision is locked.

The reason for three sources is that they serve different needs. Frontmatter is for things that belong with the post forever (a specific schedule baked into the file). Directives are for one-off English notes you can write naturally. Prompts are for last-minute overrides (“actually, schedule it later”).

Four publish modes

Two of the modes are obvious: post immediately or schedule for later. A third (dry-run) just parses the file and prints what would happen, without ever opening a browser. I use it when I’m debugging the parser.

The interesting one is review. This is the human-in-the-loop mode. The skill opens the composer, fills everything in, attaches the images, applies the preview-card override if you set one and then stops. The composer sits there with everything ready, waiting for me to click Post or Schedule manually. I click the button.

Auto-camelCase for hashtags

LinkedIn’s hashtag system terminates at the first non-letter character. If you write #AI-agents, the composer renders #AI (a real hashtag link) followed by literal -agents (plain text).

The skill normalizes hyphens and underscores to camelCase up front: #AI-agents becomes #AIAgents, #growth_hacking becomes #GrowthHacking. It surfaces a warning in the success summary so you can verify the result. This is required, not optional. LinkedIn won’t fix it for you.

Mentions go through real autocomplete

There’s a way to construct LinkedIn mention elements by hand and inject them directly. I tested it; LinkedIn accepts them at publish time and renders real profile links.

The skill doesn’t do that. It uses real keystrokes, waits for LinkedIn’s autocomplete dropdown to appear and clicks the matching person from the list.

Two reasons:

  1. The author shouldn’t have to look up LinkedIn’s internal IDs for every person they want to tag. That defeats the point of writing posts in Markdown.
  2. The internal shape of those mention elements is LinkedIn’s private business. They can change it any time. The autocomplete dropdown is part of the visible product and is much more stable.

Matching is by surname, case-insensitively, with tolerance for accented characters: typing Pawel Huryn correctly surfaces Paweł Huryn. If the dropdown doesn’t have a match for someone (because you have no LinkedIn connection to them), the skill falls back to typing the name as plain text and warns you. Your post still publishes; the mention just isn’t a real link.

The file picker

This is the part of the build Claude could not figure out on its own.

When the skill clicked “Add” to attach an image, the OS file picker popped up and stayed open. The image actually got attached — there’s a separate path that feeds the file in directly — but the file picker just sat there blocking everything until someone clicked Cancel manually.

Claude spent a long time trying to suppress it. It patched the obvious places where the click might originate. Claude confirmed the listener was firing on the right element, in the right context, with the right setup. The picker still opened.

Then I, the human in the loop watching the Claude-driven browser, clicked the “Upload from computer” button manually with my actual mouse. The picker did not open. Same code. Same listener. Same DOM. Manual click: picker suppressed. Automated click via the browser-driver: picker opened.

The difference turned out to be how Chromium classifies the events. Clicks coming from automation get treated specially and trigger the picker before any JavaScript can stop them. Clicks coming from a real human don’t. There’s no way to ask the browser-driver to send a “less trusted” click. The fix was to skip the click entirely and feed the file in through a separate path that doesn’t involve clicking the button at all.

A few other gotchas, faster

LinkedIn rejects SVG. Even when you bypass the file picker entirely, LinkedIn’s frontend rejects anything outside a small list of accepted formats with a “Something went wrong” toast. The skill rasterizes SVG, HEIC, AVIF, BMP and TIFF to PNG before upload.

Autocomplete dropdowns are flaky. A naive “click the option by its name” approach races against the dropdown refreshing while Claude is still typing. The fix is to find the matching option directly in the page rather than going through the abstraction the browser-driver normally uses.

Some buttons aren’t real buttons. LinkedIn’s primary action buttons are styled <div> elements that behave like buttons but aren’t. Anything that searches for actual <button> elements misses them. It took a while for Claude to figure that one out.

These are the kinds of things you only learn by trying and testing.

Architecture: a main agent that doesn’t do the dirty work

The skill splits work between two agents.

The main agent runs in your conversation. It reads your prompt, reads the file, applies the parameter precedence, asks for clarification if anything is ambiguous and packages everything into a self-contained brief.

It then dispatches a subagent with that brief and a one-line instruction: read the runbook, execute every step, return a summary.

The subagent does all the noisy work. Image conversion. Browser session checks. Composer open. Body typing. Image uploads. Schedule or post or stop-at-review. Pop-up dismissal. Hundreds of small automated steps. None of it leaks into your conversation.

When the subagent returns, it gives the main agent a small JSON: did it succeed, what was the post URL or scheduled time, what warnings happened, where’s the screenshot, where’s the temp directory, did it clean up the temp directory. The main agent formats that for the user and stops.

The split is what makes the skill usable in long conversations. Without it, every invocation would dump tens of kilobytes of intermediate state into the chat history and there’d be nothing left for actual work.

What works today

  • Publish or schedule from a local Markdown file
  • Bold, italic, bold italic — formatting LinkedIn doesn’t render natively, simulated with the right Unicode characters
  • Nested bullet lists with four distinct glyph levels
  • Mentions matched via real autocomplete, including tolerance for accented characters
  • Hashtags auto-fixed when they contain hyphens or underscores (LinkedIn breaks those otherwise)
  • Inline links rewritten so the URL is readable in the post
  • Image upload, including SVG and other formats LinkedIn rejects (the skill rasterizes them to PNG)
  • Preview-card override (force a specific URL or suppress the card entirely)
  • Four publish modes: post, schedule, review, dry-run
  • Three parameter sources combined with precedence
  • MDX support: any JSX components in the source file are silently stripped before parsing

What it does not do

  • Video uploads
  • Document attachments (PDF, PPT)
  • LinkedIn long-form articles, polls or newsletters
  • DMs, comments, reactions
  • Multi-account posting (single LinkedIn profile per browser profile, today)
  • Editing posts after they’re published
  • Cross-posting to other platforms

These are listed in the skill’s TODO.md. None of them are blockers for v1; they’re future work if I or anyone else wants them.

What’s next

On the roadmap (full list, with rationale and rough effort estimates, in the skill’s TODO.md):

  • npm packaging. Today the skill installs by copying the folder into a Claude skills folder and running npm install once. A future version would publish properly, with an installer that handles all of that. Mechanical work; the design doesn’t change.
  • Editing scheduled posts. The skill can schedule a post but can’t go back and tweak one. The flow exists in LinkedIn’s UI; the skill would need to navigate to the scheduled-posts page, find the right one, open it, apply edits.
  • Companion skills, eventually: linkedin-comment and linkedin-repost would share most of the browser plumbing with this one.

None of those change the core. The core is the runbook, the parameter resolution, the body-typing flow and the workarounds for LinkedIn’s specific quirks. Those are stable.

The skill is on my machine. Not on GitHub yet. If there’s interest in a public version, I’ll set up a repo and the npm package. Drop me a message and I’ll move that up the queue.

I’m also planning to use the skill itself to publish an upcoming series of LinkedIn posts about Match Amplified. Honestly, that’s why I built it in the first place. If you see one of those posts on your LinkedIn feed and the formatting is exactly right for once, that’s how.

Related Posts

How I built JRM Lab: from WordPress to Ghost to Astro

This article is about the decisions I took along the way when building JRM Lab. I wanted a personal site that does two things: publish articles, and present [products and p

read more
From the Buildathon to Match Amplified

From the Buildathon to Match Amplified

Table of contents Context: The Buildathon I'm writing about the Buildathon a bit late. Intentionally. I wanted to share what came after, not just what happened during. Back in January 2026, I

read more
Match Amplified: the agentic architecture under the hood

Match Amplified: the agentic architecture under the hood

Match Amplified: The Agentic Architecture Under the Hood This is the technical companion to the Match Amplified product case and part of a series of articles o

read more
Match Amplified: From Lovable to Claude Code

Match Amplified: From Lovable to Claude Code

From Lovable to Claude Code https://www.matchamplified.com I've used Lovable for personal projects before: a winding-down routine webapp, a budget app with receipt "OCR" and LLM-powered auto-categ

read more
Match Amplified: VPS and infrastructure

Match Amplified: VPS and infrastructure

VPS and infrastructure: learning the boring stuff that keeps it all running This post is about the boring stuff that doesn't make it into demos or screenshots. But without it, nothing runs. During

read more
Match Amplified: what's already built

Match Amplified: what's already built

What's already built in Match Amplified What does Match Amplified actually do? The platform is in beta, but it's a fully functional beta, not a landing page with a waitlist. What's live today: **

read more
Match Amplified: the roadmap

Match Amplified: the roadmap

The roadmap: where Match Amplified is heading https://www.matchamplified.com Last post was about what Match Amplified already does. This one is about where it's going. A roadmap for a solo side p

read more

Match Amplified: choosing the name

How I chose the name Match Amplified Naming a project is almost as hard as building it! 😅 During the Buildathon, we called it NextStep. It was a nice name, but with too many collisions in the jo

read more
Match Amplified: what's next

Match Amplified: what's next

What's next: an honest closer to the series This is the last post in the series. Time to be honest about where things stand. What Match Amplified is now A side project and a portfolio piece. A

read more