Skip to content
← Public packages

@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

KindNameNotes
User secretxBearerTokenApp-only bearer reads
OAuth integrationxDefault user-context
OAuth integrationx-<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

ExportImport
overviewkody:@kentcdodds/x
accountskody:@kentcdodds/x/accounts
smoke-testkody:@kentcdodds/x/smoke-test
requestkody:@kentcdodds/x/request
refresh-oauth-tokenkody:@kentcdodds/x/refresh-oauth-token
get-mekody:@kentcdodds/x/get-me
get-user-by-usernamekody:@kentcdodds/x/get-user-by-username
search-recentkody:@kentcdodds/x/search-recent
get-postkody:@kentcdodds/x/get-post
get-post-threadkody:@kentcdodds/x/get-post-thread
get-user-postskody:@kentcdodds/x/get-user-posts
summarize-post-metricskody:@kentcdodds/x/summarize-post-metrics
create-postkody:@kentcdodds/x/create-post
reply-postkody:@kentcdodds/x/reply-post
delete-postkody:@kentcdodds/x/delete-post
like-post / unlike-postkody:@kentcdodds/x/like-post, .../unlike-post
repost-post / unrepost-postkody:@kentcdodds/x/repost-post, .../unrepost-post
bookmark-post / remove-bookmarkkody:@kentcdodds/x/bookmark-post, .../remove-bookmark
follow-user / unfollow-userkody:@kentcdodds/x/follow-user, .../unfollow-user
send-dmkody:@kentcdodds/x/send-dm
list-dm-eventskody:@kentcdodds/x/list-dm-events
get-dm-conversationkody:@kentcdodds/x/get-dm-conversation
list-chat-conversationskody:@kentcdodds/x/list-chat-conversations
get-chat-conversationkody:@kentcdodds/x/get-chat-conversation
send-chatkody:@kentcdodds/x/send-chat
adaptkody:@kentcdodds/x/adapt
migrate-from-valueskody:@kentcdodds/x/migrate-from-values
sidecar/sourcekody:@kentcdodds/x/sidecar/source
media-upload-guidekody:@kentcdodds/x/media-upload-guide
upload-mediakody:@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-metrics over raw request loops for status URLs and engagement tables.
  • Never WebFetch x.com / twitter.com status pages — they return 403.
  • After ~6 rapid api.x.com calls the host may return Service Unavailable — batch via helpers and keep pages small.
  • get-post expands referenced posts and shapes quotedPost (and repliedToPost / retweetedPost) when includes are present. Set includeQuotePosts: true only when you need the quote_tweets list (GET /2/tweets/:id/quote_tweets, default max 10, hard cap 25) — rate-limited.
  • get-user-posts: pass originalsOnly: true for exclude=retweets,replies plus residual RT @ / leading-@ cleanup; text prefers note_tweet.text.
  • Params: postId (not tweetId), postFields (maps to tweet.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()
}
  1. Rewrite README ## Intent for the forking user.
  2. Connect OAuth x or x-<purpose>.
  3. Deploy sidecar: import sidecarSource from 'kody:@you/x/sidecar/source' then sidecarSource({ app: 'your-x-chat' }). Set Fly env SIDECAR_TOKEN. Do not use the listing author’s Fly app.
  4. After publish: migrate-from-values with { sidecarUrl: 'https://your-x-chat.fly.dev' } (package storage wins over owner-config default).
  5. Save xChatPin / xChatSidecarToken with your sidecar host approved — use prefilled URLs from adapt; never ask the user to paste the PIN in chat.
  6. Smoke list-chat-conversations then get-chat-conversation; keep send-chat dry-run unless the user confirms a send.
  7. Publish; community_publish if listing. Share /@username/x, not /community/{listingId}.

Edge cases

  • Account resolution: omit → x; account: 'purpose' → x-purpose; bare account values already starting with x- are used as-is; integration wins 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 *-tweet export 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.