Automating X posts with GitHub Actions and Claude: how we built it for Storyie

Storyie Engineering Team
7 min read

How we built a two-stage X (Twitter) automation pipeline for Storyie — Claude generates draft posts on a weekly cron, humans review and schedule them, and a second workflow fires the actual tweets. Architecture, status-state design, retry logic, and the lessons we learned running it in production.

Storyie is a diary and journaling app. We want to post regularly on X — journaling tips, product updates, links to new blog posts — but writing tweet-sized content by hand every week is not a good use of engineering time. We also did not want a fully autonomous bot firing off posts with no review step; AI-generated content can drift from the brand voice in subtle ways.

The solution we landed on is a two-stage pipeline: Claude drafts posts on a weekly cron and stores them as drafts in the database; a human reviews and marks them scheduled; a second workflow publishes on schedule. The whole thing lives in packages/x-posting inside our pnpm monorepo.

TL;DR

  • Stage 1 (weekly cron): GitHub Actions calls Claude via claude-code-action. Claude returns JSON with multiple post variants. We store them in Supabase as draft.
  • Stage 2 (manual or push trigger): A separate workflow reads scheduled rows and publishes via the X API. Status transitions prevent double-posting.
  • Blog posts auto-queue a promotional tweet when pushed to main.
  • Tips content lives in Markdown files so edits go through PR review.

Concern

Approach

Content generation

claude-code-action with tools disabled; JSON output, multiple variants

Review checkpoint

draft → review → scheduled before anything touches the X API

Double-post prevention

posting state acquired before the API call; second worker skips it

Retry logic

Rate-limit: header-derived wait; 5xx: exponential backoff; 401/403: no retry

Blog integration

Push-triggered workflow detects new .md files, deduplicates by slug

Tips management

Markdown files with YAML frontmatter, Zod-validated at load time

Architecture overview

The pipeline splits cleanly into two workflows.

Stage 1 — content generation (every Monday)

GitHub Actions (cron) → build prompt → claude-code-action → parse JSON → insert draft rows

Stage 2 — posting (manual or on push)

GitHub Actions → read scheduled rows → X API → update status

Separating the two means a generation failure on Monday never blocks posts that are already in the scheduled queue. It also means we can re-run generation without any risk of publishing something mid-flight.

Generating content with Claude

Prompt structure

We use claude-code-action, Anthropic's official GitHub Action for calling Claude from CI. The prompt is assembled dynamically by prompt-builder.ts:

Brand context  (app description, tone, what to avoid)
  ↓
Recent posts   (last N published posts pulled from DB to avoid repetition)
  ↓
Category instructions  (different guidance per post type)
  ↓
Output format  (JSON array of variants, each with text and hashtags)

Feeding recent posts back into the prompt is the most impactful part. Without it, Claude tends to repeat the same angles and phrases. Passing the last few weeks of output and asking for something with a different framing consistently produces more varied drafts.

Post categories

We generate four types of posts:

  • tips — practical journaling advice and reflection prompts
  • development — feature announcements and product updates
  • blog — promotional posts linking to new engineering blog articles
  • custom — general engagement content

Each category gets its own prompt section with specific instructions. For tips the guidance emphasizes concrete, actionable advice over generic encouragement. For development posts it asks Claude to lead with the user benefit rather than the technical implementation detail.

Locking down Claude in CI

Running an AI agent in GitHub Actions requires some care. We do not want Claude attempting to read files, run shell commands, or make network requests — it should only produce text.

claude_args: |
  --max-turns 5
  --model claude-sonnet-4-5-20250929
  --append-system-prompt "You are a content generator ONLY. Do NOT use any tools."
  --disallowedTools "Bash,Task,WebSearch,WebFetch,Write,Edit,MultiEdit"

--disallowedTools is the hard enforcement layer. The system prompt is a softer signal, but the disallowed list means Claude literally cannot invoke those tools even if it tries. Output comes back as a JSON array of variants; we parse it in a post-processing script and write the rows to Supabase.

Status state machine

Every post row moves through the following states:

draft → review → scheduled → posting → posted
                                   ↘ failed

draft is the AI output, unreviewed. We move it to review when we want to look at it, then to scheduled when it is ready to go. The posting workflow never touches draft or review rows.

The posting state is an in-flight lock. Before making any X API call, the workflow atomically transitions the row from scheduled → posting. A second concurrent workflow run will skip any row already in posting. Without this, two overlapping runs could both read the same scheduled row and publish a duplicate.

Failed posts increment retryCount and set retryable based on the error type. The retry decision is explicit rather than automatic: the workflow checks retryable and only requeues if it is true.

X API client

We use the twitter-api-v2 library with OAuth 1.0a. The retry logic handles three distinct failure modes differently:

  • 429 rate limit: Read the x-rate-limit-reset header. That timestamp tells us exactly when the limit resets. We wait until then rather than guessing with a fixed backoff.
  • 5xx server errors and network failures: Exponential backoff — 2 s, 4 s, 8 s. The X API has intermittent 500s; retrying after a brief wait resolves most of them.
  • 401/403 auth errors: Mark retryable: false immediately. A credential problem will not resolve by waiting.

One X-specific detail worth noting: the API counts any URL as 23 characters regardless of its actual length (URLs are wrapped in t.co shortlinks). The validateContent function accounts for this before submission. Getting it wrong on a post that runs close to the 280-character limit means the tweet gets truncated server-side.

Blog-post auto-promotion

When a new blog post lands on main, we want a tweet to go out without any manual steps. A workflow handles this:

on:
  push:
    branches: [main]
    paths: ["apps/web/content/blog/*.md"]

On trigger, we diff the commit to identify newly added files, extract the slug from each filename, and check the x_posts table for any existing row with that slug in a non-failed state. If one exists, we skip. If not, we insert a scheduled row. The author pushes a Markdown file; the tweet queues automatically.

Tips content in Markdown

Rather than storing journaling tips as database rows, we keep them as Markdown files:

packages/x-posting/content/tips/
├── tip-001.md
├── tip-002.md
├── tip-003.md
└── ...

Each file has a YAML frontmatter block:

---
id: tip-001
language: en
hashtags: ['journaling', 'reflection']
---
Write about one thing that surprised you today, not just what happened.

content-loader.ts reads and validates these with Zod at runtime. Keeping tips as files means content changes go through a normal PR — they appear in git history, can be code-reviewed by the team, and can be reverted with a single revert commit. No one needs database access to add or edit a tip.

Monorepo package layout

The whole system lives in packages/x-posting as @storyie/x-posting:

packages/x-posting/
├── src/
│   ├── db/          # Drizzle queries (x_posts table)
│   ├── lib/         # prompt-builder, content-loader, validateContent
│   ├── services/    # posting service, scheduler, X client
│   └── types/       # shared TypeScript types
├── content/
│   └── tips/        # journaling tip Markdown files
├── scripts/         # CLI scripts for local testing and backfills
└── .env.production  # encrypted X API credentials (dotenvx)

The package depends on @storyie/database for shared Drizzle table definitions, but only adds the x_posts-specific queries inside itself. The web app has no dependency on @storyie/x-posting, so X-related changes are completely isolated from the main application.

What we learned

AI-generated content needs a review gate

Claude's output quality is high enough that most drafts are usable. But "most" is not "all." Occasionally a post has the wrong tone, uses a slightly awkward hashtag, or leads with an angle that does not fit the brand. The draft → review → scheduled pipeline exists specifically because we learned this in the first week: a fully automated flow will eventually publish something you wish it had not.

The right mental model is semi-automation: AI handles the creative labor, humans make the final call. That is a better use of both.

Character counting requires URL awareness

This is easy to miss until it bites you. X wraps all URLs in t.co shortlinks, so a 60-character URL and a 15-character URL both count as 23. If your content validation does not account for this, a post that looks fine locally can arrive at the API over the limit. We validate after substituting URL lengths.

API cost is negligible for weekly generation

claude-code-action charges against the Claude API. For one weekly generation run producing a handful of drafts across all categories, the cost works out to a few cents per run. For a solo or small-team project, this is a non-issue.

Related Posts

Try Storyie

If you want to see what the automated tips look like in practice, follow @storyieapp on X. Or skip the meta layer and just start a diary at storyie.com — available on iOS and Android beta.