← All posts

How to Auto-Apply on Upwork Without Breaking ToS

"Auto-apply" has a bad reputation on Upwork, and it earned it honestly. The tools that promise to submit proposals while you sleep do it by logging into your account and clicking as you — and that gets accounts banned, no warning, JSS and reviews gone with it.

Since Upwork shipped its own official MCP server in August 2026, there's a version of this that Upwork itself sanctions: a job posts, your agent notices, checks it, drafts a proposal, and hands you the whole thing on your phone ready to send. You read it, you approve it, it goes out through Upwork's own tool. This guide builds that.

What's automated end to end: watching the marketplace, re-checking the job against your own bar, drafting the proposal, and getting it in front of you the moment it's ready. What isn't, and can't be: the actual send. That gate stays because Upwork's own terms require it — more on the exact clause below — not because we're being precious about it.

What you'll need:

  • Vibeworker account, with a filter built for the work you want
  • A Telegram bot (a few messages to @BotFather, free)
  • An API key for whatever model does the reasoning and drafting — Claude, GPT, GLM, whatever you already have
  • Somewhere to run a small always-on endpoint — a Vercel project works well, since it's just two HTTP routes
  • An Upwork account in good standing, to authorize Upwork's own MCP server

Nothing below is Claude-specific. MCP is a protocol, not an Anthropic product — Upwork's server and Vibeworker's server both speak it to any compliant client. The reasoning step is one API call asking a model to judge a job and write a proposal; any model with decent instruction-following handles that fine. The code samples use Claude's SDK because it's what this post's author reaches for, not because anything here requires it.

Build time: 30–45 minutes, most of it Telegram bot setup and testing.


How it works

New job posts on Upwork
  → Vibeworker scores it and checks it against your filter
  → Match → webhook fires to your agent, with full job + score data
  → Your agent re-checks the job against your own bar, drafts a proposal
  → Telegram message: the job, the score, the full draft, one tap to approve
  → You approve
  → Agent submits through Upwork's own MCP — Connects spend on your approval

Nothing here opens a browser tab until the very last, optional step of reading your sent proposal on Upwork's own site.


Step 1: Build the filter, turn on the webhook

  1. Sign up and build a filter for the work you actually want — category, budget, keywords.
  2. In the filter's Alert settings → Webhook, paste your endpoint URL (Step 2 gives you one) and enable it.

You can leave Vibeworker's native Telegram alerts off for this filter — your agent is about to send you a richer message than the plain job alert, and getting both would just be noise. (Full webhook payload reference →)

No persistent endpoint to point a webhook at? A webhook isn't mandatory — Vibeworker's REST API works just as well as the trigger, polled from a cron instead of pushed to a route:

// poll.mjs — run every few minutes via cron, systemd timer, whatever you've got
import { readFile, writeFile } from 'node:fs/promises';

const seen = new Set(JSON.parse(await readFile('seen.json', 'utf8').catch(() => '[]')));

const res = await fetch(
  'https://kttkatrmvlzsepgprqqd.supabase.co/functions/v1/public-jobs?sort=newest&limit=25',
  { headers: { Authorization: `Bearer ${process.env.VIBEWORKER_API_KEY}` } },
);
const { data } = await res.json();

for (const job of data.filter((j) => !seen.has(j.id))) {
  await evaluateAndDraft(job); // same logic as the webhook handler below
  seen.add(job.id);
}
await writeFile('seen.json', JSON.stringify([...seen]));

This is a genuinely fine substitute for the webhook — dedup, scoring, everything downstream is identical, you're just pulling instead of being pushed to. It is not a fine substitute for polling Upwork's own MCP directly instead of paying for Vibeworker at all, which is worth being direct about: Upwork's API & MCP Terms (4.1) explicitly withhold authorization for "activity designed to enumerate or continuously monitor Upwork's available content corpus." A cron against mcp.upwork.com is exactly that. A cron against Vibeworker's API is polling data Vibeworker already scraped and scored independently — a different corpus, under a different contract, one you're a paying party to rather than in breach of.


Step 2: The agent that receives the trigger

Whether a job arrives via webhook POST or the poll script above, the same function handles it: ask Claude to re-check the job and draft a proposal, then message you on Telegram with both.

// evaluateAndDraft.ts — called from either the webhook route or poll.mjs
import Anthropic from '@anthropic-ai/sdk';

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });

export async function evaluateAndDraft({ job, match, client }) {
  // A cheap gate before spending an LLM call at all — tune to your own bar
  if (match.scoreRedFlags < 6) return;

  const draft = await anthropic.messages.create({
    model: 'claude-sonnet-5',
    max_tokens: 1024,
    messages: [{
      role: 'user',
      content: `Job: ${job.title}
Description: ${job.description}
Budget: ${job.budget.display} (${job.type})
Client: ${client.totalSpent} spent, ${client.hireRate}% hire rate, ${client.location}
Vibeworker scores: quick-win ${match.scoreQuickWin}/10, scope clarity ${match.scoreScopeClarity}/10, red flags ${match.scoreRedFlags}/10 (higher = cleaner)

First line: "GOOD" or "SKIP" — would you personally apply to this, given the scores and description?
If GOOD, follow with a proposal under 150 words: lead with the client's actual problem, reference something specific from the brief, no generic openers, end with one question that proves you read it.`,
    }],
  });

  const text = draft.content[0].type === 'text' ? draft.content[0].text : '';
  if (text.startsWith('SKIP')) return;

  const proposalText = text.replace(/^GOOD\s*/, '');

  // Store the pending draft (a DB row, Vercel KV, whatever you're using) —
  // the Telegram approval callback needs it to know what "approve" means
  const pendingId = await storePendingDraft({ job, match, proposalText });

  await sendTelegramMessage({
    text: `*${job.title}*\n${job.budget.display} · ${job.type}\nQuickWin ${match.scoreQuickWin}/10\n\n${proposalText}\n\n[View job](${job.url})`,
    replyMarkup: {
      inline_keyboard: [[
        { text: '✅ Approve & submit', callback_data: `approve:${pendingId}` },
        { text: '✋ Skip', callback_data: `skip:${pendingId}` },
      ]],
    },
  });
}

Swap anthropic.messages.create for openai.chat.completions.create (Codex/GPT), GLM's API, or a local model — same prompt, same job-in-proposal-out shape. This function is the only place a model gets called at all.

The webhook route itself is now just plumbing on top of that function:

// app/api/vibeworker-webhook/route.ts
import { evaluateAndDraft } from '../../../evaluateAndDraft';

export async function POST(req: Request) {
  const body = await req.json();
  if (body.event === 'job.matched') await evaluateAndDraft(body);
  return new Response('ok');
}

The re-check matters more than it looks. Vibeworker's score is computed once, at ingest, the same for every user with similar preferences. The agent's pass happens with your specific instructions in the prompt — your niche, your rate floor, red lines Vibeworker's scoring doesn't know about — so it's a second, personalized filter, not a redundant one.


Step 3: Connect the agent to Upwork's official MCP

Your Telegram bot's "Approve" button needs to actually submit. That's the one action that goes through Upwork's own MCP server, never your own code touching Upwork directly.

Authorize it once, the same way any MCP client does:

npx @modelcontextprotocol/inspector https://mcp.upwork.com/mcp

or through whichever OAuth helper your MCP client library provides — the important part is that this is a real, interactive browser login on your Upwork account, once. Store the resulting access and refresh tokens somewhere your webhook handler can read them (an env var for testing, a secrets store for real use).

// app/api/telegram-webhook/route.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

export async function POST(req: Request) {
  const update = await req.json();
  const data: string = update.callback_query?.data ?? '';
  const [action, pendingId] = data.split(':');
  const pending = await getPendingDraft(pendingId);
  if (!pending) return new Response('ok');

  if (action === 'skip') {
    await answerCallback(update.callback_query.id, 'Skipped.');
    return new Response('ok');
  }

  const transport = new StreamableHTTPClientTransport(
    new URL('https://mcp.upwork.com/mcp'),
    { requestInit: { headers: { Authorization: `Bearer ${await getUpworkAccessToken()}` } } },
  );
  const upwork = new Client({ name: 'my-upwork-agent', version: '1.0.0' }, { capabilities: {} });
  await upwork.connect(transport);

  // Tool name and argument shape: confirm against the live tool list —
  // run `list_tools` once after connecting and read what's actually there
  // rather than trusting a name printed in a blog post.
  await upwork.callTool({
    name: 'submit_proposal',
    arguments: { jobUrl: pending.job.url, coverLetter: pending.proposalText },
  });

  await answerCallback(update.callback_query.id, 'Submitted.');
  return new Response('ok');
}

Point your Telegram bot's webhook at this route (setWebhook in the Bot API, one curl call) and the loop closes: tapping Approve is the only thing that reaches Upwork.


Why the approve button isn't optional

This is worth being precise about, because it's the entire premise of the title.

Clause 5.9 of Upwork's own API & MCP Terms of Use prohibits an Agent "independently selecting, ranking, scoring, or recommending among candidates, postings, proposals, or contracts using criteria the Agent itself determines," and separately prohibits it taking consequential action "absent the Principal's specific, contemporaneous direction identifying its object." An agent that judges a job itself and submits without you looking at that specific job first is exactly what that clause exists to stop.

That's not a Vibeworker house rule layered on top — it's the contract the reader accepts the moment they authorize their own account against mcp.upwork.com. Removing the approval step doesn't just go against what we'd recommend, it puts the account at risk under terms the reader agreed to directly, for the tool doing the submitting. There's no version of "fully unattended" that's also "without breaking ToS," so this guide doesn't have one.

What the approve step buys you that a fully unattended pipeline can't: your specific say-so on your specific Connects spend, on the one thing in this whole loop that costs real money and touches a real client relationship.


Limits

Everything through the draft step runs on your own Anthropic usage — cheap, since each check is one call. Upwork's server has no fee of its own; you pay in Connects, same as the website, only on jobs you actually approve.

If you'd rather trigger this by opening a Claude session yourself instead of running a standing webhook receiver, the MCP setup guide covers that version — same two servers, no server to host.


This is a starting point, not a spec

Nothing above is the one correct way to build this. A paid Vibeworker plan gives you the job feed on demand, unlimited, in real time — that's the actual product. This guide is one opinionated wiring of it, not the wiring.

Swap Telegram for Slack or Discord. Change the quality bar in the prompt to match how you actually pick jobs. Route the approval through a different channel, or through nothing at all if you're just checking a dashboard yourself. None of that changes what you're paying for — the data stays the same, unlimited and current, whatever you build on top of it.

The easiest way to build your own version: don't retype any of this by hand. Paste the whole guide, or just this page's URL, to whatever agent you already work in and ask it to adapt the pipeline to your setup — your notification channel, your evaluation criteria, your language. It has everything it needs to build the variant that fits how you actually work, not how we do.


Vibeworker scores every Upwork job against your profile and fires the moment a real match posts. Get started free →


Michael Watkins

Michael Watkins

Founder of Vibeworker. Helping freelancers win the Upwork game through speed and data.

Stop missing the jobs that matter

Vibeworker watches the Upwork feed and alerts you the moment a high-fit job appears — before the proposals pile up.

Get started free →