Send a finished Obsidian/local Markdown draft straight to the X Articles drafts, completing the X long-form outlet of the publish family (publish-x-thread produces thread text; this skill produces rich-text long-form drafts).
Customer-readable description
What this skill can do
| Customer wants | The skill will | Customer can see |
|---|---|---|
| Post a Markdown long-form article to X Articles | Parse title/cover/body images → create a new draft in the browser → paste rich text → insert images in place → mechanical verification | The live browser operation process, draft URL, verification checklist, and final receipt |
| The article has no cover image | Stop and remind (an X Article without a cover looks bad), and continue without a cover only after explicit consent | Reminders and confirmation questions |
| Missing dependencies / not logged in / no Premium+ | Stop and clearly state what is missing | Login/subscription guidance, without filling in any credentials on your behalf |
Security bottom line: save drafts only,never click "Publish"; the login state stays in the browser only, no cookies are exported, and no credentials are written to disk.
How customers use it
Other recognizable phrasings include "upload to X article", "X Articles draft", "post this as an X long-form article"; when there is only a regular short post or thread, hand off to soia-media-publish-x-thread。
- Say "post <file> as an X Article". First-time use requires logging in once: the script pops up a browser window, and after manual login it is reused long-term (shares the same login state as
soia-media-publish-x-threadso once either skill has logged in, neither needs to log in again); the account needs a subscription that includes the "write articles" entitlement. - The Agent first does a dry-run parse and reports: title, cover, number of body images, list of missing images; if the cover is missing, it asks you first.
- The entire browser stage is visible (by default it uses a host-agnostic Playwright script, the same in any environment that can run Python); after completion it gives the draft URL and verification checklist, for you to review manually and publish.
Dependencies and installation
claude plugin marketplace add soia-team/soia-open-skills
claude plugin install soia-media-content@soia
When you only want this one skill, you can use the npx route. Note that the skill will land in the shared source of truth ~/.agents/skills; if the plugin is also installed, the same skill will appear as two indexes and drift apart, so it is recommended to choose one of the two:
npx skills add soia-team/soia-open-media-content-skills -g -a '*' -s soia-media-publish-x-article -y
- Cross-platform Python 3.9+ (macOS / Windows / Linux all work): the main route uses Playwright's bundled browser + native clipboard API, with no dependency on system-specific tools like osascript / sips.
pip install playwright && python -m playwright install chromium - The login state shares the same local profile directory with
soia-media-publish-x-thread(~/.config/soia-skills/soia-media-publish-x-thread/x-profile, can be overridden with--profile-dir/SOIA_X_PROFILE_DIR; the script does a read-only fallback for the old namespace): if you have already logged in withx_post.py login, it works directly; if not logged in, go to that skill and runpython3 scripts/x_post.py login(this skill itself does not provide aloginsubcommand and relies on the shared profile to avoid logging in a second time). - The alternative route (when the host happens to provide browser control tools, such as Claude Code's claude-in-chrome) requires macOS and uses
clipboard_x.py(osascript/sips); see "Alternative route" below. If there is no host tool, or you are in a non-macOS environment, just use the main route. - The X account must have a Premium subscription that includes the "Write Articles" entitlement (the US$4 Premium tier shown on the 2026-07 purchase page includes it; refer to the real-time entitlement table on the X purchase page)
- No private config.yml: this skill does not touch any key/cookie
WorkBuddy The loading unit is role-based experts rather than plugins,npx skills add -a '*' cannot cover it, it needs to be installed separately, see docs/install/workbuddy.md。
Logging and completion receipt
After execution, you must output: what was done, draft URL, validation results (item by item), skipped/failed items, next steps (manual review and publication).
Positioning and closed loop
compose-article-draft produces the finished article → this skill pushes an X Articles draft (benchmark publish-wechat-draft versus WeChat Official Account). "Only create drafts, never publish" is the same safety philosophy as on the WeChat Official Account side.
Trigger words
| The user says | Action |
|---|---|
| "Publish as X Article", "Upload X article", "Push to X drafts" | Full flow (parse → upload → validate) |
| "First see whether this can be posted to X" | Run only the parse dry-run, report without touching the browser |
| "Post an X / post a tweet" (ordinary short post) | Not part of this skill: for short post/thread text use soia-media-publish-x-thread; this skill only does Articles long-form drafts |
Workflow
Step 1: Parse (without touching the browser)
python3 scripts/parse_x_article.py <article.md> > /tmp/x_article.json
python3 scripts/parse_x_article.py <article.md> --html-only > /tmp/x_article_body.html
JSON fields:title / cover_image(only when the article starts with an image) / content_images[](including block_index and after_text)/ dividers[] / html / missing_images。
Two gates, pass them before touching the browser:
missing_images > 0→ stop, give the user the list of missing images (you can use--search-dirto specify a directory to search for them).cover_imageis null → stop, and remind them "an X Article with no cover looks bad; it is recommended to add an image at the beginning of the article"; continue only if the user explicitly says not to add one (in that case, all images are treated as body images).
Step 2: Write the draft to X Articles
The main route is the bundled Playwright script x_article_post.py, host-agnostic—it behaves consistently in any host that can run Python, such as Claude Code, Codex, Gemini CLI, opencode, etc., and does not depend on any host-specific browser tooling; both routes share the same set of mechanical validation criteria from Step 3.
Main route: scripts/x_article_post.py (host-agnostic)
Dependencies (one-time):pip install playwright && python -m playwright install chromium。
python3 scripts/x_article_post.py status # 检查登录态(与 x_post.py 共用 profile)
python3 scripts/x_article_post.py check # 探测 Articles 入口
python3 scripts/x_article_post.py draft /tmp/x_article.json # 完整草稿:封面→标题→正文→插图→分割线→校验
-
Log in: this skill itself does not provide the
loginsubcommand. If not logged in, go tosoia-media-publish-x-threadand runpython3 scripts/x_post.py loginonce; both sides share the same local profile, so logging in once works for both. -
checkreturns one of four states:not_logged_in→ not logged in; follow the instructions above to log in and retry.no_articles_access→ the account does not have the "Write Articles" entitlement, or/compose/articles404; the JSON includes the following fixed reminder text.ok→ a clickable creation entry was found, and you can continuedraft。unknown→ the page structure does not match expectations (possibly the editor was redesigned, or it may be a real DOM that has never been verified); the JSON includes the screenshot path, do not guess clicks; handle it according to "Boundaries and exceptions" below.
no_articles_accessFixed reminder text (never subscribe on the user's behalf; byte-for-byte identical to the script'sNO_PREMIUM_MESSAGEconstant, not paraphrased):The current account does not have X Premium with the "Write Articles" entitlement enabled; please go to https://x.com/i/premium_sign_up after subscribing (as of 2026-07, the US$4/month Premium tier includes this entitlement) and try again.
-
draftThe first internal step is to reuse thecheckdetermination: if notokimmediately stop and return that status as-is, without continuing to click anything. -
Body images and dividers are always inserted in
block_indexfrom largest to smallestreverse order—the editor is a dynamic document, and inserting the earlier ones first shifts all later anchors; this is a hard correctness requirement, not a style preference. -
Clipboard uses Playwright's native Clipboard API (
navigator.clipboard.write+ granted clipboard permission), working across macOS / Windows / Linux; the paste key is chosen automatically by platform: Cmd+V (macOS) / Ctrl+V (other platforms), with no reliance on osascript. -
Cover upload uses a real file input control (
input[type=file]+set_input_files), which is more reliable than clipboard/drag-and-drop. -
There is no code path related to publishing:
x_article_post.pythere is no code capable of clicking or locating the "Publish" button—this is a hard constraint of the script itself, stricter thanpublish-x-thread's draft/send two tiers (in the Articles scenario, the send tier does not exist at all). -
Unexpected exceptions are caught by an overall fallback: it produces a structured error JSON (error message + page URL/title + screenshot path), does not throw a bare traceback, and does not keep clicking blindly while in an exception state.
-
Output is a single-line JSON receipt, machine-readable by any host.
Alternative route: host browser tools
When the host happens to provide browser control (such as Claude Code's claude-in-chrome, which reuses the user's real Chrome login state via a browser extension), it may be used; the client has zero login cost, and manual intervention during page redesigns is more intuitive;when no host tool is available, this must not be used as a reason to skip the script's main route. The acceptance criteria are exactly the same as the main route (see step 3).
-
Browser surface choice: claude-in-chrome (real Chrome already logged in) or Browser pane (if not logged in, have the user log in;do not enter credentials on their behalf)。
-
Navigate to
https://x.com/compose/articles—the landing page is thedrafts listpage, not the editor; click the "create/write" button directly to enter the editor (do not wait for "Add title" to appear; it only renders after the click). -
Redirect to the login page = not logged in;
/compose/articles404 or no "Articles" entry in the creator studio = the account has no Articles entitlement. Stop and remind using the fixed wording (never subscribe on their behalf; wording as above). -
Has cover: click the cover upload control, use file upload to upload
cover_image; X will pop up a media editing layer, and **you must click "Apply"** to close it, otherwise the editor is blocked by the overlay and the cover does not take effect. In the title box (placeholder "Add title/添加标题") entertitle。 -
Rich-text body paste:
python3 scripts/clipboard_x.py html --file /tmp/x_article_body.htmlclick the editor body area →
Cmd+V. Headings/bold/links/lists/quotes are preserved by the HTML clipboard flavor. After pasting, spot-check whether the text of the first and last paragraphs is both present. -
Images and dividers (both in block_index from largest to smallestreverse order): for each
content_images[i]:python3 scripts/clipboard_x.py image <path> --max-bytes 3000000use
after_textto find the target paragraph → click → press the End keyto move the cursor to the end of the paragraph (to prevent clicking into a link within the paragraph) →Cmd+VPaste → wait for "Uploading media/Uploading" to disappear before inserting the next one. Divider (dividers[], also in reverse order): X ignores<hr>, you must click the corresponding position and then use the editor's "Insert > Divider" menu.Reason for reverse order: the editor is a dynamic document; inserting earlier items first will shift all subsequent anchors.
Step 3: Mechanical verification (not complete unless all pass)
| Verification item | Standard |
|---|---|
| Title | Editor title == JSON title |
| First/last sentence of body | Both can be found in the editor |
| Media count | Number of images in editor == cover (0/1) + content_images count |
| Divider count | == dividers count |
| Draft status | autosave_confirmed: after all images/dividers are handled, first wait a while for the (presumed) debounced autosave to take effect, then try to observe the "Saved/Saved" prompt;draft_url: record the draft URL |
The main route's draft subcommand has already completed these five items in the checks field of the JSON receipt (draft_url/autosave_confirmed When an exact result cannot be obtained, honestly give null/false, do not fabricate;autosave_confirmed: false does not equal failure, it just means it could not be confirmed, handle it according to the "Boundaries and Exceptions" above); the alternative route requires manual or host browser tool item-by-item verification.
Step 4: Receipt
Draft URL + item-by-item results of the verification checklist + "Please review manually and click publish yourself."Under no circumstances click publish.
Format support boundaries
| Element | Handling |
|---|---|
| H2/H3, bold, italic, links, ordered/unordered lists, quotes | Preserved natively |
| Code block | Convert to quote block (X does not support <pre>) |
| Tables / mermaid | X does not support: first manually convert to PNG images and insert, or accept degraded loss; the parser passes through the text as-is |
--- Divider |
Insert via editor Insert/Insert menu (main route see x_article_post.py of insert_divider_at, alternative route see step 2 step 6) |
| H1 | Only used as a title, not included in the body |
Boundaries and exceptions
| Scenario | Handling |
|---|---|
| Redirected to login page | Stop, have the user log in in the browser and retry; do not touch credentials |
| No Articles entry (404 on /compose/articles or no create entry) | Stop, remind using the fixed wording from step 2 and provide the activation direct link https://x.com/i/premium_sign_up; never subscribe on their behalf; main route corresponds to check/draft of no_articles_access status |
| Page structure cannot be determined to belong to any of the above cases | Stop, report truthfully as unknown(main route automatically attaches screenshot + page URL/title), do not force categorization, do not guess clicks |
| Missing image | Step 1 gate intercepts, provide a checklist |
| No cover | Gate reminder, continue only after explicit consent |
| Formatting lost after paste | Check the clipboard path (main route see JSON's warnings; alternative route checks clipboard script output); retry once; if it still fails, report the actual result rather than falsely claiming success |
| Image upload stuck | Wait for the upload indicator to disappear (the main route has a 15s timeout cap); on timeout, take a screenshot and report instead of blindly continuing |
| Divider insertion failed | Do not abort the entire draft: the main route counts the failed item in warnings(including block_index/after_text), and the rest of the content is completed as usual |
| X editor redesign makes controls unfindable | Stop and report the actual page state; do not guess at clicks |
draft retry after a mid-way failure |
Each call on the main route clicks "create" to start a new article; it does not continue or deduplicate the previously failed draft. Before retrying, if you suspect the previous attempt partially wrote something, first manually check the Articles draft list on X and clean up duplicate/half-finished drafts; retrying only adds a new article and will not automatically merge or overwrite |
checks.autosave_confirmed is false |
This means the script could not observe the "saved/Saved" prompt—the content may have been pasted but not necessarily persisted;warnings will also show a prompt; do not judge success based only on ok: true ; you need to manually open checks.draft_url to verify |
Script specifications (scripts/)
| Script | Responsibility | Dependencies |
|---|---|---|
parse_x_article.py |
MD → JSON (title/cover/images/dividers/HTML); adapted from wshuyi/x-article-publisher-skill (MIT, see repository THIRD_PARTY_NOTICES) | stdlib |
x_article_post.py |
Host-agnostic Playwright main route: login-state reuse (shares the same profile with x_post.py ), Articles entry detection (check: not_logged_in/no_articles_access/ok/unknown), fully automated draft (draft: cover→title→body→inline images→dividers→mechanical validation), cross-platform clipboard (Playwright native Clipboard API, no osascript); contains no publishing code path whatsoever |
pip install playwright && python -m playwright install chromium |
clipboard_x.py |
For the alternative route only: HTML/images onto the macOS clipboard (osascript;--max-bytes automatically downsamples to a temporary copy when over the limit) |
stdlib + macOS osascript/sips |
Validation and testing
Script-layer fixture forward test (no X account needed; must be run after changing scripts;parse_x_article.py/clipboard_x.py was not changed this time, so the standard is unchanged):
- Create a test article containing a frontmatter title, a cover image at the start, 2 body images (png+jpg), a
---divider, a code block, ordered/unordered lists, and links. parse_x_article.pyAssertions:titletaken from frontmatter;cover_imageis an absolute path and points to the image at the start;content_images[].block_indexincrements,after_texthas no___CODE___marker and has had markdown notation stripped; after deleting the image at the start,cover_imageis null (gate semantics).clipboard_x.py htmlAfterosascript -e 'clipboard info'must contain«class HTML»;imageproduces for png/jpg respectively«class PNGf»/JPEG picture;--max-byteswhen over the limit, goes through a sips temporary copy andthe original file's byte count remains unchanged。
x_article_post.py After one round of implementation + three-lens independent adversarial review (security / correctness / cross-platform), fixes were made based on the findings: added wait_for_autosave_signal(after all inline images and dividers have been processed and before formally closing the browser, first wait for the debounced autosave to take effect and then try to observe "saved/Saved"; the result is written into checks.autosave_confirmed, and when it cannot be observed, a warning is given instead of silently continuing—fixing "the close timing may precede persistence, yet it still reports ok:true" blocking-level issue), fall back to the body editing area when the divider anchor cannot be found (consistent with body images, to avoid landing at a residual cursor position from the previous operation), degrade to a warning instead of aborting the entire draft when the cover file upload fails, and change the media count validation to count only within the role="main" area (to avoid page-level <img> such as the navigation bar avatar polluting the count). Cross-platform lens review found no issues.
x_article_post.py——Actually run before this PR was merged:
python3 -m py_compile+ast.parsesyntax validation passed;importlibactually executed module top-level code (regex compilation, etc.) with no exceptions.- Manual review +
grep -in "publish\|发布"full-text verification: the hits are all comments/docstrings/path names, with not a single hit of.click()/.press()calls—verifying the hard constraint that there is "no release code path whatsoever." status/checkAlready tested and passed with a real X account (local shared login session):statuscorrectly reportedlogged_in: true;checkcorrectly returned for an account known not to be subscribed to Premiumno_articles_access,messagefields matched byte-for-byte with the script'sNO_PREMIUM_MESSAGEconstants; directly scraping that page verified the classification hit path—title未找到页面 / X, body contains "this page doesn't exist", hitting theNOT_FOUND_TEXT_MARKERS"page doesn't exist" substring (going through the body-text matching branch, not the title"404"match, and not a lucky hit on the Premium-upsell fallback branch).
x_article_post.py——Not yet run before this PR was merged, honestly left for follow-up verification(not packaged as "tested"):
checktheokbranch and the completedraftflow (cover upload, title, rich-text body paste, inserting images in reverse order byblock_index, divider menu insertion,autosave_confirmedobservation, mechanical validation)—requires a real Premium account with a subscription that includes the "write articles" entitlement; there is currently no available account to verify with;find_create_control/find_title_input/find_body_editor/SAVED_INDICATOR_RE/ divider menu and other selectors are all reasonable guesses based on text/role location and have not been verified on the real editor DOM; the first real run will very likely hit boundaries such as "X editor redesign, control not found" orautosave_confirmed: false, and the selectors and this document need to be updated according to the actual page.- In the cross-platform clipboard path,
navigator.clipboard.write+Control+Von Windows/Linux—this development environment is macOS, and only thesys.platformplatform detection logic itself was verified, not run on a real Windows/Linux machine.
The browser end-to-end standard remains unchanged: pass criterion is all five items green in the step 3 mechanical validation (including autosave_confirmed: true) + a draft URL; when selectors fail due to an editor redesign, update the selector descriptions in this document based on the actual page.
Completion receipt
- What was done: one sentence + draft URL.
- Validation checklist: step 3 item-by-item results; mark failed items honestly.
- Next steps: publish on your own after manual review; suggest using
soia-media-generate-article-imageto generate the cover.