Overview
The X bot (@Mouth_App) is a standalone Node.js service that monitors Twitter/X for mentions, parses challenge intents using an LLM, creates draft challenges in the database, and replies with a link to pvp.mouth.app/challenge/{id}.
It is purely a distribution layer — it does not handle funds, deploy contracts, or resolve challenges. All on-chain operations happen when users click the link and interact with the web app.
Service Architecture
The bot runs as an independent process, separate from the backend API. Both share the same Neon PostgreSQL database.
x-bot/
├── src/
│ ├── index.ts # Entry point — DB warmup + polling loop
│ ├── lib/
│ │ ├── env.ts # Environment variables
│ │ ├── twitter.ts # X API v2 client (read mentions + post replies)
│ │ ├── db.ts # Drizzle + postgres connection with retry
│ │ └── schema.ts # draft_challenges table definition
│ └── services/
│ ├── tweet-parser.ts # LLM-based tweet parsing (Claude Haiku)
│ ├── mention-poller.ts # Polling loop + draft creation
│ └── reply-composer.ts # Tweet reply templates
├── .env
├── package.json
└── tsconfig.json
Key Dependencies
| Package | Purpose |
|---|
twitter-api-v2 | X API v2 client — read mentions, post replies |
ai + @ai-sdk/anthropic | AI SDK for LLM-based tweet parsing |
drizzle-orm + postgres | Database ORM (shared Neon instance) |
dotenv | Environment variable loading |
tsx | TypeScript execution (dev mode) |
Mention Polling
The bot uses the User Mention Timeline endpoint (GET /2/users/:id/mentions) to fetch new mentions every 30 seconds.
Polling Flow
- On startup, fetch the bot’s user ID from
@Mouth_App username
- Poll mentions with
since_id to only get new tweets
- For each mention:
- Dedup check: skip if
tweet_id already exists in draft_challenges
- LLM parse: extract challenge parameters from tweet text
- Confidence filter: skip if LLM confidence < 0.5 (not a real challenge)
- Create draft: insert into
draft_challenges with parsed data
- Post reply: respond with challenge link
Rate Limits
The X API free/pay-per-use tier provides:
- Mention reads: polled every 30 seconds, 100 tweets per request
- Tweet writes: replies posted per processed challenge
The bot tracks since_id in memory to avoid re-fetching old mentions. On restart, it re-fetches recent mentions but deduplicates via the database.
The parser uses Claude Haiku 4.5 (claude-haiku-4-5-20251001) via the Anthropic API to extract structured challenge data from natural language tweets.
A raw tweet like:
@Mouth_App I bet $200 ETH hits 5k by end of April vs @cryptoguru
Output
{
"title": "ETH hits 5k by end of April",
"amount": 200,
"opponentUsername": "cryptoguru",
"expirationDescription": "end of April",
"odds": 50,
"confidence": 0.92
}
| Field | Extraction Logic |
|---|
title | Core prediction/claim, cleaned of @mentions |
amount | Dollar amount from $, USD, USDC patterns. Min $10, default 0 |
opponentUsername | @mentioned handle that isn’t @Mouth_App. Null = open challenge |
expirationDescription | Natural language date (“end of April”, “by Friday”, “Q2”) |
odds | Challenger’s perceived probability (10-90). Default 50 |
confidence | 0-1 score. Below 0.5 = tweet is a question, joke, or unrelated mention |
Cost
Claude Haiku 4.5 is the cheapest model available. Each tweet parse costs approximately $0.001-0.002 — negligible even at high volume.
Database: draft_challenges
Draft challenges are stored in a separate table from bets, with no foreign keys to users. This is intentional:
- The bot doesn’t know if the challenger or opponent are registered users
- Draft only stores X handles as strings — identity resolution happens when users log in
- A draft becomes a real
bet only after the challenger confirms and deposits USDC
draft_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- X data
challenger_x_handle TEXT NOT NULL, -- @username who created the challenge
opponent_x_handle TEXT, -- @username being challenged (NULL = open)
tweet_id TEXT NOT NULL UNIQUE, -- original mention tweet ID (dedup key)
reply_tweet_id TEXT, -- bot's reply tweet ID
tweet_url TEXT, -- link to original tweet
-- Challenge params (LLM-parsed)
title TEXT NOT NULL, -- prediction statement
amount NUMERIC, -- USDC amount (0 if not specified)
odds INTEGER NOT NULL DEFAULT 50,-- 10-90
expiration_description TEXT, -- raw natural language date
-- Lifecycle
status TEXT NOT NULL DEFAULT 'pending', -- pending, confirmed, expired, cancelled
bet_id UUID, -- set when draft → real bet
created_at TIMESTAMP DEFAULT NOW(),
confirmed_at TIMESTAMP
)
Indexes
CREATE UNIQUE INDEX idx_drafts_tweet_id ON draft_challenges(tweet_id);
CREATE INDEX idx_drafts_status ON draft_challenges(status);
CREATE INDEX idx_drafts_challenger ON draft_challenges(challenger_x_handle);
Draft Lifecycle
Reply Templates
The bot posts different replies depending on the event:
Challenge Created (PvP)
@opponent, you've been called out by @challenger!
ETH hits 5k by end of April
$200 USDC
Accept the challenge or back down:
pvp.mouth.app/challenge/{id}
Challenge Created (Open)
@challenger just threw down a challenge!
ETH hits 5k by end of April
$200 USDC
Think they're wrong? Put your money where your mouth is:
pvp.mouth.app/challenge/{id}
Challenge Accepted
It's ON!
@challenger vs @opponent
ETH hits 5k by end of April
$400 USDC pot
pvp.mouth.app/challenge/{id}
Challenge Resolved
Challenge settled!
@winner wins against @loser
ETH hits 5k by end of April
Payout: $392 USDC
pvp.mouth.app/challenge/{id}
X API Authentication
The bot uses OAuth 1.0a User Context for posting replies (writing as @Mouth_App) and Bearer Token for reading mentions.
| Credential | Purpose |
|---|
X_BEARER_TOKEN | App-level read access (mention timeline) |
X_CONSUMER_KEY + X_CONSUMER_SECRET | App identity (API Key pair) |
X_ACCESS_TOKEN + X_ACCESS_TOKEN_SECRET | User context for @Mouth_App (read + write) |
The X app must have Read and Write permissions enabled, and the app type must be set to Web App, Automated App or Bot (confidential client). After changing permissions, the Access Token must be regenerated.
Environment Variables
| Variable | Required | Description |
|---|
X_BEARER_TOKEN | Yes | X API Bearer Token for reading mentions |
X_CONSUMER_KEY | Yes | X API Consumer Key |
X_CONSUMER_SECRET | Yes | X API Consumer Secret |
X_ACCESS_TOKEN | Yes | X API Access Token (Read+Write) |
X_ACCESS_TOKEN_SECRET | Yes | X API Access Token Secret |
X_BOT_USERNAME | No | Bot account handle (default: Mouth_App) |
POLL_INTERVAL_MS | No | Polling interval in ms (default: 30000) |
ANTHROPIC_API_KEY | Yes | Anthropic API key for Claude Haiku |
DATABASE_URL | Yes | Neon PostgreSQL connection string |
APP_URL | No | Base URL for challenge links (default: https://pvp.mouth.app) |
Deployment
The bot is deployed as a standalone service on Railway, separate from the backend API. Both connect to the same Neon PostgreSQL database.
Railway
├── mouth-backend (Hono API, port 3001)
└── mouth-x-bot (polling service, no HTTP port)
Neon PostgreSQL
├── users, bets, bet_opponents, disputes, activity_log (backend tables)
└── draft_challenges (bot table)
Why Standalone?
- Isolation: if the bot crashes or hits X rate limits, the API stays up
- Independent scaling: bot is I/O-bound (X API + LLM), API is request-driven
- Simpler deploys: bot can be restarted without affecting live users
Draft → Bet Conversion
When a user clicks the challenge link (pvp.mouth.app/challenge/{id}), the flow is:
- Frontend fetches draft data from
GET /challenges/:id
- Challenger logs in with X via Privy → backend recognizes the X handle
- Review: challenger sees pre-filled form (title, amount, odds, expiration) — can edit
- Confirm + Deposit: challenger deposits USDC → backend creates the real
bet entry, deploys smart contract, and updates draft_challenges.bet_id + draft_challenges.status = 'confirmed'
- Opponent clicks same link → sees “Accept Challenge” → deposits → bet goes active
- Bot posts update on X: “It’s ON!”
The draft_challenges table has no foreign keys to users. Identity resolution (X handle → wallet address) only happens when the user logs in and the backend calls resolveXHandleToWallet(). See Identity & Wallets for details on pre-generated wallets.