Skip to main content
All Tutorials
intermediatepostiz

Build a daily competitor-tracking agent with Postiz CLI

competitive-intelligence

~45 min hands-on6 min readMay 9, 2026
Recipe code coming soon — subscribe to get notified

Prerequisites

  • Postiz instance (self-hosted or cloud, v0.42+)
  • Node.js 20+
  • Anthropic API key

What you'll build

By the end of this tutorial you'll have a Node.js agent that:

  1. Reads a list of competitor Twitter/X handles from a config file
  2. Fetches their last 10 posts via the public API
  3. Sends the posts to Claude with a prompt asking for a one-paragraph competitive digest
  4. Schedules that digest as a LinkedIn post via the Postiz CLI
  5. Runs automatically every morning via a GitHub Action

The whole thing is under 150 lines of code. You don't need a database, a server, or any infrastructure beyond a Postiz account and an Anthropic API key.

Before you start

You'll need:

  • A Postiz account (cloud at postiz.com, or self-hosted). The CLI requires Postiz v0.42 or later.
  • An Anthropic API key. You can get one at console.anthropic.com. The agent uses claude-3-5-haiku, which runs at a fraction of a cent per call.
  • Node.js 20 or later installed locally.
  • A GitHub repository to host the Action (a private repo is fine).

Set the following as GitHub repository secrets before running anything:

POSTIZ_API_KEY=your_postiz_api_key
POSTIZ_ACCOUNT_ID=your_linkedin_account_id
ANTHROPIC_API_KEY=your_anthropic_api_key

To find your Postiz API key and account ID, go to your Postiz dashboard → Settings → API.

Step 1 — Set up the project

Clone the recipe repository and install dependencies:

git clone https://github.com/aitoolfinder/recipes.git
cd recipes/postiz-cli-content-agent
npm install

Or start from scratch:

mkdir postiz-competitor-agent
cd postiz-competitor-agent
npm init -y
npm install @anthropic-ai/sdk
npm install -D @types/node tsx

Step 2 — Define your competitor list

Create competitors.json at the project root:

{
  "handles": [
    "buffer",
    "hootsuite",
    "sproutsocial"
  ],
  "platform": "twitter",
  "lookbackHours": 24
}

The agent will read this file at runtime. You can update the handles list without touching any code.

Step 3 — Write the fetch module

Create src/fetch-posts.ts:

import { readFileSync } from 'fs'

interface CompetitorConfig {
  handles: string[]
  platform: string
  lookbackHours: number
}

interface Post {
  handle: string
  text: string
  url: string
  publishedAt: string
}

export async function fetchCompetitorPosts(): Promise<Post[]> {
  const config: CompetitorConfig = JSON.parse(
    readFileSync('competitors.json', 'utf-8')
  )

  const cutoff = new Date(Date.now() - config.lookbackHours * 60 * 60 * 1000)
  const posts: Post[] = []

  for (const handle of config.handles) {
    try {
      // Using the public Twitter/X API v2 search endpoint (no auth needed for public tweets)
      const url = `https://api.twitter.com/2/tweets/search/recent?query=from:${handle}&max_results=10&tweet.fields=created_at,text`
      const res = await fetch(url, {
        headers: {
          // Bearer token is optional for public search, rate limit is lower without it
          ...(process.env.TWITTER_BEARER_TOKEN
            ? { Authorization: `Bearer ${process.env.TWITTER_BEARER_TOKEN}` }
            : {}),
        },
      })

      if (!res.ok) {
        console.warn(`Could not fetch posts for @${handle}: ${res.status}`)
        continue
      }

      const data = await res.json()
      for (const tweet of data.data ?? []) {
        const postedAt = new Date(tweet.created_at)
        if (postedAt < cutoff) continue
        posts.push({
          handle,
          text: tweet.text,
          url: `https://twitter.com/${handle}/status/${tweet.id}`,
          publishedAt: tweet.created_at,
        })
      }
    } catch (err) {
      console.warn(`Failed to fetch @${handle}:`, err)
    }
  }

  return posts
}

Step 4 — Write the Claude digest module

Create src/generate-digest.ts:

import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic()

const SYSTEM_PROMPT = `You are a competitive intelligence analyst for a social media scheduling company.
Your job is to read competitor social posts and write a concise, useful digest for the product team.
Focus on: product announcements, pricing changes, feature launches, customer complaints, and positioning shifts.
Be specific — include actual quotes when they illustrate a point. Avoid generic observations.`

export async function generateDigest(posts: Array<{ handle: string; text: string; url: string }>): Promise<string> {
  if (posts.length === 0) {
    return null as unknown as string
  }

  const postsText = posts
    .map(p => `@${p.handle}: "${p.text}" (${p.url})`)
    .join('\n\n')

  const message = await client.messages.create({
    model: 'claude-haiku-4-5',
    max_tokens: 500,
    system: SYSTEM_PROMPT,
    messages: [
      {
        role: 'user',
        content: `Here are the competitor posts from the last 24 hours:\n\n${postsText}\n\nWrite a 2-3 sentence competitive digest suitable for posting on LinkedIn. Start with the most significant signal. Include a specific example or quote.`,
      },
    ],
  })

  const content = message.content[0]
  return content.type === 'text' ? content.text : ''
}

Step 5 — Schedule via Postiz CLI

Install the Postiz CLI globally:

npm install -g @postiz/cli

Authenticate with your Postiz instance:

postiz login --api-key $POSTIZ_API_KEY --url https://app.postiz.com

Create src/schedule-post.ts:

import { execSync } from 'child_process'

export function schedulePost(content: string, accountId: string): void {
  // Schedule for 9 AM tomorrow to catch the morning audience
  const tomorrow = new Date()
  tomorrow.setDate(tomorrow.getDate() + 1)
  tomorrow.setHours(9, 0, 0, 0)
  const scheduledFor = tomorrow.toISOString()

  const escaped = content.replace(/"/g, '\\"').replace(/\n/g, '\\n')

  execSync(
    `postiz post create \
      --account "${accountId}" \
      --content "${escaped}" \
      --scheduled-at "${scheduledFor}"`,
    { stdio: 'inherit' }
  )

  console.log(`Post scheduled for ${scheduledFor}`)
}

Step 6 — Wire it together

Create src/agent.ts:

import { fetchCompetitorPosts } from './fetch-posts'
import { generateDigest } from './generate-digest'
import { schedulePost } from './schedule-post'

async function main() {
  console.log('Fetching competitor posts...')
  const posts = await fetchCompetitorPosts()
  console.log(`Found ${posts.length} posts in the last 24 hours`)

  if (posts.length === 0) {
    console.log('No posts found. Exiting without scheduling.')
    return
  }

  console.log('Generating digest with Claude...')
  const digest = await generateDigest(posts)

  if (!digest) {
    console.log('Claude returned empty digest. Exiting.')
    return
  }

  console.log('Digest:', digest)

  const accountId = process.env.POSTIZ_ACCOUNT_ID
  if (!accountId) throw new Error('POSTIZ_ACCOUNT_ID is not set')

  console.log('Scheduling post via Postiz CLI...')
  schedulePost(digest, accountId)

  console.log('Done.')
}

main().catch(err => {
  console.error(err)
  process.exit(1)
})

Add the run script to package.json:

{
  "scripts": {
    "agent": "tsx src/agent.ts"
  }
}

Test it locally:

POSTIZ_API_KEY=xxx POSTIZ_ACCOUNT_ID=yyy ANTHROPIC_API_KEY=zzz npm run agent

Step 7 — Deploy via GitHub Actions

Create .github/workflows/competitor-agent.yml:

name: Daily Competitor Digest

on:
  schedule:
    - cron: '0 7 * * 1-5'  # 7 AM UTC Monday–Friday
  workflow_dispatch:         # Allow manual trigger from GitHub UI

jobs:
  run-agent:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run agent
        env:
          POSTIZ_API_KEY: ${{ secrets.POSTIZ_API_KEY }}
          POSTIZ_ACCOUNT_ID: ${{ secrets.POSTIZ_ACCOUNT_ID }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

Push to your repository. The Action will run every weekday at 7 AM UTC, schedule the post for 9 AM the next day, and show you the digest in the Action logs.

Common errors

postiz: command not found — The CLI didn't install globally. Try npx @postiz/cli post create ... instead, or add ./node_modules/.bin to your PATH in the Action.

401 Unauthorized from Postiz — Your API key has expired or been revoked. Generate a new one in Postiz → Settings → API.

Claude returns too much text — Reduce max_tokens to 300 and add "Maximum 3 sentences" to the prompt.

No posts found even though competitors are active — The Twitter/X public search endpoint has rate limits without a bearer token. Add TWITTER_BEARER_TOKEN to your secrets and it to the fetch headers.

Next steps

  • Add a Slack notification step after scheduling so the team sees the digest in real time
  • Feed the digest back into a database to track competitive signals over time
  • Extend competitors.json to include LinkedIn and YouTube handles
  • Try the full Nevo David stack story to understand how this fits into a larger content operation

Get the full recipe

Clone the starter repo and follow along in your own environment.

View on GitHub →

Related Stacks

The Next New Thing$45K

How Nevo David grew Postiz from $17K to $45K MRR by going CLI-first

by Nevo David

postiz
6 min readMay 2026