---
title: "Build an Intent-Based LinkedIn Outreach System in an Afternoon"
description: "Step-by-step tutorial: clone our open-source starter, connect a LinkedIn account, set up keyword + engagement + watchlist signals, and ship an end-to-end outreach agent that fires only on real buying intent. Built on myagentmail."
date: "2026-04-28"
author: "myagentmail"
tags: ["tutorial", "linkedin", "intent-signals", "outreach", "ai-agents", "automation"]
image: "/blog-images/building-cold-outreach-agent.png"
---

# Build an Intent-Based LinkedIn Outreach System in an Afternoon

Most outreach is broken because it ignores intent. You build a list, you blast a sequence, and you hope. The reply rate is whatever it is.

There's a more useful version of outreach: wait for someone to *signal* that they're in-market, then reach out within hours, while the context is still fresh. The signal might be a complaint about their current vendor, a comment on a competitor's launch post, or a job change into a role with budget. Each of those is a moment where a thoughtful, specific connection note has 5–20× the response rate of a cold blast.

This tutorial walks through building that system — the whole loop, from signal capture to drafted message in your approval queue — on top of the [myagentmail-outreach-starter](https://github.com/kamskans/myagentmail-outreach-starter) repo. About an afternoon's work, three accounts (myagentmail, OpenAI, your LinkedIn), zero infrastructure to operate.

## What you're building

```
┌──────────────────┐    ┌──────────────────┐    ┌──────────────────┐
│   LinkedIn       │    │   MyAgentMail    │    │  Your starter    │
│   - posts        │───▶│   - polls every  │───▶│   - HMAC-verify  │
│   - reactions    │    │     N hours      │    │   - GPT drafts   │
│   - comments     │    │   - LLM filters  │    │     a note       │
│   - job changes  │    │     by your rule │    │   - queues for   │
│                  │    │   - HMAC webhook │    │     approval     │
└──────────────────┘    └──────────────────┘    └─────────┬────────┘
                                                          │
                                                          ▼
                                                ┌──────────────────┐
                                                │  You: 1-click    │
                                                │  Approve →       │
                                                │  Connection sent │
                                                └──────────────────┘
```

Three things make this loop different from generic outreach tools:

1. **Three signal types**, not just keyword search. Watch for posts matching a phrase (*keyword*), watch for engagers on a specific person or company's posts (*engagement*), watch for job changes on a list of profiles (*watchlist*). All three feed the same approval queue.
2. **A plain-English firing rule** decides what fires, not just the keyword. The classifier treats your rule as authoritative — *"flag founders complaining about cold email; skip vendors selling outbound tools, agencies, content marketers"* — and cites a one-sentence reason on every match.
3. **Your real LinkedIn account does the work.** No proxy farms, no rotating residential IPs, no shared scraping pools. Connection requests arrive from your actual profile, which is what makes the response rate non-trivial in the first place.

## Setup — three accounts, ten minutes

| # | Step | Where | Notes |
|---|------|-------|-------|
| 1 | Sign up at myagentmail.com | https://myagentmail.com | Free signup. |
| 2 | Subscribe to the LinkedIn add-on | Dashboard → Billing | Solo tier ($29/mo) is enough for this tutorial. |
| 3 | Grab your master API key | Dashboard → API Keys | One key authorizes inbox, LinkedIn, and signals. |
| 4 | Get an OpenAI key | platform.openai.com | The starter uses `gpt-4o-mini` to draft notes — pennies a day. |
| 5 | Clone the starter | `git clone https://github.com/kamskans/myagentmail-outreach-starter` | ~3,000 lines of TypeScript. Fork it. |
| 6 | Connect a LinkedIn account | The starter's `/accounts` page | Either email + password (PIN or mobile-app push) or paste your `li_at` + `JSESSIONID` cookies. AES-256-GCM encrypted at rest. |

Configure your `.env`:

```bash
MYAGENTMAIL_API_KEY=tk_...
OPENAI_API_KEY=sk-...
# Webhook secret comes after you create your first signal, in the next section.
MYAGENTMAIL_WEBHOOK_SECRET=
```

`npm install && npm run dev`. The starter runs on `localhost:3000`. Use [ngrok](https://ngrok.com) or [cloudflared](https://github.com/cloudflare/cloudflared) to expose your laptop to public webhooks if you want signals to fire from the cloud cron — or use the **Run now** button on each signal to trigger polls manually while you develop.

## Pick the right signal kind for what you're tracking

The single most important decision in setting this up is which kind of signal matches your use case. Here's the table I'd want pinned to my desk on day one:

| If you want to find… | Use this signal kind | Example |
|---|---|---|
| People publicly describing a pain you solve | **Keyword** | *"outbound is broken"*, *"our CRM is killing us"* |
| ICPs warming up to a competitor | **Engagement** on the competitor's company page | Watch [`linkedin.com/company/competitor/`](https://www.linkedin.com/company/myagentmail/) → fire on every commenter who's a head of sales |
| ICPs your champion influencer's audience | **Engagement** on a profile | Watch a niche author whose audience IS your ICP → fire on engagers matching role/seniority |
| Champions who just got budget at a new company | **Watchlist** | List of 30 past customers' profile URLs → fire when their new role matches an ICP company |

Engagement and watchlist signals are the ones that meaningfully differentiate from generic intent providers. Keyword search is everywhere; engagement-on-a-tracked-actor and job-change-on-a-curated-list are not. Both feed the killer personalization angle: your connection note can quote the engager's own comment back at them, or congratulate the specific role transition — neither of which requires guessing.

## Signal 1: keyword — the warm-up

Open the starter at `/managed-signals`, click **New signal**, pick **Keyword**, and fill it in:

- **Name:** *Founders complaining about cold email*
- **Query:** `outbound is broken`
- **Firing rule:** *"Flag founders or operators complaining about cold email or outbound burnout. Skip vendors selling outbound tools, agencies pitching their services, and content marketers writing thought-leadership posts."*
- **Cadence:** Daily
- **Webhook URL:** `https://your-ngrok-url.ngrok.io/api/webhook` (or `http://localhost:3000/api/webhook` if you're triggering polls manually)
- **Filter:** Medium and above

Click **Create signal**. MyAgentMail returns a `whsec_...` secret — paste it into `MYAGENTMAIL_WEBHOOK_SECRET` in `.env` and restart the dev server. From here on, every match arrives signed; the starter's webhook handler verifies the signature before accepting it.

How it works under the hood: every poll runs a two-pass pipeline. Pass 1 is a cheap text-only triage that drops vendor pitches and content marketers. Pass 2 looks up the author's actual role + company on LinkedIn, then runs your firing rule with that verified context. The classifier returns `{engage: bool, intent: low|medium|high, reason: "one sentence"}`. The reason is shown on every match in your queue — every fire is auditable end-to-end.

## Signal 2: engagement — the high-intent one

This is the kind that earns its keep. The premise: someone who *engages* with a post by an actor you've chosen to track is, by their action, a hand-raised lead. The post is the trigger; the engager is the target.

```typescript
import { MyAgentMail } from "myagentmail";
const mam = new MyAgentMail({ apiKey: process.env.MYAGENTMAIL_API_KEY! });

await mam.linkedin.signals.createEngagement({
  name: "Acme company-page engagers",
  target: {
    kind: "company",
    url: "https://www.linkedin.com/company/acme/",
  },
  intentDescription:
    "Flag engagers who are heads-of-sales or RevOps at SaaS companies " +
    "between Series A and C. Skip Acme employees, direct competitors, " +
    "and recruiters.",
  webhookUrl: "https://your-ngrok-url.ngrok.io/api/webhook",
  filterMinIntent: "medium",
});
```

When it fires, the starter receives a `signal.engagement` payload like this:

```json
{
  "type": "signal.engagement",
  "signal": { "id": "...", "name": "Acme company-page engagers", "kind": "engagement" },
  "match":  { "id": "...", "foundAt": "2026-04-28T14:22:11Z" },
  "target": { "kind": "company", "url": "https://www.linkedin.com/company/acme/", "label": "Acme Inc." },
  "post": {
    "url": "https://www.linkedin.com/feed/update/urn:li:activity:.../",
    "excerpt": "We shipped real-time replay this morning..."
  },
  "engager": {
    "name": "Jane Smith",
    "profileUrl": "https://www.linkedin.com/in/jane-smith/",
    "headline": "VP RevOps at Beta",
    "role": "VP RevOps",
    "company": "Beta",
    "action": "commented",
    "commentText": "We're hitting this exact problem at Beta — DM'd you."
  },
  "classification": { "engage": true, "intent": "high", "reason": "..." }
}
```

The starter's webhook handler dispatches on `event.type` and routes engagement matches to a kind-specific drafter (`draftEngagementConnectMessage` in `src/lib/agent.ts`). This drafter quotes the engager's verbatim comment back at them — the killer personalization angle:

> *"Jane — saw your comment on Acme's replay launch. We hit the same problem at Beta last quarter; happy to share what worked. Open to connecting?"*

That note has a 3–5× higher acceptance rate than a generic "saw your post, would love to connect" — because it proves you actually read what they wrote.

**A note on cadence:** engagement signals fan out hard (multiple posts × reactions + comments per poll), so sub-daily cadence is gated by tier. Solo (2 sessions) clamps to **daily**; team (10 sessions) unlocks **every_12h**; agency (50) unlocks **every_6h**. Connect more accounts to run more frequent polls.

## Signal 3: watchlist — the slow burn

Job changes are warm-intro gold for the first 30–60 days. A VP of Sales who just moved from Beta to Acme has new budget, new tools to evaluate, and zero loyalty to incumbent vendors at Acme yet. If you've kept a list of ex-customers, ex-coworkers, or champions at past accounts, this signal converts that list into a perpetual "tell me when one of these people gets new budget" feed.

```typescript
await mam.linkedin.signals.createWatchlist({
  name: "Past champions on the move",
  profileUrls: [
    "https://www.linkedin.com/in/ex-champion-one/",
    "https://www.linkedin.com/in/ex-champion-two/",
    // ...up to 500 entries per signal
  ],
  intentDescription:
    "Fire when this person's NEW role is at a SaaS company between $5M and " +
    "$100M ARR and the title implies budget over outbound or sales tooling. " +
    "Skip moves into agencies and consultancies.",
  webhookUrl: "https://your-ngrok-url.ngrok.io/api/webhook",
  filterMinIntent: "medium",
});
```

Each entry is polled weekly through MyAgentMail's profile cache. The first poll is a snapshot — it records the role/company without firing. From the second poll onward, role/company diffs trigger the firing rule with the *new* role + company, so your rule decides whether the move is one you care about.

When it fires:

```json
{
  "type": "signal.job_change",
  "signal": { "id": "...", "name": "Past champions on the move", "kind": "job_change_watchlist" },
  "match":  { "id": "...", "foundAt": "2026-04-28T..." },
  "person": { "name": "Jane Doe", "profileUrl": "https://www.linkedin.com/in/jane-doe-eng/", "headline": "VP Engineering at Acme" },
  "change": { "oldRole": "Director of Engineering", "oldCompany": "OldCo", "newRole": "VP Engineering", "newCompany": "Acme" },
  "classification": { "engage": true, "intent": "high", "reason": "..." }
}
```

The starter routes this to `draftJobChangeConnectMessage`, which writes a specific congratulatory opener — *"Jane — saw the move from OldCo into the VP Eng seat at Acme. Curious what your first 90-day priorities look like; happy to share what worked when [past customer] made the same jump"* — instead of the generic *"congrats on the new role!"* every recruiter sends.

## What the queue looks like

Every match — regardless of kind — lands as one row in your approval queue at `/queue` with three fields:

- The drafted connection note (280 chars, GPT-4o-mini, ready to send)
- The reasoning line (*"Engaged on Acme Inc. (commented) — VP RevOps at Beta matches our ICP — intent: high"*)
- A one-click **Approve** that calls myagentmail's `/v1/linkedin/connections` and sends the request from your actual LinkedIn account

You stay in the loop on every send — that's the point. The agent does the work of finding signal, classifying it, and drafting the note. You spend 30 seconds reviewing. The whole approval queue is in `src/app/queue/page.tsx` if you want to swap the UI for something else.

## Wire it for production

A few things to do once the basic loop works:

**1. Multi-account routing.** Connect a second and third LinkedIn account in the starter's `/accounts` page. Then create signals with `sessionId: null` (the default) — myagentmail's session router automatically distributes polls across all healthy sessions, multiplying your daily quota by the number of accounts you've connected. Sub-daily cadences require this.

**2. Public webhook endpoint.** Deploy the starter to Vercel. Update each signal's webhook URL to point at your deployed domain. The starter reads `MYAGENTMAIL_WEBHOOK_SECRET` per signal — if you run multiple signals with different secrets, change `src/app/api/webhook/route.ts` to look up the right secret per signal ID.

**3. Tune the firing rules.** The classifier treats your `intentDescription` as authoritative. If you're getting too many false positives, add explicit "skip" examples (*"skip recruiters posting job ads", "skip authors at competing vendors"*). If you're getting too few matches, loosen the role/seniority constraints.

**4. Tune the drafters.** The three drafters in `src/lib/agent.ts` are short prompts. Edit them to match your voice. Add a `productPitch` argument if you want the note to mention what you do; remove the constraint on emoji if your audience uses them. The whole thing is ~40 lines.

**5. Watch the parse-rate logs.** Engagement signals do a small amount of LinkedIn page scraping under the hood (LinkedIn migrated comment text off their public API last year). MyAgentMail emits a structured log line per poll — `[engagement-poll] signal=X linkedin_comments=20 parsed_comments=18` — so when their next deploy breaks the parser you'll notice within a day instead of a month. If parsed_comments drops to zero across multiple polls, file a ticket; we re-derive parsers from real customer payloads, fix usually within hours.

## What you've shipped

In the time it took to read this, you have:

- Three live LinkedIn signals — one keyword, one engagement, one watchlist — running on your own connected account on a daily cadence
- An HMAC-verified webhook handler that drafts a personalized note per match using GPT, with a different drafter per signal kind
- An approval queue you can review in 30 seconds per match
- A one-click connection-request flow that sends from your real profile

That's the whole intent-based outreach loop. The rest is product judgment: which signals to add, how to tune the firing rules, how aggressive to be in approving. The infra is done.

## Resources

- **Open-source starter:** [github.com/kamskans/myagentmail-outreach-starter](https://github.com/kamskans/myagentmail-outreach-starter)
- **LinkedIn API reference:** [myagentmail.com/skills/myagentmail/references/linkedin.md](https://myagentmail.com/skills/myagentmail/references/linkedin.md) — full schemas for all three signal kinds, webhook payloads, and the multi-session router
- **TypeScript SDK:** `npm install myagentmail`
- **MCP server:** `npm install myagentmail-mcp` — same operations exposed as tools for Claude Desktop, Cursor, Windsurf, Cline
- **Discord & support:** [myagentmail.com](https://myagentmail.com)

If you build something interesting on top of this, send it our way. The starter is intentionally minimal — three thousand lines of forkable code — because we'd rather you customize it than wait for us to ship features.
