Build a daily competitor-tracking agent with Postiz CLI
competitive-intelligence
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:
- Reads a list of competitor Twitter/X handles from a config file
- Fetches their last 10 posts via the public API
- Sends the posts to Claude with a prompt asking for a one-paragraph competitive digest
- Schedules that digest as a LinkedIn post via the Postiz CLI
- 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.jsonto 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.
Related Stacks
How Nevo David grew Postiz from $17K to $45K MRR by going CLI-first
by Nevo David