🧠 HeyCMO
Features

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

PropertyValue
Phase3.1
SchemaLinkedinOutreachQueue
Policy moduleapps/api/agent/lib/linkedin-outreach-policy.ts
CompanionChrome extension (separate Web Store package)
UI/outreach/linkedin

Action types

The queue table has a single action column with three values:

ActionDescriptionDay cap
connection_requestSend a connection invite, with an optional ≀280-char note. Profile must be a linkedin.com/in/<slug> URL.15/day
messageDirect message to a 1st-degree connection. Body up to 2000 chars.30/day
commentComment 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──▢ expired

URL 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:

  1. SELECT ... FOR UPDATE SKIP LOCKED for the oldest approved row whose lockedUntil is null or expired and whose expiresAt is in the future.
  2. Inside the same transaction, runs canSendNow({ customerId, action }). If capped, returns null without locking (the row stays available; the cap will clear in 24h).
  3. Otherwise, sets lockedUntil = now() + 5 min and 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/...:

MethodPathCallerPurpose
GET/api/linkedin/queue/:customerIdUIList queue (filterable by ?status=).
POST/api/linkedin/queue/:customerId/:id/approveUIFlip pending_review β†’ approved.
POST/api/linkedin/queue/:customerId/:id/skipUIFlip pending or approved β†’ skipped.
GET/api/linkedin/queue/:customerId/nextExtensionClaim the next approved job (FOR UPDATE SKIP LOCKED + cap check).
POST/api/linkedin/queue/:customerId/:id/resultExtensionReport 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, expireStaleDrafts
  • apps/api/agent/lib/__tests__/linkedin-outreach-policy.test.ts β€” full unit coverage
  • apps/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)

On this page