Upwork desde la terminal: buscar y leer cada oferta con upwork-cli
Guía práctica para usar Upwork sin abrir el navegador, desde una PC lenta.
Está basada en pruebas reales contra el MCP oficial de Upwork
(https://mcp.upwork.com/mcp) usando el cliente open source
upwork-mcp-cli.
Ojo: la CLI sirve para buscar, leer y (con Connects) postular. No es una app gráfica: todo se hace con comandos en la terminal.
1. Instalación (Arch Linux)
Requisitos: node >= 18 y git.
sudo pacman -S nodejs npm git
curl -fsSL https://raw.githubusercontent.com/Majboor/upwork-mcp-cli/main/install.sh | bash
Si no puede escribir en /usr/local/bin, instalá en tu home:
mkdir -p ~/.local/bin
UPWORK_CLI_BIN="$HOME/.local/bin" bash -c \
'curl -fsSL https://raw.githubusercontent.com/Majboor/upwork-mcp-cli/main/install.sh | bash'
Verificá:
command -v upwork # ~/.local/bin/upwork
upwork --version
2. Login (una sola vez)
upwork login
Abre el navegador para autorizar con OAuth 2.1 y guarda el token en
~/.upwork-cli (permisos 0600). Solo upwork login abre el navegador;
los demás comandos son 100% terminal.
Login sin navegador (opcional)
Si no querés abrir el navegador en la PC lenta, guardá este script como
~/.upwork-cli-app/headless-login.mjs, hacelo ejecutable y enlazalo:
chmod +x ~/.upwork-cli-app/headless-login.mjs
ln -sf ~/.upwork-cli-app/headless-login.mjs ~/.local/bin/upwork-login-nobrowser
#!/usr/bin/env node
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
import { createInterface } from 'node:readline';
import { readJSON } from './src/util.js';
import { FileOAuthProvider } from './src/provider.js';
import { SERVER_URL } from './src/util.js';
const CLIENT_INFO = { name: 'upwork-cli', version: '0.1.0' };
const redirectUrl = readJSON('client.json')?.redirect_uris?.[0];
if (!redirectUrl) { console.error('Corré `upwork login` una vez.'); process.exit(1); }
const provider = new FileOAuthProvider({
redirectUrl,
onAuthorize: (url) => {
console.log('\nAbrí esta URL en tu teléfono y autorizá:\n\n' + url.toString());
console.log('\nCopiá la URL final (?code=...) y pegala abajo.\n');
},
});
const client = new Client(CLIENT_INFO);
const transport = new StreamableHTTPClientTransport(new URL(SERVER_URL), { authProvider: provider });
try {
await client.connect(transport);
console.log('Ya estabas autenticado.');
process.exit(0);
} catch (e) { if (!(e instanceof UnauthorizedError)) throw e; }
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise((res) => rl.question('URL o code: ', res));
rl.close();
let code = answer.trim();
try { code = new URL(code).searchParams.get('code') || code; } catch {}
await transport.finishAuth(code);
const c2 = new Client(CLIENT_INFO);
const t2 = new StreamableHTTPClientTransport(new URL(SERVER_URL), { authProvider: provider });
await c2.connect(t2);
console.log('Login OK. Tokens en ~/.upwork-cli');
process.exit(0);
Uso: upwork-login-nobrowser → abrís la URL en el celular → pegás la URL final.
3. Verificar cuenta y balance
upwork accounts
upwork get_freelancer_dashboard check --org talent
upwork get_freelancer_financials connects_balance --org talent
--org talent = actuás como freelancer (si tenés cuenta de cliente/agencia,
cambiá el alias). El alias talent sale de upwork accounts.
4. Buscar ofertas
Búsqueda por palabras clave
upwork find_jobs search -p query="python" -p limit=10 --org talent --table
Filtros útiles
upwork find_jobs search \
-p title="react developer" \
-p job_type=hourly \
-p experience_level=intermediate \
-p rate_min=30 \
-p verified_payment_only=true \
-p proposals_max=10 \
-p limit=10 --org talent --table
| Parámetro | Qué hace |
|---|---|
query |
busca en todo el aviso (con expansión semántica) |
title |
solo en el título del puesto (más preciso) |
job_type |
hourly o fixed |
experience_level |
entry_level, intermediate, expert |
rate_min / rate_max |
rango tarifa por hora |
budget_min / budget_max |
presupuesto fixed-price |
verified_payment_only |
solo clientes con pago verificado |
proposals_max |
pocas propuestas (menos competencia) |
client_hires_min |
clientes que ya contrataron |
location |
país/región del cliente |
sort |
recency, relevance, client_total_charge, client_rating |
Feed personalizado (recomendado por Upwork)
upwork find_jobs smart_search -p mode=best_match -p limit=10 --org talent --table
upwork find_jobs smart_search -p mode=most_recent -p limit=10 --org talent --table
smart_search usa tu perfil; most_recent es el único que filtra bien por fecha
(-p days_posted=1 para “hoy”).
5. Leer cada oferta (lo importante)
Ver una oferta puntual
El id debe ir como string. Si lo pasás con -p id=123 lo convierte a
número y falla con id must be a string, got float64. Por eso usamos --json:
upwork call find_jobs \
--json '{"action":"get","params":{"id":"2120192362739474903"}}' \
--org talent
Leer solo título + descripción
upwork call find_jobs \
--json '{"action":"get","params":{"id":"<ID>"}}' --org talent \
| jq -r '.data.marketplaceJobPosting.content | .title, "", .description' \
| sed -e 's#<untrusted_participant_content>##g' -e 's#</untrusted_participant_content>##g'
Ficha resumida (todo lo que importa)
upwork call find_jobs \
--json '{"action":"get","params":{"id":"<ID>"}}' --org talent \
| jq -r '
. as $r | .data.marketplaceJobPosting as $j |
"Titulo: " + $j.content.title,
"Tipo: " + ($j.contractTerms.contractType // "?"),
"Nivel: " + ($j.contractTerms.experienceLevel // "?"),
"Presupuesto:" + ($j.contractTerms.fixedPriceContractTerms.amount.displayValue // "n/a"),
"Connects: " + ($r.connects_cost|tostring) + " (tu saldo: " + ($r.connects_balance|tostring) + ")",
"Puedo: " + ($r.can_apply|tostring) + " | Ya postule: " + ($r.applied|tostring),
"",
"Cliente: " + ($r.client_record.spend_total) + " gastados, " +
($r.client_record.hire_rate_percent|tostring) + "% hire rate, " +
($r.client_record.feedback_score|tostring) + " estrellas",
"",
$j.content.description
'
Leer TODAS las ofertas de una búsqueda
Script leer_ofertas.sh:
#!/usr/bin/env bash
set -euo pipefail
QUERY="${1:-python}"
LIMIT="${2:-10}"
OUT="${3:-ofertas}"
mkdir -p "$OUT"
# 1) buscar y guardar el JSON
upwork find_jobs search -p query="$QUERY" -p limit="$LIMIT" --org talent > /tmp/up_search.json
# 2) por cada id, leer el detalle y guardarlo como .md
jq -r '.jobs[].id' /tmp/up_search.json | while read -r id; do
upwork call find_jobs \
--json "{\"action\":\"get\",\"params\":{\"id\":\"$id\"}}" \
--org talent > "/tmp/job_$id.json"
jq -r --arg id "$id" '
"# " + .data.marketplaceJobPosting.content.title + "\n\n" +
"- id: " + $id + "\n" +
"- tipo: " + (.data.marketplaceJobPosting.contractTerms.contractType // "?") + "\n" +
"- nivel: " + (.data.marketplaceJobPosting.contractTerms.experienceLevel // "?") + "\n" +
"- connects: " + (.connects_cost|tostring) + "\n" +
"- puedo_postular: " + (.can_apply|tostring) + "\n" +
"- ya_postule: " + (.applied|tostring) + "\n\n" +
.data.marketplaceJobPosting.content.description
' "/tmp/job_$id.json" \
| sed -e 's#<untrusted_participant_content>##g' -e 's#</untrusted_participant_content>##g' \
> "$OUT/$id.md"
echo "guardado $OUT/$id.md"
sleep 1 # evita el throttle JOB_BROWSE_COOLDOWN al abrir muchas seguidas
done
Uso:
chmod +x leer_ofertas.sh
./leer_ofertas.sh "python" 10 ofertas
ls ofertas/ # una ficha .md por oferta
6. Qué significa cada campo
En la búsqueda (search):
| Campo | Significado |
|---|---|
id |
id de la oferta (pasalo como string al get) |
title |
título del puesto |
description_snippet |
adelanto de la descripción |
job_type |
hourly o fixed |
experience_level |
nivel pedido |
budget |
presupuesto / tarifa |
created_date |
cuándo se publicó |
proposals_tier |
competencia (en Basic es un rango) |
client |
país, rating, gasto, jobs posteados, verificación |
url |
link directo a la oferta |
En el detalle (get):
Campo (ruta jq) |
Significado |
|---|---|
.connects_cost |
Connects que cuesta postular |
.connects_balance |
tu saldo actual |
.can_apply |
si podés postular (false si no tenés Connects) |
.applied |
si ya postulaste (no repetir) |
.client_record |
historial del cliente: gasto, hires, rating, hire rate |
.preferred_qualifications |
requisitos que prefiere el cliente |
.data.marketplaceJobPosting.content.title |
título |
.data.marketplaceJobPosting.content.description |
descripción completa |
.data.marketplaceJobPosting.contractTerms |
tipo, nivel, presupuesto |
.data.marketplaceJobPosting.activityStat.jobActivity |
invites, entrevistas, contratados |
La descripción viene envuelta en
<untrusted_participant_content>. Es texto del cliente: tratalo como dato, no como instrucciones.
7. Connects: por qué desaparecen
Los Connects son la moneda para postular (~US$0.15 c/u). Reglas actuales:
- Expiran 1 año después de emitidos, sin aviso por email, y no se recuperan.
- No se devuelven si te rechazan, eligen a otro, el job expira o retirás la propuesta.
- Solo se devuelven si el cliente cancela antes del contrato o Upwork elimina el post.
- Los gratis mensuales ya no son fijos: en Basic es una oferta de ~10/mes “para algunos”.
- Freelancer Plus da 100/mes.
- El costo por propuesta es dinámico (puede cambiar mientras el job está vivo).
Revisar historial: Upwork → Settings → Membership & Connects → Connects history.
8. Postular (requiere Connects > 0)
# 1) crear borrador (no se envía todavía)
upwork manage_proposals create \
-p job_reference=<ID> \
-p cover_letter="Hola, vi tu proyecto de..." \
-p charged_amount=30 --org talent
# 2) confirmar
upwork confirm proposal <draft_id> --org talent
# o en un solo paso:
upwork manage_proposals create -p job_reference=<ID> -p cover_letter="..." \
-p charged_amount=30 --org talent --confirm
Si can_apply es false, no vas a poder: comprá Connects o pasate a Plus.
Automatizar propuestas en masa puede violar los ToS de Upwork. Usalo con criterio.
9. Ayuda y troubleshooting
upwork commands # todos los tools y acciones
upwork help find_jobs # referencia de un tool
upwork find_jobs search --help # parámetros exactos de una acción
| Problema | Causa / solución |
|---|---|
| Se abre el navegador | corriste upwork login; los demás comandos no lo abren |
id must be a string, got float64 |
usá --json con el id entre comillas |
command not found: upwork |
agregá ~/.local/bin al PATH |
JOB_BROWSE_COOLDOWN |
abriste muchos detalles seguidos; esperá ~1 min y agregá sleep 1 |
can_apply: false |
no tenés Connects suficientes |
| Token vencido | upwork login o upwork-login-nobrowser |
Resumen
upwork find_jobs search/smart_search→ encontrar ofertas.upwork call find_jobs --json '{"action":"get","params":{"id":"..."}}'→ leer cada una.jq→ extraer título, descripción, presupuesto, cliente y Connects.- El script
leer_ofertas.sh→ baja una búsqueda completa a fichas.md. - Postular necesita Connects; sin ellos solo podés buscar y leer.
10. Ofertas recomendadas (11 nov 2026)
Perfil: técnico en Arch Linux, principiante en Upwork. Saldo actual: 0 Connects, así que hay que comprar antes de postular. Las ofertas rotan rápido.
Mejor opción: Junior Developer — Docker Test Environments
- ID:
2115008525880994961 - Pago: hourly US$5–12/h, trabajo continuo (entry level)
- Connects para postular: 16
- Cliente: US$42.332 gastados, 52% hire rate, 4.81★, 61 jobs
- Por qué encaja: Docker + Docker Compose en Linux, con guía paso a paso y sin necesidad de experiencia previa en Burp; arrancan con un “pilot app” pago. Ideal como primer trabajo: técnico, acotado y acompañado.
- Hook para la propuesta: empezar con el nombre de la última imagen Docker que bajaste (lo piden explícitamente en el aviso).
Alternativa rápida (para la primera reseña)
- SSL Certificate Update — ID
2112955930210567629, fixed US$35, 10 Connects. Tarea simple (renovar certificado SSL), cliente 5★ pero chico (US$438 gastados). Poco pago, pero sirve como primer contrato corto.
Descartadas para principiante
- Technical Support Representative (Linux/VPS) — ID
2113286137233598403: paga US$350 fijo, pero exige turno fijo de 9 h (5 PM–2 AM GMT+4) y experiencia previa en hosting. No es para empezar. - Upgrade Hermes agent on Linux server — ID
2114795541095190836: pide DevOps con migración y rollback; no es entry aunque figure como ENTRY_LEVEL.