Skip to main content
Sign Up
Back to all posts

How to Build a Twitter Scheduler CLI with the PostPeer API

·Yoav Mendelson
twitter schedulerX/twitter apitypescriptclisdkmcpautomationtutorial

If you've ever looked at a social media scheduling dashboard and thought "I could replace this with a 200-line script and be happier," this guide is for you.

I built a tiny Twitter scheduler I run from my terminal every day. It uses the PostPeer API for the actual posting and scheduling, an AI SDK for drafting, and that's it. No dashboard subscription. No no-code platform. One TypeScript file I can read top to bottom and change whenever I feel like it.

This post walks through how to build the same thing yourself, even if you've never set up a TypeScript project before. By the end you'll have a working Twitter scheduler CLI that:

  • Reads your already-posted tweets so the AI doesn't repeat your voice.
  • Drafts tweets from a one-line description of your day, OR polishes a draft you wrote.
  • Picks a smart, well-spaced posting time.
  • Schedules everything through the PostPeer Twitter API.

You can fork this into a LinkedIn scheduler, a YouTube uploader, or a multi-platform cross-poster by changing a few lines. The PostPeer SDK is the same shape across every supported platform.

Why build your own instead of using a tool?

A few reasons people end up writing their own scheduler:

  1. You want full control over the tweet generation prompts (voice, format, what to avoid).
  2. You don't want a recurring SaaS bill for something you'd touch for 30 seconds a day.
  3. You want it wired into your own workflow (a cron job, a CI step, a Slack command, a keybinding).
  4. You like seeing every line of code that posts to your account.

If none of those matter to you, PostPeer's free Twitter post scheduler is fine. It's a UI, no code required. This guide is for the other group.

What you need before starting

You need three things:

  1. Node.js installed. Download it from nodejs.org (anything 20 or newer is fine). After installing, open a terminal and run node --version to confirm it's there.
  2. A PostPeer account with Twitter connected. Sign up at the PostPeer dashboard (20 free posts, no credit card). Connect your Twitter/X account in the integrations page. Then go to the API keys section and copy your access key.
  3. An AI provider key. This guide uses Google Gemini because it's cheap and fast for short structured output, but Claude or OpenAI work the same way with a one-line change. Grab a key from Google AI Studio (free tier is plenty for personal use).

You don't need to know TypeScript inside out. If you've ever opened a terminal and run npm install, you're good.

Step 1: Set up the project

Open a terminal and create a folder for the script:

mkdir tweet-cli
cd tweet-cli
npm init -y

npm init -y creates a package.json with default settings. Now install the dependencies:

npm install @postpeer/node @clack/prompts @ai-sdk/google ai zod
npm install -D typescript tsx @types/node

What each one does:

  • @postpeer/node is the PostPeer SDK. One package, every platform.
  • @clack/prompts makes the terminal UI (selects, text inputs, spinners).
  • @ai-sdk/google and ai are Vercel's AI SDK with the Gemini provider.
  • zod lets us force the AI to return data in a strict shape.
  • tsx runs TypeScript files directly so you don't need a build step.

Now create the script file:

touch tweet.ts

And add a .env file with your two keys:

POSTPEER_API_KEY=your_postpeer_key_here
AI_API_KEY=your_google_ai_key_here

To make npm start run the script and load the env file, edit package.json and add this script:

{
  "scripts": {
    "start": "tsx --env-file=.env tweet.ts"
  }
}

That's the whole setup. Now open tweet.ts and let's build it.

Step 2: Imports and SDK setup

At the top of tweet.ts:

import Postpeer from "@postpeer/node";
import * as p from "@clack/prompts";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { generateText, Output } from "ai";
import { z } from "zod";
 
const postpeer = new Postpeer({
  apiKey: process.env["POSTPEER_API_KEY"],
  baseURL: "https://api.postpeer.dev",
});
 
const google = createGoogleGenerativeAI({ apiKey: process.env["AI_API_KEY"] });
const model = google("gemini-2.5-flash");

The postpeer client is what we'll use for every API call. To swap Gemini for Claude or GPT later, you'd change those last two lines and the import.

Step 3: Load the connected account and recent tweets

Before we can schedule anything we need two things from PostPeer: the ID of your connected Twitter account, and your recent posts (so the AI knows your voice and doesn't repeat itself).

async function loadAccount() {
  const s = p.spinner();
  s.start("Loading your account & recent posts");
  const [{ integrations }, { posts }] = await Promise.all([
    postpeer.connect.integrations.list(),
    postpeer.posts.list({ platform: ["twitter"], limit: 20, sort: "desc" }),
  ]);
  const twitter = integrations.find((i) => i.platform === "twitter");
  if (!twitter) {
    s.stop("No Twitter account connected");
    p.outro("Connect Twitter in PostPeer first.");
    return null;
  }
  s.stop(`Connected: ${twitter.displayName ?? twitter.id}`);
  return {
    twitter,
    recentPosts: posts.map((post) => post.content).filter(Boolean),
  };
}

postpeer.connect.integrations.list() returns every social account you've connected. We filter for Twitter. postpeer.posts.list() returns your recent posts on that platform.

Both calls run in parallel with Promise.all so it's one round-trip of waiting, not two. If no Twitter account is connected, we bail out and tell the user.

Step 4: Two ways to write a tweet

I switch between two flows depending on my mood:

  1. The AI asks me about my day and drafts 3 tweet ideas.
  2. I write a draft, and the AI polishes it into 3 variants without changing the meaning.

Here's the menu:

async function askMode() {
  return p.select({
    message: "How do you want to write this tweet?",
    options: [
      { value: "ai",     label: "AI from scratch — tell it about your day" },
      { value: "polish", label: "Polish my draft — keep my words, reshape it" },
    ],
  });
}

And the "from scratch" generator. The Zod schema forces the AI to return exactly 3 strings, each under 280 characters:

async function generateTweets(day: string, recentPosts: string[]) {
  const { output } = await generateText({
    model,
    output: Output.object({
      schema: z.object({
        tweets: z.array(z.string().max(280)).length(3),
      }),
    }),
    prompt: `Write 3 distinct tweet ideas based on this person's day.
Casual, first-person, no hashtags, no emojis.
 
Their day: ${day}
 
Their recent tweets (don't repeat themes or phrasing):
${recentPosts.map((t, i) => `${i + 1}. ${t}`).join("\n")}`,
  });
  return output!.tweets;
}

The prompt is doing most of the work here. My real version has stricter rules (no generic "building in public" platitudes, vary sentence shape, etc.). That's exactly the kind of thing you should rewrite for your own voice. The prompt is the product.

The polish mode uses the same generateText shape with a different prompt: preserve meaning, don't invent new ideas, don't add "how was your day?" filler, return 3 variants that differ in line breaks and tightness.

Step 5: Pick a smart posting time

You don't want every tweet stacked at 9:00am UTC. That's the "I have a scheduler" smell. So we ask the AI to pick a slot within the next 48 hours that respects peak windows, avoids clustering with anything you've already scheduled, and uses off-the-hour minutes.

First we get the list of already-scheduled tweets from PostPeer:

const { posts: scheduled } = await postpeer.posts.scheduled.list();
const upcoming = scheduled.map((sp) => sp.scheduledFor).filter(Boolean);

One call, no manual bookkeeping. Then we hand that list to the AI:

async function pickSmartTime(upcoming: string[]) {
  const now = new Date().toISOString();
  const { output } = await generateText({
    model,
    output: Output.object({
      schema: z.object({
        scheduledFor: z.iso.datetime({ offset: true }),
      }),
    }),
    prompt: `Pick the best time to post on Twitter/X for a founder audience.
 
Now: ${now}.
 
Rules:
- Strictly in the future, within the next 48 hours.
- Prefer weekday peaks: 13:00 to 16:00 UTC or 22:00 to 01:00 UTC.
- Stay at least 4 hours from any already-scheduled post.
- Avoid :00, :15, :30, :45. Pick off-the-hour minutes.
 
Already-scheduled:
${upcoming.map((t) => `- ${t}`).join("\n") || "(none)"}`,
  });
  return output!.scheduledFor;
}

Adjust the rules to match your audience. If your followers are mostly in Asia, change the peak windows. If you don't care about clustering, drop that line.

Step 6: Schedule the tweet

The actual write to PostPeer is one SDK call. If scheduledFor is set, it goes on the calendar. If not, it publishes immediately.

async function schedulePost(
  accountId: string,
  content: string,
  scheduledFor: string | undefined,
) {
  const result = await postpeer.posts.create({
    content,
    platforms: [{ platform: "twitter", accountId }],
    ...(scheduledFor ? { scheduledFor } : { publishNow: true }),
  });
  const link = `https://postpeer.dev/dashboard/posts?post=${result.postId}`;
  p.log.info(`${result.status}\n${link}`);
}

platforms is an array. To cross-post to LinkedIn at the same time, add a second entry:

platforms: [
  { platform: "twitter",  accountId: twitterId },
  { platform: "linkedin", accountId: linkedinId },
],

Same call, same response shape, every platform PostPeer supports.

Step 7: Wire it together

The main function ties every step into one flow:

async function main() {
  p.intro("✦ postpeer · daily tweet");
 
  const account = await loadAccount();
  if (!account) return;
 
  const mode = await askMode();
 
  let tweet: string | null;
  if (mode === "ai") {
    const day = await askAboutDay();
    tweet = await buildTweetWithAI(day, account.recentPosts);
  } else {
    const draft = await askOriginalDraft();
    tweet = await buildTweetWithPolish(draft, account.recentPosts);
  }
  if (!tweet) return;
 
  const when = await askWhenToPost();
  if (!when) return;
 
  await schedulePost(account.twitter.id, tweet, when.scheduledFor);
  p.outro("✓ Done");
}
 
main().catch((err) => {
  p.log.error(err instanceof Error ? err.message : String(err));
  process.exit(1);
});

Load, ask mode, draft or polish, pick a time, schedule. Every line of the flow is right there.

Step 8: Run it

From the project folder:

npm start

The terminal walks you through it: pick a mode, type your day or draft, pick from the 3 generated options, choose a time, done. The script prints the post ID and a link straight to that post in your PostPeer dashboard.

How to use the PostPeer MCP server to customize this

This is where it gets fun. PostPeer ships an MCP server (Model Context Protocol) that exposes every SDK method as a live tool inside Claude. With it connected, you can ask Claude "add a flag that lets me schedule a thread instead of a single tweet" and it knows the exact SDK calls because they're in its toolbelt, not guesses from training data.

Setting up the MCP in Claude Code

claude mcp add postpeer -- npx -y postpeer-mcp

Then make sure POSTPEER_API_KEY is set in your environment.

Setting up the MCP in Claude Desktop

Open your Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json on Mac) and add:

{
  "mcpServers": {
    "postpeer": {
      "command": "npx",
      "args": ["-y", "postpeer-mcp"],
      "env": { "POSTPEER_API_KEY": "your_key_here" }
    }
  }
}

Restart Claude Desktop and the PostPeer tools show up. The same config format works in Cursor and any other MCP-compatible client.

Once the MCP is wired up, ask Claude things like:

  • "Add a --platform flag so I can schedule to LinkedIn or Twitter from the same script."
  • "Show me the next 5 scheduled posts and let me cancel any of them."
  • "Write a script that drafts a thread from a long blog post and schedules each tweet 5 minutes apart."

It writes the right SDK calls the first time because it can introspect them.

Where to take it next

This script is one file. A few directions to fork it:

  • Cross-post to multiple platforms in one call by adding entries to the platforms array. PostPeer supports Twitter, LinkedIn, Instagram, YouTube, Pinterest, and more.
  • Drop the AI parts and feed the script tweets from a markdown file or a spreadsheet. The PostPeer scheduling call is the same.
  • Wrap it in a cron job to drain a queue of pre-written tweets at smart times.
  • Replace Gemini with Claude or GPT by changing two lines.
  • Add a thread mode with multiple content posts chained together.
  • Expose it as a Slack slash command so your team can schedule from chat.

For a full reference of what the Twitter scheduling API supports (media uploads, timezones, thread structure, polls), check the PostPeer docs.

Wrap up

The whole point of building your own Twitter scheduler is that the surface area is tiny. The PostPeer SDK does the platform-specific work (OAuth, rate limits, media format conversions, the breaking changes Twitter ships every few months). Your script is just the part that's yours: the prompts, the timing logic, the workflow shape.

If you want to start from a clean base, grab a PostPeer API key (20 free posts, no card), connect Twitter, and paste the snippets above into a tweet.ts. You'll be scheduling from your terminal in under 10 minutes.