@chirpie/sdk
One typed client for posting to X, Bluesky, LinkedIn, Mastodon and Telegram, with Threads, Instagram and Facebook coming soon. Chirpie handles the OAuth, the token refresh, the media upload and the per-platform quirks, so posting to every network is one method rather than one SDK per network.
Threads, scheduling, drafts, deletion and analytics are all in here too, and the same account you post from works identically through the CLI, the MCP server, and the REST API.
Install
npm install @chirpie/sdk
Quick start
import { ChirpieClient } from "@chirpie/sdk";
const chirpie = new ChirpieClient({ apiKey: process.env.CHIRPIE_API_KEY! });
const accounts = await chirpie.listAccounts();
const post = await chirpie.createPost({
account_id: accounts[0].id,
text: "Shipped a new release.",
});
console.log(post.status, post.platform_post_url);
Get an API key from the dashboard, or run npx chirpie login, which saves one to ~/.chirpie/config.json.
Configuration
const chirpie = new ChirpieClient({
apiKey: "chirpie_sk_...",
baseUrl: "https://chirpie.ai",
});
Or pick up credentials the CLI already saved:
import { ChirpieClient, requireConfig } from "@chirpie/sdk";
const config = requireConfig();
const chirpie = new ChirpieClient({ apiKey: config.api_key, baseUrl: config.base_url });
CHIRPIE_API_KEY takes precedence over the config file. CHIRPIE_BASE_URL overrides the base URL.
Posts
await chirpie.createPost({ account_id, text: "Hello" });
await chirpie.createPost({ account_id, text: "Later", schedule_at: "2026-04-01T14:00:00Z" });
await chirpie.createPost({
account_id,
text: "Half nine, my time",
schedule_at: "2026-11-01T09:30:00",
timezone: "America/New_York",
});
await chirpie.createPost(
{ account_id, text: "Hello" },
{ idempotencyKey: "campaign-2026-11-01" }
);
await chirpie.createPost({ account_id, text: "Look", media_urls: ["https://example.com/a.png"] });
const media = await chirpie.uploadMedia({ data: bytes, filename: "shot.png" });
await chirpie.createPost({
account_id,
text: "Look",
media: [{ id: media.id, alt: "The new dashboard" }],
});
const posts = await chirpie.listPosts({ status: "published", limit: 20, offset: 0 });
const one = await chirpie.getPost(posts[0].id);
const [queued] = await chirpie.listPosts({ status: "scheduled", limit: 1 });
await chirpie.updatePost(queued.id, { text: "Now with the typo fixed" });
await chirpie.updatePost(queued.id, { schedule_at: "2027-04-02T09:00:00Z" });
await chirpie.updatePost(queued.id, { first_comment: "Full write-up: https://example.com" });
const { cancelled_ids } = await chirpie.deletePost(one.id);
await chirpie.hidePost(one.id);
await chirpie.unhidePost(one.id);
await chirpie.listPosts({ include_hidden: true });
A thread is atomic: if any part fails, every part that had already published is
deleted from the platform and the whole thread's quota is refunded. Read what
happened with ChirpieApiError.threadRollback(). upstream_error means nothing
is left on the platform and a retry is safe; thread_rollback_incomplete means
the posts in still_live are really still up, so a retry would publish them
twice.
The request field is schedule_at; the response field is scheduled_at. Sending scheduled_at back is rejected rather than published immediately.
first_comment publishes one comment under the post the moment it goes out, the
"link in the first comment" pattern. X, Threads, Instagram and Facebook only:
anywhere else the call throws 400 first_comment_unsupported rather than
dropping it. It counts as one post against your monthly quota.
const post = await chirpie.createPost({
account_id,
text: "We rebuilt scheduling this week.",
first_comment: "Full write-up: https://example.com/blog/scheduling",
});
post.first_comment;
if (post.first_comment?.status === "failed") {
await chirpie.retryFirstComment(post.id);
}
createThread takes first_comment too: one comment for the whole thread,
published under the last part and reported on that part.
On a fan-out the shared first_comment reaches every account unless its
account_configurations entry says otherwise. An entry naming a
first_comment replaces it for that account, and first_comment: "" publishes
that account with none, which is how one call sends a first comment to the
accounts that take one while an account whose platform has none still publishes
the post.
Stories, reels and other publishing options
configuration says how a platform that publishes more than one way should
publish this post. Instagram takes a feed post, a story or a reel, and a
Facebook Page takes a feed post or a story. Leave it out and everything
goes to the feed. Instagram and Facebook are coming soon.
await chirpie.createPost({
account_id: instagram_account,
text: "Three minutes on how we schedule posts.",
media: [{ id: video_id }],
configuration: {
instagram: {
placement: "reel",
video_cover_timestamp_ms: 1500,
collaborators: ["a_co_author"],
share_to_feed: true,
},
},
});
await chirpie.createPost({
account_id: instagram_account,
text: "",
media: [{ id: image_id }],
configuration: { instagram: { placement: "story" } },
});
await chirpie.createPost({
account_id: facebook_page,
text: "The write-up is out.",
configuration: { facebook: { link: "https://example.com/blog/scheduling" } },
});
Nothing is dropped quietly. A block keyed on a platform that takes no options,
or a field the placement you chose does not carry, throws
400 configuration_unsupported naming the platform and the field.
Each placement carries its own options:
instagram feed: up to 10 images, collaborators (at most 3 usernames)
and user_tags, each of which needs both x and y.
instagram story: exactly one image or video, no caption (send empty
text), no first comment and no collaborators. user_tags may carry
coordinates or leave them out.
instagram reel: exactly one video and no images, plus collaborators,
user_tags (the username on its own, since Instagram reads coordinates only
on images and stories), cover or video_cover_timestamp_ms but never both,
share_to_feed and trial_reel.
facebook feed: a link, shown as a preview.
facebook story: exactly one image or video, no text, no first comment
and no link.
A story and a reel are each a single post, so createThread refuses either
placement. On a fan-out one block serves every account of that platform, and an
account_configurations entry naming its own configuration replaces it for
that account. On updatePost an absent configuration keeps the options the
post already has, and configuration: {} puts it back to a plain feed post.
Several accounts in one call
Name account_ids instead of account_id to publish the same post to up to 25 accounts at once.
const { group_id, results } = await chirpie.createPost({
account_ids: [x_account, bsky_account, linkedin_account],
text: "Shared text",
account_configurations: {
[bsky_account]: { text: "Shorter, for Bluesky", media: [] },
},
});
for (const result of results) {
if (!result.success) console.error(result.account_id, result.error?.message);
}
const posts = await chirpie.listPosts({ group_id });
results carries one entry per account, in the order they were named. The call resolves even when some accounts failed, so check success on each: the accounts that worked stay published. Anything that fails the request as a whole (a character limit, a media rule, a spent quota) throws as usual and nothing is published. Each account may be named once.
An override says only what differs: an absent field inherits the request's own, and naming any media field replaces the shared media for that account outright. Every post carries the group_id it belongs to, or null when it went to a single account.
Threads work the same way, with posts replacing the whole thread for one account:
await chirpie.createThread({
account_ids: [x_account, bsky_account],
posts: [{ text: "One" }, { text: "Two" }],
account_configurations: {
[bsky_account]: { posts: [{ text: "A" }, { text: "B" }, { text: "C" }] },
},
});
Threads
const thread = await chirpie.createThread({
account_id,
posts: [{ text: "One" }, { text: "Two" }, { text: "Three" }],
schedule_at: "2026-04-01T14:00:00Z",
timezone: "America/New_York",
});
2 to 25 posts. X, Bluesky, Threads, Mastodon and Telegram publish them as a native reply chain; LinkedIn, Instagram and Facebook publish each item as a standalone post. A thread counts as N posts against your quota.
Drafts
draft: true saves a post or a thread without sending it. Nothing reaches the platform, nothing is queued, and nothing counts against your quota until you promote it.
const draft = await chirpie.createPost({
account_id,
text: "Half an idea. Finish it later.",
draft: true,
});
for (const w of draft.warnings) console.log(w.platform, w.code, w.message);
await chirpie.createThread({ account_id, posts: [{ text: "Opening line" }], draft: true });
const drafts = await chirpie.listPosts({ status: "draft" });
await chirpie.updatePost(draft.id, { schedule_at: "2027-04-02T09:00:00Z", draft: true });
await chirpie.updatePost(draft.id, { schedule_at: "2027-04-02T09:00:00Z" });
await chirpie.updatePost(draft.id, { publish: true });
A draft is held to far less than a post: the text may be empty, the media may be missing, and a schedule_at on it is only the time you have in mind. Promotion runs every rule a create runs and spends the quota the draft never spent, so anything refused leaves the draft exactly as it was. The answer is a new post carrying promoted_from_draft_id, or promoted_from_draft_ids for a draft thread, which is promoted whole.
Accounts
const { accounts, accounts_limit, accounts_active } = await chirpie.listAccountsWithLimits();
const { authorization_url } = await chirpie.connectXAccount();
await chirpie.connectBlueskyAccount({ platform: "bluesky", identifier: "you.bsky.social", app_password: "xxxx-xxxx-xxxx-xxxx" });
await chirpie.connectTelegramAccount({ platform: "telegram", bot_token: "...", chat_id: "@yourchannel" });
const off = await chirpie.deactivateAccount(accounts[0].id);
await chirpie.activateAccount(accounts[1].id);
const gone = await chirpie.disconnectAccount(accounts[2].id);
console.log(gone.disconnected, gone.canceled_posts);
Also available: connectLinkedInAccount (pass "pages", or call connectLinkedInPagesAccount, for the LinkedIn Pages you administer: coming soon), connectMastodonAccount, and connectThreadsAccount, connectInstagramAccount and connectFacebookAccount (coming soon). connectInstagramAccount takes via: "instagram" (the default) signs in with Instagram, "facebook" signs in with Facebook and connects the Instagram accounts linked to the Pages the user shares, several at once, parking any beyond your plan's account limit with inactive_reason: "plan_limit". Both publish identically; only an account connected via Facebook can have a published post deleted from Chirpie. Each account reports its route as oauth_variant. Threads, Instagram, Facebook, Pinterest, TikTok, YouTube and Google Business Profile are coming soon; accounts already connected keep posting as normal.
Your own X developer app
Connect X accounts through your own X app so posts bill your X API credits, and X link posts are not surcharged. Full walkthrough: chirpie.ai/docs/x-byo-keys.
await chirpie.setXKeys({ client_id, client_secret, label: "Acme social app" });
await chirpie.getXKeysStatus();
await chirpie.removeXKeys();
Analytics and keys
const metrics = await chirpie.getPostAnalytics(post.id);
await chirpie.getPostAnalytics(post.id, { refresh: true });
const { key, expires_at } = await chirpie.createKey("My Bot");
await chirpie.createKey({ name: "Publishing bot", scopes: ["posts:write", "media:write"] });
await chirpie.listKeys();
await chirpie.revokeKey(id);
uploadMedia(input, options), retryFirstComment(id, options) and
replyToComment(postId, commentId, text, options) take the same
{ idempotencyKey } options object the create methods do, but generate nothing.
Errors
import { ChirpieApiError, ChirpieError } from "@chirpie/sdk";
try {
await chirpie.createPost({ account_id, text });
} catch (err) {
if (err instanceof ChirpieApiError) {
err.code;
err.status;
err.message;
} else if (err instanceof ChirpieError) {
err.message;
}
}
The full code list is at chirpie.ai/docs/errors.
Types
Every input and response type is exported, including ApiPost, ApiThread, ApiAccount, AccountList, ApiAnalytics, ApiKeyInfo, CreatePostInput, CreateThreadInput, UpdatePostInput, ApiFirstComment, ListPostsOptions, and the draft types DraftWarning, DraftPostResponse, DraftThreadResponse, FanOutDraftPostResponse and FanOutDraftThreadResponse.
Links
MIT