# crosspost: one post, many networks, one strategy per service

> crosspost is an Apache-2.0 JavaScript library and CLI that posts the same message to Bluesky, Mastodon, Twitter, LinkedIn, Discord, Telegram, dev.to, Nostr and Slack, with a strategy class per service and a Client that fans the post out to all of them. It suits a developer publishing from a script or a CI job, and it delegates the hard part, authentication, to each service's own credential model.

**humanwhocodes/crosspost** — A JavaScript utility for posting across multiple social networks at once

- Repository: https://github.com/humanwhocodes/crosspost
- Stars: 579 · Forks: 28
- Language: JavaScript
- License: Apache-2.0
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/humanwhocodes-crosspost

## What a strategy per service actually buys you

crosspost is described as a utility for posting across multiple social networks at once, by Nicholas C. Zakas. The API is split into two parts, and the split is the design.

The first part is the Client class, which can be used to post the same message across multiple services. The second part is a set of strategy implementations, one for each service: BlueskyStrategy, MastodonStrategy, TwitterStrategy, LinkedInStrategy, DiscordStrategy, DiscordWebhookStrategy, TelegramStrategy, DevtoStrategy and NostrStrategy, with the Nostr one noted as requiring Node.js v22 or later.

A strategy is the adapter for one network's authentication and its particular posting call. Each strategy requires its own parameters, specific to the service, and the README states that if you only want to post to a particular service you can just directly use the strategy for that service.

That last sentence is the part that makes the library coherent. The Client is a convenience for the common case, not a requirement. A developer who wants to post to only Mastodon can construct MastodonStrategy and call it, and never touch the fan-out machinery.

The credentials differ enough between services that the strategy pattern is not ceremony. Bluesky wants an identifier, a password and a host, with the note that you should use an app password and not your login password, and that you.social is one host while bsky.social is the one for most people. Mastodon wants an access token and a host. Twitter wants four OAuth values, an access token key, an access token secret, an API consumer key and an API consumer secret. LinkedIn wants an access token. Discord wants a bot token and a channel ID, the Discord webhook variant wants a webhook URL, Telegram wants a bot token and a chat ID, dev.to wants an API key, and Nostr wants a private key in hex or bech32 format plus a list of relays.

## One Client, nine strategies, and the per-service differences

The example in the README builds every strategy and then hands them to a single Client:

```js
// create a client that will post to all services
const client = new Client({
	strategies: [
		bluesky,
		mastodon,
		twitter,
		linkedin,
		discord,
		discordWebhook,
		telegram,
		devto,
		nostr,
	],
});
```

Once the client exists, posting to everything is one call with the message text. The images option carries up to four images, which must be PNG, JPEG or GIF, and each image is an object with the binary data as a Uint8Array and an alt text.

There is a second form of the images option worth knowing about. An image can carry a public URL as well as the data, and the README explains why: include a public URL for the image so strategies that support remote images, currently Dev.to, reference it instead of embedding the data. The URL must be http: or https:.

A third form is postTo, which posts to specific services rather than all of them. Each entry has its own message and a strategyId, which uses the strategy's id property, and an entry can carry its own images:

```js
// post to specific services using postTo
await client.postTo([
	{
		message: "Hello Twitter!",
		strategyId: "twitter", // Uses the strategy's id property
	},
	{
		message: "Hello Mastodon and Bluesky!",
		strategyId: "mastodon",
		images: [
			{
				data: imageData, // Uint8Array of image data
				alt: "Description of the image",
			},
		],
	},
]);
```

Note that the strategyId values here are lowercase service names, not class names, and that a postTo entry is bound to one service even when its message reads as if it addresses two, so the example's second entry goes only to Mastodon despite its text.

The other thing the API supports is an AbortSignal, passed through a signal option, which is how a caller cancels an in-flight fan-out.

## The CLI, and the option list that does not match the strategy list

crosspost also has a command line interface, and the stated reason is to allow for incorporation into CI systems. The usage block lists the options: --twitter and -t, --mastodon and -m, --bluesky and -b, --linkedin and -l, --discord and -d for posting via a bot, --discord-webhook, --devto, --telegram, --slack and -s, --nostr and -n, and --mcp to start an MCP server. There are also --file to read the message from a file, --image, --image-url, --image-alt, --help and --version.

The examples are short:

```shell
# Post a message to multiple services
npx @humanwhocodes/crosspost -t -m -b "Check out this beach!"
```

```shell
# Post a message with an image file to multiple services
npx @humanwhocodes/crosspost -t -m -b --image ./photo.jpg --image-alt "A beautiful sunset" "Check out this beach!"
```

Reading a message from a file uses the -f short form, and the image short form is -i:

```shell
# Post a message with an image to multiple services
npx @humanwhocodes/crosspost -t -m -b -f message.txt -i path/to/image.jpg
```

There is a discrepancy in the documentation that a reader will notice. The strategy list in the API section names nine strategies and Slack is not among them, while the CLI usage block offers a --slack and -s option, and the environment variable section lists a Slack entry with SLACK_TOKEN and SLACK_CHANNEL. The library is by Nicholas C. Zakas, who maintains both the API and the CLI, so the most likely explanation is that the README's strategy enumeration is out of date rather than that Slack was removed, but the visible documentation does not settle it, and a SlackStrategy is not shown in the import example. Anyone planning to use the Slack path should check the package exports rather than assume.

The image constraints are specified precisely: --image-url accepts only http: and https: URLs, downloads images up to 20MB, and cannot be combined with --image.

## Credentials come from the environment, not from the code

The CLI section lists the environment variables each strategy needs, and the pattern is uniform.

Twitter needs TWITTER_ACCESS_TOKEN_KEY, TWITTER_ACCESS_TOKEN_SECRET, TWITTER_API_CONSUMER_KEY and TWITTER_API_CONSUMER_SECRET. Mastodon needs MASTODON_ACCESS_TOKEN and MASTODON_HOST. Bluesky needs BLUESKY_HOST, BLUESKY_IDENTIFIER and BLUESKY_PASSWORD. LinkedIn needs LINKEDIN_ACCESS_TOKEN. Discord needs DISCORD_BOT_TOKEN and DISCORD_CHANNEL_ID, the Discord webhook path needs DISCORD_WEBHOOK_URL, dev.to needs DEVTO_API_KEY, Telegram needs TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID, Slack needs SLACK_TOKEN and SLACK_CHANNEL, and Nostr needs NOSTR_PRIVATE_KEY and NOSTR_RELAYS.

That is the part of the design that matters most in production. The credentials are read from the process environment, which means they come from your secret store, your CI secret configuration or your shell profile, and they never end up in the source file where the strategy objects are constructed. The README notes as a tip that you can load environment variables from a .env file, which is the usual pattern for local development and the reason the CLI is usable at all without a shell script in front of it.

The split between the code and the environment is clean. The code says which service and which parameters matter, the environment says what the secret is, and the two meet at the strategy constructor. That is the right way round for a tool whose whole purpose is to hold credentials for nine unrelated services.

The same care shows in the constructor comments, where each strategy records what kind of credential it needs, an OAuth app for Twitter, an app password for Bluesky, a bot token and channel ID for Discord, a private key and relays for Nostr. Those comments are what a reader uses to work out which service requires which kind of account setup before they start.

## The package build, and the MCP server mode

The package manifest describes a modern, small Node package. It is published as @humanwhocodes/crosspost at version 1.0.4, is an ES module with type module, exposes dist/index.js as both main and module, and ships type definitions alongside. The bin entry maps the crosspost command to dist/bin.js, and the exports map is import-only, so the package is ESM only and there is no CommonJS build to fall back to. The files field ships dist and nothing else, and publishConfig sets the access to public.

The engines field requires Node 20 or newer, which is a stricter floor than the 18 that many tools still claim, and the Nostr strategy needs Node.js v22 or later, so the effective requirement for that one network is higher than the package floor.

The toolchain is worth noting because it says what the maintainer expects of the project. TypeScript compiles the build with tsc, ESLint covers src/ and tests/, Prettier formats, and there is a gitHooks entry running lint-staged on pre-commit. The test setup is thorough for a small library: mocha for unit tests, a separate package build test that runs node tests/pkg.test.mjs, an attw check for package types with an esm-only profile, and a JSR dry-run publish test. There is a jsr.json at the repository root, so the package is also distributed to the JSR registry.

The MCP mode is the newest thing in the CLI. The --mcp flag starts an MCP server, and the scripts include an mcp:inspect target that builds and then runs the inspector against node dist/bin.js with the --mcp flag and a CROSSPOST_DOTENV environment variable set. That means the same binary that posts to social networks can also be driven as a tool by an agent, which is a natural fit for a package whose atomic operation is, send this text to these accounts.

Releases are managed with release-please, and the release tags show a steady cadence: v1.0.2 in November 2025, v1.0.3 in February 2026 and v1.0.4 in April 2026.

## Where crosspost is the wrong tool

The first limitation is that a post is a post. There is no per-service message tailoring, no truncation for a network with a shorter limit, no mention rewriting, no per-service hashtag behaviour. Every strategy receives the same text and the same images. For a developer whose network posts are deliberately different from each other, that is the whole job, and this library does not do it.

The second is that a failure is a failure. The README does not describe per-strategy error isolation, so a rejection from one network, a rate limit or an expired token takes down the call rather than degrading to a partial success. A tool that fans out to nine services needs a very good reason for one of them to be down, and the fact that a developer can call postTo with a subset means partial posting is possible, but only by choosing the subset up front rather than by reacting to a failure.

The third is scheduling. The stated motivation for the command line interface is incorporation into CI systems, which is the right instinct, but there is no scheduler, no queue, no retry policy and no history. If you want a post published on a schedule, you are writing the schedule.

The alternative worth comparing is writing the fan-out yourself against each API, which for two or three services is often less work than configuring nine credential sets. The difference in approach is that crosspost centralises the authentication plumbing, the image handling and the abort signal, all of which are the fiddly parts, while leaving the orchestration to you. If you are posting to three services, a dozen lines of fetch calls and a bit of environment variable reading may be all you need. If you are posting to all nine, or you want the MCP mode so an agent can post on your behalf, the library earns its place immediately, and the credential setup is the real cost of using it rather than any code you have to write.

## Conclusion

crosspost fits a developer who wants one message to reach several networks from a script, a static site build or a CI job, since the Client fans out across strategies and the CLI makes the same operation a one-liner. It is the wrong choice if you need scheduling, per-network message tailoring or a hosted dashboard, because the library does one thing and does it in one call, and it is the wrong choice if you cannot manage nine sets of API credentials. Before you adopt it, read the strategy list against the networks you actually use, remember that a failed post to one service is a failure you have to handle rather than a partial success you can ignore, and check the Node.js version requirement, since the Nostr strategy needs Node.js v22 or later while the package as a whole requires Node 20.

## FAQ

### What is @humanwhocodes/crosspost?

It is a JavaScript utility for posting across multiple social networks at once, built around a Client class and one strategy implementation per service, including Bluesky, Mastodon, Twitter, LinkedIn, Discord, Discord webhook, Telegram, dev.to and Nostr.

### How do I install crosspost and post from the command line?

Install it with npm install @humanwhocodes/crosspost, then run npx @humanwhocodes/crosspost -t -m -b "your message" to post to Twitter, Mastodon and Bluesky. Flags select the services, --image attaches a file, and -f reads the message from a file.

### Where does crosspost read API credentials from?

From environment variables, one set per service, such as TWITTER_ACCESS_TOKEN_KEY and MASTODON_ACCESS_TOKEN, BLUESKY_PASSWORD, DISCORD_BOT_TOKEN, TELEGRAM_BOT_TOKEN, DEVTO_API_KEY and NOSTR_PRIVATE_KEY. The README also notes you can load them from a .env file.

### Can crosspost post to only some of the networks?

Yes. Each strategy can be used on its own, and the postTo method takes an array of entries with a message and a strategyId, so you can target individual services with different messages and images.

### What licence is crosspost released under?

It is licensed under Apache 2.0, with the licence file at the repository root. The current version is 1.0.4, the package requires Node 20 or newer with the Nostr strategy needing Node.js v22 or later, and the last push was on 2026-10-14.

## Sources

- [humanwhocodes/crosspost on GitHub](https://github.com/humanwhocodes/crosspost)
- [Issues](https://github.com/humanwhocodes/crosspost/issues)
- [License: Apache-2.0](https://github.com/humanwhocodes/crosspost/blob/main/LICENSE)
- [README](https://github.com/humanwhocodes/crosspost/blob/main/README.md)
- [Releases](https://github.com/humanwhocodes/crosspost/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/humanwhocodes-crosspost
