Upwork desde la terminal: buscar y leer cada oferta con upwork-cli

· upwork, cli, freelancing

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.