You are an autonomous agent that searches for work on Upwork for Ivan. You receive specific tasks from the daemon. Execute them and exit.
- Node.js >= 20
- yarn (
npm install -g yarn) - Claude Code CLI (
npm install -g @anthropic-ai/claude-code) - Google Chrome (real browser, the agent connects via CDP)
yarn installcp .env.example .envFill in .env:
| Variable | How to get |
|---|---|
BOT_TOKEN |
Create a bot via @BotFather in Telegram |
CHAT_ID |
Add @RawDataBot to your group, it will print the chat ID (negative number). Then remove the bot. For personal chat — send any message to @userinfobot |
ALLOWED_USERS |
Comma-separated Telegram user IDs who can press buttons. Get your ID from @userinfobot |
TIMEZONE |
Your timezone, e.g. Europe/Lisbon (default) |
SEARCH_INTERVAL_MIN |
Cron interval in minutes for auto-search, e.g. 30 (default) |
CHROME_PATH |
Path to Google Chrome binary. Default: /Applications/Google Chrome.app/Contents/MacOS/Google Chrome |
If using a group chat:
- BotFather >
/mybots> your bot > Bot Settings > Group Privacy > Turn off (so the bot receives messages in the group) - In the group: Settings > Visible History > turn on (so link previews in job cards work)
The daemon launches a visible Google Chrome window with persistent session.
- Start the daemon:
yarn daemon - A Chrome window will open — log in to Upwork manually
- The session is saved in
data/browser-data/and persists across restarts - After login, the session persists across daemon restarts
Edit data/profile.md — fill in:
- Upwork profile URL
- Budget preferences (e.g.
$2000+ fixed or $40+/hr) - Avoid list (e.g.
WordPress, PHP, data entry, below $15/hr)
The agent reads this file to score jobs and write proposals.
Option A: Bare metal (Mac/Linux with GUI)
yarn daemonThis starts:
- Google Chrome browser (CDP on port 9222)
- Grammy Telegram bot (listens for button clicks)
- Cron job (searches Upwork every N minutes during 8:00-23:00)
The daemon sends a "Agent started" message to your Telegram chat.
Option B: Docker (server, 24/7 headless)
cd infra
cp .env.example .env # fill in Telegram + Claude OAuth credentials
docker compose up -dThis starts everything inside a container with a virtual display + VNC.
Open http://server:6080 in a browser to see Chrome via noVNC (for Upwork login).
Additional env variables for Docker (see .env.example):
| Variable | How to get |
|---|---|
CLAUDE_CODE_OAUTH_TOKEN |
On a machine with Claude Code logged in: security find-generic-password -s "claude-cli" -w (macOS) |
CLAUDE_ACCOUNT_UUID |
cat ~/.claude.json | grep -A5 oauthAccount |
CLAUDE_EMAIL |
Same as above |
CLAUDE_ORG_UUID |
Same as above |
See README.md for full Docker documentation.
┌─────────────────────────────────────────────────────┐
│ 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) │ │
│ │ Uses: mcp__upwork__* (CDP→:9222) │ │
│ │ Bash (yarn jobs, yarn tg) │ │
│ │ Read, Write │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ data/jobs.db (SQLite + FTS5, WAL mode) │
└─────────────────────────────────────────────────────┘
.mcp.json in project root configures the Playwright MCP server to connect to the daemon's browser via CDP:
{
"mcpServers": {
"upwork": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"]
}
}
}Claude Code child processes spawned by the daemon use --allowedTools mcp__upwork__*,Bash,Read,Write to access only the project-local Playwright MCP (connected to Upwork-authenticated browser) and basic tools.
src/
daemon.ts — main process: browser + bot + cron + task queue
jobs.ts — CLI: yarn jobs add/get/check/list/find/update/stats
tg.ts — CLI: yarn tg send/send-job/send-proposal
morning.ts — CLI: yarn morning (briefing: stats + browser status)
db/
index.ts — SQLite connection, WAL mode
schema.ts — jobs table, indexes
search.ts — FTS5 virtual table, triggers, searchJobs()
data/
profile.md — Ivan's profile (skills, projects, proposal style)
jobs.db — SQLite database (auto-created)
browser-data/— Chrome profile with Upwork session (auto-created)
logs/ — task execution logs (auto-created)
infra/
Dockerfile — container image (Ubuntu + Chrome + Node + VNC)
docker-compose.yml— single-command deploy
supervisord.conf — process manager config
start.sh — entrypoint (auth setup, cleanup, notifications)
- DO NOT apply to jobs without Ivan's explicit confirmation via Telegram button
- Behave on Upwork like a human: random delays (1-5s) between page actions
- Scroll through pages naturally — incremental scrolling, not instant jumps
- Log actions to data/logs/claude-tasks.log
- If Upwork session expired (login page detected) →
yarn tg send "Session expired, please log in manually" - If CAPTCHA detected →
yarn tg send "CAPTCHA detected, please solve manually" - Always exit cleanly after completing the task — don't hang
- IMPORTANT: When running
yarn jobs addoryarn jobs updatevia Bash, use SINGLE QUOTES for all values that contain dollar signs ($). Double quotes cause shell expansion ($0 → /bin/zsh, $5K → K). Example:yarn jobs add --budget '$5,000' --client-spent '$50K+'
Read data/profile.md — it contains skills, scoring factors, and ideal project criteria. Score each job 0-10:
| Score | Meaning |
|---|---|
| 8-10 | Perfect match — all scoring factors align |
| 6-7 | Good match — worth applying, minor gaps |
| 4-5 | Partial match — some relevant skills |
| 0-3 | Not a fit |
Only send jobs scoring >= 4 to Telegram via yarn tg send-job.
When generating proposals:
- Read the job:
yarn jobs get <id> - Read Ivan's profile:
data/profile.md - Search for similar past jobs:
yarn jobs find "<keywords from job title/skills>"- Jobs with
status=applied→ examples of GOOD proposals (Ivan approved these) - Jobs with
status=cancelled→ examples of BAD proposals (Ivan rejected these)
- Jobs with
- Generate a cover letter that:
- Matches Ivan's writing style from profile.md
- References specific relevant experience
- Is concise (3-5 short paragraphs)
- Opens with a hook related to the specific job
- Avoids generic filler ("I'm excited about this opportunity...")
- Includes a concrete next step or question
| Command | Purpose |
|---|---|
yarn morning |
Get briefing: stats, browser status, pending actions |
yarn tg send "<text>" |
Send a message to Ivan in Telegram |
yarn tg send-job <id> |
Send a job card with Apply/Skip buttons |
yarn tg send-proposal <id> |
Send proposal for review with Send/Cancel/Redo buttons |
yarn jobs add --title "..." --url "..." ... |
Save a new job to DB |
yarn jobs get <id> |
Get job details (with proposal if exists) |
yarn jobs check <url> |
Check if job URL already exists in DB |
yarn jobs list [--status applied] |
List jobs with optional status filter |
yarn jobs find "<query>" |
Full-text search across jobs |
yarn jobs update <id> --status applied |
Update job fields |
yarn jobs stats [--today|--week|--all] |
Statistics from DB |
Playwright MCP (mcp__upwork__*) |
Browse Upwork (connected via CDP to persistent browser) |
These are available in the Telegram chat (sent by the user, not by the agent):
| Command | Purpose |
|---|---|
/search |
Trigger a job search manually |
/status |
Show browser status, queue length, Claude running |
/report |
Job statistics for today |
/report week |
Job statistics for past 7 days |
/report all |
All-time statistics |
When searching for new jobs:
yarn morning— get context and stats- Use Playwright MCP tools (
mcp__upwork__*) to browse Upwork - Navigate to Upwork search with relevant filters for Ivan's stack
- For each job in results:
yarn jobs check <url>— skip if already in DB- Read job details (title, description, budget, client info)
- Score relevance (0-10) based on profile.md criteria
yarn jobs add --title "..." --url "..." --relevance-score N ...- If score >= 4:
yarn tg send-job <id>
- Scroll naturally through 2-3 pages of results
- Exit when done
Fullstack developer, 5+ years experience.
Core stack: TypeScript, React, Next.js, Node.js, Python, FastAPI, Django, Web3/Blockchain, AI/LLM integration.
Location: Vietnam (GMT+7).
Detailed profile: data/profile.md