Skip to content

Add LinkedIn as a connections-only channel - #18

Open
pallaoro wants to merge 3 commits into
mainfrom
linkedin-channel
Open

pallaoro wants to merge 3 commits into
mainfrom
linkedin-channel

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 17, 2026

Copy link
Copy Markdown
Member

What

LinkedIn joins the inbox as a channel the agent holds, like Telegram or Slack:

  • Incoming: the agent mirrors messages through POST /api/ingest.
  • Outgoing: the agent sends queued messages from GET /api/outbox.

LinkedIn has no messaging API for member accounts, so the agent works from its own signed-in browser. Automated use of a member account is against LinkedIn's User Agreement and can get the account restricted, so the channel is kept narrow.

Rules

Enforced by the server:

  • A person writes every message: replies need a signed-in user (403 otherwise). The agent delivers LinkedIn messages but never writes one.
  • A person opens every thread: POST /api/conversations with linkedin needs a signed-in user (403 for the agent), and the handle must be a profile URL (422 otherwise).
  • One opening message per thread: a thread takes one message until the contact replies, then stays open. A failed send doesn't count, so it can be retried. Our own earlier messages mirrored from LinkedIn count as the opener.
  • Text only: attachments and templates are refused (400).
  • Daily limits: at most 50 messages and 20 opening messages per rolling 24 hours per org (LINKEDIN_DAILY_MESSAGES / LINKEDIN_DAILY_OPENERS override them). Anything past a limit stays queued and is held back from GET /api/outbox until there's room; nothing fails. The counts use when a message actually left (new sent_at column), set when the agent confirms a send or from the original time of a message it mirrors in. A new opening column marks openers. Mirrored sends count; failed sends don't.
  • One address per person: the handle is the public profile URL in one standard form (https://www.linkedin.com/in/<slug>). Country subdomains, capitals and tracking parameters all land in the same thread, and a /sales/… link never becomes an address.
  • Other channels are unchanged.

Enforced by the agent:

  • Connections only: outbox items carry opening: true for a thread's first message. agent.md tells the agent to send it only to a 1st-degree connection, and otherwise to mark it failed ("Not a LinkedIn connection"), never sending a connection request instead. The app can't see anyone's connections, so this check sits with the agent.

UI

  • LinkedIn brand mark, a sidebar tile (briefcase, blue) and a channel colour.
  • New conversation: LinkedIn asks for a profile URL, skips the people-app picker (those records carry phone and email, not a LinkedIn profile), and says plainly that it's for connections only, that there's one opening message, and that messaging first carries more restriction risk than replying.
  • Composer: names an opening message as one and hides the attach button.
  • Waiting threads: a thread whose opener is out shows "Waiting for them to write" where the composer would be. Internal notes still work.

Agent contract (agent.md)

A LinkedIn section. The agent must:

  • stay inside its own browser session;
  • never extract the session cookie, sign in with a password, or use a library that does;
  • send only outbox items, at a slow pace;
  • send openers to connections only;
  • stop at any sign-in page, security check or limit notice, and mark the item failed with what LinkedIn showed.

Checked

  • tsc --noEmit clean, and vite build passes.
  • node --test tests/*.test.mjs: 23 pass: 10 in tests/linkedin.test.mjs and 4 in tests/linkedin-limits.test.mjs, running against the real worker in Miniflare.
  • The key tests fail when their rule is removed: an agent-authored reply, a second opener, and holding back items past the daily limit.
  • In the running app:
    • A replied thread shows the LinkedIn chip, the profile URL and a text-only composer.
    • A thread waiting on the contact shows the waiting notice.
    • The New conversation dialog shows LinkedIn with the profile-URL field and the connections notice.
  • The local wrangler dev proxy repeatedly dropped with "Network connection lost", so the send flow was verified through the tests rather than clicked through.
  • Not run: a real LinkedIn inbox. That part is the agent's, per agent.md.

Notes

pnpm-workspace.yaml still has placeholder values (esbuild: set this to true or false), so pnpm install exits non-zero without building esbuild or workerd. That's already on main; this PR doesn't change it.

LinkedIn messages join the inbox the same way the other agent-held channels
do: the agent mirrors them through /api/ingest and sends queued replies from
/api/outbox. LinkedIn offers no messaging API for member accounts, so the agent
works from its own signed-in browser, and the channel is deliberately narrow:

- A reply is accepted only once the contact has written in that thread, and it
  never expires. A thread cannot be started from the app, and the channel is
  left out of "New conversation".
- Every LinkedIn reply is written by a signed-in person; the agent delivers it
  but cannot author one. Replies are text only.
- A contact's address is their public profile URL in one canonical form, so
  the variants LinkedIn produces land in one thread, and a /sales/ link that
  only opens for a signed-in seat never becomes an address.

A thread nobody has answered shows "Waiting for them to write" in place of the
composer. agent.md sets the agent's rules for this channel: stay inside the
browser session, never extract the session cookie or sign in with a password,
send only outbox items, keep a slow pace, and stop at any sign-in page, check
or limit notice.
LinkedIn was reply-only. A signed-in person can now start a LinkedIn thread
from "New conversation" with a profile URL, and send one opening message. The
thread then waits until the contact replies; a send that failed does not count,
so it can be retried. The agent can still never open a thread or write a
message.

The app cannot see someone's connections, so that check sits with the agent:
outbox items carry `opening: true` for the first message in a thread, and
agent.md tells the agent to send it only to a 1st-degree connection and to fail
it otherwise, never sending a connection request instead.

The dialog asks for a LinkedIn profile URL, skips the people-app picker (its
records carry phone and email, not a LinkedIn profile), and says plainly that
messaging first carries more restriction risk than replying. The composer names
an opening message as one.
@pallaoro pallaoro changed the title Add LinkedIn as a reply-only channel Add LinkedIn as a connections-only channel Sep 17, 2026
An org's LinkedIn account now sends at most 50 messages and 20 opening messages
over a rolling 24 hours. A restriction lands on a real person's account, so the
app enforces this itself instead of trusting how much people queue.

Messages past a limit are not refused and do not fail: they stay queued, and
the outbox holds them back until the rolling day has room again. The counts
use when a message actually left, not when it was queued, so two new message
columns record that: sent_at (set when the agent confirms a send, or from the
original time of a message it mirrors in as sent) and opening (set when a
person queues a thread's first message). Mirrored messages count too, since
the limit protects the account whoever sent from it; failed sends do not.

The defaults sit at the cautious end of what LinkedIn automation vendors
publish, and LINKEDIN_DAILY_MESSAGES / LINKEDIN_DAILY_OPENERS change them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant