@kentcdodds/x
X API v2 helpers for tweets, search, legacy DMs, and encrypted X Chat via a Fly XDK sidecar.
AGENTS.md
381 lines · 14.2 KB · Markdown@kentcdodds/x — agent notes
Human setup and intent live in README.md. This file is for
agents: imports, smoke/dryRun snippets, fork adapt, and edge cases. Secrets by
name only — never paste values. Do not disable live webhooks or jobs.
Surface naming uses post (get-post, create-post, …). X API paths and
query keys remain /tweets, tweet.fields, quote_tweets, tweet_id in
request bodies.
Secrets / storage / integrations
| Kind | Name | Notes |
|---|---|---|
| User secret | xBearerToken | App-only bearer reads |
| OAuth integration | x | Default user-context |
| OAuth integration | x-<purpose> | Extra accounts (e.g. x-kodykoala) |
OAuth user-context calls use createAuthenticatedFetch(integrationName) with tokens stored on the connection. Mirrored accessTokenSecretName / refreshTokenSecretName fields are optional legacy metadata — do not require them.
| User secret | xChatPin | Existing x.com Chat PIN |
| User secret | xChatSidecarToken | Fly sidecar SIDECAR_TOKEN |
| Package storage | xChatSidecarUrl | Your Fly origin |
Hosts: api.x.com (OAuth/bearer); your Fly sidecar host only for Chat secrets.
Owner-config defaults (Fly app / fallback URL) live in src/owner-config.ts.
Import paths
| Export | Import |
|---|---|
| overview | kody:@kentcdodds/x |
| accounts | kody:@kentcdodds/x/accounts |
| smoke-test | kody:@kentcdodds/x/smoke-test |
| request | kody:@kentcdodds/x/request |
| refresh-oauth-token | kody:@kentcdodds/x/refresh-oauth-token |
| get-me | kody:@kentcdodds/x/get-me |
| get-user-by-username | kody:@kentcdodds/x/get-user-by-username |
| search-recent | kody:@kentcdodds/x/search-recent |
| get-post | kody:@kentcdodds/x/get-post |
| get-post-thread | kody:@kentcdodds/x/get-post-thread |
| get-user-posts | kody:@kentcdodds/x/get-user-posts |
| summarize-post-metrics | kody:@kentcdodds/x/summarize-post-metrics |
| create-post | kody:@kentcdodds/x/create-post |
| reply-post | kody:@kentcdodds/x/reply-post |
| delete-post | kody:@kentcdodds/x/delete-post |
| like-post / unlike-post | kody:@kentcdodds/x/like-post, .../unlike-post |
| repost-post / unrepost-post | kody:@kentcdodds/x/repost-post, .../unrepost-post |
| bookmark-post / remove-bookmark | kody:@kentcdodds/x/bookmark-post, .../remove-bookmark |
| follow-user / unfollow-user | kody:@kentcdodds/x/follow-user, .../unfollow-user |
| send-dm | kody:@kentcdodds/x/send-dm |
| list-dm-events | kody:@kentcdodds/x/list-dm-events |
| get-dm-conversation | kody:@kentcdodds/x/get-dm-conversation |
| list-chat-conversations | kody:@kentcdodds/x/list-chat-conversations |
| get-chat-conversation | kody:@kentcdodds/x/get-chat-conversation |
| send-chat | kody:@kentcdodds/x/send-chat |
| adapt | kody:@kentcdodds/x/adapt |
| migrate-from-values | kody:@kentcdodds/x/migrate-from-values |
| sidecar/source | kody:@kentcdodds/x/sidecar/source |
| media-upload-guide | kody:@kentcdodds/x/media-upload-guide |
| upload-media | kody:@kentcdodds/x/upload-media |
Prefer static kody:@kentcdodds/x/... imports from execute. Do not lead with
packages.invoke.
Prefer post helpers over raw request
- Prefer
get-post/get-post-thread/summarize-post-metricsover rawrequestloops for status URLs and engagement tables. - Never WebFetch x.com / twitter.com status pages — they return 403.
- After ~6 rapid
api.x.comcalls the host may return Service Unavailable — batch via helpers and keep pages small. get-postexpands referenced posts and shapesquotedPost(andrepliedToPost/retweetedPost) when includes are present. SetincludeQuotePosts: trueonly when you need the quote_tweets list (GET /2/tweets/:id/quote_tweets, default max 10, hard cap 25) — rate-limited.get-user-posts: passoriginalsOnly: truefor exclude=retweets,replies plus residualRT @/ leading-@cleanup;textprefersnote_tweet.text.- Params:
postId(nottweetId),postFields(maps totweet.fields).
Follows / new followers
Not in public_metrics. Do not scrape the X Analytics UI. Do not silently
omit follows from tables that claim to include follow rates —
summarize-post-metrics always sets followsPer1k: null with
unavailableReason: 'not_in_public_metrics'. Elevated Analytics API is out of
scope unless already wired.
Smoke: get-post + summarize (known Kent post)
import getUserByUsername from 'kody:@kentcdodds/x/get-user-by-username'
import getUserPosts from 'kody:@kentcdodds/x/get-user-posts'
import getPost from 'kody:@kentcdodds/x/get-post'
import summarizePostMetrics from 'kody:@kentcdodds/x/summarize-post-metrics'
export default async function main() {
const user = await getUserByUsername({ username: 'kentcdodds' })
const list = await getUserPosts({
id: user.data.id,
maxResults: 5,
originalsOnly: true,
})
const id = list.data[0].id
const post = await getPost({ id })
const table = await summarizePostMetrics({
posts: list.data,
sort: 'likesPer1k',
})
return {
postId: id,
textPreview: String(post.data?.text || '').slice(0, 120),
hasMetrics: Boolean(post.data?.public_metrics),
quotedPost: Boolean(post.data?.quotedPost),
rowCount: table.count,
followsPer1k: table.rows?.[0]?.followsPer1k ?? null,
}
}Smoke test (read-only)
import smokeTest from 'kody:@kentcdodds/x/smoke-test'
export default async function main() {
return await smokeTest()
// => { ok: true, bearer: { user: { id, username } } }
// HTTP 429 counts as auth-plumbing-verified
}Prefer a single bearer read when smoking the free tier.
import getMe from 'kody:@kentcdodds/x/get-me'
export default async function main() {
return await getMe({ account: 'kodykoala' })
}dryRun / confirm (writes)
Write helpers return a dry-run payload unless confirm: true after explicit
user approval:
import createPost from 'kody:@kentcdodds/x/create-post'
export default async function main() {
return await createPost({
account: 'kodykoala',
text: 'Hello from Kody',
dryRun: true,
})
// => { dryRun: true, mode: 'standalone', method: 'POST', url: '...', requiresConfirm: true }
}Replies (do not use bare replyTo without checking mode)
X expects reply: { in_reply_to_tweet_id: '<parentId>' }. create-post also
maps aliases replyTo / inReplyTo / inReplyToTweetId / parentId /
parentTweetId into that shape, then rejects other unknown keys (no silent
drop; unknown keys suggest nearest aliases). Dry-run / confirm results
include mode: 'reply' | 'standalone' plus inReplyToTweetId / parentId
when replying. If the user asked for a reply, dry-run first and confirm
mode: 'reply' before confirm: true. Prefer ./reply-post when a parent id
is required (it refuses without one).
Do not start reply text with reply-chain @handles. X auto-prefixes those
mentions; leading @name tokens are stripped before send. Dry-run / confirm
results include originalText, text (after strip), and strippedHandles.
Exclude auto-mentions: pass reply.exclude_reply_user_ids: string[] (X API)
or top-level excludeReplyUserIds / exclude_reply_user_ids when someone asked
not to be mentioned, or a follow-up should not ping everyone in the reply chain.
Dry-run / confirm echo those ids (and forward them on reply in the API body).
import createPost from 'kody:@kentcdodds/x/create-post'
import replyPost from 'kody:@kentcdodds/x/reply-post'
export default async function main() {
const viaCanonical = await createPost({
account: 'kodykoala',
text: 'Thanks!',
reply: {
in_reply_to_tweet_id: '2103670212524380386',
exclude_reply_user_ids: ['2049113393231978496'],
},
dryRun: true,
})
// => { dryRun: true, mode: 'reply', excludeReplyUserIds: ['…'], ... }
const viaAlias = await createPost({
account: 'kodykoala',
text: 'Thanks!',
replyTo: '2103670212524380386',
dryRun: true,
})
// same mapped body + mode: 'reply'
return await replyPost({
account: 'kodykoala',
text: 'Thanks!',
inReplyToTweetId: '2103670212524380386',
excludeReplyUserIds: ['2049113393231978496'],
dryRun: true,
})
}Same guard for delete/like/unlike/repost/unrepost/bookmark/remove-bookmark/
follow/unfollow, send-dm, send-chat, upload-media, and non-GET request.
Media upload (./upload-media)
Chunked X API v2 upload on api.x.com (not upload.x.com). Requires OAuth
media.write. Dry-run by default; confirm: true to upload. Returns
{ mediaId, mediaKey, state } for create-post media.media_ids.
import uploadMedia from 'kody:@kentcdodds/x/upload-media'
import createPost from 'kody:@kentcdodds/x/create-post'
export default async function main() {
const preview = await uploadMedia({
account: 'kodykoala',
url: 'https://example.com/tiny.jpg',
mediaType: 'image/jpeg',
dryRun: true,
})
// => { dryRun: true, action: 'upload-media', requiresConfirm: true, steps: [...], ... }
// After confirm:true upload:
// const { mediaId } = await uploadMedia({ ..., confirm: true })
// await createPost({ text: '...', media: { media_ids: [mediaId] }, dryRun: true })
return preview
}See also ./media-upload-guide for the initialize/append/finalize/STATUS steps.
Idempotent DM / Chat sends
Pass a stable idempotencyKey on send-dm / send-chat for each logical
message. Same (account, idempotencyKey) within 7 days returns the prior
successful send result from package storage instead of sending again. Use a new
key for intentional follow-ups. dryRun and unconfirmed calls do not write
fingerprints.
import sendDm from 'kody:@kentcdodds/x/send-dm'
export default async function main() {
return await sendDm({
account: 'kodykoala',
username: 'kentcdodds',
text: 'Hello!',
dryRun: true,
idempotencyKey: 'outreach-2026-09-11:kentcdodds:hello',
})
}import sendChat from 'kody:@kentcdodds/x/send-chat'
export default async function main() {
return await sendChat({
username: 'kentcdodds',
text: 'Hello!',
dryRun: true,
idempotencyKey: 'chat-2026-09-11:kentcdodds:hello',
})
}Encrypted X Chat vs legacy DMs
Legacy /dm_events routes often look empty after X Chat encryption upgrades.
For live 1-1 threads use Chat helpers (ciphertext on api.x.com, decrypt on
your Fly sidecar). Other packages should import these helpers — never call
the sidecar directly.
import listChatConversations from 'kody:@kentcdodds/x/list-chat-conversations'
import getChatConversation from 'kody:@kentcdodds/x/get-chat-conversation'
import sendChat from 'kody:@kentcdodds/x/send-chat'
export default async function main() {
const inbox = await listChatConversations({ maxResults: 5 })
const thread = await getChatConversation({
conversationId: inbox.data[0].id,
maxResults: 10,
})
return await sendChat({
conversationId: inbox.data[0].id,
text: 'Hello!',
dryRun: true,
})
}Legacy DM reads/sends (OAuth dm.read / dm.write only; no sidecar):
import listDmEvents from 'kody:@kentcdodds/x/list-dm-events'
import getDmConversation from 'kody:@kentcdodds/x/get-dm-conversation'
import sendDm from 'kody:@kentcdodds/x/send-dm'
export default async function main() {
const recent = await listDmEvents({ maxResults: 10 })
const withUser = await getDmConversation({ username: 'kentcdodds' })
return await sendDm({ username: 'kentcdodds', text: 'Hello!', dryRun: true })
}send-dm / send-chat parse inputs with Remix Schema (unknownKeys: 'error'). Target exactly one of: conversationId (aliases dm_conversation_id / conversation_id), participantId (aliases userId / to / recipient / recipientId — non-numeric values map to username), username, or (DM only) participantIds (group). Text aliases: message / body / content. Dry-run / confirm results echo targetMode plus the resolved target fields. Unknown keys are rejected with did-you-mean hints.
Fork adapt (community copies)
import adapt from 'kody:@kentcdodds/x/adapt'
export default async function main() {
return await adapt()
}- Rewrite README
## Intentfor the forking user. - Connect OAuth
xorx-<purpose>. - Deploy sidecar:
import sidecarSource from 'kody:@you/x/sidecar/source'thensidecarSource({ app: 'your-x-chat' }). Set Fly envSIDECAR_TOKEN. Do not use the listing author’s Fly app. - After publish:
migrate-from-valueswith{ sidecarUrl: 'https://your-x-chat.fly.dev' }(package storage wins over owner-config default). - Save
xChatPin/xChatSidecarTokenwith your sidecar host approved — use prefilled URLs fromadapt; never ask the user to paste the PIN in chat. - Smoke
list-chat-conversationsthenget-chat-conversation; keepsend-chatdry-run unless the user confirms a send. - Publish;
community_publishif listing. Share/@username/x, not/community/{listingId}.
Edge cases
- Account resolution: omit →
x;account: 'purpose'→x-purpose; bareaccountvalues already starting withx-are used as-is;integrationwins when set. - Free-tier bearer is heavily rate-limited; treat 429 as plumbing-ok for smoke.
- Never generate or register a new X Chat keypair — unlock with
xChatPin. - Always prefer dry-run before any live write; smoke is not permission to post or DM.
- v3 breaking: old
*-tweetexport paths are removed; use*-post.
Activity → Pam (moved)
Use @kentcdodds/kodykoala-activity for mint webhook, subscriptions, ignore list,
event log, and Pam wake. Example:
import handle from 'kody:@kentcdodds/kodykoala-activity/handle-activity-event'
import subscribe from 'kody:@kentcdodds/kodykoala-activity/subscribe-activity'Do not call removed kody:@kentcdodds/x/handle-activity-event (deleted in 3.9).
Input schema (like/bookmark/follow/repost/delete-post)
./like-post, ./unlike-post, ./repost-post, ./unrepost-post,
./bookmark-post, ./remove-bookmark, and ./delete-post map post id aliases
(postId / tweetId / statusId / id / url / postUrl / tweetUrl /
statusUrl via parsePostId) and reject unknown keys.
./follow-user / ./unfollow-user map targetUserId aliases (target_user_id /
userId / id) and username (handle / screenName); username resolves via
getUserByUsername when needed.