LinkedIn Outreach
BYO Chrome extension companion that runs connection requests, DMs, and comments from the customer's own browser β heycmo never holds the LinkedIn cookie.
LinkedIn Outreach
LinkedIn outreach uses a bring-your-own Chrome extension model. heycmo's backend drafts and approves the actions; the customer's browser runs them via DOM events on their own machine. heycmo never sees, stores, or proxies the LinkedIn session cookie β LinkedIn auth lives entirely in the customer's browser.
This is the only LinkedIn automation posture that doesn't put the customer's account at restriction risk.
Configuration
| Property | Value |
|---|---|
| Phase | 3.1 |
| Schema | LinkedinOutreachQueue |
| Policy module | apps/api/agent/lib/linkedin-outreach-policy.ts |
| Companion | Chrome extension (separate Web Store package) |
| UI | /outreach/linkedin |
Action types
The queue table has a single action column with three values:
| Action | Description | Day cap |
|---|---|---|
connection_request | Send a connection invite, with an optional β€280-char note. Profile must be a linkedin.com/in/<slug> URL. | 15/day |
message | Direct message to a 1st-degree connection. Body up to 2000 chars. | 30/day |
comment | Comment on a prospect's feed post. URL can be any LinkedIn URL (post, article). Body up to 1000 chars. | 50/day |
Caps are per-customer per rolling-24h window β not calendar day, because customer timezones vary and LinkedIn's rate-limit clock is effectively a sliding window.
Hard limits
From linkedin-outreach-policy.ts:
export const MAX_CONNECTIONS_PER_DAY = 15;
export const MAX_MESSAGES_PER_DAY = 30;
export const MAX_COMMENTS_PER_DAY = 50;
export const MAX_CONNECTION_NOTE_LENGTH = 280;
export const MAX_MESSAGE_LENGTH = 2000;
export const MAX_COMMENT_LENGTH = 1000;
export const DRAFT_EXPIRY_DAYS = 7;A warm account safely sends 15β20 connection invites per day. Tools that push 30+ are what cause restrictions within 1β3 weeks. We default conservative.
The LinkedIn UI truncates connection notes at ~280 chars (even though the API technically allows 300), so we cap at 280 to avoid surprise truncations.
Schema
model LinkedinOutreachQueue {
id String @id @default(uuid()) @db.Uuid
customerId String @db.Uuid
action String /// 'connection_request' | 'message' | 'comment'
status String @default("pending_review")
prospectUrl String
prospectName String?
prospectCompany String?
prospectRole String?
body String
dayOffset Int @default(0)
intentSource String?
intentReason String?
approvedAt DateTime?
approvedByUserId String? @db.Uuid
sentAt DateTime?
resultUrl String?
errorMessage String?
lockedUntil DateTime?
expiresAt DateTime @default(dbgenerated("now() + interval '7 days'"))
@@unique([customerId, prospectUrl, action, dayOffset])
}Status flow:
pending_review ββapproveβββΆ approved ββclaimβββΆ (locked for 5 min) ββresultβββΆ sent
β β β
ββskipβββΆ skipped βββcap hitβββΆ stays approved ββββΆ failed
β
ββ7 daysβββΆ expiredURL validation: 1st-degree only
isValidLinkedInProfileUrl(url) accepts only /in/<slug> URLs:
β
https://linkedin.com/in/jane-doe
β
https://www.linkedin.com/in/jane-doe/
β
linkedin.com/in/jane-doe
β https://linkedin.com/company/...
β https://linkedin.com/in/ (empty slug)For connection_request and message actions, the prospect URL must pass this check. For comment actions, the URL can be any linkedin.com/... URL β comments come from feed-scrape signals on posts, not profiles.
Pre-flight per action
Three separate pre-flights, each returning { ok, reasons[] }:
preflightConnectionRequestβ URL must be/in/; note (if present) β€280 chars.preflightMessageβ URL must be/in/; body required, β€2000 chars.preflightCommentβ URL must be a LinkedIn URL; body required, β€1000 chars.
Queue lifecycle
Drafting (heycmo)
Mia's lead-outreach workflow (or the customer manually) inserts a row with status='pending_review'. The unique index (customerId, prospectUrl, action, dayOffset) makes drafting idempotent β re-running a workflow can never duplicate.
Approval
The customer reviews the draft in /outreach/linkedin and either approves or skips. Approval sets status='approved', stamps approvedAt + approvedByUserId. Skipping sets status='skipped' (terminal).
Claim by extension
The Chrome extension polls GET /api/linkedin/queue/:customerId/next every 90β300 seconds (randomized). The endpoint runs claimNextJob(customerId), which:
SELECT ... FOR UPDATE SKIP LOCKEDfor the oldest approved row whoselockedUntilis null or expired and whoseexpiresAtis in the future.- Inside the same transaction, runs
canSendNow({ customerId, action }). If capped, returns null without locking (the row stays available; the cap will clear in 24h). - Otherwise, sets
lockedUntil = now() + 5 minand returns the job.
The 5-minute lock means: if the extension tab is closed mid-action or the user's machine sleeps, the job becomes claimable again on the next tick.
Result reporting
After running the DOM action, the extension POSTs to /api/linkedin/queue/:customerId/:id/result with { success, resultUrl?, errorMessage? }. On success: markJobSent sets status='sent', stamps sentAt, clears lockedUntil. On failure: markJobFailed sets status='failed' with the truncated error message.
Randomized intervals
The extension itself enforces 90β300s randomized intervals between actions (not the backend). This is deliberate β the variance has to come from the actor's machine, not from server-side scheduling, to look like organic behavior.
7-day draft expiry
A daily cron calls expireStaleDrafts():
await prisma.linkedinOutreachQueue.updateMany({
where: {
status: { in: ['pending_review', 'approved'] },
expiresAt: { lt: new Date() },
},
data: { status: 'expired' },
});Drafts that have been sitting for more than 7 days are stale β the prospect's situation has changed, the intent signal is cold. Expiring keeps the queue clean and forces fresh research instead of stale outreach.
API endpoints
All endpoints are tenant-scoped under /api/linkedin/queue/:customerId/...:
| Method | Path | Caller | Purpose |
|---|---|---|---|
GET | /api/linkedin/queue/:customerId | UI | List queue (filterable by ?status=). |
POST | /api/linkedin/queue/:customerId/:id/approve | UI | Flip pending_review β approved. |
POST | /api/linkedin/queue/:customerId/:id/skip | UI | Flip pending or approved β skipped. |
GET | /api/linkedin/queue/:customerId/next | Extension | Claim the next approved job (FOR UPDATE SKIP LOCKED + cap check). |
POST | /api/linkedin/queue/:customerId/:id/result | Extension | Report success/failure. |
UI at /outreach/linkedin
The page surfaces:
- Pending review β drafts Mia or the customer have queued, awaiting approval. One-click approve or skip.
- Approved & in flight β what the extension will pick up next.
- Sent β completed actions, with
resultUrl(the resulting connection request, DM, or comment URL when LinkedIn returns one). - Failed / expired β for triage.
Counters along the top show today's sends per action vs the cap (12 / 15 connection requests today).
Privacy posture
The whole point of the BYO-extension model:
- heycmo's backend never holds the LinkedIn session cookie.
- heycmo's backend never proxies LinkedIn HTTP requests.
- The actor IP that LinkedIn sees is the customer's IP, not a datacenter IP.
- All DOM actions run inside the customer's logged-in tab.
The contract with LinkedIn is clean: the customer is the actor, heycmo is the authoring tool. The server-side caps and 7-day draft expiry exist so the customer can't accidentally turn it into an aggressive automation tool.
Source files
apps/api/agent/lib/linkedin-outreach-policy.tsβ caps, validators, pre-flights,canSendNow,claimNextJob,expireStaleDraftsapps/api/agent/lib/__tests__/linkedin-outreach-policy.test.tsβ full unit coverageapps/api/infra/server.tsβ endpoints (search for/api/linkedin/)apps/web/src/pages/LinkedInOutreach.tsxβ customer UI- Chrome extension β separate repo + Web Store listing (linked from the in-app onboarding)
Cold Email
Plain-text outbound sequences with hard caps, pre-flight policy, and a queue-drain scheduler that protects your sender reputation.
SEO + GEO Scanner
One-click dual-score audit for any URL β SEO + Generative Engine Optimization, with per-engine breakdowns, a prioritized checklist, and "Fix with AI" routing.