
When Your Drafter Doubles the Body: Building the WithAgents Content Plugin
The 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.
View companion repoThe first time I tested the new drafter on a post that already had a draft, the editor doubled in size. The paste worked — LinkedIn's Lexical editor consumed the synthetic ClipboardEvent and returned defaultPrevented: true, which is the signal I'd verified across 33 successful first-time drafts. Autosave fired at t=1. "Draft - saved" was visible in the toolbar. By every signal the recipe documented, the draft was correct.
It wasn't. The editor was 18,256 characters when it should have been ~9,200. The new body had pasted cleanly. So had the old body, which the recipe's "clear" step had not actually cleared.
This is post 34. The first 33 worked. That's the kind of bug you only find after you've already shipped the manual recipe to itself.
What This Plugin Replaces
The work it codifies is not new. Across the prior 33 posts, drafting a LinkedIn Pulse article meant doing a sequence of steps by hand, every time, in the same order, with a chrome-devtools session a human operator drove. Snapshot the page. Click the title. Type the title. Dispatch a synthetic ClipboardEvent('paste') with a DataTransfer carrying text/html and text/plain. Upload the cover. Poll for the cover dialog. Click "Next". Poll for "Draft - saved". Screenshot. Append the draft URL to a registry. Each step has an exact payload that works and several near-payloads that don't. None of it was a skill or a command. It was a memorized recipe.
Memorized recipes don't scale and they don't reliably reproduce. The first 33 worked because I was the one driving them. The 34th was going to be driven by the plugin, and the plugin found a bug the recipe had carried since the start.
The Pipeline
The orchestrator is /wa-publish <slug>. It runs the chain with a pause-for-approval gate between every stage. Two gates — ship and register — cannot be auto-approved even with a --yes-through flag, because they touch production state (Vercel) and external state (Supabase + LinkedIn API).
The whole shape is a deliberate refusal to automate the one thing that cannot be automated honestly: LinkedIn's API does not publish Pulse articles. Only the web editor does. So the pipeline stops between SHIP and REGISTER, tells the operator which draft URL to publish, and waits.
What Was Already Real
Before I started writing skill markdown, I spent twenty minutes inventorying what infrastructure actually existed in the repo. This is the step I skipped at first and then went back to do, because two of the commands I was about to write referenced things I couldn't find.
- 01scripts/publish-scheduled.js — launchd-triggered every 5–15 minutes, reads Supabase scheduled_posts, fires due rows via API. 19KB.
- 02scripts/requeue-after-url-added.js — flips a linkedin-article row from failed back to pending once the Pulse URL is known.
- 03pulse-url-registry.json — 33 entries, schema {slug: {url, added}}.
- 04posts/post-NN-slug/social/ — four variants per post: linkedin-article.md (long), linkedin.md (teaser), twitter.md (thread), newsletter.md (email).
- 05posts/post-NN-slug/assets/ — three hero variants per post, each as .png + .svg + @3x.png, generated via Stitch MCP.
- 06DESIGN.md §6 — the verbatim Stitch prompt block mirroring /wa/project/design-systems/design-systems.jsx. Operator Console aesthetic, crimson #FF3D52, no legacy Midnight Observatory palette.
Not real: a devlog-publisher skill. The series posts reference it by name as if it existed. There is no SKILL.md for it in ~/.claude, ~/.omc, or any of the companion repos I checked. It's the name of a process, not a callable tool.
The honest move was to write /wa-write so the command implements the process directly — read real Claude Code session transcripts, mine for real metrics, draft the body — rather than wrap a skill that doesn't exist. If someone later builds a real devlog-publisher, /wa-write can be updated to delegate. Today it does the mining itself.
The Bug
The recipe doc had this for the clear-step in re-draft mode:
It looks correct. Native selection API selects the editor's contents. execCommand('delete') deletes the selection. This is the pattern that worked on contenteditable divs since 2005.
LinkedIn does not use a contenteditable div. It uses Lexical, Facebook's editor framework. Lexical owns its own internal model and synchronizes the DOM. It listens for a specific event — InputEvent('beforeinput', { inputType: 'deleteContentBackward' }) — and routes the deletion through its model. It does not implement execCommand. The call returned true (per the spec), the selection was visually emptied for a frame, and then Lexical re-rendered its model on top of the cleared DOM. The model still contained the old body. The next paste appended to that model.
The diagnostic was the editor length probe. After the first paste, I ran:
editorLen: 18256. has_old_opener: true. Both fragments — the new opener with em-dashes I'd just typed and the old "I know, I know," from the original draft — were in the editor.
The Fix
The clear step that actually works:
Three things matter. The Cmd+A keydown gives Lexical's keyboard handler the select-all signal in its own framework. The native selection range is a belt-and-braces — getSelection() is what the visible cursor reads from. The beforeinput event with deleteContentBackward is the signal Lexical's deletion handler actually listens to.
After this step, editor.innerText.length was 4 — just newlines. The second paste landed clean. editorLen was 9,165. The new opener was present. The old opener was gone. Autosave fired at t=1. "Draft - saved" appeared.
The recipe doc now has a Step 4.5 — Clear editor (re-draft mode ONLY) section with the working payload, because the next time I forget which framework LinkedIn uses, I want the recipe to remember.
F-03 — The Surface That Wasn't There
The two edits I'd made to the source post were a subtitle change and a body opener change. The body opener change rendered in the editor and showed up in the screenshot. The subtitle change did not.
LinkedIn's Pulse Article editor exposes the title textbox and the body editor. It does not expose a subtitle field. The subtitle metadata exists — it's in posts/<slug>/social/linkedin-article.md, the staging JSON carries it, and the published article surfaces it on the public Pulse page. But the editor pre-publish has no way to see or verify it. The subtitle is consumed during publish/preview, not during edit.
I'd been about to write the verdict as PASS on both edits. The body change was visually verifiable; I described what I saw in the screenshot. The subtitle change was structurally invisible. Writing it as PASS would have been a fabrication — I had no evidence the subtitle change had any effect, because there was no surface to inspect.
The verdict.md now says: "Edit 1 (subtitle): in staging JSON, not verifiable in editor — defer to publish-preview verification." That's the honest claim.
Structural Enforcement
The plugin contains a single hook — linkedin-no-publish.js. It is a PreToolUse hook bound to mcp__chrome-devtools__evaluate_script and mcp__chrome-devtools__click. It inspects the tool input, checks whether the URL matches linkedin.com and whether the payload mentions Publish or Schedule, and exits 2 with stderr if both are true. Exit 2 blocks the tool call.
Seven cases verified after I patched a regex bug I caught in V3b on the first verification pass:
The escape hatch matters. The hook is not a moral position. It is a guard against the worst kind of bug — an accidental publish during a debugging session, while the operator is testing a paste and the recipe somehow ends up at the Publish button. If the operator deliberately wants to publish through automation later, they can set the env var. They have to do it on purpose. The default is no.
Shipping Post-34 With Itself
This post is the 34th in the series. It is being shipped through the plugin it documents. The scaffold ran first. The write — this body — ran second. After this comes the cover stage, the social variants, the LinkedIn draft (against a brand-new article, not a re-draft, so the F-02 clear step is not exercised), the site sync, the deploy, the manual publish, and the registration.
If any stage fails, the orchestrator pauses and reports. If a stage produces a verdict I can't honestly call PASS, the post does not ship until the gap is closed.
The 33rd post was about session-data drift between intent and code. The 34th is about a bug that survived 33 manual runs and only surfaced when the runs became reproducible. Reproducibility is the prerequisite for catching the bug that hides in your habit.
Habit doesn't catch its own bugs.
Continue the series
- 33Edge CasesOrbit: Find Drift Between What You Asked and What Actually ShippedPlans are evidence, not history. Mining the JSONL session transcripts you already have on disk to compare intent against claim against codebase truth.
- 32Edge CasesThe 529 CascadeAnthropic returns 529 (overload) intermittently. Naive retries themselves consume the bucket and storm the API a hundred times over. The retry policy that costs less, not more.
- 31Edge CasesDrift DetectionSpecs and code diverge silently. The 60-day audit pattern, the four-class taxonomy (dead, drifted, lying, fine), and what I do every quarter to make sure my docs still describe what my code actually does.
- 30Edge CasesNotification Architecture for Async AgentsAgents finish at three in the morning. Severity model, channel routing, the exhausted-state-fires-alert pattern. The notification layer that lets you sleep while your work runs.