submit_proposal
Submit a proposal for an Upwork job by providing the job URL, cover letter, bid rate, and screening answers. Automates typing and submission to help you apply efficiently.
Instructions
Submit a proposal/bid for an Upwork job. Before calling this:
Call get_job_details to get screening questions
Craft a personalized cover letter that:
Opens with the client's specific problem (not "I am an expert in...")
Shows concrete n8n examples relevant to their use case
Mentions specific workflow patterns (HTTP Request, Webhook, Code node, etc.)
Ends with a clear CTA
Answer ALL screening questions thoughtfully
The agent will automatically type and submit the proposal.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| job_url | Yes | Full Upwork job URL | |
| cover_letter | Yes | Full proposal text (cover letter) | |
| bid_rate | No | Hourly rate or fixed price in USD | |
| screening_answers | No | Answers to screening questions in order (from get_job_details) | |
| boost_bid | No | Use extra Connects to boost proposal. Default: false |
Implementation Reference
- src/tools/submit-proposal.ts:44-222 (handler)The main handler function that executes the submit_proposal tool logic: navigates to the job, clicks 'Apply Now', sets bid rate, fills cover letter, answers screening questions, and submits the proposal via headless browser (Puppeteer/Playwright).
export async function submitProposal(input: SubmitProposalInput): Promise<ProposalResult> { const page = await ensureLoggedIn(); const bidRate = input.bid_rate ?? config.bid.default; try { console.error('[submitProposal] Navigating to job:', input.job_url); await page.goto(input.job_url, { waitUntil: 'domcontentloaded', timeout: 30000 }); await humanDelay(2000, 4000); // Find and click "Apply Now" button const applySelectors = [ '[data-test="apply-button"]', '[data-cy="apply-button"]', 'a[href*="/proposals/new"]', 'button:has-text("Apply Now")', 'a:has-text("Apply Now")', ]; let clicked = false; for (const sel of applySelectors) { const btn = page.locator(sel).first(); if (await btn.isVisible({ timeout: 3000 }).catch(() => false)) { await btn.click(); clicked = true; console.error('[submitProposal] Clicked apply button:', sel); break; } } if (!clicked) { return { success: false, message: 'Could not find Apply Now button. Job may be closed or already applied.' }; } await humanDelay(2000, 4000); await page.waitForURL('**/proposals/**', { timeout: 15000 }).catch(() => { console.error('[submitProposal] URL did not change to /proposals/, continuing anyway'); }); // ─── Set bid rate ──────────────────────────────────────────────────────── const hourlyRateInput = page.locator( '[data-test="rate-input"] input, input[name="rate"], #rate-input, input[placeholder*="rate"], input[placeholder*="Rate"]' ).first(); if (await hourlyRateInput.isVisible({ timeout: 5000 }).catch(() => false)) { await hourlyRateInput.click({ clickCount: 3 }); await hourlyRateInput.fill(bidRate.toString()); console.error('[submitProposal] Set bid rate:', bidRate); await humanDelay(500, 1000); } // For fixed-price jobs — milestone / total price const fixedPriceInput = page.locator( 'input[name="bid_amount"], input[placeholder*="Total price"], [data-test="bid-price"] input' ).first(); if (await fixedPriceInput.isVisible({ timeout: 2000 }).catch(() => false)) { await fixedPriceInput.click({ clickCount: 3 }); await fixedPriceInput.fill(bidRate.toString()); console.error('[submitProposal] Set fixed price:', bidRate); await humanDelay(500, 1000); } // ─── Fill cover letter ─────────────────────────────────────────────────── const coverLetterSelectors = [ 'textarea[name="cover_letter"]', '[data-test="cover-letter"] textarea', '[data-cy="cover-letter"] textarea', 'textarea[placeholder*="cover"]', '.cover-letter textarea', '#cover_letter', ]; let coverLetterFilled = false; for (const sel of coverLetterSelectors) { const textarea = page.locator(sel).first(); if (await textarea.isVisible({ timeout: 3000 }).catch(() => false)) { await textarea.click(); await textarea.fill(''); // Type human-like await page.keyboard.type(input.cover_letter, { delay: Math.random() * 20 + 10 }); coverLetterFilled = true; console.error('[submitProposal] Cover letter filled.'); break; } } if (!coverLetterFilled) { console.error('[submitProposal] WARNING: Could not find cover letter textarea'); } await humanDelay(1000, 2000); // ─── Answer screening questions ────────────────────────────────────────── if (input.screening_answers && input.screening_answers.length > 0) { const questionTextareas = await page.locator( '[data-test="additional-question"] textarea, [data-cy="qa-answer"] textarea, .screening-question textarea' ).all(); for (let i = 0; i < Math.min(questionTextareas.length, input.screening_answers.length); i++) { const answer = input.screening_answers[i]; if (answer) { await questionTextareas[i].click(); await questionTextareas[i].fill(''); await page.keyboard.type(answer, { delay: Math.random() * 20 + 10 }); await humanDelay(500, 1000); console.error(`[submitProposal] Answered question ${i + 1}`); } } } await humanDelay(1500, 3000); // ─── Submit ────────────────────────────────────────────────────────────── const submitSelectors = [ '[data-test="submit-proposal"]', '[data-cy="submit-proposal"]', 'button[type="submit"]:has-text("Submit")', 'button:has-text("Submit Proposal")', 'button:has-text("Send Proposal")', ]; let submitted = false; for (const sel of submitSelectors) { const btn = page.locator(sel).first(); if (await btn.isVisible({ timeout: 3000 }).catch(() => false)) { console.error('[submitProposal] Clicking submit:', sel); await btn.click(); submitted = true; break; } } if (!submitted) { return { success: false, message: 'Could not find Submit button. Proposal NOT submitted.' }; } await humanDelay(3000, 5000); // Check for success const currentUrl = page.url(); const successIndicators = [ '[data-test="proposal-submitted"]', '[data-cy="proposal-submitted"]', '.proposal-submitted', ':has-text("Proposal Submitted")', ]; for (const sel of successIndicators) { if (await page.locator(sel).isVisible({ timeout: 5000 }).catch(() => false)) { return { success: true, message: 'Proposal submitted successfully!', proposal_url: currentUrl, }; } } // If URL changed to proposals list, likely succeeded if (currentUrl.includes('/proposals') && !currentUrl.includes('/new')) { return { success: true, message: 'Proposal submitted (redirected to proposals page).', proposal_url: currentUrl, }; } return { success: true, message: 'Proposal likely submitted. Please verify manually.', proposal_url: currentUrl, }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error('[submitProposal] Error:', msg); return { success: false, message: `Error submitting proposal: ${msg}` }; } finally { await page.close(); } } - src/tools/submit-proposal.ts:5-36 (schema)Zod schema defining the input shape for submit_proposal: job_url, cover_letter, bid_rate, estimated_duration, screening_answers, boost_bid, plus the ProposalResult output type.
export const SubmitProposalSchema = z.object({ job_url: z.string().describe('Full URL of the Upwork job posting'), cover_letter: z .string() .describe( 'The proposal/cover letter text. Should be personalized, mention the client\'s specific needs, and demonstrate n8n expertise.' ), bid_rate: z .number() .optional() .describe( `Hourly rate or fixed price bid in USD. Defaults to ${config.bid.default}. Range: ${config.bid.min}–${config.bid.max}.` ), estimated_duration: z .string() .optional() .describe('Estimated time to complete (for fixed-price jobs), e.g. "1 week", "3 days"'), screening_answers: z .array(z.string()) .optional() .default([]) .describe( 'Answers to screening questions in order. Get questions first via get_job_details.' ), boost_bid: z .coerce.boolean() .optional() .default(false) .describe('Whether to use extra Connects to boost the proposal visibility'), }); export type SubmitProposalInput = z.infer<typeof SubmitProposalSchema>; - src/index.ts:102-130 (registration)MCP tool registration in index.ts (the primary MCP server entry point) declaring the tool name 'submit_proposal' with its description and JSON Schema inputSchema.
{ name: 'submit_proposal', description: `Submit a proposal/bid for an Upwork job. Before calling this: 1. Call get_job_details to get screening questions 2. Craft a personalized cover letter that: - Opens with the client's specific problem (not "I am an expert in...") - Shows concrete n8n examples relevant to their use case - Mentions specific workflow patterns (HTTP Request, Webhook, Code node, etc.) - Ends with a clear CTA 3. Answer ALL screening questions thoughtfully The agent will automatically type and submit the proposal.`, inputSchema: { type: 'object', properties: { job_url: { type: 'string', description: 'Full Upwork job URL' }, cover_letter: { type: 'string', description: 'Full proposal text (cover letter)' }, bid_rate: { type: ['number', 'string'], description: 'Hourly rate or fixed price in USD' }, screening_answers: { type: 'array', items: { type: 'string' }, description: 'Answers to screening questions in order (from get_job_details)', }, boost_bid: { type: 'boolean', description: 'Use extra Connects to boost proposal. Default: false' }, }, required: ['job_url', 'cover_letter'], }, }, - src/index.ts:303-307 (registration)The switch-case dispatch in index.ts that parses incoming args with SubmitProposalSchema and calls submitProposal().
case 'submit_proposal': { const input = SubmitProposalSchema.parse(args); result = await submitProposal(input); break; } - src/gateway.ts:100-114 (registration)Secondary MCP gateway registration in gateway.ts also declaring 'submit_proposal' with a simplified inputSchema for an alternative MCP endpoint.
{ name: 'submit_proposal', description: 'Submit a proposal for an Upwork job. Call get_job_details first to get screening questions.', inputSchema: { type: 'object', properties: { job_url: { type: 'string' }, cover_letter: { type: 'string' }, bid_rate: { type: ['number', 'string'] }, screening_answers: { type: 'array', items: { type: 'string' } }, boost_bid: { type: 'boolean' }, }, required: ['job_url', 'cover_letter'], }, },