Skip to main content
Glama

Curtis runs your LinkedIn outreach from your own machine, at the pace you would run it yourself — and keeps running it after you have stopped paying attention.

Curtis is an MCP server that runs on your laptop, which means you talk to him from chat — in Claude Code, Codex, or any MCP client. You log into LinkedIn once, in a real browser window. You hand him a list. He works through it over the following weeks: one profile at a time, inside working hours, behind a warm-up ramp, slowing himself down when the numbers say to.

you:     "import ~/prospects.csv and start reaching out"
Curtis:  → import_contacts → create_campaign → enroll_contacts → engine_control start
         "Campaign created: 350 contacts. Up to 12 invites/day, Mon–Fri 9–18,
          rising each week. At this pace the invitations take about a month."

...a week later...

you:     "how's it going?"
Curtis:  "48 invites sent, 23 accepted (48%), 3 replies. Acceptance rate is healthy,
          so next week the ramp moves to 16/day. Two profiles failed — both already
          1st-degree connections."

No LinkedIn API, no OAuth, no password handed to anyone. Curtis drives a real Chrome window with Playwright, using your session and your IP.


Contents

The idea Why Curtis exists · What Curtis actually does · Who this is for · What Curtis will not do · Read this before installing

Getting it running Requirements · Install · Connect your MCP client · Usage from chat · How it works

Reference CSV format · The recommended sequence · The safety controller · MCP tools · CLI and environment · Where your data lives

Working on it Architecture · When it breaks: selectors · Development · Security · Disclaimer


Related MCP server: LinkedIn MCP Server

Why Curtis exists

Outreach that works is not complicated. You find someone worth talking to, you connect, you wait, and once they accept you say something that could only have been written to them. Each of those steps takes about thirty seconds.

The problem is never the thirty seconds. It is thirty seconds times three hundred people, spread across six weeks, where the part that actually converts — the message after they accept — lands days later, on a day when you are busy with something else. Personal outreach is not hard. It is boring, and it asks you to keep a routine alive for longer than enthusiasm lasts. That is a scheduling problem, and willpower is a bad scheduler.

So people reach for a growth tool, and hit the other failure mode. Those tools solve the persistence problem by removing the person: a few hundred invitations a week, from a data-centre IP, with one template. That cuts against you twice. LinkedIn names "using automation tools to send invitations" as one of three stated triggers for restricting an account — and a second stated trigger is a high share of invitations that get ignored, which is what a template sent to hundreds of people earns. The volume that is supposed to compensate for the impersonality is the same volume that makes it impersonal.

Both failure modes come from treating outreach as a volume problem. It is not. It is a persistence problem wearing a volume problem's clothes.

Curtis is the third option. He does exactly the work you would do by hand, at roughly the speed you would do it, from your own computer — and he is still doing it in week six.

What Curtis actually does

He works as you, not as a service. There is no account to create and no integration to authorise. Curtis opens the Chrome that is already on your machine, you log in by hand once — 2FA and Google SSO both work fine — and from then on he reuses that session. Same browser, same fingerprint, same home IP as when you browse LinkedIn yourself. Your credentials are never seen, typed, or stored by anything but you and Chrome.

He is slow, and the slowness is the product. One action at a time, forty seconds to three minutes twenty between actions, a longer break every six to twelve actions, and only inside the window you configure — Monday to Friday, 9 to 18, by default. A list of two hundred contacts takes weeks, not an afternoon. That is not a limitation Curtis works around; it is the entire design. Bursts and 3 a.m. activity are the cheapest signals to detect, and they are also what makes outreach feel like outreach to the person receiving it.

He watches the number that matters and brakes himself. The volume knob is not the interesting one; the acceptance rate is. When it falls below your threshold, Curtis reduces the daily target on his own, because a low acceptance rate means the list or the message is wrong, and sending more of a message people ignore is precisely the behaviour that gets accounts restricted. If LinkedIn shows a captcha or a restriction notice, he stops completely and stays stopped across restarts until you tell him the situation is resolved.

He keeps going when you are not there. Installed as a user service, Curtis runs in the background, survives you closing your editor, and keeps advancing sequences that are measured in days. This is the part that is genuinely hard to replicate by hand: not the sending, the remembering.

He never takes over your screen. After the login there is no window. Curtis works in the background while you use your computer normally — no Chrome popping to the foreground, no cursor moving on its own, no session you are afraid to touch.

Who this is for

Curtis is built for people whose LinkedIn account is personally theirs and personally valuable:

  • Founders and solo sellers doing their own outreach, in their own name, where twenty good conversations beat two thousand invitations and the reply rate is the only metric that pays.

  • People building a network or looking for work — reaching hiring managers, people in a field you are moving into, alumni. Low volume, high personalisation, and an account you cannot afford to have restricted, because it is your CV.

What those have in common: a restriction is not an inconvenience to be absorbed by rotating to another seat. It is your professional identity. Curtis is conservative because the person running him cannot be reckless.

He is not built for agencies running outreach across client accounts at volume, for anyone who wants five hundred invitations a week, or for running unattended in the cloud. If that is the job, the honest answer is that a different tool fits it better — and that the risk profile is different from the one Curtis is designed around.

What Curtis will not do

The refusals are as much the product as the features:

  • No cloud. Curtis cannot run without your machine awake. That is a real limitation and it is also the reason LinkedIn sees your ordinary browser from your ordinary IP.

  • No note in the invitation by default. Five per month on a free account is a budget, and it belongs in the first message where it costs nothing.

  • No silent limit-raising. Curtis lowers his own limits on his own; he never raises them on his own. Raising one goes through update_safety_settings, whose tool description instructs the model to state the added risk and ask you first. Being honest about the mechanism: that is a prompt-level rule, not a code-level gate — the daemon will accept a raise it is handed. The hard bounds that are enforced in code are the schema's (weeklyInviteCeiling ≤ 700, and so on).

  • No "unlimited" mode. The weekly ceiling is a hard stop, not a suggestion, and the HALT on a captcha or restriction survives restarts by design — clearing it is a deliberate act, not a retry.

  • No contact database. Curtis works on a list you already have and have a legitimate reason to contact. He does not scrape one for you.

  • No telemetry. No analytics, no phone-home: nothing about your contacts or your campaigns leaves your computer, and Curtis's own code opens exactly two kinds of connection — LinkedIn, and 127.0.0.1/healthz, which is the CLI asking its own daemon whether it is up. What Curtis cannot claim is that no packet goes anywhere else: he drives your system Chrome, and Chrome keeps doing what Chrome does. Measured on a blank page with no LinkedIn navigation, it reached accounts.google.com, android.clients.google.com, clients2.google.com, www.google.com and www.gstatic.com — sign-in state, component updates, Safe Browsing. Those are Google's calls, not Curtis's, and they carry none of your data, but "no network call to anything but LinkedIn" would have been a false sentence and this one is the true one.

  • No promises about not getting banned. See the next section, which you should read before installing anything.


Read this before installing

⚠️ This section is not boilerplate. Read it before you install anything.

  • Automating LinkedIn violates its User Agreement (§8.2 forbids "bots or other automated methods to access the Services, add or download contacts, send or redirect messages"). Penalties range from a temporary restriction to a permanent ban.

  • Nobody can guarantee you won't get restricted — not Curtis, not the paid tools. Low volume and human pacing reduce the risk. They do not remove it.

  • Be sceptical of the numbers you find online. "100–200 invites/week", "75/day", "recovery under 15%" are figures from automation-vendor blogs with no primary source. LinkedIn publishes no numeric invite limit, weekly or total-pending, and states it cannot show you how much headroom you have left. Do not build your safety margin on them.

What LinkedIn actually says (primary sources)

  • Three stated triggers for an invite restriction: too many invites in a short period; a high share of invites ignored or marked as spam; and "using automation tools to send invitations" (Help a551012).

  • A restriction typically lasts a week, LinkedIn cannot shorten it, and Support will not tell you the reason. It scales up to a month for too many pending invites.

  • Withdrawing pending invites does not lift a restriction, and after withdrawing you cannot re-invite the same person for ~3 weeks (Help a550555).

  • Free accounts can attach a personalised note to only 5 invites per month (unlimited on Premium). That cap is monthly, not weekly.

  • The network limit remains 30,000 first-degree connections.

The ceilings Curtis ships with (weeklyInviteCeiling, caps, the ramp) are deliberate caution, not known LinkedIn thresholds. The signal worth watching is your acceptance rate, not a number from a blog post.

Use it responsibly, on your own account, at your own risk. See Disclaimer.


Requirements

Node.js

≥ 22.22.2 (required by mcp-use v2)

Browser

Google Chrome (recommended) or Playwright's bundled Chromium

OS

macOS or Linux. On Windows everything works except service installation

Install

npm install -g curtis-mcp

The package is curtis-mcp — plain curtis was already taken on npm — but the command it installs is curtis. It ships prebuilt, so nothing compiles on your machine: measured on a clean prefix with an empty npm cache, 23 seconds.

Don't install it from the git URL. npm install -g "git+https://…" does not work on this dependency tree, and the failure is not yours to debug: npm puts the 120-odd dependencies inside the package directory and, at that size, races with its own unpacking. The install dies half-written with an ENOENT on a dependency directory that vanished under it — Cannot cd into …/node_modules/fast-safe-stringify, or spawn sh ENOENT on better-sqlite3's install script, a different one each run. The same URL without -g works, and other packages install fine with -g: it is this combination.

Then run the initial setup:

curtis setup

This creates ~/.curtis/, generates the access token, verifies your browser and prints the next steps. If you don't have Chrome installed:

npx playwright install chromium

Connect your MCP client

Start the daemon and ask for the configuration line:

curtis daemon start && curtis mcp-config

Claude Code

claude mcp add --transport http curtis http://127.0.0.1:4311/mcp --header "Authorization: Bearer YOUR_TOKEN"

Codex — in ~/.codex/config.toml:

[mcp_servers.curtis]
url = "http://127.0.0.1:4311/mcp"
bearer_token_env_var = "CURTIS_TOKEN"

Codex reads the token from the environment rather than from the file, so it also needs, in your shell profile:

export CURTIS_TOKEN="YOUR_TOKEN"

curtis mcp-config prints all of this already filled in with your token.

The token lives in ~/.curtis/token with mode 0600. Do not share it: whoever holds it can send invites and messages as you.

Usage, from chat

1. Log into LinkedIn (once)

"log into LinkedIn"

A Chrome window opens on the login page. Sign in normally — Google SSO and 2FA both work. This is the only moment you see the browser: once you are in, the window closes and Curtis works in the background from then on. He never sees or stores your password, only the browser session, kept locally. On later runs you are already signed in.

2. Import your list and start

"import ~/Desktop/prospects.csv and start reaching out; once they accept, write: 'Hi {firstName}, thanks for connecting!'"

3. Check in

"where are we?" · "how many invites went out this week?" · "what's the acceptance rate?"

4. Slow down or stop

"drop to 8 invites a day" · "pause" · "stop everything"


How it works

You don't see the browser

Curtis works in the background: no window appearing or moving while you are doing something else. The one time you see Chrome is the login, because you type the credentials yourself.

This is not plain headless mode, and the difference is worth explaining because the whole design rests on it:

  • it drives system Chrome, not an automation Chromium, so the reported WebGL renderer is your actual GPU (measured: ANGLE (Apple, ANGLE Metal Renderer: Apple M1) in background too, not SwiftShader);

  • the User-Agent is cleaned of the Headless marker, in navigator.userAgent and in the HTTP User-Agent header alike. Client hints are not rewritten — they cannot be, a Playwright UA override does not propagate to them — but they need no rewriting: measured, headless Chrome already sends Sec-CH-UA: "Google Chrome";v="151", "Chromium";v="151", identical to the windowed one;

  • real display values (colour depth) are read during login and reused in the background.

Measured across navigator.webdriver, plugins, languages, User-Agent, client hints, WebGL renderer and colour depth, the background browser after first login is identical to the windowed one. Window dimensions are the exception, and honesty is worth more here than a clean claim: innerWidth/innerHeight match (1440×900, Playwright's viewport), but outerWidth/outerHeight do not — 1440×900 in background against 1420×786 in the window, with screenX/screenY differing too. Neither value is what a real Chrome would report: viewport emulation makes the window smaller than its own viewport when visible, and exactly equal to it when not. That is a residual tell in both modes, not one that background mode introduces.

If you want to watch anyway, HEADFUL=true brings the window back. That is mostly useful when a selector stops matching and you need to look at the page yourself.

Keeping campaigns moving with the client closed

Sequences run for days. So they keep advancing after you close Claude Code, install the daemon as a user service:

curtis service install

On macOS this creates a LaunchAgent, on Linux a systemd user unit. The service also starts the engine at login, so campaigns proceed on their own — always inside the time window, ramp and ceilings. --no-autostart keeps only the MCP server running.

curtis service uninstall   # to undo

CSV format

You need at least the profile URL column. Headers are recognised in English and Italian, case-insensitively.

profile_url,first_name,last_name,company,headline,industry
https://www.linkedin.com/in/jane-doe/,Jane,Doe,Acme,CEO @ Acme,Manufacturing
linkedin.com/in/john-smith,John,Smith,Beta Ltd,CTO,Software

Field

Accepted headers

Profile URL (required)

profile_url, url, linkedin, linkedin url, profile, profilo, link

First name

first_name, firstname, nome

Last name

last_name, lastname, cognome

Full name

full_name, name, nominativo

Company

company, azienda, organizzazione

Headline

headline, title, role, titolo, qualifica, ruolo

Location

location, city, località, città

Email

email, e-mail, mail

Any other column becomes {custom.COLUMN_NAME}, usable in templates. One good Industry or Event column is worth more than ten lines of generic copy.

If a row contains only a slug (jane-doe) instead of a URL, it is reconstructed into a profile URL but flagged in the import result. Check those: a typo in that column would otherwise pass silently as a valid profile.

Message templates

  • Placeholders: {firstName} {lastName} {fullName} {company} {headline} {location} {custom.NAME}

  • Spintax: {Hi|Hello|Hey} {firstName}! picks one variant per contact.

Identical messages sent in bulk are one of the strongest bot signals, and one of the strongest ignore me signals to a human. Use spintax, and give one specific reason you are writing to that person in particular. If the message works verbatim for anyone, it works for no one.


visit profile → wait 1 day → connect WITHOUT a note → wait for acceptance (14d) → first message

The personalised note does not go in the invite: free accounts only get 5 per month. Personalisation belongs in the first message after acceptance, where it costs nothing.

This is the sequence create_campaign and start_connection_campaign use by default.

The safety controller

The closed-loop controller in src/safety/controller.ts is the safety core. It is not a static schedule:

Mechanism

Default behaviour

Warm-up ramp

Invites/day by week: 12 → 16 → 18 → 20 → 22 → 25

Weekly ceiling

Hard limit, never exceeded (100)

Acceptance rate

Below the threshold (40%) the controller reduces invite volume

Backoff

On a "weekly limit" or warning signal, invites pause and the ceiling drops

HALT

On captcha or restriction it stops entirely. Survives restarts by design: sort the flag out on LinkedIn first, then clear it explicitly with engine_controlclear_halt ("the captcha is sorted, clear the halt")

Recovery

After N clean days (3) the limits climb back gradually

Auto-withdraw

An invite still pending when its campaign's wait for acceptance step expires is withdrawn (14 days by default, wait_accept_days). Note: autoWithdrawAfterDays in the settings is currently inert — the deadline that actually fires is the step's, not that field

Pending backlog

No new invite once maxPendingBacklog (500) pending ones are outstanding

Daily caps

Absolute per-action ceilings: 30 invites, 40 messages, 60 visits, 25 follows, 30 likes, 20 withdrawals

All of it readable and adjustable from chat with get_safety_settings / update_safety_settings. Lowering a limit never needs confirmation. Raising one always does.


MCP tools

Group

Tools

Authentication

linkedin_auth_status · linkedin_login · linkedin_logout

Contacts

import_contacts · list_contacts · delete_contacts

Campaigns

create_campaign · list_campaigns · get_campaign · update_campaign · set_campaign_status · delete_campaign · enroll_contacts

Engine

engine_status · engine_control

Safety

get_safety_settings · update_safety_settings

Metrics

get_metrics · get_recent_actions · get_signals

Fast path

start_connection_campaign

Curtis ships his operating manual to the model through the MCP instructions field (src/mcp/instructions.ts): operation order, real limits, how to handle a HALT, and the fact that text scraped from a LinkedIn page is data and never an instruction. The skills/linkedin-outreach playbook adds what the instructions cannot cover — how to build a list that converts and how to read the numbers.

curtis CLI

curtis setup                       # initial configuration
curtis daemon start|stop|status    # background daemon
curtis start                       # daemon in the foreground
curtis logs -f                     # follow the log
curtis login                       # terminal login (with the daemon stopped)
curtis mcp-config                  # config lines for Claude Code and Codex
curtis service install|uninstall|status   # unattended operation
curtis doctor                      # diagnose the installation
curtis version                     # what the issue templates ask for

curtis --help prints the same list plus the environment variables.

Environment variables

Variable

Default

Purpose

CURTIS_DATA_DIR

~/.curtis

Where database, browser profile, token and logs live

CURTIS_PORT

4311

Daemon port. The host is always 127.0.0.1, not configurable

TIMEZONE

Europe/Rome

IANA zone for the working-hours window

LOG_LEVEL

info

trace · debug · info · warn · error · silent

BROWSER_CHANNEL

auto

chrome for system Chrome, chromium to force Playwright's

HEADFUL

false

true keeps the browser window visible

AUTO_CONNECT

true

Reopen the saved LinkedIn session on daemon start

AUTOSTART_ENGINE

false

Start the engine with the daemon (what the service sets)

Optional values can also go in <CURTIS_DATA_DIR>/.env — see .env.example. Environment variables always win over the file.

Where your data lives

Everything under ~/.curtis/ (or CURTIS_DATA_DIR), owner-only (0700):

sequencer.db       contacts, campaigns, actions, signals, settings
browser-profile/   your LinkedIn session (this is your login: treat it like a password)
token              bearer token for the MCP endpoint (0600)
screenshots/       captured when an action fails
browser-hints.json display values recorded at login, replayed in the background
daemon.log
daemon.pid         pid of the running daemon
terms-accepted     records that you accepted the risk notice in `curtis setup`
.env               your optional settings, if you created one

None of it ever leaves your computer.

Removing Curtis

curtis service uninstall     # if you installed the service
curtis daemon stop
npm uninstall -g curtis-mcp
rm -rf ~/.curtis             # deletes the LinkedIn session, contacts and history

The last line is the one that matters: uninstalling the package leaves the data directory in place, and that directory contains a live LinkedIn session. Log out from chat with linkedin_logout first if you want the session invalidated on LinkedIn's side too, rather than just deleted locally.


Architecture

curtis start
  └─ daemon (one process)
       ├─ SQLite            contacts, campaigns, actions, settings
       ├─ Engine            worker loop: one action at a time, human delays
       ├─ Playwright        real Chrome, persistent profile
       └─ MCPServer         http://127.0.0.1:4311/mcp   (mcp-use v2, Streamable HTTP)

A daemon rather than an ephemeral MCP server because campaigns run for days and Playwright locks the browser profile to a single process: login, engine and tools have to live together. mcp-use v2 offers no stdio transport, but both Claude Code and Codex speak Streamable HTTP.

src/
  cli.ts               the `curtis` command
  daemon.ts            daemon process
  config.ts            paths, port, token, safety defaults
  mcp/                 MCP server: instructions, zod schemas, tools
  service/             application logic shared by the tools
  safety/controller.ts adaptive rate controller (safety core)
  sequencer/engine.ts  worker loop
  browser/             persistent session, background mode, human behaviour
  linkedin/            selectors (FRAGILE), guards, Playwright actions
  db/  importer/  util/  platform/
scripts/               selector maintenance tooling (see below)
skills/                playbook installable as a Claude Code skill
test/                  node:test, pure logic, no browser

When it breaks: selectors

This is the recurring maintenance task. LinkedIn changes the DOM and selectors stop matching. When an action fails the engine saves a screenshot whose path shows up in get_recent_actions, together with the aria-labels it actually saw on the page.

How the selectors are built, and why

Rewritten after probing the live DOM, and verified in the field with real sends. Two things to know before touching them:

  • Do not select by role. The top-card controls are not <button>s: "Connect" is an <a> with aria-label="Invite <Name> to connect" and no role="button". The old getByRole('button', { name: /^connect$/ }) returned zero matches — that was the bug.

  • Every selector is anchored to the person's name tokens. The "More profiles for you" sidebar has its own "Connect" buttons; without anchoring you end up inviting someone else. If the name can't be derived, Curtis clicks nothing and reports the failure.

Two more invariants that look like details and are not: never click({ force: true }) (it clicks by coordinates, and with the sticky top-nav overlapping it hits the "Claim Premium Page" banner and lands you in Premium checkout — this actually happened); and after every click the tool verifies it hasn't ended up on a Premium/checkout page, stopping if it has.

The RX table in src/linkedin/selectors.ts carries English and Italian alternatives, because LinkedIn renders its UI in the account's language. Those Italian strings are load-bearing: removing them blinds the tool on Italian accounts, silently, with every test still green.

The repair loop

# 1. see how the page looks now, on real profiles
npx tsx scripts/probe-targets.ts targets.json

# 2. fix src/linkedin/selectors.ts

# 3. verify the logic against a synthetic DOM (no network, no account)
npm run test:selectors

# 4. try one real send in isolation, outside the engine
npx tsx scripts/connect-no-note.ts targets.json 0

scripts/selectors-fixture-test.ts locks in the regressions already seen (the sidebar Connect, the sticky Premium banner, the hidden "Send" that stole the match). Re-run it every time you touch selectors.ts. A green fixture does not prove the live UI still looks like that — only step 1 tells you that.

Step 4 sends real connection requests to whoever is in targets.json, outside the engine and outside its counters. Point it at one profile you are happy to invite.

targets.json is a local file of real profile URLs used for probing. It is git-ignored and must stay that way: it contains third parties' personal data.

Development

npm install
npm run typecheck
npm test               # pure logic: templates, CSV, safety controller
npm run test:selectors # selectors against a synthetic DOM (needs a browser)
npm run build
npm run dev            # daemon in the foreground

If npm run test:selectors can't find Playwright's browser, point it at Chrome:

PW_BIN="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" npm run test:selectors

To work without touching your real data:

CURTIS_DATA_DIR=/tmp/curtis-dev CURTIS_PORT=4399 npm run dev

Contributions are welcome — see CONTRIBUTING.md. Selector fixes are the most valuable kind.

Security

The MCP endpoint listens on loopback only and is protected by a bearer token, because reaching it means being able to act on your LinkedIn account. Text scraped from LinkedIn pages is treated as untrusted data on its way into a model's context. The threat model, the design decisions behind it and how to report a vulnerability are in SECURITY.md.

Disclaimer

Curtis is a personal, educational project. Using it violates LinkedIn's Terms of Service and may get your account restricted or permanently banned. It is provided as is, with no warranty of any kind: the authors accept no liability for any consequence of its use, and anyone who distributes or runs it assumes full responsibility.

You are also responsible for how you treat other people's data. Only contact people you have a legitimate reason to contact, honour opt-outs, and remember that under the GDPR the contact list you import is personal data you are processing.

Low volume, gradual ramp, common sense.

License

MIT © Matteo Legrottaglie

A
license - permissive license
Not graded
quality - not tested
A
maintenance

Maintenance

Maintainers
Response time
Release cycle
1Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Run LinkedIn outreach from your AI chat: find leads, launch campaigns, send, and reply.

  • Give AI agents the LinkedIn tools to find, qualify, engage, and follow up with prospects.

  • Managed LinkedIn MCP server for AI agents: search, connect, message and enrich on accounts you own.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/matteolegrottaglie/curtis'

If you have feedback or need assistance with the MCP directory API, please join our Discord server