Skip to main content

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

PackagePurpose
twitter-api-v2X API v2 client — read mentions, post replies
ai + @ai-sdk/anthropicAI SDK for LLM-based tweet parsing
drizzle-orm + postgresDatabase ORM (shared Neon instance)
dotenvEnvironment variable loading
tsxTypeScript 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

  1. On startup, fetch the bot’s user ID from @Mouth_App username
  2. Poll mentions with since_id to only get new tweets
  3. 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.

LLM Tweet Parser

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.

Input

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
}

Extraction Rules

FieldExtraction Logic
titleCore prediction/claim, cleaned of @mentions
amountDollar amount from $, USD, USDC patterns. Min $10, default 0
opponentUsername@mentioned handle that isn’t @Mouth_App. Null = open challenge
expirationDescriptionNatural language date (“end of April”, “by Friday”, “Q2”)
oddsChallenger’s perceived probability (10-90). Default 50
confidence0-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.
CredentialPurpose
X_BEARER_TOKENApp-level read access (mention timeline)
X_CONSUMER_KEY + X_CONSUMER_SECRETApp identity (API Key pair)
X_ACCESS_TOKEN + X_ACCESS_TOKEN_SECRETUser 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

VariableRequiredDescription
X_BEARER_TOKENYesX API Bearer Token for reading mentions
X_CONSUMER_KEYYesX API Consumer Key
X_CONSUMER_SECRETYesX API Consumer Secret
X_ACCESS_TOKENYesX API Access Token (Read+Write)
X_ACCESS_TOKEN_SECRETYesX API Access Token Secret
X_BOT_USERNAMENoBot account handle (default: Mouth_App)
POLL_INTERVAL_MSNoPolling interval in ms (default: 30000)
ANTHROPIC_API_KEYYesAnthropic API key for Claude Haiku
DATABASE_URLYesNeon PostgreSQL connection string
APP_URLNoBase 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:
  1. Frontend fetches draft data from GET /challenges/:id
  2. Challenger logs in with X via Privy → backend recognizes the X handle
  3. Review: challenger sees pre-filled form (title, amount, odds, expiration) — can edit
  4. Confirm + Deposit: challenger deposits USDC → backend creates the real bet entry, deploys smart contract, and updates draft_challenges.bet_id + draft_challenges.status = 'confirmed'
  5. Opponent clicks same link → sees “Accept Challenge” → deposits → bet goes active
  6. 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.