Autonomous Upwork job search agent powered by Claude Code. Finds relevant jobs, scores them against your profile, generates tailored proposals, and submits them — all controlled via Telegram buttons.
The agent runs as a background daemon that connects three components:
- Google Chrome with your logged-in Upwork session (via Chrome DevTools Protocol)
- Telegram bot that sends you job cards and lets you approve/skip/redo with inline buttons
- Claude Code that does the actual browsing, scoring, and proposal writing
Cron (every N min) or /search command
|
v
Claude Code browses Upwork
using your Chrome session
|
v
Scores each job 0-10
based on your profile
|
v
Jobs scoring >= 4 sent
to Telegram with buttons
|
v
You press [Apply] or [Skip]
|
v
Claude generates proposal
matching your writing style
|
v
You review: [Send] [Cancel] [Redo]
|
v
Claude submits on Upwork
There are two ways to run the agent:
| Docker (recommended) | Bare metal | |
|---|---|---|
| Best for | Server, 24/7, headless | Mac/Linux desktop with monitor |
| Requirements | Docker, Docker Compose | Node.js, Chrome, Claude Code CLI |
| Chrome access | Via noVNC in browser (http://server:6080) |
Native Chrome window |
| Setup time | ~5 min | ~15 min |
- Docker and Docker Compose
- Claude Code CLI subscription (for OAuth token)
- Telegram account
git clone <repo-url>
cd upwork-agent- Open @BotFather in Telegram
- Send
/newbot, follow the prompts, copy the bot token - Create a group chat (or use personal chat) and add the bot
- Get the chat ID:
- For groups: add @RawDataBot, it prints the chat ID (negative number), then remove it
- For personal chat: send any message to @userinfobot
- Get your user ID: send any message to @userinfobot
- If using a group:
- BotFather >
/mybots> your bot > Bot Settings > Group Privacy > Turn off - In the group: Settings > Visible History > turn on (so link previews in job cards work)
- BotFather >
cp .env.example infra/.envEdit infra/.env:
| Variable | Description | How to get |
|---|---|---|
BOT_TOKEN |
Telegram bot token | From @BotFather |
CHAT_ID |
Telegram chat ID | See step 2 above |
ALLOWED_USERS |
User IDs who can press buttons | From @userinfobot, comma-separated |
TIMEZONE |
Your timezone | Europe/Lisbon (default) |
SEARCH_INTERVAL_MIN |
Auto-search interval in minutes | Default: 20 |
CLAUDE_CODE_OAUTH_TOKEN |
Claude Code OAuth token | See below |
CLAUDE_ACCOUNT_UUID |
Account UUID | See below |
CLAUDE_EMAIL |
Account email | See below |
CLAUDE_ORG_UUID |
Organization UUID | See below |
Getting Claude Code OAuth credentials — on a machine where Claude Code is already logged in:
# OAuth token (macOS)
security find-generic-password -s "claude-cli" -w
# Account UUID, email, org UUID
cat ~/.claude.json | grep -A5 oauthAccountcp data/profile.example.md data/profile.mdEdit data/profile.md with your name, tech stack, experience, scoring criteria, and proposal style. The agent reads this file to score jobs and write proposals.
cd infra
docker compose up -dFirst build takes ~5 minutes. After that:
- Open
http://server:6080in a browser — you'll see Chrome via noVNC - Log in to Upwork manually in that Chrome
- The session persists in a Docker volume across restarts
The bot sends a Telegram notification when the browser is ready.
All commands from infra/ directory:
cd infra
docker compose logs -f # follow logs
docker compose restart # restart
docker compose down # stop
docker compose up -d --build # rebuild after code changes- Node.js >= 20
- Google Chrome (real browser, not Chromium — Upwork detects
navigator.webdriverin Playwright's bundled Chromium) - Claude Code CLI with active subscription
- Telegram account
macOS:
brew install node
npm install -g yarn @anthropic-ai/claude-code
# Google Chrome — install from https://www.google.com/chrome/Ubuntu Desktop:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs lsof
npm install -g yarn @anthropic-ai/claude-code
wget -q -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo dpkg -i /tmp/chrome.deb && sudo apt --fix-broken install -ygit clone <repo-url>
cd upwork-agent
yarn installSame as Docker Option A, step 2.
cp .env.example .envEdit .env:
| Variable | Description | Default |
|---|---|---|
BOT_TOKEN |
Telegram bot token from BotFather | required |
CHAT_ID |
Telegram chat ID (negative for groups) | required |
ALLOWED_USERS |
Comma-separated Telegram user IDs | required |
TIMEZONE |
Your timezone | Europe/Lisbon |
SEARCH_INTERVAL_MIN |
Auto-search interval in minutes (8:00-23:00) | 20 |
CHROME_PATH |
Path to Chrome binary | Auto-detected by OS |
cp data/profile.example.md data/profile.mdEdit with your name, tech stack, experience, scoring criteria, and proposal style.
yarn daemonA Chrome window opens with a separate profile (data/browser-data/). Log in to Upwork manually. The session persists across daemon restarts.
yarn daemonThe daemon will:
- Auto-search every N minutes during 8:00-23:00 (your timezone)
- Send matching jobs to Telegram
- Wait for your button presses
- Generate and submit proposals on your command
- Send a heartbeat ping every 6 hours
| Command | What it does |
|---|---|
/search |
Run a job search right now |
/status |
Show browser status, queue length, Claude running |
/report |
Job stats for today |
/report week |
Job stats for the past 7 days |
/report all |
Job stats for all time |
On job cards:
| Button | Action |
|---|---|
| Apply | Generate a proposal for this job |
| Skip | Mark as skipped, no further action |
On proposals:
| Button | Action |
|---|---|
| Send | Submit the proposal on Upwork |
| Cancel | Discard the proposal |
| Redo | Generate a completely different proposal |
On errors:
| Button | Action |
|---|---|
| Retry | Re-run the failed action |
These can be used independently from the daemon:
# Job database
yarn jobs add --title '...' --url '...' --relevance-score 7
yarn jobs get <id>
yarn jobs check <url>
yarn jobs list
yarn jobs list --status applied
yarn jobs find "react typescript AI"
yarn jobs update <id> --status applied
yarn jobs stats # today
yarn jobs stats --week # past 7 days
yarn jobs stats --all # all time
# Telegram
yarn tg send "Hello"
yarn tg send-job <id>
yarn tg send-proposal <id>
# Briefing
yarn morning+----------------------------------------------------------+
| yarn daemon (src/daemon.ts) |
| |
| +---------------+ +--------------+ +---------------+ |
| | Google Chrome | | Grammy Bot | | Cron | |
| | (real browser) | | (Telegram) | | Scheduler | |
| | CDP :9222 | | | | | |
| +-------+-------+ +------+-------+ +-------+-------+ |
| | | | |
| | +------+------------------+ |
| | | Task Queue (mutex) |
| | | one Claude Code at a time |
| | +------+-------------------- |
| | | |
| | spawn('claude', ['-p', task, ...]) |
| | | |
| +-------+------------------+------------------------+ |
| | Claude Code (child process) | |
| | Tools: Playwright MCP (CDP -> :9222) | |
| | Bash (yarn jobs, yarn tg) | |
| | Read, Write | |
| +---------------------------------------------------+ |
| |
| data/jobs.db (SQLite + FTS5, WAL mode) |
+----------------------------------------------------------+
- Real Chrome, not Playwright Chromium — Upwork uses Cloudflare Turnstile which detects
navigator.webdriver=truein Playwright's bundled Chromium. Real Chrome doesn't have this marker. - CDP connection — Chrome runs with
--remote-debugging-port=9222, daemon connects viachromium.connectOverCDP(). The Playwright MCP server also connects to this port. - Mutex task queue — Only one Claude Code process runs at a time. Tasks are queued and processed sequentially.
- Model routing — Haiku for search and submit (tool-heavy, speed matters), Sonnet for proposals and redo (creative writing, quality matters).
- Persistent session — Chrome uses a separate
--user-data-dirso cookies and login survive restarts. Keepalive reloads prevent session expiry.
src/
daemon.ts Main process: Chrome + bot + cron + task queue
jobs.ts CLI for job database operations
tg.ts CLI for Telegram messaging
morning.ts CLI for daily briefing
db/
index.ts SQLite connection (WAL mode)
schema.ts Jobs table and indexes
search.ts FTS5 full-text search
data/
profile.md Your profile (gitignored, copy from example)
profile.example.md Template for profile.md
jobs.db SQLite database (auto-created)
browser-data/ Chrome profile with Upwork session (auto-created)
logs/ Task execution logs
infra/
Dockerfile Container image (Ubuntu + Chrome + Node + VNC)
docker-compose.yml Single-command deploy
supervisord.conf Process manager (Xvfb, x11vnc, noVNC, daemon)
start.sh Entrypoint (auth, cleanup, notifications)
.mcp.json Playwright MCP server config (CDP endpoint)
.env Bot token, chat ID, settings (gitignored)
.env.example Template for .env (both bare metal and Docker)
CLAUDE.md Agent instructions for Claude Code
| Status | Meaning |
|---|---|
new |
Just added to DB, not scored high enough to send |
sent |
Sent to Telegram, awaiting your decision |
approved |
You pressed Apply, proposal is being generated |
skipped |
You pressed Skip |
cancelled |
You pressed Cancel on a proposal |
applied |
Proposal submitted on Upwork |
Bot doesn't respond to commands
- Check Group Privacy is turned off in BotFather
- Verify your user ID is in
ALLOWED_USERS - If you converted a group to a supergroup, update
CHAT_ID(it changes)
Upwork blocks access
- "Cannot verify your request" — IP/VPN issue, try switching VPN or disabling it
- CAPTCHA — the agent tries to click it automatically (2 attempts), then notifies you to solve manually
- Session expired — log in manually in the Chrome window
"Not enough Connects"
- Buy Connects on Upwork, then press the Retry button in Telegram
Chrome won't start
- Check
CHROME_PATHin.env(or leave it empty for auto-detection) - Kill zombie processes:
lsof -ti:9222 | xargs kill -9 - In Docker: stale lock files are cleaned automatically on start. If still failing:
docker compose restart
Docker: Claude Code auth error
- Check that
CLAUDE_CODE_OAUTH_TOKENis filled ininfra/.env - OAuth tokens expire — get a fresh one and
docker compose restart
Docker: noVNC not accessible
- Port 6080 is bound to
127.0.0.1by default - For remote access change
"127.0.0.1:6080:6080"to"6080:6080"indocker-compose.yml
Search finds 0 jobs
- The agent rotates through search queries randomly
- Check
data/profile.mdscoring criteria — threshold is >= 4
The daemon runs several background tasks:
| Process | Interval | Purpose |
|---|---|---|
| Job search | Every N min, 8:00-23:00 | Find new jobs |
| Keepalive | Every 10 min | Reload page to prevent session expiry |
| Heartbeat | Every 6 hours | Send "alive" ping to Telegram |
MIT