cli/skills/x-articles/SKILL.md
Edit x.com (Twitter) long-form article drafts reliably. Use this for markdown imports, bulk formatting, code blocks, headings, lists, and repeated inline styling. Inspect and validate with Playwriter, but prefer x.com (Twitter) article GraphQL mutations for deterministic updates.
npx skillsauth add remorses/kimaki x-articlesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Use this skill when editing long-form article drafts on x.com/compose/articles
(Twitter Articles).
Before using this skill, read the playwriter skill and run:
playwriter skill
This skill assumes Playwriter is already set up and connected to the user's existing Chrome session.
Read the full output. Do not pipe it through head, tail, or other
truncation commands.
Use Playwriter for three things:
For anything bigger than a tiny tweak, do not rely on manual typing inside
the editor. Generate the article content_state locally and send the same
GraphQL mutation x.com (Twitter) already uses.
The article body is represented as a content_state object with two main
parts:
blocks: ordered content blocksentity_map: supporting entities, especially code blocksImportant block types:
unstyled — normal paragraphheader-two — section subheadingordered-list-item — numbered list itematomic — embedded block like a markdown code blockImportant entity type:
MARKDOWN — used for code blocks, with the markdown fence stored in
entity_map[*].value.data.markdownLonger example content_state:
{
"blocks": [
{
"key": "k0",
"text": "event sourcing for application state",
"type": "header-two",
"data": {},
"entity_ranges": [],
"inline_style_ranges": []
},
{
"key": "k1",
"text": "your clanker loves state",
"type": "unstyled",
"data": {},
"entity_ranges": [],
"inline_style_ranges": [
{ "offset": 19, "length": 5, "style": "Bold" }
]
},
{
"key": "k2",
"text": "doubles your final app state",
"type": "ordered-list-item",
"data": {},
"entity_ranges": [],
"inline_style_ranges": []
},
{
"key": "k3",
"text": "doubles your bugs",
"type": "ordered-list-item",
"data": {},
"entity_ranges": [],
"inline_style_ranges": []
},
{
"key": "k4",
"text": " ",
"type": "atomic",
"data": {},
"entity_ranges": [
{ "key": 0, "offset": 0, "length": 1 }
],
"inline_style_ranges": []
},
{
"key": "k5",
"text": "if you can derive it, don't store it.",
"type": "unstyled",
"data": {},
"entity_ranges": [],
"inline_style_ranges": [
{ "offset": 7, "length": 6, "style": "Bold" }
]
}
],
"entity_map": [
{
"key": "0",
"value": {
"type": "MARKDOWN",
"mutability": "Mutable",
"data": {
"markdown": "```typescript\nfunction shouldShowFooter() {\n return true\n}\n```"
}
}
}
]
}
This is the minimum mental model:
blocks is the article in orderatomic blocks that point into entity_mapinline_style_rangesFind the existing article editor page in the connected browser. The URL format is:
https://x.com/compose/articles/edit/<article_id>
Always parse and keep the numeric article_id. The content mutation needs it.
Example Playwriter check:
playwriter session new
playwriter -s 1 -e '
state.page = context.pages().find((p) => {
return p.url().includes("/compose/articles/edit/")
})
if (!state.page) {
throw new Error("No article editor page found")
}
console.log(state.page.url())
'
Use the UI to learn how the editor reacts before doing bulk updates. Good exploration tasks:
SottotitoloAfter each change, inspect the rendered HTML with getCleanHTML().
Example validation command:
playwriter -s 1 -e '
state.page = context.pages().find((p) => {
return p.url().includes("/compose/articles/edit/")
})
console.log(
await getCleanHTML({
locator: state.page.locator("[data-testid=\"composer\"]"),
showDiffSinceLastCall: false,
}),
)
'
Watch GraphQL requests while making one tiny manual change. This gives you the exact mutation names and payload shapes used by the current x.com (Twitter) editor.
The two important mutations found in this session were:
ArticleEntityUpdateTitleArticleEntityUpdateContentThe content mutation URL looked like:
https://x.com/i/api/graphql/<queryId>/ArticleEntityUpdateContent
The exact queryId can change over time. Do not hardcode it blindly without
first confirming it from a real request in the current session.
Example request logger:
playwriter -s 1 -e '
state.page = context.pages().find((p) => {
return p.url().includes("/compose/articles/edit/")
})
state.requests = []
state.page.removeAllListeners("request")
state.page.on("request", (req) => {
if (req.url().includes("ArticleEntity") || req.url().includes("graphql")) {
state.requests.push({
url: req.url(),
method: req.method(),
postData: req.postData(),
})
}
})
console.log(
"Ready: now make one tiny manual edit in the page, then rerun this command to inspect state.requests",
)
'
Once you know the current mutation shape, generate the full content_state
locally and send the content update directly.
This is the reliable path for:
Concrete pattern:
content_state in a local JSON filect0 from document.cookieArticleEntityUpdateContent with the same queryId and feature flagsAfter every direct mutation:
getCleanHTML()Do not trust the visual editor alone.
Example reload + search:
playwriter -s 1 -e '
state.page = context.pages().find((p) => {
return p.url().includes("/compose/articles/edit/")
})
await state.page.reload({ waitUntil: "domcontentloaded" })
await waitForPageLoad({ page: state.page, timeout: 8000 })
console.log(
await getCleanHTML({
locator: state.page.locator("[data-testid=\"composer\"]"),
search: /debugging with event streams|typescript|ordered-list-item/i,
showDiffSinceLastCall: false,
}),
)
'
Use:
{
"type": "unstyled",
"text": "your paragraph text"
}
Use:
{
"type": "header-two",
"text": "debugging with event streams"
}
Each item is its own block:
{
"type": "ordered-list-item",
"text": "doubles your bug surface"
}
Code blocks are not plain text blocks. They are:
atomic block in blocksMARKDOWN entity in entity_mapThe atomic block points to the entity with entity_ranges.
The entity markdown should include the full fence, for example:
```typescript
const x = 1
```
If you want the visible language label to say typescript, the stored fence
must be ```typescript, not ```ts.
Bold text is represented with inlineStyleRanges inside a block.
Important session learning:
BoldBOLDExample:
{
"text": "your clanker loves state",
"inlineStyleRanges": [
{ "offset": 19, "length": 5, "style": "Bold" }
]
}
Always calculate offsets against the raw block text exactly as stored.
The manual editor flow has several traps:
After creating a heading, pressing Enter once can keep the next block in the
same heading style. To reset to a paragraph, press Enter again.
Typing after a code block is unreliable. The editor can:
For anything more than a tiny manual tweak, use direct content updates instead.
The editor can look correct while the underlying block structure is wrong. Always inspect the HTML or mutation payload.
If the relay server restarts or the extension reconnects, Playwriter sessions can disappear. If that happens, create a new Playwriter session and reattach to the already-open article page.
Recovery command:
playwriter session new
playwriter -s 1 -e '
state.page = context.pages().find((p) => {
return p.url().includes("/compose/articles/edit/")
})
if (!state.page) {
throw new Error("No article editor page found")
}
console.log(state.page.url())
'
Direct content updates need proper auth headers. In this session, the direct
fetch() worked only after including:
x-csrf-token from the ct0 cookieIf you get 403, inspect the successful browser request and match its headers.
In this session, the direct fetch succeeded only after matching:
x-csrf-tokenx-twitter-active-userx-twitter-auth-typex-twitter-client-languageAfter updating an article, verify all of these:
header-twoordered-list-itemmarkdown-code-blocktypescriptunstyled## headings to header-twoordered-list-itematomic + MARKDOWN entitiesArticleEntityUpdateContentThe fastest implementation is usually:
./tmp/x-article-content-state.jsonfs.readFileSyncoffset and lengthinlineStyleRanges with style Boldcontent_stateUpdate the markdown entity fences. Example:
```ts```typescriptThen resend the full content_state and reload the editor.
Use this pattern when you already have the right queryId and payload shape:
playwriter -s 1 -e '
const fs = require("node:fs")
state.page = context.pages().find((p) => {
return p.url().includes("/compose/articles/edit/")
})
const articleId = state.page.url().match(/edit\/(\d+)/)?.[1]
const contentState = JSON.parse(
fs.readFileSync("./tmp/x-article-content-state.json", "utf8"),
)
const csrfToken = await state.page.evaluate(() => {
return document.cookie
.split("; ")
.find((x) => x.startsWith("ct0="))
?.slice(4) || ""
})
const payload = {
variables: {
content_state: contentState,
article_entity: articleId,
},
features: {
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
rweb_tipjar_consumption_enabled: false,
verified_phone_label_enabled: false,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true,
},
queryId: "<capture-from-real-request>",
}
const response = await state.page.evaluate(async ({ payload, csrfToken }) => {
const res = await fetch(
`https://x.com/i/api/graphql/${payload.queryId}/ArticleEntityUpdateContent`,
{
method: "POST",
credentials: "include",
headers: {
authorization: "<capture-from-real-request>",
"content-type": "application/json",
"x-csrf-token": csrfToken,
"x-twitter-active-user": "yes",
"x-twitter-auth-type": "OAuth2Session",
"x-twitter-client-language": "it",
},
body: JSON.stringify(payload),
},
)
return { status: res.status, text: await res.text() }
}, { payload, csrfToken })
console.log(response.status)
console.log(response.text.slice(0, 1000))
'
Replace the bearer token and queryId with values captured from a successful
browser request in the current session.
Use this default unless the task is tiny:
content_state locallyThat is the fastest path and the most likely to work in one shot.
development
Opinionated TypeScript npm package template for ESM packages. Enforces src→dist builds with tsc, strict TypeScript defaults, explicit exports, and publish-safe package metadata. Use this when creating or updating any npm package in this repo.
documentation
Best practices for creating a SKILL.md file. Covers file structure, frontmatter, writing style, and where to place skills in a repository. Use when the user wants to create a new skill, update an existing skill, write a SKILL.md, or asks how skills work.
documentation
Best practices for creating a SKILL.md file. Covers file structure, frontmatter, writing style, and where to place skills in a repository. Use when the user wants to create a new skill, update an existing skill, write a SKILL.md, or asks how skills work.
tools
Centralized state management pattern using Zustand vanilla stores. One immutable state atom, functional transitions via setState(), and a single subscribe() for all reactive side effects. Based on Rich Hickey's "Simple Made Easy" principles: prefer values over mutable state, derive instead of cache, centralize transitions, and push side effects to the edges. Resource co-location in the same store is also valid when lifecycle management is safer that way. Also covers state encapsulation: keeping state local to its owner (closures, plugins, factory functions) so it doesn't leak across the app, reducing the blast radius of mutations. Also covers event sourcing: keeping a bounded event buffer and deriving state with pure functions instead of mutable flags, making event handlers easy to test and reason about. Use this skill when building any stateful TypeScript application (servers, extensions, CLIs, relays) to keep state simple, testable, and easy to reason about. ALWAYS read this skill when a project uses zustand/vanilla for state management outside of React.