> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mouth.app/llms.txt
> Use this file to discover all available pages before exploring further.

# X Bot Service

> Technical architecture of the Mouth X bot — tweet parsing, draft challenges, and social notifications.

## 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.

```mermaid theme={null}
sequenceDiagram
    participant User as @challenger
    participant X as X (Twitter)
    participant Bot as Mouth Bot
    participant LLM as Claude Haiku
    participant DB as Neon PostgreSQL
    participant Opponent as @opponent

    User->>X: "@Mouth_App I bet $200 ETH > 5k by April vs @opponent"
    Bot->>X: Poll mentions (every 30s)
    X-->>Bot: New mention found
    Bot->>DB: Check tweet_id (dedup)
    DB-->>Bot: Not processed
    Bot->>LLM: Parse tweet text
    LLM-->>Bot: {title, amount, opponent, expiration, odds, confidence}
    Bot->>DB: INSERT into draft_challenges
    Bot->>X: Reply with challenge link
    X-->>User: "pvp.mouth.app/challenge/{id}"
    X-->>Opponent: Tagged in reply
    Opponent->>X: Clicks link
    Note over Opponent: Logs in → reviews → accepts → deposits USDC
```

## 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

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

```json theme={null}
{
  "title": "ETH hits 5k by end of April",
  "amount": 200,
  "opponentUsername": "cryptoguru",
  "expirationDescription": "end of April",
  "odds": 50,
  "confidence": 0.92
}
```

### Extraction Rules

| 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

```sql theme={null}
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

```sql theme={null}
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

```mermaid theme={null}
stateDiagram-v2
    [*] --> pending: Bot creates draft from tweet
    pending --> confirmed: Challenger logs in + deposits
    pending --> expired: Acceptance deadline passes
    pending --> cancelled: Challenger cancels
    confirmed --> [*]: Linked to real bet (bet_id set)
    expired --> [*]
    cancelled --> [*]
```

## 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) |

<Note>
  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.
</Note>

## 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:

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!"

<Note>
  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](/architecture/identity-and-wallets) for details on pre-generated wallets.
</Note>
