# LinkedIn — sessions, signals, searches, multi-session routing

This reference covers the full LinkedIn surface of the myagentmail
API. Read after the SKILL.md if you're building any of:

- a real-time intent watcher (Gojiberry-style)
- a one-shot ICP discovery / preview
- agentic outreach that emails the matched lead
- automation that respects per-account LinkedIn rate limits

The same `tk_…` master key works for everything below. Inbox-scoped
`ak_…` keys can also call most LinkedIn endpoints — confirm via the
key's permissions.

## Mental model — three things that look similar but aren't

1. **`POST /v1/linkedin/search/content` (raw search)** — single-shot
   keyword search returning posts as LinkedIn ranks them. No
   classifier, no firing rule, no profile enrichment. Fast and free
   of LLM cost. Use it when you want raw results to feed your own
   pipeline.

2. **`POST /v1/linkedin/searches` (historical search w/ firing rule)** —
   same underlying LinkedIn data, but every result runs through the
   two-pass classifier with the customer's plain-English firing rule
   applied. Persisted server-side so re-opening a past search is free.
   Use this when you want buyer-intent quality, not raw breadth.

3. **`POST /v1/linkedin/signals` (recurring watcher)** — a forever-
   running version of #2 that polls every N hours, fires on NEW
   matches only (dedup by post URL), and optionally HMAC-signs
   webhooks to your endpoint. Use this when the user's job is "tell
   me when someone matches my ICP," not "find me everyone who
   already did."

If you're picking between #2 and #3: **#3 is for the marketing
funnel** ("hot leads in real time"), **#2 is for the demo / discovery
flow** ("show me 50 examples right now"). Most apps need both.

## Connect a LinkedIn account

Three options. The dashboard widget calls the same endpoints under
the hood — agents use the API directly.

```typescript
// Option A: email + password (handles 2FA challenges)
const result = await client.linkedin.sessions.create({
  email: "user@example.com",
  password: "...",
  label: "Founder account",
});
// May return { ok: false, challenge: true, challengeId, challengeType: "pin" | "mobile" }
// → call .verify({ challengeId, pin }) for PIN, .poll({ challengeId }) for mobile-app approval

// Option B: import existing cookies the user extracted themselves
await client.linkedin.sessions.import({
  liAt: "AQE...",
  jsessionId: "ajax:...",
  label: "CEO account",
});

// Option C: drop-in React widget (browser-side)
// import { LinkedInConnect } from "@myagentmail/react"
// <LinkedInConnect proxyUrl="/api/myagentmail/linkedin" onConnected={...} />
```

Cookies are AES-256-GCM encrypted at rest. Each session is tenant-
scoped. The customer's account never leaves their connected session —
we never use shared scrape farms or rotating proxies.

## Auto-distribute (the default; almost always correct)

Every signal and search accepts an optional `sessionId`. Pass `null`
or omit it and the **session router** picks the least-utilized
healthy session at every action — search, profile lookup, connection
request, message send. Each connected LinkedIn account multiplies
the tenant's daily LinkedIn quota.

```typescript
// Auto — the recommended default.
await client.linkedin.signals.create({
  name: "...",
  query: "...",
  intentDescription: "...",
  // sessionId omitted == null == auto-distribute
});

// Pin — only when you need outreach to come from a specific profile
// (e.g. the CEO's account for personal-feeling outreach).
await client.linkedin.signals.create({
  name: "...",
  query: "...",
  intentDescription: "...",
  sessionId: "ses_specific_id",
});
```

You will see most agents incorrectly try to "smart-pick" a session by
listing sessions and choosing the first one. Don't do that — it
recreates the pinned-mode pathology. Pass `null` and let the router
pick.

## Firing rule — the most important field

The firing rule is a **plain-English description** the LLM classifier
treats as authoritative. The keyword (`query`) is just a coarse
LinkedIn-side pre-filter.

A good firing rule names:

1. **Who fires** — the role, seniority, company stage that should
   match.
2. **What signal** — the pain or behavior in the post that should
   match.
3. **Who explicitly does NOT fire** — vendors / agencies / recruiters
   / content marketers selling the same thing the buyer would buy.

```text
Flag as ready when the author is a founder, CEO, or head-of-sales at
a B2B SaaS company between Seed and Series B, complaining about cold
email being broken, low reply rates, or the team being burned out on
outbound. Skip vendors selling outbound tools, agencies offering
"done-for-you outbound", recruiters posting job ads, and content
marketers / coaches writing thought-leadership posts.
```

The negatives are non-optional. Without them the classifier surfaces
every "I help SaaS founders 3x their outbound" content marketer
imitating your customer's pain.

## Run a one-shot historical search

Synchronous; returns 10–15 s typical. Persisted so re-opening is free.

```typescript
const { search, results } = await client.linkedin.searches.run({
  query: "outbound is broken",
  intentDescription: "...",
  lookback: "past-month",        // past-24h | past-week | past-month
  minIntent: "medium",            // optional classifier floor
  limit: 50,                      // 1-100, default 50
});

// search.id  → re-fetch later with .get(id) — no LinkedIn quota spent
// search.tookMs, search.resultCount, search.errorCode (if any)

for (const r of results) {
  // r.author.name / r.author.role / r.author.company  ← verified
  // r.author.profileUrl
  // r.postUrl, r.postExcerpt
  // r.classification.intent: "high" | "medium" | "low"
  // r.classification.reason: one sentence citing evidence from the post
  // r.triageScore: 0-100 (Pass-1 confidence)
}
```

`r.author.role` and `r.author.company` are populated when the upstream
profile lookup succeeded. Null when it was deferred (per-poll
profile-lookup cap reached) — uncommon for searches because they have
a higher cap than recurring polls.

## Create a recurring intent watcher

```typescript
const { signal } = await client.linkedin.signals.create({
  name: "Cold-email complainers",
  query: "outbound is broken",
  intentDescription: "...",
  cadence: "every_6h",          // daily | every_12h | every_6h | manual
  webhookUrl: "https://my-app.com/hooks/intent",
  filterMinIntent: "medium",     // webhook delivery floor; matches at
                                  // or above this intent fire the webhook
});

// signal.webhookSecret is returned ONCE on create. Store it.
// HMAC-SHA256(secret, raw_request_body) === header X-MyAgentMail-Signature ("v1=<hex>")
```

Polls run server-side on a 5-minute cron. Each poll:

1. Picks a healthy session via the router (or the pinned one).
2. Searches LinkedIn for the keyword over the past 24 h.
3. **Pass-1 triage** (cheap text-only classifier) drops obvious
   rejects without paying any LinkedIn quota.
4. **Pass-2 verify** — for triage-pass posts (capped per poll),
   fetches the author's profile from cache or via session-router-
   selected profile lookup, runs the full classifier with role +
   company as authoritative context.
5. New matches that pass the customer's `filterMinIntent` fire a
   webhook delivery (HMAC-signed, exponential retry up to 5 attempts
   over ~12 h).

Posts that pass triage but blow the per-poll profile-lookup budget
are stored with `pendingEnrichment: true` and the next poll picks
them up. They don't fire webhooks until enrichment completes — we
don't ship text-only false positives.

## Webhook payload shape

```json
{
  "type": "signal.match",
  "signal": { "id": "...", "name": "...", "query": "..." },
  "match": { "id": "...", "foundAt": "2026-04-27T..." },
  "post": {
    "url": "https://www.linkedin.com/feed/update/urn:li:activity:...",
    "excerpt": "...",
    "postedAt": null
  },
  "author": {
    "name": "Jane Smith",
    "profileUrl": "https://www.linkedin.com/in/jane-smith/",
    "headline": "Founder & CEO at Acme",
    "role": "Founder & CEO",
    "company": "Acme"
  },
  "classification": {
    "engage": true,
    "intent": "high",
    "reason": "The author writes 'our outbound is dead' — direct match for the firing rule's complaint pattern."
  }
}
```

Verify the signature before trusting the body:

```typescript
import crypto from "node:crypto";

export async function POST(req: Request) {
  const body = await req.text();   // raw, before JSON.parse
  const sig = req.headers.get("x-myagentmail-signature") || "";
  const expected = "v1=" + crypto
    .createHmac("sha256", process.env.SIGNAL_WEBHOOK_SECRET!)
    .update(body)
    .digest("hex");
  if (sig !== expected) return new Response("bad signature", { status: 401 });

  const event = JSON.parse(body);
  if (event.type !== "signal.match") return new Response("ok");
  // ... your per-match logic
}
```

## All five signal kinds share one endpoint

This is the most common point of confusion: `keyword`, `engagement`,
`job_change_watchlist`, `connection_accepted`, and `message_received`
are **not** separate endpoints. They are all `POST /v1/linkedin/signals`,
discriminated by the `kind` field in the JSON body.

```
POST /v1/linkedin/signals          → create any kind (kind in body)
GET  /v1/linkedin/signals          → list all kinds
GET  /v1/linkedin/signals/:id      → fetch one
PATCH /v1/linkedin/signals/:id     → update
DELETE /v1/linkedin/signals/:id    → remove
POST /v1/linkedin/signals/:id/run  → force-run a poll

GET    /v1/linkedin/signals/:id/watchlist           → list entries (watchlist signals only)
POST   /v1/linkedin/signals/:id/watchlist           → add entries
DELETE /v1/linkedin/signals/:id/watchlist/:entryId  → remove an entry
```

The body shape per kind:

| `kind` | Required body fields |
|---|---|
| `keyword` | `name`, `query`, `intentDescription` |
| `engagement` | `name`, `target: {kind, url}`, `intentDescription` |
| `job_change_watchlist` | `name`, `watchlist: [{profileUrl}, ...]`, `intentDescription` |
| `connection_accepted` | `name`, `sessionId` (required), `intentDescription` |
| `message_received` | `name`, `sessionId` (required), `intentDescription` |

Common optional fields across all kinds: `cadence`, `webhookUrl`,
`filterMinIntent`, `sessionId`.

The two **inbox kinds** (`connection_accepted` + `message_received`) are
session-scoped and require a pinned `sessionId` — auto-distribute makes
no sense since the inbox + 1st-degree network belong to one specific
LinkedIn account. They have no `intentDescription` semantics (every
event fires by definition; `intentDescription` is stored as a label).
Recommended cadence is `hourly` (cheap reads).

### connection_accepted — outbound vs inbound attribution

When someone you sent an invite to accepts, this signal fires with
`connection.attribution: "outbound"`. When a stranger adds you (no
prior invite from your side), it fires with `attribution: "inbound"`.
This attribution is the bit Unipile's API doesn't surface — we do it
by diffing new connections against `linkedin_invitations_sent`, the
table the route handler populates on every successful
`POST /v1/linkedin/connections`.

Webhook payload:

```json
{
  "type": "signal.connection_accepted",
  "signal": { "id": "...", "name": "...", "kind": "connection_accepted" },
  "match": { "id": "...", "foundAt": "..." },
  "connection": {
    "profileUrn": "urn:li:fsd_profile:...",
    "name": "...",
    "profileUrl": "https://www.linkedin.com/in/.../",
    "headline": "...",
    "connectedAt": "...",
    "attribution": "outbound" | "inbound"
  }
}
```

### message_received — inbox watcher

Fires on any inbound DM (your own outbound messages are filtered out).
Includes a "note-bearing-invite shortcut": when an invite-with-note is
accepted, LinkedIn auto-creates a 1:1 chat with the original note as
the first message, so this signal catches the acceptance event a few
hours faster than `connection_accepted`'s slower connection-list diff
— and ALSO fires the connection_accepted signal on the same session
if one exists.

Webhook payload:

```json
{
  "type": "signal.message_received",
  "signal": { "id": "...", "name": "...", "kind": "message_received" },
  "match": { "id": "...", "foundAt": "..." },
  "message": {
    "messageUrn": "urn:li:msg_message:...",
    "text": "...",
    "deliveredAt": "..."
  },
  "sender": {
    "name": "...",
    "profileUrl": "...",
    "headline": null
  }
}
```

## Inbox endpoints (read + send)

Aside from signals, there are direct REST endpoints for the same
surface. Use these for one-shot agent work; use signals for
continuous monitoring.

```
POST /v1/linkedin/messages              → send a DM. Body: { sessionId, text,
                                            recipient | conversationUrn }.
                                          Recipient (preferred) is a profile URL,
                                          slug, ACoA... profileId, or full URN —
                                          the backend find-or-creates the 1:1
                                          thread. conversationUrn is for appending
                                          to a known thread.
GET  /v1/linkedin/conversations         → list inbox threads
GET  /v1/linkedin/conversations/:urn/messages
                                        → read messages in one thread
GET  /v1/linkedin/connections           → list 1st-degree connections
GET  /v1/linkedin/invitations/sent      → list pending outbound invites
POST /v1/linkedin/connections           → send a connection request (with note)

POST /v1/linkedin/posts                 → publish a feed post.
                                          Body: { sessionId, text (≤3000),
                                                  visibility?: ANYONE | CONNECTIONS,
                                                  image?: { dataUrl OR
                                                    base64 + contentType,
                                                    altText?, filename? } }
                                          image.dataUrl is the agent-friendly form:
                                          "data:image/png;base64,...". Max ~10MB,
                                          png/jpeg/gif/webp.
GET  /v1/linkedin/posts/by-author       → recent posts by a profile or company
GET  /v1/linkedin/posts/:urn/comments   → commenters with bodies
GET  /v1/linkedin/posts/:urn/reactions  → reactors with reactionType (LIKE,
                                          CELEBRATE, SUPPORT, ...)
```

## Engagement signals — watch a person or company for engagers

The keyword signal asks "who is talking about X?" An **engagement
signal** asks "who is engaging with this specific person/company's
posts?" — a much narrower, much higher-signal funnel. Use it to:

- Track competitor company pages: every reactor or commenter on Acme
  Inc.'s posts is a hand-raised lead for Acme's category.
- Track influencer profiles in your niche: their engagers self-select
  as your ICP.
- Track a champion's posts at a target account: the engagers are
  warm intros into the same company.

Mechanics:

1. We resolve the actor URL → `urn:li:fsd_profile:...` or
   `urn:li:fsd_company:...` at create time. Profile = `/in/{slug}`,
   company = `/company/{slug}`.
2. Each poll fetches the actor's recent posts since
   `lastSeenPostUrn` (first poll caps at 14 days).
3. For every new post we list reactors + commenters, dedup engagers
   across posts (comment beats reaction so we keep the verbatim
   comment text), triage cheap, profile-cache verify the survivors,
   and run the firing rule against each engager's role + company +
   headline.
4. We fire `signal.engagement` webhooks per matched engager. The
   `engager.commentText` is the killer field — quote it back in your
   outreach.

```typescript
await client.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 / RevOps / VPs at SaaS " +
    "companies between Series A and C — skip Acme employees, " +
    "competitors, and recruiters.",
  cadence: "every_12h",
  webhookUrl: "https://your-app.com/hooks/engagement",
  filterMinIntent: "medium",
});
```

Equivalent raw HTTP (use this if you're not on the TypeScript SDK):

```bash
curl -X POST https://myagentmail.com/v1/linkedin/signals \
  -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "engagement",
    "name": "Acme company-page engagers",
    "target": { "kind": "company", "url": "https://www.linkedin.com/company/acme/" },
    "intentDescription": "Flag engagers who are heads-of-sales / RevOps / VPs at SaaS companies between Series A and C — skip Acme employees, competitors, and recruiters.",
    "cadence": "every_12h",
    "webhookUrl": "https://your-app.com/hooks/engagement",
    "filterMinIntent": "medium"
  }'
```

`signal.engagement` payload:

```json
{
  "type": "signal.engagement",
  "signal": { "id": "...", "name": "...", "kind": "engagement" },
  "match":  { "id": "...", "foundAt": "2026-04-27T..." },
  "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…",
    "postedAt": null
  },
  "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": "..." }
}
```

Cost: one engagement-poll session-action per cycle, plus profile
lookups for the top-N triage-pass engagers (capped at
`PROFILE_LOOKUPS_PER_POLL`, default 5). Per-session daily budget for
engagement-poll is 50.

## Watchlist signals — track job changes for people you care about

Job changes are the cleanest non-content buying signal on LinkedIn —
"VP Eng moved from Beta to Acme" creates two warm-intro opportunities
inside Acme on day one. A **watchlist signal** is a list of profile
URLs we poll weekly; we fire only when role/company changes AND the
firing rule matches the new role.

```typescript
await client.linkedin.signals.createWatchlist({
  name: "Champions that left the gate keepers",
  profileUrls: [
    "https://www.linkedin.com/in/jane-doe-eng/",
    "https://www.linkedin.com/in/john-smith-pm/",
    // …up to 500 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-app.com/hooks/job-change",
  filterMinIntent: "medium",
});
```

Equivalent raw HTTP. Note that `watchlist` is an array of objects
(not strings) — each entry is `{profileUrl, label?}`:

```bash
curl -X POST https://myagentmail.com/v1/linkedin/signals \
  -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "job_change_watchlist",
    "name": "Champions that left the gate keepers",
    "watchlist": [
      { "profileUrl": "https://www.linkedin.com/in/jane-doe-eng/" },
      { "profileUrl": "https://www.linkedin.com/in/john-smith-pm/" }
    ],
    "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.",
    "webhookUrl": "https://your-app.com/hooks/job-change",
    "filterMinIntent": "medium"
  }'
```

Watchlist polls walk entries oldest-polled-first; each entry is
poll-rate-limited via the shared profile-lookup cache (7-day TTL,
30-min same-slug dedup across sessions). The per-session
`profile_lookup` budget (30/day) caps how many entries can be
refreshed in a cycle, so per-entry cadence ends up around weekly for
a 200-entry watchlist on a 1-account tenant.

First poll for an entry is a SNAPSHOT — we record the role/company
without firing. Subsequent polls diff and fire.

`signal.job_change` payload:

```json
{
  "type": "signal.job_change",
  "signal": { "id": "...", "name": "...", "kind": "job_change_watchlist" },
  "match":  { "id": "...", "foundAt": "2026-04-27T..." },
  "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": "..." }
}
```

Manage the list anytime:

```typescript
await client.linkedin.signals.watchlist.list(signalId);
await client.linkedin.signals.watchlist.add(signalId, [
  "https://www.linkedin.com/in/new-target/",
]);
await client.linkedin.signals.watchlist.remove(signalId, entryId);
```

Equivalent raw HTTP:

```bash
# List watchlist entries
curl https://myagentmail.com/v1/linkedin/signals/$SIGNAL_ID/watchlist \
  -H "Authorization: Bearer $MYAGENTMAIL_API_KEY"

# Add entries (note: same {profileUrl} object shape as create)
curl -X POST https://myagentmail.com/v1/linkedin/signals/$SIGNAL_ID/watchlist \
  -H "Authorization: Bearer $MYAGENTMAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "entries": [{ "profileUrl": "https://www.linkedin.com/in/new-target/" }] }'

# Remove a single entry
curl -X DELETE https://myagentmail.com/v1/linkedin/signals/$SIGNAL_ID/watchlist/$ENTRY_ID \
  -H "Authorization: Bearer $MYAGENTMAIL_API_KEY"
```

## End-to-end recipe — find a lead and email them

The whole intent-based outreach loop in six API calls:

```typescript
// 1. Connect a LinkedIn account (one-time, via widget or .sessions.create)
// 2. Capture the user's ICP as a firing rule (your UI)
// 3. Create the signal — auto-distribute mode
const { signal } = await client.linkedin.signals.create({
  name: user.companyName + " — buyer intent",
  query: user.keyword,
  intentDescription: user.firingRule,
  cadence: "every_6h",
  webhookUrl: `https://${user.subdomain}.yourapp.com/hooks/intent`,
  filterMinIntent: "medium",
});
await db.users.update(user.id, { signalSecret: signal.webhookSecret });

// 4. (Optional) one-shot preview screen — show the user 25 hits before
//    they pay, so they trust the rule
const seed = await client.linkedin.searches.run({
  query: user.keyword,
  intentDescription: user.firingRule,
  lookback: "past-month",
  minIntent: "medium",
  limit: 25,
});

// 5. On webhook arrival (your endpoint at /hooks/intent):
//    verify signature → use the verified author.role + author.company
//    + post excerpt to compose a personalized opener with your LLM →
//    send via myagentmail
const opener = await llm.compose({
  task: "personalized_opener",
  post: event.post,
  author: event.author,        // role + company are authoritative
  reason: event.classification.reason,
  seller: { product: "...", positioning: "..." },
});

await client.messages.send(process.env.SENDER_INBOX_ID!, {
  to: resolvedEmail,            // your enrichment provider resolves
                                // LinkedIn profile → email
  subject: opener.subject,
  plainBody: opener.body,
  verified: true,
});

// 6. (Optional) follow-up sequence — schedule a check after N days,
//    if no reply, send a different-angle follow-up via the messages
//    API. The starter repo's signal-runner handles this pattern.
```

## Per-session quota visibility

```typescript
const { sessions, budget } = await client.linkedin.sessions.utilization();

// budget = { search_signal: 50, search_history: 50, profile_lookup: 30,
//            engagement_poll: 50, system_poll: 200, ... }
// per session: { sessionId, label, counts: { profile_lookup: 17, ... },
//                remaining: { profile_lookup: 13, ... },
//                rateLimitedUntil, status }
```

Two read-class buckets are tracked separately on purpose:

- **`engagement_poll`** — caps user-controllable read activity:
  engagement signals you create, plus direct API reads against
  `/posts/by-author`, `/posts/:urn/comments`, `/posts/:urn/reactions`.
  Visible quota in the dashboard. Default cap: 50/day per session.
- **`system_poll`** — caps the auto-managed inbox signals
  (`connection_accepted`, `message_received`) that we provision
  automatically when a session connects. You can't tune their
  cadence; charging them against your visible quota would be
  misleading. Generous cap (200/day), degrades silently when
  exceeded — never blocks user-driven actions.

Use this to:

- Decide whether to retry now or back off.
- Surface a "connect another LinkedIn account to add headroom" nudge
  in your UI when `remaining.profile_lookup < 5` for every session.
- Pick the healthiest session yourself if you want pinned mode but
  also want resilience (rare — auto-distribute does this for you).

## Polling cadence — what we do and why

LinkedIn doesn't expose a webhook for connection acceptance or
inbound DMs, so we poll. How we poll matters more than how often.

### Cadence per signal kind

| Signal kind | Default cadence | Mean webhook latency | Why |
|---|---|---|---|
| `connection_accepted` (system) | `every_6h` | ~3 hours | Acceptance is batchy and low-latency-tolerant. Industry peers (Unipile, etc.) target similar windows. |
| `message_received` (system) | `hourly` | ~30 min | Inbound DMs are user-visible; sub-hour matters for "did they reply?" use cases. |
| `engagement` (user-created) | configurable: `daily` / `every_12h` / `every_6h` / `hourly` / `manual` | as configured | You decide. We default to `every_6h` for new engagement signals. |
| `keyword` / `job_change_watchlist` | configurable | as configured | You decide. |

### Why these intervals (and not "every 5 minutes")

LinkedIn's anti-bot fingerprints **fixed-clock periodic polling** —
the same account hitting the same endpoint at HH:00:05 every hour for
weeks. We avoid that pattern in two ways:

1. **Conservative base interval.** `connection_accepted` checks four
   times a day, not 24×. Most of those polls would have returned no
   new acceptances anyway — accepting a LinkedIn invite isn't an
   hourly-frequency event.
2. **±10% jitter on every `next_poll_at` calculation.** Hourly polls
   land at 54–66 min intervals. `every_6h` polls land at 5h24–6h36.
   Independent jitter per signal also smooths the LinkedIn call rate
   per session — two signals on the same account don't fire together.

Both choices line up with [Unipile's published guidance](https://developer.unipile.com/docs/detecting-accepted-invitations):
*"a few times per day with random delay, not at fixed time."*

### When you need sub-minute latency

If you need to react to an invitation acceptance faster than the
~3-hour mean — for example, an agent that sends an immediate DM the
moment a connection accepts — use the **invite-with-note shortcut**:

When the invite contains a personal note, accepting it auto-creates
a 1:1 chat between the two parties. That chat trips the
`message_received` signal (cadence: hourly + jitter, ~30-min mean
latency), which fans out to any `connection_accepted` signals on the
same session as a synthetic match. Net result: an invite-with-note
acceptance fires both signals within ~30 minutes vs ~3 hours.

This is the same pattern Unipile uses internally. The trade-off is
that you must include a note when sending the invite (which is good
outreach practice anyway) and the latency is still in minutes, not
seconds. LinkedIn doesn't expose a true real-time webhook to anyone.

### When to override the default cadence

```typescript
await client.linkedin.signals.update(signalId, {
  cadence: "hourly", // tighter than the default
});
```

Two cases worth knowing:

- **You want faster connection-accept detection without the
  invite-with-note workaround**: bump `connection_accepted` cadence
  from `every_6h` to `hourly`. Cost: 4× more polls per day on that
  signal. With a single connected session you'll absorb this fine
  (well under the 200/day `system_poll` cap). Account-flag risk is
  marginally higher because you're closer to LinkedIn's "feels like
  a bot" threshold.
- **You want to slow down `message_received`** for an account that
  doesn't get many DMs: drop to `every_6h` to halve the call rate.
  Trade-off is the same: webhook latency ~3h instead of ~30min.

System signals are user-visible in `client.linkedin.signals.list({ kind: 'connection_accepted' })`
and editable via `signals.update()`, so you can tune per-account if
you have one chatty session and one quiet one.

## Rate limits, errors, and what to do about them

| Code | Meaning | Right move |
|---|---|---|
| `RATE_LIMITED` (429) | Per-tenant API rate limit. Headers: `Retry-After`, `X-RateLimit-Bucket`. | Pause the tool loop for `retryAfterSec`. Tighter on `bucket: "llm"` (60/min) — searches and signals/run are LLM-heavy. |
| `LINKEDIN_RATE_LIMITED` | LinkedIn returned 429/999/checkpoint to one of the customer's sessions. That session is paused for 24 h. | If pinned, switch to auto-distribute (`sessionId: null`) so other sessions absorb the load. If already auto, prompt the user to connect another account. Other sessions keep working. |
| `NO_SESSION_AVAILABLE` | Every connected session is rate-limited or out of daily budget. | The user needs to connect another LinkedIn account, or wait for sessions to cool down. |
| `SESSION_NOT_FOUND` | Pinned session id is invalid or revoked. | Fall back to `sessionId: null` (auto). |
| `TOO_MANY_SESSIONS` (403) | Tenant hit the per-tenant LinkedIn-session cap (default 10). | Disconnect an unused session before connecting a new one. |
| `WEBHOOK_LIMIT_REACHED` (403) | Tenant hit the 100-webhook ceiling. | Delete a stale webhook. |
| `AUTH_LOCKOUT` (429) | This IP sent 20 failed-auth attempts in 60 s and is locked out for 15 min. | Stop retrying with the bad key. Surface a clear "fix MYAGENTMAIL_KEY" error to the user. |

The SDK populates `err.retryAfterSec` and `err.bucket` on
`MyAgentMailError` for 429s — honor them and you'll never trip the
auth lockout from a rate-limit retry loop.

## What this surface deliberately does not do

- **No scraping middlemen.** The customer's real LinkedIn session is
  used. Apify / proxy farms / "rotating residential" are not involved.
- **No DMs / message sending.** Only connection requests today. Use
  email for follow-up after the connection is accepted.
- **No firmographic enrichment beyond LinkedIn role + company.** Wire
  Apollo / Clearbit / your own if you need industry, headcount, HQ.
- **No multi-step email sequences.** Send each follow-up via the
  messages API; orchestrate cadence in your code or via the starter
  repo's `outboundExecuteCycle` pattern.
- **No hard guarantee LinkedIn data is "fresh."** Profile lookups are
  cached tenant-wide for 7 days. A user who switched jobs three days
  ago may still surface with their old role until the cache expires.

## See also

- [References → webhooks.md](./webhooks.md) — generic webhook
  delivery (separate from signal webhooks; same HMAC pattern).
- [References → websockets.md](./websockets.md) — real-time inbox
  events (separate channel from signals).
- KB → "Build an AI lead agent in 6 API calls" at
  <https://myagentmail.com/kb/example-lead-agent>.
- Open-source starter implementing this whole loop:
  <https://github.com/kamskans/myagentmail-outreach-starter>.
