Independent research + triage desk for posting on X. Not affiliated with, endorsed by, or part of X Corp.
Live: xcopilot.dev
Official X API search → DeepSeek triage in a Vite dashboard. Scout finds cool threads worth a human reply — no AI-written reply drafts.
Status: Stream 1 — agenda → DeepSeek Chat queries → recent search (official X API) → triaged thread cards (Start/Stop Scout).
- Paste an agenda (who/what to engage, voice, avoid list).
- DeepSeek Chat expands the agenda into 2–4 short X search queries (one LLM call).
- Sidecar runs those queries via the official X API recent search (
GET /2/tweets/search/recent, app-only bearer). - A second DeepSeek call triages the results (summary + bait risk + engage hint).
- Review cool thread cards, Open on X, and reply yourself (no auto-engage; no AI draft replies).
After search, POST /api/search sends the returned threads (max 20) to DeepSeek in one batched call and enriches each card with:
| Field | Meaning |
|---|---|
summary |
One sentence: what the post is about and why it was likely posted |
baitScore |
0–100 engagement-bait risk — higher is worse (mirrored onto score) |
flags |
e.g. engagement_bait, promo, github_plug, genuine_question, on_agenda |
intent |
Short read, e.g. "engagement farming" |
engage |
skip | consider | priority |
reason |
One clause explaining the score |
The agenda is passed along, so a specific on-agenda question scores low even though it is a question. Only posts with a numeric bait score are returned to the UI — incomplete triage items, omitted ids (after one repair), and overflow past the 20-thread triage cap are dropped and noted in triageWarning. If triage fails entirely, search still returns 200 with an empty thread list plus the warning (never a wall of unscored — rows). In the UI, summaries replace the tweet text as the card headline (original stays underneath) and skip threads are dimmed.
Mark interacted records the thread + author in a local sidecar file data/interactions.json (gitignored). For the next 24 hours, later searches drop other posts from that same @handle before triage, so we do not keep hammering the same account or waste DeepSeek tokens on them. The search status line reports how many posts were filtered. Restarting the sidecar keeps the cooldown (file persist).
The same action also writes an Obsidian-friendly Markdown note under knowledge/interactions/ (gitignored) that includes the thread context and the reply you typed on X (POST /api/interacted requires reply). Point Obsidian at the knowledge/ folder to browse agent memories locally — never commit that directory.
The last successful Scout run is also cached in memory and data/last-scout.json (gitignored). On dashboard load, GET /api/scout/last restores Threads / queries (cooled-down authors filtered out) so a reload or API restart does not wipe the list.
Scout stage lines are appended to data/scout-log.json (gitignored; last 1000) via GET/POST /api/scout/log and shown in the Scout strip with time-ago + 100-line pages.
Before triage, posts with more than 480 characters (or obvious N/M thread openers like 1/17 …) are dropped so walls of text never reach DeepSeek or the accordion. Override with X_MAX_THREAD_CHARS in .env, or via Settings in the UI (hamburger menu) — the UI sends filters on each Scout run and wins over env for that request. X Articles are hard-dropped by default when the search payload marks an article (toggle in Settings). When a note tweet body is present, that text is used for the char cap instead of the short full_text teaser. The search status line reports how many were dropped.
Scout is x-copilot’s search mini-agent. Use Start Scout / Stop Scout on the dashboard. Flow:
- Plan queries (DeepSeek), then pace X recent search (20 hits/query).
- Hard-filter bucket (cooldown + Article/char/links/self-reply) with no LLM until the bucket has K candidates (UI sends
bucketSize: 20; server accepts 5|10|20). Keep searching / cycling queries (one replan, search budget) while the bucket is short. - LLM-qualify the full bucket. Cool =
engagepriority/considerandbaitScore ≤ 45. - Keep cool threads and refill until Cool threads target (
targetCool, 1–20) or supply is exhausted. If a full bucket yields 0 cool, discard and refill. Budget/Stop →exhausted/aborted; hit target →stopReason: target.
Status shows Candidates n/K while filling and Cool n/target as cools accumulate. Prefer POST /api/scout/run (NDJSON; done includes coolCount, bucketSize, stopReason, threads, opencodeTurns). POST /api/search remains a non-streaming batch JSON fallback. Sessions are rate-limited: one run at a time, then a 15s cooldown (UI + sidecar 429) before the next Start — Stop does not bypass that gate.
Vite UI → local Node sidecar → X API v2 (app-only bearer)
↓
Gemini / DeepSeek
Bearer token and LLM keys stay in .env on the sidecar. The browser never stores credentials. Public DNS + bind notes: docs/PUBLIC_DEPLOY.md.
cp .env.example .env
# set X_API_BEARER_TOKEN (+ X_OPERATOR_USERNAME), and GEMINI_API_KEY or DEEPSEEK_API_KEY
npm install
npm run test:session # prove Pay Per Use bearer works
npm run build:server # emit server/dist for production-shaped runs
npm run dev:server # tsx watch → http://127.0.0.1:8787
npm run dev # http://127.0.0.1:5173 (proxies /api → sidecar)Health: curl http://127.0.0.1:8787/api/health
API verify: curl http://127.0.0.1:8787/api/session/verify
Important: Vite alone is not enough. If you only run npm run dev, Search hits a dead proxy and shows a proxy/500 error. Always run dev:server too.
| Script | What it runs |
|---|---|
npm run dev:server |
tsx watch server/src/index.ts |
npm run build:server |
tsc -p tsconfig.server.json → server/dist/ |
npm run test:session |
tsx scripts/test-session.ts |
npm test |
Unit tests (node:test via tsx) |
npm run test:search -- "query" |
Live recent-search smoke |
UI typecheck stays on root tsconfig.json (noEmit); the API uses tsconfig.server.json (NodeNext emit).
Use a Pay Per Use project/app from console.x.com (not Ads).
| Env var | Role |
|---|---|
X_API_BEARER_TOKEN |
App-only Bearer (keep URL-encoding as issued) |
X_API_KEY / X_API_SECRET |
Consumer key/secret (optional; stored for OAuth later) |
X_OPERATOR_USERNAME |
Your handle (no @) for Mark detect / reply discover |
X_OPERATOR_USER_ID |
Optional numeric id |
- Create a Project/App under Pay Per Use.
- Billing → buy credits + set a spending limit.
- Copy the App Bearer Token into
.envasX_API_BEARER_TOKEN=...(do not decode%2F/%2B/%3D). - Set
X_OPERATOR_USERNAME=yourhandle. - Run:
npm run test:sessionExpected success looks like:
OK via api_users_by_username
@yourhandle (id=…)
If you see HTTP 402, buy credits. HTTP 401 usually means the bearer was URL-decoded or rotated — paste it again as shown in the console.
Reads use GET /2/tweets/search/recent and tweet lookup. Personal tooling only — no mass automation, no auto-posting in this MVP. You are responsible for complying with X’s terms and applicable law.
| Path | Role |
|---|---|
src/ |
Vite dashboard (agenda, Scout, threads) |
server/src/ |
TypeScript sidecar (HTTP API + X API v2 + recent search) |
server/dist/ |
Compiled sidecar (gitignored; from build:server) |
scripts/test-session.ts |
CLI X API smoke test |
tsconfig.server.json |
Server emit config |
pm2-manager.sh |
start/stop/restart/status/logs/setup-logrotate |
ecosystem.config.example.cjs |
PM2 template (copy → local ecosystem.config.cjs) |
.cursor/rules/ |
Agent rules (e.g. Graphite stack PRs) |
docs/MVP_PLAN.md |
Stream 1 scope |
docs/PUBLIC_DEPLOY.md |
api.xcopilot.dev DNS, bind, TLS |
wrangler.toml |
Cloudflare Workers static SPA (xcopilot.dev) |
.env.example |
Required secrets (no real values) |
The sidecar can run under PM2 with logs under ./logs/ (rotated; never wiped on restart).
cp ecosystem.config.example.cjs ecosystem.config.cjs # once; gitignored
npm i -g pm2 # if needed
./pm2-manager.sh setup-logrotate # once: pm2-logrotate defaults
./pm2-manager.sh start # build:server + start x-copilot-api
./pm2-manager.sh restart # or: ./pm2-manager.sh restart prod
./pm2-manager.sh restart --skip-build # reuse server/dist
./pm2-manager.sh status
./pm2-manager.sh logs # tail x-copilot-api
./pm2-manager.sh stop| Item | Value |
|---|---|
| App name | x-copilot-api |
| Port | 8787 (bind 127.0.0.1 by default; BIND_HOST=0.0.0.0 behind Cloudflare — see docs/PUBLIC_DEPLOY.md) |
| Out log | logs/x-copilot-api.out.log |
| Err log | logs/x-copilot-api.err.log |
| Ecosystem | ecosystem.config.cjs (from example; not tracked) |
setup-logrotate sets pm2-logrotate to max_size=10M, retain=14, compress=true. Restart/start/stop do not truncate logs/.
The dashboard is a static Vite build. Workers holds no secrets — the browser picks http://127.0.0.1:8787 on localhost and https://api.xcopilot.dev otherwise (src/lib/apiBase.ts).
npm run deploy:workers # vite build && npx wrangler deployThen attach the custom domain xcopilot.dev in the Cloudflare dashboard (or wrangler.toml [[routes]]). There is no www. DNS for api is a grey-cloud A record to the VPS — see docs/PUBLIC_DEPLOY.md.
Sign-in: hamburger menu → Continue with Google (allowlisted email) or Continue with X. OAuth redirects hit the API host, then bounce back to this SPA.
- Agenda → Scout → triaged cool thread cards
- Human-in-the-loop posting only (Open on X; no AI reply drafts)
- README documents official X API setup + Pay Per Use credits
MIT — see LICENSE.