Skip to content

feat: Add thread posting support - #157

Draft
nzakas with Copilot wants to merge 7 commits into
mainfrom
copilot/update-crosspost-to-support-threads
Draft

nzakas with Copilot wants to merge 7 commits into
mainfrom
copilot/update-crosspost-to-support-threads

Conversation

Copilot AI commented Jan 19, 2026 •

Copy link
Copy Markdown
Contributor

Adds thread posting, where each message replies to the previous one, to the library, the CLI, and the MCP server.

Behavior

Client.postThread(entries, { signal }) posts to every configured service:

Service How the thread is posted
Twitter/X reply.in_reply_to_tweet_id
Bluesky record.reply with root and parent refs
Mastodon in_reply_to_id
Nostr NIP-10 marked e tags (root / reply)
Telegram reply_parameters (an entry's images reply to its text)
Discord (bot) message_reference
Slack thread_ts of the first message
LinkedIn, Dev.to, Discord (webhook) These can't reply to posts, so they get one post with the messages separated by blank lines
  • Every entry is validated before anything is posted. An invalid entry throws a TypeError and nothing is posted.
  • If a thread stops partway through (API error or abort), the strategy throws a ThreadError whose responses hold the posts already published. Client.postThread() returns that as a FailureResponse with urls for those posts, so the partial thread can be linked to or cleaned up.
  • On success, SuccessResponse.response is an array with one response per post, url is the first post's URL, and urls has every URL. An error from getUrlFromResponse() no longer rejects the whole call.
  • ThreadError is exported from the package.

CLI

# one post per argument
npx @humanwhocodes/crosspost -t -m -b --thread "First post 🧵" "Second post" "The end!"

# or from a file, with posts separated by lines containing only ---
npx @humanwhocodes/crosspost -t -m -b --thread --file thread.txt

An --image or --image-url image is attached to the first post. If a thread stops partway through, the CLI prints the URLs of the posts that were published.

MCP

A new post-thread-to-social-media tool takes messages and an optional strategyIds. Its description lists which services post real replies.

Changes since the original draft

  • Rebased onto main. The unrelated package-lock.json churn and the removal of edit_history_tweet_ids are gone. That field is now optional because the X client library doesn't type it.
  • Fixed Client.postThread() rejecting for every real strategy. It had passed the array of responses to getUrlFromResponse().
  • Replaced the fallback that published one post per entry. That created N Dev.to articles or N unrelated LinkedIn posts.
  • Added native support for Nostr, Slack, Telegram, and Discord.
  • Shared the validate-then-post loop in src/util/threads.js.

Testing

  • npm test passes: 402 passing, 2 pending (the same 2 as main).
  • Each thread-capable strategy has tests that check its reply field on requests 2+, that an invalid entry posts nothing, and (for Twitter, Bluesky, and Mastodon) that a failure partway through returns a ThreadError with the published posts.
  • New tests cover the thread helpers, Client.postThread(), the MCP tool, and the CLI --thread errors.

Threads have not been posted to the real services, only against mocked APIs.

🤖 Generated with Claude Code

https://claude.ai/code/session_013NkESLBzNRvDYxNgSisFeZ

Copilot AI changed the title [WIP] Update Crosspost to support posting to threads Add thread posting support across social media platforms Jan 19, 2026
Copilot AI requested a review from nzakas January 19, 2026 19:37

@nzakas nzakas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot there's some duplication between the postThread and post methods on strategies. Clean it up so we're not duplicating logic.

Copilot AI commented Jan 23, 2026

Copy link
Copy Markdown
Contributor Author

@copilot there's some duplication between the postThread and post methods on strategies. Clean it up so we're not duplicating logic.

Refactored all three strategies to eliminate duplication. Each strategy now uses private helper methods that contain the shared logic:

  • Twitter: #postTweet() helper handles posting with optional reply info
  • Mastodon: #postStatus() helper handles posting with optional reply info
  • Bluesky: postMessage() helper extended to accept optional reply info

The public post() API remains unchanged. Both post() and postThread() delegate to these helpers, eliminating all code duplication (97 lines removed).

Commit: 52ae9fc

Copilot AI requested a review from nzakas January 23, 2026 00:04

@nzakas nzakas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the thread support, requested by @nzakas. The reply mechanics for the three native strategies look right: Bluesky sets record.reply with root and parent {uri, cid} from the createRecord responses, Mastodon sends in_reply_to_id, and X sends reply.in_reply_to_tweet_id. Images attach per entry and the abort signal reaches every request. The problems are mostly in how Client.postThread() handles results and failures. Roughly by severity:

  1. Client.postThread() rejects for every real strategy that has getUrlFromResponse() (inline, src/client.js). The strategy result is now an array, but it's passed straight to getUrlFromResponse(), which expects a single post. Twitter, Bluesky, and Mastodon all throw on an array, and the throw happens outside Promise.allSettled(). So the thread gets posted, then the whole call rejects and every other strategy's result is lost too. I reproduced this with the PR's client.js and Twitter's getUrlFromResponse(): it rejects with Tweet ID not found in response. The client test for this uses a mock postThread() that returns a plain object, which hides the bug.
  2. Partial failure loses the posts that already went out. If post N fails, the posts before it are live, but the FailureResponse holds only the error. The caller can't find, link, or clean up the half-posted thread. This applies to the native postThread() methods and the client fallback, and aborting mid-thread has the same effect.
  3. Entries are validated inside the posting loop instead of before it. A bad entry at position 3 is found only after entries 1-2 are published. validatePostOptions() / Mastodon's image checks are also skipped for thread entries, so a bad image fails partway through the upload.
  4. The fallback of posting each entry separately does damage on some platforms. On Dev.to each entry becomes its own published article, titled with its first line. On LinkedIn it makes N separate feed posts. Also, several platforms that get the fallback do support replies: Nostr (NIP-10 e tags), Slack (thread_ts), Telegram (reply_parameters), and Discord (message_reference, already in the webhook typedefs). The original request was to add postThread() wherever threads are native, so these could get real implementations, or at least a fallback that doesn't publish N unrelated posts.
  5. Test coverage: no strategy-level tests for postThread() in tests/strategies/{twitter,bluesky,mastodon}.test.js. Nothing checks that the second request includes in_reply_to_id, reply.in_reply_to_tweet_id, or record.reply.root/parent, which is where the platform-specific bugs would be.

Not in the diff:

  • No CLI, MCP, or README changes. The PR is titled "across social media platforms", but there's no way to post a thread from crosspost or the MCP server, and the new Client#postThread() / Strategy#postThread API isn't documented. The CLI will need to decide how to split input into posts (e.g. a delimiter line such as ---) and whether to check each entry against each strategy's MAX_MESSAGE_LENGTH/calculateMessageLength() before posting anything. Nothing checks length now, so one entry over the limit fails the thread partway through.
  • Branch state: 6 commits behind main. That includes #159 (Bluesky aspect ratio, which touches postMessage()), the LinkedIn API update, and the MCP SDK upgrade. It merges cleanly, but no CI checks have run on this branch (mergeStateStatus: UNSTABLE, "no checks reported"), so a rebase or merge from main and a CI run would help.
  • Unrelated churn: the package-lock.json "peer": true removals, Prettier reformatting of src/bin.js, tests/bin.test.js, and tests/strategies/bluesky.test.js, and removal of the data.edit_history_tweet_ids property from TwitterPostResponse (X still returns that field). These would be better dropped or split out.

On backward compatibility, post() and postTo() are unchanged and postThread is optional on Strategy, so existing callers are fine. The one contract change for third-party strategies is item 1: getUrlFromResponse() now gets whatever postThread() returns.

Comment thread src/client.js Outdated
return new SuccessResponse(
this.#strategies[i].name,
result.value,
this.#strategies[i].getUrlFromResponse?.(result.value),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: result.value here is an array, from native postThread() or from the fallback loop, but every built-in getUrlFromResponse() expects a single post response:

  • Twitter: response?.data?.id is undefined, so it throws Tweet ID not found in response
  • Bluesky and Mastodon: response?.uri is undefined, so it throws Post URI not found in response

This runs inside the .map() after Promise.allSettled(), so the throw rejects the whole postThread() call after the thread is already live, and the other strategies' results are thrown away too. I reproduced it with this file and Twitter's getUrlFromResponse().

Suggestion: build the URLs per post, e.g. url from value[0] (the thread root) plus a urls array with one entry per post. Also wrap the call so a URL failure can't turn a successful post into a rejection. Then SuccessResponse.response is always an array for threads, which is worth documenting.

Comment thread src/client.js Outdated
});
responses.push(response);
}
return responses;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial failure: if strategy.post() throws on entry N, the posts for entries 0..N-1 are already published, but responses is dropped and the caller gets a FailureResponse with only the error. I confirmed this: with a strategy that fails on the 2nd of 3 entries, the result is [{ ok: false, reason: "boom" }] and there's no record of the first post. Native postThread() implementations lose data the same way, and so does an abort mid-thread.

A thread-level failure needs to carry what was posted. For example, a ThreadError with .responses (and .urls) that both the fallback and native implementations throw, surfaced on the FailureResponse. That way users can link to or delete the partial thread.

Comment thread src/client.js
this.#strategies.map(async strategy => {
// If strategy has native postThread support, use it
if (strategy.postThread) {
return strategy.postThread(entries, postOptions);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About the fallback on the next few lines: calling post() once per entry is harmful for some strategies. DevtoStrategy.post() creates a published article titled with the message's first line, so a 5-entry thread becomes 5 Dev.to articles. LinkedinStrategy makes 5 unrelated feed posts.

Also, several fallback strategies do support replies natively and could implement postThread(): Nostr (NIP-10 ["e", rootId, relay, "root"] / "reply" tags), Slack (thread_ts), Telegram (reply_parameters.message_id), and Discord (message_reference). For the rest, consider making the fallback opt-in, or posting only the first entry (or the entries joined together) rather than N standalone posts.

Comment thread src/strategies/twitter.js Outdated
let previousTweetId;

for (const entry of entries) {
if (!entry.message) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entries are validated inside the posting loop, so if entry 3 has no message, tweets 1-2 are already posted when the TypeError is thrown. Also, post() calls validatePostOptions() but this path never does, so bad images (not an array, or non-Uint8Array data) are found only when the upload fails partway through the thread.

Suggest validating every entry (message plus validatePostOptions({ images })) in a loop before posting anything. The same applies to the Bluesky and Mastodon postThread() implementations, and ideally to Client.postThread() as well.

Comment thread src/strategies/bluesky.js Outdated
let previousPost;

for (const entry of entries) {
if (!entry.message) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as in twitter.js: validation happens mid-loop, after earlier entries are already posted, and validatePostOptions() (which post() calls) is skipped for thread entries. Suggest validating all entries up front. The reply refs themselves look right: root = first response, parent = previous response, both {uri, cid}.

Comment thread src/strategies/mastodon.js Outdated
let previousStatusId;

for (const entry of entries) {
if (!entry.message) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as in twitter.js. Also, the refactor moved the image validation into post() only, so postThread() passes images to #postStatus() without any checks. Moving that validation into a shared helper, or into #postStatus(), and running it for every entry before the first status is posted would fix both.

Comment thread tests/client.test.js Outdated
name: "Strategy",
id: "strategy1",
postThread() {
return Promise.resolve({ id: "123" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mock returns a single object, but real postThread() implementations (and the fallback) return an array. That's why this test passes even though Client.postThread() rejects with the real Twitter, Bluesky, and Mastodon getUrlFromResponse() (see the comment on src/client.js). Please make the mock return an array.

There are also no postThread() tests in tests/strategies/{twitter,bluesky,mastodon}.test.js. They should check that request 2+ carries reply.in_reply_to_tweet_id, in_reply_to_id, and record.reply.root/parent with the right ids, and cover a failure partway through a thread.

Copilot AI and others added 7 commits September 10, 2026 14:48
…stodon)

Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com>
Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com>
Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com>
Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com>
Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com>
- Add src/util/threads.js with ThreadError plus helpers to validate,
  post, join, and split thread entries
- Validate every entry before posting anything
- Throw a ThreadError with the published responses when a thread stops
  partway through, and expose their URLs on FailureResponse
- Post threads natively on Nostr (NIP-10), Slack (thread_ts), Telegram
  (reply_parameters), and Discord bot (message_reference), in addition
  to Twitter, Bluesky, and Mastodon
- Post the thread as a single message on services that can't reply
  (LinkedIn, Dev.to, Discord webhook) instead of separate posts
- Return the URL of every post from Client.postThread(), and don't let
  a getUrlFromResponse() error reject the call
- Add --thread to the CLI (one post per argument, or --- separators)
- Add a post-thread-to-social-media tool to the MCP server
- Document threads in the README

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NkESLBzNRvDYxNgSisFeZ
@nzakas
nzakas force-pushed the copilot/update-crosspost-to-support-threads branch from 52ae9fc to 4f851d2 Compare September 10, 2026 19:05
@nzakas nzakas changed the title Add thread posting support across social media platforms feat: Add thread posting support Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants